From 8a35987d9feaca74b69a9c67a4f892079ba45b7a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:46:23 +0000 Subject: [PATCH 001/338] feat(agent): support bounded binary stdin --- .../Sources/DoryCore/DoryCore.swift | 42 +++++++++++ .../Sources/DorydKit/AgentControl.swift | 50 +++++++++++++ .../DorydKitTests/AgentControlTests.swift | 4 ++ dory-core/agent/src/exec.rs | 70 ++++++++++++++++++- dory-core/ffi/src/agent_forward.rs | 35 ++++++++++ dory-core/ffi/src/remote.rs | 23 ++++++ dory-core/pb/proto/agent.proto | 3 + dory-core/remote/src/agent_client.rs | 14 ++++ 8 files changed, 240 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index 4680d49c..28b9e08d 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -380,6 +380,27 @@ public final class DoryAgentControlHandle: @unchecked Sendable { return DoryExecResult(raw) } + public func execWithInput( + argv: [String], + stdin: Data, + cwd: String = "", + env: [DoryExecEnvironment] = [], + timeoutMs: UInt64 = 30_000, + outputLimitBytes: UInt64 = 1024 * 1024 + ) throws -> DoryExecResult { + let raw = try withControl { + try $0.execWithInput( + argv: argv, + cwd: cwd, + env: env.map(\.ffiValue), + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes, + stdin: stdin + ) + } + return DoryExecResult(raw) + } + public func close() { lock.lock() control = nil @@ -462,6 +483,27 @@ public final class DoryRemoteAgentHandle: @unchecked Sendable { return DoryExecResult(raw) } + public func execWithInput( + argv: [String], + stdin: Data, + cwd: String = "", + env: [DoryExecEnvironment] = [], + timeoutMs: UInt64 = 30_000, + outputLimitBytes: UInt64 = 1024 * 1024 + ) throws -> DoryExecResult { + let raw = try withRemote { + try $0.execWithInput( + argv: argv, + cwd: cwd, + env: env.map(\.ffiValue), + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes, + stdin: stdin + ) + } + return DoryExecResult(raw) + } + public func close() { lock.lock() remote = nil diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index 721cb00c..8ff581f9 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -32,9 +32,37 @@ public protocol AgentControlClient: Sendable { timeoutMs: UInt64, outputLimitBytes: UInt64 ) throws -> DoryExecResult + func execWithInput( + argv: [String], + stdin: Data, + cwd: String, + env: [DoryExecEnvironment], + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult func close() } +public extension AgentControlClient { + func execWithInput( + argv: [String], + stdin: Data, + cwd: String, + env: [DoryExecEnvironment], + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult { + guard stdin.isEmpty else { throw AgentControlError.standardInputUnsupported } + return try exec( + argv: argv, + cwd: cwd, + env: env, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + } +} + extension DoryAgentControlHandle: AgentControlClient {} public final class AgentControl: @unchecked Sendable { @@ -104,6 +132,24 @@ public final class AgentControl: @unchecked Sendable { ) } + public func execWithInput( + argv: [String], + stdin: Data, + cwd: String = "", + env: [DoryExecEnvironment] = [], + timeoutMs: UInt64 = 30_000, + outputLimitBytes: UInt64 = 1024 * 1024 + ) throws -> DoryExecResult { + try connectedClient().execWithInput( + argv: argv, + stdin: stdin, + cwd: cwd, + env: env, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + } + public func disconnect() { lock.lock() let current = client @@ -138,6 +184,10 @@ public final class AgentControl: @unchecked Sendable { } } +public enum AgentControlError: Error, Sendable { + case standardInputUnsupported +} + public enum LocalAgentControlError: Error, Sendable, Equatable, CustomStringConvertible { case missingEndpoint case pathTooLong(String) diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index 1b74a30e..bfaf6862 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -25,6 +25,10 @@ final class AgentControlTests: XCTestCase { let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp") XCTAssertEqual(exec.exitCode, 0) XCTAssertEqual(String(data: exec.stdout, encoding: .utf8), "ok\n") + XCTAssertThrowsError(try control.execWithInput( + argv: ["/bin/cat"], + stdin: Data("payload".utf8) + )) XCTAssertEqual(counter.value, 1) control.disconnect() diff --git a/dory-core/agent/src/exec.rs b/dory-core/agent/src/exec.rs index 3748b87c..87b3aa7b 100644 --- a/dory-core/agent/src/exec.rs +++ b/dory-core/agent/src/exec.rs @@ -1,13 +1,14 @@ use dory_pb::agent::{ExecRequest, ExecResponse}; use std::collections::HashMap; use thiserror::Error; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; const DEFAULT_TIMEOUT_MS: u64 = 30_000; const MAX_TIMEOUT_MS: u64 = 10 * 60_000; const DEFAULT_OUTPUT_LIMIT: usize = 1024 * 1024; const MAX_OUTPUT_LIMIT: usize = 16 * 1024 * 1024; +const MAX_STDIN_BYTES: usize = 16 * 1024 * 1024; // After the child is reaped, any bytes still in the pipes are bounded by the kernel buffer — unless // a descendant inherited the write end. Bound the drain so such a descendant can never hang the RPC. const DRAIN_TIMEOUT_MS: u64 = 5_000; @@ -50,6 +51,12 @@ pub async fn run(req: ExecRequest) -> Result { return Err(ExecError::EmptyProgram); } + if req.stdin.len() > MAX_STDIN_BYTES { + return Err(ExecError::InvalidConstraint(format!( + "stdin exceeds {MAX_STDIN_BYTES} bytes" + ))); + } + let stdin = req.stdin; let (environment, constraints) = parse_environment_and_constraints(req.env)?; let mut command = Command::new(program); command.args(req.argv.iter().skip(1)); @@ -63,6 +70,7 @@ pub async fn run(req: ExecRequest) -> Result { } command.stdout(std::process::Stdio::piped()); command.stderr(std::process::Stdio::piped()); + command.stdin(std::process::Stdio::piped()); // Own process group so a timeout can kill the whole tree, not just the direct child — a // backgrounded descendant would otherwise survive the kill and hold the output pipes open. #[cfg(unix)] @@ -72,8 +80,16 @@ pub async fn run(req: ExecRequest) -> Result { let _wait_guard = crate::reaper::managed_child_wait_guard().await; let mut child = command.spawn()?; let group_pid = child.id(); + let child_stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); + let stdin_task = tokio::spawn(async move { + if let Some(mut stream) = child_stdin { + stream.write_all(&stdin).await?; + stream.shutdown().await?; + } + Ok::<(), std::io::Error>(()) + }); let limit = output_limit(req.output_limit_bytes); let stdout_task = tokio::spawn(async move { match stdout { @@ -102,6 +118,18 @@ pub async fn run(req: ExecRequest) -> Result { let (stdout, stdout_truncated) = drain_output(stdout_task, group_pid).await?; let (stderr, stderr_truncated) = drain_output(stderr_task, group_pid).await?; + // Commands are allowed to exit without consuming all stdin. The process status/stderr is the + // authoritative result; never let a closed input pipe turn a completed command into an RPC + // failure or leave the writer task alive. + if tokio::time::timeout( + std::time::Duration::from_millis(DRAIN_TIMEOUT_MS), + stdin_task, + ) + .await + .is_err() + { + kill_process_group(group_pid); + } Ok(ExecResponse { exit_code: status.code().unwrap_or(if timed_out { 124 } else { 128 }), @@ -329,6 +357,7 @@ mod tests { env: Vec::new(), timeout_ms: 5_000, output_limit_bytes: 1024, + stdin: Vec::new(), }) .await .unwrap(); @@ -355,6 +384,7 @@ mod tests { }], timeout_ms: 5_000, output_limit_bytes: 4, + stdin: Vec::new(), }) .await .unwrap(); @@ -379,6 +409,7 @@ mod tests { env: Vec::new(), timeout_ms: 60_000, output_limit_bytes: 1024, + stdin: Vec::new(), }) .await .unwrap(); @@ -401,6 +432,7 @@ mod tests { env: Vec::new(), timeout_ms: 50, output_limit_bytes: 1024, + stdin: Vec::new(), }) .await .unwrap(); @@ -488,6 +520,7 @@ mod tests { ], timeout_ms: 5_000, output_limit_bytes: 1_024, + stdin: Vec::new(), }) .await .unwrap(); @@ -498,4 +531,39 @@ mod tests { format!("{uid} 64 unset") ); } + + #[tokio::test] + async fn exec_delivers_binary_stdin() { + let payload = vec![0, 1, 2, 0xff, b'\n']; + let out = run(ExecRequest { + argv: vec!["/bin/cat".into()], + cwd: String::new(), + env: Vec::new(), + timeout_ms: 5_000, + output_limit_bytes: 1_024, + stdin: payload.clone(), + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert_eq!(out.stdout, payload); + } + + #[tokio::test] + async fn exec_rejects_oversized_stdin() { + let error = run(ExecRequest { + argv: vec!["/bin/cat".into()], + cwd: String::new(), + env: Vec::new(), + timeout_ms: 5_000, + output_limit_bytes: 1_024, + stdin: vec![0; MAX_STDIN_BYTES + 1], + }) + .await + .unwrap_err(); + + assert!(matches!(error, ExecError::InvalidConstraint(_))); + assert_eq!(error.code(), 400); + } } diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 29e7a203..6f63e5ab 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -174,6 +174,28 @@ impl AgentControl { ))?; Ok(exec_result(out)) } + + pub fn exec_with_input( + &self, + argv: Vec, + cwd: String, + env: Vec, + timeout_ms: u64, + output_limit_bytes: u64, + stdin: Vec, + ) -> Result { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let out = runtime.block_on(self.client.exec_with_input( + argv, + cwd, + env.into_iter().map(|item| (item.key, item.value)).collect(), + timeout_ms, + output_limit_bytes, + stdin, + ))?; + Ok(exec_result(out)) + } } impl Drop for AgentControl { @@ -271,6 +293,19 @@ mod tests { .expect("exec"); assert_eq!(exec.exit_code, 0); assert_eq!(exec.stdout, b"ffi-exec"); + let input = vec![0, 1, 2, 0xff, b'\n']; + let echoed = control + .exec_with_input( + vec!["/bin/cat".into()], + String::new(), + Vec::new(), + 5_000, + 1024, + input.clone(), + ) + .expect("exec with input"); + assert_eq!(echoed.exit_code, 0); + assert_eq!(echoed.stdout, input); let _ = std::fs::remove_file(&path); } diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs index 9cf4b111..b17f5807 100644 --- a/dory-core/ffi/src/remote.rs +++ b/dory-core/ffi/src/remote.rs @@ -204,6 +204,29 @@ impl RemoteAgent { Ok(exec_result(out)) } + /// Run a bounded command and deliver a binary-safe stdin payload through the agent RPC. + pub fn exec_with_input( + &self, + argv: Vec, + cwd: String, + env: Vec, + timeout_ms: u64, + output_limit_bytes: u64, + stdin: Vec, + ) -> Result { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let out = runtime.block_on(self.agent.client.exec_with_input( + argv, + cwd, + env.into_iter().map(|item| (item.key, item.value)).collect(), + timeout_ms, + output_limit_bytes, + stdin, + ))?; + Ok(exec_result(out)) + } + /// Push `local_root` to `remote_root`, making the remote an exact replica (host-authoritative). pub fn push( &self, diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 5dc679d0..7fe17388 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -64,6 +64,9 @@ message ExecRequest { repeated ExecEnv env = 3; uint64 timeout_ms = 4; uint64 output_limit_bytes = 5; + // Optional bounded stdin payload. This is binary-safe so desktop integration can transfer + // clipboard images without placing their contents in argv or the environment. + bytes stdin = 6; } message ExecResponse { int32 exit_code = 1; diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 0a1d3310..02ba3209 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -133,6 +133,19 @@ impl AgentClient { env: Vec<(String, String)>, timeout_ms: u64, output_limit_bytes: u64, + ) -> Result { + self.exec_with_input(argv, cwd, env, timeout_ms, output_limit_bytes, Vec::new()) + .await + } + + pub async fn exec_with_input( + &self, + argv: Vec, + cwd: String, + env: Vec<(String, String)>, + timeout_ms: u64, + output_limit_bytes: u64, + stdin: Vec, ) -> Result { let env = env .into_iter() @@ -152,6 +165,7 @@ impl AgentClient { env, timeout_ms, output_limit_bytes, + stdin, }), server_timeout + EXEC_GRACE, ) From 491b69e2d6e8711043e338e3e6e70aeb01cc9a20 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:48:30 +0000 Subject: [PATCH 002/338] feat(desktop): add shared runtime and clipboard contracts --- dory-core-swift/Package.swift | 7 +- .../DoryCore/DoryDesktopRuntimeContract.swift | 9 + .../DoryDesktopClipboardPolicy.swift | 33 ++ .../DoryDesktopRuntimeContract.swift | 100 ++++++ .../DoryDesktopClipboardCoordinator.swift | 320 ++++++++++++++++++ .../DoryDesktopRuntimeContractTests.swift | 48 +++ .../DoryDesktopClipboardPolicyTests.swift | 26 ++ 7 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift create mode 100644 dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift diff --git a/dory-core-swift/Package.swift b/dory-core-swift/Package.swift index ad2214e7..6554d82e 100644 --- a/dory-core-swift/Package.swift +++ b/dory-core-swift/Package.swift @@ -21,7 +21,10 @@ let package = Package( ], targets: [ .binaryTarget(name: "DoryFFI", path: "artifacts/DoryFFI.xcframework"), - .target(name: "DoryOperations"), + .target( + name: "DoryOperations", + linkerSettings: [.linkedLibrary("z")] + ), .target( name: "DoryCore", dependencies: ["DoryFFI", "DoryOperations"] @@ -37,7 +40,7 @@ let package = Package( ), .target( name: "DoryVMMKit", - dependencies: ["DoryCore", "DorydKit"], + dependencies: ["DoryCore", "DorydKit", "DoryOperations"], linkerSettings: [ .linkedFramework("AppKit"), .linkedFramework("Virtualization"), diff --git a/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift b/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift new file mode 100644 index 00000000..d238bc26 --- /dev/null +++ b/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift @@ -0,0 +1,9 @@ +import DoryOperations + +// Keep the contract available through DoryCore for helper clients that already import it, while +// the canonical definitions live in DoryOperations so the app UI and the daemon use identical +// values without making the app link the FFI-backed core module. +public typealias DoryDesktopVMMPreference = DoryOperations.DoryDesktopVMMPreference +public typealias DoryDesktopGraphicsPreference = DoryOperations.DoryDesktopGraphicsPreference +public typealias DoryDesktopGraphicsBackend = DoryOperations.DoryDesktopGraphicsBackend +public typealias DoryDesktopRuntimeContractError = DoryOperations.DoryDesktopRuntimeContractError diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift new file mode 100644 index 00000000..698c1235 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift @@ -0,0 +1,33 @@ +public enum DoryDesktopClipboardPolicy: String, CaseIterable, Sendable { + case off + case hostToGuest = "host-to-guest" + case guestToHost = "guest-to-host" + case bidirectional + + public static let environmentKey = "DORY_CLIPBOARD_POLICY" + + public init(environment: [String: String]) { + guard let rawValue = environment[Self.environmentKey] else { + self = .bidirectional + return + } + self = Self(rawValue: rawValue) ?? .off + } + + public var allowsHostToGuest: Bool { + self == .hostToGuest || self == .bidirectional + } + + public var allowsGuestToHost: Bool { + self == .guestToHost || self == .bidirectional + } + + public var displayName: String { + switch self { + case .off: "Off" + case .hostToGuest: "Mac to Linux" + case .guestToHost: "Linux to Mac" + case .bidirectional: "Bidirectional" + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift new file mode 100644 index 00000000..b428e971 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Stable host/guest contract for choosing the desktop virtualization implementation. +/// +/// `automatic` is the product default: doryd prefers Dory's accelerated raw-Hypervisor runtime +/// when it is available, while retaining Virtualization.framework as the selectable compatibility +/// runtime. The explicit values are useful for qualification and for recovering a machine on a +/// host whose accelerated graphics stack is temporarily unavailable. +public enum DoryDesktopVMMPreference: String, CaseIterable, Sendable, Codable { + public static let environmentKey = "DORY_DESKTOP_VMM" + + case automatic = "auto" + case accelerated + case compatible + + public init(environment: [String: String]) throws { + let raw = environment[Self.environmentKey] ?? Self.automatic.rawValue + guard let value = Self(rawValue: raw) else { + throw DoryDesktopRuntimeContractError.invalidValue( + key: Self.environmentKey, + value: raw, + allowed: Self.allCases.map(\.rawValue) + ) + } + self = value + } +} + +/// Requested graphics behavior for Dory's raw-Hypervisor desktop runtime. +/// +/// The default first attempts the combined VirGL2 + Venus backend: VirGL accelerates the desktop +/// compositor while Venus gives Vulkan applications a real Apple-GPU path. Hosts without a +/// qualified Venus renderer fall back to classic VirGL, then to virtio-gpu 2D, so graphics +/// acceleration cannot make the Linux desktop unbootable. +public enum DoryDesktopGraphicsPreference: String, CaseIterable, Sendable, Codable { + public static let environmentKey = "DORY_DESKTOP_GRAPHICS" + public static let legacyClassicOnlyEnvironmentKey = "DORY_VIRGL_CLASSIC_ONLY" + + case automatic = "auto" + case virgl + case virglVenus = "virgl-venus" + case software + + public init(environment: [String: String]) throws { + if let raw = environment[Self.environmentKey] { + guard let value = Self(rawValue: raw) else { + throw DoryDesktopRuntimeContractError.invalidValue( + key: Self.environmentKey, + value: raw, + allowed: Self.allCases.map(\.rawValue) + ) + } + self = value + return + } + + // Preserve machines created while the experimental toggle was the only public contract. + switch environment[Self.legacyClassicOnlyEnvironmentKey] { + case "1": self = .virgl + case "0": self = .virglVenus + default: self = .automatic + } + } +} + +/// The graphics backend actually attached to a running raw-Hypervisor desktop. +public enum DoryDesktopGraphicsBackend: String, Sendable, Codable { + case virgl + case virglVenus = "virgl-venus" + case software + + public var isAccelerated: Bool { self != .software } + + /// Kernel token read by the versioned guest integration scripts. + public var kernelArgument: String { "dory.graphics=\(rawValue)" } + + /// Compatibility token for desktop images predating `dory.graphics`. + public var legacyKernelArgument: String? { + isAccelerated ? "dory.gpu=venus" : nil + } + + public var displayName: String { + switch self { + case .virgl: "VirGL2" + case .virglVenus: "VirGL2 + Venus" + case .software: "software" + } + } +} + +public enum DoryDesktopRuntimeContractError: Error, Equatable, LocalizedError { + case invalidValue(key: String, value: String, allowed: [String]) + + public var errorDescription: String? { + switch self { + case let .invalidValue(key, value, allowed): + "Invalid \(key)=\(value); expected one of \(allowed.joined(separator: ", "))" + } + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift new file mode 100644 index 00000000..d19662da --- /dev/null +++ b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift @@ -0,0 +1,320 @@ +import AppKit +import DoryCore +import DoryOperations +import Foundation + +private struct DoryDesktopClipboardPayload: Sendable, Equatable { + // Leave headroom for the protobuf envelope under dory-proto's 16 MiB frame ceiling. + static let maximumBytes = 15 * 1024 * 1024 + + let mimeType: String + let data: Data + + init?(mimeType: String, data: Data) { + guard data.count <= Self.maximumBytes else { return nil } + self.mimeType = mimeType + self.data = data + } +} + +/// Host-side clipboard integration shared by the raw Hypervisor.framework and +/// Virtualization.framework desktop paths. Guest commands travel over Dory's authenticated agent +/// channel; AppKit access stays on the main thread and blocking work stays on one private queue. +public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { + public typealias Executor = @Sendable ( + _ argv: [String], + _ stdin: Data, + _ timeoutMs: UInt64, + _ outputLimitBytes: UInt64 + ) throws -> DoryExecResult + public typealias ShortcutSender = @MainActor @Sendable (_ linuxKeyCode: UInt16) -> Void + + private let policy: DoryDesktopClipboardPolicy + private let execute: Executor + private let sendShortcut: ShortcutSender + private let pasteboard: NSPasteboard + private let startupRetryDelay: TimeInterval + private let startupRetryLimit: Int + private let queue = DispatchQueue(label: "dev.dory.desktop-clipboard", qos: .userInitiated) + private let log: @Sendable (String) -> Void + private var observations = [NSObjectProtocol]() + private var guestReady = false + private var lastPushedHostChangeCount = -1 + + public convenience init( + policy: DoryDesktopClipboardPolicy, + execute: @escaping Executor, + sendShortcut: @escaping ShortcutSender, + log: @escaping @Sendable (String) -> Void + ) { + self.init( + policy: policy, + execute: execute, + sendShortcut: sendShortcut, + pasteboard: .general, + startupRetryDelay: 1, + startupRetryLimit: 60, + log: log + ) + } + + init( + policy: DoryDesktopClipboardPolicy, + execute: @escaping Executor, + sendShortcut: @escaping ShortcutSender, + pasteboard: NSPasteboard, + startupRetryDelay: TimeInterval, + startupRetryLimit: Int, + log: @escaping @Sendable (String) -> Void + ) { + self.policy = policy + self.execute = execute + self.sendShortcut = sendShortcut + self.pasteboard = pasteboard + self.startupRetryDelay = startupRetryDelay + self.startupRetryLimit = max(0, startupRetryLimit) + self.log = log + } + + @MainActor + public func start() { + lastPushedHostChangeCount = pasteboard.changeCount + observations.append(NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: NSApplication.shared, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pushHostClipboardIfChanged(force: false) } + }) + observations.append(NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: NSApplication.shared, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pullGuestClipboard() } + }) + } + + @MainActor + public func markGuestReady() { + queue.async { [weak self] in + guard let self else { return } + let available: Bool + do { + let result = try self.execute( + ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"], + Data(), + 5_000, + 4_096 + ) + available = result.exitCode == 0 && !result.timedOut + } catch { + available = false + self.log("clipboard capability probe failed: \(error)") + } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.guestReady = available + if available { + // The agent commonly becomes ready a few seconds before GDM creates the + // user's Wayland/X11 clipboard. Keep retrying the initial transfer so a + // Mac clipboard copied before boot is not silently dropped. + self.pushHostClipboardIfChanged( + force: true, + startupRetriesRemaining: self.startupRetryLimit + ) + } else { + self.log("clipboard integration is unavailable until guest tools are updated") + } + } + } + } + } + + @MainActor + public func stop() { + guestReady = false + for observation in observations { + NotificationCenter.default.removeObserver(observation) + } + observations.removeAll() + } + + /// Returns true when a macOS Command+C/X/V gesture was translated to its Linux Ctrl shortcut. + @MainActor + public func handleMacShortcut(_ event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command), + let character = event.charactersIgnoringModifiers?.lowercased() else { + return false + } + switch character { + case "c": + sendShortcut(46) + scheduleGuestReadIfAllowed() + return true + case "x": + sendShortcut(45) + scheduleGuestReadIfAllowed() + return true + case "v": + guard policy.allowsHostToGuest, guestReady, + let payload = Self.readHostClipboard(from: pasteboard) else { + sendShortcut(47) + return true + } + queue.async { [weak self] in + guard let self else { return } + let didWrite = self.writeGuestClipboard(payload) + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + if didWrite { + self.lastPushedHostChangeCount = self.pasteboard.changeCount + } + self.sendShortcut(47) + } + } + } + return true + default: + return false + } + } + + @MainActor + private func scheduleGuestReadIfAllowed() { + guard guestReady, policy.allowsGuestToHost else { return } + queue.asyncAfter(deadline: .now() + 0.15) { [weak self] in + self?.readGuestClipboardAndPublishToHost() + } + } + + @MainActor + private func pushHostClipboardIfChanged( + force: Bool, + startupRetriesRemaining: Int = 0 + ) { + guard guestReady, policy.allowsHostToGuest else { return } + let changeCount = pasteboard.changeCount + guard force || changeCount != lastPushedHostChangeCount, + let payload = Self.readHostClipboard(from: pasteboard) else { return } + queue.async { [weak self] in + guard let self else { return } + let didWrite = self.writeGuestClipboard(payload) + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + if didWrite, self.pasteboard.changeCount == changeCount { + self.lastPushedHostChangeCount = changeCount + } else if !didWrite, + self.guestReady, + startupRetriesRemaining > 0 { + DispatchQueue.main.asyncAfter( + deadline: .now() + self.startupRetryDelay + ) { [weak self] in + MainActor.assumeIsolated { + self?.pushHostClipboardIfChanged( + force: true, + startupRetriesRemaining: startupRetriesRemaining - 1 + ) + } + } + } + } + } + } + } + + @MainActor + private func pullGuestClipboard() { + guard guestReady, policy.allowsGuestToHost else { return } + queue.async { [weak self] in self?.readGuestClipboardAndPublishToHost() } + } + + private func writeGuestClipboard(_ payload: DoryDesktopClipboardPayload) -> Bool { + do { + let result = try execute( + ["/usr/lib/dory/clipboard", "set", payload.mimeType], + payload.data, + 5_000, + 64 * 1024 + ) + guard result.exitCode == 0, !result.timedOut else { + log("clipboard write failed (exit=\(result.exitCode), timedOut=\(result.timedOut))") + return false + } + return true + } catch { + log("clipboard write failed: \(error)") + return false + } + } + + private func readGuestClipboardAndPublishToHost() { + guard let payload = readGuestClipboard() else { return } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + Self.writeHostClipboard(payload, to: self.pasteboard) + self.lastPushedHostChangeCount = self.pasteboard.changeCount + } + } + } + + private func readGuestClipboard() -> DoryDesktopClipboardPayload? { + for mimeType in ["image/png", "text/plain;charset=utf-8", "text/plain"] { + do { + let result = try execute( + ["/usr/lib/dory/clipboard", "get", mimeType], + Data(), + 5_000, + UInt64(DoryDesktopClipboardPayload.maximumBytes) + ) + guard result.exitCode == 0, !result.timedOut, !result.stdoutTruncated else { continue } + if mimeType == "image/png", result.stdout.isEmpty { continue } + if let payload = DoryDesktopClipboardPayload(mimeType: mimeType, data: result.stdout) { + return payload + } + } catch { + log("clipboard read failed: \(error)") + return nil + } + } + return nil + } + + @MainActor + private static func readHostClipboard( + from pasteboard: NSPasteboard + ) -> DoryDesktopClipboardPayload? { + if let png = pasteboard.data(forType: .png), + let payload = DoryDesktopClipboardPayload(mimeType: "image/png", data: png) { + return payload + } + if let tiff = pasteboard.data(forType: .tiff), + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]), + let payload = DoryDesktopClipboardPayload(mimeType: "image/png", data: png) { + return payload + } + guard let string = pasteboard.string(forType: .string) else { return nil } + return DoryDesktopClipboardPayload( + mimeType: "text/plain;charset=utf-8", + data: Data(string.utf8) + ) + } + + @MainActor + private static func writeHostClipboard( + _ payload: DoryDesktopClipboardPayload, + to pasteboard: NSPasteboard + ) { + pasteboard.clearContents() + if payload.mimeType == "image/png" { + pasteboard.setData(payload.data, forType: .png) + } else { + pasteboard.setString(String(decoding: payload.data, as: UTF8.self), forType: .string) + } + } +} diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift new file mode 100644 index 00000000..a9072c24 --- /dev/null +++ b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift @@ -0,0 +1,48 @@ +@testable import DoryCore +import XCTest + +final class DoryDesktopRuntimeContractTests: XCTestCase { + func testDesktopVMMPreferenceDefaultsAndValidates() throws { + XCTAssertEqual(try DoryDesktopVMMPreference(environment: [:]), .automatic) + XCTAssertEqual( + try DoryDesktopVMMPreference(environment: [ + DoryDesktopVMMPreference.environmentKey: "compatible", + ]), + .compatible + ) + XCTAssertThrowsError(try DoryDesktopVMMPreference(environment: [ + DoryDesktopVMMPreference.environmentKey: "fastest", + ])) + } + + func testGraphicsPreferencePreservesLegacyMachinesButPrefersNewContract() throws { + XCTAssertEqual(try DoryDesktopGraphicsPreference(environment: [:]), .automatic) + XCTAssertEqual( + try DoryDesktopGraphicsPreference(environment: [ + DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1", + ]), + .virgl + ) + XCTAssertEqual( + try DoryDesktopGraphicsPreference(environment: [ + DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1", + DoryDesktopGraphicsPreference.environmentKey: "virgl-venus", + ]), + .virglVenus + ) + XCTAssertThrowsError(try DoryDesktopGraphicsPreference(environment: [ + DoryDesktopGraphicsPreference.environmentKey: "metal", + ])) + } + + func testResolvedGraphicsBackendsPublishTruthfulKernelTokens() { + XCTAssertEqual(DoryDesktopGraphicsBackend.virgl.kernelArgument, "dory.graphics=virgl") + XCTAssertEqual( + DoryDesktopGraphicsBackend.virglVenus.kernelArgument, + "dory.graphics=virgl-venus" + ) + XCTAssertEqual(DoryDesktopGraphicsBackend.software.kernelArgument, "dory.graphics=software") + XCTAssertNotNil(DoryDesktopGraphicsBackend.virgl.legacyKernelArgument) + XCTAssertNil(DoryDesktopGraphicsBackend.software.legacyKernelArgument) + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift new file mode 100644 index 00000000..0f937f93 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import DoryOperations + +@Suite("Desktop clipboard policy") +struct DoryDesktopClipboardPolicyTests { + @Test("defaults existing desktops to bidirectional sharing") + func defaultPolicy() { + let policy = DoryDesktopClipboardPolicy(environment: [:]) + #expect(policy == .bidirectional) + #expect(policy.allowsHostToGuest) + #expect(policy.allowsGuestToHost) + } + + @Test("directional and off policies enforce both boundaries") + func directionalPolicies() { + #expect(DoryDesktopClipboardPolicy.hostToGuest.allowsHostToGuest) + #expect(!DoryDesktopClipboardPolicy.hostToGuest.allowsGuestToHost) + #expect(!DoryDesktopClipboardPolicy.guestToHost.allowsHostToGuest) + #expect(DoryDesktopClipboardPolicy.guestToHost.allowsGuestToHost) + #expect(!DoryDesktopClipboardPolicy.off.allowsHostToGuest) + #expect(!DoryDesktopClipboardPolicy.off.allowsGuestToHost) + #expect(DoryDesktopClipboardPolicy(environment: [ + DoryDesktopClipboardPolicy.environmentKey: "invalid" + ]) == .off) + } +} From 0363ef0869d4f0a2c9a39f45558adb659982660f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:50:16 +0000 Subject: [PATCH 003/338] feat(hv): add accelerated Linux desktop runtime --- Packages/ContainerizationEngine/Package.swift | 11 + Packages/ContainerizationEngine/README.md | 2 +- .../DoryHV/GVProxyDesktopLaunchPlan.swift | 19 + .../DoryHV/GuestVsockSocketBridge.swift | 66 + .../Sources/DoryHV/Machine.swift | 72 +- .../Sources/DoryHV/VirglRenderer.swift | 758 ++++++++--- .../Sources/DoryHV/VirtioGPU.swift | 1020 ++++++++++++++- .../Sources/DoryHV/VirtioInput.swift | 289 +++++ .../Sources/DoryHV/VirtioMMIO.swift | 16 +- .../Sources/DoryHV/VirtioSound.swift | 564 +++++++++ .../Sources/DoryHV/Virtqueue.swift | 32 +- .../Sources/DoryHV/X86BootPlan.swift | 1 + .../Sources/dory-hv/DesktopAudioBackend.swift | 498 ++++++++ .../Sources/dory-hv/DesktopMetalDisplay.swift | 440 +++++++ .../Sources/dory-hv/DesktopMode.swift | 674 ++++++++++ .../Sources/dory-hv/EngineMode.swift | 3 +- .../Sources/dory-hv/main.swift | 90 +- .../Tests/DoryHVTests/DeviceLogicTests.swift | 1116 ++++++++++++++++- .../GVProxyDesktopLaunchPlanTests.swift | 20 + .../GuestShutdownCommandTests.swift | 20 + .../Tests/DoryHVTests/VirtqueueTests.swift | 20 + .../dory-hv.entitlements | 8 +- .../DoryCore/GuestShutdownCommand.swift | 17 + patches/moltenvk-dory-native-arrays.patch | 37 + .../virglrenderer-macos-fragment-coord.patch | 177 +++ 25 files changed, 5769 insertions(+), 201 deletions(-) create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift create mode 100644 patches/moltenvk-dory-native-arrays.patch create mode 100644 patches/virglrenderer-macos-fragment-coord.patch diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift index 4ecd0cb4..8a75d2ce 100644 --- a/Packages/ContainerizationEngine/Package.swift +++ b/Packages/ContainerizationEngine/Package.swift @@ -35,6 +35,7 @@ let package = Package( .linkedFramework("CoreServices"), .linkedFramework("IOKit"), .linkedFramework("IOUSBHost"), + .linkedFramework("OpenGL"), ] ), .executableTarget( @@ -42,6 +43,16 @@ let package = Package( dependencies: [ "DoryHV", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DorydKit", package: "dory-core-swift"), + .product(name: "DoryOperations", package: "dory-core-swift"), + .product(name: "DoryVMMKit", package: "dory-core-swift"), + ], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("AVFAudio"), + .linkedFramework("AVFoundation"), + .linkedFramework("Metal"), + .linkedFramework("MetalKit"), ] ), .testTarget( diff --git a/Packages/ContainerizationEngine/README.md b/Packages/ContainerizationEngine/README.md index 06f5d3de..8320a765 100644 --- a/Packages/ContainerizationEngine/README.md +++ b/Packages/ContainerizationEngine/README.md @@ -12,7 +12,7 @@ The full process, storage, networking, and trust-boundary contract is documented - Arm64 and x86_64 raw-HV boot/device implementations. Public 0.4 releases remain Apple-silicon only until an Intel candidate passes dedicated physical qualification. -- Virtio block, network, vsock, rng, balloon, GPU-preview, and VirtioFS devices. +- Virtio block, network, vsock, rng, balloon, VirGL/Venus GPU, and VirtioFS devices. - A copyless guest networking path through the provenance-pinned `gvproxy` helper. - Host-share coherence, bounded FSEvents batching, queue/backpressure telemetry, and recovery. - Published-port, SSH-agent, host-AI, and guest-control bridges. diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift new file mode 100644 index 00000000..8644902d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift @@ -0,0 +1,19 @@ +/// Builds the isolated gvproxy command line used by persistent desktop VMs. +/// +/// Desktop control and shell access travel over dedicated per-machine vsock bridges. Keeping +/// gvproxy on private Unix sockets—and explicitly disabling its legacy SSH forward—prevents one +/// desktop from claiming a host-global port needed by another desktop or by its own restart. +package enum GVProxyDesktopLaunchPlan { + package static func arguments( + mtu: Int, + datapathSocket: String, + apiSocket: String + ) -> [String] { + [ + "-mtu", String(mtu), + "-listen-vfkit", "unixgram://\(datapathSocket)", + "-listen", "unix://\(apiSocket)", + "-ssh-port", "-1", + ] + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift new file mode 100644 index 00000000..1183ee22 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift @@ -0,0 +1,66 @@ +import Darwin +import Foundation + +/// Publishes one private host Unix socket and relays each accepted connection to a fixed guest +/// vsock port. Desktop machines use it for the agent control and shell endpoints expected by doryd. +public final class GuestVsockSocketBridge: @unchecked Sendable { + private let socketPath: String + private let guestPort: UInt32 + private let log: @Sendable (String) -> Void + + public init( + socketPath: String, + guestPort: UInt32, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.socketPath = socketPath + self.guestPort = guestPort + self.log = log + } + + public static func validateSocketPath(_ socketPath: String) throws { + try VsockUnixRelay.validateSocketPath(socketPath) + } + + public func attach(to vsock: VirtioVsock) throws { + let listener = try VsockUnixRelay.makeListener(socketPath: socketPath, mode: 0o600) + let path = socketPath + let port = guestPort + let log = log + let box = VsockBox(vsock) + Thread.detachNewThread { + while true { + let client = accept(listener, nil, nil) + guard client >= 0 else { + if errno == EINTR { continue } + log("vsock bridge accept failed on \(path): errno \(errno)") + break + } + var noSigpipe: Int32 = 1 + _ = setsockopt( + client, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) + let connection = ConnectionBox(box.vsock.connect(port: port)) + Thread.detachNewThread { + VsockUnixRelay.serve(client: client, connection: connection.value) + } + } + close(listener) + } + log("vsock bridge serving \(socketPath) over guest port \(guestPort)") + } + + private final class VsockBox: @unchecked Sendable { + let vsock: VirtioVsock + init(_ vsock: VirtioVsock) { self.vsock = vsock } + } + + private final class ConnectionBox: @unchecked Sendable { + let value: VsockConnection + init(_ value: VsockConnection) { self.value = value } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift index 5f0f3ccc..735e4d83 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift @@ -18,17 +18,29 @@ public enum GuestLayout { public static let virtioFirstIRQ: UInt32 = 16 // SPI numbers 16... (intid 48...) public static let ramBase: UInt64 = 0x8000_0000 public static let dtbOffset: UInt64 = 256 << 20 + /// Direct-boot initrds live beyond the kernel/DTB reservation while remaining well inside + /// the minimum supported 1-GiB guest. Keeping this deterministic also makes the DTB contract + /// straightforward to test and diagnose. + public static let initrdOffset: UInt64 = 320 << 20 public static let daxWindowBase: UInt64 = 0xC_0000_0000 } public struct MachineConfiguration { public var kernelPath: String + public var initrdPath: String? public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int - public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) { + public init( + kernelPath: String, + initrdPath: String? = nil, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { self.kernelPath = kernelPath + self.initrdPath = initrdPath self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -125,11 +137,29 @@ public final class Machine: @unchecked Sendable { guard kernel.textOffset + kernel.imageSize < GuestLayout.dtbOffset else { throw VMError.bootFailure("kernel image overlaps DTB placement") } - let dtb = try buildDeviceTree() + let initrdRange = try loadInitrdIfPresent() + let dtb = try buildDeviceTree(initrdRange: initrdRange) try memory.write(dtb, at: dtbAddress) } - private func buildDeviceTree() throws -> [UInt8] { + private func loadInitrdIfPresent() throws -> Range? { + guard let path = configuration.initrdPath else { return nil } + let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + guard !data.isEmpty else { + throw VMError.bootFailure("initrd is empty: \(path)") + } + let start = GuestLayout.ramBase + GuestLayout.initrdOffset + let (end, overflowed) = start.addingReportingOverflow(UInt64(data.count)) + guard !overflowed, + end > start, + end <= GuestLayout.ramBase + configuration.memoryBytes else { + throw VMError.bootFailure("initrd does not fit in guest memory") + } + try memory.write(Array(data), at: start) + return start..?) throws -> [UInt8] { let gicPhandle: UInt32 = 1 let clockPhandle: UInt32 = 2 let virtualTimer = try Self.reservedIntid(HV_GIC_INT_EL1_VIRTUAL_TIMER) @@ -148,6 +178,10 @@ public final class Machine: @unchecked Sendable { fdt.beginNode("chosen") fdt.property("bootargs", string: configuration.commandLine) fdt.property("stdout-path", string: "/pl011@\(String(GuestLayout.uartBase, radix: 16))") + if let initrdRange { + fdt.property("linux,initrd-start", cells64: [initrdRange.lowerBound]) + fdt.property("linux,initrd-end", cells64: [initrdRange.upperBound]) + } fdt.endNode() fdt.beginNode("memory@\(String(GuestLayout.ramBase, radix: 16))") @@ -596,12 +630,20 @@ public enum GuestLayout { public struct MachineConfiguration { public var kernelPath: String + public var initrdPath: String? public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int - public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) { + public init( + kernelPath: String, + initrdPath: String? = nil, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { self.kernelPath = kernelPath + self.initrdPath = initrdPath self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -648,6 +690,21 @@ public final class Machine: @unchecked Sendable { entryPoint = try kernel.load(into: memory) startInfoAddress = X86GuestLayout.pvhStartInfo + let initrdData = try configuration.initrdPath.map { + try Data(contentsOf: URL(fileURLWithPath: $0), options: .mappedIfSafe) + } + if let initrdData, initrdData.isEmpty { + throw VMError.bootFailure("initrd is empty: \(configuration.initrdPath ?? "")") + } + let initrdAddress = X86GuestLayout.initrd + if let initrdData { + let (end, overflowed) = initrdAddress.addingReportingOverflow(UInt64(initrdData.count)) + guard !overflowed, end <= configuration.memoryBytes else { + throw VMError.bootFailure("initrd does not fit in guest memory") + } + try memory.write(Array(initrdData), at: initrdAddress) + } + let plan = X86BootPlanBuilder.build( baseCommandLine: configuration.commandLine, memoryBytes: configuration.memoryBytes, @@ -658,11 +715,16 @@ public final class Machine: @unchecked Sendable { commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine, modulesPhysicalAddress: X86GuestLayout.pvhModules, memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap, - modules: [], + modules: initrdData.map { + [PVHModule(physicalAddress: initrdAddress, size: UInt64($0.count))] + } ?? [], memoryMap: plan.memoryMap ) try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo) try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine) + if !pvh.modules.isEmpty { + try memory.write(Array(pvh.modules), at: X86GuestLayout.pvhModules) + } try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap) let mpTable = MPTableBuilder.build( diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift index c1b311e9..f958e0e1 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift @@ -1,21 +1,50 @@ import Darwin import Foundation +import OpenGL +import OpenGL.GL3 public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { public let libraryPath: String - public let moltenVKICDPath: String + public let moltenVKICDPath: String? public let capsets: [VirtioGPUCapset] private let handle: UnsafeMutableRawPointer private let callbacks: UnsafeMutablePointer + private let glContextManager: VirglCGLContextManager private let functions: Functions + private let submissionSyncMode: SubmissionSyncMode + private let classicOnly: Bool + private let rendererLock = NSRecursiveLock() + private var pollSource: DispatchSourceRead? + private var pollTimer: DispatchSourceTimer? + // Each guest renderer context gets one rolling GL fence. Submissions only flush the producer + // context; scanout readback waits for the latest fence from every producer in ctx0. This keeps + // shared textures coherent without blocking the vCPU on glFinish after every command buffer. + private var pendingSubmissionSyncs: [UInt32: GLsync] = [:] + // Retain guest-provided labels so a renderer failure identifies Mutter, Firefox, or the Vulkan + // client that supplied the command stream without relying on process IDs. + private var contextLabels: [UInt32: String] = [:] + // Fence creation in classic VirGL must happen in the producer's OpenGL context. Keep each + // context's negotiated capset so Venus fences can stay on their native renderer timeline while + // VirGL fences explicitly restore the producer context after Dory clears thread-local CGL state. + private var contextFlags: [UInt32: UInt32] = [:] + // virglrenderer retains the *iovec array pointer* supplied at resource creation/attach; it + // does not copy those descriptors. Swift's temporary Array storage therefore cannot be used + // for resource backing even though the guest-memory pointers inside each descriptor are + // stable. Keep an owned C allocation alive for exactly as long as the renderer resource. + private var resourceIOVecs: [UInt32: OwnedIOVecs] = [:] - // Fence completions arrive on virglrenderer's internal threads (ASYNC_FENCE_CB); the C - // callbacks have no per-instance cookie routing here, so a single active renderer is registered - // globally (one renderer per engine process) and forwards into the device's sink. + // The C callbacks have no per-instance cookie routing here, so a single active renderer is + // registered globally (one renderer per engine process). A callback can run synchronously from + // virgl_renderer_poll while rendererLock is held. Calling the virtio device from there would + // invert rendererLock and the transport's queue lock: a vCPU creating the next fence holds the + // queue lock while waiting for rendererLock, and the poll callback would hold rendererLock while + // waiting for that queue lock. Buffer completions here and deliver them only after the renderer + // API boundary releases rendererLock. private static let fenceLock = NSLock() nonisolated(unsafe) private static weak var activeRenderer: VirglRenderer? nonisolated(unsafe) private var fenceSink: ((UInt32, UInt32, UInt64) -> Void)? + nonisolated(unsafe) private var pendingFenceSignals: [(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64)] = [] public var onFenceSignaled: ((UInt32, UInt32, UInt64) -> Void)? { get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return fenceSink } @@ -24,33 +53,46 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { fileprivate static func signalFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { fenceLock.lock() - let sink = activeRenderer?.fenceSink + activeRenderer?.pendingFenceSignals.append((contextID, ringIndex, fenceID)) fenceLock.unlock() - sink?(contextID, ringIndex, fenceID) } public static func discover( environment: [String: String] = ProcessInfo.processInfo.environment ) throws -> VirglRenderer { + let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1" guard let libraryPath = firstExistingPath(candidates: virglRendererCandidates(environment: environment)) else { throw VMError.invalidConfiguration( - "gpu=venus requires libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks" + "accelerated graphics require libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks" ) } - guard let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment)) else { + let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment)) + guard classicOnly || moltenVKICD != nil else { throw VMError.invalidConfiguration( - "gpu=venus requires MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d" + "VirGL + Venus graphics require MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d" ) } - return try VirglRenderer(libraryPath: libraryPath, moltenVKICDPath: moltenVKICD) + return try VirglRenderer( + libraryPath: libraryPath, + moltenVKICDPath: moltenVKICD, + environment: environment + ) } - public init(libraryPath: String, moltenVKICDPath: String) throws { + public init( + libraryPath: String, + moltenVKICDPath: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { + let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1" guard FileManager.default.fileExists(atPath: libraryPath) else { throw VMError.invalidConfiguration("virglrenderer library not found: \(libraryPath)") } - guard FileManager.default.fileExists(atPath: moltenVKICDPath) else { - throw VMError.invalidConfiguration("MoltenVK ICD not found: \(moltenVKICDPath)") + if !classicOnly { + guard let moltenVKICDPath, + FileManager.default.fileExists(atPath: moltenVKICDPath) else { + throw VMError.invalidConfiguration("VirGL + Venus graphics require a valid MoltenVK ICD") + } } guard let handle = dlopen(libraryPath, RTLD_NOW | RTLD_LOCAL) else { let message = dlerror().map { String(cString: $0) } ?? "unknown dlopen failure" @@ -59,13 +101,28 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { do { let functions = try Functions(handle: handle) + let glContextManager = try VirglCGLContextManager() + if !classicOnly, + dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") == nil { + throw VMError.invalidConfiguration( + "libvirglrenderer at \(libraryPath) is missing Dory's macOS Venus fence fix; rebuild or install the pinned Dory renderer" + ) + } + if !classicOnly, + dlsym(handle, "dory_moltenvk_spirv_native_array_fix") == nil { + throw VMError.invalidConfiguration( + "libvirglrenderer at \(libraryPath) is not linked to Dory's patched MoltenVK runtime; the ICD path alone cannot replace a directly linked MoltenVK dylib" + ) + } guard functions.resourceMap != nil || functions.resourceGetMapPtr != nil else { throw VMError.invalidConfiguration( "libvirglrenderer at \(libraryPath) exports neither virgl_renderer_resource_map nor virgl_renderer_resource_get_map_ptr; Dory's Venus path needs one to expose host-visible blobs to the guest" ) } - setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1) + if let moltenVKICDPath { + setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1) + } if let sym = dlsym(handle, "virgl_set_log_callback") { typealias SetLogCallback = @convention(c) ( @@ -80,9 +137,9 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { callbacks.initialize(to: VirglRendererCallbacks( version: 4, writeFence: doryVirglWriteFence, - createGLContext: nil, - destroyGLContext: nil, - makeCurrent: nil, + createGLContext: doryVirglCreateGLContext, + destroyGLContext: doryVirglDestroyGLContext, + makeCurrent: doryVirglMakeCurrent, getDRMFD: nil, writeContextFence: doryVirglWriteContextFence, getServerFD: nil, @@ -91,42 +148,72 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { // Host-allocated HOST3D blobs (the zero-copy map model libkrun uses): do NOT set // USE_GUEST_VRAM, which would make virglrenderer choose guest-backed storage that returns - // no mappable host pointer. ASYNC_FENCE_CB delivers fence completions from the renderer's - // own threads, so no poll loop is needed for real fence signalling. - let flags = RendererFlags.venus | RendererFlags.noVirgl | RendererFlags.asyncFenceCB - let initStatus = functions.initialize(nil, flags, UnsafeMutableRawPointer(callbacks)) + // no mappable host pointer. Keep both renderers active: VirGL gives the Linux desktop a + // reliable accelerated OpenGL compositor through CGL, while Venus remains available to + // Vulkan applications and compute workloads. The macOS renderer build does not expose + // the Linux eventfd needed by virglrenderer's threaded/async fence mode, so Dory polls + // its fence timelines explicitly below. This covers both VirGL and Venus contexts. + let flags = classicOnly ? 0 : RendererFlags.venus + let rendererCookie = Unmanaged.passUnretained(glContextManager).toOpaque() + let initStatus = functions.initialize(rendererCookie, flags, UnsafeMutableRawPointer(callbacks)) guard initStatus == 0 else { callbacks.deinitialize(count: 1) callbacks.deallocate() - throw VMError.invalidConfiguration("virgl_renderer_init(Venus) failed with status \(initStatus)") + throw VMError.invalidConfiguration("virgl_renderer_init(VirGL + Venus) failed with status \(initStatus)") } - var maxVersion: UInt32 = 0 - var maxSize: UInt32 = 0 - functions.getCapSet(VirtioGPUCapsetID.venus, &maxVersion, &maxSize) - // Venus reports maxVersion == 0 (it negotiates its protocol via the capset data, not the - // capset version number, unlike virgl). Gate on maxSize only. - guard maxSize > 0 else { + var discoveredCapsets = [VirtioGPUCapset]() + for capsetID in [VirtioGPUCapsetID.virgl, VirtioGPUCapsetID.virgl2, VirtioGPUCapsetID.venus] { + var maxVersion: UInt32 = 0 + var maxSize: UInt32 = 0 + functions.getCapSet(capsetID, &maxVersion, &maxSize) + guard maxSize > 0 else { continue } + var capsetData = [UInt8](repeating: 0, count: Int(maxSize)) + capsetData.withUnsafeMutableBytes { buffer in + functions.fillCaps(capsetID, maxVersion, buffer.baseAddress) + } + discoveredCapsets.append(VirtioGPUCapset( + id: capsetID, + maxVersion: maxVersion, + data: capsetData + )) + } + let hasVirgl2 = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.virgl2 }) + let hasVenus = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.venus }) + guard hasVirgl2, classicOnly || hasVenus else { functions.cleanup(nil) callbacks.deinitialize(count: 1) callbacks.deallocate() - throw VMError.invalidConfiguration("virglrenderer did not report a Venus capset") + throw VMError.invalidConfiguration( + classicOnly + ? "virglrenderer did not report the VirGL2 capset" + : "virglrenderer did not report both VirGL2 and Venus capsets" + ) } - var capsetData = [UInt8](repeating: 0, count: Int(maxSize)) - capsetData.withUnsafeMutableBytes { buffer in - functions.fillCaps(VirtioGPUCapsetID.venus, maxVersion, buffer.baseAddress) - } + // CGL contexts are current per host thread. Initialization runs on the app thread, + // while virtio notifications can subsequently arrive on any vCPU thread. Leave no + // renderer context attached to the initializer so each command can bind it safely on + // the thread that is actually executing the command. + glContextManager.clearCurrent() self.libraryPath = libraryPath self.moltenVKICDPath = moltenVKICDPath - self.capsets = [VirtioGPUCapset(id: VirtioGPUCapsetID.venus, maxVersion: maxVersion, data: capsetData)] + self.capsets = discoveredCapsets self.handle = handle self.callbacks = callbacks + self.glContextManager = glContextManager self.functions = functions + self.submissionSyncMode = SubmissionSyncMode(environment: environment) + self.classicOnly = classicOnly Self.fenceLock.lock() Self.activeRenderer = self Self.fenceLock.unlock() + FileHandle.standardError.write(Data( + "dory-gpu: renderer mode=\(classicOnly ? "classic" : "virgl+venus") " + .appending("cross-context synchronization mode=\(submissionSyncMode.rawValue)\n").utf8 + )) + startFencePolling() } catch { dlclose(handle) throw error @@ -134,81 +221,180 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { } deinit { + pollSource?.cancel() + pollSource = nil + pollTimer?.cancel() + pollTimer = nil Self.fenceLock.lock() if Self.activeRenderer === self { Self.activeRenderer = nil } Self.fenceLock.unlock() + rendererLock.lock() + functions.forceContext0() + deletePendingSubmissionSyncs() functions.cleanup(nil) + resourceIOVecs.removeAll() + glContextManager.clearCurrent() + rendererLock.unlock() callbacks.deinitialize(count: 1) callbacks.deallocate() dlclose(handle) } - /// Registers a host fence so virglrenderer signals it (via the async callbacks) once all GPU - /// work submitted before it has completed. Context fences carry Venus's per-ring ordering; - /// plain fences ride the global ctx0 timeline. + /// Registers a host fence so virglrenderer signals it once all GPU work submitted before it has + /// completed. Context fences carry Venus's per-ring ordering; plain fences ride the global ctx0 + /// timeline. public func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws { - if contextFence, let contextCreateFence = functions.contextCreateFence { + try withRendererContext(forceContextZero: false) { + let capsetID = contextFlags[contextID].map { $0 & 0xff } + // Besides binding ctx0 on this host thread, force_ctx_0 invalidates virglrenderer's + // process-global current-context cache. Without that invalidation a zero-length submit + // to the previous producer can be treated as an already-complete switch even though + // Dory cleared the thread-local CGL context at the prior API boundary. + functions.forceContext0() + if contextID != 0, capsetID != VirtioGPUCapsetID.venus { + // virgl_renderer_create_fence records a ctx0 timeline entry but inserts glFenceSync + // into whichever GL context is current. QEMU naturally leaves the producer current; + // Dory deliberately clears CGL at each API boundary because vCPU calls can migrate + // across host threads. A zero-length submit performs virglrenderer's normal context + // switch without changing guest state, restoring the producer before fence creation. + try check( + functions.submitCommand(nil, Int32(bitPattern: contextID), 0), + "virgl_renderer_submit_cmd(context restore for fence)" + ) + } + if contextFence, let contextCreateFence = functions.contextCreateFence { + try check( + contextCreateFence(contextID, 0, ringIndex, fenceID), + "virgl_renderer_context_create_fence" + ) + return + } try check( - contextCreateFence(contextID, 0, ringIndex, fenceID), - "virgl_renderer_context_create_fence" + functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID), + "virgl_renderer_create_fence" ) - return } - try check( - functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID), - "virgl_renderer_create_fence" - ) } public func createContext(id: UInt32, flags: UInt32, name: String) throws { - let status = name.withCString { pointer in - functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer) + try withRendererContext { + let status = name.withCString { pointer in + functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer) + } + guard status == 0 else { + throw VMError.invalidConfiguration( + "virgl_renderer_context_create_with_flags failed with status \(status) " + + "(context=\(id), flags=0x\(String(flags, radix: 16)), name=\(name))" + ) + } + contextLabels[id] = name + contextFlags[id] = flags } - try check(status, "virgl_renderer_context_create_with_flags") } public func destroyContext(id: UInt32) throws { - functions.contextDestroy(id) + try withRendererContext { + functions.contextDestroy(id) + contextLabels.removeValue(forKey: id) + contextFlags.removeValue(forKey: id) + } } public func attachResource(contextID: UInt32, resourceID: UInt32) throws { - functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) + try withRendererContext { + functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) + } } public func detachResource(contextID: UInt32, resourceID: UInt32) throws { - functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) + try withRendererContext { + functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) + } } public func submit3D(contextID: UInt32, command: [UInt8]) throws { - var command = command - while command.count % 4 != 0 { command.append(0) } - let dwordCount = Int32(command.count / 4) - let status = command.withUnsafeMutableBytes { buffer in - functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount) + try withRendererContext { + var command = command + while command.count % 4 != 0 { command.append(0) } + let dwordCount = Int32(command.count / 4) + let status = command.withUnsafeMutableBytes { buffer in + functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount) + } + guard status == 0 else { + let label = contextLabels[contextID] ?? "unknown" + let summary = Self.commandSummary(command) + throw VMError.invalidConfiguration( + "virgl_renderer_submit_cmd failed with status \(status) " + + "(context=\(contextID), name=\(label), bytes=\(command.count), commands=\(summary))" + ) + } + // Contexts share textures, but cross-context visibility still needs explicit ordering. + // Keep the latest completion fence for each producer and resolve it at the readback + // boundary. glFinish is retained as a diagnostic mode for isolating driver bugs. + if submissionSyncMode == .finishAfterSubmit { + glFinish() + return + } + if let previous = pendingSubmissionSyncs.removeValue(forKey: contextID) { + glDeleteSync(previous) + } + if let sync = glFenceSync(GLenum(GL_SYNC_GPU_COMMANDS_COMPLETE), 0) { + pendingSubmissionSyncs[contextID] = sync + glFlush() + } else { + // Extremely old/limited OpenGL implementations still remain correct. + glFinish() + } + } + } + + private static func commandSummary(_ command: [UInt8]) -> String { + var offset = 0 + var descriptions = [String]() + while offset + 4 <= command.count, descriptions.count < 24 { + let header = UInt32(command[offset]) + | (UInt32(command[offset + 1]) << 8) + | (UInt32(command[offset + 2]) << 16) + | (UInt32(command[offset + 3]) << 24) + let opcode = header & 0xff + let length = Int(header >> 16) + descriptions.append("\(offset / 4):\(opcode)/\(length)") + let advance = (length + 1) * 4 + guard advance > 0, advance <= command.count - offset else { break } + offset += advance } - try check(status, "virgl_renderer_submit_cmd") + if offset < command.count { descriptions.append("...") } + return descriptions.joined(separator: ",") } public func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws { - var args = VirglRendererResourceCreateArgs( - handle: resource.resourceID, - target: resource.target, - format: resource.format, - bind: resource.bind, - width: resource.width, - height: resource.height, - depth: resource.depth, - arraySize: resource.arraySize, - lastLevel: resource.lastLevel, - samples: resource.samples, - flags: resource.flags - ) - let status = try withIOVecs(entries) { pointer, count in - withUnsafeMutablePointer(to: &args) { argsPointer in - functions.resourceCreate(UnsafeMutableRawPointer(argsPointer), pointer, count) + try withRendererContext { + var args = VirglRendererResourceCreateArgs( + handle: resource.resourceID, + target: resource.target, + format: resource.format, + bind: resource.bind, + width: resource.width, + height: resource.height, + depth: resource.depth, + arraySize: resource.arraySize, + lastLevel: resource.lastLevel, + samples: resource.samples, + flags: resource.flags + ) + let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries) + let status = withUnsafeMutablePointer(to: &args) { argsPointer in + functions.resourceCreate( + UnsafeMutableRawPointer(argsPointer), + backing?.pointer, + backing?.count ?? 0 + ) + } + try check(status, "virgl_renderer_resource_create") + if let backing { + resourceIOVecs[resource.resourceID] = backing } } - try check(status, "virgl_renderer_resource_create") } public func createBlob( @@ -220,108 +406,253 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { size: UInt64, entries: [VirtioGPUMemoryEntry] ) throws { - var args = VirglRendererResourceCreateBlobArgs( - resourceHandle: resourceID, - contextID: contextID, - blobMemory: blobMemory, - blobFlags: blobFlags, - blobID: blobID, - size: size, - iovecs: nil, - iovecCount: 0 - ) - let status = try withIOVecs(entries) { pointer, count in - args.iovecs = pointer - args.iovecCount = count - return withUnsafePointer(to: &args) { argsPointer in + try withRendererContext { + let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries) + var args = VirglRendererResourceCreateBlobArgs( + resourceHandle: resourceID, + contextID: contextID, + blobMemory: blobMemory, + blobFlags: blobFlags, + blobID: blobID, + size: size, + iovecs: backing?.pointer, + iovecCount: backing?.count ?? 0 + ) + let status = withUnsafePointer(to: &args) { argsPointer in functions.resourceCreateBlob(UnsafeRawPointer(argsPointer)) } + try check(status, "virgl_renderer_resource_create_blob") + if let backing { + resourceIOVecs[resourceID] = backing + } } - try check(status, "virgl_renderer_resource_create_blob") } public func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws { - let status = try withIOVecs(entries) { pointer, count in - functions.resourceAttachIOV(Int32(bitPattern: resourceID), pointer, Int32(count)) + try withRendererContext { + guard !entries.isEmpty else { + throw VMError.invalidConfiguration("cannot attach empty backing to renderer resource \(resourceID)") + } + let backing = OwnedIOVecs(entries: entries) + let status = functions.resourceAttachIOV( + Int32(bitPattern: resourceID), + backing.pointer, + Int32(backing.count) + ) + try check(status, "virgl_renderer_resource_attach_iov") + resourceIOVecs[resourceID] = backing } - try check(status, "virgl_renderer_resource_attach_iov") } public func detachBacking(resourceID: UInt32) throws { - var detached: UnsafeMutablePointer? - var count: Int32 = 0 - functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count) + try withRendererContext { + var detached: UnsafeMutablePointer? + var count: Int32 = 0 + functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count) + resourceIOVecs.removeValue(forKey: resourceID) + } } public func unrefResource(resourceID: UInt32) throws { - functions.resourceUnref(resourceID) + try withRendererContext { + functions.resourceUnref(resourceID) + resourceIOVecs.removeValue(forKey: resourceID) + } } public func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping { - var pointer: UnsafeMutableRawPointer? - var size: UInt64 = 0 - // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns - // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the - // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent. - if let getPtr = functions.resourceGetMapPtr { - var address: UInt64 = 0 - try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr") - pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) - } else if let map = functions.resourceMap { - try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map") - } else { - throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint") - } - guard let hostPointer = pointer else { - throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)") + try withRendererContext { + var pointer: UnsafeMutableRawPointer? + var size: UInt64 = 0 + // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns + // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the + // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent. + let requiresRendererUnmap: Bool + if let getPtr = functions.resourceGetMapPtr { + var address: UInt64 = 0 + try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr") + pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) + // get_map_ptr exposes the existing Vulkan allocation without changing virglrenderer's + // resource-map state. Calling resource_unmap for this path is therefore invalid. + requiresRendererUnmap = false + } else if let map = functions.resourceMap { + try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map") + requiresRendererUnmap = true + } else { + throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint") + } + guard let hostPointer = pointer else { + throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)") + } + var mapInfo: UInt32 = 0 + try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info") + return VirtioGPUBlobMapping( + hostPointer: hostPointer, + size: size, + mapInfo: mapInfo & 0x0f, + requiresRendererUnmap: requiresRendererUnmap + ) } - var mapInfo: UInt32 = 0 - try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info") - return VirtioGPUBlobMapping(hostPointer: hostPointer, size: size, mapInfo: mapInfo & 0x0f) } public func unmapBlob(resourceID: UInt32) throws { - try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap") + try withRendererContext { + try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap") + } } public func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferWriteIOV( - transfer.resourceID, - transfer.contextID, - Int32(bitPattern: transfer.level), - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - count - ) + try withRendererContext { + var box = VirglBox(values: transfer.box) + let status = try withIOVecs(entries) { pointer, count in + withUnsafePointer(to: &box) { boxPointer in + functions.transferWriteIOV( + transfer.resourceID, + transfer.contextID, + Int32(bitPattern: transfer.level), + transfer.stride, + transfer.layerStride, + UnsafeRawPointer(boxPointer), + transfer.offset, + pointer, + count + ) + } } + try check(status, "virgl_renderer_transfer_write_iov") } - try check(status, "virgl_renderer_transfer_write_iov") } public func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferReadIOV( - transfer.resourceID, - transfer.contextID, - transfer.level, - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - Int32(count) + try withRendererContext { + try waitForPendingSubmissions() + var box = VirglBox(values: transfer.box) + let status = try withIOVecs(entries) { pointer, count in + withUnsafePointer(to: &box) { boxPointer in + functions.transferReadIOV( + transfer.resourceID, + transfer.contextID, + transfer.level, + transfer.stride, + transfer.layerStride, + UnsafeRawPointer(boxPointer), + transfer.offset, + pointer, + Int32(count) + ) + } + } + try check(status, "virgl_renderer_transfer_read_iov") + } + } + + private func waitForPendingSubmissions() throws { + for sync in pendingSubmissionSyncs.values { + switch submissionSyncMode { + case .clientWaitBeforeReadback: + // glWaitSync only orders commands subsequently issued by its *current* context. + // virglrenderer may switch from ctx0 to a resource context during transfer_read, + // so a server-side wait in ctx0 does not protect that readback. A client wait + // establishes completion before any context switch and is valid for shared sync + // objects created by Firefox, Mutter, or another guest GL producer. + try waitForCompletion(sync) + case .serverWaitBeforeReadback: + glWaitSync(sync, 0, GLuint64(GL_TIMEOUT_IGNORED)) + case .finishAfterSubmit: + break + } + glDeleteSync(sync) + } + pendingSubmissionSyncs.removeAll(keepingCapacity: true) + } + + private func waitForCompletion(_ sync: GLsync) throws { + let deadline = ProcessInfo.processInfo.systemUptime + 5 + while true { + let result = glClientWaitSync(sync, 0, 100_000_000) + switch result { + case GLenum(GL_ALREADY_SIGNALED), GLenum(GL_CONDITION_SATISFIED): + return + case GLenum(GL_TIMEOUT_EXPIRED): + guard ProcessInfo.processInfo.systemUptime < deadline else { + throw VMError.invalidConfiguration( + "timed out waiting for a shared OpenGL submission before scanout readback" + ) + } + default: + throw VMError.invalidConfiguration( + "glClientWaitSync failed before scanout readback (status=0x\(String(result, radix: 16)))" ) } } - try check(status, "virgl_renderer_transfer_read_iov") + } + + private func deletePendingSubmissionSyncs() { + for sync in pendingSubmissionSyncs.values { glDeleteSync(sync) } + pendingSubmissionSyncs.removeAll() + } + + /// virglrenderer caches its active context globally because QEMU normally calls it from one + /// event-loop thread. Dory's MMIO notifications can originate on any vCPU thread, while CGL's + /// current context is thread-local. Resetting to ctx0 at each serialized API boundary makes + /// virglrenderer issue the appropriate make-current callback on the actual calling thread. + private func withRendererContext( + forceContextZero: Bool = true, + _ body: () throws -> T + ) throws -> T { + rendererLock.lock() + if forceContextZero { + functions.forceContext0() + } + defer { + glContextManager.clearCurrent() + rendererLock.unlock() + deliverPendingFenceSignals() + } + return try body() + } + + /// Runs the device-facing part of a fence completion outside rendererLock. Besides avoiding the + /// lock inversion documented above, this keeps virglrenderer's C callback short and prevents + /// guest used-ring writes or virtual interrupts from re-entering the renderer while it polls. + private func deliverPendingFenceSignals() { + Self.fenceLock.lock() + let signals = pendingFenceSignals + pendingFenceSignals.removeAll(keepingCapacity: true) + let sink = fenceSink + Self.fenceLock.unlock() + guard let sink else { return } + for signal in signals { + sink(signal.contextID, signal.ringIndex, signal.fenceID) + } + } + + /// virglrenderer's threaded fence worker signals this descriptor when the caller must run + /// `virgl_renderer_poll` (notably to service GL queries before retiring a fence). Without an + /// event-loop subscriber, a compositor can wait forever after submitting its first frame. + private func startFencePolling() { + let descriptor = functions.getPollFD() + let queue = DispatchQueue(label: "dev.dory.virgl.poll", qos: .userInteractive) + let poll: @Sendable () -> Void = { [weak self] in + guard let self else { return } + try? self.withRendererContext { + self.functions.poll() + } + } + if descriptor >= 0 { + let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue) + source.setEventHandler(handler: poll) + pollSource = source + source.resume() + } else { + // CGL builds have no eventfd. One millisecond keeps guest fence latency below a frame + // without burning a vCPU in a busy loop; poll itself is non-blocking. + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .milliseconds(1), leeway: .milliseconds(1)) + timer.setEventHandler(handler: poll) + pollTimer = timer + timer.resume() + } } private func withIOVecs( @@ -393,13 +724,48 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { } private enum RendererFlags { + static let threadSync: Int32 = 1 << 1 static let venus: Int32 = 1 << 6 - static let noVirgl: Int32 = 1 << 7 static let asyncFenceCB: Int32 = 1 << 8 static let useGuestVRAM: Int32 = 1 << 14 } +private enum SubmissionSyncMode: String { + case clientWaitBeforeReadback = "client-wait" + case serverWaitBeforeReadback = "server-wait" + case finishAfterSubmit = "finish" + + init(environment: [String: String]) { + let requested = environment["DORY_VIRGL_SYNC_MODE"]?.lowercased() + self = SubmissionSyncMode(rawValue: requested ?? "") ?? .serverWaitBeforeReadback + } +} + +private final class OwnedIOVecs { + let pointer: UnsafeMutablePointer + let count: UInt32 + + init(entries: [VirtioGPUMemoryEntry]) { + precondition(!entries.isEmpty) + count = UInt32(entries.count) + pointer = .allocate(capacity: entries.count) + for (index, entry) in entries.enumerated() { + pointer.advanced(by: index).initialize(to: iovec( + iov_base: entry.pointer, + iov_len: entry.length + )) + } + } + + deinit { + pointer.deinitialize(count: Int(count)) + pointer.deallocate() + } +} + private enum VirtioGPUCapsetID { + static let virgl: UInt32 = 1 + static let virgl2: UInt32 = 2 static let venus: UInt32 = 4 } @@ -430,6 +796,97 @@ private let doryVirglWriteContextFence: WriteContextFenceCallback = { _, context VirglRenderer.signalFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) } +/// virglrenderer's classic 3D path needs the VMM to supply host OpenGL contexts when it is not +/// using EGL or GLX. CGL is the native offscreen OpenGL context API on macOS and remains backed by +/// the Apple GPU, so the guest compositor avoids llvmpipe without adding another window surface. +private final class VirglCGLContextManager: @unchecked Sendable { + private let lock = NSLock() + private let pixelFormat: CGLPixelFormatObj + private var shareContext: CGLContextObj? + + init() throws { + var format: CGLPixelFormatObj? + var formatCount: GLint = 0 + var attributes: [CGLPixelFormatAttribute] = [ + kCGLPFAAccelerated, + kCGLPFAOpenGLProfile, + CGLPixelFormatAttribute(kCGLOGLPVersion_GL4_Core.rawValue), + CGLPixelFormatAttribute(0), + ] + let status = CGLChoosePixelFormat(&attributes, &format, &formatCount) + guard status == kCGLNoError, formatCount > 0, let format else { + throw VMError.invalidConfiguration("cannot create an accelerated macOS OpenGL pixel format (CGL status \(status.rawValue))") + } + pixelFormat = format + } + + deinit { + CGLDestroyPixelFormat(pixelFormat) + } + + func createContext(shared: Bool) -> UnsafeMutableRawPointer? { + lock.lock() + defer { lock.unlock() } + var context: CGLContextObj? + let sharedContext = shared ? shareContext : nil + let status = CGLCreateContext(pixelFormat, sharedContext, &context) + guard status == kCGLNoError, let context else { + FileHandle.standardError.write(Data( + "dory-gpu: CGL context creation failed shared=\(shared) status=\(status.rawValue)\n".utf8 + )) + return nil + } + if shareContext == nil { + shareContext = context + } + return UnsafeMutableRawPointer(context) + } + + func destroyContext(_ pointer: UnsafeMutableRawPointer) { + let context = pointer.assumingMemoryBound(to: CGLContextObj.Pointee.self) + lock.lock() + let wasRoot = shareContext == context + if wasRoot { + shareContext = nil + } + lock.unlock() + if CGLGetCurrentContext() == context { + _ = CGLSetCurrentContext(nil) + } + CGLDestroyContext(context) + } + + func makeCurrent(_ pointer: UnsafeMutableRawPointer?) -> Int32 { + let context = pointer.map { $0.assumingMemoryBound(to: CGLContextObj.Pointee.self) } + return CGLSetCurrentContext(context) == kCGLNoError ? 0 : -1 + } + + func clearCurrent() { + _ = CGLSetCurrentContext(nil) + } +} + +private func doryVirglContextManager(_ cookie: UnsafeMutableRawPointer?) -> VirglCGLContextManager? { + cookie.map { Unmanaged.fromOpaque($0).takeUnretainedValue() } +} + +private let doryVirglCreateGLContext: CreateGLContextCallback = { cookie, _, parameters in + guard let manager = doryVirglContextManager(cookie), let parameters else { return nil } + // C layout: int version; bool shared; 3 bytes padding; int major; int minor; int compat. + let shared = parameters.load(fromByteOffset: 4, as: UInt8.self) != 0 + return manager.createContext(shared: shared) +} + +private let doryVirglDestroyGLContext: DestroyGLContextCallback = { cookie, context in + guard let manager = doryVirglContextManager(cookie), let context else { return } + manager.destroyContext(context) +} + +private let doryVirglMakeCurrent: MakeCurrentCallback = { cookie, _, context in + guard let manager = doryVirglContextManager(cookie) else { return -1 } + return manager.makeCurrent(context) +} + private struct VirglRendererCallbacks { var version: Int32 var writeFence: WriteFenceCallback? @@ -493,6 +950,7 @@ private struct Functions { typealias FillCaps = @convention(c) (UInt32, UInt32, UnsafeMutableRawPointer?) -> Void typealias ContextCreateWithFlags = @convention(c) (UInt32, UInt32, UInt32, UnsafePointer?) -> Int32 typealias ContextDestroy = @convention(c) (UInt32) -> Void + typealias ForceContext0 = @convention(c) () -> Void typealias ContextResource = @convention(c) (Int32, Int32) -> Void typealias SubmitCommand = @convention(c) (UnsafeMutableRawPointer?, Int32, Int32) -> Int32 typealias ResourceCreate = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt32) -> Int32 @@ -529,6 +987,8 @@ private struct Functions { ) -> Int32 typealias CreateFence = @convention(c) (Int32, UInt32) -> Int32 typealias ContextCreateFence = @convention(c) (UInt32, UInt32, UInt32, UInt64) -> Int32 + typealias Poll = @convention(c) () -> Void + typealias GetPollFD = @convention(c) () -> Int32 let initialize: Initialize let cleanup: Cleanup @@ -536,6 +996,7 @@ private struct Functions { let fillCaps: FillCaps let contextCreateWithFlags: ContextCreateWithFlags let contextDestroy: ContextDestroy + let forceContext0: ForceContext0 let contextAttachResource: ContextResource let contextDetachResource: ContextResource let submitCommand: SubmitCommand @@ -553,6 +1014,8 @@ private struct Functions { let transferReadIOV: TransferReadIOV let createFence: CreateFence let contextCreateFence: ContextCreateFence? + let poll: Poll + let getPollFD: GetPollFD init(handle: UnsafeMutableRawPointer) throws { initialize = try Self.required(handle, "virgl_renderer_init") @@ -561,6 +1024,7 @@ private struct Functions { fillCaps = try Self.required(handle, "virgl_renderer_fill_caps") contextCreateWithFlags = try Self.required(handle, "virgl_renderer_context_create_with_flags") contextDestroy = try Self.required(handle, "virgl_renderer_context_destroy") + forceContext0 = try Self.required(handle, "virgl_renderer_force_ctx_0") contextAttachResource = try Self.required(handle, "virgl_renderer_ctx_attach_resource") contextDetachResource = try Self.required(handle, "virgl_renderer_ctx_detach_resource") submitCommand = try Self.required(handle, "virgl_renderer_submit_cmd") @@ -578,6 +1042,8 @@ private struct Functions { transferReadIOV = try Self.required(handle, "virgl_renderer_transfer_read_iov") createFence = try Self.required(handle, "virgl_renderer_create_fence") contextCreateFence = Self.optional(handle, "virgl_renderer_context_create_fence") + poll = try Self.required(handle, "virgl_renderer_poll") + getPollFD = try Self.required(handle, "virgl_renderer_get_poll_fd") } private static func required(_ handle: UnsafeMutableRawPointer, _ name: String) throws -> T { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 3fa9c245..301380a0 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -25,16 +25,24 @@ public struct VirtioGPUMemoryEntry { } /// A host-visible blob mapping produced by the renderer: the host pointer virglrenderer owns (to be -/// hv_vm_map'd into the guest window), its size, and the guest-facing cache map info. +/// hv_vm_map'd into the guest window), its size, the guest-facing cache map info, and whether the +/// renderer API created map state which must later be explicitly released. public struct VirtioGPUBlobMapping { public var hostPointer: UnsafeMutableRawPointer public var size: UInt64 public var mapInfo: UInt32 + public var requiresRendererUnmap: Bool - public init(hostPointer: UnsafeMutableRawPointer, size: UInt64, mapInfo: UInt32) { + public init( + hostPointer: UnsafeMutableRawPointer, + size: UInt64, + mapInfo: UInt32, + requiresRendererUnmap: Bool = true + ) { self.hostPointer = hostPointer self.size = size self.mapInfo = mapInfo + self.requiresRendererUnmap = requiresRendererUnmap } } @@ -62,6 +70,55 @@ public struct VirtioGPUTransfer3D { public var box: [UInt32] } +public struct VirtioGPURect: Sendable, Equatable { + public var x: UInt32 + public var y: UInt32 + public var width: UInt32 + public var height: UInt32 + + public init(x: UInt32, y: UInt32, width: UInt32, height: UInt32) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +/// One copied scanout update ready for a host display surface. `width` and `height` describe the +/// complete scanout, while `bytes` contains only `dirtyRect` rows at `stride` bytes per row. The +/// device never exposes guest pointers to the UI layer, and small browser repaints therefore avoid +/// copying the rest of a 4K framebuffer merely to update one damaged rectangle. +public struct VirtioGPUScanoutFrame: Sendable, Equatable { + public var scanoutID: UInt32 + public var resourceID: UInt32 + public var format: UInt32 + public var width: UInt32 + public var height: UInt32 + public var stride: UInt32 + public var dirtyRect: VirtioGPURect + public var bytes: Data + + public init( + scanoutID: UInt32, + resourceID: UInt32, + format: UInt32, + width: UInt32, + height: UInt32, + stride: UInt32, + dirtyRect: VirtioGPURect, + bytes: Data + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.format = format + self.width = width + self.height = height + self.stride = stride + self.dirtyRect = dirtyRect + self.bytes = bytes + } +} + public protocol VirtioGPURenderer: AnyObject { var capsets: [VirtioGPUCapset] { get } func createContext(id: UInt32, flags: UInt32, name: String) throws @@ -175,11 +232,60 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi public let sharedMemoryRegions: [VirtioSharedMemoryRegion] private let scanoutCount: UInt32 + private let displayLock = NSLock() + private var scanoutWidth: UInt32 + private var scanoutHeight: UInt32 + private var pendingDisplayEvents: UInt32 = 0 + private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? + private let onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? private let renderer: VirtioGPURenderer? private let capsets: [VirtioGPUCapset] private let hostVisibleMemory: VirtioGPUHostVisibleMemory? private var resourceEntries: [UInt32: [VirtioGPUMemoryEntry]] = [:] private var blobResources: [UInt32: BlobResource] = [:] + /// UUIDs back VIRTIO_GPU_F_RESOURCE_UUID, which Linux exposes as the cross-device DRM + /// capability required by Mesa Venus before it will create a Vulkan instance. Keep an assigned + /// UUID stable for the lifetime of each renderer resource; the value is an opaque identity to + /// the guest and does not imply a host dma-buf export on macOS. + private var resourceUUIDs: [UInt32: [UInt8]] = [:] + + private struct Resource2D { + var format: UInt32 + var width: UInt32 + var height: UInt32 + var backing: [VirtioGPUMemoryEntry] = [] + } + + private struct Resource3D { + var format: UInt32 + var width: UInt32 + var height: UInt32 + } + + private struct ScanoutBinding { + enum Source { + case resource2D + case resource3D + case blob(format: UInt32, width: UInt32, height: UInt32, stride: UInt32, offset: UInt32) + } + + var resourceID: UInt32 + var rect: VirtioGPURect + var source: Source + } + + private var resources2D: [UInt32: Resource2D] = [:] + private var resources3D: [UInt32: Resource3D] = [:] + private var published3DResources: Set = [] + /// Keep one full-resource readback allocation per renderer resource. A partial renderer + /// transfer writes its first pixel at `offset` and then advances by `stride`; the source box's + /// x/y coordinates are not implicitly added to the destination address. Supplying the matching + /// full-resource offset lets later scanout extraction use ordinary resource coordinates. + private var rendererReadbackBuffers: [UInt32: Data] = [:] + private var scanouts: [UInt32: ScanoutBinding] = [:] + private var commandFailureCounts: [UInt32: Int] = [:] + private let traceResourceLifecycle: Bool + private var resourceTraceSequence: UInt64 = 0 // Real fence signalling: a fenced command's descriptor is held here and completed only when the // renderer signals the fence (from its own thread), per the virtio-gpu contract — responding @@ -215,6 +321,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi static let resourceDetachBacking: UInt32 = 0x0107 static let getCapsetInfo: UInt32 = 0x0108 static let getCapset: UInt32 = 0x0109 + static let resourceAssignUUID: UInt32 = 0x010B static let resourceCreateBlob: UInt32 = 0x010C static let setScanoutBlob: UInt32 = 0x010D static let ctxCreate: UInt32 = 0x0200 @@ -236,6 +343,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi static let okDisplayInfo: UInt32 = 0x1101 static let okCapsetInfo: UInt32 = 0x1102 static let okCapset: UInt32 = 0x1103 + static let okResourceUUID: UInt32 = 0x1105 static let okMapInfo: UInt32 = 0x1106 static let errorUnspecified: UInt32 = 0x1200 static let errorInvalidParameter: UInt32 = 0x1202 @@ -253,7 +361,10 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } private struct BlobResource { + var memory: UInt32 var size: UInt64 + var mapping: VirtioGPUBlobMapping? + var guestMapped = false } /// - Parameters: @@ -263,33 +374,83 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi hostMemoryBase: UInt64, hostMemorySize: UInt64 = 256 * 1024 * 1024, scanoutCount: UInt32 = 0, + scanoutWidth: UInt32 = 1_280, + scanoutHeight: UInt32 = 800, renderer: VirtioGPURenderer? = nil, - hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil + hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, + traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1", + onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil, + onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil ) { + let boundedScanoutCount = min(scanoutCount, 16) self.renderer = renderer self.capsets = renderer?.capsets ?? [] + self.traceResourceLifecycle = traceResourceLifecycle self.deviceFeatures = renderer == nil ? 0 : Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit self.sharedMemoryRegions = [ VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize) ] - self.scanoutCount = scanoutCount + self.scanoutCount = boundedScanoutCount + self.scanoutWidth = max(1, scanoutWidth) + self.scanoutHeight = max(1, scanoutHeight) self.hostVisibleMemory = hostVisibleMemory + self.onScanoutFrame = onScanoutFrame + self.onScanoutResourceReleased = onScanoutResourceReleased renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) } } public var configSpace: [UInt8] { + displayLock.lock() + let events = pendingDisplayEvents + let count = scanoutCount + displayLock.unlock() var config = [UInt8]() - config.appendLE(UInt32(0)) // events_read + config.appendLE(events) // events_read config.appendLE(UInt32(0)) // events_clear - config.appendLE(scanoutCount) // num_scanouts + config.appendLE(count) // num_scanouts config.appendLE(UInt32(capsets.count)) // num_capsets return config } + /// Publishes a new preferred scanout size and raises VIRTIO_GPU_EVENT_DISPLAY. The Linux DRM + /// driver responds by re-reading GET_DISPLAY_INFO and issuing a real modeset, so the guest + /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed + /// framebuffer on the host. + public func updateScanoutSize( + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + let boundedWidth = min(16_384, max(1, width)) + let boundedHeight = min(16_384, max(1, height)) + displayLock.lock() + let changed = scanoutWidth != boundedWidth || scanoutHeight != boundedHeight + if changed { + scanoutWidth = boundedWidth + scanoutHeight = boundedHeight + pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY + } + displayLock.unlock() + if changed { transport.notifyConfigChange() } + } + + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { + guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return } + var cleared: UInt32 = 0 + for byte in 0..> UInt64(byte * 8)) & 0xFF) << UInt32((position - 4) * 8) + } + displayLock.lock() + pendingDisplayEvents &= ~cleared + displayLock.unlock() + } + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { guard queue == 0 || queue == 1 else { return } fenceLock.lock() @@ -325,18 +486,33 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi let contextID = request.leUInt32(at: 16) let ringIndex = UInt32(request[20]) let contextFence = flags & HeaderFlag.infoRingIndex != 0 - do { - try renderer.createFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID, contextFence: contextFence) - } catch { - return false - } // ctx0 fences signal without context/ring coordinates, so they queue under (0, 0). let key = contextFence ? FenceKey(contextID: contextID, ringIndex: ringIndex) : FenceKey(contextID: 0, ringIndex: 0) + // Publish the waiter before creating the renderer fence. With a fast host GPU (and in + // particular after a synchronizing readback), virglrenderer may invoke its completion + // callback from createFence itself or immediately on another thread. Registering after + // createFence loses that edge forever and stalls the guest compositor on its first frame. fenceLock.lock() pendingFences[key, default: []].append(PendingFence(fenceID: fenceID, response: response, chain: chain)) fenceLock.unlock() + do { + try renderer.createFence( + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID, + contextFence: contextFence + ) + } catch { + fenceLock.lock() + if var waiting = pendingFences[key] { + waiting.removeAll { $0.fenceID == fenceID } + pendingFences[key] = waiting.isEmpty ? nil : waiting + } + fenceLock.unlock() + return false + } return true } @@ -388,13 +564,219 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi switch command { case Command.getDisplayInfo: + displayLock.lock() + let width = scanoutWidth + let height = scanoutHeight + let count = scanoutCount + displayLock.unlock() var response = responseHeader(type: Response.okDisplayInfo, request: request) - response.append(contentsOf: repeatElement(UInt8(0), count: 16 * 24)) + for index in 0..<16 { + response.appendLE(UInt32(0)) + response.appendLE(UInt32(0)) + response.appendLE(index < count ? width : 0) + response.appendLE(index < count ? height : 0) + response.appendLE(index < count ? UInt32(1) : 0) + response.appendLE(UInt32(0)) + } return response + case Command.resourceCreate2D: + return scanoutCommand(request: request) { + try requireLength(request, 40) + let resourceID = request.leUInt32(at: 24) + let format = request.leUInt32(at: 28) + let width = request.leUInt32(at: 32) + let height = request.leUInt32(at: 36) + guard resourceID != 0, + resources2D[resourceID] == nil, + resources3D[resourceID] == nil, + blobResources[resourceID] == nil, + width > 0, height > 0, + width <= 16_384, height <= 16_384, + Self.isSupportedScanoutFormat(format), + UInt64(width) * UInt64(height) * 4 <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("invalid virtio-gpu 2D resource") + } + // VirGL clients may use a RESOURCE_CREATE_2D allocation as a texture after + // attaching it to a renderer context. Keep the renderer's global resource table in + // lockstep with the device-side scanout table, matching QEMU's virgl path. Without + // this registration virgl_renderer_ctx_attach_resource silently ignores the ID and + // a later sampler-view creation fails as an illegal resource. + if let renderer { + try renderer.createResource3D( + VirtioGPUResourceCreate3D( + resourceID: resourceID, + target: 2, + format: format, + bind: 1 << 1, + width: width, + height: height, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1 + ), + entries: [] + ) + } + resources2D[resourceID] = Resource2D( + format: format, + width: width, + height: height + ) + return responseHeader(type: Response.okNoData, request: request) + } + case Command.setScanout: + return scanoutCommand(request: request) { + try requireLength(request, 48) + let scanoutID = request.leUInt32(at: 40) + let resourceID = request.leUInt32(at: 44) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout id") + } + // Linux disables a scanout with resource_id=0 while changing modes. The rectangle + // is ignored by the protocol in that case and is commonly all zeroes, so do not + // reject the legitimate disable before inspecting the resource id. + if resourceID == 0 { + let previous = scanouts.removeValue(forKey: scanoutID) + if let previous, case .blob = previous.source, let renderer { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + } + return responseHeader(type: Response.okNoData, request: request) + } + let rect = try scanoutRect(from: request, at: 24) + let source: ScanoutBinding.Source + let resourceWidth: UInt32 + let resourceHeight: UInt32 + if let resource = resources2D[resourceID] { + source = .resource2D + resourceWidth = resource.width + resourceHeight = resource.height + } else if let resource = resources3D[resourceID] { + source = .resource3D + resourceWidth = resource.width + resourceHeight = resource.height + } else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout resource") + } + guard Self.contains(rect: rect, width: resourceWidth, height: resourceHeight) else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout rectangle") + } + let previous = scanouts.updateValue( + ScanoutBinding( + resourceID: resourceID, + rect: rect, + source: source + ), + forKey: scanoutID + ) + if let previous, case .blob = previous.source, let renderer { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + } + // A compositor may render and flush its next buffer before atomically binding it + // to the KMS scanout (notably when returning from a fullscreen/direct-scanout + // surface). A host display that only observes flushes would then keep showing the + // old application buffer forever. Publish the newly bound resource immediately; + // renderer readback waits for outstanding producer work before copying it. + let frames: [VirtioGPUScanoutFrame] + switch source { + case .resource2D: + guard let resource = resources2D[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu 2D scanout resource disappeared") + } + frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect) + case .resource3D: + guard let renderer, let resource = resources3D[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + frames = try rendererScanoutFrames( + resourceID: resourceID, + resource: resource, + dirtyRect: rect, + renderer: renderer + ) + case .blob: + frames = [] + } + for frame in frames { onScanoutFrame?(frame) } + return responseHeader(type: Response.okNoData, request: request) + } + case Command.transferToHost2D: + return scanoutCommand(request: request) { + try requireLength(request, 56) + let rect = try scanoutRect(from: request, at: 24) + let offset = request.leUInt64(at: 40) + let resourceID = request.leUInt32(at: 48) + guard let resource = resources2D[resourceID], + Self.contains(rect: rect, width: resource.width, height: resource.height), + offset < UInt64(resource.width) * UInt64(resource.height) * 4 else { + throw VMError.invalidConfiguration("invalid virtio-gpu 2D transfer") + } + // Guest backing is directly mapped into this process. The later RESOURCE_FLUSH is + // the ownership boundary at which Dory copies a coherent frame for the host UI. + return responseHeader(type: Response.okNoData, request: request) + } + case Command.resourceFlush: + return scanoutCommand(request: request) { + try requireLength(request, 48) + let rect = try scanoutRect(from: request, at: 24) + let resourceID = request.leUInt32(at: 40) + if let resource = resources2D[resourceID] { + guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { + throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") + } + let frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect) + for frame in frames { + onScanoutFrame?(frame) + } + return responseHeader(type: Response.okNoData, request: request) + } + if let resource = resources3D[resourceID] { + guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { + throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource flush") + } + guard let renderer else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + let frames = try rendererScanoutFrames( + resourceID: resourceID, + resource: resource, + dirtyRect: rect, + renderer: renderer + ) + for frame in frames { + onScanoutFrame?(frame) + } + return responseHeader(type: Response.okNoData, request: request) + } + guard let blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") + } + let frames = try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect) + for frame in frames { + onScanoutFrame?(frame) + } + return responseHeader(type: Response.okNoData, request: request) + } case Command.getCapsetInfo: return capsetInfoResponse(request: request) case Command.getCapset: return capsetResponse(request: request) + case Command.resourceAssignUUID: + return scanoutCommand(request: request) { + try requireLength(request, 32) + let resourceID = request.leUInt32(at: 24) + guard resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || blobResources[resourceID] != nil else { + throw VMError.invalidConfiguration("virtio-gpu UUID request for unknown resource") + } + let uuid = resourceUUIDs[resourceID] ?? Self.makeResourceUUID() + resourceUUIDs[resourceID] = uuid + var response = responseHeader(type: Response.okResourceUUID, request: request) + response.append(contentsOf: uuid) + return response + } case Command.ctxCreate: return rendererCommand(request: request) { renderer in guard request.count >= 96 else { throw VMError.unexpectedExit("short virtio-gpu ctx_create") } @@ -403,24 +785,38 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi let contextInit = request.leUInt32(at: 28) let nameBytes = request[32..<(32 + nameLength)].prefix { $0 != 0 } let name = String(decoding: nameBytes, as: UTF8.self) - try renderer.createContext(id: contextID, flags: contextInit & 0xff, name: name) + try renderer.createContext( + id: contextID, + flags: Self.rendererContextFlags(requested: contextInit, capsets: renderer.capsets), + name: name + ) + traceResourceEvent("context-create", contextID: contextID, detail: "name=\(name) init=0x\(String(contextInit, radix: 16))") return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDestroy: return rendererCommand(request: request) { renderer in - try renderer.destroyContext(id: request.leUInt32(at: 16)) + let contextID = request.leUInt32(at: 16) + try renderer.destroyContext(id: contextID) + traceResourceEvent("context-destroy", contextID: contextID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxAttachResource: return rendererCommand(request: request) { renderer in try requireLength(request, 32) - try renderer.attachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24)) + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + traceResourceEvent("attach-begin", contextID: contextID, resourceID: resourceID) + try renderer.attachResource(contextID: contextID, resourceID: resourceID) + traceResourceEvent("attach-end", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDetachResource: return rendererCommand(request: request) { renderer in try requireLength(request, 32) - try renderer.detachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24)) + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + try renderer.detachResource(contextID: contextID, resourceID: resourceID) + traceResourceEvent("detach", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.submit3D: @@ -447,23 +843,55 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi samples: request.leUInt32(at: 60), flags: request.leUInt32(at: 64) ) + guard resource.resourceID != 0, + resources2D[resource.resourceID] == nil, + resources3D[resource.resourceID] == nil, + blobResources[resource.resourceID] == nil else { + throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource") + } try renderer.createResource3D(resource, entries: resourceEntries[resource.resourceID] ?? []) + resources3D[resource.resourceID] = Resource3D( + format: resource.format, + width: resource.width, + height: resource.height + ) + traceResourceEvent("create-3d", contextID: request.leUInt32(at: 16), resourceID: resource.resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceAttachBacking: - return rendererCommand(request: request) { renderer in + return scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) let entries = try memoryEntries(from: request, count: request.leUInt32(at: 28), offset: 32, transport: transport) + if var resource = resources2D[resourceID] { + if let renderer { + try renderer.attachBacking(resourceID: resourceID, entries: entries) + } + resource.backing = entries + resources2D[resourceID] = resource + return responseHeader(type: Response.okNoData, request: request) + } resourceEntries[resourceID] = entries - try renderer.attachBacking(resourceID: resourceID, entries: entries) + try rendererCommandThrowing { renderer in + try renderer.attachBacking(resourceID: resourceID, entries: entries) + } return responseHeader(type: Response.okNoData, request: request) } case Command.resourceDetachBacking: - return rendererCommand(request: request) { renderer in + return scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) - try renderer.detachBacking(resourceID: resourceID) + if var resource = resources2D[resourceID] { + if let renderer { + try renderer.detachBacking(resourceID: resourceID) + } + resource.backing = [] + resources2D[resourceID] = resource + return responseHeader(type: Response.okNoData, request: request) + } + try rendererCommandThrowing { renderer in + try renderer.detachBacking(resourceID: resourceID) + } resourceEntries.removeValue(forKey: resourceID) return responseHeader(type: Response.okNoData, request: request) } @@ -472,6 +900,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi try requireLength(request, 56) let resourceID = request.leUInt32(at: 24) let size = request.leUInt64(at: 48) + guard resourceID != 0, + size > 0, + size <= UInt64(Int.max), + resources2D[resourceID] == nil, + resources3D[resourceID] == nil, + blobResources[resourceID] == nil else { + throw VMError.invalidConfiguration("invalid virtio-gpu blob resource") + } let entries = try memoryEntries(from: request, count: request.leUInt32(at: 36), offset: 56, transport: transport) try renderer.createBlob( resourceID: resourceID, @@ -483,7 +919,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi entries: entries ) resourceEntries[resourceID] = entries - blobResources[resourceID] = BlobResource(size: size) + blobResources[resourceID] = BlobResource( + memory: request.leUInt32(at: 28), + size: size, + mapping: nil + ) + traceResourceEvent( + "create-blob", + contextID: request.leUInt32(at: 16), + resourceID: resourceID, + detail: "memory=\(request.leUInt32(at: 28)) blob=\(request.leUInt64(at: 40)) size=\(size)" + ) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceMapBlob: @@ -497,13 +943,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } // virglrenderer owns the blob's host memory; ask it to map, then expose that pointer to // the guest by hv_vm_mapping it into the window at the requested offset. - let mapping = try renderer.mapBlob(resourceID: resourceID) + let mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer) try hostVisibleMemory.map( resourceID: resourceID, hostPointer: mapping.hostPointer, offset: offset, size: mapping.size != 0 ? mapping.size : blob.size ) + if var updated = blobResources[resourceID] { + updated.guestMapped = true + blobResources[resourceID] = updated + } var response = responseHeader(type: Response.okMapInfo, request: request) response.appendLE(mapping.mapInfo) response.appendLE(UInt32(0)) @@ -514,17 +964,44 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) hostVisibleMemory?.unmap(resourceID: resourceID) - try renderer.unmapBlob(resourceID: resourceID) + guard var blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu unmap of unknown blob") + } + blob.guestMapped = false + blobResources[resourceID] = blob + try releaseBlobMappingIfUnused(resourceID: resourceID, renderer: renderer) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceUnref: - return rendererCommand(request: request) { renderer in + return scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) + resourceUUIDs.removeValue(forKey: resourceID) + traceResourceEvent("unref-begin", contextID: request.leUInt32(at: 16), resourceID: resourceID) + if resources2D.removeValue(forKey: resourceID) != nil { + if let renderer { + try renderer.unrefResource(resourceID: resourceID) + } + scanouts = scanouts.filter { $0.value.resourceID != resourceID } + onScanoutResourceReleased?(resourceID) + traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=2d") + return responseHeader(type: Response.okNoData, request: request) + } hostVisibleMemory?.unmap(resourceID: resourceID) - try renderer.unrefResource(resourceID: resourceID) + try rendererCommandThrowing { renderer in + if blobResources[resourceID]?.mapping?.requiresRendererUnmap == true { + try renderer.unmapBlob(resourceID: resourceID) + } + try renderer.unrefResource(resourceID: resourceID) + } + scanouts = scanouts.filter { $0.value.resourceID != resourceID } resourceEntries.removeValue(forKey: resourceID) + resources3D.removeValue(forKey: resourceID) + published3DResources.remove(resourceID) + rendererReadbackBuffers.removeValue(forKey: resourceID) blobResources.removeValue(forKey: resourceID) + onScanoutResourceReleased?(resourceID) + traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=renderer") return responseHeader(type: Response.okNoData, request: request) } case Command.transferToHost3D: @@ -539,9 +1016,73 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi try renderer.transferFromHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? []) return responseHeader(type: Response.okNoData, request: request) } - case Command.resourceCreate2D, Command.setScanout, Command.resourceFlush, - Command.transferToHost2D, Command.setScanoutBlob: - return responseHeader(type: Response.okNoData, request: request) + case Command.setScanoutBlob: + return scanoutCommand(request: request) { + try requireLength(request, 96) + let scanoutID = request.leUInt32(at: 40) + let resourceID = request.leUInt32(at: 44) + let width = request.leUInt32(at: 48) + let height = request.leUInt32(at: 52) + let format = request.leUInt32(at: 56) + let stride = request.leUInt32(at: 64) + let offset = request.leUInt32(at: 80) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout id") + } + if resourceID == 0 { + let previous = scanouts.removeValue(forKey: scanoutID) + if let previous, case .blob = previous.source, let renderer { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + } + return responseHeader(type: Response.okNoData, request: request) + } + let rect = try scanoutRect(from: request, at: 24) + guard let blob = blobResources[resourceID], + width > 0, height > 0, + width <= 16_384, height <= 16_384, + Self.isSupportedScanoutFormat(format), + Self.contains(rect: rect, width: width, height: height), + stride >= width * 4, + request.leUInt32(at: 68) == 0, + request.leUInt32(at: 72) == 0, + request.leUInt32(at: 76) == 0, + request.leUInt32(at: 84) == 0, + request.leUInt32(at: 88) == 0, + request.leUInt32(at: 92) == 0, + Self.scanoutByteRangeIsValid( + width: width, + height: height, + stride: stride, + offset: offset, + resourceSize: blob.size + ) else { + throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout resource") + } + let previous = scanouts.updateValue( + ScanoutBinding( + resourceID: resourceID, + rect: rect, + source: .blob( + format: format, + width: width, + height: height, + stride: stride, + offset: offset + ) + ), + forKey: scanoutID + ) + if let previous, + previous.resourceID != resourceID, + case .blob = previous.source, + let renderer { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + } + for frame in try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect) { + onScanoutFrame?(frame) + } + return responseHeader(type: Response.okNoData, request: request) + } default: return responseHeader(type: Response.errorInvalidParameter, request: request) } @@ -581,10 +1122,433 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi do { return try body(renderer) } catch { + logCommandFailure(request: request, error: error) return responseHeader(type: Response.errorInvalidParameter, request: request) } } + private func rendererCommandThrowing( + _ body: (VirtioGPURenderer) throws -> Void + ) throws { + guard let renderer else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + try body(renderer) + } + + private func scanoutCommand( + request: [UInt8], + _ body: () throws -> [UInt8] + ) -> [UInt8] { + do { + return try body() + } catch { + logCommandFailure(request: request, error: error) + return responseHeader(type: Response.errorInvalidParameter, request: request) + } + } + + /// Linux may retry a failed renderer command many times per second. Preserve the status and + /// identifying fields needed for diagnosis without flooding the VM's serial log. + private func logCommandFailure(request: [UInt8], error: Error) { + guard request.count >= 4 else { return } + let command = request.leUInt32(at: 0) + let count = commandFailureCounts[command, default: 0] + 1 + commandFailureCounts[command] = count + guard count <= 3 else { return } + let contextID = request.count >= 20 ? request.leUInt32(at: 16) : 0 + let detail: String + if command == Command.submit3D { + let size = request.count >= 28 ? request.leUInt32(at: 24) : 0 + detail = "bytes=\(size)" + } else { + let resourceID = request.count >= 28 ? request.leUInt32(at: 24) : 0 + detail = "resource=\(resourceID)" + } + FileHandle.standardError.write(Data( + "dory-gpu: command=0x\(String(command, radix: 16)) context=\(contextID) \(detail) failed: \(error)\n".utf8 + )) + } + + private func traceResourceEvent( + _ event: String, + contextID: UInt32 = 0, + resourceID: UInt32? = nil, + detail: String = "" + ) { + guard traceResourceLifecycle else { return } + resourceTraceSequence &+= 1 + let resource = resourceID.map(String.init) ?? "-" + let kind: String + if let resourceID { + if resources2D[resourceID] != nil { kind = "2d" } + else if resources3D[resourceID] != nil { kind = "3d" } + else if blobResources[resourceID] != nil { kind = "blob" } + else { kind = "missing" } + } else { + kind = "-" + } + let suffix = detail.isEmpty ? "" : " \(detail)" + FileHandle.standardError.write(Data( + "dory-gpu-trace: seq=\(resourceTraceSequence) event=\(event) context=\(contextID) resource=\(resource) kind=\(kind)\(suffix)\n".utf8 + )) + } + + private func scanoutRect(from request: [UInt8], at offset: Int) throws -> VirtioGPURect { + try requireLength(request, offset + 16) + let rect = VirtioGPURect( + x: request.leUInt32(at: offset), + y: request.leUInt32(at: offset + 4), + width: request.leUInt32(at: offset + 8), + height: request.leUInt32(at: offset + 12) + ) + guard rect.width > 0, rect.height > 0 else { + throw VMError.invalidConfiguration("empty virtio-gpu rectangle") + } + return rect + } + + private static func contains(rect: VirtioGPURect, width: UInt32, height: UInt32) -> Bool { + rect.x <= width && rect.width <= width - rect.x + && rect.y <= height && rect.height <= height - rect.y + } + + private static func intersection(_ lhs: VirtioGPURect, _ rhs: VirtioGPURect) -> VirtioGPURect? { + let left = max(UInt64(lhs.x), UInt64(rhs.x)) + let top = max(UInt64(lhs.y), UInt64(rhs.y)) + let right = min(UInt64(lhs.x) + UInt64(lhs.width), UInt64(rhs.x) + UInt64(rhs.width)) + let bottom = min(UInt64(lhs.y) + UInt64(lhs.height), UInt64(rhs.y) + UInt64(rhs.height)) + guard right > left, bottom > top else { return nil } + return VirtioGPURect( + x: UInt32(left), + y: UInt32(top), + width: UInt32(right - left), + height: UInt32(bottom - top) + ) + } + + private static func isSupportedScanoutFormat(_ format: UInt32) -> Bool { + // All formats below are the 32-bit virtio-gpu formats accepted by Linux's DRM helper. + // The host presentation layer retains the format so it can select the matching Metal + // swizzle instead of rewriting every pixel in this transport thread. + [1, 2, 3, 4, 67, 68, 121, 134].contains(format) + } + + private static func scanoutByteRangeIsValid( + width: UInt32, + height: UInt32, + stride: UInt32, + offset: UInt32, + resourceSize: UInt64 + ) -> Bool { + guard width > 0, height > 0 else { return false } + let finalRow = UInt64(height - 1) * UInt64(stride) + let finalPixel = UInt64(width) * 4 + return UInt64(offset) <= resourceSize + && finalRow <= resourceSize - UInt64(offset) + && finalPixel <= resourceSize - UInt64(offset) - finalRow + } + + private func scanoutFrames( + resourceID: UInt32, + resource: Resource2D, + dirtyRect: VirtioGPURect + ) throws -> [VirtioGPUScanoutFrame] { + let resourceByteCount = Int(UInt64(resource.width) * UInt64(resource.height) * 4) + var source = Data(capacity: resourceByteCount) + for entry in resource.backing where source.count < resourceByteCount { + let count = min(entry.length, resourceByteCount - source.count) + source.append(entry.pointer.assumingMemoryBound(to: UInt8.self), count: count) + } + guard source.count == resourceByteCount else { + throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete") + } + + let sourceStride = Int(resource.width) * 4 + var frames = [VirtioGPUScanoutFrame]() + for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) + where binding.resourceID == resourceID { + guard case .resource2D = binding.source else { continue } + guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue } + let outputStride = Int(dirty.width) * 4 + var pixels = Data(capacity: outputStride * Int(dirty.height)) + for row in 0.. [VirtioGPUScanoutFrame] { + let bindings = scanouts.sorted(by: { $0.key < $1.key }).compactMap { + (scanoutID, binding) -> (UInt32, ScanoutBinding, UInt32, UInt32, UInt32, UInt32, UInt32)? in + guard binding.resourceID == resourceID, + case let .blob(format, width, height, stride, offset) = binding.source else { + return nil + } + return (scanoutID, binding, format, width, height, stride, offset) + } + guard !bindings.isEmpty else { return [] } + + let guestEntries = blob.memory == 1 ? (resourceEntries[resourceID] ?? []) : [] + let mapping: VirtioGPUBlobMapping? + if guestEntries.isEmpty { + guard let renderer else { + throw VMError.invalidConfiguration("virtio-gpu blob scanout has no accessible backing") + } + mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer) + } else { + mapping = nil + } + + var frames = [VirtioGPUScanoutFrame]() + for (scanoutID, binding, format, width, height, stride, offset) in bindings { + guard Self.contains(rect: dirtyRect, width: width, height: height) else { + throw VMError.invalidConfiguration("virtio-gpu blob damage exceeds framebuffer") + } + guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue } + let outputStride = Int(dirty.width) * 4 + var pixels = Data(capacity: outputStride * Int(dirty.height)) + for row in 0.. [VirtioGPUScanoutFrame] { + let publishFullResource = !published3DResources.contains(resourceID) + var frames = [VirtioGPUScanoutFrame]() + for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) + where binding.resourceID == resourceID { + guard case .resource3D = binding.source else { continue } + guard let damaged = Self.intersection(dirtyRect, binding.rect) else { continue } + let readRect = publishFullResource ? binding.rect : damaged + let resourceStride = UInt64(resource.width) * 4 + let resourceByteCount = resourceStride * UInt64(resource.height) + let readbackOffset = UInt64(readRect.y) * resourceStride + UInt64(readRect.x) * 4 + let outputStride = UInt64(readRect.width) * 4 + let outputByteCount = outputStride * UInt64(readRect.height) + guard resourceStride <= UInt64(UInt32.max), + resourceByteCount <= UInt64(Int.max), + outputStride <= UInt64(UInt32.max), + outputByteCount <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("virtio-gpu 3D scanout is too large") + } + var readback = rendererReadbackBuffers[resourceID] + ?? Data(count: Int(resourceByteCount)) + if readback.count != Int(resourceByteCount) { + readback = Data(count: Int(resourceByteCount)) + } + try readback.withUnsafeMutableBytes { bytes in + guard let baseAddress = bytes.baseAddress else { + throw VMError.invalidConfiguration("virtio-gpu 3D scanout has no storage") + } + try renderer.transferFromHost3D( + VirtioGPUTransfer3D( + resourceID: resourceID, + contextID: 0, + level: 0, + stride: UInt32(resourceStride), + layerStride: 0, + offset: readbackOffset, + box: [readRect.x, readRect.y, 0, readRect.width, readRect.height, 1] + ), + entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)] + ) + } + rendererReadbackBuffers[resourceID] = readback + + var pixels = Data(count: Int(outputByteCount)) + pixels.withUnsafeMutableBytes { destination in + readback.withUnsafeBytes { source in + guard let destinationBase = destination.baseAddress, + let sourceBase = source.baseAddress else { return } + let rowBytes = Int(outputStride) + for row in 0.. Data { + guard count >= 0 else { + throw VMError.invalidConfiguration("invalid virtio-gpu backing range") + } + var skip = offset + var remaining = count + var bytes = Data(capacity: count) + for entry in entries where remaining > 0 { + let entryLength = UInt64(entry.length) + if skip >= entryLength { + skip -= entryLength + continue + } + let entryOffset = Int(skip) + let available = entry.length - entryOffset + let copied = min(available, remaining) + bytes.append( + entry.pointer.advanced(by: entryOffset).assumingMemoryBound(to: UInt8.self), + count: copied + ) + remaining -= copied + skip = 0 + } + guard remaining == 0 else { + throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete") + } + return bytes + } + + private func ensureBlobMapping( + resourceID: UInt32, + renderer: VirtioGPURenderer + ) throws -> VirtioGPUBlobMapping { + guard var blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu map of unknown blob") + } + if let mapping = blob.mapping { return mapping } + let mapping = try renderer.mapBlob(resourceID: resourceID) + blob.mapping = mapping + blobResources[resourceID] = blob + return mapping + } + + /// Linux creates an implicit default context for primary-node clients before userspace can issue + /// VIRTGPU_CONTEXT_INIT. The standard default is VirGL2 when it is advertised; a renderer offering + /// one capset has an unambiguous default. Resolving zero here also prevents the kernel from retrying + /// an unsupported legacy context on a Venus-only GPU. + static func rendererContextFlags(requested: UInt32, capsets: [VirtioGPUCapset]) -> UInt32 { + let requestedCapset = requested & 0xff + if requestedCapset == 0 { + if capsets.contains(where: { $0.id == 2 }) { + return 2 + } + if capsets.count == 1 { + return capsets[0].id & 0xff + } + } + return requestedCapset + } + + private static func makeResourceUUID() -> [UInt8] { + var value = UUID().uuid + return withUnsafeBytes(of: &value) { Array($0) } + } + + private func releaseBlobMappingIfUnused( + resourceID: UInt32, + renderer: VirtioGPURenderer + ) throws { + guard var blob = blobResources[resourceID], + let mapping = blob.mapping, + !blob.guestMapped, + !scanouts.values.contains(where: { binding in + guard binding.resourceID == resourceID else { return false } + if case .blob = binding.source { return true } + return false + }) else { + return + } + if mapping.requiresRendererUnmap { + try renderer.unmapBlob(resourceID: resourceID) + } + blob.mapping = nil + blobResources[resourceID] = blob + } + private func memoryEntries( from request: [UInt8], count: UInt32, diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift new file mode 100644 index 00000000..4e51408c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift @@ -0,0 +1,289 @@ +import Foundation + +public struct VirtioInputEvent: Sendable, Equatable { + public var type: UInt16 + public var code: UInt16 + public var value: Int32 + + public init(type: UInt16, code: UInt16, value: Int32) { + self.type = type + self.code = code + self.value = value + } + + public static let synchronize = VirtioInputEvent(type: 0, code: 0, value: 0) + + fileprivate var bytes: [UInt8] { + var result = [UInt8]() + result.append(contentsOf: withUnsafeBytes(of: type.littleEndian, Array.init)) + result.append(contentsOf: withUnsafeBytes(of: code.littleEndian, Array.init)) + result.append(contentsOf: withUnsafeBytes(of: UInt32(bitPattern: value).littleEndian, Array.init)) + return result + } +} + +/// Converts AppKit's content-direction scroll deltas into Linux evdev wheel events while retaining +/// sub-tick movement. AppKit and evdev use opposite signs for the same visible scroll direction. +public struct VirtioInputScrollAccumulator: Sendable { + private var verticalRemainder: Double = 0 + private var horizontalRemainder: Double = 0 + + public init() {} + + public mutating func events( + horizontalDelta: Double, + verticalDelta: Double, + hasPreciseDeltas: Bool + ) -> [VirtioInputEvent] { + let scale: Double = hasPreciseDeltas ? 12 : 120 + let vertical = -verticalDelta * scale + let horizontal = -horizontalDelta * scale + verticalRemainder += vertical + horizontalRemainder += horizontal + + let verticalTicks = Int32(verticalRemainder / 120) + let horizontalTicks = Int32(horizontalRemainder / 120) + verticalRemainder -= Double(verticalTicks) * 120 + horizontalRemainder -= Double(horizontalTicks) * 120 + + var result = [VirtioInputEvent]() + let verticalHighResolution = Int32(vertical.rounded()) + let horizontalHighResolution = Int32(horizontal.rounded()) + if verticalHighResolution != 0 { + result.append(VirtioInputEvent(type: 2, code: 11, value: verticalHighResolution)) + } + if verticalTicks != 0 { + result.append(VirtioInputEvent(type: 2, code: 8, value: verticalTicks)) + } + if horizontalHighResolution != 0 { + result.append(VirtioInputEvent(type: 2, code: 12, value: horizontalHighResolution)) + } + if horizontalTicks != 0 { + result.append(VirtioInputEvent(type: 2, code: 6, value: horizontalTicks)) + } + return result + } +} + +/// A combined virtio keyboard, absolute pointer, buttons, and high-resolution wheel device. +/// Host input is submitted as whole evdev frames ending in SYN_REPORT; a frame waits until the +/// guest has posted enough receive buffers, so Dory never delivers half of a pointer update. +public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { + public let deviceID: UInt32 = 18 + public let deviceFeatures: UInt64 = 0 + public let queueCount = 2 + + private enum ConfigSelect { + static let name: UInt8 = 0x01 + static let serial: UInt8 = 0x02 + static let deviceIDs: UInt8 = 0x03 + static let propertyBits: UInt8 = 0x10 + static let eventBits: UInt8 = 0x11 + static let absoluteInfo: UInt8 = 0x12 + } + + private enum EventType { + static let synchronize: UInt8 = 0 + static let key: UInt8 = 1 + static let relative: UInt8 = 2 + static let absolute: UInt8 = 3 + static let led: UInt8 = 17 + } + + private let lock = NSLock() + private weak var transport: VirtioMMIOTransport? + private var selectedConfig: UInt8 = 0 + private var selectedSubconfig: UInt8 = 0 + private var availableEventBuffers = [VirtqueueChain]() + private var pendingFrames = [[VirtioInputEvent]]() + private let maximumPendingFrames = 256 + private let statusHandler: (@Sendable (VirtioInputEvent) -> Void)? + + public init(statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil) { + self.statusHandler = statusHandler + } + + public var configSpace: [UInt8] { + lock.lock() + let select = selectedConfig + let subselect = selectedSubconfig + lock.unlock() + + var payload = [UInt8]() + switch select { + case ConfigSelect.name where subselect == 0: + payload = Array("Dory keyboard and pointer".utf8) + case ConfigSelect.serial where subselect == 0: + payload = Array("dory-input-0".utf8) + case ConfigSelect.deviceIDs where subselect == 0: + payload.appendLE(UInt16(0x06)) // BUS_VIRTUAL + payload.appendLE(UInt16(0xD072)) + payload.appendLE(UInt16(0x0001)) + payload.appendLE(UInt16(0x0001)) + case ConfigSelect.propertyBits where subselect == 0: + payload = [0] + case ConfigSelect.eventBits: + payload = Self.eventBitmap(type: subselect) + case ConfigSelect.absoluteInfo where subselect == 0 || subselect == 1: + payload.appendLE(UInt32(0)) + payload.appendLE(UInt32(32_767)) + payload.appendLE(UInt32(0)) + payload.appendLE(UInt32(0)) + payload.appendLE(UInt32(100)) + default: + break + } + + payload = Array(payload.prefix(128)) + var config = [select, subselect, UInt8(payload.count), 0, 0, 0, 0, 0] + config.append(contentsOf: payload) + config.append(contentsOf: repeatElement(0, count: 136 - config.count)) + return config + } + + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { + guard offset < 2, width > 0 else { return } + lock.lock() + for index in 0..> UInt64(index * 8)) + if position == 0 { selectedConfig = byte } + if position == 1 { selectedSubconfig = byte } + } + lock.unlock() + } + + public func deviceReady(transport: VirtioMMIOTransport) { + lock.lock() + self.transport = transport + lock.unlock() + } + + public func deviceReset(transport: VirtioMMIOTransport) { + lock.lock() + self.transport = nil + availableEventBuffers.removeAll() + pendingFrames.removeAll() + lock.unlock() + } + + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + guard queue == 0 else { return } + lock.lock() + availableEventBuffers.removeAll() + if !ready { pendingFrames.removeAll() } + lock.unlock() + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + switch queue { + case 0: + drainEventQueue(transport: transport) + case 1: + drainStatusQueue(transport: transport) + default: + break + } + } + + /// Queues one atomic evdev update. `SYN_REPORT` is appended when the caller omitted it. + public func send(frame events: [VirtioInputEvent]) { + guard !events.isEmpty else { return } + var complete = events + if complete.last != .synchronize { complete.append(.synchronize) } + + lock.lock() + pendingFrames.append(complete) + if pendingFrames.count > maximumPendingFrames { + pendingFrames.removeFirst(pendingFrames.count - maximumPendingFrames) + } + let transport = self.transport + lock.unlock() + + if let transport { + transport.withQueueLock { + drainEventQueue(transport: transport) + } + } + } + + private func drainEventQueue(transport: VirtioMMIOTransport) { + let queue = transport.queues[0] + var invalidBuffers = [VirtqueueChain]() + while let chain = (try? queue.pop()) ?? nil { + let writableCount = chain.writableSegments.reduce(0) { $0 + $1.length } + if writableCount >= 8 { + availableEventBuffers.append(chain) + } else { + invalidBuffers.append(chain) + } + } + + lock.lock() + var publications = [(VirtqueueChain, VirtioInputEvent)]() + while let frame = pendingFrames.first, availableEventBuffers.count >= frame.count { + pendingFrames.removeFirst() + for event in frame { + publications.append((availableEventBuffers.removeFirst(), event)) + } + } + lock.unlock() + + var interrupt = false + for chain in invalidBuffers { + interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt + } + for (chain, event) in publications { + let written = chain.writeBytes(event.bytes) + interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt + } + if interrupt { transport.notifyUsed() } + } + + private func drainStatusQueue(transport: VirtioMMIOTransport) { + let queue = transport.queues[1] + var interrupt = false + while let chain = (try? queue.pop()) ?? nil { + let bytes = chain.readBytes(maximum: 8) + if bytes.count == 8 { + statusHandler?(VirtioInputEvent( + type: bytes.leUInt16(at: 0), + code: bytes.leUInt16(at: 2), + value: Int32(bitPattern: bytes.leUInt32(at: 4)) + )) + } + interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt + } + if interrupt { transport.notifyUsed() } + } + + private static func eventBitmap(type: UInt8) -> [UInt8] { + switch type { + case EventType.synchronize: + return bitmap(codes: [0]) + case EventType.key: + // Standard keyboard range plus primary mouse buttons. + return bitmap(codes: Array(1...255) + Array(272...276)) + case EventType.relative: + // Horizontal/vertical wheels and their high-resolution companions. + return bitmap(codes: [6, 8, 11, 12]) + case EventType.absolute: + return bitmap(codes: [0, 1]) + case EventType.led: + return bitmap(codes: [0, 1, 2]) + default: + return [] + } + } + + private static func bitmap(codes: [Int]) -> [UInt8] { + guard let maximum = codes.max(), maximum >= 0 else { return [] } + var bytes = [UInt8](repeating: 0, count: maximum / 8 + 1) + for code in codes where code >= 0 { + bytes[code / 8] |= UInt8(1 << (code % 8)) + } + while bytes.last == 0 { bytes.removeLast() } + return bytes + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift index ea526507..a7839ecf 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift @@ -72,6 +72,7 @@ public final class VirtioMMIOTransport: MMIODevice { private var sharedMemorySelect: UInt32 = 0 private var status: UInt32 = 0 private var interruptStatus: UInt32 = 0 + private var configGeneration: UInt32 = 0 private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)] @@ -102,6 +103,19 @@ public final class VirtioMMIOTransport: MMIODevice { interrupt() } + /// Signals that device configuration visible at 0x100 changed. Virtio drivers distinguish + /// this from a used-ring interrupt through ISR bit 1 and use ConfigGeneration to take a + /// coherent snapshot when the host changes multiple fields during one resize. + public func notifyConfigChange() { + registerLock.lock() + configGeneration &+= 1 + interruptLock.lock() + interruptStatus |= 2 + interruptLock.unlock() + registerLock.unlock() + interrupt() + } + public func hostPointer(at guestAddress: UInt64, count: UInt64) throws -> UnsafeMutableRawPointer { try memory.hostPointer(at: guestAddress, count: count) } @@ -139,7 +153,7 @@ public final class VirtioMMIOTransport: MMIODevice { case 0x0B4: return selectedSharedMemoryRegion?.length.highUInt32 ?? UInt64(UInt32.max) case 0x0B8: return selectedSharedMemoryRegion?.guestBase.lowUInt32 ?? 0 case 0x0BC: return selectedSharedMemoryRegion?.guestBase.highUInt32 ?? 0 - case 0x0FC: return 0 // ConfigGeneration + case 0x0FC: return UInt64(configGeneration) case 0x100...: return readConfig(offset: offset - 0x100, width: width) default: diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift new file mode 100644 index 00000000..81f34000 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift @@ -0,0 +1,564 @@ +import Foundation + +public enum VirtioSoundDirection: UInt8, Sendable { + case output = 0 + case input = 1 +} + +public struct VirtioSoundPCMParameters: Equatable, Sendable { + public var bufferBytes: Int + public var periodBytes: Int + public var sampleRate: Double + public var channels: Int + public var bytesPerSample: Int + + public init( + bufferBytes: Int, + periodBytes: Int, + sampleRate: Double, + channels: Int, + bytesPerSample: Int = 2 + ) { + self.bufferBytes = bufferBytes + self.periodBytes = periodBytes + self.sampleRate = sampleRate + self.channels = channels + self.bytesPerSample = bytesPerSample + } + + public var bytesPerFrame: Int { channels * bytesPerSample } +} + +/// Host audio implementation used by the virtio-snd transport. Dory's production implementation +/// is backed by AVAudioEngine; tests use an in-memory backend so the device state machine and queue +/// completion rules remain deterministic. +public protocol VirtioSoundHost: AnyObject, Sendable { + func configure( + streamID: Int, + direction: VirtioSoundDirection, + parameters: VirtioSoundPCMParameters + ) -> Bool + func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool + func start(streamID: Int, direction: VirtioSoundDirection) -> Bool + func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool + func release(streamID: Int, direction: VirtioSoundDirection) + func enqueuePlayback( + _ data: Data, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (_ success: Bool, _ latencyBytes: UInt32) -> Void + ) -> Bool + func requestCapture( + byteCount: Int, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (_ data: Data?, _ latencyBytes: UInt32) -> Void + ) -> Bool + func reset() +} + +/// VirtIO 1.2 sound device with one stereo-capable output stream and one stereo-capable input +/// stream. The Linux virtio-snd driver sees standard S16_LE 44.1/48 kHz PCM endpoints; actual Mac +/// device conversion and permission handling live behind `VirtioSoundHost`. +public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { + public let deviceID: UInt32 = 25 + public let deviceFeatures: UInt64 = 0 + public let queueCount = 4 // control, event, playback TX, capture RX + + private enum Request { + static let pcmInfo: UInt32 = 0x0100 + static let pcmSetParameters: UInt32 = 0x0101 + static let pcmPrepare: UInt32 = 0x0102 + static let pcmRelease: UInt32 = 0x0103 + static let pcmStart: UInt32 = 0x0104 + static let pcmStop: UInt32 = 0x0105 + } + + private enum Status: UInt32 { + case ok = 0x8000 + case badMessage = 0x8001 + case notSupported = 0x8002 + case ioError = 0x8003 + } + + private enum Lifecycle { + case idle + case parameters + case prepared + case running + } + + private struct Stream { + var direction: VirtioSoundDirection + var lifecycle: Lifecycle = .idle + var parameters: VirtioSoundPCMParameters? + } + + private struct PendingIO { + var streamID: Int + var chain: VirtqueueChain + var generation: UInt64 + var payloadBytes: Int + } + + private static let streamCount = 2 + private static let pcmInfoSize = 32 + private static let s16Format: UInt8 = 5 + private static let supportedFormats: UInt64 = 1 << s16Format + private static let rateValues: [UInt8: Double] = [6: 44_100, 7: 48_000] + private static let supportedRates: UInt64 = rateValues.keys.reduce(0) { $0 | (1 << $1) } + + private let host: VirtioSoundHost + private let log: @Sendable (String) -> Void + private let lock = NSLock() + private var streams = [ + Stream(direction: .output), + Stream(direction: .input), + ] + private var nextRequestID: UInt64 = 1 + // Each stream advances independently so releasing capture cannot invalidate an in-flight + // playback completion (and vice versa). + private var streamGenerations = [UInt64](repeating: 1, count: streamCount) + private var pendingPlayback = [UInt64: PendingIO]() + private var pendingCapture = [UInt64: PendingIO]() + + public init( + host: VirtioSoundHost, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.host = host + self.log = log + } + + public var configSpace: [UInt8] { + var bytes = [UInt8]() + bytes.appendLE(UInt32(0)) + bytes.appendLE(UInt32(Self.streamCount)) + bytes.appendLE(UInt32(0)) + return bytes + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + switch queue { + case 0: drainControlQueue(transport) + case 1: break // no unsolicited events are advertised + case 2: drainPlaybackQueue(transport) + case 3: drainCaptureQueue(transport) + default: break + } + } + + public func deviceReset(transport: VirtioMMIOTransport) { + lock.lock() + for index in streamGenerations.indices { streamGenerations[index] &+= 1 } + pendingPlayback.removeAll() + pendingCapture.removeAll() + streams = [Stream(direction: .output), Stream(direction: .input)] + lock.unlock() + host.reset() + } + + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + guard !ready, queue == 2 || queue == 3 else { return } + lock.lock() + let streamID = queue == 2 ? 0 : 1 + let playbackCount = pendingPlayback.count + let captureCount = pendingCapture.count + streamGenerations[streamID] &+= 1 + if queue == 2 { pendingPlayback.removeAll() } + if queue == 3 { pendingCapture.removeAll() } + lock.unlock() + if playbackCount > 0 || captureCount > 0 { + log("virtio sound queue \(queue) disabled with playback=\(playbackCount), capture=\(captureCount)") + } + } + + private func drainControlQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[0] + var interrupt = false + while let chain = (try? queue.pop()) ?? nil { + let capacity = chain.writableSegments.reduce(0) { $0 + $1.length } + let response = processControlRequest( + chain.readBytes(maximum: 64 * 1024), + responseCapacity: capacity, + transport: transport + ) + let written = chain.writeBytes(response) + interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt + } + if interrupt { transport.notifyUsed() } + } + + private func drainPlaybackQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[2] + while let chain = (try? queue.pop()) ?? nil { + let request = chain.readBytes() + guard request.count >= 4 else { + completeImmediately(chain, queue: queue, status: .badMessage, transport: transport) + continue + } + let streamID = Int(request.leUInt32(at: 0)) + let audio = Data(request.dropFirst(4)) + let pending: (UInt64, PendingIO, VirtioSoundPCMParameters)? = lock.withLock { + guard streamID == 0, + streams[streamID].direction == .output, + streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running, + let parameters = streams[streamID].parameters, + !audio.isEmpty, + audio.count % parameters.bytesPerFrame == 0 else { return nil } + let id = allocateRequestID() + let value = PendingIO( + streamID: streamID, + chain: chain, + generation: streamGenerations[streamID], + payloadBytes: audio.count + ) + pendingPlayback[id] = value + return (id, value, parameters) + } + guard let (requestID, _, parameters) = pending else { + completeImmediately(chain, queue: queue, status: .ioError, transport: transport) + continue + } + let accepted = host.enqueuePlayback(audio, parameters: parameters) { [weak self, weak transport] success, latency in + guard let self, let transport else { return } + self.completePlayback( + requestID: requestID, + success: success, + latencyBytes: latency, + transport: transport + ) + } + if !accepted { + completePlayback( + requestID: requestID, + success: false, + latencyBytes: 0, + transport: transport + ) + } + } + } + + private func drainCaptureQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[3] + while let chain = (try? queue.pop()) ?? nil { + let request = chain.readBytes(maximum: 4) + let writableBytes = chain.writableSegments.reduce(0) { $0 + $1.length } + guard request.count == 4, writableBytes > 8 else { + completeCaptureImmediately( + chain, + payloadBytes: max(0, writableBytes - 8), + queue: queue, + status: .badMessage, + transport: transport + ) + continue + } + let streamID = Int(request.leUInt32(at: 0)) + let payloadBytes = writableBytes - 8 + let pending: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { + guard streamID == 1, + streams[streamID].direction == .input, + streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running, + let parameters = streams[streamID].parameters, + payloadBytes % parameters.bytesPerFrame == 0 else { return nil } + let id = allocateRequestID() + pendingCapture[id] = PendingIO( + streamID: streamID, + chain: chain, + generation: streamGenerations[streamID], + payloadBytes: payloadBytes + ) + return (id, parameters) + } + guard let (requestID, parameters) = pending else { + completeCaptureImmediately( + chain, + payloadBytes: payloadBytes, + queue: queue, + status: .ioError, + transport: transport + ) + continue + } + let accepted = host.requestCapture(byteCount: payloadBytes, parameters: parameters) { + [weak self, weak transport] data, latency in + guard let self, let transport else { return } + self.completeCapture( + requestID: requestID, + data: data, + latencyBytes: latency, + transport: transport + ) + } + if !accepted { + completeCapture( + requestID: requestID, + data: nil, + latencyBytes: 0, + transport: transport + ) + } + } + } + + private func completePlayback( + requestID: UInt64, + success: Bool, + latencyBytes: UInt32, + transport: VirtioMMIOTransport + ) { + var interrupt = false + transport.withQueueLock { + let pending: PendingIO? = lock.withLock { + guard let value = pendingPlayback.removeValue(forKey: requestID), + value.generation == streamGenerations[value.streamID] else { return nil } + return value + } + guard let pending else { return } + let status = Self.ioStatus(success ? .ok : .ioError, latencyBytes: latencyBytes) + let written = pending.chain.writeBytes(status) + interrupt = (try? transport.queues[2].push(pending.chain, written: written)) ?? false + } + if interrupt { transport.notifyUsed() } + } + + private func completeCapture( + requestID: UInt64, + data: Data?, + latencyBytes: UInt32, + transport: VirtioMMIOTransport + ) { + var interrupt = false + transport.withQueueLock { + let pending: PendingIO? = lock.withLock { + guard let value = pendingCapture.removeValue(forKey: requestID), + value.generation == streamGenerations[value.streamID] else { return nil } + return value + } + guard let pending else { return } + let validData = data.flatMap { $0.count == pending.payloadBytes ? $0 : nil } + let payload = validData.map(Array.init) ?? [] + let payloadWritten = pending.chain.writeBytes(payload) + let status = Self.ioStatus( + validData == nil ? .ioError : .ok, + latencyBytes: latencyBytes + ) + let statusWritten = pending.chain.writeBytes( + status, + atWritableOffset: pending.payloadBytes + ) + let usedLength = validData == nil ? statusWritten : payloadWritten + statusWritten + interrupt = (try? transport.queues[3].push(pending.chain, written: usedLength)) ?? false + } + if interrupt { transport.notifyUsed() } + } + + private func completeImmediately( + _ chain: VirtqueueChain, + queue: Virtqueue, + status: Status, + transport: VirtioMMIOTransport + ) { + let written = chain.writeBytes(Self.ioStatus(status, latencyBytes: 0)) + if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() } + } + + private func completeCaptureImmediately( + _ chain: VirtqueueChain, + payloadBytes: Int, + queue: Virtqueue, + status: Status, + transport: VirtioMMIOTransport + ) { + let written = chain.writeBytes( + Self.ioStatus(status, latencyBytes: 0), + atWritableOffset: payloadBytes + ) + if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() } + } + + private func processControlRequest( + _ request: [UInt8], + responseCapacity: Int, + transport: VirtioMMIOTransport? + ) -> [UInt8] { + guard request.count >= 4 else { return Self.header(.badMessage) } + let code = request.leUInt32(at: 0) + switch code { + case Request.pcmInfo: + return pcmInfoResponse(request, responseCapacity: responseCapacity) + case Request.pcmSetParameters: + return setParametersResponse(request) + case Request.pcmPrepare, Request.pcmRelease, Request.pcmStart, Request.pcmStop: + guard request.count >= 8 else { return Self.header(.badMessage) } + let streamID = Int(request.leUInt32(at: 4)) + return lifecycleResponse(code: code, streamID: streamID, transport: transport) + default: + return Self.header(.notSupported) + } + } + + private func pcmInfoResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] { + guard request.count >= 16 else { return Self.header(.badMessage) } + let start = Int(request.leUInt32(at: 4)) + let count = Int(request.leUInt32(at: 8)) + let itemSize = Int(request.leUInt32(at: 12)) + guard start >= 0, count > 0, start <= Self.streamCount, + count <= Self.streamCount - start, + itemSize >= Self.pcmInfoSize, + responseCapacity >= 4 + count * itemSize else { + return Self.header(.badMessage) + } + var response = Self.header(.ok) + for streamID in start..<(start + count) { + var item = [UInt8]() + item.appendLE(UInt32(1)) + item.appendLE(UInt32(0)) + item.appendLE(Self.supportedFormats) + item.appendLE(Self.supportedRates) + item.append(streamID == 0 ? VirtioSoundDirection.output.rawValue : VirtioSoundDirection.input.rawValue) + item.append(1) + item.append(2) + item.append(contentsOf: repeatElement(0, count: 5)) + item.append(contentsOf: repeatElement(0, count: itemSize - item.count)) + response.append(contentsOf: item) + } + return response + } + + private func setParametersResponse(_ request: [UInt8]) -> [UInt8] { + guard request.count >= 24 else { return Self.header(.badMessage) } + let streamID = Int(request.leUInt32(at: 4)) + let bufferBytes = Int(request.leUInt32(at: 8)) + let periodBytes = Int(request.leUInt32(at: 12)) + let features = request.leUInt32(at: 16) + let channels = Int(request[20]) + let format = request[21] + let rate = request[22] + guard streamID >= 0, streamID < streams.count, + bufferBytes > 0, periodBytes > 0, + bufferBytes % periodBytes == 0, + features == 0, + channels >= 1, channels <= 2, + format == Self.s16Format, + let sampleRate = Self.rateValues[rate] else { + return Self.header(.notSupported) + } + let parameters = VirtioSoundPCMParameters( + bufferBytes: bufferBytes, + periodBytes: periodBytes, + sampleRate: sampleRate, + channels: channels + ) + let direction = streams[streamID].direction + guard host.configure(streamID: streamID, direction: direction, parameters: parameters) else { + return Self.header(.ioError) + } + lock.withLock { + streams[streamID].parameters = parameters + streams[streamID].lifecycle = .parameters + } + return Self.header(.ok) + } + + private func lifecycleResponse( + code: UInt32, + streamID: Int, + transport: VirtioMMIOTransport? + ) -> [UInt8] { + guard streamID >= 0, streamID < streams.count else { return Self.header(.badMessage) } + let current = lock.withLock { streams[streamID] } + switch code { + case Request.pcmPrepare: + guard current.parameters != nil, + current.lifecycle == .parameters || current.lifecycle == .prepared, + host.prepare(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .prepared } + case Request.pcmStart: + guard current.lifecycle == .prepared, + host.start(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .running } + case Request.pcmStop: + guard current.lifecycle == .running, + host.stop(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .prepared } + case Request.pcmRelease: + guard current.lifecycle == .prepared || current.lifecycle == .parameters else { + return Self.header(.ioError) + } + host.release(streamID: streamID, direction: current.direction) + if let transport { flushPending(streamID: streamID, transport: transport) } + lock.withLock { streams[streamID].lifecycle = .parameters } + default: + return Self.header(.notSupported) + } + return Self.header(.ok) + } + + private func flushPending(streamID: Int, transport: VirtioMMIOTransport) { + let playback: [PendingIO] + let capture: [PendingIO] + lock.lock() + streamGenerations[streamID] &+= 1 + playback = pendingPlayback.values.filter { $0.streamID == streamID } + capture = pendingCapture.values.filter { $0.streamID == streamID } + pendingPlayback = pendingPlayback.filter { $0.value.streamID != streamID } + pendingCapture = pendingCapture.filter { $0.value.streamID != streamID } + lock.unlock() + + if !playback.isEmpty || !capture.isEmpty { + log("virtio sound stream \(streamID) released with playback=\(playback.count), capture=\(capture.count) pending") + } + + var interrupt = false + for pending in playback { + let written = pending.chain.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) + interrupt = ((try? transport.queues[2].push(pending.chain, written: written)) ?? false) || interrupt + } + for pending in capture { + let written = pending.chain.writeBytes( + Self.ioStatus(.ioError, latencyBytes: 0), + atWritableOffset: pending.payloadBytes + ) + interrupt = ((try? transport.queues[3].push(pending.chain, written: written)) ?? false) || interrupt + } + if interrupt { transport.notifyUsed() } + } + + private func allocateRequestID() -> UInt64 { + let value = nextRequestID + nextRequestID &+= 1 + return value + } + + private static func header(_ status: Status) -> [UInt8] { + var bytes = [UInt8]() + bytes.appendLE(status.rawValue) + return bytes + } + + private static func ioStatus(_ status: Status, latencyBytes: UInt32) -> [UInt8] { + var bytes = header(status) + bytes.appendLE(latencyBytes) + return bytes + } + + // Protocol-level test hook; production requests always arrive through controlq. + func controlResponseForTesting(_ request: [UInt8], responseCapacity: Int = 4096) -> [UInt8] { + processControlRequest(request, responseCapacity: responseCapacity, transport: nil) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift index f0df8f09..a3e17233 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift @@ -36,16 +36,36 @@ public struct VirtqueueChain { @discardableResult public func writeBytes(_ bytes: [UInt8]) -> Int { - var offset = 0 + writeBytes(bytes, atWritableOffset: 0) + } + + /// Writes into the concatenated device-writable portion of the chain at a byte offset. + /// Virtio protocols such as virtio-snd place a writable PCM payload before a writable status + /// structure, so the device must address the latter without overwriting the former. + @discardableResult + public func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { + guard requestedOffset >= 0 else { return 0 } + var sourceOffset = 0 + var writableOffset = 0 for segment in segments where segment.isDeviceWritable { - let take = min(segment.length, bytes.count - offset) + if writableOffset + segment.length <= requestedOffset { + writableOffset += segment.length + continue + } + let destinationOffset = max(0, requestedOffset - writableOffset) + let available = segment.length - destinationOffset + let take = min(available, bytes.count - sourceOffset) guard take > 0 else { break } - bytes[offset..<(offset + take)].withUnsafeBytes { source in - segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take) + bytes[sourceOffset..<(sourceOffset + take)].withUnsafeBytes { source in + segment.pointer.advanced(by: destinationOffset).copyMemory( + from: source.baseAddress!, + byteCount: take + ) } - offset += take + sourceOffset += take + writableOffset += segment.length } - return offset + return sourceOffset } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift index 04d9f21a..c9988fef 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift @@ -37,6 +37,7 @@ public enum X86GuestLayout { public static let pvhCommandLine: UInt64 = 0x0009_1000 public static let pvhModules: UInt64 = 0x0009_2000 public static let pvhMemoryMap: UInt64 = 0x0009_3000 + public static let initrd: UInt64 = 0x1000_0000 public static let mpFloatingPointer: UInt64 = 0x000F_0000 public static let mpConfigurationTable: UInt64 = 0x000F_1000 public static let daxWindowBase: UInt64 = 0xC_0000_0000 diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift new file mode 100644 index 00000000..88189162 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -0,0 +1,498 @@ +@preconcurrency import AVFAudio +@preconcurrency import AVFoundation +import DoryHV +import Foundation + +/// Bridges the raw Hypervisor.framework virtio-snd device to Core Audio. Guest PCM remains the +/// standard signed 16-bit interleaved format while AVAudioEngine performs host sample-rate and +/// device conversion. +final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { + private static let deliveryQueue = DispatchQueue( + label: "com.dory.desktop.audio.completion", + qos: .userInteractive + ) + + private struct CaptureRequest { + var id: UInt64 + var byteCount: Int + var fallbackArmed: Bool + var completion: @Sendable (Data?, UInt32) -> Void + } + + private final class ConverterInput: @unchecked Sendable { + let buffer: AVAudioPCMBuffer + var served = false + + init(buffer: AVAudioPCMBuffer) { + self.buffer = buffer + } + } + + private let queue = DispatchQueue(label: "com.dory.desktop.audio", qos: .userInteractive) + // Keep playback and capture on independent graphs. PipeWire may prepare and start them in + // either order; mutating a shared running graph to add the other direction can leave an + // AVAudioPlayerNode permanently scheduled but never rendered. + private let outputEngine = AVAudioEngine() + private let inputEngine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let log: @Sendable (String) -> Void + + private var outputParameters: VirtioSoundPCMParameters? + private var inputParameters: VirtioSoundPCMParameters? + private var outputRunning = false + private var inputRunning = false + private var inputTapInstalled = false + private var permissionRequestInFlight = false + private var captureBytes = Data() + private var captureRequests = [CaptureRequest]() + private var nextCaptureRequestID: UInt64 = 1 + private var captureTapCount = 0 + private var captureFallbackLogged = false + private var nextCaptureFallbackUptime: TimeInterval = 0 + private var queuedPlaybackBytes = 0 + + init(log: @escaping @Sendable (String) -> Void) { + self.log = log + outputEngine.attach(player) + } + + func configure( + streamID: Int, + direction: VirtioSoundDirection, + parameters: VirtioSoundPCMParameters + ) -> Bool { + guard Self.valid(parameters) else { return false } + return queue.sync { + switch direction { + case .output: + player.stop() + queuedPlaybackBytes = 0 + outputEngine.disconnectNodeOutput(player) + guard let format = Self.floatFormat(parameters) else { return false } + outputEngine.connect(player, to: outputEngine.mainMixerNode, format: format) + outputParameters = parameters + outputRunning = false + case .input: + removeInputTap() + failCaptureRequests() + captureBytes.removeAll(keepingCapacity: true) + captureTapCount = 0 + captureFallbackLogged = false + nextCaptureFallbackUptime = 0 + inputParameters = parameters + inputRunning = false + } + return true + } + } + + func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: guard outputParameters != nil else { return false } + case .input: guard inputParameters != nil else { return false } + } + switch direction { + case .output: + if !outputEngine.isRunning { outputEngine.prepare() } + case .input: + // An input-only AVAudioEngine has no graph until its tap is installed. Preparing + // it here raises an Objective-C exception; installInputTapAndStartEngine() owns + // input graph preparation once macOS microphone access is available. + break + } + return true + } + } + + func start(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: + outputRunning = true + guard outputParameters != nil, startOutputEngine() else { + outputRunning = false + return false + } + player.play() + return true + case .input: + guard inputParameters != nil else { return false } + inputRunning = true + guard startInputWhenAuthorized() else { return false } + satisfyCaptureRequests() + armCaptureFallbacks() + return true + } + } + } + + func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: + player.pause() + outputRunning = false + case .input: + inputRunning = false + removeInputTap() + inputEngine.stop() + nextCaptureFallbackUptime = 0 + } + return true + } + } + + func release(streamID: Int, direction: VirtioSoundDirection) { + queue.sync { + switch direction { + case .output: + player.stop() + outputEngine.stop() + outputRunning = false + queuedPlaybackBytes = 0 + outputParameters = nil + case .input: + inputRunning = false + removeInputTap() + inputEngine.stop() + inputParameters = nil + captureBytes.removeAll(keepingCapacity: false) + nextCaptureFallbackUptime = 0 + failCaptureRequests() + } + } + } + + func enqueuePlayback( + _ data: Data, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Bool, UInt32) -> Void + ) -> Bool { + queue.sync { + guard outputParameters == parameters, + let buffer = Self.playbackBuffer(data: data, parameters: parameters) else { + return false + } + queuedPlaybackBytes += data.count + // The virtio descriptor protects the guest-owned period, not the audible speaker + // timeline. We have already copied that period into an AVAudioPCMBuffer, so complete + // it when Core Audio consumes the buffer. Waiting for `.dataPlayedBack` couples the + // Linux PCM ring to the host device's presentation callback and can deadlock ALSA on + // devices that do not publish that callback for an application-owned engine. + player.scheduleBuffer(buffer, completionCallbackType: .dataConsumed) { + [weak self] _ in + guard let self else { return } + self.queue.async { + self.queuedPlaybackBytes = max(0, self.queuedPlaybackBytes - data.count) + let latency = UInt32(clamping: self.queuedPlaybackBytes) + Self.deliver { completion(true, latency) } + } + } + // AVAudioPlayerNode stops after an empty queue. Linux commonly starts the PCM stream + // before submitting the first period, so the play() issued by start() may have already + // gone idle by the time this buffer arrives. Rearm it for every transition from an + // empty/stopped player to queued audio, otherwise virtio TX descriptors never complete. + if outputRunning { player.play() } + return true + } + } + + func requestCapture( + byteCount: Int, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Data?, UInt32) -> Void + ) -> Bool { + queue.sync { + // Linux primes capture descriptors while the PCM is prepared, before PCM_START. Keep + // those requests pending just as playback keeps its pre-roll buffers scheduled. + guard byteCount > 0, inputParameters == parameters else { return false } + let requestID = nextCaptureRequestID + nextCaptureRequestID &+= 1 + captureRequests.append(CaptureRequest( + id: requestID, + byteCount: byteCount, + fallbackArmed: false, + completion: completion + )) + satisfyCaptureRequests() + if inputRunning { armCaptureFallback(requestID: requestID) } + return true + } + } + + func reset() { + queue.sync { + player.stop() + removeInputTap() + outputEngine.stop() + inputEngine.stop() + outputParameters = nil + inputParameters = nil + outputRunning = false + inputRunning = false + queuedPlaybackBytes = 0 + captureBytes.removeAll(keepingCapacity: false) + nextCaptureFallbackUptime = 0 + failCaptureRequests() + } + } + + private func startInputWhenAuthorized() -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return installInputTapAndStartEngine() + case .notDetermined: + guard !permissionRequestInFlight else { return true } + permissionRequestInFlight = true + log("requesting Mac microphone access; Linux capture will provide paced silence until permission is resolved") + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + guard let self else { return } + self.queue.async { + self.permissionRequestInFlight = false + guard self.inputRunning else { return } + if granted, self.installInputTapAndStartEngine() { return } + self.log("microphone access is unavailable; Linux capture will continue with paced silence") + self.armCaptureFallbacks() + } + } + return true + case .denied, .restricted: + log("microphone access is denied; enable it for Dory in System Settings > Privacy & Security > Microphone") + inputRunning = false + return false + @unknown default: + inputRunning = false + return false + } + } + + private func installInputTapAndStartEngine() -> Bool { + guard inputRunning, let parameters = inputParameters else { return false } + if !inputTapInstalled { + let input = inputEngine.inputNode + let nativeFormat = input.outputFormat(forBus: 0) + guard nativeFormat.channelCount > 0, + nativeFormat.sampleRate > 0, + let targetFormat = Self.floatFormat(parameters), + let converter = AVAudioConverter(from: nativeFormat, to: targetFormat) else { + log("the selected Mac input device does not expose a usable audio format") + return false + } + input.installTap(onBus: 0, bufferSize: 1_024, format: nativeFormat) { + [weak self] buffer, _ in + let data = Self.convertCapture( + buffer: buffer, + converter: converter, + targetFormat: targetFormat, + parameters: parameters + ) + let inputFrames = buffer.frameLength + self?.queue.async { [weak self] in + guard let self else { return } + self.captureTapCount += 1 + if self.captureTapCount == 1 { + self.log("Mac microphone stream active (inputFrames=\(inputFrames), convertedBytes=\(data?.count ?? 0))") + } + if let data, !data.isEmpty { self.appendCapture(data) } + } + } + inputTapInstalled = true + } + return startInputEngine() + } + + private func startOutputEngine() -> Bool { + if outputEngine.isRunning { + if outputRunning, !player.isPlaying { player.play() } + return true + } + do { + outputEngine.prepare() + try outputEngine.start() + if outputRunning, !player.isPlaying { player.play() } + return true + } catch { + log("could not start Mac audio output: \(error)") + return false + } + } + + private func startInputEngine() -> Bool { + if inputEngine.isRunning { return true } + do { + inputEngine.prepare() + try inputEngine.start() + return true + } catch { + log("could not start Mac audio input: \(error)") + return false + } + } + + private func removeInputTap() { + guard inputTapInstalled else { return } + inputEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + + private func appendCapture(_ data: Data) { + captureBytes.append(data) + // Bound stale input if PipeWire temporarily stops submitting receive buffers. + if captureBytes.count > 4 * 1_024 * 1_024 { + captureBytes.removeFirst(captureBytes.count - 4 * 1_024 * 1_024) + } + satisfyCaptureRequests() + } + + private func satisfyCaptureRequests() { + while let request = captureRequests.first, captureBytes.count >= request.byteCount { + captureRequests.removeFirst() + let data = Data(captureBytes.prefix(request.byteCount)) + captureBytes.removeFirst(request.byteCount) + let latency = UInt32(clamping: captureBytes.count) + Self.deliver { request.completion(data, latency) } + } + if captureRequests.isEmpty { nextCaptureFallbackUptime = 0 } + } + + private func armCaptureFallbacks() { + for requestID in captureRequests.lazy.filter({ !$0.fallbackArmed }).map(\.id) { + armCaptureFallback(requestID: requestID) + } + } + + private func armCaptureFallback(requestID: UInt64) { + guard inputRunning, + let parameters = inputParameters, + let index = captureRequests.firstIndex(where: { $0.id == requestID }), + !captureRequests[index].fallbackArmed else { return } + captureRequests[index].fallbackArmed = true + let byteCount = captureRequests[index].byteCount + let frames = byteCount / parameters.bytesPerFrame + let period = Double(frames) / parameters.sampleRate + // During a permission prompt, pace from the first period. Once the real input graph is + // active, allow two periods (at least 100 ms) before treating a missed callback as silence. + let now = ProcessInfo.processInfo.systemUptime + let firstDelay = inputTapInstalled ? max(0.1, 2 * period) : period + let deadline: TimeInterval + if nextCaptureFallbackUptime > now { + deadline = nextCaptureFallbackUptime + period + } else { + deadline = now + firstDelay + } + nextCaptureFallbackUptime = deadline + queue.asyncAfter(deadline: .now() + max(0, deadline - now)) { [weak self] in + guard let self, + let pendingIndex = self.captureRequests.firstIndex(where: { $0.id == requestID }) else { + return + } + let request = self.captureRequests.remove(at: pendingIndex) + if !self.captureFallbackLogged { + self.captureFallbackLogged = true + self.log("Mac microphone frames are pending; Linux capture is using paced silence") + } + if self.captureRequests.isEmpty { self.nextCaptureFallbackUptime = 0 } + Self.deliver { request.completion(Data(count: request.byteCount), 0) } + } + } + + private func failCaptureRequests() { + let requests = captureRequests + captureRequests.removeAll(keepingCapacity: false) + for request in requests { Self.deliver { request.completion(nil, 0) } } + } + + private static func valid(_ parameters: VirtioSoundPCMParameters) -> Bool { + parameters.bytesPerSample == 2 + && (parameters.channels == 1 || parameters.channels == 2) + && (parameters.sampleRate == 44_100 || parameters.sampleRate == 48_000) + && parameters.bufferBytes > 0 + && parameters.periodBytes > 0 + && parameters.periodBytes <= parameters.bufferBytes + && parameters.periodBytes % parameters.bytesPerFrame == 0 + } + + private static func floatFormat(_ parameters: VirtioSoundPCMParameters) -> AVAudioFormat? { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: parameters.sampleRate, + channels: AVAudioChannelCount(parameters.channels), + interleaved: false + ) + } + + private static func playbackBuffer( + data: Data, + parameters: VirtioSoundPCMParameters + ) -> AVAudioPCMBuffer? { + guard !data.isEmpty, + data.count % parameters.bytesPerFrame == 0, + let format = floatFormat(parameters) else { return nil } + let frames = data.count / parameters.bytesPerFrame + guard let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(frames) + ), let channels = buffer.floatChannelData else { return nil } + buffer.frameLength = AVAudioFrameCount(frames) + data.withUnsafeBytes { raw in + guard let bytes = raw.bindMemory(to: UInt8.self).baseAddress else { return } + for frame in 0.. Data? { + let ratio = targetFormat.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(max(1, Int(ceil(Double(buffer.frameLength) * ratio)) + 32)) + guard let converted = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { + return nil + } + let input = ConverterInput(buffer: buffer) + var conversionError: NSError? + let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in + if input.served { + outStatus.pointee = .noDataNow + return nil + } + input.served = true + outStatus.pointee = .haveData + return input.buffer + } + guard conversionError == nil, + status != .error, + converted.frameLength > 0, + let channels = converted.floatChannelData else { return nil } + + var bytes = Data(count: Int(converted.frameLength) * parameters.bytesPerFrame) + bytes.withUnsafeMutableBytes { raw in + guard let output = raw.bindMemory(to: UInt8.self).baseAddress else { return } + for frame in 0..> 8) + } + } + } + return bytes + } + + private static func deliver(_ operation: @escaping @Sendable () -> Void) { + deliveryQueue.async(execute: operation) + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift new file mode 100644 index 00000000..d832fa10 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -0,0 +1,440 @@ +import AppKit +import DoryHV +import Foundation +import MetalKit + +enum DesktopMetalDisplayError: Error, CustomStringConvertible { + case metalUnavailable + case shaderCompilation(String) + + var description: String { + switch self { + case .metalUnavailable: + return "this Mac does not expose a Metal display device" + case let .shaderCompilation(detail): + return "could not build the Dory display shader: \(detail)" + } + } +} + +/// Coalesces producer-thread flushes to the newest complete frame and performs one main-thread +/// upload. This keeps a busy compositor from building an unbounded queue of stale 16 MiB frames. +final class DesktopFrameMailbox: @unchecked Sendable { + private let lock = NSLock() + private var pending = [VirtioGPUScanoutFrame]() + private var deliveryScheduled = false + nonisolated(unsafe) weak var view: DesktopMetalView? + + func submit(_ frame: VirtioGPUScanoutFrame) { + lock.lock() + if frame.dirtyRect.x == 0, frame.dirtyRect.y == 0, + frame.dirtyRect.width == frame.width, frame.dirtyRect.height == frame.height { + pending = [frame] + } else { + pending.append(frame) + if pending.count > 256 { pending.removeFirst(pending.count - 256) } + } + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + guard shouldSchedule else { return } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.deliver() + } + } + } + + /// Drop presentation storage only when the guest destroys the corresponding virtio-gpu + /// resource. Direct scanout temporarily switches between application and compositor buffers; + /// evicting an older compositor buffer merely because it was not recently visible makes its + /// next partial update appear as a black or torn frame. + func release(resourceID: UInt32) { + lock.lock() + pending.removeAll { $0.resourceID == resourceID } + lock.unlock() + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.view?.release(resourceID: resourceID) + } + } + } + + @MainActor + private func deliver() { + lock.lock() + let frames = pending + pending.removeAll(keepingCapacity: true) + deliveryScheduled = false + lock.unlock() + view?.present(frames) + } +} + +@MainActor +final class DesktopMetalView: MTKView, MTKViewDelegate { + private let commandQueue: MTLCommandQueue + private let pipeline: MTLRenderPipelineState + private let input: VirtioInput + private var resourceTextures: [UInt32: MTLTexture] = [:] + private var scanoutTexture: MTLTexture? + private var scanoutSize = CGSize.zero + private var tracking: NSTrackingArea? + private var scrollAccumulator = VirtioInputScrollAccumulator() + private var resizeGeneration: UInt64 = 0 + var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? + var onMacShortcut: ((NSEvent) -> Bool)? + + init(frame: NSRect, input: VirtioInput) throws { + guard let device = MTLCreateSystemDefaultDevice(), + let commandQueue = device.makeCommandQueue() else { + throw DesktopMetalDisplayError.metalUnavailable + } + self.input = input + self.commandQueue = commandQueue + do { + let library = try device.makeLibrary(source: Self.shaderSource, options: nil) + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "dory_scanout_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "dory_scanout_fragment") + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + self.pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } catch { + throw DesktopMetalDisplayError.shaderCompilation(String(describing: error)) + } + super.init(frame: frame, device: device) + colorPixelFormat = .bgra8Unorm + clearColor = MTLClearColorMake(0.025, 0.03, 0.04, 1) + framebufferOnly = true + enableSetNeedsDisplay = true + isPaused = true + preferredFramesPerSecond = 60 + delegate = self + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + + override func updateTrackingAreas() { + if let tracking { removeTrackingArea(tracking) } + let replacement = NSTrackingArea( + rect: bounds, + options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(replacement) + tracking = replacement + super.updateTrackingAreas() + } + + func present(_ frames: [VirtioGPUScanoutFrame]) { + var updated = false + for frame in frames { + updated = presentUpdate(frame) || updated + } + if updated { needsDisplay = true } + } + + func release(resourceID: UInt32) { + resourceTextures.removeValue(forKey: resourceID) + } + + private func presentUpdate(_ frame: VirtioGPUScanoutFrame) -> Bool { + guard let pixelFormat = Self.pixelFormat(for: frame.format), + let device, + frame.width > 0, frame.height > 0, + frame.dirtyRect.width > 0, frame.dirtyRect.height > 0, + frame.dirtyRect.x <= frame.width, + frame.dirtyRect.width <= frame.width - frame.dirtyRect.x, + frame.dirtyRect.y <= frame.height, + frame.dirtyRect.height <= frame.height - frame.dirtyRect.y, + frame.stride >= frame.dirtyRect.width * 4, + frame.bytes.count >= Int(frame.stride) * Int(frame.dirtyRect.height) else { + return false + } + var texture = resourceTextures[frame.resourceID] + if texture?.width != Int(frame.width) + || texture?.height != Int(frame.height) + || texture?.pixelFormat != pixelFormat { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: pixelFormat, + width: Int(frame.width), + height: Int(frame.height), + mipmapped: false + ) + descriptor.storageMode = .shared + descriptor.usage = [.shaderRead] + texture = device.makeTexture(descriptor: descriptor) + resourceTextures[frame.resourceID] = texture + } + guard let texture else { return false } + frame.bytes.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + texture.replace( + region: MTLRegionMake2D( + Int(frame.dirtyRect.x), + Int(frame.dirtyRect.y), + Int(frame.dirtyRect.width), + Int(frame.dirtyRect.height) + ), + mipmapLevel: 0, + withBytes: baseAddress, + bytesPerRow: Int(frame.stride) + ) + } + scanoutTexture = texture + scanoutSize = CGSize(width: Int(frame.width), height: Int(frame.height)) + return true + } + + func draw(in view: MTKView) { + guard let texture = scanoutTexture, + let descriptor = currentRenderPassDescriptor, + let drawable = currentDrawable, + let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + return + } + + let contentRect = scanoutContentRect(in: drawableSize) + let viewport = MTLViewport( + originX: contentRect.minX, + originY: contentRect.minY, + width: contentRect.width, + height: contentRect.height, + znear: 0, + zfar: 1 + ) + encoder.setViewport(viewport) + encoder.setRenderPipelineState(pipeline) + encoder.setFragmentTexture(texture, index: 0) + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + let width = UInt32(clamping: max(1, Int(size.width.rounded()))) + let height = UInt32(clamping: max(1, Int(size.height.rounded()))) + resizeGeneration &+= 1 + let generation = resizeGeneration + // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce + // receives the final Retina pixel size without reallocating scanout resources per mouse + // event while the window is still moving. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + MainActor.assumeIsolated { + guard let self, self.resizeGeneration == generation else { return } + self.onDrawableSizeChange?(width, height) + } + } + } + + override func keyDown(with event: NSEvent) { + if onMacShortcut?(event) == true { return } + if event.modifierFlags.contains(.command), + let character = event.charactersIgnoringModifiers?.lowercased(), + let code = Self.macCommandShortcutMap[character] { + sendControlShortcut(keyCode: code) + return + } + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyDown(with: event) + return + } + input.send(frame: [VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1)]) + } + + override func keyUp(with event: NSEvent) { + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyUp(with: event) + return + } + input.send(frame: [VirtioInputEvent(type: 1, code: code, value: 0)]) + } + + override func flagsChanged(with event: NSEvent) { + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode), + let flag = Self.modifierFlag(macKeyCode: event.keyCode) else { + super.flagsChanged(with: event) + return + } + input.send(frame: [ + VirtioInputEvent(type: 1, code: code, value: event.modifierFlags.contains(flag) ? 1 : 0) + ]) + } + + override func mouseMoved(with event: NSEvent) { sendPointer(event: event) } + override func mouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func rightMouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func otherMouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func mouseDown(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: true) } + override func mouseUp(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: false) } + override func rightMouseDown(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: true) } + override func rightMouseUp(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: false) } + override func otherMouseDown(with event: NSEvent) { + sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: true) + } + override func otherMouseUp(with event: NSEvent) { + sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: false) + } + + private func linuxOtherMouseButton(for buttonNumber: Int) -> UInt16 { + switch buttonNumber { + case 2: 274 // BTN_MIDDLE + case 3: 275 // BTN_SIDE + default: 276 // BTN_EXTRA + } + } + + override func scrollWheel(with event: NSEvent) { + let events = scrollAccumulator.events( + horizontalDelta: event.scrollingDeltaX, + verticalDelta: event.scrollingDeltaY, + hasPreciseDeltas: event.hasPreciseScrollingDeltas + ) + if !events.isEmpty { input.send(frame: events) } + } + + private func sendPointer( + event: NSEvent, + button: UInt16? = nil, + pressed: Bool = false + ) { + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) + let contentRect = scanoutContentRect(in: bounds.size) + let normalizedX = min(1, max(0, (point.x - contentRect.minX) / max(1, contentRect.width))) + let normalizedY = min(1, max(0, (point.y - contentRect.minY) / max(1, contentRect.height))) + var events = [ + VirtioInputEvent(type: 3, code: 0, value: Int32((normalizedX * 32_767).rounded())), + VirtioInputEvent(type: 3, code: 1, value: Int32((normalizedY * 32_767).rounded())), + ] + if let button { + events.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) + } + input.send(frame: events) + } + + private func scanoutContentRect(in targetSize: CGSize) -> CGRect { + guard scanoutSize.width > 0, scanoutSize.height > 0, + targetSize.width > 0, targetSize.height > 0 else { + return CGRect(origin: .zero, size: targetSize) + } + let sourceAspect = scanoutSize.width / scanoutSize.height + let targetAspect = targetSize.width / targetSize.height + if targetAspect > sourceAspect { + let width = targetSize.height * sourceAspect + return CGRect( + x: (targetSize.width - width) / 2, + y: 0, + width: width, + height: targetSize.height + ) + } + let height = targetSize.width / sourceAspect + return CGRect( + x: 0, + y: (targetSize.height - height) / 2, + width: targetSize.width, + height: height + ) + } + + private static func pixelFormat(for virtioFormat: UInt32) -> MTLPixelFormat? { + switch virtioFormat { + case 1, 2: .bgra8Unorm + case 3, 4, 67, 68, 121, 134: .rgba8Unorm + default: nil + } + } + + private static func modifierFlag(macKeyCode: UInt16) -> NSEvent.ModifierFlags? { + switch macKeyCode { + case 54, 55: .command + case 56, 60: .shift + case 57: .capsLock + case 58, 61: .option + case 59, 62: .control + default: nil + } + } + + private static func linuxKeyCode(macKeyCode: UInt16) -> UInt16? { + keyMap[macKeyCode] + } + + private func sendControlShortcut(keyCode: UInt16) { + input.send(frame: [ + VirtioInputEvent(type: 1, code: 125, value: 0), + VirtioInputEvent(type: 1, code: 126, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 0), + ]) + } + + // Match the Mac muscle memory supported by mature desktop hypervisors while leaving Command+Q + // to the host application. Shift remains physically pressed for gestures such as Command+Shift+T. + private static let macCommandShortcutMap: [String: UInt16] = [ + "a": 30, "f": 33, "l": 38, "n": 49, "o": 24, "p": 25, + "r": 19, "s": 31, "t": 20, "w": 17, "z": 44, + ] + + private static let keyMap: [UInt16: UInt16] = [ + 0: 30, 1: 31, 2: 32, 3: 33, 4: 35, 5: 34, 6: 44, 7: 45, + 8: 46, 9: 47, 11: 48, 12: 16, 13: 17, 14: 18, 15: 19, 16: 21, + 17: 20, 18: 2, 19: 3, 20: 4, 21: 5, 22: 7, 23: 6, 24: 13, + 25: 10, 26: 8, 27: 12, 28: 9, 29: 11, 30: 27, 31: 24, 32: 22, + 33: 26, 34: 23, 35: 25, 36: 28, 37: 38, 38: 36, 39: 40, 40: 37, + 41: 39, 42: 43, 43: 51, 44: 53, 45: 49, 46: 50, 47: 52, 48: 15, + 49: 57, 50: 41, 51: 14, 53: 1, 54: 126, 55: 125, 56: 42, 57: 58, + 58: 56, 59: 29, 60: 54, 61: 100, 62: 97, + 65: 83, 67: 55, 69: 78, 71: 69, 75: 98, 76: 96, 78: 74, 81: 117, + 82: 82, 83: 79, 84: 80, 85: 81, 86: 75, 87: 76, 88: 77, 89: 71, + 91: 72, 92: 73, + 96: 63, 97: 64, 98: 65, 99: 61, 100: 66, 101: 67, 103: 87, + 109: 68, 111: 88, 114: 110, 115: 102, 116: 104, 117: 111, 118: 62, + 119: 107, 120: 60, 121: 109, 122: 59, 123: 105, 124: 106, 125: 108, + 126: 103, + ] + + private static let shaderSource = """ + #include + using namespace metal; + + struct RasterData { + float4 position [[position]]; + float2 textureCoordinate; + }; + + vertex RasterData dory_scanout_vertex(uint vertexID [[vertex_id]]) { + const float2 positions[4] = { + float2(-1.0, -1.0), float2(1.0, -1.0), + float2(-1.0, 1.0), float2(1.0, 1.0) + }; + const float2 coordinates[4] = { + float2(0.0, 1.0), float2(1.0, 1.0), + float2(0.0, 0.0), float2(1.0, 0.0) + }; + RasterData output; + output.position = float4(positions[vertexID], 0.0, 1.0); + output.textureCoordinate = coordinates[vertexID]; + return output; + } + + fragment float4 dory_scanout_fragment( + RasterData input [[stage_in]], + texture2d scanout [[texture(0)]]) { + constexpr sampler textureSampler(coord::normalized, filter::linear); + return scanout.sample(textureSampler, input.textureCoordinate); + } + """ +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift new file mode 100644 index 00000000..d3cedac5 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -0,0 +1,674 @@ +import AppKit +import Darwin +import DoryCore +import DoryHV +import DoryOperations +import DorydKit +import DoryVMMKit +import Foundation + +enum DesktopMode { + struct Configuration { + var machineID: String + var stateDirectory: String + var kernelPath: String + var initrdPath: String? + var rootfsPath: String + var rootDevice: String + var genericGuest: Bool + var gvproxyPath: String + var handoffSocketPath: String + var agentSocketPath: String + var shellSocketPath: String + var sshAgentSocketPath: String? + var memoryMB: UInt64 + var cpuCount: Int + var shares: [DoryMachineShareConfiguration] + var environment: [String: String] + } + + private struct ResolvedGraphics { + var backend: DoryDesktopGraphicsBackend + var renderer: VirglRenderer? + } + + @MainActor + static func run(_ configuration: Configuration) throws { + let controller = try Controller(configuration: configuration) + try controller.run() + } + + @MainActor + private final class Controller: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let configuration: Configuration + private let application = NSApplication.shared + private let stateLock: EngineStateDirectoryLock + private let serialLog: FileHandle + private let machine: Machine + private let graphicsBackend: DoryDesktopGraphicsBackend + private let input: VirtioInput + private let mailbox: DesktopFrameMailbox + private let display: DesktopMetalView + private let window: NSWindow + private let vsock: VirtioVsock + private let audio: DoryMacAudioBackend + private let gvproxy: Process + private let networkSocketPaths: [String] + private let agentBridge: GuestVsockSocketBridge + private let shellBridge: GuestVsockSocketBridge + private let sshAgentBridge: HostSSHAgentBridge? + private let clipboard: DoryDesktopClipboardCoordinator? + private let firstFrame: FirstFrameGate + private var signalSources = [DispatchSourceSignal]() + private var stopError: Error? + private var stopping = false + + init(configuration: Configuration) throws { + self.configuration = configuration + try FileManager.default.createDirectory( + atPath: configuration.stateDirectory, + withIntermediateDirectories: true + ) + self.stateLock = try EngineStateDirectoryLock(stateDirectory: configuration.stateDirectory) + self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log") + let resolvedGraphics = try Self.resolveGraphics( + environment: configuration.environment, + requireVulkan: configuration.genericGuest + ) + self.graphicsBackend = resolvedGraphics.backend + self.machine = try Machine(configuration: MachineConfiguration( + kernelPath: configuration.kernelPath, + initrdPath: configuration.initrdPath, + commandLine: Self.kernelCommandLine( + machineID: configuration.machineID, + rootDevice: configuration.rootDevice, + graphicsBackend: resolvedGraphics.backend, + genericGuest: configuration.genericGuest + ), + memoryBytes: configuration.memoryMB << 20, + cpuCount: configuration.cpuCount + )) + Self.attachPlatformDevices(to: machine, serialLog: serialLog) + + self.input = VirtioInput() + self.mailbox = DesktopFrameMailbox() + let firstFrame = FirstFrameGate() + self.firstFrame = firstFrame + self.display = try DesktopMetalView( + frame: NSRect(x: 0, y: 0, width: 1_280, height: 800), + input: input + ) + mailbox.view = display + + let renderer = resolvedGraphics.renderer + let hostVisibleMemory = try renderer.map { _ in + try VirtioGPUHostVisibleMemory(guestBase: GuestLayout.daxWindowBase) + } + let gpu = VirtioGPU( + hostMemoryBase: GuestLayout.daxWindowBase, + scanoutCount: 1, + scanoutWidth: 2_560, + scanoutHeight: 1_600, + renderer: renderer, + hostVisibleMemory: hostVisibleMemory, + traceResourceLifecycle: configuration.environment["DORY_GPU_TRACE_RESOURCES"] == "1", + onScanoutFrame: { [mailbox, firstFrame] frame in + mailbox.submit(frame) + firstFrame.signal() + }, + onScanoutResourceReleased: { [mailbox] resourceID in + mailbox.release(resourceID: resourceID) + } + ) + self.vsock = VirtioVsock(guestCID: 3) + self.audio = DoryMacAudioBackend(log: Self.log) + let sound = VirtioSound(host: audio, log: Self.log) + let balloon = VirtioBalloon(memory: machine.memory) { message in + Self.log(message) + } + + let runtimeDirectory = (configuration.agentSocketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: runtimeDirectory, withIntermediateDirectories: true) + let token = String(configuration.machineID.prefix(12)) + let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" + let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" + let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" + self.networkSocketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] + for path in networkSocketPaths { unlink(path) } + + let gvproxy = Process() + gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath) + gvproxy.arguments = GVProxyDesktopLaunchPlan.arguments( + mtu: DoryNetworkMTU.resolved(), + datapathSocket: gvproxySocket, + apiSocket: apiSocket + ) + gvproxy.standardOutput = FileHandle.standardError + gvproxy.standardError = FileHandle.standardError + try gvproxy.run() + self.gvproxy = gvproxy + do { + try Self.waitForSocket(path: gvproxySocket, process: gvproxy) + let network = try VirtioNet(socketPath: vmNetworkSocket, remotePath: gvproxySocket) + var backends: [VirtioDeviceBackend] = [ + try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), + gpu, + input, + VirtioRng(), + balloon, + vsock, + sound, + ] + for share in configuration.shares { + let rawShare = try VirtioFSShareConfiguration( + tag: share.tag, + path: share.hostPath, + readOnly: share.readOnly, + guestMountPoint: share.guestPath + ) + backends.append(try rawShare.makeBackend( + requestQueueCount: min(8, max(1, configuration.cpuCount)) + )) + } + backends.append(network) + for (slot, backend) in backends.enumerated() { + let transport = Self.attachBackend(backend, to: machine, slot: slot) + if backend === gpu { + display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in + guard let gpu, let transport else { return } + gpu.updateScanoutSize(width: width, height: height, transport: transport) + } + } + } + try machine.loadBootPayload() + } catch { + ChildProcessTerminator.terminateAndReap(gvproxy) + for path in networkSocketPaths { unlink(path) } + throw error + } + + self.agentBridge = GuestVsockSocketBridge( + socketPath: configuration.agentSocketPath, + guestPort: VsockPorts.agent, + log: Self.log + ) + self.shellBridge = GuestVsockSocketBridge( + socketPath: configuration.shellSocketPath, + guestPort: 1027, + log: Self.log + ) + try agentBridge.attach(to: vsock) + try shellBridge.attach(to: vsock) + if let sshAgentSocketPath = configuration.sshAgentSocketPath { + let bridge = try HostSSHAgentBridge( + socketPath: sshAgentSocketPath, + log: Self.log + ) + bridge.attach(to: vsock) + self.sshAgentBridge = bridge + } else { + self.sshAgentBridge = nil + } + if configuration.genericGuest { + self.clipboard = nil + } else { + let clipboardControl = DorydKit.AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + let clipboardInput = self.input + self.clipboard = DoryDesktopClipboardCoordinator( + policy: DoryDesktopClipboardPolicy(environment: configuration.environment), + execute: { argv, stdin, timeoutMs, outputLimitBytes in + try clipboardControl.execWithInput( + argv: argv, + stdin: stdin, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + }, + sendShortcut: { keyCode in + clipboardInput.send(frame: [ + VirtioInputEvent(type: 1, code: 125, value: 0), + VirtioInputEvent(type: 1, code: 126, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 0), + ]) + }, + log: Self.log + ) + } + + self.window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "\(configuration.machineID) — Dory Linux" + window.contentView = display + window.minSize = NSSize(width: 640, height: 400) + window.collectionBehavior.insert(.fullScreenPrimary) + window.tabbingMode = .disallowed + window.center() + super.init() + window.delegate = self + display.onMacShortcut = { [weak clipboard] event in + clipboard?.handleMacShortcut(event) ?? false + } + clipboard?.start() + } + + func run() throws { + application.setActivationPolicy(.regular) + application.delegate = self + installSignalHandlers() + window.makeKeyAndOrderFront(nil) + application.activate() + startMachine() + application.run() + cleanup() + if let stopError { throw stopError } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + window.makeKeyAndOrderFront(nil) + return true + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + requestGuestShutdown() + return .terminateCancel + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.orderOut(nil) + return false + } + + private func startMachine() { + let machine = self.machine + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + do { + let reason = try machine.run() + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.finish(error: Self.error(for: reason)) + } + } + } catch { + DispatchQueue.main.async { + MainActor.assumeIsolated { self?.finish(error: error) } + } + } + } + + let configuration = self.configuration + let graphicsDisplayName = graphicsBackend.displayName + let firstFrame = self.firstFrame + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + if configuration.genericGuest { + guard firstFrame.wait(timeout: 90) else { + throw VMError.bootFailure( + "generic Linux guest did not publish a graphics frame within 90s" + ) + } + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + agentBuild: "dory-hv/generic-linux", + detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed" + ) + ) + return + } + let info = try Self.prepareGuest(configuration: configuration) + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { self?.clipboard?.markGuestReady() } + } + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + agentBuild: info.agentBuild, + agentSocketPath: configuration.agentSocketPath, + shellSocketPath: configuration.shellSocketPath, + detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" + ) + ) + } catch { + machine.requestStop(.crash("desktop readiness failed: \(error)")) + DispatchQueue.main.async { + MainActor.assumeIsolated { self?.finish(error: error) } + } + } + } + } + + private func requestGuestShutdown() { + guard !stopping else { return } + stopping = true + window.orderOut(nil) + let machine = self.machine + if configuration.genericGuest { + // Linux maps KEY_POWER to logind's normal power-button action. This provides a + // clean integration-free shutdown path until Dory guest tools are installed. + input.send(frame: [ + VirtioInputEvent(type: 1, code: 116, value: 1), + VirtioInputEvent(type: 1, code: 116, value: 0), + ]) + } else { + do { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + defer { control.disconnect() } + try Self.requireSuccess(control.exec( + argv: ["/bin/sh", "-c", GuestShutdownCommand.detachedDesktopRequest()], + timeoutMs: 5_000, + outputLimitBytes: 64 * 1024 + ), operation: "desktop guest shutdown request") + } catch { + Self.log("dory-hv desktop: graceful guest shutdown request failed: \(error)") + } + } + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + DoryEngineShutdownTiming.helperWatchdogSeconds + ) { + machine.requestStop(.crash("guest shutdown timed out")) + } + } + + private func finish(error: Error?) { + if stopError == nil { stopError = error } + application.stop(nil) + if let wakeEvent = NSEvent.otherEvent( + with: .applicationDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 0, + data1: 0, + data2: 0 + ) { + application.postEvent(wakeEvent, atStart: false) + } + } + + private func cleanup() { + clipboard?.stop() + signalSources.forEach { $0.cancel() } + signalSources.removeAll() + ChildProcessTerminator.terminateAndReap(gvproxy) + unlink(configuration.agentSocketPath) + unlink(configuration.shellSocketPath) + for path in networkSocketPaths { unlink(path) } + try? serialLog.close() + } + + private func installSignalHandlers() { + for number in [SIGTERM, SIGINT] { + signal(number, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: number, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.requestGuestShutdown() } + } + source.resume() + signalSources.append(source) + } + + // dory-hv is a command-line helper rather than an app bundle, so LaunchServices cannot + // reliably activate it from Dory.app. Give the UI a narrow same-user signal that asks + // the helper itself to raise its hidden or covered display window. + signal(SIGUSR1, SIG_IGN) + let raiseSource = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main) + raiseSource.setEventHandler { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.window.makeKeyAndOrderFront(nil) + self.application.activate() + } + } + raiseSource.resume() + signalSources.append(raiseSource) + } + + private nonisolated static func prepareGuest(configuration: Configuration) throws -> DoryAgentInfo { + let deadline = Date().addingTimeInterval(90) + var lastError: Error? + while Date() < deadline { + do { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + let info = try control.info() + try requireSuccess(control.exec( + argv: ["/usr/lib/dory/configure-machine"], + env: configuration.environment.sorted(by: { $0.key < $1.key }).map { + DoryExecEnvironment(key: $0.key, value: $0.value) + }, + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), operation: "guest account configuration") + for share in configuration.shares { + try requireSuccess(control.exec( + argv: ["/bin/mkdir", "-p", share.guestPath], + timeoutMs: 10_000, + outputLimitBytes: 64 * 1024 + ), operation: "create share mount point \(share.guestPath)") + try requireSuccess(control.exec( + argv: [ + "/bin/mount", "-t", "virtiofs", "-o", + share.readOnly ? "ro" : "rw", + share.tag, share.guestPath, + ], + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), operation: "mount share \(share.tag)") + } + try requireSuccess(control.exec( + argv: ["/usr/bin/touch", "/var/lib/dory/host-configured"], + timeoutMs: 10_000, + outputLimitBytes: 64 * 1024 + ), operation: "complete desktop configuration") + return info + } catch { + lastError = error + Thread.sleep(forTimeInterval: 0.25) + } + } + throw lastError ?? VMError.bootFailure("desktop agent did not become ready") + } + + private nonisolated static func requireSuccess(_ result: DoryExecResult, operation: String) throws { + guard !result.timedOut, result.exitCode == 0 else { + let stderr = String(decoding: result.stderr.prefix(4_096), as: UTF8.self) + throw VMError.bootFailure( + "\(operation) failed (exit=\(result.exitCode), timedOut=\(result.timedOut)): \(stderr)" + ) + } + } + + private static func waitForSocket(path: String, process: Process) throws { + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: path) { return } + guard process.isRunning else { + throw VMError.bootFailure("gvproxy exited before publishing its network socket") + } + usleep(50_000) + } + throw VMError.bootFailure("gvproxy did not publish its network socket") + } + + @discardableResult + private static func attachBackend( + _ backend: VirtioDeviceBackend, + to machine: Machine, + slot: Int + ) -> VirtioMMIOTransport { + let interrupt = GuestLayout.virtioFirstIRQ + UInt32(slot) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, + backend: backend, + memory: machine.memory + ) { [weak machine] in + machine?.raiseGSI(interrupt) + } + machine.attachVirtioSlot(transport) + return transport + } + + private static func attachPlatformDevices(to machine: Machine, serialLog: FileHandle) { + #if arch(arm64) + machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) + machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in + FileHandle.standardError.write(Data([byte])) + try? serialLog.write(contentsOf: Data([byte])) + }) + #endif + } + + private static func resolveGraphics( + environment: [String: String], + requireVulkan: Bool + ) throws -> ResolvedGraphics { + let preference = try DoryDesktopGraphicsPreference(environment: environment) + var rendererEnvironment = ProcessInfo.processInfo.environment + for key in [ + DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey, + "DORY_VIRGL_SYNC_MODE", + "DORY_VIRGLRENDERER_PATH", + "DORY_MOLTENVK_ICD", + ] { + if let value = environment[key] { + rendererEnvironment[key] = value + } + } + + func accelerated(classicOnly: Bool) throws -> ResolvedGraphics { + rendererEnvironment[DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey] = + classicOnly ? "1" : "0" + let renderer = try VirglRenderer.discover(environment: rendererEnvironment) + return ResolvedGraphics( + backend: classicOnly ? .virgl : .virglVenus, + renderer: renderer + ) + } + + switch preference { + case .automatic: + if requireVulkan { + do { + return try accelerated(classicOnly: false) + } catch { + throw VMError.bootFailure( + "generic Linux desktop requires Dory's Vulkan-capable Venus renderer; \(error)" + ) + } + } + do { + return try accelerated(classicOnly: false) + } catch let venusError { + log("VirGL2 + Venus unavailable; trying classic VirGL2: \(venusError)") + do { + return try accelerated(classicOnly: true) + } catch let virglError { + log("accelerated graphics unavailable; using software scanout: \(virglError)") + return ResolvedGraphics(backend: .software, renderer: nil) + } + } + case .virgl: + return try accelerated(classicOnly: true) + case .virglVenus: + return try accelerated(classicOnly: false) + case .software: + return ResolvedGraphics(backend: .software, renderer: nil) + } + } + + private static func kernelCommandLine( + machineID: String, + rootDevice: String, + graphicsBackend: DoryDesktopGraphicsBackend, + genericGuest: Bool + ) -> String { + var arguments = [ + "console=ttyAMA0", + "earlycon=pl011,mmio32,0x0c000000", + "root=\(rootDevice)", + "rw", + "rootwait", + "panic=1", + "dory.machine_id=\(machineID)", + graphicsBackend.kernelArgument, + ] + if genericGuest { + // Installer initramfs images commonly default to their live-media boot path + // (Ubuntu's casper is one example). The same kernel/initramfs can boot the + // installed root directly when the local-root path is selected explicitly. + arguments.append("boot=local") + // Direct-kernel boot does not consume the EFI System Partition. Installer + // kernels can omit optional FAT/NLS modules that a distro's installed fstab + // expects for /boot/efi, so keep that nonessential mount out of the boot + // transaction without modifying the guest filesystem. + arguments.append("systemd.mask=boot-efi.mount") + } + if let legacy = graphicsBackend.legacyKernelArgument { + arguments.append(legacy) + } + return arguments.joined(separator: " ") + } + + private static func openAppendLog(_ path: String) throws -> FileHandle { + if !FileManager.default.fileExists(atPath: path) { + guard FileManager.default.createFile(atPath: path, contents: nil) else { + throw VMError.bootFailure("could not create serial log: \(path)") + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) + } + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + try handle.seekToEnd() + return handle + } + + private static func error(for reason: GuestStopReason) -> Error? { + switch reason { + case .powerOff: nil + case .reset: VMError.unexpectedExit("desktop guest requested reset") + case let .crash(detail): VMError.unexpectedExit(detail) + } + } + + private nonisolated static func log(_ message: String) { + FileHandle.standardError.write(Data("dory-hv desktop: \(message)\n".utf8)) + } + } +} + +private final class FirstFrameGate: @unchecked Sendable { + private let condition = NSCondition() + private var ready = false + + func signal() { + condition.lock() + ready = true + condition.broadcast() + condition.unlock() + } + + func wait(timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + while !ready { + if !condition.wait(until: deadline) { break } + } + let result = ready + condition.unlock() + return result + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift index 08db9c12..9872bcbf 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift @@ -562,7 +562,8 @@ enum EngineMode { )) note( "experimental gpu=venus: attached virtio-gpu with virglrenderer " - + "\(renderer.libraryPath) and MoltenVK ICD \(renderer.moltenVKICDPath)" + + "\(renderer.libraryPath) and MoltenVK ICD " + + "\(renderer.moltenVKICDPath ?? "unavailable")" ) } let vsock = VirtioVsock(guestCID: 3) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index ab36e25d..3c7a1abe 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -1,5 +1,6 @@ import DoryHV import DoryCore +import DorydKit import Foundation signal(SIGPIPE, SIG_IGN) @@ -188,7 +189,7 @@ func runAgentPing(_ options: Options) { let arguments = Array(CommandLine.arguments.dropFirst()) guard let command = arguments.first else { - fail("usage: dory-hv [options]") + fail("usage: dory-hv [options]") } switch command { @@ -418,6 +419,93 @@ case "boot": } catch { fail("\(error)") } +case "desktop": + var machineID: String? + var stateDirectory: String? + var kernel: String? + var initrd: String? + var rootfs: String? + var rootDevice = "/dev/vda" + var genericGuest = false + var gvproxy: String? + var handoffSocket: String? + var agentSocket: String? + var shellSocket: String? + var sshAgentSocket: String? + var memoryMB: UInt64 = 6_144 + var cpus = 6 + var shares = [DoryMachineShareConfiguration]() + var environment = [String: String]() + var iterator = arguments.dropFirst().makeIterator() + while let argument = iterator.next() { + switch argument { + case "--machine-id": machineID = iterator.next() + case "--state-dir": stateDirectory = iterator.next() + case "--kernel": kernel = iterator.next() + case "--initrd": initrd = iterator.next() + case "--rootfs": rootfs = iterator.next() + case "--root-device": rootDevice = iterator.next() ?? rootDevice + case "--generic-guest": genericGuest = true + case "--gvproxy": gvproxy = iterator.next() + case "--handoff-sock": handoffSocket = iterator.next() + case "--agent-sock": agentSocket = iterator.next() + case "--shell-sock": shellSocket = iterator.next() + case "--ssh-agent-socket": sshAgentSocket = iterator.next() + case "--memory-mb", "--mem-mb": + memoryMB = iterator.next().flatMap(UInt64.init) ?? memoryMB + case "--cpus": cpus = iterator.next().flatMap(Int.init) ?? cpus + case "--share": + guard let value = iterator.next() else { fail("desktop --share requires a value") } + do { shares.append(try DoryMachineShareConfiguration(argument: value)) } + catch { fail("invalid desktop share: \(error)") } + case "--env": + guard let value = iterator.next(), let equals = value.firstIndex(of: "=") else { + fail("desktop --env requires KEY=VALUE") + } + environment[String(value[.. [UInt8] { + try writeDescriptor( + memory, + index: 0, + addr: requestBuffer, + len: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0) + try memory.write(request, at: requestBuffer) + let slot = UInt64(availableIndex % 8) + try memory.write(UInt16(0), at: availRing + 4 + slot * 2) + availableIndex &+= 1 + try memory.write(availableIndex, at: availRing + 2) + gpu.handleKick(queue: 0, transport: transport) + let usedSlot = UInt64((availableIndex - 1) % 8) + let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8) + return try memory.readBytes(at: responseBuffer, count: Int(responseLength)) + } + + var create = gpuRequest(type: 0x0101, fenceID: 0, contextID: 0, ringIndex: 0) + create.appendLE(UInt32(7)) // resource_id + create.appendLE(UInt32(1)) // B8G8R8A8_UNORM + create.appendLE(UInt32(2)) + create.appendLE(UInt32(2)) + #expect(leUInt32(try submit(create), at: 0) == 0x1100) + #expect(renderer.createdResources.count == 1) + let rendererResource = try #require(renderer.createdResources.first) + #expect(rendererResource.resourceID == 7) + #expect(rendererResource.target == 2) + #expect(rendererResource.bind == 1 << 1) + #expect(rendererResource.width == 2) + #expect(rendererResource.height == 2) + #expect(rendererResource.depth == 1) + #expect(rendererResource.arraySize == 1) + #expect(rendererResource.flags == 1) + + let pixelBuffer = base + 0x6000 + let pixels: [UInt8] = [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ] + try memory.write(pixels, at: pixelBuffer) + var attach = gpuRequest(type: 0x0106, fenceID: 0, contextID: 0, ringIndex: 0) + attach.appendLE(UInt32(7)) + attach.appendLE(UInt32(1)) + attach.appendLE(pixelBuffer) + attach.appendLE(UInt32(pixels.count)) + attach.appendLE(UInt32(0)) + #expect(leUInt32(try submit(attach), at: 0) == 0x1100) + #expect(renderer.attachedBackingResourceIDs == [7]) + + var setScanout = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(7)) + #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100) + let boundFrame = try #require(frameBox.value) + #expect(boundFrame.dirtyRect == VirtioGPURect(x: 0, y: 0, width: 2, height: 2)) + #expect(Array(boundFrame.bytes) == pixels) + + var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(0)) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(2)) + flush.appendLE(UInt32(7)) + flush.appendLE(UInt32(0)) + #expect(leUInt32(try submit(flush), at: 0) == 0x1100) + + let frame = try #require(frameBox.value) + #expect(frame.scanoutID == 0) + #expect(frame.resourceID == 7) + #expect(frame.format == 1) + #expect(frame.width == 2) + #expect(frame.height == 2) + #expect(frame.stride == 4) + #expect(frame.dirtyRect == VirtioGPURect(x: 1, y: 0, width: 1, height: 2)) + #expect(Array(frame.bytes) == [5, 6, 7, 8, 13, 14, 15, 16]) + + var detach = gpuRequest(type: 0x0107, fenceID: 0, contextID: 0, ringIndex: 0) + detach.appendLE(UInt32(7)) + detach.appendLE(UInt32(0)) + #expect(leUInt32(try submit(detach), at: 0) == 0x1100) + #expect(renderer.detachedBackingResourceIDs == [7]) + + var unref = gpuRequest(type: 0x0102, fenceID: 0, contextID: 0, ringIndex: 0) + unref.appendLE(UInt32(7)) + unref.appendLE(UInt32(0)) + #expect(leUInt32(try submit(unref), at: 0) == 0x1100) + #expect(releasedResources.value == 7) + #expect(renderer.unreferencedResourceIDs == [7]) + } + + @Test func threeDimensionalPartialFlushUsesFullResourceReadbackStride() throws { + let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) + let frameBox = ScanoutFrameBox() + let renderer = FakeVirtioGPURenderer(capsets: []) + renderer.setTransferReadback( + width: 4, + height: 3, + pixels: Array(0..<48).map(UInt8.init) + ) + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + scanoutCount: 1, + scanoutWidth: 4, + scanoutHeight: 3, + renderer: renderer, + onScanoutFrame: { frameBox.store($0) } + ) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: gpu, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descTable, + availRing: availRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + + var availableIndex = UInt16(0) + func submit(_ request: [UInt8]) throws -> [UInt8] { + try writeDescriptor( + memory, + index: 0, + addr: requestBuffer, + len: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0) + try memory.write(request, at: requestBuffer) + let slot = UInt64(availableIndex % 8) + try memory.write(UInt16(0), at: availRing + 4 + slot * 2) + availableIndex &+= 1 + try memory.write(availableIndex, at: availRing + 2) + gpu.handleKick(queue: 0, transport: transport) + let usedSlot = UInt64((availableIndex - 1) % 8) + let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8) + return try memory.readBytes(at: responseBuffer, count: Int(responseLength)) + } + + var create = gpuRequest(type: 0x0204, fenceID: 0, contextID: 0, ringIndex: 0) + create.appendLE(UInt32(7)) // resource_id + create.appendLE(UInt32(2)) // PIPE_TEXTURE_2D + create.appendLE(UInt32(1)) // B8G8R8A8_UNORM + create.appendLE(UInt32(1 << 1)) // PIPE_BIND_RENDER_TARGET + create.appendLE(UInt32(4)) + create.appendLE(UInt32(3)) + create.appendLE(UInt32(1)) + create.appendLE(UInt32(1)) + create.appendLE(UInt32(0)) + create.appendLE(UInt32(0)) + create.appendLE(UInt32(1)) + create.appendLE(UInt32(0)) + #expect(leUInt32(try submit(create), at: 0) == 0x1100) + + var setScanout = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(4)) + setScanout.appendLE(UInt32(3)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(7)) + #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100) + #expect(Array(try #require(frameBox.value).bytes) == Array(0..<48).map(UInt8.init)) + + renderer.setTransferReadback( + width: 4, + height: 3, + pixels: Array(100..<148).map(UInt8.init) + ) + renderer.transferFromHostCalls.removeAll() + var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(2)) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(7)) + flush.appendLE(UInt32(0)) + #expect(leUInt32(try submit(flush), at: 0) == 0x1100) + + let transfer = try #require(renderer.transferFromHostCalls.last) + #expect(transfer.stride == 16) + #expect(transfer.entryLengths == [48]) + #expect(transfer.offset == 20) + #expect(transfer.box == [1, 1, 0, 2, 1, 1]) + let frame = try #require(frameBox.value) + #expect(frame.stride == 8) + #expect(frame.dirtyRect == VirtioGPURect(x: 1, y: 1, width: 2, height: 1)) + #expect(Array(frame.bytes) == Array(120..<128).map(UInt8.init)) + } + + @Test func guestBlobFlushPublishesStrideAwareScanoutFrame() throws { + let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) + let frameBox = ScanoutFrameBox() + let renderer = FakeVirtioGPURenderer(capsets: []) + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + scanoutCount: 1, + scanoutWidth: 2, + scanoutHeight: 2, + renderer: renderer, + onScanoutFrame: { frameBox.store($0) } + ) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: gpu, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descTable, + availRing: availRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + + var availableIndex = UInt16(0) + func submit(_ request: [UInt8]) throws -> [UInt8] { + try writeDescriptor( + memory, + index: 0, + addr: requestBuffer, + len: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0) + try memory.write(request, at: requestBuffer) + let slot = UInt64(availableIndex % 8) + try memory.write(UInt16(0), at: availRing + 4 + slot * 2) + availableIndex &+= 1 + try memory.write(availableIndex, at: availRing + 2) + gpu.handleKick(queue: 0, transport: transport) + let usedSlot = UInt64((availableIndex - 1) % 8) + let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8) + return try memory.readBytes(at: responseBuffer, count: Int(responseLength)) + } + + let pixelBuffer = base + 0x6000 + let pixels: [UInt8] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 90, 91, 92, 93, + 9, 10, 11, 12, 13, 14, 15, 16, 94, 95, 96, 97, + ] + try memory.write(pixels, at: pixelBuffer) + var createBlob = gpuRequest(type: 0x010C, fenceID: 0, contextID: 0, ringIndex: 0) + createBlob.appendLE(UInt32(7)) + createBlob.appendLE(UInt32(1)) // VIRTIO_GPU_BLOB_MEM_GUEST + createBlob.appendLE(UInt32(0)) + createBlob.appendLE(UInt32(1)) + createBlob.appendLE(UInt64(0)) + createBlob.appendLE(UInt64(pixels.count)) + createBlob.appendLE(pixelBuffer) + createBlob.appendLE(UInt32(pixels.count)) + createBlob.appendLE(UInt32(0)) + #expect(leUInt32(try submit(createBlob), at: 0) == 0x1100) + + var setScanout = gpuRequest(type: 0x010D, fenceID: 0, contextID: 0, ringIndex: 0) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(7)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(1)) // B8G8R8A8_UNORM + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(12)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100) + + var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0) + flush.appendLE(UInt32(0)) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(2)) + flush.appendLE(UInt32(1)) + flush.appendLE(UInt32(7)) + flush.appendLE(UInt32(0)) + #expect(leUInt32(try submit(flush), at: 0) == 0x1100) + + let frame = try #require(frameBox.value) + #expect(frame.scanoutID == 0) + #expect(frame.resourceID == 7) + #expect(frame.format == 1) + #expect(frame.width == 2) + #expect(frame.height == 2) + #expect(frame.stride == 8) + #expect(frame.dirtyRect == VirtioGPURect(x: 0, y: 1, width: 2, height: 1)) + #expect(Array(frame.bytes) == [9, 10, 11, 12, 13, 14, 15, 16]) + } + + @Test func host3DBlobScanoutMapsRendererMemoryUntilDisplayIsReleased() throws { + let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) + let frameBox = ScanoutFrameBox() + let rendererPixels: [UInt8] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 80, 81, 82, 83, + 9, 10, 11, 12, 13, 14, 15, 16, 84, 85, 86, 87, + ] + let renderer = FakeVirtioGPURenderer(capsets: [], blobBytes: rendererPixels) + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + scanoutCount: 1, + renderer: renderer, + onScanoutFrame: { frameBox.store($0) } + ) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: gpu, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descTable, + availRing: availRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + + var availableIndex = UInt16(0) + func submit(_ request: [UInt8]) throws -> [UInt8] { + try writeDescriptor(memory, index: 0, addr: requestBuffer, len: UInt32(request.count), flags: 0x1, next: 1) + try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0) + try memory.write(request, at: requestBuffer) + try memory.write(UInt16(0), at: availRing + 4 + UInt64(availableIndex % 8) * 2) + availableIndex &+= 1 + try memory.write(availableIndex, at: availRing + 2) + gpu.handleKick(queue: 0, transport: transport) + let usedSlot = UInt64((availableIndex - 1) % 8) + let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8) + return try memory.readBytes(at: responseBuffer, count: Int(responseLength)) + } + + var createBlob = gpuRequest(type: 0x010C, fenceID: 0, contextID: 3, ringIndex: 0) + createBlob.appendLE(UInt32(9)) + createBlob.appendLE(UInt32(2)) // VIRTIO_GPU_BLOB_MEM_HOST3D + createBlob.appendLE(UInt32(1)) // mappable + createBlob.appendLE(UInt32(0)) + createBlob.appendLE(UInt64(44)) + createBlob.appendLE(UInt64(rendererPixels.count)) + #expect(leUInt32(try submit(createBlob), at: 0) == 0x1100) + + var setScanout = gpuRequest(type: 0x010D, fenceID: 0, contextID: 0, ringIndex: 0) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(9)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(2)) + setScanout.appendLE(UInt32(1)) + setScanout.appendLE(UInt32(0)) + setScanout.appendLE(UInt32(12)) + for _ in 0..<7 { setScanout.appendLE(UInt32(0)) } + #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100) + let boundFrame = try #require(frameBox.value) + #expect(boundFrame.dirtyRect == VirtioGPURect(x: 0, y: 0, width: 2, height: 2)) + #expect(Array(boundFrame.bytes) == [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ]) + + var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0) + flush.appendLE(UInt32(0)) + flush.appendLE(UInt32(0)) + flush.appendLE(UInt32(2)) + flush.appendLE(UInt32(2)) + flush.appendLE(UInt32(9)) + flush.appendLE(UInt32(0)) + #expect(leUInt32(try submit(flush), at: 0) == 0x1100) + #expect(renderer.mapBlobCount == 1) + #expect(Array(try #require(frameBox.value).bytes) == [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ]) + + var disable = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0) + disable.appendLE(UInt32(0)) + disable.appendLE(UInt32(0)) + disable.appendLE(UInt32(2)) + disable.appendLE(UInt32(2)) + disable.appendLE(UInt32(0)) + disable.appendLE(UInt32(0)) + #expect(leUInt32(try submit(disable), at: 0) == 0x1100) + #expect(renderer.unmapBlobCount == 1) } @Test func fencedCommandDefersCompletionUntilRendererSignals() throws { @@ -994,11 +1549,79 @@ import Testing } } +private final class ScanoutFrameBox: @unchecked Sendable { + private let lock = NSLock() + private var frame: VirtioGPUScanoutFrame? + + func store(_ frame: VirtioGPUScanoutFrame) { + lock.lock() + self.frame = frame + lock.unlock() + } + + var value: VirtioGPUScanoutFrame? { + lock.lock() + defer { lock.unlock() } + return frame + } +} + +private final class ScanoutResourceBox: @unchecked Sendable { + private let lock = NSLock() + private var resourceID: UInt32? + + func store(_ resourceID: UInt32) { + lock.lock() + self.resourceID = resourceID + lock.unlock() + } + + var value: UInt32? { + lock.lock() + defer { lock.unlock() } + return resourceID + } +} + private final class FakeVirtioGPURenderer: VirtioGPURenderer { - let capsets: [VirtioGPUCapset] + struct TransferFromHostCall { + var stride: UInt32 + var offset: UInt64 + var entryLengths: [Int] + var box: [UInt32] + } - init(capsets: [VirtioGPUCapset]) { + let capsets: [VirtioGPUCapset] + private let blobStorage: UnsafeMutableRawPointer? + private let blobStorageSize: UInt64 + private(set) var mapBlobCount = 0 + private(set) var unmapBlobCount = 0 + private(set) var createdResources: [VirtioGPUResourceCreate3D] = [] + private(set) var attachedBackingResourceIDs: [UInt32] = [] + private(set) var detachedBackingResourceIDs: [UInt32] = [] + private(set) var unreferencedResourceIDs: [UInt32] = [] + private var transferReadbackWidth: UInt32 = 0 + private var transferReadbackHeight: UInt32 = 0 + private var transferReadbackPixels = [UInt8]() + var transferFromHostCalls = [TransferFromHostCall]() + + init(capsets: [VirtioGPUCapset], blobBytes: [UInt8]? = nil) { self.capsets = capsets + if let blobBytes { + let storage = UnsafeMutableRawPointer.allocate(byteCount: blobBytes.count, alignment: 16) + blobBytes.withUnsafeBytes { bytes in + storage.copyMemory(from: bytes.baseAddress!, byteCount: blobBytes.count) + } + self.blobStorage = storage + self.blobStorageSize = UInt64(blobBytes.count) + } else { + self.blobStorage = nil + self.blobStorageSize = 0 + } + } + + deinit { + blobStorage?.deallocate() } func createContext(id: UInt32, flags: UInt32, name: String) throws {} @@ -1006,7 +1629,9 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { func attachResource(contextID: UInt32, resourceID: UInt32) throws {} func detachResource(contextID: UInt32, resourceID: UInt32) throws {} func submit3D(contextID: UInt32, command: [UInt8]) throws {} - func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws {} + func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws { + createdResources.append(resource) + } func createBlob( resourceID: UInt32, contextID: UInt32, @@ -1016,15 +1641,67 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { size: UInt64, entries: [VirtioGPUMemoryEntry] ) throws {} - func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws {} - func detachBacking(resourceID: UInt32) throws {} - func unrefResource(resourceID: UInt32) throws {} + func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws { + attachedBackingResourceIDs.append(resourceID) + } + func detachBacking(resourceID: UInt32) throws { + detachedBackingResourceIDs.append(resourceID) + } + func unrefResource(resourceID: UInt32) throws { + unreferencedResourceIDs.append(resourceID) + } func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping { - VirtioGPUBlobMapping(hostPointer: UnsafeMutableRawPointer(bitPattern: 0x1000)!, size: 4096, mapInfo: 2) + mapBlobCount += 1 + return VirtioGPUBlobMapping( + hostPointer: blobStorage ?? UnsafeMutableRawPointer(bitPattern: 0x1000)!, + size: blobStorage == nil ? 4096 : blobStorageSize, + mapInfo: 2 + ) } - func unmapBlob(resourceID: UInt32) throws {} + func unmapBlob(resourceID: UInt32) throws { unmapBlobCount += 1 } func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {} - func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {} + func setTransferReadback(width: UInt32, height: UInt32, pixels: [UInt8]) { + transferReadbackWidth = width + transferReadbackHeight = height + transferReadbackPixels = pixels + } + + func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { + transferFromHostCalls.append(TransferFromHostCall( + stride: transfer.stride, + offset: transfer.offset, + entryLengths: entries.map(\.length), + box: transfer.box + )) + guard transferReadbackWidth > 0, transferReadbackHeight > 0 else { return } + guard transfer.box.count == 6, + transfer.box[2] == 0, transfer.box[5] == 1, + transfer.box[0] <= transferReadbackWidth, + transfer.box[3] <= transferReadbackWidth - transfer.box[0], + transfer.box[1] <= transferReadbackHeight, + transfer.box[4] <= transferReadbackHeight - transfer.box[1], + transferReadbackPixels.count == Int(transferReadbackWidth * transferReadbackHeight * 4), + let destination = entries.first else { + throw VMError.invalidConfiguration("invalid fake renderer readback") + } + let rowBytes = Int(transfer.box[3] * 4) + for row in 0.. Void)? /// Registered fences accumulate; tests fire them via `signalFence` to model asynchronous @@ -1048,6 +1725,425 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { } } +@Suite struct VirtioSoundTests { + @Test func advertisesStandardPlaybackAndCaptureStreams() { + let host = TestSoundHost() + let sound = VirtioSound(host: host) + + #expect(sound.deviceID == 25) + #expect(sound.queueCount == 4) + #expect(sound.configSpace.leUInt32(at: 0) == 0) + #expect(sound.configSpace.leUInt32(at: 4) == 2) + #expect(sound.configSpace.leUInt32(at: 8) == 0) + + var request = [UInt8]() + request.appendLE(UInt32(0x0100)) + request.appendLE(UInt32(0)) + request.appendLE(UInt32(2)) + request.appendLE(UInt32(32)) + let response = sound.controlResponseForTesting(request, responseCapacity: 68) + + #expect(response.count == 68) + #expect(response.leUInt32(at: 0) == 0x8000) + #expect(response[28] == VirtioSoundDirection.output.rawValue) + #expect(response[60] == VirtioSoundDirection.input.rawValue) + #expect(response[29] == 1 && response[30] == 2) + #expect(response[61] == 1 && response[62] == 2) + #expect(response.leUInt64(at: 12) & (1 << 5) != 0) // S16_LE + #expect(response.leUInt64(at: 20) & (1 << 6) != 0) // 44.1 kHz + #expect(response.leUInt64(at: 20) & (1 << 7) != 0) // 48 kHz + } + + @Test func enforcesTheVirtioPCMStreamLifecycle() { + let host = TestSoundHost() + let sound = VirtioSound(host: host) + + #expect(status(sound, setParameters(streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0102, streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0104, streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0105, streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0103, streamID: 0)) == 0x8000) + #expect(host.events == [ + "configure-0-output-48000-2", + "prepare-0-output", + "start-0-output", + "stop-0-output", + "release-0-output", + ]) + } + + @Test func rejectsFormatsThatLinuxWasNotToldAreAvailable() { + let host = TestSoundHost() + let sound = VirtioSound(host: host) + + #expect(status(sound, setParameters(streamID: 0, format: 1)) == 0x8002) + #expect(host.events.isEmpty) + } + + @Test func playbackDescriptorCompletesOnlyAfterHostPlaybackCallback() throws { + let base = GuestLayout.ramBase + let descriptorTable = base + 0x1000 + let availableRing = base + 0x2000 + let usedRing = base + 0x3000 + let requestBuffer = base + 0x4000 + let responseBuffer = base + 0x5000 + let host = TestSoundHost() + host.acceptPlayback = true + let sound = VirtioSound(host: host) + + #expect(status(sound, setParameters(streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0102, streamID: 0)) == 0x8000) + #expect(status(sound, lifecycle(0x0104, streamID: 0)) == 0x8000) + + let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: sound, + memory: memory + ) {} + transport.queues[2].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[2].setReady(true) + + let audio = [UInt8](repeating: 0x40, count: 16) + var request = [UInt8]() + request.appendLE(UInt32(0)) + request.append(contentsOf: audio) + try writeDescriptor( + memory, + table: descriptorTable, + index: 0, + address: requestBuffer, + length: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeDescriptor( + memory, + table: descriptorTable, + index: 1, + address: responseBuffer, + length: 8, + flags: 0x2, + next: 0 + ) + try memory.write(request, at: requestBuffer) + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(0), at: availableRing + 4) + try memory.write(UInt16(1), at: availableRing + 2) + + sound.handleKick(queue: 2, transport: transport) + + #expect(host.playbackData == Data(audio)) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0) + let completion = try #require(host.playbackCompletion) + completion(true, 256) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1) + #expect(try memory.read(UInt32.self, at: usedRing + 8) == 8) + #expect(try memory.read(UInt32.self, at: responseBuffer) == 0x8000) + #expect(try memory.read(UInt32.self, at: responseBuffer + 4) == 256) + } + + @Test func captureDescriptorCanPrimeBeforeStartAndReturnsPayloadThenStatus() throws { + let base = GuestLayout.ramBase + let descriptorTable = base + 0x1000 + let availableRing = base + 0x2000 + let usedRing = base + 0x3000 + let requestBuffer = base + 0x4000 + let audioBuffer = base + 0x5000 + let statusBuffer = base + 0x6000 + let host = TestSoundHost() + host.acceptCapture = true + let sound = VirtioSound(host: host) + + #expect(status(sound, setParameters(streamID: 1)) == 0x8000) + #expect(status(sound, lifecycle(0x0102, streamID: 1)) == 0x8000) + + let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: sound, + memory: memory + ) {} + transport.queues[3].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[3].setReady(true) + + try writeDescriptor( + memory, + table: descriptorTable, + index: 0, + address: requestBuffer, + length: 4, + flags: 0x1, + next: 1 + ) + try writeDescriptor( + memory, + table: descriptorTable, + index: 1, + address: audioBuffer, + length: 16, + flags: 0x3, + next: 2 + ) + try writeDescriptor( + memory, + table: descriptorTable, + index: 2, + address: statusBuffer, + length: 8, + flags: 0x2, + next: 0 + ) + try memory.write([UInt8](repeating: 0, count: 4), at: requestBuffer) + try memory.write(UInt32(1), at: requestBuffer) + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(0), at: availableRing + 4) + try memory.write(UInt16(1), at: availableRing + 2) + + sound.handleKick(queue: 3, transport: transport) + + #expect(host.captureByteCount == 16) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0) + let completion = try #require(host.captureCompletion) + let audio = Data((0..<16).map(UInt8.init)) + completion(audio, 512) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1) + #expect(try memory.read(UInt32.self, at: usedRing + 8) == 24) + #expect(try memory.readBytes(at: audioBuffer, count: 16) == [UInt8](audio)) + #expect(try memory.read(UInt32.self, at: statusBuffer) == 0x8000) + #expect(try memory.read(UInt32.self, at: statusBuffer + 4) == 512) + } + + private func status(_ sound: VirtioSound, _ request: [UInt8]) -> UInt32 { + sound.controlResponseForTesting(request).leUInt32(at: 0) + } + + private func lifecycle(_ code: UInt32, streamID: UInt32) -> [UInt8] { + var request = [UInt8]() + request.appendLE(code) + request.appendLE(streamID) + return request + } + + private func setParameters( + streamID: UInt32, + channels: UInt8 = 2, + format: UInt8 = 5, + rate: UInt8 = 7 + ) -> [UInt8] { + var request = [UInt8]() + request.appendLE(UInt32(0x0101)) + request.appendLE(streamID) + request.appendLE(UInt32(32_768)) + request.appendLE(UInt32(4_096)) + request.appendLE(UInt32(0)) + request.append(channels) + request.append(format) + request.append(rate) + request.append(0) + return request + } + + private func writeDescriptor( + _ memory: GuestMemory, + table: UInt64, + index: UInt64, + address: UInt64, + length: UInt32, + flags: UInt16, + next: UInt16 + ) throws { + let descriptor = table + index * 16 + try memory.write(address, at: descriptor) + try memory.write(length, at: descriptor + 8) + try memory.write(flags, at: descriptor + 12) + try memory.write(next, at: descriptor + 14) + } + + private final class TestSoundHost: VirtioSoundHost, @unchecked Sendable { + var events = [String]() + var acceptPlayback = false + var acceptCapture = false + var playbackData: Data? + var playbackCompletion: (@Sendable (Bool, UInt32) -> Void)? + var captureByteCount: Int? + var captureCompletion: (@Sendable (Data?, UInt32) -> Void)? + + func configure( + streamID: Int, + direction: VirtioSoundDirection, + parameters: VirtioSoundPCMParameters + ) -> Bool { + events.append("configure-\(streamID)-\(direction)-\(Int(parameters.sampleRate))-\(parameters.channels)") + return true + } + + func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool { + events.append("prepare-\(streamID)-\(direction)") + return true + } + + func start(streamID: Int, direction: VirtioSoundDirection) -> Bool { + events.append("start-\(streamID)-\(direction)") + return true + } + + func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool { + events.append("stop-\(streamID)-\(direction)") + return true + } + + func release(streamID: Int, direction: VirtioSoundDirection) { + events.append("release-\(streamID)-\(direction)") + } + + func enqueuePlayback( + _ data: Data, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Bool, UInt32) -> Void + ) -> Bool { + guard acceptPlayback else { return false } + playbackData = data + playbackCompletion = completion + return true + } + + func requestCapture( + byteCount: Int, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Data?, UInt32) -> Void + ) -> Bool { + guard acceptCapture else { return false } + captureByteCount = byteCount + captureCompletion = completion + return true + } + + func reset() { events.append("reset") } + } +} + +@Suite struct VirtioInputTests { + private let base = GuestLayout.ramBase + private var descriptorTable: UInt64 { base + 0x1000 } + private var availableRing: UInt64 { base + 0x2000 } + private var usedRing: UInt64 { base + 0x3000 } + private var eventBuffers: UInt64 { base + 0x4000 } + + @Test func advertisesKeyboardPointerWheelAndAbsoluteAxes() { + let input = VirtioInput() + + input.writeConfig(offset: 0, value: 0x11, width: 1) + input.writeConfig(offset: 1, value: 1, width: 1) // EV_KEY + var config = input.configSpace + #expect(config[2] >= 35) + #expect(config[8 + 30] & (1 << 7) != 0) // KEY 247 + #expect(config[8 + 34] & 1 != 0) // BTN_LEFT 272 + + input.writeConfig(offset: 1, value: 2, width: 1) // EV_REL + config = input.configSpace + #expect(config[8 + 1] & (1 << 3) != 0) // REL_WHEEL_HI_RES 11 + + input.writeConfig(offset: 0, value: 0x12, width: 1) + input.writeConfig(offset: 1, value: 0, width: 1) // ABS_X + config = input.configSpace + #expect(config[2] == 20) + #expect(config.leUInt32(at: 12) == 32_767) + } + + @Test func convertsAppKitScrollDirectionToLinuxWheelDirection() { + var scroll = VirtioInputScrollAccumulator() + let events = scroll.events( + horizontalDelta: -1, + verticalDelta: 1, + hasPreciseDeltas: false + ) + + #expect(events == [ + VirtioInputEvent(type: 2, code: 11, value: -120), + VirtioInputEvent(type: 2, code: 8, value: -1), + VirtioInputEvent(type: 2, code: 12, value: 120), + VirtioInputEvent(type: 2, code: 6, value: 1), + ]) + } + + @Test func accumulatesPreciseScrollIntoDiscreteLinuxTicks() { + var scroll = VirtioInputScrollAccumulator() + for _ in 0..<9 { + let events = scroll.events( + horizontalDelta: 0, + verticalDelta: 1, + hasPreciseDeltas: true + ) + #expect(events == [VirtioInputEvent(type: 2, code: 11, value: -12)]) + } + let finalEvents = scroll.events( + horizontalDelta: 0, + verticalDelta: 1, + hasPreciseDeltas: true + ) + #expect(finalEvents == [ + VirtioInputEvent(type: 2, code: 11, value: -12), + VirtioInputEvent(type: 2, code: 8, value: -1), + ]) + } + + @Test func waitsForEnoughGuestBuffersBeforePublishingWholeInputFrame() throws { + let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) + let input = VirtioInput() + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: input, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + input.deviceReady(transport: transport) + + for index in 0..<3 { + let descriptor = descriptorTable + UInt64(index) * 16 + try memory.write(eventBuffers + UInt64(index) * 8, at: descriptor) + try memory.write(UInt32(8), at: descriptor + 8) + try memory.write(UInt16(2), at: descriptor + 12) // device-writable + try memory.write(UInt16(0), at: descriptor + 14) + try memory.write(UInt16(index), at: availableRing + 4 + UInt64(index) * 2) + } + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(2), at: availableRing + 2) + + input.send(frame: [ + VirtioInputEvent(type: 1, code: 30, value: 1), + VirtioInputEvent(type: 3, code: 0, value: 16_000), + ]) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0) + + try memory.write(UInt16(3), at: availableRing + 2) + input.handleKick(queue: 0, transport: transport) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 3) + #expect(try memory.read(UInt16.self, at: eventBuffers) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 2) == 30) + #expect(try memory.read(UInt32.self, at: eventBuffers + 4) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 8) == 3) + #expect(try memory.read(UInt32.self, at: eventBuffers + 12) == 16_000) + #expect(try memory.read(UInt16.self, at: eventBuffers + 16) == 0) + } +} + @Suite struct PL011Tests { @Test func transmitsToSinkAndReportsReadyFlags() { var out = [UInt8]() diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift new file mode 100644 index 00000000..ebb30c90 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import DoryHV + +@Suite struct GVProxyDesktopLaunchPlanTests { + @Test func desktopNetworkingUsesOnlyPerMachineSockets() { + let arguments = GVProxyDesktopLaunchPlan.arguments( + mtu: 1_280, + datapathSocket: "/private/dory/machine-a/gv.sock", + apiSocket: "/private/dory/machine-a/api.sock" + ) + + #expect(arguments == [ + "-mtu", "1280", + "-listen-vfkit", "unixgram:///private/dory/machine-a/gv.sock", + "-listen", "unix:///private/dory/machine-a/api.sock", + "-ssh-port", "-1", + ]) + #expect(!arguments.contains("2222")) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift index f3f44ea0..4465a373 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift @@ -143,4 +143,24 @@ struct GuestShutdownCommandTests { #expect(process.terminationStatus == 0) } + + @Test func detachedDesktopRequestUsesGracefulSystemShutdown() throws { + let command = GuestShutdownCommand.detachedDesktopRequest() + + #expect(command.contains("systemd-run")) + #expect(command.contains("--on-active=1s")) + #expect(command.contains("systemctl poweroff")) + #expect(!command.contains("poweroff -f")) + #expect(!command.hasSuffix("&")) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-n", "-c", command] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 0) + } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift index da6d8d5f..89a79352 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift @@ -60,6 +60,26 @@ import Testing #expect(try queue.pop() == nil) // ring drained } + @Test func writesAcrossWritableSegmentsAtAProtocolOffset() throws { + let memory = try makeMemory() + let queue = Virtqueue(memory: memory) + queue.configure(size: 8, descriptorTable: descTable, availRing: availRing, usedRing: usedRing) + queue.setReady(true) + + try writeDescriptor(memory, index: 0, addr: dataIn, len: 5, flags: 0x3, next: 1) + try writeDescriptor(memory, index: 1, addr: dataIn + 5, len: 7, flags: 0x2, next: 0) + try memory.write([UInt8](repeating: 0xAA, count: 12), at: dataIn) + try memory.write(UInt16(0), at: availRing) + try memory.write(UInt16(0), at: availRing + 4) + try memory.write(UInt16(1), at: availRing + 2) + + let chain = try #require(try queue.pop()) + #expect(chain.writeBytes([1, 2, 3, 4], atWritableOffset: 4) == 4) + #expect(try memory.readBytes(at: dataIn, count: 12) == [ + 0xAA, 0xAA, 0xAA, 0xAA, 1, 2, 3, 4, 0xAA, 0xAA, 0xAA, 0xAA, + ]) + } + @Test func pushIsSafeAfterResetToZeroSize() throws { let memory = try makeMemory() let queue = Virtqueue(memory: memory) diff --git a/Packages/ContainerizationEngine/dory-hv.entitlements b/Packages/ContainerizationEngine/dory-hv.entitlements index ee0058be..c550707a 100644 --- a/Packages/ContainerizationEngine/dory-hv.entitlements +++ b/Packages/ContainerizationEngine/dory-hv.entitlements @@ -2,7 +2,11 @@ - com.apple.security.hypervisor - + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + com.apple.security.hypervisor + diff --git a/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift b/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift index 96f51591..8d0cb00b 100644 --- a/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift +++ b/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift @@ -37,6 +37,23 @@ public enum GuestShutdownCommand { + " ) >/var/log/dory-shutdown.log 2>&1 String { + // Let PID 1 own the delayed request. A shell backgrounded by dory-agent remains in the + // agent's process group/cgroup and can be reaped before it reaches `systemctl poweroff`. + // A transient systemd service survives the RPC process and gives the agent time to return + // its success response before the graphical session begins shutting down. + "echo shutdown requested >/var/log/dory-shutdown.log; sync; " + + "systemd-run --quiet --collect --unit=dory-host-poweroff " + + "--on-active=1s /usr/bin/systemctl poweroff " + + ">>/var/log/dory-shutdown.log 2>&1" + } + private static func shutdownSequence() -> String { let attempts = DoryEngineShutdownTiming.dockerdPollAttempts let interval = DoryEngineShutdownTiming.pollIntervalSeconds diff --git a/patches/moltenvk-dory-native-arrays.patch b/patches/moltenvk-dory-native-arrays.patch new file mode 100644 index 00000000..a39f01a0 --- /dev/null +++ b/patches/moltenvk-dory-native-arrays.patch @@ -0,0 +1,37 @@ +diff --git a/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp b/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp +index 406f4f8..56e9bda 100644 +--- a/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp ++++ b/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp +@@ -28,6 +28,11 @@ using namespace std; + using namespace spv; + using namespace SPIRV_CROSS_NAMESPACE; + ++extern "C" MVK_PUBLIC_SYMBOL uint32_t dory_moltenvk_spirv_native_array_fix(); ++extern "C" MVK_PUBLIC_SYMBOL uint32_t dory_moltenvk_spirv_native_array_fix() { ++ return 1; ++} ++ + + #pragma mark - + #pragma mark SPIRVToMSLConversionConfiguration +@@ -89,6 +93,9 @@ MVK_PUBLIC_SYMBOL SPIRVToMSLConversionOptions::SPIRVToMSLConversionOptions() { + #endif + + mslOptions.pad_fragment_output_components = true; ++ // Dory: keep fixed-size arrays native so values extracted from nested ++ // Vulkan structs retain the same Metal type when passed to helper functions. ++ mslOptions.force_native_arrays = true; + } + + MVK_PUBLIC_SYMBOL bool mvk::MSLShaderInterfaceVariable::matches(const mvk::MSLShaderInterfaceVariable& other) const { +@@ -295,6 +302,10 @@ MVK_PUBLIC_SYMBOL bool SPIRVToMSLConverter::convert(SPIRVToMSLConversionConfigur + + // Establish the MSL options for the compiler + // This needs to be done in two steps...for CompilerMSL and its superclass. ++ // Dory: Metal has no by-value native-array parameters. Force SPIRV-Cross ++ // to keep fixed arrays native at the final conversion boundary as well; ++ // cached/deserialized conversion configurations may predate this default. ++ shaderConfig.options.mslOptions.force_native_arrays = true; + pMSLCompiler->set_msl_options(shaderConfig.options.mslOptions); + + auto scOpts = pMSLCompiler->get_common_options(); diff --git a/patches/virglrenderer-macos-fragment-coord.patch b/patches/virglrenderer-macos-fragment-coord.patch new file mode 100644 index 00000000..325d1b64 --- /dev/null +++ b/patches/virglrenderer-macos-fragment-coord.patch @@ -0,0 +1,177 @@ +diff --git a/src/virglrenderer.h b/src/virglrenderer.h +index 12a9904..5d940b7 100644 +--- a/src/virglrenderer.h ++++ b/src/virglrenderer.h +@@ -34,6 +34,8 @@ struct virgl_box; + struct iovec; + + #define VIRGL_EXPORT __attribute__((visibility("default"))) ++VIRGL_EXPORT int dory_virglrenderer_macos_fragment_coord_fix(void); ++VIRGL_EXPORT int dory_virglrenderer_macos_venus_fence_fix(void); + + typedef void *virgl_renderer_gl_context; + +diff --git a/src/virglrenderer.c b/src/virglrenderer.c +index 6c565d2..d0f3b21 100644 +--- a/src/virglrenderer.c ++++ b/src/virglrenderer.c +@@ -61,6 +61,19 @@ struct global_state { + + static struct global_state state; + ++/* Release tooling requires this marker before it will bundle a renderer on macOS. */ ++VIRGL_EXPORT int ++dory_virglrenderer_macos_fragment_coord_fix(void) ++{ ++ return 1; ++} ++ ++VIRGL_EXPORT int ++dory_virglrenderer_macos_venus_fence_fix(void) ++{ ++ return 1; ++} ++ + /* new API - just wrap internal API for now */ + + static int virgl_renderer_resource_create_internal(struct virgl_renderer_resource_create_args *args, +diff --git a/src/vrend_shader.c b/src/vrend_shader.c +index 327523f..50b3c08 100644 +--- a/src/vrend_shader.c ++++ b/src/vrend_shader.c +@@ -6266,10 +6266,14 @@ static void emit_header(const struct dump_ctx *ctx, + + if (ctx->prog_type == TGSI_PROCESSOR_VERTEX && ctx->cfg->use_explicit_locations) + emit_ext(glsl_strbufs, "ARB_explicit_attrib_location", "require"); +- if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx)) ++ if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx) && ++ ctx->glsl_ver_required < 150) + emit_ext(glsl_strbufs, "ARB_fragment_coord_conventions", "require"); + +- if (ctx->ubo_used_mask) +- emit_ext(glsl_strbufs, "ARB_uniform_buffer_object", "require"); ++ if (ctx->ubo_used_mask) { ++#ifndef __APPLE__ ++ emit_ext(glsl_strbufs, "ARB_uniform_buffer_object", "require"); ++#endif ++ } + + if (ctx->num_cull_dist_prop || ctx->key->num_in_cull || ctx->key->num_out_cull) +@@ -8181,6 +8185,13 @@ bool vrend_convert_shader(const struct vrend_context *rctx, + ctx.shader_req_bits |= SHADER_REQ_SHADER_NOPERSPECTIVE_INTERPOLATION; + } + ++ /* Fragment coordinate layout qualifiers are core in GLSL 1.50. Some ++ * core-profile implementations (notably macOS) support the core syntax ++ * without exposing GL_ARB_fragment_coord_conventions as an extension. */ ++ if (!cfg->use_gles && cfg->glsl_version >= 150 && ++ ctx.prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(&ctx)) ++ ctx.glsl_ver_required = require_glsl_ver(&ctx, 150); ++ + emit_header(&ctx, &ctx.glsl_strbufs); + ctx.glsl_ver_required = emit_ios(&ctx, &ctx.glsl_strbufs, &ctx.generic_ios, + &ctx.texcoord_ios, &ctx.patches_emitted_mask, +diff --git a/src/venus_context.c b/src/venus_context.c +index 45a98af..5302069 100644 +--- a/src/venus_context.c ++++ b/src/venus_context.c +@@ -231,33 +231,19 @@ venus_context_retire_fences_internal(struct venus_context *ctx, uint32_t ring_i + static void + venus_context_retire_fences(struct virgl_context *base) + { +- fprintf(stderr, "%s: UNIMPLEMENTED\n", __func__); +-#if 0 +- struct proxy_context *ctx = (struct proxy_context *)base; +- +- uint64_t new_busy_mask = 0; +- uint64_t old_busy_mask = ctx->timeline_busy_mask; +- while (old_busy_mask) { +- const uint32_t ring_idx = u_bit_scan64(&old_busy_mask); +- const uint32_t cur_seqno = proxy_context_load_timeline_seqno(ctx, ring_idx); +- if (!proxy_context_retire_timeline_fences_locked(ctx, ring_idx, cur_seqno)) +- new_busy_mask |= 1ull << ring_idx; +- } +- +- ctx->timeline_busy_mask = new_busy_mask; +- +- if (proxy_renderer.flags & VIRGL_RENDERER_ASYNC_FENCE_CB) +- mtx_unlock(&ctx->timeline_mutex); +-#endif ++ /* vkr_renderer is always initialized with VKR_RENDERER_ASYNC_FENCE_CB and ++ * retires Venus fences through venus_state_cb_retire_fence. The outer ++ * virglrenderer was intentionally initialized without its Linux eventfd ++ * flag on macOS, so virgl_renderer_poll still visits this context. There ++ * is nothing to poll here; the callback is the sole completion path. ++ */ ++ (void)base; + } + + static int + venus_context_get_fencing_fd(struct virgl_context *base) + { +- struct venus_context *ctx = (struct venus_context *)base; +- +- //fprintf(stderr, "%s: UNIMPLEMENTED\n", __func__); +- +- //assert(!(venus_renderer.flags & VIRGL_RENDERER_ASYNC_FENCE_CB)); +- //return ctx->sync_thread.fence_eventfd; ++ /* The krunkit macOS renderer has no Linux eventfd. */ ++ (void)base; ++ return -1; + } +@@ -414,7 +400,8 @@ venus_state_cb_debug_logger(UNUSED enum virgl_log_level_flags log_level, + const char *message, + UNUSED void* user_data) + { +- //fprintf(stderr, "%s: %s\n", __func__, message); ++ /* Do not discard renderer protocol/device failures on macOS. */ ++ fprintf(stderr, "dory-venus: %s\n", message); + } + + static void +diff --git a/src/venus/vkr_ring.c b/src/venus/vkr_ring.c +index 1ba50e8..4d60065 100644 +--- a/src/venus/vkr_ring.c ++++ b/src/venus/vkr_ring.c +@@ -280,6 +280,9 @@ vkr_ring_thread(void *arg) + const uint32_t cmd_size = vkr_ring_load_tail(ring) - ring->buffer.cur; + if (cmd_size) { + if (cmd_size > ring->buffer.size) { ++ vkr_log("ring command size %u exceeds buffer size %zu " ++ "(head %u, tail %u)", cmd_size, ring->buffer.size, ++ ring->buffer.cur, vkr_ring_load_tail(ring)); + ret = -EINVAL; + break; + } +diff --git a/src/venus/vkr_pipeline.c b/src/venus/vkr_pipeline.c +index c142ab1..cfa9b17 100644 +--- a/src/venus/vkr_pipeline.c ++++ b/src/venus/vkr_pipeline.c +@@ -111,8 +111,12 @@ vkr_dispatch_vkCreateGraphicsPipelines(struct vn_dispatch_context *dispatch, + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct object_array arr; + +- if (vkr_graphics_pipeline_create_array(ctx, args, &arr) < VK_SUCCESS) ++ const VkResult result = ++ vkr_graphics_pipeline_create_array(ctx, args, &arr); ++ if (result < VK_SUCCESS) { ++ vkr_log("vkCreateGraphicsPipelines failed (%d)", result); + return; ++ } + + vkr_pipeline_add_array(ctx, dev, &arr, args->pPipelines); + } +@@ -125,8 +129,12 @@ vkr_dispatch_vkCreateComputePipelines(struct vn_dispatch_context *dispatch, + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct object_array arr; + +- if (vkr_compute_pipeline_create_array(ctx, args, &arr) < VK_SUCCESS) ++ const VkResult result = ++ vkr_compute_pipeline_create_array(ctx, args, &arr); ++ if (result < VK_SUCCESS) { ++ vkr_log("vkCreateComputePipelines failed (%d)", result); + return; ++ } + + vkr_pipeline_add_array(ctx, dev, &arr, args->pPipelines); + } From 096f3d0210c659cf858e895bdb6840d840f67d9e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:52:43 +0000 Subject: [PATCH 004/338] feat(vm): add durable EFI Linux installation --- Dory/Features/Components/ComponentsView.swift | 9 +- Dory/Features/Machines/MachinesView.swift | 95 +- Dory/Features/Sheets/NewMachineSheet.swift | 269 ++- Dory/Models/AppStore.swift | 212 ++- Dory/Models/Models.swift | 2 + Dory/Runtime/Doryd/DorydClient.swift | 78 +- .../Machines/DesktopMachineAssets.swift | 9 +- Dory/Runtime/Machines/MachineService.swift | 7 + Dory/Runtime/Shared/SharedVMProvisioner.swift | 7 +- DoryTests/DorydClientTests.swift | 20 + DoryTests/NewMachineSettingsTests.swift | 52 + .../DoryInstalledLinuxBootBundle.swift | 301 ++++ .../DoryOperations/DoryInstallerISO.swift | 1100 ++++++++++++ .../DoryLinuxInstalledDisk.swift | 154 ++ .../Sources/DoryVMMKit/DoryVMM.swift | 259 ++- .../DoryVMMDesktopApplication.swift | 118 +- .../DoryVMMKit/DoryVMMSerialConsole.swift | 267 +++ .../Sources/DorydKit/DorydConfiguration.swift | 25 + .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 82 +- .../Sources/DorydKit/HvProcess.swift | 58 +- .../Sources/DorydKit/MachineManager.swift | 1557 +++++++++++++++-- dory-core-swift/Sources/dory-vmm/Info.plist | 2 + .../Sources/dory-vmm/dory-vmm.entitlements | 2 + dory-core-swift/Sources/dorydctl/main.swift | 126 +- .../DoryInstalledLinuxBootBundleTests.swift | 78 + .../DoryInstallerISOTests.swift | 235 +++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 315 +++- .../DorydConfigurationTests.swift | 41 + .../Tests/DorydKitTests/HvProcessTests.swift | 49 + .../DorydKitTests/MachineManagerTests.swift | 785 ++++++++- 31 files changed, 6034 insertions(+), 281 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift diff --git a/Dory/Features/Components/ComponentsView.swift b/Dory/Features/Components/ComponentsView.swift index 29f45483..c58436b1 100644 --- a/Dory/Features/Components/ComponentsView.swift +++ b/Dory/Features/Components/ComponentsView.swift @@ -424,6 +424,7 @@ struct ComponentsView: View { let store = try DoryComponentStore.selected() let installer = DoryComponentInstaller(store: store) let digest = DoryComponentCatalogVerifier.digest(catalogData) + var activatedComponents: Set = [id] for release in try installationOrder(id, catalog: catalog) { if let current = try store.installedComponent(release.id), current.version == release.version, @@ -436,12 +437,18 @@ struct ComponentsView: View { _ = try await installer.install(release, catalogData: catalogData) { update in Task { @MainActor in self.progress[release.id] = update } } + activatedComponents.insert(release.id) busy.remove(release.id) } + let desktopUpdates = try await appStore.updateManagedDesktops(affectedBy: activatedComponents) statuses = store.list(catalog: catalog, catalogDigest: digest) HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) if showSuccess { - appStore.showSettingsSuccess("\(displayName(id)) is installed and verified.") + let updated = desktopUpdates.isEmpty + ? "" + : " Updated " + String(desktopUpdates.count) + " existing desktop" + + (desktopUpdates.count == 1 ? "." : "s.") + appStore.showSettingsSuccess("\(displayName(id)) is installed and verified." + updated) } return true } catch { diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 37259f01..9bba664b 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1,4 +1,5 @@ import Darwin +import DoryOperations import SwiftUI struct MachinesView: View { @@ -158,7 +159,7 @@ private struct MachineCard: View { } .padding(.top, 16).padding(.bottom, 14) - if machine.username != "root" { + if machine.username != "root", !machine.loginShell.isEmpty { Text("\(machine.username) · \(machine.loginShell)") .font(.system(size: 11)).foregroundStyle(p.text3).lineLimit(1) .padding(.bottom, 12) @@ -247,6 +248,17 @@ private struct MachineCard: View { Button { store.openMachineEdit(machine) } label: { Label("Edit…", systemImage: "slider.horizontal.3") } + if machine.bootMode == .efi { + Divider() + Button { + store.setMachineInstallerMedia(machine, attached: !machine.installerMediaAttached) + } label: { + Label( + machine.installerMediaAttached ? "Eject Installer ISO" : "Attach Installer ISO", + systemImage: machine.installerMediaAttached ? "eject" : "opticaldiscdrive" + ) + } + } } label: { Image(systemName: "ellipsis.circle").font(.system(size: 14, weight: .semibold)) .foregroundStyle(p.text2) @@ -350,6 +362,9 @@ private struct MachineEditSheet: View { @State private var address = "" @State private var displayMode: MachineDisplayMode = .headless @State private var guestUsername = "dory" + @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional + @State private var runtimePreference = DoryDesktopVMMPreference.automatic + @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic @State private var environment: [String: String] = [:] private struct MountRow: Identifiable, Hashable { @@ -369,6 +384,8 @@ private struct MachineEditSheet: View { VStack(alignment: .leading, spacing: 18) { warning machineTypeBlock + runtimeBlock + clipboardBlock resourceRow addressBlock mountsBlock @@ -391,6 +408,9 @@ private struct MachineEditSheet: View { displayMode = settings.displayMode environment = settings.env guestUsername = settings.env["DORY_GUEST_USER"] ?? "dory" + clipboardPolicy = DoryDesktopClipboardPolicy(environment: settings.env) + runtimePreference = (try? DoryDesktopVMMPreference(environment: settings.env)) ?? .automatic + graphicsPreference = (try? DoryDesktopGraphicsPreference(environment: settings.env)) ?? .automatic mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) } } @@ -401,7 +421,10 @@ private struct MachineEditSheet: View { .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 1) { Text("Edit \(machine.name)").font(.system(size: 15, weight: .bold)).foregroundStyle(p.text) - Text("Apply user, resources, address and mounted folders").font(.system(size: 11.5)).foregroundStyle(p.text3) + Text(machine.bootMode == .efi + ? "Apply resources, address and mounted folders" + : "Apply user, resources, address and mounted folders") + .font(.system(size: 11.5)).foregroundStyle(p.text3) } Spacer() } @@ -411,7 +434,9 @@ private struct MachineEditSheet: View { private var warning: some View { HStack(spacing: 9) { Image(systemName: "info.circle.fill").font(.system(size: 13)).foregroundStyle(p.accent) - Text("Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.") + Text(machine.bootMode == .efi + ? "Resource and mount changes restart a running machine automatically. The EFI machine type is fixed to protect its installed disk." + : "Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.") .font(.system(size: 12)).foregroundStyle(p.text2) Spacer(minLength: 0) } @@ -428,13 +453,13 @@ private struct MachineEditSheet: View { .frame(width: 34, height: 34) .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 2) { - Text(displayMode == .desktop ? "Desktop Linux" : "Headless Linux") + Text(machine.bootMode == .efi ? "Custom EFI Linux" : (displayMode == .desktop ? "Desktop Linux" : "Headless Linux")) .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Text(displayMode == .desktop ? "\(machine.distro) \(machine.version)" : "Lightweight Dory Linux") .font(.system(size: 11)).foregroundStyle(p.text3) } Spacer(minLength: 0) - if displayMode == .desktop { + if displayMode == .desktop, machine.bootMode != .efi { TextField("dory", text: $guestUsername) .textFieldStyle(.plain) .font(.mono(11.5)).foregroundStyle(p.text) @@ -478,6 +503,55 @@ private struct MachineEditSheet: View { } } + @ViewBuilder private var clipboardBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("CLIPBOARD SHARING") + Picker("Clipboard sharing", selection: $clipboardPolicy) { + ForEach(DoryDesktopClipboardPolicy.allCases, id: \.self) { policy in + Text(policy.displayName).tag(policy) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-clipboard-policy") + Text("Control whether text and images can move between this Linux desktop and your Mac.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var runtimeBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + VStack(alignment: .leading, spacing: 9) { + sectionLabel("DISPLAY ENGINE") + HStack(spacing: 14) { + Picker("Virtual machine", selection: $runtimePreference) { + Text("Automatic").tag(DoryDesktopVMMPreference.automatic) + Text("Accelerated").tag(DoryDesktopVMMPreference.accelerated) + Text("Compatibility").tag(DoryDesktopVMMPreference.compatible) + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("edit-machine-vmm-preference") + + Picker("Graphics", selection: $graphicsPreference) { + Text("Automatic").tag(DoryDesktopGraphicsPreference.automatic) + Text("VirGL").tag(DoryDesktopGraphicsPreference.virgl) + Text("VirGL + Venus").tag(DoryDesktopGraphicsPreference.virglVenus) + Text("Software").tag(DoryDesktopGraphicsPreference.software) + } + .frame(maxWidth: .infinity) + .disabled(runtimePreference == .compatible) + .accessibilityIdentifier("edit-machine-graphics-preference") + } + Text(runtimePreference == .compatible + ? "Compatibility uses Apple's Virtualization framework; the raw graphics choice is kept for when you switch back." + : "Automatic uses VirGL for the desktop and Venus for Vulkan apps, then falls back safely when acceleration is unavailable.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } + private var addressBlock: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("DNS TARGET OVERRIDE") @@ -613,7 +687,7 @@ private struct MachineEditSheet: View { guard !host.isEmpty, !guest.isEmpty else { return nil } return MountPair(host: host, guest: guest, readOnly: row.readOnly) } - if displayMode == .desktop { + if displayMode == .desktop, machine.bootMode != .efi { let previousUsername = environment["DORY_GUEST_USER"] ?? "dory" if previousUsername != normalizedGuestUsername { let previousHome = "/home/\(previousUsername)" @@ -631,6 +705,10 @@ private struct MachineEditSheet: View { } environment["DORY_GUEST_USER"] = normalizedGuestUsername environment["DORY_GUEST_UID"] = environment["DORY_GUEST_UID"] ?? String(getuid()) + environment[DoryDesktopClipboardPolicy.environmentKey] = clipboardPolicy.rawValue + environment[DoryDesktopVMMPreference.environmentKey] = runtimePreference.rawValue + environment[DoryDesktopGraphicsPreference.environmentKey] = graphicsPreference.rawValue + environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey) } let settings = MachineSettings( cpus: cpus, @@ -638,7 +716,8 @@ private struct MachineEditSheet: View { mounts: mounts, env: environment, address: address.trimmingCharacters(in: .whitespacesAndNewlines), - displayMode: displayMode + displayMode: displayMode, + bootMode: machine.bootMode ) let target = machine store.editMachineTarget = nil @@ -650,7 +729,7 @@ private struct MachineEditSheet: View { } private var guestUsernameInvalid: Bool { - guard displayMode == .desktop else { return false } + guard displayMode == .desktop, machine.bootMode != .efi else { return false } return normalizedGuestUsername.range( of: "^[a-z_][a-z0-9_-]{0,31}$", options: .regularExpression diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 04edc9ef..2593fab4 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -1,6 +1,7 @@ import Darwin import DoryOperations import SwiftUI +import UniformTypeIdentifiers struct NewMachineSheet: View { @Environment(AppStore.self) private var store @@ -12,6 +13,23 @@ struct NewMachineSheet: View { @State private var displayMode: MachineDisplayMode @State private var desktopDistro: DesktopMachineDistro = .debian @State private var guestUsername = NewMachineSheet.defaultGuestUsername() + @State private var customISOInstall = false + @State private var installerISOPath = "" + @State private var installerISOCheck: InstallerISOCheck = .none + @State private var diskSizeGB = 64 + + private enum InstallerISOCheck: Equatable { + case none + case checking + case compatible( + DoryInstallerISOArchitecture, + DoryInstallerISORuntimeQualification + ) + case unknown(DoryInstallerISOMediaIdentity) + case unstable(String) + case incompatible(String) + case failed(String) + } enum Stage: Hashable { case useCase, form } @State private var stage: Stage @@ -30,9 +48,14 @@ struct NewMachineSheet: View { } init(displayMode: MachineDisplayMode) { + let resources = Self.recommendedDesktopResources() _displayMode = State(initialValue: displayMode) _stage = State(initialValue: displayMode == .desktop ? .form : .useCase) _name = State(initialValue: NewMachineSheet.defaultName()) + if displayMode == .desktop { + _cpus = State(initialValue: resources.cpus) + _memoryGB = State(initialValue: resources.memoryGB) + } if let installedDistro = DesktopMachineDistro.allCases.first(where: { AppInfo.componentAvailable($0.componentID) }) { @@ -206,7 +229,9 @@ struct NewMachineSheet: View { return "\(useCase.title) — tweak anything below" } if displayMode == .desktop { - return "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon" + return customISOInstall + ? "Install an arm64 Linux distribution from ISO · Apple EFI" + : "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon" } return "Headless Linux · native Apple Silicon" } @@ -232,17 +257,125 @@ struct NewMachineSheet: View { private var desktopDistroSection: some View { VStack(alignment: .leading, spacing: 9) { - sectionLabel("DESKTOP DISTRIBUTION") - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) { - ForEach(installedDesktopDistros) { distro in - desktopDistroButton(distro) + sectionLabel("INSTALLATION SOURCE") + Picker("", selection: $customISOInstall) { + Text("Dory desktop").tag(false) + Text("Custom ISO").tag(true) + } + .labelsHidden() + .pickerStyle(.segmented) + .onChange(of: customISOInstall) { _, custom in + if custom { + selectedRecipe = nil + cpus = DoryInstallerMachinePolicy.defaultCPUCount + } + } + + if customISOInstall { + customISOSection + } else { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) { + ForEach(installedDesktopDistros) { distro in + desktopDistroButton(distro) + } } + Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.") + .font(.system(size: 11)).foregroundStyle(p.text3) } - Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.") + } + } + + private var customISOSection: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: chooseInstallerISO) { + HStack(spacing: 8) { + Image(systemName: "opticaldiscdrive").foregroundStyle(p.accent) + Text(installerISOPath.isEmpty ? "Choose Linux installer ISO…" : installerISOPath) + .font(.mono(11.5)).foregroundStyle(installerISOPath.isEmpty ? p.text3 : p.text) + .lineLimit(1).truncationMode(.head) + Spacer(minLength: 0) + Text("Choose").font(.system(size: 11, weight: .semibold)).foregroundStyle(p.accent) + } + .padding(11) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(p.border)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("custom-linux-iso-picker") + + installerISOStatus + + HStack(spacing: 16) { + sectionLabel("VIRTUAL DISK") + boundedResourceControl( + value: $diskSizeGB, + range: 16...512, + display: { "\($0) GB" }, + valueIdentifier: "custom-linux-disk-size", + decrementIdentifier: "custom-linux-disk-decrement", + incrementIdentifier: "custom-linux-disk-increment" + ) + } + Text("Dory stores a private copy of the ISO, a thin-provisioned disk, a stable VM identity, and persistent EFI NVRAM. Choose an arm64 ISO on Apple Silicon.") .font(.system(size: 11)).foregroundStyle(p.text3) + Label( + "Dory starts installers with a balanced 4-vCPU default. EFI architecture and exact-media runtime qualification are checked separately.", + systemImage: "cpu" + ) + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + + @ViewBuilder private var installerISOStatus: some View { + switch installerISOCheck { + case .none: + EmptyView() + case .checking: + HStack(spacing: 7) { + ProgressView().controlSize(.mini) + Text("Checking EFI architecture before import…") + } + .font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2) + case let .compatible(architecture, qualification): + switch qualification { + case let .qualified(message): + isoStatusRow(icon: "checkmark.circle.fill", color: p.green, text: message) + case .unqualified: + isoStatusRow( + icon: "questionmark.circle.fill", + color: p.amber, + text: architecture == .multiArchitecture + ? "Universal EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory." + : "ARM64 EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory." + ) + case let .knownUnstable(message): + isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message) + } + case .unknown: + isoStatusRow( + icon: "questionmark.circle.fill", + color: p.amber, + text: "EFI architecture was not recognizable; Dory can try this custom image." + ) + case let .unstable(message): + isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message) + case let .incompatible(message): + isoStatusRow(icon: "xmark.octagon.fill", color: p.red, text: message) + case let .failed(message): + isoStatusRow(icon: "exclamationmark.triangle.fill", color: p.red, text: message) } } + private func isoStatusRow(icon: String, color: Color, text: String) -> some View { + HStack(alignment: .top, spacing: 7) { + Image(systemName: icon).font(.system(size: 11, weight: .semibold)).foregroundStyle(color) + Text(text).font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .accessibilityIdentifier("custom-linux-iso-compatibility") + } + private var installedDesktopDistros: [DesktopMachineDistro] { DesktopMachineDistro.allCases.filter { AppInfo.componentAvailable($0.componentID) } } @@ -298,6 +431,10 @@ struct NewMachineSheet: View { private var devEnvironmentSection: some View { VStack(alignment: .leading, spacing: 9) { sectionLabel("DEV ENVIRONMENT") + if customISOInstall { + Text("Choose packages and applications inside the Linux installer.") + .font(.system(size: 11.5)).foregroundStyle(p.text2) + } else { Picker("", selection: Binding( get: { selectedRecipe?.id ?? "" }, set: { selectedRecipe = $0.isEmpty ? nil : DevRecipe.forID($0) } @@ -310,6 +447,7 @@ struct NewMachineSheet: View { ? "Recipes install verified apt packages after the desktop starts." : "Recipes install verified Alpine packages after the VM starts.") .font(.system(size: 11)).foregroundStyle(p.text3) + } } } @@ -319,7 +457,7 @@ struct NewMachineSheet: View { HStack(spacing: 9) { Image(systemName: "person.crop.circle.badge.checkmark") .font(.system(size: 14)).foregroundStyle(p.accent) - if displayMode == .desktop { + if displayMode == .desktop, !customISOInstall { Text("Linux user").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Spacer(minLength: 0) TextField("dory", text: $guestUsername) @@ -331,9 +469,11 @@ struct NewMachineSheet: View { .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(guestUsernameInvalid ? p.red : p.border)) .accessibilityIdentifier("new-machine-guest-user") } else { - Text("Administrator shell").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + Text(customISOInstall ? "Linux account" : "Administrator shell") + .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Spacer(minLength: 0) - Text("root · /bin/sh").font(.mono(11.5)).foregroundStyle(p.text3) + Text(customISOInstall ? "Created in the installer" : "root · /bin/sh") + .font(.mono(11.5)).foregroundStyle(p.text3) } } if guestUsernameInvalid { @@ -572,7 +712,9 @@ struct NewMachineSheet: View { Image(systemName: "terminal") .font(.system(size: 11)).foregroundStyle(p.text3) Text(displayMode == .desktop - ? "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)" + ? (customISOInstall + ? customISOFooterDescription + : "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)") : "Dory Linux · arm64 · root shell") .font(.mono(11.5)).foregroundStyle(p.text3).lineLimit(1) } @@ -611,7 +753,9 @@ struct NewMachineSheet: View { private var createDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty || !nameValid - || guestUsernameInvalid + || (!customISOInstall && guestUsernameInvalid) + || (customISOInstall && installerISOPath.isEmpty) + || (customISOInstall && installerISOCheckBlocksCreate) || store.machineBusy || !engineReady || mountsOutsideHome @@ -641,10 +785,82 @@ struct NewMachineSheet: View { settings.mounts.append(MountPair(host: NSHomeDirectory(), guest: sharedHomeGuestPath)) } let machineName = name - let recipe = selectedRecipe + let recipe = customISOInstall ? nil : selectedRecipe Task { _ = await store.createMachine(name: machineName, recipe: recipe, settings: settings) } } + private func chooseInstallerISO() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + if let isoType = UTType(filenameExtension: "iso") { + panel.allowedContentTypes = [isoType] + } + panel.message = "Choose an arm64 Linux installer ISO" + guard panel.runModal() == .OK, let url = panel.url else { return } + installerISOPath = url.path + installerISOCheck = .checking + let selectedPath = url.path + Task { + let result: (DoryInstallerISOMediaIdentity?, String?) = await Task.detached( + priority: .userInitiated + ) { + let hasSecurityScope = url.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { url.stopAccessingSecurityScopedResource() } + } + do { + return (try DoryInstallerISOInspector.mediaIdentity(atPath: selectedPath), nil) + } catch { + return (nil, error.localizedDescription) + } + }.value + guard installerISOPath == selectedPath else { return } + guard let identity = result.0 else { + installerISOCheck = .failed(result.1 ?? "Dory could not inspect this ISO.") + return + } + switch DoryInstallerISOInspector.compatibility( + of: identity.architecture, + hostArchitecture: DoryInstallerISOInspector.currentHostArchitecture + ) { + case .compatible: + let qualification = DoryInstallerISORuntimeCatalog.qualification(of: identity) + if case let .knownUnstable(message) = qualification { + installerISOCheck = .unstable(message) + } else { + installerISOCheck = .compatible(identity.architecture, qualification) + } + case .unknown: + installerISOCheck = .unknown(identity) + case let .incompatible(message): + installerISOCheck = .incompatible(message) + } + } + } + + private var installerISOCheckBlocksCreate: Bool { + switch installerISOCheck { + case .compatible, .unknown: + false + case .none, .checking, .unstable, .incompatible, .failed: + true + } + } + + private var customISOFooterDescription: String { + switch installerISOCheck { + case .compatible(.multiArchitecture, _): "Custom universal Linux · EFI · \(diskSizeGB) GB" + case .compatible(.arm64, _): "Custom arm64 Linux · EFI · \(diskSizeGB) GB" + case .compatible(.x86_64, _): "Custom x86_64 Linux · EFI · \(diskSizeGB) GB" + case .compatible(.unknown, _), .unknown: "Custom Linux · EFI architecture unknown · \(diskSizeGB) GB" + case .incompatible: "Intel x86_64 ISO · incompatible" + case .unstable: "Known-unstable installer · choose different media" + case .none, .checking, .failed: "Custom Linux · EFI · \(diskSizeGB) GB" + } + } + static func buildSettings( cpus: Int, memoryGB: Int, @@ -663,6 +879,9 @@ struct NewMachineSheet: View { environment["DORY_DESKTOP_NAME"] = desktopDistro.displayName environment["DORY_DESKTOP_VERSION"] = desktopDistro.version environment["DORY_DESKTOP_ENVIRONMENT"] = desktopDistro.desktopName + environment[DoryDesktopClipboardPolicy.environmentKey] = DoryDesktopClipboardPolicy.bidirectional.rawValue + environment[DoryDesktopVMMPreference.environmentKey] = DoryDesktopVMMPreference.automatic.rawValue + environment[DoryDesktopGraphicsPreference.environmentKey] = DoryDesktopGraphicsPreference.automatic.rawValue } return MachineSettings( cpus: cpus, @@ -674,6 +893,23 @@ struct NewMachineSheet: View { ) } + static func recommendedDesktopResources( + activeProcessorCount: Int = ProcessInfo.processInfo.activeProcessorCount, + physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory + ) -> (cpus: Int, memoryGB: Int) { + let cpus = max(4, min(8, activeProcessorCount / 2)) + let hostMemoryGB = Int(physicalMemory / 1_073_741_824) + let memoryGB: Int + if hostMemoryGB >= 24 { + memoryGB = 8 + } else if hostMemoryGB >= 16 { + memoryGB = 6 + } else { + memoryGB = 4 + } + return (cpus, memoryGB) + } + private func collectedSettings() -> MachineSettings { let mounts = mountRows.compactMap { row -> MountPair? in let host = row.host.trimmingCharacters(in: .whitespaces) @@ -681,7 +917,7 @@ struct NewMachineSheet: View { guard !host.isEmpty, !guest.isEmpty else { return nil } return MountPair(host: host, guest: guest) } - return Self.buildSettings( + var settings = Self.buildSettings( cpus: cpus, memoryGB: memoryGB, mounts: mounts, @@ -690,6 +926,13 @@ struct NewMachineSheet: View { desktopDistro: desktopDistro, guestUsername: normalizedGuestUsername ) + if customISOInstall { + settings.bootMode = .efi + settings.installerISOPath = installerISOPath + settings.diskSizeGB = diskSizeGB + settings.env = ["DORY_CUSTOM_LINUX": "1"] + } + return settings } static func defaultName() -> String { diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 5da45c84..cff78697 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5085,7 +5085,8 @@ final class AppStore { mounts: status.shares.map(Self.mountPair(fromDoryd:)), env: status.environment, address: status.configuredAddress, - displayMode: status.displayMode + displayMode: status.displayMode, + bootMode: status.bootMode ) } catch { actionError = "Could not load doryd machine settings: \(error)" @@ -5160,26 +5161,29 @@ final class AppStore { .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } ?? status.state let isDesktop = status.displayMode == .desktop + let isCustomLinux = status.bootMode == .efi let desktopDistro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"]) - let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isDesktop ? "dory" : "root") + let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root")) return Machine( name: status.id, - distro: isDesktop ? desktopDistro.displayName : "Dory Linux", - version: isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail, + distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"), + version: isCustomLinux ? "EFI · arm64" : (isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail), status: runState, cpuPercent: 0, memoryDisplay: "—", ip: status.address ?? Self.machineDNSName(name: status.id, suffix: domainSuffix), - letter: isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D", - badgeHex: isDesktop ? desktopDistro.badgeHex : 0x3B82F6, + letter: isCustomLinux ? "L" : (isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D"), + badgeHex: isCustomLinux ? 0x7C3AED : (isDesktop ? desktopDistro.badgeHex : 0x3B82F6), containerID: "", arch: "", recipe: "doryd", username: guestUsername, - loginShell: isDesktop ? "/bin/bash" : "/bin/sh", + loginShell: isCustomLinux ? "" : (isDesktop ? "/bin/bash" : "/bin/sh"), shellSocketPath: status.shellSocketPath ?? "", processID: status.pid, displayMode: status.displayMode, + bootMode: status.bootMode, + installerMediaAttached: status.installerMediaAttached, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } @@ -5232,7 +5236,10 @@ final class AppStore { actionError = "The Desktop Linux display is not available yet. Start the machine and try again." return } - guard application.activate(options: [.activateAllWindows]) else { + if application.activate(options: [.activateAllWindows]) { return } + // Raw-HV desktops run in an unbundled helper, which LaunchServices can discover by PID but + // does not always activate. The helper handles SIGUSR1 by raising its own display window. + guard Darwin.kill(processID, SIGUSR1) == 0 else { actionError = "Dory could not bring \(machine.name)'s desktop window forward." return } @@ -5363,6 +5370,24 @@ final class AppStore { } } + func setMachineInstallerMedia(_ machine: Machine, attached: Bool) { + guard requireDorydMachines(), machine.bootMode == .efi else { return } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machineUpdate( + machine.name, + installerMediaAttached: attached + ) + } catch { + actionError = "Could not \(attached ? "attach" : "eject") the installer ISO for \(machine.name): \(error)" + } + await refreshMachines() + } + } + nonisolated static func allocateFreePort() -> Int { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { return 0 } @@ -5402,6 +5427,83 @@ final class AppStore { return result } + /// Brings persistent desktop machines forward after a signed desktop component activation. + /// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the + /// last-good snapshot, reboot qualification, and automatic rollback transaction. + func updateManagedDesktops(affectedBy components: Set) async throws -> [DorydDesktopUpdateResult] { + let statuses = try await dorydClient.machineList() + let desktopStatuses = statuses.filter { $0.displayMode == .desktop } + guard !desktopStatuses.isEmpty else { return [] } + + let runtimeChanged = components.contains(.linuxDesktop) + let store = try DoryComponentStore.selected() + guard let runtimeRelease = try store.installedComponent(.linuxDesktop) else { + throw DesktopMachineAssetError.missingAsset("runtime update") + } + _ = try store.verify(.linuxDesktop) + let home = environment["HOME"] ?? NSHomeDirectory() + var prepared: [DesktopMachineDistro: DesktopMachineAssets] = [:] + var results: [DorydDesktopUpdateResult] = [] + + for status in desktopStatuses { + guard status.environment["DORY_CUSTOM_LINUX"] != "1" else { continue } + let distro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"]) + guard runtimeChanged || components.contains(distro.componentID) else { continue } + guard let distroRelease = try store.installedComponent(distro.componentID) else { + if components.contains(distro.componentID) { + throw DesktopMachineAssetError.missingAsset(distro.displayName + " update") + } + // Removing a component intentionally preserves its machine disks. A runtime-only + // update cannot update that distro until the user reinstalls its signed payload. + continue + } + _ = try store.verify(distro.componentID) + let targetVersion = distroRelease.version + "+runtime." + runtimeRelease.version + if status.environment["DORY_DESKTOP_RELEASE_VERSION"] == targetVersion { + continue + } + guard let bundlePath = DoryComponentStore.activeAssetPath( + component: distro.componentID, + path: "dory-desktop-" + distro.rawValue + "-update-arm64.tar" + ) else { + throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update") + } + let assets: DesktopMachineAssets + if let cached = prepared[distro] { + assets = cached + } else { + assets = try await desktopMachineAssetPreparer( + home, + Self.desktopAssetEnvironment(processEnvironment: environment, distro: distro), + Bundle.main.resourcePath + ) + prepared[distro] = assets + } + + busyMachines.insert(status.id) + defer { busyMachines.remove(status.id) } + do { + let result = try await dorydClient.machineDesktopUpdate( + status.id, + distro: distro.rawValue, + version: targetVersion, + bundlePath: bundlePath, + kernelPath: assets.kernelPath + ) + results.append(result) + } catch { + throw DesktopMachineAssetError.filesystem( + "Could not update " + status.id + ". Dory restored its last-good snapshot. " + + String(describing: error) + ) + } + } + if !results.isEmpty { + _ = await refreshMachines() + } + return results + } + nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings { var copy = settings if copy.env.isEmpty { copy.env = existing.env } @@ -5445,23 +5547,31 @@ final class AppStore { ) -> DorydMachineConfiguration? { let useBundledAssets = environment["DORYD_DISABLE_BUNDLED_MACHINE_ASSETS"] != "1" let arch = hostMachineAssetArch - let kernel = assets?.kernelPath - ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment) - ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) - ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil) - let rootfs = assets?.rootfsPath - ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment) - ?? installedMachinePath([ - "dory-machine-rootfs-\(arch).ext4", - "dory-machine-rootfs.ext4", - ]) - ?? (useBundledAssets ? bundledMachinePath([ - "dory-machine-rootfs-\(arch).ext4", - "dory-machine-rootfs.ext4", - "initfs-\(arch).ext4", - ]) : nil) - guard let kernel, let rootfs else { - return nil + let kernel: String + let rootfs: String + if settings.bootMode == .efi { + kernel = "" + rootfs = "" + } else { + guard let resolvedKernel = assets?.kernelPath + ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment) + ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) + ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil), + let resolvedRootfs = assets?.rootfsPath + ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment) + ?? installedMachinePath([ + "dory-machine-rootfs-\(arch).ext4", + "dory-machine-rootfs.ext4", + ]) + ?? (useBundledAssets ? bundledMachinePath([ + "dory-machine-rootfs-\(arch).ext4", + "dory-machine-rootfs.ext4", + "initfs-\(arch).ext4", + ]) : nil) else { + return nil + } + kernel = resolvedKernel + rootfs = resolvedRootfs } let memoryMB: UInt64 if let rawMemory = environment["DORYD_MACHINE_MEMORY_MB"] { @@ -5481,6 +5591,11 @@ final class AppStore { id: name, kernelPath: kernel, rootfsPath: rootfs, + bootMode: settings.bootMode, + installerISOPath: settings.installerISOPath, + diskSizeBytes: settings.diskSizeGB.flatMap { UInt64(exactly: $0) }.map { + $0 * 1024 * 1024 * 1024 + }, memoryMB: memoryMB, cpuCount: cpuCount, address: address, @@ -5501,7 +5616,15 @@ final class AppStore { actionError = "Invalid machine name: use letters, digits, and _ . - (must start alphanumeric)" return "Invalid machine name" } - if settings.displayMode == .desktop { + if settings.bootMode == .efi { + guard settings.displayMode == .desktop, + let installerISOPath = settings.installerISOPath, + FileManager.default.fileExists(atPath: installerISOPath) else { + let message = "Choose a readable Linux installer ISO before creating the VM." + actionError = message + return message + } + } else if settings.displayMode == .desktop { let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) guard AppInfo.componentAvailable(.linuxDesktop), AppInfo.componentAvailable(distro.componentID) else { @@ -5534,6 +5657,7 @@ final class AppStore { } private func createDorydMachine(name: String, settings: MachineSettings, recipe: DevRecipe?) async -> String? { + var settings = settings let address = Self.trimmedNonEmpty(settings.address) let provisioningRecipe: String? if let recipe { @@ -5555,10 +5679,41 @@ final class AppStore { activeSheet = .creatingMachine defer { busyMachines.remove(name) } + var stagedInstallerISOPath: String? + defer { + if let stagedInstallerISOPath { + try? FileManager.default.removeItem(atPath: stagedInstallerISOPath) + } + } + var createdDefinition = false do { + if settings.bootMode == .efi, let installerISOPath = settings.installerISOPath { + let sourceURL = URL(fileURLWithPath: installerISOPath) + let hasSecurityScope = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { sourceURL.stopAccessingSecurityScopedResource() } + } + appendMachineCreationLog("Checking the selected installer's EFI architecture…") + let staged = try DoryInstallerISOStager.stage(atPath: installerISOPath) + if staged.architecture == .unknown { + appendMachineCreationLog("The EFI architecture is non-standard; continuing as a custom image.") + } else { + appendMachineCreationLog("\(staged.architecture.rawValue) EFI architecture confirmed.") + } + appendMachineCreationLog("Media SHA-256: \(staged.sha256)") + if case .unqualified = staged.runtimeQualification { + appendMachineCreationLog("This exact installer/host combination is not yet runtime-qualified by Dory.") + } + appendMachineCreationLog("Installer media is staged for the VM service.") + stagedInstallerISOPath = staged.path + settings.installerISOPath = staged.path + } let desktopAssets: DesktopMachineAssets? - if settings.displayMode == .desktop { + if settings.bootMode == .efi { + appendMachineCreationLog("Importing the installer ISO and creating a thin-provisioned virtual disk…") + desktopAssets = nil + } else if settings.displayMode == .desktop { let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) appendMachineCreationLog("Preparing \(distro.displayName) \(distro.version) Desktop in the selected Dory data drive…") let home = environment["HOME"] ?? NSHomeDirectory() @@ -5651,7 +5806,8 @@ final class AppStore { mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [], env: current?.environment ?? [:], address: current?.configuredAddress, - displayMode: current?.displayMode ?? machine.displayMode + displayMode: current?.displayMode ?? machine.displayMode, + bootMode: current?.bootMode ?? machine.bootMode ) let effectiveSettings = Self.preservingHiddenMachineSettings(settings, existing: currentSettings) let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 50c25759..0ea45800 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -299,6 +299,8 @@ struct Machine: Identifiable, Hashable, Sendable { var shellSocketPath: String = "" var processID: Int32? = nil var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerMediaAttached: Bool = false var mounts: [MountPair] = [] var id: String { name } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index e01b8886..0c7d4f3c 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -22,6 +22,7 @@ nonisolated protocol DorydControlXPC { func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshots(_ machineID: String, reply: @escaping (NSArray, String) -> Void) func machineCloneSnapshot(_ machineID: String, snapshotID: String, newID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -86,6 +87,9 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { var id: String var kernelPath: String var rootfsPath: String + var bootMode: MachineBootMode = .linuxKernel + var installerISOPath: String? = nil + var diskSizeBytes: UInt64? = nil var memoryMB: UInt64 var cpuCount: Int var address: String? = nil @@ -98,6 +102,7 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { "id": id, "kernelPath": kernelPath, "rootfsPath": rootfsPath, + "bootMode": bootMode.rawValue, "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode.rawValue, @@ -105,6 +110,12 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { if let address { dictionary["address"] = address } + if let installerISOPath { + dictionary["installerISOPath"] = installerISOPath + } + if let diskSizeBytes { + dictionary["diskSizeBytes"] = diskSizeBytes + } if !shares.isEmpty { dictionary["shares"] = shares.map(\.xpcDictionary) } @@ -139,6 +150,8 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var currentBalloonTargetMB: UInt64? = nil var cpuCount: Int? var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerMediaAttached: Bool = false var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] } @@ -170,6 +183,17 @@ nonisolated struct DorydMachineProvisionResult: Sendable, Equatable { var verify: DorydMachineExecResult } +nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable { + var machineID: String + var distro: String + var version: String + var inputSHA256: String + var bundleSHA256: String + var snapshotID: String + var status: DorydMachineStatus + var restoredRunningState: Bool +} + nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var id: String var machineID: String @@ -634,7 +658,8 @@ nonisolated final class DorydClient: @unchecked Sendable { address: String? = nil, updatesAddress: Bool = false, shares: [DorydMachineShareConfiguration]? = nil, - environment: [String: String]? = nil + environment: [String: String]? = nil, + installerMediaAttached: Bool? = nil ) async throws -> DorydMachineStatus { var config: [String: Any] = [:] if let memoryMB { @@ -659,6 +684,9 @@ nonisolated final class DorydClient: @unchecked Sendable { ] as NSDictionary } } + if let installerMediaAttached { + config["installerMediaAttached"] = installerMediaAttached + } return try await withTimeout(atLeast: 120).statusCommand { proxy, reply in proxy.machineUpdate(machineID, config: config as NSDictionary, reply: reply) } decode: { @@ -710,6 +738,26 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineDesktopUpdate( + _ machineID: String, + distro: String, + version: String, + bundlePath: String, + kernelPath: String + ) async throws -> DorydDesktopUpdateResult { + let request: NSDictionary = [ + "distro": distro, + "version": version, + "bundlePath": bundlePath, + "kernelPath": kernelPath, + ] + return try await withTimeout(atLeast: 3_900).statusCommand { proxy, reply in + proxy.machineDesktopUpdate(machineID, request: request, reply: reply) + } decode: { + Self.desktopUpdateResult(from: $0) + } + } + func machineSnapshot( _ machineID: String, note: String = "", @@ -1160,6 +1208,10 @@ nonisolated final class DorydClient: @unchecked Sendable { currentBalloonTargetMB: uint64(dictionary["currentBalloonTargetMB"]), cpuCount: int(dictionary["cpuCount"]), displayMode: (dictionary["displayMode"] as? String).flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless, + bootMode: (dictionary["bootMode"] as? String).flatMap(MachineBootMode.init(rawValue:)) ?? .linuxKernel, + installerMediaAttached: (dictionary["installerMediaAttached"] as? Bool) + ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue + ?? false, shares: machineShares(from: dictionary["shares"]), environment: machineEnvironment(from: dictionary["env"]) ) @@ -1276,6 +1328,30 @@ nonisolated final class DorydClient: @unchecked Sendable { return DorydMachineProvisionResult(recipeID: recipeID, install: install, verify: verify) } + nonisolated private static func desktopUpdateResult(from dictionary: NSDictionary) -> DorydDesktopUpdateResult? { + guard let machineID = dictionary["machineID"] as? String, + let distro = dictionary["distro"] as? String, + let version = dictionary["version"] as? String, + let inputSHA256 = dictionary["inputSHA256"] as? String, + let bundleSHA256 = dictionary["bundleSHA256"] as? String, + let snapshotID = dictionary["snapshotID"] as? String, + let statusDictionary = dictionary["status"] as? NSDictionary, + let status = machineStatus(from: statusDictionary), + let restoredRunningState = dictionary["restoredRunningState"] as? Bool else { + return nil + } + return DorydDesktopUpdateResult( + machineID: machineID, + distro: distro, + version: version, + inputSHA256: inputSHA256, + bundleSHA256: bundleSHA256, + snapshotID: snapshotID, + status: status, + restoredRunningState: restoredRunningState + ) + } + nonisolated private static func machineSnapshot(from dictionary: NSDictionary) -> DorydMachineSnapshot? { guard let id = dictionary["id"] as? String, let machineID = dictionary["machineID"] as? String, diff --git a/Dory/Runtime/Machines/DesktopMachineAssets.swift b/Dory/Runtime/Machines/DesktopMachineAssets.swift index c6435087..a849760c 100644 --- a/Dory/Runtime/Machines/DesktopMachineAssets.swift +++ b/Dory/Runtime/Machines/DesktopMachineAssets.swift @@ -25,12 +25,17 @@ nonisolated enum DesktopMachineDistro: String, CaseIterable, Identifiable, Senda } } - var desktopName: String { "Xfce" } + var desktopName: String { + switch self { + case .ubuntu: "GNOME" + case .debian, .kali: "Xfce" + } + } var summary: String { switch self { case .debian: "Stable, clean desktop for everyday Linux and development" - case .ubuntu: "Familiar Ubuntu base with long-term support packages" + case .ubuntu: "Canonical's Ubuntu GNOME desktop with long-term support" case .kali: "Security lab desktop with Kali's official rolling repository" } } diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index 20b5068c..c09cb253 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -6,6 +6,10 @@ nonisolated enum MachineDisplayMode: String, Sendable, Hashable, CaseIterable { case headless case desktop } +nonisolated enum MachineBootMode: String, Sendable, Hashable, CaseIterable { + case linuxKernel = "linux-kernel" + case efi +} nonisolated struct MachineSettings: Sendable, Hashable { var cpus: Int? var memoryMB: Int? @@ -15,6 +19,9 @@ nonisolated struct MachineSettings: Sendable, Hashable { var env: [String: String] = [:] var address: String? = nil var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerISOPath: String? = nil + var diskSizeGB: Int? = nil nonisolated static let `default` = MachineSettings(cpus: nil, memoryMB: nil) } diff --git a/Dory/Runtime/Shared/SharedVMProvisioner.swift b/Dory/Runtime/Shared/SharedVMProvisioner.swift index 7585da69..add0516f 100644 --- a/Dory/Runtime/Shared/SharedVMProvisioner.swift +++ b/Dory/Runtime/Shared/SharedVMProvisioner.swift @@ -283,13 +283,16 @@ nonisolated enum SharedVMProvisioner { guard icdCandidates.contains(where: { fileManager.fileExists(atPath: $0) }) else { return false } // The Venus path exposes host-visible blobs by hv_vm_mapping the pointer virglrenderer returns // from virgl_renderer_resource_map (the libkrun/krunkit model), so probe the renderer actually - // exports a blob-map entrypoint before enabling the toggle. + // exports a blob-map entrypoint and Dory's async macOS fence fix before enabling the + // toggle. A stock or older renderer can appear usable but stall Vulkan applications. for path in rendererCandidates where fileManager.fileExists(atPath: path) { guard let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) else { continue } let hasBlobMap = dlsym(handle, "virgl_renderer_resource_map") != nil || dlsym(handle, "virgl_renderer_resource_get_map_ptr") != nil + let hasDoryFenceFix = dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") != nil + let hasDoryMoltenVKFix = dlsym(handle, "dory_moltenvk_spirv_native_array_fix") != nil dlclose(handle) - if hasBlobMap { return true } + if hasBlobMap && hasDoryFenceFix && hasDoryMoltenVKFix { return true } } return false } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 0e2b5b18..cdb4c8ed 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -2160,6 +2160,26 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] as NSDictionary, "") } + func machineDesktopUpdate( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped") + lock.unlock() + reply(true, [ + "machineID": machineID, + "distro": request["distro"] as? String ?? "ubuntu", + "version": request["version"] as? String ?? "test", + "inputSHA256": String(repeating: "1", count: 64), + "bundleSHA256": String(repeating: "2", count: 64), + "snapshotID": "du-test", + "restoredRunningState": false, + "status": current, + ] as NSDictionary, "") + } + func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let id = request["snapshotID"] as? String ?? "s\(UUID().uuidString.prefix(8).lowercased())" let row = Self.snapshotRow( diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 4905a442..6ca32090 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -1,8 +1,37 @@ import Darwin +import DoryOperations import Testing @testable import Dory struct NewMachineSettingsTests { + @Test func desktopDefaultsScaleForBrowserWorkloadsWithoutConsumingTheHost() { + let eightGB = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 8, + physicalMemory: 8 * 1_073_741_824 + ) + #expect(eightGB.cpus == 4) + #expect(eightGB.memoryGB == 4) + + let sixteenGB = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 12, + physicalMemory: 16 * 1_073_741_824 + ) + #expect(sixteenGB.cpus == 6) + #expect(sixteenGB.memoryGB == 6) + + let largerHost = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 32, + physicalMemory: 64 * 1_073_741_824 + ) + #expect(largerHost.cpus == 8) + #expect(largerHost.memoryGB == 8) + } + + @Test func customISOInstallationUsesABalancedResourceDefault() { + #expect(DoryInstallerMachinePolicy.defaultCPUCount == 4) + #expect(DoryInstallerMachinePolicy.defaultMemoryMB == 4_096) + } + @Test func collectsResourcesRegardlessOfDisclosure() { let s = NewMachineSheet.buildSettings(cpus: 4, memoryGB: 8, mounts: [MountPair(host: "/Users/u/p", guest: "/Users/u/p")], @@ -16,6 +45,9 @@ struct NewMachineSettingsTests { #expect(s.env["DORY_GUEST_UID"] == String(getuid())) #expect(s.env["DORY_DESKTOP_DISTRO"] == "debian") #expect(s.env["DORY_DESKTOP_VERSION"] == "13") + #expect(s.env[DoryDesktopClipboardPolicy.environmentKey] == "bidirectional") + #expect(s.env[DoryDesktopVMMPreference.environmentKey] == "auto") + #expect(s.env[DoryDesktopGraphicsPreference.environmentKey] == "auto") #expect(s.ports.isEmpty) } @@ -38,6 +70,23 @@ struct NewMachineSettingsTests { #expect(settings.env["DORY_GUEST_UID"] == "1001") } + @Test func recordsUbuntuAsTheCanonicalGnomeDesktop() { + let settings = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 8, + mounts: [], + displayMode: .desktop, + desktopDistro: .ubuntu, + guestUsername: "developer", + guestUID: 1_002 + ) + + #expect(settings.env["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(settings.env["DORY_DESKTOP_NAME"] == "Ubuntu") + #expect(settings.env["DORY_DESKTOP_VERSION"] == "24.04 LTS") + #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "GNOME") + } + @Test func headlessServersDoNotCarryDesktopMetadata() { let settings = NewMachineSheet.buildSettings( cpus: 2, @@ -48,5 +97,8 @@ struct NewMachineSettingsTests { #expect(settings.env["DORY_DESKTOP_DISTRO"] == nil) #expect(settings.env["DORY_GUEST_USER"] == nil) + #expect(settings.env[DoryDesktopClipboardPolicy.environmentKey] == nil) + #expect(settings.env[DoryDesktopVMMPreference.environmentKey] == nil) + #expect(settings.env[DoryDesktopGraphicsPreference.environmentKey] == nil) } } diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift new file mode 100644 index 00000000..8cbdfdda --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift @@ -0,0 +1,301 @@ +import CryptoKit +import Darwin +import Foundation + +public struct DoryInstalledLinuxBootDescriptor: Sendable, Equatable { + public let rootDevice: String + public let kernelLength: UInt64 + public let initrdLength: UInt64 + + public init(rootDevice: String, kernelLength: UInt64, initrdLength: UInt64) { + self.rootDevice = rootDevice + self.kernelLength = kernelLength + self.initrdLength = initrdLength + } +} + +/// A single portable artifact containing the direct-boot kernel, initrd, and root-device contract +/// for an EFI-installed Linux disk. EFI snapshots already preserve the machine's opaque `kernel` +/// artifact, so using one verified bundle also makes the accelerated runtime survive clone, +/// snapshot, export, import, and restore without inventing a parallel set of lifecycle rules. +public enum DoryInstalledLinuxBootBundle { + private static let magic = Data("DORYLINUXBOOT1\n".utf8) + private static let fixedHeaderBytes = 4 + 8 + 8 + 32 + 32 + private static let maximumRootDeviceBytes = 128 + private static let maximumKernelBytes: UInt64 = 256 * 1024 * 1024 + private static let maximumInitrdBytes: UInt64 = 512 * 1024 * 1024 + private static let copyChunkBytes = 4 * 1024 * 1024 + + public static func isBundle(atPath path: String) -> Bool { + guard let handle = try? openForReading(path), + let prefix = try? handle.read(upToCount: magic.count) else { + return false + } + try? handle.close() + return prefix == magic + } + + public static func write( + assets: DoryLinuxInstallerBootAssets, + rootDevice: String, + toPath path: String + ) throws { + let rootBytes = Data(rootDevice.utf8) + guard isValidRootDevice(rootDevice), + !rootBytes.isEmpty, + rootBytes.count <= maximumRootDeviceBytes else { + throw DoryInstalledLinuxBootBundleError.invalidRootDevice(rootDevice) + } + guard !assets.kernel.isEmpty, + UInt64(assets.kernel.count) <= maximumKernelBytes else { + throw DoryInstalledLinuxBootBundleError.invalidKernelSize(UInt64(assets.kernel.count)) + } + guard !assets.initrd.isEmpty, + UInt64(assets.initrd.count) <= maximumInitrdBytes else { + throw DoryInstalledLinuxBootBundleError.invalidInitrdSize(UInt64(assets.initrd.count)) + } + + let destination = URL(fileURLWithPath: path) + let parent = destination.deletingLastPathComponent() + let temporary = parent.appendingPathComponent(".\(destination.lastPathComponent).boot-\(UUID().uuidString)") + guard FileManager.default.createFile( + atPath: temporary.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + throw DoryInstalledLinuxBootBundleError.write(path, errno) + } + do { + let output = try FileHandle(forWritingTo: temporary) + try output.write(contentsOf: magic) + try output.write(contentsOf: bigEndian(UInt32(rootBytes.count))) + try output.write(contentsOf: bigEndian(UInt64(assets.kernel.count))) + try output.write(contentsOf: bigEndian(UInt64(assets.initrd.count))) + try output.write(contentsOf: Data(SHA256.hash(data: assets.kernel))) + try output.write(contentsOf: Data(SHA256.hash(data: assets.initrd))) + try output.write(contentsOf: rootBytes) + try output.write(contentsOf: assets.kernel) + try output.write(contentsOf: assets.initrd) + try output.synchronize() + try output.close() + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + guard rename(temporary.path, destination.path) == 0 else { + throw DoryInstalledLinuxBootBundleError.write(path, errno) + } + } catch { + try? FileManager.default.removeItem(at: temporary) + throw error + } + } + + public static func descriptor(atPath path: String) throws -> DoryInstalledLinuxBootDescriptor { + let input = try openForReading(path) + defer { try? input.close() } + return try readHeader(from: input).descriptor + } + + @discardableResult + public static func materialize( + fromPath path: String, + kernelPath: String, + initrdPath: String + ) throws -> DoryInstalledLinuxBootDescriptor { + let input = try openForReading(path) + defer { try? input.close() } + let header = try readHeader(from: input) + let destinations = [URL(fileURLWithPath: kernelPath), URL(fileURLWithPath: initrdPath)] + let parent = destinations[0].deletingLastPathComponent() + guard destinations[1].deletingLastPathComponent() == parent else { + throw DoryInstalledLinuxBootBundleError.invalidDestination + } + let token = UUID().uuidString + let temporaries = destinations.map { + parent.appendingPathComponent(".\($0.lastPathComponent).boot-\(token)") + } + for temporary in temporaries { + guard FileManager.default.createFile( + atPath: temporary.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + temporaries.forEach { try? FileManager.default.removeItem(at: $0) } + throw DoryInstalledLinuxBootBundleError.write(temporary.path, errno) + } + } + do { + let kernelOutput = try FileHandle(forWritingTo: temporaries[0]) + let initrdOutput = try FileHandle(forWritingTo: temporaries[1]) + defer { + try? kernelOutput.close() + try? initrdOutput.close() + } + try input.seek(toOffset: header.kernelOffset) + let kernelDigest = try copyExactly( + from: input, + to: kernelOutput, + byteCount: header.descriptor.kernelLength + ) + let initrdDigest = try copyExactly( + from: input, + to: initrdOutput, + byteCount: header.descriptor.initrdLength + ) + guard kernelDigest == header.kernelDigest, + initrdDigest == header.initrdDigest else { + throw DoryInstalledLinuxBootBundleError.digestMismatch + } + try kernelOutput.synchronize() + try initrdOutput.synchronize() + try kernelOutput.close() + try initrdOutput.close() + for temporary in temporaries { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + } + for (temporary, destination) in zip(temporaries, destinations) { + guard rename(temporary.path, destination.path) == 0 else { + throw DoryInstalledLinuxBootBundleError.write(destination.path, errno) + } + } + return header.descriptor + } catch { + temporaries.forEach { try? FileManager.default.removeItem(at: $0) } + throw error + } + } + + private struct Header { + var descriptor: DoryInstalledLinuxBootDescriptor + var kernelOffset: UInt64 + var kernelDigest: Data + var initrdDigest: Data + } + + private static func readHeader(from input: FileHandle) throws -> Header { + try input.seek(toOffset: 0) + guard try readExactly(from: input, count: magic.count) == magic else { + throw DoryInstalledLinuxBootBundleError.invalidMagic + } + let rootLength = Int(decodeUInt32(try readExactly(from: input, count: 4))) + let kernelLength = decodeUInt64(try readExactly(from: input, count: 8)) + let initrdLength = decodeUInt64(try readExactly(from: input, count: 8)) + let kernelDigest = try readExactly(from: input, count: 32) + let initrdDigest = try readExactly(from: input, count: 32) + guard rootLength > 0, rootLength <= maximumRootDeviceBytes, + kernelLength > 0, kernelLength <= maximumKernelBytes, + initrdLength > 0, initrdLength <= maximumInitrdBytes else { + throw DoryInstalledLinuxBootBundleError.invalidHeader + } + let rootData = try readExactly(from: input, count: rootLength) + guard let rootDevice = String(data: rootData, encoding: .utf8), + isValidRootDevice(rootDevice) else { + throw DoryInstalledLinuxBootBundleError.invalidHeader + } + let kernelOffset = UInt64(magic.count + fixedHeaderBytes + rootLength) + let (afterKernel, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength) + let (expectedLength, initrdOverflow) = afterKernel.addingReportingOverflow(initrdLength) + guard !kernelOverflow, !initrdOverflow, + try input.seekToEnd() == expectedLength else { + throw DoryInstalledLinuxBootBundleError.invalidHeader + } + return Header( + descriptor: DoryInstalledLinuxBootDescriptor( + rootDevice: rootDevice, + kernelLength: kernelLength, + initrdLength: initrdLength + ), + kernelOffset: kernelOffset, + kernelDigest: kernelDigest, + initrdDigest: initrdDigest + ) + } + + private static func isValidRootDevice(_ value: String) -> Bool { + value.wholeMatch(of: /\/dev\/vd[a-z][1-9][0-9]*/) != nil + } + + private static func openForReading(_ path: String) throws -> FileHandle { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryInstalledLinuxBootBundleError.open(path, errno) + } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_size > 0 else { + close(descriptor) + throw DoryInstalledLinuxBootBundleError.invalidHeader + } + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + } + + private static func copyExactly( + from input: FileHandle, + to output: FileHandle, + byteCount: UInt64 + ) throws -> Data { + var remaining = byteCount + var hasher = SHA256() + while remaining > 0 { + let count = Int(min(UInt64(copyChunkBytes), remaining)) + let data = try readExactly(from: input, count: count) + try output.write(contentsOf: data) + hasher.update(data: data) + remaining -= UInt64(data.count) + } + return Data(hasher.finalize()) + } + + private static func readExactly(from input: FileHandle, count: Int) throws -> Data { + var result = Data() + while result.count < count { + let chunk = try input.read(upToCount: count - result.count) ?? Data() + guard !chunk.isEmpty else { + throw DoryInstalledLinuxBootBundleError.invalidHeader + } + result.append(chunk) + } + return result + } + + private static func bigEndian(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + private static func bigEndian(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + private static func decodeUInt32(_ data: Data) -> UInt32 { + data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + private static func decodeUInt64(_ data: Data) -> UInt64 { + data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } +} + +public enum DoryInstalledLinuxBootBundleError: Error, LocalizedError, Sendable, Equatable { + case invalidRootDevice(String) + case invalidKernelSize(UInt64) + case invalidInitrdSize(UInt64) + case invalidMagic + case invalidHeader + case digestMismatch + case invalidDestination + case open(String, Int32) + case write(String, Int32) + + public var errorDescription: String? { + switch self { + case let .invalidRootDevice(device): "Invalid installed Linux root device: \(device)" + case let .invalidKernelSize(size): "Invalid installed Linux kernel size: \(size) bytes" + case let .invalidInitrdSize(size): "Invalid installed Linux initrd size: \(size) bytes" + case .invalidMagic: "Not a Dory installed-Linux boot bundle" + case .invalidHeader: "Installed-Linux boot bundle has an invalid or truncated header" + case .digestMismatch: "Installed-Linux boot bundle failed kernel/initrd verification" + case .invalidDestination: "Installed-Linux boot destinations must share one directory" + case let .open(path, code): "Could not open installed-Linux boot bundle \(path): \(String(cString: strerror(code)))" + case let .write(path, code): "Could not write installed-Linux boot artifact \(path): \(String(cString: strerror(code)))" + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift new file mode 100644 index 00000000..1ae95072 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift @@ -0,0 +1,1100 @@ +import Darwin +import CryptoKit +import Foundation +import zlib + +public enum DoryInstallerISOArchitecture: String, Sendable, Equatable { + case arm64 + case x86_64 + case multiArchitecture = "multi-architecture" + case unknown +} + +public enum DoryInstallerISOCompatibility: Sendable, Equatable { + case compatible + case unknown + case incompatible(String) +} + +public struct DoryInstallerISOMediaIdentity: Sendable, Equatable { + public let architecture: DoryInstallerISOArchitecture + public let sha256: String + public let byteCount: UInt64 + + public init( + architecture: DoryInstallerISOArchitecture, + sha256: String, + byteCount: UInt64 + ) { + self.architecture = architecture + self.sha256 = sha256.lowercased() + self.byteCount = byteCount + } +} + +public enum DoryInstallerISORuntimeQualification: Sendable, Equatable { + case qualified(String) + case unqualified + case knownUnstable(String) +} + +/// A qualification key for the parts of Dory's EFI device model that materially affect a guest. +/// Changing this value deliberately invalidates evidence gathered with an older virtual storage +/// controller instead of carrying a failure (or a pass) across different virtual hardware. +public enum DoryInstallerRuntimeProfile: String, Sendable, Equatable { + case legacyVirtioBlockV1 = "vz-efi-virtio-blk-v1" + case nativeNVMeFsyncV1 = "vz-efi-nvme-fsync-v1" + + public static let current = DoryInstallerRuntimeProfile.nativeNVMeFsyncV1 +} + +public struct DoryInstallerHostRuntime: Sendable { + public let architecture: String + public let hardwareModel: String + public let operatingSystemVersion: OperatingSystemVersion + public let operatingSystemBuild: String + public let runtimeProfile: String + + public init( + architecture: String, + hardwareModel: String, + operatingSystemVersion: OperatingSystemVersion, + operatingSystemBuild: String, + runtimeProfile: String = DoryInstallerRuntimeProfile.current.rawValue + ) { + self.architecture = architecture + self.hardwareModel = hardwareModel + self.operatingSystemVersion = operatingSystemVersion + self.operatingSystemBuild = operatingSystemBuild + self.runtimeProfile = runtimeProfile + } + + public static var current: DoryInstallerHostRuntime { + DoryInstallerHostRuntime( + architecture: DoryInstallerISOInspector.currentHostArchitecture, + hardwareModel: systemString("hw.model"), + operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersion, + operatingSystemBuild: systemString("kern.osversion"), + runtimeProfile: DoryInstallerRuntimeProfile.current.rawValue + ) + } + + private static func systemString(_ name: String) -> String { + var byteCount = 0 + guard sysctlbyname(name, nil, &byteCount, nil, 0) == 0, byteCount > 1 else { + return "unknown" + } + var bytes = [CChar](repeating: 0, count: byteCount) + guard sysctlbyname(name, &bytes, &byteCount, nil, 0) == 0 else { + return "unknown" + } + return String( + decoding: bytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, + as: UTF8.self + ) + } +} + +/// Runtime evidence is deliberately separate from EFI architecture compatibility. +/// +/// A matching loader proves that the host can enter the installer; it does not prove that the +/// distribution kernel is stable on a particular Virtualization.framework and Mac combination. +public enum DoryInstallerISORuntimeCatalog { + public static let ubuntu24044DesktopARM64SHA256 = + "c2610520bf582976839a1724c669e1cfed0547427be5a0ad12d457b92b46ffbe" + public static let ubuntu24043DesktopARM64SHA256 = + "cdbf0f83ab4f7d46be767e73c59b5cbca9743dd5fb887142c96f4b2df38fa5ad" + + public static func qualification( + of identity: DoryInstallerISOMediaIdentity, + on host: DoryInstallerHostRuntime = .current + ) -> DoryInstallerISORuntimeQualification { + if [ubuntu24044DesktopARM64SHA256, ubuntu24043DesktopARM64SHA256] + .contains(identity.sha256), + host.architecture.lowercased() == "arm64", + host.hardwareModel == "Mac14,10", + host.operatingSystemBuild == "26A5406e", + host.runtimeProfile == DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue { + return .knownUnstable( + "This Ubuntu 24.04 Desktop ARM64 media is known to panic or freeze with Dory's retired VirtIO-block EFI profile on this Mac and macOS build. Recreate the machine with Dory's current native-NVMe EFI profile." + ) + } + return .unqualified + } +} + +/// Balanced defaults for distribution-owned EFI installers. These are resource choices, not a +/// compatibility workaround; runtime qualification is based on the exact media and host evidence. +public enum DoryInstallerMachinePolicy { + public static let defaultCPUCount = 4 + public static let defaultMemoryMB: UInt64 = 4_096 +} + +/// Fast, mount-free architecture preflight for user-selected Linux installer media. +/// +/// Dory reads ISO9660 directory records plus bounded EFI boot regions. It never mounts the image, +/// does not execute anything from it, and does not copy a multi-gigabyte image before deciding +/// whether its EFI loader can run on this Mac. +public enum DoryInstallerISOInspector { + private static let logicalBlockSize: Int64 = 2_048 + private static let scanChunkSize = 1024 * 1024 + private static let maximumDirectoryBytes = 64 * 1024 * 1024 + private static let maximumSingleDirectoryBytes = 16 * 1024 * 1024 + private static let maximumBootRegionBytes: Int64 = 128 * 1024 * 1024 + private static let edgeScanBytes: Int64 = 8 * 1024 * 1024 + private static let trailingScanBytes: Int64 = 64 * 1024 * 1024 + + private static let arm64Markers = markerVariants(["BOOTAA64.EFI", "GRUBAA64.EFI"]) + private static let x86Markers = markerVariants(["BOOTX64.EFI", "GRUBX64.EFI"]) + private static let maximumMarkerBytes = + (arm64Markers + x86Markers).map(\.count).max() ?? 1 + + public static func architecture(atPath path: String) throws -> DoryInstallerISOArchitecture { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryInstallerISOInspectionError.open(path, errno) + } + defer { close(descriptor) } + + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size > 0 else { + throw DoryInstallerISOInspectionError.notRegularFile(path) + } + + return try architecture(descriptor: descriptor, size: Int64(info.st_size), path: path) + } + + public static func mediaIdentity(atPath path: String) throws -> DoryInstallerISOMediaIdentity { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryInstallerISOInspectionError.open(path, errno) + } + defer { close(descriptor) } + + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size > 0 else { + throw DoryInstallerISOInspectionError.notRegularFile(path) + } + + return try mediaIdentity( + descriptor: descriptor, + size: Int64(info.st_size), + path: path + ) + } + + /// Reads one bounded regular file from the ISO9660 namespace without mounting or executing + /// the image. Paths are matched case-insensitively and ISO9660 version suffixes (`;1`) are + /// ignored, which covers the layout used by mainstream Linux installer media. + public static func fileData( + atISOPath isoPath: String, + fromPath path: String, + maximumBytes: Int + ) throws -> Data { + guard maximumBytes > 0 else { + throw DoryInstallerISOInspectionError.fileTooLarge(path, 0) + } + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard !components.isEmpty, + components.allSatisfy({ $0 != "." && $0 != ".." && !$0.contains("\0") }) else { + throw DoryInstallerISOInspectionError.invalidISOPath(path) + } + + let descriptor = open(isoPath, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryInstallerISOInspectionError.open(isoPath, errno) + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size > 0 else { + throw DoryInstallerISOInspectionError.notRegularFile(isoPath) + } + + let imageSize = Int64(info.st_size) + let metadata = try inspectISO9660(descriptor: descriptor, size: imageSize, path: isoPath) + guard var current = metadata.rootDirectory else { + throw DoryInstallerISOInspectionError.fileNotFound(path) + } + for (index, component) in components.enumerated() { + let entry = try directoryEntry( + named: component, + in: current, + descriptor: descriptor, + imageSize: imageSize, + path: isoPath + ) + guard let entry else { + throw DoryInstallerISOInspectionError.fileNotFound(path) + } + let isLast = index == components.count - 1 + if isLast { + guard !entry.isDirectory else { + throw DoryInstallerISOInspectionError.fileNotFound(path) + } + guard entry.range.length <= Int64(maximumBytes) else { + throw DoryInstallerISOInspectionError.fileTooLarge(path, entry.range.length) + } + return try read( + descriptor: descriptor, + offset: entry.range.offset, + count: Int(entry.range.length), + path: isoPath + ) + } + guard entry.isDirectory else { + throw DoryInstallerISOInspectionError.fileNotFound(path) + } + current = entry.range + } + throw DoryInstallerISOInspectionError.fileNotFound(path) + } + + fileprivate static func mediaIdentity( + descriptor: Int32, + size: Int64, + path: String + ) throws -> DoryInstallerISOMediaIdentity { + let architecture = try architecture(descriptor: descriptor, size: size, path: path) + var hasher = SHA256() + var offset: Int64 = 0 + while offset < size { + let byteCount = Int(min(Int64(scanChunkSize), size - offset)) + let data = try read( + descriptor: descriptor, + offset: offset, + count: byteCount, + path: path + ) + guard !data.isEmpty else { + throw DoryInstallerISOInspectionError.read(path, EIO) + } + hasher.update(data: data) + offset += Int64(data.count) + } + let sha256 = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return DoryInstallerISOMediaIdentity( + architecture: architecture, + sha256: sha256, + byteCount: UInt64(size) + ) + } + + fileprivate static func architecture( + descriptor: Int32, + size: Int64, + path: String + ) throws -> DoryInstallerISOArchitecture { + var markers = MarkerState() + let metadata = try inspectISO9660(descriptor: descriptor, size: size, path: path) + markers.merge(metadata.markers) + + var scannedRanges = Set() + let partitionRanges = try mbrEFIPartitionRanges( + descriptor: descriptor, + size: size, + path: path + ) + for range in metadata.bootImageRanges + partitionRanges { + try scan( + descriptor: descriptor, + size: size, + range: range, + path: path, + markers: &markers, + scannedRanges: &scannedRanges + ) + if markers.architecture == .multiArchitecture { return .multiArchitecture } + } + + try scan( + descriptor: descriptor, + size: size, + range: ByteRange(offset: 0, length: min(size, edgeScanBytes)), + path: path, + markers: &markers, + scannedRanges: &scannedRanges + ) + if markers.architecture == .multiArchitecture { return .multiArchitecture } + + let trailingLength = min(size, trailingScanBytes) + try scan( + descriptor: descriptor, + size: size, + range: ByteRange(offset: size - trailingLength, length: trailingLength), + path: path, + markers: &markers, + scannedRanges: &scannedRanges + ) + return markers.architecture + } + + public static func compatibility( + of architecture: DoryInstallerISOArchitecture, + hostArchitecture: String + ) -> DoryInstallerISOCompatibility { + switch (normalizedHostArchitecture(hostArchitecture), architecture) { + case ("arm64", .x86_64): + .incompatible("This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO.") + case ("x86_64", .arm64): + .incompatible("This ISO is arm64-only. This Intel Mac requires an x86_64 EFI ISO.") + case (_, .unknown): + .unknown + default: + .compatible + } + } + + public static var currentHostArchitecture: String { + #if arch(arm64) + "arm64" + #elseif arch(x86_64) + "x86_64" + #else + "unknown" + #endif + } + + private static func normalizedHostArchitecture(_ architecture: String) -> String { + switch architecture.lowercased() { + case "aarch64", "arm64": "arm64" + case "amd64", "x86_64": "x86_64" + default: architecture.lowercased() + } + } + + private struct ISO9660Inspection { + var markers = MarkerState() + var bootImageRanges: [ByteRange] = [] + var rootDirectory: ByteRange? + } + + private static func inspectISO9660( + descriptor: Int32, + size: Int64, + path: String + ) throws -> ISO9660Inspection { + var result = ISO9660Inspection() + var rootDirectory: ByteRange? + var bootCatalogLBAs: [UInt32] = [] + + for index in 16..<272 { + let offset = Int64(index) * logicalBlockSize + guard offset + logicalBlockSize <= size else { break } + let block = try read( + descriptor: descriptor, + offset: offset, + count: Int(logicalBlockSize), + path: path + ) + guard block.count == Int(logicalBlockSize), + block[1..<6].elementsEqual(Data("CD001".utf8)) else { + if index == 16 { return result } + break + } + + switch block[0] { + case 0: + if block.count >= 75, + String(decoding: block[7..<39], as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + .hasPrefix("EL TORITO SPECIFICATION") { + bootCatalogLBAs.append(littleEndianUInt32(block, at: 71)) + } + case 1: + if let directory = directoryRange(from: block, recordOffset: 156, imageSize: size) { + rootDirectory = directory + result.rootDirectory = directory + } + case 255: + break + default: + continue + } + if block[0] == 255 { break } + } + + if let rootDirectory { + try inspectDirectories( + descriptor: descriptor, + imageSize: size, + root: rootDirectory, + path: path, + markers: &result.markers + ) + } + + for catalogLBA in bootCatalogLBAs { + result.bootImageRanges.append(contentsOf: try elToritoBootImageRanges( + descriptor: descriptor, + imageSize: size, + catalogLBA: catalogLBA, + path: path + )) + } + return result + } + + private struct ISO9660DirectoryEntry { + var range: ByteRange + var isDirectory: Bool + } + + private static func directoryEntry( + named requestedName: String, + in directory: ByteRange, + descriptor: Int32, + imageSize: Int64, + path: String + ) throws -> ISO9660DirectoryEntry? { + guard directory.length > 0, + directory.length <= Int64(maximumSingleDirectoryBytes), + directory.offset >= 0, + directory.offset + directory.length <= imageSize else { + return nil + } + let data = try read( + descriptor: descriptor, + offset: directory.offset, + count: Int(directory.length), + path: path + ) + var position = 0 + while position < data.count { + let recordLength = Int(data[position]) + if recordLength == 0 { + position = ((position / Int(logicalBlockSize)) + 1) * Int(logicalBlockSize) + continue + } + guard recordLength >= 34, position + recordLength <= data.count else { break } + let identifierLength = Int(data[position + 32]) + guard position + 33 + identifierLength <= position + recordLength else { + position += recordLength + continue + } + let identifier = data[(position + 33)..<(position + 33 + identifierLength)] + let isDotEntry = identifierLength == 1 && (identifier.first == 0 || identifier.first == 1) + if !isDotEntry { + let rawName = String(decoding: identifier, as: UTF8.self) + var isoName = rawName.split(separator: ";", maxSplits: 1).first.map(String.init) ?? rawName + // ISO9660 encodes a filename with an empty extension as `NAME.;1`. + if isoName.last == "." { isoName.removeLast() } + if isoName.caseInsensitiveCompare(requestedName) == .orderedSame { + guard data[position + 25] & 0x80 == 0, + let range = directoryRange( + from: data, + recordOffset: position, + imageSize: imageSize + ) else { + return nil + } + return ISO9660DirectoryEntry( + range: range, + isDirectory: data[position + 25] & 0x02 != 0 + ) + } + } + position += recordLength + } + return nil + } + + private static func inspectDirectories( + descriptor: Int32, + imageSize: Int64, + root: ByteRange, + path: String, + markers: inout MarkerState + ) throws { + var queue = [root] + var visited = Set() + var totalBytes = 0 + + while !queue.isEmpty, visited.count < 4_096, totalBytes < maximumDirectoryBytes { + let directory = queue.removeFirst() + guard visited.insert(directory).inserted, + directory.length > 0, + directory.length <= Int64(maximumSingleDirectoryBytes), + directory.offset >= 0, + directory.offset + directory.length <= imageSize else { + continue + } + totalBytes += Int(directory.length) + let data = try read( + descriptor: descriptor, + offset: directory.offset, + count: Int(directory.length), + path: path + ) + markers.observe(data) + + var position = 0 + while position < data.count { + let recordLength = Int(data[position]) + if recordLength == 0 { + position = ((position / Int(logicalBlockSize)) + 1) * Int(logicalBlockSize) + continue + } + guard recordLength >= 34, position + recordLength <= data.count else { break } + let identifierLength = Int(data[position + 32]) + guard position + 33 + identifierLength <= position + recordLength else { + position += recordLength + continue + } + let identifier = data[(position + 33)..<(position + 33 + identifierLength)] + let isDotEntry = identifierLength == 1 && (identifier.first == 0 || identifier.first == 1) + if !isDotEntry { + markers.observe(Data(identifier)) + let flags = data[position + 25] + if flags & 0x02 != 0, + let child = directoryRange( + from: data, + recordOffset: position, + imageSize: imageSize + ) { + queue.append(child) + } + } + position += recordLength + } + } + } + + private static func directoryRange( + from data: Data, + recordOffset: Int, + imageSize: Int64 + ) -> ByteRange? { + guard recordOffset >= 0, recordOffset + 18 <= data.count else { return nil } + let extent = Int64(littleEndianUInt32(data, at: recordOffset + 2)) * logicalBlockSize + let length = Int64(littleEndianUInt32(data, at: recordOffset + 10)) + guard extent >= 0, length > 0, extent <= imageSize, length <= imageSize - extent else { + return nil + } + return ByteRange(offset: extent, length: length) + } + + private static func elToritoBootImageRanges( + descriptor: Int32, + imageSize: Int64, + catalogLBA: UInt32, + path: String + ) throws -> [ByteRange] { + let catalogOffset = Int64(catalogLBA) * logicalBlockSize + guard catalogOffset >= 0, catalogOffset < imageSize else { return [] } + let catalog = try read( + descriptor: descriptor, + offset: catalogOffset, + count: Int(min(Int64(64 * 1024), imageSize - catalogOffset)), + path: path + ) + var ranges: [ByteRange] = [] + var platform: UInt8 = catalog.count >= 2 ? catalog[1] : 0 + var offset = 32 + while offset + 32 <= catalog.count { + let indicator = catalog[offset] + if indicator == 0x90 || indicator == 0x91 { + platform = catalog[offset + 1] + } else if indicator == 0x88, platform == 0xEF { + let imageLBA = littleEndianUInt32(catalog, at: offset + 8) + let imageOffset = Int64(imageLBA) * logicalBlockSize + if imageOffset >= 0, imageOffset < imageSize { + ranges.append(ByteRange( + offset: imageOffset, + length: min(maximumBootRegionBytes, imageSize - imageOffset) + )) + } + } + if indicator == 0x91 { break } + offset += 32 + } + return ranges + } + + private static func mbrEFIPartitionRanges( + descriptor: Int32, + size: Int64, + path: String + ) throws -> [ByteRange] { + guard size >= 512 else { return [] } + let mbr = try read(descriptor: descriptor, offset: 0, count: 512, path: path) + guard mbr.count == 512, mbr[510] == 0x55, mbr[511] == 0xAA else { return [] } + return (0..<4).compactMap { index in + let entry = 446 + index * 16 + guard mbr[entry + 4] == 0xEF else { return nil } + let offset = Int64(littleEndianUInt32(mbr, at: entry + 8)) * 512 + let declaredLength = Int64(littleEndianUInt32(mbr, at: entry + 12)) * 512 + guard offset >= 0, offset < size, declaredLength > 0 else { return nil } + return ByteRange( + offset: offset, + length: min(maximumBootRegionBytes, min(declaredLength, size - offset)) + ) + } + } + + private static func scan( + descriptor: Int32, + size: Int64, + range: ByteRange, + path: String, + markers: inout MarkerState, + scannedRanges: inout Set + ) throws { + guard range.offset >= 0, range.length > 0, range.offset < size else { return } + let bounded = ByteRange( + offset: range.offset, + length: min(range.length, size - range.offset) + ) + guard scannedRanges.insert(bounded).inserted else { return } + + var offset = bounded.offset + let end = bounded.offset + bounded.length + var carry = Data() + while offset < end { + let count = Int(min(Int64(scanChunkSize), end - offset)) + let chunk = try read(descriptor: descriptor, offset: offset, count: count, path: path) + if chunk.isEmpty { break } + var searchable = carry + searchable.append(chunk) + markers.observe(searchable) + if markers.architecture == .multiArchitecture { return } + carry = Data(searchable.suffix(maximumMarkerBytes - 1)) + offset += Int64(chunk.count) + } + } + + private static func read( + descriptor: Int32, + offset: Int64, + count: Int, + path: String + ) throws -> Data { + guard count > 0 else { return Data() } + var data = Data(count: count) + while true { + let bytesRead = data.withUnsafeMutableBytes { buffer in + pread(descriptor, buffer.baseAddress, buffer.count, off_t(offset)) + } + if bytesRead < 0 { + if errno == EINTR { continue } + throw DoryInstallerISOInspectionError.read(path, errno) + } + data.count = bytesRead + return data + } + } + + private static func markerVariants(_ values: [String]) -> [Data] { + values.flatMap { value in + [Data(value.utf8), Data(value.lowercased().utf8)] + } + } + + private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | UInt32(data[offset + 1]) << 8 + | UInt32(data[offset + 2]) << 16 + | UInt32(data[offset + 3]) << 24 + } + + private struct ByteRange: Hashable { + let offset: Int64 + let length: Int64 + } + + private struct MarkerState { + var foundArm64 = false + var foundX86 = false + + mutating func observe(_ data: Data) { + if !foundArm64 { + foundArm64 = arm64Markers.contains { data.range(of: $0) != nil } + } + if !foundX86 { + foundX86 = x86Markers.contains { data.range(of: $0) != nil } + } + } + + mutating func merge(_ other: MarkerState) { + foundArm64 = foundArm64 || other.foundArm64 + foundX86 = foundX86 || other.foundX86 + } + + var architecture: DoryInstallerISOArchitecture { + switch (foundArm64, foundX86) { + case (true, true): .multiArchitecture + case (true, false): .arm64 + case (false, true): .x86_64 + case (false, false): .unknown + } + } + } +} + +public struct DoryLinuxInstallerBootAssets: Sendable, Equatable { + public let kernel: Data + public let initrd: Data + public let kernelISOPath: String + public let initrdISOPath: String + + public init(kernel: Data, initrd: Data, kernelISOPath: String, initrdISOPath: String) { + self.kernel = kernel + self.initrd = initrd + self.kernelISOPath = kernelISOPath + self.initrdISOPath = initrdISOPath + } +} + +/// Derives the direct-boot payload for an installed Linux disk from its installer media. The EFI +/// VM remains the installation backend; these immutable files let the installed disk move to +/// Dory's accelerated VirtIO GPU runtime without attempting to emulate EFI in raw Hypervisor. +public enum DoryLinuxInstallerBootAssetExtractor { + private static let maximumCompressedKernelBytes = 128 * 1024 * 1024 + private static let maximumKernelBytes = 256 * 1024 * 1024 + private static let maximumInitrdBytes = 512 * 1024 * 1024 + private static let arm64ImageMagic: UInt32 = 0x644d_5241 + + private static let candidates: [(kernel: String, initrd: String)] = [ + ("casper/vmlinuz", "casper/initrd"), + ("live/vmlinuz", "live/initrd.img"), + ("install.a64/vmlinuz", "install.a64/initrd.gz"), + ("install.arm64/vmlinuz", "install.arm64/initrd.gz"), + ("arch/boot/aarch64/vmlinuz-linux", "arch/boot/aarch64/initramfs-linux.img"), + ] + + public static func extract(atPath isoPath: String) throws -> DoryLinuxInstallerBootAssets { + var attempted = [String]() + for candidate in candidates { + attempted.append("\(candidate.kernel) + \(candidate.initrd)") + guard let compressedKernel = try? DoryInstallerISOInspector.fileData( + atISOPath: isoPath, + fromPath: candidate.kernel, + maximumBytes: maximumCompressedKernelBytes + ), let initrd = try? DoryInstallerISOInspector.fileData( + atISOPath: isoPath, + fromPath: candidate.initrd, + maximumBytes: maximumInitrdBytes + ) else { + continue + } + let kernel = try materializeARM64Kernel(compressedKernel, source: candidate.kernel) + return DoryLinuxInstallerBootAssets( + kernel: kernel, + initrd: initrd, + kernelISOPath: candidate.kernel, + initrdISOPath: candidate.initrd + ) + } + throw DoryLinuxInstallerBootAssetError.notFound(attempted) + } + + private static func materializeARM64Kernel(_ data: Data, source: String) throws -> Data { + let kernel: Data + if data.count >= 2, data[0] == 0x1f, data[1] == 0x8b { + kernel = try gunzip(data, source: source) + } else { + kernel = data + } + guard kernel.count >= 0x3c, + littleEndianUInt32(kernel, at: 0x38) == arm64ImageMagic else { + throw DoryLinuxInstallerBootAssetError.unsupportedKernel(source) + } + return kernel + } + + private static func gunzip(_ input: Data, source: String) throws -> Data { + guard input.count >= 18 else { + throw DoryLinuxInstallerBootAssetError.invalidGzip(source) + } + let expectedSize = Int(littleEndianUInt32(input, at: input.count - 4)) + guard expectedSize > 0, expectedSize <= maximumKernelBytes else { + throw DoryLinuxInstallerBootAssetError.kernelTooLarge(source, expectedSize) + } + var output = Data(count: expectedSize) + var stream = z_stream() + let initialized = inflateInit2_( + &stream, + MAX_WBITS + 16, + ZLIB_VERSION, + Int32(MemoryLayout.size) + ) + guard initialized == Z_OK else { + throw DoryLinuxInstallerBootAssetError.invalidGzip(source) + } + defer { inflateEnd(&stream) } + + let result = input.withUnsafeBytes { inputBytes in + output.withUnsafeMutableBytes { outputBytes in + stream.next_in = UnsafeMutablePointer( + mutating: inputBytes.bindMemory(to: Bytef.self).baseAddress! + ) + stream.avail_in = uInt(inputBytes.count) + stream.next_out = outputBytes.bindMemory(to: Bytef.self).baseAddress! + stream.avail_out = uInt(outputBytes.count) + return inflate(&stream, Z_FINISH) + } + } + guard result == Z_STREAM_END, + stream.total_out == uLong(expectedSize), + stream.avail_out == 0 else { + throw DoryLinuxInstallerBootAssetError.invalidGzip(source) + } + return output + } + + private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | UInt32(data[offset + 1]) << 8 + | UInt32(data[offset + 2]) << 16 + | UInt32(data[offset + 3]) << 24 + } +} + +public enum DoryLinuxInstallerBootAssetError: Error, LocalizedError, Sendable, Equatable { + case notFound([String]) + case unsupportedKernel(String) + case invalidGzip(String) + case kernelTooLarge(String, Int) + + public var errorDescription: String? { + switch self { + case let .notFound(candidates): + "Installer ISO does not expose a supported arm64 Linux kernel/initrd pair (tried \(candidates.joined(separator: ", ")))." + case let .unsupportedKernel(path): + "Installer kernel \(path) is not a raw arm64 Linux Image that Dory can direct-boot." + case let .invalidGzip(path): + "Installer kernel \(path) has an invalid or unsupported gzip payload." + case let .kernelTooLarge(path, size): + "Installer kernel \(path) expands to an unsupported size (\(size) bytes)." + } + } +} + +public struct DoryStagedInstallerISO: Sendable, Equatable { + public let path: String + public let identity: DoryInstallerISOMediaIdentity + public let runtimeQualification: DoryInstallerISORuntimeQualification + + public var architecture: DoryInstallerISOArchitecture { identity.architecture } + public var sha256: String { identity.sha256 } + + public init( + path: String, + identity: DoryInstallerISOMediaIdentity, + runtimeQualification: DoryInstallerISORuntimeQualification + ) { + self.path = path + self.identity = identity + self.runtimeQualification = runtimeQualification + } +} + +/// Creates a private, daemon-readable handoff for user-selected installer media. +/// +/// Sandboxed apps and interactive command-line tools can read files selected from protected macOS +/// locations such as Downloads, while a launch agent intentionally cannot. This staging boundary +/// keeps those permissions in the selecting process and gives doryd only a short-lived private copy. +public enum DoryInstallerISOStager { + public static func stage( + atPath sourcePath: String, + stagingDirectory requestedDirectory: URL? = nil, + hostArchitecture: String = DoryInstallerISOInspector.currentHostArchitecture, + hostRuntime requestedHostRuntime: DoryInstallerHostRuntime? = nil + ) throws -> DoryStagedInstallerISO { + let sourceDescriptor = open( + sourcePath, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard sourceDescriptor >= 0 else { + throw DoryInstallerISOStagingError.openSource(sourcePath, errno) + } + defer { close(sourceDescriptor) } + + var sourceInfo = stat() + guard fstat(sourceDescriptor, &sourceInfo) == 0, + (sourceInfo.st_mode & S_IFMT) == S_IFREG, + sourceInfo.st_size > 0 else { + throw DoryInstallerISOStagingError.invalidSource(sourcePath) + } + + let identity = try DoryInstallerISOInspector.mediaIdentity( + descriptor: sourceDescriptor, + size: Int64(sourceInfo.st_size), + path: sourcePath + ) + if case let .incompatible(message) = DoryInstallerISOInspector.compatibility( + of: identity.architecture, + hostArchitecture: hostArchitecture + ) { + throw DoryInstallerISOStagingError.incompatible(message) + } + let currentHost = requestedHostRuntime ?? .current + let hostRuntime = DoryInstallerHostRuntime( + architecture: hostArchitecture, + hardwareModel: currentHost.hardwareModel, + operatingSystemVersion: currentHost.operatingSystemVersion, + operatingSystemBuild: currentHost.operatingSystemBuild + ) + let runtimeQualification = DoryInstallerISORuntimeCatalog.qualification( + of: identity, + on: hostRuntime + ) + if case let .knownUnstable(message) = runtimeQualification { + throw DoryInstallerISOStagingError.knownUnstable(message) + } + + let stagingDirectory = requestedDirectory + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("dory-installer-imports", isDirectory: true) + if mkdir(stagingDirectory.path, mode_t(0o700)) != 0, errno != EEXIST { + throw DoryInstallerISOStagingError.createStagingDirectory( + stagingDirectory.path, + errno + ) + } + let directoryDescriptor = open( + stagingDirectory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard directoryDescriptor >= 0 else { + throw DoryInstallerISOStagingError.openStagingDirectory( + stagingDirectory.path, + errno + ) + } + defer { close(directoryDescriptor) } + + var directoryInfo = stat() + guard fstat(directoryDescriptor, &directoryInfo) == 0, + (directoryInfo.st_mode & S_IFMT) == S_IFDIR, + directoryInfo.st_uid == geteuid(), + fchmod(directoryDescriptor, mode_t(0o700)) == 0 else { + throw DoryInstallerISOStagingError.insecureStagingDirectory(stagingDirectory.path) + } + + let destinationName = "\(UUID().uuidString.lowercased()).iso" + do { + if fclonefileat(sourceDescriptor, directoryDescriptor, destinationName, 0) != 0 { + let destinationDescriptor = openat( + directoryDescriptor, + destinationName, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard destinationDescriptor >= 0 else { + throw DoryInstallerISOStagingError.copy(sourcePath, errno) + } + defer { close(destinationDescriptor) } + guard lseek(sourceDescriptor, 0, SEEK_SET) == 0, + fcopyfile( + sourceDescriptor, + destinationDescriptor, + nil, + copyfile_flags_t(COPYFILE_DATA) + ) == 0, + fchmod(destinationDescriptor, mode_t(0o600)) == 0, + fsync(destinationDescriptor) == 0 else { + throw DoryInstallerISOStagingError.copy(sourcePath, errno) + } + } + + let stagedDescriptor = openat( + directoryDescriptor, + destinationName, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard stagedDescriptor >= 0 else { + throw DoryInstallerISOStagingError.verify(sourcePath, errno) + } + defer { close(stagedDescriptor) } + var stagedInfo = stat() + guard fchmod(stagedDescriptor, mode_t(0o600)) == 0, + fstat(stagedDescriptor, &stagedInfo) == 0, + (stagedInfo.st_mode & S_IFMT) == S_IFREG, + stagedInfo.st_uid == geteuid(), + stagedInfo.st_size == sourceInfo.st_size, + stagedInfo.st_mode & 0o077 == 0, + fsync(stagedDescriptor) == 0, + fsync(directoryDescriptor) == 0 else { + throw DoryInstallerISOStagingError.verify(sourcePath, errno) + } + } catch { + _ = unlinkat(directoryDescriptor, destinationName, 0) + throw error + } + + return DoryStagedInstallerISO( + path: stagingDirectory.appendingPathComponent(destinationName).path, + identity: identity, + runtimeQualification: runtimeQualification + ) + } +} + +public enum DoryInstallerISOStagingError: Error, LocalizedError, CustomStringConvertible { + case openSource(String, Int32) + case invalidSource(String) + case incompatible(String) + case knownUnstable(String) + case createStagingDirectory(String, Int32) + case openStagingDirectory(String, Int32) + case insecureStagingDirectory(String) + case copy(String, Int32) + case verify(String, Int32) + + public var description: String { errorDescription ?? "Installer ISO staging failed." } + + public var errorDescription: String? { + switch self { + case let .openSource(path, code): + "Could not open installer ISO \(path): \(String(cString: strerror(code)))" + case let .invalidSource(path): + "Installer ISO is not a nonempty regular file: \(path)" + case let .incompatible(message): + message + case let .knownUnstable(message): + message + case let .createStagingDirectory(path, code): + "Could not create private installer staging directory \(path): \(String(cString: strerror(code)))" + case let .openStagingDirectory(path, code): + "Could not open private installer staging directory \(path): \(String(cString: strerror(code)))" + case let .insecureStagingDirectory(path): + "Installer staging directory is not private and user-owned: \(path)" + case let .copy(path, code): + "Could not stage installer ISO \(path): \(String(cString: strerror(code)))" + case let .verify(path, code): + "Could not verify staged installer ISO \(path): \(String(cString: strerror(code)))" + } + } +} + +public enum DoryInstallerISOInspectionError: Error, LocalizedError { + case open(String, Int32) + case read(String, Int32) + case notRegularFile(String) + case invalidISOPath(String) + case fileNotFound(String) + case fileTooLarge(String, Int64) + + public var errorDescription: String? { + switch self { + case let .open(path, code): + "Could not inspect installer ISO \(path): \(String(cString: strerror(code)))" + case let .read(path, code): + "Could not read installer ISO \(path): \(String(cString: strerror(code)))" + case let .notRegularFile(path): + "Installer ISO is not a nonempty regular file: \(path)" + case let .invalidISOPath(path): + "Invalid ISO9660 file path: \(path)" + case let .fileNotFound(path): + "File was not found in the installer ISO: \(path)" + case let .fileTooLarge(path, size): + "Installer ISO file exceeds its extraction limit: \(path) (\(size) bytes)" + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift b/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift new file mode 100644 index 00000000..a2b99727 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift @@ -0,0 +1,154 @@ +import Darwin +import Foundation + +/// Discovers the installed Linux root candidate from a whole-disk GPT image. Apple +/// Virtualization presents this image as NVMe during installation; Dory's accelerated runtime +/// presents the same bytes as VirtIO block, so partition 2 becomes `/dev/vda2` without rewriting +/// the guest disk. +public enum DoryLinuxInstalledDiskInspector { + private static let sectorSize: UInt64 = 512 + private static let maximumPartitionTableBytes = 8 * 1024 * 1024 + // Linux filesystem data partition GUID 0FC63DAF-8483-4772-8E79-3D69D8477DE4 in GPT byte order. + private static let linuxFilesystemType = Data([ + 0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47, + 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4, + ]) + + public static func rootDevice( + atPath diskPath: String, + devicePrefix: String = "/dev/vda" + ) throws -> String { + guard devicePrefix.hasPrefix("/dev/"), + !devicePrefix.contains("\0"), + devicePrefix.last?.isLetter == true else { + throw DoryLinuxInstalledDiskInspectionError.invalidDevicePrefix(devicePrefix) + } + let descriptor = open(diskPath, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryLinuxInstalledDiskInspectionError.open(diskPath, errno) + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_size >= off_t(sectorSize * 34) else { + throw DoryLinuxInstalledDiskInspectionError.invalidDisk(diskPath) + } + + let header = try readExactly( + descriptor: descriptor, + offset: Int64(sectorSize), + count: Int(sectorSize), + path: diskPath + ) + guard header.prefix(8) == Data("EFI PART".utf8) else { + throw DoryLinuxInstalledDiskInspectionError.missingGPT(diskPath) + } + let entryLBA = littleEndianUInt64(header, at: 72) + let entryCount = UInt64(littleEndianUInt32(header, at: 80)) + let entrySize = UInt64(littleEndianUInt32(header, at: 84)) + let (tableByteCount, tableOverflow) = entryCount.multipliedReportingOverflow(by: entrySize) + let (tableOffset, offsetOverflow) = entryLBA.multipliedReportingOverflow(by: sectorSize) + guard !tableOverflow, + !offsetOverflow, + entryCount > 0, + entrySize >= 128, + entrySize <= 4_096, + tableByteCount > 0, + tableByteCount <= UInt64(maximumPartitionTableBytes), + tableOffset < UInt64(info.st_size), + tableByteCount <= UInt64(info.st_size) - tableOffset else { + throw DoryLinuxInstalledDiskInspectionError.invalidGPT(diskPath) + } + let entries = try readExactly( + descriptor: descriptor, + offset: Int64(tableOffset), + count: Int(tableByteCount), + path: diskPath + ) + + var candidates = [(index: Int, sectors: UInt64)]() + for index in 0.. 0, lastLBA >= firstLBA else { continue } + candidates.append((index + 1, lastLBA - firstLBA + 1)) + } + guard let root = candidates.max(by: { $0.sectors < $1.sectors }) else { + throw DoryLinuxInstalledDiskInspectionError.rootPartitionNotFound(diskPath) + } + return "\(devicePrefix)\(root.index)" + } + + private static func readExactly( + descriptor: Int32, + offset: Int64, + count: Int, + path: String + ) throws -> Data { + var data = Data(count: count) + var completed = 0 + while completed < count { + let bytesRead = data.withUnsafeMutableBytes { bytes in + pread( + descriptor, + bytes.baseAddress!.advanced(by: completed), + count - completed, + off_t(offset + Int64(completed)) + ) + } + if bytesRead < 0 { + if errno == EINTR { continue } + throw DoryLinuxInstalledDiskInspectionError.read(path, errno) + } + guard bytesRead > 0 else { + throw DoryLinuxInstalledDiskInspectionError.read(path, EIO) + } + completed += bytesRead + } + return data + } + + private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | UInt32(data[offset + 1]) << 8 + | UInt32(data[offset + 2]) << 16 + | UInt32(data[offset + 3]) << 24 + } + + private static func littleEndianUInt64(_ data: Data, at offset: Int) -> UInt64 { + UInt64(littleEndianUInt32(data, at: offset)) + | UInt64(littleEndianUInt32(data, at: offset + 4)) << 32 + } +} + +public enum DoryLinuxInstalledDiskInspectionError: Error, LocalizedError, Sendable, Equatable { + case invalidDevicePrefix(String) + case open(String, Int32) + case read(String, Int32) + case invalidDisk(String) + case missingGPT(String) + case invalidGPT(String) + case rootPartitionNotFound(String) + + public var errorDescription: String? { + switch self { + case let .invalidDevicePrefix(prefix): + "Invalid Linux block-device prefix: \(prefix)" + case let .open(path, code): + "Could not open installed Linux disk \(path): \(String(cString: strerror(code)))" + case let .read(path, code): + "Could not read installed Linux disk \(path): \(String(cString: strerror(code)))" + case let .invalidDisk(path): + "Installed Linux disk is not a valid nonempty regular image: \(path)" + case let .missingGPT(path): + "Installed Linux disk has no GPT partition table: \(path)" + case let .invalidGPT(path): + "Installed Linux disk has an invalid GPT partition table: \(path)" + case let .rootPartitionNotFound(path): + "Installed Linux disk has no Linux filesystem partition to direct-boot: \(path)" + } + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 6c4e2685..d091c62e 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1,5 +1,6 @@ import Darwin import DoryCore +import DoryOperations import DorydKit import Foundation @preconcurrency import Virtualization @@ -21,6 +22,8 @@ public struct DoryVMMArguments: Sendable, Equatable { public var dataDriveRoot: String? public var kernelPath: String? public var rootfsPath: String? + public var machineBootMode: DoryMachineBootMode = .linuxKernel + public var installerISOPath: String? public var gvproxyPath: String? public var sshAgentSocketPath: String? public var publishHost = "127.0.0.1" @@ -64,6 +67,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case missingGVProxy case invalidPublishHost(String) case invalidDisplayMode(String) + case invalidMachineBootMode(String) case invalidEnvironment(String) public var description: String { @@ -88,6 +92,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid --publish-host (expected 127.0.0.1 or 0.0.0.0): \(host)" case let .invalidDisplayMode(mode): return "invalid --display-mode (expected headless or desktop): \(mode)" + case let .invalidMachineBootMode(mode): + return "invalid --boot-mode (expected linux-kernel or efi): \(mode)" case let .invalidEnvironment(value): return "invalid --env value: \(value)" } @@ -111,6 +117,14 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { parsed.kernelPath = try value(after: argument, from: raw, index: &index) case "--rootfs": parsed.rootfsPath = try value(after: argument, from: raw, index: &index) + case "--boot-mode": + let value = try value(after: argument, from: raw, index: &index) + guard let mode = DoryMachineBootMode(rawValue: value) else { + throw DoryVMMArgumentError.invalidMachineBootMode(value) + } + parsed.machineBootMode = mode + case "--installer-iso": + parsed.installerISOPath = try value(after: argument, from: raw, index: &index) case "--gvproxy": parsed.gvproxyPath = try value(after: argument, from: raw, index: &index) case "--ssh-agent-socket": @@ -202,6 +216,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { public var stateDirectory: String public var kernelPath: String public var rootfsPath: String + public var bootMode: DoryMachineBootMode + public var installerISOPath: String? public var memoryMB: UInt64 public var cpuCount: Int public var displayMode: DoryMachineDisplayMode @@ -218,6 +234,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { stateDirectory: String, kernelPath: String, rootfsPath: String, + bootMode: DoryMachineBootMode = .linuxKernel, + installerISOPath: String? = nil, memoryMB: UInt64, cpuCount: Int, displayMode: DoryMachineDisplayMode = .headless, @@ -233,6 +251,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { self.stateDirectory = stateDirectory self.kernelPath = kernelPath self.rootfsPath = rootfsPath + self.bootMode = bootMode + self.installerISOPath = installerISOPath self.memoryMB = memoryMB self.cpuCount = cpuCount self.displayMode = displayMode @@ -288,6 +308,7 @@ public enum DoryVZConfigurationBuilder { public static func makeConfiguration( spec: DoryVZMachineSpec, serialOutput: FileHandle?, + serialInput: FileHandle? = nil, networkAttachment: VZNetworkDeviceAttachment? = nil ) throws -> VZVirtualMachineConfiguration { let fileManager = FileManager.default @@ -308,18 +329,40 @@ public enum DoryVZConfigurationBuilder { "native IPv6 requires the gvproxy file-handle network attachment" ) } - guard fileManager.fileExists(atPath: spec.kernelPath) else { - throw DoryVZMachineError.missingFile(spec.kernelPath) - } guard fileManager.fileExists(atPath: spec.rootfsPath) else { throw DoryVZMachineError.missingFile(spec.rootfsPath) } - let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: spec.kernelPath)) - bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine(machineID: spec.machineID) - let configuration = VZVirtualMachineConfiguration() - configuration.bootLoader = bootLoader + switch spec.bootMode { + case .linuxKernel: + guard fileManager.fileExists(atPath: spec.kernelPath) else { + throw DoryVZMachineError.missingFile(spec.kernelPath) + } + let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: spec.kernelPath)) + bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine(machineID: spec.machineID) + configuration.bootLoader = bootLoader + case .efi: + guard spec.displayMode == .desktop else { + throw DoryVZMachineError.validation("EFI boot requires desktop display mode") + } + let platform = VZGenericPlatformConfiguration() + platform.machineIdentifier = try persistentMachineIdentifier( + at: spec.stateDirectory + "/MachineIdentifier" + ) + let bootLoader = VZEFIBootLoader() + // Keep installation/recovery firmware state separate from the installed system. + // UEFI remembers the disk as its first boot target after an OS installation, and + // merely reconnecting a USB ISO does not override that persisted BootOrder. A + // dedicated installer variable store gives attached media a stable CD-first boot + // profile while preserving the installed disk's firmware entries for normal boots. + let variableStoreName = spec.installerISOPath == nil ? "NVRAM" : "NVRAM.installer" + bootLoader.variableStore = try persistentEFIVariableStore( + at: spec.stateDirectory + "/\(variableStoreName)" + ) + configuration.platform = platform + configuration.bootLoader = bootLoader + } configuration.cpuCount = spec.cpuCount configuration.memorySize = memorySize configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] @@ -344,14 +387,25 @@ public enum DoryVZConfigurationBuilder { let output = VZVirtioSoundDeviceOutputStreamConfiguration() output.sink = VZHostAudioOutputStreamSink() - let sound = VZVirtioSoundDeviceConfiguration() - sound.streams = [output] - configuration.audioDevices = [sound] + let outputSound = VZVirtioSoundDeviceConfiguration() + outputSound.streams = [output] + + let input = VZVirtioSoundDeviceInputStreamConfiguration() + input.source = VZHostAudioInputStreamSource() + let inputSound = VZVirtioSoundDeviceConfiguration() + inputSound.streams = [input] + configuration.audioDevices = [outputSound, inputSound] let console = VZVirtioConsoleDeviceConfiguration() let spiceAgent = VZVirtioConsolePortConfiguration() spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName - spiceAgent.attachment = VZSpiceAgentPortAttachment() + let spiceAttachment = VZSpiceAgentPortAttachment() + // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient + // bidirectional default; directional policies use Dory's agent-backed bridge instead. + spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy( + environment: spec.environment + ) == .bidirectional + spiceAgent.attachment = spiceAttachment console.ports[0] = spiceAgent configuration.consoleDevices = [console] } @@ -411,14 +465,48 @@ public enum DoryVZConfigurationBuilder { } do { - let attachment = try VZDiskImageStorageDeviceAttachment( - url: URL(fileURLWithPath: spec.rootfsPath), - readOnly: false - ) - let block = VZVirtioBlockDeviceConfiguration(attachment: attachment) - block.blockDeviceIdentifier = "dory-rootfs" - configuration.storageDevices = [block] + let rootURL = URL(fileURLWithPath: spec.rootfsPath) + if spec.bootMode == .efi { + // EFI installers exercise long, flush-heavy writes before Dory guest tools exist. + // Apple's native NVMe model avoids tying that workload to the VirtIO-block path + // used by Dory-owned direct-kernel guests. Cached host I/O with fsync semantics + // matches a normal virtual disk while retaining crash-consistent guest flushes. + let attachment = try VZDiskImageStorageDeviceAttachment( + url: rootURL, + readOnly: false, + cachingMode: .cached, + synchronizationMode: .fsync + ) + configuration.storageDevices = [ + VZNVMExpressControllerDeviceConfiguration(attachment: attachment), + ] + } else { + let attachment = try VZDiskImageStorageDeviceAttachment( + url: rootURL, + readOnly: false + ) + let block = VZVirtioBlockDeviceConfiguration(attachment: attachment) + block.blockDeviceIdentifier = "dory-rootfs" + configuration.storageDevices = [block] + } + if let installerISOPath = spec.installerISOPath { + guard spec.bootMode == .efi else { + throw DoryVZMachineError.validation("installer ISO requires EFI boot mode") + } + guard fileManager.fileExists(atPath: installerISOPath) else { + throw DoryVZMachineError.missingFile(installerISOPath) + } + let installerAttachment = try VZDiskImageStorageDeviceAttachment( + url: URL(fileURLWithPath: installerISOPath), + readOnly: true + ) + configuration.storageDevices.insert( + VZUSBMassStorageDeviceConfiguration(attachment: installerAttachment), + at: 0 + ) + } } catch { + if let error = error as? DoryVZMachineError { throw error } throw DoryVZMachineError.storageAttachment("\(error)") } @@ -439,7 +527,7 @@ public enum DoryVZConfigurationBuilder { if let serialOutput { let serial = VZVirtioConsoleDeviceSerialPortConfiguration() serial.attachment = VZFileHandleSerialPortAttachment( - fileHandleForReading: nil, + fileHandleForReading: serialInput, fileHandleForWriting: serialOutput ) configuration.serialPorts = [serial] @@ -448,6 +536,35 @@ public enum DoryVZConfigurationBuilder { return configuration } + private static func persistentMachineIdentifier(at path: String) throws -> VZGenericMachineIdentifier { + let url = URL(fileURLWithPath: path) + if FileManager.default.fileExists(atPath: path) { + let data = try Data(contentsOf: url) + guard let identifier = VZGenericMachineIdentifier(dataRepresentation: data) else { + throw DoryVZMachineError.validation("stored EFI machine identifier is invalid") + } + return identifier + } + let identifier = VZGenericMachineIdentifier() + try identifier.dataRepresentation.write(to: url, options: [.atomic]) + guard chmod(path, mode_t(0o600)) == 0 else { + throw DoryVZMachineError.syscall("chmod MachineIdentifier", errno) + } + return identifier + } + + private static func persistentEFIVariableStore(at path: String) throws -> VZEFIVariableStore { + let url = URL(fileURLWithPath: path) + if FileManager.default.fileExists(atPath: path) { + return VZEFIVariableStore(url: url) + } + let store = try VZEFIVariableStore(creatingVariableStoreAt: url) + guard chmod(path, mode_t(0o600)) == 0 else { + throw DoryVZMachineError.syscall("chmod NVRAM", errno) + } + return store + } + public static func defaultKernelCommandLine(machineID: String) -> String { "console=hvc0 root=/dev/vda rw rootwait panic=1 dory.machine_id=\(machineID)" } @@ -705,6 +822,8 @@ public enum DoryVMMMain { stateDirectory: stateDirectory, kernelPath: kernelPath, rootfsPath: rootfsPath, + bootMode: arguments.machineBootMode, + installerISOPath: arguments.installerISOPath, handoffSocketPath: handoffSocketPath, dockerdSocketPath: arguments.dockerdSocketPath ?? "\(stateDirectory)/dockerd.sock", agentSocketPath: arguments.agentSocketPath ?? "\(stateDirectory)/agent.sock", @@ -746,7 +865,8 @@ public enum DoryVMMMain { try MainActor.assumeIsolated { try DoryVMMDesktopApplication.run( runtime: runtime, - machineID: machineID + machineID: machineID, + environment: arguments.environment ) } } else { @@ -789,6 +909,8 @@ public enum DoryVMMMain { stateDirectory: String, kernelPath: String, rootfsPath: String, + bootMode: DoryMachineBootMode, + installerISOPath: String?, handoffSocketPath: String, dockerdSocketPath: String, agentSocketPath: String, @@ -816,6 +938,21 @@ public enum DoryVMMMain { ) } let serialLog = try openAppendLog("\(stateDirectory)/serial.log") + try appendBootSessionMarker( + to: serialLog, + machineID: machineID, + bootMode: bootMode, + cpuCount: cpuCount, + memoryMB: memoryMB, + runtimeProfile: bootMode == .efi + ? DoryInstallerRuntimeProfile.current.rawValue + : "linux-kernel-virtio-blk" + ) + let runtimeSocketDirectory = (controlSocketPath as NSString).deletingLastPathComponent + let serialConsole = try DoryVMMSerialConsole( + socketPath: "\(runtimeSocketDirectory)/console.sock", + log: serialLog + ) let dataDrive: DoryDataDrive? if let dataDriveRoot, machineID == "docker" { dataDrive = try DoryDataDrive(overrideRoot: dataDriveRoot) @@ -869,6 +1006,8 @@ public enum DoryVMMMain { stateDirectory: stateDirectory, kernelPath: kernelPath, rootfsPath: rootfsPath, + bootMode: bootMode, + installerISOPath: installerISOPath, memoryMB: memoryMB, cpuCount: cpuCount, displayMode: displayMode, @@ -882,7 +1021,8 @@ public enum DoryVMMMain { ) let configuration = try DoryVZConfigurationBuilder.makeConfiguration( spec: spec, - serialOutput: serialLog, + serialOutput: serialConsole.guestOutput, + serialInput: serialConsole.guestInput, networkAttachment: gvproxyNetwork?.attachment ) try validate(configuration: configuration) @@ -937,6 +1077,7 @@ public enum DoryVMMMain { controlServer: controlServer, proxies: [dockerdProxy, agentProxy, shellProxy], serialLog: serialLog, + serialConsole: serialConsole, gvproxyNetwork: gvproxyNetwork, sourcePreservingLANClient: sourcePreservingLANClient, sourcePreservingLANSessionID: sourcePreservingLANSessionID, @@ -957,6 +1098,22 @@ public enum DoryVMMMain { runtime.portForwarder?.start() onRuntimeCreated(runtime) + if bootMode == .efi { + try sendHandoff( + machineID: machineID, + handoffSocketPath: handoffSocketPath, + agentBuild: "dory-vmm/efi", + agentSocketPath: nil, + dockerdSocketPath: nil, + shellSocketPath: nil, + controlSocketPath: controlSocketPath, + detail: installerISOPath == nil + ? "EFI machine running from its installed disk" + : "EFI machine running with read-only installer media attached" + ) + return runtime + } + let agentConnection = try machine.waitForConnection(toPort: DoryGuestPorts.control, timeout: readyTimeoutSeconds) defer { agentConnection.close() } let agentInfo = try prepareAgent( @@ -1058,6 +1215,20 @@ public enum DoryVMMMain { return FileHandle(fileDescriptor: fd, closeOnDealloc: true) } + private static func appendBootSessionMarker( + to log: FileHandle, + machineID: String, + bootMode: DoryMachineBootMode, + cpuCount: Int, + memoryMB: UInt64, + runtimeProfile: String + ) throws { + let timestamp = Date().formatted(.iso8601) + let marker = "\n--- DORY BOOT \(timestamp) machine=\(machineID) mode=\(bootMode.rawValue) cpus=\(cpuCount) memoryMB=\(memoryMB) runtime=\(runtimeProfile) ---\n" + try log.write(contentsOf: Data(marker.utf8)) + try log.synchronize() + } + private static func validate(configuration: VZVirtualMachineConfiguration) throws { do { try configuration.validate() @@ -1199,6 +1370,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { private let controlServer: DoryVMMControlServer private let proxies: [DoryVZPortUnixProxy] private let serialLog: FileHandle + private let serialConsole: DoryVMMSerialConsole private let gvproxyNetwork: DoryVMMGVProxyNetwork? private let sourcePreservingLANClient: SourcePreservingLANPrivilegedClient? private let sourcePreservingLANSessionID: String? @@ -1210,6 +1382,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { controlServer: DoryVMMControlServer, proxies: [DoryVZPortUnixProxy], serialLog: FileHandle, + serialConsole: DoryVMMSerialConsole, gvproxyNetwork: DoryVMMGVProxyNetwork?, sourcePreservingLANClient: SourcePreservingLANPrivilegedClient?, sourcePreservingLANSessionID: String?, @@ -1220,6 +1393,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { self.controlServer = controlServer self.proxies = proxies self.serialLog = serialLog + self.serialConsole = serialConsole self.gvproxyNetwork = gvproxyNetwork self.sourcePreservingLANClient = sourcePreservingLANClient self.sourcePreservingLANSessionID = sourcePreservingLANSessionID @@ -1231,6 +1405,26 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { machine.isStopped } + func executeDesktopIntegration( + argv: [String], + stdin: Data, + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult { + let connection = try machine.connect(toPort: DoryGuestPorts.control) + defer { connection.close() } + let fd = dup(connection.fileDescriptor) + guard fd >= 0 else { throw DoryVZMachineError.syscall("dup", errno) } + let control = try DoryCore.connectAgentControlOverFD(fd) + defer { control.close() } + return try control.execWithInput( + argv: argv, + stdin: stdin, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + } + func requestGuestShutdown() throws { do { try requestGuestShutdownThroughAgent() @@ -1244,6 +1438,14 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { try gvproxyNetwork.requestGuestShutdown() return } + do { + try machine.requestGuestStop() + return + } catch { + FileHandle.standardError.write(Data( + "dory-vmm: virtual power-button shutdown request failed, trying transport fallback: \(error)\n".utf8 + )) + } let deadline = Date().addingTimeInterval(5) var lastError: Error? repeat { @@ -1301,6 +1503,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable { )) } gvproxyNetwork?.stop() + serialConsole.stop() try? serialLog.close() } } @@ -1410,6 +1613,20 @@ public final class DoryVZMachine: @unchecked Sendable { try stopObserver.waitUntilStopped() } + /// Ask the guest firmware/OS to shut down as if its virtual power button were pressed. + /// This is the integration-free shutdown path for arbitrary EFI guests that do not run + /// Dory's agent yet; it gives Linux a chance to flush filesystems before the VMM exits. + public func requestGuestStop() throws { + try queue.sync { + guard virtualMachine.canRequestStop else { + throw DoryVZMachineError.validation( + "virtual machine is not in a state that accepts a guest stop request" + ) + } + try virtualMachine.requestStop() + } + } + public func connect(toPort port: UInt32) throws -> VZVirtioSocketConnection { let box = BlockingResultBox() queue.async { [self] in diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index 5df9de7f..d4b53c5d 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -1,4 +1,5 @@ import AppKit +import DoryOperations import Foundation @preconcurrency import Virtualization @@ -8,24 +9,48 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow private let runtime: DoryVMMRuntime private let machineView: VZVirtualMachineView private let window: NSWindow + private let clipboard: DoryDesktopClipboardCoordinator private var pendingDisplayResize: DispatchWorkItem? private var requestedPixelSize: CGSize? private var stopError: String? - private init(runtime: DoryVMMRuntime, machineID: String) { + private init(runtime: DoryVMMRuntime, machineID: String, environment: [String: String]) { self.application = NSApplication.shared self.runtime = runtime let windowSize = NSSize(width: 1_280, height: 800) - let machineView = VZVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize)) + let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize)) machineView.virtualMachine = runtime.machine.virtualMachineForDisplay // Apple's automatic path currently requests the view's point size on Retina displays. // Dory drives the scanout with backing pixels so a 1280x800-point window renders a true // 2560x1600 guest framebuffer instead of stretching a low-resolution desktop. machineView.automaticallyReconfiguresDisplay = false - machineView.capturesSystemKeys = false + machineView.capturesSystemKeys = true self.machineView = machineView + let requestedPolicy = DoryDesktopClipboardPolicy(environment: environment) + // Bidirectional sharing is already handled by Apple's efficient SPICE transport. The + // shared coordinator still translates Mac shortcuts, while directional modes use its + // agent-backed data path to enforce the selected boundary. + let coordinatorPolicy: DoryDesktopClipboardPolicy = requestedPolicy == .bidirectional + ? .off + : requestedPolicy + self.clipboard = DoryDesktopClipboardCoordinator( + policy: coordinatorPolicy, + execute: { argv, stdin, timeoutMs, outputLimitBytes in + try runtime.executeDesktopIntegration( + argv: argv, + stdin: stdin, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + }, + sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) }, + log: { message in + FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8)) + } + ) + self.window = NSWindow( contentRect: NSRect(origin: .zero, size: windowSize), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -35,19 +60,34 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow self.window.title = "\(machineID) — Dory Linux" self.window.contentView = machineView self.window.minSize = NSSize(width: 640, height: 400) + self.window.collectionBehavior.insert(.fullScreenPrimary) + self.window.tabbingMode = .disallowed self.window.center() super.init() self.window.delegate = self + machineView.onMacShortcut = { [weak clipboard] event in + clipboard?.handleMacShortcut(event) ?? false + } } - static func run(runtime: DoryVMMRuntime, machineID: String) throws { - let controller = DoryVMMDesktopApplication(runtime: runtime, machineID: machineID) + static func run( + runtime: DoryVMMRuntime, + machineID: String, + environment: [String: String] + ) throws { + let controller = DoryVMMDesktopApplication( + runtime: runtime, + machineID: machineID, + environment: environment + ) try controller.runUntilStopped() } private func runUntilStopped() throws { application.setActivationPolicy(.regular) application.delegate = self + clipboard.start() + clipboard.markGuestReady() window.makeKeyAndOrderFront(nil) application.activate() reconfigureDisplayNow() @@ -67,6 +107,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow } application.run() + clipboard.stop() if let stopError { throw DoryVZMachineError.stoppedWithError(stopError) } @@ -162,3 +203,70 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow } } } + +/// `VZVirtualMachineView` forwards the device-oriented Core Graphics wheel deltas to Linux. +/// AppKit normally applies the user's macOS natural-scrolling preference before an `NSView` +/// consumes those deltas, but the VM view bypasses that normalization. Preserve all of the event's +/// phase and momentum metadata while correcting the delta fields only when AppKit says the host +/// preference inverted them from the physical device. +@MainActor +private final class DoryVirtualMachineView: VZVirtualMachineView { + var onMacShortcut: ((NSEvent) -> Bool)? + + override func keyDown(with event: NSEvent) { + if onMacShortcut?(event) == true { return } + super.keyDown(with: event) + } + + func sendControlShortcut(linuxKeyCode: UInt16) { + guard let macKeyCode = Self.macKeyCode(forLinuxKeyCode: linuxKeyCode) else { return } + for keyDown in [true, false] { + guard let cgEvent = CGEvent( + keyboardEventSource: nil, + virtualKey: CGKeyCode(macKeyCode), + keyDown: keyDown + ) else { continue } + cgEvent.flags = .maskControl + if let event = NSEvent(cgEvent: cgEvent) { + if keyDown { super.keyDown(with: event) } else { super.keyUp(with: event) } + } + } + } + + private static func macKeyCode(forLinuxKeyCode code: UInt16) -> UInt16? { + switch code { + case 46: 8 // C + case 45: 7 // X + case 47: 9 // V + default: nil + } + } + + override func scrollWheel(with event: NSEvent) { + guard event.isDirectionInvertedFromDevice, + let correctedCGEvent = event.cgEvent?.copy() else { + super.scrollWheel(with: event) + return + } + + Self.invertIntegerDelta(.scrollWheelEventDeltaAxis1, in: correctedCGEvent) + Self.invertIntegerDelta(.scrollWheelEventDeltaAxis2, in: correctedCGEvent) + Self.invertIntegerDelta(.scrollWheelEventPointDeltaAxis1, in: correctedCGEvent) + Self.invertIntegerDelta(.scrollWheelEventPointDeltaAxis2, in: correctedCGEvent) + Self.invertFixedPointDelta(.scrollWheelEventFixedPtDeltaAxis1, in: correctedCGEvent) + Self.invertFixedPointDelta(.scrollWheelEventFixedPtDeltaAxis2, in: correctedCGEvent) + guard let correctedEvent = NSEvent(cgEvent: correctedCGEvent) else { + super.scrollWheel(with: event) + return + } + super.scrollWheel(with: correctedEvent) + } + + private static func invertIntegerDelta(_ field: CGEventField, in event: CGEvent) { + event.setIntegerValueField(field, value: -event.getIntegerValueField(field)) + } + + private static func invertFixedPointDelta(_ field: CGEventField, in event: CGEvent) { + event.setDoubleValueField(field, value: -event.getDoubleValueField(field)) + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift new file mode 100644 index 00000000..ba1818d1 --- /dev/null +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift @@ -0,0 +1,267 @@ +import Darwin +import Foundation + +/// Bidirectional, private serial console for EFI installers and recovery boots. +/// +/// Virtualization.framework receives one end of a socket pair. The host end is multiplexed to the +/// durable serial log and one local Unix-socket client, so diagnostics remain available even when +/// no terminal is attached. The socket is user-only and lives inside Dory's short runtime directory +/// because Darwin Unix-domain socket paths have a small fixed limit. +final class DoryVMMSerialConsole: @unchecked Sendable { + let guestInput: FileHandle + let guestOutput: FileHandle + + private let hostInputDescriptor: Int32 + private let hostOutputDescriptor: Int32 + private let listenerDescriptor: Int32 + private let log: FileHandle + private let socketPath: String + private let queue = DispatchQueue(label: "dev.dory.dory-vmm.serial-console") + private let lock = NSLock() + private var running = true + private var clientDescriptor: Int32 = -1 + + init(socketPath: String, log: FileHandle) throws { + self.socketPath = socketPath + self.log = log + + var inputPair = [Int32](repeating: -1, count: 2) + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &inputPair) == 0 else { + throw DoryVZMachineError.syscall("socketpair serial console input", errno) + } + var outputPair = [Int32](repeating: -1, count: 2) + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &outputPair) == 0 else { + close(inputPair[0]) + close(inputPair[1]) + throw DoryVZMachineError.syscall("socketpair serial console output", errno) + } + let hostInput = inputPair[0] + let input = inputPair[1] + let hostOutput = outputPair[0] + let output = outputPair[1] + + let listener = socket(AF_UNIX, SOCK_STREAM, 0) + guard listener >= 0 else { + close(input) + close(output) + close(hostInput) + close(hostOutput) + throw DoryVZMachineError.syscall("socket serial console", errno) + } + + do { + try Self.configureDescriptor(hostInput, nonblocking: true) + try Self.configureDescriptor(hostOutput, nonblocking: true) + try Self.configureDescriptor(input, nonblocking: false) + try Self.configureDescriptor(output, nonblocking: false) + try Self.configureDescriptor(listener, nonblocking: true) + try Self.bind(listener: listener, path: socketPath) + } catch { + close(listener) + close(input) + close(output) + close(hostInput) + close(hostOutput) + throw error + } + + hostInputDescriptor = hostInput + hostOutputDescriptor = hostOutput + listenerDescriptor = listener + guestInput = FileHandle(fileDescriptor: input, closeOnDealloc: true) + guestOutput = FileHandle(fileDescriptor: output, closeOnDealloc: true) + + queue.async { [weak self] in self?.run() } + } + + func stop() { + lock.lock() + guard running else { + lock.unlock() + return + } + running = false + let client = clientDescriptor + clientDescriptor = -1 + lock.unlock() + + if client >= 0 { + shutdown(client, SHUT_RDWR) + close(client) + } + shutdown(listenerDescriptor, SHUT_RDWR) + close(listenerDescriptor) + shutdown(hostInputDescriptor, SHUT_RDWR) + close(hostInputDescriptor) + shutdown(hostOutputDescriptor, SHUT_RDWR) + close(hostOutputDescriptor) + try? guestInput.close() + try? guestOutput.close() + unlink(socketPath) + } + + deinit { + stop() + } + + private func run() { + var buffer = [UInt8](repeating: 0, count: 32 * 1024) + while isRunning { + let client = currentClient + var descriptors = [ + pollfd(fd: hostOutputDescriptor, events: Int16(POLLIN), revents: 0), + pollfd(fd: listenerDescriptor, events: Int16(POLLIN), revents: 0), + ] + if client >= 0 { + descriptors.append(pollfd(fd: client, events: Int16(POLLIN), revents: 0)) + } + + let result = poll(&descriptors, nfds_t(descriptors.count), 250) + if result < 0 { + if errno == EINTR { continue } + break + } + if result == 0 { continue } + + if descriptors[1].revents & Int16(POLLIN) != 0 { + acceptClient() + } + if descriptors[0].revents & Int16(POLLIN) != 0 { + let count = Darwin.read(hostOutputDescriptor, &buffer, buffer.count) + if count > 0 { + let data = Data(buffer.prefix(count)) + try? log.write(contentsOf: data) + forwardToClient(buffer: &buffer, count: count) + } else if count == 0 { + break + } else if errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR { + break + } + } + if descriptors.count == 3, descriptors[2].revents & Int16(POLLIN) != 0 { + let count = Darwin.read(client, &buffer, buffer.count) + if count > 0 { + sendAll(descriptor: hostInputDescriptor, buffer: &buffer, count: count) + } else if count == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + disconnectClient(client) + } + } + } + } + + private var isRunning: Bool { + lock.lock() + defer { lock.unlock() } + return running + } + + private var currentClient: Int32 { + lock.lock() + defer { lock.unlock() } + return clientDescriptor + } + + private func acceptClient() { + let accepted = accept(listenerDescriptor, nil, nil) + guard accepted >= 0 else { return } + do { + try Self.configureDescriptor(accepted, nonblocking: true) + } catch { + close(accepted) + return + } + + lock.lock() + let previous = clientDescriptor + clientDescriptor = accepted + lock.unlock() + if previous >= 0 { + shutdown(previous, SHUT_RDWR) + close(previous) + } + } + + private func disconnectClient(_ descriptor: Int32) { + lock.lock() + if clientDescriptor == descriptor { clientDescriptor = -1 } + lock.unlock() + shutdown(descriptor, SHUT_RDWR) + close(descriptor) + } + + private func forwardToClient(buffer: inout [UInt8], count: Int) { + let client = currentClient + guard client >= 0 else { return } + if !sendAll(descriptor: client, buffer: &buffer, count: count) { + disconnectClient(client) + } + } + + @discardableResult + private func sendAll(descriptor: Int32, buffer: inout [UInt8], count: Int) -> Bool { + var offset = 0 + while offset < count { + let sent = buffer.withUnsafeBytes { rawBuffer -> Int in + guard let base = rawBuffer.baseAddress else { return -1 } + return Darwin.send( + descriptor, + base.advanced(by: offset), + count - offset, + MSG_NOSIGNAL + ) + } + if sent > 0 { + offset += sent + continue + } + if sent < 0, errno == EINTR { continue } + if sent < 0, errno == EAGAIN || errno == EWOULDBLOCK { + return true + } + return false + } + return true + } + + private static func configureDescriptor(_ descriptor: Int32, nonblocking: Bool) throws { + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + throw DoryVZMachineError.syscall("fcntl serial console cloexec", errno) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, + fcntl( + descriptor, + F_SETFL, + nonblocking ? flags | O_NONBLOCK : flags & ~O_NONBLOCK + ) == 0 else { + throw DoryVZMachineError.syscall("fcntl serial console flags", errno) + } + } + + private static func bind(listener: Int32, path: String) throws { + let pathBytes = Array(path.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { + throw DoryVZMachineError.validation("serial console socket path is too long") + } + unlink(path) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + pathBytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + Darwin.bind(listener, raw, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw DoryVZMachineError.syscall("bind serial console", errno) + } + guard chmod(path, mode_t(0o600)) == 0 else { + throw DoryVZMachineError.syscall("chmod serial console", errno) + } + guard listen(listener, 1) == 0 else { + throw DoryVZMachineError.syscall("listen serial console", errno) + } + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift index fa8bbf06..031875f7 100644 --- a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift +++ b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift @@ -267,6 +267,7 @@ public struct DorydEnvironment: Sendable { guard let helper = executablePath(firstOf: ["DORYD_VMM_HELPER", "DORY_VMM_HELPER"], fallbackCandidates: helperCandidates(named: "dory-vmm")) else { return nil } + let acceleratedDesktop = acceleratedDesktopHelperConfiguration() let stateDirectory: String if let explicit = string("DORYD_MACHINE_STATE_DIR") { stateDirectory = explicit @@ -276,9 +277,11 @@ public struct DorydEnvironment: Sendable { } return MachineManagerConfiguration( vmmExecutablePath: helper, + acceleratedDesktopExecutablePath: acceleratedDesktop?.executablePath, stateDirectory: stateDirectory, runtimeDirectory: string("DORYD_MACHINE_RUNTIME_DIR") ?? "\(home)/.dory/machines", baseArguments: splitArguments(string("DORYD_VMM_ARGS") ?? ""), + acceleratedDesktopBaseArguments: acceleratedDesktop?.arguments ?? [], passMachineArguments: bool("DORYD_VMM_PASS_MACHINE_ARGS", default: true), logDirectory: string("DORYD_MACHINE_LOG_DIR") ?? "\(stateDirectory)/logs", requiresReadyHandoff: bool("DORYD_VMM_READY_HANDOFF", default: true), @@ -287,6 +290,28 @@ public struct DorydEnvironment: Sendable { ) } + private func acceleratedDesktopHelperConfiguration() -> ( + executablePath: String, + arguments: [String] + )? { + guard rawHVSupported, + hostGuestArch == "arm64", + bool("DORYD_ACCELERATED_DESKTOP", default: true), + let helper = executablePath( + firstOf: ["DORYD_DESKTOP_HV_HELPER", "DORYD_HV_HELPER", "DORY_HV_HELPER"], + fallbackCandidates: helperCandidates(named: "dory-hv") + ), + let gvproxy = executablePath( + firstOf: ["DORYD_GVPROXY", "DORY_GVPROXY"], + fallbackCandidates: gvproxyCandidates() + ) else { + return nil + } + let arguments = ["desktop", "--gvproxy", gvproxy] + + splitArguments(string("DORYD_DESKTOP_HV_ARGS") ?? "") + return (helper, arguments) + } + public func dataDriveConfiguration() throws -> DoryDataDrive { let explicit = string("DORYD_DATA_DRIVE") ?? string("DORY_DATA_DRIVE") let selected = explicit == nil diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index eccfad14..ff247df2 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -23,6 +23,7 @@ import Foundation func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshots(_ machineID: String, reply: @escaping (NSArray, String) -> Void) func machineCloneSnapshot(_ machineID: String, snapshotID: String, newID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index d1d59aff..ab12e3b9 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -280,7 +280,8 @@ public final class DorydService: NSObject, DorydControl { shares: update.shares, updatesShares: update.updatesShares, environment: update.environment, - updatesEnvironment: update.updatesEnvironment + updatesEnvironment: update.updatesEnvironment, + installerMediaAttached: update.installerMediaAttached ) incidentWriter?.record(type: "machine.update", detail: machineID) reply(true, status.xpcDictionary, "") @@ -380,6 +381,41 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineDesktopUpdate( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + let parsedRequest: DoryDesktopUpdateRequest + do { + parsedRequest = try DoryDesktopUpdateRequest(xpcDictionary: request) + } catch { + reply(false, [:], String(describing: error)) + return + } + let reply = StatusReply(reply) + DispatchQueue.global(qos: .utility).async { [incidentWriter] in + do { + let result = try machineManager.updateDesktop(id: machineID, request: parsedRequest) + incidentWriter?.record( + type: "machine.desktop_update", + detail: machineID + " " + result.distro + " " + result.version + " snapshot=" + result.snapshotID + ) + reply.reply(true, result.xpcDictionary, "") + } catch { + incidentWriter?.record( + type: "machine.desktop_update_failed", + detail: machineID + ": " + String(describing: error) + ) + reply.reply(false, [:], String(describing: error)) + } + } + } + public func machineSnapshot( _ machineID: String, request: NSDictionary, @@ -1146,6 +1182,17 @@ private struct MachineProvisionRequest { } } +private extension DoryDesktopUpdateRequest { + init(xpcDictionary dictionary: NSDictionary) throws { + self.init( + distro: try dictionary.requiredString("distro"), + version: try dictionary.requiredString("version"), + bundlePath: try dictionary.requiredString("bundlePath"), + kernelPath: try dictionary.requiredString("kernelPath") + ) + } +} + private struct MachineUpdateRequest { var memoryMB: UInt64? var cpuCount: Int? @@ -1155,6 +1202,7 @@ private struct MachineUpdateRequest { var updatesShares: Bool var environment: [String: String]? var updatesEnvironment: Bool + var installerMediaAttached: Bool? init(xpcDictionary dictionary: NSDictionary) throws { self.memoryMB = try dictionary.optionalUInt64("memoryMB") @@ -1170,7 +1218,9 @@ private struct MachineUpdateRequest { self.updatesShares = dictionary["shares"] != nil self.environment = dictionary["env"] == nil ? nil : try dictionary.optionalEnvironmentDictionary("env") self.updatesEnvironment = dictionary["env"] != nil - if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment { + self.installerMediaAttached = dictionary.optionalBool("installerMediaAttached") + if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment, + installerMediaAttached == nil { throw XPCRemoteConfigError.invalid("config") } } @@ -1222,10 +1272,17 @@ private extension DoryMachineConfiguration { guard let displayMode = DoryMachineDisplayMode(rawValue: rawDisplayMode) else { throw MachineManagerError.persistence("unsupported machine display mode: \(rawDisplayMode)") } + let rawBootMode = dictionary.optionalString("bootMode") ?? DoryMachineBootMode.linuxKernel.rawValue + guard let bootMode = DoryMachineBootMode(rawValue: rawBootMode) else { + throw MachineManagerError.persistence("unsupported machine boot mode: \(rawBootMode)") + } self.init( id: try dictionary.requiredString("id"), - kernelPath: try dictionary.requiredString("kernelPath"), - rootfsPath: try dictionary.requiredString("rootfsPath"), + kernelPath: dictionary.optionalString("kernelPath") ?? "", + rootfsPath: dictionary.optionalString("rootfsPath") ?? "", + bootMode: bootMode, + installerISOPath: dictionary.optionalString("installerISOPath"), + diskSizeBytes: try dictionary.optionalUInt64("diskSizeBytes"), memoryMB: try dictionary.optionalUInt64("memoryMB") ?? 2048, cpuCount: try dictionary.optionalInt("cpuCount") ?? 2, address: dictionary.optionalString("address"), @@ -1542,6 +1599,8 @@ private extension DoryMachineStatus { dictionary["currentBalloonTargetMB"] = currentBalloonTargetMB dictionary["cpuCount"] = cpuCount dictionary["displayMode"] = displayMode.rawValue + dictionary["bootMode"] = bootMode.rawValue + dictionary["installerMediaAttached"] = installerMediaAttached return dictionary as NSDictionary } } @@ -1640,6 +1699,21 @@ private extension DoryMachineSnapshot { } } +private extension DoryDesktopUpdateResult { + var xpcDictionary: NSDictionary { + [ + "machineID": machineID, + "distro": distro, + "version": version, + "inputSHA256": inputSHA256, + "bundleSHA256": bundleSHA256, + "snapshotID": snapshotID, + "status": status.xpcDictionary, + "restoredRunningState": restoredRunningState, + ] + } +} + private extension DoryExecResult { var provisionDictionary: NSDictionary { [ diff --git a/dory-core-swift/Sources/DorydKit/HvProcess.swift b/dory-core-swift/Sources/DorydKit/HvProcess.swift index d6b2b244..521fe8d6 100644 --- a/dory-core-swift/Sources/DorydKit/HvProcess.swift +++ b/dory-core-swift/Sources/DorydKit/HvProcess.swift @@ -94,6 +94,8 @@ public final class HvProcess: @unchecked Sendable { private var hasStarted = false private var suspended = false private var restartCount = 0 + private var restartPending = false + private var restartsEnabled = true private var lastTerminationStatus: Int32? private var lastLaunchError: String? @@ -118,6 +120,16 @@ public final class HvProcess: @unchecked Sendable { return process?.isRunning == true } + /// True while a helper is running or a bounded restart has already been scheduled. + /// Callers use this to distinguish a transient startup handoff from a terminal exit. + public var isRunningOrRestarting: Bool { + lock.lock() + defer { lock.unlock() } + // Keep the launch active while Foundation is between observing child exit and invoking + // its termination callback; that callback decides whether a retry is permitted. + return (!stopping && process != nil) || (!stopping && restartsEnabled && restartPending) + } + public var terminationStatus: Int32? { lock.lock() defer { lock.unlock() } @@ -146,6 +158,8 @@ public final class HvProcess: @unchecked Sendable { stopping = false suspended = false restartCount = 0 + restartPending = false + restartsEnabled = true lastTerminationStatus = nil lastLaunchError = nil try launchLocked() @@ -214,10 +228,13 @@ public final class HvProcess: @unchecked Sendable { oldLog = logHandle logHandle = nil wasUnexpected = !stopping - shouldRestart = wasUnexpected && restartCount < configuration.restartPolicy.maxRestarts + shouldRestart = wasUnexpected + && restartsEnabled + && restartCount < configuration.restartPolicy.maxRestarts if shouldRestart { restartCount += 1 } + restartPending = shouldRestart delay = configuration.restartPolicy.delay(forAttempt: restartCount) lock.unlock() try? oldLog?.close() @@ -234,15 +251,43 @@ public final class HvProcess: @unchecked Sendable { private func restartAfterUnexpectedExit() { lock.lock() - defer { lock.unlock() } - guard !stopping, process == nil else { return } + guard !stopping, restartsEnabled, restartPending, process == nil else { + restartPending = false + lock.unlock() + return + } + restartPending = false do { try launchLocked() + lock.unlock() } catch { lastLaunchError = "\(error)" + let shouldRetry = !stopping + && restartsEnabled + && restartCount < configuration.restartPolicy.maxRestarts + if shouldRetry { + restartCount += 1 + restartPending = true + } + let delay = configuration.restartPolicy.delay(forAttempt: restartCount) + lock.unlock() + if shouldRetry { + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in + self?.restartAfterUnexpectedExit() + } + } } } + /// Startup retries are useful until the supervisor accepts the ready handoff. After that + /// point an unexpected VMM exit is a real machine failure and must remain visible. + public func disableRestarts() { + lock.lock() + restartsEnabled = false + restartPending = false + lock.unlock() + } + public var isSuspended: Bool { lock.lock() defer { lock.unlock() } @@ -278,6 +323,8 @@ public final class HvProcess: @unchecked Sendable { let wasSuspended: Bool lock.lock() stopping = true + restartsEnabled = false + restartPending = false task = process terminationWaiter = self.terminationWaiter // Take-and-null the handle so exactly one of stop()/handleTermination closes it; a @@ -288,13 +335,16 @@ public final class HvProcess: @unchecked Sendable { suspended = false lock.unlock() - guard let task, task.isRunning else { + guard let task else { try? oldLog?.close() return } if wasSuspended { kill(task.processIdentifier, SIGCONT) } + // Process.isRunning may briefly read false before its termination callback fires. Send + // the signal unconditionally; ESRCH is harmless and avoids waiting for a live child to + // reach the timeout solely because Foundation exposed that transient state. kill(task.processIdentifier, signal) // Process.isRunning can flip to false when SIGTERM is delivered while the child is still diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 109103e8..c1972d9f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1,15 +1,30 @@ import CryptoKit import DoryCore +import DoryOperations import Foundation public struct MachineManagerConfiguration: Sendable, Equatable { public var vmmExecutablePath: String + /// Raw-Hypervisor helper used only for accelerated Linux desktops. EFI installers and + /// headless machines retain the established Virtualization.framework helper. + public var acceleratedDesktopExecutablePath: String? public var stateDirectory: String public var runtimeDirectory: String public var baseArguments: [String] + public var acceleratedDesktopBaseArguments: [String] public var passMachineArguments: Bool public var logDirectory: String public var requiresReadyHandoff: Bool + /// Headless Linux guests should publish the agent handoff promptly. Keep this bounded so a + /// helper that is alive but unable to boot does not remain in the starting state. + public var handoffReadyTimeoutSeconds: TimeInterval + /// Full desktop first boots perform account, display-manager, graphics, and share setup before + /// the helper can publish readiness. This must exceed the desktop helper's own preparation + /// budget, while still remaining bounded. + public var desktopHandoffReadyTimeoutSeconds: TimeInterval + /// Bounded helper retries are active only until the ready handoff. This absorbs transient + /// Virtualization.framework resource release races without masking a later VM crash. + public var startupRestartPolicy: HvRestartPolicy public var guestArchitecture: String /// Host SSH agent made available to ordinary machines. Sandboxes only receive it when their /// persisted policy contains the explicit DORY_SANDBOX_SSH_AGENT=1 grant. @@ -17,22 +32,37 @@ public struct MachineManagerConfiguration: Sendable, Equatable { public init( vmmExecutablePath: String, + acceleratedDesktopExecutablePath: String? = nil, stateDirectory: String, runtimeDirectory: String? = nil, baseArguments: [String] = [], + acceleratedDesktopBaseArguments: [String] = [], passMachineArguments: Bool = true, logDirectory: String? = nil, requiresReadyHandoff: Bool = true, + handoffReadyTimeoutSeconds: TimeInterval = 60, + desktopHandoffReadyTimeoutSeconds: TimeInterval = 180, + startupRestartPolicy: HvRestartPolicy = HvRestartPolicy( + maxRestarts: 4, + delaySeconds: 0.25, + maximumDelaySeconds: 2, + stableRunSeconds: 0 + ), guestArchitecture: String? = nil, sshAgentSocketPath: String? = nil ) { self.vmmExecutablePath = vmmExecutablePath + self.acceleratedDesktopExecutablePath = acceleratedDesktopExecutablePath self.stateDirectory = stateDirectory self.runtimeDirectory = runtimeDirectory ?? stateDirectory self.baseArguments = baseArguments + self.acceleratedDesktopBaseArguments = acceleratedDesktopBaseArguments self.passMachineArguments = passMachineArguments self.logDirectory = logDirectory ?? "\(stateDirectory)/logs" self.requiresReadyHandoff = requiresReadyHandoff + self.handoffReadyTimeoutSeconds = handoffReadyTimeoutSeconds + self.desktopHandoffReadyTimeoutSeconds = desktopHandoffReadyTimeoutSeconds + self.startupRestartPolicy = startupRestartPolicy self.guestArchitecture = guestArchitecture ?? Self.currentGuestArchitecture self.sshAgentSocketPath = sshAgentSocketPath } @@ -164,10 +194,18 @@ public enum DoryMachineDisplayMode: String, Sendable, Equatable, Hashable, Codab case desktop } +public enum DoryMachineBootMode: String, Sendable, Equatable, Hashable, Codable, CaseIterable { + case linuxKernel = "linux-kernel" + case efi +} + public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { public var id: String public var kernelPath: String public var rootfsPath: String + public var bootMode: DoryMachineBootMode + public var installerISOPath: String? + public var diskSizeBytes: UInt64? public var memoryMB: UInt64 public var cpuCount: Int public var address: String? @@ -179,6 +217,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { id: String, kernelPath: String, rootfsPath: String, + bootMode: DoryMachineBootMode = .linuxKernel, + installerISOPath: String? = nil, + diskSizeBytes: UInt64? = nil, memoryMB: UInt64 = 2048, cpuCount: Int = 2, address: String? = nil, @@ -189,6 +230,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { self.id = id self.kernelPath = kernelPath self.rootfsPath = rootfsPath + self.bootMode = bootMode + self.installerISOPath = installerISOPath + self.diskSizeBytes = diskSizeBytes self.memoryMB = memoryMB self.cpuCount = cpuCount self.address = address @@ -201,6 +245,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { case id case kernelPath case rootfsPath + case bootMode + case installerISOPath + case diskSizeBytes case memoryMB case cpuCount case address @@ -215,6 +262,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { id: try container.decode(String.self, forKey: .id), kernelPath: try container.decode(String.self, forKey: .kernelPath), rootfsPath: try container.decode(String.self, forKey: .rootfsPath), + bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel, + installerISOPath: try container.decodeIfPresent(String.self, forKey: .installerISOPath), + diskSizeBytes: try container.decodeIfPresent(UInt64.self, forKey: .diskSizeBytes), memoryMB: try container.decodeIfPresent(UInt64.self, forKey: .memoryMB) ?? 2048, cpuCount: try container.decodeIfPresent(Int.self, forKey: .cpuCount) ?? 2, address: try container.decodeIfPresent(String.self, forKey: .address), @@ -254,6 +304,8 @@ public struct DoryMachineStatus: Sendable, Equatable { public var currentBalloonTargetMB: UInt64 public var cpuCount: Int public var displayMode: DoryMachineDisplayMode + public var bootMode: DoryMachineBootMode + public var installerMediaAttached: Bool public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] @@ -276,6 +328,8 @@ public struct DoryMachineStatus: Sendable, Equatable { currentBalloonTargetMB: UInt64? = nil, cpuCount: Int = 0, displayMode: DoryMachineDisplayMode = .headless, + bootMode: DoryMachineBootMode = .linuxKernel, + installerMediaAttached: Bool = false, shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:] ) { @@ -297,6 +351,8 @@ public struct DoryMachineStatus: Sendable, Equatable { self.currentBalloonTargetMB = currentBalloonTargetMB ?? memoryMB self.cpuCount = cpuCount self.displayMode = displayMode + self.bootMode = bootMode + self.installerMediaAttached = installerMediaAttached self.shares = shares self.environment = environment } @@ -317,6 +373,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var address: String? public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var bootMode: DoryMachineBootMode + public var machineIdentifierPath: String? + public var nvramPath: String? public init( id: String, @@ -332,7 +391,10 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { displayMode: DoryMachineDisplayMode = .headless, address: String? = nil, shares: [DoryMachineShareConfiguration] = [], - environment: [String: String] = [:] + environment: [String: String] = [:], + bootMode: DoryMachineBootMode = .linuxKernel, + machineIdentifierPath: String? = nil, + nvramPath: String? = nil ) { self.id = id self.machineID = machineID @@ -348,6 +410,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.address = address self.shares = shares self.environment = environment + self.bootMode = bootMode + self.machineIdentifierPath = machineIdentifierPath + self.nvramPath = nvramPath } private enum CodingKeys: String, CodingKey { @@ -365,6 +430,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case address case shares case environment + case bootMode + case machineIdentifierPath + case nvramPath } public init(from decoder: Decoder) throws { @@ -383,11 +451,69 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { displayMode: try container.decodeIfPresent(DoryMachineDisplayMode.self, forKey: .displayMode) ?? .headless, address: try container.decodeIfPresent(String.self, forKey: .address), shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [], - environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] + environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:], + bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel, + machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath), + nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath) ) } } +public struct DoryDesktopUpdateRequest: Sendable, Equatable { + public var distro: String + public var version: String + public var bundlePath: String + public var kernelPath: String + + public init(distro: String, version: String, bundlePath: String, kernelPath: String) { + self.distro = distro + self.version = version + self.bundlePath = bundlePath + self.kernelPath = kernelPath + } +} + +public struct DoryDesktopUpdateResult: Sendable, Equatable { + public var machineID: String + public var distro: String + public var version: String + public var inputSHA256: String + public var bundleSHA256: String + public var snapshotID: String + public var status: DoryMachineStatus + public var restoredRunningState: Bool + + public init( + machineID: String, + distro: String, + version: String, + inputSHA256: String, + bundleSHA256: String, + snapshotID: String, + status: DoryMachineStatus, + restoredRunningState: Bool + ) { + self.machineID = machineID + self.distro = distro + self.version = version + self.inputSHA256 = inputSHA256 + self.bundleSHA256 = bundleSHA256 + self.snapshotID = snapshotID + self.status = status + self.restoredRunningState = restoredRunningState + } +} + +private struct DesktopUpdateJournal: Codable, Sendable, Equatable { + var schema: Int + var machineID: String + var distro: String + var version: String + var snapshotID: String + var originalWasRunning: Bool + var stage: String +} + public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvertible { case duplicateMachine(String) case invalidID(String) @@ -439,16 +565,20 @@ public final class MachineManager: @unchecked Sendable { public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void - private static let handoffReadyTimeoutSeconds: TimeInterval = 60 private static let deletionQuarantinePrefix = ".dory-machine-delete-" private static let machineDiskTemporaryPrefix = ".rootfs.ext4.tmp-" private static let machineKernelTemporaryPrefix = ".kernel.tmp-" + private static let installedLinuxKernelName = "direct-kernel" + private static let installedLinuxInitrdName = "direct-initrd" private static let machineMetadataTemporaryPrefix = ".dory-machine-metadata-" private static let machineRestoreBackupMarker = ".restore-" private static let snapshotDeletionQuarantinePrefix = ".dory-snapshot-delete-" private static let snapshotDiskTemporaryMarker = ".ext4.tmp-" private static let snapshotKernelTemporaryMarker = ".kernel.tmp-" + private static let snapshotMachineIdentifierTemporaryMarker = ".machine-identifier.tmp-" + private static let snapshotNVRAMTemporaryMarker = ".nvram.tmp-" private static let snapshotMetadataTemporaryPrefix = ".dory-snapshot-metadata-" + private static let desktopUpdateJournalName = "desktop-update.json" private static let maximumPersistedMetadataBytes: Int64 = 16 * 1024 * 1024 /// Public Apple-Silicon machine resource contract. These match the app's steppers; enforcing /// them again in doryd prevents CLI/XPC callers from persisting values that the VMM would later @@ -457,6 +587,8 @@ public final class MachineManager: @unchecked Sendable { public static let maximumMachineMemoryMB: UInt64 = 16 * 1024 public static let minimumMachineCPUCount = 1 public static let maximumMachineCPUCount = 8 + public static let minimumEFIDiskSizeBytes: UInt64 = 8 * 1024 * 1024 * 1024 + public static let maximumEFIDiskSizeBytes: UInt64 = 2 * 1024 * 1024 * 1024 * 1024 private let configuration: MachineManagerConfiguration private let agentConnector: AgentConnector @@ -484,10 +616,19 @@ public final class MachineManager: @unchecked Sendable { stateDirectory: configuration.stateDirectory, includeDescendants: true ) + if let acceleratedDesktopExecutablePath = configuration.acceleratedDesktopExecutablePath, + acceleratedDesktopExecutablePath != configuration.vmmExecutablePath { + _ = HelperProcessJanitor.terminateStaleHelpers( + executablePath: acceleratedDesktopExecutablePath, + stateDirectory: configuration.stateDirectory, + includeDescendants: true + ) + } Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory) Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory) Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory) self.machines = Self.loadPersistedMachines(configuration: configuration) + recoverInterruptedDesktopUpdates() } @discardableResult @@ -506,9 +647,42 @@ public final class MachineManager: @unchecked Sendable { guard !exists else { throw MachineManagerError.duplicateMachine(machine.id) } - guard Self.isRegularNonemptyFile(path: machine.kernelPath) else { - throw MachineManagerError.persistence("machine kernel is missing or invalid: \(machine.kernelPath)") + switch machine.bootMode { + case .linuxKernel: + guard Self.isRegularNonemptyFile(path: machine.kernelPath) else { + throw MachineManagerError.persistence("machine kernel is missing or invalid: \(machine.kernelPath)") + } + guard Self.isRegularNonemptyFile(path: machine.rootfsPath) else { + throw MachineManagerError.persistence("machine rootfs is missing or invalid: \(machine.rootfsPath)") + } + case .efi: + guard machine.displayMode == .desktop else { + throw MachineManagerError.persistence("EFI installer machines require desktop display mode") + } + if let installerISOPath = machine.installerISOPath { + guard Self.isRegularNonemptyFile(path: installerISOPath) else { + throw MachineManagerError.persistence("installer ISO is missing or invalid: \(installerISOPath)") + } + } + if Self.isRegularNonemptyFile(path: machine.rootfsPath) { + guard machine.diskSizeBytes == nil else { + throw MachineManagerError.persistence("EFI disk imports cannot also specify diskSizeBytes") + } + } else { + guard machine.installerISOPath != nil else { + throw MachineManagerError.persistence("a new EFI machine requires an installer ISO") + } + guard let diskSizeBytes = machine.diskSizeBytes, + (Self.minimumEFIDiskSizeBytes...Self.maximumEFIDiskSizeBytes).contains(diskSizeBytes) else { + throw MachineManagerError.persistence( + "EFI disk size must be between \(Self.minimumEFIDiskSizeBytes) and \(Self.maximumEFIDiskSizeBytes) bytes" + ) + } + } } + // Reject incompatible media before allocating a virtual disk or importing a potentially + // multi-gigabyte ISO. The managed copy is created only after this preflight succeeds. + try validateInstallerArchitecture(machine) let fileManager = FileManager.default do { try fileManager.createDirectory(atPath: configuration.stateDirectory, withIntermediateDirectories: true) @@ -545,6 +719,9 @@ public final class MachineManager: @unchecked Sendable { configuredAddress: preparedMachine.address, memoryMB: preparedMachine.memoryMB, cpuCount: preparedMachine.cpuCount, + displayMode: preparedMachine.displayMode, + bootMode: preparedMachine.bootMode, + installerMediaAttached: preparedMachine.installerISOPath != nil, shares: preparedMachine.shares, environment: preparedMachine.environment ) @@ -559,13 +736,26 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw MachineManagerError.unknownMachine(id) } + if entry.process?.isRunning == true { + lock.unlock() + throw MachineManagerError.alreadyRunning(id) + } + lock.unlock() do { + try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration) + try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration) try Self.validateLaunchConfiguration(entry.configuration) try validateManagedMachineArtifacts(entry.configuration) + try validateRuntimeAvailability(entry.configuration) } catch { - lock.unlock() throw error } + lock.lock() + guard let currentEntry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + entry = currentEntry if entry.process?.isRunning == true { lock.unlock() throw MachineManagerError.alreadyRunning(id) @@ -585,7 +775,19 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw error } - let process = HvProcess(configuration: processConfiguration(for: entry.configuration, handoffPath: handoffPath)) + let processConfiguration: HvProcessConfiguration + do { + processConfiguration = try self.processConfiguration( + for: entry.configuration, + handoffPath: handoffPath + ) + } catch { + handoffServer?.stop() + lock.unlock() + throw error + } + let process = HvProcess(configuration: processConfiguration) + let handoffReadyTimeout = handoffReadyTimeout(for: entry.configuration) entry.process = process entry.handoffServer = handoffServer entry.handoff = nil @@ -611,7 +813,7 @@ public final class MachineManager: @unchecked Sendable { throw error } if configuration.requiresReadyHandoff { - scheduleHandoffTimeout(id: id, process: process) + scheduleHandoffTimeout(id: id, process: process, timeout: handoffReadyTimeout) } return status(id: id) ?? DoryMachineStatus(id: id, state: .running) } @@ -620,7 +822,11 @@ public final class MachineManager: @unchecked Sendable { let started = try start(id: id) guard started.state == .starting else { return started } - let deadline = Date().addingTimeInterval(Self.handoffReadyTimeoutSeconds + 1) + let handoffReadyTimeout = handoffReadyTimeout( + displayMode: started.displayMode, + bootMode: started.bootMode + ) + let deadline = Date().addingTimeInterval(handoffReadyTimeout + 1) while Date() < deadline { guard let current = status(id: id) else { throw MachineManagerError.unknownMachine(id) @@ -643,11 +849,25 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("vmm ready handoff timed out for \(id)") } - private func scheduleHandoffTimeout(id: String, process: HvProcess) { + private func handoffReadyTimeout(for machine: DoryMachineConfiguration) -> TimeInterval { + handoffReadyTimeout(displayMode: machine.displayMode, bootMode: machine.bootMode) + } + + private func handoffReadyTimeout( + displayMode: DoryMachineDisplayMode, + bootMode: DoryMachineBootMode + ) -> TimeInterval { + if displayMode == .desktop { + return configuration.desktopHandoffReadyTimeoutSeconds + } + return configuration.handoffReadyTimeoutSeconds + } + + private func scheduleHandoffTimeout(id: String, process: HvProcess, timeout: TimeInterval) { // A VMM that boots but never completes the ready handoff would otherwise leave the // machine `.starting` forever. Bound the wait: if this exact launch is still starting // when the deadline passes, mark it failed and tear the helper down. - DispatchQueue.global().asyncAfter(deadline: .now() + Self.handoffReadyTimeoutSeconds) { [weak self] in + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in guard let self else { return } self.lock.lock() guard var entry = self.machines[id], @@ -663,7 +883,7 @@ public final class MachineManager: @unchecked Sendable { entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil entry.state = .failed - entry.lastError = "vmm ready handoff timed out after \(Int(Self.handoffReadyTimeoutSeconds))s" + entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s" self.machines[id] = entry self.lock.unlock() process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) @@ -783,7 +1003,8 @@ public final class MachineManager: @unchecked Sendable { shares: [DoryMachineShareConfiguration]? = nil, updatesShares: Bool = false, environment: [String: String]? = nil, - updatesEnvironment: Bool = false + updatesEnvironment: Bool = false, + installerMediaAttached: Bool? = nil ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } @@ -804,6 +1025,20 @@ public final class MachineManager: @unchecked Sendable { if updatesEnvironment { updated.environment = environment ?? [:] } + if let installerMediaAttached { + guard updated.bootMode == .efi else { + throw MachineManagerError.persistence("installer media is only available for EFI machines") + } + if installerMediaAttached { + let managedInstaller = machineInstallerISOPath(id: id) + guard Self.isPrivateRegularFile(path: managedInstaller) else { + throw MachineManagerError.persistence("managed installer ISO is unavailable") + } + updated.installerISOPath = managedInstaller + } else { + updated.installerISOPath = nil + } + } try Self.validateLaunchConfiguration(updated) guard updated != current else { return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) @@ -812,6 +1047,7 @@ public final class MachineManager: @unchecked Sendable { || updated.cpuCount != current.cpuCount || updated.shares != current.shares || updated.environment != current.environment + || updated.installerISOPath != current.installerISOPath if !requiresRestart { do { try persist(updated) @@ -826,6 +1062,11 @@ public final class MachineManager: @unchecked Sendable { _ = try stop(id: id) } do { + if current.bootMode == .efi, + current.installerISOPath != nil, + updated.installerISOPath == nil { + try ensureInstalledLinuxBootBundleIfNeeded(updated) + } try persist(updated) try publishConfiguration(updated) } catch { @@ -887,9 +1128,13 @@ public final class MachineManager: @unchecked Sendable { try ensurePrivateSnapshotDirectory(machineID: id) let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID) let kernelPath = snapshotKernelPath(machineID: id, snapshotID: snapshotID) + let machineIdentifierPath = snapshotMachineIdentifierPath(machineID: id, snapshotID: snapshotID) + let nvramPath = snapshotNVRAMPath(machineID: id, snapshotID: snapshotID) guard !Self.pathEntryExists(snapshotMetadataPath(machineID: id, snapshotID: snapshotID)), !Self.pathEntryExists(rootfsPath), - !Self.pathEntryExists(kernelPath) else { + !Self.pathEntryExists(kernelPath), + !Self.pathEntryExists(machineIdentifierPath), + !Self.pathEntryExists(nvramPath) else { throw MachineManagerError.duplicateSnapshot(snapshotID) } @@ -899,11 +1144,30 @@ public final class MachineManager: @unchecked Sendable { let snapshot: DoryMachineSnapshot var publishedRootfs = false var publishedKernel = false + var publishedMachineIdentifier = false + var publishedNVRAM = false do { try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsPath) publishedRootfs = true try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelPath) publishedKernel = true + if machine.bootMode == .efi { + let liveMachineIdentifierPath = machineFirmwareIdentifierPath(id: id) + let liveNVRAMPath = machineFirmwareNVRAMPath(id: id) + guard Self.isPrivateRegularFile(path: liveMachineIdentifierPath), + Self.isPrivateRegularFile(path: liveNVRAMPath) else { + throw MachineManagerError.persistence( + "EFI firmware state is unavailable; start the machine once before taking a snapshot" + ) + } + try Self.cloneOrCopyFile( + source: liveMachineIdentifierPath, + destination: machineIdentifierPath + ) + publishedMachineIdentifier = true + try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath) + publishedNVRAM = true + } snapshot = DoryMachineSnapshot( id: snapshotID, machineID: id, @@ -918,7 +1182,10 @@ public final class MachineManager: @unchecked Sendable { displayMode: machine.displayMode, address: machine.address, shares: machine.shares, - environment: machine.environment + environment: machine.environment, + bootMode: machine.bootMode, + machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, + nvramPath: machine.bootMode == .efi ? nvramPath : nil ) try persistSnapshot(snapshot) } catch { @@ -928,6 +1195,12 @@ public final class MachineManager: @unchecked Sendable { if publishedKernel { try? FileManager.default.removeItem(atPath: kernelPath) } + if publishedMachineIdentifier { + try? FileManager.default.removeItem(atPath: machineIdentifierPath) + } + if publishedNVRAM { + try? FileManager.default.removeItem(atPath: nvramPath) + } if wasRunning { _ = try? start(id: id) } @@ -953,6 +1226,197 @@ public final class MachineManager: @unchecked Sendable { return snapshot } + /// Applies a signed desktop component to an existing persistent guest. The caller provides + /// component-store paths, but doryd owns the entire transaction: a last-good disk/kernel + /// snapshot is durable before the guest is mutated, and any failed install or post-reboot + /// qualification restores that snapshot and the machine's original running state. + public func updateDesktop( + id: String, + request: DoryDesktopUpdateRequest + ) throws -> DoryDesktopUpdateResult { + operationLock.lock() + defer { operationLock.unlock() } + + let (original, originallyRunning) = try configurationAndRunningState(id: id) + guard original.bootMode == .linuxKernel else { + throw MachineManagerError.persistence("managed desktop updates do not apply to custom EFI machines") + } + guard original.displayMode == .desktop else { + throw MachineManagerError.persistence("desktop updates require a graphical machine") + } + guard ["debian", "ubuntu", "kali"].contains(request.distro), + original.environment["DORY_DESKTOP_DISTRO"] == request.distro else { + throw MachineManagerError.persistence("desktop update distribution does not match " + id) + } + guard request.version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else { + throw MachineManagerError.persistence("desktop update version is invalid") + } + guard Self.isRegularNonemptyFile(path: request.bundlePath), + Self.isRegularNonemptyFile(path: request.kernelPath) else { + throw MachineManagerError.persistence("desktop update assets are missing or invalid") + } + let bundleURL = URL(fileURLWithPath: request.bundlePath).standardizedFileURL + let bundleDirectory = bundleURL.deletingLastPathComponent().path + guard Self.isDirectory(path: bundleDirectory), bundleURL.lastPathComponent != "." else { + throw MachineManagerError.persistence("desktop update bundle directory is invalid") + } + let bundleSHA256 = try Self.sha256(path: request.bundlePath) + let snapshotID = Self.generatedSnapshotID(prefix: "du") + let snapshot = try snapshot( + id: id, + note: "Automatic last-good snapshot before " + request.distro + " " + request.version + " desktop update", + snapshotID: snapshotID + ) + var journal = DesktopUpdateJournal( + schema: 1, + machineID: id, + distro: request.distro, + version: request.version, + snapshotID: snapshot.id, + originalWasRunning: originallyRunning, + stage: "snapshot-ready" + ) + try persistDesktopUpdateJournal(journal) + + let token = String(bundleSHA256.prefix(12)) + let mountPath = "/mnt/dory-update-" + token + let guestStage = "/var/lib/dory/update-" + token + let updateShare = DoryMachineShareConfiguration( + tag: "dory-update-" + token, + hostPath: bundleDirectory, + guestPath: mountPath, + readOnly: true + ) + let bundleGuestPath = mountPath + "/" + bundleURL.lastPathComponent + + do { + var transientShares = original.shares.filter { $0.tag != updateShare.tag } + transientShares.append(updateShare) + _ = try update(id: id, shares: transientShares, updatesShares: true) + if status(id: id)?.state != .running { + _ = try startAndWaitUntilReady(id: id) + } + journal.stage = "installing" + try persistDesktopUpdateJournal(journal) + + try requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/rm", "-rf", guestStage], + stage: "clear guest staging" + ) + try requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/mkdir", "-p", guestStage], + stage: "create guest staging" + ) + try requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/tar", "-xf", bundleGuestPath, "-C", guestStage], + timeoutMs: 120_000, + stage: "extract signed payload" + ) + let install = try requireSuccessfulDesktopUpdateExec( + id: id, + argv: [guestStage + "/apply.sh", request.distro, request.version], + timeoutMs: 3_600_000, + outputLimitBytes: 16 * 1024 * 1024, + stage: "install guest update" + ) + var inputSHA256 = Self.desktopUpdateInputSHA256(from: install) + if inputSHA256 == nil { + // Package managers can emit more than the bounded exec output and displace the + // final success line. The update writes this receipt atomically before exiting, + // so read it directly instead of rolling back a successfully applied payload. + let receipt = try requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/cat", "/var/lib/dory/desktop-update.env"], + outputLimitBytes: 16 * 1024, + stage: "read the installed desktop update receipt" + ) + inputSHA256 = Self.desktopUpdateReceiptInputSHA256( + from: receipt, + distro: request.distro, + version: request.version + ) + } + guard let inputSHA256 else { + throw MachineManagerError.persistence("desktop update did not return its input fingerprint") + } + + _ = try? requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/rm", "-rf", guestStage], + stage: "clear applied payload" + ) + _ = try stop(id: id) + var updatedEnvironment = original.environment + updatedEnvironment["DORY_DESKTOP_RELEASE_VERSION"] = request.version + updatedEnvironment["DORY_DESKTOP_INPUT_SHA256"] = inputSHA256 + _ = try update( + id: id, + shares: original.shares, + updatesShares: true, + environment: updatedEnvironment, + updatesEnvironment: true + ) + try Self.cloneOrCopyFile( + source: request.kernelPath, + destination: original.kernelPath, + replaceExisting: true + ) + journal.stage = "qualifying" + try persistDesktopUpdateJournal(journal) + _ = try startAndWaitUntilReady(id: id) + try qualifyUpdatedDesktop( + id: id, + distro: request.distro, + version: request.version, + inputSHA256: inputSHA256 + ) + + let finalStatus: DoryMachineStatus + if originallyRunning { + finalStatus = status(id: id) ?? DoryMachineStatus(id: id, state: .running) + } else { + finalStatus = try stop(id: id) + } + journal.stage = "committed" + try persistDesktopUpdateJournal(journal) + try removeDesktopUpdateJournal(machineID: id) + return DoryDesktopUpdateResult( + machineID: id, + distro: request.distro, + version: request.version, + inputSHA256: inputSHA256, + bundleSHA256: bundleSHA256, + snapshotID: snapshot.id, + status: finalStatus, + restoredRunningState: originallyRunning + ) + } catch { + let updateError = error + do { + _ = try? stop(id: id) + _ = try restoreSnapshot(machineID: id, snapshotID: snapshot.id) + if originallyRunning, status(id: id)?.state != .running { + _ = try startAndWaitUntilReady(id: id) + } else if !originallyRunning, status(id: id)?.state == .running { + _ = try stop(id: id) + } + try removeDesktopUpdateJournal(machineID: id) + } catch { + throw MachineManagerError.persistence( + "desktop update " + request.version + " failed: " + String(describing: updateError) + + "; automatic rollback failed: " + String(describing: error) + ) + } + throw MachineManagerError.persistence( + "desktop update " + request.version + " failed: " + String(describing: updateError) + + "; last-good snapshot " + snapshot.id + " was restored" + ) + } + } + public func listSnapshots(machineID: String? = nil) throws -> [DoryMachineSnapshot] { operationLock.lock() defer { operationLock.unlock() } @@ -997,6 +1461,7 @@ public final class MachineManager: @unchecked Sendable { id: newID, kernelPath: snapshot.kernelPath, rootfsPath: snapshot.rootfsPath, + bootMode: snapshot.bootMode, memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount, address: nil, @@ -1006,6 +1471,17 @@ public final class MachineManager: @unchecked Sendable { ) _ = try create(machine) do { + if snapshot.bootMode == .efi { + guard let snapshotNVRAMPath = snapshot.nvramPath else { + throw MachineManagerError.persistence("EFI snapshot is missing NVRAM state") + } + // A clone gets a new Virtualization.framework machine identifier, but retains + // the guest's EFI variables (including its installed boot entries). + try Self.cloneOrCopyFile( + source: snapshotNVRAMPath, + destination: machineFirmwareNVRAMPath(id: newID) + ) + } return try start(id: newID) } catch { do { @@ -1024,6 +1500,9 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) let (machine, wasRunning) = try configurationAndRunningState(id: machineID) + guard machine.bootMode == snapshot.bootMode else { + throw MachineManagerError.persistence("snapshot boot mode does not match the machine") + } let address = try Self.normalizedAddress(snapshot.address) try Self.validateShares(snapshot.shares) try Self.validateEnvironment(snapshot.environment) @@ -1031,6 +1510,7 @@ public final class MachineManager: @unchecked Sendable { restoredMachine.memoryMB = snapshot.memoryMB restoredMachine.cpuCount = snapshot.cpuCount restoredMachine.displayMode = snapshot.displayMode + restoredMachine.bootMode = snapshot.bootMode restoredMachine.address = address restoredMachine.shares = snapshot.shares restoredMachine.environment = snapshot.environment @@ -1068,56 +1548,51 @@ public final class MachineManager: @unchecked Sendable { public func deleteSnapshot(machineID: String, snapshotID: String) throws { operationLock.lock() defer { operationLock.unlock() } - _ = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) + let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) let metadataPath = snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID) let rootfsPath = snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID) let kernelPath = snapshotKernelPath(machineID: machineID, snapshotID: snapshotID) let token = "\(Self.snapshotDeletionQuarantinePrefix)\(snapshotID)-\(UUID().uuidString)" let directory = snapshotDirectory(machineID: machineID) - let quarantinedMetadataPath = "\(directory)/\(token).json" - let quarantinedRootfsPath = "\(directory)/\(token).ext4" - let quarantinedKernelPath = "\(directory)/\(token).kernel" - do { - try FileManager.default.moveItem(atPath: rootfsPath, toPath: quarantinedRootfsPath) - } catch { - throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)") - } + var artifacts: [(label: String, source: String, quarantine: String)] = [ + ("rootfs", rootfsPath, "\(directory)/\(token).ext4"), + ("kernel", kernelPath, "\(directory)/\(token).kernel"), + ] + if snapshot.bootMode == .efi { + artifacts.append(( + "machine identifier", + snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID), + "\(directory)/\(token).machine-identifier" + )) + artifacts.append(( + "NVRAM", + snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID), + "\(directory)/\(token).nvram" + )) + } + artifacts.append(("metadata", metadataPath, "\(directory)/\(token).json")) + + var quarantined: [(label: String, source: String, quarantine: String)] = [] do { - try FileManager.default.moveItem(atPath: kernelPath, toPath: quarantinedKernelPath) - } catch { - do { - try FileManager.default.moveItem(atPath: quarantinedRootfsPath, toPath: rootfsPath) - } catch let rollbackError { - throw MachineManagerError.persistence( - "could not delete snapshot \(snapshotID): \(error); rootfs rollback failed: \(rollbackError)" - ) + for artifact in artifacts { + try FileManager.default.moveItem(atPath: artifact.source, toPath: artifact.quarantine) + quarantined.append(artifact) } - throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)") - } - do { - try FileManager.default.moveItem(atPath: metadataPath, toPath: quarantinedMetadataPath) } catch { var rollbackFailures: [String] = [] - do { - try FileManager.default.moveItem(atPath: quarantinedKernelPath, toPath: kernelPath) - } catch { - rollbackFailures.append("kernel rollback failed: \(error)") - } - do { - try FileManager.default.moveItem(atPath: quarantinedRootfsPath, toPath: rootfsPath) - } catch { - rollbackFailures.append("rootfs rollback failed: \(error)") - } - if !rollbackFailures.isEmpty { - throw MachineManagerError.persistence( - "could not delete snapshot \(snapshotID): \(error); \(rollbackFailures.joined(separator: "; "))" - ) + for artifact in quarantined.reversed() { + do { + try FileManager.default.moveItem(atPath: artifact.quarantine, toPath: artifact.source) + } catch { + rollbackFailures.append("\(artifact.label) rollback failed: \(error)") + } } - throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)") + let suffix = rollbackFailures.isEmpty ? "" : "; \(rollbackFailures.joined(separator: "; "))" + throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)\(suffix)") + } + for artifact in quarantined { + try? FileManager.default.removeItem(atPath: artifact.quarantine) } - try? FileManager.default.removeItem(atPath: quarantinedMetadataPath) - try? FileManager.default.removeItem(atPath: quarantinedRootfsPath) - try? FileManager.default.removeItem(atPath: quarantinedKernelPath) removeEmptyImportedSnapshotNamespace(machineID: machineID) } @@ -1139,6 +1614,8 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } var extractedRootfsPath: String? var extractedKernelPath: String? + var extractedMachineIdentifierPath: String? + var extractedNVRAMPath: String? var importedMachineID: String? do { let bundle = try MachineSnapshotBundle.readDescriptor(fromPath: path) @@ -1163,14 +1640,28 @@ public final class MachineManager: @unchecked Sendable { ) snapshot.rootfsPath = snapshotRootfsPath(machineID: snapshot.machineID, snapshotID: snapshot.id) snapshot.kernelPath = snapshotKernelPath(machineID: snapshot.machineID, snapshotID: snapshot.id) + if snapshot.bootMode == .efi { + snapshot.machineIdentifierPath = snapshotMachineIdentifierPath( + machineID: snapshot.machineID, + snapshotID: snapshot.id + ) + snapshot.nvramPath = snapshotNVRAMPath(machineID: snapshot.machineID, snapshotID: snapshot.id) + } else { + snapshot.machineIdentifierPath = nil + snapshot.nvramPath = nil + } try MachineSnapshotBundle.extractArtifacts( fromPath: path, expectedContentID: bundle.contentID, rootfsPath: snapshot.rootfsPath, - kernelPath: snapshot.kernelPath + kernelPath: snapshot.kernelPath, + machineIdentifierPath: snapshot.machineIdentifierPath, + nvramPath: snapshot.nvramPath ) extractedRootfsPath = snapshot.rootfsPath extractedKernelPath = snapshot.kernelPath + extractedMachineIdentifierPath = snapshot.machineIdentifierPath + extractedNVRAMPath = snapshot.nvramPath snapshot.sizeBytes = Self.fileSize(path: snapshot.rootfsPath) try persistSnapshot(snapshot) return snapshot @@ -1181,6 +1672,12 @@ public final class MachineManager: @unchecked Sendable { if let extractedKernelPath { try? FileManager.default.removeItem(atPath: extractedKernelPath) } + if let extractedMachineIdentifierPath { + try? FileManager.default.removeItem(atPath: extractedMachineIdentifierPath) + } + if let extractedNVRAMPath { + try? FileManager.default.removeItem(atPath: extractedNVRAMPath) + } if let importedMachineID { removeEmptyImportedSnapshotNamespace(machineID: importedMachineID) } @@ -1192,6 +1689,12 @@ public final class MachineManager: @unchecked Sendable { if let extractedKernelPath { try? FileManager.default.removeItem(atPath: extractedKernelPath) } + if let extractedMachineIdentifierPath { + try? FileManager.default.removeItem(atPath: extractedMachineIdentifierPath) + } + if let extractedNVRAMPath { + try? FileManager.default.removeItem(atPath: extractedNVRAMPath) + } if let importedMachineID { removeEmptyImportedSnapshotNamespace(machineID: importedMachineID) } @@ -1216,7 +1719,7 @@ public final class MachineManager: @unchecked Sendable { } private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { - if [.starting, .running].contains(entry.state), entry.process?.isRunning != true { + if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( id: id, state: .failed, @@ -1226,6 +1729,8 @@ public final class MachineManager: @unchecked Sendable { memoryMB: entry.configuration.memoryMB, cpuCount: entry.configuration.cpuCount, displayMode: entry.configuration.displayMode, + bootMode: entry.configuration.bootMode, + installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment ) @@ -1249,6 +1754,8 @@ public final class MachineManager: @unchecked Sendable { currentBalloonTargetMB: entry.currentBalloonTargetMB ?? entry.configuration.memoryMB, cpuCount: entry.configuration.cpuCount, displayMode: entry.configuration.displayMode, + bootMode: entry.configuration.bootMode, + installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment ) @@ -1257,31 +1764,132 @@ public final class MachineManager: @unchecked Sendable { private func processConfiguration( for machine: DoryMachineConfiguration, handoffPath: String? - ) -> HvProcessConfiguration { - HvProcessConfiguration( - executablePath: configuration.vmmExecutablePath, - arguments: processArguments(for: machine, handoffPath: handoffPath), - logPath: "\(configuration.logDirectory)/\(machine.id).log" + ) throws -> HvProcessConfiguration { + let target = processTarget(for: machine) + return HvProcessConfiguration( + executablePath: target.executablePath, + arguments: try processArguments( + for: machine, + handoffPath: handoffPath, + baseArguments: target.baseArguments, + acceleratedDesktop: target.acceleratedDesktop + ), + logPath: "\(configuration.logDirectory)/\(machine.id).log", + restartPolicy: configuration.requiresReadyHandoff + ? configuration.startupRestartPolicy + : .none ) } - private func processArguments(for machine: DoryMachineConfiguration, handoffPath: String?) -> [String] { - guard configuration.passMachineArguments else { - return configuration.baseArguments + private func processTarget(for machine: DoryMachineConfiguration) -> ( + executablePath: String, + baseArguments: [String], + acceleratedDesktop: Bool + ) { + let desktopPreference = try? DoryDesktopVMMPreference(environment: machine.environment) + let supportsAcceleratedBoot = machine.bootMode == .linuxKernel + || (machine.bootMode == .efi + && machine.installerISOPath == nil + && DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath)) + if configuration.passMachineArguments, + machine.displayMode == .desktop, + supportsAcceleratedBoot, + machine.installerISOPath == nil, + desktopPreference != .compatible, + let executablePath = configuration.acceleratedDesktopExecutablePath { + return (executablePath, configuration.acceleratedDesktopBaseArguments, true) + } + return (configuration.vmmExecutablePath, configuration.baseArguments, false) + } + + private func validateRuntimeAvailability(_ machine: DoryMachineConfiguration) throws { + guard machine.displayMode == .desktop, + machine.installerISOPath == nil else { + return + } + let preference = try DoryDesktopVMMPreference(environment: machine.environment) + let installedEFIDesktop = machine.bootMode == .efi + && DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) + if machine.bootMode == .efi, + preference == .accelerated, + !installedEFIDesktop { + throw MachineManagerError.persistence( + "accelerated installed-Linux runtime is unavailable; reattach and eject the installer media to derive its boot assets" + ) + } + guard preference == .accelerated || (installedEFIDesktop && preference != .compatible) else { + return } - var arguments = configuration.baseArguments + [ + guard let executablePath = configuration.acceleratedDesktopExecutablePath, + FileManager.default.isExecutableFile(atPath: executablePath) else { + throw MachineManagerError.persistence( + "accelerated desktop runtime was required but is unavailable" + ) + } + } + + private func validateInstallerArchitecture(_ machine: DoryMachineConfiguration) throws { + guard machine.bootMode == .efi, let installerISOPath = machine.installerISOPath else { + return + } + let detected: DoryInstallerISOArchitecture + do { + detected = try DoryInstallerISOInspector.architecture(atPath: installerISOPath) + } catch { + throw MachineManagerError.persistence("could not inspect installer ISO: \(error)") + } + switch DoryInstallerISOInspector.compatibility( + of: detected, + hostArchitecture: configuration.guestArchitecture + ) { + case let .incompatible(message): + throw MachineManagerError.persistence(message) + case .compatible, .unknown: + return + } + } + + private func processArguments( + for machine: DoryMachineConfiguration, + handoffPath: String?, + baseArguments: [String], + acceleratedDesktop: Bool + ) throws -> [String] { + guard configuration.passMachineArguments else { + return baseArguments + } + let acceleratedInstalledLinux = acceleratedDesktop + && machine.bootMode == .efi + && machine.installerISOPath == nil + let bootDescriptor = acceleratedInstalledLinux + ? try DoryInstalledLinuxBootBundle.descriptor(atPath: machine.kernelPath) + : nil + var arguments = baseArguments + [ "--machine-id", machine.id, "--state-dir", machineStateDirectory(id: machine.id), "--dockerd-sock", "\(machineRuntimeDirectory(id: machine.id))/d.sock", "--agent-sock", "\(machineRuntimeDirectory(id: machine.id))/a.sock", "--shell-sock", "\(machineRuntimeDirectory(id: machine.id))/s.sock", "--control-sock", "\(machineRuntimeDirectory(id: machine.id))/c.sock", - "--kernel", machine.kernelPath, + "--kernel", acceleratedInstalledLinux + ? machineInstalledLinuxKernelPath(id: machine.id) + : machine.kernelPath, "--rootfs", machine.rootfsPath, + "--boot-mode", acceleratedInstalledLinux ? "efi-installed" : machine.bootMode.rawValue, "--memory-mb", String(machine.memoryMB), "--cpus", String(machine.cpuCount), "--display-mode", machine.displayMode.rawValue, ] + if let bootDescriptor { + arguments.append(contentsOf: [ + "--initrd", machineInstalledLinuxInitrdPath(id: machine.id), + "--root-device", bootDescriptor.rootDevice, + "--generic-guest", + ]) + } + if let installerISOPath = machine.installerISOPath { + arguments.append(contentsOf: ["--installer-iso", installerISOPath]) + } if let handoffPath { arguments.append(contentsOf: ["--handoff-sock", handoffPath]) } @@ -1317,6 +1925,208 @@ public final class MachineManager: @unchecked Sendable { "\(machineRuntimeDirectory(id: id))/h.sock" } + private func desktopUpdateJournalPath(machineID: String) -> String { + "\(machineStateDirectory(id: machineID))/\(Self.desktopUpdateJournalName)" + } + + private func persistDesktopUpdateJournal(_ journal: DesktopUpdateJournal) throws { + let directory = machineStateDirectory(id: journal.machineID) + guard Self.isPrivateDirectory(path: directory) else { + throw MachineManagerError.persistence("desktop update journal owner is not private") + } + let temporaryPath = "\(directory)/.desktop-update.tmp-\(UUID().uuidString)" + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(journal) + try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath) + guard rename(temporaryPath, desktopUpdateJournalPath(machineID: journal.machineID)) == 0 else { + throw MachineManagerError.persistence( + "could not publish desktop update journal: \(String(cString: strerror(errno)))" + ) + } + } catch { + try? FileManager.default.removeItem(atPath: temporaryPath) + if let error = error as? MachineManagerError { throw error } + throw MachineManagerError.persistence("could not persist desktop update journal: \(error)") + } + } + + private func removeDesktopUpdateJournal(machineID: String) throws { + let path = desktopUpdateJournalPath(machineID: machineID) + guard Self.pathEntryExists(path) else { return } + do { + try FileManager.default.removeItem(atPath: path) + } catch { + throw MachineManagerError.persistence("could not remove desktop update journal: \(error)") + } + } + + private func recoverInterruptedDesktopUpdates() { + lock.lock() + let machineIDs = Array(machines.keys) + lock.unlock() + for machineID in machineIDs { + let path = desktopUpdateJournalPath(machineID: machineID) + guard let data = Self.readPrivateMetadata(path: path), + let journal = try? JSONDecoder().decode(DesktopUpdateJournal.self, from: data), + journal.schema == 1, + journal.machineID == machineID, + Self.isValidID(journal.snapshotID) else { + continue + } + if journal.stage == "committed" { + try? removeDesktopUpdateJournal(machineID: machineID) + continue + } + do { + _ = try restoreSnapshot(machineID: machineID, snapshotID: journal.snapshotID) + try removeDesktopUpdateJournal(machineID: machineID) + lock.lock() + if var entry = machines[machineID] { + entry.lastError = "An interrupted desktop update was rolled back to \(journal.snapshotID). Start the machine when ready." + machines[machineID] = entry + } + lock.unlock() + } catch { + lock.lock() + if var entry = machines[machineID] { + entry.state = .failed + entry.lastError = "Interrupted desktop update recovery failed: \(error)" + machines[machineID] = entry + } + lock.unlock() + } + } + } + + @discardableResult + private func requireSuccessfulDesktopUpdateExec( + id: String, + argv: [String], + env: [DoryExecEnvironment] = [], + timeoutMs: UInt64 = 30_000, + outputLimitBytes: UInt64 = 1024 * 1024, + stage: String + ) throws -> DoryExecResult { + let result = try exec( + id: id, + argv: argv, + env: env, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + guard result.exitCode == 0, !result.timedOut else { + let stderr = String(decoding: result.stderr.suffix(4096), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + throw MachineManagerError.persistence( + "desktop update could not \(stage) (exit \(result.timedOut ? 124 : result.exitCode))\(stderr.isEmpty ? "" : ": \(stderr)")" + ) + } + return result + } + + private func qualifyUpdatedDesktop( + id: String, + distro: String, + version: String, + inputSHA256: String + ) throws { + let script = """ + set -euo pipefail + . /etc/os-release + [ "$ID" = "$DORY_EXPECTED_DISTRO" ] + grep -Fqx "version=$DORY_EXPECTED_VERSION" /var/lib/dory/desktop-update.env + grep -Fqx "input_sha256=$DORY_EXPECTED_INPUT" /var/lib/dory/desktop-update.env + [ "$(systemctl get-default)" = graphical.target ] + for _ in $(seq 1 180); do + if systemctl is-active --quiet NetworkManager.service \ + && systemctl is-active --quiet display-manager.service \ + && systemctl is-active --quiet dory-boot.service \ + && systemctl is-active --quiet dory-zram.service \ + && grep -q '^/dev/zram0 ' /proc/swaps; then + break + fi + sleep 1 + done + systemctl is-active --quiet NetworkManager.service + systemctl is-active --quiet display-manager.service + systemctl is-active --quiet dory-boot.service + systemctl is-active --quiet dory-zram.service + grep -q '^/dev/zram0 ' /proc/swaps + user="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory')" + id "$user" >/dev/null + for _ in $(seq 1 120); do + if pgrep -u "$user" -f 'gnome-shell|xfce4-session' >/dev/null; then break; fi + sleep 1 + done + pgrep -u "$user" -f 'gnome-shell|xfce4-session' >/dev/null + case "$DORY_EXPECTED_DISTRO" in + debian) command -v firefox-esr >/dev/null ;; + ubuntu) command -v firefox >/dev/null ;; + kali) command -v firefox-esr >/dev/null || command -v firefox >/dev/null ;; + esac + for _ in $(seq 1 120); do + if getent ahosts example.com >/dev/null; then break; fi + sleep 1 + done + getent ahosts example.com >/dev/null + """ + _ = try requireSuccessfulDesktopUpdateExec( + id: id, + argv: ["/bin/bash", "-lc", script], + env: [ + DoryExecEnvironment(key: "DORY_EXPECTED_DISTRO", value: distro), + DoryExecEnvironment(key: "DORY_EXPECTED_VERSION", value: version), + DoryExecEnvironment(key: "DORY_EXPECTED_INPUT", value: inputSHA256), + ], + timeoutMs: 480_000, + outputLimitBytes: 4 * 1024 * 1024, + stage: "qualify the updated desktop" + ) + } + + private static func desktopUpdateInputSHA256(from result: DoryExecResult) -> String? { + let output = String(decoding: result.stdout, as: UTF8.self) + for line in output.split(separator: "\n").reversed() { + let fields = line.split(separator: " ") + guard fields.count == 7, + fields[0] == "Dory", + fields[1] == "desktop", + fields[2] == "update", + fields[3] == "applied:" else { continue } + let digest = String(fields[6]) + if digest.count == 64, digest.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) { + return digest + } + } + return nil + } + + private static func desktopUpdateReceiptInputSHA256( + from result: DoryExecResult, + distro: String, + version: String + ) -> String? { + guard !result.stdoutTruncated else { return nil } + let fields = Dictionary(uniqueKeysWithValues: String(decoding: result.stdout, as: UTF8.self) + .split(separator: "\n") + .compactMap { line -> (String, String)? in + guard let separator = line.firstIndex(of: "=") else { return nil } + return (String(line[.. DoryAgentInfo { try withAgentClient(id: id) { client in try client.info() @@ -1437,6 +2247,80 @@ public final class MachineManager: @unchecked Sendable { "\(machineStateDirectory(id: id))/kernel" } + private func machineInstalledLinuxKernelPath(id: String) -> String { + "\(machineStateDirectory(id: id))/\(Self.installedLinuxKernelName)" + } + + private func machineInstalledLinuxInitrdPath(id: String) -> String { + "\(machineStateDirectory(id: id))/\(Self.installedLinuxInitrdName)" + } + + private func machineInstallerISOPath(id: String) -> String { + "\(machineStateDirectory(id: id))/installer.iso" + } + + private func ensureInstalledLinuxBootBundleIfNeeded( + _ machine: DoryMachineConfiguration + ) throws { + guard machine.bootMode == .efi, + machine.displayMode == .desktop, + machine.installerISOPath == nil, + try DoryDesktopVMMPreference(environment: machine.environment) != .compatible, + configuration.acceleratedDesktopExecutablePath != nil, + !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return + } + let installerPath = machineInstallerISOPath(id: machine.id) + guard Self.isPrivateRegularFile(path: installerPath) else { + throw MachineManagerError.persistence( + "installed Linux acceleration requires the managed installer ISO to derive a kernel and initrd" + ) + } + do { + let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: installerPath) + let rootDevice = try DoryLinuxInstalledDiskInspector.rootDevice(atPath: machine.rootfsPath) + try DoryInstalledLinuxBootBundle.write( + assets: assets, + rootDevice: rootDevice, + toPath: machine.kernelPath + ) + } catch { + throw MachineManagerError.persistence( + "could not prepare the installed Linux accelerated runtime: \(error.localizedDescription)" + ) + } + } + + private func materializeInstalledLinuxBootRuntimeIfNeeded( + _ machine: DoryMachineConfiguration + ) throws { + guard machine.bootMode == .efi, + machine.installerISOPath == nil, + try DoryDesktopVMMPreference(environment: machine.environment) != .compatible, + DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return + } + do { + try DoryInstalledLinuxBootBundle.materialize( + fromPath: machine.kernelPath, + kernelPath: machineInstalledLinuxKernelPath(id: machine.id), + initrdPath: machineInstalledLinuxInitrdPath(id: machine.id) + ) + } catch { + throw MachineManagerError.persistence( + "could not verify the installed Linux accelerated runtime: \(error.localizedDescription)" + ) + } + } + + private func machineFirmwareIdentifierPath(id: String) -> String { + "\(machineStateDirectory(id: id))/MachineIdentifier" + } + + private func machineFirmwareNVRAMPath(id: String) -> String { + "\(machineStateDirectory(id: id))/NVRAM" + } + private func snapshotDirectory(machineID: String) -> String { "\(machineStateDirectory(id: machineID))/snapshots" } @@ -1453,6 +2337,14 @@ public final class MachineManager: @unchecked Sendable { "\(snapshotDirectory(machineID: machineID))/\(snapshotID).kernel" } + private func snapshotMachineIdentifierPath(machineID: String, snapshotID: String) -> String { + "\(snapshotDirectory(machineID: machineID))/\(snapshotID).machine-identifier" + } + + private func snapshotNVRAMPath(machineID: String, snapshotID: String) -> String { + "\(snapshotDirectory(machineID: machineID))/\(snapshotID).nvram" + } + private func availableImportedSnapshotID(machineID: String, preferredID: String) throws -> String { if !snapshotArtifactsExist(machineID: machineID, snapshotID: preferredID) { return preferredID @@ -1470,6 +2362,8 @@ public final class MachineManager: @unchecked Sendable { Self.pathEntryExists(snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID)) || Self.pathEntryExists(snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID)) || Self.pathEntryExists(snapshotKernelPath(machineID: machineID, snapshotID: snapshotID)) + || Self.pathEntryExists(snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID)) + || Self.pathEntryExists(snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID)) } private func configurationAndRunningState(id: String) throws -> (DoryMachineConfiguration, Bool) { @@ -1479,7 +2373,11 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.unknownMachine(id) } try validateManagedMachineArtifacts(entry.configuration) - return (entry.configuration, entry.process?.isRunning == true) + // Process.isRunning can briefly read false at the spawn/termination callback boundary. + // The supervisor lifecycle is authoritative: a running/starting entry with an active or + // scheduled helper must be stopped and restarted for a configuration transaction. + let active = [.starting, .running].contains(entry.state) && entry.process != nil + return (entry.configuration, active) } private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws { @@ -1496,15 +2394,40 @@ public final class MachineManager: @unchecked Sendable { private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration { let rootfsDestination = machineRootfsPath(id: machine.id) let kernelDestination = machineKernelPath(id: machine.id) - guard machine.rootfsPath != rootfsDestination, machine.kernelPath != kernelDestination else { + let installerDestination = machineInstallerISOPath(id: machine.id) + guard machine.rootfsPath != rootfsDestination, machine.kernelPath != kernelDestination, + machine.installerISOPath != installerDestination else { throw MachineManagerError.persistence("machine artifacts must be imported into managed storage") } do { - try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination) - try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination) var copy = machine + switch machine.bootMode { + case .linuxKernel: + try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination) + try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination) + case .efi: + if Self.isRegularNonemptyFile(path: machine.rootfsPath) { + try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination) + } else if let diskSizeBytes = machine.diskSizeBytes { + try Self.createPrivateSparseFile(path: rootfsDestination, sizeBytes: diskSizeBytes) + } else { + throw MachineManagerError.persistence("EFI machine is missing its disk source or size") + } + if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) { + // EFI snapshot clones carry this opaque, verified bundle in the existing + // kernel artifact, so acceleration survives the full machine lifecycle. + try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination) + } else { + try Self.createPrivateFile(path: kernelDestination, contents: Data("DORY-EFI\n".utf8)) + } + if let installerISOPath = machine.installerISOPath { + try Self.cloneOrCopyFile(source: installerISOPath, destination: installerDestination) + copy.installerISOPath = installerDestination + } + } copy.rootfsPath = rootfsDestination copy.kernelPath = kernelDestination + copy.diskSizeBytes = nil return copy } catch { throw MachineManagerError.persistence("could not prepare artifacts for \(machine.id): \(error)") @@ -1525,6 +2448,14 @@ public final class MachineManager: @unchecked Sendable { Self.isPrivateRegularFile(path: expectedKernelPath) else { throw MachineManagerError.persistence("machine kernel failed managed-storage validation") } + if let installerISOPath = machine.installerISOPath { + let expectedInstallerISOPath = machineInstallerISOPath(id: machine.id) + guard machine.bootMode == .efi, + installerISOPath == expectedInstallerISOPath, + Self.isPrivateRegularFile(path: expectedInstallerISOPath) else { + throw MachineManagerError.persistence("machine installer ISO failed managed-storage validation") + } + } } private func restoreManagedArtifacts( @@ -1536,9 +2467,13 @@ public final class MachineManager: @unchecked Sendable { let token = UUID().uuidString let rootfsBackup = "\(directory)/.restore-rootfs-\(token)" let kernelBackup = "\(directory)/.restore-kernel-\(token)" + let machineIdentifierBackup = "\(directory)/.restore-machine-identifier-\(token)" + let nvramBackup = "\(directory)/.restore-nvram-\(token)" defer { try? FileManager.default.removeItem(atPath: rootfsBackup) try? FileManager.default.removeItem(atPath: kernelBackup) + try? FileManager.default.removeItem(atPath: machineIdentifierBackup) + try? FileManager.default.removeItem(atPath: nvramBackup) } try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsBackup) do { @@ -1546,6 +2481,26 @@ public final class MachineManager: @unchecked Sendable { } catch { throw MachineManagerError.persistence("could not preserve live kernel before restore: \(error)") } + let firmwareRestore: (identifier: String, nvram: String, snapshotIdentifier: String, snapshotNVRAM: String)? + if snapshot.bootMode == .efi { + let identifier = machineFirmwareIdentifierPath(id: machine.id) + let nvram = machineFirmwareNVRAMPath(id: machine.id) + guard let snapshotIdentifier = snapshot.machineIdentifierPath, + let snapshotNVRAM = snapshot.nvramPath, + Self.isPrivateRegularFile(path: identifier), + Self.isPrivateRegularFile(path: nvram) else { + throw MachineManagerError.persistence("could not preserve live EFI firmware before restore") + } + do { + try Self.cloneOrCopyFile(source: identifier, destination: machineIdentifierBackup) + try Self.cloneOrCopyFile(source: nvram, destination: nvramBackup) + } catch { + throw MachineManagerError.persistence("could not preserve live EFI firmware before restore: \(error)") + } + firmwareRestore = (identifier, nvram, snapshotIdentifier, snapshotNVRAM) + } else { + firmwareRestore = nil + } do { try Self.cloneOrCopyFile( source: snapshot.rootfsPath, @@ -1557,6 +2512,18 @@ public final class MachineManager: @unchecked Sendable { destination: machine.kernelPath, replaceExisting: true ) + if let firmwareRestore { + try Self.cloneOrCopyFile( + source: firmwareRestore.snapshotIdentifier, + destination: firmwareRestore.identifier, + replaceExisting: true + ) + try Self.cloneOrCopyFile( + source: firmwareRestore.snapshotNVRAM, + destination: firmwareRestore.nvram, + replaceExisting: true + ) + } try commit() } catch { var rollbackFailures: [String] = [] @@ -1578,6 +2545,26 @@ public final class MachineManager: @unchecked Sendable { } catch { rollbackFailures.append("kernel rollback failed: \(error)") } + if let firmwareRestore { + do { + try Self.cloneOrCopyFile( + source: machineIdentifierBackup, + destination: firmwareRestore.identifier, + replaceExisting: true + ) + } catch { + rollbackFailures.append("machine identifier rollback failed: \(error)") + } + do { + try Self.cloneOrCopyFile( + source: nvramBackup, + destination: firmwareRestore.nvram, + replaceExisting: true + ) + } catch { + rollbackFailures.append("NVRAM rollback failed: \(error)") + } + } guard rollbackFailures.isEmpty else { throw MachineManagerError.persistence( "could not restore machine artifacts: \(error); \(rollbackFailures.joined(separator: "; "))" @@ -1706,6 +2693,8 @@ public final class MachineManager: @unchecked Sendable { let path = snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID) let expectedRootfsPath = snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID) let expectedKernelPath = snapshotKernelPath(machineID: machineID, snapshotID: snapshotID) + let expectedMachineIdentifierPath = snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID) + let expectedNVRAMPath = snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID) guard Self.isPrivateDirectory(path: snapshotDirectory(machineID: machineID)), let data = Self.readPrivateMetadata(path: path), let snapshot = try? JSONDecoder().decode(DoryMachineSnapshot.self, from: data), @@ -1719,6 +2708,19 @@ public final class MachineManager: @unchecked Sendable { Self.isPrivateRegularFile(path: expectedKernelPath) else { throw MachineManagerError.unknownSnapshot(snapshotID) } + switch snapshot.bootMode { + case .linuxKernel: + guard snapshot.machineIdentifierPath == nil, snapshot.nvramPath == nil else { + throw MachineManagerError.unknownSnapshot(snapshotID) + } + case .efi: + guard snapshot.machineIdentifierPath == expectedMachineIdentifierPath, + snapshot.nvramPath == expectedNVRAMPath, + Self.isPrivateRegularFile(path: expectedMachineIdentifierPath), + Self.isPrivateRegularFile(path: expectedNVRAMPath) else { + throw MachineManagerError.unknownSnapshot(snapshotID) + } + } var validated = snapshot validated.sizeBytes = Self.fileSize(path: expectedRootfsPath) return validated @@ -1752,6 +2754,7 @@ public final class MachineManager: @unchecked Sendable { entry.handoff = handoff entry.state = .running entry.lastError = nil + entry.process?.disableRestarts() agentSocketPath = handoff.ready.agentSocketPath case let .failure(error): entry.state = .failed @@ -1873,10 +2876,26 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.invalidEnvironment(key) } } + do { + _ = try DoryDesktopVMMPreference(environment: environment) + } catch { + throw MachineManagerError.invalidEnvironment(DoryDesktopVMMPreference.environmentKey) + } + do { + _ = try DoryDesktopGraphicsPreference(environment: environment) + } catch { + throw MachineManagerError.invalidEnvironment(DoryDesktopGraphicsPreference.environmentKey) + } } private static func validateLaunchConfiguration(_ machine: DoryMachineConfiguration) throws { try validateResources(memoryMB: machine.memoryMB, cpuCount: machine.cpuCount) + if machine.bootMode == .efi, machine.displayMode != .desktop { + throw MachineManagerError.persistence("EFI machines require desktop display mode") + } + if machine.bootMode == .linuxKernel, machine.installerISOPath != nil { + throw MachineManagerError.persistence("installer ISO requires EFI boot mode") + } let normalizedAddress = try normalizedAddress(machine.address) guard normalizedAddress == machine.address else { throw MachineManagerError.invalidAddress(machine.address ?? "") @@ -2010,6 +3029,60 @@ public final class MachineManager: @unchecked Sendable { } } + private static func createPrivateSparseFile(path: String, sizeBytes: UInt64) throws { + guard sizeBytes <= UInt64(Int64.max) else { + throw MachineManagerError.persistence("managed disk size is too large") + } + let descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600)) + guard descriptor >= 0 else { + throw MachineManagerError.persistence( + "could not create managed disk: \(String(cString: strerror(errno)))" + ) + } + defer { close(descriptor) } + guard ftruncate(descriptor, off_t(sizeBytes)) == 0, + fsync(descriptor) == 0, + isPrivateRegularFile(descriptor: descriptor) else { + let code = errno + _ = unlink(path) + throw MachineManagerError.persistence( + "could not size managed disk: \(String(cString: strerror(code)))" + ) + } + } + + private static func createPrivateFile(path: String, contents: Data) throws { + let descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600)) + guard descriptor >= 0 else { + throw MachineManagerError.persistence( + "could not create managed file: \(String(cString: strerror(errno)))" + ) + } + defer { close(descriptor) } + let wrote = contents.withUnsafeBytes { buffer -> Bool in + guard let baseAddress = buffer.baseAddress else { return contents.isEmpty } + var offset = 0 + while offset < buffer.count { + let result = write(descriptor, baseAddress.advanced(by: offset), buffer.count - offset) + if result > 0 { + offset += result + } else if result < 0, errno == EINTR { + continue + } else { + return false + } + } + return true + } + guard wrote, fsync(descriptor) == 0, isPrivateRegularFile(descriptor: descriptor) else { + let code = errno + _ = unlink(path) + throw MachineManagerError.persistence( + "could not write managed file: \(String(cString: strerror(code)))" + ) + } + } + private static func fileSize(path: String) -> Int64 { let attrs = try? FileManager.default.attributesOfItem(atPath: path) if let number = attrs?[.size] as? NSNumber { @@ -2112,6 +3185,26 @@ public final class MachineManager: @unchecked Sendable { return lstat(path, &info) == 0 && isPrivateDirectory(info: info) } + private static func isDirectory(path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR + } + + private static func sha256(path: String) throws -> String { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw MachineManagerError.persistence("could not open desktop update bundle") + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var hasher = SHA256() + while true { + let chunk = try handle.read(upToCount: 1024 * 1024) ?? Data() + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + private static func isPrivateDirectory(info: stat) -> Bool { (info.st_mode & S_IFMT) == S_IFDIR && info.st_uid == getuid() @@ -2136,6 +3229,7 @@ public final class MachineManager: @unchecked Sendable { let path = "\(root)/\(id)/machine.json" let rootfsPath = "\(root)/\(id)/rootfs.ext4" let kernelPath = "\(root)/\(id)/kernel" + let installerISOPath = "\(root)/\(id)/installer.iso" guard let data = readPrivateMetadata(path: path), let machine = try? decoder.decode(DoryMachineConfiguration.self, from: data), machine.id == id, @@ -2144,7 +3238,12 @@ public final class MachineManager: @unchecked Sendable { machine.rootfsPath == rootfsPath, machine.kernelPath == kernelPath, isPrivateRegularFile(path: rootfsPath), - isPrivateRegularFile(path: kernelPath) else { + isPrivateRegularFile(path: kernelPath), + machine.installerISOPath == nil || ( + machine.bootMode == .efi + && machine.installerISOPath == installerISOPath + && isPrivateRegularFile(path: installerISOPath) + ) else { continue } loaded[id] = MachineEntry(configuration: machine, state: .stopped) @@ -2199,6 +3298,8 @@ public final class MachineManager: @unchecked Sendable { || (entry.hasPrefix(".") && ( entry.contains(snapshotDiskTemporaryMarker) || entry.contains(snapshotKernelTemporaryMarker) + || entry.contains(snapshotMachineIdentifierTemporaryMarker) + || entry.contains(snapshotNVRAMTemporaryMarker) )) { try? fileManager.removeItem(atPath: "\(directory)/\(entry)") } @@ -2219,10 +3320,17 @@ private enum MachineSnapshotBundle { var kernelOffset: UInt64 var kernelLength: UInt64 var kernelDigest: Data + var machineIdentifierOffset: UInt64? + var machineIdentifierLength: UInt64? + var machineIdentifierDigest: Data? + var nvramOffset: UInt64? + var nvramLength: UInt64? + var nvramDigest: Data? var contentID: Data } - private static let magic = Data("DORYMACHINE3\n".utf8) + private static let v3Magic = Data("DORYMACHINE3\n".utf8) + private static let v4Magic = Data("DORYMACHINE4\n".utf8) private static let lengthByteCount = 8 private static let digestByteCount = 32 private static let maximumMetadataLength: UInt64 = 16 * 1024 * 1024 @@ -2233,20 +3341,52 @@ private enum MachineSnapshotBundle { defer { try? rootfs.close() } let kernel = try openRegularFileForReading(path: snapshot.kernelPath, requirePrivateOwnership: true) defer { try? kernel.close() } + let machineIdentifier: FileHandle? + let nvram: FileHandle? + if snapshot.bootMode == .efi { + guard let machineIdentifierPath = snapshot.machineIdentifierPath, + let nvramPath = snapshot.nvramPath else { + throw MachineManagerError.persistence("EFI snapshot is missing firmware state") + } + machineIdentifier = try openRegularFileForReading( + path: machineIdentifierPath, + requirePrivateOwnership: true + ) + nvram = try openRegularFileForReading(path: nvramPath, requirePrivateOwnership: true) + } else { + machineIdentifier = nil + nvram = nil + } + defer { + try? machineIdentifier?.close() + try? nvram?.close() + } let rootfsLength = try rootfs.seekToEnd() let kernelLength = try kernel.seekToEnd() + let machineIdentifierLength = try machineIdentifier?.seekToEnd() + let nvramLength = try nvram?.seekToEnd() guard rootfsLength > 0, rootfsLength <= UInt64(Int64.max) else { throw MachineManagerError.persistence("invalid machine snapshot rootfs size") } guard kernelLength > 0, kernelLength <= UInt64(Int64.max) else { throw MachineManagerError.persistence("invalid machine snapshot kernel size") } + if snapshot.bootMode == .efi { + guard let machineIdentifierLength, machineIdentifierLength > 0, + let nvramLength, nvramLength > 0 else { + throw MachineManagerError.persistence("invalid EFI snapshot firmware size") + } + } try rootfs.seek(toOffset: 0) try kernel.seek(toOffset: 0) + try machineIdentifier?.seek(toOffset: 0) + try nvram?.seek(toOffset: 0) var exportedSnapshot = snapshot exportedSnapshot.rootfsPath = "" exportedSnapshot.kernelPath = "" + exportedSnapshot.machineIdentifierPath = nil + exportedSnapshot.nvramPath = nil exportedSnapshot.address = nil exportedSnapshot.shares = [] exportedSnapshot.environment = [:] @@ -2276,15 +3416,31 @@ private enum MachineSnapshotBundle { } } do { - try output.write(contentsOf: magic) + let includesFirmware = snapshot.bootMode == .efi + try output.write(contentsOf: includesFirmware ? v4Magic : v3Magic) try output.write(contentsOf: bigEndianBytes(UInt64(metadata.count))) try output.write(contentsOf: bigEndianBytes(rootfsLength)) try output.write(contentsOf: bigEndianBytes(kernelLength)) + if includesFirmware { + try output.write(contentsOf: bigEndianBytes(machineIdentifierLength!)) + try output.write(contentsOf: bigEndianBytes(nvramLength!)) + } try output.write(contentsOf: metadataDigest) let rootfsDigestOffset = try output.offset() try output.write(contentsOf: Data(repeating: 0, count: digestByteCount)) let kernelDigestOffset = try output.offset() try output.write(contentsOf: Data(repeating: 0, count: digestByteCount)) + let machineIdentifierDigestOffset: UInt64? + let nvramDigestOffset: UInt64? + if includesFirmware { + machineIdentifierDigestOffset = try output.offset() + try output.write(contentsOf: Data(repeating: 0, count: digestByteCount)) + nvramDigestOffset = try output.offset() + try output.write(contentsOf: Data(repeating: 0, count: digestByteCount)) + } else { + machineIdentifierDigestOffset = nil + nvramDigestOffset = nil + } try output.write(contentsOf: metadata) let rootfsDigest = try copyExactly( from: rootfs, @@ -2298,10 +3454,36 @@ private enum MachineSnapshotBundle { byteCount: kernelLength, rejectTrailingInput: true ) + let machineIdentifierDigest: Data? + let nvramDigest: Data? + if let machineIdentifier, let machineIdentifierLength, let nvram, let nvramLength { + machineIdentifierDigest = try copyExactly( + from: machineIdentifier, + to: output, + byteCount: machineIdentifierLength, + rejectTrailingInput: true + ) + nvramDigest = try copyExactly( + from: nvram, + to: output, + byteCount: nvramLength, + rejectTrailingInput: true + ) + } else { + machineIdentifierDigest = nil + nvramDigest = nil + } try output.seek(toOffset: rootfsDigestOffset) try output.write(contentsOf: rootfsDigest) try output.seek(toOffset: kernelDigestOffset) try output.write(contentsOf: kernelDigest) + if let machineIdentifierDigestOffset, let machineIdentifierDigest, + let nvramDigestOffset, let nvramDigest { + try output.seek(toOffset: machineIdentifierDigestOffset) + try output.write(contentsOf: machineIdentifierDigest) + try output.seek(toOffset: nvramDigestOffset) + try output.write(contentsOf: nvramDigest) + } try output.synchronize() try output.close() outputIsOpen = false @@ -2331,7 +3513,9 @@ private enum MachineSnapshotBundle { fromPath path: String, expectedContentID: Data, rootfsPath: String, - kernelPath: String + kernelPath: String, + machineIdentifierPath: String?, + nvramPath: String? ) throws { let input = try openRegularFileForReading(path: path) var inputIsOpen = true @@ -2344,125 +3528,128 @@ private enum MachineSnapshotBundle { guard header.contentID == expectedContentID else { throw MachineManagerError.persistence("machine bundle changed during import") } - let rootfsURL = URL(fileURLWithPath: rootfsPath) - let kernelURL = URL(fileURLWithPath: kernelPath) - let parent = rootfsURL.deletingLastPathComponent() - guard kernelURL.deletingLastPathComponent() == parent, + var artifacts: [(offset: UInt64, length: UInt64, digest: Data, destination: URL)] = [ + (header.rootfsOffset, header.rootfsLength, header.rootfsDigest, URL(fileURLWithPath: rootfsPath)), + (header.kernelOffset, header.kernelLength, header.kernelDigest, URL(fileURLWithPath: kernelPath)), + ] + if let offset = header.machineIdentifierOffset, + let length = header.machineIdentifierLength, + let digest = header.machineIdentifierDigest, + let nvramOffset = header.nvramOffset, + let nvramLength = header.nvramLength, + let nvramDigest = header.nvramDigest, + let machineIdentifierPath, + let nvramPath { + artifacts.append((offset, length, digest, URL(fileURLWithPath: machineIdentifierPath))) + artifacts.append((nvramOffset, nvramLength, nvramDigest, URL(fileURLWithPath: nvramPath))) + } else if header.machineIdentifierOffset != nil + || machineIdentifierPath != nil + || nvramPath != nil { + throw MachineManagerError.persistence("EFI machine bundle firmware destinations are incomplete") + } + + let parent = artifacts[0].destination.deletingLastPathComponent() + guard artifacts.allSatisfy({ $0.destination.deletingLastPathComponent() == parent }), isPrivateDirectory(path: parent.path) else { throw MachineManagerError.persistence("machine snapshot destination is not private") } let token = UUID().uuidString - let temporaryRootfsURL = parent.appendingPathComponent(".\(rootfsURL.lastPathComponent).tmp-\(token)") - let temporaryKernelURL = parent.appendingPathComponent(".\(kernelURL.lastPathComponent).tmp-\(token)") - guard FileManager.default.createFile( - atPath: temporaryRootfsURL.path, - contents: nil, - attributes: [.posixPermissions: 0o600] - ) else { - try? input.close() - throw MachineManagerError.persistence("could not create temporary machine snapshot rootfs") - } - guard FileManager.default.createFile( - atPath: temporaryKernelURL.path, - contents: nil, - attributes: [.posixPermissions: 0o600] - ) else { - try? FileManager.default.removeItem(at: temporaryRootfsURL) - throw MachineManagerError.persistence("could not create temporary machine snapshot kernel") + let temporaryURLs = artifacts.map { + parent.appendingPathComponent(".\($0.destination.lastPathComponent).tmp-\(token)") + } + var createdTemporaryURLs: [URL] = [] + for temporaryURL in temporaryURLs { + guard FileManager.default.createFile( + atPath: temporaryURL.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + for created in createdTemporaryURLs { + try? FileManager.default.removeItem(at: created) + } + throw MachineManagerError.persistence("could not create temporary machine snapshot artifact") + } + createdTemporaryURLs.append(temporaryURL) } - let rootfsOutput: FileHandle - let kernelOutput: FileHandle + var outputs: [FileHandle] = [] do { - rootfsOutput = try FileHandle(forWritingTo: temporaryRootfsURL) - kernelOutput = try FileHandle(forWritingTo: temporaryKernelURL) + for temporaryURL in temporaryURLs { + outputs.append(try FileHandle(forWritingTo: temporaryURL)) + } } catch { - try? FileManager.default.removeItem(at: temporaryRootfsURL) - try? FileManager.default.removeItem(at: temporaryKernelURL) + for output in outputs { try? output.close() } + for temporaryURL in temporaryURLs { try? FileManager.default.removeItem(at: temporaryURL) } throw error } var outputsAreOpen = true defer { if outputsAreOpen { - try? rootfsOutput.close() - try? kernelOutput.close() + for output in outputs { try? output.close() } } } + var publishedDestinations: [URL] = [] do { - try input.seek(toOffset: header.rootfsOffset) - let rootfsDigest = try copyExactly( - from: input, - to: rootfsOutput, - byteCount: header.rootfsLength - ) - guard rootfsDigest == header.rootfsDigest else { - throw MachineManagerError.persistence("corrupt dory machine bundle rootfs") - } - try input.seek(toOffset: header.kernelOffset) - let kernelDigest = try copyExactly( - from: input, - to: kernelOutput, - byteCount: header.kernelLength - ) - guard kernelDigest == header.kernelDigest else { - throw MachineManagerError.persistence("corrupt dory machine bundle kernel") + for (index, artifact) in artifacts.enumerated() { + try input.seek(toOffset: artifact.offset) + let digest = try copyExactly( + from: input, + to: outputs[index], + byteCount: artifact.length + ) + guard digest == artifact.digest else { + throw MachineManagerError.persistence("corrupt dory machine bundle artifact") + } } - try rootfsOutput.synchronize() - try kernelOutput.synchronize() + for output in outputs { try output.synchronize() } try input.close() inputIsOpen = false - try rootfsOutput.close() - try kernelOutput.close() + for output in outputs { try output.close() } outputsAreOpen = false - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: temporaryRootfsURL.path - ) - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: temporaryKernelURL.path - ) - var publishedRootfs = false - var publishedKernel = false - defer { - if publishedRootfs { - try? FileManager.default.removeItem(at: rootfsURL) - } - if publishedKernel { - try? FileManager.default.removeItem(at: kernelURL) - } - } - guard link(temporaryRootfsURL.path, rootfsURL.path) == 0 else { - throw MachineManagerError.persistence( - "could not publish machine snapshot rootfs: \(String(cString: strerror(errno)))" + for temporaryURL in temporaryURLs { + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: temporaryURL.path ) } - publishedRootfs = true - guard link(temporaryKernelURL.path, kernelURL.path) == 0 else { - throw MachineManagerError.persistence( - "could not publish machine snapshot kernel: \(String(cString: strerror(errno)))" - ) + for (temporaryURL, artifact) in zip(temporaryURLs, artifacts) { + guard link(temporaryURL.path, artifact.destination.path) == 0 else { + throw MachineManagerError.persistence( + "could not publish machine snapshot artifact: \(String(cString: strerror(errno)))" + ) + } + publishedDestinations.append(artifact.destination) } - publishedKernel = true - try FileManager.default.removeItem(at: temporaryRootfsURL) - try FileManager.default.removeItem(at: temporaryKernelURL) - publishedRootfs = false - publishedKernel = false + for temporaryURL in temporaryURLs { try FileManager.default.removeItem(at: temporaryURL) } + publishedDestinations.removeAll() } catch { - try? FileManager.default.removeItem(at: temporaryRootfsURL) - try? FileManager.default.removeItem(at: temporaryKernelURL) + for destination in publishedDestinations { + try? FileManager.default.removeItem(at: destination) + } + for temporaryURL in temporaryURLs { try? FileManager.default.removeItem(at: temporaryURL) } throw error } } private static func readHeader(from input: FileHandle) throws -> Header { try input.seek(toOffset: 0) - let gotMagic = try readExactly(from: input, count: magic.count) - guard gotMagic == magic else { + let gotMagic = try readExactly(from: input, count: v3Magic.count) + let includesFirmware: Bool + if gotMagic == v3Magic { + includesFirmware = false + } else if gotMagic == v4Magic { + includesFirmware = true + } else { throw MachineManagerError.persistence("not a dory machine bundle") } let metadataLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount)) let rootfsLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount)) let kernelLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount)) + let machineIdentifierLength = includesFirmware + ? decodeUInt64(try readExactly(from: input, count: lengthByteCount)) + : nil + let nvramLength = includesFirmware + ? decodeUInt64(try readExactly(from: input, count: lengthByteCount)) + : nil guard metadataLength > 0, metadataLength <= maximumMetadataLength else { throw MachineManagerError.persistence("invalid dory machine bundle metadata") } @@ -2472,9 +3659,21 @@ private enum MachineSnapshotBundle { guard kernelLength > 0, kernelLength <= UInt64(Int64.max) else { throw MachineManagerError.persistence("invalid dory machine bundle kernel size") } + if includesFirmware { + guard let machineIdentifierLength, machineIdentifierLength > 0, + let nvramLength, nvramLength > 0 else { + throw MachineManagerError.persistence("invalid dory machine bundle firmware size") + } + } let metadataDigest = try readExactly(from: input, count: digestByteCount) let rootfsDigest = try readExactly(from: input, count: digestByteCount) let kernelDigest = try readExactly(from: input, count: digestByteCount) + let machineIdentifierDigest = includesFirmware + ? try readExactly(from: input, count: digestByteCount) + : nil + let nvramDigest = includesFirmware + ? try readExactly(from: input, count: digestByteCount) + : nil let metadata = try readExactly(from: input, count: Int(metadataLength)) guard Data(SHA256.hash(data: metadata)) == metadataDigest else { throw MachineManagerError.persistence("corrupt dory machine bundle metadata") @@ -2483,11 +3682,31 @@ private enum MachineSnapshotBundle { guard snapshot.sizeBytes == Int64(rootfsLength) else { throw MachineManagerError.persistence("machine bundle rootfs size does not match metadata") } - let fixedHeaderLength = UInt64(magic.count + (lengthByteCount * 3) + (digestByteCount * 3)) + guard (snapshot.bootMode == .efi) == includesFirmware else { + throw MachineManagerError.persistence("machine bundle boot mode does not match its artifacts") + } + let artifactCount = includesFirmware ? 5 : 3 + let fixedHeaderLength = UInt64( + v3Magic.count + (lengthByteCount * artifactCount) + (digestByteCount * artifactCount) + ) let (rootfsOffset, metadataOverflow) = fixedHeaderLength.addingReportingOverflow(metadataLength) let (kernelOffset, rootfsOverflow) = rootfsOffset.addingReportingOverflow(rootfsLength) - let (expectedFileLength, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength) - guard !metadataOverflow, !rootfsOverflow, !kernelOverflow, + let (afterKernelOffset, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength) + var machineIdentifierOffset: UInt64? + var nvramOffset: UInt64? + let expectedFileLength: UInt64 + var firmwareOverflow = false + if let machineIdentifierLength, let nvramLength { + machineIdentifierOffset = afterKernelOffset + let (computedNVRAMOffset, identifierOverflow) = afterKernelOffset.addingReportingOverflow(machineIdentifierLength) + nvramOffset = computedNVRAMOffset + let (computedFileLength, nvramOverflow) = computedNVRAMOffset.addingReportingOverflow(nvramLength) + expectedFileLength = computedFileLength + firmwareOverflow = identifierOverflow || nvramOverflow + } else { + expectedFileLength = afterKernelOffset + } + guard !metadataOverflow, !rootfsOverflow, !kernelOverflow, !firmwareOverflow, try input.seekToEnd() == expectedFileLength else { throw MachineManagerError.persistence("truncated or trailing dory machine bundle artifacts") } @@ -2495,9 +3714,17 @@ private enum MachineSnapshotBundle { identity.append(bigEndianBytes(metadataLength)) identity.append(bigEndianBytes(rootfsLength)) identity.append(bigEndianBytes(kernelLength)) + if let machineIdentifierLength, let nvramLength { + identity.append(bigEndianBytes(machineIdentifierLength)) + identity.append(bigEndianBytes(nvramLength)) + } identity.append(metadataDigest) identity.append(rootfsDigest) identity.append(kernelDigest) + if let machineIdentifierDigest, let nvramDigest { + identity.append(machineIdentifierDigest) + identity.append(nvramDigest) + } return Header( snapshot: snapshot, rootfsOffset: rootfsOffset, @@ -2506,6 +3733,12 @@ private enum MachineSnapshotBundle { kernelOffset: kernelOffset, kernelLength: kernelLength, kernelDigest: kernelDigest, + machineIdentifierOffset: machineIdentifierOffset, + machineIdentifierLength: machineIdentifierLength, + machineIdentifierDigest: machineIdentifierDigest, + nvramOffset: nvramOffset, + nvramLength: nvramLength, + nvramDigest: nvramDigest, contentID: Data(SHA256.hash(data: identity)) ) } diff --git a/dory-core-swift/Sources/dory-vmm/Info.plist b/dory-core-swift/Sources/dory-vmm/Info.plist index 0fd461ad..21735b74 100644 --- a/dory-core-swift/Sources/dory-vmm/Info.plist +++ b/dory-core-swift/Sources/dory-vmm/Info.plist @@ -14,6 +14,8 @@ APPL NSHighResolutionCapable + NSMicrophoneUsageDescription + Dory shares your Mac microphone with Linux desktop machines when you use audio input. NSPrincipalClass NSApplication diff --git a/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements b/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements index 9b3e03a0..07c35f00 100644 --- a/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements +++ b/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements @@ -2,6 +2,8 @@ + com.apple.security.device.audio-input + com.apple.security.virtualization diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 77782a69..15443acb 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -5,6 +5,7 @@ import Foundation private let engineColdStartTimeout: TimeInterval = 240 private let engineShutdownTimeout = DoryEngineShutdownTiming.hostTerminationSeconds + 5 +private let machineFileMutationTimeout: TimeInterval = 900 enum DorydCtlError: Error, CustomStringConvertible { case daemon(String) @@ -163,12 +164,13 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME - dorydctl [global] machine create NAME --kernel PATH --rootfs PATH [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE + dorydctl [global] machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH dorydctl [global] machine snapshots [NAME] dorydctl [global] machine snapshot NAME [--note NOTE] [--id ID] dorydctl [global] machine clone-snapshot NAME SNAPSHOT_ID NEW_NAME @@ -1038,7 +1040,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1060,27 +1062,60 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } try emitJSON(stats) case "create": - let name = try cursor.take("usage: dorydctl machine create NAME --kernel PATH --rootfs PATH") - guard let kernel = try cursor.optionValue("--kernel"), - let rootfs = try cursor.optionValue("--rootfs") else { - throw DorydCtlError.usage("usage: dorydctl machine create NAME --kernel PATH --rootfs PATH") - } - let memoryMB = try cursor.optionValue("--memory-mb").map { try positiveUInt64($0, option: "--memory-mb") } ?? 2048 - let cpuCount = try cursor.optionValue("--cpus").map { try positiveInt($0, option: "--cpus") } ?? 2 - let displayMode = try cursor.optionValue("--display-mode") ?? DoryMachineDisplayMode.headless.rawValue + let name = try cursor.take("usage: dorydctl machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N])") + let installerISO = try cursor.optionValue("--installer-iso") + let kernel = try cursor.optionValue("--kernel") + let rootfs = try cursor.optionValue("--rootfs") + let diskSizeGB = try cursor.optionValue("--disk-size-gb").map { + try positiveUInt64($0, option: "--disk-size-gb") + } ?? 64 + guard installerISO != nil || (kernel != nil && rootfs != nil) else { + throw DorydCtlError.usage("provide --kernel and --rootfs, or --installer-iso") + } + let defaultMemoryMB: UInt64 = installerISO == nil + ? 2_048 + : DoryInstallerMachinePolicy.defaultMemoryMB + let memoryMB = try cursor.optionValue("--memory-mb").map { + try positiveUInt64($0, option: "--memory-mb") + } ?? defaultMemoryMB + let cpuCount = try cursor.optionValue("--cpus").map { + try positiveInt($0, option: "--cpus") + } ?? DoryInstallerMachinePolicy.defaultCPUCount + let defaultDisplayMode = installerISO == nil + ? DoryMachineDisplayMode.headless.rawValue + : DoryMachineDisplayMode.desktop.rawValue + let displayMode = try cursor.optionValue("--display-mode") ?? defaultDisplayMode guard DoryMachineDisplayMode(rawValue: displayMode) != nil else { throw DorydCtlError.usage("--display-mode must be headless or desktop") } let shares = try cursor.optionValues("--share").map { try DoryMachineShareConfiguration(argument: $0) } let env = try cursor.optionValues("--env").map(parseEnvironmentRow) + var stagedInstallerISOPath: String? + defer { + if let stagedInstallerISOPath { + try? FileManager.default.removeItem(atPath: stagedInstallerISOPath) + } + } + if let installerISO { + let staged = try DoryInstallerISOStager.stage(atPath: installerISO) + stagedInstallerISOPath = staged.path + } var config: [String: Any] = [ "id": name, - "kernelPath": kernel, - "rootfsPath": rootfs, + "kernelPath": kernel ?? "", + "rootfsPath": rootfs ?? "", + "bootMode": installerISO == nil ? DoryMachineBootMode.linuxKernel.rawValue : DoryMachineBootMode.efi.rawValue, "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode, ] + if let stagedInstallerISOPath { + guard diskSizeGB <= UInt64.max / (1024 * 1024 * 1024) else { + throw DorydCtlError.usage("--disk-size-gb is too large") + } + config["installerISOPath"] = stagedInstallerISOPath + config["diskSizeBytes"] = diskSizeGB * 1024 * 1024 * 1024 + } if let address = try cursor.optionValue("--dns-target") { config["address"] = address } @@ -1097,7 +1132,10 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { if !env.isEmpty { config["env"] = env } - let status = try client.statusCommand { proxy, reply in + // Creating a machine can copy a multi-gigabyte root disk or installer ISO. + // Keep the request alive long enough for that transactional mutation to + // finish so the CLI cannot report a timeout after the daemon has succeeded. + let status = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineCreate(config as NSDictionary, reply: reply) } try emitJSON(status) @@ -1106,18 +1144,24 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitJSON(try client.statusCommand { $0.machineStart(name, reply: $1) }) case "stop": let name = try cursor.take("usage: dorydctl machine stop NAME") - try emitJSON(try client.statusCommand { $0.machineStop(name, reply: $1) }) + try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { + $0.machineStop(name, reply: $1) + }) case "update": try runMachineUpdate(cursor: &cursor, client: client) case "delete", "rm": let name = try cursor.take("usage: dorydctl machine delete NAME") - try emitCommandResult(try client.command { $0.machineDelete(name, reply: $1) }) + try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { + $0.machineDelete(name, reply: $1) + }) case "exec": try runMachineExec(cursor: &cursor, client: client) case "shell": try runMachineShell(cursor: &cursor, client: client) case "provision": try runMachineProvision(cursor: &cursor, client: client) + case "desktop-update": + try runMachineDesktopUpdate(cursor: &cursor, client: client) case "snapshots": let name = cursor.values.isEmpty ? "" : try cursor.take("usage: dorydctl machine snapshots [NAME]") guard cursor.values.isEmpty else { @@ -1138,7 +1182,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected clone-snapshot argument: \(cursor.values[0])") } - try emitJSON(try client.statusCommand { proxy, reply in + try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineCloneSnapshot(name, snapshotID: snapshotID, newID: newName, reply: reply) }) case "restore-snapshot": @@ -1147,7 +1191,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected restore-snapshot argument: \(cursor.values[0])") } - try emitJSON(try client.statusCommand { proxy, reply in + try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineRestoreSnapshot(name, snapshotID: snapshotID, reply: reply) }) case "delete-snapshot": @@ -1156,7 +1200,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected delete-snapshot argument: \(cursor.values[0])") } - try emitCommandResult(try client.command { proxy, reply in + try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { proxy, reply in proxy.machineDeleteSnapshot(name, snapshotID: snapshotID, reply: reply) }) case "export-snapshot": @@ -1166,7 +1210,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected export-snapshot argument: \(cursor.values[0])") } - try emitCommandResult(try client.command { proxy, reply in + try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { proxy, reply in proxy.machineExportSnapshot(name, snapshotID: snapshotID, path: path, reply: reply) }) case "import-snapshot": @@ -1174,7 +1218,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected import-snapshot argument: \(cursor.values[0])") } - let imported = try client.statusCommand { proxy, reply in + let imported = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineImportSnapshot(path, reply: reply) } try emitJSON(imported) @@ -1240,7 +1284,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro case "run": let name = try cursor.take(usage) guard cursor.values.isEmpty else { throw DorydCtlError.usage(usage) } - let status = try client.withTimeout(atLeast: 900).statusCommand { proxy, reply in + let status = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineBackupRun(name, reply: reply) } try emitJSON(status) @@ -1256,7 +1300,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro } func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env]" + let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]" let name = try cursor.take(usage) var config: [String: Any] = [:] if let memory = try cursor.optionValue("--memory-mb") { @@ -1308,6 +1352,15 @@ func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) thro cursor.values.removeAll { $0 == "--clear-env" } config["env"] = [] as [NSDictionary] } + let attachInstaller = cursor.values.contains("--attach-installer") + let ejectInstaller = cursor.values.contains("--eject-installer") + guard !attachInstaller || !ejectInstaller else { + throw DorydCtlError.usage("use either --attach-installer or --eject-installer, not both") + } + if attachInstaller || ejectInstaller { + cursor.values.removeAll { $0 == "--attach-installer" || $0 == "--eject-installer" } + config["installerMediaAttached"] = attachInstaller + } guard !config.isEmpty else { throw DorydCtlError.usage(usage) } @@ -1334,7 +1387,7 @@ func runMachineSnapshot(cursor: inout ArgumentCursor, client: DorydCtlClient) th guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected machine snapshot argument: \(cursor.values[0])") } - let snapshot = try client.statusCommand { proxy, reply in + let snapshot = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in proxy.machineSnapshot(name, request: request as NSDictionary, reply: reply) } try emitJSON(snapshot) @@ -1354,6 +1407,31 @@ func runMachineProvision(cursor: inout ArgumentCursor, client: DorydCtlClient) t try emitJSON(result) } +func runMachineDesktopUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { + let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH" + let name = try cursor.take(usage) + let distro = try requiredOption("--distro", cursor: &cursor, usage: usage) + guard ["debian", "ubuntu", "kali"].contains(distro) else { + throw DorydCtlError.usage("--distro must be debian, ubuntu, or kali") + } + let version = try requiredOption("--version", cursor: &cursor, usage: usage) + let bundlePath = try requiredOption("--bundle", cursor: &cursor, usage: usage) + let kernelPath = try requiredOption("--kernel", cursor: &cursor, usage: usage) + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("unexpected machine desktop-update argument: " + cursor.values[0]) + } + let request: NSDictionary = [ + "distro": distro, + "version": version, + "bundlePath": bundlePath, + "kernelPath": kernelPath, + ] + let result = try client.withTimeout(atLeast: 3_900).statusCommand { proxy, reply in + proxy.machineDesktopUpdate(name, request: request, reply: reply) + } + try emitJSON(result) +} + func runMachineShell(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { let name = try cursor.take("usage: dorydctl machine shell NAME") guard cursor.values.isEmpty else { diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift new file mode 100644 index 00000000..cb74c39b --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift @@ -0,0 +1,78 @@ +import DoryOperations +import Foundation +import XCTest + +final class DoryInstalledLinuxBootBundleTests: XCTestCase { + func testBundleRoundTripsAndMaterializesVerifiedArtifacts() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-installed-boot-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = directory.appendingPathComponent("kernel") + let kernelOutput = directory.appendingPathComponent("direct-kernel") + let initrdOutput = directory.appendingPathComponent("direct-initrd") + let kernel = Data((0..<65_537).map { UInt8(truncatingIfNeeded: $0) }) + let initrd = Data((0..<131_101).map { UInt8(truncatingIfNeeded: $0 * 3) }) + let assets = DoryLinuxInstallerBootAssets( + kernel: kernel, + initrd: initrd, + kernelISOPath: "casper/vmlinuz", + initrdISOPath: "casper/initrd" + ) + + try DoryInstalledLinuxBootBundle.write( + assets: assets, + rootDevice: "/dev/vda2", + toPath: bundle.path + ) + + XCTAssertTrue(DoryInstalledLinuxBootBundle.isBundle(atPath: bundle.path)) + XCTAssertEqual( + try DoryInstalledLinuxBootBundle.descriptor(atPath: bundle.path), + DoryInstalledLinuxBootDescriptor( + rootDevice: "/dev/vda2", + kernelLength: UInt64(kernel.count), + initrdLength: UInt64(initrd.count) + ) + ) + let descriptor = try DoryInstalledLinuxBootBundle.materialize( + fromPath: bundle.path, + kernelPath: kernelOutput.path, + initrdPath: initrdOutput.path + ) + XCTAssertEqual(descriptor.rootDevice, "/dev/vda2") + XCTAssertEqual(try Data(contentsOf: kernelOutput), kernel) + XCTAssertEqual(try Data(contentsOf: initrdOutput), initrd) + } + + func testMaterializationRejectsCorruptPayload() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-installed-boot-corrupt-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = directory.appendingPathComponent("kernel") + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data(repeating: 0x41, count: 4_096), + initrd: Data(repeating: 0x42, count: 8_192), + kernelISOPath: "kernel", + initrdISOPath: "initrd" + ), + rootDevice: "/dev/vda7", + toPath: bundle.path + ) + let handle = try FileHandle(forUpdating: bundle) + try handle.seekToEnd() + try handle.seek(toOffset: try handle.offset() - 1) + try handle.write(contentsOf: Data([0xff])) + try handle.close() + + XCTAssertThrowsError(try DoryInstalledLinuxBootBundle.materialize( + fromPath: bundle.path, + kernelPath: directory.appendingPathComponent("kernel.out").path, + initrdPath: directory.appendingPathComponent("initrd.out").path + )) { error in + XCTAssertEqual(error as? DoryInstalledLinuxBootBundleError, .digestMismatch) + } + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift new file mode 100644 index 00000000..cbd1c13c --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift @@ -0,0 +1,235 @@ +import DoryOperations +import XCTest + +final class DoryInstallerISOTests: XCTestCase { + func testDiscoversRootPartitionFromOptInInstalledDisk() throws { + guard let path = ProcessInfo.processInfo.environment["DORY_TEST_INSTALLED_DISK"], + !path.isEmpty else { + throw XCTSkip("set DORY_TEST_INSTALLED_DISK for the real-disk GPT smoke test") + } + XCTAssertEqual( + try DoryLinuxInstalledDiskInspector.rootDevice(atPath: path), + "/dev/vda2" + ) + } + + func testExtractsBootAssetsFromOptInRealInstallerISO() throws { + guard let path = ProcessInfo.processInfo.environment["DORY_TEST_INSTALLER_ISO"], + !path.isEmpty else { + throw XCTSkip("set DORY_TEST_INSTALLER_ISO for the bounded real-media smoke test") + } + + let compressedKernel = try DoryInstallerISOInspector.fileData( + atISOPath: path, + fromPath: "casper/vmlinuz", + maximumBytes: 128 * 1024 * 1024 + ) + XCTAssertGreaterThan(compressedKernel.count, 1024 * 1024) + let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: path) + + XCTAssertGreaterThan(assets.kernel.count, 1024 * 1024) + XCTAssertGreaterThan(assets.initrd.count, 1024 * 1024) + XCTAssertEqual(assets.kernel[0x38], 0x41) + XCTAssertEqual(assets.kernel[0x39], 0x52) + XCTAssertEqual(assets.kernel[0x3a], 0x4d) + XCTAssertEqual(assets.kernel[0x3b], 0x64) + } + + func testInstallerResourcePolicyUsesBalancedDefaultsWithoutClaimingCompatibility() { + XCTAssertEqual(DoryInstallerMachinePolicy.defaultCPUCount, 4) + XCTAssertEqual(DoryInstallerMachinePolicy.defaultMemoryMB, 4_096) + } + + func testDetectsEFILoaderArchitectureAcrossChunkBoundary() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-iso-inspector-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let arm = base.appendingPathComponent("arm.iso") + var armBytes = Data(repeating: 0, count: 1024 * 1024 - 4) + armBytes.append(Data("bootaa64.efi".utf8)) + try armBytes.write(to: arm) + XCTAssertEqual( + try DoryInstallerISOInspector.architecture(atPath: arm.path), + .arm64 + ) + + let x86 = base.appendingPathComponent("x86.iso") + try Data("EFI/BOOT/BOOTX64.EFI".utf8).write(to: x86) + XCTAssertEqual( + try DoryInstallerISOInspector.architecture(atPath: x86.path), + .x86_64 + ) + } + + func testReportsMultiArchitectureAndUnknownMedia() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-iso-inspector-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let mixed = base.appendingPathComponent("mixed.iso") + try Data("BOOTAA64.EFI---BOOTX64.EFI".utf8).write(to: mixed) + XCTAssertEqual( + try DoryInstallerISOInspector.architecture(atPath: mixed.path), + .multiArchitecture + ) + + let unknown = base.appendingPathComponent("unknown.iso") + try Data("custom loader".utf8).write(to: unknown) + XCTAssertEqual( + try DoryInstallerISOInspector.architecture(atPath: unknown.path), + .unknown + ) + } + + func testCompatibilityMessagesMatchTheHostArchitecture() { + XCTAssertEqual( + DoryInstallerISOInspector.compatibility(of: .arm64, hostArchitecture: "arm64"), + .compatible + ) + XCTAssertEqual( + DoryInstallerISOInspector.compatibility(of: .multiArchitecture, hostArchitecture: "arm64"), + .compatible + ) + XCTAssertEqual( + DoryInstallerISOInspector.compatibility(of: .unknown, hostArchitecture: "arm64"), + .unknown + ) + guard case let .incompatible(message) = DoryInstallerISOInspector.compatibility( + of: .x86_64, + hostArchitecture: "aarch64" + ) else { + return XCTFail("x86_64 media should be incompatible with Apple Silicon") + } + XCTAssertTrue(message.contains("Intel x86_64-only")) + XCTAssertTrue(message.contains("Apple Silicon")) + } + + func testMediaIdentityIncludesArchitectureSizeAndSHA256() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-iso-identity-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + let media = base.appendingPathComponent("installer.iso") + let contents = Data("EFI/BOOT/BOOTAA64.EFI\nqualified-by-digest".utf8) + try contents.write(to: media) + + let identity = try DoryInstallerISOInspector.mediaIdentity(atPath: media.path) + + XCTAssertEqual(identity.architecture, .arm64) + XCTAssertEqual(identity.byteCount, UInt64(contents.count)) + XCTAssertEqual( + identity.sha256, + "8f0bf0de7de4e656b9f29d4252461df93c6cb0d075d9cb993accd5722f48c27e" + ) + } + + func testRuntimeCatalogSeparatesArchitectureFromExactHostMediaEvidence() { + let ubuntu24044 = DoryInstallerISOMediaIdentity( + architecture: .arm64, + sha256: DoryInstallerISORuntimeCatalog.ubuntu24044DesktopARM64SHA256, + byteCount: 3_556_515_840 + ) + let failingHost = DoryInstallerHostRuntime( + architecture: "arm64", + hardwareModel: "Mac14,10", + operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 0, patchVersion: 0), + operatingSystemBuild: "26A5406e", + runtimeProfile: DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue + ) + guard case let .knownUnstable(message) = DoryInstallerISORuntimeCatalog.qualification( + of: ubuntu24044, + on: failingHost + ) else { + return XCTFail("the observed host/media tuple must be kept out of the installer path") + } + XCTAssertTrue(message.contains("retired VirtIO-block EFI profile")) + + let ubuntu24043 = DoryInstallerISOMediaIdentity( + architecture: .arm64, + sha256: DoryInstallerISORuntimeCatalog.ubuntu24043DesktopARM64SHA256, + byteCount: 3_473_190_912 + ) + guard case .knownUnstable = DoryInstallerISORuntimeCatalog.qualification( + of: ubuntu24043, + on: failingHost + ) else { + return XCTFail("24.04.3 also froze under the retired VirtIO-block profile") + } + + let currentStorageProfile = DoryInstallerHostRuntime( + architecture: "arm64", + hardwareModel: "Mac14,10", + operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 0, patchVersion: 0), + operatingSystemBuild: "26A5406e", + runtimeProfile: DoryInstallerRuntimeProfile.nativeNVMeFsyncV1.rawValue + ) + XCTAssertEqual( + DoryInstallerISORuntimeCatalog.qualification(of: ubuntu24043, on: currentStorageProfile), + .unqualified + ) + + let differentBuild = DoryInstallerHostRuntime( + architecture: "arm64", + hardwareModel: "Mac14,10", + operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 1, patchVersion: 0), + operatingSystemBuild: "26B100", + runtimeProfile: DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue + ) + XCTAssertEqual( + DoryInstallerISORuntimeCatalog.qualification(of: ubuntu24044, on: differentBuild), + .unqualified + ) + } + + func testStagesCompatibleMediaAsAPrivateRegularFile() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-iso-stager-\(UUID().uuidString)") + let sourceDirectory = base.appendingPathComponent("source", isDirectory: true) + let stagingDirectory = base.appendingPathComponent("staging", isDirectory: true) + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let source = sourceDirectory.appendingPathComponent("ubuntu.iso") + let contents = Data("EFI/BOOT/BOOTAA64.EFI\nubuntu desktop".utf8) + try contents.write(to: source) + + let staged = try DoryInstallerISOStager.stage( + atPath: source.path, + stagingDirectory: stagingDirectory, + hostArchitecture: "arm64" + ) + + XCTAssertEqual(staged.architecture, .arm64) + XCTAssertEqual(staged.identity.byteCount, UInt64(contents.count)) + XCTAssertFalse(staged.sha256.isEmpty) + XCTAssertEqual(staged.runtimeQualification, .unqualified) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: staged.path)), contents) + let attributes = try FileManager.default.attributesOfItem(atPath: staged.path) + XCTAssertEqual(attributes[.type] as? FileAttributeType, .typeRegular) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + let directoryAttributes = try FileManager.default.attributesOfItem(atPath: stagingDirectory.path) + XCTAssertEqual((directoryAttributes[.posixPermissions] as? NSNumber)?.intValue, 0o700) + } + + func testRejectsIncompatibleMediaBeforeCreatingAStagingDirectory() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-iso-stager-rejection-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + let source = base.appendingPathComponent("omarchy.iso") + let stagingDirectory = base.appendingPathComponent("staging", isDirectory: true) + try Data("EFI/BOOT/BOOTX64.EFI".utf8).write(to: source) + + XCTAssertThrowsError(try DoryInstallerISOStager.stage( + atPath: source.path, + stagingDirectory: stagingDirectory, + hostArchitecture: "arm64" + )) { error in + XCTAssertTrue(String(describing: error).contains("Intel x86_64-only")) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: stagingDirectory.path)) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index cad66f90..d2ba6144 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -1,3 +1,4 @@ +import AppKit import Darwin import DoryCore import DorydKit @@ -6,6 +7,113 @@ import Virtualization import XCTest final class DoryVMMKitTests: XCTestCase { + @MainActor + func testClipboardRetriesInitialHostPushUntilDesktopSessionIsReady() async throws { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("host clipboard before guest login", forType: .string) + let recorder = ClipboardWriteRecorder() + let coordinator = DoryDesktopClipboardCoordinator( + policy: .hostToGuest, + execute: { argv, stdin, _, _ in + if argv == ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"] { + return Self.execResult(exitCode: 0) + } + XCTAssertEqual(argv, [ + "/usr/lib/dory/clipboard", "set", "text/plain;charset=utf-8", + ]) + let attempt = recorder.record(stdin) + return Self.execResult(exitCode: attempt == 1 ? 1 : 0) + }, + sendShortcut: { _ in }, + pasteboard: pasteboard, + startupRetryDelay: 0.01, + startupRetryLimit: 3, + log: { _ in } + ) + + coordinator.start() + coordinator.markGuestReady() + defer { coordinator.stop() } + + let deadline = ContinuousClock.now + .seconds(2) + while recorder.attemptCount < 2, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(recorder.attemptCount, 2) + XCTAssertEqual(recorder.lastPayload, Data("host clipboard before guest login".utf8)) + } + + @MainActor + func testClipboardPullsGuestValueWhenDesktopResignsFocus() async throws { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("original host value", forType: .string) + let recorder = ClipboardWriteRecorder() + let coordinator = DoryDesktopClipboardCoordinator( + policy: .guestToHost, + execute: { argv, _, _, _ in + if argv == ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"] { + recorder.recordCapabilityProbe() + return Self.execResult(exitCode: 0) + } + if argv == ["/usr/lib/dory/clipboard", "get", "image/png"] { + return Self.execResult(exitCode: 1) + } + XCTAssertEqual(argv, [ + "/usr/lib/dory/clipboard", "get", "text/plain;charset=utf-8", + ]) + return DoryExecResult( + exitCode: 0, + stdout: Data("guest clipboard after copy".utf8), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + }, + sendShortcut: { _ in }, + pasteboard: pasteboard, + startupRetryDelay: 0.01, + startupRetryLimit: 1, + log: { _ in } + ) + + coordinator.start() + coordinator.markGuestReady() + defer { coordinator.stop() } + + let readyDeadline = ContinuousClock.now + .seconds(2) + while recorder.capabilityProbeCount == 0, ContinuousClock.now < readyDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + try await Task.sleep(for: .milliseconds(20)) + NotificationCenter.default.post( + name: NSApplication.didResignActiveNotification, + object: NSApplication.shared + ) + + let pullDeadline = ContinuousClock.now + .seconds(2) + while pasteboard.string(forType: .string) != "guest clipboard after copy", + ContinuousClock.now < pullDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(pasteboard.string(forType: .string), "guest clipboard after copy") + } + + private static func execResult(exitCode: Int32) -> DoryExecResult { + DoryExecResult( + exitCode: exitCode, + stdout: Data(), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } + func testDesktopWindowUsesRetinaBackingPixels() { XCTAssertEqual( DoryVMMDesktopApplication.targetPixelSize( @@ -75,6 +183,8 @@ final class DoryVMMKitTests: XCTestCase { "--data-drive", "/Volumes/Work/Dory.dorydrive", "--kernel", "/tmp/vmlinux", "--rootfs", "/tmp/rootfs.raw", + "--boot-mode", "efi", + "--installer-iso", "/tmp/ubuntu.iso", "--gvproxy", "/tmp/gvproxy", "--ssh-agent-socket", "/private/tmp/com.apple.launchd.fixture/Listeners", "--publish-host", "0.0.0.0", @@ -96,6 +206,8 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(arguments.dataDriveRoot, "/Volumes/Work/Dory.dorydrive") XCTAssertEqual(arguments.kernelPath, "/tmp/vmlinux") XCTAssertEqual(arguments.rootfsPath, "/tmp/rootfs.raw") + XCTAssertEqual(arguments.machineBootMode, .efi) + XCTAssertEqual(arguments.installerISOPath, "/tmp/ubuntu.iso") XCTAssertEqual(arguments.gvproxyPath, "/tmp/gvproxy") XCTAssertEqual( arguments.sshAgentSocketPath, @@ -319,6 +431,71 @@ final class DoryVMMKitTests: XCTestCase { try assertShellSyntax("\(base)/dorycfg/boot.sh") } + func testEFIProfileSeparatesInstallerAndInstalledFirmwareAndAttachesISOReadOnly() throws { + let base = "/tmp/dory-vmm-efi-\(getpid())-\(UInt32.random(in: 0...size)) + } + } + XCTAssertEqual(connectResult, 0) + + let hostInput = Data("ubuntu\n".utf8) + XCTAssertEqual(hostInput.withUnsafeBytes { bytes in + Darwin.send(client, bytes.baseAddress, bytes.count, MSG_NOSIGNAL) + }, hostInput.count) + var inputPoll = pollfd( + fd: console.guestInput.fileDescriptor, + events: Int16(POLLIN), + revents: 0 + ) + XCTAssertEqual(poll(&inputPoll, 1, 2_000), 1) + var inputBuffer = [UInt8](repeating: 0, count: 64) + let inputCount = Darwin.read( + console.guestInput.fileDescriptor, + &inputBuffer, + inputBuffer.count + ) + XCTAssertEqual(Data(inputBuffer.prefix(inputCount)), hostInput) + + let guestOutput = Data("ubuntu login: ".utf8) + try console.guestOutput.write(contentsOf: guestOutput) + var outputPoll = pollfd(fd: client, events: Int16(POLLIN), revents: 0) + XCTAssertEqual(poll(&outputPoll, 1, 2_000), 1) + var outputBuffer = [UInt8](repeating: 0, count: 64) + let outputCount = Darwin.read(client, &outputBuffer, outputBuffer.count) + XCTAssertEqual(Data(outputBuffer.prefix(outputCount)), guestOutput) + + Thread.sleep(forTimeInterval: 0.05) + try log.synchronize() + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: logPath)), guestOutput) + let attributes = try FileManager.default.attributesOfItem(atPath: socketPath) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + } + func testDeferredVMMShutdownRequestIsDeliveredExactlyOnceAfterRuntimeAttach() { let watchdog = ShutdownWatchdogRecorder() let forcedExit = ForcedExitRecorder() @@ -608,6 +874,45 @@ final class DoryVMMKitTests: XCTestCase { } } +private final class ClipboardWriteRecorder: @unchecked Sendable { + private let lock = NSLock() + private var attempts = 0 + private var payload = Data() + private var capabilityProbes = 0 + + func record(_ value: Data) -> Int { + lock.lock() + defer { lock.unlock() } + attempts += 1 + payload = value + return attempts + } + + var attemptCount: Int { + lock.lock() + defer { lock.unlock() } + return attempts + } + + var lastPayload: Data { + lock.lock() + defer { lock.unlock() } + return payload + } + + func recordCapabilityProbe() { + lock.lock() + capabilityProbes += 1 + lock.unlock() + } + + var capabilityProbeCount: Int { + lock.lock() + defer { lock.unlock() } + return capabilityProbes + } +} + private final class FakeVMMShutdownTarget: DoryVMMGuestShutdownHandling, @unchecked Sendable { private let lock = NSLock() private let requested = DispatchSemaphore(value: 0) diff --git a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift index b80b64db..5976c18e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift @@ -897,6 +897,47 @@ final class DorydConfigurationTests: XCTestCase { XCTAssertTrue(config.requiresReadyHandoff) } + func testMachineManagerConfigurationSelectsRawHVOnlyForEligibleAcceleratedDesktops() throws { + let directory = "/tmp/doryd-desktop-hv-config-\(getpid())-\(UInt32.random(in: 0..> "$1" + count=$(wc -l < "$1" | tr -d ' ') + test "$count" -ne 1 || exit 7 + exec /bin/sleep 30 + """.write(toFile: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script) + + let process = HvProcess(configuration: HvProcessConfiguration( + executablePath: script, + arguments: [marker], + restartPolicy: HvRestartPolicy(maxRestarts: 2, delaySeconds: 0.2) + )) + try process.start() + + let firstRunDeadline = Date().addingTimeInterval(2) + while Date() < firstRunDeadline, + ((try? String(contentsOfFile: marker, encoding: .utf8))? + .split(separator: "\n").count ?? 0) < 1 { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertTrue(process.isRunningOrRestarting) + + let secondRunDeadline = Date().addingTimeInterval(2) + while Date() < secondRunDeadline, + ((try? String(contentsOfFile: marker, encoding: .utf8))? + .split(separator: "\n").count ?? 0) < 2 { + Thread.sleep(forTimeInterval: 0.01) + } + let pid = try XCTUnwrap(process.pid) + process.disableRestarts() + XCTAssertEqual(kill(pid, SIGKILL), 0) + + let stoppedDeadline = Date().addingTimeInterval(2) + while Date() < stoppedDeadline, process.isRunningOrRestarting { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertFalse(process.isRunningOrRestarting) + XCTAssertEqual(try String(contentsOfFile: marker, encoding: .utf8).split(separator: "\n").count, 2) + } + func testUnexpectedExitNotifiesSupervisor() throws { let callback = expectation(description: "unexpected termination callback") let captured = LockedTermination() diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index a2448cff..a868aa6c 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -1,5 +1,7 @@ import CryptoKit +import Darwin import DoryCore +import DoryOperations @testable import DorydKit import XCTest @@ -61,9 +63,11 @@ final class MachineManagerTests: XCTestCase { let created = try manager.create(DoryMachineConfiguration( id: "dev", kernelPath: doryTestKernelPath, - rootfsPath: doryTestRootfsPath + rootfsPath: doryTestRootfsPath, + displayMode: .desktop )) XCTAssertEqual(created.state, .created) + XCTAssertEqual(created.displayMode, .desktop) let running = try manager.start(id: "dev") XCTAssertEqual(running.state, .running) @@ -78,6 +82,222 @@ final class MachineManagerTests: XCTestCase { XCTAssertTrue(manager.list().isEmpty) } + func testDesktopUpdatePreservesPersistentDiskAndRetainsLastGoodSnapshot() throws { + let base = "/tmp/dory-machine-desktop-update-\(getpid())-\(UInt32.random(in: 0..() + DispatchQueue.global(qos: .userInitiated).async { + updateResult.store(Result { + try manager.update(id: "linux", installerMediaAttached: false) + }) + updateFinished.fulfill() + } + + let updatedLaunch = try waitForMachineStatus(manager, id: "linux") { + $0.state == .starting + && !$0.installerMediaAttached + && $0.pid != nil + && $0.pid != originallyRunning.pid + } + let failedStartupPID = try XCTUnwrap(updatedLaunch.pid) + XCTAssertEqual(kill(failedStartupPID, SIGKILL), 0) + + let restarted = try waitForMachineStatus(manager, id: "linux", timeout: 3) { + $0.state == .starting + && !$0.installerMediaAttached + && $0.pid != nil + && $0.pid != failedStartupPID + } + try sendVmmHandoff( + path: try XCTUnwrap(restarted.handoffSocketPath), + ready: VmmReadyMessage(machineID: "linux"), + fileDescriptors: [] + ) + + wait(for: [updateFinished], timeout: 5) + switch try XCTUnwrap(updateResult.value) { + case let .success(status): + XCTAssertEqual(status.state, .running) + XCTAssertFalse(status.installerMediaAttached) + case let .failure(error): + XCTFail("installer eject unexpectedly rolled back: \(error)") + } + } + + func testCreateEFIMachineRejectsKnownWrongArchitectureInstaller() throws { + let base = "/tmp/dory-machine-efi-arch-\(getpid())-\(UInt32.random(in: 0.. '\(desktopCapture)'\nsleep 30\n".write( + toFile: desktopHelper, + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\nprintf '%s\\n' \"$@\" > '\(fallbackCapture)'\nsleep 30\n".write( + toFile: fallbackHelper, + atomically: true, + encoding: .utf8 + ) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: desktopHelper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallbackHelper) + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: fallbackHelper, + acceleratedDesktopExecutablePath: desktopHelper, + stateDirectory: base + "/machines", + baseArguments: ["fallback"], + acceleratedDesktopBaseArguments: ["desktop", "--gvproxy", "/tmp/gvproxy"], + requiresReadyHandoff: false + )) + defer { + try? manager.delete(id: "desktop") + try? manager.delete(id: "compatible") + try? manager.delete(id: "headless") + try? FileManager.default.removeItem(atPath: base) + } + + _ = try manager.create(DoryMachineConfiguration( + id: "desktop", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + displayMode: .desktop + )) + _ = try manager.start(id: "desktop") + for _ in 0..<100 where !FileManager.default.fileExists(atPath: desktopCapture) { + Thread.sleep(forTimeInterval: 0.01) + } + let desktopArguments = try String(contentsOfFile: desktopCapture, encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertEqual(Array(desktopArguments.prefix(3)), ["desktop", "--gvproxy", "/tmp/gvproxy"]) + let desktopDisplayIndex = try XCTUnwrap(desktopArguments.firstIndex(of: "--display-mode")) + XCTAssertEqual(desktopArguments[desktopDisplayIndex + 1], "desktop") + _ = try manager.stop(id: "desktop") + + _ = try manager.create(DoryMachineConfiguration( + id: "compatible", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + displayMode: .desktop, + environment: [DoryDesktopVMMPreference.environmentKey: "compatible"] + )) + _ = try manager.start(id: "compatible") + for _ in 0..<100 where !FileManager.default.fileExists(atPath: fallbackCapture) { + Thread.sleep(forTimeInterval: 0.01) + } + let compatibleArguments = try String(contentsOfFile: fallbackCapture, encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertEqual(compatibleArguments.first, "fallback") + let compatibleDisplayIndex = try XCTUnwrap(compatibleArguments.firstIndex(of: "--display-mode")) + XCTAssertEqual(compatibleArguments[compatibleDisplayIndex + 1], "desktop") + _ = try manager.stop(id: "compatible") + try FileManager.default.removeItem(atPath: fallbackCapture) + + _ = try manager.create(DoryMachineConfiguration( + id: "headless", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + displayMode: .headless + )) + _ = try manager.start(id: "headless") + for _ in 0..<100 where !FileManager.default.fileExists(atPath: fallbackCapture) { + Thread.sleep(forTimeInterval: 0.01) + } + let fallbackArguments = try String(contentsOfFile: fallbackCapture, encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertEqual(fallbackArguments.first, "fallback") + let fallbackDisplayIndex = try XCTUnwrap(fallbackArguments.firstIndex(of: "--display-mode")) + XCTAssertEqual(fallbackArguments[fallbackDisplayIndex + 1], "headless") + _ = try manager.stop(id: "headless") + } + + func testInstalledEFIBootBundleLaunchesThroughAcceleratedGenericLinuxRuntime() throws { + let base = "/tmp/dory-machine-installed-efi-accelerated-\(getpid())-\(UInt32.random(in: 0.. '\(capture)'\nsleep 30\n".write( + toFile: desktopHelper, + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\nexit 91\n".write(toFile: fallbackHelper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: desktopHelper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallbackHelper) + try Data("installed-disk".utf8).write(to: URL(fileURLWithPath: sourceDisk)) + let kernel = Data(repeating: 0x41, count: 8_192) + let initrd = Data(repeating: 0x42, count: 16_384) + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: kernel, + initrd: initrd, + kernelISOPath: "casper/vmlinuz", + initrdISOPath: "casper/initrd" + ), + rootDevice: "/dev/vda2", + toPath: sourceBundle + ) + let state = base + "/machines" + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: fallbackHelper, + acceleratedDesktopExecutablePath: desktopHelper, + stateDirectory: state, + acceleratedDesktopBaseArguments: ["desktop", "--gvproxy", "/tmp/gvproxy"], + requiresReadyHandoff: false + )) + defer { try? manager.delete(id: "ubuntu") } + + _ = try manager.create(DoryMachineConfiguration( + id: "ubuntu", + kernelPath: sourceBundle, + rootfsPath: sourceDisk, + bootMode: .efi, + memoryMB: 4096, + cpuCount: 4, + displayMode: .desktop + )) + _ = try manager.start(id: "ubuntu") + let arguments = try waitForFileContent(capture) + .split(separator: "\n").map(String.init) + func value(after flag: String) throws -> String { + let index = try XCTUnwrap(arguments.firstIndex(of: flag)) + return arguments[index + 1] + } + + XCTAssertEqual(Array(arguments.prefix(3)), ["desktop", "--gvproxy", "/tmp/gvproxy"]) + XCTAssertEqual(try value(after: "--boot-mode"), "efi-installed") + XCTAssertEqual(try value(after: "--root-device"), "/dev/vda2") + XCTAssertEqual(try value(after: "--kernel"), "\(state)/ubuntu/direct-kernel") + XCTAssertEqual(try value(after: "--initrd"), "\(state)/ubuntu/direct-initrd") + XCTAssertTrue(arguments.contains("--generic-guest")) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: "\(state)/ubuntu/direct-kernel")), kernel) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: "\(state)/ubuntu/direct-initrd")), initrd) + } + func testWakeClockSyncsRunningMachineAgents() throws { let base = "/tmp/dory-machine-clock-\(getpid())-\(UInt32.random(in: 0.. Result { + let box = LockedResult() + DispatchQueue.global(qos: .userInitiated).async { + box.store(Result { try manager.updateDesktop(id: id, request: request) }) + } + let deadline = Date().addingTimeInterval(timeout) + var handedOffPIDs: Set = [] + while Date() < deadline, box.value == nil { + if let status = manager.status(id: id), + status.state == .starting, + let pid = status.pid, + !handedOffPIDs.contains(pid), + let handoffPath = status.handoffSocketPath { + try sendVmmHandoff( + path: handoffPath, + ready: VmmReadyMessage( + machineID: id, + agentBuild: "dory-agent/update-test", + agentSocketPath: "/run/dory-update-test-agent.sock", + dockerdSocketPath: "/run/dory-update-test-docker.sock" + ), + fileDescriptors: [] + ) + handedOffPIDs.insert(pid) + } + Thread.sleep(forTimeInterval: 0.01) + } + return try XCTUnwrap(box.value, "desktop update timed out") +} + +private final class DesktopUpdateAgentConnector: @unchecked Sendable { + private let lock = NSLock() + private let managedRootfsPath: String + private let failsInstall: Bool + private var executions = 0 + + init(managedRootfsPath: String, failsInstall: Bool = false) { + self.managedRootfsPath = managedRootfsPath + self.failsInstall = failsInstall + } + + var execCount: Int { + lock.lock() + defer { lock.unlock() } + return executions + } + + func connect(socketPath: String) throws -> any AgentControlClient { + DesktopUpdateAgentControlClient(owner: self) + } + + func execute(argv: [String]) -> DoryExecResult { + lock.lock() + executions += 1 + lock.unlock() + if argv.first?.hasSuffix("/apply.sh") == true { + try? Data("rootfs-after".utf8).write(to: URL(fileURLWithPath: managedRootfsPath)) + if failsInstall { + return DoryExecResult( + exitCode: 42, + stdout: Data(), + stderr: Data("injected package failure".utf8), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } + let output = "Dory desktop update applied: ubuntu 1.2.3+runtime.4.5.6 " + + String(repeating: "a", count: 64) + "\n" + return DoryExecResult( + exitCode: 0, + stdout: Data(output.utf8), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } + return DoryExecResult( + exitCode: 0, + stdout: Data("ok\n".utf8), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } +} + +private final class DesktopUpdateAgentControlClient: AgentControlClient, @unchecked Sendable { + private let owner: DesktopUpdateAgentConnector + + init(owner: DesktopUpdateAgentConnector) { + self.owner = owner + } + + func info() throws -> DoryAgentInfo { + DoryAgentInfo( + protocolVersion: 1, + kernel: "Linux update-test", + agentBuild: "dory-agent/update-test", + uptimeSeconds: 1 + ) + } + + func clockSync(hostEpochNs: Int64) throws -> Bool { true } + + func portsWatch() throws -> DoryPortsSnapshot { + DoryPortsSnapshot(ports: [], added: [], removed: []) + } + + func telemetry() throws -> DoryTelemetry { + DoryTelemetry(memTotalKB: 2048, memAvailableKB: 1024, psiSomeAvg10: 0, psiFullAvg10: 0) + } + + func exec( + argv: [String], + cwd: String, + env: [DoryExecEnvironment], + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult { + owner.execute(argv: argv) + } + + func close() {} +} + private final class RecordingMachineAgentConnector: @unchecked Sendable { private let lock = NSLock() private var paths: [String] = [] From c7334f365e66513b9f9c08b3543c8e473f010532 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:54:47 +0000 Subject: [PATCH 005/338] feat(guest): build qualified accelerated Linux desktops --- guest/desktop/PINS | 5 +- guest/desktop/apply-update.sh | 122 ++++++++ guest/desktop/build-update-bundle.py | 105 +++++++ guest/desktop/build.sh | 102 +++++- guest/desktop/input-fingerprint.sh | 10 +- .../etc/NetworkManager/conf.d/10-dory.conf | 7 + .../conf.d/10-globally-managed-devices.conf | 4 + .../dory-wired.nmconnection | 16 + .../etc/apt/keyrings/packages.mozilla.org.asc | 19 ++ .../etc/apt/preferences.d/mozilla | 3 + .../etc/apt/sources.list.d/mozilla.list | 1 + .../etc/dconf/db/dory.d/00-managed-session | 11 + .../dconf/db/dory.d/locks/00-managed-session | 4 + .../rootfs-overlay/etc/dconf/profile/user | 2 + .../etc/environment.d/60-dory-desktop.conf | 3 + .../rootfs-overlay/etc/gdm3/custom.conf | 15 + .../rules.d/49-dory-passwordless-admin.rules | 8 + .../etc/systemd/system/dory-boot.service | 2 +- .../systemd/system/dory-desktop-ready.service | 2 +- .../systemd/system/dory-first-boot.service | 2 +- .../system/dory-graphics-backend.service | 12 + .../etc/systemd/system/dory-zram.service | 12 + .../main.lua.d/60-dory-virtio-sound.lua | 56 ++++ .../etc/xdg/autostart/dory-display.desktop | 2 +- .../rootfs-overlay/usr/lib/dory/clipboard | 59 ++++ .../usr/lib/dory/configure-display | 55 ++++ .../usr/lib/dory/configure-graphics-backend | 67 ++++ .../usr/lib/dory/configure-machine | 9 +- .../usr/lib/dory/configure-zram | 42 +++ .../rootfs-overlay/usr/lib/dory/first-boot | 20 +- .../lib/firefox/distribution/policies.json | 14 + .../firefox-esr/distribution/policies.json | 14 + guest/desktop/verify-build.sh | 294 +++++++++++++++++- guest/kernel/build.sh | 5 +- .../kernel/dory-accelerated-desktop.fragment | 16 + guest/kernel/dory-desktop.fragment | 5 + guest/kernel/dory-headless.fragment | 1 + guest/kernel/input-fingerprint.sh | 5 +- ...6-virtio-gpu-align-host-visible-vram.patch | 84 +++++ guest/kernel/profile.sh | 6 +- guest/kernel/verify-build.sh | 5 +- guest/mesa/PINS | 7 + guest/mesa/README.md | 16 + guest/mesa/build.sh | 142 +++++++++ guest/mesa/dory-vulkan-probe.c | 101 ++++++ guest/mesa/input-fingerprint.sh | 27 ++ ...nus-enable-dory-implicit-fencing-wsi.patch | 210 +++++++++++++ guest/mesa/verify-build.sh | 66 ++++ 48 files changed, 1756 insertions(+), 39 deletions(-) create mode 100755 guest/desktop/apply-update.sh create mode 100755 guest/desktop/build-update-bundle.py create mode 100644 guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf create mode 100644 guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection create mode 100644 guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc create mode 100644 guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla create mode 100644 guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/profile/user create mode 100644 guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf create mode 100644 guest/desktop/rootfs-overlay/etc/gdm3/custom.conf create mode 100644 guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service create mode 100644 guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/clipboard create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram create mode 100644 guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json create mode 100644 guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json create mode 100644 guest/kernel/dory-accelerated-desktop.fragment create mode 100644 guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch create mode 100644 guest/mesa/PINS create mode 100644 guest/mesa/README.md create mode 100755 guest/mesa/build.sh create mode 100644 guest/mesa/dory-vulkan-probe.c create mode 100755 guest/mesa/input-fingerprint.sh create mode 100644 guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch create mode 100755 guest/mesa/verify-build.sh diff --git a/guest/desktop/PINS b/guest/desktop/PINS index ec492063..415700e3 100644 --- a/guest/desktop/PINS +++ b/guest/desktop/PINS @@ -9,4 +9,7 @@ UBUNTU_MIRROR=https://ports.ubuntu.com/ubuntu-ports KALI_BUILDER_IMAGE=kalilinux/kali-rolling@sha256:8a1ea7281085ffef4963e82766c70869d7db910df88dcbb1f03d2899420b9577 KALI_SUITE=kali-rolling KALI_MIRROR=https://http.kali.org/kali -DESKTOP_IMAGE_SIZE_MB=6144 +DESKTOP_IMAGE_SIZE_MB=8192 +DESKTOP_FREE_SPACE_MB=2048 +MOZILLA_APT_KEY_FINGERPRINT=35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3 +MOZILLA_APT_KEY_SHA256=3ecc63922b7795eb23fdc449ff9396f9114cb3cf186d6f5b53ad4cc3ebfbb11f diff --git a/guest/desktop/apply-update.sh b/guest/desktop/apply-update.sh new file mode 100755 index 00000000..6146e75b --- /dev/null +++ b/guest/desktop/apply-update.sh @@ -0,0 +1,122 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +EXPECTED_DISTRO="${1:-}" +RELEASE_VERSION="${2:-}" + +fail() { + echo "Dory desktop update failed: $*" >&2 + exit 1 +} + +[ "$(id -u)" -eq 0 ] || fail "the update must run as root" +case "$EXPECTED_DISTRO" in + debian|ubuntu|kali) ;; + *) fail "unsupported distribution '$EXPECTED_DISTRO'" ;; +esac +[ -n "$RELEASE_VERSION" ] || fail "the target release version is missing" +[ -f "$ROOT/manifest.env" ] || fail "manifest.env is missing" +[ -f "$ROOT/SHA256SUMS" ] || fail "SHA256SUMS is missing" + +(cd "$ROOT" && sha256sum -c SHA256SUMS) || fail "the signed update payload is corrupt" + +manifest_value() { + sed -n "s/^$1=//p" "$ROOT/manifest.env" +} + +[ "$(manifest_value schema)" = 2 ] || fail "the update manifest schema is unsupported" +[ "$(manifest_value arch)" = arm64 ] || fail "the update payload has the wrong architecture" +[ "$(manifest_value distro)" = "$EXPECTED_DISTRO" ] || fail "the update payload has the wrong distribution" +INPUT_SHA256="$(manifest_value input_sha256)" +case "$INPUT_SHA256" in + [0-9a-f][0-9a-f]*) ;; + *) fail "the update payload fingerprint is invalid" ;; +esac +[ "${#INPUT_SHA256}" -eq 64 ] || fail "the update payload fingerprint is invalid" + +. /etc/os-release +[ "${ID:-}" = "$EXPECTED_DISTRO" ] || fail "this guest is ${ID:-unknown}, not $EXPECTED_DISTRO" +[ "$(uname -m)" = aarch64 ] || fail "this guest is not ARM64" + +require_package() { + package="$1" + dpkg-query -W -f='${Status}\n' "$package" 2>/dev/null \ + | grep -Fqx 'install ok installed' \ + || fail "the guest is missing required package '$package'; install a supported desktop image before updating Dory guest tools" +} + +# packages.txt is the signed package provenance for the matching full image. A guest-tools update +# must not turn into an unreviewed, network-dependent distribution upgrade. Validate the small +# runtime surface the integration layer needs, then update only Dory-owned files below. Ubuntu, +# Debian, and Kali remain responsible for their normal package updates inside the guest. +for package in dconf-cli network-manager openssh-server pipewire spice-vdagent x11-utils zstd; do + require_package "$package" +done +case "$EXPECTED_DISTRO" in + ubuntu) + for package in gdm3 gnome-shell firefox; do require_package "$package"; done + ;; + *) + for package in lightdm xfce4; do require_package "$package"; done + ;; +esac +if [ "$EXPECTED_DISTRO" = ubuntu ]; then + # Keep Ubuntu's dock pointed at the Mozilla APT launcher rather than the absent Snap ID. + sed -i "s/firefox_firefox\.desktop/firefox.desktop/g" \ + /usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override + glib-compile-schemas /usr/share/glib-2.0/schemas +fi + +# The component payload is signature-verified on the host and digest-verified again above. Apply +# only Dory-owned integration files; package-manager and user configuration remain guest-owned. +MANAGED_USER="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory\n')" +id "$MANAGED_USER" >/dev/null 2>&1 || fail "the managed desktop account is missing" +tar -xf "$ROOT/rootfs-overlay.tar" -C / +zstd -q -d -c "$ROOT/dory-mesa-venus-arm64.tar.zst" | tar -xf - -C / +install -m0755 "$ROOT/dory-agent" /usr/bin/dory-agent +chmod 0755 /usr/lib/dory/clipboard /usr/lib/dory/configure-machine /usr/lib/dory/first-boot \ + /usr/lib/dory/start-agent /usr/lib/dory/wait-host-configuration \ + /usr/lib/dory/configure-display /usr/lib/dory/configure-zram \ + /usr/lib/dory/configure-graphics-backend /usr/lib/dory/dory-vulkan-probe +chmod 0600 /etc/NetworkManager/system-connections/dory-wired.nmconnection +chmod 0644 /etc/polkit-1/rules.d/49-dory-passwordless-admin.rules +if [ -x /usr/bin/dconf ]; then + dconf update +fi + +rm -f /etc/resolv.conf +ln -s ../run/NetworkManager/resolv.conf /etc/resolv.conf +case "$EXPECTED_DISTRO" in + ubuntu) + rm -rf /etc/lightdm + sed -i "s/^AutomaticLogin=.*/AutomaticLogin=$MANAGED_USER/" /etc/gdm3/custom.conf + systemctl enable gdm3.service + ;; + *) + rm -rf /etc/gdm3 + sed -i "s/^autologin-user=.*/autologin-user=$MANAGED_USER/" \ + /etc/lightdm/lightdm.conf.d/50-dory.conf + systemctl enable lightdm.service + ;; +esac +systemctl enable NetworkManager.service NetworkManager-wait-online.service ssh.service \ + dory-first-boot.service dory-boot.service dory-desktop-ready.service dory-zram.service \ + dory-graphics-backend.service +systemctl set-default graphical.target +systemctl daemon-reload + +install -d -m0755 /var/lib/dory +RECEIPT="/var/lib/dory/desktop-update.env" +TEMP_RECEIPT="${RECEIPT}.tmp.$$" +{ + printf 'schema=1\n' + printf 'distro=%s\n' "$EXPECTED_DISTRO" + printf 'version=%s\n' "$RELEASE_VERSION" + printf 'input_sha256=%s\n' "$INPUT_SHA256" + printf 'installed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$TEMP_RECEIPT" +chmod 0644 "$TEMP_RECEIPT" +mv -f "$TEMP_RECEIPT" "$RECEIPT" + +echo "Dory desktop update applied: $EXPECTED_DISTRO $RELEASE_VERSION $INPUT_SHA256" diff --git a/guest/desktop/build-update-bundle.py b/guest/desktop/build-update-bundle.py new file mode 100755 index 00000000..32801ab2 --- /dev/null +++ b/guest/desktop/build-update-bundle.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Build the small, signed in-place update payload shipped beside each desktop rootfs.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import pathlib +import shutil +import stat +import tarfile +import tempfile + + +def digest(path: pathlib.Path) -> str: + value = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + value.update(chunk) + return value.hexdigest() + + +def add_path(archive: tarfile.TarFile, source: pathlib.Path, name: str) -> None: + info = source.lstat() + if stat.S_ISLNK(info.st_mode): + raise SystemExit(f"desktop update payload refuses symlink: {source}") + if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): + raise SystemExit(f"desktop update payload refuses special file: {source}") + tar_info = archive.gettarinfo(str(source), arcname=name) + tar_info.uid = 0 + tar_info.gid = 0 + tar_info.uname = "root" + tar_info.gname = "root" + tar_info.mtime = 0 + if source.is_dir(): + archive.addfile(tar_info) + else: + with source.open("rb") as contents: + archive.addfile(tar_info, contents) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--distro", choices=("debian", "ubuntu", "kali"), required=True) + parser.add_argument("--fingerprint", required=True) + parser.add_argument("--packages", type=pathlib.Path, required=True) + parser.add_argument("--overlay", type=pathlib.Path, required=True) + parser.add_argument("--agent", type=pathlib.Path, required=True) + parser.add_argument("--venus-runtime", type=pathlib.Path, required=True) + parser.add_argument("--apply", type=pathlib.Path, required=True) + parser.add_argument("--output", type=pathlib.Path, required=True) + args = parser.parse_args() + if len(args.fingerprint) != 64 or any(c not in "0123456789abcdef" for c in args.fingerprint): + raise SystemExit("desktop update fingerprint must be a lowercase SHA-256 digest") + for path in (args.packages, args.agent, args.venus_runtime, args.apply): + if not path.is_file() or path.is_symlink() or path.stat().st_size == 0: + raise SystemExit(f"desktop update input is invalid: {path}") + if not args.overlay.is_dir() or args.overlay.is_symlink(): + raise SystemExit(f"desktop update overlay is invalid: {args.overlay}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="dory-desktop-update-") as temporary: + staging = pathlib.Path(temporary) + shutil.copy2(args.apply, staging / "apply.sh") + shutil.copy2(args.agent, staging / "dory-agent") + shutil.copy2(args.venus_runtime, staging / "dory-mesa-venus-arm64.tar.zst") + shutil.copy2(args.packages, staging / "packages.txt") + (staging / "manifest.env").write_text( + f"schema=2\narch=arm64\ndistro={args.distro}\ninput_sha256={args.fingerprint}\n", + encoding="utf-8", + ) + os.chmod(staging / "apply.sh", 0o755) + os.chmod(staging / "dory-agent", 0o755) + + overlay_tar = staging / "rootfs-overlay.tar" + with tarfile.open(overlay_tar, "w", format=tarfile.PAX_FORMAT) as archive: + add_path(archive, args.overlay, ".") + for source in sorted(args.overlay.rglob("*")): + add_path(archive, source, source.relative_to(args.overlay).as_posix()) + + payloads = [ + "apply.sh", + "dory-agent", + "dory-mesa-venus-arm64.tar.zst", + "manifest.env", + "packages.txt", + "rootfs-overlay.tar", + ] + (staging / "SHA256SUMS").write_text( + "".join(f"{digest(staging / name)} {name}\n" for name in payloads), + encoding="utf-8", + ) + temporary_output = args.output.with_name(f".{args.output.name}.tmp-{os.getpid()}") + try: + with tarfile.open(temporary_output, "w", format=tarfile.PAX_FORMAT) as archive: + for name in [*payloads, "SHA256SUMS"]: + add_path(archive, staging / name, name) + os.replace(temporary_output, args.output) + finally: + temporary_output.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/guest/desktop/build.sh b/guest/desktop/build.sh index 97454487..3a0d6427 100755 --- a/guest/desktop/build.sh +++ b/guest/desktop/build.sh @@ -37,10 +37,15 @@ ARTIFACT_PREFIX="dory-desktop-$DISTRO" OUT="$ROOT/guest/out" mkdir -p "$OUT" IMAGE_SIZE_MB="${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}" +FREE_SPACE_MB="${DORY_DESKTOP_FREE_SPACE_MB:-$DESKTOP_FREE_SPACE_MB}" case "$IMAGE_SIZE_MB" in ''|*[!0-9]*) echo "DORY_DESKTOP_IMAGE_SIZE_MB must be an integer" >&2; exit 64 ;; esac [ "$IMAGE_SIZE_MB" -ge 4096 ] || { echo "desktop build image must be at least 4096 MB" >&2; exit 64; } +case "$FREE_SPACE_MB" in + ''|*[!0-9]*) echo "DORY_DESKTOP_FREE_SPACE_MB must be an integer" >&2; exit 64 ;; +esac +[ "$FREE_SPACE_MB" -ge 1024 ] || { echo "desktop image must retain at least 1024 MB free" >&2; exit 64; } DOCKER_BIN="${DORY_DESKTOP_DOCKER_BIN:-$(command -v docker || true)}" [ -n "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { echo "docker CLI not found" >&2; exit 1; } @@ -51,6 +56,10 @@ docker_cmd() { ZSTD_BIN="${DORY_ZSTD:-$(command -v zstd || true)}" [ -n "$ZSTD_BIN" ] && [ -x "$ZSTD_BIN" ] || { echo "zstd is required" >&2; exit 1; } +guest/mesa/build.sh arm64 +VENUS_RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst" +guest/mesa/verify-build.sh arm64 + TARGET=aarch64-unknown-linux-musl if command -v rust-lld >/dev/null 2>&1; then LINKER="$(command -v rust-lld)" @@ -83,11 +92,20 @@ rustup target add "$TARGET" >/dev/null AGENT="$ROOT/dory-core/target/$TARGET/release/dory-agent" [ -x "$AGENT" ] || { echo "dory-agent was not produced for $TARGET" >&2; exit 1; } -COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xfce4,xfce4-terminal,xfce4-notifyd,xfce4-power-manager,lightdm,lightdm-gtk-greeter,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,xterm,libgl1-mesa-dri,mesa-utils,spice-vdagent,pipewire-audio,wireplumber,polkitd,pkexec,mate-polkit,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends,mousepad,ristretto,file-roller" +COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,dconf-cli,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,x11-utils,xterm,libgl1-mesa-dri,mesa-vulkan-drivers,mesa-utils,vulkan-tools,spice-vdagent,wl-clipboard,xclip,pipewire-audio,wireplumber,polkitd,pkexec,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends" +XFCE_PACKAGES="xfce4,xfce4-terminal,xfce4-notifyd,xfce4-power-manager,lightdm,lightdm-gtk-greeter,mate-polkit,mousepad,ristretto,file-roller" case "$DISTRO" in - debian) PACKAGES="$COMMON_PACKAGES,network-manager-gnome,desktop-base" ;; - ubuntu) PACKAGES="$COMMON_PACKAGES,network-manager-gnome,ubuntu-minimal,xubuntu-default-settings" ;; - kali) PACKAGES="$COMMON_PACKAGES,network-manager-applet,nm-connection-editor,kali-desktop-xfce,kali-defaults,kali-menu" ;; + debian) PACKAGES="$COMMON_PACKAGES,$XFCE_PACKAGES,network-manager-gnome,desktop-base,firefox-esr,evince,galculator" ;; + ubuntu) + # Ubuntu is intentionally its real Canonical GNOME session, not an Ubuntu rootfs wearing + # Dory's shared Xfce profile. Hardware-only recommendations (printing, Bluetooth, firmware, + # laptop sensors) stay out of the VM, while the normal shell, dock, settings, files, themes, + # utilities are explicit so the image is complete offline. Firefox is installed below from + # Mozilla's signed ARM64 APT repository; Ubuntu's `firefox` package is only a Snap transition, + # and WebKitGTK saturated the software-rendered desktop during real browser use. + PACKAGES="$COMMON_PACKAGES,ubuntu-minimal,ubuntu-desktop-minimal,network-manager-gnome,appstream,baobab,eog,evince,file-roller,fonts-liberation,fonts-noto-color-emoji,fonts-ubuntu,gnome-calculator,gnome-characters,gnome-clocks,gnome-disk-utility,gnome-keyring,gnome-system-monitor,gnome-terminal,gnome-text-editor,gsettings-ubuntu-schemas,gvfs-fuse,libglib2.0-bin,libnss-mdns,libpam-gnome-keyring,nautilus-sendto,network-manager-config-connectivity-ubuntu,packagekit,policykit-desktop-privileges,seahorse,ubuntu-wallpapers,xcursor-themes,xdg-desktop-portal-gnome,xdg-utils,yaru-theme-gnome-shell,yaru-theme-gtk,yaru-theme-icon,yaru-theme-sound" + ;; + kali) PACKAGES="$COMMON_PACKAGES,$XFCE_PACKAGES,network-manager-applet,nm-connection-editor,kali-desktop-xfce,kali-defaults,kali-menu" ;; esac # systemd's package scripts need proc/sys mounts while the rootfs is assembled. @@ -101,6 +119,9 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \ -e DORY_DESKTOP_ARTIFACT_PREFIX="$ARTIFACT_PREFIX" \ -e DORY_DESKTOP_PACKAGES="$PACKAGES" \ -e DORY_DESKTOP_IMAGE_SIZE_MB="$IMAGE_SIZE_MB" \ + -e DORY_DESKTOP_FREE_SPACE_MB="$FREE_SPACE_MB" \ + -e DORY_MOZILLA_APT_KEY_FINGERPRINT="$MOZILLA_APT_KEY_FINGERPRINT" \ + -e DORY_MOZILLA_APT_KEY_SHA256="$MOZILLA_APT_KEY_SHA256" \ -w /build \ "$BUILDER_IMAGE" bash -euc ' find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) \ @@ -114,7 +135,7 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \ fi apt-get -o Acquire::Retries=5 update apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ - ca-certificates e2fsprogs mmdebstrap zstd + ca-certificates e2fsprogs gnupg mmdebstrap zstd mmdebstrap \ --mode=root \ @@ -128,10 +149,36 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \ "$DORY_DESKTOP_SUITE" /rootfs "$DORY_DESKTOP_MIRROR" cp -a --no-preserve=ownership /tmp/rootfs-overlay/. /rootfs/ + zstd -q -d -c /tmp/dory-mesa-venus-arm64.tar.zst | tar -xf - -C /rootfs install -m0755 /tmp/dory-agent /rootfs/usr/bin/dory-agent - chmod 0755 /rootfs/usr/lib/dory/configure-machine /rootfs/usr/lib/dory/first-boot \ + chmod 0755 /rootfs/usr/lib/dory/clipboard /rootfs/usr/lib/dory/configure-machine /rootfs/usr/lib/dory/first-boot \ /rootfs/usr/lib/dory/start-agent /rootfs/usr/lib/dory/wait-host-configuration \ - /rootfs/usr/lib/dory/configure-display + /rootfs/usr/lib/dory/configure-display /rootfs/usr/lib/dory/configure-zram \ + /rootfs/usr/lib/dory/configure-graphics-backend \ + /rootfs/usr/lib/dory/dory-vulkan-probe + + if [ "$DORY_DESKTOP_DISTRO" = ubuntu ]; then + key=/rootfs/etc/apt/keyrings/packages.mozilla.org.asc + actual_sha256="$(sha256sum "$key")" + actual_sha256="${actual_sha256%% *}" + [ "$actual_sha256" = "$DORY_MOZILLA_APT_KEY_SHA256" ] || { + echo "Mozilla APT key digest mismatch" >&2 + exit 1 + } + actual_fingerprint="$(gpg --show-keys --with-colons --fingerprint "$key" 2>/dev/null \ + | sed -n "s/^fpr:::::::::\\([^:]*\\):$/\\1/p" | head -1)" + [ "$actual_fingerprint" = "$DORY_MOZILLA_APT_KEY_FINGERPRINT" ] || { + echo "Mozilla APT key fingerprint mismatch" >&2 + exit 1 + } + chroot /rootfs apt-get -o Acquire::Retries=5 update + chroot /rootfs apt-get -o Acquire::Retries=5 install -y --no-install-recommends firefox + # The ubuntu-desktop vendor favorite points at the Snap-only desktop ID. Dory deliberately + # installs the signed Mozilla APT package, whose launcher is firefox.desktop. + sed -i "s/firefox_firefox\.desktop/firefox.desktop/g" \ + /rootfs/usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override + chroot /rootfs glib-compile-schemas /usr/share/glib-2.0/schemas + fi printf "dory\n" > /rootfs/etc/hostname cat > /rootfs/etc/hosts < /rootfs/etc/locale.gen chroot /rootfs locale-gen printf "LANG=en_US.UTF-8\n" > /rootfs/etc/default/locale + # The rootfs is assembled inside Docker, whose generated resolv.conf points at the build + # engine. At runtime the desktop VM receives different DHCP details, so NetworkManager must + # own the guest resolver file instead of inheriting the build-container nameserver. + rm -f /rootfs/etc/resolv.conf + ln -s ../run/NetworkManager/resolv.conf /rootfs/etc/resolv.conf for group in sudo audio video render input; do chroot /rootfs getent group "$group" >/dev/null || chroot /rootfs groupadd "$group" @@ -153,6 +205,11 @@ EOF chroot /rootfs passwd --lock dory printf "dory ALL=(ALL:ALL) NOPASSWD: ALL\n" > /rootfs/etc/sudoers.d/dory chmod 0440 /rootfs/etc/sudoers.d/dory + chmod 0644 /rootfs/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules + # The managed account has no password and is entered through display-manager autologin. + # Compile the system dconf policy that prevents GNOME from creating an impossible-to-unlock + # password prompt after the session has been idle. + chroot /rootfs dconf update rm -f /rootfs/etc/apt/sources.list /rootfs/etc/apt/sources.list.d/*.list \ /rootfs/etc/apt/sources.list.d/*.sources @@ -189,8 +246,19 @@ EOF esac chroot /rootfs systemctl enable NetworkManager.service NetworkManager-wait-online.service - chroot /rootfs systemctl enable lightdm.service ssh.service dory-first-boot.service \ - dory-boot.service dory-desktop-ready.service + case "$DORY_DESKTOP_DISTRO" in + ubuntu) + rm -rf /rootfs/etc/lightdm + chroot /rootfs systemctl enable gdm3.service + ;; + *) + rm -rf /rootfs/etc/gdm3 + chroot /rootfs systemctl enable lightdm.service + ;; + esac + chroot /rootfs systemctl enable ssh.service dory-first-boot.service \ + dory-boot.service dory-desktop-ready.service dory-zram.service \ + dory-graphics-backend.service chroot /rootfs systemctl set-default graphical.target rm -f /rootfs/etc/machine-id /rootfs/var/lib/dbus/machine-id /rootfs/etc/ssh/ssh_host_* @@ -208,7 +276,7 @@ EOF resize2fs -M "$image" block_count="$(dumpe2fs -h "$image" 2>/dev/null | awk "/^Block count:/{print \$3}")" block_size="$(dumpe2fs -h "$image" 2>/dev/null | awk "/^Block size:/{print \$3}")" - extra_blocks="$((512 * 1024 * 1024 / block_size))" + extra_blocks="$((DORY_DESKTOP_FREE_SPACE_MB * 1024 * 1024 / block_size))" final_blocks="$((block_count + extra_blocks))" resize2fs "$image" "$final_blocks" truncate -s "$((final_blocks * block_size))" "$image" @@ -217,11 +285,21 @@ EOF docker_cmd cp "$AGENT" "$CID:/tmp/dory-agent" docker_cmd cp guest/desktop/rootfs-overlay "$CID:/tmp/rootfs-overlay" +docker_cmd cp "$VENUS_RUNTIME" "$CID:/tmp/dory-mesa-venus-arm64.tar.zst" docker_cmd start -a "$CID" docker_cmd cp "$CID:/out/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" "$STAGING/" docker_cmd cp "$CID:/out/$ARTIFACT_PREFIX-packages-arm64.txt" "$STAGING/" docker_cmd rm "$CID" >/dev/null CID="" +python3 guest/desktop/build-update-bundle.py \ + --distro "$DISTRO" \ + --fingerprint "$INPUT_FINGERPRINT" \ + --packages "$STAGING/$ARTIFACT_PREFIX-packages-arm64.txt" \ + --overlay guest/desktop/rootfs-overlay \ + --agent "$AGENT" \ + --venus-runtime "$VENUS_RUNTIME" \ + --apply guest/desktop/apply-update.sh \ + --output "$STAGING/$ARTIFACT_PREFIX-update-arm64.tar" "$ZSTD_BIN" -q -d --sparse -f "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" \ -o "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4" @@ -236,13 +314,15 @@ STAMP="$STAGING/$ARTIFACT_PREFIX-build-arm64.stamp" printf 'image_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4" | awk '{print $1}')" printf 'compressed_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" | awk '{print $1}')" printf 'packages_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-packages-arm64.txt" | awk '{print $1}')" + printf 'update_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-update-arm64.tar" | awk '{print $1}')" } > "$STAMP" DORY_DESKTOP_OUT_DIR="$STAGING" guest/desktop/verify-build.sh arm64 "$DISTRO" for artifact in \ "$ARTIFACT_PREFIX-rootfs-arm64.ext4" \ "$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" \ - "$ARTIFACT_PREFIX-packages-arm64.txt"; do + "$ARTIFACT_PREFIX-packages-arm64.txt" \ + "$ARTIFACT_PREFIX-update-arm64.tar"; do mv -f "$STAGING/$artifact" "$OUT/$artifact" done mv -f "$STAMP" "$OUT/$ARTIFACT_PREFIX-build-arm64.stamp" diff --git a/guest/desktop/input-fingerprint.sh b/guest/desktop/input-fingerprint.sh index 3db6061e..4e4a9edd 100755 --- a/guest/desktop/input-fingerprint.sh +++ b/guest/desktop/input-fingerprint.sh @@ -35,6 +35,8 @@ fi INPUTS=( guest/desktop/PINS guest/desktop/build.sh + guest/desktop/apply-update.sh + guest/desktop/build-update-bundle.py guest/desktop/input-fingerprint.sh guest/desktop/verify-build.sh dory-core/Cargo.lock @@ -43,6 +45,9 @@ INPUTS=( while IFS= read -r input; do INPUTS+=("$input") done < <(find guest/desktop/rootfs-overlay -type f | LC_ALL=C sort) +while IFS= read -r input; do + INPUTS+=("$input") +done < <(find guest/mesa -type f | LC_ALL=C sort) for package in agent pb proto sync; do while IFS= read -r input; do INPUTS+=("$input") @@ -58,8 +63,9 @@ for package in agent pb proto sync; do done { - printf 'schema=2\narch=arm64\ndistro=%s\ntarget=%s\nimage_size_mb=%s\nrustflags=%s\n' \ - "$DISTRO" "$TARGET" "${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}" "$RUSTFLAGS_EFFECTIVE" + printf 'schema=2\narch=arm64\ndistro=%s\ntarget=%s\nimage_size_mb=%s\nfree_space_mb=%s\nrustflags=%s\n' \ + "$DISTRO" "$TARGET" "${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}" \ + "${DORY_DESKTOP_FREE_SPACE_MB:-$DESKTOP_FREE_SPACE_MB}" "$RUSTFLAGS_EFFECTIVE" rustc -Vv cargo -V printf 'linker_sha256=%s\n' "$(shasum -a 256 "$LINKER" | awk '{print $1}')" diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf index f2aa7d9c..4b2c863a 100644 --- a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf +++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf @@ -1,5 +1,12 @@ [main] plugins=keyfile +dns=default +rc-manager=file + +[keyfile] +# Ubuntu's NetworkManager package otherwise marks every Ethernet device unmanaged unless Netplan +# generated a policy. Dory owns one virtio NIC and supplies its connection profile directly. +unmanaged-devices= [device] wifi.scan-rand-mac-address=no diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf new file mode 100644 index 00000000..9e392173 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf @@ -0,0 +1,4 @@ +# Shadow Ubuntu's vendor policy, which excludes Ethernet devices unless Netplan generated a +# machine-specific override. Dory's single virtio NIC is managed by the connection profile below. +[keyfile] +unmanaged-devices= diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection b/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection new file mode 100644 index 00000000..9005d5d6 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection @@ -0,0 +1,16 @@ +[connection] +id=Dory Wired +type=ethernet +autoconnect=true +autoconnect-priority=100 + +[ethernet] + +[ipv4] +method=auto + +[ipv6] +method=auto +addr-gen-mode=default + +[proxy] diff --git a/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc b/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc new file mode 100644 index 00000000..1e2a71ed --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsBNBGCRt7MBCADkYJHHQQoL6tKrW/LbmfR9ljz7ib2aWno4JO3VKQvLwjyUMPpq +/SXXMOnx8jXwgWizpPxQYDRJ0SQXS9ULJ1hXRL/OgMnZAYvYDeV2jBnKsAIEdiG/ +e1qm8P4W9qpWJc+hNq7FOT13RzGWRx57SdLWSXo0KeY38r9lvjjOmT/cuOcmjwlD +T9XYf/RSO+yJ/AsyMdAr+ZbDeQUd9HYJiPdI04lGaGM02MjDMnx+monc+y54t+Z+ +ry1WtQdzoQt9dHlIPlV1tR+xV5DHHsejCZxu9TWzzSlL5wfBBeEz7R/OIzivGJpW +QdJzd+2QDXSRg9q2XYWP5ZVtSgjVVJjNlb6ZABEBAAHNVEFydGlmYWN0IFJlZ2lz +dHJ5IFJlcG9zaXRvcnkgU2lnbmVyIDxhcnRpZmFjdC1yZWdpc3RyeS1yZXBvc2l0 +b3J5LXNpZ25lckBnb29nbGUuY29tPsLAjgQTAQoAOBYhBDW6oLM+nrOW9ZyoOMC6 +XObcYxWjBQJgkbezAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEMC6XObc +YxWj+igIAMFh6DrAYMeq9sbZ1ZG6oAMrinUheGQbEqe76nIDQNsZnhDwZ2wWqgVC +7DgOMqlhQmOmzm7M6Nzmq2dvPwq3xC2OeI9fQyzjT72deBTzLP7PJok9PJFOMdLf +ILSsUnmMsheQt4DUO0jYAX2KUuWOIXXJaZ319QyoRNBPYa5qz7qXS7wHLOY89IDq +fHt6Aud8ER5zhyOyhytcYMeaGC1g1IKWmgewnhEq02FantMJGlmmFi2eA0EPD02G +C3742QGqRxLwjWsm5/TpyuU24EYKRGCRm7QdVIo3ugFSetKrn0byOxWGBvtu4fH8 +XWvZkRT+u+yzH1s5yFYBqc2JTrrJvRU= +=QnvN +-----END PGP PUBLIC KEY BLOCK----- diff --git a/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla b/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla new file mode 100644 index 00000000..ae0a1f9e --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla @@ -0,0 +1,3 @@ +Package: * +Pin: origin packages.mozilla.org +Pin-Priority: 1000 diff --git a/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list b/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list new file mode 100644 index 00000000..bee9edb4 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list @@ -0,0 +1 @@ +deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main diff --git a/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session new file mode 100644 index 00000000..41d2943f --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session @@ -0,0 +1,11 @@ +# Dory's managed desktop account is password-locked and enters through display-manager autologin. +# A conventional screen lock would therefore present a password prompt that can never succeed. +[org/gnome/desktop/screensaver] +lock-enabled=false + +[org/gnome/desktop/session] +idle-delay=uint32 0 + +[org/gnome/settings-daemon/plugins/power] +sleep-inactive-ac-type='nothing' +sleep-inactive-battery-type='nothing' diff --git a/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session new file mode 100644 index 00000000..01888f6c --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session @@ -0,0 +1,4 @@ +/org/gnome/desktop/screensaver/lock-enabled +/org/gnome/desktop/session/idle-delay +/org/gnome/settings-daemon/plugins/power/sleep-inactive-ac-type +/org/gnome/settings-daemon/plugins/power/sleep-inactive-battery-type diff --git a/guest/desktop/rootfs-overlay/etc/dconf/profile/user b/guest/desktop/rootfs-overlay/etc/dconf/profile/user new file mode 100644 index 00000000..f7a0336c --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/dconf/profile/user @@ -0,0 +1,2 @@ +user-db:user +system-db:dory diff --git a/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf b/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf new file mode 100644 index 00000000..4266f528 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf @@ -0,0 +1,3 @@ +# Firefox's native Wayland surface can remain alive without ever mapping a usable window on the +# raw accelerated desktop. XWayland presents the same SWGL-rendered browser reliably. +MOZ_ENABLE_WAYLAND=0 diff --git a/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf b/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf new file mode 100644 index 00000000..3b5e9658 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf @@ -0,0 +1,15 @@ +[daemon] +AutomaticLoginEnable=true +AutomaticLogin=dory +# Native Wayland clients can remain alive without reaching Dory's raw-HV scanout. Ubuntu's stock +# Xorg session keeps every application on the accelerated VirGL path that Dory presents reliably. +WaylandEnable=false +DefaultSession=ubuntu-xorg.desktop + +[security] + +[xdmcp] + +[chooser] + +[debug] diff --git a/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules b/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules new file mode 100644 index 00000000..4ff47427 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules @@ -0,0 +1,8 @@ +// Dory's managed desktop account is deliberately password-locked and receives passwordless sudo. +// Let the same active, local administrator complete graphical settings and package-management +// actions; otherwise polkit presents a password dialog that can never succeed. +polkit.addRule(function(action, subject) { + if (subject.local && subject.active && subject.isInGroup("sudo")) { + return polkit.Result.YES; + } +}); diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service index 1d0ac26d..64446885 100644 --- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service @@ -1,7 +1,7 @@ [Unit] Description=Dory guest integration agent After=dory-first-boot.service -Before=lightdm.service +Before=display-manager.service Requires=dory-first-boot.service [Service] diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service index 25765c89..95244e17 100644 --- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service @@ -1,7 +1,7 @@ [Unit] Description=Wait for Dory host desktop configuration After=dory-boot.service -Before=lightdm.service +Before=display-manager.service Requires=dory-boot.service [Service] diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service index 8d9809e6..fc3f85cd 100644 --- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service @@ -1,7 +1,7 @@ [Unit] Description=Prepare the Dory desktop machine After=local-fs.target systemd-remount-fs.service -Before=NetworkManager.service lightdm.service ssh.service dory-boot.service +Before=NetworkManager.service display-manager.service ssh.service dory-boot.service ConditionPathExists=!/var/lib/dory/first-boot-complete [Service] diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service new file mode 100644 index 00000000..b4ac7c73 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service @@ -0,0 +1,12 @@ +[Unit] +Description=Select Dory desktop graphics backend +After=local-fs.target systemd-remount-fs.service +Before=display-manager.service dory-boot.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/dory/configure-graphics-backend +RemainAfterExit=yes + +[Install] +WantedBy=graphical.target diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service new file mode 100644 index 00000000..2224f2e6 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service @@ -0,0 +1,12 @@ +[Unit] +Description=Dory compressed desktop swap +After=systemd-modules-load.service +Before=display-manager.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/dory/configure-zram +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua b/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua new file mode 100644 index 00000000..9ea44ad2 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua @@ -0,0 +1,56 @@ +-- Dory's virtio-snd device intentionally has no legacy analog mixer or UCM description. ACP +-- therefore exposes only its generic Pro Audio profile, which WirePlumber 0.4 will not select +-- automatically. Activate that profile so both the speaker and microphone nodes are present from +-- the beginning of every desktop session. +table.insert(alsa_monitor.rules, { + matches = { + { + { "device.name", "matches", "alsa_card.platform-*.virtio_mmio" }, + { "device.nick", "matches", "VirtIO SoundCard" }, + }, + }, + apply_properties = { + ["device.profile"] = "pro-audio", + ["device.description"] = "Dory Audio", + ["device.nick"] = "Dory Audio", + ["api.alsa.use-acp"] = true, + ["api.alsa.use-ucm"] = false, + ["api.alsa.soft-mixer"] = true, + ["api.alsa.ignore-dB"] = true, + ["api.acp.auto-profile"] = false, + ["api.acp.auto-port"] = false, + }, +}) + +-- Pro Audio normally puts every PCM direction in one start/stop group. Dory maps speaker and +-- microphone onto independent Mac audio graphs, so group them independently as well: opening +-- Firefox must not start an unused microphone, and recording must not keep an unused speaker +-- running. Expose conventional stereo channel names instead of the generic AUX labels produced +-- without a UCM profile. +table.insert(alsa_monitor.rules, { + matches = { + { + { "node.name", "matches", "alsa_output.platform-*.virtio_mmio.pro-output-*" }, + }, + }, + apply_properties = { + ["node.pause-on-idle"] = true, + ["node.group"] = "dory-playback", + ["node.link-group"] = "dory-playback", + ["audio.position"] = "FL,FR", + }, +}) + +table.insert(alsa_monitor.rules, { + matches = { + { + { "node.name", "matches", "alsa_input.platform-*.virtio_mmio.pro-input-*" }, + }, + }, + apply_properties = { + ["node.pause-on-idle"] = true, + ["node.group"] = "dory-capture", + ["node.link-group"] = "dory-capture", + ["audio.position"] = "FL,FR", + }, +}) diff --git a/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop b/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop index adf1a06e..7e8e52d5 100644 --- a/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop +++ b/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop @@ -2,6 +2,6 @@ Type=Application Name=Dory Retina Display Exec=/usr/lib/dory/configure-display -OnlyShowIn=XFCE; +OnlyShowIn=XFCE;GNOME;Unity; NoDisplay=true X-GNOME-Autostart-enabled=true diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard b/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard new file mode 100644 index 00000000..f05f35ca --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +operation="${1:-}" +mime="${2:-text/plain;charset=utf-8}" +case "$operation" in + get|set|types) ;; + *) echo "usage: dory-clipboard get|set|types [mime-type]" >&2; exit 64 ;; +esac +case "$mime" in + text/plain|text/plain\;charset=utf-8|image/png) ;; + *) echo "unsupported clipboard type: $mime" >&2; exit 64 ;; +esac + +user="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory\n')" +uid="$(id -u "$user")" +home="$(getent passwd "$user" | cut -d: -f6)" +runtime="/run/user/$uid" + +run_as_user() { + runuser -u "$user" -- env \ + HOME="$home" \ + USER="$user" \ + LOGNAME="$user" \ + XDG_RUNTIME_DIR="$runtime" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \ + "$@" +} + +wayland_socket="" +for candidate in "$runtime"/wayland-*; do + [ -S "$candidate" ] || continue + wayland_socket="${candidate##*/}" + break +done + +if [ -n "$wayland_socket" ] && command -v wl-copy >/dev/null 2>&1; then + case "$operation" in + set) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-copy --type "$mime" ;; + get) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-paste --no-newline --type "$mime" ;; + types) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-paste --list-types ;; + esac + exit $? +fi + +display="${DISPLAY:-:0}" +xauthority="$home/.Xauthority" +[ -f "$xauthority" ] || xauthority="" +x11_env=(DISPLAY="$display") +[ -z "$xauthority" ] || x11_env+=(XAUTHORITY="$xauthority") +x11_target="$mime" +case "$mime" in + text/plain|text/plain\;charset=utf-8) x11_target="UTF8_STRING" ;; +esac +case "$operation" in + set) run_as_user env "${x11_env[@]}" xclip -selection clipboard -in -target "$x11_target" ;; + get) run_as_user env "${x11_env[@]}" xclip -selection clipboard -out -target "$x11_target" ;; + types) run_as_user env "${x11_env[@]}" xclip -selection clipboard -out -target TARGETS ;; +esac diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display index 73862de3..c970b485 100644 --- a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display @@ -10,6 +10,54 @@ set_xfce_value() { fi } +configure_gnome() { + # GNOME autostart begins before Ubuntu's shell has finished loading its vendor defaults. Wait + # until the shell is reachable so those defaults cannot overwrite Dory's display and launcher + # corrections a moment later. + if command -v gdbus >/dev/null 2>&1; then + for _ in $(seq 1 100); do + if gdbus call --session \ + --dest org.gnome.Shell \ + --object-path /org/gnome/Shell \ + --method org.freedesktop.DBus.Properties.Get \ + org.gnome.Shell ShellVersion >/dev/null 2>&1; then + sleep 2 + break + fi + sleep 0.1 + done + fi + + gsettings set org.gnome.desktop.interface scaling-factor 2 + gsettings set org.gnome.desktop.interface text-scaling-factor 1.0 + gsettings set org.gnome.desktop.interface cursor-size 32 + # Software scanout benefits from fewer compositor effects. Both qualified accelerated modes can + # retain Ubuntu's normal motion without coupling the desktop policy to a particular renderer. + if [ "$(cat /run/dory/graphics-backend 2>/dev/null || printf software)" != software ]; then + gsettings set org.gnome.desktop.interface enable-animations true + else + gsettings set org.gnome.desktop.interface enable-animations false + fi + gsettings set org.gnome.desktop.wm.preferences button-layout 'appmenu:minimize,maximize,close' + # ubuntu-desktop's stock schema assumes the Snap desktop ID. Dory installs Mozilla's signed APT + # build instead, so repair an existing profile without discarding any favorites the user added. + local favorites + favorites="$(gsettings get org.gnome.shell favorite-apps)" + if [[ "$favorites" == *firefox_firefox.desktop* ]]; then + favorites="${favorites//firefox_firefox.desktop/firefox.desktop}" + fi + if [[ "$favorites" == *gnome-control-center.desktop* ]]; then + favorites="${favorites//gnome-control-center.desktop/org.gnome.Settings.desktop}" + fi + if [[ "$favorites" != *firefox.desktop* || "$favorites" != *org.gnome.Settings.desktop* ]]; then + gsettings set org.gnome.shell favorite-apps \ + "['org.gnome.Nautilus.desktop', 'org.gnome.Terminal.desktop', 'firefox.desktop', 'org.gnome.Settings.desktop']" + else + gsettings set org.gnome.shell favorite-apps "$favorites" + fi + xdg-settings set default-web-browser firefox.desktop >/dev/null 2>&1 || true +} + follow_preferred_display_mode() { local current_mode output_name preferred_mode snapshot @@ -28,6 +76,13 @@ follow_preferred_display_mode() { } for _ in $(seq 1 100); do + if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *ubuntu* ]] \ + && command -v gsettings >/dev/null 2>&1 \ + && gsettings writable org.gnome.desktop.interface scaling-factor 2>/dev/null \ + | grep -qx true; then + configure_gnome + follow_preferred_display_mode + fi if xfconf-query --channel xsettings --list >/dev/null 2>&1; then set_xfce_value /Gdk/WindowScalingFactor int 2 set_xfce_value /Xft/Antialias int 1 diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend new file mode 100644 index 00000000..f1bcc051 --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend @@ -0,0 +1,67 @@ +#!/bin/bash +set -euo pipefail + +environment_file=/etc/environment.d/70-dory-graphics.conf +session_environment=/etc/X11/Xsession.d/70dory-graphics +graphics_status=/run/dory/graphics-status +venus_root=/opt/dory/mesa +venus_icd=$venus_root/share/vulkan/icd.d/virtio_icd.aarch64.json +venus_probe=/usr/lib/dory/dory-vulkan-probe + +# The host publishes the backend it actually attached. Mesa selects its native driver from the +# advertised virtio-gpu capsets; a global Zink override would incorrectly force the desktop itself +# through Venus and is both slower and less compatible on MoltenVK. +rm -f "$environment_file" "$session_environment" +install -d -m0755 /run/dory +case " $(cat /proc/cmdline) " in + *' dory.graphics=virgl-venus '*) backend=virgl2+venus ;; + *' dory.graphics=virgl '*) backend=virgl2 ;; + *' dory.graphics=software '*) backend=software ;; + # Compatibility with images and VMMs from before the versioned graphics contract. + *' dory.gpu=venus '*) backend=virgl2+venus ;; + *) backend=software ;; +esac +printf '%s\n' "$backend" > /run/dory/graphics-backend +printf '%s\n' "$backend" > "$graphics_status" + +case "$backend" in + virgl2|virgl2+venus) + # GTK4's newer renderer corrupts large textures with the current VirGL/MoltenVK bridge. The + # established GL renderer uses the same accelerated virgl driver, but correctly renders + # libadwaita applications such as Files, Settings, and Calculator. Keep this backend-specific: + # the software display path should retain GTK's native fallback selection. + install -d -m0755 "$(dirname "$environment_file")" + printf '%s\n' \ + '# Dory accelerated GTK4 compatibility policy; managed by configure-graphics-backend.' \ + 'GSK_RENDERER=gl' > "$environment_file" + install -d -m0755 "$(dirname "$session_environment")" + printf '%s\n' \ + '# Dory accelerated desktop environment; managed by configure-graphics-backend.' \ + 'export GSK_RENDERER=gl' > "$session_environment" + ;; +esac + +if [ "$backend" = virgl2+venus ]; then + if [ -x "$venus_probe" ] && [ -r "$venus_icd" ]; then + if probe_output="$(env \ + LD_LIBRARY_PATH="$venus_root/lib" \ + VK_DRIVER_FILES="$venus_icd" \ + timeout 10 "$venus_probe" 2>&1)"; then + printf '%s\n' \ + "LD_LIBRARY_PATH=$venus_root/lib" \ + "VK_DRIVER_FILES=$venus_icd" >> "$environment_file" + printf '%s\n' \ + "export LD_LIBRARY_PATH=$venus_root/lib" \ + "export VK_DRIVER_FILES=$venus_icd" >> "$session_environment" + printf 'venus-ready: %s\n' "$probe_output" > "$graphics_status" + else + printf 'venus-unavailable: %s\n' "$probe_output" > "$graphics_status" + fi + else + printf 'venus-unavailable: Dory Venus runtime is missing\n' > "$graphics_status" + fi +fi + +[ ! -e "$environment_file" ] || chmod 0644 "$environment_file" +[ ! -e "$session_environment" ] || chmod 0644 "$session_environment" +chmod 0644 "$graphics_status" /run/dory/graphics-backend diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine index 8c0ef9c6..0347f459 100755 --- a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine @@ -63,6 +63,11 @@ fi if [ "$requested" != "$current" ]; then sed -i "s/^$current /$requested /" /etc/sudoers.d/dory fi -sed -i "s/^autologin-user=.*/autologin-user=$requested/" \ - /etc/lightdm/lightdm.conf.d/50-dory.conf +if [ -f /etc/lightdm/lightdm.conf.d/50-dory.conf ]; then + sed -i "s/^autologin-user=.*/autologin-user=$requested/" \ + /etc/lightdm/lightdm.conf.d/50-dory.conf +fi +if [ -f /etc/gdm3/custom.conf ]; then + sed -i "s/^AutomaticLogin=.*/AutomaticLogin=$requested/" /etc/gdm3/custom.conf +fi printf '%s\n' "$requested" > /var/lib/dory/username diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram new file mode 100644 index 00000000..c3093d59 --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +# A desktop browser can create short-lived memory peaks well beyond its steady working set. Keep +# those peaks compressed inside the guest rather than letting the kernel kill the browser or shell. +if grep -q '^/dev/zram0 ' /proc/swaps 2>/dev/null; then + exit 0 +fi + +if [ ! -b /dev/zram0 ]; then + # Dory's appliance kernel links zram into the image rather than shipping a separate module tree. + # A built-in driver exposes zram-control directly, so `modprobe` correctly reports that there is + # no module file even though the driver is available. + if [ ! -e /sys/class/zram-control ]; then + modprobe zram num_devices=1 + fi + if [ ! -b /dev/zram0 ] && [ -e /sys/class/zram-control/hot_add ]; then + cat /sys/class/zram-control/hot_add >/dev/null + fi +fi +udevadm settle 2>/dev/null || true +for _ in $(seq 1 100); do + [ -b /dev/zram0 ] && break + sleep 0.05 +done +[ -b /dev/zram0 ] || { echo 'zram device is unavailable' >&2; exit 1; } + +memory_bytes="$(awk '/^MemTotal:/ { print $2 * 1024; exit }' /proc/meminfo)" +size_bytes="$((memory_bytes / 2))" +maximum_bytes=$((2 * 1024 * 1024 * 1024)) +if [ "$size_bytes" -gt "$maximum_bytes" ]; then + size_bytes="$maximum_bytes" +fi + +# `zramctl --reset` removes a dynamically hot-added device on current util-linux. Reset through +# sysfs only when a previous, inactive configuration exists, then size the same device in place. +if [ "$(cat /sys/block/zram0/disksize)" -ne 0 ]; then + printf '1\n' > /sys/block/zram0/reset +fi +printf '%s\n' "$size_bytes" > /sys/block/zram0/disksize +mkswap --quiet /dev/zram0 +swapon --priority 100 /dev/zram0 diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot b/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot index 3df6c6ca..a04f78f2 100755 --- a/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot @@ -10,5 +10,23 @@ if [ ! -s /etc/machine-id ]; then fi rm -f /etc/ssh/ssh_host_* ssh-keygen -A -resize2fs /dev/vda + +# Published component images already fill their virtual disk. Installed machines may extend that +# disk before first boot; grow only in that case. An unconditional resize can be rounded down by +# the kernel and rejected as an online shrink when the two capacities are already equal. +device_bytes="$(blockdev --getsize64 /dev/vda)" +filesystem_blocks="$(LC_ALL=C dumpe2fs -h /dev/vda 2>/dev/null \ + | awk -F: '/^Block count:/{gsub(/[[:space:]]/, "", $2); print $2; exit}')" +filesystem_block_size="$(LC_ALL=C dumpe2fs -h /dev/vda 2>/dev/null \ + | awk -F: '/^Block size:/{gsub(/[[:space:]]/, "", $2); print $2; exit}')" +case "$device_bytes:$filesystem_blocks:$filesystem_block_size" in + *[!0-9:]*|:*|*::*|*:) echo "could not determine root filesystem capacity" >&2; exit 1 ;; +esac +filesystem_bytes="$((filesystem_blocks * filesystem_block_size))" +if [ "$device_bytes" -gt "$filesystem_bytes" ]; then + resize2fs /dev/vda +elif [ "$device_bytes" -lt "$filesystem_bytes" ]; then + echo "root device is smaller than its filesystem ($device_bytes < $filesystem_bytes)" >&2 + exit 1 +fi touch /var/lib/dory/first-boot-complete diff --git a/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json b/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json new file mode 100644 index 00000000..23c54271 --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json @@ -0,0 +1,14 @@ +{ + "policies": { + "Preferences": { + "gfx.webrender.software": { + "Value": true, + "Status": "locked" + }, + "widget.dmabuf.enabled": { + "Value": false, + "Status": "locked" + } + } + } +} diff --git a/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json b/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json new file mode 100644 index 00000000..23c54271 --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json @@ -0,0 +1,14 @@ +{ + "policies": { + "Preferences": { + "gfx.webrender.software": { + "Value": true, + "Status": "locked" + }, + "widget.dmabuf.enabled": { + "Value": false, + "Status": "locked" + } + } + } +} diff --git a/guest/desktop/verify-build.sh b/guest/desktop/verify-build.sh index b7491e67..1d29ff12 100755 --- a/guest/desktop/verify-build.sh +++ b/guest/desktop/verify-build.sh @@ -3,6 +3,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" +source guest/desktop/PINS +source guest/mesa/PINS case "${1:-arm64}" in arm64|aarch64) ;; @@ -19,14 +21,16 @@ OUT="${DORY_DESKTOP_OUT_DIR:-$ROOT/guest/out}" IMAGE="$OUT/$ARTIFACT_PREFIX-rootfs-arm64.ext4" COMPRESSED="$IMAGE.zst" PACKAGES="$OUT/$ARTIFACT_PREFIX-packages-arm64.txt" +UPDATE="$OUT/$ARTIFACT_PREFIX-update-arm64.tar" STAMP="$OUT/$ARTIFACT_PREFIX-build-arm64.stamp" +VENUS_RUNTIME="$ROOT/guest/out/dory-mesa-venus-arm64.tar.zst" fail() { echo "desktop image verification failed: $*" >&2 exit 1 } -for path in "$IMAGE" "$COMPRESSED" "$PACKAGES" "$STAMP"; do +for path in "$IMAGE" "$COMPRESSED" "$PACKAGES" "$UPDATE" "$STAMP"; do [ -s "$path" ] || fail "missing or empty $path" done @@ -45,6 +49,35 @@ EXPECTED_INPUT="$(guest/desktop/input-fingerprint.sh arm64 "$DISTRO")" || fail "$COMPRESSED digest does not match its stamp" [ "$(stamp_value packages_sha256)" = "$(shasum -a 256 "$PACKAGES" | awk '{print $1}')" ] \ || fail "$PACKAGES digest does not match its stamp" +[ "$(stamp_value update_sha256)" = "$(shasum -a 256 "$UPDATE" | awk '{print $1}')" ] \ + || fail "$UPDATE digest does not match its stamp" + +UPDATE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dory-desktop-update-verify.XXXXXX")" +trap 'rm -rf "$UPDATE_DIR"' EXIT +tar -xf "$UPDATE" -C "$UPDATE_DIR" +guest/mesa/verify-build.sh arm64 >/dev/null +for path in apply.sh dory-agent dory-mesa-venus-arm64.tar.zst manifest.env packages.txt rootfs-overlay.tar SHA256SUMS; do + [ -s "$UPDATE_DIR/$path" ] || fail "$UPDATE is missing $path" +done +(cd "$UPDATE_DIR" && shasum -a 256 -c SHA256SUMS) \ + || fail "$UPDATE payload digests do not match" +grep -Fqx 'schema=2' "$UPDATE_DIR/manifest.env" || fail "$UPDATE has an unsupported schema" +grep -Fqx 'arch=arm64' "$UPDATE_DIR/manifest.env" || fail "$UPDATE has the wrong architecture" +grep -Fqx "distro=$DISTRO" "$UPDATE_DIR/manifest.env" || fail "$UPDATE has the wrong distribution" +grep -Fqx "input_sha256=$EXPECTED_INPUT" "$UPDATE_DIR/manifest.env" \ + || fail "$UPDATE has a stale input fingerprint" +cmp -s "$PACKAGES" "$UPDATE_DIR/packages.txt" || fail "$UPDATE package manifest is stale" +cmp -s "$VENUS_RUNTIME" "$UPDATE_DIR/dory-mesa-venus-arm64.tar.zst" \ + || fail "$UPDATE contains a stale Dory Venus runtime" +grep -Fq 'packages.txt is the signed package provenance' "$UPDATE_DIR/apply.sh" \ + || fail "$UPDATE does not keep package provenance separate from guest-tools installation" +grep -Fq 'dory-mesa-venus-arm64.tar.zst' "$UPDATE_DIR/apply.sh" \ + || fail "$UPDATE does not install the Dory Venus runtime" +grep -Fq 'dconf update' "$UPDATE_DIR/apply.sh" \ + || fail "$UPDATE does not compile the managed desktop session policy" +if grep -Eq 'apt-get|with-new-pkgs|xargs .*install' "$UPDATE_DIR/apply.sh"; then + fail "$UPDATE performs a network-dependent distribution package mutation" +fi DEBUGFS="${DORY_DEBUGFS:-}" if [ -z "$DEBUGFS" ]; then @@ -60,27 +93,103 @@ if [ -z "$DEBUGFS" ]; then fi [ -n "$DEBUGFS" ] || fail "debugfs is required" +case "$DISTRO" in + ubuntu) BROWSER_POLICY_PATH=/usr/lib/firefox/distribution/policies.json ;; + *) BROWSER_POLICY_PATH=/usr/share/firefox-esr/distribution/policies.json ;; +esac + for guest_path in \ /sbin/init \ /usr/bin/dory-agent \ + /usr/lib/dory/clipboard \ /usr/lib/dory/configure-machine \ /usr/lib/dory/configure-display \ + /usr/lib/dory/configure-graphics-backend \ + /usr/lib/dory/dory-vulkan-probe \ + /usr/lib/dory/configure-zram \ /usr/lib/dory/first-boot \ /usr/lib/dory/start-agent \ /usr/lib/dory/wait-host-configuration \ /etc/systemd/system/dory-first-boot.service \ /etc/systemd/system/dory-boot.service \ /etc/systemd/system/dory-desktop-ready.service \ - /etc/lightdm/lightdm.conf.d/50-dory.conf \ + /etc/systemd/system/dory-graphics-backend.service \ + /etc/systemd/system/dory-zram.service \ + /etc/NetworkManager/conf.d/10-dory.conf \ + /etc/NetworkManager/conf.d/10-globally-managed-devices.conf \ + /etc/NetworkManager/system-connections/dory-wired.nmconnection \ + /etc/dconf/profile/user \ + /etc/dconf/db/dory.d/00-managed-session \ + /etc/dconf/db/dory.d/locks/00-managed-session \ + /etc/dconf/db/dory \ + /etc/polkit-1/rules.d/49-dory-passwordless-admin.rules \ + /etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua \ /etc/xdg/autostart/dory-display.desktop \ + "$BROWSER_POLICY_PATH" \ /home/dory/.profile \ - /usr/bin/startxfce4 \ /usr/bin/spice-vdagent \ /usr/bin/pipewire; do "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \ || fail "$IMAGE is missing $guest_path" done +for guest_path in \ + /opt/dory/mesa/lib/libvulkan_virtio.so \ + /opt/dory/mesa/lib/libxcb-keysyms.so.1.0.0 \ + /opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json \ + /opt/dory/mesa/share/dory/runtime.env; do + "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \ + || fail "$IMAGE is missing $guest_path" +done + +VENUS_ICD="$($DEBUGFS -R \ + 'cat /opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json' "$IMAGE" 2>/dev/null)" +grep -Fq '"library_path": "/opt/dory/mesa/lib/libvulkan_virtio.so"' <<<"$VENUS_ICD" \ + || fail "the desktop image Venus ICD does not select Dory's isolated library" +VENUS_MANIFEST="$($DEBUGFS -R \ + 'cat /opt/dory/mesa/share/dory/runtime.env' "$IMAGE" 2>/dev/null)" +grep -Fqx "mesa_version=$MESA_VERSION" <<<"$VENUS_MANIFEST" \ + || fail "the desktop image contains the wrong Mesa Venus version" +grep -Fqx "mesa_source_sha256=$MESA_SOURCE_SHA256" <<<"$VENUS_MANIFEST" \ + || fail "the desktop image contains an unpinned Mesa Venus source" + +CLIPBOARD_HELPER="$($DEBUGFS -R 'cat /usr/lib/dory/clipboard' "$IMAGE" 2>/dev/null)" +grep -Fq 'wl-copy --type "$mime"' <<<"$CLIPBOARD_HELPER" \ + || fail "clipboard helper does not provide Wayland writes" +grep -Fq 'xclip -selection clipboard -out' <<<"$CLIPBOARD_HELPER" \ + || fail "clipboard helper does not provide X11 reads" + +WIREPLUMBER_SOUND_RULE="$($DEBUGFS -R \ + 'cat /etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua' "$IMAGE" 2>/dev/null)" +grep -Fq '["device.profile"] = "pro-audio"' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber does not activate Dory's virtio sound profile" +grep -Fq 'alsa_card.platform-*.virtio_mmio' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber sound policy is not scoped to the virtio card" +grep -Fq 'alsa_output.platform-*.virtio_mmio.pro-output-*' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber does not configure Dory playback" +grep -Fq 'alsa_input.platform-*.virtio_mmio.pro-input-*' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber does not configure Dory capture" +[ "$(grep -Fc '["node.pause-on-idle"] = true' <<<"$WIREPLUMBER_SOUND_RULE")" -eq 2 ] \ + || fail "WirePlumber does not suspend both idle Dory audio directions" +grep -Fq '["node.group"] = "dory-playback"' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber does not isolate Dory playback lifecycle" +grep -Fq '["node.group"] = "dory-capture"' <<<"$WIREPLUMBER_SOUND_RULE" \ + || fail "WirePlumber does not isolate Dory capture lifecycle" +[ "$(grep -Fc '["audio.position"] = "FL,FR"' <<<"$WIREPLUMBER_SOUND_RULE")" -eq 2 ] \ + || fail "WirePlumber does not expose conventional stereo channel positions" + +BROWSER_POLICY="$($DEBUGFS -R "cat $BROWSER_POLICY_PATH" "$IMAGE" 2>/dev/null)" + grep -Fq '"gfx.webrender.software"' <<<"$BROWSER_POLICY" \ + || fail "$BROWSER_POLICY_PATH does not select Firefox SWGL" + grep -Fq '"widget.dmabuf.enabled"' <<<"$BROWSER_POLICY" \ + || fail "$BROWSER_POLICY_PATH does not disable Firefox DMA-BUF presentation" + [ "$(grep -Fc '"Value": true' <<<"$BROWSER_POLICY")" -eq 1 ] \ + || fail "$BROWSER_POLICY_PATH must enable exactly the SWGL preference" + [ "$(grep -Fc '"Value": false' <<<"$BROWSER_POLICY")" -eq 1 ] \ + || fail "$BROWSER_POLICY_PATH must disable exactly the DMA-BUF preference" + [ "$(grep -Fc '"Status": "locked"' <<<"$BROWSER_POLICY")" -eq 2 ] \ + || fail "$BROWSER_POLICY_PATH preferences are not locked" + for user_owned_path in /home/dory /home/dory/.profile; do "$DEBUGFS" -R "stat $user_owned_path" "$IMAGE" 2>/dev/null \ | grep -Eq 'User:[[:space:]]+1000[[:space:]]+Group:[[:space:]]+1000' \ @@ -90,11 +199,15 @@ done for root_owned_path in \ /usr/lib/dory/configure-machine \ /usr/lib/dory/configure-display \ + /usr/lib/dory/configure-graphics-backend \ + /usr/lib/dory/configure-zram \ /usr/lib/dory/first-boot \ /usr/lib/dory/start-agent \ /usr/lib/dory/wait-host-configuration \ /etc/systemd/system/dory-boot.service \ - /etc/systemd/system/dory-desktop-ready.service; do + /etc/systemd/system/dory-desktop-ready.service \ + /etc/systemd/system/dory-graphics-backend.service \ + /etc/systemd/system/dory-zram.service; do "$DEBUGFS" -R "stat $root_owned_path" "$IMAGE" 2>/dev/null \ | grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0' \ || fail "$root_owned_path is not owned by root in $IMAGE" @@ -108,6 +221,15 @@ case "$DISTRO" in grep -Fqx 'ID=debian' <<<"$OS_RELEASE" || fail "$IMAGE is not Debian" DEBIAN_VERSION="$($DEBUGFS -R 'cat /etc/debian_version' "$IMAGE" 2>/dev/null | tr -d '\r\n')" case "$DEBIAN_VERSION" in 13.*) ;; *) fail "$IMAGE contains unexpected Debian version $DEBIAN_VERSION" ;; esac + for guest_path in \ + /etc/lightdm/lightdm.conf.d/50-dory.conf \ + /usr/bin/startxfce4 \ + /usr/bin/firefox-esr \ + /usr/bin/evince \ + /usr/bin/galculator; do + "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \ + || fail "$IMAGE is missing $guest_path" + done ;; ubuntu) grep -Fqx 'ID=ubuntu' <<<"$OS_RELEASE" || fail "$IMAGE is not Ubuntu" @@ -115,9 +237,22 @@ case "$DISTRO" in "$DEBUGFS" -R 'cat /etc/apt/sources.list.d/ubuntu.sources' "$IMAGE" 2>/dev/null \ | grep -Fqx 'URIs: https://ports.ubuntu.com/ubuntu-ports' \ || fail "$IMAGE does not use the official Ubuntu HTTPS repository" + for guest_path in \ + /etc/gdm3/custom.conf \ + /usr/bin/gnome-shell \ + /usr/bin/gnome-terminal \ + /usr/bin/firefox \ + /usr/share/xsessions/ubuntu.desktop; do + "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \ + || fail "$IMAGE is missing $guest_path" + done ;; kali) grep -Fqx 'ID=kali' <<<"$OS_RELEASE" || fail "$IMAGE is not Kali Linux" + for guest_path in /etc/lightdm/lightdm.conf.d/50-dory.conf /usr/bin/startxfce4; do + "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \ + || fail "$IMAGE is missing $guest_path" + done "$DEBUGFS" -R 'stat /home/dory/.config/xfce4/panel' "$IMAGE" 2>&1 \ | grep -Fq 'Inode:' || fail "$IMAGE is missing the Kali Xfce user defaults" "$DEBUGFS" -R 'cat /etc/apt/sources.list' "$IMAGE" 2>/dev/null \ @@ -127,17 +262,156 @@ case "$DISTRO" in esac "$DEBUGFS" -R 'cat /etc/ssh/sshd_config.d/50-dory.conf' "$IMAGE" 2>/dev/null \ | grep -Fqx 'PasswordAuthentication no' || fail "SSH password login is not disabled" -"$DEBUGFS" -R 'cat /etc/lightdm/lightdm.conf.d/50-dory.conf' "$IMAGE" 2>/dev/null \ - | grep -Fqx 'autologin-user=dory' || fail "desktop autologin is not configured" +case "$DISTRO" in + ubuntu) + "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'AutomaticLogin=dory' || fail "Ubuntu GNOME autologin is not configured" + "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'WaylandEnable=false' || fail "Ubuntu GNOME does not select accelerated Xorg" + "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'DefaultSession=ubuntu-xorg.desktop' \ + || fail "Ubuntu GNOME does not default to Ubuntu on Xorg" + DCONF_SESSION="$($DEBUGFS -R \ + 'cat /etc/dconf/db/dory.d/00-managed-session' "$IMAGE" 2>/dev/null)" + grep -Fqx 'lock-enabled=false' <<<"$DCONF_SESSION" \ + || fail "Ubuntu GNOME can lock its passwordless managed account" + grep -Fqx 'idle-delay=uint32 0' <<<"$DCONF_SESSION" \ + || fail "Ubuntu GNOME can idle into an impossible password prompt" + DCONF_LOCKS="$($DEBUGFS -R \ + 'cat /etc/dconf/db/dory.d/locks/00-managed-session' "$IMAGE" 2>/dev/null)" + grep -Fqx '/org/gnome/desktop/screensaver/lock-enabled' <<<"$DCONF_LOCKS" \ + || fail "Ubuntu GNOME screen-lock policy is not immutable" + ;; + *) + "$DEBUGFS" -R 'cat /etc/lightdm/lightdm.conf.d/50-dory.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'autologin-user=dory' || fail "Xfce autologin is not configured" + ;; +esac +"$DEBUGFS" -R 'cat /etc/NetworkManager/conf.d/10-globally-managed-devices.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'unmanaged-devices=' || fail "virtio Ethernet is not opted into NetworkManager" +"$DEBUGFS" -R 'cat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'type=ethernet' || fail "the Dory wired connection is not an Ethernet profile" +if "$DEBUGFS" -R 'cat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \ + | grep -q '^interface-name='; then + fail "the Dory wired connection is pinned to a distro-specific interface name" +fi +"$DEBUGFS" -R 'stat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \ + | grep -Eq 'Mode:[[:space:]]+0600' || fail "the Dory wired connection is not private" +"$DEBUGFS" -R 'cat /etc/NetworkManager/conf.d/10-dory.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'rc-manager=file' || fail "NetworkManager does not own the guest resolver file" +RESOLV_LINK="$($DEBUGFS -R 'stat /etc/resolv.conf' "$IMAGE" 2>/dev/null \ + | sed -n 's/^Fast link dest: "\(.*\)"$/\1/p')" +[ "$RESOLV_LINK" = '../run/NetworkManager/resolv.conf' ] \ + || fail "resolv.conf does not follow NetworkManager: $RESOLV_LINK" DISPLAY_CONFIGURATION="$($DEBUGFS -R 'cat /usr/lib/dory/configure-display' "$IMAGE" 2>/dev/null)" -grep -Fq 'set_xfce_value /Gdk/WindowScalingFactor int 2' <<<"$DISPLAY_CONFIGURATION" \ - || fail "Retina desktop scaling is not configured" grep -Fq 'xrandr --output "$output_name" --mode "$preferred_mode"' <<<"$DISPLAY_CONFIGURATION" \ || fail "dynamic desktop resizing is not configured" -grep -q $'^xfce4\t' "$PACKAGES" || fail "Xfce package provenance is missing" -grep -q $'^lightdm\t' "$PACKAGES" || fail "LightDM package provenance is missing" +case "$DISTRO" in + ubuntu) + grep -Fq 'gsettings set org.gnome.desktop.interface scaling-factor 2' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME Retina scaling is not configured" + grep -Fq '/run/dory/graphics-backend' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME does not select effects based on the active graphics backend" + grep -Fq 'gsettings set org.gnome.desktop.interface enable-animations true' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME accelerated animations are not configured" + grep -Fq 'gsettings set org.gnome.desktop.interface enable-animations false' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME software-rendering fallback mode is not configured" + grep -Fq 'xdg-settings set default-web-browser firefox.desktop' <<<"$DISPLAY_CONFIGURATION" \ + || fail "Firefox is not configured as the default Ubuntu browser" + grep -Fq 'firefox_firefox.desktop/firefox.desktop' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME does not repair Ubuntu's stale Snap Firefox favorite" + grep -Fq 'gnome-control-center.desktop/org.gnome.Settings.desktop' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME does not repair Ubuntu's stale Settings favorite" + grep -Fq 'org.gnome.Settings.desktop' <<<"$DISPLAY_CONFIGURATION" \ + || fail "GNOME does not install Ubuntu's real Settings favorite" + UBUNTU_SCHEMA_OVERRIDE="$($DEBUGFS -R \ + 'cat /usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override' \ + "$IMAGE" 2>/dev/null)" + grep -Fq "'firefox.desktop'" <<<"$UBUNTU_SCHEMA_OVERRIDE" \ + || fail "Ubuntu's dock default does not point at the installed Firefox launcher" + if grep -Fq 'firefox_firefox.desktop' <<<"$UBUNTU_SCHEMA_OVERRIDE"; then + fail "Ubuntu's dock default still points at the absent Firefox Snap launcher" + fi + for package in ubuntu-desktop-minimal ubuntu-session gdm3 firefox yaru-theme-gtk; do + grep -q "^${package}[[:space:]]" "$PACKAGES" || fail "$package provenance is missing" + done + "$DEBUGFS" -R 'cat /etc/apt/keyrings/packages.mozilla.org.asc' "$IMAGE" 2>/dev/null \ + | shasum -a 256 | grep -Fq "$MOZILLA_APT_KEY_SHA256" \ + || fail "the Mozilla APT key is not the pinned key" + "$DEBUGFS" -R 'cat /etc/environment.d/60-dory-desktop.conf' "$IMAGE" 2>/dev/null \ + | grep -Fqx 'MOZ_ENABLE_WAYLAND=0' \ + || fail "Firefox is not configured for reliable XWayland presentation" + if grep -Eq '^(xfce4|lightdm)[[:space:]]' "$PACKAGES"; then + fail "the Ubuntu image still contains the retired Xfce/LightDM session" + fi + ;; + *) + grep -Fq 'set_xfce_value /Gdk/WindowScalingFactor int 2' <<<"$DISPLAY_CONFIGURATION" \ + || fail "Xfce Retina scaling is not configured" + grep -q $'^xfce4\t' "$PACKAGES" || fail "Xfce package provenance is missing" + grep -q $'^lightdm\t' "$PACKAGES" || fail "LightDM package provenance is missing" + if [ "$DISTRO" = debian ]; then + for package in firefox-esr evince galculator; do + grep -q "^${package}[[:space:]]" "$PACKAGES" || fail "$package provenance is missing" + done + fi + ;; +esac grep -q $'^spice-vdagent\t' "$PACKAGES" || fail "SPICE package provenance is missing" +grep -q $'^x11-utils\t' "$PACKAGES" || fail "X11 window qualification tools are missing" +grep -q $'^wl-clipboard\t' "$PACKAGES" || fail "Wayland clipboard package provenance is missing" +grep -q $'^xclip\t' "$PACKAGES" || fail "X11 clipboard package provenance is missing" grep -q $'^pipewire-audio\t' "$PACKAGES" || fail "PipeWire package provenance is missing" +for package in mesa-vulkan-drivers vulkan-tools; do + # dpkg's ${binary:Package} field appends an architecture qualifier for + # Multi-Arch packages (for example, mesa-vulkan-drivers:arm64). + grep -Eq "^${package}(:arm64)?[[:space:]]" "$PACKAGES" \ + || fail "$package provenance is missing" +done + +GRAPHICS_CONFIGURATION="$($DEBUGFS -R 'cat /usr/lib/dory/configure-graphics-backend' "$IMAGE" 2>/dev/null)" +grep -Fq "dory.graphics=virgl-venus" <<<"$GRAPHICS_CONFIGURATION" \ + || fail "graphics backend configuration does not recognize VirGL plus Venus" +grep -Fq "dory.graphics=virgl" <<<"$GRAPHICS_CONFIGURATION" \ + || fail "graphics backend configuration does not recognize stable VirGL" +grep -Fq "dory.graphics=software" <<<"$GRAPHICS_CONFIGURATION" \ + || fail "graphics backend configuration does not recognize software fallback" +if grep -Fq "printf '%s\\n' 'virgl2+venus'" <<<"$GRAPHICS_CONFIGURATION"; then + fail "graphics backend writes a hard-coded backend instead of the resolved runtime" +fi +grep -Fq "printf '%s\\n' \"\$backend\" > /run/dory/graphics-backend" \ + <<<"$GRAPHICS_CONFIGURATION" \ + || fail "graphics backend does not record the resolved runtime" +grep -Fq "virgl2|virgl2+venus" <<<"$GRAPHICS_CONFIGURATION" \ + || fail "accelerated graphics backends do not share a GTK renderer policy" +grep -Fq "'GSK_RENDERER=gl' > \"\$environment_file\"" <<<"$GRAPHICS_CONFIGURATION" \ + || fail "accelerated graphics does not select the qualified GTK4 GL renderer" +grep -Fq 'VK_DRIVER_FILES="$venus_icd"' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "Venus applications do not select Dory's isolated Vulkan ICD" +grep -Fq 'LD_LIBRARY_PATH="$venus_root/lib"' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "Venus applications do not select Dory's isolated Vulkan library" +grep -Fq 'timeout 10 "$venus_probe"' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "Venus is enabled without a bounded hardware capability probe" +grep -Fq 'venus-ready:' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "Venus readiness is not recorded for support diagnostics" +grep -Fq '/etc/X11/Xsession.d/70dory-graphics' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "desktop sessions do not receive the qualified graphics environment" +grep -Fq 'export VK_DRIVER_FILES=$venus_icd' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "GDM and LightDM sessions do not select Dory's Venus ICD" +grep -Fq 'export LD_LIBRARY_PATH=$venus_root/lib' <<<"$GRAPHICS_CONFIGURATION" \ + || fail "GDM and LightDM sessions do not select Dory's Venus library" +if grep -Fq 'ZED_ALLOW_EMULATED_GPU' <<<"$GRAPHICS_CONFIGURATION"; then + fail "graphics configuration masks Zed's hardware-GPU requirement" +fi +if grep -Fq 'GSK_RENDERER=cairo' <<<"$GRAPHICS_CONFIGURATION"; then + fail "accelerated graphics falls back to CPU-rendered GTK instead of qualified GL" +fi +if grep -Eq 'MESA_LOADER_DRIVER_OVERRIDE|GALLIUM_DRIVER|LIBGL_KOPPER_DRI2' <<<"$GRAPHICS_CONFIGURATION"; then + fail "graphics backend globally overrides Mesa instead of allowing API-native driver selection" +fi +if grep -Fq "grep -qw 'dory.gpu=venus' /proc/cmdline" <<<"$GRAPHICS_CONFIGURATION"; then + fail "graphics backend still relies only on the legacy Venus token" +fi ZSTD="${DORY_ZSTD:-$(command -v zstd 2>/dev/null || true)}" [ -n "$ZSTD" ] || fail "zstd is required" diff --git a/guest/kernel/build.sh b/guest/kernel/build.sh index 8fa2985c..f617f0d4 100755 --- a/guest/kernel/build.sh +++ b/guest/kernel/build.sh @@ -63,9 +63,10 @@ case "$PROFILE" in headless) CONFIGS="$CONFIGS dory-headless.fragment" ;; venus) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-gpu.fragment" ;; desktop) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-desktop.fragment" ;; + accelerated-desktop) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment" ;; esac -if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then - echo "the desktop kernel profile currently supports arm64 only" >&2 +if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then + echo "desktop kernel profiles currently support arm64 only" >&2 exit 64 fi diff --git a/guest/kernel/dory-accelerated-desktop.fragment b/guest/kernel/dory-accelerated-desktop.fragment new file mode 100644 index 00000000..7160d40c --- /dev/null +++ b/guest/kernel/dory-accelerated-desktop.fragment @@ -0,0 +1,16 @@ +# Raw Hypervisor.framework desktop profile. Keep the Linux userspace ABI at 4 KiB so FEX can run +# supported x86-64 applications. Dory's virtio-gpu kernel patch separately aligns host-visible +# Venus blobs to Hypervisor.framework's 16 KiB stage-2 mapping granule. +CONFIG_ARM64_4K_PAGES=y +CONFIG_ARM64_16K_PAGES=n +CONFIG_INPUT=y +CONFIG_INPUT_EVDEV=y +CONFIG_VIRTIO_INPUT=y + +# Keep the framebuffer console and KMS handoff used by graphical desktop guests. The generic Venus +# compute fragment disables these because the Docker engine has no display attached. +CONFIG_FB=y +CONFIG_DRM_FBDEV_EMULATION=y +CONFIG_VT=y +CONFIG_VT_CONSOLE=y +CONFIG_FRAMEBUFFER_CONSOLE=y diff --git a/guest/kernel/dory-desktop.fragment b/guest/kernel/dory-desktop.fragment index 0c6903c3..79b75e24 100644 --- a/guest/kernel/dory-desktop.fragment +++ b/guest/kernel/dory-desktop.fragment @@ -17,6 +17,11 @@ CONFIG_USB_XHCI_HCD=y CONFIG_USB_XHCI_PLATFORM=y CONFIG_USB_HID=y +# Give full desktop guests compressed swap without consuming another host-backed disk. Browsers +# and GNOME otherwise run out of reclaimable memory abruptly at the VM memory ceiling. +CONFIG_ZSMALLOC=y +CONFIG_ZRAM=y + # VZVirtioSoundDeviceConfiguration output stream. CONFIG_SOUND=y CONFIG_SND=y diff --git a/guest/kernel/dory-headless.fragment b/guest/kernel/dory-headless.fragment index bf826fa9..3534c2bb 100644 --- a/guest/kernel/dory-headless.fragment +++ b/guest/kernel/dory-headless.fragment @@ -1,3 +1,4 @@ # FEX must expose the x86-64 ABI's 4 KiB page contract. Keep the release headless kernel explicit # so a future defconfig change cannot silently make common amd64 jemalloc builds unusable. CONFIG_ARM64_4K_PAGES=y +CONFIG_ARM64_16K_PAGES=n diff --git a/guest/kernel/input-fingerprint.sh b/guest/kernel/input-fingerprint.sh index 72a87471..0458d315 100755 --- a/guest/kernel/input-fingerprint.sh +++ b/guest/kernel/input-fingerprint.sh @@ -24,9 +24,10 @@ case "$PROFILE" in headless) CONFIGS+=(dory-headless.fragment) ;; venus) CONFIGS+=(dory-virtual-display.fragment dory-gpu.fragment) ;; desktop) CONFIGS+=(dory-virtual-display.fragment dory-desktop.fragment) ;; + accelerated-desktop) CONFIGS+=(dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment) ;; esac -if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then - echo "the desktop kernel profile currently supports arm64 only" >&2 +if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then + echo "desktop kernel profiles currently support arm64 only" >&2 exit 64 fi diff --git a/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch b/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch new file mode 100644 index 00000000..e90b1c08 --- /dev/null +++ b/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch @@ -0,0 +1,84 @@ +From: Dory maintainers +Subject: [PATCH] drm/virtio: align host-visible VRAM to the Apple host page + +Hypervisor.framework maps memory at the Apple Silicon host-page granule (16 +KiB), independently of the Linux guest's stage-1 page size. Keep the GEM +object and userspace mmap at the guest's 4 KiB page granule, but allocate the +renderer blob and its host-visible aperture node at the 16 KiB host granule. + +Do not expose the padded aperture size as the GEM object size. Mesa maps the +size it originally requested, and virtio_gpu_vram_mmap rejects that mapping if +the GEM object was enlarged to the aperture size. The host padding remains +mapped at stage 2 while userspace sees only the original guest-page-aligned +object, preserving the Linux ABI and making RESOURCE_MAP_BLOB legal for +hv_vm_map. +--- + drivers/gpu/drm/virtio/virtgpu_vram.c | 25 +++++++++++++++++++------ + 1 file changed, 19 insertions(+), 6 deletions(-) + +diff --git a/drivers/gpu/drm/virtio/virtgpu_vram.c b/drivers/gpu/drm/virtio/virtgpu_vram.c +index 25df81c5ed91..21751119eb7b 100644 +--- a/drivers/gpu/drm/virtio/virtgpu_vram.c ++++ b/drivers/gpu/drm/virtio/virtgpu_vram.c +@@ -2,6 +2,9 @@ + #include "virtgpu_drv.h" + + #include ++#include ++ ++#define DORY_HOST_VISIBLE_ALIGNMENT SZ_16K + + static void virtio_gpu_vram_free(struct drm_gem_object *obj) + { +@@ -56,8 +59,12 @@ static int virtio_gpu_vram_mmap(struct drm_gem_object *obj, + else if (vram->map_info == VIRTIO_GPU_MAP_CACHE_UNCACHED) + vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot); + +- /* Partial mappings of GEM buffers don't happen much in practice. */ +- if (vm_size != vram->vram_node.size) ++ /* ++ * The aperture node is padded to the Apple host-page granule. Map the ++ * guest-page-sized GEM object and leave the private stage-2 padding out ++ * of the userspace ABI. ++ */ ++ if (vm_size != obj->size) + return -EINVAL; + + ret = io_remap_pfn_range(vma, vma->vm_start, +@@ -150,8 +157,12 @@ static int virtio_gpu_vram_map(struct virtio_gpu_object *bo) + return -EINVAL; + + spin_lock(&vgdev->host_visible_lock); +- ret = drm_mm_insert_node(&vgdev->host_visible_mm, &vram->vram_node, +- bo->base.base.size); ++ ret = drm_mm_insert_node_generic(&vgdev->host_visible_mm, ++ &vram->vram_node, ++ roundup(bo->base.base.size, ++ DORY_HOST_VISIBLE_ALIGNMENT), ++ DORY_HOST_VISIBLE_ALIGNMENT, ++ 0, 0); + spin_unlock(&vgdev->host_visible_lock); + + if (ret) +@@ -188,6 +199,7 @@ int virtio_gpu_vram_create(struct virtio_gpu_device *vgdev, + { + struct drm_gem_object *obj; + struct virtio_gpu_object_vram *vram; ++ size_t guest_size; + int ret; + + vram = kzalloc(sizeof(*vram), GFP_KERNEL); +@@ -197,8 +209,9 @@ int virtio_gpu_vram_create(struct virtio_gpu_device *vgdev, + obj = &vram->base.base.base; + obj->funcs = &virtio_gpu_vram_funcs; + +- params->size = PAGE_ALIGN(params->size); +- drm_gem_private_object_init(vgdev->ddev, obj, params->size); ++ guest_size = PAGE_ALIGN(params->size); ++ params->size = roundup(params->size, DORY_HOST_VISIBLE_ALIGNMENT); ++ drm_gem_private_object_init(vgdev->ddev, obj, guest_size); + + /* Create fake offset */ + ret = drm_gem_create_mmap_offset(obj); +-- +2.50.1 diff --git a/guest/kernel/profile.sh b/guest/kernel/profile.sh index cfda38e5..f366ea01 100644 --- a/guest/kernel/profile.sh +++ b/guest/kernel/profile.sh @@ -18,8 +18,8 @@ dory_kernel_resolve_profile() { fi case "$profile" in - headless|venus|desktop) ;; - *) echo "DORY_KERNEL_PROFILE must be headless, venus, or desktop" >&2; return 64 ;; + headless|venus|desktop|accelerated-desktop) ;; + *) echo "DORY_KERNEL_PROFILE must be headless, venus, desktop, or accelerated-desktop" >&2; return 64 ;; esac if [ "$legacy_gpu" = "1" ] && [ "$profile" != "venus" ]; then echo "DORY_EXPERIMENTAL_GPU=1 conflicts with DORY_KERNEL_PROFILE=$profile" >&2 @@ -33,6 +33,8 @@ dory_kernel_profile_suffix() { headless) printf '\n' ;; venus) printf '%s\n' '-gpu' ;; desktop) printf '%s\n' '-desktop' ;; + # This is the production desktop profile, so retain the established release artifact names. + accelerated-desktop) printf '%s\n' '-desktop' ;; *) return 64 ;; esac } diff --git a/guest/kernel/verify-build.sh b/guest/kernel/verify-build.sh index 6b660810..6add2d4c 100755 --- a/guest/kernel/verify-build.sh +++ b/guest/kernel/verify-build.sh @@ -29,8 +29,8 @@ case "$ARCH" in exit 64 ;; esac -if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then - echo "the desktop kernel profile currently supports arm64 only" >&2 +if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then + echo "desktop kernel profiles currently support arm64 only" >&2 exit 64 fi @@ -88,6 +88,7 @@ case "$PROFILE" in headless) CONFIG_FRAGMENTS+=(dory-headless.fragment) ;; venus) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-gpu.fragment) ;; desktop) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-desktop.fragment) ;; + accelerated-desktop) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment) ;; esac REQUIRED_POLICIES="$({ diff --git a/guest/mesa/PINS b/guest/mesa/PINS new file mode 100644 index 00000000..cf196c6a --- /dev/null +++ b/guest/mesa/PINS @@ -0,0 +1,7 @@ +# Dory-owned Venus runtime inputs for Apple-silicon desktop guests. +MESA_VERSION=26.1.4 +MESA_SOURCE_URL=https://archive.mesa3d.org/mesa-26.1.4.tar.xz +MESA_SOURCE_SHA256=072705caa9adf4740f1489194b13e278ad959166863b5271fe423a86353c9ab6 +MESA_BUILDER_IMAGE=ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 +MESON_VERSION=1.10.0 +MESON_WHEEL_SHA256=4b27aafce281e652dcb437b28007457411245d975c48b5db3a797d3e93ae1585 diff --git a/guest/mesa/README.md b/guest/mesa/README.md new file mode 100644 index 00000000..f44a9b12 --- /dev/null +++ b/guest/mesa/README.md @@ -0,0 +1,16 @@ +# Dory Venus runtime + +Dory ships a small, isolated Venus Vulkan ICD for accelerated ARM64 desktop guests. It lives at +`/opt/dory/mesa` and does not replace the distribution's Mesa OpenGL stack. The distribution keeps +rendering GNOME/Xfce through VirGL, while Vulkan applications such as Zed use this pinned ICD. + +The macOS renderer cannot expose Linux `sync_fd` semaphore imports. Dory's virtio-gpu transport +already orders access to shared resources, so the patch in `patches/` enables Venus' existing +implicit-fencing path, exposes WSI when that path is active, and avoids requesting the absent host +extension. Acquired-image synchronization is still consumed by Venus' driver-side semaphore path; +the extension is not merely advertised. + +`build.sh` produces `guest/out/dory-mesa-venus-arm64.tar.zst`. The archive contains only the Venus +ICD, its one non-baseline XCB dependency, a capability probe, and provenance. Desktop image and +in-place update builders consume the same archive, so a full image and an updated image cannot +silently diverge. diff --git a/guest/mesa/build.sh b/guest/mesa/build.sh new file mode 100755 index 00000000..5701d3c0 --- /dev/null +++ b/guest/mesa/build.sh @@ -0,0 +1,142 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" +source guest/mesa/PINS +source guest/kernel/docker-endpoint.sh + +case "${1:-arm64}" in + arm64|aarch64) ;; + *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;; +esac + +OUT="$ROOT/guest/out" +mkdir -p "$OUT" +RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst" +STAMP="$OUT/dory-mesa-venus-build-arm64.stamp" +INPUT_SHA256="$(guest/mesa/input-fingerprint.sh arm64)" + +if [ -s "$RUNTIME" ] && [ -s "$STAMP" ] \ + && grep -Fqx "input_sha256=$INPUT_SHA256" "$STAMP" \ + && guest/mesa/verify-build.sh arm64 >/dev/null 2>&1; then + echo "using current $RUNTIME" + exit 0 +fi + +DOCKER_BIN="${DORY_MESA_DOCKER_BIN:-$(command -v docker || true)}" +[ -n "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { echo "docker CLI not found" >&2; exit 1; } +DOCKER_ENDPOINT="$(dory_kernel_resolve_docker_endpoint "$DOCKER_BIN" "${DORY_MESA_DOCKER_HOST:-}")" +docker_cmd() { + dory_kernel_docker "$DOCKER_BIN" "$DOCKER_ENDPOINT" "$@" +} + +STAGING="$(mktemp -d "$OUT/.mesa-venus-build-arm64.XXXXXX")" +CID="" +cleanup() { + [ -z "$CID" ] || docker_cmd rm -f "$CID" >/dev/null 2>&1 || true + rm -rf "$STAGING" +} +trap cleanup EXIT + +CID="$(docker_cmd create --platform linux/arm64 \ + -e DEBIAN_FRONTEND=noninteractive \ + -e DORY_MESA_VERSION="$MESA_VERSION" \ + -e DORY_MESA_SOURCE_URL="$MESA_SOURCE_URL" \ + -e DORY_MESA_SOURCE_SHA256="$MESA_SOURCE_SHA256" \ + -e DORY_MESON_VERSION="$MESON_VERSION" \ + -e DORY_MESON_WHEEL_SHA256="$MESON_WHEEL_SHA256" \ + -w /build "$MESA_BUILDER_IMAGE" sleep infinity)" +docker_cmd start "$CID" >/dev/null +docker_cmd cp guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch \ + "$CID:/tmp/dory-venus-implicit-fencing.patch" +docker_cmd cp guest/mesa/dory-vulkan-probe.c "$CID:/tmp/dory-vulkan-probe.c" + +docker_cmd exec "$CID" bash -euo pipefail -c ' + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + build-essential bison ca-certificates curl flex libdrm-dev libexpat1-dev \ + libvulkan-dev libwayland-dev libx11-xcb-dev libxcb-dri3-dev \ + libxcb-keysyms1-dev libxcb-present-dev libxcb-randr0-dev libxcb-shm0-dev \ + libxcb-sync-dev libxcb-xfixes0-dev libxrandr-dev libxshmfence-dev \ + libzstd-dev ninja-build patch pkg-config python3-mako python3-packaging \ + python3-pip python3-ply python3-yaml wayland-protocols xz-utils zlib1g-dev zstd + + source_archive="/build/mesa-${DORY_MESA_VERSION}.tar.xz" + curl --fail --location --retry 5 --output "$source_archive" "$DORY_MESA_SOURCE_URL" + printf "%s %s\n" "$DORY_MESA_SOURCE_SHA256" "$source_archive" | sha256sum -c - + tar -xf "$source_archive" -C /build + source_dir="/build/mesa-${DORY_MESA_VERSION}" + patch -d "$source_dir" -p1 < /tmp/dory-venus-implicit-fencing.patch + + python3 -m pip download --disable-pip-version-check --no-deps \ + --dest /build "meson==${DORY_MESON_VERSION}" >/dev/null + meson_wheel="$(find /build -maxdepth 1 -type f -name "meson-${DORY_MESON_VERSION}-*.whl" -print -quit)" + [ -n "$meson_wheel" ] + printf "%s %s\n" "$DORY_MESON_WHEEL_SHA256" "$meson_wheel" | sha256sum -c - + python3 -m pip install --break-system-packages --disable-pip-version-check \ + --no-index "$meson_wheel" >/dev/null + + meson setup /build/mesa-build "$source_dir" \ + --prefix=/opt/dory/mesa \ + --libdir=lib \ + --buildtype=release \ + -Dplatforms=x11,wayland \ + -Dgallium-drivers= \ + -Dvulkan-drivers=virtio \ + -Dvulkan-layers= \ + -Dglx=disabled \ + -Degl=disabled \ + -Dgbm=disabled \ + -Dopengl=false \ + -Dgles1=disabled \ + -Dgles2=disabled \ + -Dllvm=disabled \ + -Dvideo-codecs= \ + -Dvalgrind=disabled \ + -Dlibunwind=disabled \ + -Dlmsensors=disabled \ + -Dzstd=enabled \ + -Dxmlconfig=disabled \ + -Dbuild-tests=false + ninja -C /build/mesa-build + DESTDIR=/stage ninja -C /build/mesa-build install + + install -d -m0755 /stage/usr/lib/dory /stage/opt/dory/mesa/share/dory + cc -O2 -Wall -Wextra -Werror /tmp/dory-vulkan-probe.c \ + -o /stage/usr/lib/dory/dory-vulkan-probe -lvulkan + install -m0644 /usr/lib/aarch64-linux-gnu/libxcb-keysyms.so.1.0.0 \ + /stage/opt/dory/mesa/lib/libxcb-keysyms.so.1.0.0 + ln -s libxcb-keysyms.so.1.0.0 /stage/opt/dory/mesa/lib/libxcb-keysyms.so.1 + xcb_version="$(dpkg-query -W -f="\${Version}" libxcb-keysyms1)" + printf "schema=1\nmesa_version=%s\nmesa_source_sha256=%s\nxcb_keysyms_version=%s\n" \ + "$DORY_MESA_VERSION" "$DORY_MESA_SOURCE_SHA256" "$xcb_version" \ + > /stage/opt/dory/mesa/share/dory/runtime.env + + mkdir -p /out + tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \ + -C /stage -cf - . | zstd -19 -T0 -o /out/dory-mesa-venus-arm64.tar.zst +' + +docker_cmd cp "$CID:/out/dory-mesa-venus-arm64.tar.zst" "$STAGING/" +docker_cmd rm -f "$CID" >/dev/null +CID="" +chmod 0644 "$STAGING/dory-mesa-venus-arm64.tar.zst" + +TEMP_STAMP="$STAGING/dory-mesa-venus-build-arm64.stamp" +{ + printf 'schema=1\narch=arm64\ninput_sha256=%s\n' "$INPUT_SHA256" + printf 'runtime_sha256=%s\n' \ + "$(shasum -a 256 "$STAGING/dory-mesa-venus-arm64.tar.zst" | awk '{print $1}')" + printf 'mesa_version=%s\nmesa_source_sha256=%s\n' "$MESA_VERSION" "$MESA_SOURCE_SHA256" +} > "$TEMP_STAMP" + +DORY_MESA_OUT_DIR="$STAGING" guest/mesa/verify-build.sh arm64 +mv -f "$STAGING/dory-mesa-venus-arm64.tar.zst" "$RUNTIME" +mv -f "$TEMP_STAMP" "$STAMP" +rmdir "$STAGING" +STAGING="" +trap - EXIT + +guest/mesa/verify-build.sh arm64 +echo "built $RUNTIME" diff --git a/guest/mesa/dory-vulkan-probe.c b/guest/mesa/dory-vulkan-probe.c new file mode 100644 index 00000000..84ff9c99 --- /dev/null +++ b/guest/mesa/dory-vulkan-probe.c @@ -0,0 +1,101 @@ +#include +#include +#include +#include + +static int fail(const char *message, VkResult result) +{ + fprintf(stderr, "dory-vulkan-probe: %s (%d)\n", message, result); + return 1; +} + +int main(void) +{ + VkApplicationInfo application = { + .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, + .pApplicationName = "dory-vulkan-probe", + .applicationVersion = 1, + .pEngineName = "Dory", + .engineVersion = 1, + .apiVersion = VK_API_VERSION_1_2, + }; + VkInstanceCreateInfo create = { + .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, + .pApplicationInfo = &application, + }; + VkInstance instance = VK_NULL_HANDLE; + VkResult result = vkCreateInstance(&create, NULL, &instance); + if (result != VK_SUCCESS) + return fail("vkCreateInstance failed", result); + + uint32_t count = 0; + result = vkEnumeratePhysicalDevices(instance, &count, NULL); + if (result != VK_SUCCESS || count == 0) { + vkDestroyInstance(instance, NULL); + return fail("no Vulkan physical device", result); + } + VkPhysicalDevice *devices = calloc(count, sizeof(*devices)); + if (!devices) { + vkDestroyInstance(instance, NULL); + return fail("out of memory", VK_ERROR_OUT_OF_HOST_MEMORY); + } + result = vkEnumeratePhysicalDevices(instance, &count, devices); + if (result != VK_SUCCESS) { + free(devices); + vkDestroyInstance(instance, NULL); + return fail("physical-device enumeration failed", result); + } + + int exit_code = 2; + for (uint32_t i = 0; i < count; i++) { + VkPhysicalDeviceDriverProperties driver = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES, + }; + VkPhysicalDeviceProperties2 properties = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, + .pNext = &driver, + }; + vkGetPhysicalDeviceProperties2(devices[i], &properties); + if (strcmp(driver.driverName, "venus") != 0 || + properties.properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_CPU) + continue; + + uint32_t extension_count = 0; + result = vkEnumerateDeviceExtensionProperties( + devices[i], NULL, &extension_count, NULL); + if (result != VK_SUCCESS) + continue; + VkExtensionProperties *extensions = calloc(extension_count, sizeof(*extensions)); + if (!extensions) { + exit_code = 1; + break; + } + result = vkEnumerateDeviceExtensionProperties( + devices[i], NULL, &extension_count, extensions); + int has_swapchain = 0; + if (result == VK_SUCCESS) { + for (uint32_t j = 0; j < extension_count; j++) { + if (strcmp(extensions[j].extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) { + has_swapchain = 1; + break; + } + } + } + free(extensions); + if (!has_swapchain) { + fprintf(stderr, "dory-vulkan-probe: Venus lacks VK_KHR_swapchain\n"); + exit_code = 3; + break; + } + printf("device=%s driver=%s info=%s swapchain=yes\n", + properties.properties.deviceName, driver.driverName, driver.driverInfo); + exit_code = 0; + break; + } + + free(devices); + vkDestroyInstance(instance, NULL); + if (exit_code == 2) + fprintf(stderr, "dory-vulkan-probe: no hardware Venus device\n"); + return exit_code; +} diff --git a/guest/mesa/input-fingerprint.sh b/guest/mesa/input-fingerprint.sh new file mode 100755 index 00000000..fe9edd5d --- /dev/null +++ b/guest/mesa/input-fingerprint.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" +source guest/mesa/PINS + +case "${1:-arm64}" in + arm64|aarch64) ;; + *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;; +esac + +{ + printf 'schema=1\narch=arm64\nmesa_version=%s\nmeson_version=%s\nbuilder=%s\n' \ + "$MESA_VERSION" "$MESON_VERSION" "$MESA_BUILDER_IMAGE" + for input in \ + guest/mesa/PINS \ + guest/mesa/README.md \ + guest/mesa/build.sh \ + guest/mesa/dory-vulkan-probe.c \ + guest/mesa/input-fingerprint.sh \ + guest/mesa/verify-build.sh \ + guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch; do + printf 'input=%s\n' "$input" + shasum -a 256 "$input" + done +} | shasum -a 256 | awk '{print $1}' diff --git a/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch b/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch new file mode 100644 index 00000000..5bad00ed --- /dev/null +++ b/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch @@ -0,0 +1,210 @@ +From: Dory Linux +Subject: [PATCH] venus: enable implicit-fencing WSI without host sync-fd import + +The Dory macOS renderer provides ordered implicit fencing for virtio-gpu +resources but MoltenVK cannot expose Linux sync-fd semaphore imports. Permit +Venus WSI and synchronization2 when implicit fencing is active, avoid +requesting the absent renderer extension, and bridge WSI acquire signals with +an ordinary host queue submission instead of a fake imported sync-fd payload. +Keep the normal sync-fd path for Linux renderers. +--- + src/virtio/vulkan/vn_device.c | 3 ++- + src/virtio/vulkan/vn_instance.c | 2 +- + src/virtio/vulkan/vn_physical_device.c | 7 +++++-- + src/virtio/vulkan/vn_pipeline.c | 9 +++++++-- + src/virtio/vulkan/vn_queue.c | 3 ++- + src/virtio/vulkan/vn_wsi.c | 63 ++++++++++++++++++++++++++++++---- + 6 files changed, 75 insertions(+), 11 deletions(-) + +diff --git a/src/virtio/vulkan/vn_device.c b/src/virtio/vulkan/vn_device.c +--- a/src/virtio/vulkan/vn_device.c ++++ b/src/virtio/vulkan/vn_device.c +@@ -327,7 +327,8 @@ vn_device_fix_create_info(const struct vn_device *dev, + } + + /* see vn_queue_submission_count_batch_semaphores */ +- if (!app_exts->KHR_external_semaphore_fd && has_wsi) { ++ if (!app_exts->KHR_external_semaphore_fd && has_wsi && ++ !physical_dev->instance->renderer->info.has_implicit_fencing) { + assert(physical_dev->renderer_sync_fd.semaphore_importable); + extra_exts[extra_count++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME; + } +diff --git a/src/virtio/vulkan/vn_instance.c b/src/virtio/vulkan/vn_instance.c +--- a/src/virtio/vulkan/vn_instance.c ++++ b/src/virtio/vulkan/vn_instance.c +@@ -75,7 +75,7 @@ static const driOptionDescription vn_dri_options[] = { + DRI_CONF_VK_X11_OVERRIDE_MIN_IMAGE_COUNT(0) + DRI_CONF_VK_X11_STRICT_IMAGE_COUNT(false) + DRI_CONF_VK_XWAYLAND_WAIT_READY(true) +- DRI_CONF_VENUS_IMPLICIT_FENCING(false) ++ DRI_CONF_VENUS_IMPLICIT_FENCING(true) + DRI_CONF_VENUS_WSI_MULTI_PLANE_MODIFIERS(false) + DRI_CONF_SECTION_END + DRI_CONF_SECTION_DEBUG +diff --git a/src/virtio/vulkan/vn_physical_device.c b/src/virtio/vulkan/vn_physical_device.c +--- a/src/virtio/vulkan/vn_physical_device.c ++++ b/src/virtio/vulkan/vn_physical_device.c +@@ -1216,7 +1216,8 @@ vn_physical_device_get_native_extensions( + #endif /* VK_USE_PLATFORM_ANDROID_KHR */ + + #ifdef VN_USE_WSI_PLATFORM +- if (physical_dev->renderer_sync_fd.semaphore_importable) { ++ if (physical_dev->renderer_sync_fd.semaphore_importable || ++ physical_dev->instance->renderer->info.has_implicit_fencing) { + exts->KHR_incremental_present = true; + #ifndef VK_USE_PLATFORM_WIN32_KHR + exts->KHR_present_id = true; +@@ -1272,6 +1273,8 @@ vn_physical_device_get_passthrough_extensions( + * (aka no corresponding renderer side object). + */ +- const bool can_sync2 = physical_dev->renderer_sync_fd.semaphore_importable; ++ const bool can_sync2 = ++ physical_dev->renderer_sync_fd.semaphore_importable || ++ physical_dev->instance->renderer->info.has_implicit_fencing; + #else + static const bool can_sync2 = true; + #endif +diff --git a/src/virtio/vulkan/vn_pipeline.c b/src/virtio/vulkan/vn_pipeline.c +--- a/src/virtio/vulkan/vn_pipeline.c ++++ b/src/virtio/vulkan/vn_pipeline.c +@@ -18,6 +18,7 @@ + #include "vn_descriptor_set.h" + #include "vn_device.h" + #include "vn_physical_device.h" ++#include "vn_renderer.h" + #include "vn_render_pass.h" + + /** +@@ -1685,6 +1685,8 @@ vn_CreateGraphicsPipelines(VkDevice device, + const VkAllocationCallbacks *alloc = + pAllocator ? pAllocator : &dev->base.vk.alloc; +- bool want_sync = false; ++ /* MoltenVK can reject a pipeline that native Linux Vulkan accepts. Do not ++ * lose that result through Venus' fire-and-forget pipeline fast path. */ ++ bool want_sync = dev->renderer->info.has_implicit_fencing; + VkResult result; + + /* silence -Wmaybe-uninitialized false alarm on release build with gcc */ +@@ -1768,6 +1770,8 @@ vn_CreateComputePipelines(VkDevice device, + const VkAllocationCallbacks *alloc = + pAllocator ? pAllocator : &dev->base.vk.alloc; +- bool want_sync = false; ++ /* Keep renderer object creation and the guest-visible result atomic on the ++ * implicit-fencing compatibility path. */ ++ bool want_sync = dev->renderer->info.has_implicit_fencing; + VkResult result; + + memset(pPipelines, 0, sizeof(*pPipelines) * createInfoCount); +diff --git a/src/virtio/vulkan/vn_queue.c b/src/virtio/vulkan/vn_queue.c +--- a/src/virtio/vulkan/vn_queue.c ++++ b/src/virtio/vulkan/vn_queue.c +@@ -404,7 +404,8 @@ vn_queue_submission_fix_batch_semaphores(struct vn_queue_submission *submit, + if (!vn_semaphore_wait_external(dev, sem)) + return VK_ERROR_DEVICE_LOST; + +- assert(dev->physical_device->renderer_sync_fd.semaphore_importable); ++ assert(dev->physical_device->renderer_sync_fd.semaphore_importable || ++ dev->renderer->info.has_implicit_fencing); + + const VkImportSemaphoreResourceInfoMESA res_info = { + .sType = VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_RESOURCE_INFO_MESA, +diff --git a/src/virtio/vulkan/vn_wsi.c b/src/virtio/vulkan/vn_wsi.c +--- a/src/virtio/vulkan/vn_wsi.c ++++ b/src/virtio/vulkan/vn_wsi.c +@@ -890,9 +890,26 @@ vn_AcquireNextImage2KHR(VkDevice device, + struct vn_swapchain *chain = + vn_wsi_chain_lookup(dev, pAcquireInfo->swapchain); + ++ /* The common acquire helper turns the platform acquire sync into an ++ * imported sync-fd payload when the application provides a semaphore or ++ * fence. Dory has no Linux sync-fd on its MoltenVK renderer, and signaling ++ * the same object later does not clear that imported payload. Hide the ++ * application sync objects from common WSI for the implicit-fencing path; ++ * they are signaled with an ordinary host queue submission below. ++ */ ++ VkAcquireNextImageInfoKHR acquire_info; ++ const VkAcquireNextImageInfoKHR *common_acquire_info = pAcquireInfo; ++ if (dev->renderer->info.has_implicit_fencing) { ++ acquire_info = *pAcquireInfo; ++ acquire_info.semaphore = VK_NULL_HANDLE; ++ acquire_info.fence = VK_NULL_HANDLE; ++ common_acquire_info = &acquire_info; ++ } ++ + simple_mtx_lock(&chain->acquire_mutex); + result = wsi_common_acquire_next_image2(&dev->physical_device->wsi_device, +- device, pAcquireInfo, pImageIndex); ++ device, common_acquire_info, ++ pImageIndex); + simple_mtx_unlock(&chain->acquire_mutex); + + if (VN_DEBUG(WSI) && result != VK_SUCCESS) { +@@ -908,24 +908,51 @@ vn_AcquireNextImage2KHR(VkDevice device, + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + return vn_error(dev->instance, result); + ++ /* Dory's MoltenVK renderer has no Linux sync-fd import. The common WSI ++ * acquire path still needs to signal the application's binary semaphore ++ * and/or fence, however. Submit an empty batch on a real renderer queue ++ * before returning from acquire so the host Vulkan objects receive an ++ * ordinary queue signal instead of the sync-fd import used on Linux. ++ * ++ * The submit is queued before the application can submit a wait for the ++ * acquire semaphore, preserving Vulkan ordering without relying on a ++ * dma-buf reservation object that does not exist on macOS. ++ */ ++ if (dev->renderer->info.has_implicit_fencing) { ++ assert(dev->queue_count > 0); ++ const VkSubmitInfo submit = { ++ .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, ++ .signalSemaphoreCount = ++ pAcquireInfo->semaphore != VK_NULL_HANDLE ? 1 : 0, ++ .pSignalSemaphores = ++ pAcquireInfo->semaphore != VK_NULL_HANDLE ++ ? &pAcquireInfo->semaphore ++ : NULL, ++ }; ++ const VkResult signal_result = vn_QueueSubmit( ++ vn_queue_to_handle(&dev->queues[0]), 1, &submit, ++ pAcquireInfo->fence); ++ if (signal_result != VK_SUCCESS) ++ return signal_result; ++ return vn_result(dev->instance, result); ++ } ++ + int sync_fd = -1; +- if (!dev->renderer->info.has_implicit_fencing) { +- VkDeviceMemory mem_handle = +- wsi_common_get_memory(pAcquireInfo->swapchain, *pImageIndex); +- struct vn_device_memory *mem = vn_device_memory_from_handle(mem_handle); +- struct vn_image *img = mem->dedicated_img; ++ VkDeviceMemory mem_handle = ++ wsi_common_get_memory(pAcquireInfo->swapchain, *pImageIndex); ++ struct vn_device_memory *mem = vn_device_memory_from_handle(mem_handle); ++ struct vn_image *img = mem->dedicated_img; + +- if (img->wsi.is_prime_blit_src) +- mem = img->wsi.blit_mem; ++ if (img->wsi.is_prime_blit_src) ++ mem = img->wsi.blit_mem; + +- /* Only image buffer blit is tracked (both cpu and prime), so we use if +- * condition below for potential new blit path supported on non-Linux +- * platforms. +- */ +- if (mem) { +- sync_fd = +- vn_renderer_bo_export_sync_file(dev->renderer, mem->base_bo); +- } ++ /* Only image buffer blit is tracked (both cpu and prime), so we use if ++ * condition below for potential new blit path supported on non-Linux ++ * platforms. ++ */ ++ if (mem) { ++ sync_fd = ++ vn_renderer_bo_export_sync_file(dev->renderer, mem->base_bo); + } + + int sem_fd = -1, fence_fd = -1; +-- +2.50.1 diff --git a/guest/mesa/verify-build.sh b/guest/mesa/verify-build.sh new file mode 100755 index 00000000..f5084a71 --- /dev/null +++ b/guest/mesa/verify-build.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" +source guest/mesa/PINS + +case "${1:-arm64}" in + arm64|aarch64) ;; + *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;; +esac + +OUT="${DORY_MESA_OUT_DIR:-$ROOT/guest/out}" +RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst" +STAMP="$OUT/dory-mesa-venus-build-arm64.stamp" +fail() { + echo "Dory Venus runtime verification failed: $*" >&2 + exit 1 +} + +[ -s "$RUNTIME" ] || fail "missing $RUNTIME" +[ -s "$STAMP" ] || fail "missing $STAMP" +stamp_value() { sed -n "s/^$1=//p" "$STAMP"; } +[ "$(stamp_value schema)" = 1 ] || fail "unsupported stamp schema" +[ "$(stamp_value arch)" = arm64 ] || fail "runtime was built for another architecture" +[ "$(stamp_value mesa_version)" = "$MESA_VERSION" ] || fail "Mesa version is stale" +[ "$(stamp_value mesa_source_sha256)" = "$MESA_SOURCE_SHA256" ] \ + || fail "Mesa source pin is stale" +[ "$(stamp_value input_sha256)" = "$(guest/mesa/input-fingerprint.sh arm64)" ] \ + || fail "runtime inputs are stale" +[ "$(stamp_value runtime_sha256)" = "$(shasum -a 256 "$RUNTIME" | awk '{print $1}')" ] \ + || fail "runtime digest does not match its stamp" + +ZSTD="${DORY_ZSTD:-$(command -v zstd 2>/dev/null || true)}" +[ -n "$ZSTD" ] || fail "zstd is required" +"$ZSTD" -q -t "$RUNTIME" || fail "runtime archive is corrupt" + +EXTRACT="$(mktemp -d "${TMPDIR:-/tmp}/dory-mesa-verify.XXXXXX")" +trap 'rm -rf "$EXTRACT"' EXIT +"$ZSTD" -q -d -c "$RUNTIME" | tar -xf - -C "$EXTRACT" +ICD="$EXTRACT/opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json" +LIBRARY="$EXTRACT/opt/dory/mesa/lib/libvulkan_virtio.so" +PROBE="$EXTRACT/usr/lib/dory/dory-vulkan-probe" +MANIFEST="$EXTRACT/opt/dory/mesa/share/dory/runtime.env" +for path in "$ICD" "$LIBRARY" "$PROBE" "$MANIFEST"; do + [ -s "$path" ] || fail "runtime archive is missing ${path#$EXTRACT}" +done +[ -L "$EXTRACT/opt/dory/mesa/lib/libxcb-keysyms.so.1" ] \ + || fail "runtime archive is missing its XCB keysyms ABI link" +grep -Fq '"api_version": "1.4.' "$ICD" || fail "ICD API version is invalid" +grep -Fq '"library_path": "/opt/dory/mesa/lib/libvulkan_virtio.so"' "$ICD" \ + || fail "ICD does not select the isolated Dory library" +grep -Fqx "mesa_version=$MESA_VERSION" "$MANIFEST" || fail "runtime manifest is stale" +grep -Fqx "mesa_source_sha256=$MESA_SOURCE_SHA256" "$MANIFEST" \ + || fail "runtime manifest source digest is stale" +file "$LIBRARY" | grep -Fq 'ELF 64-bit LSB shared object, ARM aarch64' \ + || fail "Venus ICD is not an ARM64 ELF shared library" +file "$PROBE" | grep -Fq 'ELF 64-bit LSB pie executable, ARM aarch64' \ + || fail "Vulkan probe is not an ARM64 ELF executable" +grep -aFq 'VK_KHR_swapchain' "$LIBRARY" \ + || fail "Venus ICD does not contain swapchain support" +if grep -aFq 'llvmpipe' "$LIBRARY"; then + fail "isolated Venus ICD unexpectedly contains the software renderer" +fi + +echo "verified Dory Mesa $MESA_VERSION Venus runtime" From b246283f6a12f81fe2b048ee9ca9f3470911f651 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 10:57:00 +0000 Subject: [PATCH 006/338] build(app): authorize desktop audio and harden helper pruning --- Dory.xcodeproj/project.pbxproj | 6 +++++- Dory/Dory.entitlements | 2 ++ DoryTests/LSUIElementBuildSettingTests.swift | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Dory.xcodeproj/project.pbxproj b/Dory.xcodeproj/project.pbxproj index 286e4079..ae4147fa 100644 --- a/Dory.xcodeproj/project.pbxproj +++ b/Dory.xcodeproj/project.pbxproj @@ -345,7 +345,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -maxdepth 1 -type f -exec rm -f {} +\n rmdir \"$HELPERS\" 2>/dev/null || true\nfi\n"; + shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\ncase \"$HELPERS\" in\n \"${TARGET_BUILD_DIR}\"/*.app/Contents/Helpers) ;;\n *) echo \"error: refusing to prune unexpected helper path: $HELPERS\" >&2; exit 1 ;;\nesac\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -depth -delete\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -583,6 +583,7 @@ ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Dory-Info.plist; @@ -590,6 +591,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -620,6 +622,7 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Dory-Info.plist; @@ -627,6 +630,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/Dory/Dory.entitlements b/Dory/Dory.entitlements index 9fddc7b3..d69c0ad5 100644 --- a/Dory/Dory.entitlements +++ b/Dory/Dory.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.server + com.apple.security.device.audio-input + diff --git a/DoryTests/LSUIElementBuildSettingTests.swift b/DoryTests/LSUIElementBuildSettingTests.swift index 052d13d6..b4e6341d 100644 --- a/DoryTests/LSUIElementBuildSettingTests.swift +++ b/DoryTests/LSUIElementBuildSettingTests.swift @@ -62,9 +62,11 @@ struct LSUIElementBuildSettingTests { @Test func appBuildPrunesStaleBundledHelpersBeforeSigning() throws { let text = try pbxproj() #expect(text.contains("Prune Stale Bundled Helpers")) - #expect(text.contains("find \\\"$HELPERS\\\" -maxdepth 1 -type f -exec rm -f {} +")) + #expect(text.contains("find \\\"$HELPERS\\\" -depth -delete")) + #expect(text.contains("refusing to prune unexpected helper path")) #expect(text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers")) #expect(!text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers/dory-vm")) + #expect(text.components(separatedBy: "ENABLE_USER_SCRIPT_SANDBOXING = NO;").count - 1 >= 2) } @Test func buildAndPublicTestRunnerScrubTransientXcodeProducts() throws { From 103ea20259745dfba04290ed82c1e5d6c21f5558 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:27:33 +0000 Subject: [PATCH 007/338] feat(platform): add versioned VM planning contracts --- .gitignore | 5 +- docs/virtual-workspace-platform.md | 977 ++++++++++ .../DoryOperations/DoryVMResourcePolicy.swift | 594 ++++++ .../DoryVirtualMachineBackendPlanner.swift | 339 ++++ .../DoryVirtualMachineCapabilities.swift | 1597 +++++++++++++++++ .../DoryVirtualMachineDefinition.swift | 977 ++++++++++ .../DoryVMResourcePolicyTests.swift | 394 ++++ ...oryVirtualMachineBackendPlannerTests.swift | 696 +++++++ .../DoryVirtualMachineCapabilitiesTests.swift | 1064 +++++++++++ .../DoryVirtualMachineDefinitionTests.swift | 556 ++++++ 10 files changed, 7198 insertions(+), 1 deletion(-) create mode 100644 docs/virtual-workspace-platform.md create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift diff --git a/.gitignore b/.gitignore index c8e10231..28db686b 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,11 @@ docs-build/ guest/agent/agent # Built GitHub Pages site (CI builds website/ and deploys the output directly) plus -# local-only planning notes. Nothing under docs/ is tracked. +# local-only planning notes. Keep only the source platform ADR under docs/. docs/ +!/docs/ +/docs/* +!/docs/virtual-workspace-platform.md !website/public/docs/ !website/public/docs/** diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md new file mode 100644 index 00000000..56c0faae --- /dev/null +++ b/docs/virtual-workspace-platform.md @@ -0,0 +1,977 @@ +# ADR: Dory virtual workspace platform architecture + +- **Status:** Accepted direction; implementation is staged +- **Date:** 2026-08-20 +- **Owners:** Dory platform team +- **Scope:** Apple-silicon Dory host first; Linux, Windows, and macOS guests +- **First release target:** Production-qualified, accelerated ARM64 Linux desktops + +## Decision + +Dory will become a virtual-workspace platform, not a collection of distribution-specific VM +launch paths. A user installs one small, signed Dory application, chooses only the components and +guest media they need, and creates persistent workspaces through one consistent product surface. + +The platform will have six explicit layers: + +1. product UI; +2. orchestration and control plane; +3. virtualization backend adapters; +4. a backend-independent virtual device model; +5. versioned guest integration; +6. a signed component and artifact supply chain. + +The control plane will select a backend by negotiating declared capabilities against a machine's +requirements. It will not select implementations by scattering checks such as “if Ubuntu,” “if +desktop,” or “if EFI” across the UI and daemon. Operating-system identity remains useful for +catalog presentation, image preparation, and guest-tools installation, but it is not the +virtualization architecture. + +The existing Linux implementation is the foundation, not a prototype to discard. The immediate +work is to extract stable contracts around it, productize the accelerated path, and qualify it. +Windows and macOS are then added as new image families, guest-integration providers, and backend +capability combinations without cloning the control plane. + +## Why this decision + +Dory already has much of the difficult machinery: + +- one per-user daemon owns local lifecycle and exposes authenticated XPC through + [`DorydService`](../dory-core-swift/Sources/DorydKit/DorydService.swift); +- [`MachineManager`](../dory-core-swift/Sources/DorydKit/MachineManager.swift) persists machine + configuration, supervises helpers, waits for readiness, and owns snapshot/clone/export/import; +- [`DoryVMM`](../dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift) configures + Virtualization.framework EFI/direct-kernel guests with storage, networking, display, input, + audio, VirtioFS, NVRAM, and a persistent machine identity; +- [`DesktopMode`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift) and the + [`DoryHV`](../Packages/ContainerizationEngine/Sources/DoryHV) device implementations provide a + custom Hypervisor.framework Linux runtime with VirtIO block, network, vsock, balloon, GPU, + input, sound, and filesystem sharing; +- [`VirglRenderer`](../Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift) provides + capability-checked VirGL2 and Venus acceleration over Metal/MoltenVK rather than pretending + `llvmpipe` is acceptable; +- [`DoryInstallerISOInspector`](../dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift) + performs bounded, non-executing ISO architecture inspection and exact-media hashing before + import; +- [`DoryInstalledLinuxBootBundle`](../dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift) + preserves a verified kernel/initrd/root-device contract for an EFI-installed Linux disk; +- [`DoryComponentCatalog`](../dory-core-swift/Sources/DoryOperations/DoryComponents.swift) already + signs catalogs, verifies asset digests, resolves dependencies, installs immutably, and activates + atomically on the selected Dory data drive; +- [`MachineBackupScheduler`](../dory-core-swift/Sources/DorydKit/MachineBackupScheduler.swift), + [`HealthReporter`](../dory-core-swift/Sources/DorydKit/HealthReporter.swift), and + [`IncidentWriter`](../dory-core-swift/Sources/DorydKit/IncidentWriter.swift) establish recovery + and diagnostics foundations; +- [`guest/desktop`](../guest/desktop), [`guest/kernel`](../guest/kernel), and + [`guest/mesa`](../guest/mesa) provide reproducible guest integration, kernels, and an isolated + accelerated Vulkan runtime. + +The present joining layer, however, still encodes product concepts in implementation switches. +For example, `DoryMachineConfiguration` combines kernel paths, rootfs paths, EFI media, display +mode, resources, shares, and an untyped environment dictionary. `MachineManager.processTarget` +then recognizes a particular combination as an “accelerated desktop.” The UI in +[`NewMachineSheet`](../Dory/Features/Sheets/NewMachineSheet.swift) similarly starts from “Linux +desktop” and “custom Linux ISO.” This works for proving Linux, but extending those switches to +Windows and macOS would produce three intertwined products and make qualification unreliable. + +The new architecture keeps the proven engines and replaces the joining logic with durable, +versioned contracts. + +## North-star user journey + +1. The user downloads and opens Dory. The core app is useful by itself and clearly reports host + compatibility. +2. A workspace gallery offers supported templates and **Install from image**. Templates display + their download size, guest architecture, qualification level, license requirements, and needed + Dory components before anything downloads. +3. Selecting Ubuntu, Windows 11 ARM, macOS, or a user-owned installer image creates one draft + `WorkspaceSpec`. Dory inspects the media without executing it and shows facts separately: + architecture compatibility, backend availability, device support, acceleration, guest-tools + status, and whether the exact combination is qualified. +4. The settings editor presents CPU, memory, storage, graphics, displays, network adapters, audio, + input, shared folders, clipboard, USB, and recovery in the same structure for every workspace. + Controls unavailable for the chosen host/image are disabled with the missing capability and a + supported alternative. Settings never silently downgrade. +5. **Create and Run** resolves components, downloads and verifies only missing artifacts, creates + durable machine state transactionally, boots the installer or template, and opens its display. +6. The workspace card exposes start, stop, pause, resume, restart, duplicate, snapshot, restore, + export, and delete. Closing a display window does not destroy or ambiguously stop the VM. +7. Dory Tools installs or becomes active when supported. Resize, clipboard, shared folders, time + sync, graceful shutdown, telemetry, application launch, and recovery report their negotiated + status in one **Integration health** surface. +8. Updates change replaceable components transactionally. User disks, firmware identity, license + state, snapshots, and settings remain intact. A failed update returns to the last qualified + component selection. +9. Diagnostics can answer “what ran?” exactly: Dory version, host build and hardware, backend, + virtual-hardware ABI, component and media digests, guest-tools version, negotiated capabilities, + and the failed lifecycle operation. + +The product promise is a physical-machine-like development and testing environment. That means +durable state, correct device semantics, responsive accelerated graphics, and reproducible +configuration. It does **not** mean claiming physical GPU passthrough, nested virtualization, or +bit-identical hardware when the host platform cannot provide it. + +## System boundaries and ownership + +```mermaid +flowchart TB + UI["Dory.app: library, create flow, settings, console"] + CP["doryd: desired state, operations, policy, recovery"] + SOLVER["Capability solver and launch planner"] + VZL["VZ Linux adapter"] + RHV["Raw-HV Linux adapter"] + WIN["Experimental QEMU/HVF/SBSA Windows adapter"] + VZM["VZ macOS adapter"] + DEV["Virtual hardware and device contracts"] + GUEST["Dory Tools guest protocol"] + SUPPLY["Signed component catalog and content-addressed store"] + + UI -->|intent and operation stream| CP + CP --> SOLVER + SOLVER --> VZL + SOLVER --> RHV + SOLVER --> WIN + SOLVER --> VZM + VZL --> DEV + RHV --> DEV + WIN --> DEV + VZM --> DEV + CP <-->|versioned authenticated channel| GUEST + CP --> SUPPLY + SOLVER --> SUPPLY +``` + +### 1. Product UI + +The UI owns presentation, editing draft intent, and explaining support. It does not select helper +executables, mutate disks, or infer readiness from a process existing. + +The machine surface should be reorganized around: + +- **Workspace library:** all local VMs, grouped or filtered by guest family and state; +- **Create workspace:** source, requirements, resources, devices, integration, review; +- **Workspace inspector:** Summary, Hardware, Network, Sharing, Recovery, Integration, Diagnostics; +- **Components:** installed runtimes, templates, guest-tools packs, sizes, provenance, updates; +- **Operations:** durable progress with cancelability and recovery, not transient button spinners. + +[`MachinesView`](../Dory/Features/Machines/MachinesView.swift) and +[`ComponentsView`](../Dory/Features/Components/ComponentsView.swift) remain the initial views, but +they consume control-plane projections. They must not reimplement compatibility decisions. + +### 2. Orchestration and control plane + +`doryd` remains the only production owner. Introduce the following logical contracts in a package +that both the app and daemon can import without linking UI or VM frameworks: + +```swift +struct WorkspaceSpec: Codable, Sendable { + let schemaVersion: Int + let id: WorkspaceID + var source: GuestSource + var requirements: CapabilityRequirements + var hardware: VirtualHardwareSpec + var integration: GuestIntegrationPolicy + var recovery: RecoveryPolicy +} + +protocol MachineBackend: Sendable { + var descriptor: BackendDescriptor { get } + func probe(_ context: HostContext) async -> BackendProbe + func plan(_ request: BackendPlanRequest) throws -> BackendLaunchPlan + func start(_ plan: BackendLaunchPlan, events: MachineEventSink) async throws + func requestStop(_ id: WorkspaceID, deadline: Duration) async throws + func pause(_ id: WorkspaceID) async throws + func resume(_ id: WorkspaceID) async throws +} +``` + +The concrete type names may change, but these boundaries may not collapse back into environment +variables or path tuples. + +Control-plane responsibilities: + +- validate and version desired state; +- serialize mutating operations per workspace; +- resolve and pin a launch plan before changing state; +- ensure required components and permissions; +- own helper process lifetime and readiness deadlines; +- reconcile observed state after daemon/host restart; +- own stable MAC addresses, device IDs, firmware files, and guest identity; +- journal every multi-step operation and perform recovery; +- publish ordered machine events and an immutable status projection over XPC; +- authorize access to files selected through the app and materialize security-scoped bookmarks; +- refuse unsupported or unqualified combinations rather than applying hidden workarounds. + +[`MachineManager`](../dory-core-swift/Sources/DorydKit/MachineManager.swift) becomes the migration +host for a `WorkspaceCoordinator`, `WorkspaceRepository`, `OperationJournal`, and +`BackendRegistry`. Its existing persistence, helper supervision, readiness handoff, snapshot logic, +and artifact safety checks should move behind those focused types incrementally. + +### 3. Backend adapters + +Backends implement host mechanisms; they do not own product policy. + +| Adapter | Initial scope | Existing foundation | +|---|---|---| +| `RawHVLinuxBackend` | Managed ARM64 Linux and supported installed ARM64 Linux with accelerated VirGL2/Venus | [`dory-hv`](../Packages/ContainerizationEngine/Sources/dory-hv), [`DoryHV`](../Packages/ContainerizationEngine/Sources/DoryHV) | +| `VZLinuxBackend` | ARM64 Linux direct boot and EFI installation; compatibility fallback | [`DoryVMMKit`](../dory-core-swift/Sources/DoryVMMKit) | +| `VZMacBackend` | macOS restore-image installation and macOS VM lifecycle | New adapter using Apple macOS-specific Virtualization.framework configuration | +| `QEMUHVFWindowsBackend` | Experimental Windows 11 ARM64 on an SBSA-style machine, accelerated by Hypervisor.framework through QEMU/HVF; unavailable in public builds until authorization, device, driver, and qualification gates pass | New, separately packaged adapter; not an extension of `DoryVMMKit` | +| `EmulatedBackend` | Possible future non-native whole-guest emulation, explicitly labeled and separately qualified | Not a release dependency | + +Backend probes return structured facts, including host OS/API requirements, guest architectures, +boot mechanisms, supported device models, maximum vCPU/memory limits, pause/save support, and +graphics API levels. `automatic` is a solver policy, not a backend. + +The current `DoryDesktopVMMPreference` and `DoryDesktopGraphicsPreference` in +[`DoryDesktopRuntimeContract`](../dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift) +remain a compatibility input during migration. New persisted workspaces store typed preference and +requirements. Environment keys remain test/diagnostic overrides only and are recorded in +diagnostics when used. + +### 4. Backend-independent device model + +`VirtualHardwareSpec` describes stable guest-visible hardware. A backend must either map each +required device to an implementation with the declared semantics or reject the plan. + +```text +VirtualHardwareSpec + cpu: architecture, count, feature policy + memory: boot size, minimum, balloon policy + firmware: direct kernel | UEFI | macOS platform + storage[]: stable ID, role, bus, format, durability, discard, read-only + networks[]: stable MAC, attachment profile, MTU, forwards, filters + displays[]: dimensions, scale, acceleration requirements + input[]: keyboard, absolute pointer, relative pointer, tablet + audio[]: direction, channels, format policy, host device policy + shares[]: stable tag, host authorization, guest mount, read-only + channels[]: console, agent, clipboard, file transfer, diagnostics + security: secure boot, TPM, entropy, isolation policy +``` + +Device identity and ordering are part of a versioned **virtual-hardware ABI**. A backend update may +not reorder disks, change NIC MACs, replace firmware identity, or change a controller visible to an +installed OS without an explicit migration. This is essential for Windows activation, macOS +identity, Linux boot, snapshots, and reliable testing. + +The raw-HV devices—[`VirtioBlk`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift), +[`VirtioNet`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift), +[`VirtioGPU`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift), +[`VirtioInput`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift), +[`VirtioSound`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift), +[`VirtioFS`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift), and +[`VirtioVsock`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift)—become one +implementation of those contracts. Virtualization.framework mappings in +[`DoryVMM`](../dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift) become another. + +### 5. Guest integration + +“Dory Tools” is a versioned protocol with optional capabilities, not a monolithic Linux script. +The daemon authenticates the VM/channel established by the launch plan, then the guest reports a +protocol version and supported operations. + +Capability groups: + +- readiness, shutdown, reboot, clock synchronization, and health; +- display topology and resize acknowledgement; +- clipboard text/image and directional policy; +- shared-folder discovery and mount status; +- file transfer and drag/drop with progress and cancellation; +- network identity and telemetry; +- filesystem freeze/thaw for application-consistent snapshots; +- package/integration update with rollback health; +- process/app launch for qualification and developer automation. + +Linux initially uses the existing Rust agent, vsock transport, and integration under +[`guest/desktop/rootfs-overlay/usr/lib/dory`](../guest/desktop/rootfs-overlay/usr/lib/dory). +Windows requires a signed Windows service and drivers where inbox drivers are insufficient. macOS +uses only supported guest-side mechanisms and Virtualization.framework facilities. Missing tools +must degrade individual integrations, not make the display or recovery console inaccessible. + +### 6. Components and artifact supply chain + +The current signed catalog is retained and generalized. A component is immutable content plus +declared capabilities; a template is metadata referencing components and permitted source media. +Examples include: + +- raw-HV Linux runtime and renderer; +- VZ Linux runtime support files; +- Linux kernel/runtime packs; +- Ubuntu/Debian/Kali templates; +- experimental QEMU/HVF/SBSA Windows runtime; +- Windows VirtIO/guest-tools driver ISO; +- macOS integration support; +- architecture-specific CLI tools; +- qualification evidence packs. + +Catalog v2 should add typed `provides`, `requires`, host constraints, artifact roles, provenance, +and qualification references while preserving v1 installation during migration: + +```json +{ + "kind": "dev.dory.component", + "schemaVersion": 2, + "id": "runtime.rawhv-linux", + "version": "0.5.0", + "architectures": ["arm64"], + "hostRequirements": { "platform": "macos", "minimumVersion": "15.0" }, + "provides": [ + "backend.rawhv-linux@2", + "device.virtio-gpu.virgl2@1", + "device.virtio-gpu.venus@1" + ], + "requires": ["app.dory-core>=0.5.0"], + "artifacts": [ + { + "role": "host-helper", + "path": "dory-hv", + "bytes": 123, + "sha256": "<64 lowercase hex characters>", + "executable": true, + "codeRequirement": "" + } + ], + "provenance": { + "sourceCommit": "", + "builder": "", + "recipeDigest": "", + "sbomDigest": "", + "attestationDigest": "" + }, + "qualification": ["linux-desktop-arm64.apple-m2.macos-15"] +} +``` + +Supply-chain rules: + +- the catalog is signed by an offline-rotatable root; metadata supports key IDs, expiry, and + rollback/freeze protection; +- every downloaded byte has a declared size and digest and is fetched over HTTPS; +- executable host artifacts also pass code-signature/designated-requirement validation; +- guest images include an SBOM, package manifest, source/recipe digests, and reproducible build + inputs; +- activation is an atomic pointer to an immutable installation; no consumer reads from a partial + download; +- active launch plans pin component digests, so an update cannot change a running or restoring VM; +- removal is dependency- and lease-aware; artifacts referenced by workspaces/snapshots remain; +- rollback restores the exact prior component selection; +- local overrides are visibly “developer/unqualified,” never indistinguishable from release bits. + +[`DoryComponentStore`](../dory-core-swift/Sources/DoryOperations/DoryComponents.swift) and +[`scripts/build-components.py`](../scripts/build-components.py) are the migration starting points. + +## Capability negotiation + +### Capability model + +A capability has a stable identifier, semantic version, attributes, quality, and evidence: + +```swift +struct Capability: Hashable, Codable, Sendable { + let id: CapabilityID // e.g. graphics.vulkan, lifecycle.pause + let version: SemanticVersion + let attributes: [String: Value] // API level, limits, formats, directions + let quality: CapabilityQuality // native, accelerated, translated, emulated + let evidence: EvidenceReference? +} + +enum RequirementStrength: Codable { case required, preferred, optional } +``` + +Capabilities come from five sources: + +1. the host probe; +2. installed backend components; +3. selected media/template metadata; +4. the virtual-hardware ABI implementation; +5. the live guest-tools handshake. + +The resolver takes `WorkspaceSpec + HostCapabilities + ComponentInventory + MediaInspection` and +returns either a fully pinned `ResolvedMachinePlan` or an ordered list of unsatisfied requirements +and alternatives. Resolution is pure and testable. Starting a VM executes the pinned plan; it does +not resolve again halfway through launch. + +Examples: + +- a requirement for `graphics.vulkan >= 1.3, quality >= accelerated` selects raw-HV + VirGL2/Venus only when renderer symbols, pinned MoltenVK, guest Mesa, and the relevant + qualification evidence are present; +- a Linux ISO with ARM64 EFI can select VZ Linux installation even without Dory Tools, while clipboard + remains `unavailable` until the guest reports it; +- an x86_64-only ISO on Apple silicon fails architecture resolution before disk allocation; +- Windows never selects the VZ Linux adapter. It requires the separately authorized and qualified + QEMU/HVF/SBSA Windows backend and does not inherit Linux's `graphics.vulkan` result; graphics + requires a Windows capability such as a qualified WDDM/DirectX level; +- macOS requires `platform.macos-vm`, a compatible restore image/hardware model, and the macOS VZ + adapter; it never selects the generic Linux EFI adapter based only on ARM64. + +### No hidden fallback + +Preferred requirements may produce a user-approved alternative. Required requirements fail. +Every fallback is persisted in the plan and visible in the UI and diagnostics. In particular, +software rendering is not an acceptable automatic substitute when a template or app qualification +requires accelerated graphics. + +## Lifecycle state machine + +Persist a stable condition plus at most one durable mutating operation. Do not encode every +operation combination in a single ever-growing enum. + +Stable conditions: + +- `defined`: desired state and durable artifacts exist; never booted; +- `stopped`: bootable, not executing; +- `running`: backend and required readiness gates are healthy; +- `paused`: execution is resident but paused; +- `suspended`: durable saved state exists and no helper runs; +- `failed`: the last transition failed and includes a recovery disposition; +- `deleting`: tombstoned; only cleanup/recovery may proceed. + +Durable operations: + +- `importing`, `provisioning`, `resolving`, `starting`, `stopping`, `pausing`, `resuming`, + `suspending`, `restoring`, `snapshotting`, `cloning`, `updating`, `repairing`, `deleting`. + +```mermaid +stateDiagram-v2 + [*] --> defined: create/import committed + defined --> stopped: provision + stopped --> running: resolve + start + readiness gates + running --> stopped: graceful stop + running --> paused: pause + paused --> running: resume + running --> suspended: quiesce + save + paused --> suspended: save + suspended --> running: validate pinned plan + restore + running --> failed: helper/device/readiness failure + stopped --> failed: validation/restore failure + failed --> stopped: repair or rollback + defined --> deleting + stopped --> deleting + suspended --> deleting + failed --> deleting + deleting --> [*]: artifacts released +``` + +Rules: + +- every operation has an ID, expected source condition, target condition, step journal, deadline, + cancellation policy, and rollback/recovery recipe; +- events carry monotonically increasing sequence numbers so UI reconnects cannot reorder state; +- readiness is a set of named gates (backend running, display frame where required, agent where + required, network where required), not one generic handoff; +- closing the UI never changes desired power state; +- daemon restart reconstructs observed state from the journal, helper identity, locks, and durable + artifacts before accepting another mutation; +- unexpected helper exit after readiness is `failed`, not an unbounded restart loop; +- retry budgets apply to individual, classified startup failures and are recorded; +- stop first requests guest shutdown, then a virtual power button when supported, then force-stops + after a visible deadline; +- pause is not suspend; a paused VM still owns RAM and host resources; +- resource/device changes declare whether they are live, require restart, or require an explicit + device-ABI migration. + +The current `DoryMachineState` and `HvProcess` restart/readiness behavior are the compatibility +implementation while this model is introduced. + +## Device and subsystem decisions + +### Storage + +- Store each workspace as a manifest referencing disks and firmware artifacts by stable IDs, not + as paths embedded throughout UI/XPC models. +- Distinguish disk roles: boot, system, data, removable installer, tools, recovery. +- Default to sparse, host-native files with explicit logical/allocated size. Reject shrinking. +- Preserve write ordering and flush semantics. A backend is not qualified until power-loss and + forced-exit tests prove filesystem recovery. +- EFI NVRAM, machine identifier, macOS auxiliary storage, secure-boot identity, and TPM state are + first-class artifacts included in snapshot/export compatibility checks. +- A snapshot manifest records parent content, storage-controller ABI, component digests, and + consistency level. Linked clones arrive only after reference counting and garbage collection are + crash-safe. +- Keep imported user media immutable in daemon-owned storage and retain its original digest and + source bookmark. Media ejection changes attachment state, not bytes. + +The current selected-drive and private-materialization foundations live in +[`DoryDataDrive`](../dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift), +[`DesktopMachineAssetProvisioner`](../Dory/Runtime/Machines/DesktopMachineAssets.swift), and +`MachineManager.prepareMachineArtifacts`. + +### Networking + +Represent networking per NIC through named attachment profiles: + +- **Shared/NAT:** default, isolated inbound, optional explicit port forwarding; +- **Host-only:** deterministic private connectivity with no external route; +- **Bridged:** explicit interface choice and permission, only when the backend/host can implement + and qualify it; +- **Disconnected:** device present, link down; +- future policy networks for test labs. + +Each NIC has a stable MAC, MTU, DNS policy, address policy, firewall/ingress policy, and counters. +Port forwards are resources with conflict detection and lifecycle reconciliation. Network changes +must survive host sleep, Wi-Fi/interface changes, VPN route changes, and daemon restarts. + +The first adapter continues to use the provenance-pinned `gvproxy` launch path in +[`GVProxyDesktopLaunchPlan`](../Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift) +and existing Dory DNS/routing. Bridged and host-only modes remain unavailable until an adapter +reports and passes those capabilities; the UI must not imply that NAT is bridged networking. + +### Display and graphics + +- A display is a stable device with point size, pixel size, scale, refresh policy, and color-space + metadata. Backends report maximum display count and dimensions. +- Window resize is a negotiated display event completed only after the guest acknowledges a mode + and renders a correctly sized frame. +- Retina scale and guest UI scale are distinct and recorded. +- The display surface and graphics acceleration capability are distinct: a VM can have a console + without 3D acceleration. +- Graphics capabilities are guest-API specific: VirGL/OpenGL, Venus/Vulkan, Windows + WDDM/DirectX, and macOS Metal are not interchangeable labels. +- Renderer and shader failures are fatal capability-health events with backend, context, and guest + application evidence. They may not silently switch the machine to `llvmpipe`. +- Multi-display, full-screen, cursor shape/hotspot, capture/release, and sleep/wake are explicit + qualification cases. + +Linux accelerated display continues through [`DesktopMetalDisplay`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift), +`VirtioGPU`, and `VirglRenderer`. The VZ compatibility display remains +[`DoryVMMDesktopApplication`](../dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift). + +### Input + +- Model keyboard, absolute pointer, relative pointer, and tablet independently. +- Persist keyboard layout policy and translate host shortcuts in the display frontend, not the + device backend. +- Keep input queues bounded and prioritize release events to avoid stuck keys/buttons. +- Clipboard shortcuts invoke negotiated clipboard actions; they are not a substitute for a + clipboard transport. +- Accessibility, international layouts, key repeat, modifier chords, gaming/raw input, and focus + changes require automated and live qualification. + +### Audio + +- Model output and input as separate optional devices with host permission and routing state. +- Negotiate sample formats/rates and perform bounded conversion behind the backend interface. +- Handle mute, underflow/overflow, host device changes, Bluetooth latency, sleep/wake, and + microphone permission revocation. +- Never block a vCPU on host audio I/O. Metrics include queue depth, dropped frames, latency, and + device reconnections. + +The raw-HV foundation is [`VirtioSound`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift) +with [`DoryMacAudioBackend`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift). +VZ already configures host audio devices in `DoryVMM`. + +### Shared folders, clipboard, and transfer + +- Host paths are authorized through persistent security-scoped bookmarks, canonicalized, and + opened with least privilege. A textual path is never authorization. +- Each share has a stable tag, explicit read-only/read-write mode, guest mount point, case and + ownership policy, and backend compatibility result. +- Default shares are minimal; the user's entire home is never an implicit desktop-workspace grant. +- Runtime add/remove is transactional and requires guest acknowledgement or a documented restart. +- DAX-like direct mappings remain disabled unless coherence and invalidation are proven. +- Clipboard has off, host-to-guest, guest-to-host, and bidirectional modes, independently for text, + image, and files. +- Drag/drop uses a versioned transfer protocol with staging, digest, conflict policy, cancellation, + progress, quarantine metadata, and cleanup; it is not implemented as an unrestricted share. + +Reuse [`DoryMachineShareConfiguration`](../dory-core-swift/Sources/DorydKit/MachineManager.swift), +[`VirtioFSShareConfiguration`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift), +and [`DoryDesktopClipboardCoordinator`](../dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift) +behind these contracts. + +## Snapshots, recovery, clone, and export + +Snapshot consistency levels are explicit: + +1. **stopped:** strongest default; backend stopped and all files flushed; +2. **guest-quiesced:** guest tools froze filesystems/applications, disks and optional device state + captured, then thawed; +3. **crash-consistent:** storage barriers completed but the guest was not quiesced; +4. **saved-state:** CPU/RAM/device state captured by a backend that declares a compatible saved + state format. + +No live snapshot is labeled application-consistent without a successful guest quiesce receipt. +Snapshot metadata contains: + +- workspace spec revision and virtual-hardware ABI; +- backend ID/version and required saved-state compatibility, if any; +- component catalog and artifact digests; +- disk graph and consistency level; +- firmware/NVRAM/machine identity/TPM/macOS auxiliary storage as applicable; +- guest-tools build and quiesce receipt; +- media identity, host qualification key, creation operation, and checksum tree. + +Restore is transactional: verify all content, snapshot current replaceable state or retain a +rollback reference, materialize to temporary names, fsync, atomically publish, then boot-verify. +Export uses a versioned archive with a signed/checksummed manifest and never assumes another host +supports the original backend. Import first reports portability and missing components; it does not +partially create a workspace. + +Extend the proven snapshot/export logic in `MachineManager`, rather than maintaining the older +container-image snapshots in [`MachineSnapshot`](../Dory/Runtime/Machines/MachineSnapshot.swift) +as a second VM truth. + +## Security model + +### Trust boundaries + +| Boundary | Rule | +|---|---| +| Dory.app | Unprivileged presentation client. Sends typed intent over same-user, production-signature-authenticated XPC. | +| `doryd` | Per-user authority for desired state, artifacts, network policy, operations, and helper supervision. Validates every client input again. | +| VM helper | One process per VM or explicit shared engine. Holds only required VM entitlements/files/descriptors. Never becomes a second control plane. | +| Privileged network helper | Performs only a pre-derived, ownership-checked network plan. No general command execution. | +| Guest | Untrusted even for Dory-built images. Device parsers, queues, agent messages, filenames, and telemetry are hostile inputs. | +| Components/media | Untrusted until signature/digest/type/size/architecture checks succeed. User media is never executed on the host. | +| Host shares | Capability-granted by bookmark and mode, scoped to one workspace/device. | + +Required controls: + +- owner-only state directories, `O_NOFOLLOW`, regular-file/link-count/owner validation, atomic + writes, fsync, and bounded parsers; +- authenticated, versioned XPC and guest channels with message-size/time limits; +- least-privilege entitlements and descriptor passing instead of global filesystem reach; +- no secrets in environment dictionaries, command lines, diagnostics, or component manifests; +- explicit network exposure and collision checks; localhost is the port-forward default; +- device fuzzing for VirtIO descriptor chains, protocol messages, ISO/GPT parsing, and archives; +- signed guest tools/drivers and verifiable updates; Windows kernel drivers require the appropriate + Microsoft signing path before public distribution; +- no automatic mounting of untrusted installer media on the host; +- clear license/consent gates for Windows and macOS media and guest integration. + +## Observability and supportability + +Every lifecycle mutation receives an operation ID propagated through UI, daemon, backend, helper, +guest agent, and component installer. Emit structured events with: + +- monotonic sequence and wall-clock time; +- workspace ID, operation ID, spec revision, backend/ABI IDs; +- host model/OS build and component/media digests; +- lifecycle transition, readiness gate, duration, deadline, and classified error; +- device health: queue stalls, resets, GPU fences/device loss, display frames, audio drops, storage + flush latency, network reconnects, share invalidations; +- resource data: vCPU time, guest/host memory, balloon target, I/O, network, renderer memory, helper + process tree, file descriptors, and threads. + +Keep a bounded per-workspace flight recorder and serial/firmware console independent of Dory Tools. +Support bundles are opt-in, redacted, size-bounded, and show the user what will be included. Raw +clipboard, file contents, credentials, host paths, and keystrokes are excluded by default. + +Build on `HealthReporter`, `IncidentWriter`, raw-HV serial logs, `DorydMachineStats`, and the current +VMM handoff. Replace string-only `lastError` as the primary diagnostic with stable error codes, +causal chains, recovery disposition, and relevant evidence references. + +## Qualification and release gates + +Code existence is not support. A capability may be advertised only when the exact release +candidate has evidence at all applicable levels: + +1. **Schema and solver tests:** serialization, migration, deterministic resolution, rejection, + fallback visibility, and stable hardware identity. +2. **Device conformance:** protocol/unit/property/fuzz tests, reset/error paths, queue saturation, + flush/barrier semantics, and backend contract tests. +3. **Backend integration:** boot, readiness, shutdown, pause/resume, failure injection, daemon + restart, host sleep/wake, component replacement, and low-disk recovery. +4. **Guest matrix:** every supported OS release/kernel/tools combination, clean install and update, + with and without guest tools. +5. **Application workloads:** browser/video/audio, IDEs including Zed, terminals, file managers, + package installers, compilers, containers where supported, graphics API probes, and sustained + I/O/network/GPU stress. +6. **Lifecycle/data safety:** snapshot/restore/clone/export/import, corrupted/truncated artifacts, + force-kill at every journal step, and boot verification of recovered data. +7. **Physical Macs:** every supported macOS major and qualified Apple-silicon generation, multiple + display scales, sleep/wake, network/VPN changes, microphone permissions, and external storage. +8. **Signed-candidate binding:** app digest, helper code signatures, component catalog digest, + artifact/media digests, guest package manifest, host model/build, and test result are inseparable. + +The public compatibility UI has four levels: + +- **Supported:** exact matrix passed and regressions gate release; +- **Preview:** bounded scope passed but the full release matrix has not; +- **Unqualified:** architecture appears possible but no exact evidence; +- **Unavailable:** a required capability is absent or policy forbids the combination. + +Software fallback never turns a failed acceleration gate into a supported accelerated result. +[`LINUX_DESKTOP_PARITY.md`](../LINUX_DESKTOP_PARITY.md) remains the Linux product checklist until +its gates are represented in executable qualification manifests. + +## Apple-silicon constraints and honest product scope + +### Linux + +- Hardware virtualization runs ARM64 guests on Apple silicon. An x86_64-only whole Linux ISO is + not a hardware-virtualized VM on this host. Dory rejects it before allocation today through + `DoryInstallerISOInspector`. +- A future whole-system emulator may run x86_64 media, but it must be labeled **emulated**, has a + different performance/compatibility promise, and cannot satisfy native-workspace qualification. +- x86_64 **applications** can run inside an ARM64 Linux guest through a separately supported + translation facility. Apple's Rosetta-for-Linux integration belongs to its + Virtualization.framework path and has host/guest requirements; Dory's other backends need their + own translated-app capability (the current Docker engine uses bundled FEX). +- Virtualization.framework supplies a reliable generic Linux display/device path, but Dory's + current high-performance Linux 3D path is the custom raw-HV VirtIO GPU plus VirGL2/Venus. It is + translated graphics, not PCIe GPU passthrough. +- Arbitrary installer compatibility is never inferred from ARM64 EFI alone. Kernel/device behavior + and sustained workload tests remain tied to media digest, host build/model, backend, and + virtual-hardware ABI. + +### Windows + +- The native target on Apple silicon is Windows 11 ARM64. Microsoft publishes ARM64 ISO media and + states that ARM64 VMs can be created on Apple-silicon Macs. Dory must not present x64 Windows ISO + installation as native virtualization. +- Apple's shipped Virtualization.framework headers document Linux boot and macOS platform/install + configurations, but expose no Windows guest platform. `VZGenericPlatformConfiguration` and an + EFI loader are not a Windows support contract. Dory will therefore not disguise Windows as a + `DoryVMM`/VZ Linux variant. +- The engineering backend is an experimental QEMU ARM `virt`/SBSA-style machine accelerated by + Hypervisor.framework through HVF. It is a separately packaged adapter with a separately versioned + virtual-hardware ABI. QEMU's ability to reach an installer is research evidence, not public Dory + support. +- Windows 11 on ARM can translate many x86/x64 user applications, but that does not make x64 + kernel drivers, anti-cheat, low-level hardware software, or every application compatible. +- A booting QEMU/HVF EFI VM is not a supported Windows product. Dory must qualify stable SBSA/UEFI + firmware, TPM/Secure Boot policy, storage/network/input/audio devices, recovery, installer + drivers, and a signed Dory Tools package. +- Linux VirGL/Venus does not provide Windows DirectX acceleration. Windows GPU support requires a + signed and qualified ARM64 WDDM display driver plus a host graphics translation path. The + Windows component cannot graduate from experimental, and Dory cannot advertise GPU acceleration, + until that path passes DirectX conformance and application qualification. Linux renderer code is + not a shortcut around this gate. +- Microsoft's current Apple-silicon support page names Windows 365 and Parallels Desktop 18–20 as + the available solutions, calls those Parallels versions authorized, and documents DirectX 12 and + nested-virtualization limitations. Before Dory distributes or advertises a local Windows product, + it needs an explicit Microsoft support/authorization and licensing review; technical boot success + cannot substitute for that review. +- Users supply or obtain Windows through an authorized Microsoft channel and are responsible for + a valid license/activation. Dory does not redistribute Windows or bypass installation/security + requirements. + +### macOS + +- macOS guests on Apple silicon use Apple's macOS-specific Virtualization.framework model, not + the generic Linux EFI model, the experimental Windows SBSA machine, or Dory's raw-HV Linux device + tree. +- Creation and installation use `VZMacOSRestoreImage`/`VZMacOSInstaller` with a host-supported IPSW, + `VZMacPlatformConfiguration`, a restore-image-derived `VZMacHardwareModel`, a persistent + `VZMacMachineIdentifier`, and matching `VZMacAuxiliaryStorage`. These artifacts are part of + machine identity and snapshot/export compatibility. +- Dory must use Apple-provided macOS graphics/input/storage mechanisms and capability-probe the + host API. The Linux VirGL/Venus stack is irrelevant to macOS guest Metal support. +- Dory will not bundle or mirror macOS restore images. The user selects an Apple-fetched IPSW or + explicitly asks Dory to fetch Apple's latest image supported by the current host. Dory validates + the restore image's supported configuration before allocation and clearly communicates applicable + Apple software-license restrictions. +- Cross-Mac restore is conditional on restore-image/hardware-model compatibility; an archive being + intact does not guarantee it is bootable on every Mac. + +### Cross-cutting host limits + +- Dory's current product is a macOS host application. “For everyone” first means a consistent + workspace product on supported Macs; Windows/Linux host implementations require their own host + adapters and qualification and are a later program. +- Nested virtualization, device passthrough, saved-state APIs, bridged networking, and other host + features are capability-probed and version-qualified. They are not inferred from marketing names + or macOS version alone. +- No public Apple API currently used by Dory provides general physical GPU passthrough. Product + language must describe translated, API-level acceleration and its tested limits. + +Primary platform references: + +- [Apple: Running GUI Linux in a virtual machine on a Mac](https://developer.apple.com/documentation/virtualization/running-gui-linux-in-a-virtual-machine-on-a-mac) +- [Apple: Running macOS in a virtual machine on Apple silicon](https://developer.apple.com/documentation/virtualization/running-macos-in-a-virtual-machine-on-apple-silicon) +- [Apple: Hypervisor framework](https://developer.apple.com/documentation/hypervisor) +- [Microsoft: Windows 11 Arm ISO files](https://learn.microsoft.com/windows/arm/iso) +- [Microsoft: Options for using Windows 11 with Apple-silicon Macs](https://support.microsoft.com/en-US/Windows/Experience/Platform-variants/options-for-using-windows-11-with-mac-computers-with-apple-m1-m2-and-m3-chips) +- [Microsoft: How emulation works on Arm](https://learn.microsoft.com/windows/arm/apps-on-arm-x86-emulation) + +## Delivery milestones + +Each milestone ends in a demonstrable, releasable vertical slice. Parallel implementation is +welcome only after shared contracts and file ownership are assigned; merging uncoordinated backend +patches into `MachineManager` is not progress. + +### Milestone 0 — Contract extraction and migration safety + +**Goal:** Make the current Linux system expressible without behavior changes. + +- Add `WorkspaceSpec v2`, capability, launch-plan, device, operation, and backend contracts. +- Add lossless migration between persisted `DoryMachineConfiguration` and the v2 Linux spec. +- Introduce `WorkspaceRepository`, `OperationJournal`, `BackendRegistry`, and pure + `CapabilityResolver` behind existing XPC calls. +- Wrap current `dory-vmm` and `dory-hv` selection as adapters; keep current launch tests passing. +- Version the virtual-hardware ABI and record it in status, snapshots, exports, and diagnostics. +- Replace new persisted environment settings with typed fields; retain read compatibility. +- Gate: existing managed desktops, headless machines, custom ISO, snapshots, and backups pass with + byte-compatible durable artifacts and rollback tests. + +### Milestone 1 — Accelerated Linux as the first complete workspace + +**Goal:** Ship a Linux desktop that users can trust for real application work. + +- Make raw-HV VirGL2/Venus the resolved backend for qualified managed ARM64 Linux desktops; + preserve VZ as an explicit compatibility/recovery backend. +- Productize import/install/eject/direct-boot for qualified ARM64 Linux ISOs without converting an + installed disk into a distro-specific image. +- Finish reliable dynamic resolution, Retina scale, full screen, cursor, input, output/input audio, + clipboard, shares, graceful shutdown, and host sleep/wake. +- Surface backend, GPU, guest tools, software fallback, and exact qualification in the UI. +- Run representative desktop/app stress, including Zed on native Venus, browsers, package updates, + compilers, media, file I/O, networking, snapshot/restore, and forced recovery. +- Gate: no known login trap, invisible text/render corruption, `llvmpipe` substitution, installer + freeze, or unexplained whole-guest stall in the signed-candidate matrix. + +### Milestone 2 — Unified Dory Tools and integration health + +**Goal:** Make integrations independently discoverable, updateable, and diagnosable. + +- Formalize the guest handshake and capability versions. +- Turn Linux overlay scripts into a versioned tools pack with transactional update/rollback. +- Implement transfer/drag-drop and snapshot freeze/thaw. +- Add integration health, repair actions, and unambiguous missing-tools behavior. +- Define Windows service/driver and macOS guest-integration packaging contracts without claiming + support yet. + +### Milestone 3 — Storage, networking, and recovery parity + +**Goal:** Make workspaces safe and configurable enough to replace a daily-use VM product. + +- Add backend-neutral NAT, host-only, disconnected, and qualified bridged profiles. +- Finish runtime share mutation, stable NIC/device identity, port-forward reconciliation, and VPN + recovery. +- Add guest-quiesced/live snapshot semantics, linked clones with safe reference accounting, durable + suspend where supported, and cross-host portability reports. +- Complete low-disk, corruption, interruption, daemon crash, helper crash, and host-reboot gates. + +### Milestone 4 — Windows 11 ARM workspace + +**Goal:** Determine whether Dory can become an authorized, supportable Windows 11 ARM product, then +create, install, run, integrate, and recover it without special-case control-plane code. + +- Complete Microsoft support/authorization, redistribution, driver-signing, and licensing review. + The milestone remains research-only unless that gate passes. +- Build the optional `QEMUHVFWindowsBackend` around pinned QEMU/HVF with an explicit ARM + `virt`/SBSA virtual-hardware ABI; do not route Windows through `DoryVMM`. +- Inspect/import user-authorized ARM64 ISO media and resolve only that Windows-capable backend/device + ABI. +- Implement and qualify UEFI/SBSA firmware, TPM/Secure Boot policy, storage, network, display, + input, audio, and removable/tools media. +- Deliver signed Dory Tools for readiness, shutdown, resize, clipboard, sharing, time sync, + telemetry, and quiesced snapshots. +- Deliver a signed ARM64 WDDM driver and host graphics translation plan, then pass DirectX and + representative application qualification before advertising acceleration or graduating the + backend from experimental. +- Test ARM64 and Windows-translated x86/x64 development applications; report exclusions honestly. +- Gate: clean install, activation-preserving identity, update, sleep/wake, recovery, snapshots, + app workloads, authorization/support sign-off, WDDM graphics evidence, and signed-candidate + evidence on physical Macs. + +### Milestone 5 — macOS workspace + +**Goal:** Add supported macOS guests as a native VZ-backed workspace family. + +- Add restore-image discovery/download consent and compatibility inspection. +- Implement `VZMacBackend` with stable hardware model, machine ID, auxiliary storage, display, + input, network, audio, and lifecycle. +- Define macOS guest integration using supported mechanisms. +- Extend snapshot/export portability checks for macOS-specific identity and restore compatibility. +- Gate: installation, OS update, Xcode/developer workloads, graphics/display, sleep/wake, recovery, + and applicable license UX. + +### Milestone 6 — Broader host platform program + +**Goal:** Evaluate Windows and Linux Dory hosts without contaminating guest/control-plane contracts. + +- Implement host services and backends behind the same `MachineBackend` and artifact contracts. +- Keep workspace manifests portable where the destination capability solver proves compatibility. +- Do not announce a host until security boundaries, installers, updates, devices, and physical-host + qualification reach the same standard as the Mac product. + +## First implementation slices and agent ownership + +To move quickly without creating another patch stack, assign agents to bounded seams: + +| Workstream | First output | Must not edit | +|---|---|---| +| Contracts | Workspace/capability/device/operation types, schema fixtures, migration tests | Backend implementations and UI | +| Resolver | Pure capability solver, rejection diagnostics, deterministic-plan tests | VM process launch code | +| Linux backend | Adapters around existing `dory-hv`/`dory-vmm`, probes, readiness gates | Product policy and component catalog | +| Supply chain | Catalog v2/provenance/leases, v1 migration, builder/verifier tests | Machine lifecycle | +| Product UI | New create/settings projections driven by resolver fixtures | Direct file/process/backend selection | +| Qualification | Machine-readable matrices and signed-candidate runner | Runtime behavior except dedicated test hooks | + +Integration order is contracts, resolver, adapters, daemon/XPC projection, UI, then release gates. +Every workstream supplies tests and an explicit migration story. One owner reviews virtual-hardware +ABI changes because a “small” disk/controller/device reorder can invalidate installed machines. + +## Performance engineering rules + +- Establish budgets before optimization: cold/warm start, first frame, input-to-present latency, + sustained frame pacing, storage fsync latency, network throughput/latency, audio latency, idle CPU, + and host memory overhead. +- Never block a vCPU on host UI, audio, network control, filesystem watching, logging, or component + work. Use bounded queues and observable backpressure. +- Avoid whole-frame copies and synchronous GPU completion on presentation paths; preserve fence and + resource lifetimes explicitly. +- Treat host memory as a VM process-tree cost. Balloon targets may not hide renderer/helper memory. +- Pin build inputs and benchmark the signed release configuration; debug/local artifacts are not + performance evidence. +- Prefer a measured fast path with a correct qualified fallback. A fallback must preserve data and + be visible; it need not claim equal performance. +- Any optimization that weakens flush, snapshot, share-coherence, or isolation semantics requires + a new capability/ABI version and qualification, not a comment. + +## Rejected approaches + +### Add more OS checks to the existing create sheet and `MachineManager` + +This is fast for one boot demo and expensive forever. It couples product, media, backend, devices, +guest tools, and qualification; Windows and macOS would multiply untestable state combinations. + +### One backend for every guest + +No current Apple host API provides the best device, graphics, firmware, and lifecycle path for all +three guest families. A common control plane and device contracts are valuable; forcing one helper +implementation is not. + +### Treat “boots” as “supported” + +Installer boot does not prove storage durability, graphics correctness, application behavior, +updates, sleep/wake, or recovery. Support requires exact-candidate evidence. + +### Ship full operating systems in Dory.app + +It makes the core download large, entangles update cadence, complicates licensing, and expands the +trusted payload. Signed optional components and user-authorized media keep installation focused and +replaceable. + +### Promise physical hardware or GPU passthrough equivalence + +Dory can provide excellent translated acceleration and stable virtual hardware, but unsupported +passthrough claims would be technically false and would make application test results misleading. + +## Consequences + +Positive consequences: + +- Linux, Windows, and macOS share lifecycle, settings, recovery, diagnostics, and artifact logic; +- new backends or devices become capability providers rather than UI/daemon rewrites; +- the UI can explain precisely why a configuration is fast, degraded, unqualified, or impossible; +- signed components stay small and independently updateable; +- persistent device identity and transaction rules protect user work; +- performance work occurs in measurable device/backend paths; +- product claims become evidence-backed. + +Costs and risks: + +- contract extraction temporarily adds adapters and schema migration code; +- the capability vocabulary and virtual-hardware ABI require disciplined ownership; +- Windows graphics and drivers are a distinct engineering program, not reuse of Linux Vulkan; +- macOS restore/licensing/platform identity constrain distribution and portability; +- the full physical qualification matrix is expensive and must be automated aggressively; +- a platform this broad must say “unavailable” rather than accumulating hidden compatibility + switches. + +These costs are accepted because they are smaller than maintaining three separate VM products and +because correctness, performance, and user trust are the core differentiators Dory needs. diff --git a/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift new file mode 100644 index 00000000..d30e9512 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift @@ -0,0 +1,594 @@ +import Foundation + +/// Host capacity captured by the platform layer and supplied to the resource policy. +/// +/// The policy intentionally does not read `ProcessInfo`, filesystem state, or any other +/// process-global value. A caller can therefore evaluate the same facts during creation, +/// editing, recovery, and tests and receive the same result. +public struct DoryVMHostResources: Codable, Sendable, Equatable { + public let logicalCPUCount: UInt64 + public let physicalMemoryBytes: UInt64 + public let freeStorageBytes: UInt64 + /// Virtual CPUs admitted to VMs that are running or in the process of starting. + /// Stopped VM configurations must not be included in this admission counter. + public let admittedVirtualCPUCount: UInt64 + /// Memory admitted to VMs that are running or in the process of starting. + /// Stopped VM configurations must not be included in this admission counter. + public let admittedMemoryBytes: UInt64 + /// Free-storage capacity already reserved for other VM disks. + public let reservedStorageBytes: UInt64 + + public init( + logicalCPUCount: UInt64, + physicalMemoryBytes: UInt64, + freeStorageBytes: UInt64, + admittedVirtualCPUCount: UInt64 = 0, + admittedMemoryBytes: UInt64 = 0, + reservedStorageBytes: UInt64 = 0 + ) { + self.logicalCPUCount = logicalCPUCount + self.physicalMemoryBytes = physicalMemoryBytes + self.freeStorageBytes = freeStorageBytes + self.admittedVirtualCPUCount = admittedVirtualCPUCount + self.admittedMemoryBytes = admittedMemoryBytes + self.reservedStorageBytes = reservedStorageBytes + } + + /// Compatibility bridge for callers of the initial admission API. The old "committed" name + /// was ambiguous; values passed here must represent running + starting VMs only. + @available(*, deprecated, message: "Use admittedVirtualCPUCount/admittedMemoryBytes; exclude stopped VMs") + public init( + logicalCPUCount: UInt64, + physicalMemoryBytes: UInt64, + freeStorageBytes: UInt64, + committedVirtualCPUCount: UInt64, + committedMemoryBytes: UInt64, + reservedStorageBytes: UInt64 = 0 + ) { + self.init( + logicalCPUCount: logicalCPUCount, + physicalMemoryBytes: physicalMemoryBytes, + freeStorageBytes: freeStorageBytes, + admittedVirtualCPUCount: committedVirtualCPUCount, + admittedMemoryBytes: committedMemoryBytes, + reservedStorageBytes: reservedStorageBytes + ) + } + + private enum CodingKeys: String, CodingKey { + case logicalCPUCount + case physicalMemoryBytes + case freeStorageBytes + case admittedVirtualCPUCount + case admittedMemoryBytes + // Schema-zero aliases decoded for migration only; new payloads never encode them. + case committedVirtualCPUCount + case committedMemoryBytes + case reservedStorageBytes + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + logicalCPUCount = try container.decode(UInt64.self, forKey: .logicalCPUCount) + physicalMemoryBytes = try container.decode(UInt64.self, forKey: .physicalMemoryBytes) + freeStorageBytes = try container.decode(UInt64.self, forKey: .freeStorageBytes) + admittedVirtualCPUCount = try container.decodeIfPresent( + UInt64.self, + forKey: .admittedVirtualCPUCount + ) ?? (try container.decodeIfPresent(UInt64.self, forKey: .committedVirtualCPUCount)) ?? 0 + admittedMemoryBytes = try container.decodeIfPresent( + UInt64.self, + forKey: .admittedMemoryBytes + ) ?? (try container.decodeIfPresent(UInt64.self, forKey: .committedMemoryBytes)) ?? 0 + reservedStorageBytes = try container.decodeIfPresent( + UInt64.self, + forKey: .reservedStorageBytes + ) ?? 0 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(logicalCPUCount, forKey: .logicalCPUCount) + try container.encode(physicalMemoryBytes, forKey: .physicalMemoryBytes) + try container.encode(freeStorageBytes, forKey: .freeStorageBytes) + try container.encode(admittedVirtualCPUCount, forKey: .admittedVirtualCPUCount) + try container.encode(admittedMemoryBytes, forKey: .admittedMemoryBytes) + try container.encode(reservedStorageBytes, forKey: .reservedStorageBytes) + } +} + +/// Resources requested for a single virtual machine. +public struct DoryVMResourceRequest: Codable, Sendable, Equatable { + public let virtualCPUCount: UInt64 + public let memoryBytes: UInt64 + public let diskBytes: UInt64 + + public init(virtualCPUCount: UInt64, memoryBytes: UInt64, diskBytes: UInt64) { + self.virtualCPUCount = virtualCPUCount + self.memoryBytes = memoryBytes + self.diskBytes = diskBytes + } +} + +public enum DoryVMResourceRequirementKind: String, Codable, CaseIterable, Sendable { + case image + case component +} + +/// Explicit sizing requirements supplied by an image manifest or optional VM component. +/// Workload policy stays guest-neutral; platform-specific requirements are composed here. +public struct DoryVMResourceRequirement: Codable, Sendable, Equatable { + public let kind: DoryVMResourceRequirementKind + public let identifier: String + public let minimum: DoryVMResourceRequest + public let recommended: DoryVMResourceRequest + + public init( + kind: DoryVMResourceRequirementKind, + identifier: String, + minimum: DoryVMResourceRequest, + recommended: DoryVMResourceRequest + ) { + self.kind = kind + self.identifier = identifier + self.minimum = minimum + self.recommended = recommended + } +} + +/// The primary workload the virtual machine must support. +public enum DoryVMWorkloadProfile: String, Codable, CaseIterable, Sendable { + /// An interactive graphical workstation, including development tools and browsers. + case desktop + /// A headless or service-oriented machine with a smaller interactive footprint. + case server + /// A temporary live environment that must have enough capacity to install an OS. + case installer +} + +/// Guidance for one resource dimension. +/// +/// `minimum` is the workload's hard requirement, `recommended` is the best target that fits +/// this host, and `maximum` is the most the host can safely offer after its reserve is +/// protected. On an undersized host, `maximum` can be lower than `minimum`; `isFeasible` +/// makes that state explicit rather than disguising it by clamping one of the limits. +public struct DoryVMResourceGuidance: Codable, Sendable, Equatable { + public let minimum: UInt64 + public let recommended: UInt64 + public let maximum: UInt64 + + public init(minimum: UInt64, recommended: UInt64, maximum: UInt64) { + self.minimum = minimum + self.recommended = recommended + self.maximum = maximum + } + + public var isFeasible: Bool { + maximum >= minimum + } +} + +/// Capacity deliberately retained for macOS and other host applications. +public struct DoryVMHostReserve: Codable, Sendable, Equatable { + public let logicalCPUCount: UInt64 + public let memoryBytes: UInt64 + public let storageBytes: UInt64 + + public init(logicalCPUCount: UInt64, memoryBytes: UInt64, storageBytes: UInt64) { + self.logicalCPUCount = logicalCPUCount + self.memoryBytes = memoryBytes + self.storageBytes = storageBytes + } +} + +public struct DoryVMResourceRecommendation: Codable, Sendable, Equatable { + public let virtualCPUCount: DoryVMResourceGuidance + public let memoryBytes: DoryVMResourceGuidance + public let diskBytes: DoryVMResourceGuidance + public let hostReserve: DoryVMHostReserve + + public init( + virtualCPUCount: DoryVMResourceGuidance, + memoryBytes: DoryVMResourceGuidance, + diskBytes: DoryVMResourceGuidance, + hostReserve: DoryVMHostReserve + ) { + self.virtualCPUCount = virtualCPUCount + self.memoryBytes = memoryBytes + self.diskBytes = diskBytes + self.hostReserve = hostReserve + } + + public var isFeasible: Bool { + virtualCPUCount.isFeasible && memoryBytes.isFeasible && diskBytes.isFeasible + } +} + +public enum DoryVMResourceDimension: String, Codable, CaseIterable, Sendable { + case cpu + case memory + case storage +} + +public enum DoryVMResourceValidationSeverity: String, Codable, CaseIterable, Sendable { + case warning + case error +} + +public enum DoryVMResourceValidationCode: String, Codable, CaseIterable, Sendable { + case invalidHostCapacity + case hostAdmissionExceedsCapacity + case storageReservationExceedsCapacity + case hostCapacityBelowWorkloadMinimum + case requestBelowWorkloadMinimum + case requestBelowRecommendation + case requestExceedsHostSafeMaximum +} + +/// A stable, machine-readable validation result suitable for UI, CLI, and API clients. +public struct DoryVMResourceValidationIssue: Codable, Sendable, Equatable { + public let code: DoryVMResourceValidationCode + public let severity: DoryVMResourceValidationSeverity + public let resource: DoryVMResourceDimension + /// The value being diagnosed: host capacity for host issues, requested capacity otherwise. + public let actual: UInt64 + /// The workload minimum, recommendation, or host-safe maximum associated with the issue. + public let threshold: UInt64 + + public init( + code: DoryVMResourceValidationCode, + severity: DoryVMResourceValidationSeverity, + resource: DoryVMResourceDimension, + actual: UInt64, + threshold: UInt64 + ) { + self.code = code + self.severity = severity + self.resource = resource + self.actual = actual + self.threshold = threshold + } +} + +public struct DoryVMResourceAssessment: Codable, Sendable, Equatable { + public let host: DoryVMHostResources + public let workload: DoryVMWorkloadProfile + public let requirements: [DoryVMResourceRequirement] + public let request: DoryVMResourceRequest + public let recommendation: DoryVMResourceRecommendation + public let issues: [DoryVMResourceValidationIssue] + + public init( + host: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement] = [], + request: DoryVMResourceRequest, + recommendation: DoryVMResourceRecommendation, + issues: [DoryVMResourceValidationIssue] + ) { + self.host = host + self.workload = workload + self.requirements = requirements + self.request = request + self.recommendation = recommendation + self.issues = issues + } + + public var isValid: Bool { + !issues.contains { $0.severity == .error } + } +} + +/// Deterministic policy for sizing one VM while preserving enough capacity for the host. +public enum DoryVMResourcePolicy { + private static let gibibyte: UInt64 = 1_073_741_824 + + /// Compute host-aware sizing guidance before a user has made a selection. + public static func recommend( + host: DoryVMHostResources, + workload: DoryVMWorkloadProfile + ) -> DoryVMResourceRecommendation { + recommend(host: host, workload: workload, requirements: []) + } + + /// Compute guidance after composing image and component requirements with the workload. + /// Each resource dimension is merged independently using its strictest requirement. + public static func recommend( + host: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement] + ) -> DoryVMResourceRecommendation { + let requirements = mergedRequirements(for: workload, additional: requirements) + let reserve = hostReserve(for: host) + let maximum = DoryVMResourceRequest( + virtualCPUCount: availableCapacity( + total: host.logicalCPUCount, + allocated: host.admittedVirtualCPUCount, + reserve: reserve.logicalCPUCount + ), + memoryBytes: availableCapacity( + total: host.physicalMemoryBytes, + allocated: host.admittedMemoryBytes, + reserve: reserve.memoryBytes + ), + diskBytes: availableCapacity( + total: host.freeStorageBytes, + allocated: host.reservedStorageBytes, + reserve: reserve.storageBytes + ) + ) + return DoryVMResourceRecommendation( + virtualCPUCount: guidance( + minimum: requirements.minimum.virtualCPUCount, + ideal: requirements.recommended.virtualCPUCount, + maximum: maximum.virtualCPUCount + ), + memoryBytes: guidance( + minimum: requirements.minimum.memoryBytes, + ideal: requirements.recommended.memoryBytes, + maximum: maximum.memoryBytes + ), + diskBytes: guidance( + minimum: requirements.minimum.diskBytes, + ideal: requirements.recommended.diskBytes, + maximum: maximum.diskBytes + ), + hostReserve: reserve + ) + } + + /// Evaluate a request against explicit host facts and workload requirements. + public static func assess( + host: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + request: DoryVMResourceRequest + ) -> DoryVMResourceAssessment { + assess(host: host, workload: workload, requirements: [], request: request) + } + + /// Evaluate a request using workload, image, and component requirements together. + public static func assess( + host: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement], + request: DoryVMResourceRequest + ) -> DoryVMResourceAssessment { + let recommendation = recommend( + host: host, + workload: workload, + requirements: requirements + ) + + var issues: [DoryVMResourceValidationIssue] = [] + appendIssues( + resource: .cpu, + hostCapacity: host.logicalCPUCount, + allocated: host.admittedVirtualCPUCount, + overCapacityCode: .hostAdmissionExceedsCapacity, + requested: request.virtualCPUCount, + guidance: recommendation.virtualCPUCount, + to: &issues + ) + appendIssues( + resource: .memory, + hostCapacity: host.physicalMemoryBytes, + allocated: host.admittedMemoryBytes, + overCapacityCode: .hostAdmissionExceedsCapacity, + requested: request.memoryBytes, + guidance: recommendation.memoryBytes, + to: &issues + ) + appendIssues( + resource: .storage, + hostCapacity: host.freeStorageBytes, + allocated: host.reservedStorageBytes, + overCapacityCode: .storageReservationExceedsCapacity, + requested: request.diskBytes, + guidance: recommendation.diskBytes, + to: &issues + ) + + return DoryVMResourceAssessment( + host: host, + workload: workload, + requirements: requirements, + request: request, + recommendation: recommendation, + issues: issues + ) + } + + private struct Requirements { + let minimum: DoryVMResourceRequest + let recommended: DoryVMResourceRequest + } + + private static func mergedRequirements( + for workload: DoryVMWorkloadProfile, + additional: [DoryVMResourceRequirement] + ) -> Requirements { + let baseline = requirements(for: workload) + let minimum = additional.reduce(baseline.minimum) { current, requirement in + maximum(current, requirement.minimum) + } + let requestedRecommendation = additional.reduce(baseline.recommended) { + current, + requirement in + maximum(current, requirement.recommended) + } + return Requirements( + minimum: minimum, + // A malformed manifest cannot lower the recommendation below its own minimum. + recommended: maximum(minimum, requestedRecommendation) + ) + } + + private static func maximum( + _ lhs: DoryVMResourceRequest, + _ rhs: DoryVMResourceRequest + ) -> DoryVMResourceRequest { + DoryVMResourceRequest( + virtualCPUCount: max(lhs.virtualCPUCount, rhs.virtualCPUCount), + memoryBytes: max(lhs.memoryBytes, rhs.memoryBytes), + diskBytes: max(lhs.diskBytes, rhs.diskBytes) + ) + } + + private static func requirements(for workload: DoryVMWorkloadProfile) -> Requirements { + switch workload { + case .desktop: + return Requirements( + minimum: DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 4 * gibibyte, + diskBytes: 32 * gibibyte + ), + recommended: DoryVMResourceRequest( + virtualCPUCount: 4, + memoryBytes: 8 * gibibyte, + diskBytes: 64 * gibibyte + ) + ) + case .server: + return Requirements( + minimum: DoryVMResourceRequest( + virtualCPUCount: 1, + memoryBytes: 2 * gibibyte, + diskBytes: 16 * gibibyte + ), + recommended: DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 4 * gibibyte, + diskBytes: 32 * gibibyte + ) + ) + case .installer: + return Requirements( + minimum: DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 4 * gibibyte, + diskBytes: 40 * gibibyte + ), + recommended: DoryVMResourceRequest( + virtualCPUCount: 4, + memoryBytes: 8 * gibibyte, + diskBytes: 64 * gibibyte + ) + ) + } + } + + private static func hostReserve(for host: DoryVMHostResources) -> DoryVMHostReserve { + // Keep at least one logical CPU and 25% of compute capacity available to the host. + let cpuReserve = max(1, ceilingFraction(host.logicalCPUCount, denominator: 4)) + // Keep at least 2 GiB and 25% of physical memory available to the host. + let memoryReserve = max(2 * gibibyte, ceilingFraction(host.physicalMemoryBytes, denominator: 4)) + // Do not consume the host's final 10 GiB or final 10% of reported free storage. + let storageReserve = max(10 * gibibyte, ceilingFraction(host.freeStorageBytes, denominator: 10)) + return DoryVMHostReserve( + logicalCPUCount: min(cpuReserve, host.logicalCPUCount), + memoryBytes: min(memoryReserve, host.physicalMemoryBytes), + storageBytes: min(storageReserve, host.freeStorageBytes) + ) + } + + /// Computes ceil(value / denominator) without multiplication or addition overflow. + private static func ceilingFraction(_ value: UInt64, denominator: UInt64) -> UInt64 { + let quotient = value / denominator + return quotient + (value % denominator == 0 ? 0 : 1) + } + + private static func subtractingWithoutUnderflow(_ value: UInt64, _ amount: UInt64) -> UInt64 { + value >= amount ? value - amount : 0 + } + + private static func availableCapacity( + total: UInt64, + allocated: UInt64, + reserve: UInt64 + ) -> UInt64 { + subtractingWithoutUnderflow(subtractingWithoutUnderflow(total, allocated), reserve) + } + + private static func guidance( + minimum: UInt64, + ideal: UInt64, + maximum: UInt64 + ) -> DoryVMResourceGuidance { + // Keep the selectable default inside the safe range whenever the workload is feasible. + let recommended = maximum >= minimum ? min(ideal, maximum) : minimum + return DoryVMResourceGuidance( + minimum: minimum, + recommended: recommended, + maximum: maximum + ) + } + + private static func appendIssues( + resource: DoryVMResourceDimension, + hostCapacity: UInt64, + allocated: UInt64, + overCapacityCode: DoryVMResourceValidationCode, + requested: UInt64, + guidance: DoryVMResourceGuidance, + to issues: inout [DoryVMResourceValidationIssue] + ) { + if hostCapacity == 0 { + issues.append(DoryVMResourceValidationIssue( + code: .invalidHostCapacity, + severity: .error, + resource: resource, + actual: hostCapacity, + threshold: 1 + )) + } + + if allocated > hostCapacity { + issues.append(DoryVMResourceValidationIssue( + code: overCapacityCode, + severity: .error, + resource: resource, + actual: allocated, + threshold: hostCapacity + )) + } + + if !guidance.isFeasible { + issues.append(DoryVMResourceValidationIssue( + code: .hostCapacityBelowWorkloadMinimum, + severity: .error, + resource: resource, + actual: guidance.maximum, + threshold: guidance.minimum + )) + } + + if requested < guidance.minimum { + issues.append(DoryVMResourceValidationIssue( + code: .requestBelowWorkloadMinimum, + severity: .error, + resource: resource, + actual: requested, + threshold: guidance.minimum + )) + } else if requested < guidance.recommended { + issues.append(DoryVMResourceValidationIssue( + code: .requestBelowRecommendation, + severity: .warning, + resource: resource, + actual: requested, + threshold: guidance.recommended + )) + } + + if requested > guidance.maximum { + issues.append(DoryVMResourceValidationIssue( + code: .requestExceedsHostSafeMaximum, + severity: .error, + resource: resource, + actual: requested, + threshold: guidance.maximum + )) + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift new file mode 100644 index 00000000..d00b0d60 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift @@ -0,0 +1,339 @@ +public enum DoryVirtualMachineBackendPreferencePolicy: String, Codable, Sendable, CaseIterable, Hashable { + /// Try the listed backends in order, then append Dory's automatic fallbacks. + case preferred + /// Evaluate only the listed backends, in the exact order supplied. + case required +} + +public struct DoryVirtualMachineBackendPlanRequest: Codable, Sendable, Equatable, Hashable { + public var guest: DoryGuestPlatform + public var bootMedia: DoryBootMedia + /// Ordered graphics contracts the caller is willing to accept. A lower level is only a + /// fallback when the caller includes it here explicitly. + public var acceptableGraphics: [DoryGraphicsAccelerationLevel] + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var virtualHardwareABIVersion: UInt16 + /// An ordered backend preference. `nil` asks the planner to use guest/media defaults. + public var backendPreferences: [DoryVirtualizationBackendIdentity]? + public var backendPreferencePolicy: DoryVirtualMachineBackendPreferencePolicy + public var allowsExperimentalBackends: Bool + + public init( + guest: DoryGuestPlatform, + bootMedia: DoryBootMedia, + acceptableGraphics: [DoryGraphicsAccelerationLevel], + devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable, + virtualHardwareABIVersion: UInt16 = 1, + backendPreferences: [DoryVirtualizationBackendIdentity]? = nil, + backendPreferencePolicy: DoryVirtualMachineBackendPreferencePolicy = .preferred, + allowsExperimentalBackends: Bool = false + ) { + self.guest = guest + self.bootMedia = bootMedia + self.acceptableGraphics = acceptableGraphics + self.devices = devices + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.backendPreferences = backendPreferences + self.backendPreferencePolicy = backendPreferencePolicy + self.allowsExperimentalBackends = allowsExperimentalBackends + } + + private enum CodingKeys: String, CodingKey { + case guest + case bootMedia + case acceptableGraphics + case devices + case virtualHardwareABIVersion + case backendPreferences + case backendPreferencePolicy + case allowsExperimentalBackends + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + bootMedia = try container.decode(DoryBootMedia.self, forKey: .bootMedia) + acceptableGraphics = try container.decode( + [DoryGraphicsAccelerationLevel].self, + forKey: .acceptableGraphics + ) + devices = try container.decodeIfPresent( + DoryVirtualMachineDeviceCapabilityRequest.self, + forKey: .devices + ) ?? .minimumBootable + virtualHardwareABIVersion = try container.decodeIfPresent( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) ?? 1 + backendPreferences = try container.decodeIfPresent( + [DoryVirtualizationBackendIdentity].self, + forKey: .backendPreferences + ) + backendPreferencePolicy = try container.decodeIfPresent( + DoryVirtualMachineBackendPreferencePolicy.self, + forKey: .backendPreferencePolicy + ) ?? .preferred + allowsExperimentalBackends = try container.decodeIfPresent( + Bool.self, + forKey: .allowsExperimentalBackends + ) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(guest, forKey: .guest) + try container.encode(bootMedia, forKey: .bootMedia) + try container.encode(acceptableGraphics, forKey: .acceptableGraphics) + try container.encode(devices, forKey: .devices) + try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion) + try container.encodeIfPresent(backendPreferences, forKey: .backendPreferences) + try container.encode(backendPreferencePolicy, forKey: .backendPreferencePolicy) + try container.encode(allowsExperimentalBackends, forKey: .allowsExperimentalBackends) + } +} + +public enum DoryVirtualMachineBackendPlanningFailureCode: String, Codable, Sendable, Hashable { + case invalidPreference = "invalid-preference" + case noCandidate = "no-candidate" +} + +public enum DoryVirtualMachineBackendPreferenceField: String, Codable, Sendable, Hashable { + case graphics + case backend +} + +public enum DoryVirtualMachineBackendPreferenceIssue: String, Codable, Sendable, Hashable { + case empty + case duplicate + case missing +} + +public struct DoryVirtualMachineBackendPlanningFailure: Codable, Sendable, Equatable, Hashable { + public var code: DoryVirtualMachineBackendPlanningFailureCode + public var preferenceField: DoryVirtualMachineBackendPreferenceField? + public var preferenceIssue: DoryVirtualMachineBackendPreferenceIssue? + public var message: String + + public init( + code: DoryVirtualMachineBackendPlanningFailureCode, + preferenceField: DoryVirtualMachineBackendPreferenceField? = nil, + preferenceIssue: DoryVirtualMachineBackendPreferenceIssue? = nil, + message: String + ) { + self.code = code + self.preferenceField = preferenceField + self.preferenceIssue = preferenceIssue + self.message = message + } +} + +/// A complete planning result. Failed plans retain every descriptor the evaluator considered so +/// callers can explain missing components and unsupported combinations without re-running policy. +public struct DoryVirtualMachineBackendPlanResult: Codable, Sendable, Equatable, Hashable { + public var selectedDescriptor: DoryVirtualMachineCapabilityDescriptor? + public var evaluatedDescriptors: [DoryVirtualMachineCapabilityDescriptor] + public var failure: DoryVirtualMachineBackendPlanningFailure? + + public var isSuccess: Bool { + selectedDescriptor != nil && failure == nil + } + + public init( + selectedDescriptor: DoryVirtualMachineCapabilityDescriptor?, + evaluatedDescriptors: [DoryVirtualMachineCapabilityDescriptor], + failure: DoryVirtualMachineBackendPlanningFailure? + ) { + self.selectedDescriptor = selectedDescriptor + self.evaluatedDescriptors = evaluatedDescriptors + self.failure = failure + } +} + +/// Pure backend selection for Apple Silicon. Host discovery belongs to the daemon; this planner +/// consumes its immutable facts and performs no I/O or framework probing. +public enum DoryAppleSiliconVirtualMachineBackendPlanner { + public static func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + host: DoryAppleSiliconHostFacts, + trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, + trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil, + trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil, + trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [] + ) -> DoryVirtualMachineBackendPlanResult { + if request.acceptableGraphics.isEmpty { + return invalidPreference( + field: .graphics, + issue: .empty, + message: "At least one acceptable graphics level is required." + ) + } + if hasDuplicates(request.acceptableGraphics) { + return invalidPreference( + field: .graphics, + issue: .duplicate, + message: "Acceptable graphics levels must not contain duplicates." + ) + } + if let backendPreferences = request.backendPreferences { + if backendPreferences.isEmpty { + return invalidPreference( + field: .backend, + issue: .empty, + message: "An explicit backend preference list must not be empty." + ) + } + if hasDuplicates(backendPreferences) { + return invalidPreference( + field: .backend, + issue: .duplicate, + message: "Backend preferences must not contain duplicates." + ) + } + } else if request.backendPreferencePolicy == .required { + return invalidPreference( + field: .backend, + issue: .missing, + message: "Required backend policy needs at least one backend." + ) + } + + let defaults = defaultBackends(for: request.guest, bootMedia: request.bootMedia.kind) + let backends: [DoryVirtualizationBackendIdentity] + if let preferences = request.backendPreferences { + switch request.backendPreferencePolicy { + case .preferred: + backends = orderedUnique(preferences + defaults) + case .required: + backends = preferences + } + } else { + backends = defaults + } + var evaluated: [DoryVirtualMachineCapabilityDescriptor] = [] + evaluated.reserveCapacity(request.acceptableGraphics.count * backends.count) + + for graphics in request.acceptableGraphics { + for backend in backends { + let capabilityRequest = DoryVirtualMachineCapabilityRequest( + guest: request.guest, + bootMedia: request.bootMedia, + backend: backend, + graphics: graphics, + devices: request.devices, + virtualHardwareABIVersion: request.virtualHardwareABIVersion + ) + evaluated.append(DoryAppleSiliconCapabilityEvaluator.evaluate( + capabilityRequest, + host: host, + trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification, + trustedBootMediaInspection: trustedBootMediaInspection, + trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance, + trustedRuntimeQualification: matchingRuntimeQualification( + for: capabilityRequest, + host: host, + in: trustedRuntimeQualifications + ) + )) + } + } + + let selected = evaluated.first { descriptor in + guard descriptor.availability.isUsable else { return false } + return descriptor.availability.supportTier == .supported + || (request.allowsExperimentalBackends + && descriptor.availability.supportTier == .experimental) + } + + if let selected { + return DoryVirtualMachineBackendPlanResult( + selectedDescriptor: selected, + evaluatedDescriptors: evaluated, + failure: nil + ) + } + + return DoryVirtualMachineBackendPlanResult( + selectedDescriptor: nil, + evaluatedDescriptors: evaluated, + failure: DoryVirtualMachineBackendPlanningFailure( + code: .noCandidate, + message: request.allowsExperimentalBackends + ? "No evaluated configuration satisfies the requested guest, media, and graphics contracts." + : "No supported configuration satisfies the request; experimental backends were not allowed." + ) + ) + } + + public static func defaultBackends( + for guest: DoryGuestPlatform, + bootMedia: DoryBootMediaKind + ) -> [DoryVirtualizationBackendIdentity] { + switch (guest.family, bootMedia) { + case (.linux, .installedLinuxBootBundle): + return [.doryHypervisor] + case (.linux, _): + return [.appleVirtualizationFramework, .qemuHypervisorFramework] + case (.macOS, _): + return [.appleVirtualizationFramework] + case (.windows, _): + return [.qemuHypervisorFramework] + } + } + + private static func hasDuplicates(_ values: [Value]) -> Bool { + Set(values).count != values.count + } + + private static func orderedUnique(_ values: [Value]) -> [Value] { + var seen: Set = [] + return values.filter { seen.insert($0).inserted } + } + + private static func matchingRuntimeQualification( + for request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + in qualifications: [DoryTrustedVirtualMachineRuntimeQualification] + ) -> DoryTrustedVirtualMachineRuntimeQualification? { + guard let hostContext = host.runtimeQualificationContext else { return nil } + return qualifications.first { qualification in + let evidence = qualification.auditEvidence + guard evidence.guest == request.guest + && evidence.bootMediaKind == request.bootMedia.kind + && evidence.backend == request.backend + && evidence.backendRuntimeBuildID == hostContext.runtimeBuildID(for: request.backend) + && evidence.graphics == request.graphics + && evidence.devices == request.devices + && evidence.virtualHardwareABIVersion == request.virtualHardwareABIVersion + && evidence.virtualHardwareABIVersion == hostContext.virtualHardwareABIVersion else { + return false + } + if request.bootMedia.kind == .virtualDisk { + return evidence.immutableArtifactSHA256 == nil + && evidence.mutableProvenance == request.bootMedia.mutableProvenance + } + guard let requestDigest = request.bootMedia.artifactSHA256, + let evidenceDigest = evidence.immutableArtifactSHA256 else { + return false + } + return evidence.mutableProvenance == nil + && evidenceDigest.lowercased() == requestDigest.lowercased() + } + } + + private static func invalidPreference( + field: DoryVirtualMachineBackendPreferenceField, + issue: DoryVirtualMachineBackendPreferenceIssue, + message: String + ) -> DoryVirtualMachineBackendPlanResult { + DoryVirtualMachineBackendPlanResult( + selectedDescriptor: nil, + evaluatedDescriptors: [], + failure: DoryVirtualMachineBackendPlanningFailure( + code: .invalidPreference, + preferenceField: field, + preferenceIssue: issue, + message: message + ) + ) + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift new file mode 100644 index 00000000..c03b3069 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -0,0 +1,1597 @@ +/// The operating-system family installed in a virtual machine. +public enum DoryGuestFamily: String, Codable, Sendable, CaseIterable, Hashable { + case linux + case windows + case macOS = "macos" +} + +/// The instruction-set architecture executed by the guest kernel. +public enum DoryGuestArchitecture: String, Codable, Sendable, CaseIterable, Hashable { + case arm64 + case x86_64 +} + +public struct DoryGuestPlatform: Codable, Sendable, Equatable, Hashable { + public var family: DoryGuestFamily + public var architecture: DoryGuestArchitecture + + public init(family: DoryGuestFamily, architecture: DoryGuestArchitecture) { + self.family = family + self.architecture = architecture + } +} + +/// The format presented to the virtual machine's firmware or boot loader. +public enum DoryBootMediaKind: String, Codable, Sendable, CaseIterable, Hashable { + case installerISO = "installer-iso" + case virtualDisk = "virtual-disk" + case installedLinuxBootBundle = "installed-linux-boot-bundle" + case macOSRestoreImage = "macos-restore-image" +} + +/// Where boot media originates. This is part of capability negotiation because some operating +/// systems may be virtualized but cannot be redistributed as a Dory-managed image. +public enum DoryBootMediaSource: String, Codable, Sendable, CaseIterable, Hashable { + case bundledByDory = "dory-bundled" + case vendorDownload = "vendor-download" + case userProvided = "user-provided" +} + +/// Resolver identity for mutable media. A revision is stable across planning but changes whenever +/// the daemon publishes a new disk state; it is not a caller assertion of disk contents. +public struct DoryMutableBootMediaProvenanceReference: Codable, Sendable, Equatable, Hashable { + public var repositoryIdentity: String + public var mediaIdentity: String + public var revision: UInt64 + + public init(repositoryIdentity: String, mediaIdentity: String, revision: UInt64) { + self.repositoryIdentity = repositoryIdentity + self.mediaIdentity = mediaIdentity + self.revision = revision + } +} + +public struct DoryBootMedia: Codable, Sendable, Equatable, Hashable { + public var kind: DoryBootMediaKind + public var source: DoryBootMediaSource + /// SHA-256 of the selected artifact, when content-addressed identity has been resolved. + public var artifactSHA256: String? + /// Resolver-owned identity requested for mutable virtual disks. Authority comes from the + /// daemon's trusted provenance receipt, not this wire reference. + public var mutableProvenance: DoryMutableBootMediaProvenanceReference? + + public init( + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + artifactSHA256: String? = nil, + mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil + ) { + self.kind = kind + self.source = source + self.artifactSHA256 = artifactSHA256 + self.mutableProvenance = mutableProvenance + } +} + +/// A stable backend identifier shared by the daemon, app, and external API. +public enum DoryVirtualizationBackendIdentity: String, Codable, Sendable, CaseIterable, Hashable { + /// Dory's native Hypervisor.framework virtual machine monitor. + case doryHypervisor = "dory-hypervisor" + /// Apple's higher-level Virtualization.framework runtime. + case appleVirtualizationFramework = "apple-virtualization-framework" + /// QEMU using Hypervisor.framework for same-architecture CPU virtualization. + case qemuHypervisorFramework = "qemu-hvf" +} + +/// The graphics guarantee made to the guest. Higher cases are not implied by lower cases; callers +/// must negotiate the exact level they require. +public enum DoryGraphicsAccelerationLevel: String, Codable, Sendable, CaseIterable, Hashable { + case none + case software + /// The host compositor presents a guest framebuffer efficiently. This does not promise a + /// guest-visible 3D API or driver. + case hostAcceleratedDisplay = "host-accelerated-display" + /// A qualified guest driver exposes hardware-backed 3D APIs inside the virtual machine. + case hardwareAccelerated3D = "hardware-accelerated-3d" +} + +/// Exact virtual-device contract requested by a definition or API client. Features are booleans +/// rather than inferred from "desktop" so a backend cannot silently omit hardware or guest tools. +public enum DoryVirtualMachineNetworkAttachmentMode: String, Codable, Sendable, CaseIterable, Hashable { + case disconnected + case sharedNAT = "shared-nat" + case bridged + case isolated +} + +public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equatable, Hashable { + public var networkAttachment: DoryVirtualMachineNetworkAttachmentMode + public var audioInput: Bool + public var audioOutput: Bool + public var keyboard: Bool + public var pointer: Bool + public var directorySharing: Bool + public var clipboard: Bool + public var clockSynchronization: Bool + public var dynamicDisplay: Bool + public var gracefulShutdown: Bool + + public init( + networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, + audioInput: Bool = false, + audioOutput: Bool = false, + keyboard: Bool = false, + pointer: Bool = false, + directorySharing: Bool = false, + clipboard: Bool = false, + clockSynchronization: Bool = false, + dynamicDisplay: Bool = false, + gracefulShutdown: Bool = false + ) { + self.networkAttachment = networkAttachment + self.audioInput = audioInput + self.audioOutput = audioOutput + self.keyboard = keyboard + self.pointer = pointer + self.directorySharing = directorySharing + self.clipboard = clipboard + self.clockSynchronization = clockSynchronization + self.dynamicDisplay = dynamicDisplay + self.gracefulShutdown = gracefulShutdown + } + + /// The smallest currently implemented cross-backend device contract. + public static let minimumBootable = DoryVirtualMachineDeviceCapabilityRequest() +} + +/// Product maturity is independent of whether the required host component is installed. +public enum DoryCapabilitySupportTier: String, Codable, Sendable, CaseIterable, Hashable { + case supported + case experimental + case unsupported +} + +public enum DoryCapabilityAvailabilityState: String, Codable, Sendable, CaseIterable, Hashable { + case available + case unavailable +} + +public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, Hashable { + case hostOperatingSystemUnsupported = "host-os-unsupported" + case guestArchitectureRequiresEmulation = "guest-architecture-requires-emulation" + case backendDoesNotSupportGuest = "backend-does-not-support-guest" + case bootMediaDoesNotSupportGuest = "boot-media-does-not-support-guest" + case bootMediaDoesNotSupportBackend = "boot-media-does-not-support-backend" + case guestMediaRedistributionUnavailable = "guest-media-redistribution-unavailable" + case graphicsModeUnsupported = "graphics-mode-unsupported" + case windows3DAccelerationUnsupported = "windows-3d-acceleration-unsupported" + case metalUnavailable = "metal-unavailable" + case acceleratedRendererUnavailable = "accelerated-renderer-unavailable" + case bootMediaArtifactDigestUnavailable = "boot-media-artifact-digest-unavailable" + case bootMediaArtifactDigestInvalid = "boot-media-artifact-digest-invalid" + case mutableBootMediaProvenanceUnavailable = "mutable-boot-media-provenance-unavailable" + case mutableBootMediaProvenanceInvalid = "mutable-boot-media-provenance-invalid" + case trustedMutableBootMediaProvenanceUnavailable = "trusted-mutable-boot-media-provenance-unavailable" + case mutableBootMediaProvenanceMismatch = "mutable-boot-media-provenance-mismatch" + case trustedBootMediaInspectionUnavailable = "trusted-boot-media-inspection-unavailable" + case bootMediaInspectionEvidenceInvalid = "boot-media-inspection-evidence-invalid" + case bootMediaArtifactInspectionMismatch = "boot-media-artifact-inspection-mismatch" + case bootMediaKindInspectionMismatch = "boot-media-kind-inspection-mismatch" + case bootMediaGuestInspectionMismatch = "boot-media-guest-inspection-mismatch" + case bootMediaArchitectureInspectionMismatch = "boot-media-architecture-inspection-mismatch" + case bootMediaNotEFIBootable = "boot-media-not-efi-bootable" + case macOSRestoreBuildIdentityUnavailable = "macos-restore-build-identity-unavailable" + case macOSRestoreHardwareModelIncompatible = "macos-restore-hardware-model-incompatible" + case macOSRestoreAuxiliaryStorageIncompatible = "macos-restore-auxiliary-storage-incompatible" + case guestImageArtifactDigestUnavailable = "guest-image-artifact-digest-unavailable" + case trustedGuestImageGraphicsQualificationUnavailable = "trusted-guest-image-graphics-qualification-unavailable" + case guestImageArtifactDigestInvalid = "guest-image-artifact-digest-invalid" + case guestImageQualificationManifestIdentityInvalid = "guest-image-qualification-manifest-identity-invalid" + case guestImageQualificationAuditEvidenceInvalid = "guest-image-qualification-audit-evidence-invalid" + case guestImageArtifactQualificationMismatch = "guest-image-artifact-qualification-mismatch" + case linuxVirtioGPUKernelDeviceUnqualified = "linux-virtio-gpu-kernel-device-unqualified" + case linuxVenusVulkanRuntimeUnqualified = "linux-venus-vulkan-runtime-unqualified" + case virtualizationFrameworkUnavailable = "virtualization-framework-unavailable" + case hypervisorFrameworkUnavailable = "hypervisor-framework-unavailable" + case backendComponentUnavailable = "backend-component-unavailable" + case backendComponentUnqualified = "backend-component-unqualified" + case windowsUEFIFirmwareUnavailable = "windows-uefi-firmware-unavailable" + case windowsSecureBootUnavailable = "windows-secure-boot-unavailable" + case windowsSBSADeviceModelUnavailable = "windows-sbsa-device-model-unavailable" + case virtualTPM20Unavailable = "virtual-tpm-2-unavailable" + case windowsStorageDriverUnavailable = "windows-storage-driver-unavailable" + case windowsNetworkDriverUnavailable = "windows-network-driver-unavailable" + case windowsDisplayDriverUnavailable = "windows-display-driver-unavailable" + case windowsInputDriverUnavailable = "windows-input-driver-unavailable" + case macOSGuestVirtualizationUnavailable = "macos-guest-virtualization-unavailable" + case macOSRestoreImageUnsupported = "macos-restore-image-unsupported" + case networkAttachmentUnsupported = "network-attachment-unsupported" + case audioInputUnsupported = "audio-input-unsupported" + case audioOutputUnsupported = "audio-output-unsupported" + case keyboardInputUnsupported = "keyboard-input-unsupported" + case pointerInputUnsupported = "pointer-input-unsupported" + case directorySharingUnsupported = "directory-sharing-unsupported" + case clipboardIntegrationUnsupported = "clipboard-integration-unsupported" + case clockSynchronizationUnsupported = "clock-synchronization-unsupported" + case dynamicDisplayUnsupported = "dynamic-display-unsupported" + case gracefulShutdownUnsupported = "graceful-shutdown-unsupported" + case runtimeQualificationUnavailable = "runtime-qualification-unavailable" + case runtimeQualificationEvidenceInvalid = "runtime-qualification-evidence-invalid" + case runtimeQualificationRequestMismatch = "runtime-qualification-request-mismatch" + case runtimeQualificationHostMismatch = "runtime-qualification-host-mismatch" + case backendSupportIsExperimental = "backend-support-is-experimental" + case windowsSupportIsExperimental = "windows-support-is-experimental" +} + +public struct DoryCapabilityReason: Codable, Sendable, Equatable, Hashable { + public var code: DoryCapabilityReasonCode + public var message: String + + public init(code: DoryCapabilityReasonCode, message: String) { + self.code = code + self.message = message + } +} + +public struct DoryCapabilityAvailability: Codable, Sendable, Equatable, Hashable { + public var supportTier: DoryCapabilitySupportTier + public var state: DoryCapabilityAvailabilityState + public var reason: DoryCapabilityReason? + + public var isUsable: Bool { + supportTier != .unsupported && state == .available + } + + public init( + supportTier: DoryCapabilitySupportTier, + state: DoryCapabilityAvailabilityState, + reason: DoryCapabilityReason? = nil + ) { + self.supportTier = supportTier + self.state = state + self.reason = reason + } +} + +/// Driver qualification is explicit so the presence of a QEMU executable alone can never make a +/// Windows VM appear runnable. Each fact represents a tested ARM64 guest driver, not merely a file. +public struct DoryWindowsGuestDriverFacts: Codable, Sendable, Equatable, Hashable { + public var storageAvailable: Bool + public var networkAvailable: Bool + public var displayAvailable: Bool + public var inputAvailable: Bool + + public init( + storageAvailable: Bool, + networkAvailable: Bool, + displayAvailable: Bool, + inputAvailable: Bool + ) { + self.storageAvailable = storageAvailable + self.networkAvailable = networkAvailable + self.displayAvailable = displayAvailable + self.inputAvailable = inputAvailable + } +} + +/// Non-secret evidence persisted with a plan so the daemon can re-resolve and revalidate the exact +/// signed manifest that authorized an artifact. It is audit evidence, never a trust decision. +public struct DorySignedArtifactQualificationEvidence: Codable, Sendable, Equatable, Hashable { + public var manifestIdentity: String + public var artifactSHA256: String + public var manifestSHA256: String + public var signingKeyID: String + public var manifestFormatVersion: UInt16 + + public init( + manifestIdentity: String, + artifactSHA256: String, + manifestSHA256: String, + signingKeyID: String, + manifestFormatVersion: UInt16 + ) { + self.manifestIdentity = manifestIdentity + self.artifactSHA256 = artifactSHA256 + self.manifestSHA256 = manifestSHA256 + self.signingKeyID = signingKeyID + self.manifestFormatVersion = manifestFormatVersion + } +} + +/// Daemon inspection receipt for arbitrary boot media. User-provided ISOs do not need a Dory +/// signature; the receipt pins the inspected bytes and deterministic inspector report instead. +/// Catalog media may additionally retain its signed manifest reference. +public struct DoryBootMediaInspectionAuditEvidence: Codable, Sendable, Equatable, Hashable { + public var inspectionIdentity: String + public var artifactSHA256: String + public var inspectionReportSHA256: String + public var inspectorID: String + public var inspectorVersion: UInt16 + public var catalogManifestEvidence: DorySignedArtifactQualificationEvidence? + + public init( + inspectionIdentity: String, + artifactSHA256: String, + inspectionReportSHA256: String, + inspectorID: String, + inspectorVersion: UInt16, + catalogManifestEvidence: DorySignedArtifactQualificationEvidence? = nil + ) { + self.inspectionIdentity = inspectionIdentity + self.artifactSHA256 = artifactSHA256 + self.inspectionReportSHA256 = inspectionReportSHA256 + self.inspectorID = inspectorID + self.inspectorVersion = inspectorVersion + self.catalogManifestEvidence = catalogManifestEvidence + } +} + +/// Resolver-owned qualification for an exact guest artifact. It is intentionally not Codable and +/// has no public initializer or public trust facts: UI/API intent cannot manufacture signature or +/// driver qualification. A signed-manifest resolver in DoryOperations creates these records. +public struct DoryTrustedGuestImageGraphicsQualification: Sendable, Equatable, Hashable { + let auditEvidence: DorySignedArtifactQualificationEvidence + let virtioGPUKernelAndDeviceSupportQualified: Bool + let venusVulkanGuestRuntimeQualified: Bool + + init( + auditEvidence: DorySignedArtifactQualificationEvidence, + virtioGPUKernelAndDeviceSupportQualified: Bool, + venusVulkanGuestRuntimeQualified: Bool + ) { + self.auditEvidence = auditEvidence + self.virtioGPUKernelAndDeviceSupportQualified = virtioGPUKernelAndDeviceSupportQualified + self.venusVulkanGuestRuntimeQualified = venusVulkanGuestRuntimeQualified + } +} + +/// Daemon-owned inspection of exact boot media. Detected format/architecture and compatibility +/// results are deliberately neither Codable nor publicly constructible; only the audit reference +/// is copied into a successful descriptor. +public struct DoryTrustedBootMediaInspection: Sendable, Equatable, Hashable { + let auditEvidence: DoryBootMediaInspectionAuditEvidence + let detectedKind: DoryBootMediaKind + let detectedGuestFamily: DoryGuestFamily + let detectedArchitecture: DoryGuestArchitecture + let isEFIBootable: Bool + let macOSBuildIdentifier: String? + let macOSHardwareModelCompatible: Bool + let macOSAuxiliaryStorageCompatible: Bool + + init( + auditEvidence: DoryBootMediaInspectionAuditEvidence, + detectedKind: DoryBootMediaKind, + detectedGuestFamily: DoryGuestFamily, + detectedArchitecture: DoryGuestArchitecture, + isEFIBootable: Bool, + macOSBuildIdentifier: String? = nil, + macOSHardwareModelCompatible: Bool = false, + macOSAuxiliaryStorageCompatible: Bool = false + ) { + self.auditEvidence = auditEvidence + self.detectedKind = detectedKind + self.detectedGuestFamily = detectedGuestFamily + self.detectedArchitecture = detectedArchitecture + self.isEFIBootable = isEFIBootable + self.macOSBuildIdentifier = macOSBuildIdentifier + self.macOSHardwareModelCompatible = macOSHardwareModelCompatible + self.macOSAuxiliaryStorageCompatible = macOSAuxiliaryStorageCompatible + } +} + +/// Codable receipt reference emitted by the daemon after it resolves an exact mutable disk +/// revision. The receipt can be revalidated without treating request fields as authority. +public struct DoryMutableBootMediaProvenanceAuditEvidence: Codable, Sendable, Equatable, Hashable { + public var receiptIdentity: String + public var provenance: DoryMutableBootMediaProvenanceReference + public var receiptSHA256: String + public var resolverID: String + public var resolverVersion: UInt16 + + public init( + receiptIdentity: String, + provenance: DoryMutableBootMediaProvenanceReference, + receiptSHA256: String, + resolverID: String, + resolverVersion: UInt16 + ) { + self.receiptIdentity = receiptIdentity + self.provenance = provenance + self.receiptSHA256 = receiptSHA256 + self.resolverID = resolverID + self.resolverVersion = resolverVersion + } +} + +public struct DoryTrustedMutableBootMediaProvenance: Sendable, Equatable, Hashable { + let auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence + + init(auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence) { + self.auditEvidence = auditEvidence + } +} + +/// Host runtime identities used to prevent a qualification for one adapter or virtual-device ABI +/// from authorizing a different installed runtime. +public struct DoryVirtualMachineRuntimeQualificationHostContext: Codable, Sendable, Equatable, Hashable { + public var virtualHardwareABIVersion: UInt16 + public var doryHypervisorRuntimeBuildID: String + public var virtualizationFrameworkAdapterBuildID: String + public var qemuRuntimeBuildID: String + + public init( + virtualHardwareABIVersion: UInt16, + doryHypervisorRuntimeBuildID: String, + virtualizationFrameworkAdapterBuildID: String, + qemuRuntimeBuildID: String + ) { + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.doryHypervisorRuntimeBuildID = doryHypervisorRuntimeBuildID + self.virtualizationFrameworkAdapterBuildID = virtualizationFrameworkAdapterBuildID + self.qemuRuntimeBuildID = qemuRuntimeBuildID + } + + func runtimeBuildID(for backend: DoryVirtualizationBackendIdentity) -> String { + switch backend { + case .doryHypervisor: + doryHypervisorRuntimeBuildID + case .appleVirtualizationFramework: + virtualizationFrameworkAdapterBuildID + case .qemuHypervisorFramework: + qemuRuntimeBuildID + } + } +} + +/// Non-secret audit reference for exact runtime qualification. Exactly one media binding is used: +/// an immutable artifact digest or a mutable daemon provenance revision. +public struct DoryVirtualMachineRuntimeQualificationEvidence: Codable, Sendable, Equatable, Hashable { + public var qualificationIdentity: String + public var qualificationReportSHA256: String + public var signingKeyID: String + public var qualificationFormatVersion: UInt16 + public var guest: DoryGuestPlatform + public var bootMediaKind: DoryBootMediaKind + public var immutableArtifactSHA256: String? + public var mutableProvenance: DoryMutableBootMediaProvenanceReference? + public var backend: DoryVirtualizationBackendIdentity + public var backendRuntimeBuildID: String + public var virtualHardwareABIVersion: UInt16 + public var graphics: DoryGraphicsAccelerationLevel + public var devices: DoryVirtualMachineDeviceCapabilityRequest + + public init( + qualificationIdentity: String, + qualificationReportSHA256: String, + signingKeyID: String, + qualificationFormatVersion: UInt16, + guest: DoryGuestPlatform, + bootMediaKind: DoryBootMediaKind, + immutableArtifactSHA256: String? = nil, + mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil, + backend: DoryVirtualizationBackendIdentity, + backendRuntimeBuildID: String, + virtualHardwareABIVersion: UInt16, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest + ) { + self.qualificationIdentity = qualificationIdentity + self.qualificationReportSHA256 = qualificationReportSHA256 + self.signingKeyID = signingKeyID + self.qualificationFormatVersion = qualificationFormatVersion + self.guest = guest + self.bootMediaKind = bootMediaKind + self.immutableArtifactSHA256 = immutableArtifactSHA256 + self.mutableProvenance = mutableProvenance + self.backend = backend + self.backendRuntimeBuildID = backendRuntimeBuildID + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.graphics = graphics + self.devices = devices + } +} + +/// Signature verification and the qualification decision remain daemon-owned and non-Codable. +public struct DoryTrustedVirtualMachineRuntimeQualification: Sendable, Equatable, Hashable { + let auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence + let runtimeQualified: Bool + + init( + auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence, + runtimeQualified: Bool + ) { + self.auditEvidence = auditEvidence + self.runtimeQualified = runtimeQualified + } +} + +/// A request is deliberately free of detected host state so it can cross process and API +/// boundaries and be evaluated by the daemon that owns the virtualization components. +public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, Hashable { + public var guest: DoryGuestPlatform + public var bootMedia: DoryBootMedia + public var backend: DoryVirtualizationBackendIdentity + public var graphics: DoryGraphicsAccelerationLevel + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var virtualHardwareABIVersion: UInt16 + + public init( + guest: DoryGuestPlatform, + bootMedia: DoryBootMedia, + backend: DoryVirtualizationBackendIdentity, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable, + virtualHardwareABIVersion: UInt16 = 1 + ) { + self.guest = guest + self.bootMedia = bootMedia + self.backend = backend + self.graphics = graphics + self.devices = devices + self.virtualHardwareABIVersion = virtualHardwareABIVersion + } + + private enum CodingKeys: String, CodingKey { + case guest + case bootMedia + case backend + case graphics + case devices + case virtualHardwareABIVersion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + bootMedia = try container.decode(DoryBootMedia.self, forKey: .bootMedia) + backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend) + graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics) + devices = try container.decodeIfPresent( + DoryVirtualMachineDeviceCapabilityRequest.self, + forKey: .devices + ) ?? .minimumBootable + virtualHardwareABIVersion = try container.decodeIfPresent( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) ?? 1 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(guest, forKey: .guest) + try container.encode(bootMedia, forKey: .bootMedia) + try container.encode(backend, forKey: .backend) + try container.encode(graphics, forKey: .graphics) + try container.encode(devices, forKey: .devices) + try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion) + } +} + +/// The daemon's immutable answer to a capability request. `schemaVersion` versions the wire shape, +/// while the evaluator version identifies the support policy used to produce the answer. +public struct DoryVirtualMachineCapabilityDescriptor: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 2 + public static let appleSiliconEvaluatorVersion: UInt16 = 2 + + public var schemaVersion: UInt16 + public var evaluatorVersion: UInt16 + public var request: DoryVirtualMachineCapabilityRequest + public var availability: DoryCapabilityAvailability + /// Exact device contract selected for a usable descriptor. No partial device downgrade occurs. + public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + public var graphicsQualificationEvidence: DorySignedArtifactQualificationEvidence? + public var bootMediaInspectionEvidence: DoryBootMediaInspectionAuditEvidence? + public var mutableBootMediaProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? + public var runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence? + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + evaluatorVersion: UInt16, + request: DoryVirtualMachineCapabilityRequest, + availability: DoryCapabilityAvailability, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil, + graphicsQualificationEvidence: DorySignedArtifactQualificationEvidence? = nil, + bootMediaInspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil, + mutableBootMediaProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil, + runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence? = nil + ) { + self.schemaVersion = schemaVersion + self.evaluatorVersion = evaluatorVersion + self.request = request + self.availability = availability + self.resolvedDevices = resolvedDevices + self.graphicsQualificationEvidence = graphicsQualificationEvidence + self.bootMediaInspectionEvidence = bootMediaInspectionEvidence + self.mutableBootMediaProvenanceEvidence = mutableBootMediaProvenanceEvidence + self.runtimeQualificationEvidence = runtimeQualificationEvidence + } +} + +/// Host observations used by the Apple Silicon policy. No global process or framework probing is +/// performed here, keeping evaluation deterministic and making stale daemon/UI state detectable. +public struct DoryAppleSiliconHostFacts: Codable, Sendable, Equatable, Hashable { + public var macOSMajorVersion: Int + public var virtualizationFrameworkAvailable: Bool + public var hypervisorFrameworkAvailable: Bool + public var doryHypervisorAvailable: Bool + public var qemuHypervisorFrameworkAvailable: Bool + public var windowsUEFIFirmwareAvailable: Bool + public var windowsSecureBootAvailable: Bool + public var windowsSBSADeviceModelAvailable: Bool + public var virtualTPM20Available: Bool + public var windowsGuestDrivers: DoryWindowsGuestDriverFacts + public var macOSGuestVirtualizationSupported: Bool + public var macOSRestoreImageInstallationSupported: Bool + public var doryMacOSBackendAvailable: Bool + public var doryMacOSBackendQualified: Bool + public var metalAvailable: Bool + public var doryAcceleratedRendererAvailable: Bool + public var runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext? + + public init( + macOSMajorVersion: Int, + virtualizationFrameworkAvailable: Bool, + hypervisorFrameworkAvailable: Bool, + doryHypervisorAvailable: Bool, + qemuHypervisorFrameworkAvailable: Bool, + windowsUEFIFirmwareAvailable: Bool, + windowsSecureBootAvailable: Bool, + windowsSBSADeviceModelAvailable: Bool, + virtualTPM20Available: Bool, + windowsGuestDrivers: DoryWindowsGuestDriverFacts, + macOSGuestVirtualizationSupported: Bool, + macOSRestoreImageInstallationSupported: Bool, + doryMacOSBackendAvailable: Bool, + doryMacOSBackendQualified: Bool, + metalAvailable: Bool, + doryAcceleratedRendererAvailable: Bool, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext? = nil + ) { + self.macOSMajorVersion = macOSMajorVersion + self.virtualizationFrameworkAvailable = virtualizationFrameworkAvailable + self.hypervisorFrameworkAvailable = hypervisorFrameworkAvailable + self.doryHypervisorAvailable = doryHypervisorAvailable + self.qemuHypervisorFrameworkAvailable = qemuHypervisorFrameworkAvailable + self.windowsUEFIFirmwareAvailable = windowsUEFIFirmwareAvailable + self.windowsSecureBootAvailable = windowsSecureBootAvailable + self.windowsSBSADeviceModelAvailable = windowsSBSADeviceModelAvailable + self.virtualTPM20Available = virtualTPM20Available + self.windowsGuestDrivers = windowsGuestDrivers + self.macOSGuestVirtualizationSupported = macOSGuestVirtualizationSupported + self.macOSRestoreImageInstallationSupported = macOSRestoreImageInstallationSupported + self.doryMacOSBackendAvailable = doryMacOSBackendAvailable + self.doryMacOSBackendQualified = doryMacOSBackendQualified + self.metalAvailable = metalAvailable + self.doryAcceleratedRendererAvailable = doryAcceleratedRendererAvailable + self.runtimeQualificationContext = runtimeQualificationContext + } +} + +/// Conservative Apple Silicon capability policy. It only reports a configuration as usable after +/// the guest/backend contract and the supplied host facts both satisfy the request. +public enum DoryAppleSiliconCapabilityEvaluator { + public static func evaluate( + _ request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, + trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil, + trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil, + trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil + ) -> DoryVirtualMachineCapabilityDescriptor { + let availability = evaluateAvailability( + request, + host: host, + trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification, + trustedBootMediaInspection: trustedBootMediaInspection, + trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance, + trustedRuntimeQualification: trustedRuntimeQualification + ) + return DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: request, + availability: availability, + resolvedDevices: availability.isUsable ? request.devices : nil, + graphicsQualificationEvidence: availability.isUsable + ? graphicsAuditEvidence( + for: request, + trustedQualification: trustedGuestImageGraphicsQualification + ) + : nil, + bootMediaInspectionEvidence: availability.isUsable + ? bootMediaAuditEvidence( + for: request, + trustedInspection: trustedBootMediaInspection + ) + : nil, + mutableBootMediaProvenanceEvidence: availability.isUsable + ? mutableBootMediaProvenanceAuditEvidence( + for: request, + trustedProvenance: trustedMutableBootMediaProvenance + ) + : nil, + runtimeQualificationEvidence: availability.supportTier == .supported + && availability.isUsable + ? trustedRuntimeQualification?.auditEvidence + : nil + ) + } + + private static func evaluateAvailability( + _ request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification?, + trustedBootMediaInspection: DoryTrustedBootMediaInspection?, + trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance?, + trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? + ) -> DoryCapabilityAvailability { + if let artifactSHA256 = request.bootMedia.artifactSHA256, + !isSHA256(artifactSHA256) { + return unsupported( + .bootMediaArtifactDigestInvalid, + "The selected boot-media artifact identity is not a valid SHA-256 digest." + ) + } + + guard request.guest.architecture == .arm64 else { + return unsupported( + .guestArchitectureRequiresEmulation, + "Apple Silicon can virtualize ARM64 guests; x86_64 guests require a CPU emulation backend." + ) + } + + guard let supportTier = supportTier(for: request) else { + return unsupported( + .backendDoesNotSupportGuest, + "The selected virtualization backend does not support this guest family." + ) + } + + guard bootMediaIsCompatible(request.bootMedia.kind, with: request.guest.family) else { + return unsupported( + .bootMediaDoesNotSupportGuest, + "The selected boot-media format is not supported for this guest family." + ) + } + + guard bootMediaIsCompatibleWithBackend(request) else { + return unsupported( + .bootMediaDoesNotSupportBackend, + "The selected backend cannot boot this media format for the requested guest." + ) + } + + if request.guest.family == .macOS || request.guest.family == .windows, + request.bootMedia.source == .bundledByDory { + return unsupported( + .guestMediaRedistributionUnavailable, + "This guest's installation media must come from its vendor or the user; Dory does not redistribute it." + ) + } + + if let unavailable = mutableBootMediaProvenanceFailure( + for: request, + trustedProvenance: trustedMutableBootMediaProvenance, + tier: supportTier + ) { + return unavailable + } + + if let unavailable = bootMediaInspectionFailure( + for: request, + trustedInspection: trustedBootMediaInspection, + tier: supportTier + ) { + return unavailable + } + + if request.guest.family == .windows, request.graphics == .hardwareAccelerated3D { + return unsupported( + .windows3DAccelerationUnsupported, + "Windows 3D acceleration is not currently supported on Apple Silicon." + ) + } + + guard graphicsContractIsSupported(for: request) else { + return unsupported( + .graphicsModeUnsupported, + "The selected backend cannot provide the requested graphics acceleration level for this guest." + ) + } + + if let unavailable = guestImageGraphicsQualificationFailure( + for: request, + trustedQualification: trustedGuestImageGraphicsQualification + ) { + return unavailable + } + if let unavailable = deviceCapabilityFailure(for: request, tier: supportTier) { + return unavailable + } + + if let unavailable = backendAvailabilityFailure(request, host: host, tier: supportTier) { + return unavailable + } + + if request.graphics == .hostAcceleratedDisplay || request.graphics == .hardwareAccelerated3D { + guard host.metalAvailable else { + return unavailable( + tier: supportTier, + code: .metalUnavailable, + message: "The requested graphics mode requires Metal on the host." + ) + } + } + + if request.backend == .doryHypervisor, + request.graphics == .hostAcceleratedDisplay || request.graphics == .hardwareAccelerated3D, + !host.doryAcceleratedRendererAvailable { + return unavailable( + tier: supportTier, + code: .acceleratedRendererUnavailable, + message: "Dory's accelerated graphics renderer is not installed or failed validation." + ) + } + + if supportTier == .supported, + let runtimeDecision = runtimeQualificationDecision( + for: request, + host: host, + trustedQualification: trustedRuntimeQualification + ) { + return runtimeDecision + } + + if supportTier == .experimental, request.guest.family == .windows { + return DoryCapabilityAvailability( + supportTier: .experimental, + state: .available, + reason: DoryCapabilityReason( + code: .windowsSupportIsExperimental, + message: "Windows virtualization is experimental and does not include 3D acceleration." + ) + ) + } + if supportTier == .experimental { + return DoryCapabilityAvailability( + supportTier: .experimental, + state: .available, + reason: DoryCapabilityReason( + code: .backendSupportIsExperimental, + message: "This virtualization backend is experimental for the requested guest." + ) + ) + } + + return DoryCapabilityAvailability(supportTier: .supported, state: .available) + } + + private static func supportTier( + for request: DoryVirtualMachineCapabilityRequest + ) -> DoryCapabilitySupportTier? { + switch (request.guest.family, request.backend) { + case (.linux, .doryHypervisor), + (.linux, .appleVirtualizationFramework): + return .supported + case (.linux, .qemuHypervisorFramework), + (.macOS, .appleVirtualizationFramework), + (.windows, .qemuHypervisorFramework): + return .experimental + default: + return nil + } + } + + private static func bootMediaIsCompatible( + _ media: DoryBootMediaKind, + with family: DoryGuestFamily + ) -> Bool { + switch family { + case .linux: + media == .installerISO || media == .virtualDisk || media == .installedLinuxBootBundle + case .windows: + media == .installerISO || media == .virtualDisk + case .macOS: + media == .macOSRestoreImage || media == .virtualDisk + } + } + + private static func bootMediaIsCompatibleWithBackend( + _ request: DoryVirtualMachineCapabilityRequest + ) -> Bool { + switch (request.guest.family, request.backend) { + case (.linux, .doryHypervisor): + return request.bootMedia.kind == .installedLinuxBootBundle + case (.linux, .appleVirtualizationFramework), + (.linux, .qemuHypervisorFramework), + (.windows, .qemuHypervisorFramework): + return request.bootMedia.kind == .installerISO || request.bootMedia.kind == .virtualDisk + case (.macOS, .appleVirtualizationFramework): + return request.bootMedia.kind == .macOSRestoreImage || request.bootMedia.kind == .virtualDisk + default: + return false + } + } + + private static func graphicsContractIsSupported( + for request: DoryVirtualMachineCapabilityRequest + ) -> Bool { + switch request.graphics { + case .none, .software: + return true + case .hostAcceleratedDisplay: + return true + case .hardwareAccelerated3D: + return (request.guest.family == .linux && request.backend == .doryHypervisor) + || (request.guest.family == .macOS && request.backend == .appleVirtualizationFramework) + } + } + + private static func backendAvailabilityFailure( + _ request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + tier: DoryCapabilitySupportTier + ) -> DoryCapabilityAvailability? { + switch request.backend { + case .doryHypervisor: + guard host.macOSMajorVersion >= 15 else { + return unavailable( + tier: tier, + code: .hostOperatingSystemUnsupported, + message: "Dory's native hypervisor backend requires macOS 15 or newer." + ) + } + guard host.hypervisorFrameworkAvailable else { + return unavailable( + tier: tier, + code: .hypervisorFrameworkUnavailable, + message: "Hypervisor.framework is unavailable on this host." + ) + } + guard host.doryHypervisorAvailable else { + return unavailable( + tier: tier, + code: .backendComponentUnavailable, + message: "Dory's native hypervisor component is not installed or failed validation." + ) + } + case .appleVirtualizationFramework: + guard host.macOSMajorVersion >= 14 else { + return unavailable( + tier: tier, + code: .hostOperatingSystemUnsupported, + message: "This Dory runtime requires macOS 14 or newer." + ) + } + guard host.virtualizationFrameworkAvailable else { + return unavailable( + tier: tier, + code: .virtualizationFrameworkUnavailable, + message: "Virtualization.framework is unavailable on this host." + ) + } + if request.guest.family == .macOS { + guard host.macOSGuestVirtualizationSupported else { + return unavailable( + tier: tier, + code: .macOSGuestVirtualizationUnavailable, + message: "This Apple Silicon host does not report support for macOS guests." + ) + } + guard host.doryMacOSBackendAvailable else { + return unavailable( + tier: tier, + code: .backendComponentUnavailable, + message: "Dory's VZMac backend adapter is not installed or failed validation." + ) + } + guard host.doryMacOSBackendQualified else { + return unavailable( + tier: tier, + code: .backendComponentUnqualified, + message: "Dory's VZMac backend adapter has not passed signed runtime qualification." + ) + } + if request.bootMedia.kind == .macOSRestoreImage, + !host.macOSRestoreImageInstallationSupported { + return unavailable( + tier: tier, + code: .macOSRestoreImageUnsupported, + message: "The selected Apple restore image cannot be installed on this host." + ) + } + } + case .qemuHypervisorFramework: + guard host.macOSMajorVersion >= 14 else { + return unavailable( + tier: tier, + code: .hostOperatingSystemUnsupported, + message: "This Dory runtime requires macOS 14 or newer." + ) + } + guard host.hypervisorFrameworkAvailable else { + return unavailable( + tier: tier, + code: .hypervisorFrameworkUnavailable, + message: "Hypervisor.framework is unavailable on this host." + ) + } + guard host.qemuHypervisorFrameworkAvailable else { + return unavailable( + tier: tier, + code: .backendComponentUnavailable, + message: "The QEMU Hypervisor.framework component is not installed or failed validation." + ) + } + if request.guest.family == .windows { + guard host.windowsUEFIFirmwareAvailable else { + return unavailable( + tier: tier, + code: .windowsUEFIFirmwareUnavailable, + message: "Qualified ARM64 UEFI firmware is required to boot this Windows guest." + ) + } + guard host.windowsSecureBootAvailable else { + return unavailable( + tier: tier, + code: .windowsSecureBootUnavailable, + message: "A qualified Secure Boot implementation is required for this Windows guest." + ) + } + guard host.windowsSBSADeviceModelAvailable else { + return unavailable( + tier: tier, + code: .windowsSBSADeviceModelUnavailable, + message: "A qualified SBSA virtual device model is required for this Windows guest." + ) + } + guard host.virtualTPM20Available else { + return unavailable( + tier: tier, + code: .virtualTPM20Unavailable, + message: "A compatible virtual TPM 2.0 device is required for this Windows guest." + ) + } + if let driverFailure = windowsDriverAvailabilityFailure(host.windowsGuestDrivers, tier: tier) { + return driverFailure + } + } + } + return nil + } + + private static func mutableBootMediaProvenanceFailure( + for request: DoryVirtualMachineCapabilityRequest, + trustedProvenance: DoryTrustedMutableBootMediaProvenance?, + tier: DoryCapabilitySupportTier + ) -> DoryCapabilityAvailability? { + guard request.bootMedia.kind == .virtualDisk else { + guard request.bootMedia.mutableProvenance == nil else { + return unavailable( + tier: tier, + code: .mutableBootMediaProvenanceInvalid, + message: "Mutable provenance is only valid for virtual-disk boot media." + ) + } + return nil + } + guard let requested = request.bootMedia.mutableProvenance else { + return unavailable( + tier: tier, + code: .mutableBootMediaProvenanceUnavailable, + message: "A mutable virtual disk requires a daemon-resolved provenance revision." + ) + } + guard mutableProvenanceIsValid(requested) else { + return unavailable( + tier: tier, + code: .mutableBootMediaProvenanceInvalid, + message: "The requested mutable-disk provenance reference is malformed." + ) + } + guard let trustedProvenance else { + return unavailable( + tier: tier, + code: .trustedMutableBootMediaProvenanceUnavailable, + message: "The daemon has not resolved the requested mutable-disk revision." + ) + } + guard mutableProvenanceEvidenceIsValid(trustedProvenance.auditEvidence) else { + return unavailable( + tier: tier, + code: .mutableBootMediaProvenanceInvalid, + message: "The daemon's mutable-disk provenance receipt is invalid." + ) + } + guard trustedProvenance.auditEvidence.provenance == requested else { + return unavailable( + tier: tier, + code: .mutableBootMediaProvenanceMismatch, + message: "The trusted provenance receipt describes a different mutable-disk revision." + ) + } + return nil + } + + private static func runtimeQualificationDecision( + for request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + trustedQualification: DoryTrustedVirtualMachineRuntimeQualification? + ) -> DoryCapabilityAvailability? { + guard let trustedQualification else { + return DoryCapabilityAvailability( + supportTier: .experimental, + state: .available, + reason: DoryCapabilityReason( + code: .runtimeQualificationUnavailable, + message: "The media is structurally bootable, but this exact backend, " + + "device ABI, and runtime combination is not qualified." + ) + ) + } + guard trustedQualification.runtimeQualified else { + return unavailable( + tier: .experimental, + code: .runtimeQualificationUnavailable, + message: "This exact media, backend, device ABI, and runtime combination failed qualification." + ) + } + let evidence = trustedQualification.auditEvidence + guard runtimeQualificationEvidenceIsValid(evidence) else { + return unavailable( + tier: .supported, + code: .runtimeQualificationEvidenceInvalid, + message: "The runtime qualification has incomplete or invalid audit evidence." + ) + } + guard evidence.guest == request.guest, + evidence.bootMediaKind == request.bootMedia.kind, + evidence.backend == request.backend, + evidence.graphics == request.graphics, + evidence.devices == request.devices, + evidence.virtualHardwareABIVersion == request.virtualHardwareABIVersion else { + return unavailable( + tier: .supported, + code: .runtimeQualificationRequestMismatch, + message: "The runtime qualification does not match the requested guest, backend, " + + "graphics, devices, or virtual-hardware ABI." + ) + } + guard let hostContext = host.runtimeQualificationContext, + hostContext.virtualHardwareABIVersion == request.virtualHardwareABIVersion, + evidence.backendRuntimeBuildID == hostContext.runtimeBuildID(for: request.backend) else { + return unavailable( + tier: .supported, + code: .runtimeQualificationHostMismatch, + message: "The runtime qualification does not match the installed backend build or device ABI." + ) + } + + if request.bootMedia.kind == .virtualDisk { + guard evidence.immutableArtifactSHA256 == nil, + evidence.mutableProvenance == request.bootMedia.mutableProvenance else { + return unavailable( + tier: .supported, + code: .runtimeQualificationRequestMismatch, + message: "The runtime qualification describes a different mutable-disk revision." + ) + } + } else { + guard let artifactSHA256 = request.bootMedia.artifactSHA256, + evidence.mutableProvenance == nil, + evidence.immutableArtifactSHA256?.lowercased() + == artifactSHA256.lowercased() else { + return unavailable( + tier: .supported, + code: .runtimeQualificationRequestMismatch, + message: "The runtime qualification describes a different immutable media artifact." + ) + } + } + return nil + } + + private static func bootMediaInspectionFailure( + for request: DoryVirtualMachineCapabilityRequest, + trustedInspection: DoryTrustedBootMediaInspection?, + tier: DoryCapabilitySupportTier + ) -> DoryCapabilityAvailability? { + guard request.bootMedia.kind == .installerISO + || request.bootMedia.kind == .macOSRestoreImage else { + return nil + } + guard let selectedArtifactSHA256 = request.bootMedia.artifactSHA256 else { + return unavailable( + tier: tier, + code: .bootMediaArtifactDigestUnavailable, + message: "Installer and restore media must have a daemon-resolved SHA-256 identity." + ) + } + guard let inspection = trustedInspection else { + return unavailable( + tier: tier, + code: .trustedBootMediaInspectionUnavailable, + message: "The daemon has not inspected this exact boot-media artifact." + ) + } + guard bootMediaInspectionEvidenceIsValid(inspection.auditEvidence) else { + return unavailable( + tier: tier, + code: .bootMediaInspectionEvidenceInvalid, + message: "The boot-media inspection has an incomplete or invalid audit receipt." + ) + } + guard inspection.auditEvidence.artifactSHA256.lowercased() + == selectedArtifactSHA256.lowercased() else { + return unavailable( + tier: tier, + code: .bootMediaArtifactInspectionMismatch, + message: "The trusted inspection does not describe the selected boot-media artifact." + ) + } + guard inspection.detectedKind == request.bootMedia.kind else { + return unavailable( + tier: tier, + code: .bootMediaKindInspectionMismatch, + message: "The inspected media format does not match the requested boot-media format." + ) + } + guard inspection.detectedGuestFamily == request.guest.family else { + return unavailable( + tier: tier, + code: .bootMediaGuestInspectionMismatch, + message: "The inspected media does not contain the requested guest operating-system family." + ) + } + guard inspection.detectedArchitecture == request.guest.architecture else { + return unavailable( + tier: tier, + code: .bootMediaArchitectureInspectionMismatch, + message: "The inspected media architecture does not match the requested guest architecture." + ) + } + guard request.bootMedia.kind != .installerISO || inspection.isEFIBootable else { + return unavailable( + tier: tier, + code: .bootMediaNotEFIBootable, + message: "The inspected media has no compatible ARM64 EFI boot path." + ) + } + + if request.bootMedia.kind == .macOSRestoreImage { + guard let build = inspection.macOSBuildIdentifier, !build.isEmpty else { + return unavailable( + tier: tier, + code: .macOSRestoreBuildIdentityUnavailable, + message: "The inspected Apple restore image has no resolved build identity." + ) + } + guard inspection.macOSHardwareModelCompatible else { + return unavailable( + tier: tier, + code: .macOSRestoreHardwareModelIncompatible, + message: "The exact Apple restore image is incompatible with the resolved virtual hardware model." + ) + } + guard inspection.macOSAuxiliaryStorageCompatible else { + return unavailable( + tier: tier, + code: .macOSRestoreAuxiliaryStorageIncompatible, + message: "The exact Apple restore image is incompatible with the resolved auxiliary storage." + ) + } + } + return nil + } + + private static func deviceCapabilityFailure( + for request: DoryVirtualMachineCapabilityRequest, + tier: DoryCapabilitySupportTier + ) -> DoryCapabilityAvailability? { + let devices = request.devices + switch devices.networkAttachment { + case .sharedNAT: + break + case .disconnected, .bridged, .isolated: + return unavailable( + tier: tier, + code: .networkAttachmentUnsupported, + message: "The selected backend does not implement the requested network attachment mode." + ) + } + + let isGraphical = request.graphics != .none + let isLinuxVZ = request.guest.family == .linux + && request.backend == .appleVirtualizationFramework + if devices.audioInput, !(isLinuxVZ && isGraphical) { + return unavailable( + tier: tier, + code: .audioInputUnsupported, + message: "The selected guest/backend contract does not implement host audio input." + ) + } + if devices.audioOutput, !(isLinuxVZ && isGraphical) { + return unavailable( + tier: tier, + code: .audioOutputUnsupported, + message: "The selected guest/backend contract does not implement host audio output." + ) + } + + let hasImplementedInput = isGraphical && ( + (request.guest.family == .linux + && (request.backend == .doryHypervisor + || request.backend == .appleVirtualizationFramework)) + || (request.guest.family == .macOS + && request.backend == .appleVirtualizationFramework) + || (request.guest.family == .windows + && request.backend == .qemuHypervisorFramework) + ) + if devices.keyboard, !hasImplementedInput { + return unavailable( + tier: tier, + code: .keyboardInputUnsupported, + message: "The selected guest/backend contract does not implement keyboard input." + ) + } + if devices.pointer, !hasImplementedInput { + return unavailable( + tier: tier, + code: .pointerInputUnsupported, + message: "The selected guest/backend contract does not implement pointer input." + ) + } + + if devices.directorySharing { + return unavailable( + tier: tier, + code: .directorySharingUnsupported, + message: "Directory sharing requires a digest-bound guest-integration " + + "qualification that is not yet modeled." + ) + } + if devices.clipboard { + return unavailable( + tier: tier, + code: .clipboardIntegrationUnsupported, + message: "Clipboard integration requires a digest-bound guest-integration " + + "qualification that is not yet modeled." + ) + } + if devices.clockSynchronization { + return unavailable( + tier: tier, + code: .clockSynchronizationUnsupported, + message: "Clock synchronization is not yet part of a qualified backend contract." + ) + } + if devices.dynamicDisplay { + return unavailable( + tier: tier, + code: .dynamicDisplayUnsupported, + message: "Dynamic display resizing is not yet part of a qualified backend contract." + ) + } + if devices.gracefulShutdown { + return unavailable( + tier: tier, + code: .gracefulShutdownUnsupported, + message: "Graceful shutdown requires a digest-bound guest-integration " + + "qualification that is not yet modeled." + ) + } + return nil + } + + private static func guestImageGraphicsQualificationFailure( + for request: DoryVirtualMachineCapabilityRequest, + trustedQualification: DoryTrustedGuestImageGraphicsQualification? + ) -> DoryCapabilityAvailability? { + guard request.guest.family == .linux, + request.backend == .doryHypervisor, + request.graphics != .none else { + return nil + } + guard let selectedArtifactSHA256 = request.bootMedia.artifactSHA256 else { + return unavailable( + tier: .supported, + code: .guestImageArtifactDigestUnavailable, + message: "The selected Linux guest artifact has no content-addressed SHA-256 identity." + ) + } + guard let qualification = trustedQualification else { + return unavailable( + tier: .supported, + code: .trustedGuestImageGraphicsQualificationUnavailable, + message: "The daemon has no trusted signed graphics qualification for this Linux guest artifact." + ) + } + guard signedEvidenceIsValid(qualification.auditEvidence) else { + return unavailable( + tier: .supported, + code: .guestImageQualificationAuditEvidenceInvalid, + message: "The guest graphics qualification has incomplete signed-manifest audit evidence." + ) + } + guard qualification.auditEvidence.artifactSHA256.lowercased() + == selectedArtifactSHA256.lowercased() else { + return unavailable( + tier: .supported, + code: .guestImageArtifactQualificationMismatch, + message: "The signed graphics qualification does not match the selected guest artifact." + ) + } + guard qualification.virtioGPUKernelAndDeviceSupportQualified else { + return unavailable( + tier: .supported, + code: .linuxVirtioGPUKernelDeviceUnqualified, + message: "The Linux kernel and image are not qualified for Dory's virtio-gpu device contract." + ) + } + guard request.graphics != .hardwareAccelerated3D + || qualification.venusVulkanGuestRuntimeQualified else { + return unavailable( + tier: .supported, + code: .linuxVenusVulkanRuntimeUnqualified, + message: "The Linux image's Venus and Vulkan guest runtime has not passed qualification." + ) + } + return nil + } + + private static func graphicsAuditEvidence( + for request: DoryVirtualMachineCapabilityRequest, + trustedQualification: DoryTrustedGuestImageGraphicsQualification? + ) -> DorySignedArtifactQualificationEvidence? { + guard request.guest.family == .linux, + request.backend == .doryHypervisor, + request.graphics != .none else { + return nil + } + return trustedQualification?.auditEvidence + } + + private static func bootMediaAuditEvidence( + for request: DoryVirtualMachineCapabilityRequest, + trustedInspection: DoryTrustedBootMediaInspection? + ) -> DoryBootMediaInspectionAuditEvidence? { + guard request.bootMedia.kind == .installerISO + || request.bootMedia.kind == .macOSRestoreImage else { + return nil + } + return trustedInspection?.auditEvidence + } + + private static func mutableBootMediaProvenanceAuditEvidence( + for request: DoryVirtualMachineCapabilityRequest, + trustedProvenance: DoryTrustedMutableBootMediaProvenance? + ) -> DoryMutableBootMediaProvenanceAuditEvidence? { + guard request.bootMedia.kind == .virtualDisk else { return nil } + return trustedProvenance?.auditEvidence + } + + private static func signedEvidenceIsValid( + _ evidence: DorySignedArtifactQualificationEvidence + ) -> Bool { + !evidence.manifestIdentity.isEmpty + && isSHA256(evidence.artifactSHA256) + && isSHA256(evidence.manifestSHA256) + && !evidence.signingKeyID.isEmpty + && evidence.manifestFormatVersion > 0 + } + + private static func bootMediaInspectionEvidenceIsValid( + _ evidence: DoryBootMediaInspectionAuditEvidence + ) -> Bool { + guard !evidence.inspectionIdentity.isEmpty, + isSHA256(evidence.artifactSHA256), + isSHA256(evidence.inspectionReportSHA256), + !evidence.inspectorID.isEmpty, + evidence.inspectorVersion > 0 else { + return false + } + guard let manifest = evidence.catalogManifestEvidence else { return true } + return signedEvidenceIsValid(manifest) + && manifest.artifactSHA256.lowercased() == evidence.artifactSHA256.lowercased() + } + + private static func mutableProvenanceIsValid( + _ provenance: DoryMutableBootMediaProvenanceReference + ) -> Bool { + !provenance.repositoryIdentity.isEmpty + && !provenance.mediaIdentity.isEmpty + && provenance.revision > 0 + } + + private static func mutableProvenanceEvidenceIsValid( + _ evidence: DoryMutableBootMediaProvenanceAuditEvidence + ) -> Bool { + !evidence.receiptIdentity.isEmpty + && mutableProvenanceIsValid(evidence.provenance) + && isSHA256(evidence.receiptSHA256) + && !evidence.resolverID.isEmpty + && evidence.resolverVersion > 0 + } + + private static func runtimeQualificationEvidenceIsValid( + _ evidence: DoryVirtualMachineRuntimeQualificationEvidence + ) -> Bool { + let mediaBindingCount = (evidence.immutableArtifactSHA256 == nil ? 0 : 1) + + (evidence.mutableProvenance == nil ? 0 : 1) + let immutableArtifactIsValid = evidence.immutableArtifactSHA256.map(isSHA256) ?? true + let mutableProvenanceIsValid = evidence.mutableProvenance.map(Self.mutableProvenanceIsValid) + ?? true + return !evidence.qualificationIdentity.isEmpty + && isSHA256(evidence.qualificationReportSHA256) + && !evidence.signingKeyID.isEmpty + && evidence.qualificationFormatVersion > 0 + && mediaBindingCount == 1 + && immutableArtifactIsValid + && mutableProvenanceIsValid + && !evidence.backendRuntimeBuildID.isEmpty + && evidence.virtualHardwareABIVersion > 0 + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...70).contains(byte) || (97...102).contains(byte) + } + } + + private static func windowsDriverAvailabilityFailure( + _ drivers: DoryWindowsGuestDriverFacts, + tier: DoryCapabilitySupportTier + ) -> DoryCapabilityAvailability? { + guard drivers.storageAvailable else { + return unavailable( + tier: tier, + code: .windowsStorageDriverUnavailable, + message: "A qualified Windows ARM64 storage driver is required." + ) + } + guard drivers.networkAvailable else { + return unavailable( + tier: tier, + code: .windowsNetworkDriverUnavailable, + message: "A qualified Windows ARM64 network driver is required." + ) + } + guard drivers.displayAvailable else { + return unavailable( + tier: tier, + code: .windowsDisplayDriverUnavailable, + message: "A qualified Windows ARM64 display driver is required." + ) + } + guard drivers.inputAvailable else { + return unavailable( + tier: tier, + code: .windowsInputDriverUnavailable, + message: "Qualified Windows ARM64 input drivers are required." + ) + } + return nil + } + + private static func unsupported( + _ code: DoryCapabilityReasonCode, + _ message: String + ) -> DoryCapabilityAvailability { + DoryCapabilityAvailability( + supportTier: .unsupported, + state: .unavailable, + reason: DoryCapabilityReason(code: code, message: message) + ) + } + + private static func unavailable( + tier: DoryCapabilitySupportTier, + code: DoryCapabilityReasonCode, + message: String + ) -> DoryCapabilityAvailability { + DoryCapabilityAvailability( + supportTier: tier, + state: .unavailable, + reason: DoryCapabilityReason(code: code, message: message) + ) + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift new file mode 100644 index 00000000..0a42efa6 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -0,0 +1,977 @@ +import Foundation + +/// Stable identity persisted independently from a VM's runtime process identity. +public struct DoryVirtualMachineIdentity: Codable, Sendable, Equatable { + public var id: String + public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + +public enum DoryVMBootMediaRole: String, Codable, Sendable, CaseIterable { + case installer + case system + case recovery +} + +/// Structured, non-secret key resolved by an artifact or share registry. +/// Both components are deliberately path/URL hostile and bounded for safe persistence. +public struct DoryVMResolverReference: Codable, Sendable, Equatable, Hashable { + public var namespace: String + public var identifier: String + + public init(namespace: String, identifier: String) { + self.namespace = namespace + self.identifier = identifier + } +} + +public enum DoryVMBootPhase: String, Codable, Sendable, CaseIterable { + case install + case normal + case live + case recovery +} + +/// A non-secret reference to media resolved by an artifact store at launch time. +/// +/// `artifact` is a structured catalog key. Credentials, signed URLs, access tokens, host paths, +/// and other secret material must remain in the resolver and are never persisted here. +public struct DoryVMBootMediaReference: Codable, Sendable, Equatable { + public var id: String + public var role: DoryVMBootMediaRole + public var kind: DoryBootMediaKind + public var source: DoryBootMediaSource + public var artifact: DoryVMResolverReference + public var removable: Bool + + public init( + id: String, + role: DoryVMBootMediaRole, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + artifact: DoryVMResolverReference, + removable: Bool + ) { + self.id = id + self.role = role + self.kind = kind + self.source = source + self.artifact = artifact + self.removable = removable + } + + public var capabilityMedia: DoryBootMedia { + DoryBootMedia(kind: kind, source: source) + } +} + +/// Boot phase, attached boot media, and deterministic firmware/device priority. +public struct DoryVMBootConfiguration: Codable, Sendable, Equatable { + public var phase: DoryVMBootPhase + public var devices: [DoryVMBootMediaReference] + public var order: [String] + + public init( + phase: DoryVMBootPhase, + devices: [DoryVMBootMediaReference], + order: [String] + ) { + self.phase = phase + self.devices = devices + self.order = order + } +} + +public enum DoryVMBackendPreferenceMode: String, Codable, Sendable, CaseIterable { + case automatic + case preferred + case required +} + +/// Backend intent, not a backend selection or availability claim. +/// +/// The daemon must still negotiate this preference through its capability evaluator. +public struct DoryVMBackendPreference: Codable, Sendable, Equatable { + public var mode: DoryVMBackendPreferenceMode + public var backend: DoryVirtualizationBackendIdentity? + + public init( + mode: DoryVMBackendPreferenceMode = .automatic, + backend: DoryVirtualizationBackendIdentity? = nil + ) { + self.mode = mode + self.backend = backend + } +} + +/// Ordered graphics contracts acceptable to the user, from most to least preferred. +/// Runtime capability negotiation must select one exact entry or reject the definition. +public struct DoryVMGraphicsPolicy: Codable, Sendable, Equatable { + public var acceptableLevels: [DoryGraphicsAccelerationLevel] + + public init(acceptableLevels: [DoryGraphicsAccelerationLevel]) { + self.acceptableLevels = acceptableLevels + } +} + +public enum DoryVMStorageRole: String, Codable, Sendable, CaseIterable { + case system + case data +} + +/// A disk artifact attached to the VM. Host paths and storage credentials are resolved outside +/// this persistence contract from its structured artifact reference. +public struct DoryVMStorageAttachment: Codable, Sendable, Equatable { + public var id: String + public var role: DoryVMStorageRole + public var artifact: DoryVMResolverReference + public var capacityBytes: UInt64 + public var readOnly: Bool + + public init( + id: String, + role: DoryVMStorageRole, + artifact: DoryVMResolverReference, + capacityBytes: UInt64, + readOnly: Bool = false + ) { + self.id = id + self.role = role + self.artifact = artifact + self.capacityBytes = capacityBytes + self.readOnly = readOnly + } +} + +public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable { + case sharedNAT = "shared-nat" + case bridged + case isolated +} + +public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { + public var enabled: Bool + public var widthPixels: UInt32 + public var heightPixels: UInt32 + public var pixelsPerInch: UInt16 + + public init( + enabled: Bool = true, + widthPixels: UInt32 = 1_920, + heightPixels: UInt32 = 1_080, + pixelsPerInch: UInt16 = 110 + ) { + self.enabled = enabled + self.widthPixels = widthPixels + self.heightPixels = heightPixels + self.pixelsPerInch = pixelsPerInch + } + + public static let disabled = DoryVMDisplayConfiguration( + enabled: false, + widthPixels: 0, + heightPixels: 0, + pixelsPerInch: 0 + ) +} + +public struct DoryVMAudioConfiguration: Codable, Sendable, Equatable { + public var inputEnabled: Bool + public var outputEnabled: Bool + + public init(inputEnabled: Bool = false, outputEnabled: Bool = true) { + self.inputEnabled = inputEnabled + self.outputEnabled = outputEnabled + } +} + +public struct DoryVMInputConfiguration: Codable, Sendable, Equatable { + public var keyboardEnabled: Bool + public var pointerEnabled: Bool + + public init(keyboardEnabled: Bool = true, pointerEnabled: Bool = true) { + self.keyboardEnabled = keyboardEnabled + self.pointerEnabled = pointerEnabled + } +} + +/// A host location shared into the guest. The host location is resolved from a non-secret opaque +/// identity so bookmarks, paths, and authorization material can be managed separately. +public struct DoryVMShare: Codable, Sendable, Equatable { + public var id: String + public var hostLocation: DoryVMResolverReference + public var guestMountPath: String + public var readOnly: Bool + + public init( + id: String, + hostLocation: DoryVMResolverReference, + guestMountPath: String, + readOnly: Bool = false + ) { + self.id = id + self.hostLocation = hostLocation + self.guestMountPath = guestMountPath + self.readOnly = readOnly + } +} + +public enum DoryVMGuestIntegration: String, Codable, Sendable, CaseIterable { + case clipboard + case clockSynchronization = "clock-synchronization" + case dynamicDisplay = "dynamic-display" + case gracefulShutdown = "graceful-shutdown" +} + +/// Minimal metadata needed for optimistic persistence and ordering. +public struct DoryVMLifecycleMetadata: Codable, Sendable, Equatable { + public var revision: UInt64 + /// Unix epoch milliseconds, encoded as an integer for stable cross-language persistence. + public var createdAtUnixMilliseconds: Int64 + /// Unix epoch milliseconds, encoded as an integer for stable cross-language persistence. + public var updatedAtUnixMilliseconds: Int64 + + public init( + revision: UInt64 = 1, + createdAtUnixMilliseconds: Int64, + updatedAtUnixMilliseconds: Int64 + ) { + self.revision = revision + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds + } + + /// Source-compatible construction bridge. Persistence always uses integer epoch milliseconds. + public init(revision: UInt64 = 1, createdAt: Date, updatedAt: Date) { + self.init( + revision: revision, + createdAtUnixMilliseconds: Self.unixMilliseconds(createdAt), + updatedAtUnixMilliseconds: Self.unixMilliseconds(updatedAt) + ) + } + + private static func unixMilliseconds(_ date: Date) -> Int64 { + let value = date.timeIntervalSince1970 * 1_000 + guard value.isFinite else { return 0 } + if value >= Double(Int64.max) { return .max } + if value <= Double(Int64.min) { return .min } + return Int64(value.rounded(.towardZero)) + } +} + +public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseIterable { + case unsupportedSchemaVersion = "unsupported-schema-version" + case unsupportedVirtualHardwareABIVersion = "unsupported-virtual-hardware-abi-version" + case emptyIdentifier = "empty-identifier" + case invalidIdentifier = "invalid-identifier" + case emptyName = "empty-name" + case invalidResolverReference = "invalid-resolver-reference" + case duplicateBootDeviceIdentifier = "duplicate-boot-device-identifier" + case invalidBootOrder = "invalid-boot-order" + case invalidBootPhase = "invalid-boot-phase" + case invalidBootAttachment = "invalid-boot-attachment" + case bootMediaRoleMismatch = "boot-media-role-mismatch" + case multipleSystemBootDevices = "multiple-system-boot-devices" + case bootMediaIncompatibleWithGuest = "boot-media-incompatible-with-guest" + case guestMediaCannotBeBundled = "guest-media-cannot-be-bundled" + case backendPreferenceMalformed = "backend-preference-malformed" + case emptyGraphicsPolicy = "empty-graphics-policy" + case duplicateGraphicsLevel = "duplicate-graphics-level" + case nonPositiveResource = "non-positive-resource" + case emptyAttachmentIdentifier = "empty-attachment-identifier" + case duplicateAttachmentIdentifier = "duplicate-attachment-identifier" + case duplicateWritableStorageArtifactIdentity = "duplicate-writable-storage-artifact-identity" + case nonPositiveAttachmentCapacity = "non-positive-attachment-capacity" + case missingSystemDisk = "missing-system-disk" + case multipleSystemDisks = "multiple-system-disks" + case readOnlySystemDisk = "read-only-system-disk" + case systemDiskCapacityMismatch = "system-disk-capacity-mismatch" + case bootDiskArtifactMismatch = "boot-disk-artifact-mismatch" + case invalidDisplayConfiguration = "invalid-display-configuration" + case emptyShareIdentifier = "empty-share-identifier" + case duplicateShareIdentifier = "duplicate-share-identifier" + case duplicateGuestMountPath = "duplicate-guest-mount-path" + case unsafeGuestMountPath = "unsafe-guest-mount-path" + case duplicateIntegration = "duplicate-integration" + case integrationRequiresDisplay = "integration-requires-display" + case legacyInstallerWorkload = "legacy-installer-workload" + case invalidLifecycleMetadata = "invalid-lifecycle-metadata" +} + +/// Machine-readable validation output. `field` uses stable dotted/indexed paths so clients can +/// associate an issue with a form control without parsing localized prose. +public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable { + public var code: DoryVMDefinitionValidationCode + public var field: String + + public init(code: DoryVMDefinitionValidationCode, field: String) { + self.code = code + self.field = field + } +} + +/// Versioned, persistence-safe VM intent shared by the app, daemon, and external API. +/// +/// This definition deliberately contains no runtime capability result, selected backend, +/// passwords, tokens, host filesystem paths, or volatile process state. +public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { + public static let oldestSupportedSchemaVersion: UInt16 = 1 + public static let currentSchemaVersion: UInt16 = 2 + public static let currentVirtualHardwareABIVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var virtualHardwareABIVersion: UInt16 + public var identity: DoryVirtualMachineIdentity + public var guest: DoryGuestPlatform + public var workload: DoryVMWorkloadProfile + public var boot: DoryVMBootConfiguration + public var backendPreference: DoryVMBackendPreference + public var graphics: DoryVMGraphicsPolicy + public var resources: DoryVMResourceRequest + public var storage: [DoryVMStorageAttachment] + public var networkMode: DoryVMNetworkMode + public var display: DoryVMDisplayConfiguration + public var audio: DoryVMAudioConfiguration + public var input: DoryVMInputConfiguration + public var shares: [DoryVMShare] + public var integrations: [DoryVMGuestIntegration] + public var lifecycle: DoryVMLifecycleMetadata + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + virtualHardwareABIVersion: UInt16 = Self.currentVirtualHardwareABIVersion, + identity: DoryVirtualMachineIdentity, + guest: DoryGuestPlatform, + workload: DoryVMWorkloadProfile, + boot: DoryVMBootConfiguration, + backendPreference: DoryVMBackendPreference = DoryVMBackendPreference(), + graphics: DoryVMGraphicsPolicy, + resources: DoryVMResourceRequest, + storage: [DoryVMStorageAttachment], + networkMode: DoryVMNetworkMode = .sharedNAT, + display: DoryVMDisplayConfiguration = DoryVMDisplayConfiguration(), + audio: DoryVMAudioConfiguration = DoryVMAudioConfiguration(), + input: DoryVMInputConfiguration = DoryVMInputConfiguration(), + shares: [DoryVMShare] = [], + integrations: [DoryVMGuestIntegration] = [], + lifecycle: DoryVMLifecycleMetadata + ) { + self.schemaVersion = schemaVersion + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.identity = identity + self.guest = guest + self.workload = workload + self.boot = boot + self.backendPreference = backendPreference + self.graphics = graphics + self.resources = resources + self.storage = storage + self.networkMode = networkMode + self.display = display + self.audio = audio + self.input = input + self.shares = shares + self.integrations = integrations + self.lifecycle = lifecycle + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case virtualHardwareABIVersion + case identity + case guest + case workload + case boot + case bootMedia + case backendPreference + case graphics + case resources + case storage + case networkMode + case display + case audio + case input + case shares + case integrations + case lifecycle + } + + private struct LegacyBootMedia: Codable { + var role: DoryVMBootMediaRole + var kind: DoryBootMediaKind + var source: DoryBootMediaSource + var artifactID: String + } + + private struct LegacyGraphics: Codable { + var desiredLevel: DoryGraphicsAccelerationLevel + var allowsFallback: Bool + } + + private struct LegacyStorage: Codable { + var id: String + var role: DoryVMStorageRole + var artifactID: String + var capacityBytes: UInt64 + var readOnly: Bool + } + + private struct LegacyShare: Codable { + var id: String + var hostLocationID: String + var guestMountPath: String + var readOnly: Bool + } + + private struct LegacyLifecycle: Codable { + var revision: UInt64 + var createdAt: Date + var updatedAt: Date + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion) + if persistedSchema == Self.oldestSupportedSchemaVersion { + let legacyBoot = try container.decode(LegacyBootMedia.self, forKey: .bootMedia) + let legacyGraphics = try container.decode(LegacyGraphics.self, forKey: .graphics) + let legacyStorage = try container.decode([LegacyStorage].self, forKey: .storage) + let legacyShares = try container.decode([LegacyShare].self, forKey: .shares) + let legacyLifecycle = try container.decode(LegacyLifecycle.self, forKey: .lifecycle) + let legacyWorkload = try container.decode(DoryVMWorkloadProfile.self, forKey: .workload) + + schemaVersion = Self.currentSchemaVersion + virtualHardwareABIVersion = try container.decodeIfPresent( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) ?? Self.currentVirtualHardwareABIVersion + identity = try container.decode(DoryVirtualMachineIdentity.self, forKey: .identity) + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + workload = legacyWorkload == .installer ? .desktop : legacyWorkload + let deviceID = legacyBoot.role == .system ? "system" : "installer" + boot = DoryVMBootConfiguration( + phase: legacyBoot.role == .system ? .normal : .install, + devices: [DoryVMBootMediaReference( + id: deviceID, + role: legacyBoot.role, + kind: legacyBoot.kind, + source: legacyBoot.source, + artifact: Self.migrateLegacyReference( + legacyBoot.artifactID, + defaultNamespace: "artifact" + ), + removable: legacyBoot.role != .system + )], + order: [deviceID] + ) + backendPreference = try container.decode( + DoryVMBackendPreference.self, + forKey: .backendPreference + ) + var levels = [legacyGraphics.desiredLevel] + if legacyGraphics.allowsFallback, + legacyGraphics.desiredLevel != .software, + legacyGraphics.desiredLevel != .none { + levels.append(.software) + } + graphics = DoryVMGraphicsPolicy(acceptableLevels: levels) + resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources) + storage = legacyStorage.map { attachment in + DoryVMStorageAttachment( + id: attachment.id, + role: attachment.role, + artifact: Self.migrateLegacyReference( + attachment.artifactID, + defaultNamespace: "artifact" + ), + capacityBytes: attachment.capacityBytes, + readOnly: attachment.readOnly + ) + } + networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode) + display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) + audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) + input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) + shares = legacyShares.map { share in + DoryVMShare( + id: share.id, + hostLocation: Self.migrateLegacyReference( + share.hostLocationID, + defaultNamespace: "host-location" + ), + guestMountPath: share.guestMountPath, + readOnly: share.readOnly + ) + } + integrations = try container.decode( + [DoryVMGuestIntegration].self, + forKey: .integrations + ) + lifecycle = DoryVMLifecycleMetadata( + revision: legacyLifecycle.revision, + createdAt: legacyLifecycle.createdAt, + updatedAt: legacyLifecycle.updatedAt + ) + return + } + + schemaVersion = persistedSchema + virtualHardwareABIVersion = try container.decode( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) + identity = try container.decode(DoryVirtualMachineIdentity.self, forKey: .identity) + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + workload = try container.decode(DoryVMWorkloadProfile.self, forKey: .workload) + boot = try container.decode(DoryVMBootConfiguration.self, forKey: .boot) + backendPreference = try container.decode(DoryVMBackendPreference.self, forKey: .backendPreference) + graphics = try container.decode(DoryVMGraphicsPolicy.self, forKey: .graphics) + resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources) + storage = try container.decode([DoryVMStorageAttachment].self, forKey: .storage) + networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode) + display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) + audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) + input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) + shares = try container.decode([DoryVMShare].self, forKey: .shares) + integrations = try container.decode([DoryVMGuestIntegration].self, forKey: .integrations) + lifecycle = try container.decode(DoryVMLifecycleMetadata.self, forKey: .lifecycle) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion) + try container.encode(identity, forKey: .identity) + try container.encode(guest, forKey: .guest) + try container.encode(workload, forKey: .workload) + try container.encode(boot, forKey: .boot) + try container.encode(backendPreference, forKey: .backendPreference) + try container.encode(graphics, forKey: .graphics) + try container.encode(resources, forKey: .resources) + try container.encode(storage, forKey: .storage) + try container.encode(networkMode, forKey: .networkMode) + try container.encode(display, forKey: .display) + try container.encode(audio, forKey: .audio) + try container.encode(input, forKey: .input) + try container.encode(shares, forKey: .shares) + try container.encode(integrations, forKey: .integrations) + try container.encode(lifecycle, forKey: .lifecycle) + } + + private static func migrateLegacyReference( + _ value: String, + defaultNamespace: String + ) -> DoryVMResolverReference { + let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2 { + let candidate = DoryVMResolverReference( + namespace: String(parts[0]).lowercased(), + identifier: String(parts[1]) + ) + if isSafeResolverReference(candidate) { + return candidate + } + } + return DoryVMResolverReference(namespace: defaultNamespace, identifier: value) + } + + /// Returns deterministic issues in field order. Backend availability is intentionally absent; + /// pass the resulting intent through capability negotiation before launch. + public func validate() -> [DoryVMDefinitionValidationIssue] { + var issues: [DoryVMDefinitionValidationIssue] = [] + + if schemaVersion != Self.currentSchemaVersion { + issues.append(issue(.unsupportedSchemaVersion, "schemaVersion")) + } + if virtualHardwareABIVersion != Self.currentVirtualHardwareABIVersion { + issues.append(issue( + .unsupportedVirtualHardwareABIVersion, + "virtualHardwareABIVersion" + )) + } + if !Self.hasContent(identity.id) { + issues.append(issue(.emptyIdentifier, "identity.id")) + } else if !Self.isSafeMachineIdentifier(identity.id) { + issues.append(issue(.invalidIdentifier, "identity.id")) + } + if !Self.hasContent(identity.name) { + issues.append(issue(.emptyName, "identity.name")) + } + + validateBootMedia(into: &issues) + validateBackendPreference(into: &issues) + validateGraphics(into: &issues) + validateResources(into: &issues) + validateStorage(into: &issues) + validateDisplay(into: &issues) + validateShares(into: &issues) + validateIntegrations(into: &issues) + validateLifecycle(into: &issues) + return issues + } + + public var isValid: Bool { + validate().isEmpty + } + + private func validateBootMedia(into issues: inout [DoryVMDefinitionValidationIssue]) { + if workload == .installer { + issues.append(issue(.legacyInstallerWorkload, "workload")) + } + + var deviceIDs: Set = [] + for (index, device) in boot.devices.enumerated() { + let field = "boot.devices[\(index)]" + if !Self.isSafeMachineIdentifier(device.id) { + issues.append(issue(.invalidIdentifier, "\(field).id")) + } else if !deviceIDs.insert(device.id).inserted { + issues.append(issue(.duplicateBootDeviceIdentifier, "\(field).id")) + } + if !Self.isSafeResolverReference(device.artifact) { + issues.append(issue(.invalidResolverReference, "\(field).artifact")) + } + + let roleMatchesKind: Bool + switch device.kind { + case .virtualDisk, .installedLinuxBootBundle: + roleMatchesKind = device.role == .system + case .installerISO, .macOSRestoreImage: + roleMatchesKind = device.role == .installer || device.role == .recovery + } + if !roleMatchesKind { + issues.append(issue(.bootMediaRoleMismatch, "\(field).role")) + } + + let shouldBeRemovable = device.role != .system + if device.removable != shouldBeRemovable { + issues.append(issue(.invalidBootAttachment, "\(field).removable")) + } + if !Self.bootMediaIsCompatible(device.kind, with: guest.family) { + issues.append(issue(.bootMediaIncompatibleWithGuest, "\(field).kind")) + } + if (guest.family == .windows || guest.family == .macOS), + device.source == .bundledByDory { + issues.append(issue(.guestMediaCannotBeBundled, "\(field).source")) + } + } + + if boot.devices.filter({ $0.role == .system }).count > 1 { + issues.append(issue(.multipleSystemBootDevices, "boot.devices")) + } + + let orderIsPermutation = !boot.order.isEmpty + && boot.order.count == boot.devices.count + && Set(boot.order).count == boot.order.count + && Set(boot.order) == Set(boot.devices.map(\.id)) + if !orderIsPermutation { + issues.append(issue(.invalidBootOrder, "boot.order")) + } + + let primary = orderedBootDevices().first + let primaryIsValid: Bool + switch boot.phase { + case .normal: + primaryIsValid = primary?.role == .system + case .install, .live: + primaryIsValid = primary?.role == .installer + case .recovery: + primaryIsValid = primary?.role == .recovery + } + if !primaryIsValid { + issues.append(issue(.invalidBootPhase, "boot.phase")) + } + } + + private func validateBackendPreference(into issues: inout [DoryVMDefinitionValidationIssue]) { + let isWellFormed = switch backendPreference.mode { + case .automatic: + backendPreference.backend == nil + case .preferred, .required: + backendPreference.backend != nil + } + if !isWellFormed { + issues.append(issue(.backendPreferenceMalformed, "backendPreference")) + } + } + + private func validateGraphics(into issues: inout [DoryVMDefinitionValidationIssue]) { + if graphics.acceptableLevels.isEmpty { + issues.append(issue(.emptyGraphicsPolicy, "graphics.acceptableLevels")) + return + } + var seen: Set = [] + for (index, level) in graphics.acceptableLevels.enumerated() + where !seen.insert(level.rawValue).inserted { + issues.append(issue(.duplicateGraphicsLevel, "graphics.acceptableLevels[\(index)]")) + } + } + + private func validateResources(into issues: inout [DoryVMDefinitionValidationIssue]) { + if resources.virtualCPUCount == 0 { + issues.append(issue(.nonPositiveResource, "resources.virtualCPUCount")) + } + if resources.memoryBytes == 0 { + issues.append(issue(.nonPositiveResource, "resources.memoryBytes")) + } + if resources.diskBytes == 0 { + issues.append(issue(.nonPositiveResource, "resources.diskBytes")) + } + } + + private func validateStorage(into issues: inout [DoryVMDefinitionValidationIssue]) { + var attachmentIDs: Set = [] + var artifactAccess: [DoryVMResolverReference: Bool] = [:] + for (index, attachment) in storage.enumerated() { + let field = "storage[\(index)]" + if !Self.hasContent(attachment.id) { + issues.append(issue(.emptyAttachmentIdentifier, "\(field).id")) + } else if !attachmentIDs.insert(attachment.id).inserted { + issues.append(issue(.duplicateAttachmentIdentifier, "\(field).id")) + } + if !Self.isSafeResolverReference(attachment.artifact) { + issues.append(issue(.invalidResolverReference, "\(field).artifact")) + } else if let allPreviousReadOnly = artifactAccess[attachment.artifact] { + if !allPreviousReadOnly || !attachment.readOnly { + issues.append(issue( + .duplicateWritableStorageArtifactIdentity, + "\(field).artifact" + )) + } + artifactAccess[attachment.artifact] = allPreviousReadOnly && attachment.readOnly + } else { + artifactAccess[attachment.artifact] = attachment.readOnly + } + if attachment.capacityBytes == 0 { + issues.append(issue(.nonPositiveAttachmentCapacity, "\(field).capacityBytes")) + } + } + + let systemDisks = storage.enumerated().filter { $0.element.role == .system } + if systemDisks.isEmpty, boot.phase != .live { + issues.append(issue(.missingSystemDisk, "storage")) + } else if systemDisks.count > 1 { + issues.append(issue(.multipleSystemDisks, "storage")) + } else if let (index, systemDisk) = systemDisks.first { + if systemDisk.readOnly { + issues.append(issue(.readOnlySystemDisk, "storage[\(index)].readOnly")) + } + if systemDisk.capacityBytes != resources.diskBytes { + issues.append(issue(.systemDiskCapacityMismatch, "storage[\(index)].capacityBytes")) + } + if let bootDisk = orderedBootDevices().first(where: { + $0.role == .system && $0.kind == .virtualDisk + }), bootDisk.artifact != systemDisk.artifact { + issues.append(issue(.bootDiskArtifactMismatch, "boot.devices")) + } + } + } + + private func validateDisplay(into issues: inout [DoryVMDefinitionValidationIssue]) { + let dimensionsArePositive = display.widthPixels > 0 + && display.heightPixels > 0 + && display.pixelsPerInch > 0 + let dimensionsAreZero = display.widthPixels == 0 + && display.heightPixels == 0 + && display.pixelsPerInch == 0 + if (display.enabled && !dimensionsArePositive) || (!display.enabled && !dimensionsAreZero) { + issues.append(issue(.invalidDisplayConfiguration, "display")) + } + } + + private func validateShares(into issues: inout [DoryVMDefinitionValidationIssue]) { + var shareIDs: Set = [] + var guestMountPaths: Set = [] + for (index, share) in shares.enumerated() { + let field = "shares[\(index)]" + if !Self.hasContent(share.id) { + issues.append(issue(.emptyShareIdentifier, "\(field).id")) + } else if !shareIDs.insert(share.id).inserted { + issues.append(issue(.duplicateShareIdentifier, "\(field).id")) + } + if !Self.isSafeResolverReference(share.hostLocation) { + issues.append(issue(.invalidResolverReference, "\(field).hostLocation")) + } + if let canonicalPath = Self.canonicalGuestMountPath( + share.guestMountPath, + family: guest.family + ) { + if !guestMountPaths.insert(canonicalPath).inserted { + issues.append(issue(.duplicateGuestMountPath, "\(field).guestMountPath")) + } + } else { + issues.append(issue(.unsafeGuestMountPath, "\(field).guestMountPath")) + } + } + } + + private func validateIntegrations(into issues: inout [DoryVMDefinitionValidationIssue]) { + var seen: Set = [] + for (index, integration) in integrations.enumerated() + where !seen.insert(integration.rawValue).inserted { + issues.append(issue(.duplicateIntegration, "integrations[\(index)]")) + } + if !display.enabled, integrations.contains(.dynamicDisplay) { + issues.append(issue(.integrationRequiresDisplay, "integrations")) + } + } + + private func validateLifecycle(into issues: inout [DoryVMDefinitionValidationIssue]) { + if lifecycle.revision == 0 + || lifecycle.createdAtUnixMilliseconds <= 0 + || lifecycle.updatedAtUnixMilliseconds < lifecycle.createdAtUnixMilliseconds { + issues.append(issue(.invalidLifecycleMetadata, "lifecycle")) + } + } + + private func issue( + _ code: DoryVMDefinitionValidationCode, + _ field: String + ) -> DoryVMDefinitionValidationIssue { + DoryVMDefinitionValidationIssue(code: code, field: field) + } + + /// Resolve boot devices through the persisted order so validation never depends on array layout. + private func orderedBootDevices() -> [DoryVMBootMediaReference] { + boot.order.compactMap { deviceID in + boot.devices.first { $0.id == deviceID } + } + } + + private static func hasContent(_ value: String) -> Bool { + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func isSafeResolverReference(_ reference: DoryVMResolverReference) -> Bool { + let namespace = Array(reference.namespace.utf8) + let identifier = Array(reference.identifier.utf8) + guard (1...32).contains(namespace.count), + namespace[0] >= 97, + namespace[0] <= 122, + namespace.dropFirst().allSatisfy({ byte in + (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 45 + }), + (1...64).contains(identifier.count), + isASCIIAlphaNumeric(identifier[0]), + identifier.dropFirst().allSatisfy({ byte in + isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45 + }) else { + return false + } + let lowercase = reference.identifier.lowercased() + let secretPrefixes = [ + "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-", + "bearer-", "password-", "secret-", "token-", "akia", + ] + return !secretPrefixes.contains(where: lowercase.hasPrefix) + && !lowercase.hasPrefix("eyj") + } + + private static func bootMediaIsCompatible( + _ kind: DoryBootMediaKind, + with family: DoryGuestFamily + ) -> Bool { + switch family { + case .linux: + kind == .installerISO || kind == .virtualDisk || kind == .installedLinuxBootBundle + case .windows: + kind == .installerISO || kind == .virtualDisk + case .macOS: + kind == .macOSRestoreImage || kind == .virtualDisk + } + } + + private static func isSafeMachineIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...63).contains(bytes.count), isASCIIAlphaNumeric(bytes[0]) else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45 + } + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + + private static func canonicalGuestMountPath( + _ path: String, + family: DoryGuestFamily + ) -> String? { + guard !path.isEmpty, !path.unicodeScalars.contains(where: { $0.value == 0 }) else { + return nil + } + switch family { + case .linux, .macOS: + guard path.hasPrefix("/"), !path.hasPrefix("//"), path != "/", !path.contains("\\") else { + return nil + } + let components = path.split(separator: "/", omittingEmptySubsequences: false).dropFirst() + guard !components.isEmpty, components.allSatisfy({ component in + !component.isEmpty && component != "." && component != ".." + }) else { + return nil + } + return path + case .windows: + let bytes = Array(path.utf8) + guard bytes.count >= 4, + (bytes[0] >= 65 && bytes[0] <= 90) || (bytes[0] >= 97 && bytes[0] <= 122), + bytes[1] == 58, + bytes[2] == 92, + !path.contains("/") else { + return nil + } + let components = path.dropFirst(3).split( + separator: "\\", + omittingEmptySubsequences: false + ) + let forbidden = "<>:|?*\"" + guard !components.isEmpty, components.allSatisfy({ component in + isSafeWindowsPathComponent(component, forbidden: forbidden) + }) else { + return nil + } + return path.lowercased() + } + } + + private static func isSafeWindowsPathComponent( + _ component: Substring, + forbidden: String + ) -> Bool { + guard !component.isEmpty, + component != ".", + component != "..", + component.last != ".", + component.last != " ", + !component.contains(where: forbidden.contains), + !component.unicodeScalars.contains(where: { scalar in + scalar.value < 32 || scalar.value == 127 + }) else { + return false + } + let rawBaseName = component.split(separator: ".", maxSplits: 1).first ?? "" + let baseName = String(rawBaseName) + .trimmingCharacters(in: CharacterSet(charactersIn: " .")) + .uppercased() + if ["CON", "PRN", "AUX", "NUL"].contains(baseName) { + return false + } + if baseName.count == 4, + (baseName.hasPrefix("COM") || baseName.hasPrefix("LPT")), + let suffix = baseName.last, + suffix >= "1" && suffix <= "9" { + return false + } + return true + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift new file mode 100644 index 00000000..cfcaf759 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift @@ -0,0 +1,394 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("VM resource policy") +struct DoryVMResourcePolicyTests { + private let gibibyte: UInt64 = 1_073_741_824 + + @Test("desktop recommendation preserves host reserves") + func desktopRecommendation() { + let host = host(cpu: 12, memoryGiB: 32, storageGiB: 500) + let request = request(cpu: 4, memoryGiB: 8, diskGiB: 64) + let result = DoryVMResourcePolicy.assess( + host: host, + workload: .desktop, + request: request + ) + + #expect(result.isValid) + #expect(result.host == host) + #expect(result.request == request) + #expect(result.issues.isEmpty) + #expect(result.recommendation.virtualCPUCount == guidance(2, 4, 9)) + #expect(result.recommendation.memoryBytes == guidanceGiB(4, 8, 24)) + #expect(result.recommendation.diskBytes == guidanceGiB(32, 64, 450)) + #expect(result.recommendation.hostReserve == DoryVMHostReserve( + logicalCPUCount: 3, + memoryBytes: 8 * gibibyte, + storageBytes: 50 * gibibyte + )) + #expect(result.recommendation.isFeasible) + } + + @Test("recommendation is lowered to fit a constrained but viable host") + func constrainedRecommendation() { + let recommendation = DoryVMResourcePolicy.recommend( + host: host(cpu: 4, memoryGiB: 8, storageGiB: 50), + workload: .desktop + ) + + #expect(recommendation.virtualCPUCount == guidance(2, 3, 3)) + #expect(recommendation.memoryBytes == guidanceGiB(4, 6, 6)) + #expect(recommendation.diskBytes == guidanceGiB(32, 40, 40)) + #expect(recommendation.isFeasible) + } + + @Test("running and starting VM admissions reduce the next maximum") + func existingAdmissions() { + let noActiveVMs = DoryVMResourcePolicy.recommend( + host: host(cpu: 12, memoryGiB: 32, storageGiB: 500), + workload: .desktop + ) + let admittedHost = DoryVMHostResources( + logicalCPUCount: 12, + physicalMemoryBytes: 32 * gibibyte, + freeStorageBytes: 500 * gibibyte, + admittedVirtualCPUCount: 4, + admittedMemoryBytes: 8 * gibibyte, + reservedStorageBytes: 100 * gibibyte + ) + let result = DoryVMResourcePolicy.assess( + host: admittedHost, + workload: .desktop, + request: request(cpu: 4, memoryGiB: 8, diskGiB: 64) + ) + + #expect(noActiveVMs.virtualCPUCount.maximum == 9) + #expect(result.isValid) + #expect(result.host == admittedHost) + #expect(result.recommendation.virtualCPUCount.maximum == 5) + #expect(result.recommendation.memoryBytes.maximum == 16 * gibibyte) + #expect(result.recommendation.diskBytes.maximum == 350 * gibibyte) + } + + @Test("stopped VM compute is not admitted while its disk reservation remains") + func stoppedVMCapacitySemantics() { + let host = DoryVMHostResources( + logicalCPUCount: 12, + physicalMemoryBytes: 32 * gibibyte, + freeStorageBytes: 500 * gibibyte, + admittedVirtualCPUCount: 0, + admittedMemoryBytes: 0, + reservedStorageBytes: 100 * gibibyte + ) + let recommendation = DoryVMResourcePolicy.recommend(host: host, workload: .desktop) + + #expect(recommendation.virtualCPUCount.maximum == 9) + #expect(recommendation.memoryBytes.maximum == 24 * gibibyte) + #expect(recommendation.diskBytes.maximum == 350 * gibibyte) + } + + @Test("image and component requirements compose per resource dimension") + func composableRequirements() throws { + let windowsImage = DoryVMResourceRequirement( + kind: .image, + identifier: "windows-11-arm64", + minimum: request(cpu: 2, memoryGiB: 8, diskGiB: 64), + recommended: request(cpu: 4, memoryGiB: 16, diskGiB: 128) + ) + let developmentTools = DoryVMResourceRequirement( + kind: .component, + identifier: "development-tools", + minimum: request(cpu: 6, memoryGiB: 12, diskGiB: 20), + recommended: request(cpu: 8, memoryGiB: 32, diskGiB: 40) + ) + let recommendation = DoryVMResourcePolicy.recommend( + host: host(cpu: 32, memoryGiB: 128, storageGiB: 1_000), + workload: .desktop, + requirements: [windowsImage, developmentTools] + ) + + #expect(recommendation.virtualCPUCount.minimum == 6) + #expect(recommendation.virtualCPUCount.recommended == 8) + #expect(recommendation.memoryBytes.minimum == 12 * gibibyte) + #expect(recommendation.memoryBytes.recommended == 32 * gibibyte) + #expect(recommendation.diskBytes.minimum == 64 * gibibyte) + #expect(recommendation.diskBytes.recommended == 128 * gibibyte) + + let encoded = try JSONEncoder().encode([windowsImage, developmentTools]) + let decoded = try JSONDecoder().decode([DoryVMResourceRequirement].self, from: encoded) + #expect(decoded == [windowsImage, developmentTools]) + } + + @Test("explicit minimum cannot be weakened by a lower recommendation") + func requirementRecommendationNormalization() { + let requirement = DoryVMResourceRequirement( + kind: .component, + identifier: "large-memory-component", + minimum: request(cpu: 8, memoryGiB: 24, diskGiB: 80), + recommended: request(cpu: 1, memoryGiB: 1, diskGiB: 1) + ) + let result = DoryVMResourcePolicy.assess( + host: host(cpu: 32, memoryGiB: 128, storageGiB: 1_000), + workload: .server, + requirements: [requirement], + request: request(cpu: 8, memoryGiB: 24, diskGiB: 80) + ) + + #expect(result.isValid) + #expect(result.issues.isEmpty) + #expect(result.requirements == [requirement]) + #expect(result.recommendation.virtualCPUCount.recommended == 8) + #expect(result.recommendation.memoryBytes.recommended == 24 * gibibyte) + #expect(result.recommendation.diskBytes.recommended == 80 * gibibyte) + } + + @Test("workload profiles have intentional requirements", arguments: [ + (DoryVMWorkloadProfile.server, UInt64(1), UInt64(2), UInt64(16), UInt64(2), UInt64(4), UInt64(32)), + (DoryVMWorkloadProfile.desktop, UInt64(2), UInt64(4), UInt64(32), UInt64(4), UInt64(8), UInt64(64)), + (DoryVMWorkloadProfile.installer, UInt64(2), UInt64(4), UInt64(40), UInt64(4), UInt64(8), UInt64(64)), + ]) + func workloadRequirements( + workload: DoryVMWorkloadProfile, + minimumCPU: UInt64, + minimumMemoryGiB: UInt64, + minimumDiskGiB: UInt64, + recommendedCPU: UInt64, + recommendedMemoryGiB: UInt64, + recommendedDiskGiB: UInt64 + ) { + let result = DoryVMResourcePolicy.assess( + host: host(cpu: 64, memoryGiB: 128, storageGiB: 2_000), + workload: workload, + request: request( + cpu: recommendedCPU, + memoryGiB: recommendedMemoryGiB, + diskGiB: recommendedDiskGiB + ) + ) + + #expect(result.isValid) + #expect(result.recommendation.virtualCPUCount.minimum == minimumCPU) + #expect(result.recommendation.virtualCPUCount.recommended == recommendedCPU) + #expect(result.recommendation.memoryBytes.minimum == minimumMemoryGiB * gibibyte) + #expect(result.recommendation.memoryBytes.recommended == recommendedMemoryGiB * gibibyte) + #expect(result.recommendation.diskBytes.minimum == minimumDiskGiB * gibibyte) + #expect(result.recommendation.diskBytes.recommended == recommendedDiskGiB * gibibyte) + } + + @Test("valid but conservative resources produce warnings") + func belowRecommendationWarnings() { + let result = DoryVMResourcePolicy.assess( + host: host(cpu: 8, memoryGiB: 16, storageGiB: 200), + workload: .desktop, + request: request(cpu: 2, memoryGiB: 4, diskGiB: 32) + ) + + #expect(result.isValid) + #expect(result.issues.count == 3) + #expect(result.issues.allSatisfy { issue in + issue.code == .requestBelowRecommendation && issue.severity == .warning + }) + #expect(Set(result.issues.map(\.resource)) == Set(DoryVMResourceDimension.allCases)) + } + + @Test("requests below minimum and above safe maximum are errors") + func invalidRequest() { + let result = DoryVMResourcePolicy.assess( + host: host(cpu: 8, memoryGiB: 16, storageGiB: 100), + workload: .desktop, + request: request(cpu: 1, memoryGiB: 13, diskGiB: 95) + ) + + #expect(!result.isValid) + #expect(result.issues.contains(issue(.requestBelowWorkloadMinimum, .cpu, actual: 1, threshold: 2))) + #expect(result.issues.contains(issue( + .requestExceedsHostSafeMaximum, + .memory, + actual: 13 * gibibyte, + threshold: 12 * gibibyte + ))) + #expect(result.issues.contains(issue( + .requestExceedsHostSafeMaximum, + .storage, + actual: 95 * gibibyte, + threshold: 90 * gibibyte + ))) + } + + @Test("undersized hosts report infeasible workload dimensions") + func undersizedHost() { + let result = DoryVMResourcePolicy.assess( + host: host(cpu: 2, memoryGiB: 4, storageGiB: 45), + workload: .installer, + request: request(cpu: 2, memoryGiB: 4, diskGiB: 40) + ) + + #expect(!result.isValid) + #expect(!result.recommendation.isFeasible) + #expect(result.issues.contains(issue( + .hostCapacityBelowWorkloadMinimum, + .cpu, + actual: 1, + threshold: 2 + ))) + #expect(result.issues.contains(issue( + .hostCapacityBelowWorkloadMinimum, + .memory, + actual: 2 * gibibyte, + threshold: 4 * gibibyte + ))) + #expect(result.issues.contains(issue( + .hostCapacityBelowWorkloadMinimum, + .storage, + actual: 35 * gibibyte, + threshold: 40 * gibibyte + ))) + } + + @Test("zero host facts are diagnosed independently") + func invalidHostFacts() { + let result = DoryVMResourcePolicy.assess( + host: DoryVMHostResources( + logicalCPUCount: 0, + physicalMemoryBytes: 0, + freeStorageBytes: 0 + ), + workload: .server, + request: DoryVMResourceRequest(virtualCPUCount: 0, memoryBytes: 0, diskBytes: 0) + ) + + #expect(!result.isValid) + #expect(result.issues.filter { $0.code == .invalidHostCapacity }.count == 3) + #expect(result.issues.filter { $0.code == .hostCapacityBelowWorkloadMinimum }.count == 3) + #expect(result.issues.filter { $0.code == .requestBelowWorkloadMinimum }.count == 3) + } + + @Test("maximum integer host facts do not overflow") + func overflowSafety() { + let host = DoryVMHostResources( + logicalCPUCount: .max, + physicalMemoryBytes: .max, + freeStorageBytes: .max + ) + let result = DoryVMResourcePolicy.assess( + host: host, + workload: .server, + request: request(cpu: 2, memoryGiB: 4, diskGiB: 32) + ) + + let expectedReserve = UInt64.max / 4 + 1 + #expect(result.isValid) + #expect(result.recommendation.hostReserve.logicalCPUCount == expectedReserve) + #expect(result.recommendation.hostReserve.memoryBytes == expectedReserve) + #expect(result.recommendation.hostReserve.storageBytes == UInt64.max / 10 + 1) + #expect(result.recommendation.virtualCPUCount.maximum == UInt64.max - expectedReserve) + } + + @Test("admissions and storage reservations beyond capacity are diagnosed separately") + func excessiveAdmissions() { + let host = DoryVMHostResources( + logicalCPUCount: 4, + physicalMemoryBytes: 8 * gibibyte, + freeStorageBytes: 20 * gibibyte, + admittedVirtualCPUCount: .max, + admittedMemoryBytes: .max, + reservedStorageBytes: .max + ) + let result = DoryVMResourcePolicy.assess( + host: host, + workload: .server, + request: request(cpu: 1, memoryGiB: 2, diskGiB: 16) + ) + + #expect(!result.isValid) + #expect(result.recommendation.virtualCPUCount.maximum == 0) + #expect(result.recommendation.memoryBytes.maximum == 0) + #expect(result.recommendation.diskBytes.maximum == 0) + #expect(result.issues.filter { $0.code == .hostAdmissionExceedsCapacity }.count == 2) + #expect(result.issues.filter { $0.code == .storageReservationExceedsCapacity }.count == 1) + } + + @Test("assessment has a stable Codable representation") + func codableRoundTrip() throws { + let original = DoryVMResourcePolicy.assess( + host: host(cpu: 10, memoryGiB: 24, storageGiB: 250), + workload: .installer, + request: request(cpu: 4, memoryGiB: 8, diskGiB: 64) + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DoryVMResourceAssessment.self, from: data) + #expect(decoded == original) + } + + @Test("older host facts decode with zero active admissions") + func hostFactsBackwardCompatibility() throws { + let data = Data(#"{"logicalCPUCount":8,"physicalMemoryBytes":17179869184,"freeStorageBytes":214748364800}"#.utf8) + let decoded = try JSONDecoder().decode(DoryVMHostResources.self, from: data) + + #expect(decoded == host(cpu: 8, memoryGiB: 16, storageGiB: 200)) + #expect(decoded.admittedVirtualCPUCount == 0) + #expect(decoded.admittedMemoryBytes == 0) + #expect(decoded.reservedStorageBytes == 0) + + let legacy = Data(#""" + { + "logicalCPUCount":8,"physicalMemoryBytes":17179869184, + "freeStorageBytes":214748364800,"committedVirtualCPUCount":2, + "committedMemoryBytes":4294967296,"reservedStorageBytes":34359738368 + } + """#.utf8) + let migrated = try JSONDecoder().decode(DoryVMHostResources.self, from: legacy) + #expect(migrated.admittedVirtualCPUCount == 2) + #expect(migrated.admittedMemoryBytes == 4 * gibibyte) + #expect(migrated.reservedStorageBytes == 32 * gibibyte) + + let currentJSON = try #require(String( + data: JSONEncoder().encode(migrated), + encoding: .utf8 + )) + #expect(currentJSON.contains("admittedVirtualCPUCount")) + #expect(!currentJSON.contains("committedVirtualCPUCount")) + } + + private func host(cpu: UInt64, memoryGiB: UInt64, storageGiB: UInt64) -> DoryVMHostResources { + DoryVMHostResources( + logicalCPUCount: cpu, + physicalMemoryBytes: memoryGiB * gibibyte, + freeStorageBytes: storageGiB * gibibyte + ) + } + + private func request(cpu: UInt64, memoryGiB: UInt64, diskGiB: UInt64) -> DoryVMResourceRequest { + DoryVMResourceRequest( + virtualCPUCount: cpu, + memoryBytes: memoryGiB * gibibyte, + diskBytes: diskGiB * gibibyte + ) + } + + private func guidance(_ minimum: UInt64, _ recommended: UInt64, _ maximum: UInt64) -> DoryVMResourceGuidance { + DoryVMResourceGuidance(minimum: minimum, recommended: recommended, maximum: maximum) + } + + private func guidanceGiB(_ minimum: UInt64, _ recommended: UInt64, _ maximum: UInt64) -> DoryVMResourceGuidance { + guidance(minimum * gibibyte, recommended * gibibyte, maximum * gibibyte) + } + + private func issue( + _ code: DoryVMResourceValidationCode, + _ resource: DoryVMResourceDimension, + actual: UInt64, + threshold: UInt64 + ) -> DoryVMResourceValidationIssue { + DoryVMResourceValidationIssue( + code: code, + severity: .error, + resource: resource, + actual: actual, + threshold: threshold + ) + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift new file mode 100644 index 00000000..da290f8b --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift @@ -0,0 +1,696 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Virtual machine backend planner") +struct DoryVirtualMachineBackendPlannerTests { + private static let guestArtifactSHA256 = String(repeating: "c", count: 64) + private static let linuxISOArtifactSHA256 = String(repeating: "a", count: 64) + private static let windowsISOArtifactSHA256 = String(repeating: "b", count: 64) + private static let macOSRestoreArtifactSHA256 = String(repeating: "d", count: 64) + private static let manifestSHA256 = String(repeating: "e", count: 64) + private static let inspectionReportSHA256 = String(repeating: "f", count: 64) + private static let provenanceReceiptSHA256 = String(repeating: "1", count: 64) + private static let runtimeQualificationReportSHA256 = String(repeating: "2", count: 64) + private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification( + auditEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: "dory-linux-desktop-arm64-gpu-v1", + artifactSHA256: guestArtifactSHA256, + manifestSHA256: manifestSHA256, + signingKeyID: "dory-release-2026", + manifestFormatVersion: 1 + ), + virtioGPUKernelAndDeviceSupportQualified: true, + venusVulkanGuestRuntimeQualified: true + ) + + private static let host = DoryAppleSiliconHostFacts( + macOSMajorVersion: 15, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: true, + windowsUEFIFirmwareAvailable: true, + windowsSecureBootAvailable: true, + windowsSBSADeviceModelAvailable: true, + virtualTPM20Available: true, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: true, + networkAvailable: true, + displayAvailable: true, + inputAvailable: true + ), + macOSGuestVirtualizationSupported: true, + macOSRestoreImageInstallationSupported: true, + doryMacOSBackendAvailable: true, + doryMacOSBackendQualified: true, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: "dory-hv-2026.8", + virtualizationFrameworkAdapterBuildID: "dory-vz-2026.8", + qemuRuntimeBuildID: "qemu-hvf-2026.8" + ) + ) + + @Test("default backend order reflects currently runnable boot paths") + func truthfulDefaults() { + let linux = DoryGuestPlatform(family: .linux, architecture: .arm64) + let macOS = DoryGuestPlatform(family: .macOS, architecture: .arm64) + let windows = DoryGuestPlatform(family: .windows, architecture: .arm64) + + #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: linux, + bootMedia: .installerISO + ) == [.appleVirtualizationFramework, .qemuHypervisorFramework]) + #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: linux, + bootMedia: .installedLinuxBootBundle + ) == [.doryHypervisor]) + #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: macOS, + bootMedia: .macOSRestoreImage + ) == [.appleVirtualizationFramework]) + #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: windows, + bootMedia: .installerISO + ) == [.qemuHypervisorFramework]) + } + + @Test("Linux ISO selects Virtualization.framework before experimental QEMU") + func linuxInstallerSelection() throws { + let result = plan( + family: .linux, + media: .installerISO, + graphics: [.software] + ) + let selected = try #require(result.selectedDescriptor) + + #expect(result.isSuccess) + #expect(selected.request.backend == .appleVirtualizationFramework) + #expect(selected.availability.supportTier == .supported) + #expect(selected.bootMediaInspectionEvidence?.artifactSHA256 + == Self.linuxISOArtifactSHA256) + #expect(result.evaluatedDescriptors.map(\.request.backend) == [ + .appleVirtualizationFramework, + .qemuHypervisorFramework, + ]) + } + + @Test("structurally bootable ISO requires experimental opt-in without runtime qualification") + func unqualifiedISORuntimeRequiresOptIn() throws { + let denied = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + automaticallyQualifyRuntime: false + ) + let allowed = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + allowsExperimental: true, + automaticallyQualifyRuntime: false + ) + + #expect(denied.failure?.code == .noCandidate) + #expect(denied.evaluatedDescriptors.first?.availability.supportTier == .experimental) + #expect(denied.evaluatedDescriptors.first?.availability.reason?.code + == .runtimeQualificationUnavailable) + #expect(try #require(allowed.selectedDescriptor).availability.supportTier + == .experimental) + } + + @Test("mutable virtual disks cannot be planned from a digest without provenance") + func mutableDiskRequiresProvenanceReceipt() { + let result = plan( + family: .linux, + media: .virtualDisk, + graphics: [.software], + allowsExperimental: true, + mediaArtifactSHA256: String(repeating: "9", count: 64), + automaticallyResolveMutableProvenance: false, + automaticallyQualifyRuntime: false + ) + + #expect(result.failure?.code == .noCandidate) + #expect(result.evaluatedDescriptors.allSatisfy { + $0.availability.reason?.code == .mutableBootMediaProvenanceUnavailable + }) + } + + @Test("installed Linux direct boot selects Dory's native hypervisor") + func installedLinuxSelection() throws { + let result = plan( + family: .linux, + media: .installedLinuxBootBundle, + graphics: [.hardwareAccelerated3D, .software], + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + let selected = try #require(result.selectedDescriptor) + + #expect(selected.request.backend == .doryHypervisor) + #expect(selected.request.graphics == .hardwareAccelerated3D) + #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256) + #expect(selected.runtimeQualificationEvidence?.backend == .doryHypervisor) + #expect(result.evaluatedDescriptors.count == 2) + } + + @Test("planner defaults cannot authorize Linux 3D without image qualification") + func installedLinuxUnqualifiedGraphicsIsFailClosed() throws { + let noFallback = plan( + family: .linux, + media: .installedLinuxBootBundle, + graphics: [.hardwareAccelerated3D], + mediaArtifactSHA256: Self.guestArtifactSHA256 + ) + let explicitHeadlessFallback = plan( + family: .linux, + media: .installedLinuxBootBundle, + graphics: [.hardwareAccelerated3D, .none], + mediaArtifactSHA256: Self.guestArtifactSHA256 + ) + let selected = try #require(explicitHeadlessFallback.selectedDescriptor) + + #expect(noFallback.failure?.code == .noCandidate) + #expect(noFallback.evaluatedDescriptors.first?.availability.reason?.code + == .trustedGuestImageGraphicsQualificationUnavailable) + #expect(selected.request.graphics == .none) + #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256) + } + + @Test("preferred backend order is honored across explicitly allowed support tiers") + func backendPreferenceOrderIsHonored() throws { + let result = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backends: [.qemuHypervisorFramework, .appleVirtualizationFramework], + allowsExperimental: true + ) + let selected = try #require(result.selectedDescriptor) + + #expect(selected.request.backend == .qemuHypervisorFramework) + #expect(result.evaluatedDescriptors.map(\.request.backend) == [ + .qemuHypervisorFramework, + .appleVirtualizationFramework, + ]) + } + + @Test("preferred backends permit automatic fallback while required backends do not") + func backendPreferencePolicyControlsFallback() throws { + var unavailableQEMUHost = Self.host + unavailableQEMUHost.qemuHypervisorFrameworkAvailable = false + let preferred = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backends: [.qemuHypervisorFramework], + backendPolicy: .preferred, + host: unavailableQEMUHost, + allowsExperimental: true + ) + let required = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backends: [.qemuHypervisorFramework], + backendPolicy: .required, + host: unavailableQEMUHost, + allowsExperimental: true + ) + + #expect(try #require(preferred.selectedDescriptor).request.backend + == .appleVirtualizationFramework) + #expect(preferred.evaluatedDescriptors.map(\.request.backend) == [ + .qemuHypervisorFramework, + .appleVirtualizationFramework, + ]) + #expect(required.failure?.code == .noCandidate) + #expect(required.evaluatedDescriptors.map(\.request.backend) + == [.qemuHypervisorFramework]) + } + + @Test("graphics is never downgraded unless the caller supplied the fallback") + func graphicsFallbackMustBeExplicit() throws { + let noFallback = plan( + family: .linux, + media: .installerISO, + graphics: [.hardwareAccelerated3D] + ) + let withFallback = plan( + family: .linux, + media: .installerISO, + graphics: [.hardwareAccelerated3D, .software] + ) + let selected = try #require(withFallback.selectedDescriptor) + + #expect(!noFallback.isSuccess) + #expect(noFallback.failure?.code == .noCandidate) + #expect(noFallback.evaluatedDescriptors.count == 2) + #expect(selected.request.graphics == .software) + #expect(withFallback.evaluatedDescriptors.count == 4) + } + + @Test("Windows remains unavailable unless experimental backends are explicitly allowed") + func windowsRequiresExperimentalOptIn() throws { + let denied = plan( + family: .windows, + media: .installerISO, + graphics: [.software] + ) + let allowed = plan( + family: .windows, + media: .installerISO, + graphics: [.hardwareAccelerated3D, .software], + allowsExperimental: true + ) + let selected = try #require(allowed.selectedDescriptor) + + #expect(denied.failure?.code == .noCandidate) + #expect(denied.evaluatedDescriptors.first?.availability.isUsable == true) + #expect(selected.availability.supportTier == .experimental) + #expect(selected.request.graphics == .software) + #expect(allowed.evaluatedDescriptors.first?.availability.reason?.code + == .windows3DAccelerationUnsupported) + } + + @Test("macOS default is VZ and retains external Apple media provenance") + func macOSSelection() throws { + let result = plan( + family: .macOS, + media: .macOSRestoreImage, + source: .vendorDownload, + graphics: [.hardwareAccelerated3D], + allowsExperimental: true + ) + let selected = try #require(result.selectedDescriptor) + + #expect(selected.request.backend == .appleVirtualizationFramework) + #expect(selected.availability.supportTier == .experimental) + #expect(selected.request.bootMedia.source == .vendorDownload) + #expect(result.evaluatedDescriptors.count == 1) + } + + @Test("empty and duplicate preferences fail before capability evaluation") + func invalidPreferences() { + let emptyGraphics = plan(family: .linux, media: .installerISO, graphics: []) + let duplicateGraphics = plan( + family: .linux, + media: .installerISO, + graphics: [.software, .software] + ) + let emptyBackends = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backends: [] + ) + let duplicateBackends = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backends: [.appleVirtualizationFramework, .appleVirtualizationFramework] + ) + let missingRequiredBackends = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + backendPolicy: .required + ) + + #expect(emptyGraphics.failure?.code == .invalidPreference) + #expect(emptyGraphics.failure?.preferenceField == .graphics) + #expect(emptyGraphics.failure?.preferenceIssue == .empty) + #expect(duplicateGraphics.failure?.preferenceIssue == .duplicate) + #expect(emptyBackends.failure?.preferenceField == .backend) + #expect(emptyBackends.failure?.preferenceIssue == .empty) + #expect(duplicateBackends.failure?.preferenceIssue == .duplicate) + #expect(missingRequiredBackends.failure?.preferenceIssue == .missing) + #expect(emptyGraphics.evaluatedDescriptors.isEmpty) + #expect(duplicateBackends.evaluatedDescriptors.isEmpty) + } + + @Test("planner results preserve evaluation evidence through Codable") + func codableResult() throws { + let result = plan( + family: .linux, + media: .installerISO, + graphics: [.hardwareAccelerated3D] + ) + let data = try JSONEncoder().encode(result) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineBackendPlanResult.self, + from: data + ) + + #expect(decoded == result) + #expect(decoded.failure?.code == .noCandidate) + #expect(decoded.evaluatedDescriptors.count == 2) + } + + @Test("successful 3D plans retain revalidation evidence through Codable") + func qualifiedPlanAuditRoundTrip() throws { + let result = plan( + family: .linux, + media: .installedLinuxBootBundle, + graphics: [.hardwareAccelerated3D], + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + let encoded = try JSONEncoder().encode(result) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineBackendPlanResult.self, + from: encoded + ) + let selected = try #require(decoded.selectedDescriptor) + + #expect(selected.graphicsQualificationEvidence + == Self.qualifiedLinuxGraphics.auditEvidence) + #expect(selected.graphicsQualificationEvidence?.manifestSHA256 + == Self.manifestSHA256) + #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256) + #expect(selected.runtimeQualificationEvidence?.qualificationIdentity + == "linux-runtime-qualification-v1") + } + + @Test("runtime inventory selection is order independent for ISO digest and runtime build") + func runtimeInventoryMatchesExactISORecordInEitherOrder() throws { + let exact = runtimeQualification( + media: .installerISO, + immutableArtifactSHA256: Self.linuxISOArtifactSHA256, + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.8" + ) + let staleDigest = runtimeQualification( + media: .installerISO, + immutableArtifactSHA256: String(repeating: "7", count: 64), + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.8" + ) + let staleRuntime = runtimeQualification( + media: .installerISO, + immutableArtifactSHA256: Self.linuxISOArtifactSHA256, + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.7" + ) + + for inventory in [ + [staleDigest, staleRuntime, exact], + [exact, staleRuntime, staleDigest], + ] { + let result = plan( + family: .linux, + media: .installerISO, + graphics: [.software], + trustedRuntimeQualifications: inventory + ) + let selected = try #require(result.selectedDescriptor) + #expect(selected.availability.supportTier == .supported) + #expect(selected.runtimeQualificationEvidence?.immutableArtifactSHA256 + == Self.linuxISOArtifactSHA256) + #expect(selected.runtimeQualificationEvidence?.backendRuntimeBuildID + == "dory-vz-2026.8") + } + } + + @Test("runtime inventory selection is order independent for mutable revision and runtime build") + func runtimeInventoryMatchesExactMutableRecordInEitherOrder() throws { + let exactProvenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "dory-test-store", + mediaIdentity: "linux-system-disk", + revision: 7 + ) + let staleProvenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "dory-test-store", + mediaIdentity: "linux-system-disk", + revision: 6 + ) + let exact = runtimeQualification( + media: .virtualDisk, + mutableProvenance: exactProvenance, + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.8" + ) + let staleRevision = runtimeQualification( + media: .virtualDisk, + mutableProvenance: staleProvenance, + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.8" + ) + let staleRuntime = runtimeQualification( + media: .virtualDisk, + mutableProvenance: exactProvenance, + backend: .appleVirtualizationFramework, + runtimeBuildID: "dory-vz-2026.7" + ) + + for inventory in [ + [staleRevision, staleRuntime, exact], + [exact, staleRuntime, staleRevision], + ] { + let result = plan( + family: .linux, + media: .virtualDisk, + graphics: [.software], + trustedRuntimeQualifications: inventory + ) + let selected = try #require(result.selectedDescriptor) + #expect(selected.availability.supportTier == .supported) + #expect(selected.runtimeQualificationEvidence?.mutableProvenance?.revision == 7) + #expect(selected.runtimeQualificationEvidence?.backendRuntimeBuildID + == "dory-vz-2026.8") + } + } + + @Test("planner carries the exact device request and rejects unsupported modes") + func plannerNegotiatesDevices() throws { + let minimum = plan( + family: .linux, + media: .virtualDisk, + graphics: [.software] + ) + let disconnected = plan( + family: .linux, + media: .virtualDisk, + graphics: [.software], + devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected) + ) + + #expect(try #require(minimum.selectedDescriptor).resolvedDevices == .minimumBootable) + #expect(disconnected.failure?.code == .noCandidate) + #expect(disconnected.evaluatedDescriptors.allSatisfy { + $0.availability.reason?.code == .networkAttachmentUnsupported + }) + } + + @Test("legacy planner requests decode with conservative policy and device defaults") + func legacyPlanRequestDecoding() throws { + let data = Data(#""" + { + "guest":{"family":"linux","architecture":"arm64"}, + "bootMedia":{"kind":"virtual-disk","source":"user-provided"}, + "acceptableGraphics":["software"], + "backendPreferences":["apple-virtualization-framework"], + "allowsExperimentalBackends":false + } + """#.utf8) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineBackendPlanRequest.self, + from: data + ) + + #expect(decoded.devices == .minimumBootable) + #expect(decoded.backendPreferencePolicy == .preferred) + } + + private func plan( + family: DoryGuestFamily, + media: DoryBootMediaKind, + source: DoryBootMediaSource = .userProvided, + graphics: [DoryGraphicsAccelerationLevel], + devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable, + backends: [DoryVirtualizationBackendIdentity]? = nil, + backendPolicy: DoryVirtualMachineBackendPreferencePolicy = .preferred, + host: DoryAppleSiliconHostFacts = DoryVirtualMachineBackendPlannerTests.host, + allowsExperimental: Bool = false, + mediaArtifactSHA256: String? = nil, + trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, + automaticallyTrustBootMedia: Bool = true, + trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil, + automaticallyResolveMutableProvenance: Bool = true, + automaticallyQualifyRuntime: Bool = true, + trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification]? = nil + ) -> DoryVirtualMachineBackendPlanResult { + let resolvedArtifactSHA256 = mediaArtifactSHA256 + ?? defaultArtifactSHA256(family: family, media: media) + let resolvedInspection = trustedBootMediaInspection + ?? (automaticallyTrustBootMedia + ? makeBootMediaInspection( + family: family, + media: media, + artifactSHA256: resolvedArtifactSHA256 + ) + : nil) + let mutableProvenance = automaticallyResolveMutableProvenance && media == .virtualDisk + ? DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "dory-test-store", + mediaIdentity: "\(family.rawValue)-system-disk", + revision: 7 + ) + : nil + let trustedMutableProvenance = makeTrustedMutableProvenance(mutableProvenance) + let planRequest = DoryVirtualMachineBackendPlanRequest( + guest: DoryGuestPlatform(family: family, architecture: .arm64), + bootMedia: DoryBootMedia( + kind: media, + source: source, + artifactSHA256: resolvedArtifactSHA256, + mutableProvenance: mutableProvenance + ), + acceptableGraphics: graphics, + devices: devices, + backendPreferences: backends, + backendPreferencePolicy: backendPolicy, + allowsExperimentalBackends: allowsExperimental + ) + let resolvedRuntimeQualifications = trustedRuntimeQualifications + ?? (automaticallyQualifyRuntime + ? makeRuntimeQualifications(request: planRequest, host: host) + : []) + return DoryAppleSiliconVirtualMachineBackendPlanner.plan( + planRequest, + host: host, + trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification, + trustedBootMediaInspection: resolvedInspection, + trustedMutableBootMediaProvenance: trustedMutableProvenance, + trustedRuntimeQualifications: resolvedRuntimeQualifications + ) + } + + private func defaultArtifactSHA256( + family: DoryGuestFamily, + media: DoryBootMediaKind + ) -> String? { + switch (family, media) { + case (.linux, .installerISO): + Self.linuxISOArtifactSHA256 + case (.windows, .installerISO): + Self.windowsISOArtifactSHA256 + case (.macOS, .macOSRestoreImage): + Self.macOSRestoreArtifactSHA256 + default: + nil + } + } + + private func makeBootMediaInspection( + family: DoryGuestFamily, + media: DoryBootMediaKind, + artifactSHA256: String? + ) -> DoryTrustedBootMediaInspection? { + guard media == .installerISO || media == .macOSRestoreImage, + let artifactSHA256 else { + return nil + } + return DoryTrustedBootMediaInspection( + auditEvidence: DoryBootMediaInspectionAuditEvidence( + inspectionIdentity: "inspection-\(family.rawValue)-\(media.rawValue)", + artifactSHA256: artifactSHA256, + inspectionReportSHA256: Self.inspectionReportSHA256, + inspectorID: "dory-media-inspector", + inspectorVersion: 1 + ), + detectedKind: media, + detectedGuestFamily: family, + detectedArchitecture: .arm64, + isEFIBootable: true, + macOSBuildIdentifier: family == .macOS ? "25A123" : nil, + macOSHardwareModelCompatible: family == .macOS, + macOSAuxiliaryStorageCompatible: family == .macOS + ) + } + + private func makeTrustedMutableProvenance( + _ provenance: DoryMutableBootMediaProvenanceReference? + ) -> DoryTrustedMutableBootMediaProvenance? { + guard let provenance else { return nil } + return DoryTrustedMutableBootMediaProvenance( + auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "mutable-disk-receipt-\(provenance.mediaIdentity)", + provenance: provenance, + receiptSHA256: Self.provenanceReceiptSHA256, + resolverID: "dory-artifact-resolver", + resolverVersion: 1 + ) + ) + } + + private func makeRuntimeQualifications( + request: DoryVirtualMachineBackendPlanRequest, + host: DoryAppleSiliconHostFacts + ) -> [DoryTrustedVirtualMachineRuntimeQualification] { + guard request.guest.family == .linux, + let hostContext = host.runtimeQualificationContext else { + return [] + } + return request.acceptableGraphics.flatMap { graphics in + DoryVirtualizationBackendIdentity.allCases.compactMap { backend in + let immutableArtifactSHA256 = request.bootMedia.kind == .virtualDisk + ? nil : request.bootMedia.artifactSHA256 + guard immutableArtifactSHA256 != nil + || request.bootMedia.mutableProvenance != nil else { + return nil + } + return DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "linux-runtime-qualification-v1", + qualificationReportSHA256: Self.runtimeQualificationReportSHA256, + signingKeyID: "dory-runtime-qualification-2026", + qualificationFormatVersion: 1, + guest: request.guest, + bootMediaKind: request.bootMedia.kind, + immutableArtifactSHA256: immutableArtifactSHA256, + mutableProvenance: request.bootMedia.kind == .virtualDisk + ? request.bootMedia.mutableProvenance : nil, + backend: backend, + backendRuntimeBuildID: hostContext.runtimeBuildID(for: backend), + virtualHardwareABIVersion: request.virtualHardwareABIVersion, + graphics: graphics, + devices: request.devices + ), + runtimeQualified: true + ) + } + } + } + + private func runtimeQualification( + media: DoryBootMediaKind, + immutableArtifactSHA256: String? = nil, + mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil, + backend: DoryVirtualizationBackendIdentity, + runtimeBuildID: String + ) -> DoryTrustedVirtualMachineRuntimeQualification { + DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "linux-runtime-qualification-v1", + qualificationReportSHA256: Self.runtimeQualificationReportSHA256, + signingKeyID: "dory-runtime-qualification-2026", + qualificationFormatVersion: 1, + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMediaKind: media, + immutableArtifactSHA256: immutableArtifactSHA256, + mutableProvenance: mutableProvenance, + backend: backend, + backendRuntimeBuildID: runtimeBuildID, + virtualHardwareABIVersion: 1, + graphics: .software, + devices: .minimumBootable + ), + runtimeQualified: true + ) + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift new file mode 100644 index 00000000..c8d9c0d7 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -0,0 +1,1064 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Virtual machine capability negotiation") +struct VirtualMachineCapabilitiesTests { + private static let guestArtifactSHA256 = String(repeating: "a", count: 64) + private static let linuxISOArtifactSHA256 = String(repeating: "b", count: 64) + private static let windowsISOArtifactSHA256 = String(repeating: "c", count: 64) + private static let macOSRestoreArtifactSHA256 = String(repeating: "d", count: 64) + private static let manifestSHA256 = String(repeating: "e", count: 64) + private static let inspectionReportSHA256 = String(repeating: "f", count: 64) + private static let provenanceReceiptSHA256 = String(repeating: "1", count: 64) + private static let runtimeQualificationReportSHA256 = String(repeating: "2", count: 64) + private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification( + auditEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: "dory-linux-desktop-arm64-gpu-v1", + artifactSHA256: guestArtifactSHA256, + manifestSHA256: manifestSHA256, + signingKeyID: "dory-release-2026", + manifestFormatVersion: 1 + ), + virtioGPUKernelAndDeviceSupportQualified: true, + venusVulkanGuestRuntimeQualified: true + ) + + private static let provisionedHost = DoryAppleSiliconHostFacts( + macOSMajorVersion: 15, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: true, + windowsUEFIFirmwareAvailable: true, + windowsSecureBootAvailable: true, + windowsSBSADeviceModelAvailable: true, + virtualTPM20Available: true, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: true, + networkAvailable: true, + displayAvailable: true, + inputAvailable: true + ), + macOSGuestVirtualizationSupported: true, + macOSRestoreImageInstallationSupported: true, + doryMacOSBackendAvailable: true, + doryMacOSBackendQualified: true, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: "dory-hv-2026.8", + virtualizationFrameworkAdapterBuildID: "dory-vz-2026.8", + qemuRuntimeBuildID: "qemu-hvf-2026.8" + ) + ) + + @Test("installed Linux ARM64 with native 3D is supported when all components are present") + func linuxNative3DIsSupported() { + let descriptor = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + + #expect(descriptor.schemaVersion == 2) + #expect(descriptor.evaluatorVersion == 2) + #expect(descriptor.availability.supportTier == .supported) + #expect(descriptor.availability.state == .available) + #expect(descriptor.availability.reason == nil) + #expect(descriptor.availability.isUsable) + #expect(descriptor.graphicsQualificationEvidence + == Self.qualifiedLinuxGraphics.auditEvidence) + #expect(descriptor.runtimeQualificationEvidence?.backend == .doryHypervisor) + } + + @Test("host renderer facts cannot authorize an unqualified Linux guest image") + func linux3DRequiresExactSignedGuestQualification() { + let missing = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D + ) + let invalidArtifactIdentity = evaluateLinux3D( + mediaArtifactSHA256: "not-a-sha256", + trustedQualification: Self.qualifiedLinuxGraphics + ) + let missingManifestIdentity = trustedQualification(signedManifestIdentity: "") + let invalidManifestDigest = trustedQualification(manifestSHA256: "invalid") + let mismatched = trustedQualification( + qualifiedArtifactSHA256: String(repeating: "b", count: 64) + ) + let noVirtioGPU = trustedQualification(virtioGPUQualified: false) + let noVenus = trustedQualification(venusVulkanQualified: false) + + #expect(missing.availability.reason?.code == .guestImageArtifactDigestUnavailable) + #expect(invalidArtifactIdentity.availability.reason?.code + == .bootMediaArtifactDigestInvalid) + #expect(evaluateLinux3D(trustedQualification: missingManifestIdentity).availability.reason?.code + == .guestImageQualificationAuditEvidenceInvalid) + #expect(evaluateLinux3D(trustedQualification: invalidManifestDigest).availability.reason?.code + == .guestImageQualificationAuditEvidenceInvalid) + #expect(evaluateLinux3D(trustedQualification: mismatched).availability.reason?.code + == .guestImageArtifactQualificationMismatch) + #expect(evaluateLinux3D(trustedQualification: noVirtioGPU).availability.reason?.code + == .linuxVirtioGPUKernelDeviceUnqualified) + #expect(evaluateLinux3D(trustedQualification: noVenus).availability.reason?.code + == .linuxVenusVulkanRuntimeUnqualified) + #expect(evaluateLinux3D( + trustedQualification: Self.qualifiedLinuxGraphics + ).availability.isUsable) + } + + @Test("caller-authored qualification fields cannot cross the request trust boundary") + func spoofedRequestQualificationIsIgnored() throws { + let request = DoryVirtualMachineCapabilityRequest( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMedia: DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .userProvided, + artifactSHA256: Self.guestArtifactSHA256 + ), + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D + ) + let encoded = try JSONEncoder().encode(request) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object["guestImageGraphicsQualification"] = [ + "manifestSignatureVerified": true, + "qualifiedArtifactSHA256": Self.guestArtifactSHA256, + "virtioGPUKernelAndDeviceSupportQualified": true, + "venusVulkanGuestRuntimeQualified": true, + ] + let spoofed = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineCapabilityRequest.self, + from: spoofed + ) + let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate( + decoded, + host: Self.provisionedHost + ) + + #expect(descriptor.availability.reason?.code + == .trustedGuestImageGraphicsQualificationUnavailable) + } + + @Test("native hypervisor only accepts the post-install direct-boot bundle") + func nativeHypervisorRejectsInstallerISO() { + let native = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software + ) + let installer = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software + ) + + #expect(native.availability.reason?.code == .bootMediaDoesNotSupportBackend) + #expect(!native.availability.isUsable) + #expect(installer.availability.isUsable) + } + + @Test("Apple Silicon never labels x86_64 execution as virtualization") + func x86RequiresEmulation() { + let descriptor = evaluate( + family: .linux, + architecture: .x86_64, + media: .installerISO, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .software + ) + + #expect(descriptor.availability.supportTier == .unsupported) + #expect(descriptor.availability.state == .unavailable) + #expect(descriptor.availability.reason?.code == .guestArchitectureRequiresEmulation) + #expect(!descriptor.availability.isUsable) + } + + @Test("Windows ARM64 is explicitly experimental and never promises 3D") + func windowsSupportBoundaries() { + let experimental = evaluate( + family: .windows, + media: .installerISO, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .hostAcceleratedDisplay + ) + let threeD = evaluate( + family: .windows, + media: .virtualDisk, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .hardwareAccelerated3D + ) + + #expect(experimental.availability.supportTier == .experimental) + #expect(experimental.availability.state == .available) + #expect(experimental.availability.reason?.code == .windowsSupportIsExperimental) + #expect(experimental.availability.isUsable) + #expect(threeD.availability.supportTier == .unsupported) + #expect(threeD.availability.reason?.code == .windows3DAccelerationUnsupported) + #expect(!threeD.availability.isUsable) + } + + @Test("Windows support does not leak onto backends that cannot boot it") + func windowsRequiresQEMUBackend() { + let descriptor = evaluate( + family: .windows, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software + ) + + #expect(descriptor.availability.supportTier == .unsupported) + #expect(descriptor.availability.reason?.code == .backendDoesNotSupportGuest) + } + + @Test("macOS media is vendor or user supplied, never Dory redistributed") + func macOSMediaProvenance() { + let bundled = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .bundledByDory, + backend: .appleVirtualizationFramework, + graphics: .hardwareAccelerated3D + ) + let vendor = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .vendorDownload, + backend: .appleVirtualizationFramework, + graphics: .hardwareAccelerated3D + ) + + #expect(bundled.availability.supportTier == .unsupported) + #expect(bundled.availability.reason?.code == .guestMediaRedistributionUnavailable) + #expect(vendor.availability.supportTier == .experimental) + #expect(vendor.availability.state == .available) + } + + @Test("Windows installation media is never represented as Dory redistributable") + func windowsMediaProvenance() { + let descriptor = evaluate( + family: .windows, + media: .installerISO, + source: .bundledByDory, + backend: .qemuHypervisorFramework, + graphics: .software + ) + + #expect(descriptor.availability.supportTier == .unsupported) + #expect(descriptor.availability.reason?.code == .guestMediaRedistributionUnavailable) + } + + @Test("guest-specific boot formats are rejected before host probing") + func bootMediaCompatibility() { + let windowsKernel = evaluate( + family: .windows, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .software + ) + let linuxRestore = evaluate( + family: .linux, + media: .macOSRestoreImage, + source: .vendorDownload, + backend: .doryHypervisor, + graphics: .software + ) + + #expect(windowsKernel.availability.reason?.code == .bootMediaDoesNotSupportGuest) + #expect(linuxRestore.availability.reason?.code == .bootMediaDoesNotSupportGuest) + } + + @Test("missing renderer blocks accelerated native graphics but not software graphics") + func rendererAvailabilityIsExact() { + var host = Self.provisionedHost + host.doryAcceleratedRendererAvailable = false + + let accelerated = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D, + host: host, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + let software = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + host: host, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + + #expect(accelerated.availability.supportTier == .supported) + #expect(accelerated.availability.state == .unavailable) + #expect(accelerated.availability.reason?.code == .acceleratedRendererUnavailable) + #expect(software.availability.isUsable) + } + + @Test("host facts distinguish product support from local component availability") + func backendComponentAvailability() { + var host = Self.provisionedHost + host.qemuHypervisorFrameworkAvailable = false + + let descriptor = evaluate( + family: .windows, + media: .installerISO, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .software, + host: host + ) + + #expect(descriptor.availability.supportTier == .experimental) + #expect(descriptor.availability.state == .unavailable) + #expect(descriptor.availability.reason?.code == .backendComponentUnavailable) + } + + @Test("Windows requires a complete qualified boot and driver stack") + func windowsRequiresCompleteRuntime() { + var noFirmware = Self.provisionedHost + noFirmware.windowsUEFIFirmwareAvailable = false + var noDeviceModel = Self.provisionedHost + noDeviceModel.windowsSBSADeviceModelAvailable = false + var noSecureBoot = Self.provisionedHost + noSecureBoot.windowsSecureBootAvailable = false + var noTPM = Self.provisionedHost + noTPM.virtualTPM20Available = false + var noStorage = Self.provisionedHost + noStorage.windowsGuestDrivers.storageAvailable = false + var noNetwork = Self.provisionedHost + noNetwork.windowsGuestDrivers.networkAvailable = false + var noDisplay = Self.provisionedHost + noDisplay.windowsGuestDrivers.displayAvailable = false + var noInput = Self.provisionedHost + noInput.windowsGuestDrivers.inputAvailable = false + + #expect(windows(host: noFirmware).availability.reason?.code == .windowsUEFIFirmwareUnavailable) + #expect(windows(host: noSecureBoot).availability.reason?.code == .windowsSecureBootUnavailable) + #expect(windows(host: noDeviceModel).availability.reason?.code == .windowsSBSADeviceModelUnavailable) + #expect(windows(host: noTPM).availability.reason?.code == .virtualTPM20Unavailable) + #expect(windows(host: noStorage).availability.reason?.code == .windowsStorageDriverUnavailable) + #expect(windows(host: noNetwork).availability.reason?.code == .windowsNetworkDriverUnavailable) + #expect(windows(host: noDisplay).availability.reason?.code == .windowsDisplayDriverUnavailable) + #expect(windows(host: noInput).availability.reason?.code == .windowsInputDriverUnavailable) + } + + @Test("macOS availability requires host-qualified VZMac and restore-image support") + func macOSHostQualification() { + var noPlatform = Self.provisionedHost + noPlatform.macOSGuestVirtualizationSupported = false + var noRestore = Self.provisionedHost + noRestore.macOSRestoreImageInstallationSupported = false + + let platform = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + host: noPlatform + ) + let restore = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + host: noRestore + ) + + #expect(platform.availability.reason?.code == .macOSGuestVirtualizationUnavailable) + #expect(restore.availability.reason?.code == .macOSRestoreImageUnsupported) + } + + @Test("Apple host support cannot substitute for Dory's qualified VZMac adapter") + func macOSRequiresQualifiedDoryAdapter() { + var noAdapter = Self.provisionedHost + noAdapter.doryMacOSBackendAvailable = false + var unqualified = Self.provisionedHost + unqualified.doryMacOSBackendQualified = false + + let absent = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + host: noAdapter + ) + let notQualified = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + host: unqualified + ) + + #expect(absent.availability.supportTier == .experimental) + #expect(absent.availability.state == .unavailable) + #expect(absent.availability.reason?.code == .backendComponentUnavailable) + #expect(notQualified.availability.reason?.code == .backendComponentUnqualified) + } + + @Test("native backend host requirements are deterministic") + func nativeBackendHostRequirements() { + var oldHost = Self.provisionedHost + oldHost.macOSMajorVersion = 14 + let oldOS = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + host: oldHost, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + + var noHypervisor = Self.provisionedHost + noHypervisor.hypervisorFrameworkAvailable = false + let noFramework = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + host: noHypervisor, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics + ) + + #expect(oldOS.availability.reason?.code == .hostOperatingSystemUnsupported) + #expect(noFramework.availability.reason?.code == .hypervisorFrameworkUnavailable) + } + + @Test("capability descriptors have stable Codable round trips") + func codableRoundTrip() throws { + let descriptor = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .vendorDownload, + backend: .appleVirtualizationFramework, + graphics: .hardwareAccelerated3D + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = try encoder.encode(descriptor) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineCapabilityDescriptor.self, + from: encoded + ) + let json = try #require(String(data: encoded, encoding: .utf8)) + + #expect(decoded == descriptor) + #expect(json.contains(#""schemaVersion":2"#)) + #expect(json.contains(#""family":"macos""#)) + #expect(json.contains(#""kind":"macos-restore-image""#)) + #expect(json.contains(#""backend":"apple-virtualization-framework""#)) + #expect(json.contains(#""graphics":"hardware-accelerated-3d""#)) + #expect(decoded.bootMediaInspectionEvidence?.artifactSHA256 + == Self.macOSRestoreArtifactSHA256) + } + + @Test("qualified graphics audit evidence survives descriptor persistence") + func graphicsAuditEvidenceRoundTrip() throws { + let descriptor = evaluateLinux3D( + trustedQualification: Self.qualifiedLinuxGraphics + ) + let encoded = try JSONEncoder().encode(descriptor) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineCapabilityDescriptor.self, + from: encoded + ) + + #expect(decoded.graphicsQualificationEvidence + == Self.qualifiedLinuxGraphics.auditEvidence) + #expect(decoded.graphicsQualificationEvidence?.artifactSHA256 + == Self.guestArtifactSHA256) + #expect(decoded.graphicsQualificationEvidence?.manifestSHA256 + == Self.manifestSHA256) + #expect(decoded.graphicsQualificationEvidence?.signingKeyID == "dory-release-2026") + #expect(decoded.graphicsQualificationEvidence?.manifestFormatVersion == 1) + #expect(decoded.runtimeQualificationEvidence?.backendRuntimeBuildID + == "dory-hv-2026.8") + #expect(decoded.runtimeQualificationEvidence?.virtualHardwareABIVersion == 1) + } + + @Test("raw-HV graphical modes require digest-bound virtio-gpu qualification") + func everyRawGraphicalModeRequiresGuestDriverQualification() { + for graphics in [ + DoryGraphicsAccelerationLevel.software, + .hostAcceleratedDisplay, + .hardwareAccelerated3D, + ] { + let descriptor = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: graphics, + mediaArtifactSHA256: Self.guestArtifactSHA256, + automaticallyTrustGuestGraphics: false + ) + #expect(descriptor.availability.reason?.code + == .trustedGuestImageGraphicsQualificationUnavailable) + } + + let noVirtio = trustedQualification(virtioGPUQualified: false) + let software = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: noVirtio, + automaticallyTrustGuestGraphics: false + ) + let noVenus = trustedQualification(venusVulkanQualified: false) + let hostDisplay = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + trustedGuestImageGraphicsQualification: noVenus, + automaticallyTrustGuestGraphics: false + ) + + #expect(software.availability.reason?.code == .linuxVirtioGPUKernelDeviceUnqualified) + #expect(hostDisplay.availability.isUsable) + } + + @Test("all supplied media digests are validated before backend availability") + func malformedDigestFailsForVZSoftware() { + let descriptor = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + mediaArtifactSHA256: "spoof" + ) + + #expect(descriptor.availability.reason?.code == .bootMediaArtifactDigestInvalid) + #expect(!descriptor.availability.isUsable) + } + + @Test("structural ISO inspection never implies supported runtime compatibility") + func structuralISOWithoutRuntimeQualificationIsExperimental() { + let descriptor = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyQualifyRuntime: false + ) + + #expect(descriptor.availability.supportTier == .experimental) + #expect(descriptor.availability.state == .available) + #expect(descriptor.availability.reason?.code == .runtimeQualificationUnavailable) + #expect(descriptor.bootMediaInspectionEvidence?.artifactSHA256 + == Self.linuxISOArtifactSHA256) + #expect(descriptor.runtimeQualificationEvidence == nil) + } + + @Test("runtime qualification is exact to backend build and virtual-device ABI") + func runtimeQualificationCannotCrossRuntimeContexts() throws { + let request = DoryVirtualMachineCapabilityRequest( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMedia: DoryBootMedia( + kind: .installerISO, + source: .userProvided, + artifactSHA256: Self.linuxISOArtifactSHA256 + ), + backend: .appleVirtualizationFramework, + graphics: .software, + devices: .minimumBootable, + virtualHardwareABIVersion: 1 + ) + let valid = try #require(makeRuntimeQualification( + request: request, + host: Self.provisionedHost + )) + var wrongBuildEvidence = valid.auditEvidence + wrongBuildEvidence.backendRuntimeBuildID = "other-vz-build" + var wrongABIEvidence = valid.auditEvidence + wrongABIEvidence.virtualHardwareABIVersion = 2 + let inspection = makeBootMediaInspection( + family: .linux, + architecture: .arm64, + media: .installerISO, + artifactSHA256: Self.linuxISOArtifactSHA256 + ) + + let wrongBuild = DoryAppleSiliconCapabilityEvaluator.evaluate( + request, + host: Self.provisionedHost, + trustedBootMediaInspection: inspection, + trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: wrongBuildEvidence, + runtimeQualified: true + ) + ) + let wrongABI = DoryAppleSiliconCapabilityEvaluator.evaluate( + request, + host: Self.provisionedHost, + trustedBootMediaInspection: inspection, + trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: wrongABIEvidence, + runtimeQualified: true + ) + ) + + #expect(wrongBuild.availability.reason?.code == .runtimeQualificationHostMismatch) + #expect(wrongABI.availability.reason?.code == .runtimeQualificationRequestMismatch) + #expect(!wrongBuild.availability.isUsable) + #expect(!wrongABI.availability.isUsable) + } + + @Test("a content digest cannot replace daemon provenance for a mutable disk") + func mutableDiskRequiresTrustedRevisionProvenance() { + let digestOnly = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + mediaArtifactSHA256: String(repeating: "8", count: 64), + automaticallyResolveMutableProvenance: false, + automaticallyQualifyRuntime: false + ) + let provenanceButUnqualified = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyQualifyRuntime: false + ) + + #expect(digestOnly.availability.reason?.code + == .mutableBootMediaProvenanceUnavailable) + #expect(!digestOnly.availability.isUsable) + #expect(provenanceButUnqualified.availability.supportTier == .experimental) + #expect(provenanceButUnqualified.availability.reason?.code + == .runtimeQualificationUnavailable) + #expect(provenanceButUnqualified.mutableBootMediaProvenanceEvidence?.provenance.revision + == 7) + } + + @Test("installer media architecture and EFI bootability come from trusted inspection") + func installerInspectionIsAuthoritative() { + let x86Inspection = makeBootMediaInspection( + family: .linux, + architecture: .x86_64, + media: .installerISO, + artifactSHA256: Self.linuxISOArtifactSHA256 + ) + var nonEFI = try! #require(makeBootMediaInspection( + family: .linux, + architecture: .arm64, + media: .installerISO, + artifactSHA256: Self.linuxISOArtifactSHA256 + )) + nonEFI = DoryTrustedBootMediaInspection( + auditEvidence: nonEFI.auditEvidence, + detectedKind: nonEFI.detectedKind, + detectedGuestFamily: nonEFI.detectedGuestFamily, + detectedArchitecture: nonEFI.detectedArchitecture, + isEFIBootable: false + ) + + let mislabeled = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyTrustBootMedia: false, + trustedBootMediaInspection: x86Inspection + ) + let unbootable = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyTrustBootMedia: false, + trustedBootMediaInspection: nonEFI + ) + let uninspected = evaluate( + family: .linux, + media: .installerISO, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyTrustBootMedia: false + ) + + #expect(mislabeled.availability.reason?.code + == .bootMediaArchitectureInspectionMismatch) + #expect(unbootable.availability.reason?.code == .bootMediaNotEFIBootable) + #expect(uninspected.availability.reason?.code == .trustedBootMediaInspectionUnavailable) + } + + @Test("macOS restore compatibility is bound to the exact inspected artifact") + func macOSRestoreInspectionIsExact() { + let mismatched = makeBootMediaInspection( + family: .macOS, + architecture: .arm64, + media: .macOSRestoreImage, + artifactSHA256: String(repeating: "9", count: 64) + ) + let incompatible = DoryTrustedBootMediaInspection( + auditEvidence: DoryBootMediaInspectionAuditEvidence( + inspectionIdentity: "macos-restore-inspection", + artifactSHA256: Self.macOSRestoreArtifactSHA256, + inspectionReportSHA256: Self.inspectionReportSHA256, + inspectorID: "dory-media-inspector", + inspectorVersion: 1 + ), + detectedKind: .macOSRestoreImage, + detectedGuestFamily: .macOS, + detectedArchitecture: .arm64, + isEFIBootable: true, + macOSBuildIdentifier: "25A123", + macOSHardwareModelCompatible: false, + macOSAuxiliaryStorageCompatible: true + ) + + let mismatchResult = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .vendorDownload, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyTrustBootMedia: false, + trustedBootMediaInspection: mismatched + ) + let incompatibleResult = evaluate( + family: .macOS, + media: .macOSRestoreImage, + source: .vendorDownload, + backend: .appleVirtualizationFramework, + graphics: .software, + automaticallyTrustBootMedia: false, + trustedBootMediaInspection: incompatible + ) + + #expect(mismatchResult.availability.reason?.code + == .bootMediaArtifactInspectionMismatch) + #expect(incompatibleResult.availability.reason?.code + == .macOSRestoreHardwareModelIncompatible) + } + + @Test("device contract is exact and unsupported modes fail closed") + func deviceCapabilitiesAreNegotiated() { + let resolved = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: .minimumBootable + ) + let disconnected = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected) + ) + let spoofedGuestTools = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: DoryVirtualMachineDeviceCapabilityRequest(directorySharing: true) + ) + + #expect(resolved.resolvedDevices == .minimumBootable) + #expect(disconnected.availability.reason?.code == .networkAttachmentUnsupported) + #expect(spoofedGuestTools.availability.reason?.code == .directorySharingUnsupported) + } + + @Test("version-one descriptor JSON remains readable with conservative device defaults") + func versionOneDescriptorDecoding() throws { + let data = Data(#""" + { + "schemaVersion":1, + "evaluatorVersion":1, + "request":{ + "guest":{"family":"linux","architecture":"arm64"}, + "bootMedia":{"kind":"virtual-disk","source":"user-provided"}, + "backend":"apple-virtualization-framework", + "graphics":"software" + }, + "availability":{"supportTier":"supported","state":"available"} + } + """#.utf8) + let descriptor = try JSONDecoder().decode( + DoryVirtualMachineCapabilityDescriptor.self, + from: data + ) + + #expect(descriptor.schemaVersion == 1) + #expect(descriptor.request.devices == .minimumBootable) + #expect(descriptor.resolvedDevices == nil) + #expect(descriptor.graphicsQualificationEvidence == nil) + #expect(descriptor.bootMediaInspectionEvidence == nil) + } + + private func evaluate( + family: DoryGuestFamily, + architecture: DoryGuestArchitecture = .arm64, + media: DoryBootMediaKind, + source: DoryBootMediaSource, + backend: DoryVirtualizationBackendIdentity, + graphics: DoryGraphicsAccelerationLevel, + host: DoryAppleSiliconHostFacts = provisionedHost, + mediaArtifactSHA256: String? = nil, + trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, + automaticallyTrustGuestGraphics: Bool = true, + devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable, + automaticallyTrustBootMedia: Bool = true, + trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil, + automaticallyResolveMutableProvenance: Bool = true, + mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil, + trustedMutableProvenance: DoryTrustedMutableBootMediaProvenance? = nil, + automaticallyQualifyRuntime: Bool = true, + trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil + ) -> DoryVirtualMachineCapabilityDescriptor { + let resolvedArtifactSHA256 = mediaArtifactSHA256 + ?? defaultArtifactSHA256(family: family, media: media) + let resolvedInspection = trustedBootMediaInspection + ?? (automaticallyTrustBootMedia + ? makeBootMediaInspection( + family: family, + architecture: architecture, + media: media, + artifactSHA256: resolvedArtifactSHA256 + ) + : nil) + let resolvedGraphicsQualification = trustedGuestImageGraphicsQualification + ?? (automaticallyTrustGuestGraphics + && family == .linux + && backend == .doryHypervisor + && graphics != .none + && resolvedArtifactSHA256 == Self.guestArtifactSHA256 + ? Self.qualifiedLinuxGraphics + : nil) + let resolvedMutableProvenance = mutableProvenance + ?? (automaticallyResolveMutableProvenance && media == .virtualDisk + ? DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "dory-test-store", + mediaIdentity: "\(family.rawValue)-system-disk", + revision: 7 + ) + : nil) + let resolvedTrustedMutableProvenance = trustedMutableProvenance + ?? (automaticallyResolveMutableProvenance + ? makeTrustedMutableProvenance(resolvedMutableProvenance) + : nil) + let request = DoryVirtualMachineCapabilityRequest( + guest: DoryGuestPlatform(family: family, architecture: architecture), + bootMedia: DoryBootMedia( + kind: media, + source: source, + artifactSHA256: resolvedArtifactSHA256, + mutableProvenance: resolvedMutableProvenance + ), + backend: backend, + graphics: graphics, + devices: devices + ) + let resolvedRuntimeQualification = trustedRuntimeQualification + ?? (automaticallyQualifyRuntime + ? makeRuntimeQualification(request: request, host: host) + : nil) + return DoryAppleSiliconCapabilityEvaluator.evaluate( + request, + host: host, + trustedGuestImageGraphicsQualification: resolvedGraphicsQualification, + trustedBootMediaInspection: resolvedInspection, + trustedMutableBootMediaProvenance: resolvedTrustedMutableProvenance, + trustedRuntimeQualification: resolvedRuntimeQualification + ) + } + + private func windows( + host: DoryAppleSiliconHostFacts + ) -> DoryVirtualMachineCapabilityDescriptor { + evaluate( + family: .windows, + media: .installerISO, + source: .userProvided, + backend: .qemuHypervisorFramework, + graphics: .software, + host: host + ) + } + + private func evaluateLinux3D( + mediaArtifactSHA256: String = guestArtifactSHA256, + trustedQualification: DoryTrustedGuestImageGraphicsQualification? + ) -> DoryVirtualMachineCapabilityDescriptor { + evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D, + mediaArtifactSHA256: mediaArtifactSHA256, + trustedGuestImageGraphicsQualification: trustedQualification, + automaticallyTrustGuestGraphics: false + ) + } + + private func trustedQualification( + signedManifestIdentity: String = "dory-linux-desktop-arm64-gpu-v1", + qualifiedArtifactSHA256: String = guestArtifactSHA256, + manifestSHA256: String = manifestSHA256, + signingKeyID: String = "dory-release-2026", + manifestFormatVersion: UInt16 = 1, + virtioGPUQualified: Bool = true, + venusVulkanQualified: Bool = true + ) -> DoryTrustedGuestImageGraphicsQualification { + DoryTrustedGuestImageGraphicsQualification( + auditEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: signedManifestIdentity, + artifactSHA256: qualifiedArtifactSHA256, + manifestSHA256: manifestSHA256, + signingKeyID: signingKeyID, + manifestFormatVersion: manifestFormatVersion + ), + virtioGPUKernelAndDeviceSupportQualified: virtioGPUQualified, + venusVulkanGuestRuntimeQualified: venusVulkanQualified + ) + } + + private func defaultArtifactSHA256( + family: DoryGuestFamily, + media: DoryBootMediaKind + ) -> String? { + switch (family, media) { + case (.linux, .installerISO): + Self.linuxISOArtifactSHA256 + case (.windows, .installerISO): + Self.windowsISOArtifactSHA256 + case (.macOS, .macOSRestoreImage): + Self.macOSRestoreArtifactSHA256 + default: + nil + } + } + + private func makeBootMediaInspection( + family: DoryGuestFamily, + architecture: DoryGuestArchitecture, + media: DoryBootMediaKind, + artifactSHA256: String? + ) -> DoryTrustedBootMediaInspection? { + guard media == .installerISO || media == .macOSRestoreImage, + let artifactSHA256 else { + return nil + } + return DoryTrustedBootMediaInspection( + auditEvidence: DoryBootMediaInspectionAuditEvidence( + inspectionIdentity: "inspection-\(family.rawValue)-\(media.rawValue)", + artifactSHA256: artifactSHA256, + inspectionReportSHA256: Self.inspectionReportSHA256, + inspectorID: "dory-media-inspector", + inspectorVersion: 1 + ), + detectedKind: media, + detectedGuestFamily: family, + detectedArchitecture: architecture, + isEFIBootable: true, + macOSBuildIdentifier: family == .macOS ? "25A123" : nil, + macOSHardwareModelCompatible: family == .macOS, + macOSAuxiliaryStorageCompatible: family == .macOS + ) + } + + private func makeTrustedMutableProvenance( + _ provenance: DoryMutableBootMediaProvenanceReference? + ) -> DoryTrustedMutableBootMediaProvenance? { + guard let provenance else { return nil } + return DoryTrustedMutableBootMediaProvenance( + auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "mutable-disk-receipt-\(provenance.mediaIdentity)", + provenance: provenance, + receiptSHA256: Self.provenanceReceiptSHA256, + resolverID: "dory-artifact-resolver", + resolverVersion: 1 + ) + ) + } + + private func makeRuntimeQualification( + request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts + ) -> DoryTrustedVirtualMachineRuntimeQualification? { + guard request.guest.family == .linux, + request.backend == .doryHypervisor + || request.backend == .appleVirtualizationFramework, + let hostContext = host.runtimeQualificationContext else { + return nil + } + let immutableArtifactSHA256 = request.bootMedia.kind == .virtualDisk + ? nil : request.bootMedia.artifactSHA256 + guard immutableArtifactSHA256 != nil || request.bootMedia.mutableProvenance != nil else { + return nil + } + return DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "linux-runtime-qualification-v1", + qualificationReportSHA256: Self.runtimeQualificationReportSHA256, + signingKeyID: "dory-runtime-qualification-2026", + qualificationFormatVersion: 1, + guest: request.guest, + bootMediaKind: request.bootMedia.kind, + immutableArtifactSHA256: immutableArtifactSHA256, + mutableProvenance: request.bootMedia.kind == .virtualDisk + ? request.bootMedia.mutableProvenance : nil, + backend: request.backend, + backendRuntimeBuildID: hostContext.runtimeBuildID(for: request.backend), + virtualHardwareABIVersion: request.virtualHardwareABIVersion, + graphics: request.graphics, + devices: request.devices + ), + runtimeQualified: true + ) + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift new file mode 100644 index 00000000..37625101 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -0,0 +1,556 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Virtual machine definition") +struct DoryVirtualMachineDefinitionTests { + private let gibibyte: UInt64 = 1_073_741_824 + private let nowMilliseconds: Int64 = 1_787_200_000_000 + + @Test("schema 2 round trips with stable resolver and timestamp representations") + func currentRoundTrip() throws { + let original = linuxDefinition() + #expect(original.isValid) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(original) + let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: data) + #expect(decoded == original) + + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"schemaVersion\":2")) + #expect(json.contains("\"virtualHardwareABIVersion\":1")) + #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000")) + #expect(json.contains("\"namespace\":\"boot\"")) + #expect(!json.contains("\"bootMedia\"")) + #expect(!json.contains("artifactID")) + #expect(!json.contains("hostLocationID")) + } + + @Test("oldest schema 1 golden JSON migrates deterministically") + func oldestSchemaMigration() throws { + let golden = Data(#""" + { + "schemaVersion": 1, + "identity": {"id":"legacy-vm","name":"Legacy Installer"}, + "guest": {"family":"linux","architecture":"arm64"}, + "workload": "installer", + "bootMedia": { + "role":"installer","kind":"installer-iso","source":"user-provided", + "artifactID":"install:ubuntu-24.04" + }, + "backendPreference": {"mode":"automatic"}, + "graphics": {"desiredLevel":"host-accelerated-display","allowsFallback":true}, + "resources": {"virtualCPUCount":4,"memoryBytes":8589934592,"diskBytes":68719476736}, + "storage": [{ + "id":"system","role":"system","artifactID":"disk:legacy-system", + "capacityBytes":68719476736,"readOnly":false + }], + "networkMode":"shared-nat", + "display":{"enabled":true,"widthPixels":1920,"heightPixels":1080,"pixelsPerInch":110}, + "audio":{"inputEnabled":false,"outputEnabled":true}, + "input":{"keyboardEnabled":true,"pointerEnabled":true}, + "shares":[], + "integrations":["dynamic-display","graceful-shutdown"], + "lifecycle":{"revision":1,"createdAt":721692800,"updatedAt":721692800} + } + """#.utf8) + + let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden) + #expect(migrated.schemaVersion == 2) + #expect(migrated.virtualHardwareABIVersion == 1) + #expect(migrated.workload == .desktop) + #expect(migrated.boot.phase == .install) + #expect(migrated.boot.order == ["installer"]) + #expect(migrated.boot.devices[0].artifact == reference("install", "ubuntu-24.04")) + #expect(migrated.graphics.acceptableLevels == [.hostAcceleratedDisplay, .software]) + #expect(migrated.lifecycle.createdAtUnixMilliseconds == 1_700_000_000_000) + #expect(migrated.isValid) + + let upgraded = try JSONEncoder().encode(migrated) + let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8)) + #expect(upgradedJSON.contains("\"schemaVersion\":2")) + #expect(upgradedJSON.contains("createdAtUnixMilliseconds")) + #expect(!upgradedJSON.contains("\"bootMedia\"")) + } + + @Test("install and live phases are independent from desktop or server workload") + func bootPhaseIndependentFromWorkload() { + let desktopInstaller = installerDefinition(family: .linux, workload: .desktop) + let serverInstaller = installerDefinition(family: .windows, workload: .server) + var liveWorkspace = installerDefinition(family: .linux, workload: .desktop) + liveWorkspace.boot.phase = .live + liveWorkspace.storage = [] + + #expect(desktopInstaller.isValid) + #expect(serverInstaller.isValid) + #expect(liveWorkspace.isValid) + + var legacy = desktopInstaller + legacy.workload = .installer + #expect(has(.legacyInstallerWorkload, "workload", in: legacy.validate())) + } + + @Test("recovery and boot order are explicit") + func bootOrderAndRecovery() { + var definition = recoveryDefinition() + #expect(definition.isValid) + + definition.boot.order = ["system", "recovery"] + #expect(has(.invalidBootPhase, "boot.phase", in: definition.validate())) + + definition.boot.order = ["recovery", "missing"] + #expect(has(.invalidBootOrder, "boot.order", in: definition.validate())) + + definition.boot.order = ["recovery", "system"] + definition.boot.devices[1].id = "recovery" + #expect(has( + .duplicateBootDeviceIdentifier, + "boot.devices[1].id", + in: definition.validate() + )) + } + + @Test("boot attachment role and removability are validated") + func bootAttachmentInvariants() { + var definition = installerDefinition(family: .linux) + definition.boot.devices[0].removable = false + #expect(has( + .invalidBootAttachment, + "boot.devices[0].removable", + in: definition.validate() + )) + + definition.boot.devices[0].removable = true + definition.boot.devices[0].role = .system + #expect(has(.bootMediaRoleMismatch, "boot.devices[0].role", in: definition.validate())) + #expect(has(.invalidBootPhase, "boot.phase", in: definition.validate())) + } + + @Test("guest families reject incompatible media", arguments: [ + (DoryGuestFamily.linux, DoryBootMediaKind.macOSRestoreImage), + (DoryGuestFamily.windows, DoryBootMediaKind.installedLinuxBootBundle), + (DoryGuestFamily.macOS, DoryBootMediaKind.installerISO), + ]) + func guestMediaCompatibility(family: DoryGuestFamily, kind: DoryBootMediaKind) { + var definition = linuxDefinition() + definition.guest.family = family + definition.boot.devices[0].kind = kind + #expect(has( + .bootMediaIncompatibleWithGuest, + "boot.devices[0].kind", + in: definition.validate() + )) + } + + @Test("Windows and macOS media cannot claim Dory redistribution") + func proprietaryMediaProvenance() { + var windows = installerDefinition(family: .windows) + windows.boot.devices[0].source = .bundledByDory + var macOS = installerDefinition(family: .macOS) + macOS.boot.devices[0].source = .bundledByDory + + #expect(has(.guestMediaCannotBeBundled, "boot.devices[0].source", in: windows.validate())) + #expect(has(.guestMediaCannotBeBundled, "boot.devices[0].source", in: macOS.validate())) + } + + @Test("schema identity lifecycle and positive resources are validated") + func foundationalInvariants() { + var definition = linuxDefinition() + definition.schemaVersion = 99 + definition.virtualHardwareABIVersion = 2 + definition.identity = DoryVirtualMachineIdentity(id: " ", name: "\n") + definition.resources = DoryVMResourceRequest( + virtualCPUCount: 0, + memoryBytes: 0, + diskBytes: 0 + ) + definition.lifecycle = DoryVMLifecycleMetadata( + revision: 0, + createdAtUnixMilliseconds: 0, + updatedAtUnixMilliseconds: -1 + ) + + let issues = definition.validate() + #expect(has(.unsupportedSchemaVersion, "schemaVersion", in: issues)) + #expect(has(.unsupportedVirtualHardwareABIVersion, "virtualHardwareABIVersion", in: issues)) + #expect(has(.emptyIdentifier, "identity.id", in: issues)) + #expect(has(.emptyName, "identity.name", in: issues)) + #expect(has(.nonPositiveResource, "resources.virtualCPUCount", in: issues)) + #expect(has(.nonPositiveResource, "resources.memoryBytes", in: issues)) + #expect(has(.nonPositiveResource, "resources.diskBytes", in: issues)) + #expect(has(.invalidLifecycleMetadata, "lifecycle", in: issues)) + } + + @Test("machine identifiers match the launch-safe MachineManager contract") + func machineIdentifierContract() { + for invalidID in [ + "-starts-with-dash", "contains+plus", "contains space", "é", + String(repeating: "a", count: 64), + ] { + var definition = linuxDefinition() + definition.identity.id = invalidID + #expect(has(.invalidIdentifier, "identity.id", in: definition.validate())) + } + } + + @Test("resolver references reject paths URLs controls and token-like values") + func hostileResolverReferences() { + let hostile = [ + reference("artifact", "/Users/me/image.iso"), + reference("artifact", "https://example.com/image.iso"), + reference("artifact", "C:\\images\\disk.raw"), + reference("artifact", "name\u{001F}control"), + reference("artifact", "sk-proj-123456"), + reference("artifact", "ghp_123456"), + reference("artifact", "eyJhbGciOiJIUzI1NiJ9"), + reference("Bad-Namespace", "image"), + reference("artifact", String(repeating: "a", count: 65)), + ] + + for hostileReference in hostile { + var boot = linuxDefinition() + boot.boot.devices[0].artifact = hostileReference + #expect(has( + .invalidResolverReference, + "boot.devices[0].artifact", + in: boot.validate() + )) + + var share = linuxDefinition() + share.shares[0].hostLocation = hostileReference + #expect(has( + .invalidResolverReference, + "shares[0].hostLocation", + in: share.validate() + )) + } + } + + @Test("storage identifiers capacities and one system disk are enforced") + func storageInvariants() { + var definition = linuxDefinition() + definition.storage.append(DoryVMStorageAttachment( + id: "system", + role: .system, + artifact: reference("disk", "duplicate"), + capacityBytes: 0, + readOnly: true + )) + let issues = definition.validate() + #expect(has(.duplicateAttachmentIdentifier, "storage[1].id", in: issues)) + #expect(has(.nonPositiveAttachmentCapacity, "storage[1].capacityBytes", in: issues)) + #expect(has(.multipleSystemDisks, "storage", in: issues)) + + definition.storage = [] + #expect(has(.missingSystemDisk, "storage", in: definition.validate())) + } + + @Test("artifact reuse is allowed only when every occurrence is read-only") + func storageArtifactAliasing() { + let shared = reference("disk", "shared-data") + + var readWriteThenReadOnly = linuxDefinition() + readWriteThenReadOnly.storage.append(storage("data-rw", artifact: shared, readOnly: false)) + readWriteThenReadOnly.storage.append(storage("data-ro", artifact: shared, readOnly: true)) + #expect(has( + .duplicateWritableStorageArtifactIdentity, + "storage[2].artifact", + in: readWriteThenReadOnly.validate() + )) + + var readOnlyThenReadWrite = linuxDefinition() + readOnlyThenReadWrite.storage.append(storage("data-ro", artifact: shared, readOnly: true)) + readOnlyThenReadWrite.storage.append(storage("data-rw", artifact: shared, readOnly: false)) + #expect(has( + .duplicateWritableStorageArtifactIdentity, + "storage[2].artifact", + in: readOnlyThenReadWrite.validate() + )) + + var allReadOnly = linuxDefinition() + allReadOnly.storage.append(storage("data-ro-1", artifact: shared, readOnly: true)) + allReadOnly.storage.append(storage("data-ro-2", artifact: shared, readOnly: true)) + #expect(!allReadOnly.validate().contains { + $0.code == .duplicateWritableStorageArtifactIdentity + }) + #expect(allReadOnly.isValid) + } + + @Test("system disk agrees with resource and virtual-disk boot identity") + func systemDiskAgreement() { + var definition = linuxDefinition(kind: .virtualDisk) + definition.storage[0].readOnly = true + definition.storage[0].capacityBytes = 63 * gibibyte + definition.storage[0].artifact = reference("disk", "other") + + let issues = definition.validate() + #expect(has(.readOnlySystemDisk, "storage[0].readOnly", in: issues)) + #expect(has(.systemDiskCapacityMismatch, "storage[0].capacityBytes", in: issues)) + #expect(has(.bootDiskArtifactMismatch, "boot.devices", in: issues)) + } + + @Test("system boot validation follows boot order and rejects ambiguous system devices") + func systemBootResolutionUsesPersistedOrder() { + let systemA = DoryVMBootMediaReference( + id: "system-a", + role: .system, + kind: .virtualDisk, + source: .userProvided, + artifact: reference("disk", "system-a"), + removable: false + ) + let systemB = DoryVMBootMediaReference( + id: "system-b", + role: .system, + kind: .virtualDisk, + source: .userProvided, + artifact: reference("disk", "system-b"), + removable: false + ) + + var arrayAOrderB = linuxDefinition(kind: .virtualDisk) + arrayAOrderB.storage[0].artifact = systemA.artifact + arrayAOrderB.boot.devices = [systemA, systemB] + arrayAOrderB.boot.order = [systemB.id, systemA.id] + let arrayAOrderBIssues = arrayAOrderB.validate() + #expect(has(.multipleSystemBootDevices, "boot.devices", in: arrayAOrderBIssues)) + #expect(has(.bootDiskArtifactMismatch, "boot.devices", in: arrayAOrderBIssues)) + + var arrayBOrderA = arrayAOrderB + arrayBOrderA.boot.devices = [systemB, systemA] + arrayBOrderA.boot.order = [systemA.id, systemB.id] + let arrayBOrderAIssues = arrayBOrderA.validate() + #expect(has(.multipleSystemBootDevices, "boot.devices", in: arrayBOrderAIssues)) + #expect(!has(.bootDiskArtifactMismatch, "boot.devices", in: arrayBOrderAIssues)) + } + + @Test("Unix mount paths are canonical and unique") + func unixSharePaths() { + for unsafePath in [ + "relative/path", "/srv/../secret", "/srv//source", "/srv/source/", "//srv/source", + ] { + var definition = linuxDefinition() + definition.shares[0].guestMountPath = unsafePath + #expect(has(.unsafeGuestMountPath, "shares[0].guestMountPath", in: definition.validate())) + } + + var duplicate = linuxDefinition() + duplicate.shares.append(share("source-copy", path: "/workspace/source")) + #expect(has(.duplicateGuestMountPath, "shares[1].guestMountPath", in: duplicate.validate())) + } + + @Test("Windows paths reject aliases and unsafe device components") + func windowsSharePaths() { + var definition = installerDefinition(family: .windows) + definition.shares = [share("source", path: "C:\\Workspace\\Source")] + #expect(definition.isValid) + + definition.shares.append(share("source-copy", path: "c:\\workspace\\source")) + #expect(has(.duplicateGuestMountPath, "shares[1].guestMountPath", in: definition.validate())) + definition.shares.removeLast() + + for unsafePath in [ + "Workspace\\Source", "C:\\Workspace\\..\\Secret", "C:/Workspace/Source", + "\\\\server\\share", "C:\\Workspace\\CON.txt", "C:\\Workspace\\CON .txt", + "C:\\Workspace\\PRN.data", "C:\\Workspace\\AUX.log", "C:\\Workspace\\NUL", + "C:\\Workspace\\COM1.txt", "C:\\Workspace\\LPT9.log", + "C:\\Workspace\\trailing.", "C:\\Workspace\\trailing ", + "C:\\Workspace\\control\u{001F}char", + ] { + definition.shares[0].guestMountPath = unsafePath + #expect(has(.unsafeGuestMountPath, "shares[0].guestMountPath", in: definition.validate())) + } + } + + @Test("graphics and backend preferences are explicit structural contracts") + func graphicsAndBackendPolicy() { + var definition = linuxDefinition() + definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: []) + #expect(has(.emptyGraphicsPolicy, "graphics.acceptableLevels", in: definition.validate())) + + definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software, .software]) + #expect(has( + .duplicateGraphicsLevel, + "graphics.acceptableLevels[1]", + in: definition.validate() + )) + + definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D]) + definition.backendPreference = DoryVMBackendPreference( + mode: .required, + backend: .doryHypervisor + ) + #expect(definition.isValid) + + definition.backendPreference = DoryVMBackendPreference(mode: .required, backend: nil) + #expect(has(.backendPreferenceMalformed, "backendPreference", in: definition.validate())) + } + + @Test("dynamic display integration requires an enabled display") + func dynamicDisplayRequiresDisplay() { + var definition = linuxDefinition() + definition.display = .disabled + #expect(has(.integrationRequiresDisplay, "integrations", in: definition.validate())) + + definition.integrations.removeAll { $0 == .dynamicDisplay } + #expect(definition.isValid) + } + + private func linuxDefinition( + kind: DoryBootMediaKind = .installedLinuxBootBundle + ) -> DoryVirtualMachineDefinition { + let bootArtifact = kind == .virtualDisk + ? reference("disk", "system") + : reference("boot", "ubuntu-24.04") + return DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: "vm-ubuntu-dev", name: "Ubuntu Development"), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", + role: .system, + kind: kind, + source: .userProvided, + artifact: bootArtifact, + removable: false + )], + order: ["system"] + ), + backendPreference: DoryVMBackendPreference( + mode: .preferred, + backend: .doryHypervisor + ), + graphics: DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D]), + resources: resources(), + storage: [DoryVMStorageAttachment( + id: "system", + role: .system, + artifact: reference("disk", "system"), + capacityBytes: 64 * gibibyte + )], + shares: [share("source", path: "/workspace/source")], + integrations: [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown], + lifecycle: lifecycle() + ) + } + + private func installerDefinition( + family: DoryGuestFamily, + workload: DoryVMWorkloadProfile = .desktop + ) -> DoryVirtualMachineDefinition { + let kind: DoryBootMediaKind = family == .macOS ? .macOSRestoreImage : .installerISO + let source: DoryBootMediaSource = family == .macOS ? .vendorDownload : .userProvided + return DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity( + id: "vm-\(family.rawValue)-installer", + name: "\(family.rawValue) Installer" + ), + guest: DoryGuestPlatform(family: family, architecture: .arm64), + workload: workload, + boot: DoryVMBootConfiguration( + phase: .install, + devices: [DoryVMBootMediaReference( + id: "installer", + role: .installer, + kind: kind, + source: source, + artifact: reference("install", family.rawValue), + removable: true + )], + order: ["installer"] + ), + graphics: DoryVMGraphicsPolicy( + acceptableLevels: [.hostAcceleratedDisplay, .software] + ), + resources: resources(), + storage: [DoryVMStorageAttachment( + id: "system", + role: .system, + artifact: reference("disk", family.rawValue), + capacityBytes: 64 * gibibyte + )], + lifecycle: lifecycle() + ) + } + + private func recoveryDefinition() -> DoryVirtualMachineDefinition { + var definition = linuxDefinition(kind: .virtualDisk) + definition.boot = DoryVMBootConfiguration( + phase: .recovery, + devices: [ + DoryVMBootMediaReference( + id: "recovery", + role: .recovery, + kind: .installerISO, + source: .userProvided, + artifact: reference("recovery", "ubuntu-rescue"), + removable: true + ), + DoryVMBootMediaReference( + id: "system", + role: .system, + kind: .virtualDisk, + source: .userProvided, + artifact: reference("disk", "system"), + removable: false + ), + ], + order: ["recovery", "system"] + ) + return definition + } + + private func resources() -> DoryVMResourceRequest { + DoryVMResourceRequest( + virtualCPUCount: 4, + memoryBytes: 8 * gibibyte, + diskBytes: 64 * gibibyte + ) + } + + private func storage( + _ id: String, + artifact: DoryVMResolverReference, + readOnly: Bool + ) -> DoryVMStorageAttachment { + DoryVMStorageAttachment( + id: id, + role: .data, + artifact: artifact, + capacityBytes: 10 * gibibyte, + readOnly: readOnly + ) + } + + private func share(_ id: String, path: String) -> DoryVMShare { + DoryVMShare( + id: id, + hostLocation: reference("bookmark", id), + guestMountPath: path + ) + } + + private func reference(_ namespace: String, _ identifier: String) -> DoryVMResolverReference { + DoryVMResolverReference(namespace: namespace, identifier: identifier) + } + + private func lifecycle() -> DoryVMLifecycleMetadata { + DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: nowMilliseconds, + updatedAtUnixMilliseconds: nowMilliseconds + ) + } + + private func has( + _ code: DoryVMDefinitionValidationCode, + _ field: String, + in issues: [DoryVMDefinitionValidationIssue] + ) -> Bool { + issues.contains(DoryVMDefinitionValidationIssue(code: code, field: field)) + } +} From c42bca09cb7b42181ca18ecc2dd3838814d15e5f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:34:12 +0000 Subject: [PATCH 008/338] ci(release): qualify accelerated Linux desktops --- .../verify-release-workflow-contract.py | 62 +++ .github/workflows/release.yml | 49 +- .gitignore | 3 + scripts/build-components.py | 10 +- scripts/build.sh | 89 +++- scripts/bundle-engine.sh | 130 ++++- scripts/desktop-linux-live-gate.sh | 492 ++++++++++++++++++ scripts/test-build-components.sh | 9 +- 8 files changed, 812 insertions(+), 32 deletions(-) create mode 100755 scripts/desktop-linux-live-gate.sh diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 03ef1f25..17a6a036 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -20,6 +20,68 @@ def require(text: str, value: str, message: str) -> None: bump = workflow.split(" bump-cask:", 1)[1].split("\n verify-public-release:", 1)[0] final = workflow.split(" verify-public-release:", 1)[1] +for distro in ("debian", "ubuntu", "kali"): + require( + workflow, + f'guest/desktop/build.sh arm64 "$distro"', + f"release workflow does not build the {distro} desktop from its commit", + ) + require( + workflow, + f"guest/out/dory-desktop-{distro}-rootfs-arm64.ext4.zst", + f"same-commit guest artifact omits the {distro} desktop", + ) + require( + workflow, + f'guest/desktop/verify-build.sh arm64 "$distro"', + f"release candidate does not verify the {distro} desktop", + ) +require(workflow, "guest/out/Image-desktop.zst", "same-commit guest artifact omits the desktop kernel") +require( + workflow, + "DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64", + "release candidate does not verify its desktop kernel", +) +require( + workflow, + "DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop", + "physical release gate does not receive its same-commit desktop kernel", +) +live_smoke = Path("scripts/release-candidate-live-smoke.sh").read_text(encoding="utf-8") +desktop_gate_path = Path("scripts/desktop-linux-live-gate.sh") +if not desktop_gate_path.stat().st_mode & 0o100: + raise SystemExit("release workflow contract: desktop live gate is not executable") +desktop_gate = desktop_gate_path.read_text(encoding="utf-8") +require( + live_smoke, + "scripts/desktop-linux-live-gate.sh", + "physical release smoke does not boot and exercise the managed desktops", +) +for distro in ("debian", "ubuntu", "kali"): + require( + live_smoke, + f'--{distro}-rootfs "$DESKTOP_{distro.upper()}_ROOTFS"', + f"physical desktop gate omits {distro}", + ) + require( + live_smoke, + f'--{distro}-update "$DESKTOP_{distro.upper()}_UPDATE"', + f"physical desktop gate omits the {distro} in-place update payload", + ) +for proof in ( + "browser-running", + "grep -q 'capture' /proc/asound/pcm", + "grep -q 'playback' /proc/asound/pcm", + "persistence-pass", + "Dory Wired", + "com.apple.security.device.audio-input", + 'grep -F "$VMM"', + "machine desktop-update", + "rollback-pass", + "last-good snapshot", +): + require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}") + require(candidate, "release-build/components/arm64/*", "candidate omits component payloads") require(publication, "release-build/components/arm64/*", "GitHub release omits component payloads") for name in ("catalog.json", "catalog.json.sha256", "catalog.json.sig"): diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cadde46..944c8bbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,12 +270,23 @@ jobs: DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64 DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 guest/initfs/verify-build.sh arm64 + - name: Build and verify every Linux desktop payload from this commit + run: | + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/build.sh arm64 "$distro" + done + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/verify-build.sh arm64 "$distro" + done - name: Upload same-commit arm64 guest payload uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dory-guest-arm64-${{ github.sha }} retention-days: 30 if-no-files-found: error + compression-level: 0 path: | guest/out/Image guest/out/Image.zst @@ -288,6 +299,21 @@ jobs: guest/out/initfs-arm64.ext4 guest/out/dory-agent-arm64 guest/out/initfs-build-arm64.stamp + guest/out/Image-desktop.zst + guest/out/config-arm64-desktop + guest/out/kernel-build-arm64-desktop.stamp + guest/out/dory-desktop-debian-rootfs-arm64.ext4.zst + guest/out/dory-desktop-debian-packages-arm64.txt + guest/out/dory-desktop-debian-update-arm64.tar + guest/out/dory-desktop-debian-build-arm64.stamp + guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4.zst + guest/out/dory-desktop-ubuntu-packages-arm64.txt + guest/out/dory-desktop-ubuntu-update-arm64.tar + guest/out/dory-desktop-ubuntu-build-arm64.stamp + guest/out/dory-desktop-kali-rootfs-arm64.ext4.zst + guest/out/dory-desktop-kali-packages-arm64.txt + guest/out/dory-desktop-kali-update-arm64.tar + guest/out/dory-desktop-kali-build-arm64.stamp prepublication-quality: name: macOS pre-publication quality gate @@ -375,9 +401,19 @@ jobs: path: guest/out - name: Independently verify every downloaded guest payload run: | + zstd -q -d --sparse -f guest/out/Image-desktop.zst -o guest/out/Image-desktop + for distro in debian ubuntu kali; do + zstd -q -d --sparse -f \ + "guest/out/dory-desktop-$distro-rootfs-arm64.ext4.zst" \ + -o "guest/out/dory-desktop-$distro-rootfs-arm64.ext4" + done DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64 DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 guest/initfs/verify-build.sh arm64 + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/verify-build.sh arm64 "$distro" + done - name: Prove the tracked release source exactly matches the commit run: | @@ -432,6 +468,9 @@ jobs: - name: Ensure Metal toolchain (SwiftTerm ships Metal shaders) run: xcodebuild -downloadComponent MetalToolchain || true + - name: Install pinned GPU renderer build prerequisites + run: brew install meson ninja pkgconf molten-vk libepoxy + - name: Preserve previous released appcast history run: | previous="$RUNNER_TEMP/previous-appcast.xml" @@ -591,7 +630,7 @@ jobs: printf 'path=%s\n' "$sparkle_source" >> "$GITHUB_OUTPUT" - name: Exercise the notarized direct-download candidate on a clean physical Mac - timeout-minutes: 30 + timeout-minutes: 60 env: DORY_RELEASE_CLEAN_USER: '1' DORY_RELEASE_EXTERNAL_VOLUME_ROOT: ${{ vars.DORY_EXTERNAL_VOLUME_TEST_ROOT }} @@ -604,6 +643,14 @@ jobs: DORY_RELEASE_CORPORATE_VPN_PROBE_HOST: ${{ vars.DORY_CORPORATE_VPN_PROBE_HOST }} DORY_RELEASE_CORPORATE_VPN_PROBE_URL: ${{ vars.DORY_CORPORATE_VPN_PROBE_URL }} DORY_RELEASE_TAILSCALE_EXIT_NODE: ${{ vars.DORY_TAILSCALE_EXIT_NODE }} + DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop + DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-debian-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_KALI_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-kali-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_DEBIAN_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-debian-update-arm64.tar + DORY_RELEASE_DESKTOP_UBUNTU_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-update-arm64.tar + DORY_RELEASE_DESKTOP_KALI_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-kali-update-arm64.tar + DORY_RELEASE_DESKTOP_VERSION: ${{ steps.ver.outputs.version }} run: | scripts/direct-dmg-install-gate.sh \ --dmg "${{ steps.build.outputs.dmg }}" \ diff --git a/.gitignore b/.gitignore index 28db686b..876491ce 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,9 @@ secrets* ExportOptions.plist # Release pipeline output +# Exact-candidate release gates are source, even when a checkout's private exclude file hides +# untracked scripts by default. +!/scripts/desktop-linux-live-gate.sh release-build*/ /release/ /dist/ diff --git a/scripts/build-components.py b/scripts/build-components.py index 90eca6db..ac6c7694 100755 --- a/scripts/build-components.py +++ b/scripts/build-components.py @@ -104,7 +104,7 @@ def validate_sources(repo: pathlib.Path, source_root: pathlib.Path, kubectl: pat run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo) run([str(repo / "guest/initfs/verify-build.sh"), "arm64"], cwd=repo) desktop_env = dict(os.environ) - desktop_env["DORY_KERNEL_PROFILE"] = "desktop" + desktop_env["DORY_KERNEL_PROFILE"] = "accelerated-desktop" run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo, env=desktop_env) for distro in ("debian", "ubuntu", "kali"): run([str(repo / "guest/desktop/verify-build.sh"), "arm64", distro], cwd=repo) @@ -137,7 +137,7 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di ( "ubuntu", "Ubuntu 24.04 LTS Desktop", - "An Ubuntu 24.04 LTS Xfce desktop with its own packages and official repositories.", + "Canonical's Ubuntu 24.04 LTS GNOME desktop with its own packages and official repositories.", ), ( "kali", @@ -170,6 +170,12 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di "delivery": "none", "executable": False, }, + { + "path": f"dory-desktop-{distro}-update-arm64.tar", + "source": source_root / f"dory-desktop-{distro}-update-arm64.tar", + "delivery": "none", + "executable": False, + }, ], } ) diff --git a/scripts/build.sh b/scripts/build.sh index 1eba9656..1ba75d05 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -47,6 +47,16 @@ if [ "$DESKTOP_BUNDLE_MODE" = all ] && [ "${DORY_BUILD_DEBUG_HELPERS:-1}" != 1 ] exit 64 fi +# Replacing or re-signing the same DerivedData bundle while its app, daemon, or helpers are +# executing can leave macOS provenance locks behind and corrupt the bundle's CodeResources. +# Fail before deleting any product so the caller can stop that runtime cleanly first. +running_product_pattern="$HOME/Library/Developer/Xcode/DerivedData/Dory-.*/Build/Products/$XCODE_CONFIGURATION/Dory\.app/Contents/" +if pgrep -f "$running_product_pattern" >/dev/null 2>&1; then + echo "error: a $XCODE_CONFIGURATION Dory build product is running; quit Dory and unload its development daemon before rebuilding" >&2 + pgrep -alf "$running_product_pattern" >&2 || true + exit 1 +fi + # shellcheck source=gvproxy-payload.sh source scripts/gvproxy-payload.sh @@ -185,7 +195,7 @@ bundle_debug_desktop_assets() { [ "$(uname -m)" = "arm64" ] || return 0 kernel="guest/out/Image-desktop" [ -f "$kernel" ] || { echo "error: all-inclusive build is missing $kernel" >&2; return 1; } - guest/kernel/verify-build.sh arm64 desktop >/dev/null || return 1 + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 >/dev/null || return 1 kernel_out="$app/Contents/Resources/dory-desktop-kernel-arm64.lzfse" if [ ! -f "$kernel_out" ] || [ "$kernel" -nt "$kernel_out" ]; then "$compressor" lzfse compress "$kernel" "$kernel_out" || return 1 @@ -278,6 +288,12 @@ bundle_debug_hv_helper() { com.apple.security.hypervisor PLIST + /usr/libexec/PlistBuddy \ + -c 'Add :com.apple.security.cs.disable-library-validation bool true' \ + "$entitlements" >/dev/null || return 1 + /usr/libexec/PlistBuddy \ + -c 'Add :com.apple.security.device.audio-input bool true' \ + "$entitlements" >/dev/null || return 1 for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do [ -d "$app" ] || continue @@ -334,6 +350,67 @@ PLIST rm -f "$gvproxy_tmp" "$gvproxy_tmp.provenance" } +bundle_debug_gpu_renderer() { + local renderer provenance molten_prefix molten molten_provenance epoxy icd app framework resources dylib dep + [ "${DORY_BUILD_DEBUG_HELPERS:-1}" = "1" ] || return 0 + renderer="$PWD/.build/virglrenderer/libvirglrenderer.dylib" + provenance="$PWD/.build/virglrenderer/provenance.txt" + molten_prefix="$PWD/.build/moltenvk" + molten_provenance="$molten_prefix/provenance.txt" + echo "note: building and bundling Dory's patched GPU renderer" >&2 + if [ ! -f "$molten_prefix/lib/libMoltenVK.dylib" ] \ + || ! nm -gU "$molten_prefix/lib/libMoltenVK.dylib" \ + | grep '_dory_moltenvk_spirv_native_array_fix$' >/dev/null; then + scripts/build-moltenvk.sh --prefix "$molten_prefix" --provenance "$molten_provenance" || return 1 + fi + DORY_MOLTENVK_PREFIX="$molten_prefix" scripts/build-virglrenderer.sh \ + --output "$renderer" --provenance "$provenance" || return 1 + molten="$molten_prefix/lib/libMoltenVK.dylib" + epoxy="$(brew --prefix libepoxy 2>/dev/null)/lib/libepoxy.0.dylib" + icd="$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json" + for dylib in "$renderer" "$molten" "$epoxy"; do + [ -f "$dylib" ] || { echo "error: missing GPU renderer dependency $dylib" >&2; return 1; } + done + [ -f "$icd" ] || { echo "error: missing MoltenVK_icd.json" >&2; return 1; } + + for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do + [ -d "$app" ] || continue + framework="$app/Contents/Frameworks" + resources="$app/Contents/Resources" + mkdir -p "$framework" "$resources/vulkan/icd.d" + install -m0755 "$renderer" "$framework/libvirglrenderer.dylib" + install -m0755 "$molten" "$framework/libMoltenVK.dylib" + install -m0755 "$epoxy" "$framework/libepoxy.0.dylib" + for dylib in "$framework/libvirglrenderer.dylib" \ + "$framework/libMoltenVK.dylib" \ + "$framework/libepoxy.0.dylib"; do + install_name_tool -id "@rpath/$(basename "$dylib")" "$dylib" 2>/dev/null || true + for dep in $(otool -L "$dylib" | awk 'NR > 1 {print $1}'); do + case "$dep" in + /opt/homebrew/*|/usr/local/*) + install_name_tool -change "$dep" "@rpath/$(basename "$dep")" "$dylib" 2>/dev/null || true ;; + esac + done + otool -l "$dylib" | grep -q 'path @loader_path ' \ + || install_name_tool -add_rpath @loader_path "$dylib" 2>/dev/null \ + || true + codesign --force -s - "$dylib" >/dev/null || return 1 + done + sed -E 's#"library_path"[[:space:]]*:[[:space:]]*"[^"]+"#"library_path": "@executable_path/../Frameworks/libMoltenVK.dylib"#' \ + "$icd" > "$resources/vulkan/icd.d/MoltenVK_icd.json" + install -m0644 "$provenance" "$resources/virglrenderer-provenance.txt" + install -m0644 "$molten_provenance" "$resources/moltenvk-provenance.txt" + echo "bundled_sha256=$(shasum -a 256 "$framework/libvirglrenderer.dylib" | awk '{print $1}')" \ + >> "$resources/virglrenderer-provenance.txt" + nm -gU "$framework/libvirglrenderer.dylib" \ + | grep '_dory_virglrenderer_macos_fragment_coord_fix$' >/dev/null || return 1 + nm -gU "$framework/libvirglrenderer.dylib" \ + | grep '_dory_virglrenderer_macos_venus_fence_fix$' >/dev/null || return 1 + nm -gU "$framework/libMoltenVK.dylib" \ + | grep '_dory_moltenvk_spirv_native_array_fix$' >/dev/null || return 1 + done +} + bundle_doryd_swiftpm_helpers() { local configuration bin_path entitlements app product helper vmm_app vmm_executable [ "${DORY_BUILD_DORYD_HELPERS:-1}" = "1" ] || return 0 @@ -352,7 +429,11 @@ bundle_doryd_swiftpm_helpers() { done bin_path="$(swift build --package-path dory-core-swift -c "$configuration" --show-bin-path 2>/dev/null)" - entitlements="dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements" + entitlements="$(mktemp "${TMPDIR:-/tmp}/dory-vmm-entitlements.XXXXXX")" || return 1 + cp dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements "$entitlements" || return 1 + /usr/libexec/PlistBuddy \ + -c 'Add :com.apple.security.cs.disable-library-validation bool true' \ + "$entitlements" >/dev/null || return 1 for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do [ -d "$app" ] || continue @@ -382,6 +463,7 @@ bundle_doryd_swiftpm_helpers() { "$app/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" plutil -lint "$app/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" >/dev/null done + rm -f "$entitlements" } bundle_debug_transfer_helper() { @@ -697,6 +779,9 @@ PLIST if [ "$status" -eq 0 ]; then bundle_debug_hv_helper || status=$? fi +if [ "$status" -eq 0 ]; then + bundle_debug_gpu_renderer || status=$? +fi if [ "$status" -eq 0 ]; then bundle_doryd_swiftpm_helpers || status=$? fi diff --git a/scripts/bundle-engine.sh b/scripts/bundle-engine.sh index 6225865b..33c75c61 100755 --- a/scripts/bundle-engine.sh +++ b/scripts/bundle-engine.sh @@ -15,8 +15,8 @@ # * Contents/Helpers/gvproxy — userspace networking (Apache-2.0) for the dory-hv engine. # * Contents/Helpers/docker, docker-buildx, docker-compose — Docker Core host CLIs. # * kubectl is exported as the independently installable Kubernetes component in Core builds. -# * Contents/Frameworks/libvirglrenderer.dylib, libMoltenVK.dylib — optional experimental -# Venus/Vulkan renderer payload for in-guest GPU acceleration. +# * Contents/Frameworks/libvirglrenderer.dylib, libMoltenVK.dylib — pinned VirGL/Venus renderer +# payload for in-guest GPU acceleration. # * Contents/Resources/dory-hv-kernel- — legacy raw kernel payload. # * Contents/Resources/dory-machine-rootfs-.ext4 — legacy raw machine payload. # * Contents/Resources/dory-hv-kernel-.lzfse — LZFSE PVH/Image kernel for dory-hv. @@ -300,13 +300,27 @@ fetch_url_stdout() { dylib_has_symbol() { local dylib="$1" symbol="$2" [ -f "$dylib" ] || return 1 - nm -gU "$dylib" 2>/dev/null | grep -q "_$symbol" + nm -gU "$dylib" 2>/dev/null | grep "_$symbol" >/dev/null +} + +virglrenderer_is_dory_compatible() { + local dylib="$1" + dylib_has_symbol "$dylib" dory_virglrenderer_macos_fragment_coord_fix || return 1 + dylib_has_symbol "$dylib" dory_virglrenderer_macos_venus_fence_fix || return 1 + otool -L "$dylib" 2>/dev/null | grep -q '@loader_path/libMoltenVK\.dylib' || return 1 + dylib_has_symbol "$dylib" virgl_renderer_resource_get_map_ptr \ + || dylib_has_symbol "$dylib" virgl_renderer_resource_map +} + +moltenvk_is_dory_compatible() { + dylib_has_symbol "$1" dory_moltenvk_spirv_native_array_fix } find_compatible_virglrenderer() { local cand for cand in "${DORY_VIRGLRENDERER_PATH:-}" \ "${DORY_VIRGLRENDERER:-}" \ + "$REPO_ROOT/release-build/virglrenderer/libvirglrenderer.dylib" \ "$FRAMEWORKS/libvirglrenderer.dylib" \ /opt/homebrew/lib/libvirglrenderer.dylib \ /opt/homebrew/opt/virglrenderer/lib/libvirglrenderer.dylib \ @@ -315,14 +329,13 @@ find_compatible_virglrenderer() { /usr/local/opt/virglrenderer/lib/libvirglrenderer.dylib \ /usr/local/opt/virglrenderer/lib/libvirglrenderer.1.dylib; do [ -n "$cand" ] && [ -f "$cand" ] || continue - # The Venus path maps host-visible blobs via virgl_renderer_resource_get_map_ptr (the - # libkrun/krunkit model); the slp/krunkit build exports it. resource_map is the fallback. - if dylib_has_symbol "$cand" virgl_renderer_resource_get_map_ptr \ - || dylib_has_symbol "$cand" virgl_renderer_resource_map; then + # Dory's markers prove that the macOS shader and async Venus fence fixes are present. The map + # entry point is also mandatory because Venus blobs must be exposed as host-visible memory. + if virglrenderer_is_dory_compatible "$cand"; then printf '%s\n' "$cand" return 0 fi - echo " WARNING: $cand exports no virgl_renderer_resource_get_map_ptr/resource_map; skipping for Venus GPU bundling" >&2 + echo " WARNING: $cand is not Dory's patched VirGL/Venus renderer; skipping it" >&2 done return 1 } @@ -356,20 +369,21 @@ find_moltenvk_icd() { find_moltenvk_dylib() { local icd="${1:-}" library_path cand base_dir relative_dir if [ -n "${DORY_MOLTENVK_DYLIB:-}" ] && [ -f "$DORY_MOLTENVK_DYLIB" ]; then - printf '%s\n' "$DORY_MOLTENVK_DYLIB" - return 0 + moltenvk_is_dory_compatible "$DORY_MOLTENVK_DYLIB" \ + && { printf '%s\n' "$DORY_MOLTENVK_DYLIB"; return 0; } fi if [ -n "$icd" ] && [ -f "$icd" ]; then library_path="$(sed -nE 's/.*"library_path"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$icd" | head -1)" if [ -n "$library_path" ]; then if [ "${library_path#/}" != "$library_path" ] && [ -f "$library_path" ]; then - printf '%s\n' "$library_path" - return 0 + moltenvk_is_dory_compatible "$library_path" \ + && { printf '%s\n' "$library_path"; return 0; } fi base_dir="$(cd "$(dirname "$icd")" && pwd)" relative_dir="$(dirname "$library_path")" cand="$(cd "$base_dir/$relative_dir" 2>/dev/null && pwd)/$(basename "$library_path")" - [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; } + [ -f "$cand" ] && moltenvk_is_dory_compatible "$cand" \ + && { printf '%s\n' "$cand"; return 0; } fi fi for cand in "$FRAMEWORKS/libMoltenVK.dylib" \ @@ -377,7 +391,8 @@ find_moltenvk_dylib() { /opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib \ /usr/local/lib/libMoltenVK.dylib \ /usr/local/opt/molten-vk/lib/libMoltenVK.dylib; do - [ -n "$cand" ] && [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; } + [ -n "$cand" ] && [ -f "$cand" ] || continue + moltenvk_is_dory_compatible "$cand" && { printf '%s\n' "$cand"; return 0; } done return 1 } @@ -436,18 +451,53 @@ warn_or_fail_missing_bundle_asset() { } bundle_venus_renderer() { - local virgl icd molten bundled_icd dylib - echo "==> Bundling experimental Venus GPU renderer (virglrenderer + MoltenVK)…" - if ! virgl="$(find_compatible_virglrenderer)"; then - warn_or_fail_optional_venus "no compatible libvirglrenderer.dylib found; install the slp/krunkit tap (brew install slp/krunkit/virglrenderer molten-vk libepoxy) or set DORY_VIRGLRENDERER_PATH to a Venus build that exports virgl_renderer_resource_get_map_ptr" + local virgl icd molten bundled_icd dylib provenance managed_virgl managed_provenance explicit_virgl sibling_provenance + local molten_prefix managed_molten_prefix molten_provenance + echo "==> Bundling Dory's pinned VirGL/Venus GPU renderer (virglrenderer + MoltenVK)…" + + managed_virgl="$REPO_ROOT/release-build/virglrenderer/libvirglrenderer.dylib" + managed_provenance="$REPO_ROOT/release-build/virglrenderer/provenance.txt" + managed_molten_prefix="$REPO_ROOT/release-build/moltenvk" + molten_prefix="${DORY_MOLTENVK_PREFIX:-$managed_molten_prefix}" + molten_provenance="$molten_prefix/provenance.txt" + if [ ! -f "$molten_prefix/lib/libMoltenVK.dylib" ] \ + || [ ! -f "$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json" ] \ + || ! moltenvk_is_dory_compatible "$molten_prefix/lib/libMoltenVK.dylib"; then + if ! "$REPO_ROOT/scripts/build-moltenvk.sh" \ + --prefix "$molten_prefix" \ + --provenance "$molten_provenance"; then + warn_or_fail_optional_venus "failed to build Dory's pinned MoltenVK runtime; install full Xcode and retry" + return 0 + fi + fi + molten="$molten_prefix/lib/libMoltenVK.dylib" + icd="$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json" + provenance="" + explicit_virgl="${DORY_VIRGLRENDERER_PATH:-${DORY_VIRGLRENDERER:-}}" + if [ -z "$explicit_virgl" ]; then + if ! DORY_MOLTENVK_PREFIX="$molten_prefix" "$REPO_ROOT/scripts/build-virglrenderer.sh" \ + --output "$managed_virgl" \ + --provenance "$managed_provenance"; then + warn_or_fail_optional_venus "failed to build Dory's pinned VirGL renderer; install meson, ninja, pkg-config, and libepoxy" + return 0 + fi + virgl="$managed_virgl" + provenance="$managed_provenance" + elif virglrenderer_is_dory_compatible "$explicit_virgl"; then + virgl="$explicit_virgl" + sibling_provenance="$(dirname "$explicit_virgl")/provenance.txt" + [ -f "$sibling_provenance" ] && provenance="$sibling_provenance" + else + warn_or_fail_optional_venus "explicit VirGL renderer $explicit_virgl is missing Dory's shader/fence fixes, bundled-MoltenVK linkage, or a required Venus map entry point" return 0 fi - if ! icd="$(find_moltenvk_icd)"; then - warn_or_fail_optional_venus "MoltenVK_icd.json not found; install/provide MoltenVK or set DORY_MOLTENVK_ICD" + + if ! virgl="$(find_compatible_virglrenderer)"; then + warn_or_fail_optional_venus "Dory's patched libvirglrenderer.dylib could not be built or found" return 0 fi - if ! molten="$(find_moltenvk_dylib "$icd")"; then - warn_or_fail_optional_venus "libMoltenVK.dylib not found; install/provide MoltenVK or set DORY_MOLTENVK_DYLIB" + if ! moltenvk_is_dory_compatible "$molten"; then + warn_or_fail_optional_venus "MoltenVK is missing Dory's SPIRV native-array compatibility fix" return 0 fi @@ -456,12 +506,32 @@ bundle_venus_renderer() { copy_dylib_dependency_closure "$virgl" libvirglrenderer.dylib copy_dylib_dependency_closure "$molten" libMoltenVK.dylib - if ! dylib_has_symbol "$FRAMEWORKS/libvirglrenderer.dylib" virgl_renderer_resource_get_map_ptr \ - && ! dylib_has_symbol "$FRAMEWORKS/libvirglrenderer.dylib" virgl_renderer_resource_map; then - warn_or_fail_optional_venus "bundled libvirglrenderer.dylib exports no virgl_renderer_resource_get_map_ptr/resource_map" + if ! virglrenderer_is_dory_compatible "$FRAMEWORKS/libvirglrenderer.dylib"; then + warn_or_fail_optional_venus "bundled libvirglrenderer.dylib lost Dory's compatibility markers or required Venus map entry point" return 0 fi + if [ -n "$provenance" ] && [ -f "$provenance" ]; then + install -m0644 "$provenance" "$RESOURCES/virglrenderer-provenance.txt" + else + { + echo "source=explicit" + echo "path=$virgl" + echo "verified_sha256=$(shasum -a 256 "$virgl" | awk '{print $1}')" + echo "features=venus,macos-core-shader-compat-v2,macos-venus-fence-callback-v1,moltenvk-native-array-v1" + } > "$RESOURCES/virglrenderer-provenance.txt" + fi + if [ -f "$molten_provenance" ]; then + install -m0644 "$molten_provenance" "$RESOURCES/moltenvk-provenance.txt" + else + { + echo "source=explicit" + echo "path=$molten" + echo "features=spirv-native-function-arrays-v1" + echo "verified_sha256=$(shasum -a 256 "$molten" | awk '{print $1}')" + } > "$RESOURCES/moltenvk-provenance.txt" + fi + bundled_icd="$RESOURCES/vulkan/icd.d/MoltenVK_icd.json" if grep -q '"library_path"' "$icd"; then sed -E 's#"library_path"[[:space:]]*:[[:space:]]*"[^"]+"#"library_path": "@executable_path/../Frameworks/libMoltenVK.dylib"#' "$icd" > "$bundled_icd" @@ -485,9 +555,14 @@ bundle_venus_renderer() { sign_runtime_payload "$dylib" done < <(find "$FRAMEWORKS" -maxdepth 1 -type f -name '*.dylib' -print) + echo "bundled_sha256=$(shasum -a 256 "$FRAMEWORKS/libvirglrenderer.dylib" | awk '{print $1}')" \ + >> "$RESOURCES/virglrenderer-provenance.txt" + echo " bundled Frameworks/libvirglrenderer.dylib (from $virgl)" echo " bundled Frameworks/libMoltenVK.dylib (from $molten)" echo " bundled Resources/vulkan/icd.d/MoltenVK_icd.json" + echo " bundled Resources/virglrenderer-provenance.txt" + echo " bundled Resources/moltenvk-provenance.txt" } find_debugfs() { @@ -750,7 +825,10 @@ if [ -d "$PKG" ]; then cat > "$DORY_HV_ENTITLEMENTS" <<'PLIST' -com.apple.security.hypervisor + + com.apple.security.hypervisor + com.apple.security.device.audio-input + PLIST bundle_swiftpm_executable "$PKG" release dory-hv "$HELPERS/dory-hv" "$DORY_HV_ENTITLEMENTS" rm -f "$DORY_HV_ENTITLEMENTS" diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh new file mode 100755 index 00000000..1add0c89 --- /dev/null +++ b/scripts/desktop-linux-live-gate.sh @@ -0,0 +1,492 @@ +#!/bin/bash +# Boot and exercise every managed desktop with the exact signed release candidate. This gate is +# intentionally destructive only to its uniquely named temporary machines and work directory. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +CTL="" +KERNEL="" +DEBIAN_ROOTFS="" +UBUNTU_ROOTFS="" +KALI_ROOTFS="" +DEBIAN_UPDATE="" +UBUNTU_UPDATE="" +KALI_UPDATE="" +DESKTOP_VERSION="" +SELECTED_DISTRO="all" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-desktop-linux-live" +CONFIRM="" + +usage() { + echo "usage: desktop-linux-live-gate.sh --ctl PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2 + exit 64 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --ctl) CTL="${2:?missing path}"; shift 2 ;; + --kernel) KERNEL="${2:?missing path}"; shift 2 ;; + --debian-rootfs) DEBIAN_ROOTFS="${2:?missing path}"; shift 2 ;; + --ubuntu-rootfs) UBUNTU_ROOTFS="${2:?missing path}"; shift 2 ;; + --kali-rootfs) KALI_ROOTFS="${2:?missing path}"; shift 2 ;; + --debian-update) DEBIAN_UPDATE="${2:?missing path}"; shift 2 ;; + --ubuntu-update) UBUNTU_UPDATE="${2:?missing path}"; shift 2 ;; + --kali-update) KALI_UPDATE="${2:?missing path}"; shift 2 ;; + --distro) SELECTED_DISTRO="${2:?missing distro}"; shift 2 ;; + --version) DESKTOP_VERSION="${2:?missing version}"; shift 2 ;; + --workroot) WORKROOT="${2:?missing path}"; shift 2 ;; + --confirm) CONFIRM="${2:?missing confirmation}"; shift 2 ;; + *) usage ;; + esac +done + +[ "$CONFIRM" = EXACT-CANDIDATE-DESKTOPS ] || usage +case "$SELECTED_DISTRO" in + all|debian|ubuntu|kali) ;; + *) echo "desktop live gate: unsupported distro selection: $SELECTED_DISTRO" >&2; exit 64 ;; +esac +[ -x "$CTL" ] || { echo "desktop live gate: missing dorydctl: $CTL" >&2; exit 66; } +HELPERS="$(cd "$(dirname "$CTL")" && pwd)" +VMM="$HELPERS/dory-hv" +VZ_VMM="$HELPERS/dory-vmm" +[ -x "$VMM" ] || { echo "desktop live gate: accelerated candidate dory-hv is missing: $VMM" >&2; exit 66; } +[ -x "$VZ_VMM" ] || { echo "desktop live gate: fallback candidate dory-vmm is missing: $VZ_VMM" >&2; exit 66; } +assets=("$KERNEL") +case "$SELECTED_DISTRO" in + all) assets+=("$DEBIAN_ROOTFS" "$UBUNTU_ROOTFS" "$KALI_ROOTFS" "$DEBIAN_UPDATE" "$UBUNTU_UPDATE" "$KALI_UPDATE") ;; + debian) assets+=("$DEBIAN_ROOTFS" "$DEBIAN_UPDATE") ;; + ubuntu) assets+=("$UBUNTU_ROOTFS" "$UBUNTU_UPDATE") ;; + kali) assets+=("$KALI_ROOTFS" "$KALI_UPDATE") ;; +esac +for asset in "${assets[@]}"; do + [ -s "$asset" ] || { echo "desktop live gate: missing asset: $asset" >&2; exit 66; } +done +absolute_asset() { + local asset_input="$1" + local asset_directory + asset_directory="$(cd "$(dirname "$asset_input")" && pwd -P)" + printf '%s/%s\n' "$asset_directory" "$(basename "$asset_input")" +} +KERNEL="$(absolute_asset "$KERNEL")" +case "$SELECTED_DISTRO" in + all) + DEBIAN_ROOTFS="$(absolute_asset "$DEBIAN_ROOTFS")" + UBUNTU_ROOTFS="$(absolute_asset "$UBUNTU_ROOTFS")" + KALI_ROOTFS="$(absolute_asset "$KALI_ROOTFS")" + DEBIAN_UPDATE="$(absolute_asset "$DEBIAN_UPDATE")" + UBUNTU_UPDATE="$(absolute_asset "$UBUNTU_UPDATE")" + KALI_UPDATE="$(absolute_asset "$KALI_UPDATE")" + ;; + debian) + DEBIAN_ROOTFS="$(absolute_asset "$DEBIAN_ROOTFS")" + DEBIAN_UPDATE="$(absolute_asset "$DEBIAN_UPDATE")" + ;; + ubuntu) + UBUNTU_ROOTFS="$(absolute_asset "$UBUNTU_ROOTFS")" + UBUNTU_UPDATE="$(absolute_asset "$UBUNTU_UPDATE")" + ;; + kali) + KALI_ROOTFS="$(absolute_asset "$KALI_ROOTFS")" + KALI_UPDATE="$(absolute_asset "$KALI_UPDATE")" + ;; +esac +printf '%s\n' "$DESKTOP_VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$' \ + || { echo "desktop live gate: invalid desktop version: $DESKTOP_VERSION" >&2; exit 64; } +case "$WORKROOT" in + ""|/|"$HOME"|"$ROOT"|"${RUNNER_TEMP:-/definitely-not-this-path}") + echo "desktop live gate: unsafe workroot: $WORKROOT" >&2 + exit 64 + ;; +esac +case "$WORKROOT" in + *dory*desktop*) ;; + *) echo "desktop live gate: workroot must identify the Dory desktop gate: $WORKROOT" >&2; exit 64 ;; +esac + +rm -rf "$WORKROOT" +mkdir -p "$WORKROOT/share" "$WORKROOT/evidence" +printf 'Dory desktop release gate\n' > "$WORKROOT/share/host-marker.txt" +codesign --verify --strict "$VMM" +codesign -d --entitlements :- "$VMM" > "$WORKROOT/evidence/dory-hv-entitlements.plist" 2>&1 +grep -q 'com.apple.security.hypervisor' "$WORKROOT/evidence/dory-hv-entitlements.plist" +grep -q 'com.apple.security.device.audio-input' "$WORKROOT/evidence/dory-hv-entitlements.plist" +grep -q 'com.apple.security.cs.disable-library-validation' "$WORKROOT/evidence/dory-hv-entitlements.plist" +codesign --verify --strict "$VZ_VMM" +codesign -d --entitlements :- "$VZ_VMM" > "$WORKROOT/evidence/dory-vmm-entitlements.plist" 2>&1 +grep -q 'com.apple.security.virtualization' "$WORKROOT/evidence/dory-vmm-entitlements.plist" +grep -q 'com.apple.security.device.audio-input' "$WORKROOT/evidence/dory-vmm-entitlements.plist" +grep -q 'NSMicrophoneUsageDescription' "$HELPERS/../Info.plist" +ACTIVE_MACHINE="" + +cleanup() { + result=$? + set +e + if [ -n "$ACTIVE_MACHINE" ]; then + "$CTL" machine stop "$ACTIVE_MACHINE" >/dev/null 2>&1 || true + "$CTL" machine delete "$ACTIVE_MACHINE" >/dev/null 2>&1 || true + fi + trap - EXIT INT TERM + exit "$result" +} +trap cleanup EXIT INT TERM + +exec_json() { + machine="$1" + shift + "$CTL" machine exec "$machine" --json --timeout-ms 120000 \ + --output-limit-bytes 262144 -- "$@" +} + +assert_exec_token() { + machine="$1" + token="$2" + shift 2 + output="$(exec_json "$machine" "$@")" + if ! printf '%s\n' "$output" | python3 -c ' +import json, sys +token = sys.argv[1] +body = json.load(sys.stdin) +assert body["exitCode"] == 0 and not body["timedOut"], body +assert token in body["stdout"], body +' "$token"; then + printf '%s\n' "$output" >&2 + return 1 + fi + printf '%s\n' "$output" +} + +wait_for_exec_token() { + machine="$1" + token="$2" + shift 2 + for attempt in $(seq 1 60); do + if output="$(assert_exec_token "$machine" "$token" "$@" 2>/dev/null)"; then + printf '%s\n' "$output" + return 0 + fi + sleep 1 + done + echo "desktop live gate: $machine did not satisfy $token readiness" >&2 + assert_exec_token "$machine" "$token" "$@" +} + +wait_for_desktop() { + machine="$1" + manager="$2" + session="$3" + for attempt in $(seq 1 120); do + if assert_exec_token "$machine" desktop-ready sh -lc \ + "systemctl is-active '$manager' >/dev/null && pgrep -u dorygate -x '$session' >/dev/null && echo desktop-ready" \ + >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + "$CTL" machine status "$machine" >&2 || true + echo "desktop live gate: $machine did not reach its graphical session" >&2 + return 1 +} + +wait_for_running() { + machine="$1" + for attempt in $(seq 1 240); do + if "$CTL" machine status "$machine" 2>/dev/null | python3 -c ' +import json, sys +body = json.load(sys.stdin) +raise SystemExit(0 if body.get("state") == "running" else 1) +'; then + return 0 + fi + sleep 0.25 + done + "$CTL" machine status "$machine" >&2 || true + echo "desktop live gate: $machine did not complete its ready handoff" >&2 + return 1 +} + +run_desktop() { + distro="$1" + rootfs="$2" + manager="$3" + session="$4" + browser="$5" + browser_pattern="$6" + browser_desktop="$7" + update_bundle="$8" + shift 8 + expected_apps="$*" + machine="dory-release-desktop-${distro}-$$" + ACTIVE_MACHINE="$machine" + + if "$CTL" machine status "$machine" >/dev/null 2>&1; then + echo "desktop live gate: refusing to overwrite existing machine $machine" >&2 + exit 1 + fi + + created="$WORKROOT/evidence/$distro-create.json" + "$CTL" machine create "$machine" \ + --kernel "$KERNEL" --rootfs "$rootfs" --memory-mb 4096 --cpus 4 \ + --display-mode desktop \ + --share "releasegate=$WORKROOT/share:/home/dorygate/Mac:ro" \ + --env "DORY_DESKTOP_DISTRO=$distro" \ + --env DORY_DESKTOP_VMM=accelerated \ + --env DORY_DESKTOP_GRAPHICS=virgl \ + --env DORY_GUEST_USER=dorygate --env DORY_GUEST_UID=1550 > "$created" + python3 - "$created" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +assert body["state"] == "created", body +assert body["displayMode"] == "desktop", body +PY + + "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-start.json" + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + "$CTL" machine status "$machine" > "$WORKROOT/evidence/$distro-running.json" + machine_pid="$(python3 - "$WORKROOT/evidence/$distro-running.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +assert body["state"] == "running" and body["displayMode"] == "desktop", body +print(body["pid"]) +PY +)" + ps -ww -p "$machine_pid" -o command= | grep -F "$VMM" \ + > "$WORKROOT/evidence/$distro-vmm-command.txt" + + app_checks="" + for app in $expected_apps; do + app_checks="$app_checks command -v '$app' >/dev/null;" + done + wait_for_exec_token "$machine" system-pass sh -lc " + set -eu + systemctl is-active '$manager' >/dev/null + systemctl is-active dory-zram.service >/dev/null + test \"\$(cat /run/dory/graphics-backend)\" = virgl2 + grep -q '^/dev/zram0 ' /proc/swaps + pgrep -u dorygate -x '$session' >/dev/null + desktop_uid=\$(id -u dorygate) + desktop_environment=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \ + systemctl --user show-environment) + printf '%s\n' \"\$desktop_environment\" | grep -Fqx 'MOZ_ENABLE_WAYLAND=0' + printf '%s\n' \"\$desktop_environment\" | grep -Fqx 'GSK_RENDERER=gl' + ethernet=\$(nmcli -t -f DEVICE,TYPE,STATE device status | awk -F: '\$2 ~ /^ethernet\$/ && \$3 ~ /^connected\$/ { print \$1; exit }') + test -n \"\$ethernet\" + ip -4 -o addr show dev \"\$ethernet\" | grep -q ' inet ' + test \"\$(nmcli -g GENERAL.CONNECTION device show \"\$ethernet\")\" = 'Dory Wired' + test \"\$(readlink /etc/resolv.conf)\" = ../run/NetworkManager/resolv.conf + getent ahostsv4 example.com >/dev/null + test \"\$(curl -4 -fsS --max-time 30 -o /dev/null -w '%{http_code}' https://example.com)\" = 200 + command -v '$browser' >/dev/null + command -v gio >/dev/null + test -f '$browser_desktop' + command -v xwininfo >/dev/null + $app_checks + grep -q 'virtio-snd' /proc/asound/cards + grep -q 'playback' /proc/asound/pcm + grep -q 'capture' /proc/asound/pcm + audio_status=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \ + wpctl status) + test \"\$(printf '%s\\n' \"\$audio_status\" | grep -Fc 'Dory Audio Pro')\" -ge 2 + runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \ + timeout 15 aplay -D pipewire -q -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/zero + runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \ + timeout 15 arecord -D pipewire -q -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/null + mountpoint -q /home/dorygate/Mac + grep -qx 'Dory desktop release gate' /home/dorygate/Mac/host-marker.txt + ! touch /home/dorygate/Mac/write-must-fail 2>/dev/null + printf persistence-pass > /home/dorygate/.dory-release-marker + echo system-pass + " > "$WORKROOT/evidence/$distro-system.json" + + assert_exec_token "$machine" browser-window-mapped sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|DBUS_SESSION_BUS_ADDRESS|XAUTHORITY|XDG_RUNTIME_DIR)=') + display=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + dbus=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p') + xauth=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + configured_runtime=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^XDG_RUNTIME_DIR=//p') + test -n \"\$display\" + test -n \"\$xauth\" + test \"\$configured_runtime\" = \"\$runtime\" + if [ '$distro' = ubuntu ]; then + favorites=\$(runuser -u dorygate -- env DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" \ + XDG_RUNTIME_DIR=\"\$runtime\" gsettings get org.gnome.shell favorite-apps) + printf '%s\\n' \"\$favorites\" | grep -Fq \"'firefox.desktop'\" + ! printf '%s\\n' \"\$favorites\" | grep -Fq 'firefox_firefox.desktop' + fi + runuser -u dorygate -- env DISPLAY=\"\$display\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" \ + XAUTHORITY=\"\$xauth\" XDG_RUNTIME_DIR=\"\$runtime\" MOZ_ENABLE_WAYLAND=0 \ + gio launch '$browser_desktop' https://example.com >/tmp/dory-release-browser.log 2>&1 + for _ in \$(seq 1 30); do + if pgrep -u dorygate -f '$browser_pattern' >/dev/null \ + && runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xwininfo -root -tree 2>/dev/null \ + | grep -Eq '\(\"Navigator\" \"firefox(-esr)?\"\)'; then + echo browser-running + echo browser-window-mapped + exit 0 + fi + sleep 1 + done + cat /tmp/dory-release-browser.log >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-browser.json" + + if [ "$distro" = ubuntu ]; then + assert_exec_token "$machine" gtk-windows-mapped sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|DBUS_SESSION_BUS_ADDRESS|XAUTHORITY|XDG_RUNTIME_DIR|GSK_RENDERER)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + dbus=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + renderer=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^GSK_RENDERER=//p') + test \"\$renderer\" = gl + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \ + XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \ + gio launch /usr/share/applications/org.gnome.Nautilus.desktop \ + >/tmp/dory-release-files.log 2>&1 + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \ + XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \ + gio launch /usr/share/applications/org.gnome.Calculator.desktop \ + >/tmp/dory-release-calculator.log 2>&1 + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \ + XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \ + gio launch /usr/share/applications/org.gnome.Settings.desktop \ + >/tmp/dory-release-settings.log 2>&1 + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \ + XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \ + gnome-terminal >/tmp/dory-release-terminal.log 2>&1 + for _ in \$(seq 1 30); do + windows=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xwininfo -root -tree 2>/dev/null) + if printf '%s\n' \"\$windows\" | grep -Fq '(\"org.gnome.Nautilus\" \"org.gnome.Nautilus\")' \ + && printf '%s\n' \"\$windows\" | grep -Fq '(\"gnome-calculator\" \"gnome-calculator\")' \ + && printf '%s\n' \"\$windows\" | grep -Fq '(\"gnome-control-center\" \"gnome-control-center\")' \ + && printf '%s\n' \"\$windows\" | grep -Eq '\(\"gnome-terminal-server\" \"Gnome-terminal(-server)?\"\)'; then + for process_pattern in '/usr/bin/nautilus' '/usr/bin/gnome-calculator' 'gnome-control-center'; do + process_id=\$(pgrep -n -u dorygate -f \"\$process_pattern\") + tr '\\0' '\\n' <\"/proc/\$process_id/environ\" | grep -Fqx 'GSK_RENDERER=gl' + done + echo gtk-windows-mapped + exit 0 + fi + sleep 1 + done + cat /tmp/dory-release-files.log /tmp/dory-release-calculator.log \ + /tmp/dory-release-settings.log /tmp/dory-release-terminal.log >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-gtk-windows.json" + fi + + "$CTL" machine stop "$machine" > "$WORKROOT/evidence/$distro-stop.json" + "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-restart.json" + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + assert_exec_token "$machine" persistence-pass sh -lc \ + "cat /home/dorygate/.dory-release-marker; mountpoint -q /home/dorygate/Mac" \ + > "$WORKROOT/evidence/$distro-persistence.json" + + "$CTL" machine desktop-update "$machine" \ + --distro "$distro" --version "$DESKTOP_VERSION" \ + --bundle "$update_bundle" --kernel "$KERNEL" \ + > "$WORKROOT/evidence/$distro-desktop-update.json" + python3 - "$WORKROOT/evidence/$distro-desktop-update.json" "$DESKTOP_VERSION" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +assert body["version"] == sys.argv[2], body +assert body["status"]["state"] == "running", body +assert len(body["inputSHA256"]) == 64, body +assert len(body["bundleSHA256"]) == 64, body +assert body["snapshotID"], body +PY + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + assert_exec_token "$machine" update-pass sh -lc \ + "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo update-pass" \ + > "$WORKROOT/evidence/$distro-update-qualified.json" + + bad_update="$WORKROOT/$distro-corrupt-update.tar" + printf 'not a tar archive\n' > "$bad_update" + if "$CTL" machine desktop-update "$machine" \ + --distro "$distro" --version "$DESKTOP_VERSION-fault" \ + --bundle "$bad_update" --kernel "$KERNEL" \ + > "$WORKROOT/evidence/$distro-rollback-stdout.json" \ + 2> "$WORKROOT/evidence/$distro-rollback-stderr.txt"; then + echo "desktop live gate: corrupt $distro update unexpectedly succeeded" >&2 + exit 1 + fi + grep -q 'last-good snapshot' "$WORKROOT/evidence/$distro-rollback-stderr.txt" + grep -q 'was restored' "$WORKROOT/evidence/$distro-rollback-stderr.txt" + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + assert_exec_token "$machine" rollback-pass sh -lc \ + "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo rollback-pass" \ + > "$WORKROOT/evidence/$distro-rollback-qualified.json" + + "$CTL" machine stop "$machine" >/dev/null + "$CTL" machine delete "$machine" >/dev/null + if "$CTL" machine status "$machine" >/dev/null 2>&1; then + echo "desktop live gate: temporary machine survived deletion: $machine" >&2 + exit 1 + fi + ACTIVE_MACHINE="" + printf '%s=PASS\n' "$distro" >> "$WORKROOT/evidence/desktop-results.txt" +} + +if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then + run_desktop debian "$DEBIAN_ROOTFS" lightdm xfce4-session firefox-esr \ + 'firefox-esr|/firefox' /usr/share/applications/firefox-esr.desktop "$DEBIAN_UPDATE" \ + xfce4-terminal thunar mousepad ristretto file-roller evince galculator +fi +if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = ubuntu ]; then + run_desktop ubuntu "$UBUNTU_ROOTFS" gdm3 gnome-shell firefox \ + 'firefox' /usr/share/applications/firefox.desktop "$UBUNTU_UPDATE" \ + gnome-terminal nautilus gnome-text-editor eog file-roller evince \ + gnome-calculator gnome-control-center +fi +if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = kali ]; then + run_desktop kali "$KALI_ROOTFS" lightdm xfce4-session firefox-esr \ + 'firefox-esr|/firefox' /usr/share/applications/firefox-esr.desktop "$KALI_UPDATE" \ + xfce4-terminal thunar mousepad ristretto file-roller atril +fi + +{ + printf 'source_commit=%s\n' "${GITHUB_SHA:-local}" + printf 'distros=%s\n' "$SELECTED_DISTRO" + printf 'kernel_sha256=%s\n' "$(shasum -a 256 "$KERNEL" | awk '{print $1}')" + if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then + printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" + printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" + fi + if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = ubuntu ]; then + printf 'ubuntu_rootfs_sha256=%s\n' "$(shasum -a 256 "$UBUNTU_ROOTFS" | awk '{print $1}')" + printf 'ubuntu_update_sha256=%s\n' "$(shasum -a 256 "$UBUNTU_UPDATE" | awk '{print $1}')" + fi + if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = kali ]; then + printf 'kali_rootfs_sha256=%s\n' "$(shasum -a 256 "$KALI_ROOTFS" | awk '{print $1}')" + printf 'kali_update_sha256=%s\n' "$(shasum -a 256 "$KALI_UPDATE" | awk '{print $1}')" + fi + printf 'status=PASS\n' +} > "$WORKROOT/evidence/manifest.txt" + +echo "Desktop Linux exact-candidate live gate: PASS ($WORKROOT/evidence/manifest.txt)" diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh index f7e6cb71..5ae3265c 100755 --- a/scripts/test-build-components.sh +++ b/scripts/test-build-components.sh @@ -27,7 +27,14 @@ write_fixture "$SOURCE/Image-desktop" 196608 for distro in debian ubuntu kali; do write_fixture "$SOURCE/dory-desktop-$distro-rootfs-arm64.ext4" 327680 printf 'schema=fixture\n' > "$SOURCE/dory-desktop-$distro-build-arm64.stamp" - printf 'xfce4\tfixture\n' > "$SOURCE/dory-desktop-$distro-packages-arm64.txt" + if [ "$distro" = ubuntu ]; then + printf 'ubuntu-desktop-minimal\tfixture\nubuntu-session\tfixture\ngdm3\tfixture\n' \ + > "$SOURCE/dory-desktop-$distro-packages-arm64.txt" + else + printf 'xfce4\tfixture\nlightdm\tfixture\n' \ + > "$SOURCE/dory-desktop-$distro-packages-arm64.txt" + fi + write_fixture "$SOURCE/dory-desktop-$distro-update-arm64.tar" 4096 done printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp" From 9ea0b6819b215e5c9dbddad8d26d05c1152e4812 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:34:48 +0000 Subject: [PATCH 009/338] docs: define Linux desktop parity and support status --- CHANGELOG.md | 41 ++++++++++++++ COMPATIBILITY.md | 25 +++++---- LINUX_DESKTOP_PARITY.md | 81 ++++++++++++++++++++++++++++ README.md | 44 +++++++++++---- website/index.html | 2 +- website/public/agent-guide.json | 2 +- website/public/docs/agents.md | 2 +- website/public/docs/architecture.md | 7 +-- website/public/docs/compatibility.md | 14 +++-- website/public/docs/operations.md | 2 +- website/public/llms-full.txt | 6 +-- website/public/llms.txt | 2 +- website/src/App.tsx | 26 ++++----- 13 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 LINUX_DESKTOP_PARITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aef2d159..88958c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## Unreleased +### Changed + +- Added custom arm64 Linux installation from a user-selected ISO. Dory now imports the installer + into private managed storage, creates a configurable thin-provisioned disk, boots through native + EFI with a stable VM identity and persistent NVRAM, and lets users eject or reattach installer + media from the machine menu. +- Extended custom EFI snapshots to capture and transactionally restore the VM disk, machine + identifier, and NVRAM together. EFI snapshot exports use an integrity-checked portable bundle, + while clones retain EFI boot variables and receive a new hardware identity. +- Enabled full-screen participation and system-key capture in the native Linux display window. + +- Rebuilt the Ubuntu 24.04 LTS desktop component around Canonical's GNOME session, Yaru themes, + Ubuntu Dock, Files, Settings, standard desktop utilities, and a working native-package browser + instead of presenting the shared Xfce profile as Ubuntu Desktop. +- Completed Debian's everyday desktop application set with Firefox ESR, Evince, and Galculator. +- Enabled explicit bidirectional SPICE clipboard transport and added a VirtIO microphone input + stream alongside desktop speaker output. +- Made release candidates rebuild and verify the desktop kernel and all three desktop root filesystems + from the release commit instead of accepting pre-existing local guest artifacts. +- Added a physical-Mac release gate that boots every managed desktop with the signed candidate + helper, launches its browser, and verifies applications, networking, audio devices, persistence, + read-only sharing, entitlements, and exact process provenance. +- Added signed, versioned in-place updates for existing Debian, Ubuntu, and Kali desktops. Dory now + preserves the persistent guest disk and account, creates a last-good snapshot, updates packages, + browser and guest integration, boots the new desktop kernel, qualifies the graphical session, + and automatically rolls back any failed or interrupted update. +- Extended the physical-Mac release gate to apply each exact desktop update payload and prove that + a corrupt payload restores the last-good snapshot without losing guest data or running state. + +### Fixed + +- Stopped Firefox and Firefox ESR from producing blank, black, or duplicated desktop frames on + Dory's current macOS VirGL renderer. Managed desktops now apply Mozilla's supported + per-browser software-compositing fallback while leaving the Linux desktop and other compatible + applications on the accelerated graphics path. +- Made the desktop VM's virtio Ethernet adapter an explicit NetworkManager connection. Ubuntu's + vendor policy no longer leaves the guest offline when no Netplan-generated profile exists. +- Made that managed connection independent of the guest's interface name, covering both Debian and + Ubuntu's `enp0s1` and Kali's `eth0` without falling back to an auto-generated profile. +- Fixed `machine create` reporting a newly created desktop as `headless` until its first status refresh. + ## 0.4.5 - 2026-08-13 ### Fixed diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d4f78702..152189ea 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -68,20 +68,26 @@ engine resources. | Capability | Status | |---|---| -| Guest OS | Managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 | +| Guest OS | Managed Ubuntu 24.04 LTS GNOME, Debian 13 Xfce, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 | | Access | Configurable desktop user, graphical session, embedded or selected external terminal, `dory machine shell`, and command execution | | Resources | CPU and memory configuration with guest-reported statistics | | Snapshots and export/import | Supported | | Scheduled local recovery bundles | Supported; owner-only durable schedules, archive re-import verification on every run, periodic disposable boot verification, and scheduler-owned retention | | Managed remote/offsite machine backup | **Unavailable**; Dory does not claim an S3 or hosted backup service | | Development recipes | Curated Node, Python, Go, Rust, Java, Ruby, and DevOps toolsets for Debian and Alpine | -| Graphical Linux sessions | Supported with managed Debian, Ubuntu, and Kali Xfce profiles on Apple Silicon | -| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching Xfce scaling | - -Desktop machines run normal graphical and command-line applications with glibc and systemd. Their -disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use Alpine, -musl, `root`, and `/bin/sh`. Arbitrary desktop images and guest kernel modules are not part of the -current contract. +| Graphical Linux sessions | Supported with managed Ubuntu GNOME and Debian/Kali Xfce profiles on Apple Silicon | +| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching GNOME or Xfce scaling | +| Desktop Vulkan | Supported through Dory's isolated, capability-probed Venus runtime on Apple-silicon raw-HV; Automatic falls back to classic VirGL and software when unavailable | +| Existing desktop updates | Signed in-place package, browser, guest-integration, and kernel updates with a retained last-good snapshot and automatic failure/interruption rollback | +| Custom arm64 installer ISO | Preview: architecture preflight, exact-media SHA-256/runtime evidence, native EFI boot, private managed ISO copy and recovery console, thin-provisioned disk, persistent machine identity/NVRAM, and attach/eject lifecycle | + +Managed desktop machines run normal graphical and command-line applications with glibc and systemd. +Their disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use +Alpine, musl, `root`, and `/bin/sh`. Custom arm64 ISO installation is preview until real-distribution +installation, reboot, media-ejection, device, and guest-tools qualification passes on physical Macs. +Architecture compatibility does not imply runtime qualification: Dory records the exact ISO hash, +host model, and macOS build, blocks known-unstable tuples, and labels unseen combinations as +unqualified. ## Networking @@ -127,7 +133,7 @@ grants; `full` is an explicit unrestricted choice. See the ## Preview -- In-guest Venus/Vulkan acceleration is preview on the Apple-silicon raw-HV tier. +- Custom arm64 Linux installation from ISO through EFI is preview pending exact-candidate physical-Mac qualification. Ubuntu 24.04.3 and 24.04.4 ARM64 are known unstable on Mac14,10/macOS build 26A5406e with Dory's retired VirtIO-block EFI profile. Under the current native-NVMe/fsync profile, Ubuntu 24.04.3 completed installation, package updates, ISO ejection, and persistent GNOME/Chrome boot, but a later whole-guest stall during Chromium snap installation keeps that exact tuple unqualified. - Remote SSH workspace foundations and custom machine kernel/rootfs inputs remain preview with the exact limits reported by `dory agent guide --json`. @@ -137,7 +143,6 @@ grants; `full` is an explicit unrestricted choice. See the engine fails closed until a complete guest USB/IP RPC and physical qualification exist. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. -- Desktop images beyond the managed Debian, Ubuntu, and Kali Xfce profiles are unavailable. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are unavailable in 0.4. diff --git a/LINUX_DESKTOP_PARITY.md b/LINUX_DESKTOP_PARITY.md new file mode 100644 index 00000000..db4d97a1 --- /dev/null +++ b/LINUX_DESKTOP_PARITY.md @@ -0,0 +1,81 @@ +# Linux desktop parity contract + +This document defines Dory's release bar for a Parallels-class Linux experience on Apple Silicon. +The comparison target is the Linux feature set in Parallels Desktop 26, not its Windows-only +features. A capability is not considered shipped because code or a package exists: it must pass an +automated test in the signed release candidate and a live test on physical supported Macs. + +Primary references: + +- [Parallels Tools overview](https://docs.parallels.com/landing/pdfm-ug/parallels-desktop-for-mac-26-users-guide/advanced-topics/installing-and-updating-parallels-tools/parallels-tools-overview) +- [Parallels Apple-silicon limitations](https://kb.parallels.com/en/128914) +- [Parallels Linux OpenGL support](https://kb.parallels.com/124138) +- [Parallels Virtio GPU and VirGL](https://kb.parallels.com/en/128518) +- [Apple GUI Linux VM reference](https://developer.apple.com/documentation/virtualization/running-gui-linux-in-a-virtual-machine-on-a-mac) +- [Apple shared directories](https://developer.apple.com/documentation/virtualization/shared-directories) +- [Apple clipboard sharing](https://developer.apple.com/documentation/virtualization/clipboard-sharing) +- [Apple VM audio](https://developer.apple.com/documentation/virtualization/audio) +- [Apple Intel-binary translation in Linux](https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms) + +## Non-negotiable release contract + +| Area | Required behavior | Current state | +|---|---|---| +| Official desktops | Ubuntu uses Canonical GNOME and Yaru; Debian and Kali use complete, distro-native Xfce sessions | Ubuntu's same-commit image and signed-app candidate passed the physical-Mac live gate, including restart, persistence, guest-tools update, corrupt-update rejection, rollback, and post-rollback qualification; Debian and Kali still need the same candidate-bound run | +| Applications | Each managed desktop includes a browser, terminal, files, settings, editor, archive viewer, image viewer, PDF viewer, package sources, and can install and launch more apps | Ubuntu's signed candidate mapped and rendered Firefox, Files, Settings, Calculator, and Terminal correctly; Debian and Kali plus the broader app set still need candidate-bound evidence | +| Custom Linux | Create an ARM64 Linux VM from a user-selected ISO through EFI, with persistent NVRAM and install media lifecycle | The signed app securely stages protected user-selected media, fingerprints the exact bytes, reports architecture compatibility separately from runtime qualification, imports accepted media into private daemon-managed storage, creates a thin disk, and boots through persistent EFI. Desktop ISO machines use balanced 4-vCPU/4-GB resource defaults rather than treating a CPU count as a compatibility workaround. A private bidirectional serial console gives every EFI boot durable diagnostics and recovery input. The EFI root disk is now native NVMe with fsync semantics; Dory-owned direct-kernel guests retain their VirtIO-block contract. Ubuntu 24.04.3 ARM64 completed installation and booted its persistent desktop after ISO ejection, but a later whole-guest stall during a Chromium snap installation keeps the exact runtime unqualified | +| Native and Intel apps | Native ARM64 apps work normally; supported x86_64 Linux applications use Apple's Linux translation runtime with guided setup and clear compatibility reporting | Missing for desktop machines | +| Display | Retina rendering, dynamic resolution during resize, full screen, correct scaling, cursor integration, and multi-display support | Single-display Retina, resize, native full-screen participation, and system-key capture exist; full qualification and multi-display are missing | +| Graphics | Hardware-accelerated Linux graphics meeting Parallels' advertised OpenGL level, with a software fallback and an application compatibility suite | Raw-HV desktop VirGL2/Venus rendering and Metal scanout are implemented; GTK4 uses its qualified accelerated GL renderer to avoid newer-renderer texture corruption, Firefox uses its reliable XWayland presentation path, and Files/Settings/Calculator/Firefox are locally normal. The broader OpenGL suite and rebuilt exact-candidate gate remain | +| Clipboard | Bidirectional text and image clipboard with an explicit off/host-to-guest/guest-to-host/bidirectional policy | Binary-safe host/guest transport, native shortcuts, focus synchronization, Wayland/X11 adapters, and policy UI are implemented; all-distro live and exact-candidate qualification remain | +| Drag and drop | Bidirectional file drag and drop between Finder and the Linux desktop with conflict, cancellation, and progress handling | Missing | +| Shared folders | Add/remove read-only or read-write Mac folders, stable guest paths, permissions, large-file tests, file watching, and safe runtime updates | Scoped VirtioFS shares exist; runtime mutation and complete compatibility qualification are missing | +| Audio | Speaker output and microphone input, device/permission handling, mute, reconnect, and host sleep/wake recovery | Output and microphone devices plus signing/privacy declarations added; exact-candidate capture, controls, and recovery qualification pending | +| Devices | USB storage plus qualified physical USB attachment/detachment and remembered routing; camera and removable-media behavior is explicit | Host discovery only; passthrough missing | +| Networking | NAT/shared networking, bridged networking, host-only networking, stable addressing, DNS/VPN behavior, port forwarding, and offline recovery | NAT and Dory addressing exist; per-machine bridged and host-only modes are missing | +| Lifecycle | Graceful stop, pause/resume, durable suspend-to-disk, host sleep/wake, crash recovery, and resource reconfiguration | Managed guests shut down through the Dory agent; arbitrary EFI guests now fall back to Virtualization.framework's native virtual power-button request so filesystems can flush without guest tools. Pause foundations and disk persistence exist; saved machine state is missing | +| Data safety | Consistent snapshots, restore, clone, linked clone, export/import, and corruption/interruption recovery | Snapshot, restore, clone, and export/import include EFI machine identity/NVRAM with transactional rollback and bundle integrity checks; linked clones and live-state consistency need work | +| Guest tools | Versioned Dory guest tools update automatically and provide clipboard, resize, shares, time sync, drag/drop, telemetry, and compatibility health | Agent and Dory-owned integration files, including clipboard adapters, ship in one offline, deterministic desktop-update transaction. The signed package list is provenance and a compatibility preflight, not permission to run a hidden distribution upgrade; drag/drop and a unified compatibility-health surface remain missing | +| Updates | Existing desktops receive tested guest-tools and integration updates in place with rollback, while normal distro package and application updates remain available inside the guest; recreating the VM is never the upgrade path | Durable daemon journals, retained last-good snapshots, automatic failure/interruption rollback, and UI activation exist. The Ubuntu signed candidate passed the schema-v2 offline guest-tools update, corrupt-bundle rejection, last-good rollback, and post-rollback requalification; distro package-update UX and the remaining distros still need release evidence | +| Release provenance | Desktop kernel and every rootfs are built and verified from the release commit, signed into the component catalog, then boot-tested from that exact catalog | Same-commit build plus signed-helper physical-Mac boot gate added; the workflow must still pass before release | + +## Current installer-media evidence + +- Ubuntu 24.04.4 Desktop ARM64 (`c2610520bf582976839a1724c669e1cfed0547427be5a0ad12d457b92b46ffbe`) is architecture-compatible but runtime-known-unstable on Mac14,10 with macOS build 26A5406e and Dory's retired `vz-efi-virtio-blk-v1` profile. This exact media/host/profile tuple panicked or froze at one, two, and six vCPUs, so Dory blocks that tuple instead of presenting a resource-count workaround. It has not yet been qualified with the materially different native-NVMe profile. +- Ubuntu 24.04.3 Desktop ARM64 (`cdbf0f83ab4f7d46be767e73c59b5cbca9743dd5fb887142c96f4b2df38fa5ad`) also froze during installation with the retired VirtIO-block EFI profile. With `vz-efi-nvme-fsync-v1`, its 6.14.0-27 installer completed `curtin_install`, post-install configuration, and unattended package updates against a 64-GB NVMe disk. After ejecting the ISO, the installed system cold-booted, reached GNOME, retained Chrome, and browsed HTTPS over the VirtIO network. A later whole-guest stall was observed while App Center installed the Chromium snap, so this exact native-NVMe tuple remains unqualified pending a reproduced kernel/runtime diagnosis and sustained application stress pass. +- The user-supplied Omarchy 4.0.0 ISO (`9224fab3720560f771969a99a499e5f7e0f8e2d6a0681d872d52f05fb5003da4`) is x86_64 EFI-only. Dory rejects it before disk allocation or ISO staging with: `This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO.` This is the same whole-guest architecture limit documented for hardware-virtualized Linux on Apple silicon; native Omarchy qualification requires upstream ARM64 EFI media. + +## Qualification matrix + +Every supported managed desktop and every custom-ISO path must pass the following on each supported +macOS major version and qualified Apple-silicon generation: + +1. Cold boot, login, shutdown, restart, host sleep/wake, pause/resume, and durable suspend/restore. +2. DHCP, DNS, IPv4/IPv6, VPN route changes, host-only connectivity, bridged connectivity, and port forwarding. +3. Browser HTTPS navigation, package update, GUI package installation, application launch, file open/save, and reboot persistence. +4. Window resize, full screen, Retina scaling, cursor capture/release, keyboard shortcuts, clipboard text/image, and drag/drop in both directions. +5. Speaker playback, microphone recording, shared-folder read/write and read-only enforcement, USB attach/detach, and removable media. +6. OpenGL compatibility, accelerated rendering correctness, software fallback, and representative developer/creative GUI workloads. +7. Snapshot, restore, clone, export/import, interrupted-operation recovery, and low-disk behavior. +8. Native ARM64 and supported x86_64 application execution with architecture and failure diagnostics. +9. Upgrade from every supported prior Dory desktop image without losing user accounts, applications, + settings, shared-folder configuration, or workload data; failed updates must roll back cleanly. + +Evidence must identify the release commit, app digest, component catalog digest, desktop image digest, +Mac model, macOS version, guest distribution, guest package manifest, and pass/fail result. + +## Platform-equivalent scope + +The contract does not require a capability that Parallels itself marks unavailable for Linux on +Apple Silicon, such as nested virtualization or booting a complete Intel Linux distribution through +hardware virtualization. It does require supported Intel Linux *applications* inside ARM Linux. +Windows-only Coherence is not a Linux parity requirement. A beta-only host API does not count as a +shipping capability; Dory must either provide a qualified fallback or mark that host version +unsupported for the parity release. + +## Release rule + +Dory must not describe its Linux experience as Parallels-equivalent while any required row is +missing or only preview. The next public desktop release may ship when every required capability is +either `PASS` in candidate-bound evidence or explicitly outside Parallels' own Apple-silicon Linux +contract. diff --git a/README.md b/README.md index b5b5648e..845f8bcc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you |---|---| | Docker | Docker 29 API and CLI, Buildx, BuildKit, Compose v2, registries, bind mounts, volumes, and custom networks | | Native app | Containers, images, volumes, networks, Compose projects, Kubernetes, Linux machines, health, migration, and settings | -| Linux machines | Full Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with configurable resources, scoped mounts, networking, recipes, snapshots, clone, import, and export | +| Linux machines | Full Ubuntu 24.04 LTS GNOME, Debian 13 Xfce, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with configurable resources, scoped mounts, networking, recipes, snapshots, clone, import, and export | | Kubernetes | One-click k3s with selectable v1.34, v1.35, and v1.36 presets plus a native resource browser | | Migration | Transactional full or exact-selection import from Docker Desktop, OrbStack, Colima, Rancher Desktop, Podman, or another Docker-compatible socket, with a selected/verified/omitted completeness report | | Storage | One managed `.dorydrive`, external APFS drive support, sparse growth, verified backup, restore, and safe selection | @@ -71,6 +71,16 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you configurable, and free guest pages can be returned to macOS. - **Linux machines beside containers.** Machines are separate VMs with their own disk, address, resources, shell, shares, and snapshots. They are not disguised containers. +- **Install custom arm64 Linux.** Choose an installer ISO and virtual-disk size in the desktop + creation flow. Dory fingerprints the exact media, reports architecture compatibility separately + from runtime qualification, and privately imports accepted bytes. It preserves the VM's EFI + identity and NVRAM; eject or reattach the ISO from the machine menu after installation. A private + bidirectional serial console supplies durable boot diagnostics and recovery input. Snapshots + preserve the disk and EFI firmware state together, including across export/import. +- **Desktops update without recreation.** Signed Debian, Ubuntu, Kali, and graphical-kernel + component updates are applied to existing persistent guests. Dory creates a last-good snapshot, + preserves installed applications and user data, reboots and qualifies the desktop, and restores + the snapshot automatically if installation, boot, or qualification fails. - **Every important setting is in the app.** Engine resources, storage, migration, automatic and custom domains, low ports, LAN access, Auto-Idle, machine environment policy, USB, and managed defaults all have a graphical path. @@ -114,7 +124,7 @@ migration, diagnostics, and recovery. The signed component catalog offers: | Linux Machines | Headless VPS-style Linux guests | Docker Core | | Linux Desktop Runtime | Shared graphical VM kernel | Docker Core | | Debian 13 Desktop | Debian 13 Xfce image | Linux Desktop Runtime | -| Ubuntu 24.04 LTS Desktop | Ubuntu Xfce image | Linux Desktop Runtime | +| Ubuntu 24.04 LTS Desktop | Canonical Ubuntu GNOME image | Linux Desktop Runtime | | Kali Linux Desktop | Kali rolling Xfce image | Linux Desktop Runtime | Component download and installed sizes come from the signed release catalog, not estimates. Dory @@ -286,9 +296,9 @@ store, so push a built image to a registry or import it into the cluster before Install Linux Machines for headless guests. Graphical guests additionally need the Linux Desktop Runtime and the selected Debian, Ubuntu, or Kali distribution component. -Dory Linux machines are persistent, separate VMs rather than containers. The app offers full Xfce -desktops based on Debian 13, Ubuntu 24.04 LTS, or Kali Linux rolling for graphical and command-line -applications. A lightweight Alpine-based headless profile remains available for services, +Dory Linux machines are persistent, separate VMs rather than containers. The app offers Canonical's +Ubuntu 24.04 LTS GNOME desktop plus Debian 13 and Kali Linux rolling Xfce desktops for graphical and +command-line applications. A lightweight Alpine-based headless profile remains available for services, terminals, test environments, and agent work. Each machine has its own disk, address, resources, shares, and snapshots. @@ -297,7 +307,7 @@ From the app or CLI you can: - create, start, stop, delete, and inspect machines; - choose Desktop Linux or Headless Linux when creating a machine in the app; - choose 1 to 8 CPUs and 1 to 16 GiB of memory per machine; -- configure the desktop Linux username, then use its Xfce session, embedded terminal, or an external +- configure the desktop Linux username, then use its GNOME or Xfce session, embedded terminal, or an external terminal selected in Settings; - use a root shell for lightweight headless machines or `dory machine shell NAME`; - execute structured commands with `dory machine exec NAME --json -- COMMAND`; @@ -336,13 +346,24 @@ deletes only scheduler-owned archives and snapshots; manual snapshots are never local backup contract, not an S3 or managed offsite service, so copy important verified archives to independent storage as part of your normal backup policy. -Desktop machines use native arm64 Debian 13, Ubuntu 24.04 LTS, or Kali rolling with systemd, Xfce, -Bash, a configurable login user, and a 64 GiB thin-provisioned disk stored in the selected +Desktop machines use native arm64 Ubuntu 24.04 LTS with GNOME or Debian 13 and Kali rolling with +Xfce, plus systemd, Bash, a configurable login user, and a 64 GiB thin-provisioned disk stored in the selected `.dorydrive`. Their window follows the Mac display at a true 2x framebuffer, resizes dynamically, -and configures Xfce for Retina-sharp text and controls. They run normal graphical and command-line +and configures the selected desktop for Retina-sharp text and controls. They run normal graphical and command-line Linux applications and can mount the Mac home at `~/Mac` only when the user enables that share. +Installing a newer desktop component updates matching existing guests in place; their automatic +last-good snapshot remains available in the normal snapshot list for manual rollback. Headless machines retain the smaller Alpine, `root`, and `/bin/sh` contract. +The custom ISO path accepts arm64 Linux installation media on Apple Silicon. It verifies the media +architecture before allocation, computes an SHA-256 identity, and evaluates exact media/host +runtime evidence separately; a known-unstable tuple is blocked while unknown media is clearly +reported as unqualified. It uses native EFI and an NVMe root disk with fsync semantics, plus VirtIO +display, network, audio, pointer, keyboard, clipboard, and memory-balloon devices; the distribution must include drivers for the +devices it needs. Desktop ISO machines default to four vCPUs and 4 GB of memory as balanced resource +defaults, not as compatibility claims. This path remains preview until the real-installer +physical-Mac release matrix passes. + ### Machine secrets and host access New machines do not receive arbitrary host environment variables. Settings contains an allow-list. @@ -686,9 +707,10 @@ docker run --rm \ - **Unavailable — Intel hosts:** Apple Silicon is the only qualified host architecture. Intel support is planned for a later release after dedicated hardware validation. -- **Supported — Desktop Linux:** managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce arm64 profiles. +- **Supported — Desktop Linux:** managed Ubuntu 24.04 LTS GNOME plus Debian 13 and Kali rolling Xfce arm64 profiles. - **Supported — Headless Linux:** Alpine-based arm64 guests with an initial root `/bin/sh` login. -- **Preview — Venus/Vulkan:** opt-in on the Apple-silicon raw-HV path. Host AI services work without it. +- **Supported — Desktop Venus/Vulkan:** managed arm64 desktops automatically use the isolated, + capability-probed Venus path for Vulkan apps, with ordered fallback to classic VirGL and software. - **Supported discovery / unavailable passthrough — USB:** host discovery is available. Attach, detach, and remembered replay are disabled until the engine has a complete guest USB/IP RPC and verified guest-kernel support. - **Unavailable — audio passthrough:** not part of the current release. diff --git a/website/index.html b/website/index.html index 1bff779e..3eb5f506 100644 --- a/website/index.html +++ b/website/index.html @@ -51,7 +51,7 @@ "Docker 29 engine and command line tools", "Docker Compose v2 and Buildx", "Local k3s Kubernetes", - "Managed Debian, Ubuntu, and Kali Xfce desktops", + "Managed Ubuntu GNOME and Debian/Kali Xfce desktops", "Retina-sharp dynamically resizing Linux display", "Persistent headless Dory Linux servers", "Docker runtime migration", diff --git a/website/public/agent-guide.json b/website/public/agent-guide.json index d91563f1..e5a57c56 100644 --- a/website/public/agent-guide.json +++ b/website/public/agent-guide.json @@ -194,7 +194,7 @@ "safeForAutomation": true, "json": true, "destructiveRequires": ["delete requires --confirm NAME", "restore requires --confirm NAME and creates a retained pre-restore snapshot", "delete-snapshot requires --confirm SNAPSHOT_ID"], - "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Debian, Ubuntu, and Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection." + "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Ubuntu GNOME and Debian/Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection." }, { "id": "machine-backup", diff --git a/website/public/docs/agents.md b/website/public/docs/agents.md index b82f1e27..ddf4fe27 100644 --- a/website/public/docs/agents.md +++ b/website/public/docs/agents.md @@ -65,7 +65,7 @@ servers and should not be described as an MCP catalog or gateway. dory machine exec dev --json -- /bin/sh -lc 'uname -a' ``` -The result uses `dev.dory.machine.exec v1` and includes command status and bounded output. Inspect each machine before choosing commands: desktop machines use the selected Debian, Ubuntu, or Kali profile with systemd, Xfce, Bash, and a configured user, while headless machines use Alpine with an initial root `/bin/sh` login. +The result uses `dev.dory.machine.exec v1` and includes command status and bounded output. Inspect each machine before choosing commands: Ubuntu desktop machines use GNOME, Debian and Kali desktop machines use Xfce, and all three use systemd, Bash, and a configured user; headless machines use Alpine with an initial root `/bin/sh` login. Agents may inspect verified local backup schedules without changing them: diff --git a/website/public/docs/architecture.md b/website/public/docs/architecture.md index 9b67a21b..443b46a0 100644 --- a/website/public/docs/architecture.md +++ b/website/public/docs/architecture.md @@ -29,9 +29,10 @@ Docker dataplane, selected `.dorydrive`, and userspace gvproxy network contract. - **Supported:** Apple-silicon Docker/Compose/Buildx on macOS 14+, raw-HV on macOS 15+, Sonoma VZ fallback, explicit external Docker sockets, dedicated agent sandbox VMs, Build Activity for Dory-launched work, exact-selection transactional migration, verified scheduled local machine - backups, Dory's control MCP, and host USB discovery. -- **Preview:** Venus/Vulkan on the arm64 raw-HV path, remote SSH workspace foundations, and custom - machine kernel/rootfs inputs within the published image contract. + backups, Dory's control MCP, host USB discovery, and capability-probed Venus/Vulkan for managed + arm64 desktop applications. +- **Preview:** Remote SSH workspace foundations and custom machine kernel/rootfs inputs within the + published image contract. - **Unavailable:** USB attach/detach/replay, audio passthrough, managed remote/offsite machine backup, third-party MCP catalog/gateway, image-update orchestration, mDNS/multicast relay or general L2 bridging, Intel public releases, and Windows/Linux host apps. diff --git a/website/public/docs/compatibility.md b/website/public/docs/compatibility.md index 156acf08..78f917ba 100644 --- a/website/public/docs/compatibility.md +++ b/website/public/docs/compatibility.md @@ -18,7 +18,8 @@ and a default container `nofile` limit of 65536 - Common linux/amd64 images on Apple Silicon through FEX - k3s v1.34, v1.35, and v1.36 presets -- Persistent arm64 Linux machines: managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with resources, scoped mounts, network addresses, recipes, snapshots, clone, import, export, and verified scheduled local recovery bundles +- Persistent arm64 Linux machines: managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with resources, scoped mounts, network addresses, recipes, snapshots, clone, import, export, verified scheduled local recovery bundles, and signed in-place desktop updates with automatic rollback +- Hardware Vulkan in managed arm64 desktops through Dory's isolated, capability-probed Venus runtime, with automatic fallback to classic VirGL and software - Managed `.dorydrive` storage, sparse growth, verified backup, restore, and external local APFS drives - Transactional full or exact-selection migration from detected Docker-compatible engines, with dependency closure, rollback, source-drift rejection, and selected/verified/omitted completeness evidence - Localhost ports, optional local domains and HTTPS, built-in low-port forwarding, custom resolver and proxy ports, and opt-in LAN access @@ -41,7 +42,7 @@ Compatibility can vary when a client depends on another product's private paths, ## Current machine boundary -Desktop machines run normal graphical and command-line applications with Debian 13, Ubuntu 24.04 LTS, or Kali rolling, plus glibc, systemd, Xfce, Bash, and a configurable login user. Their window uses a true 2x guest framebuffer, dynamically follows its Mac window, and applies matching Xfce scaling. Headless machines use Alpine, musl, `root`, and `/bin/sh`. Arbitrary desktop images and guest kernel modules are outside the current contract. +Desktop machines run normal graphical and command-line applications with Ubuntu 24.04 LTS GNOME or Debian 13/Kali rolling Xfce, plus glibc, systemd, Bash, and a configurable login user. Their window uses a true 2x guest framebuffer, dynamically follows its Mac window, and applies matching desktop scaling. Headless machines use Alpine, musl, `root`, and `/bin/sh`. User-selected arm64 EFI installer ISOs are preview: Dory separates architecture preflight from exact-media/host runtime evidence, privately stages accepted media, and preserves disk, NVRAM, and install-media lifecycle. Other arbitrary desktop images and guest kernel modules are outside the current contract. ## Supported @@ -56,7 +57,12 @@ Desktop machines run normal graphical and command-line applications with Debian ## Preview -- In-guest Venus/Vulkan acceleration on the Apple-silicon raw-HV tier. +- Custom arm64 Linux installation from ISO through EFI until installation, reboot, media ejection, + devices, and guest integration pass the exact-candidate physical-Mac matrix. + Ubuntu 24.04.3 and 24.04.4 are known unstable on Mac14,10/macOS build 26A5406e with the retired + VirtIO-block EFI profile. The current native-NVMe/fsync profile completed Ubuntu 24.04.3 install, + updates, ISO ejection, and persistent GNOME/Chrome boot, but a later Chromium-install guest stall + keeps that exact tuple unqualified. - Remote SSH workspace foundations and custom machine kernel/rootfs inputs within their published image and trust limits. @@ -64,7 +70,7 @@ Desktop machines run normal graphical and command-line applications with Debian - USB attach, detach, and replay until the guest USB/IP RPC and physical qualification exist - Intel host builds before dedicated physical qualification -- Desktop images beyond the managed Debian, Ubuntu, and Kali Xfce profiles +- Complete x86_64 guest operating systems on Apple Silicon hardware virtualization - Audio passthrough - Managed remote/offsite machine backup or S3 backup - Third-party MCP catalog/gateway; Dory's local MCP controls Dory only diff --git a/website/public/docs/operations.md b/website/public/docs/operations.md index 63ef9fa9..3165101d 100644 --- a/website/public/docs/operations.md +++ b/website/public/docs/operations.md @@ -39,7 +39,7 @@ Desktop distributions add the shared Linux Desktop Runtime automatically. Removi ## Linux desktops and servers -The app separates graphical Linux Desktops from lightweight Linux Servers. A new desktop can use Debian 13, Ubuntu 24.04 LTS, or Kali rolling with Xfce, systemd, Bash, and a configurable login user. Its display uses a true 2x guest framebuffer with matching Xfce scaling and follows the Mac window as it resizes. +The app separates graphical Linux Desktops from lightweight Linux Servers. A new desktop can use Ubuntu 24.04 LTS with Canonical's GNOME session, or Debian 13 and Kali rolling with Xfce. All three use systemd, Bash, and a configurable login user. The display uses a true 2x guest framebuffer with matching desktop scaling and follows the Mac window as it resizes. Desktop creation also controls CPU, memory, development recipe, Mac home sharing, and scoped folders. Each desktop has a thin-provisioned 64 GiB disk in the selected Dory data drive. Headless servers use Alpine with an initial root `/bin/sh` login. Install `linux-machines` for headless servers or a matching `desktop-*` component for graphical machines. diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index ad788aac..e267d10d 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -12,10 +12,10 @@ Telemetry: none Dory has three distinct execution surfaces: 1. The shared container engine is one persistent Linux VM that serves the Docker API. Docker containers, images, volumes, networks, Compose, BuildKit, and k3s use this engine. -2. Dory Linux machines are separate persistent arm64 VMs. Managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops have configurable users and Retina-sharp resizable displays; lightweight Alpine headless machines retain root /bin/sh. Each has its own disk, address, resources, scoped mounts, and snapshots. +2. Dory Linux machines are separate persistent arm64 VMs. Managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops have configurable users, Retina-sharp resizable displays, and signed in-place updates with automatic last-good snapshot rollback; lightweight Alpine headless machines retain root /bin/sh. Each has its own disk, address, resources, scoped mounts, and snapshots. 3. Agent sandboxes are supported, dedicated Dory Linux VMs created for bounded non-root commands. They have no ambient host files, network, or credentials and are deleted by default. -Do not describe a Dory Linux machine as a container. Inspect its display mode and identity: desktop machines use the selected managed Debian, Ubuntu, or Kali profile with systemd, Xfce, Bash, and a configurable user; headless machines are Alpine-based, root, and /bin/sh. +Do not describe a Dory Linux machine as a container. Inspect its display mode and identity: Ubuntu desktop machines use GNOME, Debian and Kali desktop machines use Xfce, and all three use systemd, Bash, and a configurable user; headless machines are Alpine-based, root, and /bin/sh. ## Installation and host tools @@ -219,7 +219,7 @@ and deliberately omitted objects. Dory never deletes source data. dory machine backup run NAME dory machine backup remove NAME -The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13, Ubuntu 24.04 LTS, or Kali rolling, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching Xfce scaling. A new graphical machine requires its matching `desktop-*` component; a headless machine requires `linux-machines`. +The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13 Xfce, Ubuntu 24.04 LTS GNOME, or Kali rolling Xfce, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching desktop scaling. A new graphical machine requires its matching `desktop-*` component; a headless machine requires `linux-machines`. The machine command also supports clone, import, export, and snapshot deletion. Destructive Dory commands repeat the exact target with `--confirm`; restore first creates and reports a retained diff --git a/website/public/llms.txt b/website/public/llms.txt index c632d80a..95bc4b6f 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -2,7 +2,7 @@ > Dory is a native, open-source macOS runtime for Docker, Compose, Kubernetes, full Linux desktops, persistent servers, local networking, migration, diagnostics, and coding-agent automation. -Dory 0.4.5 supports Apple Silicon on macOS 14 or later and bundles its own Docker engine and command-line tools. Its optional Linux-machine profiles are managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops with configurable users plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release. +Dory supports Apple Silicon on macOS 14 or later and bundles its own Docker engine and command-line tools. Its optional Linux-machine profiles are managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops with configurable users, signed in-place updates, and automatic last-good snapshot rollback, plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release. Dory ships one Docker Core app. A signed catalog adds removable Kubernetes, Linux Machines, Linux Desktop Runtime, Debian, Ubuntu, and Kali components to the selected Dory data drive. The website verifies the catalog and shows the exact total before download. Removing a component reclaims its payload without deleting workload data. diff --git a/website/src/App.tsx b/website/src/App.tsx index b83595b4..4fc49350 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -287,21 +287,21 @@ const surfaces: Array<{ icon: ServerStackIcon, label: 'Real Linux machines', title: 'A full Linux desktop beside your containers.', - copy: 'Create a managed Debian, Ubuntu, or Kali Xfce desktop for graphical and command-line apps, or choose lightweight headless Linux for services and agents.', + copy: 'Create a managed Ubuntu GNOME or Debian/Kali Xfce desktop for graphical and command-line apps, or choose lightweight headless Linux for services and agents.', facts: ['Three desktop distributions', 'Scoped Mac folders', 'Snapshot and clone'], command: 'dory machine shell dev', }, ] const desktopDistros = [ - ['Debian', '13', 'Stable, clean desktop for everyday Linux and development', '#a80030'], - ['Ubuntu', '24.04 LTS', 'A familiar long-term-support base for development and daily work', '#e95420'], - ['Kali Linux', 'Rolling', 'A focused security lab backed by Kali\'s official rolling repository', '#367bf0'], + ['Debian', '13', 'Xfce', 'Stable, clean desktop for everyday Linux and development', '#a80030'], + ['Ubuntu', '24.04 LTS', 'GNOME', 'Canonical\'s familiar long-term-support desktop for development and daily work', '#e95420'], + ['Kali Linux', 'Rolling', 'Xfce', 'A focused security lab backed by Kali\'s official rolling repository', '#367bf0'], ] const desktopCapabilities = [ - ['Real Linux guest', 'Native arm64, systemd, Xfce, Bash, apt, and normal graphical or command-line applications'], - ['Retina display', 'A true 2x guest framebuffer follows the window and keeps the Xfce desktop sharp as it resizes'], + ['Real Linux guest', 'Native arm64, systemd, GNOME or Xfce, Bash, apt, and normal graphical or command-line applications'], + ['Retina display', 'A true 2x guest framebuffer follows the window and keeps the selected desktop sharp as it resizes'], ['Your identity', 'Choose the Linux username, CPU, memory, development recipe, and only the Mac folders to share'], ['Persistent and recoverable', 'A thin 64 GiB disk lives in the selected .dorydrive with snapshots, clone, import, and export'], ] @@ -382,7 +382,7 @@ const faqs = [ }, { question: 'Are Linux machines full desktop VMs?', - answer: 'Yes on Apple Silicon. Choose managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling with systemd, Xfce, Bash, a configurable user, a Retina-sharp resizable display, and a 64 GiB thin-provisioned disk. Lightweight Alpine headless machines remain available for terminal and service workloads.', + answer: 'Yes on Apple Silicon. Choose Ubuntu 24.04 LTS with Canonical\'s GNOME session, or Debian 13 and Kali rolling with Xfce. Each has systemd, Bash, a configurable user, a Retina-sharp resizable display, and a 64 GiB thin-provisioned disk. Signed component updates preserve the guest and keep a last-good snapshot for automatic rollback. Lightweight Alpine headless machines remain available for terminal and service workloads.', }, { question: 'Which Dory components should I install?', @@ -399,9 +399,9 @@ const faqs = [ ] const releaseStates = [ - ['Supported', 'Docker, Compose, Kubernetes, Linux desktops and servers, migration, storage, networking, health, MCP, policy-enforced sandbox VMs, and USB discovery'], - ['Preview', 'In-guest Venus/Vulkan, remote SSH workspace foundations, and bounded custom machine images'], - ['Unavailable', 'USB passthrough, Intel host releases, desktop images beyond Debian/Ubuntu/Kali, and audio passthrough'], + ['Supported', 'Docker, Compose, Kubernetes, hardware-accelerated Linux desktops and servers, migration, storage, networking, health, MCP, policy-enforced sandbox VMs, and USB discovery'], + ['Preview', 'Remote SSH workspace foundations and bounded custom machine images'], + ['Unavailable', 'USB passthrough, Intel host releases, and desktop images beyond Debian/Ubuntu/Kali'], ] type DemoView = 'containers' | 'kubernetes' | 'machines' @@ -528,7 +528,7 @@ function KubernetesDemo() { function MachinesDemo({ tick }: { tick: number }) { const machines = [ - { name: 'workbench', status: 'Running', cpu: 8.6 + (tick % 4), memory: '2.4 GB', address: '192.168.127.11', shell: 'augustus · /bin/bash', distro: 'Ubuntu 24.04 LTS · Xfce · arm64' }, + { name: 'workbench', status: 'Running', cpu: 8.6 + (tick % 4), memory: '2.4 GB', address: '192.168.127.11', shell: 'augustus · /bin/bash', distro: 'Ubuntu 24.04 LTS · GNOME · arm64' }, { name: 'security-lab', status: 'Stopped', cpu: 0, memory: 'Not running', address: 'security-lab.dory.local', shell: 'analyst · /bin/bash', distro: 'Kali Linux Rolling · Xfce · arm64' }, ] return ( @@ -853,10 +853,10 @@ function App() { distribution, login user, resources, development tools, and only the Mac folders it should see. Then open its desktop or terminal without leaving Dory.

- {desktopDistros.map(([name, version, copy, color]) => ( + {desktopDistros.map(([name, version, desktop, copy, color]) => (
{name.slice(0, 1)} -

{name}

{version} · Xfce · arm64

{copy}

+

{name}

{version} · {desktop} · arm64

{copy}

))}
From 7467ecd46aa67ee1915df4850785bf4a793c2f7a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:42:04 +0000 Subject: [PATCH 010/338] feat(workspace): add durable definition repository --- .../DorydKit/DoryWorkspaceRepository.swift | 464 ++++++++++++++++++ .../DoryWorkspaceRepositoryTests.swift | 215 ++++++++ 2 files changed, 679 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift new file mode 100644 index 00000000..56d64cce --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift @@ -0,0 +1,464 @@ +import CryptoKit +import Darwin +import DoryOperations +import Foundation + +public enum DoryWorkspaceRepositoryError: Error, Sendable, Equatable, CustomStringConvertible { + case invalidIdentifier(String) + case invalidDefinition([DoryVMDefinitionValidationIssue]) + case workspaceExists(String) + case workspaceNotFound(String) + case staleRevision(expected: UInt64, actual: UInt64) + case invalidRevision(expected: UInt64, actual: UInt64) + case identityChanged(String) + case legacyAuthorityRequired(String) + case staleLegacyProjection(String) + case invalidRecord(String) + case filesystem(String) + + public var description: String { + switch self { + case let .invalidIdentifier(id): + return "invalid workspace identifier: \(id)" + case let .invalidDefinition(issues): + return "invalid workspace definition: " + + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ") + case let .workspaceExists(id): + return "workspace already exists: \(id)" + case let .workspaceNotFound(id): + return "workspace does not exist: \(id)" + case let .staleRevision(expected, actual): + return "stale workspace revision: expected \(expected), found \(actual)" + case let .invalidRevision(expected, actual): + return "invalid replacement workspace revision: expected \(expected), found \(actual)" + case let .identityChanged(id): + return "workspace identity cannot change during replacement: \(id)" + case let .legacyAuthorityRequired(id): + return "workspace \(id) is a legacy projection and requires authoritative legacy bytes" + case let .staleLegacyProjection(id): + return "workspace \(id) does not match the authoritative legacy configuration" + case let .invalidRecord(path): + return "invalid workspace repository record: \(path)" + case let .filesystem(message): + return message + } + } +} + +/// One durable desired-state record. +/// +/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact canonical +/// `DoryMachineConfiguration` bytes that remain authoritative. Once all consumers use the v2 +/// contract, authoritative records omit this digest and use optimistic lifecycle revisions. +public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable { + public static let schemaVersion: UInt16 = 1 + + public let schemaVersion: UInt16 + public let legacyConfigurationSHA256: String? + public let definition: DoryVirtualMachineDefinition + + public init( + definition: DoryVirtualMachineDefinition, + legacyConfigurationSHA256: String? = nil + ) { + schemaVersion = Self.schemaVersion + self.legacyConfigurationSHA256 = legacyConfigurationSHA256 + self.definition = definition + } +} + +/// Crash-safe persistence for versioned workspace desired state. +/// +/// Records live beside the existing machine artifacts, use owner-only files, reject links, and +/// publish through a fully written and fsynced temporary file. The repository never persists host +/// paths or credentials because `DoryVirtualMachineDefinition` contains resolver references only. +public final class DoryWorkspaceRepository: @unchecked Sendable { + public static let recordFileName = "workspace-v2.json" + + private static let temporaryPrefix = ".workspace-v2." + private static let maximumRecordBytes = 16 * 1_024 * 1_024 + + public let root: String + private let lock = NSLock() + + public init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + } + + public func create(_ definition: DoryVirtualMachineDefinition) throws { + lock.lock() + defer { lock.unlock() } + try Self.validate(definition) + guard definition.lifecycle.revision == 1 else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: 1, + actual: definition.lifecycle.revision + ) + } + let path = try prepareWorkspaceDirectory(id: definition.identity.id) + + "/\(Self.recordFileName)" + guard !Self.pathExists(path) else { + throw DoryWorkspaceRepositoryError.workspaceExists(definition.identity.id) + } + try publish( + DoryWorkspaceRepositoryRecord(definition: definition), + path: path, + replacing: false + ) + } + + public func replace( + _ definition: DoryVirtualMachineDefinition, + expectedRevision: UInt64 + ) throws { + lock.lock() + defer { lock.unlock() } + try Self.validate(definition) + let current = try readRecordUnlocked(id: definition.identity.id) + guard current.legacyConfigurationSHA256 == nil else { + throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(definition.identity.id) + } + guard current.definition.lifecycle.revision == expectedRevision else { + throw DoryWorkspaceRepositoryError.staleRevision( + expected: expectedRevision, + actual: current.definition.lifecycle.revision + ) + } + guard expectedRevision < UInt64.max, + definition.lifecycle.revision == expectedRevision + 1 else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: expectedRevision == UInt64.max ? UInt64.max : expectedRevision + 1, + actual: definition.lifecycle.revision + ) + } + guard definition.identity.id == current.definition.identity.id, + definition.lifecycle.createdAtUnixMilliseconds + == current.definition.lifecycle.createdAtUnixMilliseconds else { + throw DoryWorkspaceRepositoryError.identityChanged(definition.identity.id) + } + let path = recordPath(id: definition.identity.id) + try publish( + DoryWorkspaceRepositoryRecord(definition: definition), + path: path, + replacing: true + ) + } + + /// Publish a deterministic v2 projection while legacy machine metadata remains authoritative. + /// A crash on either side of the two-file migration is detected by the bound digest and can be + /// repaired by projecting the authoritative legacy record again. + public func publishLegacyProjection( + _ definition: DoryVirtualMachineDefinition, + authoritativeLegacyData: Data + ) throws { + lock.lock() + defer { lock.unlock() } + try Self.validate(definition) + guard !authoritativeLegacyData.isEmpty else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id) + } + let directory = try prepareWorkspaceDirectory(id: definition.identity.id) + let record = DoryWorkspaceRepositoryRecord( + definition: definition, + legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData) + ) + try publish( + record, + path: directory + "/\(Self.recordFileName)", + replacing: Self.pathExists(directory + "/\(Self.recordFileName)") + ) + } + + public func read(id: String) throws -> DoryVirtualMachineDefinition { + lock.lock() + defer { lock.unlock() } + let record = try readRecordUnlocked(id: id) + guard record.legacyConfigurationSHA256 == nil else { + throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(id) + } + return record.definition + } + + public func readLegacyProjection( + id: String, + authoritativeLegacyData: Data + ) throws -> DoryVirtualMachineDefinition { + lock.lock() + defer { lock.unlock() } + let record = try readRecordUnlocked(id: id) + guard let expectedDigest = record.legacyConfigurationSHA256 else { + throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id)) + } + guard expectedDigest == Self.sha256(authoritativeLegacyData) else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(id) + } + return record.definition + } + + public func remove(id: String) throws { + lock.lock() + defer { lock.unlock() } + try Self.validateIdentifier(id) + let path = recordPath(id: id) + guard Self.pathExists(path) else { + throw DoryWorkspaceRepositoryError.workspaceNotFound(id) + } + _ = try Self.secureRead(path: path) + guard unlink(path) == 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "remove workspace record at \(path): \(String(cString: strerror(errno)))" + ) + } + try Self.fsyncDirectory(workspaceDirectory(id: id)) + } + + private func readRecordUnlocked(id: String) throws -> DoryWorkspaceRepositoryRecord { + try Self.validateIdentifier(id) + let path = recordPath(id: id) + guard Self.pathExists(path) else { + throw DoryWorkspaceRepositoryError.workspaceNotFound(id) + } + let data = try Self.secureRead(path: path) + let decoder = JSONDecoder() + guard let record = try? decoder.decode(DoryWorkspaceRepositoryRecord.self, from: data), + record.schemaVersion == DoryWorkspaceRepositoryRecord.schemaVersion, + record.definition.identity.id == id, + record.definition.validate().isEmpty, + record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + return record + } + + private func prepareWorkspaceDirectory(id: String) throws -> String { + try Self.validateIdentifier(id) + try Self.preparePrivateDirectory(root) + let directory = workspaceDirectory(id: id) + try Self.preparePrivateDirectory(directory) + return directory + } + + private func workspaceDirectory(id: String) -> String { + root + "/" + id + } + + private func recordPath(id: String) -> String { + workspaceDirectory(id: id) + "/" + Self.recordFileName + } + + private func publish( + _ record: DoryWorkspaceRepositoryRecord, + path: String, + replacing: Bool + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data: Data + do { + data = try encoder.encode(record) + } catch { + throw DoryWorkspaceRepositoryError.filesystem( + "encode workspace record for \(record.definition.identity.id): \(error)" + ) + } + guard !data.isEmpty, data.count <= Self.maximumRecordBytes else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + + let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path + let temporaryPath = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString.lowercased())" + let descriptor = open( + temporaryPath, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "create workspace temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + var shouldRemoveTemporary = true + defer { + _ = close(descriptor) + if shouldRemoveTemporary { _ = unlink(temporaryPath) } + } + do { + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var written = 0 + while written < data.count { + let count = Darwin.write( + descriptor, + base.advanced(by: written), + data.count - written + ) + if count < 0, errno == EINTR { continue } + guard count > 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "write workspace temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + written += count + } + } + guard fsync(descriptor) == 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "fsync workspace temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + if replacing { + guard rename(temporaryPath, path) == 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "publish workspace record at \(path): \(String(cString: strerror(errno)))" + ) + } + } else { + guard link(temporaryPath, path) == 0 else { + if errno == EEXIST { + throw DoryWorkspaceRepositoryError.workspaceExists( + record.definition.identity.id + ) + } + throw DoryWorkspaceRepositoryError.filesystem( + "publish workspace record at \(path): \(String(cString: strerror(errno)))" + ) + } + guard unlink(temporaryPath) == 0 else { + _ = unlink(path) + throw DoryWorkspaceRepositoryError.filesystem( + "remove workspace temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + } + shouldRemoveTemporary = false + try Self.fsyncDirectory(directory) + } catch let error as DoryWorkspaceRepositoryError { + throw error + } catch { + throw DoryWorkspaceRepositoryError.filesystem( + "publish workspace record at \(path): \(error)" + ) + } + } + + private static func validate(_ definition: DoryVirtualMachineDefinition) throws { + let issues = definition.validate() + guard issues.isEmpty else { + throw DoryWorkspaceRepositoryError.invalidDefinition(issues) + } + } + + private static func validateIdentifier(_ id: String) throws { + let bytes = Array(id.utf8) + guard (1...63).contains(bytes.count), isAlphaNumeric(bytes[0]), + bytes.dropFirst().allSatisfy({ byte in + isAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45 + }) else { + throw DoryWorkspaceRepositoryError.invalidIdentifier(id) + } + } + + private static func isAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + + private static func preparePrivateDirectory(_ path: String) throws { + if !pathExists(path) { + do { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw DoryWorkspaceRepositoryError.filesystem( + "create workspace directory at \(path): \(error)" + ) + } + _ = chmod(path, mode_t(0o700)) + } + var info = stat() + guard lstat(path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == getuid(), + (info.st_mode & 0o077) == 0 else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + } + + private static func secureRead(path: String) throws -> Data { + var before = stat() + guard lstat(path, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == getuid(), + before.st_nlink == 1, + (before.st_mode & 0o077) == 0, + before.st_size > 0, + before.st_size <= maximumRecordBytes else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + defer { _ = close(descriptor) } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + after.st_size == before.st_size else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + do { + let data = try handle.read(upToCount: maximumRecordBytes + 1) ?? Data() + guard !data.isEmpty, data.count <= maximumRecordBytes, + data.count == Int(after.st_size) else { + throw DoryWorkspaceRepositoryError.invalidRecord(path) + } + return data + } catch let error as DoryWorkspaceRepositoryError { + throw error + } catch { + throw DoryWorkspaceRepositoryError.filesystem( + "read workspace record at \(path): \(error)" + ) + } + } + + private static func fsyncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_CLOEXEC) + guard descriptor >= 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "open workspace directory at \(path): \(String(cString: strerror(errno)))" + ) + } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { + throw DoryWorkspaceRepositoryError.filesystem( + "fsync workspace directory at \(path): \(String(cString: strerror(errno)))" + ) + } + } + + private static func pathExists(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift new file mode 100644 index 00000000..403f3120 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift @@ -0,0 +1,215 @@ +import Darwin +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Workspace repository") +struct DoryWorkspaceRepositoryTests { + @Test("authoritative records create read and replace with optimistic revisions") + func authoritativeLifecycle() throws { + try withRepository { repository, _ in + let initial = definition() + try repository.create(initial) + #expect(try repository.read(id: initial.identity.id) == initial) + + var replacement = initial + replacement.identity.name = "Renamed workspace" + replacement.lifecycle.revision = 2 + replacement.lifecycle.updatedAtUnixMilliseconds += 1 + try repository.replace(replacement, expectedRevision: 1) + #expect(try repository.read(id: initial.identity.id) == replacement) + + #expect(throws: DoryWorkspaceRepositoryError.staleRevision(expected: 1, actual: 2)) { + try repository.replace(replacement, expectedRevision: 1) + } + } + } + + @Test("legacy projections are bound to exact authoritative bytes and can be reconciled") + func legacyProjectionBinding() throws { + try withRepository { repository, _ in + let firstLegacy = Data("legacy-v1".utf8) + let secondLegacy = Data("legacy-v2".utf8) + let workspace = definition() + + try repository.publishLegacyProjection( + workspace, + authoritativeLegacyData: firstLegacy + ) + #expect( + try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: firstLegacy + ) == workspace + ) + #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) { + _ = try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: secondLegacy + ) + } + #expect(throws: DoryWorkspaceRepositoryError.legacyAuthorityRequired(workspace.identity.id)) { + _ = try repository.read(id: workspace.identity.id) + } + + var reconciled = workspace + reconciled.lifecycle.revision = 2 + reconciled.lifecycle.updatedAtUnixMilliseconds += 1 + try repository.publishLegacyProjection( + reconciled, + authoritativeLegacyData: secondLegacy + ) + #expect( + try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: secondLegacy + ) == reconciled + ) + } + } + + @Test("invalid definitions and revision or identity changes fail before publication") + func validationAndRevisionSafety() throws { + try withRepository { repository, root in + var invalid = definition() + invalid.identity.id = "../escape" + #expect(throws: DoryWorkspaceRepositoryError.self) { + try repository.create(invalid) + } + #expect(!FileManager.default.fileExists(atPath: root + "/escape")) + + let initial = definition() + try repository.create(initial) + var skippedRevision = initial + skippedRevision.lifecycle.revision = 3 + skippedRevision.lifecycle.updatedAtUnixMilliseconds += 1 + #expect(throws: DoryWorkspaceRepositoryError.invalidRevision(expected: 2, actual: 3)) { + try repository.replace(skippedRevision, expectedRevision: 1) + } + + var changedCreation = initial + changedCreation.lifecycle.revision = 2 + changedCreation.lifecycle.createdAtUnixMilliseconds -= 1 + changedCreation.lifecycle.updatedAtUnixMilliseconds += 1 + #expect(throws: DoryWorkspaceRepositoryError.identityChanged(initial.identity.id)) { + try repository.replace(changedCreation, expectedRevision: 1) + } + } + } + + @Test("repository rejects symlink hard-link public and oversized records") + func hostileRecordTypes() throws { + try withRepository { repository, root in + let workspace = definition() + try repository.create(workspace) + let directory = root + "/" + workspace.identity.id + let record = directory + "/" + DoryWorkspaceRepository.recordFileName + let saved = directory + "/saved" + try FileManager.default.moveItem(atPath: record, toPath: saved) + + #expect(symlink(saved, record) == 0) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try repository.read(id: workspace.identity.id) + } + #expect(unlink(record) == 0) + + #expect(link(saved, record) == 0) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try repository.read(id: workspace.identity.id) + } + #expect(unlink(record) == 0) + + try FileManager.default.copyItem(atPath: saved, toPath: record) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: record + ) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try repository.read(id: workspace.identity.id) + } + try FileManager.default.removeItem(atPath: record) + + #expect(FileManager.default.createFile(atPath: record, contents: Data([0]))) + let descriptor = open(record, O_WRONLY | O_CLOEXEC) + #expect(descriptor >= 0) + #expect(ftruncate(descriptor, off_t(17 * 1_024 * 1_024)) == 0) + _ = close(descriptor) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: record + ) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try repository.read(id: workspace.identity.id) + } + } + } + + @Test("remove rejects unsafe identifiers and durably removes one record") + func removeSafety() throws { + try withRepository { repository, root in + let workspace = definition() + try repository.create(workspace) + try repository.remove(id: workspace.identity.id) + #expect( + !FileManager.default.fileExists( + atPath: root + "/" + workspace.identity.id + "/" + + DoryWorkspaceRepository.recordFileName + ) + ) + #expect(throws: DoryWorkspaceRepositoryError.invalidIdentifier("../escape")) { + try repository.remove(id: "../escape") + } + } + } + + private func withRepository( + _ body: (DoryWorkspaceRepository, String) throws -> Void + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-workspace-repository-\(UUID().uuidString)") + .path + defer { try? FileManager.default.removeItem(atPath: root) } + try body(DoryWorkspaceRepository(root: root), root) + } + + private func definition() -> DoryVirtualMachineDefinition { + let disk = DoryVMResolverReference(namespace: "machine", identifier: "disk-main") + return DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: "workspace-one", name: "Workspace One"), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", + role: .system, + kind: .virtualDisk, + source: .userProvided, + artifact: disk, + removable: false + )], + order: ["system"] + ), + graphics: DoryVMGraphicsPolicy( + acceptableLevels: [.hardwareAccelerated3D, .software] + ), + resources: DoryVMResourceRequest( + virtualCPUCount: 4, + memoryBytes: 8 * 1_024 * 1_024 * 1_024, + diskBytes: 64 * 1_024 * 1_024 * 1_024 + ), + storage: [DoryVMStorageAttachment( + id: "system", + role: .system, + artifact: disk, + capacityBytes: 64 * 1_024 * 1_024 * 1_024 + )], + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + } +} From c9af373ea83c5ed3712d9825fe87cc019295a51d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:45:03 +0000 Subject: [PATCH 011/338] feat(vm): add backend registry and Linux adapters --- .../LinuxMachineBackendAdapters.swift | 438 ++++++++++++++++++ .../Sources/DorydKit/MachineBackend.swift | 363 +++++++++++++++ .../DorydKitTests/MachineBackendTests.swift | 343 ++++++++++++++ 3 files changed, 1144 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift create mode 100644 dory-core-swift/Sources/DorydKit/MachineBackend.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift new file mode 100644 index 00000000..c01f4890 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -0,0 +1,438 @@ +import DoryOperations +import Foundation + +public typealias MachineBackendLifecycleHandler = @Sendable (String) throws -> MachineBackendRuntimeObservation + +/// Hooks implemented by the existing machine launcher. Keeping them injectable lets the seam be +/// qualified before MachineManager starts consuming BackendRegistry. +public struct MachineBackendCompatibilityOperations: Sendable { + public var start: MachineBackendLifecycleHandler + public var stop: MachineBackendLifecycleHandler + + public init( + start: @escaping MachineBackendLifecycleHandler, + stop: @escaping MachineBackendLifecycleHandler + ) { + self.start = start + self.stop = stop + } + + /// Compatibility bridge to today's launch path. Both Linux adapters may share this bridge; + /// their validated backend plans determine which adapter is allowed to invoke it. + public init(machineManager: MachineManager) { + self.init( + start: { id in + MachineBackendRuntimeObservation(try machineManager.start(id: id)) + }, + stop: { id in + MachineBackendRuntimeObservation(try machineManager.stop(id: id)) + } + ) + } +} + +public extension MachineBackendRuntimeObservation { + init(_ status: DoryMachineStatus) { + self.init( + machineID: status.id, + state: MachineBackendRuntimeState(status.state), + processIdentifier: status.pid, + failureMessage: status.lastError + ) + } +} + +private extension MachineBackendRuntimeState { + init(_ state: DoryMachineState) { + switch state { + case .created: self = .created + case .starting: self = .starting + case .running: self = .running + case .stopped: self = .stopped + case .failed: self = .failed + } + } +} + +private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { + typealias MachineValidator = @Sendable ( + DoryMachineConfiguration, + DoryVirtualMachineCapabilityDescriptor + ) -> String? + + let descriptor: MachineBackendDescriptor + private let executablePath: String? + private let executableIsAvailable: @Sendable (String) -> Bool + private let operations: MachineBackendCompatibilityOperations + private let validateMachine: MachineValidator + + init( + descriptor: MachineBackendDescriptor, + executablePath: String?, + executableIsAvailable: @escaping @Sendable (String) -> Bool, + operations: MachineBackendCompatibilityOperations, + validateMachine: @escaping MachineValidator + ) { + self.descriptor = descriptor + self.executablePath = executablePath + self.executableIsAvailable = executableIsAvailable + self.operations = operations + self.validateMachine = validateMachine + } + + func probe() -> MachineBackendProbeResult { + guard let executablePath, !executablePath.isEmpty else { + return unavailableProbe( + code: .componentNotConfigured, + message: "The backend helper path is not configured." + ) + } + guard executableIsAvailable(executablePath) else { + return unavailableProbe( + code: .componentUnavailable, + message: "The configured backend helper is unavailable or not executable." + ) + } + return MachineBackendProbeResult(descriptor: descriptor, state: .available) + } + + func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { + let currentProbe = probe() + guard currentProbe.isAvailable else { + return MachineBackendPlanResult( + plan: nil, + probe: currentProbe, + failure: currentProbe.failure + ) + } + guard request.capabilityPlan.failure == nil, + let capability = request.capabilityPlan.selectedDescriptor else { + return failedPlan( + probe: currentProbe, + code: .capabilityPlanRejected, + message: "The product capability planner did not select a backend." + ) + } + guard request.capabilityPlan.evaluatedDescriptors.contains(capability), + capability.schemaVersion == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion, + capability.evaluatorVersion == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + capability.availability.isUsable, + capability.resolvedDevices == capability.request.devices else { + return failedPlan( + probe: currentProbe, + code: .capabilitySelectionInvalid, + message: "The selected capability is not a complete usable planner result." + ) + } + guard capability.request.backend == descriptor.identity else { + return failedPlan( + probe: currentProbe, + code: .backendIdentityMismatch, + message: "The selected capability belongs to a different backend." + ) + } + guard descriptor.guestFamilies.contains(capability.request.guest.family), + descriptor.guestArchitectures.contains(capability.request.guest.architecture) else { + return failedPlan( + probe: currentProbe, + code: .guestUnsupported, + message: "The selected guest is not implemented by this backend adapter." + ) + } + guard descriptor.bootMediaKinds.contains(capability.request.bootMedia.kind) else { + return failedPlan( + probe: currentProbe, + code: .bootMediaUnsupported, + message: "The selected boot-media kind is not implemented by this backend adapter." + ) + } + if let validationFailure = validateMachine(request.machine, capability) { + return failedPlan( + probe: currentProbe, + code: .machineConfigurationIncompatible, + message: validationFailure + ) + } + return MachineBackendPlanResult( + plan: MachineBackendPlan( + backend: descriptor, + machine: request.machine, + capability: capability + ), + probe: currentProbe, + failure: nil + ) + } + + func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { + guard plan.backend == descriptor, + plan.capability.request.backend == descriptor.identity else { + return failedOperation( + .start, + code: .backendIdentityMismatch, + message: "The launch plan belongs to a different backend adapter." + ) + } + let revalidated = self.plan(MachineBackendPlanRequest( + machine: plan.machine, + capabilityPlan: DoryVirtualMachineBackendPlanResult( + selectedDescriptor: plan.capability, + evaluatedDescriptors: [plan.capability], + failure: nil + ) + )) + guard revalidated.plan == plan else { + return failedOperation( + .start, + code: revalidated.failure?.code ?? .capabilitySelectionInvalid, + message: revalidated.failure?.message + ?? "The backend launch plan could not be revalidated." + ) + } + guard descriptor.lifecycle.start else { + return unsupportedOperation(.start) + } + return perform(.start, machineID: plan.machine.id, operation: operations.start) + } + + func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.backend == descriptor.identity else { + return failedOperation( + .stop, + code: .backendIdentityMismatch, + message: "The runtime request belongs to a different backend adapter." + ) + } + guard descriptor.lifecycle.stop else { + return unsupportedOperation(.stop) + } + return perform(.stop, machineID: request.machineID, operation: operations.stop) + } + + func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.backend == descriptor.identity else { + return failedOperation( + .pause, + code: .backendIdentityMismatch, + message: "The runtime request belongs to a different backend adapter." + ) + } + return unsupportedOperation(.pause) + } + + func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.backend == descriptor.identity else { + return failedOperation( + .resume, + code: .backendIdentityMismatch, + message: "The runtime request belongs to a different backend adapter." + ) + } + return unsupportedOperation(.resume) + } + + private func unavailableProbe( + code: MachineBackendFailureCode, + message: String + ) -> MachineBackendProbeResult { + MachineBackendProbeResult( + descriptor: descriptor, + state: .unavailable, + failure: MachineBackendFailure( + code: code, + backend: descriptor.identity, + message: message + ) + ) + } + + private func failedPlan( + probe: MachineBackendProbeResult, + code: MachineBackendFailureCode, + message: String + ) -> MachineBackendPlanResult { + MachineBackendPlanResult( + plan: nil, + probe: probe, + failure: MachineBackendFailure( + code: code, + backend: descriptor.identity, + message: message + ) + ) + } + + private func perform( + _ operation: MachineBackendLifecycleOperation, + machineID: String, + operation handler: MachineBackendLifecycleHandler + ) -> MachineBackendOperationResult { + do { + return MachineBackendOperationResult( + operation: operation, + backend: descriptor.identity, + observation: try handler(machineID), + failure: nil + ) + } catch { + return failedOperation( + operation, + code: .lifecycleOperationFailed, + message: String(describing: error) + ) + } + } + + private func unsupportedOperation( + _ operation: MachineBackendLifecycleOperation + ) -> MachineBackendOperationResult { + failedOperation( + operation, + code: .lifecycleOperationUnsupported, + message: "The current backend adapter does not implement \(operation.rawValue)." + ) + } + + private func failedOperation( + _ operation: MachineBackendLifecycleOperation, + code: MachineBackendFailureCode, + message: String + ) -> MachineBackendOperationResult { + MachineBackendOperationResult( + operation: operation, + backend: descriptor.identity, + observation: nil, + failure: MachineBackendFailure( + code: code, + backend: descriptor.identity, + message: message + ) + ) + } +} + +/// Compatibility adapter for Dory's current direct-kernel/raw-Hypervisor Linux desktop path. +public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable { + public static let backendDescriptor = MachineBackendDescriptor( + identity: .doryHypervisor, + implementationIdentifier: "dory.raw-hv-linux.compatibility.v1", + guestFamilies: [.linux], + guestArchitectures: [.arm64], + bootMediaKinds: [.installedLinuxBootBundle], + lifecycle: .currentMachineManager + ) + + private let core: LinuxMachineBackendAdapterCore + + public var descriptor: MachineBackendDescriptor { core.descriptor } + + public init( + executablePath: String?, + operations: MachineBackendCompatibilityOperations, + executableIsAvailable: @escaping @Sendable (String) -> Bool = { + FileManager.default.isExecutableFile(atPath: $0) + } + ) { + core = LinuxMachineBackendAdapterCore( + descriptor: Self.backendDescriptor, + executablePath: executablePath, + executableIsAvailable: executableIsAvailable, + operations: operations, + validateMachine: { machine, _ in + guard machine.displayMode == .desktop else { + return "The current raw-HV machine path is implemented only for desktop Linux." + } + guard machine.installerISOPath == nil else { + return "The raw-HV machine path cannot boot attached installer media." + } + guard machine.bootMode == .linuxKernel || machine.bootMode == .efi else { + return "The machine boot mode is not implemented by the raw-HV adapter." + } + do { + guard try DoryDesktopVMMPreference(environment: machine.environment) != .compatible else { + return "The machine explicitly requests the Virtualization.framework compatibility path." + } + } catch { + return "The machine contains an invalid desktop backend preference." + } + if machine.bootMode == .efi, + !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) { + return "An EFI-installed raw-HV machine requires a verified installed-Linux boot bundle." + } + return nil + } + ) + } + + public func probe() -> MachineBackendProbeResult { core.probe() } + public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) } + public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) } + public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) } + public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) } + public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) } +} + +/// Compatibility adapter for the current Virtualization.framework EFI Linux helper path. +public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @unchecked Sendable { + public static let backendDescriptor = MachineBackendDescriptor( + identity: .appleVirtualizationFramework, + implementationIdentifier: "dory.vz-linux.compatibility.v1", + guestFamilies: [.linux], + guestArchitectures: [.arm64], + bootMediaKinds: [.installerISO, .virtualDisk], + lifecycle: .currentMachineManager + ) + + private let core: LinuxMachineBackendAdapterCore + + public var descriptor: MachineBackendDescriptor { core.descriptor } + + public init( + executablePath: String?, + operations: MachineBackendCompatibilityOperations, + executableIsAvailable: @escaping @Sendable (String) -> Bool = { + FileManager.default.isExecutableFile(atPath: $0) + } + ) { + core = LinuxMachineBackendAdapterCore( + descriptor: Self.backendDescriptor, + executablePath: executablePath, + executableIsAvailable: executableIsAvailable, + operations: operations, + validateMachine: { machine, capability in + guard machine.bootMode == .efi, machine.displayMode == .desktop else { + return "The current Virtualization.framework media path requires an EFI desktop machine." + } + switch capability.request.bootMedia.kind { + case .installerISO: + guard machine.installerISOPath?.isEmpty == false else { + return "An installer capability requires attached installer media." + } + case .virtualDisk: + guard machine.installerISOPath == nil else { + return "A virtual-disk capability cannot retain attached installer media." + } + if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) { + do { + guard try DoryDesktopVMMPreference(environment: machine.environment) == .compatible else { + return "An installed-Linux boot bundle must explicitly select the compatibility path to use this adapter." + } + } catch { + return "The machine contains an invalid desktop backend preference." + } + } + default: + return "The selected media is not implemented by the Virtualization.framework Linux adapter." + } + return nil + } + ) + } + + public func probe() -> MachineBackendProbeResult { core.probe() } + public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) } + public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) } + public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) } + public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) } + public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift new file mode 100644 index 00000000..44c79856 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -0,0 +1,363 @@ +import DoryOperations +import Foundation + +/// Lifecycle operations implemented by a daemon backend adapter. This is intentionally separate +/// from product support policy: it describes only what the concrete launch mechanism can do. +public struct MachineBackendLifecycleCapabilities: Codable, Sendable, Equatable, Hashable { + public var start: Bool + public var stop: Bool + public var pause: Bool + public var resume: Bool + + public init(start: Bool, stop: Bool, pause: Bool, resume: Bool) { + self.start = start + self.stop = stop + self.pause = pause + self.resume = resume + } + + /// `MachineManager` currently exposes start and stop, but not pause and resume. + public static let currentMachineManager = MachineBackendLifecycleCapabilities( + start: true, + stop: true, + pause: false, + resume: false + ) +} + +public struct MachineBackendDescriptor: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var identity: DoryVirtualizationBackendIdentity + /// Stable identity for the daemon adapter implementation, independent of the helper build. + public var implementationIdentifier: String + public var guestFamilies: [DoryGuestFamily] + public var guestArchitectures: [DoryGuestArchitecture] + public var bootMediaKinds: [DoryBootMediaKind] + public var lifecycle: MachineBackendLifecycleCapabilities + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + identity: DoryVirtualizationBackendIdentity, + implementationIdentifier: String, + guestFamilies: [DoryGuestFamily], + guestArchitectures: [DoryGuestArchitecture], + bootMediaKinds: [DoryBootMediaKind], + lifecycle: MachineBackendLifecycleCapabilities + ) { + self.schemaVersion = schemaVersion + self.identity = identity + self.implementationIdentifier = implementationIdentifier + self.guestFamilies = guestFamilies + self.guestArchitectures = guestArchitectures + self.bootMediaKinds = bootMediaKinds + self.lifecycle = lifecycle + } +} + +public enum MachineBackendProbeState: String, Codable, Sendable, Equatable, Hashable { + case available + case unavailable +} + +public enum MachineBackendFailureCode: String, Codable, Sendable, Equatable, Hashable { + case componentNotConfigured = "component-not-configured" + case componentUnavailable = "component-unavailable" + case duplicateRegistration = "duplicate-registration" + case backendNotRegistered = "backend-not-registered" + case capabilityPlanRejected = "capability-plan-rejected" + case capabilitySelectionInvalid = "capability-selection-invalid" + case backendIdentityMismatch = "backend-identity-mismatch" + case guestUnsupported = "guest-unsupported" + case bootMediaUnsupported = "boot-media-unsupported" + case machineConfigurationIncompatible = "machine-configuration-incompatible" + case lifecycleOperationUnsupported = "lifecycle-operation-unsupported" + case lifecycleOperationFailed = "lifecycle-operation-failed" +} + +public struct MachineBackendFailure: Codable, Error, Sendable, Equatable, Hashable { + public var code: MachineBackendFailureCode + public var backend: DoryVirtualizationBackendIdentity? + public var message: String + + public init( + code: MachineBackendFailureCode, + backend: DoryVirtualizationBackendIdentity? = nil, + message: String + ) { + self.code = code + self.backend = backend + self.message = message + } +} + +public struct MachineBackendProbeResult: Codable, Sendable, Equatable, Hashable { + public var descriptor: MachineBackendDescriptor + public var state: MachineBackendProbeState + public var failure: MachineBackendFailure? + + public var isAvailable: Bool { state == .available && failure == nil } + + public init( + descriptor: MachineBackendDescriptor, + state: MachineBackendProbeState, + failure: MachineBackendFailure? = nil + ) { + self.descriptor = descriptor + self.state = state + self.failure = failure + } +} + +/// Daemon-local bridge between a persisted machine and an already evaluated product capability. +/// The adapter does not choose a support tier or downgrade the planner's requested devices. +public struct MachineBackendPlanRequest: Codable, Sendable, Equatable, Hashable { + public var machine: DoryMachineConfiguration + public var capabilityPlan: DoryVirtualMachineBackendPlanResult + + public init( + machine: DoryMachineConfiguration, + capabilityPlan: DoryVirtualMachineBackendPlanResult + ) { + self.machine = machine + self.capabilityPlan = capabilityPlan + } +} + +public struct MachineBackendPlan: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var backend: MachineBackendDescriptor + public var machine: DoryMachineConfiguration + public var capability: DoryVirtualMachineCapabilityDescriptor + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + backend: MachineBackendDescriptor, + machine: DoryMachineConfiguration, + capability: DoryVirtualMachineCapabilityDescriptor + ) { + self.schemaVersion = schemaVersion + self.backend = backend + self.machine = machine + self.capability = capability + } +} + +public struct MachineBackendPlanResult: Codable, Sendable, Equatable, Hashable { + public var plan: MachineBackendPlan? + public var probe: MachineBackendProbeResult? + public var failure: MachineBackendFailure? + + public var isSuccess: Bool { plan != nil && failure == nil } + + public init( + plan: MachineBackendPlan?, + probe: MachineBackendProbeResult?, + failure: MachineBackendFailure? + ) { + self.plan = plan + self.probe = probe + self.failure = failure + } +} + +public enum MachineBackendRuntimeState: String, Codable, Sendable, Equatable, Hashable { + case created + case starting + case running + case paused + case stopped + case failed +} + +public struct MachineBackendRuntimeObservation: Codable, Sendable, Equatable, Hashable { + public var machineID: String + public var state: MachineBackendRuntimeState + public var processIdentifier: Int32? + public var failureMessage: String? + + public init( + machineID: String, + state: MachineBackendRuntimeState, + processIdentifier: Int32? = nil, + failureMessage: String? = nil + ) { + self.machineID = machineID + self.state = state + self.processIdentifier = processIdentifier + self.failureMessage = failureMessage + } +} + +public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashable { + public var machineID: String + public var backend: DoryVirtualizationBackendIdentity + + public init(machineID: String, backend: DoryVirtualizationBackendIdentity) { + self.machineID = machineID + self.backend = backend + } +} + +public enum MachineBackendLifecycleOperation: String, Codable, Sendable, Equatable, Hashable { + case start + case stop + case pause + case resume +} + +public struct MachineBackendOperationResult: Codable, Sendable, Equatable, Hashable { + public var operation: MachineBackendLifecycleOperation + public var backend: DoryVirtualizationBackendIdentity + public var observation: MachineBackendRuntimeObservation? + public var failure: MachineBackendFailure? + + public var isSuccess: Bool { observation != nil && failure == nil } + + public init( + operation: MachineBackendLifecycleOperation, + backend: DoryVirtualizationBackendIdentity, + observation: MachineBackendRuntimeObservation?, + failure: MachineBackendFailure? + ) { + self.operation = operation + self.backend = backend + self.observation = observation + self.failure = failure + } +} + +/// Mechanism boundary owned by doryd. Implementations validate a planner selection and adapt an +/// existing launcher; they must not decide product support tiers or silently alter capabilities. +public protocol MachineBackend: Sendable { + var descriptor: MachineBackendDescriptor { get } + + func probe() -> MachineBackendProbeResult + func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult + func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult + func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult + func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult + func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult +} + +/// Immutable registry for deterministic backend discovery and dispatch. +public struct BackendRegistry: Sendable { + private let backends: [DoryVirtualizationBackendIdentity: any MachineBackend] + + public init(backends: [any MachineBackend]) throws { + var indexed: [DoryVirtualizationBackendIdentity: any MachineBackend] = [:] + for backend in backends { + let identity = backend.descriptor.identity + guard indexed[identity] == nil else { + throw MachineBackendFailure( + code: .duplicateRegistration, + backend: identity, + message: "A backend is already registered for \(identity.rawValue)." + ) + } + indexed[identity] = backend + } + self.backends = indexed + } + + public var descriptors: [MachineBackendDescriptor] { + orderedBackends.map(\.descriptor) + } + + public func backend( + for identity: DoryVirtualizationBackendIdentity + ) -> (any MachineBackend)? { + backends[identity] + } + + public func probeAll() -> [MachineBackendProbeResult] { + orderedBackends.map { $0.probe() } + } + + public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { + guard request.capabilityPlan.failure == nil, + let selected = request.capabilityPlan.selectedDescriptor else { + return MachineBackendPlanResult( + plan: nil, + probe: nil, + failure: MachineBackendFailure( + code: .capabilityPlanRejected, + message: "The product capability planner did not select a backend." + ) + ) + } + let identity = selected.request.backend + guard let backend = backends[identity] else { + return MachineBackendPlanResult( + plan: nil, + probe: nil, + failure: MachineBackendFailure( + code: .backendNotRegistered, + backend: identity, + message: "No daemon adapter is registered for \(identity.rawValue)." + ) + ) + } + return backend.plan(request) + } + + public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { + guard let backend = backends[plan.backend.identity] else { + return missingBackendResult(operation: .start, identity: plan.backend.identity) + } + return backend.start(plan) + } + + public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + dispatch(.stop, request: request) { $0.stop(request) } + } + + public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + dispatch(.pause, request: request) { $0.pause(request) } + } + + public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + dispatch(.resume, request: request) { $0.resume(request) } + } + + private var orderedBackends: [any MachineBackend] { + backends.values.sorted { + let left = $0.descriptor + let right = $1.descriptor + if left.identity.rawValue != right.identity.rawValue { + return left.identity.rawValue < right.identity.rawValue + } + return left.implementationIdentifier < right.implementationIdentifier + } + } + + private func dispatch( + _ operation: MachineBackendLifecycleOperation, + request: MachineBackendRuntimeRequest, + action: (any MachineBackend) -> MachineBackendOperationResult + ) -> MachineBackendOperationResult { + guard let backend = backends[request.backend] else { + return missingBackendResult(operation: operation, identity: request.backend) + } + return action(backend) + } + + private func missingBackendResult( + operation: MachineBackendLifecycleOperation, + identity: DoryVirtualizationBackendIdentity + ) -> MachineBackendOperationResult { + MachineBackendOperationResult( + operation: operation, + backend: identity, + observation: nil, + failure: MachineBackendFailure( + code: .backendNotRegistered, + backend: identity, + message: "No daemon adapter is registered for \(identity.rawValue)." + ) + ) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift new file mode 100644 index 00000000..4b46ca3e --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -0,0 +1,343 @@ +@testable import DorydKit +import DoryOperations +import XCTest + +final class MachineBackendTests: XCTestCase { + func testConcreteDescriptorsDescribeOnlyCurrentLinuxMechanisms() { + let raw = RawHVLinuxMachineBackend.backendDescriptor + XCTAssertEqual(raw.identity, .doryHypervisor) + XCTAssertEqual(raw.guestFamilies, [.linux]) + XCTAssertEqual(raw.guestArchitectures, [.arm64]) + XCTAssertEqual(raw.bootMediaKinds, [.installedLinuxBootBundle]) + XCTAssertEqual(raw.lifecycle, .currentMachineManager) + + let vz = VirtualizationFrameworkLinuxMachineBackend.backendDescriptor + XCTAssertEqual(vz.identity, .appleVirtualizationFramework) + XCTAssertEqual(vz.guestFamilies, [.linux]) + XCTAssertEqual(vz.guestArchitectures, [.arm64]) + XCTAssertEqual(vz.bootMediaKinds, [.installerISO, .virtualDisk]) + XCTAssertFalse(vz.lifecycle.pause) + XCTAssertFalse(vz.lifecycle.resume) + } + + func testProbeFailsClosedForMissingAndUnavailableComponents() { + let operations = recordingOperations().operations + let missing = RawHVLinuxMachineBackend( + executablePath: nil, + operations: operations, + executableIsAvailable: { _ in true } + ).probe() + XCTAssertEqual(missing.state, .unavailable) + XCTAssertEqual(missing.failure?.code, .componentNotConfigured) + + let unavailable = VirtualizationFrameworkLinuxMachineBackend( + executablePath: "/fixture/dory-vmm", + operations: operations, + executableIsAvailable: { _ in false } + ).probe() + XCTAssertEqual(unavailable.state, .unavailable) + XCTAssertEqual(unavailable.failure?.code, .componentUnavailable) + + let available = RawHVLinuxMachineBackend( + executablePath: "/fixture/dory-hv", + operations: operations, + executableIsAvailable: { $0 == "/fixture/dory-hv" } + ).probe() + XCTAssertTrue(available.isAvailable) + XCTAssertNil(available.failure) + } + + func testRegistryRejectsDuplicateBackendIdentity() { + let operations = recordingOperations().operations + let first = availableRawBackend(operations: operations) + let second = availableRawBackend(operations: operations) + + XCTAssertThrowsError(try BackendRegistry(backends: [first, second])) { error in + let failure = error as? MachineBackendFailure + XCTAssertEqual(failure?.code, .duplicateRegistration) + XCTAssertEqual(failure?.backend, .doryHypervisor) + } + } + + func testRegistryProbeOrderIsStableAcrossRegistrationOrder() throws { + let operations = recordingOperations().operations + let raw = availableRawBackend(operations: operations) + let vz = availableVZBackend(operations: operations) + + let forward = try BackendRegistry(backends: [raw, vz]).probeAll() + let reverse = try BackendRegistry(backends: [vz, raw]).probeAll() + let expected: [DoryVirtualizationBackendIdentity] = [ + .appleVirtualizationFramework, + .doryHypervisor, + ] + XCTAssertEqual(forward.map(\.descriptor.identity), expected) + XCTAssertEqual(reverse.map(\.descriptor.identity), expected) + } + + func testRegistryPlansAndDispatchesRawHVLifecycleWithoutChangingOperations() throws { + let recorder = recordingOperations() + let registry = try BackendRegistry(backends: [ + availableRawBackend(operations: recorder.operations), + ]) + let request = MachineBackendPlanRequest( + machine: rawMachine(), + capabilityPlan: capabilityPlan( + backend: .doryHypervisor, + media: .installedLinuxBootBundle + ) + ) + + let result = registry.plan(request) + let plan = try XCTUnwrap(result.plan) + XCTAssertTrue(result.isSuccess) + XCTAssertEqual(plan.backend.identity, .doryHypervisor) + XCTAssertEqual(plan.machine.id, "raw-linux") + + let started = registry.start(plan) + XCTAssertTrue(started.isSuccess) + XCTAssertEqual(started.observation?.state, .running) + + let stopped = registry.stop(MachineBackendRuntimeRequest( + machineID: plan.machine.id, + backend: .doryHypervisor + )) + XCTAssertTrue(stopped.isSuccess) + XCTAssertEqual(stopped.observation?.state, .stopped) + XCTAssertEqual(recorder.events, ["start:raw-linux", "stop:raw-linux"]) + } + + func testRegistryPlansVZInstallerWithPlannerSelection() throws { + let registry = try BackendRegistry(backends: [ + availableVZBackend(operations: recordingOperations().operations), + ]) + let result = registry.plan(MachineBackendPlanRequest( + machine: vzInstallerMachine(), + capabilityPlan: capabilityPlan( + backend: .appleVirtualizationFramework, + media: .installerISO + ) + )) + + XCTAssertTrue(result.isSuccess) + XCTAssertEqual(result.plan?.backend.identity, .appleVirtualizationFramework) + XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installerISO) + } + + func testPlanningFailsClosedWhenProductPlannerHasNoSelection() throws { + let registry = try BackendRegistry(backends: [ + availableRawBackend(operations: recordingOperations().operations), + ]) + let result = registry.plan(MachineBackendPlanRequest( + machine: rawMachine(), + capabilityPlan: DoryVirtualMachineBackendPlanResult( + selectedDescriptor: nil, + evaluatedDescriptors: [], + failure: DoryVirtualMachineBackendPlanningFailure( + code: .noCandidate, + message: "fixture" + ) + ) + )) + + XCTAssertFalse(result.isSuccess) + XCTAssertEqual(result.failure?.code, .capabilityPlanRejected) + } + + func testPlanningFailsClosedForUnregisteredSelectedBackend() throws { + let registry = try BackendRegistry(backends: [ + availableRawBackend(operations: recordingOperations().operations), + ]) + let result = registry.plan(MachineBackendPlanRequest( + machine: vzInstallerMachine(), + capabilityPlan: capabilityPlan( + backend: .appleVirtualizationFramework, + media: .installerISO + ) + )) + + XCTAssertEqual(result.failure?.code, .backendNotRegistered) + XCTAssertEqual(result.failure?.backend, .appleVirtualizationFramework) + } + + func testAdapterRejectsSelectionMissingFromPlannerEvaluation() { + let backend = availableRawBackend(operations: recordingOperations().operations) + let descriptor = capabilityDescriptor( + backend: .doryHypervisor, + media: .installedLinuxBootBundle + ) + let result = backend.plan(MachineBackendPlanRequest( + machine: rawMachine(), + capabilityPlan: DoryVirtualMachineBackendPlanResult( + selectedDescriptor: descriptor, + evaluatedDescriptors: [], + failure: nil + ) + )) + + XCTAssertEqual(result.failure?.code, .capabilitySelectionInvalid) + } + + func testRawAdapterRejectsConfigurationThatWouldRouteToCompatibilityLauncher() { + let backend = availableRawBackend(operations: recordingOperations().operations) + var machine = rawMachine() + machine.environment[DoryDesktopVMMPreference.environmentKey] = "compatible" + let result = backend.plan(MachineBackendPlanRequest( + machine: machine, + capabilityPlan: capabilityPlan( + backend: .doryHypervisor, + media: .installedLinuxBootBundle + ) + )) + + XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible) + } + + func testVZInstallerPlanRequiresAttachedInstallerMedia() { + let backend = availableVZBackend(operations: recordingOperations().operations) + var machine = vzInstallerMachine() + machine.installerISOPath = nil + let result = backend.plan(MachineBackendPlanRequest( + machine: machine, + capabilityPlan: capabilityPlan( + backend: .appleVirtualizationFramework, + media: .installerISO + ) + )) + + XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible) + } + + func testPauseAndResumeAreExplicitlyUnsupportedAndDoNotInvokeLauncher() { + let recorder = recordingOperations() + let backend = availableRawBackend(operations: recorder.operations) + let request = MachineBackendRuntimeRequest( + machineID: "raw-linux", + backend: .doryHypervisor + ) + + XCTAssertEqual(backend.pause(request).failure?.code, .lifecycleOperationUnsupported) + XCTAssertEqual(backend.resume(request).failure?.code, .lifecycleOperationUnsupported) + XCTAssertEqual(recorder.events, []) + } + + func testBackendPlanRoundTripsWithPlannerCapabilityEvidence() throws { + let backend = availableVZBackend(operations: recordingOperations().operations) + let plan = try XCTUnwrap(backend.plan(MachineBackendPlanRequest( + machine: vzInstallerMachine(), + capabilityPlan: capabilityPlan( + backend: .appleVirtualizationFramework, + media: .installerISO + ) + )).plan) + + let data = try JSONEncoder().encode(plan) + XCTAssertEqual(try JSONDecoder().decode(MachineBackendPlan.self, from: data), plan) + } + + private func availableRawBackend( + operations: MachineBackendCompatibilityOperations + ) -> RawHVLinuxMachineBackend { + RawHVLinuxMachineBackend( + executablePath: "/fixture/dory-hv", + operations: operations, + executableIsAvailable: { _ in true } + ) + } + + private func availableVZBackend( + operations: MachineBackendCompatibilityOperations + ) -> VirtualizationFrameworkLinuxMachineBackend { + VirtualizationFrameworkLinuxMachineBackend( + executablePath: "/fixture/dory-vmm", + operations: operations, + executableIsAvailable: { _ in true } + ) + } + + private func rawMachine() -> DoryMachineConfiguration { + DoryMachineConfiguration( + id: "raw-linux", + kernelPath: "/fixture/direct-kernel", + rootfsPath: "/fixture/linux.raw", + bootMode: .linuxKernel, + displayMode: .desktop + ) + } + + private func vzInstallerMachine() -> DoryMachineConfiguration { + DoryMachineConfiguration( + id: "vz-linux", + kernelPath: "/fixture/nvram", + rootfsPath: "/fixture/linux.raw", + bootMode: .efi, + installerISOPath: "/fixture/ubuntu.iso", + displayMode: .desktop + ) + } + + private func capabilityPlan( + backend: DoryVirtualizationBackendIdentity, + media: DoryBootMediaKind + ) -> DoryVirtualMachineBackendPlanResult { + let descriptor = capabilityDescriptor(backend: backend, media: media) + return DoryVirtualMachineBackendPlanResult( + selectedDescriptor: descriptor, + evaluatedDescriptors: [descriptor], + failure: nil + ) + } + + private func capabilityDescriptor( + backend: DoryVirtualizationBackendIdentity, + media: DoryBootMediaKind + ) -> DoryVirtualMachineCapabilityDescriptor { + let request = DoryVirtualMachineCapabilityRequest( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMedia: DoryBootMedia(kind: media, source: .userProvided), + backend: backend, + graphics: .hostAcceleratedDisplay, + devices: .minimumBootable + ) + return DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: request, + availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + ), + resolvedDevices: request.devices + ) + } + + private func recordingOperations() -> OperationRecorder { + OperationRecorder() + } +} + +private final class OperationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedEvents: [String] = [] + + var events: [String] { + lock.withLock { recordedEvents } + } + + lazy var operations = MachineBackendCompatibilityOperations( + start: { [weak self] id in + self?.append("start:\(id)") + return MachineBackendRuntimeObservation( + machineID: id, + state: .running, + processIdentifier: 42 + ) + }, + stop: { [weak self] id in + self?.append("stop:\(id)") + return MachineBackendRuntimeObservation(machineID: id, state: .stopped) + } + ) + + private func append(_ event: String) { + lock.withLock { recordedEvents.append(event) } + } +} From 033c70f9e0cd5590d1f73bb48c1976eb0b3cf9a0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:47:03 +0000 Subject: [PATCH 012/338] feat(workspace): bridge legacy Linux configurations --- .../DoryMachineConfigurationMigration.swift | 659 ++++++++++++++++++ ...ryMachineConfigurationMigrationTests.swift | 296 ++++++++ 2 files changed, 955 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift new file mode 100644 index 00000000..3df0347a --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -0,0 +1,659 @@ +import CryptoKit +import DoryOperations +import Foundation + +/// Explicit evidence needed to project legacy machine metadata without consulting host globals +/// or guessing from mutable files while migration is in progress. +public struct DoryMachineConfigurationMigrationFacts: Sendable, Equatable { + public var guestArchitecture: DoryGuestArchitecture + /// Logical capacity of the legacy root disk. Persisted configurations normally no longer + /// contain `diskSizeBytes`, so the repository or artifact inspector must supply this fact. + public var systemDiskCapacityBytes: UInt64? + /// Required only for an installed EFI machine whose installer is no longer attached. + public var installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot? + public var lifecycle: DoryVMLifecycleMetadata + + public init( + guestArchitecture: DoryGuestArchitecture, + systemDiskCapacityBytes: UInt64? = nil, + installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot? = nil, + lifecycle: DoryVMLifecycleMetadata + ) { + self.guestArchitecture = guestArchitecture + self.systemDiskCapacityBytes = systemDiskCapacityBytes + self.installedEFIBoot = installedEFIBoot + self.lifecycle = lifecycle + } +} + +/// The two installed-EFI arrangements supported by today's MachineManager. +public enum DoryMachineConfigurationInstalledEFIBoot: String, Codable, Sendable, Equatable { + /// UEFI loads the installed system directly from its virtual disk. + case firmwareDisk = "firmware-disk" + /// Dory's verified kernel/initrd/root-device bundle launches the installed disk directly. + case installedLinuxBootBundle = "installed-linux-boot-bundle" +} + +/// The exact legacy boot ABI represented by a migration result. +public enum DoryMachineConfigurationLegacyBootContract: String, Codable, Sendable, Equatable { + case managedDirectKernel = "managed-direct-kernel" + case efiInstaller = "efi-installer" + case efiFirmwareDisk = "efi-firmware-disk" + case efiInstalledDirectBoot = "efi-installed-direct-boot" +} + +public enum DoryMachineConfigurationArtifactRole: String, Codable, Sendable, Equatable, Hashable { + case kernel + case systemDisk = "system-disk" + case installerISO = "installer-iso" +} + +/// Transitional resolver binding. It is intentionally not part of WorkspaceSpec persistence: +/// paths remain under the legacy metadata authority until an artifact repository owns them. +public struct DoryMachineConfigurationArtifactBinding: Sendable, Equatable { + public var role: DoryMachineConfigurationArtifactRole + public var reference: DoryVMResolverReference + public var path: String + + public init( + role: DoryMachineConfigurationArtifactRole, + reference: DoryVMResolverReference, + path: String + ) { + self.role = role + self.reference = reference + self.path = path + } +} + +/// Transitional host-share binding kept outside the path-hostile WorkspaceSpec. +public struct DoryMachineConfigurationShareBinding: Sendable, Equatable { + public var reference: DoryVMResolverReference + public var hostPath: String + + public init(reference: DoryVMResolverReference, hostPath: String) { + self.reference = reference + self.hostPath = hostPath + } +} + +public enum DoryMachineConfigurationMigrationError: Error, Sendable, Equatable, LocalizedError { + case invalidLegacyPayload + case invalidLegacyPreference(String) + case invalidLegacyConfiguration(String) + case missingSystemDiskCapacity + case conflictingSystemDiskCapacity(configured: UInt64, inspected: UInt64) + case missingInstalledEFIBootFact + case invalidDefinition([DoryVMDefinitionValidationIssue]) + case unsupportedDefinitionChange(String) + case resourceNotRepresentable(String) + case unresolvedArtifact(DoryVMResolverReference) + case unresolvedShare(DoryVMResolverReference) + + public var errorDescription: String? { + switch self { + case .invalidLegacyPayload: + "Legacy machine configuration JSON is invalid." + case let .invalidLegacyPreference(key): + "Legacy runtime preference \(key) is invalid." + case let .invalidLegacyConfiguration(reason): + "Legacy machine configuration is not migration-safe: \(reason)" + case .missingSystemDiskCapacity: + "The legacy root disk capacity must be supplied explicitly." + case let .conflictingSystemDiskCapacity(configured, inspected): + "Configured disk capacity \(configured) does not match supplied capacity \(inspected)." + case .missingInstalledEFIBootFact: + "Installed EFI boot requires an explicit firmware-disk or direct-boot-bundle fact." + case let .invalidDefinition(issues): + "Projected workspace definition is invalid: " + + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ") + case let .unsupportedDefinitionChange(field): + "The legacy machine format cannot represent the workspace change at \(field)." + case let .resourceNotRepresentable(field): + "The workspace resource cannot be represented by legacy field \(field)." + case let .unresolvedArtifact(reference): + "No legacy artifact binding exists for \(reference.namespace):\(reference.identifier)." + case let .unresolvedShare(reference): + "No legacy share binding exists for \(reference.namespace):\(reference.identifier)." + } + } +} + +/// A lossless in-memory bridge while `machine.json` remains authoritative. +/// +/// `definition` is safe for WorkspaceSpec persistence. The authoritative legacy configuration +/// and path bindings are deliberately non-Codable so a workspace record cannot accidentally copy +/// host paths, environment secrets, or other compatibility-only state. +public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { + public var definition: DoryVirtualMachineDefinition + public let bootContract: DoryMachineConfigurationLegacyBootContract + public let artifactBindings: [DoryMachineConfigurationArtifactBinding] + public let shareBindings: [DoryMachineConfigurationShareBinding] + + private let authoritativeLegacyConfiguration: DoryMachineConfiguration + private let baselineDefinition: DoryVirtualMachineDefinition + + fileprivate init( + definition: DoryVirtualMachineDefinition, + bootContract: DoryMachineConfigurationLegacyBootContract, + artifactBindings: [DoryMachineConfigurationArtifactBinding], + shareBindings: [DoryMachineConfigurationShareBinding], + authoritativeLegacyConfiguration: DoryMachineConfiguration + ) { + self.definition = definition + self.bootContract = bootContract + self.artifactBindings = artifactBindings + self.shareBindings = shareBindings + self.authoritativeLegacyConfiguration = authoritativeLegacyConfiguration + baselineDefinition = definition + } + + public func artifactPath(for reference: DoryVMResolverReference) -> String? { + artifactBindings.first { $0.reference == reference }?.path + } + + public func hostPath(for reference: DoryVMResolverReference) -> String? { + shareBindings.first { $0.reference == reference }?.hostPath + } + + /// Reconstruct the legacy projection. Unsupported v2-only changes fail rather than being + /// silently dropped, while fields represented by both contracts are written from typed v2 + /// values. An untouched definition reconstructs an equal legacy value and canonical bytes. + public func legacyConfiguration() throws -> DoryMachineConfiguration { + let issues = definition.validate() + guard issues.isEmpty else { + throw DoryMachineConfigurationMigrationError.invalidDefinition(issues) + } + guard definition.identity.id == baselineDefinition.identity.id else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("identity.id") + } + guard definition.identity.name == baselineDefinition.identity.name else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("identity.name") + } + guard definition.guest == baselineDefinition.guest else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("guest") + } + guard definition.workload == baselineDefinition.workload else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("workload") + } + guard definition.boot == baselineDefinition.boot else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("boot") + } + guard definition.networkMode == baselineDefinition.networkMode else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("networkMode") + } + guard definition.display == baselineDefinition.display else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("display") + } + guard definition.audio == baselineDefinition.audio else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("audio") + } + guard definition.input == baselineDefinition.input else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("input") + } + guard definition.integrations == baselineDefinition.integrations else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("integrations") + } + guard definition.storage.count == 1, + definition.storage[0].id == baselineDefinition.storage[0].id, + definition.storage[0].role == .system, + !definition.storage[0].readOnly, + artifactPath(for: definition.storage[0].artifact) != nil else { + if let attachment = definition.storage.first, + artifactPath(for: attachment.artifact) == nil { + throw DoryMachineConfigurationMigrationError.unresolvedArtifact(attachment.artifact) + } + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("storage") + } + + guard definition.resources.virtualCPUCount <= UInt64(Int.max) else { + throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("cpuCount") + } + let mebibyte: UInt64 = 1_048_576 + guard definition.resources.memoryBytes > 0, + definition.resources.memoryBytes % mebibyte == 0 else { + throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("memoryMB") + } + let memoryMB = definition.resources.memoryBytes / mebibyte + let cpuCount = Int(definition.resources.virtualCPUCount) + + var diskSizeBytes = authoritativeLegacyConfiguration.diskSizeBytes + if diskSizeBytes != nil { + diskSizeBytes = definition.storage[0].capacityBytes + } else if definition.storage[0].capacityBytes != baselineDefinition.storage[0].capacityBytes + || definition.resources.diskBytes != baselineDefinition.resources.diskBytes { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "resources.diskBytes" + ) + } + guard definition.resources.diskBytes == definition.storage[0].capacityBytes else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "storage[0].capacityBytes" + ) + } + + var environment = authoritativeLegacyConfiguration.environment + try applyBackendPreference(to: &environment) + try applyGraphicsPolicy(to: &environment) + + let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in + guard let hostPath = hostPath(for: share.hostLocation) else { + throw DoryMachineConfigurationMigrationError.unresolvedShare(share.hostLocation) + } + let migrated = DoryMachineShareConfiguration( + tag: share.id, + hostPath: hostPath, + guestPath: share.guestMountPath, + readOnly: share.readOnly + ) + do { + try migrated.validate() + } catch { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("shares") + } + return migrated + } + + return DoryMachineConfiguration( + id: definition.identity.id, + kernelPath: authoritativeLegacyConfiguration.kernelPath, + rootfsPath: authoritativeLegacyConfiguration.rootfsPath, + bootMode: authoritativeLegacyConfiguration.bootMode, + installerISOPath: authoritativeLegacyConfiguration.installerISOPath, + diskSizeBytes: diskSizeBytes, + memoryMB: memoryMB, + cpuCount: cpuCount, + address: authoritativeLegacyConfiguration.address, + displayMode: authoritativeLegacyConfiguration.displayMode, + shares: shares, + environment: environment + ) + } + + public func authoritativeLegacyData() throws -> Data { + try DoryMachineConfigurationMigrationBridge.encodeLegacy(legacyConfiguration()) + } + + private func applyBackendPreference(to environment: inout [String: String]) throws { + guard definition.backendPreference != baselineDefinition.backendPreference else { return } + guard bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot, + authoritativeLegacyConfiguration.displayMode == .desktop else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "backendPreference" + ) + } + let raw: String + switch (definition.backendPreference.mode, definition.backendPreference.backend) { + case (.automatic, nil): + raw = DoryDesktopVMMPreference.automatic.rawValue + case (.preferred, .doryHypervisor?): + raw = DoryDesktopVMMPreference.accelerated.rawValue + case (.preferred, .appleVirtualizationFramework?): + raw = DoryDesktopVMMPreference.compatible.rawValue + default: + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "backendPreference" + ) + } + environment[DoryDesktopVMMPreference.environmentKey] = raw + } + + private func applyGraphicsPolicy(to environment: inout [String: String]) throws { + guard definition.graphics != baselineDefinition.graphics else { return } + guard bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot, + authoritativeLegacyConfiguration.displayMode == .desktop else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("graphics") + } + let preference: DoryDesktopGraphicsPreference + switch definition.graphics.acceptableLevels { + case [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]: + preference = .automatic + case [.hardwareAccelerated3D]: + preference = .virglVenus + case [.software]: + preference = .software + default: + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("graphics") + } + environment[DoryDesktopGraphicsPreference.environmentKey] = preference.rawValue + environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey) + } +} + +/// Pure migration entry points. MachineManager remains the legacy persistence owner; this bridge +/// neither writes files nor reads ProcessInfo or filesystem state. +public enum DoryMachineConfigurationMigrationBridge { + private static let mebibyte: UInt64 = 1_048_576 + + public static func decodeAndMigrate( + _ legacyData: Data, + facts: DoryMachineConfigurationMigrationFacts + ) throws -> DoryMachineConfigurationMigrationResult { + let configuration: DoryMachineConfiguration + do { + configuration = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ) + } catch { + throw DoryMachineConfigurationMigrationError.invalidLegacyPayload + } + return try migrate(configuration, facts: facts) + } + + /// Canonical legacy bytes match MachineManager's existing persistence formatter and schema. + public static func encodeLegacy(_ configuration: DoryMachineConfiguration) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + return try encoder.encode(configuration) + } catch { + throw DoryMachineConfigurationMigrationError.invalidLegacyPayload + } + } + + public static func migrate( + _ configuration: DoryMachineConfiguration, + facts: DoryMachineConfigurationMigrationFacts + ) throws -> DoryMachineConfigurationMigrationResult { + guard configuration.cpuCount > 0 else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "cpuCount must be positive" + ) + } + let (memoryBytes, memoryOverflow) = configuration.memoryMB.multipliedReportingOverflow( + by: mebibyte + ) + guard !memoryOverflow, memoryBytes > 0 else { + throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("memoryMB") + } + let diskCapacity: UInt64 + if let configured = configuration.diskSizeBytes, + let inspected = facts.systemDiskCapacityBytes, + configured != inspected { + throw DoryMachineConfigurationMigrationError.conflictingSystemDiskCapacity( + configured: configured, + inspected: inspected + ) + } else if let supplied = facts.systemDiskCapacityBytes ?? configuration.diskSizeBytes, + supplied > 0 { + diskCapacity = supplied + } else { + throw DoryMachineConfigurationMigrationError.missingSystemDiskCapacity + } + guard !configuration.kernelPath.isEmpty else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "kernelPath is empty" + ) + } + guard !configuration.rootfsPath.isEmpty else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "rootfsPath is empty" + ) + } + + let bootContract = try legacyBootContract(configuration, facts: facts) + let vmmPreference: DoryDesktopVMMPreference + let graphicsPreference: DoryDesktopGraphicsPreference + do { + vmmPreference = try DoryDesktopVMMPreference(environment: configuration.environment) + } catch { + throw DoryMachineConfigurationMigrationError.invalidLegacyPreference( + DoryDesktopVMMPreference.environmentKey + ) + } + do { + graphicsPreference = try DoryDesktopGraphicsPreference( + environment: configuration.environment + ) + } catch { + throw DoryMachineConfigurationMigrationError.invalidLegacyPreference( + DoryDesktopGraphicsPreference.environmentKey + ) + } + + let systemDiskReference = stableReference( + namespace: "legacy-artifact", + identity: configuration.id + "\u{0}system-disk\u{0}" + configuration.rootfsPath + ) + let kernelReference = stableReference( + namespace: "legacy-artifact", + identity: configuration.id + "\u{0}kernel\u{0}" + configuration.kernelPath + ) + var artifactBindings = [ + DoryMachineConfigurationArtifactBinding( + role: .kernel, + reference: kernelReference, + path: configuration.kernelPath + ), + DoryMachineConfigurationArtifactBinding( + role: .systemDisk, + reference: systemDiskReference, + path: configuration.rootfsPath + ), + ] + + let boot: DoryVMBootConfiguration + switch bootContract { + case .managedDirectKernel: + boot = normalBoot( + kind: .installedLinuxBootBundle, + source: configuration.environment["DORY_CUSTOM_LINUX"] == "1" + ? .userProvided : .bundledByDory, + artifact: kernelReference + ) + case .efiInstaller: + guard let installerPath = configuration.installerISOPath, + !installerPath.isEmpty else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "installerISOPath is empty" + ) + } + let installerReference = stableReference( + namespace: "legacy-artifact", + identity: configuration.id + "\u{0}installer-iso\u{0}" + installerPath + ) + artifactBindings.append(DoryMachineConfigurationArtifactBinding( + role: .installerISO, + reference: installerReference, + path: installerPath + )) + boot = DoryVMBootConfiguration( + phase: .install, + devices: [DoryVMBootMediaReference( + id: "installer", + role: .installer, + kind: .installerISO, + source: .userProvided, + artifact: installerReference, + removable: true + )], + order: ["installer"] + ) + case .efiFirmwareDisk: + boot = normalBoot( + kind: .virtualDisk, + source: .userProvided, + artifact: systemDiskReference + ) + case .efiInstalledDirectBoot: + boot = normalBoot( + kind: .installedLinuxBootBundle, + source: .userProvided, + artifact: kernelReference + ) + } + + var shareBindings: [DoryMachineConfigurationShareBinding] = [] + let shares = configuration.shares.enumerated().map { index, share in + let reference = stableReference( + namespace: "legacy-share", + identity: configuration.id + "\u{0}\(index)\u{0}" + share.tag + "\u{0}" + share.hostPath + ) + shareBindings.append(DoryMachineConfigurationShareBinding( + reference: reference, + hostPath: share.hostPath + )) + return DoryVMShare( + id: share.tag, + hostLocation: reference, + guestMountPath: share.guestPath, + readOnly: share.readOnly + ) + } + + let isDesktop = configuration.displayMode == .desktop + let acceleratedBoot = bootContract == .managedDirectKernel + || bootContract == .efiInstalledDirectBoot + let backendPreference: DoryVMBackendPreference + if isDesktop, acceleratedBoot { + backendPreference = typedBackendPreference(vmmPreference) + } else { + backendPreference = DoryVMBackendPreference( + mode: .preferred, + backend: .appleVirtualizationFramework + ) + } + let graphics: DoryVMGraphicsPolicy + if !isDesktop { + graphics = DoryVMGraphicsPolicy(acceptableLevels: [.none]) + } else if acceleratedBoot { + graphics = typedGraphicsPolicy(graphicsPreference) + } else { + graphics = DoryVMGraphicsPolicy( + acceptableLevels: [.hostAcceleratedDisplay, .software] + ) + } + + let definition = DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: configuration.id, name: configuration.id), + guest: DoryGuestPlatform(family: .linux, architecture: facts.guestArchitecture), + workload: isDesktop ? .desktop : .server, + boot: boot, + backendPreference: backendPreference, + graphics: graphics, + resources: DoryVMResourceRequest( + virtualCPUCount: UInt64(configuration.cpuCount), + memoryBytes: memoryBytes, + diskBytes: diskCapacity + ), + storage: [DoryVMStorageAttachment( + id: "system", + role: .system, + artifact: systemDiskReference, + capacityBytes: diskCapacity + )], + networkMode: .sharedNAT, + display: isDesktop ? DoryVMDisplayConfiguration() : .disabled, + audio: isDesktop + ? DoryVMAudioConfiguration() + : DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), + input: isDesktop + ? DoryVMInputConfiguration() + : DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), + shares: shares, + integrations: isDesktop + ? [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown] + : [.clockSynchronization, .gracefulShutdown], + lifecycle: facts.lifecycle + ) + let issues = definition.validate() + guard issues.isEmpty else { + throw DoryMachineConfigurationMigrationError.invalidDefinition(issues) + } + return DoryMachineConfigurationMigrationResult( + definition: definition, + bootContract: bootContract, + artifactBindings: artifactBindings, + shareBindings: shareBindings, + authoritativeLegacyConfiguration: configuration + ) + } + + private static func legacyBootContract( + _ configuration: DoryMachineConfiguration, + facts: DoryMachineConfigurationMigrationFacts + ) throws -> DoryMachineConfigurationLegacyBootContract { + switch configuration.bootMode { + case .linuxKernel: + guard configuration.installerISOPath == nil else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "linux-kernel boot cannot attach installer media" + ) + } + return .managedDirectKernel + case .efi: + guard configuration.displayMode == .desktop else { + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "EFI headless mode is not supported by MachineManager" + ) + } + if configuration.installerISOPath != nil { return .efiInstaller } + switch facts.installedEFIBoot { + case .firmwareDisk?: return .efiFirmwareDisk + case .installedLinuxBootBundle?: return .efiInstalledDirectBoot + case nil: throw DoryMachineConfigurationMigrationError.missingInstalledEFIBootFact + } + } + } + + private static func normalBoot( + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + artifact: DoryVMResolverReference + ) -> DoryVMBootConfiguration { + DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", + role: .system, + kind: kind, + source: source, + artifact: artifact, + removable: false + )], + order: ["system"] + ) + } + + private static func typedBackendPreference( + _ preference: DoryDesktopVMMPreference + ) -> DoryVMBackendPreference { + switch preference { + case .automatic: + DoryVMBackendPreference() + case .accelerated: + DoryVMBackendPreference(mode: .preferred, backend: .doryHypervisor) + case .compatible: + DoryVMBackendPreference( + mode: .preferred, + backend: .appleVirtualizationFramework + ) + } + } + + private static func typedGraphicsPolicy( + _ preference: DoryDesktopGraphicsPreference + ) -> DoryVMGraphicsPolicy { + switch preference { + case .automatic: + DoryVMGraphicsPolicy( + acceptableLevels: [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software] + ) + case .virgl, .virglVenus: + DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D]) + case .software: + DoryVMGraphicsPolicy(acceptableLevels: [.software]) + } + } + + private static func stableReference( + namespace: String, + identity: String + ) -> DoryVMResolverReference { + let digest = SHA256.hash(data: Data(identity.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return DoryVMResolverReference(namespace: namespace, identifier: digest) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift new file mode 100644 index 00000000..177db8e6 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -0,0 +1,296 @@ +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("Legacy machine configuration migration") +struct DoryMachineConfigurationMigrationTests { + private let gibibyte: UInt64 = 1_073_741_824 + + @Test("managed desktop projects typed policy and round trips canonical legacy bytes") + func managedDesktopRoundTrip() throws { + let legacy = DoryMachineConfiguration( + id: "ubuntu-dev", + kernelPath: "/managed/ubuntu-dev/kernel", + rootfsPath: "/managed/ubuntu-dev/rootfs.ext4", + memoryMB: 8_192, + cpuCount: 6, + address: "192.168.64.20", + displayMode: .desktop, + shares: [DoryMachineShareConfiguration( + tag: "source", + hostPath: "/Users/developer/Source", + guestPath: "/workspace/source", + readOnly: true + )], + environment: [ + DoryDesktopVMMPreference.environmentKey: "accelerated", + DoryDesktopGraphicsPreference.environmentKey: "virgl", + "DORY_DESKTOP_DISTRO": "ubuntu", + "PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace", + ] + ) + let migrated = try migrate(legacy, capacity: 96 * gibibyte) + + #expect(migrated.definition.isValid) + #expect(migrated.bootContract == .managedDirectKernel) + #expect(migrated.definition.workload == .desktop) + #expect(migrated.definition.boot.phase == .normal) + #expect(migrated.definition.boot.devices[0].kind == .installedLinuxBootBundle) + #expect(migrated.definition.boot.devices[0].source == .bundledByDory) + #expect(migrated.definition.backendPreference == DoryVMBackendPreference( + mode: .preferred, + backend: .doryHypervisor + )) + #expect(migrated.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + #expect(migrated.definition.resources.memoryBytes == 8 * gibibyte) + #expect(migrated.definition.resources.virtualCPUCount == 6) + #expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte) + #expect(migrated.hostPath(for: migrated.definition.shares[0].hostLocation) + == "/Users/developer/Source") + #expect(migrated.artifactPath(for: migrated.definition.storage[0].artifact) + == legacy.rootfsPath) + + let definitionData = try JSONEncoder().encode(migrated.definition) + let definitionJSON = try #require(String(data: definitionData, encoding: .utf8)) + #expect(!definitionJSON.contains("/Users/developer")) + #expect(!definitionJSON.contains("PRIVATE_RUNTIME_VALUE")) + #expect(!definitionJSON.contains("do-not-copy-into-workspace")) + + #expect(try migrated.legacyConfiguration() == legacy) + #expect(try migrated.authoritativeLegacyData() + == DoryMachineConfigurationMigrationBridge.encodeLegacy(legacy)) + + let repeated = try migrate(legacy, capacity: 96 * gibibyte) + #expect(repeated.definition.storage[0].artifact == migrated.definition.storage[0].artifact) + #expect(repeated.definition.boot.devices[0].artifact + == migrated.definition.boot.devices[0].artifact) + #expect(repeated.definition.shares[0].hostLocation + == migrated.definition.shares[0].hostLocation) + } + + @Test("headless Linux maps to an explicit non-graphical Virtualization.framework contract") + func headlessRoundTrip() throws { + let legacy = DoryMachineConfiguration( + id: "build-server", + kernelPath: "/managed/build-server/kernel", + rootfsPath: "/managed/build-server/rootfs.ext4", + memoryMB: 4_096, + cpuCount: 4, + displayMode: .headless, + environment: ["SERVICE_TOKEN": "legacy-only-value"] + ) + let migrated = try migrate(legacy, capacity: 48 * gibibyte) + + #expect(migrated.definition.workload == .server) + #expect(migrated.definition.display == .disabled) + #expect(migrated.definition.graphics.acceptableLevels == [.none]) + #expect(migrated.definition.backendPreference.backend == .appleVirtualizationFramework) + #expect(!migrated.definition.audio.inputEnabled) + #expect(!migrated.definition.audio.outputEnabled) + #expect(!migrated.definition.input.keyboardEnabled) + #expect(!migrated.definition.input.pointerEnabled) + #expect(try migrated.legacyConfiguration() == legacy) + } + + @Test("custom EFI ISO maps installer media and preserves blank-disk intent") + func customEFIInstallerRoundTrip() throws { + let legacy = DoryMachineConfiguration( + id: "omarchy", + kernelPath: "/managed/omarchy/kernel", + rootfsPath: "/managed/omarchy/rootfs.ext4", + bootMode: .efi, + installerISOPath: "/managed/omarchy/installer.iso", + diskSizeBytes: 80 * gibibyte, + memoryMB: 8_192, + cpuCount: 6, + displayMode: .desktop, + environment: ["DORY_CUSTOM_LINUX": "1"] + ) + let migrated = try migrate(legacy, capacity: 80 * gibibyte) + + #expect(migrated.bootContract == .efiInstaller) + #expect(migrated.definition.boot.phase == .install) + #expect(migrated.definition.boot.devices[0].kind == .installerISO) + #expect(migrated.definition.boot.devices[0].source == .userProvided) + #expect(migrated.definition.boot.devices[0].removable) + #expect(migrated.definition.backendPreference.backend == .appleVirtualizationFramework) + #expect(migrated.artifactPath(for: migrated.definition.boot.devices[0].artifact) + == legacy.installerISOPath) + #expect(try migrated.legacyConfiguration() == legacy) + } + + @Test("installed EFI firmware and direct-boot bundle mappings remain distinct") + func installedEFIMappings() throws { + let legacy = DoryMachineConfiguration( + id: "installed-linux", + kernelPath: "/managed/installed-linux/kernel", + rootfsPath: "/managed/installed-linux/rootfs.ext4", + bootMode: .efi, + memoryMB: 4_096, + cpuCount: 4, + displayMode: .desktop, + environment: [DoryDesktopVMMPreference.environmentKey: "accelerated"] + ) + + let firmware = try migrate( + legacy, + capacity: 64 * gibibyte, + installedEFI: .firmwareDisk + ) + #expect(firmware.bootContract == .efiFirmwareDisk) + #expect(firmware.definition.boot.devices[0].kind == .virtualDisk) + #expect(firmware.definition.boot.devices[0].artifact + == firmware.definition.storage[0].artifact) + #expect(firmware.definition.backendPreference.backend == .appleVirtualizationFramework) + #expect(try firmware.legacyConfiguration() == legacy) + + let direct = try migrate( + legacy, + capacity: 64 * gibibyte, + installedEFI: .installedLinuxBootBundle + ) + #expect(direct.bootContract == .efiInstalledDirectBoot) + #expect(direct.definition.boot.devices[0].kind == .installedLinuxBootBundle) + #expect(direct.definition.boot.devices[0].artifact + != direct.definition.storage[0].artifact) + #expect(direct.definition.backendPreference.backend == .doryHypervisor) + #expect(try direct.legacyConfiguration() == legacy) + } + + @Test("typed policy edits use canonical legacy environment keys without losing other values") + func typedPolicyBackProjection() throws { + let legacy = DoryMachineConfiguration( + id: "desktop", + kernelPath: "/managed/desktop/kernel", + rootfsPath: "/managed/desktop/rootfs.ext4", + memoryMB: 4_096, + cpuCount: 4, + displayMode: .desktop, + environment: [ + DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1", + "PRESERVE": "yes", + ] + ) + var migrated = try migrate(legacy, capacity: 64 * gibibyte) + migrated.definition.backendPreference = DoryVMBackendPreference( + mode: .preferred, + backend: .appleVirtualizationFramework + ) + migrated.definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software]) + let projected = try migrated.legacyConfiguration() + + #expect(projected.environment[DoryDesktopVMMPreference.environmentKey] == "compatible") + #expect(projected.environment[DoryDesktopGraphicsPreference.environmentKey] == "software") + #expect(projected.environment[ + DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey + ] == nil) + #expect(projected.environment["PRESERVE"] == "yes") + } + + @Test("unsupported and under-specified migrations fail explicitly") + func explicitMigrationFailures() throws { + let direct = DoryMachineConfiguration( + id: "direct", + kernelPath: "/managed/direct/kernel", + rootfsPath: "/managed/direct/rootfs.ext4" + ) + #expect(throws: DoryMachineConfigurationMigrationError.missingSystemDiskCapacity) { + try DoryMachineConfigurationMigrationBridge.migrate( + direct, + facts: facts(capacity: nil) + ) + } + + let installedEFI = DoryMachineConfiguration( + id: "installed", + kernelPath: "/managed/installed/kernel", + rootfsPath: "/managed/installed/rootfs.ext4", + bootMode: .efi, + displayMode: .desktop + ) + #expect(throws: DoryMachineConfigurationMigrationError.missingInstalledEFIBootFact) { + try DoryMachineConfigurationMigrationBridge.migrate( + installedEFI, + facts: facts(capacity: 64 * gibibyte) + ) + } + + var migrated = try migrate(direct, capacity: 64 * gibibyte) + migrated.definition.backendPreference = DoryVMBackendPreference( + mode: .required, + backend: .doryHypervisor + ) + #expect(throws: DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "backendPreference" + )) { + try migrated.legacyConfiguration() + } + + migrated = try migrate(direct, capacity: 64 * gibibyte) + migrated.definition.storage[0].artifact = DoryVMResolverReference( + namespace: "artifact", + identifier: "unknown" + ) + #expect(throws: DoryMachineConfigurationMigrationError.unresolvedArtifact( + DoryVMResolverReference(namespace: "artifact", identifier: "unknown") + )) { + try migrated.legacyConfiguration() + } + } + + @Test("old legacy JSON defaults decode and retain the canonical machine.json wire shape") + func legacyGoldenWireCompatibility() throws { + let oldData = Data(#"{"id":"legacy","kernelPath":"/m/legacy/kernel","rootfsPath":"/m/legacy/rootfs.ext4"}"#.utf8) + let migrated = try DoryMachineConfigurationMigrationBridge.decodeAndMigrate( + oldData, + facts: facts(capacity: 32 * gibibyte) + ) + let canonical = try migrated.authoritativeLegacyData() + let canonicalString = try #require(String(data: canonical, encoding: .utf8)) + #expect(canonicalString == #""" + { + "bootMode" : "linux-kernel", + "cpuCount" : 2, + "displayMode" : "headless", + "environment" : { + + }, + "id" : "legacy", + "kernelPath" : "\/m\/legacy\/kernel", + "memoryMB" : 2048, + "rootfsPath" : "\/m\/legacy\/rootfs.ext4", + "shares" : [ + + ] + } + """#) + } + + private func migrate( + _ configuration: DoryMachineConfiguration, + capacity: UInt64, + installedEFI: DoryMachineConfigurationInstalledEFIBoot? = nil + ) throws -> DoryMachineConfigurationMigrationResult { + try DoryMachineConfigurationMigrationBridge.migrate( + configuration, + facts: facts(capacity: capacity, installedEFI: installedEFI) + ) + } + + private func facts( + capacity: UInt64?, + installedEFI: DoryMachineConfigurationInstalledEFIBoot? = nil + ) -> DoryMachineConfigurationMigrationFacts { + DoryMachineConfigurationMigrationFacts( + guestArchitecture: .arm64, + systemDiskCapacityBytes: capacity, + installedEFIBoot: installedEFI, + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_787_200_000_000, + updatedAtUnixMilliseconds: 1_787_200_000_000 + ) + ) + } +} From 95a548b5c25fad6812da1907ef1cc62abb3dd8d8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 11:57:54 +0000 Subject: [PATCH 013/338] feat(workspace): journal lifecycle operations --- .../DoryOperationJournalModels.swift | 17 + .../DoryOperationJournalPublication.swift | 14 +- .../DoryWorkspaceLifecycleOperation.swift | 559 ++++++++++++++++++ ...DoryWorkspaceLifecycleOperationTests.swift | 261 ++++++++ 4 files changed, 844 insertions(+), 7 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift index 2ffff482..e0245122 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift @@ -6,6 +6,20 @@ public enum DoryOperationKind: String, Codable, CaseIterable, Sendable { case driveRestore case driveRelocation case driveUpgrade + case workspaceImport + case workspaceProvision + case workspaceResolve + case workspaceStart + case workspaceStop + case workspacePause + case workspaceResume + case workspaceSuspend + case workspaceRestore + case workspaceSnapshot + case workspaceClone + case workspaceUpdate + case workspaceRepair + case workspaceDelete } public enum DoryOperationAuthorityKind: String, Codable, CaseIterable, Sendable { @@ -13,6 +27,9 @@ public enum DoryOperationAuthorityKind: String, Codable, CaseIterable, Sendable case dataDrive case backupArchive case filesystem + case workspace + case machineBackend + case componentSet } public enum DoryOperationPhase: String, Codable, CaseIterable, Sendable { diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift index c27f4813..a419151d 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift @@ -25,7 +25,7 @@ extension DoryOperationJournalStore { do { try Self.createPrivateDirectory(partial) try Self.createOperationDirectories(in: partial) - if completenessPlanData != nil { + if completenessPlanData != nil || !specifications.isEmpty { try Self.createPrivateDirectory(partial + "/specs/objects") } @@ -37,12 +37,12 @@ extension DoryOperationJournalStore { completenessPlanData, to: partial + "/specs/completeness-plan.json" ) - for specification in specifications.sorted(by: { $0.digest < $1.digest }) { - try Self.publish( - specification.data, - to: partial + "/specs/objects/" + specification.digest - ) - } + } + for specification in specifications.sorted(by: { $0.digest < $1.digest }) { + try Self.publish( + specification.data, + to: partial + "/specs/objects/" + specification.digest + ) } try Self.publish(try Self.encoded(initial.state, pretty: true), to: partial + "/state.json") try Self.publish(try Self.encoded(initial.event, pretty: false), to: partial + "/events.ndjson") diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift new file mode 100644 index 00000000..134d9874 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift @@ -0,0 +1,559 @@ +import Foundation + +public enum DoryWorkspaceLifecycleState: String, Codable, CaseIterable, Sendable { + case absent + case defined + case stopped + case running + case paused + case suspended + case failed + case deleting +} + +public enum DoryWorkspaceMutationKind: String, Codable, CaseIterable, Sendable { + case importing + case provisioning + case resolving + case starting + case stopping + case pausing + case resuming + case suspending + case restoring + case snapshotting + case cloning + case updating + case repairing + case deleting + + public var journalKind: DoryOperationKind { + switch self { + case .importing: .workspaceImport + case .provisioning: .workspaceProvision + case .resolving: .workspaceResolve + case .starting: .workspaceStart + case .stopping: .workspaceStop + case .pausing: .workspacePause + case .resuming: .workspaceResume + case .suspending: .workspaceSuspend + case .restoring: .workspaceRestore + case .snapshotting: .workspaceSnapshot + case .cloning: .workspaceClone + case .updating: .workspaceUpdate + case .repairing: .workspaceRepair + case .deleting: .workspaceDelete + } + } +} + +public struct DoryWorkspaceResolvedCondition: Codable, Sendable, Equatable { + public var planRevision: UInt64 + public var planDigest: String + public var backendID: String + public var backendRuntimeBuildID: String + public var virtualHardwareABIVersion: UInt16 + + public init( + planRevision: UInt64, + planDigest: String, + backendID: String, + backendRuntimeBuildID: String, + virtualHardwareABIVersion: UInt16 + ) { + self.planRevision = planRevision + self.planDigest = planDigest + self.backendID = backendID + self.backendRuntimeBuildID = backendRuntimeBuildID + self.virtualHardwareABIVersion = virtualHardwareABIVersion + } + + fileprivate var isValid: Bool { + planRevision > 0 + && DoryOperationJournalStore.isDigest(planDigest) + && DoryOperationJournalStore.isToken(backendID) + && DoryOperationJournalStore.isToken(backendRuntimeBuildID) + && virtualHardwareABIVersion > 0 + } +} + +public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable { + public var workspaceID: String + public var state: DoryWorkspaceLifecycleState + public var definitionRevision: UInt64? + public var resolved: DoryWorkspaceResolvedCondition? + + public init( + workspaceID: String, + state: DoryWorkspaceLifecycleState, + definitionRevision: UInt64? = nil, + resolved: DoryWorkspaceResolvedCondition? = nil + ) { + self.workspaceID = workspaceID + self.state = state + self.definitionRevision = definitionRevision + self.resolved = resolved + } + + fileprivate var isValid: Bool { + guard DoryOperationJournalStore.isToken(workspaceID) else { return false } + if state == .absent { + return definitionRevision == nil && resolved == nil + } + return definitionRevision.map { $0 > 0 } == true + && (resolved?.isValid ?? true) + && (!(state == .running || state == .paused || state == .suspended) + || resolved != nil) + } +} + +public enum DoryWorkspaceOperationStage: String, Codable, CaseIterable, Sendable { + case resolve + case prepare + case quiesce + case mutate + case launch + case readiness + case publish + case cleanup + case rollback +} + +public struct DoryWorkspaceOperationStep: Codable, Sendable, Equatable { + public var id: String + public var stage: DoryWorkspaceOperationStage + public var deadlineOffsetMilliseconds: UInt64 + + public init( + id: String, + stage: DoryWorkspaceOperationStage, + deadlineOffsetMilliseconds: UInt64 + ) { + self.id = id + self.stage = stage + self.deadlineOffsetMilliseconds = deadlineOffsetMilliseconds + } + + fileprivate var isValid: Bool { + DoryOperationJournalStore.isToken(id) && deadlineOffsetMilliseconds > 0 + } +} + +public enum DoryWorkspaceReadinessGateKind: String, Codable, CaseIterable, Sendable, Hashable { + case backendRunning + case firstDisplayFrame + case guestAgent + case network + case storageFlush +} + +public struct DoryWorkspaceReadinessGate: Codable, Sendable, Equatable { + public var kind: DoryWorkspaceReadinessGateKind + public var required: Bool + public var deadlineOffsetMilliseconds: UInt64 + + public init( + kind: DoryWorkspaceReadinessGateKind, + required: Bool = true, + deadlineOffsetMilliseconds: UInt64 + ) { + self.kind = kind + self.required = required + self.deadlineOffsetMilliseconds = deadlineOffsetMilliseconds + } +} + +public struct DoryWorkspaceRetryBudget: Codable, Sendable, Equatable { + public var failureClass: String + public var maximumAttempts: UInt8 + + public init(failureClass: String, maximumAttempts: UInt8) { + self.failureClass = failureClass + self.maximumAttempts = maximumAttempts + } + + fileprivate var isValid: Bool { + DoryOperationJournalStore.isToken(failureClass) && maximumAttempts > 0 + } +} + +public enum DoryWorkspaceCancellationPolicy: String, Codable, CaseIterable, Sendable { + case prohibited + case beforeGuestMutation + case beforePublish + case rollbackRequired +} + +public enum DoryWorkspaceRecoveryDisposition: String, Codable, CaseIterable, Sendable { + case retry + case rollback + case repair + case manualIntervention +} + +public struct DoryWorkspaceRecoveryRecipe: Codable, Sendable, Equatable { + public var disposition: DoryWorkspaceRecoveryDisposition + public var stepIDs: [String] + + public init(disposition: DoryWorkspaceRecoveryDisposition, stepIDs: [String]) { + self.disposition = disposition + self.stepIDs = stepIDs + } + + fileprivate var isValid: Bool { + !stepIDs.isEmpty + && Set(stepIDs).count == stepIDs.count + && stepIDs.allSatisfy(DoryOperationJournalStore.isToken) + } +} + +public enum DoryWorkspaceOperationValidationCode: String, Codable, Sendable { + case invalidSchemaVersion + case invalidOperationID + case invalidCondition + case invalidTransition + case invalidTargetWorkspace + case invalidDeadline + case invalidSteps + case invalidReadinessGates + case invalidRetryBudgets + case invalidRecoveryRecipe +} + +public struct DoryWorkspaceOperationValidationIssue: Codable, Sendable, Equatable { + public var code: DoryWorkspaceOperationValidationCode + public var field: String + + public init(code: DoryWorkspaceOperationValidationCode, field: String) { + self.code = code + self.field = field + } +} + +public struct DoryWorkspaceLifecycleJournalBinding: Sendable, Equatable { + public let plan: DoryOperationPlan + public let specification: DoryOperationSpecification + + public init(plan: DoryOperationPlan, specification: DoryOperationSpecification) { + self.plan = plan + self.specification = specification + } +} + +/// Immutable lifecycle intent bound into the existing crash-safe operation journal. +/// +/// This object contains no host paths, credentials, or mutable runtime handles. Executors append +/// actual progress to `DoryOperationJournalStore`; this specification is the exact transition, +/// deadline, retry, readiness, cancellation, and recovery contract they must follow. +public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { + public static let schemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var operationID: UUID + public var kind: DoryWorkspaceMutationKind + public var source: DoryWorkspaceLifecycleCondition + public var target: DoryWorkspaceLifecycleCondition + public var targetWorkspaceID: String? + public var createdAtUnixMilliseconds: Int64 + public var deadlineUnixMilliseconds: Int64 + public var steps: [DoryWorkspaceOperationStep] + public var readinessGates: [DoryWorkspaceReadinessGate] + public var retryBudgets: [DoryWorkspaceRetryBudget] + public var cancellationPolicy: DoryWorkspaceCancellationPolicy + public var recovery: DoryWorkspaceRecoveryRecipe + + public init( + operationID: UUID = UUID(), + kind: DoryWorkspaceMutationKind, + source: DoryWorkspaceLifecycleCondition, + target: DoryWorkspaceLifecycleCondition, + targetWorkspaceID: String? = nil, + createdAtUnixMilliseconds: Int64, + deadlineUnixMilliseconds: Int64, + steps: [DoryWorkspaceOperationStep], + readinessGates: [DoryWorkspaceReadinessGate] = [], + retryBudgets: [DoryWorkspaceRetryBudget] = [], + cancellationPolicy: DoryWorkspaceCancellationPolicy, + recovery: DoryWorkspaceRecoveryRecipe + ) { + schemaVersion = Self.schemaVersion + self.operationID = operationID + self.kind = kind + self.source = source + self.target = target + self.targetWorkspaceID = targetWorkspaceID + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + self.deadlineUnixMilliseconds = deadlineUnixMilliseconds + self.steps = steps + self.readinessGates = readinessGates + self.retryBudgets = retryBudgets + self.cancellationPolicy = cancellationPolicy + self.recovery = recovery + } + + public func validate() -> [DoryWorkspaceOperationValidationIssue] { + var issues: [DoryWorkspaceOperationValidationIssue] = [] + func add(_ code: DoryWorkspaceOperationValidationCode, _ field: String) { + issues.append(DoryWorkspaceOperationValidationIssue(code: code, field: field)) + } + + if schemaVersion != Self.schemaVersion { add(.invalidSchemaVersion, "schemaVersion") } + if operationID == UUID.zero { add(.invalidOperationID, "operationID") } + if !source.isValid { add(.invalidCondition, "source") } + if !target.isValid { add(.invalidCondition, "target") } + if kind == .cloning { + if targetWorkspaceID != target.workspaceID { + add(.invalidTargetWorkspace, "targetWorkspaceID") + } + } else if source.workspaceID != target.workspaceID { + add(.invalidCondition, "target.workspaceID") + } + if Self.requiresResolvedSource(kind), source.resolved == nil { + add(.invalidCondition, "source") + } + if Self.requiresResolvedTarget(kind), target.resolved == nil { + add(.invalidCondition, "target") + } + if Self.requiresUnchangedResolvedPlan(kind), source.resolved != target.resolved { + add(.invalidCondition, "target.resolved") + } + if !Self.allows(kind: kind, source: source.state, target: target.state) { + add(.invalidTransition, "target.state") + } + let validTargetWorkspace = targetWorkspaceID.map { + DoryOperationJournalStore.isToken($0) && $0 != source.workspaceID + } + if kind == .cloning { + if validTargetWorkspace != true { add(.invalidTargetWorkspace, "targetWorkspaceID") } + } else if targetWorkspaceID != nil { + add(.invalidTargetWorkspace, "targetWorkspaceID") + } + if createdAtUnixMilliseconds < 0 + || deadlineUnixMilliseconds <= createdAtUnixMilliseconds { + add(.invalidDeadline, "deadlineUnixMilliseconds") + } + + let stepIDs = steps.map(\.id) + let stepDeadlines = steps.map(\.deadlineOffsetMilliseconds) + if steps.isEmpty + || !steps.allSatisfy(\.isValid) + || Set(stepIDs).count != stepIDs.count + || stepDeadlines != stepDeadlines.sorted() + || stepDeadlines.last.map(deadlineContains(offset:)) != true { + add(.invalidSteps, "steps") + } + + let gateKinds = readinessGates.map(\.kind) + if Set(gateKinds).count != gateKinds.count + || readinessGates.contains(where: { + $0.deadlineOffsetMilliseconds == 0 + || !deadlineContains(offset: $0.deadlineOffsetMilliseconds) + }) + || (Self.requiresBackendReadiness(kind, targetState: target.state) + && !readinessGates.contains(where: { $0.kind == .backendRunning && $0.required })) { + add(.invalidReadinessGates, "readinessGates") + } + + let failureClasses = retryBudgets.map(\.failureClass) + if Set(failureClasses).count != failureClasses.count + || !retryBudgets.allSatisfy(\.isValid) { + add(.invalidRetryBudgets, "retryBudgets") + } + if !recovery.isValid || !Set(recovery.stepIDs).isSubset(of: Set(stepIDs)) { + add(.invalidRecoveryRecipe, "recovery") + } + return issues + } + + public func journalSpecification() throws -> DoryOperationSpecification { + let issues = validate() + guard issues.isEmpty else { + let fields = issues.map { "\($0.code.rawValue):\($0.field)" }.joined(separator: ",") + throw DoryOperationJournalError.invalidPlan("workspace lifecycle contract \(fields)") + } + return try DoryOperationSpecification(canonical: self) + } + + /// Creates the immutable journal plan and the exact specification bytes it selects. + /// `dependencyClosureDigest` binds the separately resolved component/media closure. + public func journalBinding( + dependencyClosureDigest: String + ) throws -> DoryWorkspaceLifecycleJournalBinding { + guard DoryOperationJournalStore.isDigest(dependencyClosureDigest) else { + throw DoryOperationJournalError.invalidPlan( + "workspace lifecycle dependency closure digest" + ) + } + let specification = try journalSpecification() + let sourceFingerprint = DoryOperationJournalStore.digest( + try DoryOperationJournalStore.encoded(source, pretty: false) + ) + let targetFingerprint = DoryOperationJournalStore.digest( + try DoryOperationJournalStore.encoded(target, pretty: false) + ) + let plan = DoryOperationPlan( + id: operationID, + kind: kind.journalKind, + createdAt: Date(timeIntervalSince1970: Double(createdAtUnixMilliseconds) / 1_000), + source: DoryOperationAuthority( + kind: .workspace, + id: source.workspaceID, + fingerprint: sourceFingerprint + ), + target: DoryOperationAuthority( + kind: .workspace, + id: target.workspaceID, + fingerprint: targetFingerprint + ), + selectionDigest: specification.digest, + dependencyClosureDigest: dependencyClosureDigest, + successCriteriaDigest: targetFingerprint + ) + return DoryWorkspaceLifecycleJournalBinding( + plan: plan, + specification: specification + ) + } + + private static func requiresBackendReadiness( + _ kind: DoryWorkspaceMutationKind, + targetState: DoryWorkspaceLifecycleState + ) -> Bool { + kind == .starting || kind == .resuming + || (kind == .restoring && targetState == .running) + } + + private static func requiresResolvedSource(_ kind: DoryWorkspaceMutationKind) -> Bool { + switch kind { + case .starting, .stopping, .pausing, .resuming, .suspending, .restoring, + .snapshotting, .cloning, .updating: + true + case .importing, .provisioning, .resolving, .repairing, .deleting: + false + } + } + + private static func requiresResolvedTarget(_ kind: DoryWorkspaceMutationKind) -> Bool { + switch kind { + case .provisioning, .resolving, .starting, .stopping, .pausing, .resuming, + .suspending, .restoring, .snapshotting, .cloning, .updating, .repairing: + true + case .importing, .deleting: + false + } + } + + private static func requiresUnchangedResolvedPlan(_ kind: DoryWorkspaceMutationKind) -> Bool { + switch kind { + case .starting, .stopping, .pausing, .resuming, .suspending, .snapshotting: + true + case .importing, .provisioning, .resolving, .restoring, .cloning, .updating, + .repairing, .deleting: + false + } + } + + private func deadlineContains(offset: UInt64) -> Bool { + guard createdAtUnixMilliseconds >= 0, + deadlineUnixMilliseconds > createdAtUnixMilliseconds, + offset <= UInt64(Int64.max) else { + return false + } + let signedOffset = Int64(offset) + guard createdAtUnixMilliseconds <= Int64.max - signedOffset else { return false } + return createdAtUnixMilliseconds + signedOffset <= deadlineUnixMilliseconds + } + + private static func allows( + kind: DoryWorkspaceMutationKind, + source: DoryWorkspaceLifecycleState, + target: DoryWorkspaceLifecycleState + ) -> Bool { + switch kind { + case .importing: source == .absent && target == .defined + case .provisioning: source == .defined && target == .stopped + case .resolving: + source != .absent && source != .deleting && target == source + case .starting: source == .stopped && target == .running + case .stopping: (source == .running || source == .paused) && target == .stopped + case .pausing: source == .running && target == .paused + case .resuming: source == .paused && target == .running + case .suspending: + (source == .running || source == .paused) && target == .suspended + case .restoring: + (source == .stopped || source == .suspended || source == .failed) + && (target == .stopped || target == .running) + case .snapshotting: + (source == .stopped || source == .running || source == .paused) + && target == source + case .cloning: + (source == .stopped || source == .suspended) && target == .stopped + case .updating: + (source == .stopped || source == .running) && target == source + case .repairing: source == .failed && target == .stopped + case .deleting: + source != .absent && source != .deleting && target == .deleting + } + } +} + +extension DoryOperationJournalStore { + /// Atomically publishes a workspace lifecycle journal and its exact immutable contract. + public func begin( + _ binding: DoryWorkspaceLifecycleJournalBinding, + fileManager: FileManager = .default + ) throws -> DoryOperationLease { + let operation: DoryWorkspaceLifecycleOperation + do { + operation = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: binding.specification.data + ) + } catch { + throw DoryOperationJournalError.invalidPlan( + "workspace lifecycle specification decoding" + ) + } + let expected = try operation.journalBinding( + dependencyClosureDigest: binding.plan.dependencyClosureDigest + ) + guard expected == binding else { + throw DoryOperationJournalError.invalidPlan( + "workspace lifecycle journal binding mismatch" + ) + } + return try begin( + binding.plan, + completenessPlanData: nil, + specifications: [binding.specification], + at: Date( + timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000 + ), + fileManager: fileManager + ) + } +} + +extension DoryOperationLease { + /// Reloads and rebinds the immutable lifecycle contract after restart. + public func readWorkspaceLifecycleOperation() throws -> DoryWorkspaceLifecycleOperation { + let record = try read() + let data = try readSpecification(digest: record.plan.selectionDigest) + let operation: DoryWorkspaceLifecycleOperation + do { + operation = try JSONDecoder().decode(DoryWorkspaceLifecycleOperation.self, from: data) + } catch { + throw DoryOperationJournalError.invalidRecord(operationDirectory) + } + guard let expected = try? operation.journalBinding( + dependencyClosureDigest: record.plan.dependencyClosureDigest + ), expected.plan == record.plan, expected.specification.data == data else { + throw DoryOperationJournalError.invalidRecord(operationDirectory) + } + return operation + } +} + +private extension UUID { + static let zero = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift new file mode 100644 index 00000000..26dd0117 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift @@ -0,0 +1,261 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Workspace lifecycle operation contract") +struct DoryWorkspaceLifecycleOperationTests { + @Test("start binds exact state plan ABI deadlines readiness and recovery into journal bytes") + func validStartContract() throws { + let operation = makeOperation() + #expect(operation.validate().isEmpty) + #expect(operation.kind.journalKind == .workspaceStart) + + let specification = try operation.journalSpecification() + #expect(DoryOperationJournalStore.isDigest(specification.digest)) + let decoded = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: specification.data + ) + #expect(decoded == operation) + #expect(decoded.target.resolved?.backendRuntimeBuildID == "dory-hv-1.0.0") + #expect(decoded.target.resolved?.virtualHardwareABIVersion == 1) + + let binding = try operation.journalBinding( + dependencyClosureDigest: String(repeating: "b", count: 64) + ) + #expect(binding.plan.id == operation.operationID) + #expect(binding.plan.kind == .workspaceStart) + #expect(binding.plan.selectionDigest == binding.specification.digest) + #expect(binding.plan.source.id == "workspace-one") + #expect(binding.plan.target.id == "workspace-one") + #expect(binding.plan.dependencyClosureDigest == String(repeating: "b", count: 64)) + #expect(binding.plan.successCriteriaDigest == binding.plan.target.fingerprint) + } + + @Test("journal atomically persists and restart rebinds the exact lifecycle contract") + func journalPersistenceAndTamperDetection() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-workspace-lifecycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: home) } + + let operation = makeOperation() + let binding = try operation.journalBinding( + dependencyClosureDigest: String(repeating: "b", count: 64) + ) + let store = try DoryOperationJournalStore(home: home.path) + var lease: DoryOperationLease? = try store.begin(binding) + #expect(try lease?.readWorkspaceLifecycleOperation() == operation) + lease = nil + + let acquired = try store.acquire(operation.operationID) + #expect(try acquired.readWorkspaceLifecycleOperation() == operation) + let specificationPath = store.operationDirectory(for: operation.operationID) + + "/specs/objects/" + binding.specification.digest + var tampered = binding.specification.data + tampered.append(Data("tampered".utf8)) + try tampered.write(to: URL(fileURLWithPath: specificationPath)) + #expect(throws: DoryOperationJournalError.self) { + _ = try acquired.readWorkspaceLifecycleOperation() + } + } + + @Test("all lifecycle mutation kinds have stable journal kinds") + func journalKinds() { + #expect(Set(DoryWorkspaceMutationKind.allCases.map(\.journalKind.rawValue)).count + == DoryWorkspaceMutationKind.allCases.count) + #expect(DoryWorkspaceMutationKind.deleting.journalKind == .workspaceDelete) + #expect(DoryWorkspaceMutationKind.snapshotting.journalKind == .workspaceSnapshot) + } + + @Test("start requires exact resolved source target and backend readiness") + func startRequirements() { + var operation = makeOperation() + operation.source.resolved = nil + #expect(has(.invalidCondition, "source", operation)) + + operation = makeOperation() + operation.readinessGates = [] + #expect(has(.invalidReadinessGates, "readinessGates", operation)) + + operation = makeOperation() + operation.target.state = .paused + #expect(has(.invalidTransition, "target.state", operation)) + } + + @Test("deadlines steps retry budgets and recovery are deterministic and bounded") + func executionContractValidation() { + var operation = makeOperation() + operation.steps.swapAt(0, 1) + #expect(has(.invalidSteps, "steps", operation)) + + operation = makeOperation() + operation.steps[1].id = operation.steps[0].id + #expect(has(.invalidSteps, "steps", operation)) + + operation = makeOperation() + operation.retryBudgets.append(operation.retryBudgets[0]) + #expect(has(.invalidRetryBudgets, "retryBudgets", operation)) + + operation = makeOperation() + operation.recovery.stepIDs = ["not-in-plan"] + #expect(has(.invalidRecoveryRecipe, "recovery", operation)) + + operation = makeOperation() + operation.deadlineUnixMilliseconds = operation.createdAtUnixMilliseconds + #expect(has(.invalidDeadline, "deadlineUnixMilliseconds", operation)) + } + + @Test("clone requires a distinct target workspace and delete enters tombstone state") + func targetRules() { + let resolved = resolvedCondition() + var clone = DoryWorkspaceLifecycleOperation( + kind: .cloning, + source: condition(.stopped, resolved: resolved), + target: condition(.stopped, workspaceID: "workspace-copy", resolved: resolved), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("clone", 30_000)], + cancellationPolicy: .beforePublish, + recovery: .init(disposition: .rollback, stepIDs: ["clone"]) + ) + #expect(has(.invalidTargetWorkspace, "targetWorkspaceID", clone)) + clone.targetWorkspaceID = "workspace-copy" + #expect(clone.validate().isEmpty) + + let deletion = DoryWorkspaceLifecycleOperation( + kind: .deleting, + source: condition(.stopped, resolved: resolved), + target: condition(.deleting, resolved: resolved), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("tombstone", 10_000), step("release-artifacts", 50_000)], + cancellationPolicy: .prohibited, + recovery: .init(disposition: .repair, stepIDs: ["tombstone"]) + ) + #expect(deletion.validate().isEmpty) + } + + @Test("restore readiness follows the target and an unresolved definition can be deleted") + func targetSensitiveRequirements() { + let resolved = resolvedCondition() + let restoreStopped = DoryWorkspaceLifecycleOperation( + kind: .restoring, + source: condition(.stopped, resolved: resolved), + target: condition(.stopped, resolved: resolved), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("restore", 50_000)], + cancellationPolicy: .rollbackRequired, + recovery: .init(disposition: .rollback, stepIDs: ["restore"]) + ) + #expect(restoreStopped.validate().isEmpty) + + var restoreRunning = restoreStopped + restoreRunning.target.state = .running + #expect(has(.invalidReadinessGates, "readinessGates", restoreRunning)) + + let deleteDefined = DoryWorkspaceLifecycleOperation( + kind: .deleting, + source: condition(.defined, resolved: nil), + target: condition(.deleting, resolved: nil), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("tombstone", 10_000)], + cancellationPolicy: .prohibited, + recovery: .init(disposition: .repair, stepIDs: ["tombstone"]) + ) + #expect(deleteDefined.validate().isEmpty) + } + + @Test("operation identifiers reject path secret and control-like values") + func identifierSafety() { + var operation = makeOperation() + operation.source.workspaceID = "../workspace" + operation.target.workspaceID = "../workspace" + #expect(has(.invalidCondition, "source", operation)) + #expect(has(.invalidCondition, "target", operation)) + + operation = makeOperation() + operation.retryBudgets[0].failureClass = "token=secret" + #expect(has(.invalidRetryBudgets, "retryBudgets", operation)) + } + + @Test("zero UUID and absent condition payloads fail closed") + func identityAndAbsentCondition() { + var operation = makeOperation() + operation.operationID = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + #expect(has(.invalidOperationID, "operationID", operation)) + + operation = makeOperation() + operation.source = DoryWorkspaceLifecycleCondition( + workspaceID: "workspace-one", + state: .absent, + definitionRevision: 1 + ) + #expect(has(.invalidCondition, "source", operation)) + } + + private func makeOperation() -> DoryWorkspaceLifecycleOperation { + let resolved = resolvedCondition() + return DoryWorkspaceLifecycleOperation( + operationID: UUID(uuidString: "8f9f5b13-77ee-4f59-8a13-1704147c7f00")!, + kind: .starting, + source: condition(.stopped, resolved: resolved), + target: condition(.running, resolved: resolved), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("validate-plan", 5_000), step("launch", 20_000), step("ready", 55_000)], + readinessGates: [ + .init(kind: .backendRunning, deadlineOffsetMilliseconds: 20_000), + .init(kind: .firstDisplayFrame, deadlineOffsetMilliseconds: 40_000), + .init(kind: .guestAgent, deadlineOffsetMilliseconds: 55_000) + ], + retryBudgets: [ + .init(failureClass: "helper-crash", maximumAttempts: 2), + .init(failureClass: "agent-timeout", maximumAttempts: 1) + ], + cancellationPolicy: .beforeGuestMutation, + recovery: .init(disposition: .rollback, stepIDs: ["validate-plan", "launch"]) + ) + } + + private func condition( + _ state: DoryWorkspaceLifecycleState, + workspaceID: String = "workspace-one", + resolved: DoryWorkspaceResolvedCondition? + ) -> DoryWorkspaceLifecycleCondition { + DoryWorkspaceLifecycleCondition( + workspaceID: workspaceID, + state: state, + definitionRevision: 3, + resolved: resolved + ) + } + + private func resolvedCondition() -> DoryWorkspaceResolvedCondition { + DoryWorkspaceResolvedCondition( + planRevision: 4, + planDigest: String(repeating: "a", count: 64), + backendID: "dory-hv-linux", + backendRuntimeBuildID: "dory-hv-1.0.0", + virtualHardwareABIVersion: 1 + ) + } + + private func step(_ id: String, _ deadline: UInt64) -> DoryWorkspaceOperationStep { + DoryWorkspaceOperationStep( + id: id, + stage: id == "ready" ? .readiness : .mutate, + deadlineOffsetMilliseconds: deadline + ) + } + + private func has( + _ code: DoryWorkspaceOperationValidationCode, + _ field: String, + _ operation: DoryWorkspaceLifecycleOperation + ) -> Bool { + operation.validate().contains { $0.code == code && $0.field == field } + } +} From 304542a4cd21246f91687eed137e788f1c52ffbf Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 12:06:06 +0000 Subject: [PATCH 014/338] feat(workspace): reconcile legacy projections --- .../DorydKit/DoryWorkspaceRepository.swift | 244 ++++++++++++- .../Sources/DorydKit/MachineManager.swift | 238 +++++++++++- .../DoryWorkspaceRepositoryTests.swift | 119 ++++++ ...chineManagerWorkspaceProjectionTests.swift | 341 ++++++++++++++++++ 4 files changed, 934 insertions(+), 8 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift index 56d64cce..4bdb1307 100644 --- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift +++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift @@ -47,7 +47,7 @@ public enum DoryWorkspaceRepositoryError: Error, Sendable, Equatable, CustomStri /// One durable desired-state record. /// -/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact canonical +/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact raw /// `DoryMachineConfiguration` bytes that remain authoritative. Once all consumers use the v2 /// contract, authoritative records omit this digest and use optimistic lifecycle revisions. public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable { @@ -55,14 +55,76 @@ public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable { public let schemaVersion: UInt16 public let legacyConfigurationSHA256: String? + /// Digest of canonical non-lifecycle facts used by the legacy migration bridge. Raw metadata + /// alone is insufficient because artifact inspection can change the boot contract in place. + public let legacyMigrationFactsSHA256: String? public let definition: DoryVirtualMachineDefinition public init( definition: DoryVirtualMachineDefinition, - legacyConfigurationSHA256: String? = nil + legacyConfigurationSHA256: String? = nil, + legacyMigrationFactsSHA256: String? = nil ) { schemaVersion = Self.schemaVersion self.legacyConfigurationSHA256 = legacyConfigurationSHA256 + self.legacyMigrationFactsSHA256 = legacyMigrationFactsSHA256 + self.definition = definition + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case legacyConfigurationSHA256 + case legacyMigrationFactsSHA256 + case definition + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion) + legacyConfigurationSHA256 = try container.decodeIfPresent( + String.self, + forKey: .legacyConfigurationSHA256 + ) + // Records written before facts became part of legacy authority do not contain this key. + legacyMigrationFactsSHA256 = try container.decodeIfPresent( + String.self, + forKey: .legacyMigrationFactsSHA256 + ) + definition = try container.decode( + DoryVirtualMachineDefinition.self, + forKey: .definition + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encodeIfPresent( + legacyConfigurationSHA256, + forKey: .legacyConfigurationSHA256 + ) + try container.encodeIfPresent( + legacyMigrationFactsSHA256, + forKey: .legacyMigrationFactsSHA256 + ) + try container.encode(definition, forKey: .definition) + } +} + +public enum DoryWorkspaceLegacyProjectionReconcileState: String, Sendable, Equatable { + case unchanged + case published +} + +public struct DoryWorkspaceLegacyProjectionReconcileResult: Sendable, Equatable { + public let state: DoryWorkspaceLegacyProjectionReconcileState + public let definition: DoryVirtualMachineDefinition + + public init( + state: DoryWorkspaceLegacyProjectionReconcileState, + definition: DoryVirtualMachineDefinition + ) { + self.state = state self.definition = definition } } @@ -158,14 +220,146 @@ public final class DoryWorkspaceRepository: @unchecked Sendable { throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id) } let directory = try prepareWorkspaceDirectory(id: definition.identity.id) + let path = directory + "/\(Self.recordFileName)" + let replacing = Self.pathExists(path) + if replacing { + let current = try readRecordUnlocked(id: definition.identity.id) + guard current.legacyConfigurationSHA256 != nil else { + throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(definition.identity.id) + } + // This compatibility API cannot validate facts-bound projections. Require callers to + // use reconcileLegacyProjection rather than silently dropping half the authority. + guard current.legacyMigrationFactsSHA256 == nil else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id) + } + guard current.definition.lifecycle.revision < UInt64.max, + definition.lifecycle.revision + == current.definition.lifecycle.revision + 1 else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: current.definition.lifecycle.revision == UInt64.max + ? UInt64.max : current.definition.lifecycle.revision + 1, + actual: definition.lifecycle.revision + ) + } + guard definition.identity.id == current.definition.identity.id, + definition.lifecycle.createdAtUnixMilliseconds + == current.definition.lifecycle.createdAtUnixMilliseconds else { + throw DoryWorkspaceRepositoryError.identityChanged(definition.identity.id) + } + } else { + guard definition.lifecycle.revision == 1 else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: 1, + actual: definition.lifecycle.revision + ) + } + } let record = DoryWorkspaceRepositoryRecord( definition: definition, legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData) ) try publish( record, - path: directory + "/\(Self.recordFileName)", - replacing: Self.pathExists(directory + "/\(Self.recordFileName)") + path: path, + replacing: replacing + ) + } + + /// Atomically reconcile a derived legacy projection and its two-part authority tuple. + /// + /// An exact metadata+facts restart does not rewrite or bump. Any changed bytes, changed facts, + /// or changed derived definition advances the revision while retaining `createdAt`. Invalid + /// projections can be regenerated, but a native v2 record is never overwritten by legacy. + public func reconcileLegacyProjection( + _ candidate: DoryVirtualMachineDefinition, + authoritativeLegacyData: Data, + authoritativeMigrationFactsData: Data + ) throws -> DoryWorkspaceLegacyProjectionReconcileResult { + lock.lock() + defer { lock.unlock() } + try Self.validate(candidate) + guard !authoritativeLegacyData.isEmpty, !authoritativeMigrationFactsData.isEmpty else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(candidate.identity.id) + } + + let directory = try prepareWorkspaceDirectory(id: candidate.identity.id) + let path = directory + "/\(Self.recordFileName)" + let legacyDigest = Self.sha256(authoritativeLegacyData) + let factsDigest = Self.sha256(authoritativeMigrationFactsData) + let pathExists = Self.pathExists(path) + let current: DoryWorkspaceRepositoryRecord? + if pathExists { + do { + current = try readRecordUnlocked(id: candidate.identity.id) + } catch { + // A damaged legacy-derived projection is disposable and can be recreated from + // authority. Native v2 state and ambiguous damage must never be overwritten. + guard try Self.hasRecoverableLegacyAuthorityMarker(path: path) else { + throw error + } + current = nil + } + } else { + current = nil + } + + if let current { + guard current.legacyConfigurationSHA256 != nil else { + throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(candidate.identity.id) + } + var sameLifecycleCandidate = candidate + sameLifecycleCandidate.lifecycle = current.definition.lifecycle + if current.legacyConfigurationSHA256 == legacyDigest, + current.legacyMigrationFactsSHA256 == factsDigest, + current.definition == sameLifecycleCandidate { + return DoryWorkspaceLegacyProjectionReconcileResult( + state: .unchanged, + definition: current.definition + ) + } + guard current.definition.lifecycle.revision < UInt64.max else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: UInt64.max, + actual: UInt64.max + ) + } + var replacement = candidate + replacement.lifecycle = DoryVMLifecycleMetadata( + revision: current.definition.lifecycle.revision + 1, + createdAtUnixMilliseconds: current.definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: Self.nextUpdatedTimestamp( + previous: current.definition.lifecycle.updatedAtUnixMilliseconds, + proposed: candidate.lifecycle.updatedAtUnixMilliseconds + ) + ) + try Self.validate(replacement) + let record = DoryWorkspaceRepositoryRecord( + definition: replacement, + legacyConfigurationSHA256: legacyDigest, + legacyMigrationFactsSHA256: factsDigest + ) + try publish(record, path: path, replacing: true) + return DoryWorkspaceLegacyProjectionReconcileResult( + state: .published, + definition: replacement + ) + } + + guard candidate.lifecycle.revision == 1 else { + throw DoryWorkspaceRepositoryError.invalidRevision( + expected: 1, + actual: candidate.lifecycle.revision + ) + } + let record = DoryWorkspaceRepositoryRecord( + definition: candidate, + legacyConfigurationSHA256: legacyDigest, + legacyMigrationFactsSHA256: factsDigest + ) + try publish(record, path: path, replacing: pathExists) + return DoryWorkspaceLegacyProjectionReconcileResult( + state: .published, + definition: candidate ) } @@ -189,12 +383,34 @@ public final class DoryWorkspaceRepository: @unchecked Sendable { guard let expectedDigest = record.legacyConfigurationSHA256 else { throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id)) } + guard record.legacyMigrationFactsSHA256 == nil else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(id) + } guard expectedDigest == Self.sha256(authoritativeLegacyData) else { throw DoryWorkspaceRepositoryError.staleLegacyProjection(id) } return record.definition } + public func readLegacyProjection( + id: String, + authoritativeLegacyData: Data, + authoritativeMigrationFactsData: Data + ) throws -> DoryVirtualMachineDefinition { + lock.lock() + defer { lock.unlock() } + let record = try readRecordUnlocked(id: id) + guard let expectedLegacyDigest = record.legacyConfigurationSHA256, + let expectedFactsDigest = record.legacyMigrationFactsSHA256 else { + throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id)) + } + guard expectedLegacyDigest == Self.sha256(authoritativeLegacyData), + expectedFactsDigest == Self.sha256(authoritativeMigrationFactsData) else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(id) + } + return record.definition + } + public func remove(id: String) throws { lock.lock() defer { lock.unlock() } @@ -224,7 +440,10 @@ public final class DoryWorkspaceRepository: @unchecked Sendable { record.schemaVersion == DoryWorkspaceRepositoryRecord.schemaVersion, record.definition.identity.id == id, record.definition.validate().isEmpty, - record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true else { + record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true, + record.legacyMigrationFactsSHA256.map(Self.isSHA256) ?? true, + record.legacyMigrationFactsSHA256 == nil + || record.legacyConfigurationSHA256 != nil else { throw DoryWorkspaceRepositoryError.invalidRecord(path) } return record @@ -461,4 +680,19 @@ public final class DoryWorkspaceRepository: @unchecked Sendable { (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) } } + + private static func hasRecoverableLegacyAuthorityMarker(path: String) throws -> Bool { + let data = try secureRead(path: path) + guard let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any], + let digest = dictionary["legacyConfigurationSHA256"] as? String else { + return false + } + return isSHA256(digest) + } + + private static func nextUpdatedTimestamp(previous: Int64, proposed: Int64) -> Int64 { + guard previous < Int64.max else { return Int64.max } + return max(previous + 1, proposed) + } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index c1972d9f..d53c847f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -561,6 +561,35 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert } } +public enum DoryWorkspaceProjectionState: String, Sendable, Equatable { + case current + case regenerated + case unavailable +} + +public enum DoryWorkspaceProjectionFailureCode: String, Sendable, Equatable { + case unsupportedLegacyConfiguration = "unsupported-legacy-configuration" + case repositoryFailure = "repository-failure" +} + +/// Non-fatal migration status. Legacy `machine.json` remains runnable when a projection cannot +/// be produced; callers can surface this diagnostic without treating the VM itself as corrupt. +public struct DoryWorkspaceProjectionDiagnostic: Sendable, Equatable { + public var state: DoryWorkspaceProjectionState + public var failureCode: DoryWorkspaceProjectionFailureCode? + public var message: String? + + public init( + state: DoryWorkspaceProjectionState, + failureCode: DoryWorkspaceProjectionFailureCode? = nil, + message: String? = nil + ) { + self.state = state + self.failureCode = failureCode + self.message = message + } +} + public final class MachineManager: @unchecked Sendable { public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void @@ -594,10 +623,12 @@ public final class MachineManager: @unchecked Sendable { private let agentConnector: AgentConnector private let balloonController: any MachineBalloonControlling private let processStarter: ProcessStarter + private let workspaceRepository: DoryWorkspaceRepository private let operationLock = NSRecursiveLock() private let lock = NSLock() private var machines: [String: MachineEntry] = [:] private var deletingMachineIDs: Set = [] + private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:] public init( configuration: MachineManagerConfiguration, @@ -611,6 +642,7 @@ public final class MachineManager: @unchecked Sendable { self.balloonController = balloonController self.agentConnector = agentConnector self.processStarter = processStarter + self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory) _ = HelperProcessJanitor.terminateStaleHelpers( executablePath: configuration.vmmExecutablePath, stateDirectory: configuration.stateDirectory, @@ -627,7 +659,9 @@ public final class MachineManager: @unchecked Sendable { Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory) Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory) Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory) + Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory) self.machines = Self.loadPersistedMachines(configuration: configuration) + reconcileLoadedWorkspaceProjections() recoverInterruptedDesktopUpdates() } @@ -686,6 +720,7 @@ public final class MachineManager: @unchecked Sendable { let fileManager = FileManager.default do { try fileManager.createDirectory(atPath: configuration.stateDirectory, withIntermediateDirectories: true) + Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory) } catch { throw MachineManagerError.persistence("could not create machine state root: \(error)") } @@ -744,6 +779,16 @@ public final class MachineManager: @unchecked Sendable { do { try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration) try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration) + if let authoritativeLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: entry.configuration.id) + ) { + // Boot-bundle materialization changes authoritative inspection facts without + // changing machine.json. Bind that fact change and advance the projection here. + reconcileWorkspaceProjection( + machine: entry.configuration, + authoritativeLegacyData: authoritativeLegacyData + ) + } try Self.validateLaunchConfiguration(entry.configuration) try validateManagedMachineArtifacts(entry.configuration) try validateRuntimeAvailability(entry.configuration) @@ -986,6 +1031,7 @@ public final class MachineManager: @unchecked Sendable { lock.lock() deletingMachineIDs.remove(id) + workspaceProjectionDiagnostics.removeValue(forKey: id) lock.unlock() try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id)) @@ -1718,6 +1764,15 @@ public final class MachineManager: @unchecked Sendable { return statuses } + public func workspaceProjectionDiagnostic( + id: String + ) -> DoryWorkspaceProjectionDiagnostic? { + lock.lock() + defer { lock.unlock() } + guard machines[id] != nil else { return nil } + return workspaceProjectionDiagnostics[id] + } + private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( @@ -2629,14 +2684,13 @@ public final class MachineManager: @unchecked Sendable { let fileManager = FileManager.default let directory = machineStateDirectory(id: machine.id) let temporaryPath = "\(directory)/\(Self.machineMetadataTemporaryPrefix)\(UUID().uuidString)" + var authoritativeLegacyData: Data? do { try fileManager.createDirectory( atPath: directory, withIntermediateDirectories: true ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(machine) + let data = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine) let path = machineConfigPath(id: machine.id) try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic) try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath) @@ -2645,6 +2699,7 @@ public final class MachineManager: @unchecked Sendable { "could not publish machine metadata: \(String(cString: strerror(errno)))" ) } + authoritativeLegacyData = data } catch let error as MachineManagerError { try? fileManager.removeItem(atPath: temporaryPath) throw error @@ -2652,6 +2707,164 @@ public final class MachineManager: @unchecked Sendable { try? fileManager.removeItem(atPath: temporaryPath) throw MachineManagerError.persistence("\(error)") } + // The rename above is the commit point. Projection is intentionally best-effort and + // ordered afterwards: a v2 failure must never roll back or hide working legacy metadata. + if let authoritativeLegacyData { + reconcileWorkspaceProjection( + machine: machine, + authoritativeLegacyData: authoritativeLegacyData + ) + } + } + + private func reconcileLoadedWorkspaceProjections() { + let configurations = machines.values.map(\.configuration) + .sorted { $0.id < $1.id } + for machine in configurations { + guard let data = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)) else { + setWorkspaceProjectionDiagnostic( + DoryWorkspaceProjectionDiagnostic( + state: .unavailable, + failureCode: .repositoryFailure, + message: "authoritative legacy metadata could not be reread" + ), + id: machine.id + ) + continue + } + reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data) + } + } + + private func reconcileWorkspaceProjection( + machine: DoryMachineConfiguration, + authoritativeLegacyData: Data + ) { + do { + let facts = try workspaceMigrationFacts(for: machine) + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + machine, + facts: facts + ) + let result = try workspaceRepository.reconcileLegacyProjection( + migration.definition, + authoritativeLegacyData: authoritativeLegacyData, + authoritativeMigrationFactsData: try Self.workspaceMigrationAuthorityData(facts) + ) + setWorkspaceProjectionDiagnostic( + DoryWorkspaceProjectionDiagnostic( + state: result.state == .unchanged ? .current : .regenerated + ), + id: machine.id + ) + } catch let error as DoryMachineConfigurationMigrationError { + setWorkspaceProjectionDiagnostic( + DoryWorkspaceProjectionDiagnostic( + state: .unavailable, + failureCode: .unsupportedLegacyConfiguration, + message: error.localizedDescription + ), + id: machine.id + ) + } catch let error as DoryWorkspaceRepositoryError { + setWorkspaceProjectionDiagnostic( + DoryWorkspaceProjectionDiagnostic( + state: .unavailable, + failureCode: .repositoryFailure, + message: error.description + ), + id: machine.id + ) + } catch { + setWorkspaceProjectionDiagnostic( + DoryWorkspaceProjectionDiagnostic( + state: .unavailable, + failureCode: .repositoryFailure, + message: String(describing: error) + ), + id: machine.id + ) + } + } + + private func workspaceMigrationFacts( + for machine: DoryMachineConfiguration + ) throws -> DoryMachineConfigurationMigrationFacts { + let architecture: DoryGuestArchitecture + switch configuration.guestArchitecture.lowercased() { + case "arm64", "aarch64": + architecture = .arm64 + case "amd64", "x86_64": + architecture = .x86_64 + default: + throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration( + "unsupported guest architecture \(configuration.guestArchitecture)" + ) + } + + var diskInfo = stat() + guard lstat(machine.rootfsPath, &diskInfo) == 0, + (diskInfo.st_mode & S_IFMT) == S_IFREG, + diskInfo.st_size > 0 else { + throw DoryMachineConfigurationMigrationError.missingSystemDiskCapacity + } + let diskCapacity = UInt64(diskInfo.st_size) + + let installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot? + if machine.bootMode == .efi, machine.installerISOPath == nil { + installedEFIBoot = DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) + ? .installedLinuxBootBundle : .firmwareDisk + } else { + installedEFIBoot = nil + } + + return DoryMachineConfigurationMigrationFacts( + guestArchitecture: architecture, + systemDiskCapacityBytes: diskCapacity, + installedEFIBoot: installedEFIBoot, + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: workspaceCreationTimestamp(id: machine.id), + updatedAtUnixMilliseconds: workspaceCreationTimestamp(id: machine.id) + ) + ) + } + + private func workspaceCreationTimestamp(id: String) -> Int64 { + var info = stat() + guard lstat(machineStateDirectory(id: id), &info) == 0 else { return 1 } + let time = info.st_birthtimespec.tv_sec > 0 + ? info.st_birthtimespec : info.st_ctimespec + guard time.tv_sec > 0 else { return 1 } + let seconds = Int64(time.tv_sec) + let (milliseconds, overflow) = seconds.multipliedReportingOverflow(by: 1_000) + guard !overflow else { return Int64.max } + let nanos = max(Int64(0), Int64(time.tv_nsec)) / 1_000_000 + let (result, additionOverflow) = milliseconds.addingReportingOverflow(nanos) + return additionOverflow ? Int64.max : max(1, result) + } + + private static func workspaceMigrationAuthorityData( + _ facts: DoryMachineConfigurationMigrationFacts + ) throws -> Data { + let authority = WorkspaceMigrationAuthorityFacts( + guestArchitecture: facts.guestArchitecture, + systemDiskCapacityBytes: facts.systemDiskCapacityBytes, + installedEFIBoot: facts.installedEFIBoot, + lifecycle: facts.lifecycle + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(authority) + } + + private func setWorkspaceProjectionDiagnostic( + _ diagnostic: DoryWorkspaceProjectionDiagnostic, + id: String + ) { + lock.lock() + workspaceProjectionDiagnostics[id] = diagnostic + lock.unlock() } private func persistSnapshot(_ snapshot: DoryMachineSnapshot) throws { @@ -3185,6 +3398,18 @@ public final class MachineManager: @unchecked Sendable { return lstat(path, &info) == 0 && isPrivateDirectory(info: info) } + /// Workspace records share MachineManager's state root. Tighten an existing owned directory + /// without following links; failure merely leaves projection publishing unavailable. + private static func restrictWorkspaceProjectionRootIfOwned(_ path: String) { + var info = stat() + guard lstat(path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == getuid() else { + return + } + _ = chmod(path, mode_t(0o700)) + } + private static func isDirectory(path: String) -> Bool { var info = stat() return lstat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR @@ -3838,6 +4063,13 @@ private enum MachineSnapshotBundle { } } +private struct WorkspaceMigrationAuthorityFacts: Codable { + var guestArchitecture: DoryGuestArchitecture + var systemDiskCapacityBytes: UInt64? + var installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot? + var lifecycle: DoryVMLifecycleMetadata +} + private struct MachineEntry { var configuration: DoryMachineConfiguration var state: DoryMachineState diff --git a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift index 403f3120..b6fba079 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift @@ -66,9 +66,124 @@ struct DoryWorkspaceRepositoryTests { authoritativeLegacyData: secondLegacy ) == reconciled ) + #expect(throws: DoryWorkspaceRepositoryError.invalidRevision(expected: 3, actual: 1)) { + try repository.publishLegacyProjection( + workspace, + authoritativeLegacyData: firstLegacy + ) + } + #expect( + try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: secondLegacy + ) == reconciled + ) + } + } + + @Test("facts-bound projections require both exact authorities") + func factsBoundProjectionAuthority() throws { + try withRepository { repository, _ in + let legacy = Data("legacy-v1".utf8) + let factsA = Data("facts-a".utf8) + let factsB = Data("facts-b".utf8) + let workspace = definition() + + let result = try repository.reconcileLegacyProjection( + workspace, + authoritativeLegacyData: legacy, + authoritativeMigrationFactsData: factsA + ) + #expect(result.state == .published) + #expect( + try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: legacy, + authoritativeMigrationFactsData: factsA + ) == workspace + ) + #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) { + _ = try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: legacy, + authoritativeMigrationFactsData: factsB + ) + } + #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) { + _ = try repository.readLegacyProjection( + id: workspace.identity.id, + authoritativeLegacyData: legacy + ) + } + #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) { + try repository.publishLegacyProjection( + workspace, + authoritativeLegacyData: legacy + ) + } + } + } + + @Test("legacy publication cannot overwrite native or ambiguously corrupt v2 state") + func legacyCannotOverwriteNativeState() throws { + try withRepository { repository, root in + let workspace = definition() + try repository.create(workspace) + let path = recordPath(root: root, id: workspace.identity.id) + let nativeBytes = try Data(contentsOf: URL(fileURLWithPath: path)) + + #expect(throws: DoryWorkspaceRepositoryError.legacyAuthorityRequired(workspace.identity.id)) { + try repository.publishLegacyProjection( + workspace, + authoritativeLegacyData: Data("legacy".utf8) + ) + } + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == nativeBytes) + + let corruptNative = Data("{\"schemaVersion\":1,\"definition\":{}}".utf8) + try corruptNative.write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try repository.reconcileLegacyProjection( + workspace, + authoritativeLegacyData: Data("legacy".utf8), + authoritativeMigrationFactsData: Data("facts".utf8) + ) + } + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == corruptNative) } } + @Test("golden prior repository record decodes without migration facts digest") + func priorRecordCompatibility() throws { + let workspace = definition() + let definitionJSON = try #require(String( + data: JSONEncoder().encode(workspace), + encoding: .utf8 + )) + let legacyDigest = String(repeating: "a", count: 64) + let priorRecord = Data( + """ + { + "definition": \(definitionJSON), + "legacyConfigurationSHA256": "\(legacyDigest)", + "schemaVersion": 1 + } + """.utf8 + ) + + let decoded = try JSONDecoder().decode( + DoryWorkspaceRepositoryRecord.self, + from: priorRecord + ) + #expect(decoded.definition == workspace) + #expect(decoded.legacyConfigurationSHA256 == legacyDigest) + #expect(decoded.legacyMigrationFactsSHA256 == nil) + } + @Test("invalid definitions and revision or identity changes fail before publication") func validationAndRevisionSafety() throws { try withRepository { repository, root in @@ -212,4 +327,8 @@ struct DoryWorkspaceRepositoryTests { ) ) } + + private func recordPath(root: String, id: String) -> String { + root + "/" + id + "/" + DoryWorkspaceRepository.recordFileName + } } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift new file mode 100644 index 00000000..77257069 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift @@ -0,0 +1,341 @@ +import CryptoKit +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("MachineManager workspace projection integration") +struct MachineManagerWorkspaceProjectionTests { + private let gibibyte: UInt64 = 1_073_741_824 + + @Test("create update reload and revert preserve authority with monotonic revisions") + func createUpdateReloadAndRevert() throws { + try withStateRoot("lifecycle") { base, state in + let hostShare = base + "/host-secret" + try FileManager.default.createDirectory( + atPath: hostShare, + withIntermediateDirectories: false + ) + let manager = makeManager(state: state) + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048, + cpuCount: 2, + displayMode: .desktop, + shares: [DoryMachineShareConfiguration( + tag: "source", + hostPath: hostShare, + guestPath: "/workspace/source" + )], + environment: ["PRIVATE_TOKEN": "projection-must-not-persist-this"] + )) + + let firstLegacy = try legacyData(state: state, id: "dev") + let firstRecord = try record(state: state, id: "dev") + #expect(firstRecord.definition.lifecycle.revision == 1) + #expect(firstRecord.legacyConfigurationSHA256 == sha256(firstLegacy)) + #expect(firstRecord.legacyMigrationFactsSHA256 != nil) + #expect(manager.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated) + + let workspaceJSON = try #require(String( + data: Data(contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "dev"))), + encoding: .utf8 + )) + #expect(!workspaceJSON.contains(hostShare)) + #expect(!workspaceJSON.contains("projection-must-not-persist-this")) + #expect(!workspaceJSON.contains(state + "/dev/rootfs.ext4")) + let machineJSON = try #require(String(data: firstLegacy, encoding: .utf8)) + #expect(machineJSON.contains("projection-must-not-persist-this")) + + _ = try manager.update(id: "dev", memoryMB: 4_096) + let secondRecord = try record(state: state, id: "dev") + #expect(secondRecord.definition.lifecycle.revision == 2) + #expect(secondRecord.definition.lifecycle.createdAtUnixMilliseconds + == firstRecord.definition.lifecycle.createdAtUnixMilliseconds) + #expect(secondRecord.definition.lifecycle.updatedAtUnixMilliseconds + > firstRecord.definition.lifecycle.updatedAtUnixMilliseconds) + #expect(secondRecord.definition.resources.memoryBytes == 4 * gibibyte) + #expect(secondRecord.legacyConfigurationSHA256 + == sha256(try legacyData(state: state, id: "dev"))) + + let recordBytesBeforeReload = try Data( + contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "dev")) + ) + let reloaded = makeManager(state: state) + #expect(reloaded.status(id: "dev")?.memoryMB == 4_096) + #expect(reloaded.workspaceProjectionDiagnostic(id: "dev")?.state == .current) + #expect(try Data(contentsOf: URL( + fileURLWithPath: recordPath(state: state, id: "dev") + )) == recordBytesBeforeReload) + + _ = try reloaded.update(id: "dev", memoryMB: 2_048) + let reverted = try record(state: state, id: "dev") + #expect(reverted.definition.lifecycle.revision == 3) + #expect(reverted.definition.lifecycle.createdAtUnixMilliseconds + == firstRecord.definition.lifecycle.createdAtUnixMilliseconds) + #expect(reverted.definition.resources.memoryBytes == 2 * gibibyte) + } + } + + @Test("noncanonical authoritative bytes remain untouched and missing projection is repaired") + func noncanonicalLegacyBytesRemainAuthoritative() throws { + try withStateRoot("noncanonical") { _, state in + let directory = state + "/legacy" + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + _ = chmod(directory, mode_t(0o700)) + let kernel = directory + "/kernel" + let rootfs = directory + "/rootfs.ext4" + try writePrivate(Data("kernel".utf8), path: kernel) + try writePrivate(Data("rootfs".utf8), path: rootfs) + let raw = Data( + "{\"rootfsPath\":\"\(rootfs)\",\"id\":\"legacy\"," + .appending("\"kernelPath\":\"\(kernel)\"}").utf8 + ) + try writePrivate(raw, path: directory + "/machine.json") + + let manager = makeManager(state: state) + #expect(manager.status(id: "legacy")?.memoryMB == 2_048) + #expect(manager.workspaceProjectionDiagnostic(id: "legacy")?.state == .regenerated) + #expect(try legacyData(state: state, id: "legacy") == raw) + let projected = try record(state: state, id: "legacy") + #expect(projected.legacyConfigurationSHA256 == sha256(raw)) + + let projectionBytes = try Data( + contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "legacy")) + ) + let restarted = makeManager(state: state) + #expect(restarted.workspaceProjectionDiagnostic(id: "legacy")?.state == .current) + #expect(try legacyData(state: state, id: "legacy") == raw) + #expect(try Data(contentsOf: URL( + fileURLWithPath: recordPath(state: state, id: "legacy") + )) == projectionBytes) + + try FileManager.default.removeItem(atPath: recordPath(state: state, id: "legacy")) + let repaired = makeManager(state: state) + #expect(repaired.workspaceProjectionDiagnostic(id: "legacy")?.state == .regenerated) + #expect(FileManager.default.fileExists( + atPath: recordPath(state: state, id: "legacy") + )) + #expect(try legacyData(state: state, id: "legacy") == raw) + } + } + + @Test("stale projection is repaired from crash-ordered legacy metadata") + func staleProjectionRepair() throws { + try withStateRoot("stale") { _, state in + let manager = makeManager(state: state) + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048 + )) + let first = try record(state: state, id: "dev") + + let path = state + "/dev/machine.json" + var authoritative = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: try legacyData(state: state, id: "dev") + ) + authoritative.memoryMB = 6_144 + let changedBytes = try DoryMachineConfigurationMigrationBridge.encodeLegacy(authoritative) + try writePrivate(changedBytes, path: path) + + let recovered = makeManager(state: state) + #expect(recovered.status(id: "dev")?.memoryMB == 6_144) + #expect(recovered.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated) + let repaired = try record(state: state, id: "dev") + #expect(repaired.definition.lifecycle.revision == first.definition.lifecycle.revision + 1) + #expect(repaired.definition.resources.memoryBytes == 6 * gibibyte) + #expect(repaired.legacyConfigurationSHA256 == sha256(changedBytes)) + } + } + + @Test("inspection fact changes advance projection without rewriting machine metadata") + func inspectionFactsAreAuthority() throws { + try withStateRoot("facts") { _, state in + let manager = makeManager(state: state) + _ = try manager.create(DoryMachineConfiguration( + id: "installed", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + bootMode: .efi, + memoryMB: 4_096, + cpuCount: 4, + displayMode: .desktop + )) + let rawBefore = try legacyData(state: state, id: "installed") + let firmware = try record(state: state, id: "installed") + #expect(firmware.definition.boot.devices[0].kind == .virtualDisk) + + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data(repeating: 0x41, count: 4_096), + initrd: Data(repeating: 0x42, count: 8_192), + kernelISOPath: "casper/vmlinuz", + initrdISOPath: "casper/initrd" + ), + rootDevice: "/dev/vda2", + toPath: state + "/installed/kernel" + ) + + let reconciled = makeManager(state: state) + #expect(reconciled.workspaceProjectionDiagnostic(id: "installed")?.state == .regenerated) + #expect(try legacyData(state: state, id: "installed") == rawBefore) + let direct = try record(state: state, id: "installed") + #expect(direct.definition.lifecycle.revision == firmware.definition.lifecycle.revision + 1) + #expect(direct.definition.lifecycle.createdAtUnixMilliseconds + == firmware.definition.lifecycle.createdAtUnixMilliseconds) + #expect(direct.definition.boot.devices[0].kind == .installedLinuxBootBundle) + #expect(direct.legacyConfigurationSHA256 == firmware.legacyConfigurationSHA256) + #expect(direct.legacyMigrationFactsSHA256 != firmware.legacyMigrationFactsSHA256) + } + } + + @Test("projection failure keeps disk and in-memory legacy configuration consistent") + func projectionFailureAfterLegacyCommitIsNonfatal() throws { + try withStateRoot("projection-failure") { _, state in + let manager = makeManager(state: state) + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048 + )) + let projectionPath = recordPath(state: state, id: "dev") + try FileManager.default.removeItem(atPath: projectionPath) + try FileManager.default.createDirectory( + atPath: projectionPath, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + + let status = try manager.update(id: "dev", memoryMB: 4_096) + #expect(status.memoryMB == 4_096) + let persisted = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: try legacyData(state: state, id: "dev") + ) + #expect(persisted.memoryMB == 4_096) + let diagnostic = try #require(manager.workspaceProjectionDiagnostic(id: "dev")) + #expect(diagnostic.state == .unavailable) + #expect(diagnostic.failureCode == .repositoryFailure) + + try FileManager.default.removeItem(atPath: projectionPath) + let repaired = makeManager(state: state) + #expect(repaired.status(id: "dev")?.memoryMB == 4_096) + #expect(repaired.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated) + } + } + + @Test("unsupported migration facts do not prevent legacy create or load") + func unsupportedFactsRemainDiagnosticOnly() throws { + try withStateRoot("unsupported") { _, state in + let manager = makeManager(state: state, architecture: "mips64") + let created = try manager.create(DoryMachineConfiguration( + id: "legacy", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath + )) + #expect(created.state == .created) + let diagnostic = try #require(manager.workspaceProjectionDiagnostic(id: "legacy")) + #expect(diagnostic.state == .unavailable) + #expect(diagnostic.failureCode == .unsupportedLegacyConfiguration) + #expect(FileManager.default.fileExists(atPath: state + "/legacy/machine.json")) + #expect(!FileManager.default.fileExists( + atPath: recordPath(state: state, id: "legacy") + )) + + let reloaded = makeManager(state: state, architecture: "mips64") + #expect(reloaded.status(id: "legacy")?.state == .stopped) + #expect(reloaded.workspaceProjectionDiagnostic(id: "legacy")?.failureCode + == .unsupportedLegacyConfiguration) + } + } + + @Test("snapshot restore publishes the restored authoritative configuration") + func snapshotRestoreProjection() throws { + try withStateRoot("restore") { _, state in + let manager = makeManager(state: state) + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048 + )) + _ = try manager.snapshot(id: "dev", snapshotID: "before") + _ = try manager.update(id: "dev", memoryMB: 4_096) + #expect(try record(state: state, id: "dev").definition.lifecycle.revision == 2) + + let restored = try manager.restoreSnapshot(machineID: "dev", snapshotID: "before") + #expect(restored.memoryMB == 2_048) + let projection = try record(state: state, id: "dev") + #expect(projection.definition.lifecycle.revision == 3) + #expect(projection.definition.resources.memoryBytes == 2 * gibibyte) + #expect(projection.legacyConfigurationSHA256 + == sha256(try legacyData(state: state, id: "dev"))) + } + } + + private func makeManager( + state: String, + architecture: String = "arm64" + ) -> MachineManager { + MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: state, + passMachineArguments: false, + requiresReadyHandoff: false, + guestArchitecture: architecture + )) + } + + private func withStateRoot( + _ label: String, + _ body: (String, String) throws -> Void + ) throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-projection-\(label)-\(UUID().uuidString.lowercased())") + .path + try FileManager.default.createDirectory( + atPath: base, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + defer { try? FileManager.default.removeItem(atPath: base) } + try body(base, base + "/machines") + } + + private func legacyData(state: String, id: String) throws -> Data { + try Data(contentsOf: URL(fileURLWithPath: state + "/\(id)/machine.json")) + } + + private func record(state: String, id: String) throws -> DoryWorkspaceRepositoryRecord { + try JSONDecoder().decode( + DoryWorkspaceRepositoryRecord.self, + from: Data(contentsOf: URL(fileURLWithPath: recordPath(state: state, id: id))) + ) + } + + private func recordPath(state: String, id: String) -> String { + state + "/\(id)/" + DoryWorkspaceRepository.recordFileName + } + + private func writePrivate(_ data: Data, path: String) throws { + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} From cf826ab3e458f9ccd81ad3fd8dc0f5129addb1ce Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 12:14:52 +0000 Subject: [PATCH 015/338] feat(vm): persist resolved launch plans --- .../DorydKit/DoryResolvedMachinePlan.swift | 1526 +++++++++++++++++ .../DoryResolvedMachinePlanRepository.swift | 423 +++++ .../DoryResolvedMachinePlanTests.swift | 927 ++++++++++ 3 files changed, 2876 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift new file mode 100644 index 00000000..365c8039 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -0,0 +1,1526 @@ +import DoryOperations +import Foundation + +public enum DoryResolvedMachinePlanMigrationDisposition: String, Codable, Sendable, Hashable { + case current + /// The record can be decoded for migration, but cannot authorize a launch. + case requiresReplanning = "requires-replanning" +} + +public struct DoryResolvedBackendComponentEvidence: Codable, Sendable, Equatable, Hashable { + public var componentIdentifier: String + public var buildIdentifier: String + public var artifactSHA256: String + + public init( + componentIdentifier: String, + buildIdentifier: String, + artifactSHA256: String + ) { + self.componentIdentifier = componentIdentifier + self.buildIdentifier = buildIdentifier + self.artifactSHA256 = artifactSHA256 + } +} + +/// Exact resolved media identity. Immutable media is content-addressed; mutable disks use a +/// daemon-issued provenance revision and receipt instead of pretending their bytes never change. +public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashable { + public var resolverReference: DoryVMResolverReference? + public var media: DoryBootMedia + public var inspectionEvidence: DoryBootMediaInspectionAuditEvidence? + public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? + + public init( + resolverReference: DoryVMResolverReference?, + media: DoryBootMedia, + inspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil, + mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil + ) { + self.resolverReference = resolverReference + self.media = media + self.inspectionEvidence = inspectionEvidence + self.mutableProvenanceEvidence = mutableProvenanceEvidence + } +} + +/// Persisted audit references only. Signature verification and qualification decisions remain +/// daemon-owned; these references let the daemon resolve and verify the exact evidence again. +public struct DoryResolvedMachineQualificationEvidence: Codable, Sendable, Equatable, Hashable { + public var graphics: DorySignedArtifactQualificationEvidence? + public var runtime: DoryVirtualMachineRuntimeQualificationEvidence? + + public init( + graphics: DorySignedArtifactQualificationEvidence? = nil, + runtime: DoryVirtualMachineRuntimeQualificationEvidence? = nil + ) { + self.graphics = graphics + self.runtime = runtime + } +} + +/// Exact resource admission captured by the daemon policy immediately before plan publication. +/// The snapshot includes existing commitments and the reserve retained for macOS. +public struct DoryResolvedMachineResourceAdmissionEvidence: Codable, Sendable, Equatable, Hashable { + public var admittedVirtualCPUCount: UInt64 + public var admittedMemoryBytes: UInt64 + public var admittedStorageBytes: UInt64 + public var hostLogicalCPUCount: UInt64 + public var hostPhysicalMemoryBytes: UInt64 + public var hostFreeStorageBytes: UInt64 + public var existingVirtualCPUCommitment: UInt64 + public var existingMemoryCommitmentBytes: UInt64 + public var existingStorageReservationBytes: UInt64 + public var hostReservedLogicalCPUCount: UInt64 + public var hostReservedMemoryBytes: UInt64 + public var hostReservedStorageBytes: UInt64 + public var admissionIdentity: String + public var admissionReportSHA256: String + public var assessorIdentifier: String + public var assessorVersion: UInt16 + + public init( + admittedVirtualCPUCount: UInt64, + admittedMemoryBytes: UInt64, + admittedStorageBytes: UInt64, + hostLogicalCPUCount: UInt64, + hostPhysicalMemoryBytes: UInt64, + hostFreeStorageBytes: UInt64, + existingVirtualCPUCommitment: UInt64, + existingMemoryCommitmentBytes: UInt64, + existingStorageReservationBytes: UInt64, + hostReservedLogicalCPUCount: UInt64, + hostReservedMemoryBytes: UInt64, + hostReservedStorageBytes: UInt64, + admissionIdentity: String, + admissionReportSHA256: String, + assessorIdentifier: String, + assessorVersion: UInt16 + ) { + self.admittedVirtualCPUCount = admittedVirtualCPUCount + self.admittedMemoryBytes = admittedMemoryBytes + self.admittedStorageBytes = admittedStorageBytes + self.hostLogicalCPUCount = hostLogicalCPUCount + self.hostPhysicalMemoryBytes = hostPhysicalMemoryBytes + self.hostFreeStorageBytes = hostFreeStorageBytes + self.existingVirtualCPUCommitment = existingVirtualCPUCommitment + self.existingMemoryCommitmentBytes = existingMemoryCommitmentBytes + self.existingStorageReservationBytes = existingStorageReservationBytes + self.hostReservedLogicalCPUCount = hostReservedLogicalCPUCount + self.hostReservedMemoryBytes = hostReservedMemoryBytes + self.hostReservedStorageBytes = hostReservedStorageBytes + self.admissionIdentity = admissionIdentity + self.admissionReportSHA256 = admissionReportSHA256 + self.assessorIdentifier = assessorIdentifier + self.assessorVersion = assessorVersion + } +} + +/// Explicit host qualification key. A generic runtime qualification identity is not sufficient: +/// backend conformance also depends on the exact Mac model and operating-system build. +public struct DoryResolvedHostQualificationEvidence: Codable, Sendable, Equatable, Hashable { + public var qualificationIdentity: String + public var qualificationReportSHA256: String + public var hostHardwareModelIdentifier: String + public var hostOperatingSystemBuild: String + public var backend: DoryVirtualizationBackendIdentity + public var backendRuntimeBuildIdentifier: String + public var virtualHardwareABIVersion: UInt16 + public var qualifierIdentifier: String + public var qualifierVersion: UInt16 + + public init( + qualificationIdentity: String, + qualificationReportSHA256: String, + hostHardwareModelIdentifier: String, + hostOperatingSystemBuild: String, + backend: DoryVirtualizationBackendIdentity, + backendRuntimeBuildIdentifier: String, + virtualHardwareABIVersion: UInt16, + qualifierIdentifier: String, + qualifierVersion: UInt16 + ) { + self.qualificationIdentity = qualificationIdentity + self.qualificationReportSHA256 = qualificationReportSHA256 + self.hostHardwareModelIdentifier = hostHardwareModelIdentifier + self.hostOperatingSystemBuild = hostOperatingSystemBuild + self.backend = backend + self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.qualifierIdentifier = qualifierIdentifier + self.qualifierVersion = qualifierVersion + } +} + +/// Explicit opt-in captured when an experimental product support tier is selected. +public struct DoryResolvedExperimentalSupportAuthorization: Codable, Sendable, Equatable, Hashable { + public var authorizationIdentity: String + public var definitionRevision: UInt64 + public var backend: DoryVirtualizationBackendIdentity + public var authorizedAtUnixMilliseconds: Int64 + + public init( + authorizationIdentity: String, + definitionRevision: UInt64, + backend: DoryVirtualizationBackendIdentity, + authorizedAtUnixMilliseconds: Int64 + ) { + self.authorizationIdentity = authorizationIdentity + self.definitionRevision = definitionRevision + self.backend = backend + self.authorizedAtUnixMilliseconds = authorizedAtUnixMilliseconds + } +} + +public enum DoryResolvedMachineCandidateRejection: String, Codable, Sendable, Hashable { + case unavailable + case experimentalNotAuthorized = "experimental-not-authorized" +} + +public struct DoryResolvedMachineRejectedCandidate: Codable, Sendable, Equatable, Hashable { + public var backend: DoryVirtualizationBackendIdentity + public var graphics: DoryGraphicsAccelerationLevel + public var availability: DoryCapabilityAvailability + public var rejection: DoryResolvedMachineCandidateRejection + + public init( + backend: DoryVirtualizationBackendIdentity, + graphics: DoryGraphicsAccelerationLevel, + availability: DoryCapabilityAvailability, + rejection: DoryResolvedMachineCandidateRejection + ) { + self.backend = backend + self.graphics = graphics + self.availability = availability + self.rejection = rejection + } +} + +public struct DoryResolvedMachineFallbackAuthorization: Codable, Sendable, Equatable, Hashable { + public var authorizationIdentity: String + public var definitionRevision: UInt64 + public var fromBackend: DoryVirtualizationBackendIdentity + public var fromGraphics: DoryGraphicsAccelerationLevel + public var toBackend: DoryVirtualizationBackendIdentity + public var toGraphics: DoryGraphicsAccelerationLevel + public var authorizedAtUnixMilliseconds: Int64 + + public init( + authorizationIdentity: String, + definitionRevision: UInt64, + fromBackend: DoryVirtualizationBackendIdentity, + fromGraphics: DoryGraphicsAccelerationLevel, + toBackend: DoryVirtualizationBackendIdentity, + toGraphics: DoryGraphicsAccelerationLevel, + authorizedAtUnixMilliseconds: Int64 + ) { + self.authorizationIdentity = authorizationIdentity + self.definitionRevision = definitionRevision + self.fromBackend = fromBackend + self.fromGraphics = fromGraphics + self.toBackend = toBackend + self.toGraphics = toGraphics + self.authorizedAtUnixMilliseconds = authorizedAtUnixMilliseconds + } +} + +public enum DoryResolvedMachineSelectionDisposition: String, Codable, Sendable, Hashable { + case primary + /// A later backend or graphics level explicitly listed by a required planner request. + case explicitAlternative = "explicit-alternative" + case approvedFallback = "approved-fallback" +} + +/// Complete, non-secret selection trace. The original planner request is retained so diagnostics +/// can distinguish an explicit primary choice from an approved fallback. +public struct DoryResolvedMachineBackendSelectionEvidence: Codable, Sendable, Equatable, Hashable { + public var disposition: DoryResolvedMachineSelectionDisposition + public var plannerRequest: DoryVirtualMachineBackendPlanRequest + public var selectedEvaluationIndex: UInt32 + public var rejectedCandidates: [DoryResolvedMachineRejectedCandidate] + public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? + + public init( + disposition: DoryResolvedMachineSelectionDisposition, + plannerRequest: DoryVirtualMachineBackendPlanRequest, + selectedEvaluationIndex: UInt32, + rejectedCandidates: [DoryResolvedMachineRejectedCandidate], + fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil + ) { + self.disposition = disposition + self.plannerRequest = plannerRequest + self.selectedEvaluationIndex = selectedEvaluationIndex + self.rejectedCandidates = rejectedCandidates + self.fallbackAuthorization = fallbackAuthorization + } + + public static func resolving( + request: DoryVirtualMachineBackendPlanRequest, + result: DoryVirtualMachineBackendPlanResult, + definitionRevision: UInt64, + fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil + ) throws -> DoryResolvedMachineBackendSelectionEvidence { + guard result.failure == nil, + let selected = result.selectedDescriptor, + let selectedIndex = result.evaluatedDescriptors.firstIndex(of: selected), + selectedIndex <= Int(UInt32.max), + selected.availability.isUsable, + selected.request.guest == request.guest, + selected.request.bootMedia == request.bootMedia, + selected.request.devices == request.devices, + selected.request.virtualHardwareABIVersion == request.virtualHardwareABIVersion, + request.acceptableGraphics.contains(selected.request.graphics) else { + throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid + } + guard let orderedCandidates = orderedCandidates(for: request), + orderedCandidates.indices.contains(selectedIndex), + orderedCandidates[selectedIndex].0 == selected.request.backend, + orderedCandidates[selectedIndex].1 == selected.request.graphics else { + throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid + } + + var rejected: [DoryResolvedMachineRejectedCandidate] = [] + for (index, descriptor) in result.evaluatedDescriptors.prefix(selectedIndex).enumerated() { + guard descriptor.request.guest == request.guest, + descriptor.request.bootMedia == request.bootMedia, + descriptor.request.devices == request.devices, + descriptor.request.virtualHardwareABIVersion == request.virtualHardwareABIVersion, + descriptor.request.backend == orderedCandidates[index].0, + descriptor.request.graphics == orderedCandidates[index].1 else { + throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid + } + let rejection: DoryResolvedMachineCandidateRejection + if !descriptor.availability.isUsable { + rejection = .unavailable + } else if descriptor.availability.supportTier == .experimental, + !request.allowsExperimentalBackends { + rejection = .experimentalNotAuthorized + } else { + throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid + } + rejected.append(DoryResolvedMachineRejectedCandidate( + backend: descriptor.request.backend, + graphics: descriptor.request.graphics, + availability: descriptor.availability, + rejection: rejection + )) + } + + guard selectedIndex > 0 else { + guard fallbackAuthorization == nil else { + throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid + } + return DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: request, + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ) + } + if request.backendPreferencePolicy == .required { + guard fallbackAuthorization == nil else { + throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid + } + return DoryResolvedMachineBackendSelectionEvidence( + disposition: .explicitAlternative, + plannerRequest: request, + selectedEvaluationIndex: UInt32(selectedIndex), + rejectedCandidates: rejected + ) + } + guard let authorization = fallbackAuthorization else { + throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationRequired + } + guard let first = rejected.first, + authorization.definitionRevision == definitionRevision, + authorization.fromBackend == first.backend, + authorization.fromGraphics == first.graphics, + authorization.toBackend == selected.request.backend, + authorization.toGraphics == selected.request.graphics else { + throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid + } + return DoryResolvedMachineBackendSelectionEvidence( + disposition: .approvedFallback, + plannerRequest: request, + selectedEvaluationIndex: UInt32(selectedIndex), + rejectedCandidates: rejected, + fallbackAuthorization: authorization + ) + } + + fileprivate static func orderedCandidates( + for request: DoryVirtualMachineBackendPlanRequest + ) -> [(DoryVirtualizationBackendIdentity, DoryGraphicsAccelerationLevel)]? { + guard !request.acceptableGraphics.isEmpty, + Set(request.acceptableGraphics).count == request.acceptableGraphics.count else { + return nil + } + let defaults = DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: request.guest, + bootMedia: request.bootMedia.kind + ) + let backends: [DoryVirtualizationBackendIdentity] + if let preferences = request.backendPreferences { + guard !preferences.isEmpty, Set(preferences).count == preferences.count else { + return nil + } + if request.backendPreferencePolicy == .required { + backends = preferences + } else { + var seen: Set = [] + backends = (preferences + defaults).filter { seen.insert($0).inserted } + } + } else { + guard request.backendPreferencePolicy != .required else { return nil } + backends = defaults + } + return request.acceptableGraphics.flatMap { graphics in + backends.map { backend in (backend, graphics) } + } + } +} + +public enum DoryResolvedMachinePlanConstructionError: Error, Sendable, Equatable { + case backendDescriptorMismatch + case capabilityDescriptorInvalid + case plannerResultInvalid + case fallbackDisallowed + case fallbackAuthorizationRequired + case fallbackAuthorizationInvalid +} + +public enum DoryResolvedMachinePlanValidationCode: String, Codable, Sendable, Hashable { + case unsupportedSchemaVersion = "unsupported-schema-version" + case legacyPlanRequiresReplanning = "legacy-plan-requires-replanning" + case invalidMachineIdentifier = "invalid-machine-identifier" + case invalidRevision = "invalid-revision" + case invalidDefinitionDigest = "invalid-definition-digest" + case invalidBackendImplementation = "invalid-backend-implementation" + case invalidBackendRuntimeBuild = "invalid-backend-runtime-build" + case invalidVirtualHardwareABI = "invalid-virtual-hardware-abi" + case invalidResolverReference = "invalid-resolver-reference" + case invalidMediaBinding = "invalid-media-binding" + case invalidMediaEvidence = "invalid-media-evidence" + case unsupportedRuntimeCombination = "unsupported-runtime-combination" + case duplicateComponent = "duplicate-component" + case unorderedComponents = "unordered-components" + case invalidComponentEvidence = "invalid-component-evidence" + case missingGraphicsQualification = "missing-graphics-qualification" + case invalidGraphicsQualification = "invalid-graphics-qualification" + case missingRuntimeQualification = "missing-runtime-qualification" + case runtimeQualificationMismatch = "runtime-qualification-mismatch" + case invalidResourceAdmission = "invalid-resource-admission" + case invalidHostQualification = "invalid-host-qualification" + case unsupportedSupportTier = "unsupported-support-tier" + case missingExperimentalAuthorization = "missing-experimental-authorization" + case invalidExperimentalAuthorization = "invalid-experimental-authorization" + case missingSelectionEvidence = "missing-selection-evidence" + case invalidSelectionEvidence = "invalid-selection-evidence" + case fallbackDisallowed = "fallback-disallowed" + case missingFallbackAuthorization = "missing-fallback-authorization" + case invalidFallbackAuthorization = "invalid-fallback-authorization" +} + +public struct DoryResolvedMachinePlanValidationIssue: Codable, Sendable, Equatable, Hashable { + public var code: DoryResolvedMachinePlanValidationCode + public var field: String + + public init(code: DoryResolvedMachinePlanValidationCode, field: String) { + self.code = code + self.field = field + } +} + +/// Durable output of capability resolution. Unlike desired state, every field is an exact launch +/// decision or non-secret audit reference and is replaced whenever any bound evidence changes. +public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { + public static let oldestSupportedSchemaVersion: UInt16 = 1 + public static let currentSchemaVersion: UInt16 = 2 + + public var schemaVersion: UInt16 + public var sourceSchemaVersion: UInt16 + public var migrationDisposition: DoryResolvedMachinePlanMigrationDisposition + public var machineID: String + public var definitionRevision: UInt64 + public var definitionSHA256: String? + public var planRevision: UInt64 + public var createdAtUnixMilliseconds: Int64 + public var updatedAtUnixMilliseconds: Int64 + public var guest: DoryGuestPlatform + public var backend: DoryVirtualizationBackendIdentity + public var backendImplementationIdentifier: String + public var backendRuntimeBuildIdentifier: String + public var virtualHardwareABIVersion: UInt16 + public var bootMedia: DoryResolvedMachineBootMedia + public var components: [DoryResolvedBackendComponentEvidence] + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var graphics: DoryGraphicsAccelerationLevel + public var supportTier: DoryCapabilitySupportTier + public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence? + public var qualificationEvidence: DoryResolvedMachineQualificationEvidence + public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence? + public var hostQualification: DoryResolvedHostQualificationEvidence? + public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + + public init( + machineID: String, + definitionRevision: UInt64, + definitionSHA256: String, + planRevision: UInt64, + createdAtUnixMilliseconds: Int64, + updatedAtUnixMilliseconds: Int64, + guest: DoryGuestPlatform, + backend: DoryVirtualizationBackendIdentity, + backendImplementationIdentifier: String, + backendRuntimeBuildIdentifier: String, + virtualHardwareABIVersion: UInt16, + bootMedia: DoryResolvedMachineBootMedia, + components: [DoryResolvedBackendComponentEvidence], + devices: DoryVirtualMachineDeviceCapabilityRequest, + graphics: DoryGraphicsAccelerationLevel, + supportTier: DoryCapabilitySupportTier, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence, + qualificationEvidence: DoryResolvedMachineQualificationEvidence, + resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, + hostQualification: DoryResolvedHostQualificationEvidence, + experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil + ) { + schemaVersion = Self.currentSchemaVersion + sourceSchemaVersion = Self.currentSchemaVersion + migrationDisposition = .current + self.machineID = machineID + self.definitionRevision = definitionRevision + self.definitionSHA256 = definitionSHA256 + self.planRevision = planRevision + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds + self.guest = guest + self.backend = backend + self.backendImplementationIdentifier = backendImplementationIdentifier + self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.bootMedia = bootMedia + self.components = components + self.devices = devices + self.graphics = graphics + self.supportTier = supportTier + self.selectionEvidence = selectionEvidence + self.qualificationEvidence = qualificationEvidence + self.resourceAdmission = resourceAdmission + self.hostQualification = hostQualification + self.experimentalAuthorization = experimentalAuthorization + } + + /// Builds the durable record from the planner-selected capability without allowing the + /// caller to substitute its backend, graphics, devices, ABI, or qualification references. + public init( + machineID: String, + definitionRevision: UInt64, + definitionSHA256: String, + planRevision: UInt64, + createdAtUnixMilliseconds: Int64, + updatedAtUnixMilliseconds: Int64, + backendDescriptor: MachineBackendDescriptor, + backendRuntimeBuildIdentifier: String, + resolverReference: DoryVMResolverReference?, + components: [DoryResolvedBackendComponentEvidence], + resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, + hostQualification: DoryResolvedHostQualificationEvidence, + experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil, + plannerRequest: DoryVirtualMachineBackendPlanRequest, + plannerResult: DoryVirtualMachineBackendPlanResult, + fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil + ) throws { + guard let selectedCapability = plannerResult.selectedDescriptor else { + throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid + } + guard backendDescriptor.identity == selectedCapability.request.backend else { + throw DoryResolvedMachinePlanConstructionError.backendDescriptorMismatch + } + guard selectedCapability.schemaVersion + == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion, + selectedCapability.evaluatorVersion + == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + selectedCapability.availability.isUsable, + selectedCapability.availability.supportTier != .unsupported, + selectedCapability.resolvedDevices == selectedCapability.request.devices else { + throw DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid + } + let selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving( + request: plannerRequest, + result: plannerResult, + definitionRevision: definitionRevision, + fallbackAuthorization: fallbackAuthorization + ) + self.init( + machineID: machineID, + definitionRevision: definitionRevision, + definitionSHA256: definitionSHA256, + planRevision: planRevision, + createdAtUnixMilliseconds: createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: updatedAtUnixMilliseconds, + guest: selectedCapability.request.guest, + backend: selectedCapability.request.backend, + backendImplementationIdentifier: backendDescriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: backendRuntimeBuildIdentifier, + virtualHardwareABIVersion: selectedCapability.request.virtualHardwareABIVersion, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: resolverReference, + media: selectedCapability.request.bootMedia, + inspectionEvidence: selectedCapability.bootMediaInspectionEvidence, + mutableProvenanceEvidence: selectedCapability.mutableBootMediaProvenanceEvidence + ), + components: components, + devices: selectedCapability.request.devices, + graphics: selectedCapability.request.graphics, + supportTier: selectedCapability.availability.supportTier, + selectionEvidence: selectionEvidence, + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: selectedCapability.graphicsQualificationEvidence, + runtime: selectedCapability.runtimeQualificationEvidence + ), + resourceAdmission: resourceAdmission, + hostQualification: hostQualification, + experimentalAuthorization: experimentalAuthorization + ) + guard validate().isEmpty else { + throw DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid + } + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case sourceSchemaVersion + case migrationDisposition + case machineID + case definitionRevision + case definitionSHA256 + case planRevision + case createdAtUnixMilliseconds + case updatedAtUnixMilliseconds + case guest + case backend + case backendImplementationIdentifier + case backendRuntimeBuildIdentifier + case backendRuntimeBuildID + case virtualHardwareABIVersion + case bootMedia + case components + case componentDigests + case devices + case graphics + case supportTier + case selectionEvidence + case qualificationEvidence + case resourceAdmission + case hostQualification + case experimentalAuthorization + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion) + switch persistedSchema { + case Self.oldestSupportedSchemaVersion: + schemaVersion = Self.currentSchemaVersion + sourceSchemaVersion = persistedSchema + migrationDisposition = .requiresReplanning + machineID = try container.decode(String.self, forKey: .machineID) + definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision) + definitionSHA256 = nil + planRevision = try container.decode(UInt64.self, forKey: .planRevision) + createdAtUnixMilliseconds = try container.decodeIfPresent( + Int64.self, + forKey: .createdAtUnixMilliseconds + ) ?? 0 + updatedAtUnixMilliseconds = try container.decodeIfPresent( + Int64.self, + forKey: .updatedAtUnixMilliseconds + ) ?? createdAtUnixMilliseconds + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend) + backendImplementationIdentifier = "legacy-unresolved" + backendRuntimeBuildIdentifier = try container.decode( + String.self, + forKey: .backendRuntimeBuildID + ) + virtualHardwareABIVersion = try container.decode( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) + bootMedia = DoryResolvedMachineBootMedia( + resolverReference: nil, + media: try container.decode(DoryBootMedia.self, forKey: .bootMedia) + ) + let legacyDigests = try container.decode( + [String: String].self, + forKey: .componentDigests + ) + components = legacyDigests.keys.sorted().map { identifier in + DoryResolvedBackendComponentEvidence( + componentIdentifier: identifier, + buildIdentifier: "legacy-unresolved", + artifactSHA256: legacyDigests[identifier] ?? "" + ) + } + devices = try container.decodeIfPresent( + DoryVirtualMachineDeviceCapabilityRequest.self, + forKey: .devices + ) ?? .minimumBootable + graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics) + supportTier = .experimental + selectionEvidence = nil + qualificationEvidence = DoryResolvedMachineQualificationEvidence() + resourceAdmission = nil + hostQualification = nil + experimentalAuthorization = nil + case Self.currentSchemaVersion: + schemaVersion = persistedSchema + sourceSchemaVersion = try container.decodeIfPresent( + UInt16.self, + forKey: .sourceSchemaVersion + ) ?? persistedSchema + migrationDisposition = try container.decode( + DoryResolvedMachinePlanMigrationDisposition.self, + forKey: .migrationDisposition + ) + machineID = try container.decode(String.self, forKey: .machineID) + definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision) + definitionSHA256 = try container.decodeIfPresent(String.self, forKey: .definitionSHA256) + planRevision = try container.decode(UInt64.self, forKey: .planRevision) + createdAtUnixMilliseconds = try container.decode( + Int64.self, + forKey: .createdAtUnixMilliseconds + ) + updatedAtUnixMilliseconds = try container.decode( + Int64.self, + forKey: .updatedAtUnixMilliseconds + ) + guest = try container.decode(DoryGuestPlatform.self, forKey: .guest) + backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend) + backendImplementationIdentifier = try container.decode( + String.self, + forKey: .backendImplementationIdentifier + ) + backendRuntimeBuildIdentifier = try container.decode( + String.self, + forKey: .backendRuntimeBuildIdentifier + ) + virtualHardwareABIVersion = try container.decode( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) + bootMedia = try container.decode(DoryResolvedMachineBootMedia.self, forKey: .bootMedia) + components = try container.decode( + [DoryResolvedBackendComponentEvidence].self, + forKey: .components + ) + devices = try container.decode( + DoryVirtualMachineDeviceCapabilityRequest.self, + forKey: .devices + ) + graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics) + supportTier = try container.decode(DoryCapabilitySupportTier.self, forKey: .supportTier) + selectionEvidence = try container.decodeIfPresent( + DoryResolvedMachineBackendSelectionEvidence.self, + forKey: .selectionEvidence + ) + qualificationEvidence = try container.decode( + DoryResolvedMachineQualificationEvidence.self, + forKey: .qualificationEvidence + ) + resourceAdmission = try container.decodeIfPresent( + DoryResolvedMachineResourceAdmissionEvidence.self, + forKey: .resourceAdmission + ) + hostQualification = try container.decodeIfPresent( + DoryResolvedHostQualificationEvidence.self, + forKey: .hostQualification + ) + experimentalAuthorization = try container.decodeIfPresent( + DoryResolvedExperimentalSupportAuthorization.self, + forKey: .experimentalAuthorization + ) + default: + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Unsupported resolved machine plan schema \(persistedSchema)." + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(sourceSchemaVersion, forKey: .sourceSchemaVersion) + try container.encode(migrationDisposition, forKey: .migrationDisposition) + try container.encode(machineID, forKey: .machineID) + try container.encode(definitionRevision, forKey: .definitionRevision) + try container.encodeIfPresent(definitionSHA256, forKey: .definitionSHA256) + try container.encode(planRevision, forKey: .planRevision) + try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds) + try container.encode(updatedAtUnixMilliseconds, forKey: .updatedAtUnixMilliseconds) + try container.encode(guest, forKey: .guest) + try container.encode(backend, forKey: .backend) + try container.encode(backendImplementationIdentifier, forKey: .backendImplementationIdentifier) + try container.encode(backendRuntimeBuildIdentifier, forKey: .backendRuntimeBuildIdentifier) + try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion) + try container.encode(bootMedia, forKey: .bootMedia) + try container.encode(components, forKey: .components) + try container.encode(devices, forKey: .devices) + try container.encode(graphics, forKey: .graphics) + try container.encode(supportTier, forKey: .supportTier) + try container.encodeIfPresent(selectionEvidence, forKey: .selectionEvidence) + try container.encode(qualificationEvidence, forKey: .qualificationEvidence) + try container.encodeIfPresent(resourceAdmission, forKey: .resourceAdmission) + try container.encodeIfPresent(hostQualification, forKey: .hostQualification) + try container.encodeIfPresent(experimentalAuthorization, forKey: .experimentalAuthorization) + } + + public func validate() -> [DoryResolvedMachinePlanValidationIssue] { + var issues: [DoryResolvedMachinePlanValidationIssue] = [] + func add(_ code: DoryResolvedMachinePlanValidationCode, _ field: String) { + issues.append(DoryResolvedMachinePlanValidationIssue(code: code, field: field)) + } + + if schemaVersion != Self.currentSchemaVersion { + add(.unsupportedSchemaVersion, "schemaVersion") + } + if migrationDisposition != .current || sourceSchemaVersion != Self.currentSchemaVersion { + add(.legacyPlanRequiresReplanning, "migrationDisposition") + } + if !Self.isSafeIdentifier(machineID) { + add(.invalidMachineIdentifier, "machineID") + } + if definitionRevision == 0 { add(.invalidRevision, "definitionRevision") } + if planRevision == 0 { add(.invalidRevision, "planRevision") } + if updatedAtUnixMilliseconds < createdAtUnixMilliseconds { + add(.invalidRevision, "updatedAtUnixMilliseconds") + } + if !(definitionSHA256.map(Self.isSHA256) ?? false) { + add(.invalidDefinitionDigest, "definitionSHA256") + } + if !Self.isSafeEvidenceIdentifier(backendImplementationIdentifier) { + add(.invalidBackendImplementation, "backendImplementationIdentifier") + } + if !Self.isSafeEvidenceIdentifier(backendRuntimeBuildIdentifier) { + add(.invalidBackendRuntimeBuild, "backendRuntimeBuildIdentifier") + } + if virtualHardwareABIVersion == 0 { + add(.invalidVirtualHardwareABI, "virtualHardwareABIVersion") + } + + validateBootMedia(into: &issues) + validateComponents(into: &issues) + validateQualifications(into: &issues) + validateResourceAdmission(into: &issues) + validateHostQualification(into: &issues) + validateSupportAuthorization(into: &issues) + validateSelectionEvidence(into: &issues) + return issues + } + + private func validateBootMedia( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + func add(_ code: DoryResolvedMachinePlanValidationCode, _ field: String) { + issues.append(DoryResolvedMachinePlanValidationIssue(code: code, field: field)) + } + if let reference = bootMedia.resolverReference, + !Self.isSafeResolverReference(reference) { + add(.invalidResolverReference, "bootMedia.resolverReference") + } + let immutable = bootMedia.media.artifactSHA256 + let mutable = bootMedia.media.mutableProvenance + if immutable.map(Self.isSHA256) == false || (immutable == nil) == (mutable == nil) { + add(.invalidMediaBinding, "bootMedia.media") + } + if bootMedia.media.kind == .virtualDisk { + if mutable == nil || immutable != nil { + add(.invalidMediaBinding, "bootMedia.media.mutableProvenance") + } + } else if immutable == nil || mutable != nil { + add(.invalidMediaBinding, "bootMedia.media.artifactSHA256") + } + let runtimeCombinationIsImplemented: Bool + switch (guest.family, backend) { + case (.linux, .doryHypervisor): + runtimeCombinationIsImplemented = bootMedia.media.kind == .installedLinuxBootBundle + case (.linux, .appleVirtualizationFramework), + (.linux, .qemuHypervisorFramework), + (.windows, .qemuHypervisorFramework): + runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO + || bootMedia.media.kind == .virtualDisk + case (.macOS, .appleVirtualizationFramework): + runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage + || bootMedia.media.kind == .virtualDisk + default: + runtimeCombinationIsImplemented = false + } + if !runtimeCombinationIsImplemented { + add(.unsupportedRuntimeCombination, "bootMedia.media.kind") + } + if let inspection = bootMedia.inspectionEvidence { + guard Self.isSafeEvidenceIdentifier(inspection.inspectionIdentity), + Self.isSHA256(inspection.artifactSHA256), + Self.isSHA256(inspection.inspectionReportSHA256), + Self.isSafeEvidenceIdentifier(inspection.inspectorID), + inspection.inspectorVersion > 0, + inspection.artifactSHA256.lowercased() == immutable?.lowercased() else { + add(.invalidMediaEvidence, "bootMedia.inspectionEvidence") + return + } + } else if bootMedia.media.kind == .installerISO + || bootMedia.media.kind == .macOSRestoreImage { + add(.invalidMediaEvidence, "bootMedia.inspectionEvidence") + } + if let evidence = bootMedia.mutableProvenanceEvidence { + guard Self.isSafeEvidenceIdentifier(evidence.receiptIdentity), + Self.isSHA256(evidence.receiptSHA256), + Self.isSafeEvidenceIdentifier(evidence.resolverID), + evidence.resolverVersion > 0, + evidence.provenance == mutable else { + add(.invalidMediaEvidence, "bootMedia.mutableProvenanceEvidence") + return + } + } else if mutable != nil { + add(.invalidMediaEvidence, "bootMedia.mutableProvenanceEvidence") + } + } + + private func validateComponents( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + var identifiers: Set = [] + for (index, component) in components.enumerated() { + let field = "components[\(index)]" + if !identifiers.insert(component.componentIdentifier).inserted { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .duplicateComponent, + field: "\(field).componentIdentifier" + )) + } + if !Self.isSafeEvidenceIdentifier(component.componentIdentifier) + || !Self.isSafeEvidenceIdentifier(component.buildIdentifier) + || !Self.isSHA256(component.artifactSHA256) { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidComponentEvidence, + field: field + )) + } + } + if components.isEmpty { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidComponentEvidence, + field: "components" + )) + } + if components.map(\.componentIdentifier) != components.map(\.componentIdentifier).sorted() { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .unorderedComponents, + field: "components" + )) + } + } + + private func validateQualifications( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + if backend == .doryHypervisor, graphics != .none { + guard let graphicsEvidence = qualificationEvidence.graphics else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .missingGraphicsQualification, + field: "qualificationEvidence.graphics" + )) + return validateRuntimeQualification(into: &issues) + } + if !Self.isValid(graphicsEvidence) + || graphicsEvidence.artifactSHA256.lowercased() + != bootMedia.media.artifactSHA256?.lowercased() { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidGraphicsQualification, + field: "qualificationEvidence.graphics" + )) + } + } else if let graphicsEvidence = qualificationEvidence.graphics, + !Self.isValid(graphicsEvidence) { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidGraphicsQualification, + field: "qualificationEvidence.graphics" + )) + } + validateRuntimeQualification(into: &issues) + } + + private func validateRuntimeQualification( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + guard let runtime = qualificationEvidence.runtime else { + if supportTier == .supported { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .missingRuntimeQualification, + field: "qualificationEvidence.runtime" + )) + } + return + } + let mediaMatches = runtime.immutableArtifactSHA256?.lowercased() + == bootMedia.media.artifactSHA256?.lowercased() + && runtime.mutableProvenance == bootMedia.media.mutableProvenance + guard Self.isSafeEvidenceIdentifier(runtime.qualificationIdentity), + Self.isSHA256(runtime.qualificationReportSHA256), + Self.isSafeEvidenceIdentifier(runtime.signingKeyID), + runtime.qualificationFormatVersion > 0, + runtime.guest == guest, + runtime.bootMediaKind == bootMedia.media.kind, + mediaMatches, + runtime.backend == backend, + runtime.backendRuntimeBuildID == backendRuntimeBuildIdentifier, + runtime.virtualHardwareABIVersion == virtualHardwareABIVersion, + runtime.graphics == graphics, + runtime.devices == devices else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .runtimeQualificationMismatch, + field: "qualificationEvidence.runtime" + )) + return + } + } + + private func validateResourceAdmission( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + guard let admission = resourceAdmission else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidResourceAdmission, + field: "resourceAdmission" + )) + return + } + let identifiersAreValid = Self.isSafeEvidenceIdentifier(admission.admissionIdentity) + && Self.isSHA256(admission.admissionReportSHA256) + && Self.isSafeEvidenceIdentifier(admission.assessorIdentifier) + && admission.assessorVersion > 0 + let admittedValuesAreValid = admission.admittedVirtualCPUCount > 0 + && admission.admittedMemoryBytes > 0 + && admission.admittedStorageBytes > 0 + let hostValuesAreValid = admission.hostLogicalCPUCount > 0 + && admission.hostPhysicalMemoryBytes > 0 + && admission.hostFreeStorageBytes > 0 + let cpuFits = Self.sumFits( + within: admission.hostLogicalCPUCount, + admission.existingVirtualCPUCommitment, + admission.hostReservedLogicalCPUCount, + admission.admittedVirtualCPUCount + ) + let memoryFits = Self.sumFits( + within: admission.hostPhysicalMemoryBytes, + admission.existingMemoryCommitmentBytes, + admission.hostReservedMemoryBytes, + admission.admittedMemoryBytes + ) + let storageFits = Self.sumFits( + within: admission.hostFreeStorageBytes, + admission.existingStorageReservationBytes, + admission.hostReservedStorageBytes, + admission.admittedStorageBytes + ) + if !identifiersAreValid || !admittedValuesAreValid || !hostValuesAreValid + || !cpuFits || !memoryFits || !storageFits { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidResourceAdmission, + field: "resourceAdmission" + )) + } + } + + private func validateHostQualification( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + guard let host = hostQualification, + Self.isSafeEvidenceIdentifier(host.qualificationIdentity), + Self.isSHA256(host.qualificationReportSHA256), + Self.isSafeEvidenceIdentifier(host.hostHardwareModelIdentifier), + Self.isSafeEvidenceIdentifier(host.hostOperatingSystemBuild), + Self.isSafeEvidenceIdentifier(host.qualifierIdentifier), + host.qualifierVersion > 0, + host.backend == backend, + host.backendRuntimeBuildIdentifier == backendRuntimeBuildIdentifier, + host.virtualHardwareABIVersion == virtualHardwareABIVersion else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidHostQualification, + field: "hostQualification" + )) + return + } + } + + private func validateSupportAuthorization( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + switch supportTier { + case .unsupported: + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .unsupportedSupportTier, + field: "supportTier" + )) + case .supported: + if experimentalAuthorization != nil { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidExperimentalAuthorization, + field: "experimentalAuthorization" + )) + } + case .experimental: + guard let authorization = experimentalAuthorization else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .missingExperimentalAuthorization, + field: "experimentalAuthorization" + )) + return + } + if !Self.isSafeEvidenceIdentifier(authorization.authorizationIdentity) + || authorization.definitionRevision != definitionRevision + || authorization.backend != backend + || authorization.authorizedAtUnixMilliseconds <= 0 { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidExperimentalAuthorization, + field: "experimentalAuthorization" + )) + } + } + } + + private func validateSelectionEvidence( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + guard let selection = selectionEvidence else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .missingSelectionEvidence, + field: "selectionEvidence" + )) + return + } + let request = selection.plannerRequest + guard request.guest == guest, + request.bootMedia == bootMedia.media, + request.devices == devices, + request.virtualHardwareABIVersion == virtualHardwareABIVersion, + !request.acceptableGraphics.isEmpty, + Set(request.acceptableGraphics).count == request.acceptableGraphics.count else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.plannerRequest" + )) + return + } + + guard let orderedCandidates = DoryResolvedMachineBackendSelectionEvidence + .orderedCandidates(for: request) else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.plannerRequest" + )) + return + } + let selectedIndex = Int(selection.selectedEvaluationIndex) + guard orderedCandidates.indices.contains(selectedIndex), + orderedCandidates[selectedIndex].0 == backend, + orderedCandidates[selectedIndex].1 == graphics, + selection.rejectedCandidates.count == selectedIndex else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.selectedEvaluationIndex" + )) + return + } + for (index, rejected) in selection.rejectedCandidates.enumerated() { + let expected = orderedCandidates[index] + let rejectionIsValid = switch rejected.rejection { + case .unavailable: + !rejected.availability.isUsable + case .experimentalNotAuthorized: + rejected.availability.isUsable + && rejected.availability.supportTier == .experimental + && !request.allowsExperimentalBackends + } + if rejected.backend != expected.0 + || rejected.graphics != expected.1 + || !rejectionIsValid { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.rejectedCandidates[\(index)]" + )) + } + } + if supportTier == .experimental, !request.allowsExperimentalBackends { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.plannerRequest.allowsExperimentalBackends" + )) + } + + switch selection.disposition { + case .primary: + if selectedIndex != 0 || !selection.rejectedCandidates.isEmpty + || selection.fallbackAuthorization != nil { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.disposition" + )) + } + case .explicitAlternative: + if request.backendPreferencePolicy != .required + || selectedIndex == 0 + || selection.fallbackAuthorization != nil { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.disposition" + )) + } + case .approvedFallback: + if request.backendPreferencePolicy == .required { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .fallbackDisallowed, + field: "selectionEvidence.disposition" + )) + } + guard selectedIndex > 0 else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidSelectionEvidence, + field: "selectionEvidence.disposition" + )) + return + } + guard let authorization = selection.fallbackAuthorization else { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .missingFallbackAuthorization, + field: "selectionEvidence.fallbackAuthorization" + )) + return + } + let first = selection.rejectedCandidates[0] + if !Self.isSafeEvidenceIdentifier(authorization.authorizationIdentity) + || authorization.definitionRevision != definitionRevision + || authorization.fromBackend != first.backend + || authorization.fromGraphics != first.graphics + || authorization.toBackend != backend + || authorization.toGraphics != graphics + || authorization.authorizedAtUnixMilliseconds <= 0 { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidFallbackAuthorization, + field: "selectionEvidence.fallbackAuthorization" + )) + } + } + } + + private static func isValid(_ evidence: DorySignedArtifactQualificationEvidence) -> Bool { + isSafeEvidenceIdentifier(evidence.manifestIdentity) + && isSHA256(evidence.artifactSHA256) + && isSHA256(evidence.manifestSHA256) + && isSafeEvidenceIdentifier(evidence.signingKeyID) + && evidence.manifestFormatVersion > 0 + } + + static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + static func isSafeIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...63).contains(bytes.count), isAlphaNumeric(bytes[0]) else { return false } + return bytes.dropFirst().allSatisfy { + isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 + } + } + + private static func isSafeResolverReference(_ value: DoryVMResolverReference) -> Bool { + isSafeEvidenceIdentifier(value.namespace) && isSafeEvidenceIdentifier(value.identifier) + } + + private static func isSafeEvidenceIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...256).contains(bytes.count) else { return false } + return bytes.allSatisfy { byte in + isAlphaNumeric(byte) || byte == 45 || byte == 46 || byte == 47 + || byte == 58 || byte == 64 || byte == 95 + } + } + + private static func isAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + + private static func sumFits(within total: UInt64, _ values: UInt64...) -> Bool { + var remaining = total + for value in values { + guard value <= remaining else { return false } + remaining -= value + } + return true + } +} + +/// Fresh daemon/resolver output supplied immediately before start. It deliberately duplicates the +/// launch-critical plan fields so every mismatch can be named rather than hidden behind a Bool. +public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, Hashable { + public var guest: DoryGuestPlatform + public var backend: DoryVirtualizationBackendIdentity + public var backendImplementationIdentifier: String + public var backendRuntimeBuildIdentifier: String + public var virtualHardwareABIVersion: UInt16 + public var bootMedia: DoryResolvedMachineBootMedia + public var components: [DoryResolvedBackendComponentEvidence] + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var graphics: DoryGraphicsAccelerationLevel + public var supportTier: DoryCapabilitySupportTier + public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence? + public var qualificationEvidence: DoryResolvedMachineQualificationEvidence + public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence? + public var hostQualification: DoryResolvedHostQualificationEvidence? + public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + + public init( + guest: DoryGuestPlatform, + backend: DoryVirtualizationBackendIdentity, + backendImplementationIdentifier: String, + backendRuntimeBuildIdentifier: String, + virtualHardwareABIVersion: UInt16, + bootMedia: DoryResolvedMachineBootMedia, + components: [DoryResolvedBackendComponentEvidence], + devices: DoryVirtualMachineDeviceCapabilityRequest, + graphics: DoryGraphicsAccelerationLevel, + supportTier: DoryCapabilitySupportTier, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence?, + qualificationEvidence: DoryResolvedMachineQualificationEvidence, + resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence?, + hostQualification: DoryResolvedHostQualificationEvidence?, + experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + ) { + self.guest = guest + self.backend = backend + self.backendImplementationIdentifier = backendImplementationIdentifier + self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.bootMedia = bootMedia + self.components = components + self.devices = devices + self.graphics = graphics + self.supportTier = supportTier + self.selectionEvidence = selectionEvidence + self.qualificationEvidence = qualificationEvidence + self.resourceAdmission = resourceAdmission + self.hostQualification = hostQualification + self.experimentalAuthorization = experimentalAuthorization + } + + public init(plan: DoryResolvedMachinePlan) { + guest = plan.guest + backend = plan.backend + backendImplementationIdentifier = plan.backendImplementationIdentifier + backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier + virtualHardwareABIVersion = plan.virtualHardwareABIVersion + bootMedia = plan.bootMedia + components = plan.components + devices = plan.devices + graphics = plan.graphics + supportTier = plan.supportTier + selectionEvidence = plan.selectionEvidence + qualificationEvidence = plan.qualificationEvidence + resourceAdmission = plan.resourceAdmission + hostQualification = plan.hostQualification + experimentalAuthorization = plan.experimentalAuthorization + } +} + +public struct DoryResolvedMachinePlanStartRevalidationInput: Codable, Sendable, Equatable, Hashable { + public var machineID: String + public var expectedPlanRevision: UInt64 + public var currentDefinitionRevision: UInt64 + public var currentDefinitionSHA256: String + public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence + + public init( + machineID: String, + expectedPlanRevision: UInt64, + currentDefinitionRevision: UInt64, + currentDefinitionSHA256: String, + runtimeEvidence: DoryResolvedMachineRuntimeEvidence + ) { + self.machineID = machineID + self.expectedPlanRevision = expectedPlanRevision + self.currentDefinitionRevision = currentDefinitionRevision + self.currentDefinitionSHA256 = currentDefinitionSHA256 + self.runtimeEvidence = runtimeEvidence + } +} + +public enum DoryResolvedMachinePlanRevalidationCode: String, Codable, Sendable, Hashable { + case storedPlanInvalid = "stored-plan-invalid" + case machineIdentityMismatch = "machine-identity-mismatch" + case planRevisionMismatch = "plan-revision-mismatch" + case definitionRevisionMismatch = "definition-revision-mismatch" + case definitionDigestMismatch = "definition-digest-mismatch" + case guestMismatch = "guest-mismatch" + case backendMismatch = "backend-mismatch" + case backendImplementationMismatch = "backend-implementation-mismatch" + case backendRuntimeBuildMismatch = "backend-runtime-build-mismatch" + case virtualHardwareABIMismatch = "virtual-hardware-abi-mismatch" + case bootMediaEvidenceMismatch = "boot-media-evidence-mismatch" + case componentEvidenceMismatch = "component-evidence-mismatch" + case deviceContractMismatch = "device-contract-mismatch" + case graphicsMismatch = "graphics-mismatch" + case supportTierMismatch = "support-tier-mismatch" + case selectionEvidenceMismatch = "selection-evidence-mismatch" + case qualificationEvidenceMismatch = "qualification-evidence-mismatch" + case resourceAdmissionMismatch = "resource-admission-mismatch" + case hostQualificationMismatch = "host-qualification-mismatch" + case experimentalAuthorizationMismatch = "experimental-authorization-mismatch" +} + +public struct DoryResolvedMachinePlanRevalidationIssue: Codable, Sendable, Equatable, Hashable { + public var code: DoryResolvedMachinePlanRevalidationCode + public var field: String + + public init(code: DoryResolvedMachinePlanRevalidationCode, field: String) { + self.code = code + self.field = field + } +} + +public enum DoryResolvedMachinePlanRevalidationState: String, Codable, Sendable, Hashable { + case valid + case rejected +} + +public struct DoryResolvedMachinePlanRevalidationResult: Codable, Sendable, Equatable, Hashable { + public var state: DoryResolvedMachinePlanRevalidationState + public var issues: [DoryResolvedMachinePlanRevalidationIssue] + + public var mayStart: Bool { state == .valid && issues.isEmpty } + + public init( + state: DoryResolvedMachinePlanRevalidationState, + issues: [DoryResolvedMachinePlanRevalidationIssue] + ) { + self.state = state + self.issues = issues + } +} + +public enum DoryResolvedMachinePlanStartValidator { + public static func revalidate( + _ plan: DoryResolvedMachinePlan, + against input: DoryResolvedMachinePlanStartRevalidationInput + ) -> DoryResolvedMachinePlanRevalidationResult { + var issues: [DoryResolvedMachinePlanRevalidationIssue] = [] + func compare( + _ actual: T, + _ expected: T, + code: DoryResolvedMachinePlanRevalidationCode, + field: String + ) { + if actual != expected { + issues.append(DoryResolvedMachinePlanRevalidationIssue(code: code, field: field)) + } + } + + if !plan.validate().isEmpty { + issues.append(DoryResolvedMachinePlanRevalidationIssue( + code: .storedPlanInvalid, + field: "plan" + )) + } + compare(input.machineID, plan.machineID, code: .machineIdentityMismatch, field: "machineID") + compare( + input.expectedPlanRevision, + plan.planRevision, + code: .planRevisionMismatch, + field: "planRevision" + ) + compare( + input.currentDefinitionRevision, + plan.definitionRevision, + code: .definitionRevisionMismatch, + field: "definitionRevision" + ) + compare( + Optional(input.currentDefinitionSHA256.lowercased()), + plan.definitionSHA256?.lowercased(), + code: .definitionDigestMismatch, + field: "definitionSHA256" + ) + let runtime = input.runtimeEvidence + compare(runtime.guest, plan.guest, code: .guestMismatch, field: "guest") + compare(runtime.backend, plan.backend, code: .backendMismatch, field: "backend") + compare( + runtime.backendImplementationIdentifier, + plan.backendImplementationIdentifier, + code: .backendImplementationMismatch, + field: "backendImplementationIdentifier" + ) + compare( + runtime.backendRuntimeBuildIdentifier, + plan.backendRuntimeBuildIdentifier, + code: .backendRuntimeBuildMismatch, + field: "backendRuntimeBuildIdentifier" + ) + compare( + runtime.virtualHardwareABIVersion, + plan.virtualHardwareABIVersion, + code: .virtualHardwareABIMismatch, + field: "virtualHardwareABIVersion" + ) + compare(runtime.bootMedia, plan.bootMedia, code: .bootMediaEvidenceMismatch, field: "bootMedia") + compare(runtime.components, plan.components, code: .componentEvidenceMismatch, field: "components") + compare(runtime.devices, plan.devices, code: .deviceContractMismatch, field: "devices") + compare(runtime.graphics, plan.graphics, code: .graphicsMismatch, field: "graphics") + compare( + runtime.supportTier, + plan.supportTier, + code: .supportTierMismatch, + field: "supportTier" + ) + compare( + runtime.selectionEvidence, + plan.selectionEvidence, + code: .selectionEvidenceMismatch, + field: "selectionEvidence" + ) + compare( + runtime.qualificationEvidence, + plan.qualificationEvidence, + code: .qualificationEvidenceMismatch, + field: "qualificationEvidence" + ) + compare( + runtime.resourceAdmission, + plan.resourceAdmission, + code: .resourceAdmissionMismatch, + field: "resourceAdmission" + ) + compare( + runtime.hostQualification, + plan.hostQualification, + code: .hostQualificationMismatch, + field: "hostQualification" + ) + compare( + runtime.experimentalAuthorization, + plan.experimentalAuthorization, + code: .experimentalAuthorizationMismatch, + field: "experimentalAuthorization" + ) + + return DoryResolvedMachinePlanRevalidationResult( + state: issues.isEmpty ? .valid : .rejected, + issues: issues + ) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift new file mode 100644 index 00000000..d308c9f5 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift @@ -0,0 +1,423 @@ +import CryptoKit +import Darwin +import Foundation + +public enum DoryResolvedMachinePlanRepositoryError: Error, Sendable, Equatable, CustomStringConvertible { + case invalidMachineIdentifier(String) + case invalidPlan([DoryResolvedMachinePlanValidationIssue]) + case planExists(String) + case planNotFound(String) + case stalePlanRevision(expected: UInt64, actual: UInt64) + case invalidPlanRevision(expected: UInt64, actual: UInt64) + case machineIdentityChanged(String) + case invalidRecord(String) + case filesystem(String) + + public var description: String { + switch self { + case let .invalidMachineIdentifier(id): + "invalid resolved-plan machine identifier: \(id)" + case let .invalidPlan(issues): + "invalid resolved plan: " + + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ") + case let .planExists(id): + "resolved plan already exists: \(id)" + case let .planNotFound(id): + "resolved plan does not exist: \(id)" + case let .stalePlanRevision(expected, actual): + "stale resolved-plan revision: expected \(expected), found \(actual)" + case let .invalidPlanRevision(expected, actual): + "invalid replacement plan revision: expected \(expected), found \(actual)" + case let .machineIdentityChanged(id): + "resolved-plan machine identity cannot change: \(id)" + case let .invalidRecord(path): + "invalid resolved-plan repository record: \(path)" + case let .filesystem(message): + message + } + } +} + +/// Integrity envelope for a resolved plan. Schema v1 records decode for migration but have no +/// integrity digest and therefore always retain the plan's `requiresReplanning` disposition. +public struct DoryResolvedMachinePlanRepositoryRecord: Codable, Sendable, Equatable { + public static let oldestSupportedSchemaVersion: UInt16 = 1 + public static let currentSchemaVersion: UInt16 = 2 + + public var schemaVersion: UInt16 + public var planSHA256: String? + public var plan: DoryResolvedMachinePlan + + public init(planSHA256: String, plan: DoryResolvedMachinePlan) { + schemaVersion = Self.currentSchemaVersion + self.planSHA256 = planSHA256 + self.plan = plan + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case planSHA256 + case plan + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion) + guard schemaVersion == Self.oldestSupportedSchemaVersion + || schemaVersion == Self.currentSchemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Unsupported resolved-plan record schema \(schemaVersion)." + ) + } + planSHA256 = try container.decodeIfPresent(String.self, forKey: .planSHA256) + plan = try container.decode(DoryResolvedMachinePlan.self, forKey: .plan) + } +} + +/// Crash-safe, owner-only persistence for daemon-resolved runtime plans. +public final class DoryResolvedMachinePlanRepository: @unchecked Sendable { + public static let recordFileName = "resolved-plan.json" + + private static let temporaryPrefix = ".resolved-plan." + private static let maximumRecordBytes = 4 * 1_024 * 1_024 + + public let root: String + private let lock = NSLock() + + public init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + } + + public func create(_ plan: DoryResolvedMachinePlan) throws { + lock.lock() + defer { lock.unlock() } + try Self.validateCurrentPlan(plan) + guard plan.planRevision == 1 else { + throw DoryResolvedMachinePlanRepositoryError.invalidPlanRevision( + expected: 1, + actual: plan.planRevision + ) + } + let path = try prepareMachineDirectory(id: plan.machineID) + + "/\(Self.recordFileName)" + guard !Self.pathExists(path) else { + throw DoryResolvedMachinePlanRepositoryError.planExists(plan.machineID) + } + try publish(plan, path: path, replacing: false) + } + + public func replace( + _ plan: DoryResolvedMachinePlan, + expectedPlanRevision: UInt64 + ) throws { + lock.lock() + defer { lock.unlock() } + try Self.validateCurrentPlan(plan) + let current = try readRecordUnlocked(id: plan.machineID).plan + guard current.planRevision == expectedPlanRevision else { + throw DoryResolvedMachinePlanRepositoryError.stalePlanRevision( + expected: expectedPlanRevision, + actual: current.planRevision + ) + } + guard expectedPlanRevision < UInt64.max, + plan.planRevision == expectedPlanRevision + 1 else { + throw DoryResolvedMachinePlanRepositoryError.invalidPlanRevision( + expected: expectedPlanRevision == UInt64.max + ? UInt64.max + : expectedPlanRevision + 1, + actual: plan.planRevision + ) + } + guard plan.machineID == current.machineID, + plan.createdAtUnixMilliseconds == current.createdAtUnixMilliseconds else { + throw DoryResolvedMachinePlanRepositoryError.machineIdentityChanged(plan.machineID) + } + try publish(plan, path: recordPath(id: plan.machineID), replacing: true) + } + + public func read(id: String) throws -> DoryResolvedMachinePlan { + lock.lock() + defer { lock.unlock() } + return try readRecordUnlocked(id: id).plan + } + + public func remove(id: String) throws { + lock.lock() + defer { lock.unlock() } + try Self.validateMachineIdentifier(id) + let path = recordPath(id: id) + guard Self.pathExists(path) else { + throw DoryResolvedMachinePlanRepositoryError.planNotFound(id) + } + _ = try Self.secureRead(path: path) + guard unlink(path) == 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "remove resolved plan at \(path): \(String(cString: strerror(errno)))" + ) + } + try Self.fsyncDirectory(machineDirectory(id: id)) + } + + private func readRecordUnlocked(id: String) throws -> DoryResolvedMachinePlanRepositoryRecord { + try Self.validateMachineIdentifier(id) + let path = recordPath(id: id) + guard Self.pathExists(path) else { + throw DoryResolvedMachinePlanRepositoryError.planNotFound(id) + } + let data = try Self.secureRead(path: path) + let record: DoryResolvedMachinePlanRepositoryRecord + do { + record = try JSONDecoder().decode(DoryResolvedMachinePlanRepositoryRecord.self, from: data) + } catch { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + guard record.plan.machineID == id else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + switch record.schemaVersion { + case DoryResolvedMachinePlanRepositoryRecord.oldestSupportedSchemaVersion: + guard record.plan.sourceSchemaVersion == DoryResolvedMachinePlan.oldestSupportedSchemaVersion, + record.plan.migrationDisposition == .requiresReplanning, + record.planSHA256 == nil else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + case DoryResolvedMachinePlanRepositoryRecord.currentSchemaVersion: + guard let expected = record.planSHA256, + Self.isSHA256(expected), + expected == Self.planSHA256(record.plan), + record.plan.validate().isEmpty else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + default: + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + return record + } + + private func prepareMachineDirectory(id: String) throws -> String { + try Self.validateMachineIdentifier(id) + try Self.preparePrivateDirectory(root) + let directory = machineDirectory(id: id) + try Self.preparePrivateDirectory(directory) + return directory + } + + private func machineDirectory(id: String) -> String { root + "/" + id } + + private func recordPath(id: String) -> String { + machineDirectory(id: id) + "/" + Self.recordFileName + } + + private func publish( + _ plan: DoryResolvedMachinePlan, + path: String, + replacing: Bool + ) throws { + let record = DoryResolvedMachinePlanRepositoryRecord( + planSHA256: Self.planSHA256(plan), + plan: plan + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data: Data + do { + data = try encoder.encode(record) + } catch { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "encode resolved plan for \(plan.machineID): \(error)" + ) + } + guard !data.isEmpty, data.count <= Self.maximumRecordBytes else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + + let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path + let temporaryPath = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString.lowercased())" + let descriptor = open( + temporaryPath, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "create resolved-plan temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + var shouldRemoveTemporary = true + defer { + _ = close(descriptor) + if shouldRemoveTemporary { _ = unlink(temporaryPath) } + } + do { + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var written = 0 + while written < data.count { + let count = Darwin.write( + descriptor, + base.advanced(by: written), + data.count - written + ) + if count < 0, errno == EINTR { continue } + guard count > 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "write resolved-plan temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + written += count + } + } + guard fsync(descriptor) == 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "fsync resolved-plan temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + if replacing { + guard rename(temporaryPath, path) == 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "publish resolved plan at \(path): \(String(cString: strerror(errno)))" + ) + } + } else { + guard link(temporaryPath, path) == 0 else { + if errno == EEXIST { + throw DoryResolvedMachinePlanRepositoryError.planExists(plan.machineID) + } + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "publish resolved plan at \(path): \(String(cString: strerror(errno)))" + ) + } + guard unlink(temporaryPath) == 0 else { + _ = unlink(path) + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "remove resolved-plan temporary record at \(temporaryPath): " + + String(cString: strerror(errno)) + ) + } + } + shouldRemoveTemporary = false + try Self.fsyncDirectory(directory) + } catch let error as DoryResolvedMachinePlanRepositoryError { + throw error + } catch { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "publish resolved plan at \(path): \(error)" + ) + } + } + + private static func validateCurrentPlan(_ plan: DoryResolvedMachinePlan) throws { + let issues = plan.validate() + guard issues.isEmpty else { + throw DoryResolvedMachinePlanRepositoryError.invalidPlan(issues) + } + } + + private static func validateMachineIdentifier(_ id: String) throws { + guard DoryResolvedMachinePlan.isSafeIdentifier(id) else { + throw DoryResolvedMachinePlanRepositoryError.invalidMachineIdentifier(id) + } + } + + private static func preparePrivateDirectory(_ path: String) throws { + if !pathExists(path) { + do { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "create resolved-plan directory at \(path): \(error)" + ) + } + _ = chmod(path, mode_t(0o700)) + } + var info = stat() + guard lstat(path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == getuid(), + (info.st_mode & 0o077) == 0 else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + } + + private static func secureRead(path: String) throws -> Data { + var before = stat() + guard lstat(path, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == getuid(), + before.st_nlink == 1, + (before.st_mode & 0o077) == 0, + before.st_size > 0, + before.st_size <= maximumRecordBytes else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + defer { _ = close(descriptor) } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + do { + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.read(upToCount: maximumRecordBytes + 1) ?? Data() + guard !data.isEmpty, + data.count <= maximumRecordBytes, + data.count == Int(after.st_size) else { + throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path) + } + return data + } catch let error as DoryResolvedMachinePlanRepositoryError { + throw error + } catch { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "read resolved plan at \(path): \(error)" + ) + } + } + + private static func fsyncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_CLOEXEC) + guard descriptor >= 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "open resolved-plan directory at \(path): \(String(cString: strerror(errno)))" + ) + } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { + throw DoryResolvedMachinePlanRepositoryError.filesystem( + "fsync resolved-plan directory at \(path): \(String(cString: strerror(errno)))" + ) + } + } + + private static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(plan) else { return "" } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSHA256(_ value: String) -> Bool { + DoryResolvedMachinePlan.isSHA256(value) + } + + private static func pathExists(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift new file mode 100644 index 00000000..be75d5cc --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift @@ -0,0 +1,927 @@ +import Darwin +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Resolved machine plan") +struct DoryResolvedMachinePlanTests { + @Test("current exact plan validates and round trips") + func roundTrip() throws { + let plan = supportedPlan() + #expect(plan.validate().isEmpty) + let encoded = try JSONEncoder().encode(plan) + #expect(try JSONDecoder().decode(DoryResolvedMachinePlan.self, from: encoded) == plan) + } + + @Test("golden schema v1 decodes only as a non-runnable migration") + func goldenV1Migration() throws { + let migrated = try JSONDecoder().decode( + DoryResolvedMachinePlan.self, + from: Data(Self.goldenV1Plan.utf8) + ) + #expect(migrated.schemaVersion == DoryResolvedMachinePlan.currentSchemaVersion) + #expect(migrated.sourceSchemaVersion == 1) + #expect(migrated.migrationDisposition == .requiresReplanning) + #expect(migrated.backendRuntimeBuildIdentifier == "raw-runtime-1") + #expect(migrated.components.map(\.componentIdentifier) == ["dory-hv", "renderer"]) + #expect(migrated.validate().contains { $0.code == .legacyPlanRequiresReplanning }) + + let input = DoryResolvedMachinePlanStartRevalidationInput( + machineID: migrated.machineID, + expectedPlanRevision: migrated.planRevision, + currentDefinitionRevision: migrated.definitionRevision, + currentDefinitionSHA256: digest("1"), + runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: migrated) + ) + let result = DoryResolvedMachinePlanStartValidator.revalidate(migrated, against: input) + #expect(!result.mayStart) + #expect(result.issues.contains { $0.code == .storedPlanInvalid }) + } + + @Test("exact fresh evidence authorizes start") + func exactStartEvidence() { + let plan = supportedPlan() + let result = DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: exactInput(for: plan) + ) + #expect(result.mayStart) + #expect(result.issues.isEmpty) + } + + @Test("definition and plan revisions are exact start gates") + func revisionMismatch() { + let plan = supportedPlan() + var input = exactInput(for: plan) + input.expectedPlanRevision += 1 + input.currentDefinitionRevision += 1 + input.currentDefinitionSHA256 = digest("9") + + let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.map(\.code)) + #expect(codes.contains(.planRevisionMismatch)) + #expect(codes.contains(.definitionRevisionMismatch)) + #expect(codes.contains(.definitionDigestMismatch)) + } + + @Test("backend runtime component host and admission evidence mismatches are explicit") + func runtimeEvidenceMismatch() { + let plan = supportedPlan() + var input = exactInput(for: plan) + input.runtimeEvidence.backendRuntimeBuildIdentifier = "raw-runtime-2" + input.runtimeEvidence.components[0].artifactSHA256 = digest("9") + input.runtimeEvidence.hostQualification?.hostOperatingSystemBuild = "26B999" + input.runtimeEvidence.resourceAdmission?.existingMemoryCommitmentBytes += 1 + + let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.map(\.code)) + #expect(codes.contains(.backendRuntimeBuildMismatch)) + #expect(codes.contains(.componentEvidenceMismatch)) + #expect(codes.contains(.hostQualificationMismatch)) + #expect(codes.contains(.resourceAdmissionMismatch)) + } + + @Test("immutable media and qualification evidence mismatches are exact") + func immutableEvidenceMismatch() { + let plan = supportedPlan() + var input = exactInput(for: plan) + input.runtimeEvidence.bootMedia.media.artifactSHA256 = digest("9") + input.runtimeEvidence.qualificationEvidence.runtime?.qualificationReportSHA256 = digest("8") + + let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.map(\.code)) + #expect(codes.contains(.bootMediaEvidenceMismatch)) + #expect(codes.contains(.qualificationEvidenceMismatch)) + } + + @Test("mutable disk provenance revision mismatch rejects start") + func mutableProvenanceMismatch() { + let plan = mutableVZPlan() + #expect(plan.validate().isEmpty) + var input = exactInput(for: plan) + input.runtimeEvidence.bootMedia.media.mutableProvenance?.revision += 1 + + let result = DoryResolvedMachinePlanStartValidator.revalidate(plan, against: input) + #expect(!result.mayStart) + #expect(result.issues.contains { $0.code == .bootMediaEvidenceMismatch }) + } + + @Test("support tier is revalidated and unsupported plans never validate") + func supportTierSafety() { + let plan = supportedPlan() + var input = exactInput(for: plan) + input.runtimeEvidence.supportTier = .experimental + #expect(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.contains { $0.code == .supportTierMismatch }) + + var unsupported = plan + unsupported.supportTier = .unsupported + #expect(unsupported.validate().contains { $0.code == .unsupportedSupportTier }) + #expect(!DoryResolvedMachinePlanStartValidator.revalidate( + unsupported, + against: exactInput(for: unsupported) + ).mayStart) + } + + @Test("experimental plan requires exact persisted authorization") + func experimentalAuthorization() { + var plan = supportedPlan() + plan.supportTier = .experimental + plan.qualificationEvidence.runtime = nil + plan.selectionEvidence?.plannerRequest.allowsExperimentalBackends = true + #expect(plan.validate().contains { $0.code == .missingExperimentalAuthorization }) + + plan.experimentalAuthorization = DoryResolvedExperimentalSupportAuthorization( + authorizationIdentity: "experimental-consent-1", + definitionRevision: plan.definitionRevision, + backend: plan.backend, + authorizedAtUnixMilliseconds: plan.createdAtUnixMilliseconds + ) + #expect(plan.validate().isEmpty) + var input = exactInput(for: plan) + input.runtimeEvidence.experimentalAuthorization?.authorizationIdentity = "different-consent" + #expect(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.contains { $0.code == .experimentalAuthorizationMismatch }) + } + + @Test("selected capability construction rejects descriptor and availability substitution") + func selectedCapabilityConstruction() { + let plan = supportedPlan() + let capability = capabilityDescriptor(from: plan, availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + )) + let plannerRequest = backendPlannerRequest(from: plan) + let plannerResult = DoryVirtualMachineBackendPlanResult( + selectedDescriptor: capability, + evaluatedDescriptors: [capability], + failure: nil + ) + + #expect(throws: DoryResolvedMachinePlanConstructionError.backendDescriptorMismatch) { + _ = try DoryResolvedMachinePlan( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + definitionSHA256: plan.definitionSHA256!, + planRevision: 1, + createdAtUnixMilliseconds: plan.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: plan.updatedAtUnixMilliseconds, + backendDescriptor: VirtualizationFrameworkLinuxMachineBackend.backendDescriptor, + backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier, + resolverReference: plan.bootMedia.resolverReference, + components: plan.components, + resourceAdmission: plan.resourceAdmission!, + hostQualification: plan.hostQualification!, + plannerRequest: plannerRequest, + plannerResult: plannerResult + ) + } + + let unavailable = capabilityDescriptor(from: plan, availability: DoryCapabilityAvailability( + supportTier: .unsupported, + state: .unavailable + )) + let unavailableResult = DoryVirtualMachineBackendPlanResult( + selectedDescriptor: unavailable, + evaluatedDescriptors: [unavailable], + failure: nil + ) + #expect(throws: DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid) { + _ = try DoryResolvedMachinePlan( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + definitionSHA256: plan.definitionSHA256!, + planRevision: 1, + createdAtUnixMilliseconds: plan.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: plan.updatedAtUnixMilliseconds, + backendDescriptor: RawHVLinuxMachineBackend.backendDescriptor, + backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier, + resolverReference: plan.bootMedia.resolverReference, + components: plan.components, + resourceAdmission: plan.resourceAdmission!, + hostQualification: plan.hostQualification!, + plannerRequest: plannerRequest, + plannerResult: unavailableResult + ) + } + } + + @Test("internal evidence bindings reject mismatched host runtime and resource overcommit") + func structuralEvidenceValidation() { + var hostMismatch = supportedPlan() + hostMismatch.hostQualification?.backendRuntimeBuildIdentifier = "other-runtime" + #expect(hostMismatch.validate().contains { $0.code == .invalidHostQualification }) + + var overcommitted = supportedPlan() + overcommitted.resourceAdmission?.existingMemoryCommitmentBytes = UInt64.max + #expect(overcommitted.validate().contains { $0.code == .invalidResourceAdmission }) + + var qualificationMismatch = supportedPlan() + qualificationMismatch.qualificationEvidence.runtime?.backendRuntimeBuildID = "other-runtime" + #expect(qualificationMismatch.validate().contains { $0.code == .runtimeQualificationMismatch }) + } + + @Test("preferred unavailable candidate persists an approved named fallback") + func approvedFallback() throws { + let plan = try fallbackVZPlan(policy: .preferred, includeAuthorization: true) + let selection = try #require(plan.selectionEvidence) + #expect(selection.disposition == .approvedFallback) + #expect(selection.selectedEvaluationIndex == 1) + #expect(selection.rejectedCandidates.count == 1) + #expect(selection.rejectedCandidates[0].backend == .doryHypervisor) + #expect(selection.rejectedCandidates[0].availability.reason?.code + == .bootMediaDoesNotSupportBackend) + #expect(selection.fallbackAuthorization?.fromBackend == .doryHypervisor) + #expect(selection.fallbackAuthorization?.toBackend == .appleVirtualizationFramework) + #expect(plan.validate().isEmpty) + #expect(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: exactInput(for: plan) + ).mayStart) + + let roundTrip = try JSONDecoder().decode( + DoryResolvedMachinePlan.self, + from: JSONEncoder().encode(plan) + ) + #expect(roundTrip.selectionEvidence == selection) + } + + @Test("preferred fallback requires approval and required alternatives do not") + func fallbackPolicySafety() throws { + #expect(throws: DoryResolvedMachinePlanConstructionError.fallbackAuthorizationRequired) { + _ = try fallbackVZPlan(policy: .preferred, includeAuthorization: false) + } + + let requiredAlternative = try fallbackVZPlan( + policy: .required, + includeAuthorization: false + ) + #expect(requiredAlternative.selectionEvidence?.disposition == .explicitAlternative) + #expect(requiredAlternative.selectionEvidence?.fallbackAuthorization == nil) + #expect(requiredAlternative.validate().isEmpty) + + #expect(throws: DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid) { + _ = try fallbackVZPlan(policy: .required, includeAuthorization: true) + } + } + + @Test("required policy permits an explicitly listed lower graphics contract") + func requiredGraphicsAlternative() throws { + var plan = supportedPlan() + let request = DoryVirtualMachineBackendPlanRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + acceptableGraphics: [.hardwareAccelerated3D, .hostAcceleratedDisplay], + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ) + let rejected = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + backend: .doryHypervisor, + graphics: .hardwareAccelerated3D, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ), + availability: DoryCapabilityAvailability( + supportTier: .unsupported, + state: .unavailable, + reason: DoryCapabilityReason( + code: .acceleratedRendererUnavailable, + message: "The requested renderer is unavailable." + ) + ) + ) + let selected = capabilityDescriptor( + from: plan, + availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + ) + ) + plan.selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving( + request: request, + result: DoryVirtualMachineBackendPlanResult( + selectedDescriptor: selected, + evaluatedDescriptors: [rejected, selected], + failure: nil + ), + definitionRevision: plan.definitionRevision + ) + + #expect(plan.selectionEvidence?.disposition == .explicitAlternative) + #expect(plan.selectionEvidence?.selectedEvaluationIndex == 1) + #expect(plan.validate().isEmpty) + } + + @Test("required policy rejects a backend the request did not list") + func requiredImplicitBackendRejection() { + let plan = mutableVZPlan() + let request = DoryVirtualMachineBackendPlanRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + acceptableGraphics: [plan.graphics], + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ) + let selected = capabilityDescriptor( + from: plan, + availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + ) + ) + let result = DoryVirtualMachineBackendPlanResult( + selectedDescriptor: selected, + evaluatedDescriptors: [selected], + failure: nil + ) + + #expect(throws: DoryResolvedMachinePlanConstructionError.plannerResultInvalid) { + _ = try DoryResolvedMachineBackendSelectionEvidence.resolving( + request: request, + result: result, + definitionRevision: plan.definitionRevision + ) + } + } + + @Test("missing or changed fallback evidence rejects start") + func fallbackTamperSafety() throws { + let plan = try fallbackVZPlan(policy: .preferred, includeAuthorization: true) + var missing = plan + missing.selectionEvidence?.fallbackAuthorization = nil + #expect(missing.validate().contains { $0.code == .missingFallbackAuthorization }) + #expect(!DoryResolvedMachinePlanStartValidator.revalidate( + missing, + against: exactInput(for: missing) + ).mayStart) + + var input = exactInput(for: plan) + input.runtimeEvidence.selectionEvidence?.fallbackAuthorization?.authorizationIdentity + = "different-fallback-consent" + #expect(DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: input + ).issues.contains { $0.code == .selectionEvidenceMismatch }) + } + + private func exactInput( + for plan: DoryResolvedMachinePlan + ) -> DoryResolvedMachinePlanStartRevalidationInput { + DoryResolvedMachinePlanStartRevalidationInput( + machineID: plan.machineID, + expectedPlanRevision: plan.planRevision, + currentDefinitionRevision: plan.definitionRevision, + currentDefinitionSHA256: plan.definitionSHA256 ?? "", + runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: plan) + ) + } + + private func capabilityDescriptor( + from plan: DoryResolvedMachinePlan, + availability: DoryCapabilityAvailability + ) -> DoryVirtualMachineCapabilityDescriptor { + DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + backend: plan.backend, + graphics: plan.graphics, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ), + availability: availability, + resolvedDevices: availability.isUsable ? plan.devices : nil, + graphicsQualificationEvidence: plan.qualificationEvidence.graphics, + bootMediaInspectionEvidence: plan.bootMedia.inspectionEvidence, + mutableBootMediaProvenanceEvidence: plan.bootMedia.mutableProvenanceEvidence, + runtimeQualificationEvidence: plan.qualificationEvidence.runtime + ) + } + + private func fallbackVZPlan( + policy: DoryVirtualMachineBackendPreferencePolicy, + includeAuthorization: Bool + ) throws -> DoryResolvedMachinePlan { + var plan = mutableVZPlan() + let request = DoryVirtualMachineBackendPlanRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + acceptableGraphics: [plan.graphics], + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion, + backendPreferences: [.doryHypervisor, .appleVirtualizationFramework], + backendPreferencePolicy: policy + ) + let rejectedRequest = DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + backend: .doryHypervisor, + graphics: plan.graphics, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ) + let rejected = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: rejectedRequest, + availability: DoryCapabilityAvailability( + supportTier: .unsupported, + state: .unavailable, + reason: DoryCapabilityReason( + code: .bootMediaDoesNotSupportBackend, + message: "The preferred backend cannot boot this media." + ) + ) + ) + let selected = capabilityDescriptor( + from: plan, + availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + ) + ) + let result = DoryVirtualMachineBackendPlanResult( + selectedDescriptor: selected, + evaluatedDescriptors: [rejected, selected], + failure: nil + ) + let authorization = includeAuthorization + ? DoryResolvedMachineFallbackAuthorization( + authorizationIdentity: "fallback-consent-1", + definitionRevision: plan.definitionRevision, + fromBackend: .doryHypervisor, + fromGraphics: plan.graphics, + toBackend: .appleVirtualizationFramework, + toGraphics: plan.graphics, + authorizedAtUnixMilliseconds: plan.createdAtUnixMilliseconds + ) + : nil + plan.selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving( + request: request, + result: result, + definitionRevision: plan.definitionRevision, + fallbackAuthorization: authorization + ) + return plan + } + + fileprivate static let goldenV1Plan = """ + { + "schemaVersion": 1, + "machineID": "workspace-one", + "definitionRevision": 3, + "planRevision": 4, + "createdAtUnixMilliseconds": 1700000000000, + "updatedAtUnixMilliseconds": 1700000000100, + "guest": {"family": "linux", "architecture": "arm64"}, + "backend": "dory-hypervisor", + "backendRuntimeBuildID": "raw-runtime-1", + "virtualHardwareABIVersion": 1, + "bootMedia": { + "kind": "installed-linux-boot-bundle", + "source": "dory-bundled", + "artifactSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "componentDigests": { + "renderer": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "dory-hv": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "devices": { + "networkAttachment": "shared-nat", + "audioInput": false, + "audioOutput": false, + "keyboard": false, + "pointer": false, + "directorySharing": false, + "clipboard": false, + "clockSynchronization": false, + "dynamicDisplay": false, + "gracefulShutdown": false + }, + "graphics": "host-accelerated-display" + } + """ +} + +@Suite("Resolved machine plan repository") +struct DoryResolvedMachinePlanRepositoryTests { + @Test("create read replace uses owner-only crash-safe optimistic revisions") + func optimisticLifecycle() throws { + try withRepository { repository, root in + let initial = supportedPlan() + try repository.create(initial) + #expect(try repository.read(id: initial.machineID) == initial) + + let recordPath = root + "/" + initial.machineID + "/" + + DoryResolvedMachinePlanRepository.recordFileName + var info = stat() + #expect(lstat(recordPath, &info) == 0) + #expect((info.st_mode & 0o777) == 0o600) + #expect(info.st_nlink == 1) + + var replacement = initial + replacement.planRevision = 2 + replacement.updatedAtUnixMilliseconds += 1 + try repository.replace(replacement, expectedPlanRevision: 1) + #expect(try repository.read(id: initial.machineID) == replacement) + #expect(throws: DoryResolvedMachinePlanRepositoryError.stalePlanRevision( + expected: 1, + actual: 2 + )) { + try repository.replace(replacement, expectedPlanRevision: 1) + } + + var skipped = replacement + skipped.planRevision = 4 + #expect(throws: DoryResolvedMachinePlanRepositoryError.invalidPlanRevision( + expected: 3, + actual: 4 + )) { + try repository.replace(skipped, expectedPlanRevision: 2) + } + } + } + + @Test("record integrity digest rejects edited plan bytes") + func tamperedRecord() throws { + try withRepository { repository, root in + let plan = supportedPlan() + try repository.create(plan) + let path = root + "/" + plan.machineID + "/" + + DoryResolvedMachinePlanRepository.recordFileName + var text = try String(contentsOfFile: path, encoding: .utf8) + text = text.replacingOccurrences(of: "raw-runtime-1", with: "raw-runtime-2") + try Data(text.utf8).write(to: URL(fileURLWithPath: path)) + _ = chmod(path, mode_t(0o600)) + #expect(throws: DoryResolvedMachinePlanRepositoryError.invalidRecord(path)) { + _ = try repository.read(id: plan.machineID) + } + } + } + + @Test("schema v1 repository record remains readable but cannot authorize launch") + func repositoryV1Migration() throws { + try withRepository { repository, root in + let machineID = "workspace-one" + let directory = root + "/" + machineID + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let record = "{\"schemaVersion\":1,\"plan\":\(DoryResolvedMachinePlanTests.goldenV1Plan)}" + let path = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName + #expect(FileManager.default.createFile( + atPath: path, + contents: Data(record.utf8), + attributes: [.posixPermissions: 0o600] + )) + + let migrated = try repository.read(id: machineID) + #expect(migrated.migrationDisposition == .requiresReplanning) + #expect(!DoryResolvedMachinePlanStartValidator.revalidate( + migrated, + against: DoryResolvedMachinePlanStartRevalidationInput( + machineID: machineID, + expectedPlanRevision: migrated.planRevision, + currentDefinitionRevision: migrated.definitionRevision, + currentDefinitionSHA256: digest("1"), + runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: migrated) + ) + ).mayStart) + } + } + + @Test("repository rejects symlink hard-link public and oversized records") + func hostileRecords() throws { + try withRepository { repository, root in + let plan = supportedPlan() + try repository.create(plan) + let directory = root + "/" + plan.machineID + let record = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName + let saved = directory + "/saved" + try FileManager.default.moveItem(atPath: record, toPath: saved) + + #expect(symlink(saved, record) == 0) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try repository.read(id: plan.machineID) + } + #expect(unlink(record) == 0) + + #expect(link(saved, record) == 0) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try repository.read(id: plan.machineID) + } + #expect(unlink(record) == 0) + + try FileManager.default.copyItem(atPath: saved, toPath: record) + _ = chmod(record, mode_t(0o644)) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try repository.read(id: plan.machineID) + } + #expect(unlink(record) == 0) + + #expect(FileManager.default.createFile(atPath: record, contents: Data([0]))) + let descriptor = open(record, O_WRONLY | O_CLOEXEC) + #expect(descriptor >= 0) + #expect(ftruncate(descriptor, off_t(5 * 1_024 * 1_024)) == 0) + _ = close(descriptor) + _ = chmod(record, mode_t(0o600)) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try repository.read(id: plan.machineID) + } + } + } + + @Test("invalid and experimental-without-authorization plans are never published") + func publicationValidation() throws { + try withRepository { repository, _ in + var unsupported = supportedPlan() + unsupported.supportTier = .unsupported + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + try repository.create(unsupported) + } + + var experimental = supportedPlan() + experimental.supportTier = .experimental + experimental.qualificationEvidence.runtime = nil + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + try repository.create(experimental) + } + } + } + + private func withRepository( + _ body: (DoryResolvedMachinePlanRepository, String) throws -> Void + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-resolved-plan-\(UUID().uuidString)") + .path + defer { try? FileManager.default.removeItem(atPath: root) } + try body(DoryResolvedMachinePlanRepository(root: root), root) + } +} + +private func supportedPlan() -> DoryResolvedMachinePlan { + let artifact = digest("a") + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: artifact + ) + return DoryResolvedMachinePlan( + machineID: "workspace-one", + definitionRevision: 3, + definitionSHA256: digest("1"), + planRevision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000, + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + backend: .doryHypervisor, + backendImplementationIdentifier: "dory.raw-hv-linux.compatibility.v1", + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: DoryVMResolverReference( + namespace: "artifact", + identifier: "ubuntu-desktop-1" + ), + media: media + ), + components: [ + DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: "raw-runtime-1", + artifactSHA256: digest("d") + ), + DoryResolvedBackendComponentEvidence( + componentIdentifier: "renderer", + buildIdentifier: "renderer-1", + artifactSHA256: digest("e") + ), + ], + devices: devices, + graphics: .hostAcceleratedDisplay, + supportTier: .supported, + selectionEvidence: primarySelectionEvidence( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + media: media, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + devices: devices + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: DorySignedArtifactQualificationEvidence( + manifestIdentity: "ubuntu-graphics-1", + artifactSHA256: artifact, + manifestSHA256: digest("b"), + signingKeyID: "dory-release-1", + manifestFormatVersion: 1 + ), + runtime: runtimeQualification( + media: media, + backend: .doryHypervisor, + runtimeBuild: "raw-runtime-1", + graphics: .hostAcceleratedDisplay, + devices: devices + ) + ), + resourceAdmission: resourceAdmission(), + hostQualification: hostQualification( + backend: .doryHypervisor, + runtimeBuild: "raw-runtime-1" + ) + ) +} + +private func mutableVZPlan() -> DoryResolvedMachinePlan { + let provenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "machine-store", + mediaIdentity: "workspace-one-disk", + revision: 7 + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let media = DoryBootMedia( + kind: .virtualDisk, + source: .userProvided, + mutableProvenance: provenance + ) + return DoryResolvedMachinePlan( + machineID: "workspace-one", + definitionRevision: 3, + definitionSHA256: digest("1"), + planRevision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000, + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + backend: .appleVirtualizationFramework, + backendImplementationIdentifier: "dory.vz-linux.compatibility.v1", + backendRuntimeBuildIdentifier: "vz-runtime-1", + virtualHardwareABIVersion: 1, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: DoryVMResolverReference( + namespace: "machine", + identifier: "workspace-one-disk" + ), + media: media, + mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "disk-receipt-7", + provenance: provenance, + receiptSHA256: digest("7"), + resolverID: "machine-store", + resolverVersion: 1 + ) + ), + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-vmm", + buildIdentifier: "vz-runtime-1", + artifactSHA256: digest("d") + )], + devices: devices, + graphics: .software, + supportTier: .supported, + selectionEvidence: primarySelectionEvidence( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + media: media, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: devices + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + runtime: runtimeQualification( + media: media, + backend: .appleVirtualizationFramework, + runtimeBuild: "vz-runtime-1", + graphics: .software, + devices: devices + ) + ), + resourceAdmission: resourceAdmission(), + hostQualification: hostQualification( + backend: .appleVirtualizationFramework, + runtimeBuild: "vz-runtime-1" + ) + ) +} + +private func runtimeQualification( + media: DoryBootMedia, + backend: DoryVirtualizationBackendIdentity, + runtimeBuild: String, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest +) -> DoryVirtualMachineRuntimeQualificationEvidence { + DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: digest("c"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMediaKind: media.kind, + immutableArtifactSHA256: media.artifactSHA256, + mutableProvenance: media.mutableProvenance, + backend: backend, + backendRuntimeBuildID: runtimeBuild, + virtualHardwareABIVersion: 1, + graphics: graphics, + devices: devices + ) +} + +private func primarySelectionEvidence( + guest: DoryGuestPlatform, + media: DoryBootMedia, + backend: DoryVirtualizationBackendIdentity, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest +) -> DoryResolvedMachineBackendSelectionEvidence { + DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: DoryVirtualMachineBackendPlanRequest( + guest: guest, + bootMedia: media, + acceptableGraphics: [graphics], + devices: devices, + backendPreferences: [backend], + backendPreferencePolicy: .required + ), + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ) +} + +private func backendPlannerRequest( + from plan: DoryResolvedMachinePlan +) -> DoryVirtualMachineBackendPlanRequest { + plan.selectionEvidence?.plannerRequest ?? DoryVirtualMachineBackendPlanRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + acceptableGraphics: [plan.graphics], + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion, + backendPreferences: [plan.backend], + backendPreferencePolicy: .required + ) +} + +private func resourceAdmission() -> DoryResolvedMachineResourceAdmissionEvidence { + DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: 4, + admittedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + admittedStorageBytes: 64 * 1_024 * 1_024 * 1_024, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024, + existingVirtualCPUCommitment: 2, + existingMemoryCommitmentBytes: 4 * 1_024 * 1_024 * 1_024, + existingStorageReservationBytes: 32 * 1_024 * 1_024 * 1_024, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024, + admissionIdentity: "resource-admission-1", + admissionReportSHA256: digest("f"), + assessorIdentifier: "dory-resource-policy", + assessorVersion: 1 + ) +} + +private func hostQualification( + backend: DoryVirtualizationBackendIdentity, + runtimeBuild: String +) -> DoryResolvedHostQualificationEvidence { + DoryResolvedHostQualificationEvidence( + qualificationIdentity: "host-qualification-1", + qualificationReportSHA256: digest("6"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: backend, + backendRuntimeBuildIdentifier: runtimeBuild, + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) +} + +private func digest(_ character: Character) -> String { + String(repeating: String(character), count: 64) +} From e80d40a124e972ec8945173bb9a4ed98059ec0ac Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 12:38:06 +0000 Subject: [PATCH 016/338] feat(vm): add daemon runtime planning and revalidation --- ...emonVirtualMachineLaunchPlanResolver.swift | 371 ++++++++++++ ...yDaemonVirtualMachineRuntimePlanning.swift | 499 ++++++++++++++++ ...onVirtualMachineRuntimePlanningTests.swift | 531 ++++++++++++++++++ 3 files changed, 1401 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift new file mode 100644 index 00000000..e24ba4f2 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift @@ -0,0 +1,371 @@ +import DoryOperations +import Foundation + +public protocol DoryDaemonVirtualMachineExactCapabilityEvaluating: Sendable { + func evaluate( + _ request: DoryVirtualMachineCapabilityRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineCapabilityDescriptor +} + +/// Re-evaluates one exact persisted choice. It does not invoke backend selection and cannot +/// downgrade graphics, devices, or backend identity during start. +public struct DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator: + DoryDaemonVirtualMachineExactCapabilityEvaluating +{ + public init() {} + + public func evaluate( + _ request: DoryVirtualMachineCapabilityRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineCapabilityDescriptor { + DoryAppleSiliconCapabilityEvaluator.evaluate( + request, + host: inventory.hostFacts, + trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification, + trustedBootMediaInspection: inventory.media.bootInspection, + trustedMutableBootMediaProvenance: inventory.media.mutableProvenance, + trustedRuntimeQualification: inventory.exactStartRuntimeQualification + ) + } +} + +public struct DoryDaemonVirtualMachineStartEvidenceCollection: Sendable, Equatable { + public var capability: DoryVirtualMachineCapabilityDescriptor + public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence + + public init( + capability: DoryVirtualMachineCapabilityDescriptor, + runtimeEvidence: DoryResolvedMachineRuntimeEvidence + ) { + self.capability = capability + self.runtimeEvidence = runtimeEvidence + } +} + +public protocol DoryDaemonVirtualMachineStartEvidenceCollecting: Sendable { + func collectFreshEvidence( + for plan: DoryResolvedMachinePlan + ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection +} + +public enum DoryDaemonVirtualMachineStartEvidenceFailureCode: String, Sendable, Equatable { + case mediaReferenceUnavailable = "media-reference-unavailable" + case inventoryUnavailable = "inventory-unavailable" + case mediaInventoryMismatch = "media-inventory-mismatch" + case backendUnavailable = "backend-unavailable" + case backendInventoryUnavailable = "backend-inventory-unavailable" + case exactCapabilityUnavailable = "exact-capability-unavailable" +} + +public struct DoryDaemonVirtualMachineStartEvidenceFailure: Error, Sendable, Equatable { + public var code: DoryDaemonVirtualMachineStartEvidenceFailureCode + public var message: String + + public init(code: DoryDaemonVirtualMachineStartEvidenceFailureCode, message: String) { + self.code = code + self.message = message + } +} + +/// Production evidence collector. Every volatile field is rebuilt from a fresh daemon inventory; +/// persisted audit references are never promoted back into trusted decisions. +public final class DoryDaemonVirtualMachineStartEvidenceCollector: + DoryDaemonVirtualMachineStartEvidenceCollecting, + @unchecked Sendable +{ + private let registry: BackendRegistry + private let inventory: any DoryDaemonVirtualMachineTrustInventory + private let evaluator: any DoryDaemonVirtualMachineExactCapabilityEvaluating + + public init( + registry: BackendRegistry, + inventory: any DoryDaemonVirtualMachineTrustInventory, + evaluator: any DoryDaemonVirtualMachineExactCapabilityEvaluating = + DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator() + ) { + self.registry = registry + self.inventory = inventory + self.evaluator = evaluator + } + + public func collectFreshEvidence( + for plan: DoryResolvedMachinePlan + ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection { + guard let reference = plan.bootMedia.resolverReference else { + throw failure(.mediaReferenceUnavailable, "The persisted plan has no media resolver reference.") + } + let persistedRequest = DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + backend: plan.backend, + graphics: plan.graphics, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ) + let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + do { + snapshot = try inventory.startInventory(for: DoryDaemonVirtualMachineStartInventoryRequest( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + planRevision: plan.planRevision, + bootMediaReference: reference, + exactCapabilityRequest: persistedRequest + )) + } catch { + throw failure(.inventoryUnavailable, "Fresh trusted start inventory could not be resolved.") + } + guard snapshot.media.reference == reference else { + throw failure(.mediaInventoryMismatch, "Fresh media resolved under a different identity.") + } + guard let backend = registry.backend(for: plan.backend), + backend.descriptor.identity == plan.backend else { + throw failure(.backendUnavailable, "The persisted backend is not registered.") + } + guard let runtime = snapshot.backendRuntime(for: plan.backend) else { + throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.") + } + let exactRequest = DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: snapshot.media.media, + backend: plan.backend, + graphics: plan.graphics, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ) + let capability = evaluator.evaluate(exactRequest, inventory: snapshot) + guard capability.schemaVersion + == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion, + capability.evaluatorVersion + == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + capability.request == exactRequest, + capability.availability.isUsable, + capability.resolvedDevices == exactRequest.devices else { + throw failure(.exactCapabilityUnavailable, "Fresh evidence does not authorize the exact capability.") + } + let runtimeEvidence = DoryResolvedMachineRuntimeEvidence( + guest: exactRequest.guest, + backend: exactRequest.backend, + backendImplementationIdentifier: backend.descriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, + virtualHardwareABIVersion: exactRequest.virtualHardwareABIVersion, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: snapshot.media.reference, + media: exactRequest.bootMedia, + inspectionEvidence: capability.bootMediaInspectionEvidence, + mutableProvenanceEvidence: capability.mutableBootMediaProvenanceEvidence + ), + components: runtime.components.sorted { + $0.componentIdentifier < $1.componentIdentifier + }, + devices: exactRequest.devices, + graphics: exactRequest.graphics, + supportTier: capability.availability.supportTier, + selectionEvidence: plan.selectionEvidence, + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: capability.graphicsQualificationEvidence, + runtime: capability.runtimeQualificationEvidence + ), + resourceAdmission: snapshot.resourceAdmission, + hostQualification: runtime.hostQualification, + experimentalAuthorization: plan.experimentalAuthorization + ) + return DoryDaemonVirtualMachineStartEvidenceCollection( + capability: capability, + runtimeEvidence: runtimeEvidence + ) + } + + private func failure( + _ code: DoryDaemonVirtualMachineStartEvidenceFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachineStartEvidenceFailure { + DoryDaemonVirtualMachineStartEvidenceFailure(code: code, message: message) + } +} + +public struct DoryDaemonVirtualMachineLaunchPlanRequest: Sendable { + public var definition: DoryVirtualMachineDefinition + /// Fresh canonical sorted-key encoding of `definition`. + public var canonicalDefinitionData: Data + public var machine: DoryMachineConfiguration + public var expectedPlanRevision: UInt64 + + public init( + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data, + machine: DoryMachineConfiguration, + expectedPlanRevision: UInt64 + ) { + self.definition = definition + self.canonicalDefinitionData = canonicalDefinitionData + self.machine = machine + self.expectedPlanRevision = expectedPlanRevision + } +} + +/// Non-Codable by design: compatibility configuration may contain paths or environment secrets. +public struct DoryDaemonVirtualMachineLaunchPlanResolution: Sendable { + public var resolvedPlan: DoryResolvedMachinePlan + public var resolvedPlanSHA256: String + public var revalidation: DoryResolvedMachinePlanRevalidationResult + public var backendPlan: MachineBackendPlan + + public init( + resolvedPlan: DoryResolvedMachinePlan, + resolvedPlanSHA256: String, + revalidation: DoryResolvedMachinePlanRevalidationResult, + backendPlan: MachineBackendPlan + ) { + self.resolvedPlan = resolvedPlan + self.resolvedPlanSHA256 = resolvedPlanSHA256 + self.revalidation = revalidation + self.backendPlan = backendPlan + } +} + +public enum DoryDaemonVirtualMachineLaunchPlanFailureCode: String, Sendable, Equatable { + case invalidDefinition = "invalid-definition" + case emptyDefinitionAuthority = "empty-definition-authority" + case definitionAuthorityMismatch = "definition-authority-mismatch" + case machineIdentityMismatch = "machine-identity-mismatch" + case planNotFound = "plan-not-found" + case planRepositoryRejected = "plan-repository-rejected" + case freshEvidenceUnavailable = "fresh-evidence-unavailable" + case staleOrMismatchedPlan = "stale-or-mismatched-plan" + case backendPlanRejected = "backend-plan-rejected" + case backendPlanIdentityMismatch = "backend-plan-identity-mismatch" +} + +public struct DoryDaemonVirtualMachineLaunchPlanFailure: Error, Sendable, Equatable { + public var code: DoryDaemonVirtualMachineLaunchPlanFailureCode + public var message: String + public var revalidationIssues: [DoryResolvedMachinePlanRevalidationIssue] + + public init( + code: DoryDaemonVirtualMachineLaunchPlanFailureCode, + message: String, + revalidationIssues: [DoryResolvedMachinePlanRevalidationIssue] = [] + ) { + self.code = code + self.message = message + self.revalidationIssues = revalidationIssues + } +} + +public protocol DoryDaemonVirtualMachineLaunchPlanResolving: Sendable { + func resolve( + _ request: DoryDaemonVirtualMachineLaunchPlanRequest + ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution +} + +/// Reads and revalidates a previously selected plan. It deliberately has no capability planner, +/// fallback authorization, or plan-writing dependency, so start cannot silently re-plan. +public final class DoryDaemonVirtualMachineLaunchPlanResolver: + DoryDaemonVirtualMachineLaunchPlanResolving, + @unchecked Sendable +{ + private let registry: BackendRegistry + private let plans: any DoryResolvedMachinePlanStoring + private let evidenceCollector: any DoryDaemonVirtualMachineStartEvidenceCollecting + + public init( + registry: BackendRegistry, + plans: any DoryResolvedMachinePlanStoring, + evidenceCollector: any DoryDaemonVirtualMachineStartEvidenceCollecting + ) { + self.registry = registry + self.plans = plans + self.evidenceCollector = evidenceCollector + } + + public func resolve( + _ request: DoryDaemonVirtualMachineLaunchPlanRequest + ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution { + guard request.definition.validate().isEmpty else { + throw failure(.invalidDefinition, "Workspace definition validation failed.") + } + guard !request.canonicalDefinitionData.isEmpty else { + throw failure(.emptyDefinitionAuthority, "Fresh definition authority bytes are required.") + } + guard request.canonicalDefinitionData + == DoryDaemonVirtualMachinePlanningCoordinator.canonicalDefinitionData( + request.definition + ) else { + throw failure( + .definitionAuthorityMismatch, + "Definition bytes are not the canonical encoding of the supplied workspace." + ) + } + guard request.machine.id == request.definition.identity.id else { + throw failure(.machineIdentityMismatch, "Legacy machine and workspace identities differ.") + } + let plan: DoryResolvedMachinePlan + do { + plan = try plans.read(id: request.definition.identity.id) + } catch let error as DoryResolvedMachinePlanRepositoryError { + if case .planNotFound = error { + throw failure(.planNotFound, "No durable resolved plan exists for this machine.") + } + throw failure(.planRepositoryRejected, "The durable plan record failed repository validation.") + } catch { + throw failure(.planRepositoryRejected, "The durable plan record could not be read.") + } + let fresh: DoryDaemonVirtualMachineStartEvidenceCollection + do { fresh = try evidenceCollector.collectFreshEvidence(for: plan) } + catch { + throw failure(.freshEvidenceUnavailable, "Fresh trusted runtime evidence is unavailable.") + } + let revalidation = DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: DoryResolvedMachinePlanStartRevalidationInput( + machineID: request.definition.identity.id, + expectedPlanRevision: request.expectedPlanRevision, + currentDefinitionRevision: request.definition.lifecycle.revision, + currentDefinitionSHA256: DoryDaemonVirtualMachinePlanningCoordinator.sha256( + request.canonicalDefinitionData + ), + runtimeEvidence: fresh.runtimeEvidence + ) + ) + guard revalidation.mayStart else { + throw DoryDaemonVirtualMachineLaunchPlanFailure( + code: .staleOrMismatchedPlan, + message: "The persisted plan no longer matches current daemon evidence.", + revalidationIssues: revalidation.issues + ) + } + let capabilityPlan = DoryVirtualMachineBackendPlanResult( + selectedDescriptor: fresh.capability, + evaluatedDescriptors: [fresh.capability], + failure: nil + ) + let mapped = registry.plan(MachineBackendPlanRequest( + machine: request.machine, + capabilityPlan: capabilityPlan + )) + guard let backendPlan = mapped.plan, mapped.failure == nil else { + throw failure(.backendPlanRejected, "The exact backend adapter rejected the launch plan.") + } + guard backendPlan.backend.identity == plan.backend, + backendPlan.backend.implementationIdentifier + == plan.backendImplementationIdentifier, + backendPlan.capability == fresh.capability, + backendPlan.machine.id == plan.machineID else { + throw failure(.backendPlanIdentityMismatch, "The adapter plan differs from the durable selection.") + } + return DoryDaemonVirtualMachineLaunchPlanResolution( + resolvedPlan: plan, + resolvedPlanSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan), + revalidation: revalidation, + backendPlan: backendPlan + ) + } + + private func failure( + _ code: DoryDaemonVirtualMachineLaunchPlanFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachineLaunchPlanFailure { + DoryDaemonVirtualMachineLaunchPlanFailure(code: code, message: message) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift new file mode 100644 index 00000000..5791f382 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -0,0 +1,499 @@ +import CryptoKit +import DoryOperations +import Foundation + +/// Inventory output stays daemon-local and non-Codable. The opaque trusted records can only be +/// created by DoryOperations resolvers, so API/UI intent cannot manufacture qualification facts. +public struct DoryDaemonVirtualMachineResolvedMedia: Sendable { + public var reference: DoryVMResolverReference + public var media: DoryBootMedia + public var guestGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? + public var bootInspection: DoryTrustedBootMediaInspection? + public var mutableProvenance: DoryTrustedMutableBootMediaProvenance? + + public init( + reference: DoryVMResolverReference, + media: DoryBootMedia, + guestGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, + bootInspection: DoryTrustedBootMediaInspection? = nil, + mutableProvenance: DoryTrustedMutableBootMediaProvenance? = nil + ) { + self.reference = reference + self.media = media + self.guestGraphicsQualification = guestGraphicsQualification + self.bootInspection = bootInspection + self.mutableProvenance = mutableProvenance + } +} + +public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatable { + public var backend: DoryVirtualizationBackendIdentity + public var runtimeBuildIdentifier: String + public var components: [DoryResolvedBackendComponentEvidence] + public var hostQualification: DoryResolvedHostQualificationEvidence + + public init( + backend: DoryVirtualizationBackendIdentity, + runtimeBuildIdentifier: String, + components: [DoryResolvedBackendComponentEvidence], + hostQualification: DoryResolvedHostQualificationEvidence + ) { + self.backend = backend + self.runtimeBuildIdentifier = runtimeBuildIdentifier + self.components = components + self.hostQualification = hostQualification + } +} + +public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { + public var hostFacts: DoryAppleSiliconHostFacts + public var media: DoryDaemonVirtualMachineResolvedMedia + public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory] + public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence + public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] + /// Resolver-selected record for one exact start request. The evaluator still verifies every + /// media, backend-build, ABI, graphics, and device binding before treating it as authority. + public var exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? + + public init( + hostFacts: DoryAppleSiliconHostFacts, + media: DoryDaemonVirtualMachineResolvedMedia, + backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory], + resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, + runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [], + exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil + ) { + self.hostFacts = hostFacts + self.media = media + self.backendRuntimes = backendRuntimes + self.resourceAdmission = resourceAdmission + self.runtimeQualifications = runtimeQualifications + self.exactStartRuntimeQualification = exactStartRuntimeQualification + } + + public func backendRuntime( + for backend: DoryVirtualizationBackendIdentity + ) -> DoryDaemonVirtualMachineBackendRuntimeInventory? { + let matches = backendRuntimes.filter { $0.backend == backend } + return matches.count == 1 ? matches[0] : nil + } +} + +public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable { + public var machineID: String + public var definitionRevision: UInt64 + public var guest: DoryGuestPlatform + public var bootMedia: DoryVMBootMediaReference + public var resources: DoryVMResourceRequest + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var acceptableGraphics: [DoryGraphicsAccelerationLevel] + public var virtualHardwareABIVersion: UInt16 + + public init( + machineID: String, + definitionRevision: UInt64, + guest: DoryGuestPlatform, + bootMedia: DoryVMBootMediaReference, + resources: DoryVMResourceRequest, + devices: DoryVirtualMachineDeviceCapabilityRequest, + acceptableGraphics: [DoryGraphicsAccelerationLevel], + virtualHardwareABIVersion: UInt16 + ) { + self.machineID = machineID + self.definitionRevision = definitionRevision + self.guest = guest + self.bootMedia = bootMedia + self.resources = resources + self.devices = devices + self.acceptableGraphics = acceptableGraphics + self.virtualHardwareABIVersion = virtualHardwareABIVersion + } +} + +public struct DoryDaemonVirtualMachineStartInventoryRequest: Sendable, Equatable { + public var machineID: String + public var definitionRevision: UInt64 + public var planRevision: UInt64 + public var bootMediaReference: DoryVMResolverReference + public var exactCapabilityRequest: DoryVirtualMachineCapabilityRequest + + public init( + machineID: String, + definitionRevision: UInt64, + planRevision: UInt64, + bootMediaReference: DoryVMResolverReference, + exactCapabilityRequest: DoryVirtualMachineCapabilityRequest + ) { + self.machineID = machineID + self.definitionRevision = definitionRevision + self.planRevision = planRevision + self.bootMediaReference = bootMediaReference + self.exactCapabilityRequest = exactCapabilityRequest + } +} + +/// Implemented only by daemon-owned resolvers. Callers provide intent; they never provide trusted +/// booleans or signed-evidence decisions. +public protocol DoryDaemonVirtualMachineTrustInventory: Sendable { + func planningInventory( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot + + func startInventory( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot +} + +public protocol DoryDaemonVirtualMachineCapabilityPlanning: Sendable { + func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineBackendPlanResult +} + +public struct DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner: + DoryDaemonVirtualMachineCapabilityPlanning +{ + public init() {} + + public func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineBackendPlanResult { + DoryAppleSiliconVirtualMachineBackendPlanner.plan( + request, + host: inventory.hostFacts, + trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification, + trustedBootMediaInspection: inventory.media.bootInspection, + trustedMutableBootMediaProvenance: inventory.media.mutableProvenance, + trustedRuntimeQualifications: inventory.runtimeQualifications + ) + } +} + +public protocol DoryResolvedMachinePlanStoring: Sendable { + func create(_ plan: DoryResolvedMachinePlan) throws + func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws + func read(id: String) throws -> DoryResolvedMachinePlan +} + +extension DoryResolvedMachinePlanRepository: DoryResolvedMachinePlanStoring {} + +public enum DoryDaemonVirtualMachinePlanPublication: Sendable, Equatable { + case create + case replace(expectedPlanRevision: UInt64) +} + +public struct DoryDaemonVirtualMachinePlanningRequest: Sendable { + public var definition: DoryVirtualMachineDefinition + /// Canonical sorted-key encoding of `definition`. Only its digest is persisted. + public var canonicalDefinitionData: Data + public var machine: DoryMachineConfiguration + public var publication: DoryDaemonVirtualMachinePlanPublication + public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? + public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + + public init( + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data, + machine: DoryMachineConfiguration, + publication: DoryDaemonVirtualMachinePlanPublication, + fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil, + experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil + ) { + self.definition = definition + self.canonicalDefinitionData = canonicalDefinitionData + self.machine = machine + self.publication = publication + self.fallbackAuthorization = fallbackAuthorization + self.experimentalAuthorization = experimentalAuthorization + } +} + +public struct DoryDaemonVirtualMachinePlanningResult: Sendable { + public var plannerRequest: DoryVirtualMachineBackendPlanRequest + public var plannerResult: DoryVirtualMachineBackendPlanResult + public var resolvedPlan: DoryResolvedMachinePlan + public var resolvedPlanSHA256: String + public var backendPlan: MachineBackendPlan + + public init( + plannerRequest: DoryVirtualMachineBackendPlanRequest, + plannerResult: DoryVirtualMachineBackendPlanResult, + resolvedPlan: DoryResolvedMachinePlan, + resolvedPlanSHA256: String, + backendPlan: MachineBackendPlan + ) { + self.plannerRequest = plannerRequest + self.plannerResult = plannerResult + self.resolvedPlan = resolvedPlan + self.resolvedPlanSHA256 = resolvedPlanSHA256 + self.backendPlan = backendPlan + } +} + +public enum DoryDaemonVirtualMachinePlanningFailureCode: String, Sendable, Equatable { + case invalidDefinition = "invalid-definition" + case emptyDefinitionAuthority = "empty-definition-authority" + case definitionAuthorityMismatch = "definition-authority-mismatch" + case machineIdentityMismatch = "machine-identity-mismatch" + case bootMediaUnavailable = "boot-media-unavailable" + case mediaInventoryMismatch = "media-inventory-mismatch" + case capabilityUnavailable = "capability-unavailable" + case backendUnavailable = "backend-unavailable" + case backendInventoryUnavailable = "backend-inventory-unavailable" + case resourceAdmissionMismatch = "resource-admission-mismatch" + case planConstructionRejected = "plan-construction-rejected" + case backendPlanRejected = "backend-plan-rejected" + case persistenceRejected = "persistence-rejected" +} + +public struct DoryDaemonVirtualMachinePlanningFailure: Error, Sendable, Equatable { + public var code: DoryDaemonVirtualMachinePlanningFailureCode + public var message: String + + public init(code: DoryDaemonVirtualMachinePlanningFailureCode, message: String) { + self.code = code + self.message = message + } +} + +public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Sendable { + private let registry: BackendRegistry + private let inventory: any DoryDaemonVirtualMachineTrustInventory + private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning + private let plans: any DoryResolvedMachinePlanStoring + private let now: @Sendable () -> Int64 + + public init( + registry: BackendRegistry, + inventory: any DoryDaemonVirtualMachineTrustInventory, + plans: any DoryResolvedMachinePlanStoring, + capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning = + DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(), + now: @escaping @Sendable () -> Int64 = { + Int64((Date().timeIntervalSince1970 * 1_000).rounded(.towardZero)) + } + ) { + self.registry = registry + self.inventory = inventory + self.plans = plans + self.capabilityPlanner = capabilityPlanner + self.now = now + } + + public func resolveAndPersist( + _ input: DoryDaemonVirtualMachinePlanningRequest + ) throws -> DoryDaemonVirtualMachinePlanningResult { + let definition = input.definition + let definitionIssues = definition.validate() + guard definitionIssues.isEmpty else { + throw failure(.invalidDefinition, "Workspace definition validation failed.") + } + guard !input.canonicalDefinitionData.isEmpty else { + throw failure(.emptyDefinitionAuthority, "Definition authority bytes are required.") + } + guard input.canonicalDefinitionData == Self.canonicalDefinitionData(definition) else { + throw failure( + .definitionAuthorityMismatch, + "Definition bytes are not the canonical encoding of the supplied workspace." + ) + } + guard input.machine.id == definition.identity.id else { + throw failure(.machineIdentityMismatch, "Legacy machine and workspace identities differ.") + } + guard let bootReference = Self.primaryBootMedia(in: definition) else { + throw failure(.bootMediaUnavailable, "The workspace has no primary boot device.") + } + let devices = Self.devices(for: definition) + let inventoryRequest = DoryDaemonVirtualMachineInventoryRequest( + machineID: definition.identity.id, + definitionRevision: definition.lifecycle.revision, + guest: definition.guest, + bootMedia: bootReference, + resources: definition.resources, + devices: devices, + acceptableGraphics: definition.graphics.acceptableLevels, + virtualHardwareABIVersion: definition.virtualHardwareABIVersion + ) + let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + do { + snapshot = try inventory.planningInventory(for: inventoryRequest) + } catch { + throw failure(.capabilityUnavailable, "Trusted planning inventory could not be resolved.") + } + guard snapshot.media.reference == bootReference.artifact, + snapshot.media.media.kind == bootReference.kind, + snapshot.media.media.source == bootReference.source else { + throw failure(.mediaInventoryMismatch, "Resolved media does not match the boot reference.") + } + guard snapshot.resourceAdmission.admittedVirtualCPUCount + == definition.resources.virtualCPUCount, + snapshot.resourceAdmission.admittedMemoryBytes == definition.resources.memoryBytes, + snapshot.resourceAdmission.admittedStorageBytes == definition.resources.diskBytes else { + throw failure(.resourceAdmissionMismatch, "Resource admission does not match requested resources.") + } + + let plannerRequest = Self.plannerRequest( + definition: definition, + media: snapshot.media.media, + devices: devices, + allowsExperimental: input.experimentalAuthorization != nil + ) + let plannerResult = capabilityPlanner.plan(plannerRequest, inventory: snapshot) + guard plannerResult.failure == nil, let selected = plannerResult.selectedDescriptor else { + throw failure(.capabilityUnavailable, "No trusted runnable capability was selected.") + } + guard let backend = registry.backend(for: selected.request.backend) else { + throw failure(.backendUnavailable, "The selected backend is not registered.") + } + guard let runtime = snapshot.backendRuntime(for: selected.request.backend) else { + throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.") + } + let backendResult = registry.plan(MachineBackendPlanRequest( + machine: input.machine, + capabilityPlan: plannerResult + )) + guard let backendPlan = backendResult.plan, backendResult.failure == nil, + backendPlan.backend == backend.descriptor, + backendPlan.machine == input.machine, + backendPlan.capability == selected else { + throw failure(.backendPlanRejected, "The selected adapter rejected the machine plan.") + } + + let timing: (revision: UInt64, created: Int64, updated: Int64) + switch input.publication { + case .create: + let timestamp = now() + timing = (1, timestamp, timestamp) + case let .replace(expected): + let current: DoryResolvedMachinePlan + do { current = try plans.read(id: definition.identity.id) } + catch { throw failure(.persistenceRejected, "The current resolved plan could not be read.") } + guard expected < UInt64.max else { + throw failure(.persistenceRejected, "The resolved plan revision is exhausted.") + } + timing = (expected + 1, current.createdAtUnixMilliseconds, + max(now(), current.createdAtUnixMilliseconds)) + } + + let plan: DoryResolvedMachinePlan + do { + plan = try DoryResolvedMachinePlan( + machineID: definition.identity.id, + definitionRevision: definition.lifecycle.revision, + definitionSHA256: Self.sha256(input.canonicalDefinitionData), + planRevision: timing.revision, + createdAtUnixMilliseconds: timing.created, + updatedAtUnixMilliseconds: timing.updated, + backendDescriptor: backend.descriptor, + backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, + resolverReference: snapshot.media.reference, + components: runtime.components.sorted { + $0.componentIdentifier < $1.componentIdentifier + }, + resourceAdmission: snapshot.resourceAdmission, + hostQualification: runtime.hostQualification, + experimentalAuthorization: input.experimentalAuthorization, + plannerRequest: plannerRequest, + plannerResult: plannerResult, + fallbackAuthorization: input.fallbackAuthorization + ) + } catch { + throw failure(.planConstructionRejected, "Trusted evidence did not form a valid durable plan.") + } + do { + switch input.publication { + case .create: try plans.create(plan) + case let .replace(expected): + try plans.replace(plan, expectedPlanRevision: expected) + } + } catch { + throw failure(.persistenceRejected, "The durable plan could not be published.") + } + return DoryDaemonVirtualMachinePlanningResult( + plannerRequest: plannerRequest, + plannerResult: plannerResult, + resolvedPlan: plan, + resolvedPlanSHA256: Self.planSHA256(plan), + backendPlan: backendPlan + ) + } + + static func primaryBootMedia( + in definition: DoryVirtualMachineDefinition + ) -> DoryVMBootMediaReference? { + guard let id = definition.boot.order.first else { return nil } + return definition.boot.devices.first { $0.id == id } + } + + static func devices( + for definition: DoryVirtualMachineDefinition + ) -> DoryVirtualMachineDeviceCapabilityRequest { + let networkAttachment: DoryVirtualMachineNetworkAttachmentMode + switch definition.networkMode { + case .sharedNAT: networkAttachment = .sharedNAT + case .bridged: networkAttachment = .bridged + case .isolated: networkAttachment = .isolated + } + return DoryVirtualMachineDeviceCapabilityRequest( + networkAttachment: networkAttachment, + audioInput: definition.audio.inputEnabled, + audioOutput: definition.audio.outputEnabled, + keyboard: definition.input.keyboardEnabled, + pointer: definition.input.pointerEnabled, + directorySharing: !definition.shares.isEmpty, + clipboard: definition.integrations.contains(.clipboard), + clockSynchronization: definition.integrations.contains(.clockSynchronization), + dynamicDisplay: definition.integrations.contains(.dynamicDisplay), + gracefulShutdown: definition.integrations.contains(.gracefulShutdown) + ) + } + + private static func plannerRequest( + definition: DoryVirtualMachineDefinition, + media: DoryBootMedia, + devices: DoryVirtualMachineDeviceCapabilityRequest, + allowsExperimental: Bool + ) -> DoryVirtualMachineBackendPlanRequest { + let preferences = definition.backendPreference.backend.map { [$0] } + let policy: DoryVirtualMachineBackendPreferencePolicy = + definition.backendPreference.mode == .required ? .required : .preferred + return DoryVirtualMachineBackendPlanRequest( + guest: definition.guest, + bootMedia: media, + acceptableGraphics: definition.graphics.acceptableLevels, + devices: devices, + virtualHardwareABIVersion: definition.virtualHardwareABIVersion, + backendPreferences: preferences, + backendPreferencePolicy: policy, + allowsExperimentalBackends: allowsExperimental + ) + } + + static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let bytes = try? encoder.encode(plan) else { return "" } + return sha256(bytes) + } + + public static func canonicalDefinitionData( + _ definition: DoryVirtualMachineDefinition + ) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(definition)) ?? Data() + } + + static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func failure( + _ code: DoryDaemonVirtualMachinePlanningFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachinePlanningFailure { + DoryDaemonVirtualMachinePlanningFailure(code: code, message: message) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift new file mode 100644 index 00000000..08014aa7 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -0,0 +1,531 @@ +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Daemon virtual-machine runtime planning") +struct DoryDaemonVirtualMachineRuntimePlanningTests { + @Test("planning constructs persists and exposes an exact plan digest") + func planningSuccess() throws { + let fixture = try Fixture() + let result = try fixture.coordinator.resolveAndPersist(fixture.planningRequest()) + + #expect(result.resolvedPlan.validate().isEmpty) + #expect(result.resolvedPlan.definitionSHA256 + == DoryDaemonVirtualMachinePlanningCoordinator.sha256(fixture.canonicalDefinition)) + #expect(result.resolvedPlanSHA256.count == 64) + #expect(result.backendPlan.backend.identity == .doryHypervisor) + #expect(try fixture.store.read(id: fixture.definition.identity.id) == result.resolvedPlan) + } + + @Test("canonical definition bytes cannot be substituted") + func canonicalDefinitionBinding() throws { + let fixture = try Fixture() + var request = fixture.planningRequest() + request.canonicalDefinitionData = Data("not-the-definition".utf8) + do { + _ = try fixture.coordinator.resolveAndPersist(request) + Issue.record("Expected canonical definition rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningFailure { + #expect(failure.code == .definitionAuthorityMismatch) + } + } + + @Test("planning rejects an adapter that substitutes machine or capability") + func substitutedBackendPlan() throws { + let fixture = try Fixture() + for substitution in [BackendPlanSubstitution.machine, .capability] { + let registry = try BackendRegistry(backends: [SubstitutingBackend(substitution)]) + let coordinator = DoryDaemonVirtualMachinePlanningCoordinator( + registry: registry, + inventory: fixture.inventory, + plans: FixturePlanStore(), + capabilityPlanner: FixturePlanner(capability: fixture.capability), + now: { 1_700_000_000_000 } + ) + do { + _ = try coordinator.resolveAndPersist(fixture.planningRequest()) + Issue.record("Expected substituted backend plan rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningFailure { + #expect(failure.code == .backendPlanRejected) + } + } + } + + @Test("default planner does not promote missing runtime qualification") + func defaultPlannerFailsClosed() throws { + let fixture = try Fixture() + let request = fixture.plannerRequest + let result = DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner().plan( + request, + inventory: fixture.inventory.snapshot + ) + #expect(result.selectedDescriptor == nil) + #expect(result.failure?.code == .noCandidate) + #expect(result.evaluatedDescriptors.first?.availability.reason?.code + == .runtimeQualificationUnavailable) + } + + @Test("create to start maps only the persisted backend without replanning") + func createToStart() throws { + let fixture = try Fixture() + let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest()) + let collector = FixtureEvidenceCollector(collection: DoryDaemonVirtualMachineStartEvidenceCollection( + capability: planned.plannerResult.selectedDescriptor!, + runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan) + )) + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: fixture.registry, + plans: fixture.store, + evidenceCollector: collector + ) + let resolved = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest( + definition: fixture.definition, + canonicalDefinitionData: fixture.canonicalDefinition, + machine: fixture.machine, + expectedPlanRevision: planned.resolvedPlan.planRevision + )) + + #expect(resolved.revalidation.mayStart) + #expect(resolved.resolvedPlan == planned.resolvedPlan) + #expect(resolved.resolvedPlanSHA256 == planned.resolvedPlanSHA256) + #expect(resolved.backendPlan.backend.identity == planned.resolvedPlan.backend) + #expect(collector.collectionCount == 1) + } + + @Test("fresh evidence mismatch rejects before backend plan mapping") + func startEvidenceMismatch() throws { + let fixture = try Fixture() + let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest()) + var evidence = DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan) + evidence.backendRuntimeBuildIdentifier = "different-runtime" + let collector = FixtureEvidenceCollector(collection: DoryDaemonVirtualMachineStartEvidenceCollection( + capability: planned.plannerResult.selectedDescriptor!, + runtimeEvidence: evidence + )) + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: fixture.registry, + plans: fixture.store, + evidenceCollector: collector + ) + do { + _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest( + definition: fixture.definition, + canonicalDefinitionData: fixture.canonicalDefinition, + machine: fixture.machine, + expectedPlanRevision: planned.resolvedPlan.planRevision + )) + Issue.record("Expected fresh evidence rejection") + } catch let failure as DoryDaemonVirtualMachineLaunchPlanFailure { + #expect(failure.code == .staleOrMismatchedPlan) + #expect(failure.revalidationIssues.contains { + $0.code == .backendRuntimeBuildMismatch + }) + } + } + + @Test("missing durable plan has a typed launch failure") + func missingPlan() throws { + let fixture = try Fixture() + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: fixture.registry, + plans: fixture.store, + evidenceCollector: FixtureEvidenceCollector(error: FixtureError.unavailable) + ) + do { + _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest( + definition: fixture.definition, + canonicalDefinitionData: fixture.canonicalDefinition, + machine: fixture.machine, + expectedPlanRevision: 1 + )) + Issue.record("Expected missing plan rejection") + } catch let failure as DoryDaemonVirtualMachineLaunchPlanFailure { + #expect(failure.code == .planNotFound) + } + } +} + +private struct Fixture { + let definition: DoryVirtualMachineDefinition + let canonicalDefinition: Data + let machine: DoryMachineConfiguration + let media: DoryBootMedia + let plannerRequest: DoryVirtualMachineBackendPlanRequest + let capability: DoryVirtualMachineCapabilityDescriptor + let store = FixturePlanStore() + let inventory: FixtureInventory + let registry: BackendRegistry + let coordinator: DoryDaemonVirtualMachinePlanningCoordinator + + init() throws { + let bootArtifact = DoryVMResolverReference( + namespace: "artifact", + identifier: "ubuntu-runtime" + ) + let diskArtifact = DoryVMResolverReference( + namespace: "artifact", + identifier: "ubuntu-disk" + ) + let resources = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 2 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ) + definition = DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: "runtime-linux", name: "Runtime Linux"), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", + role: .system, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifact: bootArtifact, + removable: false + )], + order: ["system"] + ), + backendPreference: DoryVMBackendPreference( + mode: .required, + backend: .doryHypervisor + ), + graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]), + resources: resources, + storage: [DoryVMStorageAttachment( + id: "system-disk", + role: .system, + artifact: diskArtifact, + capacityBytes: resources.diskBytes + )], + audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), + input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + canonicalDefinition = DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(definition) + machine = DoryMachineConfiguration( + id: definition.identity.id, + kernelPath: "/fixture/direct-kernel", + rootfsPath: "/fixture/linux.raw", + bootMode: .linuxKernel, + displayMode: .desktop + ) + media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: digest("a") + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + plannerRequest = DoryVirtualMachineBackendPlanRequest( + guest: definition.guest, + bootMedia: media, + acceptableGraphics: [.none], + devices: devices, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ) + let runtimeQualification = DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: digest("b"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: definition.guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: media.artifactSHA256, + backend: .doryHypervisor, + backendRuntimeBuildID: "raw-runtime-1", + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices + ) + capability = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: DoryVirtualMachineCapabilityRequest( + guest: definition.guest, + bootMedia: media, + backend: .doryHypervisor, + graphics: .none, + devices: devices + ), + availability: DoryCapabilityAvailability(supportTier: .supported, state: .available), + resolvedDevices: devices, + runtimeQualificationEvidence: runtimeQualification + ) + let admission = resourceAdmission(resources) + let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot( + hostFacts: hostFacts(), + media: DoryDaemonVirtualMachineResolvedMedia(reference: bootArtifact, media: media), + backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( + backend: .doryHypervisor, + runtimeBuildIdentifier: "raw-runtime-1", + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: "raw-runtime-1", + artifactSHA256: digest("c") + )], + hostQualification: hostQualification() + )], + resourceAdmission: admission + ) + inventory = FixtureInventory(snapshot: snapshot) + registry = try BackendRegistry(backends: [RawHVLinuxMachineBackend( + executablePath: "/fixture/dory-hv", + operations: MachineBackendCompatibilityOperations( + start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + ), + executableIsAvailable: { _ in true } + )]) + coordinator = DoryDaemonVirtualMachinePlanningCoordinator( + registry: registry, + inventory: inventory, + plans: store, + capabilityPlanner: FixturePlanner(capability: capability), + now: { 1_700_000_000_000 } + ) + } + + func planningRequest() -> DoryDaemonVirtualMachinePlanningRequest { + DoryDaemonVirtualMachinePlanningRequest( + definition: definition, + canonicalDefinitionData: canonicalDefinition, + machine: machine, + publication: .create + ) + } +} + +private struct FixturePlanner: DoryDaemonVirtualMachineCapabilityPlanning { + let capability: DoryVirtualMachineCapabilityDescriptor + + func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineBackendPlanResult { + DoryVirtualMachineBackendPlanResult( + selectedDescriptor: capability, + evaluatedDescriptors: [capability], + failure: nil + ) + } +} + +private enum BackendPlanSubstitution { case machine, capability } + +private struct SubstitutingBackend: MachineBackend { + let substitution: BackendPlanSubstitution + let descriptor = RawHVLinuxMachineBackend.backendDescriptor + + init(_ substitution: BackendPlanSubstitution) { + self.substitution = substitution + } + + func probe() -> MachineBackendProbeResult { + MachineBackendProbeResult(descriptor: descriptor, state: .available) + } + + func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { + guard var capability = request.capabilityPlan.selectedDescriptor else { + return MachineBackendPlanResult( + plan: nil, + probe: probe(), + failure: MachineBackendFailure( + code: .capabilityPlanRejected, + backend: descriptor.identity, + message: "Fixture planner did not select a capability." + ) + ) + } + var machine = request.machine + switch substitution { + case .machine: + machine.id = "substituted-machine" + case .capability: + capability.request.bootMedia.artifactSHA256 = digest("9") + } + return MachineBackendPlanResult( + plan: MachineBackendPlan( + backend: descriptor, + machine: machine, + capability: capability + ), + probe: probe(), + failure: nil + ) + } + + func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { + unsupported(.start) + } + + func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + unsupported(.stop) + } + + func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + unsupported(.pause) + } + + func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + unsupported(.resume) + } + + private func unsupported( + _ operation: MachineBackendLifecycleOperation + ) -> MachineBackendOperationResult { + MachineBackendOperationResult( + operation: operation, + backend: descriptor.identity, + observation: nil, + failure: MachineBackendFailure( + code: .lifecycleOperationUnsupported, + backend: descriptor.identity, + message: "Fixture backend does not launch machines." + ) + ) + } +} + +private struct FixtureInventory: DoryDaemonVirtualMachineTrustInventory { + let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + + func planningInventory( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { snapshot } + + func startInventory( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { snapshot } +} + +private final class FixturePlanStore: DoryResolvedMachinePlanStoring, @unchecked Sendable { + private let lock = NSLock() + private var plan: DoryResolvedMachinePlan? + + func create(_ plan: DoryResolvedMachinePlan) throws { + lock.withLock { self.plan = plan } + } + + func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws { + lock.withLock { self.plan = plan } + } + + func read(id: String) throws -> DoryResolvedMachinePlan { + try lock.withLock { + guard let plan, plan.machineID == id else { + throw DoryResolvedMachinePlanRepositoryError.planNotFound(id) + } + return plan + } + } +} + +private final class FixtureEvidenceCollector: + DoryDaemonVirtualMachineStartEvidenceCollecting, + @unchecked Sendable +{ + private let lock = NSLock() + private let result: Result + private var count = 0 + + init(collection: DoryDaemonVirtualMachineStartEvidenceCollection) { + result = .success(collection) + } + + init(error: FixtureError) { + result = .failure(error) + } + + var collectionCount: Int { lock.withLock { count } } + + func collectFreshEvidence( + for plan: DoryResolvedMachinePlan + ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection { + try lock.withLock { + count += 1 + return try result.get() + } + } +} + +private enum FixtureError: Error { case unavailable } + +private func hostFacts() -> DoryAppleSiliconHostFacts { + DoryAppleSiliconHostFacts( + macOSMajorVersion: 26, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, + networkAvailable: false, + displayAvailable: false, + inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: "raw-runtime-1", + virtualizationFrameworkAdapterBuildID: "vz-runtime-1", + qemuRuntimeBuildID: "qemu-runtime-1" + ) + ) +} + +private func resourceAdmission( + _ resources: DoryVMResourceRequest +) -> DoryResolvedMachineResourceAdmissionEvidence { + DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: resources.virtualCPUCount, + admittedMemoryBytes: resources.memoryBytes, + admittedStorageBytes: resources.diskBytes, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024, + existingVirtualCPUCommitment: 2, + existingMemoryCommitmentBytes: 2 * 1_024 * 1_024 * 1_024, + existingStorageReservationBytes: 16 * 1_024 * 1_024 * 1_024, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024, + admissionIdentity: "resource-admission-1", + admissionReportSHA256: digest("d"), + assessorIdentifier: "dory-resource-policy", + assessorVersion: 1 + ) +} + +private func hostQualification() -> DoryResolvedHostQualificationEvidence { + DoryResolvedHostQualificationEvidence( + qualificationIdentity: "host-qualification-1", + qualificationReportSHA256: digest("e"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) +} + +private func digest(_ value: Character) -> String { + String(repeating: String(value), count: 64) +} From d4b45c0dcb71c7978639e3cb36c8c5da543038df Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 12:46:45 +0000 Subject: [PATCH 017/338] feat(vm): gate launches on resolved runtime evidence --- .../LinuxMachineBackendAdapters.swift | 67 +- .../Sources/DorydKit/MachineBackend.swift | 22 + .../Sources/DorydKit/MachineManager.swift | 513 ++++++++++++++- dory-core-swift/Sources/doryd/main.swift | 13 +- ...eManagerResolvedPlanIntegrationTests.swift | 607 ++++++++++++++++++ 5 files changed, 1180 insertions(+), 42 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index c01f4890..f7876170 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -2,11 +2,15 @@ import DoryOperations import Foundation public typealias MachineBackendLifecycleHandler = @Sendable (String) throws -> MachineBackendRuntimeObservation +public typealias MachineBackendAuthorizedStartHandler = @Sendable ( + MachineBackendLaunchBinding +) throws -> MachineBackendRuntimeObservation /// Hooks implemented by the existing machine launcher. Keeping them injectable lets the seam be /// qualified before MachineManager starts consuming BackendRegistry. public struct MachineBackendCompatibilityOperations: Sendable { public var start: MachineBackendLifecycleHandler + public var authorizedStart: MachineBackendAuthorizedStartHandler public var stop: MachineBackendLifecycleHandler public init( @@ -14,21 +18,24 @@ public struct MachineBackendCompatibilityOperations: Sendable { stop: @escaping MachineBackendLifecycleHandler ) { self.start = start + authorizedStart = { binding in try start(binding.machineID) } self.stop = stop } - /// Compatibility bridge to today's launch path. Both Linux adapters may share this bridge; - /// their validated backend plans determine which adapter is allowed to invoke it. - public init(machineManager: MachineManager) { - self.init( - start: { id in - MachineBackendRuntimeObservation(try machineManager.start(id: id)) - }, - stop: { id in - MachineBackendRuntimeObservation(try machineManager.stop(id: id)) - } - ) + public init( + authorizedStart: @escaping MachineBackendAuthorizedStartHandler, + stop: @escaping MachineBackendLifecycleHandler + ) { + start = { _ in + throw MachineBackendFailure( + code: .lifecycleOperationFailed, + message: "An adapter-issued launch binding is required." + ) + } + self.authorizedStart = authorizedStart + self.stop = stop } + } public extension MachineBackendRuntimeObservation { @@ -61,6 +68,7 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { ) -> String? let descriptor: MachineBackendDescriptor + private let componentIdentifier: String private let executablePath: String? private let executableIsAvailable: @Sendable (String) -> Bool private let operations: MachineBackendCompatibilityOperations @@ -68,12 +76,14 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { init( descriptor: MachineBackendDescriptor, + componentIdentifier: String, executablePath: String?, executableIsAvailable: @escaping @Sendable (String) -> Bool, operations: MachineBackendCompatibilityOperations, validateMachine: @escaping MachineValidator ) { self.descriptor = descriptor + self.componentIdentifier = componentIdentifier self.executablePath = executablePath self.executableIsAvailable = executableIsAvailable self.operations = operations @@ -192,7 +202,19 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { guard descriptor.lifecycle.start else { return unsupportedOperation(.start) } - return perform(.start, machineID: plan.machine.id, operation: operations.start) + guard let executablePath, !executablePath.isEmpty else { + return failedOperation( + .start, + code: .componentNotConfigured, + message: "The backend helper path is not configured." + ) + } + return performAuthorizedStart(MachineBackendLaunchBinding( + machineID: plan.machine.id, + backend: descriptor, + componentIdentifier: componentIdentifier, + executablePath: executablePath + )) } func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { @@ -283,6 +305,25 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { } } + private func performAuthorizedStart( + _ binding: MachineBackendLaunchBinding + ) -> MachineBackendOperationResult { + do { + return MachineBackendOperationResult( + operation: .start, + backend: descriptor.identity, + observation: try operations.authorizedStart(binding), + failure: nil + ) + } catch { + return failedOperation( + .start, + code: .lifecycleOperationFailed, + message: String(describing: error) + ) + } + } + private func unsupportedOperation( _ operation: MachineBackendLifecycleOperation ) -> MachineBackendOperationResult { @@ -335,6 +376,7 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable ) { core = LinuxMachineBackendAdapterCore( descriptor: Self.backendDescriptor, + componentIdentifier: "dory-hv", executablePath: executablePath, executableIsAvailable: executableIsAvailable, operations: operations, @@ -396,6 +438,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ ) { core = LinuxMachineBackendAdapterCore( descriptor: Self.backendDescriptor, + componentIdentifier: "dory-vmm", executablePath: executablePath, executableIsAvailable: executableIsAvailable, operations: operations, diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index 44c79856..0d2ff36e 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -202,6 +202,28 @@ public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashab } } +/// Daemon-local launch authority issued by the exact adapter instance that revalidated a plan. +/// It is deliberately not Codable: host executable paths are runtime wiring, never persisted VM +/// desired state or portable plan evidence. +public struct MachineBackendLaunchBinding: Sendable, Equatable { + public var machineID: String + public var backend: MachineBackendDescriptor + public var componentIdentifier: String + public var executablePath: String + + public init( + machineID: String, + backend: MachineBackendDescriptor, + componentIdentifier: String, + executablePath: String + ) { + self.machineID = machineID + self.backend = backend + self.componentIdentifier = componentIdentifier + self.executablePath = executablePath + } +} + public enum MachineBackendLifecycleOperation: String, Codable, Sendable, Equatable, Hashable { case start case stop diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d53c847f..d90f4d17 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -590,9 +590,35 @@ public struct DoryWorkspaceProjectionDiagnostic: Sendable, Equatable { } } +public struct DoryMachineResolvedLaunchIdentity: Sendable, Equatable { + public var planRevision: UInt64 + public var planSHA256: String + public var definitionRevision: UInt64 + public var backend: DoryVirtualizationBackendIdentity + public var backendRuntimeBuildIdentifier: String + public var virtualHardwareABIVersion: UInt16 + + public init(plan: DoryResolvedMachinePlan, planSHA256: String) { + planRevision = plan.planRevision + self.planSHA256 = planSHA256 + definitionRevision = plan.definitionRevision + backend = plan.backend + backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier + virtualHardwareABIVersion = plan.virtualHardwareABIVersion + } +} + +public enum DoryMachineLaunchPolicy: String, Sendable, Equatable { + /// Explicit transition mode for machines that predate durable workspace plans. + case legacyCompatibility + /// A start is rejected unless the complete persisted-plan trust path is installed. + case requireResolvedPlan +} + public final class MachineManager: @unchecked Sendable { public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void + public typealias ResolvedPlanRevisionProvider = @Sendable (_ machineID: String) -> UInt64? private static let deletionQuarantinePrefix = ".dory-machine-delete-" private static let machineDiskTemporaryPrefix = ".rootfs.ext4.tmp-" @@ -624,14 +650,22 @@ public final class MachineManager: @unchecked Sendable { private let balloonController: any MachineBalloonControlling private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository + private let launchPolicy: DoryMachineLaunchPolicy private let operationLock = NSRecursiveLock() private let lock = NSLock() private var machines: [String: MachineEntry] = [:] private var deletingMachineIDs: Set = [] private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:] + private var resolvedLaunchRegistry: BackendRegistry? + private var resolvedLaunchPlanResolver: (any DoryDaemonVirtualMachineLaunchPlanResolving)? + private var resolvedLaunchPlanStore: (any DoryResolvedMachinePlanStoring)? + private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider? + private var pendingResolvedStart: PendingResolvedMachineStart? + private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:] public init( configuration: MachineManagerConfiguration, + launchPolicy: DoryMachineLaunchPolicy = .legacyCompatibility, balloonController: any MachineBalloonControlling = UnixMachineBalloonController(), agentConnector: @escaping AgentConnector = { socketPath in try LocalAgentControl.connect(socketPath: socketPath) @@ -639,6 +673,7 @@ public final class MachineManager: @unchecked Sendable { processStarter: @escaping ProcessStarter = { process in try process.start() } ) { self.configuration = configuration + self.launchPolicy = launchPolicy self.balloonController = balloonController self.agentConnector = agentConnector self.processStarter = processStarter @@ -665,6 +700,61 @@ public final class MachineManager: @unchecked Sendable { recoverInterruptedDesktopUpdates() } + /// Enables the explicit plan-driven launch path exactly once. Machines keep using the + /// labeled legacy compatibility path until this production trust infrastructure is injected; + /// once installed, a start never falls back when plan validation or adapter dispatch fails. + public func installResolvedLaunchInfrastructure( + registry: BackendRegistry, + resolver: any DoryDaemonVirtualMachineLaunchPlanResolving, + plans: any DoryResolvedMachinePlanStoring, + expectedPlanRevision: @escaping ResolvedPlanRevisionProvider + ) throws { + operationLock.lock() + defer { operationLock.unlock() } + guard launchPolicy == .requireResolvedPlan else { + throw MachineManagerError.persistence( + "resolved launch infrastructure requires the requireResolvedPlan policy" + ) + } + guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil, + resolvedLaunchPlanStore == nil, + resolvedPlanRevisionProvider == nil, + pendingResolvedStart == nil else { + throw MachineManagerError.persistence( + "resolved launch infrastructure is already installed" + ) + } + resolvedLaunchRegistry = registry + resolvedLaunchPlanResolver = resolver + resolvedLaunchPlanStore = plans + resolvedPlanRevisionProvider = expectedPlanRevision + } + + /// Operations for the current Linux compatibility adapters. Start is protected by a + /// single-use authorization installed only after persisted-plan revalidation; calling the + /// returned operation directly cannot bypass the plan-driven public start path. + public func resolvedLaunchCompatibilityOperations( + for backend: DoryVirtualizationBackendIdentity + ) -> MachineBackendCompatibilityOperations { + MachineBackendCompatibilityOperations( + authorizedStart: { [weak self] binding in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + return try self.performAuthorizedResolvedBackendStart( + binding: binding, + expectedBackend: backend + ) + }, + stop: { [weak self] id in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + return MachineBackendRuntimeObservation(try self.stop(id: id)) + } + ) + } + @discardableResult public func create(_ machine: DoryMachineConfiguration) throws -> DoryMachineStatus { operationLock.lock() @@ -766,8 +856,270 @@ public final class MachineManager: @unchecked Sendable { public func start(id: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + switch launchPolicy { + case .legacyCompatibility: + let prepared = try prepareMachineStart( + id: id, + requiresAuthoritativeDefinition: false + ) + return try spawnPreparedMachine(prepared.machine, launchBinding: nil) + case .requireResolvedPlan: + guard let registry = resolvedLaunchRegistry, + let resolver = resolvedLaunchPlanResolver, + let planStore = resolvedLaunchPlanStore, + let revisionProvider = resolvedPlanRevisionProvider else { + throw MachineManagerError.persistence( + "resolved launch infrastructure is not installed" + ) + } + return try startResolvedMachine( + id: id, + registry: registry, + resolver: resolver, + planStore: planStore, + revisionProvider: revisionProvider + ) + } + } + + private func startResolvedMachine( + id: String, + registry: BackendRegistry, + resolver: any DoryDaemonVirtualMachineLaunchPlanResolving, + planStore: any DoryResolvedMachinePlanStoring, + revisionProvider: ResolvedPlanRevisionProvider + ) throws -> DoryMachineStatus { + let prepared = try prepareMachineStart(id: id, requiresAuthoritativeDefinition: true) + guard let definition = prepared.definition, + let definitionData = prepared.canonicalDefinitionData else { + throw MachineManagerError.persistence( + "authoritative workspace definition is unavailable for resolved launch" + ) + } + guard let expectedPlanRevision = revisionProvider(id), expectedPlanRevision > 0 else { + throw MachineManagerError.persistence( + "no resolved-plan revision is pinned for machine \(id)" + ) + } + let resolved: DoryDaemonVirtualMachineLaunchPlanResolution + do { + resolved = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest( + definition: definition, + canonicalDefinitionData: definitionData, + machine: prepared.machine, + expectedPlanRevision: expectedPlanRevision + )) + } catch { + throw MachineManagerError.persistence("resolved launch rejected: \(error)") + } + try validateResolvedLaunch( + resolved, + machine: prepared.machine, + definition: definition, + canonicalDefinitionData: definitionData + ) + try revalidatePreparedAuthorityImmediatelyBeforeSpawn( + prepared, + expectedDefinition: definition, + expectedCanonicalDefinitionData: definitionData + ) + try revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn( + resolved, + planStore: planStore + ) + + pendingResolvedStart = PendingResolvedMachineStart( + machine: prepared.machine, + backend: resolved.backendPlan.backend, + runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, + runtimeComponents: resolved.resolvedPlan.components, + planRevision: resolved.resolvedPlan.planRevision, + planSHA256: resolved.resolvedPlanSHA256 + ) + defer { pendingResolvedStart = nil } + let operation = registry.start(resolved.backendPlan) + guard operation.isSuccess, + operation.backend == resolved.resolvedPlan.backend, + operation.observation?.machineID == id else { + let failure = operation.failure?.message ?? "backend adapter returned no observation" + throw MachineManagerError.persistence("resolved backend start failed: \(failure)") + } + guard let status = status(id: id), [.starting, .running].contains(status.state) else { + throw MachineManagerError.persistence( + "resolved backend did not enter MachineManager's prepared spawn path" + ) + } + resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity( + plan: resolved.resolvedPlan, + planSHA256: resolved.resolvedPlanSHA256 + ) + return status + } + + private func revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn( + _ resolved: DoryDaemonVirtualMachineLaunchPlanResolution, + planStore: any DoryResolvedMachinePlanStoring + ) throws { + let current: DoryResolvedMachinePlan + do { + current = try planStore.read(id: resolved.resolvedPlan.machineID) + } catch { + throw MachineManagerError.persistence( + "resolved-plan authority changed during launch validation: \(error)" + ) + } + guard current.planRevision == resolved.resolvedPlan.planRevision, + current == resolved.resolvedPlan, + try Self.canonicalResolvedPlanSHA256(current) + == resolved.resolvedPlanSHA256.lowercased() else { + throw MachineManagerError.persistence( + "resolved-plan authority changed during launch validation" + ) + } + } + + private func revalidatePreparedAuthorityImmediatelyBeforeSpawn( + _ prepared: PreparedMachineStart, + expectedDefinition: DoryVirtualMachineDefinition, + expectedCanonicalDefinitionData: Data + ) throws { + guard let preparedLegacyData = prepared.authoritativeLegacyData, + let currentLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: prepared.machine.id) + ), + currentLegacyData == preparedLegacyData, + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: currentLegacyData + ), + decoded == prepared.machine, + let currentDefinition = reconcileWorkspaceProjection( + machine: prepared.machine, + authoritativeLegacyData: currentLegacyData + ), + currentDefinition == expectedDefinition, + try Self.canonicalDefinitionData(currentDefinition) + == expectedCanonicalDefinitionData else { + throw MachineManagerError.persistence( + "machine authority changed during resolved launch validation" + ) + } + } + + private func performAuthorizedResolvedBackendStart( + binding: MachineBackendLaunchBinding, + expectedBackend: DoryVirtualizationBackendIdentity + ) throws -> MachineBackendRuntimeObservation { + operationLock.lock() + defer { operationLock.unlock() } + guard let authorization = pendingResolvedStart, + authorization.machine.id == binding.machineID, + authorization.backend == binding.backend, + binding.backend.identity == expectedBackend, + !binding.executablePath.isEmpty else { + throw MachineManagerError.persistence( + "backend start lacks a validated resolved-plan authorization" + ) + } + let executableSHA256: String + do { + executableSHA256 = try Self.fileSHA256(path: binding.executablePath) + } catch { + pendingResolvedStart = nil + throw MachineManagerError.persistence( + "resolved backend executable could not be verified: \(error)" + ) + } + let matchingComponents = authorization.runtimeComponents.filter { + $0.componentIdentifier == binding.componentIdentifier + && $0.buildIdentifier == authorization.runtimeBuildIdentifier + && $0.artifactSHA256.lowercased() == executableSHA256 + } + guard matchingComponents.count == 1 else { + pendingResolvedStart = nil + throw MachineManagerError.persistence( + "resolved backend executable does not match qualified runtime evidence" + ) + } + // Consume before process construction so a second or delayed adapter callback cannot + // reuse the validated context, including after a spawn failure. + pendingResolvedStart = nil + return MachineBackendRuntimeObservation(try spawnPreparedMachine( + authorization.machine, + launchBinding: binding + )) + } + + private func validateResolvedLaunch( + _ resolved: DoryDaemonVirtualMachineLaunchPlanResolution, + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data + ) throws { + let plan = resolved.resolvedPlan + let capability = resolved.backendPlan.capability + let definitionDigest = SHA256.hash(data: canonicalDefinitionData) + .map { String(format: "%02x", $0) }.joined() + let canonicalPlanDigest = try Self.canonicalResolvedPlanSHA256(plan) + let expectedMemoryBytes = machine.memoryMB.multipliedReportingOverflow(by: 1_048_576) + guard !expectedMemoryBytes.overflow, + resolved.revalidation.mayStart, + plan.validate().isEmpty, + plan.machineID == machine.id, + plan.definitionRevision == definition.lifecycle.revision, + plan.definitionSHA256?.lowercased() == definitionDigest, + resolved.resolvedPlanSHA256.lowercased() == canonicalPlanDigest, + plan.resourceAdmission?.admittedVirtualCPUCount == UInt64(machine.cpuCount), + plan.resourceAdmission?.admittedMemoryBytes == expectedMemoryBytes.partialValue, + plan.resourceAdmission?.admittedStorageBytes == definition.resources.diskBytes, + resolved.backendPlan.machine == machine, + resolved.backendPlan.backend.identity == plan.backend, + resolved.backendPlan.backend.implementationIdentifier + == plan.backendImplementationIdentifier, + capability.request.guest == plan.guest, + capability.request.backend == plan.backend, + capability.request.bootMedia == plan.bootMedia.media, + capability.request.devices == plan.devices, + capability.request.graphics == plan.graphics, + capability.request.virtualHardwareABIVersion == plan.virtualHardwareABIVersion, + capability.availability.supportTier == plan.supportTier, + capability.availability.isUsable, + capability.resolvedDevices == plan.devices else { + throw MachineManagerError.persistence( + "resolved plan does not exactly match current definition and adapter evidence" + ) + } + } + + private static func canonicalResolvedPlanSHA256( + _ plan: DoryResolvedMachinePlan + ) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(plan) + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func fileSHA256(path: String) throws -> String { + guard let handle = FileHandle(forReadingAtPath: path) else { + throw MachineManagerError.persistence("cannot open runtime executable at \(path)") + } + defer { _ = try? handle.close() } + var hash = SHA256() + while true { + let data = try handle.read(upToCount: 1_048_576) ?? Data() + if data.isEmpty { break } + hash.update(data: data) + } + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } + + private func prepareMachineStart( + id: String, + requiresAuthoritativeDefinition: Bool + ) throws -> PreparedMachineStart { lock.lock() - guard var entry = machines[id] else { + guard let entry = machines[id] else { lock.unlock() throw MachineManagerError.unknownMachine(id) } @@ -776,35 +1128,75 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.alreadyRunning(id) } lock.unlock() - do { - try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration) - try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration) - if let authoritativeLegacyData = Self.readPrivateMetadata( - path: machineConfigPath(id: entry.configuration.id) - ) { - // Boot-bundle materialization changes authoritative inspection facts without - // changing machine.json. Bind that fact change and advance the projection here. - reconcileWorkspaceProjection( - machine: entry.configuration, - authoritativeLegacyData: authoritativeLegacyData - ) + + let machine = entry.configuration + try ensureInstalledLinuxBootBundleIfNeeded(machine) + try materializeInstalledLinuxBootRuntimeIfNeeded(machine) + let authoritativeLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: machine.id) + ) + var authoritativeDefinition: DoryVirtualMachineDefinition? + if let authoritativeLegacyData { + if requiresAuthoritativeDefinition { + guard let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: authoritativeLegacyData + ), decoded == machine else { + throw MachineManagerError.persistence( + "authoritative machine metadata changed before resolved launch" + ) + } } - try Self.validateLaunchConfiguration(entry.configuration) - try validateManagedMachineArtifacts(entry.configuration) - try validateRuntimeAvailability(entry.configuration) - } catch { - throw error + // Boot-bundle materialization can change authoritative inspection facts without + // changing machine.json. Reconcile those facts immediately before launch. + authoritativeDefinition = reconcileWorkspaceProjection( + machine: machine, + authoritativeLegacyData: authoritativeLegacyData + ) + } else if requiresAuthoritativeDefinition { + throw MachineManagerError.persistence( + "authoritative machine metadata is unavailable for resolved launch" + ) + } + try Self.validateLaunchConfiguration(machine) + try validateManagedMachineArtifacts(machine) + if !requiresAuthoritativeDefinition { + try validateRuntimeAvailability(machine) } + if requiresAuthoritativeDefinition, authoritativeDefinition == nil { + throw MachineManagerError.persistence( + "authoritative workspace projection is unavailable for resolved launch" + ) + } + let definitionData = try authoritativeDefinition.map(Self.canonicalDefinitionData) + return PreparedMachineStart( + machine: machine, + definition: authoritativeDefinition, + canonicalDefinitionData: definitionData, + authoritativeLegacyData: authoritativeLegacyData + ) + } + + private func spawnPreparedMachine( + _ preparedMachine: DoryMachineConfiguration, + launchBinding: MachineBackendLaunchBinding? + ) throws -> DoryMachineStatus { lock.lock() - guard let currentEntry = machines[id] else { + guard var entry = machines[preparedMachine.id] else { lock.unlock() - throw MachineManagerError.unknownMachine(id) + throw MachineManagerError.unknownMachine(preparedMachine.id) + } + guard entry.configuration == preparedMachine else { + lock.unlock() + throw MachineManagerError.persistence( + "machine configuration changed after launch validation" + ) } - entry = currentEntry if entry.process?.isRunning == true { lock.unlock() - throw MachineManagerError.alreadyRunning(id) + throw MachineManagerError.alreadyRunning(preparedMachine.id) } + let id = preparedMachine.id let handoffPath = configuration.requiresReadyHandoff ? handoffSocketPath(id: id) : nil let launchID = UUID() let handoffServer: VmmHandoffServer? @@ -824,7 +1216,8 @@ public final class MachineManager: @unchecked Sendable { do { processConfiguration = try self.processConfiguration( for: entry.configuration, - handoffPath: handoffPath + handoffPath: handoffPath, + resolvedLaunchBinding: launchBinding ) } catch { handoffServer?.stop() @@ -1033,6 +1426,7 @@ public final class MachineManager: @unchecked Sendable { deletingMachineIDs.remove(id) workspaceProjectionDiagnostics.removeValue(forKey: id) lock.unlock() + resolvedLaunchIdentities.removeValue(forKey: id) try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id)) if let quarantinePath { @@ -1773,6 +2167,15 @@ public final class MachineManager: @unchecked Sendable { return workspaceProjectionDiagnostics[id] } + /// Immutable identity of the last plan-driven launch accepted for this machine. Runtime + /// status/snapshot/export can consume this hook without copying the full trust evidence yet. + public func resolvedLaunchIdentity(id: String) -> DoryMachineResolvedLaunchIdentity? { + operationLock.lock() + defer { operationLock.unlock() } + guard machines[id] != nil else { return nil } + return resolvedLaunchIdentities[id] + } + private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( @@ -1818,9 +2221,13 @@ public final class MachineManager: @unchecked Sendable { private func processConfiguration( for machine: DoryMachineConfiguration, - handoffPath: String? + handoffPath: String?, + resolvedLaunchBinding: MachineBackendLaunchBinding? ) throws -> HvProcessConfiguration { - let target = processTarget(for: machine) + let target = try processTarget( + for: machine, + resolvedLaunchBinding: resolvedLaunchBinding + ) return HvProcessConfiguration( executablePath: target.executablePath, arguments: try processArguments( @@ -1836,11 +2243,30 @@ public final class MachineManager: @unchecked Sendable { ) } - private func processTarget(for machine: DoryMachineConfiguration) -> ( + private func processTarget( + for machine: DoryMachineConfiguration, + resolvedLaunchBinding: MachineBackendLaunchBinding? + ) throws -> ( executablePath: String, baseArguments: [String], acceleratedDesktop: Bool ) { + if let binding = resolvedLaunchBinding { + switch binding.backend.identity { + case .doryHypervisor: + return ( + binding.executablePath, + configuration.acceleratedDesktopBaseArguments, + true + ) + case .appleVirtualizationFramework: + return (binding.executablePath, configuration.baseArguments, false) + default: + throw MachineManagerError.persistence( + "resolved backend \(binding.backend.identity.rawValue) has no MachineManager launcher" + ) + } + } let desktopPreference = try? DoryDesktopVMMPreference(environment: machine.environment) let supportsAcceleratedBoot = machine.bootMode == .linuxKernel || (machine.bootMode == .efi @@ -2709,8 +3135,9 @@ public final class MachineManager: @unchecked Sendable { } // The rename above is the commit point. Projection is intentionally best-effort and // ordered afterwards: a v2 failure must never roll back or hide working legacy metadata. + resolvedLaunchIdentities.removeValue(forKey: machine.id) if let authoritativeLegacyData { - reconcileWorkspaceProjection( + _ = reconcileWorkspaceProjection( machine: machine, authoritativeLegacyData: authoritativeLegacyData ) @@ -2732,14 +3159,14 @@ public final class MachineManager: @unchecked Sendable { ) continue } - reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data) + _ = reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data) } } private func reconcileWorkspaceProjection( machine: DoryMachineConfiguration, authoritativeLegacyData: Data - ) { + ) -> DoryVirtualMachineDefinition? { do { let facts = try workspaceMigrationFacts(for: machine) let migration = try DoryMachineConfigurationMigrationBridge.migrate( @@ -2757,6 +3184,7 @@ public final class MachineManager: @unchecked Sendable { ), id: machine.id ) + return result.definition } catch let error as DoryMachineConfigurationMigrationError { setWorkspaceProjectionDiagnostic( DoryWorkspaceProjectionDiagnostic( @@ -2766,6 +3194,7 @@ public final class MachineManager: @unchecked Sendable { ), id: machine.id ) + return nil } catch let error as DoryWorkspaceRepositoryError { setWorkspaceProjectionDiagnostic( DoryWorkspaceProjectionDiagnostic( @@ -2775,6 +3204,7 @@ public final class MachineManager: @unchecked Sendable { ), id: machine.id ) + return nil } catch { setWorkspaceProjectionDiagnostic( DoryWorkspaceProjectionDiagnostic( @@ -2784,9 +3214,18 @@ public final class MachineManager: @unchecked Sendable { ), id: machine.id ) + return nil } } + private static func canonicalDefinitionData( + _ definition: DoryVirtualMachineDefinition + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(definition) + } + private func workspaceMigrationFacts( for machine: DoryMachineConfiguration ) throws -> DoryMachineConfigurationMigrationFacts { @@ -4070,6 +4509,22 @@ private struct WorkspaceMigrationAuthorityFacts: Codable { var lifecycle: DoryVMLifecycleMetadata } +private struct PreparedMachineStart { + var machine: DoryMachineConfiguration + var definition: DoryVirtualMachineDefinition? + var canonicalDefinitionData: Data? + var authoritativeLegacyData: Data? +} + +private struct PendingResolvedMachineStart { + var machine: DoryMachineConfiguration + var backend: MachineBackendDescriptor + var runtimeBuildIdentifier: String + var runtimeComponents: [DoryResolvedBackendComponentEvidence] + var planRevision: UInt64 + var planSHA256: String +} + private struct MachineEntry { var configuration: DoryMachineConfiguration var state: DoryMachineState diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index 0345655d..cb9ad3dc 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -63,7 +63,18 @@ let idleController = IdleController() let dockerTier = dorydEnvironment.dockerTierConfiguration().map { DockerTier(configuration: $0, idleController: idleController) } -let machineManager = dorydEnvironment.machineManagerConfiguration().map { MachineManager(configuration: $0) } +let machineManager = dorydEnvironment.machineManagerConfiguration().map { configuration in + // Transitional and explicit: existing machines remain launchable until the production trusted + // inventory collector is installed. MachineManager's resolved-plan policy fails closed and is + // not enabled here without that evidence source. + FileHandle.standardError.write(Data( + "doryd: VM launch policy legacyCompatibility (trusted resolved-plan inventory unavailable)\n".utf8 + )) + return MachineManager( + configuration: configuration, + launchPolicy: .legacyCompatibility + ) +} let sandboxTTLReconciler = machineManager.map { manager in SandboxTTLReconciler( machines: manager, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift new file mode 100644 index 00000000..bd52bb01 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -0,0 +1,607 @@ +import CryptoKit +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("MachineManager resolved-plan launch integration", .serialized) +struct MachineManagerResolvedPlanIntegrationTests { + @Test("validated persisted plan dispatches once without public-start recursion") + func exactPlanDispatchesThroughRegistry() throws { + try withHarness("success") { manager, starter, state in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + #expect(throws: (any Error).self) { + _ = try operations.start("dev") + } + #expect(starter.count == 0) + + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + let status = try manager.start(id: "dev") + #expect(status.state == .running) + #expect(starter.count == 1) + #expect(resolver.callCount == 1) + let identity = try #require(manager.resolvedLaunchIdentity(id: "dev")) + #expect(identity.planRevision == 1) + #expect(identity.backend == .doryHypervisor) + #expect(identity.planSHA256.count == 64) + #expect(FileManager.default.fileExists(atPath: state + "/dev/machine.json")) + _ = try manager.stop(id: "dev") + } + } + + @Test("missing stale tampered and rejected plans never reach process starter") + func rejectedPlansFailBeforeSpawn() throws { + enum Scenario: CaseIterable { + case missing + case staleDefinition + case tamperedDigest + case rejectedEvidence + case adapterPlanMismatch + } + + for scenario in Scenario.allCases { + try withHarness("reject-\(scenario)") { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + switch scenario { + case .missing: + throw DoryDaemonVirtualMachineLaunchPlanFailure( + code: .planNotFound, + message: "fixture missing plan" + ) + case .staleDefinition: + var resolution = try exactResolution(request: request) + resolution.resolvedPlan.definitionRevision += 1 + resolution.resolvedPlanSHA256 = try planSHA256( + resolution.resolvedPlan + ) + return resolution + case .tamperedDigest: + var resolution = try exactResolution(request: request) + resolution.resolvedPlanSHA256 = digest("9") + return resolution + case .rejectedEvidence: + var resolution = try exactResolution(request: request) + resolution.revalidation = DoryResolvedMachinePlanRevalidationResult( + state: .rejected, + issues: [DoryResolvedMachinePlanRevalidationIssue( + code: .componentEvidenceMismatch, + field: "components" + )] + ) + return resolution + case .adapterPlanMismatch: + var resolution = try exactResolution(request: request) + resolution.backendPlan.machine.memoryMB += 1_024 + return resolution + } + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + #expect(manager.status(id: "dev")?.state == .created) + #expect(manager.resolvedLaunchIdentity(id: "dev") == nil) + } + } + } + + @Test("machine metadata mutation during evidence collection is rejected before spawn") + func authorityTOCTOUIsRejected() throws { + try withHarness("authority-toctou") { manager, starter, state in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + var changed = request.machine + changed.memoryMB += 1_024 + let path = state + "/dev/machine.json" + try DoryMachineConfigurationMigrationBridge.encodeLegacy(changed).write( + to: URL(fileURLWithPath: path), + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + return try exactResolution(request: request) + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + #expect(manager.status(id: "dev")?.state == .created) + } + } + + @Test("missing pinned plan revision fails without invoking resolver or process") + func missingPinnedRevisionFailsClosed() throws { + try withHarness("missing-revision") { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in nil } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(resolver.callCount == 0) + #expect(starter.count == 0) + } + } + + @Test("resolved plan replacement during evidence collection is rejected before spawn") + func planAuthorityTOCTOUIsRejected() throws { + try withHarness("plan-toctou") { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request) + var replacement = resolution.resolvedPlan + replacement.planRevision += 1 + replacement.updatedAtUnixMilliseconds += 1 + plans.set(replacement) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + #expect(manager.status(id: "dev")?.state == .created) + } + } + + @Test("same-path runtime replacement is rejected before process starter") + func swappedRuntimeArtifactIsRejected() throws { + try withHarness("runtime-swap") { manager, starter, state in + let runtimePath = state + "/adapter-runtime" + try writeExecutable("#!/bin/sh\nexec /bin/sleep \"$@\"\n", path: runtimePath) + let qualifiedSHA256 = try fileSHA256(path: runtimePath) + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry( + operations: operations, + executablePath: runtimePath + ) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution( + request: request, + componentSHA256: qualifiedSHA256 + ) + plans.set(resolution.resolvedPlan) + try writeExecutable("#!/bin/sh\nexit 0\n", path: runtimePath) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + } + } + + @Test("adapter-issued executable is launched instead of manager backend lookup") + func adapterExecutableBindingIsUsed() throws { + try withHarness( + "adapter-binding", + acceleratedExecutablePath: nil + ) { manager, starter, state in + let runtimePath = state + "/adapter-runtime" + try writeExecutable("#!/bin/sh\nexec /bin/sleep \"$@\"\n", path: runtimePath) + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry( + operations: operations, + executablePath: runtimePath + ) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution( + request: request, + componentSHA256: try fileSHA256(path: runtimePath) + ) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + let status = try manager.start(id: "dev") + #expect(status.state == .running) + #expect(starter.count == 1) + } + } + + @Test("launch policy explicitly gates compatibility and resolved starts") + func launchPolicyIsExplicit() throws { + try withHarness("required-no-infra") { manager, starter, _ in + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + } + try withHarness("legacy", launchPolicy: .legacyCompatibility) { + manager, starter, _ in + let status = try manager.start(id: "dev") + #expect(status.state == .running) + #expect(starter.count == 1) + } + } + + private func withHarness( + _ label: String, + launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan, + acceleratedExecutablePath: String? = "/bin/sleep", + _ body: (MachineManager, CountingProcessStarter, String) throws -> Void + ) throws { + let state = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-resolved-start-\(label)-\(UUID().uuidString)") + .path + let starter = CountingProcessStarter() + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + acceleratedDesktopExecutablePath: acceleratedExecutablePath, + stateDirectory: state, + baseArguments: ["30"], + acceleratedDesktopBaseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + launchPolicy: launchPolicy, + processStarter: { process in try starter.start(process) } + ) + defer { + _ = try? manager.stop(id: "dev") + _ = try? manager.delete(id: "dev") + _ = try? FileManager.default.removeItem(atPath: state) + } + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048, + cpuCount: 2, + displayMode: .desktop + )) + try body(manager, starter, state) + } + + private func rawRegistry( + operations: MachineBackendCompatibilityOperations, + executablePath: String = "/bin/sleep" + ) throws -> BackendRegistry { + try BackendRegistry(backends: [RawHVLinuxMachineBackend( + executablePath: executablePath, + operations: operations + )]) + } + + private func exactResolution( + request: DoryDaemonVirtualMachineLaunchPlanRequest, + componentSHA256: String? = nil + ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution { + let definitionDigest = SHA256.hash(data: request.canonicalDefinitionData) + .map { String(format: "%02x", $0) }.joined() + let artifact = digest("a") + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .userProvided, + artifactSHA256: artifact + ) + let runtime = "raw-runtime-1" + let launcherSHA256: String + if let componentSHA256 { + launcherSHA256 = componentSHA256 + } else { + launcherSHA256 = try fileSHA256(path: "/bin/sleep") + } + let plan = DoryResolvedMachinePlan( + machineID: request.machine.id, + definitionRevision: request.definition.lifecycle.revision, + definitionSHA256: definitionDigest, + planRevision: request.expectedPlanRevision, + createdAtUnixMilliseconds: request.definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: request.definition.lifecycle.updatedAtUnixMilliseconds, + guest: request.definition.guest, + backend: .doryHypervisor, + backendImplementationIdentifier: + RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtime, + virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: DoryVMResolverReference( + namespace: "machine", + identifier: "dev-kernel" + ), + media: media + ), + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: runtime, + artifactSHA256: launcherSHA256 + )], + devices: devices, + graphics: .hostAcceleratedDisplay, + supportTier: .supported, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: DoryVirtualMachineBackendPlanRequest( + guest: request.definition.guest, + bootMedia: media, + acceptableGraphics: [.hostAcceleratedDisplay], + devices: devices, + virtualHardwareABIVersion: + request.definition.virtualHardwareABIVersion, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ), + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: DorySignedArtifactQualificationEvidence( + manifestIdentity: "graphics-qualification-1", + artifactSHA256: artifact, + manifestSHA256: digest("b"), + signingKeyID: "dory-release-1", + manifestFormatVersion: 1 + ), + runtime: runtimeQualification( + guest: request.definition.guest, + media: media, + runtimeBuild: runtime, + devices: devices, + virtualHardwareABIVersion: + request.definition.virtualHardwareABIVersion + ) + ), + resourceAdmission: resourceAdmission( + machine: request.machine, + diskBytes: request.definition.resources.diskBytes + ), + hostQualification: DoryResolvedHostQualificationEvidence( + qualificationIdentity: "host-qualification-1", + qualificationReportSHA256: digest("6"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: runtime, + virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) + ) + guard plan.validate().isEmpty else { + throw MachineManagerError.persistence("fixture plan is invalid") + } + let capability = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: media, + backend: .doryHypervisor, + graphics: plan.graphics, + devices: devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ), + availability: DoryCapabilityAvailability( + supportTier: .supported, + state: .available + ), + resolvedDevices: devices, + graphicsQualificationEvidence: plan.qualificationEvidence.graphics, + runtimeQualificationEvidence: plan.qualificationEvidence.runtime + ) + return DoryDaemonVirtualMachineLaunchPlanResolution( + resolvedPlan: plan, + resolvedPlanSHA256: try planSHA256(plan), + revalidation: DoryResolvedMachinePlanStartValidator.revalidate( + plan, + against: DoryResolvedMachinePlanStartRevalidationInput( + machineID: plan.machineID, + expectedPlanRevision: plan.planRevision, + currentDefinitionRevision: plan.definitionRevision, + currentDefinitionSHA256: definitionDigest, + runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: plan) + ) + ), + backendPlan: MachineBackendPlan( + backend: RawHVLinuxMachineBackend.backendDescriptor, + machine: request.machine, + capability: capability + ) + ) + } + + private func runtimeQualification( + guest: DoryGuestPlatform, + media: DoryBootMedia, + runtimeBuild: String, + devices: DoryVirtualMachineDeviceCapabilityRequest, + virtualHardwareABIVersion: UInt16 + ) -> DoryVirtualMachineRuntimeQualificationEvidence { + DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: digest("c"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: media.artifactSHA256, + backend: .doryHypervisor, + backendRuntimeBuildID: runtimeBuild, + virtualHardwareABIVersion: virtualHardwareABIVersion, + graphics: .hostAcceleratedDisplay, + devices: devices + ) + } + + private func resourceAdmission( + machine: DoryMachineConfiguration, + diskBytes: UInt64 + ) -> DoryResolvedMachineResourceAdmissionEvidence { + DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: UInt64(machine.cpuCount), + admittedMemoryBytes: machine.memoryMB * 1_048_576, + admittedStorageBytes: diskBytes, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_073_741_824, + hostFreeStorageBytes: 512 * 1_073_741_824, + existingVirtualCPUCommitment: 0, + existingMemoryCommitmentBytes: 0, + existingStorageReservationBytes: 0, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_073_741_824, + hostReservedStorageBytes: 32 * 1_073_741_824, + admissionIdentity: "resource-admission-1", + admissionReportSHA256: digest("f"), + assessorIdentifier: "dory-resource-policy", + assessorVersion: 1 + ) + } + + private func planSHA256(_ plan: DoryResolvedMachinePlan) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return SHA256.hash(data: try encoder.encode(plan)) + .map { String(format: "%02x", $0) }.joined() + } + + private func fileSHA256(path: String) throws -> String { + SHA256.hash(data: try Data(contentsOf: URL(fileURLWithPath: path))) + .map { String(format: "%02x", $0) }.joined() + } + + private func writeExecutable(_ contents: String, path: String) throws { + try Data(contents.utf8).write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: path + ) + } + + private func digest(_ character: Character) -> String { + String(repeating: String(character), count: 64) + } +} + +private final class ClosureLaunchResolver: + DoryDaemonVirtualMachineLaunchPlanResolving, + @unchecked Sendable +{ + typealias Handler = @Sendable ( + DoryDaemonVirtualMachineLaunchPlanRequest + ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution + + private let lock = NSLock() + private var calls = 0 + private let handler: Handler + + init(handler: @escaping Handler) { + self.handler = handler + } + + var callCount: Int { lock.withLock { calls } } + + func resolve( + _ request: DoryDaemonVirtualMachineLaunchPlanRequest + ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution { + lock.withLock { calls += 1 } + return try handler(request) + } +} + +private final class CountingProcessStarter: @unchecked Sendable { + private let lock = NSLock() + private var starts = 0 + + var count: Int { lock.withLock { starts } } + + func start(_ process: HvProcess) throws { + lock.withLock { starts += 1 } + try process.start() + } +} + +private final class MutablePlanStore: DoryResolvedMachinePlanStoring, @unchecked Sendable { + private let lock = NSLock() + private var plan: DoryResolvedMachinePlan? + + func set(_ plan: DoryResolvedMachinePlan?) { + lock.withLock { self.plan = plan } + } + + func create(_ plan: DoryResolvedMachinePlan) throws { set(plan) } + + func replace( + _ plan: DoryResolvedMachinePlan, + expectedPlanRevision: UInt64 + ) throws { + lock.withLock { self.plan = plan } + } + + func read(id: String) throws -> DoryResolvedMachinePlan { + guard let plan = lock.withLock({ self.plan }), plan.machineID == id else { + throw DoryResolvedMachinePlanRepositoryError.planNotFound(id) + } + return plan + } +} From 96740b3c3240145ea757c16add2ef280b9d13833 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 13:02:49 +0000 Subject: [PATCH 018/338] feat(vm): verify signed runtime qualifications --- .../DoryOperations/DoryComponents.swift | 91 +++- ...yVirtualMachineQualificationManifest.swift | 506 ++++++++++++++++++ ...ualMachineQualificationManifestTests.swift | 338 ++++++++++++ 3 files changed, 931 insertions(+), 4 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift index bbcf9233..a1769191 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift @@ -150,9 +150,35 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable { } } +/// Catalog-declared location of semantic VM qualification data. The manifest bytes are an +/// ordinary component asset, so their installed digest is covered by the signed catalog and the +/// component store's immutable-installation checks. Schema-v1 catalogs cannot declare this root. +public struct DoryComponentVirtualMachineQualificationAsset: Codable, Sendable, Equatable { + public let component: DoryComponentID + public let path: String + public let manifestIdentity: String + public let manifestFormatVersion: UInt16 + public let signingKeyID: String + + public init( + component: DoryComponentID, + path: String, + manifestIdentity: String, + manifestFormatVersion: UInt16, + signingKeyID: String + ) { + self.component = component + self.path = path + self.manifestIdentity = manifestIdentity + self.manifestFormatVersion = manifestFormatVersion + self.signingKeyID = signingKeyID + } +} + public struct DoryComponentCatalog: Codable, Sendable, Equatable { public static let kind = "dev.dory.component-catalog" - public static let schemaVersion = 1 + public static let oldestSupportedSchemaVersion = 1 + public static let schemaVersion = 2 public let kind: String public let schemaVersion: Int @@ -161,21 +187,25 @@ public struct DoryComponentCatalog: Codable, Sendable, Equatable { public let minimumAppVersion: String public let architecture: String public let components: [DoryComponentRelease] + public let virtualMachineQualification: DoryComponentVirtualMachineQualificationAsset? public init( + schemaVersion: Int = Self.schemaVersion, releaseVersion: String, generatedAt: String, minimumAppVersion: String, architecture: String, - components: [DoryComponentRelease] + components: [DoryComponentRelease], + virtualMachineQualification: DoryComponentVirtualMachineQualificationAsset? = nil ) { kind = Self.kind - schemaVersion = Self.schemaVersion + self.schemaVersion = schemaVersion self.releaseVersion = releaseVersion self.generatedAt = generatedAt self.minimumAppVersion = minimumAppVersion self.architecture = architecture self.components = components + self.virtualMachineQualification = virtualMachineQualification } public func component(_ id: DoryComponentID) -> DoryComponentRelease? { @@ -256,7 +286,8 @@ public enum DoryComponentCatalogVerifier { appVersion: String ) throws { guard catalog.kind == DoryComponentCatalog.kind, - catalog.schemaVersion == DoryComponentCatalog.schemaVersion, + catalog.schemaVersion >= DoryComponentCatalog.oldestSupportedSchemaVersion, + catalog.schemaVersion <= DoryComponentCatalog.schemaVersion, validVersion(catalog.releaseVersion), validVersion(catalog.minimumAppVersion), validTimestamp(catalog.generatedAt), @@ -284,6 +315,27 @@ public enum DoryComponentCatalogVerifier { for component in catalog.components { try validate(component, available: Set(ids)) } + if catalog.schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion, + catalog.virtualMachineQualification != nil { + throw DoryComponentError.invalidCatalog( + "schema-v1 catalogs cannot declare VM qualification authority" + ) + } + if let qualification = catalog.virtualMachineQualification { + guard catalog.schemaVersion == DoryComponentCatalog.schemaVersion, + qualification.component.isRemovable, + safeRelativePath(qualification.path), + !qualification.manifestIdentity.isEmpty, + qualification.manifestFormatVersion > 0, + !qualification.signingKeyID.isEmpty, + catalog.component(qualification.component)?.assets.contains(where: { + $0.path == qualification.path + }) == true else { + throw DoryComponentError.invalidCatalog( + "VM qualification authority is incomplete or is not a declared component asset" + ) + } + } try validateDependencyGraph(catalog.components) } @@ -749,6 +801,37 @@ public struct DoryComponentStore: Sendable { } } + /// Reads one immutable installed asset only after revalidating its complete component + /// generation and the bytes returned by this read. This closes the verify/use race for + /// security metadata such as VM qualification manifests. + public func verifiedAssetData( + component id: DoryComponentID, + path: String, + maximumBytes: Int + ) throws -> Data { + guard maximumBytes > 0, + DoryComponentCatalogVerifier.safeRelativePath(path) else { + throw DoryComponentError.invalidAsset(path) + } + let installed = try verify(id) + guard let asset = installed.assets.first(where: { $0.path == path }), + asset.installedBytes <= UInt64(maximumBytes), + let assetPath = assetPath(component: id, path: path) else { + throw DoryComponentError.invalidAsset(path) + } + let data: Data + do { + data = try PrivateRecordFile.read(at: assetPath, maximumBytes: maximumBytes) + } catch { + throw DoryComponentError.invalidAsset(path) + } + guard UInt64(data.count) == asset.installedBytes, + DoryComponentCatalogVerifier.digest(data) == asset.installedSHA256 else { + throw DoryComponentError.digestMismatch(path) + } + return data + } + public func isInstalledAndValid(_ id: DoryComponentID) -> Bool { guard let installed = try? installedComponent(id) else { return false } return (try? validateInstalledAssets(installed)) != nil diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift new file mode 100644 index 00000000..9cd55f76 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift @@ -0,0 +1,506 @@ +import CryptoKit +import Foundation + +public struct DoryVirtualMachineQualifiedComponent: Codable, Sendable, Equatable, Hashable { + public var componentIdentifier: String + public var buildIdentifier: String + public var artifactSHA256: String + + public init(componentIdentifier: String, buildIdentifier: String, artifactSHA256: String) { + self.componentIdentifier = componentIdentifier + self.buildIdentifier = buildIdentifier + self.artifactSHA256 = artifactSHA256.lowercased() + } +} + +/// One signed, exact result from Dory's backend conformance matrix. This is data inside a +/// catalog-authenticated asset; decoding this type alone never creates trusted capability facts. +public struct DoryVirtualMachineQualificationRecord: Codable, Sendable, Equatable, Hashable { + public var qualificationIdentity: String + public var guest: DoryGuestPlatform + public var bootMediaKind: DoryBootMediaKind + public var bootMediaSource: DoryBootMediaSource + public var immutableArtifactSHA256: String? + public var mutableProvenance: DoryMutableBootMediaProvenanceReference? + public var backend: DoryVirtualizationBackendIdentity + public var backendImplementationIdentifier: String + public var backendRuntimeBuildIdentifier: String + public var virtualHardwareABIVersion: UInt16 + public var graphics: DoryGraphicsAccelerationLevel + public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var hostHardwareModelIdentifier: String + public var hostOperatingSystemBuild: String + public var components: [DoryVirtualMachineQualifiedComponent] + public var virtioGPUKernelAndDeviceSupportQualified: Bool + public var venusVulkanGuestRuntimeQualified: Bool + + public init( + qualificationIdentity: String, + guest: DoryGuestPlatform, + bootMediaKind: DoryBootMediaKind, + bootMediaSource: DoryBootMediaSource, + immutableArtifactSHA256: String? = nil, + mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil, + backend: DoryVirtualizationBackendIdentity, + backendImplementationIdentifier: String, + backendRuntimeBuildIdentifier: String, + virtualHardwareABIVersion: UInt16, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest, + hostHardwareModelIdentifier: String, + hostOperatingSystemBuild: String, + components: [DoryVirtualMachineQualifiedComponent], + virtioGPUKernelAndDeviceSupportQualified: Bool = false, + venusVulkanGuestRuntimeQualified: Bool = false + ) { + self.qualificationIdentity = qualificationIdentity + self.guest = guest + self.bootMediaKind = bootMediaKind + self.bootMediaSource = bootMediaSource + self.immutableArtifactSHA256 = immutableArtifactSHA256?.lowercased() + self.mutableProvenance = mutableProvenance + self.backend = backend + self.backendImplementationIdentifier = backendImplementationIdentifier + self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.graphics = graphics + self.devices = devices + self.hostHardwareModelIdentifier = hostHardwareModelIdentifier + self.hostOperatingSystemBuild = hostOperatingSystemBuild + self.components = components.sorted { $0.componentIdentifier < $1.componentIdentifier } + self.virtioGPUKernelAndDeviceSupportQualified = + virtioGPUKernelAndDeviceSupportQualified + self.venusVulkanGuestRuntimeQualified = venusVulkanGuestRuntimeQualified + } +} + +public struct DoryVirtualMachineQualificationManifest: Codable, Sendable, Equatable { + public static let kind = "dev.dory.virtual-machine-qualification-manifest" + public static let schemaVersion: UInt16 = 1 + + public var kind: String + public var schemaVersion: UInt16 + public var manifestIdentity: String + public var catalogReleaseVersion: String + public var architecture: String + public var signingKeyID: String + public var records: [DoryVirtualMachineQualificationRecord] + + public init( + manifestIdentity: String, + catalogReleaseVersion: String, + architecture: String, + signingKeyID: String, + records: [DoryVirtualMachineQualificationRecord] + ) { + kind = Self.kind + schemaVersion = Self.schemaVersion + self.manifestIdentity = manifestIdentity + self.catalogReleaseVersion = catalogReleaseVersion + self.architecture = architecture + self.signingKeyID = signingKeyID + self.records = records + } +} + +public enum DoryVirtualMachineQualificationAuthorityError: + Error, Sendable, Equatable, CustomStringConvertible +{ + case catalogUnavailable + case catalogSchemaUnsupported(Int) + case authorityUndeclared + case declarationInvalid + case installedComponentUnavailable + case installedComponentCatalogMismatch + case manifestUnreadable + case manifestInvalid(String) + case qualificationUnavailable + case mediaInspectionFailed + + public var description: String { + switch self { + case .catalogUnavailable: "no signature-verified component catalog is cached" + case let .catalogSchemaUnsupported(version): + "component catalog schema \(version) cannot authorize VM qualifications" + case .authorityUndeclared: "component catalog does not declare a VM qualification asset" + case .declarationInvalid: "VM qualification declaration is invalid" + case .installedComponentUnavailable: "qualification component is not installed and verified" + case .installedComponentCatalogMismatch: + "installed qualification component does not belong to the verified catalog" + case .manifestUnreadable: "qualification manifest asset cannot be read and verified" + case let .manifestInvalid(detail): "qualification manifest is invalid: \(detail)" + case .qualificationUnavailable: "no exact signed VM qualification matches the request" + case .mediaInspectionFailed: "boot media does not match its signed qualification" + } + } +} + +/// Non-Codable authority returned only after the entire catalog -> installed generation -> asset +/// digest chain has been checked. It is safe to pass inside the daemon, never through API intent. +public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { + public let catalogDigest: String + public let catalogReleaseVersion: String + public let componentIdentifier: String + public let componentVersion: String + public let manifestSHA256: String + public let manifestIdentity: String + public let signingKeyID: String + + fileprivate let manifest: DoryVirtualMachineQualificationManifest + + fileprivate init( + catalogDigest: String, + catalogReleaseVersion: String, + componentIdentifier: String, + componentVersion: String, + manifestSHA256: String, + manifestIdentity: String, + signingKeyID: String, + manifest: DoryVirtualMachineQualificationManifest + ) { + self.catalogDigest = catalogDigest + self.catalogReleaseVersion = catalogReleaseVersion + self.componentIdentifier = componentIdentifier + self.componentVersion = componentVersion + self.manifestSHA256 = manifestSHA256 + self.manifestIdentity = manifestIdentity + self.signingKeyID = signingKeyID + self.manifest = manifest + } + + public func resolve( + request: DoryVirtualMachineCapabilityRequest, + backendImplementationIdentifier: String, + backendRuntimeBuildIdentifier: String, + hostHardwareModelIdentifier: String, + hostOperatingSystemBuild: String, + installedComponents: [DoryVirtualMachineQualifiedComponent] + ) throws -> DoryResolvedTrustedVirtualMachineQualification { + let components = installedComponents.sorted { + $0.componentIdentifier < $1.componentIdentifier + } + let matches = manifest.records.filter { + $0.guest == request.guest + && $0.bootMediaKind == request.bootMedia.kind + && $0.bootMediaSource == request.bootMedia.source + && $0.immutableArtifactSHA256?.lowercased() + == request.bootMedia.artifactSHA256?.lowercased() + && $0.mutableProvenance == request.bootMedia.mutableProvenance + && $0.backend == request.backend + && $0.backendImplementationIdentifier == backendImplementationIdentifier + && $0.backendRuntimeBuildIdentifier == backendRuntimeBuildIdentifier + && $0.virtualHardwareABIVersion == request.virtualHardwareABIVersion + && $0.graphics == request.graphics + && $0.devices == request.devices + && $0.hostHardwareModelIdentifier == hostHardwareModelIdentifier + && $0.hostOperatingSystemBuild == hostOperatingSystemBuild + && $0.components == components + } + guard matches.count == 1, let record = matches.first else { + throw DoryVirtualMachineQualificationAuthorityError.qualificationUnavailable + } + + let evidence = DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: record.qualificationIdentity, + qualificationReportSHA256: Self.digest(Self.canonicalData(record)), + signingKeyID: signingKeyID, + qualificationFormatVersion: manifest.schemaVersion, + guest: record.guest, + bootMediaKind: record.bootMediaKind, + immutableArtifactSHA256: record.immutableArtifactSHA256, + mutableProvenance: record.mutableProvenance, + backend: record.backend, + backendRuntimeBuildID: record.backendRuntimeBuildIdentifier, + virtualHardwareABIVersion: record.virtualHardwareABIVersion, + graphics: record.graphics, + devices: record.devices + ) + let runtime = DoryTrustedVirtualMachineRuntimeQualification( + auditEvidence: evidence, + runtimeQualified: true + ) + let graphics: DoryTrustedGuestImageGraphicsQualification? + if record.guest.family == .linux, + record.backend == .doryHypervisor, + record.graphics != .none { + graphics = DoryTrustedGuestImageGraphicsQualification( + auditEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: manifestIdentity, + artifactSHA256: request.bootMedia.artifactSHA256 ?? "", + manifestSHA256: manifestSHA256, + signingKeyID: signingKeyID, + manifestFormatVersion: manifest.schemaVersion + ), + virtioGPUKernelAndDeviceSupportQualified: + record.virtioGPUKernelAndDeviceSupportQualified, + venusVulkanGuestRuntimeQualified: + record.venusVulkanGuestRuntimeQualified + ) + } else { + graphics = nil + } + return DoryResolvedTrustedVirtualMachineQualification( + record: record, + runtime: runtime, + graphics: graphics, + manifestIdentity: manifestIdentity, + manifestSHA256: manifestSHA256, + signingKeyID: signingKeyID + ) + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public struct DoryResolvedTrustedVirtualMachineQualification: Sendable { + public let record: DoryVirtualMachineQualificationRecord + public let runtime: DoryTrustedVirtualMachineRuntimeQualification + public let graphics: DoryTrustedGuestImageGraphicsQualification? + fileprivate let manifestIdentity: String + fileprivate let manifestSHA256: String + fileprivate let signingKeyID: String +} + +public enum DoryVirtualMachineQualificationAuthorityResolver { + public static let maximumManifestBytes = 4 * 1_024 * 1_024 + + public static func resolve( + store: DoryComponentStore, + publicKey: String, + expectedArchitecture: String, + appVersion: String + ) throws -> DoryVerifiedVirtualMachineQualificationAuthority { + guard let cached = try store.cachedCatalog( + publicKey: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) else { + throw DoryVirtualMachineQualificationAuthorityError.catalogUnavailable + } + guard cached.catalog.schemaVersion == DoryComponentCatalog.schemaVersion else { + throw DoryVirtualMachineQualificationAuthorityError.catalogSchemaUnsupported( + cached.catalog.schemaVersion + ) + } + guard let declaration = cached.catalog.virtualMachineQualification else { + throw DoryVirtualMachineQualificationAuthorityError.authorityUndeclared + } + guard let release = cached.catalog.component(declaration.component), + let declaredAsset = release.assets.first(where: { + $0.path == declaration.path + }) else { + throw DoryVirtualMachineQualificationAuthorityError.declarationInvalid + } + let installed: DoryInstalledComponent + do { + installed = try store.verify(declaration.component) + } catch { + throw DoryVirtualMachineQualificationAuthorityError.installedComponentUnavailable + } + let catalogDigest = DoryComponentCatalogVerifier.digest(cached.data) + guard installed.catalogDigest == catalogDigest, + installed.version == release.version, + installed.assets == release.assets, + installed.assets.contains(declaredAsset) else { + throw DoryVirtualMachineQualificationAuthorityError + .installedComponentCatalogMismatch + } + let data: Data + do { + data = try store.verifiedAssetData( + component: declaration.component, + path: declaration.path, + maximumBytes: maximumManifestBytes + ) + } catch { + throw DoryVirtualMachineQualificationAuthorityError.manifestUnreadable + } + let manifest: DoryVirtualMachineQualificationManifest + do { + manifest = try JSONDecoder().decode( + DoryVirtualMachineQualificationManifest.self, + from: data + ) + } catch { + throw DoryVirtualMachineQualificationAuthorityError.manifestInvalid( + "JSON could not be decoded" + ) + } + try validate( + manifest, + declaration: declaration, + catalog: cached.catalog, + manifestSHA256: declaredAsset.installedSHA256, + expectedSigningKeyID: signingKeyID(publicKey) + ) + return DoryVerifiedVirtualMachineQualificationAuthority( + catalogDigest: catalogDigest, + catalogReleaseVersion: cached.catalog.releaseVersion, + componentIdentifier: installed.id.rawValue, + componentVersion: installed.version, + manifestSHA256: declaredAsset.installedSHA256, + manifestIdentity: manifest.manifestIdentity, + signingKeyID: manifest.signingKeyID, + manifest: manifest + ) + } + + private static func validate( + _ manifest: DoryVirtualMachineQualificationManifest, + declaration: DoryComponentVirtualMachineQualificationAsset, + catalog: DoryComponentCatalog, + manifestSHA256: String, + expectedSigningKeyID: String + ) throws { + guard manifest.kind == DoryVirtualMachineQualificationManifest.kind, + manifest.schemaVersion == DoryVirtualMachineQualificationManifest.schemaVersion, + declaration.manifestFormatVersion == manifest.schemaVersion, + declaration.manifestIdentity == manifest.manifestIdentity, + declaration.signingKeyID == manifest.signingKeyID, + manifest.signingKeyID == expectedSigningKeyID, + manifest.catalogReleaseVersion == catalog.releaseVersion, + manifest.architecture == catalog.architecture, + !manifest.records.isEmpty, + isSHA256(manifestSHA256), + Set(manifest.records.map(\.qualificationIdentity)).count + == manifest.records.count, + manifest.records.allSatisfy(recordIsValid) else { + throw DoryVirtualMachineQualificationAuthorityError.manifestInvalid( + "header, key, catalog, or record binding is incomplete" + ) + } + } + + private static func recordIsValid(_ record: DoryVirtualMachineQualificationRecord) -> Bool { + let mediaBindingCount = (record.immutableArtifactSHA256 == nil ? 0 : 1) + + (record.mutableProvenance == nil ? 0 : 1) + return !record.qualificationIdentity.isEmpty + && mediaBindingCount == 1 + && (record.immutableArtifactSHA256.map(isSHA256) ?? true) + && record.mutableProvenance.map { + !$0.repositoryIdentity.isEmpty && !$0.mediaIdentity.isEmpty && $0.revision > 0 + } ?? true + && !record.backendImplementationIdentifier.isEmpty + && !record.backendRuntimeBuildIdentifier.isEmpty + && record.virtualHardwareABIVersion > 0 + && !record.hostHardwareModelIdentifier.isEmpty + && !record.hostOperatingSystemBuild.isEmpty + && !record.components.isEmpty + && Set(record.components.map(\.componentIdentifier)).count + == record.components.count + && record.components.allSatisfy { + !$0.componentIdentifier.isEmpty + && !$0.buildIdentifier.isEmpty + && isSHA256($0.artifactSHA256) + } + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...70).contains(byte) + || (97...102).contains(byte) + } + } + + private static func signingKeyID(_ publicKeyBase64: String) -> String { + guard let bytes = Data(base64Encoded: publicKeyBase64) else { return "" } + return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + } +} + +public enum DoryQualifiedBootMediaInspector { + public static let inspectorID = "dory.iso9660-efi-inspector" + public static let inspectorVersion: UInt16 = 1 + + /// Structural inspection is bound to a catalog-authenticated exact qualification. This avoids + /// treating a caller-declared guest family as a fact about arbitrary ISO bytes. + public static func inspectInstallerISO( + atPath path: String, + qualification: DoryResolvedTrustedVirtualMachineQualification + ) throws -> (media: DoryBootMedia, inspection: DoryTrustedBootMediaInspection) { + let record = qualification.record + guard record.bootMediaKind == .installerISO, + let expectedDigest = record.immutableArtifactSHA256 else { + throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed + } + let identity: DoryInstallerISOMediaIdentity + do { identity = try DoryInstallerISOInspector.mediaIdentity(atPath: path) } + catch { + throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed + } + let architecture: DoryGuestArchitecture + switch identity.architecture { + case .arm64: architecture = .arm64 + case .x86_64: architecture = .x86_64 + case .multiArchitecture: architecture = record.guest.architecture + case .unknown: + throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed + } + guard identity.sha256 == expectedDigest.lowercased(), + architecture == record.guest.architecture else { + throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed + } + let report = ISOInspectionReport( + artifactSHA256: identity.sha256, + byteCount: identity.byteCount, + architecture: identity.architecture.rawValue, + guest: record.guest, + efiBootable: true + ) + let evidence = DoryBootMediaInspectionAuditEvidence( + inspectionIdentity: "\(inspectorID):\(identity.sha256)", + artifactSHA256: identity.sha256, + inspectionReportSHA256: digest(canonicalData(report)), + inspectorID: inspectorID, + inspectorVersion: inspectorVersion, + catalogManifestEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: qualification.manifestIdentity, + artifactSHA256: identity.sha256, + manifestSHA256: qualification.manifestSHA256, + signingKeyID: qualification.signingKeyID, + manifestFormatVersion: + DoryVirtualMachineQualificationManifest.schemaVersion + ) + ) + return ( + DoryBootMedia( + kind: .installerISO, + source: record.bootMediaSource, + artifactSHA256: identity.sha256 + ), + DoryTrustedBootMediaInspection( + auditEvidence: evidence, + detectedKind: .installerISO, + detectedGuestFamily: record.guest.family, + detectedArchitecture: architecture, + isEFIBootable: true + ) + ) + } + + private struct ISOInspectionReport: Codable { + var artifactSHA256: String + var byteCount: UInt64 + var architecture: String + var guest: DoryGuestPlatform + var efiBootable: Bool + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift new file mode 100644 index 00000000..71bcf487 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift @@ -0,0 +1,338 @@ +@testable import DoryOperations +import CryptoKit +import Foundation +import XCTest + +final class DoryVirtualMachineQualificationManifestTests: XCTestCase { + func testSignedCatalogInstalledManifestMintsOnlyExactTrustedQualification() throws { + let fixture = try QualificationFixture() + defer { fixture.cleanup() } + let installed = try fixture.installAuthority() + let authority = try fixture.resolveAuthority() + + XCTAssertEqual(authority.catalogDigest, installed.catalogDigest) + XCTAssertEqual(authority.manifestIdentity, fixture.manifest.manifestIdentity) + let resolved = try authority.resolve( + request: fixture.request, + backendImplementationIdentifier: + RawBackendContract.implementationIdentifier, + backendRuntimeBuildIdentifier: fixture.runtimeBuild, + hostHardwareModelIdentifier: fixture.hostModel, + hostOperatingSystemBuild: fixture.hostBuild, + installedComponents: fixture.runtimeComponents + ) + let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate( + fixture.request, + host: fixture.hostFacts, + trustedGuestImageGraphicsQualification: resolved.graphics, + trustedRuntimeQualification: resolved.runtime + ) + XCTAssertEqual(descriptor.availability.supportTier, .supported) + XCTAssertEqual(descriptor.availability.state, .available) + XCTAssertEqual( + descriptor.runtimeQualificationEvidence?.qualificationIdentity, + fixture.record.qualificationIdentity + ) + } + + func testCatalogV1CannotBecomeQualificationAuthority() throws { + let fixture = try QualificationFixture(catalogSchemaVersion: 1, declaresAuthority: false) + defer { fixture.cleanup() } + try fixture.installAuthority() + + XCTAssertThrowsError(try fixture.resolveAuthority()) { error in + XCTAssertEqual( + error as? DoryVirtualMachineQualificationAuthorityError, + .catalogSchemaUnsupported(1) + ) + } + } + + func testMissingDeclarationFailsClosed() throws { + let fixture = try QualificationFixture(declaresAuthority: false) + defer { fixture.cleanup() } + try fixture.installAuthority() + + XCTAssertThrowsError(try fixture.resolveAuthority()) { error in + XCTAssertEqual( + error as? DoryVirtualMachineQualificationAuthorityError, + .authorityUndeclared + ) + } + } + + func testTamperedCatalogSignatureAndManifestFailClosed() throws { + do { + let fixture = try QualificationFixture() + defer { fixture.cleanup() } + try fixture.installAuthority() + let signaturePath = fixture.store.root + "/catalog.sig" + try Data("not-a-signature".utf8).write(to: URL(fileURLWithPath: signaturePath)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: signaturePath + ) + XCTAssertThrowsError(try fixture.resolveAuthority()) { error in + XCTAssertEqual(error as? DoryComponentError, .invalidSignature) + } + } + + do { + let fixture = try QualificationFixture() + defer { fixture.cleanup() } + let installed = try fixture.installAuthority() + let path = try XCTUnwrap(fixture.store.assetPath( + component: .linuxMachines, + path: fixture.manifestPath + )) + var data = try Data(contentsOf: URL(fileURLWithPath: path)) + data.append(0x20) + try data.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + XCTAssertFalse(installed.assets.isEmpty) + XCTAssertThrowsError(try fixture.resolveAuthority()) { error in + XCTAssertEqual( + error as? DoryVirtualMachineQualificationAuthorityError, + .installedComponentUnavailable + ) + } + } + } + + func testStaleRuntimeBuildAndComponentDigestCannotMintTrust() throws { + let fixture = try QualificationFixture() + defer { fixture.cleanup() } + try fixture.installAuthority() + let authority = try fixture.resolveAuthority() + + XCTAssertThrowsError(try authority.resolve( + request: fixture.request, + backendImplementationIdentifier: RawBackendContract.implementationIdentifier, + backendRuntimeBuildIdentifier: "sha256:stale", + hostHardwareModelIdentifier: fixture.hostModel, + hostOperatingSystemBuild: fixture.hostBuild, + installedComponents: fixture.runtimeComponents + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineQualificationAuthorityError, + .qualificationUnavailable + ) + } + + var components = fixture.runtimeComponents + components[0].artifactSHA256 = String(repeating: "f", count: 64) + XCTAssertThrowsError(try authority.resolve( + request: fixture.request, + backendImplementationIdentifier: RawBackendContract.implementationIdentifier, + backendRuntimeBuildIdentifier: fixture.runtimeBuild, + hostHardwareModelIdentifier: fixture.hostModel, + hostOperatingSystemBuild: fixture.hostBuild, + installedComponents: components + )) + } +} + +private enum RawBackendContract { + static let implementationIdentifier = "dory.raw-hv-linux.compatibility.v1" +} + +private final class QualificationFixture { + let root: URL + let store: DoryComponentStore + let privateKey = Curve25519.Signing.PrivateKey() + let appVersion = "1.0.0" + let manifestPath = "vm-qualifications.json" + let hostModel = "Mac15,3" + let hostBuild = "26A5406c" + let mediaDigest = String(repeating: "a", count: 64) + let helperDigest = String(repeating: "b", count: 64) + let runtimeBuild: String + let runtimeComponents: [DoryVirtualMachineQualifiedComponent] + let request: DoryVirtualMachineCapabilityRequest + let record: DoryVirtualMachineQualificationRecord + let manifest: DoryVirtualMachineQualificationManifest + let catalog: DoryComponentCatalog + let catalogData: Data + let catalogSignature: String + let manifestData: Data + + var hostFacts: DoryAppleSiliconHostFacts { + DoryAppleSiliconHostFacts( + macOSMajorVersion: 26, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, + networkAvailable: false, + displayAvailable: false, + inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: runtimeBuild, + virtualizationFrameworkAdapterBuildID: "", + qemuRuntimeBuildID: "" + ) + ) + } + + init(catalogSchemaVersion: Int = 2, declaresAuthority: Bool = true) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-vm-qualification-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let drive = try DoryDataDrive(home: root.path) + try drive.prepare() + store = DoryComponentStore(drive: drive) + try store.prepare() + + runtimeBuild = "sha256:\(helperDigest)" + runtimeComponents = [DoryVirtualMachineQualifiedComponent( + componentIdentifier: "dory-hv", + buildIdentifier: runtimeBuild, + artifactSHA256: helperDigest + )] + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + request = DoryVirtualMachineCapabilityRequest( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMedia: DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: mediaDigest + ), + backend: .doryHypervisor, + graphics: .none, + devices: devices + ) + record = DoryVirtualMachineQualificationRecord( + qualificationIdentity: "raw-linux-arm64-none-v1", + guest: request.guest, + bootMediaKind: request.bootMedia.kind, + bootMediaSource: request.bootMedia.source, + immutableArtifactSHA256: mediaDigest, + backend: request.backend, + backendImplementationIdentifier: RawBackendContract.implementationIdentifier, + backendRuntimeBuildIdentifier: runtimeBuild, + virtualHardwareABIVersion: request.virtualHardwareABIVersion, + graphics: request.graphics, + devices: devices, + hostHardwareModelIdentifier: hostModel, + hostOperatingSystemBuild: hostBuild, + components: runtimeComponents + ) + let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation) + manifest = DoryVirtualMachineQualificationManifest( + manifestIdentity: "dory-vm-qualifications-2026.8", + catalogReleaseVersion: "1.0.0", + architecture: "arm64", + signingKeyID: signingKeyID, + records: [record] + ) + manifestData = try Self.encoded(manifest) + let asset = DoryComponentAsset( + path: manifestPath, + url: "https://example.invalid/\(manifestPath)", + downloadBytes: UInt64(manifestData.count), + installedBytes: UInt64(manifestData.count), + sha256: Self.digest(manifestData), + installedSHA256: Self.digest(manifestData) + ) + let core = DoryComponentRelease( + id: .dockerCore, + version: "1.0.0", + displayName: "Docker Core", + summary: "Core", + dependencies: [], + downloadBytes: 1, + installedBytes: 1, + assets: [] + ) + let machines = DoryComponentRelease( + id: .linuxMachines, + version: "1.0.0", + displayName: "Linux Machines", + summary: "Qualified Linux runtimes", + dependencies: [.dockerCore], + downloadBytes: UInt64(manifestData.count), + installedBytes: UInt64(manifestData.count), + assets: [asset] + ) + catalog = DoryComponentCatalog( + schemaVersion: catalogSchemaVersion, + releaseVersion: "1.0.0", + generatedAt: "2026-08-20T12:00:00.000Z", + minimumAppVersion: appVersion, + architecture: "arm64", + components: [core, machines], + virtualMachineQualification: declaresAuthority + ? DoryComponentVirtualMachineQualificationAsset( + component: .linuxMachines, + path: manifestPath, + manifestIdentity: manifest.manifestIdentity, + manifestFormatVersion: manifest.schemaVersion, + signingKeyID: signingKeyID + ) : nil + ) + catalogData = try Self.encoded(catalog) + catalogSignature = try privateKey.signature(for: catalogData).base64EncodedString() + } + + @discardableResult + func installAuthority() throws -> DoryInstalledComponent { + _ = try store.cacheCatalog( + data: catalogData, + signature: catalogSignature, + publicKey: privateKey.publicKey.rawRepresentation.base64EncodedString(), + expectedArchitecture: "arm64", + appVersion: appVersion + ) + let source = root.appendingPathComponent("qualification-source.json") + try manifestData.write(to: source) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: source.path + ) + return try store.install( + try XCTUnwrap(catalog.component(.linuxMachines)), + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + downloadedAssets: [manifestPath: source.path] + ) + } + + func resolveAuthority() throws -> DoryVerifiedVirtualMachineQualificationAuthority { + try DoryVirtualMachineQualificationAuthorityResolver.resolve( + store: store, + publicKey: privateKey.publicKey.rawRepresentation.base64EncodedString(), + expectedArchitecture: "arm64", + appVersion: appVersion + ) + } + + func cleanup() { try? FileManager.default.removeItem(at: root) } + + private static func encoded(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + Data("\n".utf8) + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} From c5501d828288bb1e69b85c9776c51c9f98066b89 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 13:30:26 +0000 Subject: [PATCH 019/338] feat(vm): expose immutable runtime identity --- Dory/Models/AppStore.swift | 5 +- Dory/Models/Models.swift | 1 + Dory/Runtime/Doryd/DorydClient.swift | 318 +++++++++++++- Dory/Runtime/Machines/MachineSnapshot.swift | 8 +- DoryTests/DorydClientTests.swift | 188 +++++++- .../DorydKit/DoryMachineRuntimeIdentity.swift | 324 ++++++++++++++ .../Sources/DorydKit/DorydService.swift | 110 ++++- .../Sources/DorydKit/MachineManager.swift | 406 +++++++++++++++++- .../DoryMachineRuntimeIdentityTests.swift | 108 +++++ .../DorydKitTests/DorydServiceTests.swift | 12 + ...eManagerResolvedPlanIntegrationTests.swift | 147 +++++++ 11 files changed, 1604 insertions(+), 23 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index cff78697..68bbdba5 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5184,6 +5184,7 @@ final class AppStore { displayMode: status.displayMode, bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, + runtimeIdentity: status.runtimeIdentity, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } @@ -6004,7 +6005,9 @@ final class AppStore { version: "disk", arch: snapshot.architecture, boot: "vz", - recipe: "doryd" + recipe: "doryd", + runtimeIdentity: snapshot.runtimeIdentity, + artifactEvidence: snapshot.artifactEvidence ) } diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 0ea45800..07022f82 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -301,6 +301,7 @@ struct Machine: Identifiable, Hashable, Sendable { var displayMode: MachineDisplayMode = .headless var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var mounts: [MountPair] = [] var id: String { name } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 0c7d4f3c..243bcf45 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -131,6 +131,265 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { } } +nonisolated struct DorydMachineRuntimeComponentIdentity: Codable, Sendable, Equatable, Hashable { + var componentIdentifier: String + var buildIdentifier: String + var artifactSHA256: String +} + +nonisolated struct DorydMachineRuntimeBootMediaIdentity: Codable, Sendable, Equatable, Hashable { + var kind: String + var source: String + var artifactSHA256: String? = nil + var resolverNamespace: String? = nil + var resolverIdentifier: String? = nil + var inspectionIdentity: String? = nil + var inspectionReportSHA256: String? = nil + var provenanceReceiptIdentity: String? = nil + var provenanceReceiptSHA256: String? = nil + var provenanceRevision: UInt64? = nil +} + +nonisolated struct DorydMachineRuntimeQualificationReference: Codable, Sendable, Equatable, Hashable { + var manifestIdentity: String? = nil + var artifactSHA256: String? = nil + var manifestSHA256: String? = nil + var qualificationIdentity: String? = nil + var qualificationReportSHA256: String? = nil + var signingKeyID: String? = nil + var qualifierIdentifier: String? = nil + + var isValidGraphicsReference: Bool { + manifestIdentity?.isSafeEvidenceIdentifier == true + && artifactSHA256?.isLowercaseSHA256 == true + && manifestSHA256?.isLowercaseSHA256 == true + && signingKeyID?.isSafeEvidenceIdentifier == true + && qualificationIdentity == nil + && qualificationReportSHA256 == nil + && qualifierIdentifier == nil + } + + var isValidRuntimeReference: Bool { + qualificationIdentity?.isSafeEvidenceIdentifier == true + && qualificationReportSHA256?.isLowercaseSHA256 == true + && signingKeyID?.isSafeEvidenceIdentifier == true + && manifestIdentity == nil + && artifactSHA256 == nil + && manifestSHA256 == nil + && qualifierIdentifier == nil + } + + var isValidHostReference: Bool { + qualificationIdentity?.isSafeEvidenceIdentifier == true + && qualificationReportSHA256?.isLowercaseSHA256 == true + && qualifierIdentifier?.isSafeEvidenceIdentifier == true + && manifestIdentity == nil + && artifactSHA256 == nil + && manifestSHA256 == nil + && signingKeyID == nil + } +} + +nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Hashable { + static let currentSchemaVersion: UInt16 = 1 + var schemaVersion: UInt16 + var mode: String + var virtualHardwareABIVersion: UInt16 + var invalidationReason: String? = nil + var definitionRevision: UInt64? = nil + var definitionSHA256: String? = nil + var planRevision: UInt64? = nil + var planSHA256: String? = nil + var backend: String? = nil + var backendImplementationIdentifier: String? = nil + var backendRuntimeBuildIdentifier: String? = nil + var supportTier: String? = nil + var selectionDisposition: String? = nil + var fallbackAuthorizationIdentity: String? = nil + var experimentalAuthorizationIdentity: String? = nil + var graphicsQualification: DorydMachineRuntimeQualificationReference? = nil + var runtimeQualification: DorydMachineRuntimeQualificationReference? = nil + var hostQualification: DorydMachineRuntimeQualificationReference? = nil + var components: [DorydMachineRuntimeComponentIdentity]? = nil + var bootMedia: DorydMachineRuntimeBootMediaIdentity? = nil + + static let legacyCompatibility = Self( + schemaVersion: currentSchemaVersion, + mode: "legacy-compatibility", + virtualHardwareABIVersion: 1 + ) + + var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + virtualHardwareABIVersion > 0 else { + return false + } + switch mode { + case "legacy-compatibility": + return invalidationReason == nil && hasNoResolvedEvidence + case "requires-replanning": + return invalidationReason?.isSafeEvidenceIdentifier == true + && hasNoResolvedEvidence + case "resolved-plan": + return isValidResolvedEvidence + default: + return false + } + } + + private var hasNoResolvedEvidence: Bool { + definitionRevision == nil + && definitionSHA256 == nil + && planRevision == nil + && planSHA256 == nil + && backend == nil + && backendImplementationIdentifier == nil + && backendRuntimeBuildIdentifier == nil + && supportTier == nil + && selectionDisposition == nil + && fallbackAuthorizationIdentity == nil + && experimentalAuthorizationIdentity == nil + && graphicsQualification == nil + && runtimeQualification == nil + && hostQualification == nil + && components == nil + && bootMedia == nil + } + + private var isValidResolvedEvidence: Bool { + guard invalidationReason == nil, + let definitionRevision, definitionRevision > 0, + definitionSHA256?.isLowercaseSHA256 == true, + let planRevision, planRevision > 0, + planSHA256?.isLowercaseSHA256 == true, + backend?.isSafeEvidenceIdentifier == true, + backendImplementationIdentifier?.isSafeEvidenceIdentifier == true, + backendRuntimeBuildIdentifier?.isSafeEvidenceIdentifier == true, + let supportTier, + let selectionDisposition, + ["primary", "explicit-alternative", "approved-fallback"] + .contains(selectionDisposition), + let components, !components.isEmpty, + Set(components.map(\.componentIdentifier)).count == components.count, + components.allSatisfy({ component in + component.componentIdentifier.isSafeEvidenceIdentifier + && component.buildIdentifier.isSafeEvidenceIdentifier + && component.artifactSHA256.isLowercaseSHA256 + }), + let bootMedia, bootMedia.isValid else { + return false + } + if let graphicsQualification, !graphicsQualification.isValidGraphicsReference { + return false + } + guard hostQualification?.isValidHostReference == true else { return false } + switch supportTier { + case "supported": + guard runtimeQualification?.isValidRuntimeReference == true, + experimentalAuthorizationIdentity == nil else { + return false + } + case "experimental": + guard experimentalAuthorizationIdentity?.isSafeEvidenceIdentifier == true, + runtimeQualification.map(\.isValidRuntimeReference) ?? true else { + return false + } + default: + return false + } + switch selectionDisposition { + case "approved-fallback": + return fallbackAuthorizationIdentity?.isSafeEvidenceIdentifier == true + case "primary", "explicit-alternative": + return fallbackAuthorizationIdentity == nil + default: + return false + } + } +} + +nonisolated private extension String { + var isLowercaseSHA256: Bool { + utf8.count == 64 && utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + var isSafeEvidenceIdentifier: Bool { + let bytes = Array(utf8) + guard (1...256).contains(bytes.count) else { return false } + return bytes.allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 45 || byte == 46 || byte == 47 + || byte == 58 || byte == 64 || byte == 95 + } + } +} + +nonisolated private extension DorydMachineRuntimeBootMediaIdentity { + var isValid: Bool { + let immutableKinds = [ + "installer-iso", + "installed-linux-boot-bundle", + "macos-restore-image", + ] + guard immutableKinds.contains(kind) || kind == "virtual-disk", + ["dory-bundled", "vendor-download", "user-provided"].contains(source) else { + return false + } + let immutable = artifactSHA256?.isLowercaseSHA256 == true + let mutable = provenanceReceiptIdentity?.isSafeEvidenceIdentifier == true + && provenanceReceiptSHA256?.isLowercaseSHA256 == true + && (provenanceRevision ?? 0) > 0 + let hasAnyMutableField = provenanceReceiptIdentity != nil + || provenanceReceiptSHA256 != nil + || provenanceRevision != nil + guard immutable != mutable, + hasAnyMutableField == mutable, + (kind == "virtual-disk") == mutable else { + return false + } + guard (resolverNamespace == nil) == (resolverIdentifier == nil), + resolverNamespace.map(\.isSafeEvidenceIdentifier) ?? true, + resolverIdentifier.map(\.isSafeEvidenceIdentifier) ?? true, + (inspectionIdentity == nil) == (inspectionReportSHA256 == nil), + inspectionIdentity.map(\.isSafeEvidenceIdentifier) ?? true, + inspectionReportSHA256.map(\.isLowercaseSHA256) ?? true else { + return false + } + if kind == "installer-iso" || kind == "macos-restore-image" { + guard inspectionIdentity != nil else { return false } + } + return true + } +} + +nonisolated struct DorydMachineSnapshotArtifact: Codable, Sendable, Equatable, Hashable { + var byteCount: UInt64 + var sha256: String + + var isValid: Bool { byteCount > 0 && sha256.isLowercaseSHA256 } +} + +nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var rootfs: DorydMachineSnapshotArtifact + var kernel: DorydMachineSnapshotArtifact + var machineIdentifier: DorydMachineSnapshotArtifact? + var nvram: DorydMachineSnapshotArtifact? + + var isValid: Bool { + schemaVersion == 1 + && rootfs.isValid + && kernel.isValid + && (machineIdentifier?.isValid ?? true) + && (nvram?.isValid ?? true) + && ((machineIdentifier == nil) == (nvram == nil)) + } +} + nonisolated struct DorydMachineStatus: Sendable, Equatable { var id: String var state: String @@ -154,6 +413,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var installerMediaAttached: Bool = false var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility } nonisolated struct DorydMachineExecResult: Sendable, Equatable { @@ -205,6 +465,8 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var architecture: String var memoryMB: UInt64 var cpuCount: Int + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil } nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { @@ -1186,7 +1448,8 @@ nonisolated final class DorydClient: @unchecked Sendable { nonisolated private static func machineStatus(from dictionary: NSDictionary) -> DorydMachineStatus? { guard let id = dictionary["id"] as? String, - let state = dictionary["state"] as? String else { + let state = dictionary["state"] as? String, + let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else { return nil } return DorydMachineStatus( @@ -1213,10 +1476,26 @@ nonisolated final class DorydClient: @unchecked Sendable { ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue ?? false, shares: machineShares(from: dictionary["shares"]), - environment: machineEnvironment(from: dictionary["env"]) + environment: machineEnvironment(from: dictionary["env"]), + runtimeIdentity: runtimeIdentity ) } + /// Only an absent key is compatible with an older daemon. A present identity is an evidence + /// claim and must decode and validate exactly; malformed or future-schema claims fail closed. + nonisolated private static func machineRuntimeIdentity( + from dictionary: NSDictionary + ) -> DorydMachineRuntimeIdentity? { + guard let encoded = dictionary["runtimeIdentity"] else { + return .legacyCompatibility + } + guard let identity = decoded(DorydMachineRuntimeIdentity.self, from: encoded), + identity.isValid else { + return nil + } + return identity + } + nonisolated private static func machineShares(from value: Any?) -> [DorydMachineShareConfiguration] { let rows: [NSDictionary] if let swiftRows = value as? [NSDictionary] { @@ -1362,7 +1641,12 @@ nonisolated final class DorydClient: @unchecked Sendable { let kernelPath = dictionary["kernelPath"] as? String, let architecture = dictionary["architecture"] as? String, let memoryMB = uint64(dictionary["memoryMB"]), - let cpuCount = int(dictionary["cpuCount"]) else { + let cpuCount = int(dictionary["cpuCount"]), + let runtimeIdentity = machineRuntimeIdentity(from: dictionary), + let artifactEvidence = machineSnapshotArtifactEvidence( + from: dictionary, + runtimeIdentity: runtimeIdentity + ) else { return nil } return DorydMachineSnapshot( @@ -1375,10 +1659,36 @@ nonisolated final class DorydClient: @unchecked Sendable { kernelPath: kernelPath, architecture: architecture, memoryMB: memoryMB, - cpuCount: cpuCount + cpuCount: cpuCount, + runtimeIdentity: runtimeIdentity, + artifactEvidence: artifactEvidence.value ) } + private struct ParsedMachineSnapshotArtifactEvidence { + var value: DorydMachineSnapshotArtifactEvidence? + } + + /// Artifact evidence follows the same compatibility rule as runtime identity: absence is + /// accepted only for snapshots from a legacy daemon. A present claim must be well formed, + /// and every non-legacy identity requires evidence. + nonisolated private static func machineSnapshotArtifactEvidence( + from dictionary: NSDictionary, + runtimeIdentity: DorydMachineRuntimeIdentity + ) -> ParsedMachineSnapshotArtifactEvidence? { + guard let encoded = dictionary["artifactEvidence"] else { + guard runtimeIdentity.mode == "legacy-compatibility" else { return nil } + return ParsedMachineSnapshotArtifactEvidence(value: nil) + } + guard let evidence = decoded( + DorydMachineSnapshotArtifactEvidence.self, + from: encoded + ), evidence.isValid else { + return nil + } + return ParsedMachineSnapshotArtifactEvidence(value: evidence) + } + nonisolated private static func machineSnapshots(from rows: NSArray) -> [DorydMachineSnapshot]? { let dictionaries = rows.compactMap { $0 as? NSDictionary } guard dictionaries.count == rows.count else { return nil } diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift index 600916c4..aee8238c 100644 --- a/Dory/Runtime/Machines/MachineSnapshot.swift +++ b/Dory/Runtime/Machines/MachineSnapshot.swift @@ -16,11 +16,15 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { let uid: Int? let homePath: String? let loginShell: String + let runtimeIdentity: DorydMachineRuntimeIdentity + let artifactEvidence: DorydMachineSnapshotArtifactEvidence? nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String, sizeBytes: Int64, distro: String, version: String, arch: String, boot: String, recipe: String, username: String = "root", uid: Int? = nil, homePath: String? = nil, - loginShell: String = "/bin/sh") { + loginShell: String = "/bin/sh", + runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility, + artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil) { self.id = id self.imageRef = imageRef self.machineName = machineName @@ -36,6 +40,8 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { self.uid = uid self.homePath = homePath self.loginShell = loginShell + self.runtimeIdentity = runtimeIdentity + self.artifactEvidence = artifactEvidence } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index cdb4c8ed..560e67c1 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -243,6 +243,7 @@ struct DorydClientTests { #expect(startedMachine.agentSocketPath == "/tmp/agent.sock") #expect(startedMachine.address == "192.168.215.40") #expect(startedMachine.configuredAddress == "192.168.215.40") + #expect(startedMachine.runtimeIdentity == .legacyCompatibility) #expect(startedMachine.shares == [ DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), ]) @@ -258,6 +259,7 @@ struct DorydClientTests { #expect(provisionedMachine.verify.stdout == "cargo 1.0\n") #expect(snapshot.id == "s1") #expect(snapshot.machineID == "dev") + #expect(snapshot.runtimeIdentity == .legacyCompatibility) #expect(snapshots.map(\.id).contains("s1")) #expect(clonedSnapshot.id == "dev-copy") #expect(restoredSnapshot.id == "dev") @@ -321,6 +323,162 @@ struct DorydClientTests { ]) } + @Test func presentInvalidRuntimeIdentityFailsStatusAndSnapshotClosed() async throws { + let resolvedWithoutComponentsOrMedia = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + resolvedWithoutComponentsOrMedia.removeObject(forKey: "components") + resolvedWithoutComponentsOrMedia.removeObject(forKey: "bootMedia") + let mixedQualification = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + mixedQualification["runtimeQualification"] = [ + "qualificationIdentity": "runtime-qualification-1", + "qualificationReportSHA256": String(repeating: "3", count: 64), + "signingKeyID": "dory-runtime-1", + "manifestIdentity": "wrong-shape-field", + ] + let orphanProvenance = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + orphanProvenance["bootMedia"] = [ + "kind": "installed-linux-boot-bundle", + "source": "user-provided", + "artifactSHA256": String(repeating: "6", count: 64), + "provenanceReceiptIdentity": "orphan-receipt", + ] + for identity in [ + [ + "schemaVersion": 2, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + ] as NSDictionary, + [ + "schemaVersion": 1, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + "components": [[ + "componentIdentifier": "dory-hv", + "buildIdentifier": "runtime-1", + "artifactSHA256": String(repeating: "a", count: 64), + ]], + ] as NSDictionary, + resolvedWithoutComponentsOrMedia, + mixedQualification, + orphanProvenance, + ] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: identity) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + do { + _ = try await client.machineList() + Issue.record("present invalid status identity must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid identity", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-identity" + ) + Issue.record("present invalid snapshot identity must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + } + } + + @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws { + let malformedEvidence = [ + "schemaVersion": 1, + "rootfs": [ + "byteCount": 0, + "sha256": String(repeating: "a", count: 64), + ], + "kernel": [ + "byteCount": 1, + "sha256": String(repeating: "b", count: 64), + ], + ] as NSDictionary + for fixture in [ + (runtime: [ + "schemaVersion": 1, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + ] as NSDictionary, + artifacts: malformedEvidence), + (runtime: validResolvedRuntimeIdentity(), artifacts: nil), + ] as [(runtime: NSDictionary, artifacts: NSDictionary?)] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + runtimeIdentityOverride: fixture.runtime, + artifactEvidenceOverride: fixture.artifacts + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid artifacts", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-artifacts" + ) + Issue.record("invalid or missing non-legacy artifact evidence must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + } + } + + private func validResolvedRuntimeIdentity() -> NSDictionary { + [ + "schemaVersion": 1, + "mode": "resolved-plan", + "virtualHardwareABIVersion": 1, + "definitionRevision": UInt64(1), + "definitionSHA256": String(repeating: "1", count: 64), + "planRevision": UInt64(1), + "planSHA256": String(repeating: "2", count: 64), + "backend": "dory-hypervisor", + "backendImplementationIdentifier": "dev.dory.raw-hv-linux", + "backendRuntimeBuildIdentifier": "runtime-1", + "supportTier": "supported", + "selectionDisposition": "primary", + "runtimeQualification": [ + "qualificationIdentity": "runtime-qualification-1", + "qualificationReportSHA256": String(repeating: "3", count: 64), + "signingKeyID": "dory-runtime-1", + ], + "hostQualification": [ + "qualificationIdentity": "host-qualification-1", + "qualificationReportSHA256": String(repeating: "4", count: 64), + "qualifierIdentifier": "dory-host-qualifier", + ], + "components": [[ + "componentIdentifier": "dory-hv", + "buildIdentifier": "runtime-1", + "artifactSHA256": String(repeating: "5", count: 64), + ]], + "bootMedia": [ + "kind": "installed-linux-boot-bundle", + "source": "user-provided", + "artifactSHA256": String(repeating: "6", count: 64), + ], + ] as NSDictionary + } + @MainActor @Test func healthRecoveryUsesDaemonOwnedSubsystemRepair() async { let listener = NSXPCListener.anonymous() @@ -1790,6 +1948,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineCreateConfig: NSDictionary? private var _latestMachineUpdateConfig: NSDictionary? private var _latestMachineProvisionRecipe: String? + private var runtimeIdentityOverride: NSDictionary? + private var artifactEvidenceOverride: NSDictionary? private var snapshots: [String: [NSDictionary]] = [:] private var backupStatuses: [String: NSDictionary] = [:] var engineStartCount: Int { @@ -1871,10 +2031,19 @@ private final class FakeDorydService: NSObject, DorydControlXPC { init( socketPath: String = "/tmp/doryd-test.sock", - engineShutdownReplyDelay: TimeInterval = 0 + engineShutdownReplyDelay: TimeInterval = 0, + runtimeIdentityOverride: NSDictionary? = nil, + artifactEvidenceOverride: NSDictionary? = nil ) { self.socketPath = socketPath self.engineShutdownReplyDelay = engineShutdownReplyDelay + self.runtimeIdentityOverride = runtimeIdentityOverride + self.artifactEvidenceOverride = artifactEvidenceOverride + if let runtimeIdentityOverride, + let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { + existing["runtimeIdentity"] = runtimeIdentityOverride + machines["dev"] = existing.copy() as? NSDictionary + } } func setEngineStatus(_ state: String, detail: String = "ok") { @@ -2182,13 +2351,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC { func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let id = request["snapshotID"] as? String ?? "s\(UUID().uuidString.prefix(8).lowercased())" - let row = Self.snapshotRow( + let baseRow = Self.snapshotRow( id: id, machineID: machineID, note: request["note"] as? String ?? "", createdISO: request["createdISO"] as? String ?? "2026-07-07T00:00:00Z" ) lock.lock() + let row: NSDictionary + if let runtimeIdentityOverride, + let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + mutable["runtimeIdentity"] = runtimeIdentityOverride + if let artifactEvidenceOverride { + mutable["artifactEvidence"] = artifactEvidenceOverride + } + row = mutable.copy() as? NSDictionary ?? baseRow + } else if let artifactEvidenceOverride, + let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + mutable["artifactEvidence"] = artifactEvidenceOverride + row = mutable.copy() as? NSDictionary ?? baseRow + } else { + row = baseRow + } _machineSnapshotCount += 1 snapshots[machineID, default: []].insert(row, at: 0) lock.unlock() diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift new file mode 100644 index 00000000..8014b3e6 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift @@ -0,0 +1,324 @@ +import CryptoKit +import DoryOperations +import Foundation + +public enum DoryMachineRuntimeIdentityMode: String, Codable, Sendable, Hashable { + case legacyCompatibility = "legacy-compatibility" + case resolvedPlan = "resolved-plan" + /// The machine is governed by the resolved-plan launch policy, but its current desired + /// state or mutable artifacts are not covered by a launch-authorizing plan yet. + case requiresReplanning = "requires-replanning" +} + +public enum DoryMachineRuntimeIdentityInvalidationReason: String, Codable, Sendable, Hashable { + case definitionChanged = "definition-changed" + case restoredSnapshot = "restored-snapshot" + case planRecoveryFailed = "plan-recovery-failed" + case planNotInstalled = "plan-not-installed" +} + +public enum DoryMachineRuntimeIdentityValidationCode: String, Codable, Sendable, Hashable { + case unsupportedSchemaVersion = "unsupported-schema-version" + case legacyCarriesResolvedEvidence = "legacy-carries-resolved-evidence" + case missingResolvedEvidence = "missing-resolved-evidence" + case invalidResolvedPlan = "invalid-resolved-plan" + case invalidPlanDigest = "invalid-plan-digest" + case planDigestMismatch = "plan-digest-mismatch" + case invalidVirtualHardwareABI = "invalid-virtual-hardware-abi" + case invalidInvalidationReason = "invalid-invalidation-reason" +} + +public struct DoryMachineRuntimeIdentityValidationIssue: + Codable, + Sendable, + Equatable, + Hashable +{ + public var code: DoryMachineRuntimeIdentityValidationCode + public var field: String + + public init(code: DoryMachineRuntimeIdentityValidationCode, field: String) { + self.code = code + self.field = field + } +} + +/// Immutable, non-secret evidence identifying the exact runtime selected for a machine launch. +/// The resolved plan is retained whole so status, snapshots, exports, and diagnostics cannot drift +/// into mutually inconsistent subsets of definition, backend, media, component, or qualification +/// evidence. Legacy launches are represented explicitly and never masquerade as resolved evidence. +public struct DoryMachineRuntimeIdentity: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + /// The virtual-hardware ABI used by metadata written before runtime identity became + /// explicit. This value is deliberately independent of the current ABI so a future ABI + /// bump cannot relabel historical snapshots as newly compatible. + public static let oldestLegacyVirtualHardwareABIVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var mode: DoryMachineRuntimeIdentityMode + public var virtualHardwareABIVersion: UInt16 + public var invalidationReason: DoryMachineRuntimeIdentityInvalidationReason? + public var resolvedPlanSHA256: String? + public var resolvedPlan: DoryResolvedMachinePlan? + + public static func legacyCompatibility(virtualHardwareABIVersion: UInt16 = 1) -> Self { + Self( + schemaVersion: currentSchemaVersion, + mode: .legacyCompatibility, + virtualHardwareABIVersion: virtualHardwareABIVersion, + invalidationReason: nil, + resolvedPlanSHA256: nil, + resolvedPlan: nil + ) + } + + public static func requiresReplanning( + virtualHardwareABIVersion: UInt16 = 1, + reason: DoryMachineRuntimeIdentityInvalidationReason + ) -> Self { + Self( + schemaVersion: currentSchemaVersion, + mode: .requiresReplanning, + virtualHardwareABIVersion: virtualHardwareABIVersion, + invalidationReason: reason, + resolvedPlanSHA256: nil, + resolvedPlan: nil + ) + } + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + mode: DoryMachineRuntimeIdentityMode, + virtualHardwareABIVersion: UInt16, + invalidationReason: DoryMachineRuntimeIdentityInvalidationReason? = nil, + resolvedPlanSHA256: String?, + resolvedPlan: DoryResolvedMachinePlan? + ) { + self.schemaVersion = schemaVersion + self.mode = mode + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.invalidationReason = invalidationReason + self.resolvedPlanSHA256 = resolvedPlanSHA256 + self.resolvedPlan = resolvedPlan + } + + public init(resolvedPlan: DoryResolvedMachinePlan, planSHA256: String) throws { + self.init( + mode: .resolvedPlan, + virtualHardwareABIVersion: resolvedPlan.virtualHardwareABIVersion, + invalidationReason: nil, + resolvedPlanSHA256: planSHA256.lowercased(), + resolvedPlan: resolvedPlan + ) + guard validate().isEmpty else { + throw MachineManagerError.persistence("invalid immutable runtime identity") + } + } + + public var definitionRevision: UInt64? { resolvedPlan?.definitionRevision } + public var definitionSHA256: String? { resolvedPlan?.definitionSHA256 } + public var planRevision: UInt64? { resolvedPlan?.planRevision } + public var backend: DoryVirtualizationBackendIdentity? { resolvedPlan?.backend } + public var backendImplementationIdentifier: String? { + resolvedPlan?.backendImplementationIdentifier + } + public var backendRuntimeBuildIdentifier: String? { + resolvedPlan?.backendRuntimeBuildIdentifier + } + public var supportTier: DoryCapabilitySupportTier? { resolvedPlan?.supportTier } + public var selectionDisposition: DoryResolvedMachineSelectionDisposition? { + resolvedPlan?.selectionEvidence?.disposition + } + public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? { + resolvedPlan?.selectionEvidence?.fallbackAuthorization + } + public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? { + resolvedPlan?.experimentalAuthorization + } + public var qualificationEvidence: DoryResolvedMachineQualificationEvidence? { + resolvedPlan?.qualificationEvidence + } + public var hostQualification: DoryResolvedHostQualificationEvidence? { + resolvedPlan?.hostQualification + } + public var components: [DoryResolvedBackendComponentEvidence] { + resolvedPlan?.components ?? [] + } + public var bootMedia: DoryResolvedMachineBootMedia? { resolvedPlan?.bootMedia } + + public func validate() -> [DoryMachineRuntimeIdentityValidationIssue] { + guard schemaVersion == Self.currentSchemaVersion else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .unsupportedSchemaVersion, + field: "schemaVersion" + )] + } + guard virtualHardwareABIVersion > 0 else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidVirtualHardwareABI, + field: "virtualHardwareABIVersion" + )] + } + switch mode { + case .legacyCompatibility: + guard invalidationReason == nil, + resolvedPlanSHA256 == nil, resolvedPlan == nil else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .legacyCarriesResolvedEvidence, + field: "resolvedPlan" + )] + } + return [] + case .requiresReplanning: + guard invalidationReason != nil else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidInvalidationReason, + field: "invalidationReason" + )] + } + guard resolvedPlanSHA256 == nil, resolvedPlan == nil else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .legacyCarriesResolvedEvidence, + field: "resolvedPlan" + )] + } + return [] + case .resolvedPlan: + guard invalidationReason == nil else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidInvalidationReason, + field: "invalidationReason" + )] + } + guard let digest = resolvedPlanSHA256, let plan = resolvedPlan else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .missingResolvedEvidence, + field: resolvedPlan == nil ? "resolvedPlan" : "resolvedPlanSHA256" + )] + } + guard Self.isSHA256(digest) else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidPlanDigest, + field: "resolvedPlanSHA256" + )] + } + guard plan.validate().isEmpty else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidResolvedPlan, + field: "resolvedPlan" + )] + } + guard plan.virtualHardwareABIVersion == virtualHardwareABIVersion else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .invalidVirtualHardwareABI, + field: "virtualHardwareABIVersion" + )] + } + guard digest == Self.planSHA256(plan) else { + return [DoryMachineRuntimeIdentityValidationIssue( + code: .planDigestMismatch, + field: "resolvedPlanSHA256" + )] + } + return [] + } + } + + public static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(plan) else { return "" } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSHA256(_ value: String) -> Bool { + value.count == 64 && value.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case mode + case virtualHardwareABIVersion + case invalidationReason + case resolvedPlanSHA256 + case resolvedPlan + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + schemaVersion: try container.decodeIfPresent( + UInt16.self, + forKey: .schemaVersion + ) ?? Self.currentSchemaVersion, + mode: try container.decode(DoryMachineRuntimeIdentityMode.self, forKey: .mode), + virtualHardwareABIVersion: try container.decodeIfPresent( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) ?? Self.oldestLegacyVirtualHardwareABIVersion, + invalidationReason: try container.decodeIfPresent( + DoryMachineRuntimeIdentityInvalidationReason.self, + forKey: .invalidationReason + ), + resolvedPlanSHA256: try container.decodeIfPresent( + String.self, + forKey: .resolvedPlanSHA256 + ), + resolvedPlan: try container.decodeIfPresent( + DoryResolvedMachinePlan.self, + forKey: .resolvedPlan + ) + ) + } +} + +public struct DoryMachineSnapshotArtifact: Codable, Sendable, Equatable, Hashable { + public var byteCount: UInt64 + public var sha256: String + + public init(byteCount: UInt64, sha256: String) { + self.byteCount = byteCount + self.sha256 = sha256.lowercased() + } + + public var isValid: Bool { + byteCount > 0 + && sha256.count == 64 + && sha256.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } +} + +/// Content identity for the mutable artifacts captured by a snapshot. This deliberately carries +/// no daemon-local paths: exports can move across hosts without leaking or trusting host layout. +public struct DoryMachineSnapshotArtifactEvidence: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var rootfs: DoryMachineSnapshotArtifact + public var kernel: DoryMachineSnapshotArtifact + public var machineIdentifier: DoryMachineSnapshotArtifact? + public var nvram: DoryMachineSnapshotArtifact? + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + rootfs: DoryMachineSnapshotArtifact, + kernel: DoryMachineSnapshotArtifact, + machineIdentifier: DoryMachineSnapshotArtifact? = nil, + nvram: DoryMachineSnapshotArtifact? = nil + ) { + self.schemaVersion = schemaVersion + self.rootfs = rootfs + self.kernel = kernel + self.machineIdentifier = machineIdentifier + self.nvram = nvram + } + + public var isValid: Bool { + schemaVersion == Self.currentSchemaVersion + && rootfs.isValid + && kernel.isValid + && (machineIdentifier?.isValid ?? true) + && (nvram?.isValid ?? true) + && ((machineIdentifier == nil) == (nvram == nil)) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index ab12e3b9..014db761 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1601,6 +1601,109 @@ private extension DoryMachineStatus { dictionary["displayMode"] = displayMode.rawValue dictionary["bootMode"] = bootMode.rawValue dictionary["installerMediaAttached"] = installerMediaAttached + dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary + return dictionary as NSDictionary + } +} + +private extension DoryMachineRuntimeIdentity { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "mode": mode.rawValue, + "virtualHardwareABIVersion": virtualHardwareABIVersion, + ] + if let invalidationReason { + dictionary["invalidationReason"] = invalidationReason.rawValue + } + guard let plan = resolvedPlan, let resolvedPlanSHA256 else { + return dictionary as NSDictionary + } + dictionary["definitionRevision"] = plan.definitionRevision + if let definitionSHA256 = plan.definitionSHA256 { + dictionary["definitionSHA256"] = definitionSHA256 + } + dictionary["planRevision"] = plan.planRevision + dictionary["planSHA256"] = resolvedPlanSHA256 + dictionary["backend"] = plan.backend.rawValue + dictionary["backendImplementationIdentifier"] = plan.backendImplementationIdentifier + dictionary["backendRuntimeBuildIdentifier"] = plan.backendRuntimeBuildIdentifier + dictionary["supportTier"] = plan.supportTier.rawValue + if let selectionDisposition = plan.selectionEvidence?.disposition { + dictionary["selectionDisposition"] = selectionDisposition.rawValue + } + if let fallback = plan.selectionEvidence?.fallbackAuthorization { + dictionary["fallbackAuthorizationIdentity"] = fallback.authorizationIdentity + } + if let experimental = plan.experimentalAuthorization { + dictionary["experimentalAuthorizationIdentity"] = experimental.authorizationIdentity + } + if let graphics = plan.qualificationEvidence.graphics { + dictionary["graphicsQualification"] = [ + "manifestIdentity": graphics.manifestIdentity, + "artifactSHA256": graphics.artifactSHA256, + "manifestSHA256": graphics.manifestSHA256, + "signingKeyID": graphics.signingKeyID, + ] as NSDictionary + } + if let runtime = plan.qualificationEvidence.runtime { + dictionary["runtimeQualification"] = [ + "qualificationIdentity": runtime.qualificationIdentity, + "qualificationReportSHA256": runtime.qualificationReportSHA256, + "signingKeyID": runtime.signingKeyID, + ] as NSDictionary + } + if let host = plan.hostQualification { + dictionary["hostQualification"] = [ + "qualificationIdentity": host.qualificationIdentity, + "qualificationReportSHA256": host.qualificationReportSHA256, + "qualifierIdentifier": host.qualifierIdentifier, + ] as NSDictionary + } + dictionary["components"] = plan.components.map { component in + [ + "componentIdentifier": component.componentIdentifier, + "buildIdentifier": component.buildIdentifier, + "artifactSHA256": component.artifactSHA256, + ] as NSDictionary + } + var media: [String: Any] = [ + "kind": plan.bootMedia.media.kind.rawValue, + "source": plan.bootMedia.media.source.rawValue, + ] + if let digest = plan.bootMedia.media.artifactSHA256 { + media["artifactSHA256"] = digest + } + if let reference = plan.bootMedia.resolverReference { + media["resolverNamespace"] = reference.namespace + media["resolverIdentifier"] = reference.identifier + } + if let inspection = plan.bootMedia.inspectionEvidence { + media["inspectionIdentity"] = inspection.inspectionIdentity + media["inspectionReportSHA256"] = inspection.inspectionReportSHA256 + } + if let provenance = plan.bootMedia.mutableProvenanceEvidence { + media["provenanceReceiptIdentity"] = provenance.receiptIdentity + media["provenanceReceiptSHA256"] = provenance.receiptSHA256 + media["provenanceRevision"] = provenance.provenance.revision + } + dictionary["bootMedia"] = media as NSDictionary + return dictionary as NSDictionary + } +} + +private extension DoryMachineSnapshotArtifactEvidence { + var xpcDictionary: NSDictionary { + func artifact(_ value: DoryMachineSnapshotArtifact) -> NSDictionary { + ["byteCount": value.byteCount, "sha256": value.sha256] + } + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "rootfs": artifact(rootfs), + "kernel": artifact(kernel), + ] + if let machineIdentifier { dictionary["machineIdentifier"] = artifact(machineIdentifier) } + if let nvram { dictionary["nvram"] = artifact(nvram) } return dictionary as NSDictionary } } @@ -1683,7 +1786,7 @@ private extension MachineRecipeProvisionResult { private extension DoryMachineSnapshot { var xpcDictionary: NSDictionary { - [ + var dictionary: [String: Any] = [ "id": id, "machineID": machineID, "note": note, @@ -1695,7 +1798,12 @@ private extension DoryMachineSnapshot { "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode.rawValue, + "runtimeIdentity": runtimeIdentity.xpcDictionary, ] + if let artifactEvidence { + dictionary["artifactEvidence"] = artifactEvidence.xpcDictionary + } + return dictionary as NSDictionary } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d90f4d17..7533357f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -308,6 +308,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var installerMediaAttached: Bool public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var runtimeIdentity: DoryMachineRuntimeIdentity public init( id: String, @@ -331,7 +332,11 @@ public struct DoryMachineStatus: Sendable, Equatable { bootMode: DoryMachineBootMode = .linuxKernel, installerMediaAttached: Bool = false, shares: [DoryMachineShareConfiguration] = [], - environment: [String: String] = [:] + environment: [String: String] = [:], + runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) ) { self.id = id self.state = state @@ -355,6 +360,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.installerMediaAttached = installerMediaAttached self.shares = shares self.environment = environment + self.runtimeIdentity = runtimeIdentity } } @@ -376,6 +382,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var bootMode: DoryMachineBootMode public var machineIdentifierPath: String? public var nvramPath: String? + public var runtimeIdentity: DoryMachineRuntimeIdentity + public var artifactEvidence: DoryMachineSnapshotArtifactEvidence? public init( id: String, @@ -394,7 +402,12 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { environment: [String: String] = [:], bootMode: DoryMachineBootMode = .linuxKernel, machineIdentifierPath: String? = nil, - nvramPath: String? = nil + nvramPath: String? = nil, + runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ), + artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil ) { self.id = id self.machineID = machineID @@ -413,6 +426,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.bootMode = bootMode self.machineIdentifierPath = machineIdentifierPath self.nvramPath = nvramPath + self.runtimeIdentity = runtimeIdentity + self.artifactEvidence = artifactEvidence } private enum CodingKeys: String, CodingKey { @@ -433,6 +448,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case bootMode case machineIdentifierPath case nvramPath + case runtimeIdentity + case artifactEvidence } public init(from decoder: Decoder) throws { @@ -454,7 +471,18 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:], bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel, machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath), - nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath) + nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath), + runtimeIdentity: try container.decodeIfPresent( + DoryMachineRuntimeIdentity.self, + forKey: .runtimeIdentity + ) ?? .legacyCompatibility( + virtualHardwareABIVersion: + DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion + ), + artifactEvidence: try container.decodeIfPresent( + DoryMachineSnapshotArtifactEvidence.self, + forKey: .artifactEvidence + ) ) } } @@ -695,7 +723,10 @@ public final class MachineManager: @unchecked Sendable { Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory) Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory) Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory) - self.machines = Self.loadPersistedMachines(configuration: configuration) + self.machines = Self.loadPersistedMachines( + configuration: configuration, + launchPolicy: launchPolicy + ) reconcileLoadedWorkspaceProjections() recoverInterruptedDesktopUpdates() } @@ -728,6 +759,10 @@ public final class MachineManager: @unchecked Sendable { resolvedLaunchPlanResolver = resolver resolvedLaunchPlanStore = plans resolvedPlanRevisionProvider = expectedPlanRevision + recoverResolvedRuntimeIdentities( + plans: plans, + expectedPlanRevision: expectedPlanRevision + ) } /// Operations for the current Linux compatibility adapters. Start is protected by a @@ -834,7 +869,12 @@ public final class MachineManager: @unchecked Sendable { try validateManagedMachineArtifacts(preparedMachine) try persist(preparedMachine) lock.lock() - machines[machine.id] = MachineEntry(configuration: preparedMachine, state: .created) + let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine() + machines[machine.id] = MachineEntry( + configuration: preparedMachine, + state: .created, + runtimeIdentity: initialRuntimeIdentity + ) lock.unlock() committed = true return DoryMachineStatus( @@ -848,7 +888,8 @@ public final class MachineManager: @unchecked Sendable { bootMode: preparedMachine.bootMode, installerMediaAttached: preparedMachine.installerISOPath != nil, shares: preparedMachine.shares, - environment: preparedMachine.environment + environment: preparedMachine.environment, + runtimeIdentity: initialRuntimeIdentity ) } @@ -927,6 +968,10 @@ public final class MachineManager: @unchecked Sendable { resolved, planStore: planStore ) + let runtimeIdentity = try DoryMachineRuntimeIdentity( + resolvedPlan: resolved.resolvedPlan, + planSHA256: resolved.resolvedPlanSHA256 + ) pendingResolvedStart = PendingResolvedMachineStart( machine: prepared.machine, @@ -944,6 +989,12 @@ public final class MachineManager: @unchecked Sendable { let failure = operation.failure?.message ?? "backend adapter returned no observation" throw MachineManagerError.persistence("resolved backend start failed: \(failure)") } + lock.lock() + if var entry = machines[id] { + entry.runtimeIdentity = runtimeIdentity + machines[id] = entry + } + lock.unlock() guard let status = status(id: id), [.starting, .running].contains(status.state) else { throw MachineManagerError.persistence( "resolved backend did not enter MachineManager's prepared spawn path" @@ -1564,6 +1615,9 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.invalidID(snapshotID) } let (machine, wasRunning) = try configurationAndRunningState(id: id) + guard let snapshotRuntimeIdentity = runtimeIdentity(id: id) else { + throw MachineManagerError.unknownMachine(id) + } try Self.validateLaunchConfiguration(machine) try ensurePrivateSnapshotDirectory(machineID: id) let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID) @@ -1608,6 +1662,12 @@ public final class MachineManager: @unchecked Sendable { try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath) publishedNVRAM = true } + let artifactEvidence = try Self.snapshotArtifactEvidence( + rootfsPath: rootfsPath, + kernelPath: kernelPath, + machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, + nvramPath: machine.bootMode == .efi ? nvramPath : nil + ) snapshot = DoryMachineSnapshot( id: snapshotID, machineID: id, @@ -1625,7 +1685,9 @@ public final class MachineManager: @unchecked Sendable { environment: machine.environment, bootMode: machine.bootMode, machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, - nvramPath: machine.bootMode == .efi ? nvramPath : nil + nvramPath: machine.bootMode == .efi ? nvramPath : nil, + runtimeIdentity: snapshotRuntimeIdentity, + artifactEvidence: artifactEvidence ) try persistSnapshot(snapshot) } catch { @@ -1897,6 +1959,7 @@ public final class MachineManager: @unchecked Sendable { operationLock.lock() defer { operationLock.unlock() } let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) + try validateSnapshotRuntimeCompatibility(snapshot, machine: nil, cloning: true) let machine = DoryMachineConfiguration( id: newID, kernelPath: snapshot.kernelPath, @@ -1940,6 +2003,7 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) let (machine, wasRunning) = try configurationAndRunningState(id: machineID) + try validateSnapshotRuntimeCompatibility(snapshot, machine: machine, cloning: false) guard machine.bootMode == snapshot.bootMode else { throw MachineManagerError.persistence("snapshot boot mode does not match the machine") } @@ -1964,13 +2028,20 @@ public final class MachineManager: @unchecked Sendable { if var entry = machines[machineID] { entry.configuration = restoredMachine entry.currentBalloonTargetMB = nil + entry.runtimeIdentity = runtimeIdentityAfterSnapshotRestore( + snapshot.runtimeIdentity + ) machines[machineID] = entry } lock.unlock() } - let status = wasRunning - ? try start(id: machineID) - : (status(id: machineID) ?? DoryMachineStatus(id: machineID, state: .stopped)) + let status: DoryMachineStatus + if wasRunning, launchPolicy == .legacyCompatibility { + status = try start(id: machineID) + } else { + status = self.status(id: machineID) + ?? DoryMachineStatus(id: machineID, state: .stopped) + } return status } catch let error as MachineManagerError { if wasRunning { @@ -2069,6 +2140,7 @@ public final class MachineManager: @unchecked Sendable { ) } try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount) + try Self.validateSnapshotRuntimeIdentity(snapshot) snapshot.address = nil snapshot.shares = [] snapshot.environment = [:] @@ -2149,6 +2221,12 @@ public final class MachineManager: @unchecked Sendable { return statusLocked(id: id, entry: entry) } + public func runtimeIdentity(id: String) -> DoryMachineRuntimeIdentity? { + lock.lock() + defer { lock.unlock() } + return machines[id]?.runtimeIdentity + } + public func list() -> [DoryMachineStatus] { lock.lock() let statuses = machines.keys.sorted().compactMap { id in @@ -2190,7 +2268,8 @@ public final class MachineManager: @unchecked Sendable { bootMode: entry.configuration.bootMode, installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, - environment: entry.configuration.environment + environment: entry.configuration.environment, + runtimeIdentity: entry.runtimeIdentity ) } return DoryMachineStatus( @@ -2215,7 +2294,8 @@ public final class MachineManager: @unchecked Sendable { bootMode: entry.configuration.bootMode, installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, - environment: entry.configuration.environment + environment: entry.configuration.environment, + runtimeIdentity: entry.runtimeIdentity ) } @@ -2869,9 +2949,92 @@ public final class MachineManager: @unchecked Sendable { } entry.configuration = configuration entry.currentBalloonTargetMB = nil + entry.runtimeIdentity = runtimeIdentityForUnplannedMachine( + reason: .definitionChanged + ) machines[configuration.id] = entry } + private func runtimeIdentityForUnplannedMachine( + reason: DoryMachineRuntimeIdentityInvalidationReason = .planNotInstalled + ) -> DoryMachineRuntimeIdentity { + switch launchPolicy { + case .legacyCompatibility: + return .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + case .requireResolvedPlan: + return .requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: reason + ) + } + } + + private func runtimeIdentityAfterSnapshotRestore( + _ snapshotIdentity: DoryMachineRuntimeIdentity + ) -> DoryMachineRuntimeIdentity { + switch launchPolicy { + case .legacyCompatibility: + return .legacyCompatibility( + virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion + ) + case .requireResolvedPlan: + return .requiresReplanning( + virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion, + reason: .restoredSnapshot + ) + } + } + + /// Reconstructs durable identity only when the persisted plan still binds the exact current + /// authoritative definition. Missing, stale, or migration-only plans remain visibly blocked + /// instead of being mislabeled as legacy compatibility after a daemon restart. + private func recoverResolvedRuntimeIdentities( + plans: any DoryResolvedMachinePlanStoring, + expectedPlanRevision: ResolvedPlanRevisionProvider + ) { + let entries: [(String, DoryMachineConfiguration)] = { + lock.lock() + defer { lock.unlock() } + return machines.map { ($0.key, $0.value.configuration) } + }() + for (id, machine) in entries { + var recovered = DoryMachineRuntimeIdentity.requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + if let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)), + let definition = reconcileWorkspaceProjection( + machine: machine, + authoritativeLegacyData: legacyData + ), + let canonicalData = try? Self.canonicalDefinitionData(definition), + let plan = try? plans.read(id: id), + expectedPlanRevision(id) == plan.planRevision, + plan.machineID == id, + plan.definitionRevision == definition.lifecycle.revision, + plan.definitionSHA256 == Self.sha256(data: canonicalData), + plan.migrationDisposition == .current, + plan.validate().isEmpty, + let identity = try? DoryMachineRuntimeIdentity( + resolvedPlan: plan, + planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan) + ) { + recovered = identity + } + lock.lock() + if var entry = machines[id] { + entry.runtimeIdentity = recovered + machines[id] = entry + } + lock.unlock() + } + } + private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration { let rootfsDestination = machineRootfsPath(id: machine.id) let kernelDestination = machineKernelPath(id: machine.id) @@ -3311,6 +3474,8 @@ public final class MachineManager: @unchecked Sendable { let directory = snapshotDirectory(machineID: snapshot.machineID) let temporaryPath = "\(directory)/\(Self.snapshotMetadataTemporaryPrefix)\(snapshot.id)-\(UUID().uuidString)" do { + try Self.validateSnapshotRuntimeIdentity(snapshot) + try Self.validateSnapshotArtifactEvidence(snapshot) guard Self.isPrivateDirectory(path: directory) else { throw MachineManagerError.persistence("machine snapshot path is not a private directory") } @@ -3355,6 +3520,7 @@ public final class MachineManager: @unchecked Sendable { snapshot.rootfsPath == expectedRootfsPath, snapshot.kernelPath == expectedKernelPath, snapshot.architecture == configuration.guestArchitecture, + snapshot.runtimeIdentity.validate().isEmpty, (try? Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount)) != nil, Self.isPrivateRegularFile(path: expectedRootfsPath), Self.isPrivateRegularFile(path: expectedKernelPath) else { @@ -3373,11 +3539,169 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.unknownSnapshot(snapshotID) } } + try Self.validateSnapshotArtifactEvidence(snapshot) var validated = snapshot validated.sizeBytes = Self.fileSize(path: expectedRootfsPath) return validated } + fileprivate static func validateSnapshotRuntimeIdentity( + _ snapshot: DoryMachineSnapshot + ) throws { + guard snapshot.runtimeIdentity.validate().isEmpty, + snapshot.runtimeIdentity.virtualHardwareABIVersion + == DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion else { + throw MachineManagerError.persistence("invalid machine snapshot runtime identity") + } + if snapshot.runtimeIdentity.mode == .resolvedPlan, + snapshot.artifactEvidence == nil { + throw MachineManagerError.persistence( + "resolved machine snapshot is missing mutable artifact evidence" + ) + } + guard let plan = snapshot.runtimeIdentity.resolvedPlan else { + return + } + let memoryBytes = snapshot.memoryMB.multipliedReportingOverflow(by: 1_048_576) + guard let artifactEvidence = snapshot.artifactEvidence, + snapshot.sizeBytes > 0, + let snapshotSizeBytes = UInt64(exactly: snapshot.sizeBytes), + !memoryBytes.overflow, + plan.machineID == snapshot.machineID, + plan.guest.architecture.rawValue == snapshot.architecture, + plan.resourceAdmission?.admittedVirtualCPUCount == UInt64(snapshot.cpuCount), + plan.resourceAdmission?.admittedMemoryBytes == memoryBytes.partialValue, + plan.resourceAdmission?.admittedStorageBytes == snapshotSizeBytes, + artifactEvidence.rootfs.byteCount == snapshotSizeBytes else { + throw MachineManagerError.persistence( + "machine snapshot resources do not match immutable runtime evidence" + ) + } + } + + private static func snapshotArtifactEvidence( + rootfsPath: String, + kernelPath: String, + machineIdentifierPath: String?, + nvramPath: String? + ) throws -> DoryMachineSnapshotArtifactEvidence { + DoryMachineSnapshotArtifactEvidence( + rootfs: try snapshotArtifact(path: rootfsPath), + kernel: try snapshotArtifact(path: kernelPath), + machineIdentifier: try machineIdentifierPath.map(snapshotArtifact(path:)), + nvram: try nvramPath.map(snapshotArtifact(path:)) + ) + } + + private static func snapshotArtifact(path: String) throws -> DoryMachineSnapshotArtifact { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw MachineManagerError.persistence("could not open machine snapshot artifact") + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + let byteCount = try handle.seekToEnd() + guard byteCount > 0 else { + throw MachineManagerError.persistence("machine snapshot artifact is empty") + } + try handle.seek(toOffset: 0) + var hasher = SHA256() + while true { + let chunk = try handle.read(upToCount: 4 * 1_024 * 1_024) ?? Data() + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + return DoryMachineSnapshotArtifact( + byteCount: byteCount, + sha256: hasher.finalize().map { String(format: "%02x", $0) }.joined() + ) + } + + fileprivate static func validateSnapshotArtifactEvidence( + _ snapshot: DoryMachineSnapshot + ) throws { + guard let expected = snapshot.artifactEvidence else { + // Old legacy snapshots remain decodable and usable under the explicitly selected + // compatibility policy. New snapshots always persist evidence. + guard snapshot.runtimeIdentity.mode == .legacyCompatibility else { + throw MachineManagerError.persistence( + "machine snapshot artifact evidence requires migration" + ) + } + return + } + guard expected.isValid, + (snapshot.bootMode == .efi) == (expected.machineIdentifier != nil) else { + throw MachineManagerError.persistence("invalid machine snapshot artifact evidence") + } + let actual = try snapshotArtifactEvidence( + rootfsPath: snapshot.rootfsPath, + kernelPath: snapshot.kernelPath, + machineIdentifierPath: snapshot.machineIdentifierPath, + nvramPath: snapshot.nvramPath + ) + guard actual == expected else { + throw MachineManagerError.persistence( + "machine snapshot artifacts do not match immutable evidence" + ) + } + } + + private func validateSnapshotRuntimeCompatibility( + _ snapshot: DoryMachineSnapshot, + machine: DoryMachineConfiguration?, + cloning: Bool + ) throws { + try Self.validateSnapshotRuntimeIdentity(snapshot) + switch snapshot.runtimeIdentity.mode { + case .legacyCompatibility: + guard launchPolicy == .legacyCompatibility else { + throw MachineManagerError.persistence( + "legacy snapshot requires explicit legacy compatibility launch policy" + ) + } + case .resolvedPlan: + guard !cloning else { + throw MachineManagerError.persistence( + "resolved snapshot identity is machine-bound and must be replanned before cloning" + ) + } + guard launchPolicy == .requireResolvedPlan, + let plan = snapshot.runtimeIdentity.resolvedPlan, + let registry = resolvedLaunchRegistry, + let planStore = resolvedLaunchPlanStore, + let descriptor = registry.backend(for: plan.backend)?.descriptor, + descriptor.identity == plan.backend, + descriptor.implementationIdentifier == plan.backendImplementationIdentifier, + let machine, + machine.id == plan.machineID else { + throw MachineManagerError.persistence( + "snapshot runtime is incompatible with installed resolved-launch infrastructure" + ) + } + let currentPlan: DoryResolvedMachinePlan + do { + currentPlan = try planStore.read(id: machine.id) + } catch { + throw MachineManagerError.persistence( + "snapshot resolved plan is unavailable: \(error)" + ) + } + guard currentPlan == plan, + snapshot.runtimeIdentity.resolvedPlanSHA256 + == DoryMachineRuntimeIdentity.planSHA256(currentPlan) else { + throw MachineManagerError.persistence( + "snapshot resolved plan no longer matches durable launch authority" + ) + } + case .requiresReplanning: + guard launchPolicy == .requireResolvedPlan, !cloning else { + throw MachineManagerError.persistence( + "snapshot requires a new resolved plan before it can launch" + ) + } + } + } + private func handleHandoff( machineID: String, launchID: UUID, @@ -3869,6 +4193,10 @@ public final class MachineManager: @unchecked Sendable { return hasher.finalize().map { String(format: "%02x", $0) }.joined() } + private static func sha256(data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + private static func isPrivateDirectory(info: stat) -> Bool { (info.st_mode & S_IFMT) == S_IFDIR && info.st_uid == getuid() @@ -3882,7 +4210,10 @@ public final class MachineManager: @unchecked Sendable { && info.st_size > 0 } - private static func loadPersistedMachines(configuration: MachineManagerConfiguration) -> [String: MachineEntry] { + private static func loadPersistedMachines( + configuration: MachineManagerConfiguration, + launchPolicy: DoryMachineLaunchPolicy + ) -> [String: MachineEntry] { let root = configuration.stateDirectory guard let ids = try? FileManager.default.contentsOfDirectory(atPath: root) else { return [:] @@ -3910,7 +4241,24 @@ public final class MachineManager: @unchecked Sendable { ) else { continue } - loaded[id] = MachineEntry(configuration: machine, state: .stopped) + let identity: DoryMachineRuntimeIdentity = switch launchPolicy { + case .legacyCompatibility: + .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + case .requireResolvedPlan: + .requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + } + loaded[id] = MachineEntry( + configuration: machine, + state: .stopped, + runtimeIdentity: identity + ) } return loaded } @@ -4001,6 +4349,8 @@ private enum MachineSnapshotBundle { private static let copyChunkSize = 4 * 1024 * 1024 static func write(snapshot: DoryMachineSnapshot, toPath path: String) throws { + try MachineManager.validateSnapshotRuntimeIdentity(snapshot) + try MachineManager.validateSnapshotArtifactEvidence(snapshot) let rootfs = try openRegularFileForReading(path: snapshot.rootfsPath, requirePrivateOwnership: true) defer { try? rootfs.close() } let kernel = try openRegularFileForReading(path: snapshot.kernelPath, requirePrivateOwnership: true) @@ -4343,12 +4693,32 @@ private enum MachineSnapshotBundle { throw MachineManagerError.persistence("corrupt dory machine bundle metadata") } let snapshot = try JSONDecoder().decode(DoryMachineSnapshot.self, from: metadata) + try MachineManager.validateSnapshotRuntimeIdentity(snapshot) guard snapshot.sizeBytes == Int64(rootfsLength) else { throw MachineManagerError.persistence("machine bundle rootfs size does not match metadata") } guard (snapshot.bootMode == .efi) == includesFirmware else { throw MachineManagerError.persistence("machine bundle boot mode does not match its artifacts") } + if let evidence = snapshot.artifactEvidence { + guard evidence.isValid, + evidence.rootfs.byteCount == rootfsLength, + evidence.rootfs.sha256 == digestHex(rootfsDigest), + evidence.kernel.byteCount == kernelLength, + evidence.kernel.sha256 == digestHex(kernelDigest), + evidence.machineIdentifier?.byteCount == machineIdentifierLength, + evidence.machineIdentifier?.sha256 == machineIdentifierDigest.map(digestHex), + evidence.nvram?.byteCount == nvramLength, + evidence.nvram?.sha256 == nvramDigest.map(digestHex) else { + throw MachineManagerError.persistence( + "machine bundle artifacts do not match immutable snapshot evidence" + ) + } + } else if snapshot.runtimeIdentity.mode != .legacyCompatibility { + throw MachineManagerError.persistence( + "machine bundle artifact evidence requires migration" + ) + } let artifactCount = includesFirmware ? 5 : 3 let fixedHeaderLength = UInt64( v3Magic.count + (lengthByteCount * artifactCount) + (digestByteCount * artifactCount) @@ -4407,6 +4777,10 @@ private enum MachineSnapshotBundle { ) } + private static func digestHex(_ digest: Data) -> String { + digest.map { String(format: "%02x", $0) }.joined() + } + private static func openRegularFileForReading( path: String, requirePrivateOwnership: Bool = false @@ -4535,6 +4909,10 @@ private struct MachineEntry { var runtimeAddress: String? var currentBalloonTargetMB: UInt64? var lastError: String? + var runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) } extension MachineManager: WakeClockSyncing { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift new file mode 100644 index 00000000..c7534409 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift @@ -0,0 +1,108 @@ +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("Machine runtime identity", .serialized) +struct DoryMachineRuntimeIdentityTests { + @Test("legacy and replanning identities round trip with an explicit hardware ABI") + func compatibilityIdentityRoundTrip() throws { + let identities: [DoryMachineRuntimeIdentity] = [ + .legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ), + .requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .definitionChanged + ), + ] + for identity in identities { + #expect(identity.validate().isEmpty) + let data = try JSONEncoder().encode(identity) + #expect(try JSONDecoder().decode(DoryMachineRuntimeIdentity.self, from: data) == identity) + #expect(identity.virtualHardwareABIVersion == 1) + } + } + + @Test("old snapshot metadata decodes as explicit legacy compatibility") + func oldestSnapshotGoldenDecodes() throws { + let data = Data(#""" + { + "id":"s1","machineID":"dev","note":"","createdISO":"2026-01-01T00:00:00Z", + "rootfsPath":"/state/dev/snapshots/s1.ext4","sizeBytes":4, + "kernelPath":"/state/dev/snapshots/s1.kernel","architecture":"arm64", + "memoryMB":2048,"cpuCount":2,"displayMode":"headless","shares":[], + "environment":{},"bootMode":"linux-kernel" + } + """#.utf8) + let snapshot = try JSONDecoder().decode(DoryMachineSnapshot.self, from: data) + #expect(snapshot.runtimeIdentity.mode == .legacyCompatibility) + #expect( + snapshot.runtimeIdentity.virtualHardwareABIVersion + == DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion + ) + #expect( + snapshot.runtimeIdentity.virtualHardwareABIVersion + != DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion + 1 + ) + #expect(snapshot.artifactEvidence == nil) + } + + @Test("new snapshots bind mutable artifacts and reject later disk tampering") + func snapshotArtifactTamperingIsRejected() throws { + let root = "/tmp/dory-runtime-identity-\(UUID().uuidString.lowercased())" + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: root, + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + )) + defer { try? FileManager.default.removeItem(atPath: root) } + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath + )) + let snapshot = try manager.snapshot(id: "dev", snapshotID: "s1") + let evidence = try #require(snapshot.artifactEvidence) + #expect(evidence.isValid) + #expect(evidence.rootfs.sha256.count == 64) + #expect(snapshot.runtimeIdentity.mode == .legacyCompatibility) + #expect(snapshot.runtimeIdentity.virtualHardwareABIVersion == 1) + + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: snapshot.rootfsPath)) + try handle.seek(toOffset: 0) + try handle.write(contentsOf: Data("tampered".utf8)) + try handle.close() + #expect(throws: MachineManagerError.self) { + _ = try manager.restoreSnapshot(machineID: "dev", snapshotID: "s1") + } + } + + @Test("resolved-plan policy never labels an unplanned machine as legacy") + func requiredPolicyStartsAsRequiresReplanning() throws { + let root = "/tmp/dory-runtime-identity-required-\(UUID().uuidString.lowercased())" + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: root, + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + launchPolicy: .requireResolvedPlan + ) + defer { try? FileManager.default.removeItem(atPath: root) } + let created = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath + )) + #expect(created.runtimeIdentity.mode == .requiresReplanning) + #expect(created.runtimeIdentity.invalidationReason == .planNotInstalled) + #expect(created.runtimeIdentity.virtualHardwareABIVersion == 1) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 590d8678..36b9bf35 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1098,6 +1098,10 @@ final class DorydServiceTests: XCTestCase { XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "running") XCTAssertNotNil(body["pid"]) + let runtime = body["runtimeIdentity"] as? NSDictionary + XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility") + XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1) + XCTAssertNil(runtime?["resolvedPlan"]) start.fulfill() } wait(for: [start], timeout: 5) @@ -1376,6 +1380,14 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(body["machineID"] as? String, "dev") XCTAssertEqual(body["note"] as? String, "before") XCTAssertEqual(body["architecture"] as? String, doryTestGuestArchitecture) + let runtime = body["runtimeIdentity"] as? NSDictionary + XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility") + XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1) + let artifacts = body["artifactEvidence"] as? NSDictionary + XCTAssertEqual( + ((artifacts?["rootfs"] as? NSDictionary)?["sha256"] as? String)?.count, + 64 + ) snapshotReply.fulfill() } wait(for: [snapshotReply], timeout: 5) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index bd52bb01..e1ae95d2 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -37,8 +37,155 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(identity.planRevision == 1) #expect(identity.backend == .doryHypervisor) #expect(identity.planSHA256.count == 64) + #expect(status.runtimeIdentity.mode == .resolvedPlan) + #expect(status.runtimeIdentity.planRevision == 1) + #expect(status.runtimeIdentity.definitionSHA256?.count == 64) + #expect( + status.runtimeIdentity.backendImplementationIdentifier + == RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier + ) + #expect(status.runtimeIdentity.virtualHardwareABIVersion == 1) + #expect(status.runtimeIdentity.components.count == 1) + let service = DorydService( + socketPath: state + "/service.sock", + machineManager: manager + ) + var xpcRows: NSArray = [] + service.machineList { rows, message in + #expect(message.isEmpty) + xpcRows = rows + } + let xpcStatus = try #require(xpcRows.firstObject as? NSDictionary) + let xpcIdentity = try #require( + xpcStatus["runtimeIdentity"] as? NSDictionary + ) + #expect(xpcIdentity["mode"] as? String == "resolved-plan") + #expect(xpcIdentity["planSHA256"] as? String == identity.planSHA256) + #expect(xpcIdentity["backend"] as? String == "dory-hypervisor") + #expect(xpcIdentity["resolvedPlan"] == nil) + let encodedXPC = try JSONSerialization.data(withJSONObject: xpcIdentity) + let xpcText = String(decoding: encodedXPC, as: UTF8.self) + #expect(!xpcText.contains("/bin/sleep")) + #expect(!xpcText.contains(state)) #expect(FileManager.default.fileExists(atPath: state + "/dev/machine.json")) _ = try manager.stop(id: "dev") + let snapshot = try manager.snapshot(id: "dev", snapshotID: "evidence") + #expect(snapshot.runtimeIdentity == status.runtimeIdentity) + #expect(snapshot.artifactEvidence?.rootfs.sha256.count == 64) + #expect(snapshot.artifactEvidence?.kernel.sha256.count == 64) + var tamperedIdentity = snapshot.runtimeIdentity + tamperedIdentity.resolvedPlanSHA256 = String(repeating: "0", count: 64) + #expect(tamperedIdentity.validate().contains { $0.code == .planDigestMismatch }) + + let restored = try manager.restoreSnapshot( + machineID: "dev", + snapshotID: "evidence" + ) + #expect(restored.state == .stopped) + #expect(restored.runtimeIdentity.mode == .requiresReplanning) + #expect(restored.runtimeIdentity.invalidationReason == .restoredSnapshot) + } + } + + @Test("resolved snapshot disk capacity is bound to resource admission") + func resolvedSnapshotRejectsSelfConsistentDifferentCapacityDisk() throws { + try withHarness("snapshot-storage-evidence") { manager, _, state in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + _ = try manager.start(id: "dev") + _ = try manager.stop(id: "dev") + var snapshot = try manager.snapshot(id: "dev", snapshotID: "capacity") + + let rootfsURL = URL(fileURLWithPath: snapshot.rootfsPath) + let handle = try FileHandle(forWritingTo: rootfsURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data([0])) + try handle.close() + let rootfsData = try Data(contentsOf: rootfsURL) + let rootfsSHA256 = SHA256.hash(data: rootfsData) + .map { String(format: "%02x", $0) }.joined() + snapshot.sizeBytes = Int64(rootfsData.count) + snapshot.artifactEvidence?.rootfs = DoryMachineSnapshotArtifact( + byteCount: UInt64(rootfsData.count), + sha256: rootfsSHA256 + ) + + let metadataPath = state + "/dev/snapshots/capacity.json" + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(snapshot).write( + to: URL(fileURLWithPath: metadataPath), + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: metadataPath + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.restoreSnapshot(machineID: "dev", snapshotID: "capacity") + } + } + } + + @Test("daemon restart recovers exact plan identity and definition changes invalidate it") + func restartRecoveryAndConfigurationInvalidation() throws { + try withHarness("restart-recovery") { manager, _, state in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + _ = try manager.start(id: "dev") + _ = try manager.stop(id: "dev") + + let restarted = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + acceleratedDesktopExecutablePath: "/bin/sleep", + stateDirectory: state, + baseArguments: ["30"], + acceleratedDesktopBaseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + launchPolicy: .requireResolvedPlan + ) + #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .requiresReplanning) + let restartedOperations = restarted.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + try restarted.installResolvedLaunchInfrastructure( + registry: rawRegistry(operations: restartedOperations), + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .resolvedPlan) + let updated = try restarted.update(id: "dev", memoryMB: 4_096) + #expect(updated.runtimeIdentity.mode == .requiresReplanning) + #expect(updated.runtimeIdentity.invalidationReason == .definitionChanged) } } From 2462e18c34d4b66b998143d353cffc0a10d98373 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 13:54:02 +0000 Subject: [PATCH 020/338] feat(vm): add durable artifact authority --- .../DoryVMResolverReferenceValidation.swift | 38 + .../DoryVirtualMachineArtifactAuthority.swift | 690 ++++++++++++++++++ .../DoryVirtualMachineDefinition.swift | 25 +- ...VirtualMachineArtifactAuthorityTests.swift | 505 +++++++++++++ 4 files changed, 1234 insertions(+), 24 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift b/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift new file mode 100644 index 00000000..991e3d13 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift @@ -0,0 +1,38 @@ +public extension DoryVMResolverReference { + /// Persistence-safe resolver key shared by WorkspaceSpec and daemon-private authorities. + /// Host paths, URLs, controls, and common credential/token shapes are intentionally excluded. + var isValidForPersistence: Bool { + let namespaceBytes = Array(namespace.utf8) + let identifierBytes = Array(identifier.utf8) + guard (1...32).contains(namespaceBytes.count), + let firstNamespaceByte = namespaceBytes.first, + firstNamespaceByte >= 97, + firstNamespaceByte <= 122, + namespaceBytes.dropFirst().allSatisfy({ byte in + (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 45 + }), + (1...64).contains(identifierBytes.count), + let firstIdentifierByte = identifierBytes.first, + Self.isASCIIAlphaNumeric(firstIdentifierByte), + identifierBytes.dropFirst().allSatisfy({ byte in + Self.isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45 + }) else { + return false + } + let lowercase = identifier.lowercased() + let secretPrefixes = [ + "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-", + "bearer-", "password-", "secret-", "token-", "akia", + ] + return !secretPrefixes.contains(where: lowercase.hasPrefix) + && !lowercase.hasPrefix("eyj") + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift new file mode 100644 index 00000000..d533bcc5 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift @@ -0,0 +1,690 @@ +import CryptoKit +import Darwin +import Foundation + +public enum DoryVirtualMachineArtifactAuthorityError: + Error, Sendable, Equatable, CustomStringConvertible +{ + case invalidReference + case invalidPath + case insecureArtifact + case artifactMissing + case artifactChanged + case staleRevision(expected: UInt64, actual: UInt64) + case invalidRecord + case filesystem(String) + + public var description: String { + switch self { + case .invalidReference: "artifact resolver reference is invalid" + case .invalidPath: "artifact path must be absolute and canonical" + case .insecureArtifact: "artifact must be a private owner-controlled single-link file" + case .artifactMissing: "artifact authority record does not exist" + case .artifactChanged: "artifact bytes or file identity changed after publication" + case let .staleRevision(expected, actual): + "stale artifact revision: expected \(expected), found \(actual)" + case .invalidRecord: "artifact authority record is invalid or tampered" + case let .filesystem(message): message + } + } +} + +public enum DoryVirtualMachineArtifactIdentity: Codable, Sendable, Equatable, Hashable { + case immutable(sha256: String, byteCount: UInt64) + case mutable( + provenance: DoryMutableBootMediaProvenanceReference, + sha256: String, + byteCount: UInt64, + device: UInt64, + inode: UInt64, + modifiedSeconds: Int64, + modifiedNanoseconds: Int64, + changedSeconds: Int64, + changedNanoseconds: Int64 + ) +} + +/// Daemon-private durable mapping. Host paths intentionally remain outside WorkspaceSpec and the +/// public API, while the exact non-secret identity is copied into resolved launch evidence. +public struct DoryVirtualMachineArtifactAuthorityRecord: Codable, Sendable, Equatable { + public static let schemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var reference: DoryVMResolverReference + public var path: String + public var kind: DoryBootMediaKind + public var source: DoryBootMediaSource + public var identity: DoryVirtualMachineArtifactIdentity + public var authorityRevision: UInt64 + + public init( + reference: DoryVMResolverReference, + path: String, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + identity: DoryVirtualMachineArtifactIdentity, + authorityRevision: UInt64 + ) { + schemaVersion = Self.schemaVersion + self.reference = reference + self.path = path + self.kind = kind + self.source = source + self.identity = identity + self.authorityRevision = authorityRevision + } +} + +public struct DoryVerifiedVirtualMachineArtifact: Sendable { + public let reference: DoryVMResolverReference + public let path: String + public let media: DoryBootMedia + public let authorityRevision: UInt64 + public let mutableProvenance: DoryTrustedMutableBootMediaProvenance? + + fileprivate init(record: DoryVirtualMachineArtifactAuthorityRecord) { + reference = record.reference + path = record.path + authorityRevision = record.authorityRevision + switch record.identity { + case let .immutable(sha256, _): + media = DoryBootMedia( + kind: record.kind, + source: record.source, + artifactSHA256: sha256 + ) + mutableProvenance = nil + case let .mutable(provenance, _, _, _, _, _, _, _, _): + let evidence = DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "dory-artifact-authority:\(record.reference.namespace):" + + "\(record.reference.identifier):\(provenance.revision)", + provenance: provenance, + receiptSHA256: Self.digest(Self.canonicalData(record)), + resolverID: DoryVirtualMachineArtifactAuthority.resolverID, + resolverVersion: DoryVirtualMachineArtifactAuthority.resolverVersion + ) + media = DoryBootMedia( + kind: record.kind, + source: record.source, + mutableProvenance: provenance + ) + mutableProvenance = DoryTrustedMutableBootMediaProvenance( + auditEvidence: evidence + ) + } + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +/// Crash-safe daemon authority for path-hostile workspace references. Every artifact, including a +/// mutable disk revision, is rehashed on resolution. Mutable disks require an explicit publication +/// after any write; changed bytes are rejected instead of silently advancing provenance at start. +/// +/// A successful resolution is a point-in-time verification snapshot. A launcher must still close +/// the path/exec TOCTOU window with descriptor-bound or immediate pre-spawn verification. +public final class DoryVirtualMachineArtifactAuthority: @unchecked Sendable { + public static let resolverID = "dory.artifact-authority" + public static let resolverVersion: UInt16 = 1 + + private static let maximumRecordBytes = 1 * 1_024 * 1_024 + private static let temporaryPrefix = ".artifact-authority." + private static let lockFilename = ".artifact-authority.lock" + + public let root: String + private let lock = NSLock() + private let publicationFaultInjector: (@Sendable (PublicationStage) throws -> Void)? + private let inspectionProgressInjector: (@Sendable (InspectionStage) throws -> Void)? + + enum PublicationStage: Sendable { + case temporaryFileSynced + } + + enum InspectionStage: Sendable { + case firstChunkHashed(path: String) + } + + public init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + publicationFaultInjector = nil + inspectionProgressInjector = nil + } + + init( + root: String, + publicationFaultInjector: @escaping @Sendable (PublicationStage) throws -> Void + ) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + self.publicationFaultInjector = publicationFaultInjector + inspectionProgressInjector = nil + } + + init( + root: String, + inspectionProgressInjector: @escaping @Sendable (InspectionStage) throws -> Void + ) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + publicationFaultInjector = nil + self.inspectionProgressInjector = inspectionProgressInjector + } + + @discardableResult + public func publishImmutable( + reference: DoryVMResolverReference, + path: String, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + expectedAuthorityRevision: UInt64? = nil + ) throws -> DoryVerifiedVirtualMachineArtifact { + try withExclusiveAccess { + let path = try Self.validatedPath(path) + let file = try Self.inspect( + path, + hashContents: true, + progressInjector: inspectionProgressInjector + ) + let current = try readIfPresent(reference) + try Self.validateExpectedRevision(expectedAuthorityRevision, current: current) + let revision = try Self.nextRevision(current) + let record = DoryVirtualMachineArtifactAuthorityRecord( + reference: reference, + path: path, + kind: kind, + source: source, + identity: .immutable( + sha256: try Self.requiredDigest(file), + byteCount: file.byteCount + ), + authorityRevision: revision + ) + try publish(record) + return DoryVerifiedVirtualMachineArtifact(record: record) + } + } + + @discardableResult + public func publishMutable( + reference: DoryVMResolverReference, + path: String, + kind: DoryBootMediaKind = .virtualDisk, + source: DoryBootMediaSource, + expectedAuthorityRevision: UInt64? = nil + ) throws -> DoryVerifiedVirtualMachineArtifact { + try withExclusiveAccess { + guard kind == .virtualDisk else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + let path = try Self.validatedPath(path) + let file = try Self.inspect( + path, + hashContents: true, + progressInjector: inspectionProgressInjector + ) + let current = try readIfPresent(reference) + try Self.validateExpectedRevision(expectedAuthorityRevision, current: current) + let revision = try Self.nextRevision(current) + let provenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: Self.digest(Data(root.utf8)), + mediaIdentity: Self.referenceIdentity(reference), + revision: revision + ) + let record = DoryVirtualMachineArtifactAuthorityRecord( + reference: reference, + path: path, + kind: kind, + source: source, + identity: .mutable( + provenance: provenance, + sha256: try Self.requiredDigest(file), + byteCount: file.byteCount, + device: file.device, + inode: file.inode, + modifiedSeconds: file.modifiedSeconds, + modifiedNanoseconds: file.modifiedNanoseconds, + changedSeconds: file.changedSeconds, + changedNanoseconds: file.changedNanoseconds + ), + authorityRevision: revision + ) + try publish(record) + return DoryVerifiedVirtualMachineArtifact(record: record) + } + } + + public func resolve( + reference: DoryVMResolverReference, + kind: DoryBootMediaKind, + source: DoryBootMediaSource + ) throws -> DoryVerifiedVirtualMachineArtifact { + try withExclusiveAccess { + guard let record = try readIfPresent(reference) else { + throw DoryVirtualMachineArtifactAuthorityError.artifactMissing + } + guard record.kind == kind, record.source == source else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + let file: InspectedFile + switch record.identity { + case .immutable: + file = try Self.inspect( + record.path, + hashContents: true, + progressInjector: inspectionProgressInjector + ) + case .mutable: + file = try Self.inspect( + record.path, + hashContents: true, + progressInjector: inspectionProgressInjector + ) + } + guard Self.file(file, matches: record.identity) else { + throw DoryVirtualMachineArtifactAuthorityError.artifactChanged + } + return DoryVerifiedVirtualMachineArtifact(record: record) + } + } + + /// Returns daemon-private publication metadata for optimistic reconciliation. This does not + /// verify current artifact bytes and therefore never returns an opaque trusted receipt. + public func authorityRecord( + reference: DoryVMResolverReference + ) throws -> DoryVirtualMachineArtifactAuthorityRecord? { + try withExclusiveAccess { try readIfPresent(reference) } + } + + private func publish(_ record: DoryVirtualMachineArtifactAuthorityRecord) throws { + try validate(record) + let directory = root + try Self.ensurePrivateDirectory(directory) + let path = recordPath(record.reference) + let envelope = RecordEnvelope( + recordSHA256: Self.digest(Self.canonicalData(record)), + record: record + ) + let data = Self.canonicalData(envelope) + Data("\n".utf8) + let temporary = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString)" + let descriptor = temporary.withCString { + open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "create artifact authority record: errno \(errno)" + ) + } + var descriptorIsOpen = true + do { + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, + bytes.baseAddress!.advanced(by: offset), + bytes.count - offset + ) + guard count > 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "write artifact authority record: errno \(errno)" + ) + } + offset += count + } + } + guard fsync(descriptor) == 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "sync artifact authority record: errno \(errno)" + ) + } + close(descriptor) + descriptorIsOpen = false + try publicationFaultInjector?(.temporaryFileSynced) + guard rename(temporary, path) == 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "publish artifact authority record: errno \(errno)" + ) + } + try Self.syncDirectory(directory) + } catch { + if descriptorIsOpen { close(descriptor) } + unlink(temporary) + throw error + } + } + + private func readIfPresent( + _ reference: DoryVMResolverReference + ) throws -> DoryVirtualMachineArtifactAuthorityRecord? { + try Self.validate(reference) + let path = recordPath(reference) + guard FileManager.default.fileExists(atPath: path) else { return nil } + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + defer { close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_mode & 0o077 == 0, + status.st_size > 0, + status.st_size <= Self.maximumRecordBytes else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1_024) + while true { + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count < 0, errno == EINTR { continue } + guard count >= 0, data.count + count <= Self.maximumRecordBytes else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + if count == 0 { break } + data.append(contentsOf: buffer.prefix(count)) + } + guard let envelope = try? JSONDecoder().decode(RecordEnvelope.self, from: data), + data == Self.canonicalData(envelope) + Data("\n".utf8), + envelope.recordSHA256 == Self.digest(Self.canonicalData(envelope.record)), + envelope.record.reference == reference else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + try validate(envelope.record) + return envelope.record + } + + private func recordPath(_ reference: DoryVMResolverReference) -> String { + root + "/" + Self.referenceIdentity(reference) + ".json" + } + + private struct RecordEnvelope: Codable { + var recordSHA256: String + var record: DoryVirtualMachineArtifactAuthorityRecord + } + + private struct InspectedFile { + var byteCount: UInt64 + var device: UInt64 + var inode: UInt64 + var modifiedSeconds: Int64 + var modifiedNanoseconds: Int64 + var changedSeconds: Int64 + var changedNanoseconds: Int64 + var sha256: String? + } + + private static func inspect( + _ path: String, + hashContents: Bool, + progressInjector: (@Sendable (InspectionStage) throws -> Void)? + ) throws -> InspectedFile { + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.insecureArtifact + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + isPrivateArtifact(before) else { + throw DoryVirtualMachineArtifactAuthorityError.insecureArtifact + } + var digest: String? + if hashContents { + var hasher = SHA256() + var buffer = [UInt8](repeating: 0, count: 1 * 1_024 * 1_024) + var reportedProgress = false + while true { + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count < 0, errno == EINTR { continue } + guard count >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.artifactChanged + } + if count == 0 { break } + hasher.update(data: Data(buffer.prefix(count))) + if !reportedProgress { + reportedProgress = true + try progressInjector?(.firstChunkHashed(path: path)) + } + } + digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + var after = stat() + guard fstat(descriptor, &after) == 0, + isPrivateArtifact(after), + sameArtifactSnapshot(before, after) else { + throw DoryVirtualMachineArtifactAuthorityError.artifactChanged + } + return InspectedFile( + byteCount: UInt64(after.st_size), + device: UInt64(after.st_dev), + inode: UInt64(after.st_ino), + modifiedSeconds: Int64(after.st_mtimespec.tv_sec), + modifiedNanoseconds: Int64(after.st_mtimespec.tv_nsec), + changedSeconds: Int64(after.st_ctimespec.tv_sec), + changedNanoseconds: Int64(after.st_ctimespec.tv_nsec), + sha256: digest + ) + } + + private static func isPrivateArtifact(_ status: stat) -> Bool { + status.st_mode & S_IFMT == S_IFREG + && status.st_uid == geteuid() + && status.st_nlink == 1 + && status.st_mode & 0o077 == 0 + && status.st_size > 0 + } + + private static func sameArtifactSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec + && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } + + private static func file( + _ file: InspectedFile, + matches identity: DoryVirtualMachineArtifactIdentity + ) -> Bool { + switch identity { + case let .immutable(sha256, byteCount): + return file.byteCount == byteCount && file.sha256 == sha256 + case let .mutable( + _, sha256, byteCount, device, inode, modifiedSeconds, modifiedNanoseconds, + changedSeconds, changedNanoseconds + ): + return file.byteCount == byteCount + && file.sha256 == sha256 + && file.device == device + && file.inode == inode + && file.modifiedSeconds == modifiedSeconds + && file.modifiedNanoseconds == modifiedNanoseconds + && file.changedSeconds == changedSeconds + && file.changedNanoseconds == changedNanoseconds + } + } + + private static func requiredDigest(_ file: InspectedFile) throws -> String { + guard let digest = file.sha256 else { + throw DoryVirtualMachineArtifactAuthorityError.artifactChanged + } + return digest + } + + private static func validatedPath(_ value: String) throws -> String { + guard value.hasPrefix("/"), !value.contains("\0") else { + throw DoryVirtualMachineArtifactAuthorityError.invalidPath + } + let standardized = URL(fileURLWithPath: value).standardizedFileURL.path + guard standardized == value, standardized != "/" else { + throw DoryVirtualMachineArtifactAuthorityError.invalidPath + } + return standardized + } + + private static func validate(_ reference: DoryVMResolverReference) throws { + guard reference.isValidForPersistence else { + throw DoryVirtualMachineArtifactAuthorityError.invalidReference + } + } + + private func validate(_ record: DoryVirtualMachineArtifactAuthorityRecord) throws { + try Self.validate(record.reference) + guard record.schemaVersion == DoryVirtualMachineArtifactAuthorityRecord.schemaVersion, + record.authorityRevision > 0, + (try? Self.validatedPath(record.path)) == record.path else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + switch record.identity { + case let .immutable(sha256, byteCount): + guard record.kind != .virtualDisk, Self.isSHA256(sha256), byteCount > 0 else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + case let .mutable(provenance, sha256, byteCount, _, _, _, _, _, _): + guard record.kind == .virtualDisk, + byteCount > 0, + Self.isSHA256(sha256), + provenance.revision == record.authorityRevision, + provenance.mediaIdentity == Self.referenceIdentity(record.reference), + provenance.repositoryIdentity == Self.digest(Data(root.utf8)) else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + } + } + + private static func validateExpectedRevision( + _ expected: UInt64?, + current: DoryVirtualMachineArtifactAuthorityRecord? + ) throws { + guard let expected else { + guard current == nil else { + throw DoryVirtualMachineArtifactAuthorityError.staleRevision( + expected: 0, + actual: current!.authorityRevision + ) + } + return + } + let actual = current?.authorityRevision ?? 0 + guard expected == actual else { + throw DoryVirtualMachineArtifactAuthorityError.staleRevision( + expected: expected, + actual: actual + ) + } + } + + private static func nextRevision( + _ current: DoryVirtualMachineArtifactAuthorityRecord? + ) throws -> UInt64 { + guard let current else { return 1 } + guard current.authorityRevision < UInt64.max else { + throw DoryVirtualMachineArtifactAuthorityError.invalidRecord + } + return current.authorityRevision + 1 + } + + private static func referenceIdentity(_ reference: DoryVMResolverReference) -> String { + digest(Data("\(reference.namespace)\u{0}\(reference.identifier)".utf8)) + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...70).contains(byte) + || (97...102).contains(byte) + } + } + + private static func ensurePrivateDirectory(_ path: String) throws { + if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "create artifact authority directory: errno \(errno)" + ) + } + var status = stat() + guard lstat(path, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == geteuid(), + status.st_mode & 0o077 == 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "artifact authority directory is not private" + ) + } + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = path.withCString { + open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + guard descriptor >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "open artifact authority directory: errno \(errno)" + ) + } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "sync artifact authority directory: errno \(errno)" + ) + } + } + + private func withExclusiveAccess(_ body: () throws -> T) throws -> T { + try lock.withLock { + try Self.ensurePrivateDirectory(root) + let lockPath = root + "/" + Self.lockFilename + let descriptor = lockPath.withCString { + open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "open artifact authority lock: errno \(errno)" + ) + } + defer { close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_mode & 0o077 == 0 else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "artifact authority lock is not private" + ) + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { + throw DoryVirtualMachineArtifactAuthorityError.filesystem( + "lock artifact authority: errno \(errno)" + ) + } + } + defer { _ = flock(descriptor, LOCK_UN) } + return try body() + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 0a42efa6..48d70d60 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -846,30 +846,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } private static func isSafeResolverReference(_ reference: DoryVMResolverReference) -> Bool { - let namespace = Array(reference.namespace.utf8) - let identifier = Array(reference.identifier.utf8) - guard (1...32).contains(namespace.count), - namespace[0] >= 97, - namespace[0] <= 122, - namespace.dropFirst().allSatisfy({ byte in - (byte >= 97 && byte <= 122) - || (byte >= 48 && byte <= 57) - || byte == 45 - }), - (1...64).contains(identifier.count), - isASCIIAlphaNumeric(identifier[0]), - identifier.dropFirst().allSatisfy({ byte in - isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45 - }) else { - return false - } - let lowercase = reference.identifier.lowercased() - let secretPrefixes = [ - "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-", - "bearer-", "password-", "secret-", "token-", "akia", - ] - return !secretPrefixes.contains(where: lowercase.hasPrefix) - && !lowercase.hasPrefix("eyj") + reference.isValidForPersistence } private static func bootMediaIsCompatible( diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift new file mode 100644 index 00000000..b3c05d01 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift @@ -0,0 +1,505 @@ +@testable import DoryOperations +import Darwin +import Foundation +import XCTest + +final class DoryVirtualMachineArtifactAuthorityTests: XCTestCase { + func testImmutableArtifactIsContentAddressedAndRevalidated() throws { + try withFixture("immutable") { fixture in + let artifact = try fixture.file("installer.iso", data: Data("efi-image".utf8)) + let published = try fixture.authority.publishImmutable( + reference: fixture.reference, + path: artifact, + kind: .installerISO, + source: .userProvided + ) + XCTAssertEqual(published.authorityRevision, 1) + XCTAssertNotNil(published.media.artifactSHA256) + XCTAssertEqual( + try fixture.authority.resolve( + reference: fixture.reference, + kind: .installerISO, + source: .userProvided + ).media, + published.media + ) + + try Data("changed-image".utf8).write(to: URL(fileURLWithPath: artifact)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: artifact + ) + XCTAssertThrowsError(try fixture.authority.resolve( + reference: fixture.reference, + kind: .installerISO, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .artifactChanged + ) + } + } + } + + func testMutableArtifactRequiresExplicitRevisionPublication() throws { + try withFixture("mutable") { fixture in + let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096)) + let first = try fixture.authority.publishMutable( + reference: fixture.reference, + path: disk, + source: .userProvided + ) + XCTAssertEqual(first.media.mutableProvenance?.revision, 1) + XCTAssertNotNil(first.mutableProvenance) + + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: disk)) + try handle.seek(toOffset: 10) + try handle.write(contentsOf: Data([1])) + try handle.synchronize() + try handle.close() + XCTAssertThrowsError(try fixture.authority.resolve( + reference: fixture.reference, + kind: .virtualDisk, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .artifactChanged + ) + } + + let second = try fixture.authority.publishMutable( + reference: fixture.reference, + path: disk, + source: .userProvided, + expectedAuthorityRevision: 1 + ) + XCTAssertEqual(second.media.mutableProvenance?.revision, 2) + XCTAssertEqual( + try fixture.authority.resolve( + reference: fixture.reference, + kind: .virtualDisk, + source: .userProvided + ).authorityRevision, + 2 + ) + } + } + + func testMutableArtifactDigestRejectsSameSizeMutationWithRestoredMtime() throws { + try withFixture("mutable-adversarial") { fixture in + let disk = try fixture.file( + "system.raw", + data: Data(repeating: 0x41, count: 4_096) + ) + _ = try fixture.authority.publishMutable( + reference: fixture.reference, + path: disk, + source: .userProvided + ) + + var publishedStatus = stat() + XCTAssertEqual(lstat(disk, &publishedStatus), 0) + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: disk)) + try handle.seek(toOffset: 2_048) + try handle.write(contentsOf: Data([0x42])) + try handle.synchronize() + try handle.close() + let timestamps = [publishedStatus.st_atimespec, publishedStatus.st_mtimespec] + let restoreResult = timestamps.withUnsafeBufferPointer { buffer in + utimensat(AT_FDCWD, disk, buffer.baseAddress, AT_SYMLINK_NOFOLLOW) + } + XCTAssertEqual(restoreResult, 0) + + var restoredStatus = stat() + XCTAssertEqual(lstat(disk, &restoredStatus), 0) + XCTAssertEqual(restoredStatus.st_size, publishedStatus.st_size) + XCTAssertEqual( + restoredStatus.st_mtimespec.tv_sec, + publishedStatus.st_mtimespec.tv_sec + ) + XCTAssertEqual( + restoredStatus.st_mtimespec.tv_nsec, + publishedStatus.st_mtimespec.tv_nsec + ) + XCTAssertThrowsError(try fixture.authority.resolve( + reference: fixture.reference, + kind: .virtualDisk, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .artifactChanged + ) + } + } + } + + func testMutationDuringHashWithRestoredMtimeIsRejectedByChangeTime() throws { + try withFixture("mutable-in-hash") { fixture in + let disk = try fixture.file( + "system.raw", + data: Data(repeating: 0x41, count: 2 * 1_024 * 1_024) + ) + _ = try fixture.authority.publishMutable( + reference: fixture.reference, + path: disk, + source: .userProvided + ) + let mutation = try InHashMutation(path: disk) + let inspectingAuthority = DoryVirtualMachineArtifactAuthority( + root: fixture.authority.root + ) { stage in + guard case let .firstChunkHashed(path) = stage else { return } + try mutation.apply(to: path) + } + + XCTAssertThrowsError(try inspectingAuthority.resolve( + reference: fixture.reference, + kind: .virtualDisk, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .artifactChanged + ) + } + XCTAssertTrue(mutation.didApply) + } + } + + func testOptimisticRevisionAndRecordTamperFailClosed() throws { + try withFixture("revision") { fixture in + let artifact = try fixture.file("bundle", data: Data("bundle".utf8)) + _ = try fixture.authority.publishImmutable( + reference: fixture.reference, + path: artifact, + kind: .installedLinuxBootBundle, + source: .bundledByDory + ) + XCTAssertThrowsError(try fixture.authority.publishImmutable( + reference: fixture.reference, + path: artifact, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + expectedAuthorityRevision: 0 + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .staleRevision(expected: 0, actual: 1) + ) + } + + let records = try FileManager.default.contentsOfDirectory( + atPath: fixture.authority.root + ).filter { $0.hasSuffix(".json") } + let record = try XCTUnwrap(records.first) + let path = fixture.authority.root + "/" + record + var data = try Data(contentsOf: URL(fileURLWithPath: path)) + data.append(0x20) + try data.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + XCTAssertThrowsError(try fixture.authority.resolve( + reference: fixture.reference, + kind: .installedLinuxBootBundle, + source: .bundledByDory + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .invalidRecord + ) + } + } + } + + func testSymlinksHardlinksAndWorldReadableArtifactsAreRejected() throws { + try withFixture("unsafe") { fixture in + let target = try fixture.file("target", data: Data("artifact".utf8)) + let symlink = fixture.root + "/link" + try FileManager.default.createSymbolicLink( + atPath: symlink, + withDestinationPath: target + ) + XCTAssertThrowsError(try fixture.authority.publishImmutable( + reference: fixture.reference, + path: symlink, + kind: .installerISO, + source: .userProvided + )) + + let hardlink = fixture.root + "/hardlink" + XCTAssertEqual(link(target, hardlink), 0) + XCTAssertThrowsError(try fixture.authority.publishImmutable( + reference: fixture.reference, + path: target, + kind: .installerISO, + source: .userProvided + )) + try FileManager.default.removeItem(atPath: hardlink) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: target + ) + XCTAssertThrowsError(try fixture.authority.publishImmutable( + reference: fixture.reference, + path: target, + kind: .installerISO, + source: .userProvided + )) + } + } + + func testResolverReferencesUseTheWorkspacePersistenceContract() throws { + try withFixture("hostile-reference") { fixture in + let artifact = try fixture.file("image.iso", data: Data("image".utf8)) + let hostile = [ + DoryVMResolverReference(namespace: "artifact", identifier: "/tmp/image.iso"), + DoryVMResolverReference( + namespace: "artifact", + identifier: "https://example.com/image.iso" + ), + DoryVMResolverReference(namespace: "artifact", identifier: "sk-secret"), + DoryVMResolverReference(namespace: "Bad.Namespace", identifier: "image"), + DoryVMResolverReference( + namespace: "artifact", + identifier: String(repeating: "a", count: 65) + ), + ] + for reference in hostile { + XCTAssertFalse(reference.isValidForPersistence) + XCTAssertThrowsError(try fixture.authority.publishImmutable( + reference: reference, + path: artifact, + kind: .installerISO, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .invalidReference + ) + } + } + } + } + + func testTwoAuthorityInstancesSerializeOptimisticRevision() async throws { + let fixture = try ArtifactFixture("cross-instance") + defer { fixture.cleanup() } + let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096)) + _ = try fixture.authority.publishMutable( + reference: fixture.reference, + path: disk, + source: .userProvided + ) + let secondAuthority = DoryVirtualMachineArtifactAuthority(root: fixture.authority.root) + let reference = fixture.reference + + let results = await withTaskGroup( + of: Result.self, + returning: [Result].self + ) { group in + for authority in [fixture.authority, secondAuthority] { + group.addTask { + do { + let result = try authority.publishMutable( + reference: reference, + path: disk, + source: .userProvided, + expectedAuthorityRevision: 1 + ) + return .success(result.authorityRevision) + } catch let error as DoryVirtualMachineArtifactAuthorityError { + return .failure(error) + } catch { + return .failure(.filesystem("unexpected error")) + } + } + } + var values: [Result] = [] + for await result in group { values.append(result) } + return values + } + + XCTAssertEqual(results.compactMap { try? $0.get() }, [2]) + XCTAssertEqual(results.compactMap { result -> DoryVirtualMachineArtifactAuthorityError? in + guard case let .failure(error) = result else { return nil } + return error + }, [.staleRevision(expected: 1, actual: 2)]) + } + + func testMutableRecordCannotBeSubstitutedIntoAnotherAuthorityRoot() throws { + try withFixture("root-binding-source") { source in + try withFixture("root-binding-destination") { destination in + let disk = try source.file( + "system.raw", + data: Data(repeating: 0, count: 4_096) + ) + _ = try source.authority.publishMutable( + reference: source.reference, + path: disk, + source: .userProvided + ) + _ = try destination.authority.authorityRecord(reference: destination.reference) + let recordName = try XCTUnwrap(try FileManager.default.contentsOfDirectory( + atPath: source.authority.root + ).first { $0.hasSuffix(".json") }) + let destinationRecord = destination.authority.root + "/" + recordName + try FileManager.default.copyItem( + atPath: source.authority.root + "/" + recordName, + toPath: destinationRecord + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: destinationRecord + ) + + XCTAssertThrowsError(try destination.authority.resolve( + reference: destination.reference, + kind: .virtualDisk, + source: .userProvided + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineArtifactAuthorityError, + .invalidRecord + ) + } + } + } + } + + func testInterruptedReplacementPreservesOldRecordAndCleansTemporaryFile() throws { + try withFixture("interrupted-publication") { fixture in + let artifact = try fixture.file("bundle", data: Data("bundle".utf8)) + _ = try fixture.authority.publishImmutable( + reference: fixture.reference, + path: artifact, + kind: .installedLinuxBootBundle, + source: .bundledByDory + ) + let interrupted = DoryVirtualMachineArtifactAuthority( + root: fixture.authority.root + ) { stage in + guard case .temporaryFileSynced = stage else { return } + throw PublicationInterruption.injected + } + XCTAssertThrowsError(try interrupted.publishImmutable( + reference: fixture.reference, + path: artifact, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + expectedAuthorityRevision: 1 + )) { error in + XCTAssertEqual(error as? PublicationInterruption, .injected) + } + XCTAssertEqual( + try fixture.authority.resolve( + reference: fixture.reference, + kind: .installedLinuxBootBundle, + source: .bundledByDory + ).authorityRevision, + 1 + ) + XCTAssertFalse(try FileManager.default.contentsOfDirectory( + atPath: fixture.authority.root + ).contains { $0.hasPrefix(".artifact-authority.") && $0 != ".artifact-authority.lock" }) + } + } + + private func withFixture( + _ name: String, + body: (ArtifactFixture) throws -> Void + ) throws { + let fixture = try ArtifactFixture(name) + defer { fixture.cleanup() } + try body(fixture) + } +} + +private enum PublicationInterruption: Error, Equatable { + case injected +} + +private final class InHashMutation: @unchecked Sendable { + private let expectedPath: String + private let timestamps: [timespec] + private let lock = NSLock() + private var applied = false + + init(path: String) throws { + var status = stat() + guard lstat(path, &status) == 0 else { throw InHashMutationError.statFailed } + expectedPath = path + timestamps = [status.st_atimespec, status.st_mtimespec] + } + + var didApply: Bool { lock.withLock { applied } } + + func apply(to path: String) throws { + try lock.withLock { + guard !applied else { return } + guard path == expectedPath else { throw InHashMutationError.unexpectedPath } + let descriptor = path.withCString { + open($0, O_WRONLY | O_CLOEXEC | O_NOFOLLOW) + } + guard descriptor >= 0 else { throw InHashMutationError.openFailed } + defer { close(descriptor) } + var byte: UInt8 = 0x42 + guard pwrite(descriptor, &byte, 1, 0) == 1, + fsync(descriptor) == 0 else { + throw InHashMutationError.writeFailed + } + let result = timestamps.withUnsafeBufferPointer { buffer in + utimensat(AT_FDCWD, path, buffer.baseAddress, AT_SYMLINK_NOFOLLOW) + } + guard result == 0 else { throw InHashMutationError.restoreTimeFailed } + applied = true + } + } +} + +private enum InHashMutationError: Error { + case statFailed + case unexpectedPath + case openFailed + case writeFailed + case restoreTimeFailed +} + +private final class ArtifactFixture { + let root: String + let authority: DoryVirtualMachineArtifactAuthority + let reference = DoryVMResolverReference( + namespace: "legacy-artifact", + identifier: String(repeating: "a", count: 64) + ) + + init(_ name: String) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-artifact-authority-\(name)-\(UUID().uuidString)", + isDirectory: true + ).path + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: root + ) + authority = DoryVirtualMachineArtifactAuthority(root: root + "/records") + } + + func file(_ name: String, data: Data) throws -> String { + let path = root + "/" + name + try data.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + return path + } + + func cleanup() { try? FileManager.default.removeItem(atPath: root) } +} From a80448a51439fe63dd63dcdc2ef18a709006798c Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 13:54:40 +0000 Subject: [PATCH 021/338] feat(workspace): version lifecycle runtime bindings --- .../DoryWorkspaceLifecycleOperation.swift | 391 +++++++++++++++++- ...DoryWorkspaceLifecycleOperationTests.swift | 204 ++++++++- 2 files changed, 572 insertions(+), 23 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift index 134d9874..224abda1 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift @@ -77,34 +77,232 @@ public struct DoryWorkspaceResolvedCondition: Codable, Sendable, Equatable { } } +public enum DoryWorkspaceRuntimePolicy: String, Codable, CaseIterable, Sendable { + case legacyCompatibility = "legacy-compatibility" + case requireResolvedPlan = "require-resolved-plan" +} + +public enum DoryWorkspaceRuntimeAuthorizationState: String, Codable, CaseIterable, Sendable { + case legacyCompatibility = "legacy-compatibility" + case resolvedPlan = "resolved-plan" + case requiresReplanning = "requires-replanning" +} + +/// Exact runtime-policy authority for a lifecycle transition. Legacy compatibility is explicit +/// and cannot carry plan claims; resolved operation bindings must carry the complete immutable +/// plan identity used by the executor. `runtimeIdentityDigest` binds the full status identity, +/// including a requires-replanning identity when a resolved-policy restore invalidates launch. +public struct DoryWorkspaceRuntimeBinding: Codable, Sendable, Equatable { + public var policy: DoryWorkspaceRuntimePolicy + public var authorizationState: DoryWorkspaceRuntimeAuthorizationState + public var virtualHardwareABIVersion: UInt16 + public var runtimeIdentityDigest: String + public var resolved: DoryWorkspaceResolvedCondition? + + public init( + policy: DoryWorkspaceRuntimePolicy, + authorizationState: DoryWorkspaceRuntimeAuthorizationState, + virtualHardwareABIVersion: UInt16, + runtimeIdentityDigest: String, + resolved: DoryWorkspaceResolvedCondition? = nil + ) { + self.policy = policy + self.authorizationState = authorizationState + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.runtimeIdentityDigest = runtimeIdentityDigest + self.resolved = resolved + } + + public static func legacyCompatibility( + virtualHardwareABIVersion: UInt16, + runtimeIdentityDigest: String + ) -> Self { + Self( + policy: .legacyCompatibility, + authorizationState: .legacyCompatibility, + virtualHardwareABIVersion: virtualHardwareABIVersion, + runtimeIdentityDigest: runtimeIdentityDigest + ) + } + + public static func resolvedPlan( + _ resolved: DoryWorkspaceResolvedCondition, + runtimeIdentityDigest: String + ) -> Self { + Self( + policy: .requireResolvedPlan, + authorizationState: .resolvedPlan, + virtualHardwareABIVersion: resolved.virtualHardwareABIVersion, + runtimeIdentityDigest: runtimeIdentityDigest, + resolved: resolved + ) + } + + public static func requiresReplanning( + virtualHardwareABIVersion: UInt16, + runtimeIdentityDigest: String + ) -> Self { + Self( + policy: .requireResolvedPlan, + authorizationState: .requiresReplanning, + virtualHardwareABIVersion: virtualHardwareABIVersion, + runtimeIdentityDigest: runtimeIdentityDigest + ) + } + + fileprivate var isValid: Bool { + guard virtualHardwareABIVersion > 0, + DoryOperationJournalStore.isDigest(runtimeIdentityDigest) else { + return false + } + switch (policy, authorizationState) { + case (.legacyCompatibility, .legacyCompatibility): + return resolved == nil + case (.requireResolvedPlan, .resolvedPlan): + return resolved?.isValid == true + && resolved?.virtualHardwareABIVersion == virtualHardwareABIVersion + case (.requireResolvedPlan, .requiresReplanning): + return resolved == nil + default: + return false + } + } +} + +/// Content authority only; host paths and configuration values never enter the journal contract. +public struct DoryWorkspaceConfigurationAuthority: Codable, Sendable, Equatable { + public var legacyConfigurationSHA256: String + public var canonicalDefinitionSHA256: String? + + public init( + legacyConfigurationSHA256: String, + canonicalDefinitionSHA256: String? = nil + ) { + self.legacyConfigurationSHA256 = legacyConfigurationSHA256 + self.canonicalDefinitionSHA256 = canonicalDefinitionSHA256 + } + + fileprivate var isValid: Bool { + DoryOperationJournalStore.isDigest(legacyConfigurationSHA256) + && (canonicalDefinitionSHA256.map(DoryOperationJournalStore.isDigest) ?? true) + } +} + public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable { + private var persistenceSchemaVersion: UInt16 public var workspaceID: String public var state: DoryWorkspaceLifecycleState public var definitionRevision: UInt64? - public var resolved: DoryWorkspaceResolvedCondition? + public var runtime: DoryWorkspaceRuntimeBinding? + public var configurationAuthority: DoryWorkspaceConfigurationAuthority? + + /// Read-only source compatibility for schema-v1 inspection. New construction must provide an + /// exact tagged runtime binding and cannot synthesize an identity digest from a plan digest. + public var resolved: DoryWorkspaceResolvedCondition? { runtime?.resolved } public init( workspaceID: String, state: DoryWorkspaceLifecycleState, definitionRevision: UInt64? = nil, - resolved: DoryWorkspaceResolvedCondition? = nil + runtime: DoryWorkspaceRuntimeBinding? = nil, + configurationAuthority: DoryWorkspaceConfigurationAuthority? = nil ) { + persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.schemaVersion self.workspaceID = workspaceID self.state = state self.definitionRevision = definitionRevision - self.resolved = resolved + self.runtime = runtime + self.configurationAuthority = configurationAuthority } fileprivate var isValid: Bool { guard DoryOperationJournalStore.isToken(workspaceID) else { return false } if state == .absent { - return definitionRevision == nil && resolved == nil + return definitionRevision == nil && runtime == nil && configurationAuthority == nil } return definitionRevision.map { $0 > 0 } == true - && (resolved?.isValid ?? true) + && (runtime?.isValid ?? true) + && (configurationAuthority?.isValid ?? true) && (!(state == .running || state == .paused || state == .suspended) - || resolved != nil) + || (runtime != nil + && runtime?.authorizationState != .requiresReplanning)) } + + private enum CodingKeys: String, CodingKey { + case conditionSchemaVersion + case workspaceID + case state + case definitionRevision + case resolved + case runtime + case configurationAuthority + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + workspaceID = try container.decode(String.self, forKey: .workspaceID) + state = try container.decode(DoryWorkspaceLifecycleState.self, forKey: .state) + definitionRevision = try container.decodeIfPresent(UInt64.self, forKey: .definitionRevision) + if let conditionSchemaVersion = try container.decodeIfPresent( + UInt16.self, + forKey: .conditionSchemaVersion + ) { + guard conditionSchemaVersion == DoryWorkspaceLifecycleOperation.schemaVersion, + !container.contains(.resolved) else { + throw DecodingError.dataCorruptedError( + forKey: .conditionSchemaVersion, + in: container, + debugDescription: "unsupported workspace lifecycle condition schema" + ) + } + persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.schemaVersion + runtime = try container.decodeIfPresent( + DoryWorkspaceRuntimeBinding.self, + forKey: .runtime + ) + configurationAuthority = try container.decodeIfPresent( + DoryWorkspaceConfigurationAuthority.self, + forKey: .configurationAuthority + ) + } else { + guard !container.contains(.runtime), !container.contains(.configurationAuthority) else { + throw DecodingError.dataCorruptedError( + forKey: .conditionSchemaVersion, + in: container, + debugDescription: "workspace lifecycle condition schema marker is required" + ) + } + persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.oldestSupportedSchemaVersion + configurationAuthority = nil + if let legacyResolved = try container.decodeIfPresent( + DoryWorkspaceResolvedCondition.self, + forKey: .resolved + ) { + runtime = .resolvedPlan( + legacyResolved, + runtimeIdentityDigest: legacyResolved.planDigest + ) + } else { + runtime = nil + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(workspaceID, forKey: .workspaceID) + try container.encode(state, forKey: .state) + try container.encodeIfPresent(definitionRevision, forKey: .definitionRevision) + if persistenceSchemaVersion == DoryWorkspaceLifecycleOperation.oldestSupportedSchemaVersion { + try container.encodeIfPresent(runtime?.resolved, forKey: .resolved) + } else { + try container.encode(persistenceSchemaVersion, forKey: .conditionSchemaVersion) + try container.encodeIfPresent(runtime, forKey: .runtime) + try container.encodeIfPresent(configurationAuthority, forKey: .configurationAuthority) + } + } + + fileprivate var encodedSchemaVersion: UInt16 { persistenceSchemaVersion } } public enum DoryWorkspaceOperationStage: String, Codable, CaseIterable, Sendable { @@ -246,14 +444,18 @@ public struct DoryWorkspaceLifecycleJournalBinding: Sendable, Equatable { /// actual progress to `DoryOperationJournalStore`; this specification is the exact transition, /// deadline, retry, readiness, cancellation, and recovery contract they must follow. public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { - public static let schemaVersion: UInt16 = 1 + public static let oldestSupportedSchemaVersion: UInt16 = 1 + public static let schemaVersion: UInt16 = 2 public var schemaVersion: UInt16 + public var sourceSchemaVersion: UInt16 public var operationID: UUID public var kind: DoryWorkspaceMutationKind public var source: DoryWorkspaceLifecycleCondition public var target: DoryWorkspaceLifecycleCondition public var targetWorkspaceID: String? + /// Stable snapshot or clone identity needed for deterministic recovery; never a host path. + public var targetResourceID: String? public var createdAtUnixMilliseconds: Int64 public var deadlineUnixMilliseconds: Int64 public var steps: [DoryWorkspaceOperationStep] @@ -268,6 +470,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { source: DoryWorkspaceLifecycleCondition, target: DoryWorkspaceLifecycleCondition, targetWorkspaceID: String? = nil, + targetResourceID: String? = nil, createdAtUnixMilliseconds: Int64, deadlineUnixMilliseconds: Int64, steps: [DoryWorkspaceOperationStep], @@ -277,11 +480,13 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { recovery: DoryWorkspaceRecoveryRecipe ) { schemaVersion = Self.schemaVersion + sourceSchemaVersion = Self.schemaVersion self.operationID = operationID self.kind = kind self.source = source self.target = target self.targetWorkspaceID = targetWorkspaceID + self.targetResourceID = targetResourceID self.createdAtUnixMilliseconds = createdAtUnixMilliseconds self.deadlineUnixMilliseconds = deadlineUnixMilliseconds self.steps = steps @@ -297,7 +502,12 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { issues.append(DoryWorkspaceOperationValidationIssue(code: code, field: field)) } - if schemaVersion != Self.schemaVersion { add(.invalidSchemaVersion, "schemaVersion") } + if !(Self.oldestSupportedSchemaVersion...Self.schemaVersion).contains(schemaVersion) + || sourceSchemaVersion != schemaVersion + || source.encodedSchemaVersion != schemaVersion + || target.encodedSchemaVersion != schemaVersion { + add(.invalidSchemaVersion, "schemaVersion") + } if operationID == UUID.zero { add(.invalidOperationID, "operationID") } if !source.isValid { add(.invalidCondition, "source") } if !target.isValid { add(.invalidCondition, "target") } @@ -308,14 +518,45 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } else if source.workspaceID != target.workspaceID { add(.invalidCondition, "target.workspaceID") } - if Self.requiresResolvedSource(kind), source.resolved == nil { + if Self.requiresRuntimeSource(kind), source.runtime == nil { add(.invalidCondition, "source") } - if Self.requiresResolvedTarget(kind), target.resolved == nil { + if Self.requiresRuntimeTarget(kind), target.runtime == nil { add(.invalidCondition, "target") } - if Self.requiresUnchangedResolvedPlan(kind), source.resolved != target.resolved { - add(.invalidCondition, "target.resolved") + if Self.requiresUnchangedRuntimePlan(kind), + !Self.hasSameRuntimePlan(source.runtime, target.runtime) { + add(.invalidCondition, "target.runtime") + } + if kind == .starting { + if target.runtime?.authorizationState != .resolvedPlan + && target.runtime?.authorizationState != .legacyCompatibility { + add(.invalidCondition, "target.runtime.authorizationState") + } + if source.runtime?.policy != target.runtime?.policy { + add(.invalidCondition, "target.runtime.policy") + } else if source.runtime?.authorizationState != .requiresReplanning, + !Self.hasSameRuntimePlan(source.runtime, target.runtime) { + add(.invalidCondition, "target.runtime") + } + } + if sourceSchemaVersion == Self.schemaVersion, + kind == .restoring, + source.runtime?.policy == .requireResolvedPlan, + target.runtime?.authorizationState != .requiresReplanning { + add(.invalidCondition, "target.runtime.authorizationState") + } + if source.runtime?.policy != target.runtime?.policy, + kind != .importing, kind != .provisioning, kind != .resolving { + add(.invalidCondition, "target.runtime.policy") + } + if sourceSchemaVersion == Self.schemaVersion { + if source.state != .absent, source.configurationAuthority == nil { + add(.invalidCondition, "source.configurationAuthority") + } + if target.state != .absent, target.configurationAuthority == nil { + add(.invalidCondition, "target.configurationAuthority") + } } if !Self.allows(kind: kind, source: source.state, target: target.state) { add(.invalidTransition, "target.state") @@ -328,6 +569,15 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } else if targetWorkspaceID != nil { add(.invalidTargetWorkspace, "targetWorkspaceID") } + let requiresResourceID = kind == .snapshotting || kind == .restoring || kind == .cloning + if requiresResourceID { + if sourceSchemaVersion == Self.schemaVersion, + targetResourceID.map(DoryOperationJournalStore.isToken) != true { + add(.invalidTargetWorkspace, "targetResourceID") + } + } else if targetResourceID != nil { + add(.invalidTargetWorkspace, "targetResourceID") + } if createdAtUnixMilliseconds < 0 || deadlineUnixMilliseconds <= createdAtUnixMilliseconds { add(.invalidDeadline, "deadlineUnixMilliseconds") @@ -365,6 +615,97 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { return issues } + private enum CodingKeys: String, CodingKey { + case schemaVersion + case sourceSchemaVersion + case operationID + case kind + case source + case target + case targetWorkspaceID + case targetResourceID + case createdAtUnixMilliseconds + case deadlineUnixMilliseconds + case steps + case readinessGates + case retryBudgets + case cancellationPolicy + case recovery + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion) + guard (Self.oldestSupportedSchemaVersion...Self.schemaVersion).contains(persistedSchema) else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "unsupported workspace lifecycle schema" + ) + } + schemaVersion = persistedSchema + sourceSchemaVersion = try container.decodeIfPresent(UInt16.self, forKey: .sourceSchemaVersion) + ?? persistedSchema + guard sourceSchemaVersion == persistedSchema else { + throw DecodingError.dataCorruptedError( + forKey: .sourceSchemaVersion, + in: container, + debugDescription: "workspace lifecycle source schema mismatch" + ) + } + operationID = try container.decode(UUID.self, forKey: .operationID) + kind = try container.decode(DoryWorkspaceMutationKind.self, forKey: .kind) + source = try container.decode(DoryWorkspaceLifecycleCondition.self, forKey: .source) + target = try container.decode(DoryWorkspaceLifecycleCondition.self, forKey: .target) + guard source.encodedSchemaVersion == persistedSchema, + target.encodedSchemaVersion == persistedSchema else { + throw DecodingError.dataCorruptedError( + forKey: .source, + in: container, + debugDescription: "workspace lifecycle condition schema mismatch" + ) + } + targetWorkspaceID = try container.decodeIfPresent(String.self, forKey: .targetWorkspaceID) + targetResourceID = try container.decodeIfPresent(String.self, forKey: .targetResourceID) + createdAtUnixMilliseconds = try container.decode(Int64.self, forKey: .createdAtUnixMilliseconds) + deadlineUnixMilliseconds = try container.decode(Int64.self, forKey: .deadlineUnixMilliseconds) + steps = try container.decode([DoryWorkspaceOperationStep].self, forKey: .steps) + readinessGates = try container.decodeIfPresent( + [DoryWorkspaceReadinessGate].self, + forKey: .readinessGates + ) ?? [] + retryBudgets = try container.decodeIfPresent( + [DoryWorkspaceRetryBudget].self, + forKey: .retryBudgets + ) ?? [] + cancellationPolicy = try container.decode( + DoryWorkspaceCancellationPolicy.self, + forKey: .cancellationPolicy + ) + recovery = try container.decode(DoryWorkspaceRecoveryRecipe.self, forKey: .recovery) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + if schemaVersion == Self.schemaVersion { + try container.encode(sourceSchemaVersion, forKey: .sourceSchemaVersion) + } + try container.encode(operationID, forKey: .operationID) + try container.encode(kind, forKey: .kind) + try container.encode(source, forKey: .source) + try container.encode(target, forKey: .target) + try container.encodeIfPresent(targetWorkspaceID, forKey: .targetWorkspaceID) + try container.encodeIfPresent(targetResourceID, forKey: .targetResourceID) + try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds) + try container.encode(deadlineUnixMilliseconds, forKey: .deadlineUnixMilliseconds) + try container.encode(steps, forKey: .steps) + try container.encode(readinessGates, forKey: .readinessGates) + try container.encode(retryBudgets, forKey: .retryBudgets) + try container.encode(cancellationPolicy, forKey: .cancellationPolicy) + try container.encode(recovery, forKey: .recovery) + } + public func journalSpecification() throws -> DoryOperationSpecification { let issues = validate() guard issues.isEmpty else { @@ -423,7 +764,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { || (kind == .restoring && targetState == .running) } - private static func requiresResolvedSource(_ kind: DoryWorkspaceMutationKind) -> Bool { + private static func requiresRuntimeSource(_ kind: DoryWorkspaceMutationKind) -> Bool { switch kind { case .starting, .stopping, .pausing, .resuming, .suspending, .restoring, .snapshotting, .cloning, .updating: @@ -433,7 +774,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } } - private static func requiresResolvedTarget(_ kind: DoryWorkspaceMutationKind) -> Bool { + private static func requiresRuntimeTarget(_ kind: DoryWorkspaceMutationKind) -> Bool { switch kind { case .provisioning, .resolving, .starting, .stopping, .pausing, .resuming, .suspending, .restoring, .snapshotting, .cloning, .updating, .repairing: @@ -443,16 +784,29 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } } - private static func requiresUnchangedResolvedPlan(_ kind: DoryWorkspaceMutationKind) -> Bool { + private static func requiresUnchangedRuntimePlan(_ kind: DoryWorkspaceMutationKind) -> Bool { switch kind { - case .starting, .stopping, .pausing, .resuming, .suspending, .snapshotting: + case .stopping, .pausing, .resuming, .suspending, .snapshotting: true - case .importing, .provisioning, .resolving, .restoring, .cloning, .updating, + case .importing, .provisioning, .resolving, .starting, .restoring, .cloning, .updating, .repairing, .deleting: false } } + private static func hasSameRuntimePlan( + _ source: DoryWorkspaceRuntimeBinding?, + _ target: DoryWorkspaceRuntimeBinding? + ) -> Bool { + guard let source, let target, source.policy == target.policy, + source.virtualHardwareABIVersion == target.virtualHardwareABIVersion else { + return false + } + return source.authorizationState == target.authorizationState + && source.runtimeIdentityDigest == target.runtimeIdentityDigest + && source.resolved == target.resolved + } + private func deadlineContains(offset: UInt64) -> Bool { guard createdAtUnixMilliseconds >= 0, deadlineUnixMilliseconds > createdAtUnixMilliseconds, @@ -481,7 +835,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { case .suspending: (source == .running || source == .paused) && target == .suspended case .restoring: - (source == .stopped || source == .suspended || source == .failed) + (source == .stopped || source == .suspended || source == .failed + || source == .running || source == .paused) && (target == .stopped || target == .running) case .snapshotting: (source == .stopped || source == .running || source == .paused) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift index 26dd0117..638f71d4 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift @@ -60,6 +60,44 @@ struct DoryWorkspaceLifecycleOperationTests { } } + @Test("schema v2 importing persists an explicit absent-condition discriminator") + func importingAbsentConditionJournalRoundTrip() throws { + let operation = DoryWorkspaceLifecycleOperation( + operationID: UUID(uuidString: "c4cf98dc-c476-40c6-b5e9-f05f460b465f")!, + kind: .importing, + source: DoryWorkspaceLifecycleCondition( + workspaceID: "workspace-import", + state: .absent + ), + target: condition( + .defined, + workspaceID: "workspace-import", + resolved: nil + ), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("import", 50_000)], + cancellationPolicy: .beforePublish, + recovery: .init(disposition: .rollback, stepIDs: ["import"]) + ) + #expect(operation.validate().isEmpty) + let binding = try operation.journalBinding( + dependencyClosureDigest: String(repeating: "b", count: 64) + ) + #expect(binding.specification.data.range(of: Data("conditionSchemaVersion".utf8)) != nil) + + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-workspace-import-v2-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: home) } + let store = try DoryOperationJournalStore(home: home.path) + var lease: DoryOperationLease? = try store.begin(binding) + #expect(try lease?.readWorkspaceLifecycleOperation() == operation) + lease = nil + let recovered = try store.acquire(operation.operationID) + #expect(try recovered.readWorkspaceLifecycleOperation() == operation) + } + @Test("all lifecycle mutation kinds have stable journal kinds") func journalKinds() { #expect(Set(DoryWorkspaceMutationKind.allCases.map(\.journalKind.rawValue)).count @@ -71,7 +109,7 @@ struct DoryWorkspaceLifecycleOperationTests { @Test("start requires exact resolved source target and backend readiness") func startRequirements() { var operation = makeOperation() - operation.source.resolved = nil + operation.source.runtime = nil #expect(has(.invalidCondition, "source", operation)) operation = makeOperation() @@ -113,6 +151,7 @@ struct DoryWorkspaceLifecycleOperationTests { kind: .cloning, source: condition(.stopped, resolved: resolved), target: condition(.stopped, workspaceID: "workspace-copy", resolved: resolved), + targetResourceID: "workspace-copy", createdAtUnixMilliseconds: 1_700_000_000_000, deadlineUnixMilliseconds: 1_700_000_060_000, steps: [step("clone", 30_000)], @@ -142,7 +181,8 @@ struct DoryWorkspaceLifecycleOperationTests { let restoreStopped = DoryWorkspaceLifecycleOperation( kind: .restoring, source: condition(.stopped, resolved: resolved), - target: condition(.stopped, resolved: resolved), + target: condition(.stopped, resolved: nil, requiresReplanning: true), + targetResourceID: "snapshot-one", createdAtUnixMilliseconds: 1_700_000_000_000, deadlineUnixMilliseconds: 1_700_000_060_000, steps: [step("restore", 50_000)], @@ -196,6 +236,141 @@ struct DoryWorkspaceLifecycleOperationTests { #expect(has(.invalidCondition, "source", operation)) } + @Test("schema v1 resolved journals migrate into strict tagged runtime bindings") + func schemaV1GoldenMigration() throws { + let json = """ + { + "schemaVersion": 1, + "operationID": "8F9F5B13-77EE-4F59-8A13-1704147C7F00", + "kind": "starting", + "source": { + "workspaceID": "workspace-one", "state": "stopped", "definitionRevision": 3, + "resolved": { + "planRevision": 4, + "planDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "backendID": "dory-hv-linux", "backendRuntimeBuildID": "dory-hv-1.0.0", + "virtualHardwareABIVersion": 1 + } + }, + "target": { + "workspaceID": "workspace-one", "state": "running", "definitionRevision": 3, + "resolved": { + "planRevision": 4, + "planDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "backendID": "dory-hv-linux", "backendRuntimeBuildID": "dory-hv-1.0.0", + "virtualHardwareABIVersion": 1 + } + }, + "createdAtUnixMilliseconds": 1700000000000, + "deadlineUnixMilliseconds": 1700000060000, + "steps": [ + {"id":"launch","stage":"launch","deadlineOffsetMilliseconds":20000}, + {"id":"ready","stage":"readiness","deadlineOffsetMilliseconds":55000} + ], + "readinessGates": [ + {"kind":"backendRunning","required":true,"deadlineOffsetMilliseconds":20000} + ], + "retryBudgets": [], + "cancellationPolicy": "beforeGuestMutation", + "recovery": {"disposition":"rollback","stepIDs":["launch"]} + } + """ + let migrated = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: Data(json.utf8) + ) + #expect(migrated.schemaVersion == 1) + #expect(migrated.sourceSchemaVersion == 1) + #expect(migrated.source.runtime?.policy == .requireResolvedPlan) + #expect(migrated.source.runtime?.authorizationState == .resolvedPlan) + #expect(migrated.source.runtime?.resolved?.planRevision == 4) + #expect(migrated.source.runtime?.runtimeIdentityDigest == String(repeating: "a", count: 64)) + #expect(migrated.validate().isEmpty) + + let reencoded = try JSONEncoder().encode(migrated) + let current = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: reencoded + ) + #expect(current == migrated) + + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-workspace-lifecycle-v1-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: home) } + let binding = try migrated.journalBinding( + dependencyClosureDigest: String(repeating: "b", count: 64) + ) + #expect(binding.specification.data.range(of: Data("sourceSchemaVersion".utf8)) == nil) + #expect(binding.specification.data.range(of: Data("runtimeIdentityDigest".utf8)) == nil) + #expect(binding.specification.data.range(of: Data("resolved".utf8)) != nil) + let store = try DoryOperationJournalStore(home: home.path) + var lease: DoryOperationLease? = try store.begin(binding) + #expect(try lease?.readWorkspaceLifecycleOperation() == migrated) + lease = nil + let recovered = try store.acquire(migrated.operationID) + #expect(try recovered.readWorkspaceLifecycleOperation() == migrated) + + let legacyRestoreData = Data( + json.replacingOccurrences(of: "\"kind\": \"starting\"", with: "\"kind\": \"restoring\"") + .replacingOccurrences(of: "\"state\": \"running\"", with: "\"state\": \"stopped\"") + .utf8 + ) + let legacyRestore = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: legacyRestoreData + ) + #expect(legacyRestore.validate().isEmpty) + #expect(legacyRestore.target.runtime?.authorizationState == .resolvedPlan) + } + + @Test("schema v2 cannot claim v1 authority and exact runtime identity cannot drift") + func schemaAndRuntimeAuthorityAreExact() throws { + let dishonest = """ + { + "schemaVersion": 2, "sourceSchemaVersion": 1, + "operationID": "8F9F5B13-77EE-4F59-8A13-1704147C7F00", + "kind": "starting", "source": {}, "target": {}, + "createdAtUnixMilliseconds": 1700000000000, + "deadlineUnixMilliseconds": 1700000060000, + "steps": [], "readinessGates": [], "retryBudgets": [], + "cancellationPolicy": "prohibited", + "recovery": {"disposition":"rollback","stepIDs":[]} + } + """ + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: Data(dishonest.utf8) + ) + } + + var operation = makeOperation() + operation.target.runtime?.runtimeIdentityDigest = String(repeating: "f", count: 64) + #expect(has(.invalidCondition, "target.runtime", operation)) + } + + @Test("legacy compatibility is an explicit ABI and identity binding, never a plan claim") + func legacyCompatibilityBindingIsExplicit() { + var operation = makeOperation() + let legacy = DoryWorkspaceRuntimeBinding.legacyCompatibility( + virtualHardwareABIVersion: 1, + runtimeIdentityDigest: String(repeating: "9", count: 64) + ) + operation.source.runtime = legacy + operation.target.runtime = legacy + #expect(operation.validate().isEmpty) + + operation.target.runtime = DoryWorkspaceRuntimeBinding( + policy: .legacyCompatibility, + authorizationState: .legacyCompatibility, + virtualHardwareABIVersion: 1, + runtimeIdentityDigest: String(repeating: "9", count: 64), + resolved: resolvedCondition() + ) + #expect(has(.invalidCondition, "target", operation)) + } + private func makeOperation() -> DoryWorkspaceLifecycleOperation { let resolved = resolvedCondition() return DoryWorkspaceLifecycleOperation( @@ -223,13 +398,32 @@ struct DoryWorkspaceLifecycleOperationTests { private func condition( _ state: DoryWorkspaceLifecycleState, workspaceID: String = "workspace-one", - resolved: DoryWorkspaceResolvedCondition? + resolved: DoryWorkspaceResolvedCondition?, + requiresReplanning: Bool = false ) -> DoryWorkspaceLifecycleCondition { - DoryWorkspaceLifecycleCondition( + let runtime: DoryWorkspaceRuntimeBinding? + if let resolved { + runtime = .resolvedPlan( + resolved, + runtimeIdentityDigest: String(repeating: "e", count: 64) + ) + } else if requiresReplanning { + runtime = .requiresReplanning( + virtualHardwareABIVersion: 1, + runtimeIdentityDigest: String(repeating: "f", count: 64) + ) + } else { + runtime = nil + } + return DoryWorkspaceLifecycleCondition( workspaceID: workspaceID, state: state, definitionRevision: 3, - resolved: resolved + runtime: runtime, + configurationAuthority: DoryWorkspaceConfigurationAuthority( + legacyConfigurationSHA256: String(repeating: "c", count: 64), + canonicalDefinitionSHA256: String(repeating: "d", count: 64) + ) ) } From db9f596ff3334fc8ebfe1def7ded1e443718a729 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 14:08:04 +0000 Subject: [PATCH 022/338] fix(diagnostics): omit VM environments from CLI output --- .../DoryMachineDiagnosticsProjection.swift | 40 ++++++++++++++ dory-core-swift/Sources/dorydctl/main.swift | 5 +- ...oryMachineDiagnosticsProjectionTests.swift | 55 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift new file mode 100644 index 00000000..cf25c25f --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Produces the machine shape that is safe to print, log, or place in a support bundle. +/// +/// Legacy machine configuration can contain arbitrary host/guest environment values. Those values +/// are launch authority, not diagnostics, and an opaque credential cannot be identified reliably +/// from its bytes. The public CLI therefore removes environment containers structurally instead of +/// attempting value-pattern redaction. +public enum DoryMachineDiagnosticsProjection { + private static let environmentKeys: Set = [ + "env", + "environment", + "environment_variables", + "environmentvariables", + ] + + public static func supportSafeMachineStatus(_ status: NSDictionary) -> NSDictionary { + sanitize(status) as? NSDictionary ?? [:] + } + + public static func supportSafeMachineList(_ statuses: NSArray) -> NSArray { + sanitize(statuses) as? NSArray ?? [] + } + + private static func sanitize(_ value: Any) -> Any { + if let dictionary = value as? NSDictionary { + let result = NSMutableDictionary(capacity: dictionary.count) + for (rawKey, item) in dictionary { + guard let key = rawKey as? String else { continue } + if environmentKeys.contains(key.lowercased()) { continue } + result[key] = sanitize(item) + } + return result.copy() as? NSDictionary ?? [:] + } + if let array = value as? NSArray { + return array.map(sanitize) as NSArray + } + return value + } +} diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 15443acb..04e15936 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -1048,10 +1048,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { message.isEmpty ? finish(.success(body)) : finish(.failure(DorydCtlError.daemon(message))) } } - try emitJSON(rows) + try emitJSON(DoryMachineDiagnosticsProjection.supportSafeMachineList(rows)) case "status": let name = try cursor.take("usage: dorydctl machine status NAME") - try emitJSON(try machineDictionary(name: name, client: client)) + let status = try machineDictionary(name: name, client: client) + try emitJSON(DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status)) case "stats": let name = try cursor.take("usage: dorydctl machine stats NAME") guard cursor.values.isEmpty else { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift new file mode 100644 index 00000000..d1e4256e --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import DorydKit + +@Suite("Machine diagnostics projection") +struct DoryMachineDiagnosticsProjectionTests { + @Test("support projection structurally omits opaque environment secrets") + func omitsEnvironmentContainers() throws { + let opaqueSecret = "opaque-machine-credential-7f3b" + let statuses: NSArray = [ + [ + "id": "dev", + "state": "running", + "address": "dev.dory.local", + "environment": [ + ["key": "ANTHROPIC_API_KEY", "value": opaqueSecret], + ], + "nested": [ + "env": ["OPENAI_API_KEY": opaqueSecret], + "runtime": "qualified", + ], + ] as NSDictionary, + ] + + let projected = DoryMachineDiagnosticsProjection.supportSafeMachineList(statuses) + let data = try JSONSerialization.data(withJSONObject: projected) + let text = String(decoding: data, as: UTF8.self) + let machine = try #require(projected.firstObject as? NSDictionary) + let nested = try #require(machine["nested"] as? NSDictionary) + + #expect(machine["id"] as? String == "dev") + #expect(machine["address"] as? String == "dev.dory.local") + #expect(machine["environment"] == nil) + #expect(nested["env"] == nil) + #expect(nested["runtime"] as? String == "qualified") + #expect(!text.contains(opaqueSecret)) + } + + @Test("single status projection recognizes alternate environment spellings") + func omitsAlternateSpellings() { + let status: NSDictionary = [ + "id": "dev", + "ENV": ["SECRET": "opaque"], + "environment_variables": ["SECRET": "opaque"], + "environmentVariables": ["SECRET": "opaque"], + ] + + let projected = DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status) + + #expect(projected["id"] as? String == "dev") + #expect(projected["ENV"] == nil) + #expect(projected["environment_variables"] == nil) + #expect(projected["environmentVariables"] == nil) + } +} From c69e6679f6d9107e28459afe92cd16a8df569779 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 14:20:58 +0000 Subject: [PATCH 023/338] fix(vm): stop persisting host credentials --- Dory/Features/Settings/SettingsView.swift | 110 +++---------------- Dory/Models/AppStore.swift | 62 ++++++----- Dory/Runtime/Machines/MachineEnvImport.swift | 78 ------------- DoryTests/AppStoreEnvAllowListTests.swift | 41 +++---- DoryTests/DorydClientTests.swift | 27 +++-- DoryTests/MachineEnvImportTests.swift | 52 --------- DoryTests/ManagedSettingsTests.swift | 3 +- 7 files changed, 84 insertions(+), 289 deletions(-) delete mode 100644 Dory/Runtime/Machines/MachineEnvImport.swift delete mode 100644 DoryTests/MachineEnvImportTests.swift diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift index 59d2bf30..712fad1f 100644 --- a/Dory/Features/Settings/SettingsView.swift +++ b/Dory/Features/Settings/SettingsView.swift @@ -31,7 +31,6 @@ struct SettingsView: View { @State private var customDomainDraft = "" @State private var customDomainPortDraft = "80" @State private var customSocketDraft = "" - @State private var machineEnvAllowListDraft = "" @State private var engineCPUCountDraft = 1 @State private var engineMemoryGiBDraft = 2 @State private var detectedEngineSources: [DockerSourceEngine] = [] @@ -1267,51 +1266,22 @@ struct SettingsView: View { private var machines: some View { VStack(alignment: .leading, spacing: 22) { - groupLabel("NEW MACHINE DEFAULTS") - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text("Host environment allow-list") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(p.text) - Text("Only named variables are copied when a new machine is created. Empty values are skipped; values are read at creation time.") - .font(.system(size: 11.5)) - .foregroundStyle(p.text3) - .lineLimit(3) - } - Spacer(minLength: 0) - Button(action: commitMachineEnvAllowListDraft) { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 28, height: 26) - .background(p.accent, in: RoundedRectangle(cornerRadius: 7)) - } - .buttonStyle(.plain) - .help("Save environment allow-list") - .accessibilityIdentifier("machine-env-save") - } - TextField("ANTHROPIC_API_KEY, GH_TOKEN", text: $machineEnvAllowListDraft, onCommit: commitMachineEnvAllowListDraft) - .textFieldStyle(.plain) - .font(.mono(12)) - .foregroundStyle(p.text) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) - .accessibilityIdentifier("machine-env-allow-list") - VStack(spacing: 0) { - machineEnvToggleRow("ANTHROPIC_API_KEY", divider: true) - ForEach(MachineEnvImport.optionalExtras, id: \.self) { name in - machineEnvToggleRow(name, divider: name != (MachineEnvImport.optionalExtras.last ?? "")) - } + groupLabel("NEW MACHINE CREDENTIALS") + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(p.green) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 4) { + Text("Host credentials are never copied") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(p.text) + Text("New machines do not inherit API keys, tokens, or other host environment values. Legacy machine records remain compatible; scoped secret grants will be configured separately.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .lineLimit(4) } - .background(p.bgInput, in: RoundedRectangle(cornerRadius: 9)) - .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border)) - Text(machineEnvAllowListSummary) - .font(.system(size: 11.5)) - .foregroundStyle(p.text3) - .lineLimit(2) + Spacer(minLength: 0) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) @@ -1320,27 +1290,13 @@ struct SettingsView: View { groupLabel("AGENT SANDBOXES") VStack(spacing: 0) { - machinePolicyRow("Persistent machines", "No host folders are shared unless the create request includes mounts. Settings above control which host env names may be copied.", divider: true) + machinePolicyRow("Persistent machines", "No host folders or host environment values are shared unless a typed, scoped grant explicitly authorizes them.", divider: true) machinePolicyRow("Sandbox runs", "`dory sandbox run` starts a dedicated VM with no host file sharing by default. Add mounts explicitly for scoped workspace access.", divider: true) - machinePolicyRow("Shell access", "`dory machine shell NAME` and `dory machine exec NAME -- ...` enter the VM boundary; the agent sees machine files, mounted folders, and copied env only.", divider: false) + machinePolicyRow("Shell access", "`dory machine shell NAME` and `dory machine exec NAME -- ...` enter the VM boundary; the agent sees machine files and explicitly mounted folders only.", divider: false) } .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) } - .onAppear(perform: syncMachineEnvAllowListDraft) - .onChange(of: store.machineEnvAllowList) { _, _ in syncMachineEnvAllowListDraft() } - } - - private func machineEnvToggleRow(_ name: String, divider: Bool) -> some View { - toggleRow( - name, - machineEnvDraftNames.contains(name) ? "Copied when present" : "Not copied", - isOn: Binding( - get: { machineEnvDraftNames.contains(name) }, - set: { enabled in setMachineEnvDraft(name, enabled: enabled) } - ), - divider: divider - ) } private func machinePolicyRow(_ title: String, _ subtitle: String, divider: Bool) -> some View { @@ -1365,38 +1321,6 @@ struct SettingsView: View { .overlay(alignment: .bottom) { if divider { Rectangle().fill(p.border).frame(height: 1) } } } - private var machineEnvDraftNames: [String] { - MachineEnvImport.parse(machineEnvAllowListDraft) - } - - private var machineEnvAllowListSummary: String { - let names = MachineEnvImport.normalize(store.machineEnvAllowList) - guard !names.isEmpty else { return "No host environment variables are copied to new machines." } - return "Saved: \(names.joined(separator: ", "))" - } - - private func syncMachineEnvAllowListDraft() { - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - - private func commitMachineEnvAllowListDraft() { - let names = MachineEnvImport.parse(machineEnvAllowListDraft) - store.setMachineEnvAllowList(names) - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - - private func setMachineEnvDraft(_ name: String, enabled: Bool) { - var names = machineEnvDraftNames - if enabled { - names.append(name) - } else { - names.removeAll { $0 == name } - } - machineEnvAllowListDraft = MachineEnvImport.serialize(names) - store.setMachineEnvAllowList(names) - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - private var engine: some View { let kind = store.runtimeKind let onShared = kind == .sharedVM diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 68bbdba5..b6362224 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -80,7 +80,9 @@ final class AppStore { var showMenuBarIcon = true var routeDockerCLI = true var keepDorydRunningAfterQuit = false - var machineEnvAllowList: [String] = MachineEnvImport.defaultNames + /// Retained as an empty v1 managed-settings compatibility field. Host environment import is + /// disabled: credentials belong in scoped secret grants, never persisted machine environment. + var machineEnvAllowList: [String] = [] var openLoginsOnMac = true var externalTerminalPreference = ExternalTerminalPreference(terminal: .terminal, customApplicationPath: nil) var dockerHostConflict: DockerHostConflict.Conflict? @@ -191,7 +193,6 @@ final class AppStore { @ObservationIgnored private let authorizedNetworkingRemover: @Sendable () async throws -> Void @ObservationIgnored private let localCATrustManager: any LocalCATrustManaging @ObservationIgnored private let environment: [String: String] - @ObservationIgnored private let machineEnvResolver: @Sendable ([String]) async -> [String: String] @ObservationIgnored private let desktopMachineAssetPreparer: @Sendable ( _ home: String, _ environment: [String: String], @@ -219,9 +220,6 @@ final class AppStore { environment: [String: String] = ProcessInfo.processInfo.environment, composeCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), buildCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), - machineEnvResolver: @escaping @Sendable ([String]) async -> [String: String] = { names in - await MachineEnvImport.resolve(names: names) - }, desktopMachineAssetPreparer: @escaping @Sendable ( _ home: String, _ environment: [String: String], @@ -241,7 +239,6 @@ final class AppStore { self.environment = env self.composeCommandRunner = composeCommandRunner self.buildCommandRunner = buildCommandRunner - self.machineEnvResolver = machineEnvResolver self.desktopMachineAssetPreparer = desktopMachineAssetPreparer self.dorydClient = dorydClient self.dorydEngineEnabled = dorydFlags.enabled @@ -278,9 +275,10 @@ final class AppStore { _ = DoryUpdater.shared if let v = UserDefaults.standard.object(forKey: Self.routeDockerKey) as? Bool { routeDockerCLI = v } keepDorydRunningAfterQuit = Self.resolvedKeepDorydRunningAfterQuit(defaults: .standard) - if let raw = UserDefaults.standard.string(forKey: Self.machineEnvAllowListKey) { - machineEnvAllowList = MachineEnvImport.parse(raw) - } + // Earlier builds stored environment *names* here and copied their values into new VM + // definitions. Clear that opt-in permanently; legacy VM records remain launchable but + // no new machine inherits host credentials. + UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey) if let v = UserDefaults.standard.object(forKey: Self.openLoginsOnMacKey) as? Bool { openLoginsOnMac = v } externalTerminalPreference = ExternalTerminalPreferenceStore.load() if let saved = UserDefaults.standard.string(forKey: Self.kubernetesVersionKey) { @@ -697,13 +695,10 @@ final class AppStore { showSettingsSuccess(showMenuBarIcon ? "Menu bar icon enabled." : "Menu bar icon hidden.") } - func setMachineEnvAllowList(_ names: [String]) { - let normalized = MachineEnvImport.normalize(names) - machineEnvAllowList = normalized - UserDefaults.standard.set(MachineEnvImport.serialize(normalized), forKey: Self.machineEnvAllowListKey) - showSettingsSuccess(normalized.isEmpty - ? "New machines will not copy host environment variables." - : "New machines will copy \(normalized.count) allowed environment variable\(normalized.count == 1 ? "" : "s") when present.") + func setMachineEnvAllowList(_: [String]) { + machineEnvAllowList = [] + UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey) + showSettingsSuccess("Host environment import is disabled for new machines.") } func completeOnboarding() { @@ -5410,15 +5405,6 @@ final class AppStore { return Int(UInt16(bigEndian: result.sin_port)) } - nonisolated static func mergingEnv(_ settings: MachineSettings, resolved: [String: String]) -> MachineSettings { - guard !resolved.isEmpty else { return settings } - var copy = settings - for (key, value) in resolved where copy.env[key] == nil && !value.isEmpty { - copy.env[key] = value - } - return copy - } - nonisolated static func desktopAssetEnvironment( processEnvironment: [String: String], distro: DesktopMachineDistro @@ -5428,6 +5414,26 @@ final class AppStore { return result } + /// Temporary compatibility projection until these fields move into native WorkspaceSpec + /// properties. New app-created machines never persist arbitrary environment values. + nonisolated static func sanitizedNewMachineEnvironment( + _ environment: [String: String] + ) -> [String: String] { + let allowed: Set = [ + "DORY_CLIPBOARD_POLICY", + "DORY_CUSTOM_LINUX", + "DORY_DESKTOP_DISTRO", + "DORY_DESKTOP_ENVIRONMENT", + "DORY_DESKTOP_GRAPHICS", + "DORY_DESKTOP_NAME", + "DORY_DESKTOP_VERSION", + "DORY_DESKTOP_VMM", + "DORY_GUEST_UID", + "DORY_GUEST_USER", + ] + return environment.filter { allowed.contains($0.key) } + } + /// Brings persistent desktop machines forward after a signed desktop component activation. /// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the /// last-good snapshot, reboot qualification, and automatic rollback transaction. @@ -5602,7 +5608,7 @@ final class AppStore { address: address, displayMode: settings.displayMode, shares: dorydShares(from: settings.mounts), - environment: settings.env + environment: sanitizedNewMachineEnvironment(settings.env) ) } @@ -5641,9 +5647,7 @@ final class AppStore { return message } guard requireDorydMachines() else { return actionError } - let resolvedEnv = await machineEnvResolver(machineEnvAllowList) - let effectiveSettings = Self.mergingEnv(settings, resolved: resolvedEnv) - return await createDorydMachine(name: trimmedName, settings: effectiveSettings, recipe: recipe) + return await createDorydMachine(name: trimmedName, settings: settings, recipe: recipe) } nonisolated static func dorydRecipeID(for recipe: DevRecipe) -> String? { diff --git a/Dory/Runtime/Machines/MachineEnvImport.swift b/Dory/Runtime/Machines/MachineEnvImport.swift deleted file mode 100644 index b84369d7..00000000 --- a/Dory/Runtime/Machines/MachineEnvImport.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -nonisolated enum MachineEnvImport { - static let defaultNames: [String] = ["ANTHROPIC_API_KEY"] - static let optionalExtras: [String] = ["OPENAI_API_KEY", "GH_TOKEN", "HF_TOKEN"] - - static func normalize(_ names: [String]) -> [String] { - var seen = Set() - var ordered: [String] = [] - for name in names { - let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - guard !cleaned.isEmpty, cleaned.wholeMatch(of: /[A-Z_][A-Z0-9_]*/) != nil else { continue } - guard seen.insert(cleaned).inserted else { continue } - ordered.append(cleaned) - } - return ordered - } - - static func parse(_ raw: String) -> [String] { - let separators = CharacterSet(charactersIn: ", \t\n") - return normalize(raw.components(separatedBy: separators)) - } - - static func serialize(_ names: [String]) -> String { - normalize(names).joined(separator: ",") - } - - static let sentinel = "@@DORYENV@@" - - static func probeCommand(for names: [String]) -> String { - normalize(names).map { name in - "printf '\(sentinel)\(name)=%s\(sentinel)' \"${\(name):-}\"" - }.joined(separator: "; ") - } - - static func parseProbeOutput(_ output: String) -> [String: String] { - var result: [String: String] = [:] - let segments = output.components(separatedBy: sentinel) - for segment in segments { - guard let eq = segment.firstIndex(of: "="), segment.hasSuffix("=") == false else { continue } - let key = String(segment[segment.startIndex.. [String: String] { - let normalized = normalize(names) - guard !normalized.isEmpty else { return [:] } - let command = probeCommand(for: normalized) - let result = await withTimeout(seconds: 6) { - await Shell.runAsyncResult(loginShell(), ["-lic", command]) - } - guard let output = result?.output else { return [:] } - return parseProbeOutput(output) - } - - private static func loginShell() -> String { - if let shell = ProcessInfo.processInfo.environment["SHELL"], - FileManager.default.isExecutableFile(atPath: shell) { return shell } - return Shell.find("zsh", candidates: ["/bin/zsh", "/opt/homebrew/bin/zsh", "/usr/local/bin/zsh"]) ?? "/bin/zsh" - } - - private static func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async -> T) async -> T? { - await withTaskGroup(of: T?.self) { group in - group.addTask { await operation() } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } -} diff --git a/DoryTests/AppStoreEnvAllowListTests.swift b/DoryTests/AppStoreEnvAllowListTests.swift index 83904a93..f1e473b0 100644 --- a/DoryTests/AppStoreEnvAllowListTests.swift +++ b/DoryTests/AppStoreEnvAllowListTests.swift @@ -4,38 +4,33 @@ import Testing @MainActor struct AppStoreEnvAllowListTests { - @Test func defaultAllowListIsAnthropicOnly() { + @Test func hostEnvironmentImportDefaultsToDisabled() { let store = AppStore(runtime: MockRuntime()) - #expect(store.machineEnvAllowList == ["ANTHROPIC_API_KEY"]) - } - - @Test func setAllowListNormalizesDedupesAndPersistsUserChoice() { - defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) } - let store = AppStore(runtime: MockRuntime()) - store.setMachineEnvAllowList(["gh_token", " ", "gh_token"]) - #expect(store.machineEnvAllowList == ["GH_TOKEN"]) - #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "GH_TOKEN") + #expect(store.machineEnvAllowList.isEmpty) } - @Test func setAllowListCanDisableAutomaticEnvTransfer() { + @Test func legacyAllowListPreferenceIsClearedAndCannotBeReenabled() { defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) } + UserDefaults.standard.set("ANTHROPIC_API_KEY,GH_TOKEN", forKey: AppStore.machineEnvAllowListKey) let store = AppStore(runtime: MockRuntime()) - store.setMachineEnvAllowList([]) + store.setMachineEnvAllowList(["ANTHROPIC_API_KEY", "GH_TOKEN"]) #expect(store.machineEnvAllowList.isEmpty) - #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "") + #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == nil) } - @Test func mergingEnvAddsResolvedButUserKeysWin() { - var settings = MachineSettings.default - settings.env = ["ANTHROPIC_API_KEY": "user-set"] - let merged = AppStore.mergingEnv(settings, resolved: ["ANTHROPIC_API_KEY": "probed", "GH_TOKEN": "gh-123"]) - #expect(merged.env["ANTHROPIC_API_KEY"] == "user-set") - #expect(merged.env["GH_TOKEN"] == "gh-123") - } + @Test func newMachineEnvironmentKeepsOnlyBoundedDoryMetadata() { + let result = AppStore.sanitizedNewMachineEnvironment([ + "ANTHROPIC_API_KEY": "sk-ant-host", + "GH_TOKEN": "gh-host", + "APP_ENV": "development", + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_GUEST_USER": "dory", + ]) - @Test func mergingEnvIgnoresEmptyResolved() { - let merged = AppStore.mergingEnv(.default, resolved: [:]) - #expect(merged.env.isEmpty) + #expect(result == [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_GUEST_USER": "dory", + ]) } @Test func createMachineRejectsPathTraversalName() async { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 560e67c1..c947ca60 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -969,7 +969,7 @@ struct DorydClientTests { } @MainActor - @Test func appStoreCopiesAllowedHostEnvWhenCreatingDorydMachine() async throws { + @Test func appStoreDoesNotCopyHostCredentialsWhenCreatingDorydMachine() async throws { let base = "/tmp/damc-env-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 14:22:08 +0000 Subject: [PATCH 024/338] feat(vm): add durable resource admission ledger --- ...irtualMachineResourceAdmissionLedger.swift | 1043 +++++++++++++++++ ...lMachineResourceAdmissionLedgerTests.swift | 739 ++++++++++++ 2 files changed, 1782 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift new file mode 100644 index 00000000..65555a5a --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -0,0 +1,1043 @@ +import CryptoKit +import Darwin +import DoryOperations +import Foundation + +public enum DoryVirtualMachineResourceLeaseState: String, Codable, Sendable, CaseIterable { + case starting + case running + /// A bound start lease expired across a daemon outage. Capacity remains reserved until the + /// daemon proves whether the exact VM process is running or stopped. + case recoveryRequired = "recovery-required" + /// CPU and memory are released. The disk growth reservation remains durable until deletion. + case stopped +} + +public enum DoryVirtualMachineObservedRuntimeState: String, Sendable, CaseIterable { + case running + case stopped +} + +public struct DoryVirtualMachineResourceAdmissionPlanBinding: + Codable, Sendable, Equatable, Hashable +{ + public var machineID: String + public var definitionRevision: UInt64 + public var definitionSHA256: String + public var plannedPlanRevision: UInt64 + + public init( + machineID: String, + definitionRevision: UInt64, + definitionSHA256: String, + plannedPlanRevision: UInt64 + ) { + self.machineID = machineID + self.definitionRevision = definitionRevision + self.definitionSHA256 = definitionSHA256 + self.plannedPlanRevision = plannedPlanRevision + } +} + +public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equatable { + public static let schemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var leaseID: String + public var leaseRevision: UInt64 + public var binding: DoryVirtualMachineResourceAdmissionPlanBinding + public var boundPlanSHA256: String? + public var hostFacts: DoryVMHostResources + public var hostFactsSHA256: String + public var workload: DoryVMWorkloadProfile + public var requirements: [DoryVMResourceRequirement] + public var resources: DoryVMResourceRequest + public var state: DoryVirtualMachineResourceLeaseState + public var startingExpiresAtUnixMilliseconds: Int64? + public var createdAtUnixMilliseconds: Int64 + public var updatedAtUnixMilliseconds: Int64 + public var evidence: DoryResolvedMachineResourceAdmissionEvidence + + fileprivate init( + leaseID: String, + binding: DoryVirtualMachineResourceAdmissionPlanBinding, + hostFacts: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement], + resources: DoryVMResourceRequest, + startingExpiresAtUnixMilliseconds: Int64, + createdAtUnixMilliseconds: Int64, + evidence: DoryResolvedMachineResourceAdmissionEvidence + ) { + schemaVersion = Self.schemaVersion + self.leaseID = leaseID + leaseRevision = 1 + self.binding = binding + boundPlanSHA256 = nil + self.hostFacts = hostFacts + hostFactsSHA256 = Self.digest(hostFacts) + self.workload = workload + self.requirements = requirements + self.resources = resources + state = .starting + self.startingExpiresAtUnixMilliseconds = startingExpiresAtUnixMilliseconds + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + updatedAtUnixMilliseconds = createdAtUnixMilliseconds + self.evidence = evidence + } + + fileprivate static func digest(_ value: T) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(value)) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public struct DoryVirtualMachineResourceAdmissionLedgerSnapshot: Sendable, Equatable { + public var ledgerRevision: UInt64 + public var leases: [DoryVirtualMachineResourceAdmissionLease] + + public init( + ledgerRevision: UInt64, + leases: [DoryVirtualMachineResourceAdmissionLease] + ) { + self.ledgerRevision = ledgerRevision + self.leases = leases + } +} + +public enum DoryVirtualMachineResourceAdmissionLedgerError: + Error, Sendable, Equatable, CustomStringConvertible +{ + case invalidBinding + case invalidHostFacts + case invalidLeaseDuration + case capacityUnavailable([DoryVMResourceValidationIssue]) + case machineAlreadyReserved(String) + case leaseNotFound(String) + case staleLedgerRevision(expected: UInt64, actual: UInt64) + case staleLeaseRevision(expected: UInt64, actual: UInt64) + case invalidLeaseState(DoryVirtualMachineResourceLeaseState) + case planAlreadyBound + case planMismatch + case hostFactsMismatch + case arithmeticOverflow + case invalidRecord + case filesystem(String) + + public var description: String { + switch self { + case .invalidBinding: "resource admission plan binding is invalid" + case .invalidHostFacts: "host resource facts are invalid" + case .invalidLeaseDuration: "starting lease duration is invalid" + case .capacityUnavailable: "host capacity cannot admit the requested virtual machine" + case let .machineAlreadyReserved(machineID): + "machine already has a resource reservation: \(machineID)" + case let .leaseNotFound(leaseID): "resource admission lease not found: \(leaseID)" + case let .staleLedgerRevision(expected, actual): + "stale resource ledger revision: expected \(expected), found \(actual)" + case let .staleLeaseRevision(expected, actual): + "stale resource lease revision: expected \(expected), found \(actual)" + case let .invalidLeaseState(state): + "resource lease has invalid state: \(state.rawValue)" + case .planAlreadyBound: "resource admission lease is already bound to a plan" + case .planMismatch: "resolved plan does not match the admitted resource lease" + case .hostFactsMismatch: "current host facts do not match the admitted snapshot" + case .arithmeticOverflow: "resource commitment arithmetic overflow" + case .invalidRecord: "resource admission ledger record is invalid or tampered" + case let .filesystem(message): message + } + } +} + +/// Durable admission authority for VM starts. Callers provide host facts excluding leases owned by +/// this ledger; the ledger composes those facts with all starting/running CPU and RAM commitments +/// and every durable disk reservation under one cross-process lock. +public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendable { + public static let assessorIdentifier = "dory.resource-admission-ledger" + public static let assessorVersion: UInt16 = 1 + + private static let recordFilename = "resource-admissions.json" + private static let lockFilename = ".resource-admissions.lock" + private static let temporaryPrefix = ".resource-admissions." + private static let maximumRecordBytes = 4 * 1_024 * 1_024 + private static let maximumStartingLeaseDurationMilliseconds: Int64 = 86_400_000 + + public let root: String + private let instanceLock = NSLock() + private let now: @Sendable () -> Int64 + + public init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + now = { Int64(Date().timeIntervalSince1970 * 1_000) } + } + + init(root: String, now: @escaping @Sendable () -> Int64) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + self.now = now + } + + /// Atomically reserves a starting slot. The returned evidence is the only admission evidence + /// that should be embedded in the plan subsequently bound with `bind`. + public func reserveStarting( + binding: DoryVirtualMachineResourceAdmissionPlanBinding, + hostFacts: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement] = [], + resources: DoryVMResourceRequest, + startingLeaseDurationMilliseconds: Int64 = 120_000, + expectedLedgerRevision: UInt64? = nil + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + let recovered = try recoverExpired(in: &record, at: timestamp) + try validateExpectedLedgerRevision(expectedLedgerRevision, actual: record.ledgerRevision) + try Self.validate(binding) + try Self.validate(hostFacts) + guard startingLeaseDurationMilliseconds > 0, + startingLeaseDurationMilliseconds + <= Self.maximumStartingLeaseDurationMilliseconds, + timestamp > 0, + timestamp <= Int64.max - startingLeaseDurationMilliseconds else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration + } + let stoppedLeaseIndex = record.leases.firstIndex { + $0.binding.machineID == binding.machineID && $0.state == .stopped + } + guard !record.leases.contains(where: { + $0.binding.machineID == binding.machineID && $0.state != .stopped + }) else { + throw DoryVirtualMachineResourceAdmissionLedgerError.machineAlreadyReserved( + binding.machineID + ) + } + // A restart atomically replaces the stopped reservation. Excluding that lease from + // assessment prevents double-counting its disk while the lock prevents any interval + // in which another admission could consume the released capacity. + let leasesBeingRetained = record.leases.enumerated().compactMap { index, lease in + index == stoppedLeaseIndex ? nil : lease + } + let commitments = try Self.commitments(in: leasesBeingRetained) + let assessedHost = try Self.composing( + hostFacts, + runtime: commitments.runtime, + storage: commitments.storage + ) + let sortedRequirements = requirements.sorted(by: Self.requirementOrder) + let assessment = DoryVMResourcePolicy.assess( + host: assessedHost, + workload: workload, + requirements: sortedRequirements, + request: resources + ) + let errors = assessment.issues.filter { $0.severity == .error } + guard errors.isEmpty else { + if recovered { try persistRecoveredRecord(&record) } + throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable(errors) + } + let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] } + let leaseID = stoppedLease?.leaseID + ?? "resource-lease-\(UUID().uuidString.lowercased())" + let evidence = Self.makeEvidence( + leaseID: leaseID, + binding: binding, + hostFacts: hostFacts, + assessedHost: assessedHost, + workload: workload, + requirements: sortedRequirements, + resources: resources, + reserve: assessment.recommendation.hostReserve + ) + var lease = DoryVirtualMachineResourceAdmissionLease( + leaseID: leaseID, + binding: binding, + hostFacts: hostFacts, + workload: workload, + requirements: sortedRequirements, + resources: resources, + startingExpiresAtUnixMilliseconds: timestamp + + startingLeaseDurationMilliseconds, + createdAtUnixMilliseconds: timestamp, + evidence: evidence + ) + if let stoppedLeaseIndex, let stoppedLease { + lease.leaseRevision = try Self.incrementing(stoppedLease.leaseRevision) + record.leases[stoppedLeaseIndex] = lease + } else { + record.leases.append(lease) + } + try advanceAndPublish(&record) + return lease + } + } + + /// Completes the two-phase admission by pinning the exact canonical plan bytes. This closes + /// the circular dependency between admission evidence embedded in a plan and the final digest. + public func bind( + leaseID: String, + to plan: DoryResolvedMachinePlan, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .starting else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + guard lease.boundPlanSHA256 == nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.planAlreadyBound + } + try Self.validate(plan, against: lease, requireBoundDigest: false) + lease.boundPlanSHA256 = Self.planSHA256(plan) + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + + /// Revalidates an already bound starting lease against exact plan bytes and the same daemon + /// host snapshot. It never replans or silently refreshes evidence. + public func revalidateForStart( + leaseID: String, + plan: DoryResolvedMachinePlan, + hostFacts: DoryVMHostResources + ) throws -> DoryResolvedMachineResourceAdmissionEvidence { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + let recovered = try recoverExpired(in: &record, at: timestamp) + if recovered { try persistRecoveredRecord(&record) } + let index = try leaseIndex(leaseID, in: record) + let lease = record.leases[index] + guard lease.state == .starting else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + guard hostFacts == lease.hostFacts, + DoryVirtualMachineResourceAdmissionLease.digest(hostFacts) + == lease.hostFactsSHA256 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.hostFactsMismatch + } + try Self.validate(plan, against: lease, requireBoundDigest: true) + try Self.validateCapacity(record.leases, hostFacts: hostFacts) + return lease.evidence + } + } + + public func markRunning( + leaseID: String, + plan: DoryResolvedMachinePlan, + hostFacts: DoryVMHostResources, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .starting else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + guard hostFacts == lease.hostFacts, + DoryVirtualMachineResourceAdmissionLease.digest(hostFacts) + == lease.hostFactsSHA256 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.hostFactsMismatch + } + try Self.validate(plan, against: lease, requireBoundDigest: true) + try Self.validateCapacity(record.leases, hostFacts: hostFacts) + lease.state = .running + lease.startingExpiresAtUnixMilliseconds = nil + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + + /// Releases CPU and RAM after stop while intentionally retaining the disk reservation. + public func markStopped( + leaseID: String, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .starting || lease.state == .running, + lease.boundPlanSHA256 != nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + lease.state = .stopped + lease.startingExpiresAtUnixMilliseconds = nil + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + + /// Resolves an expired bound start using daemon-observed runtime state. Until this explicit + /// reconciliation, recovery-required leases retain CPU, memory, and storage reservations. + public func reconcileExpiredStart( + leaseID: String, + observedRuntimeState: DoryVirtualMachineObservedRuntimeState, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .recoveryRequired else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + lease.state = observedRuntimeState == .running ? .running : .stopped + lease.startingExpiresAtUnixMilliseconds = nil + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + + /// Deletes the durable disk reservation. This belongs on machine/storage deletion, not stop. + public func releaseStorageReservation( + leaseID: String, + expectedLeaseRevision: UInt64 + ) throws { + try withExclusiveAccess { + var record = try readRecord() + _ = try recoverExpired(in: &record, at: now()) + let index = try leaseIndex(leaseID, in: record) + try Self.validateLeaseRevision(expectedLeaseRevision, record.leases[index]) + guard record.leases[index].state == .stopped else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState( + record.leases[index].state + ) + } + record.leases.remove(at: index) + try advanceAndPublish(&record) + } + } + + /// Reading also performs deterministic expiry recovery under the ledger lock. + public func snapshot() throws -> DoryVirtualMachineResourceAdmissionLedgerSnapshot { + try withExclusiveAccess { + var record = try readRecord() + if try recoverExpired(in: &record, at: now()) { + try persistRecoveredRecord(&record) + } + return DoryVirtualMachineResourceAdmissionLedgerSnapshot( + ledgerRevision: record.ledgerRevision, + leases: record.leases.sorted { $0.binding.machineID < $1.binding.machineID } + ) + } + } + + private struct LedgerRecord: Codable, Sendable, Equatable { + static let schemaVersion: UInt16 = 1 + var schemaVersion: UInt16 = schemaVersion + var ledgerRevision: UInt64 = 0 + var leases: [DoryVirtualMachineResourceAdmissionLease] = [] + } + + private struct LedgerEnvelope: Codable, Sendable, Equatable { + var recordSHA256: String + var record: LedgerRecord + } + + private struct Commitments { + var runtime: DoryVMResourceRequest + var storage: UInt64 + } + + private struct AdmissionReport: Codable { + var leaseID: String + var binding: DoryVirtualMachineResourceAdmissionPlanBinding + var hostFactsSHA256: String + var workload: DoryVMWorkloadProfile + var requirements: [DoryVMResourceRequirement] + var resources: DoryVMResourceRequest + var hostReserve: DoryVMHostReserve + var existingVirtualCPUCommitment: UInt64 + var existingMemoryCommitmentBytes: UInt64 + var existingStorageReservationBytes: UInt64 + var assessorIdentifier: String + var assessorVersion: UInt16 + } + + private func withExclusiveAccess(_ body: () throws -> T) throws -> T { + try instanceLock.withLock { + try Self.ensurePrivateDirectory(root) + let path = root + "/" + Self.lockFilename + let descriptor = path.withCString { + open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { throw filesystem("open resource admission lock") } + defer { close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_mode & 0o077 == 0 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { throw filesystem("lock resource admission ledger") } + } + defer { _ = flock(descriptor, LOCK_UN) } + try cleanupTemporaryFiles() + return try body() + } + } + + private func readRecord() throws -> LedgerRecord { + let path = root + "/" + Self.recordFilename + var initial = stat() + guard lstat(path, &initial) == 0 else { + if errno == ENOENT { return LedgerRecord() } + throw filesystem("inspect resource admission ledger") + } + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + defer { close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_mode & 0o077 == 0, + status.st_size > 0, + status.st_size <= Self.maximumRecordBytes else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1_024) + while true { + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count < 0, errno == EINTR { continue } + guard count >= 0, data.count + count <= Self.maximumRecordBytes else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + if count == 0 { break } + data.append(contentsOf: buffer.prefix(count)) + } + guard let envelope = try? JSONDecoder().decode(LedgerEnvelope.self, from: data), + data == Self.canonicalData(envelope) + Data("\n".utf8), + envelope.recordSHA256 == Self.digest(Self.canonicalData(envelope.record)) else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + try Self.validate(envelope.record) + return envelope.record + } + + private func advanceAndPublish(_ record: inout LedgerRecord) throws { + record.ledgerRevision = try Self.incrementing(record.ledgerRevision) + record.leases.sort { $0.binding.machineID < $1.binding.machineID } + try publish(record) + } + + private func persistRecoveredRecord(_ record: inout LedgerRecord) throws { + try advanceAndPublish(&record) + } + + private func publish(_ record: LedgerRecord) throws { + try Self.validate(record) + let envelope = LedgerEnvelope( + recordSHA256: Self.digest(Self.canonicalData(record)), + record: record + ) + let data = Self.canonicalData(envelope) + Data("\n".utf8) + let temporary = root + "/\(Self.temporaryPrefix)\(UUID().uuidString)" + let descriptor = temporary.withCString { + open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { throw filesystem("create resource admission record") } + var isOpen = true + do { + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, + bytes.baseAddress!.advanced(by: offset), + bytes.count - offset + ) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw filesystem("write resource admission record") } + offset += count + } + } + guard fsync(descriptor) == 0 else { throw filesystem("sync resource admission record") } + close(descriptor) + isOpen = false + guard rename(temporary, root + "/" + Self.recordFilename) == 0 else { + throw filesystem("publish resource admission record") + } + try Self.syncDirectory(root) + } catch { + if isOpen { close(descriptor) } + unlink(temporary) + throw error + } + } + + private func cleanupTemporaryFiles() throws { + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory(atPath: root) + } catch { + throw filesystem("enumerate resource admission directory") + } + for name in names where name.hasPrefix(Self.temporaryPrefix) { + let path = root + "/" + name + var status = stat() + guard lstat(path, &status) == 0 else { continue } + guard status.st_uid == geteuid() else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + guard unlink(path) == 0 || errno == ENOENT else { + throw filesystem("remove stale resource admission temporary file") + } + } + } + + private func recoverExpired( + in record: inout LedgerRecord, + at timestamp: Int64 + ) throws -> Bool { + guard timestamp > 0 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration + } + var changed = false + var recovered: [DoryVirtualMachineResourceAdmissionLease] = [] + for var lease in record.leases { + guard lease.state == .starting, + let expiry = lease.startingExpiresAtUnixMilliseconds, + expiry <= timestamp else { + recovered.append(lease) + continue + } + changed = true + // The composition contract does not permit artifact creation before plan binding, so + // an abandoned pre-publication reservation owns no durable disk yet. + guard lease.boundPlanSHA256 != nil else { continue } + // A process may have crossed the launch boundary before daemon failure. Never release + // runtime capacity until a daemon-owned process observation resolves that ambiguity. + lease.state = .recoveryRequired + lease.startingExpiresAtUnixMilliseconds = nil + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + recovered.append(lease) + } + if changed { record.leases = recovered } + return changed + } + + private func leaseIndex(_ leaseID: String, in record: LedgerRecord) throws -> Int { + guard let index = record.leases.firstIndex(where: { $0.leaseID == leaseID }) else { + throw DoryVirtualMachineResourceAdmissionLedgerError.leaseNotFound(leaseID) + } + return index + } + + private func validateExpectedLedgerRevision(_ expected: UInt64?, actual: UInt64) throws { + guard let expected else { return } + guard expected == actual else { + throw DoryVirtualMachineResourceAdmissionLedgerError.staleLedgerRevision( + expected: expected, + actual: actual + ) + } + } + + private static func validateLeaseRevision( + _ expected: UInt64, + _ lease: DoryVirtualMachineResourceAdmissionLease + ) throws { + guard expected == lease.leaseRevision else { + throw DoryVirtualMachineResourceAdmissionLedgerError.staleLeaseRevision( + expected: expected, + actual: lease.leaseRevision + ) + } + } + + private static func validate( + _ plan: DoryResolvedMachinePlan, + against lease: DoryVirtualMachineResourceAdmissionLease, + requireBoundDigest: Bool + ) throws { + guard plan.validate().isEmpty, + plan.machineID == lease.binding.machineID, + plan.definitionRevision == lease.binding.definitionRevision, + plan.definitionSHA256?.lowercased() == lease.binding.definitionSHA256.lowercased(), + plan.planRevision == lease.binding.plannedPlanRevision, + plan.resourceAdmission == lease.evidence else { + throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch + } + if requireBoundDigest { + guard let bound = lease.boundPlanSHA256, + bound == planSHA256(plan) else { + throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch + } + } + } + + private static func validate(_ binding: DoryVirtualMachineResourceAdmissionPlanBinding) throws { + let machine = Array(binding.machineID.utf8) + guard (1...63).contains(machine.count), + let first = machine.first, + isAlphaNumeric(first), + machine.dropFirst().allSatisfy({ isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }), + binding.definitionRevision > 0, + DoryResolvedMachinePlan.isSHA256(binding.definitionSHA256), + binding.plannedPlanRevision > 0 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidBinding + } + } + + private static func validate(_ host: DoryVMHostResources) throws { + guard host.logicalCPUCount > 0, + host.physicalMemoryBytes > 0, + host.freeStorageBytes > 0, + host.admittedVirtualCPUCount <= host.logicalCPUCount, + host.admittedMemoryBytes <= host.physicalMemoryBytes, + host.reservedStorageBytes <= host.freeStorageBytes else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidHostFacts + } + } + + private static func validate(_ record: LedgerRecord) throws { + guard record.schemaVersion == LedgerRecord.schemaVersion, + record.ledgerRevision > 0, + Set(record.leases.map(\.leaseID)).count == record.leases.count, + Set(record.leases.map { $0.binding.machineID }).count == record.leases.count else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + for lease in record.leases { + do { + try validate(lease.binding) + try validate(lease.hostFacts) + } catch { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + guard lease.schemaVersion == DoryVirtualMachineResourceAdmissionLease.schemaVersion, + isSafeIdentifier(lease.leaseID), + lease.leaseRevision > 0, + lease.createdAtUnixMilliseconds > 0, + lease.updatedAtUnixMilliseconds >= lease.createdAtUnixMilliseconds, + lease.hostFactsSHA256 + == DoryVirtualMachineResourceAdmissionLease.digest(lease.hostFacts), + lease.resources.virtualCPUCount > 0, + lease.resources.memoryBytes > 0, + lease.resources.diskBytes > 0, + lease.requirements == lease.requirements.sorted(by: requirementOrder), + lease.evidence.admittedVirtualCPUCount == lease.resources.virtualCPUCount, + lease.evidence.admittedMemoryBytes == lease.resources.memoryBytes, + lease.evidence.admittedStorageBytes == lease.resources.diskBytes, + lease.evidence.hostLogicalCPUCount == lease.hostFacts.logicalCPUCount, + lease.evidence.hostPhysicalMemoryBytes == lease.hostFacts.physicalMemoryBytes, + lease.evidence.hostFreeStorageBytes == lease.hostFacts.freeStorageBytes, + lease.evidence.existingVirtualCPUCommitment + >= lease.hostFacts.admittedVirtualCPUCount, + lease.evidence.existingMemoryCommitmentBytes + >= lease.hostFacts.admittedMemoryBytes, + lease.evidence.existingStorageReservationBytes + >= lease.hostFacts.reservedStorageBytes, + lease.evidence.assessorIdentifier == assessorIdentifier, + lease.evidence.assessorVersion == assessorVersion, + lease.evidence.admissionIdentity == lease.leaseID, + lease.evidence.admissionReportSHA256 == admissionReportSHA256(lease), + lease.boundPlanSHA256.map(DoryResolvedMachinePlan.isSHA256) ?? true else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + let reserve = DoryVMResourcePolicy.recommend( + host: lease.hostFacts, + workload: lease.workload, + requirements: lease.requirements + ).hostReserve + guard lease.evidence.hostReservedLogicalCPUCount == reserve.logicalCPUCount, + lease.evidence.hostReservedMemoryBytes == reserve.memoryBytes, + lease.evidence.hostReservedStorageBytes == reserve.storageBytes else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + switch lease.state { + case .starting: + guard let expiry = lease.startingExpiresAtUnixMilliseconds, + expiry > lease.createdAtUnixMilliseconds else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + case .running: + guard lease.boundPlanSHA256 != nil, + lease.startingExpiresAtUnixMilliseconds == nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + case .recoveryRequired: + guard lease.boundPlanSHA256 != nil, + lease.startingExpiresAtUnixMilliseconds == nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + case .stopped: + guard lease.boundPlanSHA256 != nil, + lease.startingExpiresAtUnixMilliseconds == nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + } + } + } + + private static func admissionReportSHA256( + _ lease: DoryVirtualMachineResourceAdmissionLease + ) -> String { + digest(canonicalData(AdmissionReport( + leaseID: lease.leaseID, + binding: lease.binding, + hostFactsSHA256: lease.hostFactsSHA256, + workload: lease.workload, + requirements: lease.requirements, + resources: lease.resources, + hostReserve: DoryVMHostReserve( + logicalCPUCount: lease.evidence.hostReservedLogicalCPUCount, + memoryBytes: lease.evidence.hostReservedMemoryBytes, + storageBytes: lease.evidence.hostReservedStorageBytes + ), + existingVirtualCPUCommitment: lease.evidence.existingVirtualCPUCommitment, + existingMemoryCommitmentBytes: lease.evidence.existingMemoryCommitmentBytes, + existingStorageReservationBytes: lease.evidence.existingStorageReservationBytes, + assessorIdentifier: assessorIdentifier, + assessorVersion: assessorVersion + ))) + } + + private static func makeEvidence( + leaseID: String, + binding: DoryVirtualMachineResourceAdmissionPlanBinding, + hostFacts: DoryVMHostResources, + assessedHost: DoryVMHostResources, + workload: DoryVMWorkloadProfile, + requirements: [DoryVMResourceRequirement], + resources: DoryVMResourceRequest, + reserve: DoryVMHostReserve + ) -> DoryResolvedMachineResourceAdmissionEvidence { + let report = AdmissionReport( + leaseID: leaseID, + binding: binding, + hostFactsSHA256: DoryVirtualMachineResourceAdmissionLease.digest(hostFacts), + workload: workload, + requirements: requirements, + resources: resources, + hostReserve: reserve, + existingVirtualCPUCommitment: assessedHost.admittedVirtualCPUCount, + existingMemoryCommitmentBytes: assessedHost.admittedMemoryBytes, + existingStorageReservationBytes: assessedHost.reservedStorageBytes, + assessorIdentifier: assessorIdentifier, + assessorVersion: assessorVersion + ) + return DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: resources.virtualCPUCount, + admittedMemoryBytes: resources.memoryBytes, + admittedStorageBytes: resources.diskBytes, + hostLogicalCPUCount: assessedHost.logicalCPUCount, + hostPhysicalMemoryBytes: assessedHost.physicalMemoryBytes, + hostFreeStorageBytes: assessedHost.freeStorageBytes, + existingVirtualCPUCommitment: assessedHost.admittedVirtualCPUCount, + existingMemoryCommitmentBytes: assessedHost.admittedMemoryBytes, + existingStorageReservationBytes: assessedHost.reservedStorageBytes, + hostReservedLogicalCPUCount: reserve.logicalCPUCount, + hostReservedMemoryBytes: reserve.memoryBytes, + hostReservedStorageBytes: reserve.storageBytes, + admissionIdentity: leaseID, + admissionReportSHA256: digest(canonicalData(report)), + assessorIdentifier: assessorIdentifier, + assessorVersion: assessorVersion + ) + } + + private static func commitments( + in leases: [DoryVirtualMachineResourceAdmissionLease] + ) throws -> Commitments { + var cpu: UInt64 = 0 + var memory: UInt64 = 0 + var storage: UInt64 = 0 + for lease in leases { + storage = try adding(storage, lease.resources.diskBytes) + if lease.state == .starting || lease.state == .running + || lease.state == .recoveryRequired { + cpu = try adding(cpu, lease.resources.virtualCPUCount) + memory = try adding(memory, lease.resources.memoryBytes) + } + } + return Commitments( + runtime: DoryVMResourceRequest( + virtualCPUCount: cpu, + memoryBytes: memory, + diskBytes: 0 + ), + storage: storage + ) + } + + private static func composing( + _ host: DoryVMHostResources, + runtime: DoryVMResourceRequest, + storage: UInt64 + ) throws -> DoryVMHostResources { + DoryVMHostResources( + logicalCPUCount: host.logicalCPUCount, + physicalMemoryBytes: host.physicalMemoryBytes, + freeStorageBytes: host.freeStorageBytes, + admittedVirtualCPUCount: try adding( + host.admittedVirtualCPUCount, + runtime.virtualCPUCount + ), + admittedMemoryBytes: try adding(host.admittedMemoryBytes, runtime.memoryBytes), + reservedStorageBytes: try adding(host.reservedStorageBytes, storage) + ) + } + + private static func validateCapacity( + _ leases: [DoryVirtualMachineResourceAdmissionLease], + hostFacts: DoryVMHostResources + ) throws { + let total = try commitments(in: leases) + let composed = try composing(hostFacts, runtime: total.runtime, storage: total.storage) + let reserve = DoryVMResourcePolicy.recommend( + host: hostFacts, + workload: .server + ).hostReserve + guard sumFits( + total: composed.logicalCPUCount, + composed.admittedVirtualCPUCount, + reserve.logicalCPUCount + ), + sumFits( + total: composed.physicalMemoryBytes, + composed.admittedMemoryBytes, + reserve.memoryBytes + ), + sumFits( + total: composed.freeStorageBytes, + composed.reservedStorageBytes, + reserve.storageBytes + ) else { + throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable([]) + } + } + + private static func sumFits(total: UInt64, _ values: UInt64...) -> Bool { + var sum: UInt64 = 0 + for value in values { + let (next, overflow) = sum.addingReportingOverflow(value) + if overflow { return false } + sum = next + } + return sum <= total + } + + private static func requirementOrder( + _ lhs: DoryVMResourceRequirement, + _ rhs: DoryVMResourceRequirement + ) -> Bool { + if lhs.kind.rawValue != rhs.kind.rawValue { return lhs.kind.rawValue < rhs.kind.rawValue } + return lhs.identifier < rhs.identifier + } + + private static func adding(_ lhs: UInt64, _ rhs: UInt64) throws -> UInt64 { + let (value, overflow) = lhs.addingReportingOverflow(rhs) + guard !overflow else { + throw DoryVirtualMachineResourceAdmissionLedgerError.arithmeticOverflow + } + return value + } + + private static func incrementing(_ value: UInt64) throws -> UInt64 { + try adding(value, 1) + } + + private static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(plan)) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + return (1...256).contains(bytes.count) && bytes.allSatisfy { + isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 + } + } + + private static func isAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + + private static func ensurePrivateDirectory(_ path: String) throws { + if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST { + throw filesystem("create resource admission directory") + } + var status = stat() + guard lstat(path, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == geteuid(), + status.st_mode & 0o077 == 0 else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = path.withCString { + open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + guard descriptor >= 0 else { throw filesystem("open resource admission directory") } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { throw filesystem("sync resource admission directory") } + } + + private static func filesystem(_ operation: String) + -> DoryVirtualMachineResourceAdmissionLedgerError + { + .filesystem("\(operation): errno \(errno)") + } + + private func filesystem(_ operation: String) + -> DoryVirtualMachineResourceAdmissionLedgerError + { + Self.filesystem(operation) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift new file mode 100644 index 00000000..c3b15b2b --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -0,0 +1,739 @@ +import Darwin +import DoryOperations +@testable import DorydKit +import Foundation +import XCTest + +final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { + func testTwoLedgerInstancesAtomicallyAdmitOnlyOneContendingStart() async throws { + let fixture = try ResourceLedgerFixture("concurrent") + defer { fixture.cleanup() } + let first = fixture.ledger + let second = DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root) + let host = fixture.host + let resources = fixture.resources + let bindings = [fixture.binding("machine-a"), fixture.binding("machine-b")] + + let results = await withTaskGroup( + of: Result.self, + returning: [Result].self + ) { group in + for (ledger, binding) in zip([first, second], bindings) { + group.addTask { + do { + return .success(try ledger.reserveStarting( + binding: binding, + hostFacts: host, + workload: .desktop, + resources: resources + )) + } catch let error as DoryVirtualMachineResourceAdmissionLedgerError { + return .failure(error) + } catch { + return .failure(.filesystem("unexpected test error")) + } + } + } + var collected: [Result] = [] + for await result in group { collected.append(result) } + return collected + } + + XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1) + let failures = results.compactMap { result + -> DoryVirtualMachineResourceAdmissionLedgerError? in + guard case let .failure(error) = result else { return nil } + return error + } + XCTAssertEqual(failures.count, 1) + guard case let .capacityUnavailable(issues) = try XCTUnwrap(failures.first) else { + return XCTFail("expected capacity rejection") + } + XCTAssertTrue(issues.contains { + $0.code == .requestExceedsHostSafeMaximum && $0.resource == .cpu + }) + let snapshot = try fixture.ledger.snapshot() + XCTAssertEqual(snapshot.leases.count, 1) + XCTAssertEqual(snapshot.leases.first?.state, .starting) + } + + func testStoppedLeaseReleasesRuntimeCommitmentButRetainsStorageReservation() throws { + try withFixture("stopped-storage") { fixture in + let first = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let plan = fixture.plan(binding: first.binding, evidence: first.evidence) + let bound = try fixture.ledger.bind( + leaseID: first.leaseID, + to: plan, + expectedLeaseRevision: first.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + let stopped = try fixture.ledger.markStopped( + leaseID: running.leaseID, + expectedLeaseRevision: running.leaseRevision + ) + XCTAssertEqual(stopped.state, .stopped) + + let second = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-b"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + XCTAssertEqual(second.evidence.existingVirtualCPUCommitment, 0) + XCTAssertEqual(second.evidence.existingMemoryCommitmentBytes, 0) + XCTAssertEqual( + second.evidence.existingStorageReservationBytes, + fixture.resources.diskBytes + ) + let states = Dictionary(uniqueKeysWithValues: try fixture.ledger.snapshot().leases.map { + ($0.binding.machineID, $0.state) + }) + XCTAssertEqual(states["machine-a"], .stopped) + XCTAssertEqual(states["machine-b"], .starting) + } + } + + func testStoppedMachineRestartAtomicallyReplacesItsStorageReservation() throws { + try withFixture("stopped-restart") { fixture in + let first = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let firstPlan = fixture.plan(binding: first.binding, evidence: first.evidence) + let bound = try fixture.ledger.bind( + leaseID: first.leaseID, + to: firstPlan, + expectedLeaseRevision: first.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: firstPlan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + let stopped = try fixture.ledger.markStopped( + leaseID: running.leaseID, + expectedLeaseRevision: running.leaseRevision + ) + + var nextBinding = fixture.binding("machine-a") + nextBinding.definitionRevision += 1 + nextBinding.definitionSHA256 = digest("2") + nextBinding.plannedPlanRevision += 1 + let largerDisk = DoryVMResourceRequest( + virtualCPUCount: fixture.resources.virtualCPUCount, + memoryBytes: fixture.resources.memoryBytes, + diskBytes: fixture.resources.diskBytes + 8 * ResourceLedgerFixture.gibibyte + ) + let restarted = try fixture.ledger.reserveStarting( + binding: nextBinding, + hostFacts: fixture.host, + workload: .desktop, + resources: largerDisk + ) + + XCTAssertEqual(restarted.leaseID, stopped.leaseID) + XCTAssertEqual(restarted.leaseRevision, stopped.leaseRevision + 1) + XCTAssertEqual(restarted.state, .starting) + XCTAssertEqual(restarted.binding, nextBinding) + XCTAssertNil(restarted.boundPlanSHA256) + XCTAssertEqual(restarted.evidence.existingVirtualCPUCommitment, 0) + XCTAssertEqual(restarted.evidence.existingMemoryCommitmentBytes, 0) + XCTAssertEqual(restarted.evidence.existingStorageReservationBytes, 0) + let snapshot = try fixture.ledger.snapshot() + XCTAssertEqual(snapshot.leases.count, 1) + XCTAssertEqual(snapshot.leases.first?.resources, largerDisk) + } + } + + func testStoppedMachineRestartRollsBackOnCapacityFailureAndSerializesContention() async throws { + let fixture = try ResourceLedgerFixture("stopped-restart-contention") + defer { fixture.cleanup() } + let first = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let plan = fixture.plan(binding: first.binding, evidence: first.evidence) + let bound = try fixture.ledger.bind( + leaseID: first.leaseID, + to: plan, + expectedLeaseRevision: first.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + _ = try fixture.ledger.markStopped( + leaseID: running.leaseID, + expectedLeaseRevision: running.leaseRevision + ) + let beforeFailure = try fixture.ledger.snapshot() + let oversized = DoryVMResourceRequest( + virtualCPUCount: fixture.host.logicalCPUCount, + memoryBytes: fixture.resources.memoryBytes, + diskBytes: fixture.resources.diskBytes + ) + XCTAssertThrowsError(try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: oversized + )) { error in + guard case .capacityUnavailable = + error as? DoryVirtualMachineResourceAdmissionLedgerError else { + return XCTFail("expected capacity rejection") + } + } + XCTAssertEqual(try fixture.ledger.snapshot(), beforeFailure) + + let ledgers = [ + fixture.ledger, + DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root), + ] + let restartBinding = fixture.binding("machine-a") + let host = fixture.host + let resources = fixture.resources + let results = await withTaskGroup( + of: Result.self, + returning: [Result].self + ) { group in + for ledger in ledgers { + group.addTask { + do { + return .success(try ledger.reserveStarting( + binding: restartBinding, + hostFacts: host, + workload: .desktop, + resources: resources + )) + } catch let error as DoryVirtualMachineResourceAdmissionLedgerError { + return .failure(error) + } catch { + return .failure(.filesystem("unexpected test error")) + } + } + } + var values: [Result] = [] + for await value in group { values.append(value) } + return values + } + XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1) + XCTAssertEqual(results.compactMap { result -> String? in + guard case let .failure(.machineAlreadyReserved(machineID)) = result else { return nil } + return machineID + }, ["machine-a"]) + let snapshot = try fixture.ledger.snapshot() + XCTAssertEqual(snapshot.leases.count, 1) + XCTAssertEqual(snapshot.leases.first?.state, .starting) + XCTAssertEqual(snapshot.leases.first?.leaseID, first.leaseID) + } + + func testRestartRecoveryExpiresAbandonedStartAndPreservesBoundDisk() throws { + let clock = ResourceLedgerClock(now: 10_000) + let fixture = try ResourceLedgerFixture("recovery", clock: clock) + defer { fixture.cleanup() } + _ = try fixture.ledger.reserveStarting( + binding: fixture.binding("abandoned"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + startingLeaseDurationMilliseconds: 100 + ) + clock.advance(by: 100) + let restarted = DoryVirtualMachineResourceAdmissionLedger( + root: fixture.ledger.root, + now: clock.read + ) + XCTAssertTrue(try restarted.snapshot().leases.isEmpty) + + let durable = try restarted.reserveStarting( + binding: fixture.binding("durable"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + startingLeaseDurationMilliseconds: 100 + ) + let plan = fixture.plan(binding: durable.binding, evidence: durable.evidence) + let bound = try restarted.bind( + leaseID: durable.leaseID, + to: plan, + expectedLeaseRevision: durable.leaseRevision + ) + XCTAssertNotNil(bound.boundPlanSHA256) + clock.advance(by: 100) + + let recoveredAgain = DoryVirtualMachineResourceAdmissionLedger( + root: fixture.ledger.root, + now: clock.read + ) + let recovered = try XCTUnwrap(recoveredAgain.snapshot().leases.first) + XCTAssertEqual(recovered.state, .recoveryRequired) + XCTAssertNotNil(recovered.boundPlanSHA256) + XCTAssertNil(recovered.startingExpiresAtUnixMilliseconds) + XCTAssertThrowsError(try recoveredAgain.reserveStarting( + binding: fixture.binding("blocked-until-reconciled"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + )) { error in + guard case .capacityUnavailable = + error as? DoryVirtualMachineResourceAdmissionLedgerError else { + return XCTFail("expected retained runtime commitment") + } + } + let stopped = try recoveredAgain.reconcileExpiredStart( + leaseID: recovered.leaseID, + observedRuntimeState: .stopped, + expectedLeaseRevision: recovered.leaseRevision + ) + XCTAssertEqual(stopped.state, .stopped) + XCTAssertNoThrow(try recoveredAgain.reserveStarting( + binding: fixture.binding("allowed-after-reconciliation"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + )) + } + + func testBoundPlanAndHostFactsAreExactStartGates() throws { + try withFixture("exact-binding") { fixture in + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence) + XCTAssertThrowsError(try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: 0 + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .staleLeaseRevision(expected: 0, actual: 1) + ) + } + let bound = try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: reserved.leaseRevision + ) + var alternatePlan = plan + alternatePlan.updatedAtUnixMilliseconds += 1 + XCTAssertThrowsError(try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: alternatePlan, + expectedLeaseRevision: bound.leaseRevision + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .planAlreadyBound + ) + } + XCTAssertEqual( + try fixture.ledger.revalidateForStart( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host + ), + reserved.evidence + ) + + var changedPlan = plan + changedPlan.updatedAtUnixMilliseconds += 1 + XCTAssertThrowsError(try fixture.ledger.revalidateForStart( + leaseID: bound.leaseID, + plan: changedPlan, + hostFacts: fixture.host + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .planMismatch + ) + } + let changedHost = DoryVMHostResources( + logicalCPUCount: fixture.host.logicalCPUCount, + physicalMemoryBytes: fixture.host.physicalMemoryBytes, + freeStorageBytes: fixture.host.freeStorageBytes + 1 + ) + XCTAssertThrowsError(try fixture.ledger.revalidateForStart( + leaseID: bound.leaseID, + plan: plan, + hostFacts: changedHost + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .hostFactsMismatch + ) + } + } + } + + func testConcurrentPlanBindingIsOneShot() async throws { + let fixture = try ResourceLedgerFixture("bind-race") + defer { fixture.cleanup() } + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let firstPlan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence) + var secondPlan = firstPlan + secondPlan.updatedAtUnixMilliseconds += 1 + let ledgers = [ + fixture.ledger, + DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root), + ] + let plans = [firstPlan, secondPlan] + let results = await withTaskGroup( + of: Result.self, + returning: [Result].self + ) { group in + for (ledger, plan) in zip(ledgers, plans) { + group.addTask { + do { + return .success(try ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: reserved.leaseRevision + )) + } catch let error as DoryVirtualMachineResourceAdmissionLedgerError { + return .failure(error) + } catch { + return .failure(.filesystem("unexpected test error")) + } + } + } + var values: [Result] = [] + for await value in group { values.append(value) } + return values + } + XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1) + XCTAssertEqual(results.compactMap { result + -> DoryVirtualMachineResourceAdmissionLedgerError? in + guard case let .failure(error) = result else { return nil } + return error + }, [.staleLeaseRevision(expected: 1, actual: 2)]) + + let acceptedPlans = plans.filter { candidate in + (try? fixture.ledger.revalidateForStart( + leaseID: reserved.leaseID, + plan: candidate, + hostFacts: fixture.host + )) != nil + } + XCTAssertEqual(acceptedPlans.count, 1) + } + + func testRunningLeaseSurvivesExpiryAndStorageReleaseIsExplicit() throws { + let clock = ResourceLedgerClock(now: 20_000) + let fixture = try ResourceLedgerFixture("running", clock: clock) + defer { fixture.cleanup() } + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + startingLeaseDurationMilliseconds: 100 + ) + let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence) + let bound = try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: reserved.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + clock.advance(by: 1_000) + let restarted = DoryVirtualMachineResourceAdmissionLedger( + root: fixture.ledger.root, + now: clock.read + ) + XCTAssertEqual(try restarted.snapshot().leases.first?.state, .running) + let stopped = try restarted.markStopped( + leaseID: running.leaseID, + expectedLeaseRevision: running.leaseRevision + ) + XCTAssertEqual(try restarted.snapshot().leases.count, 1) + try restarted.releaseStorageReservation( + leaseID: stopped.leaseID, + expectedLeaseRevision: stopped.leaseRevision + ) + XCTAssertTrue(try restarted.snapshot().leases.isEmpty) + } + + func testTamperedRecordAndUnsafeRecordLinkFailClosed() throws { + try withFixture("record-safety") { fixture in + _ = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let path = fixture.ledger.root + "/resource-admissions.json" + var data = try Data(contentsOf: URL(fileURLWithPath: path)) + data.append(0x20) + try data.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + XCTAssertThrowsError(try fixture.ledger.snapshot()) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .invalidRecord + ) + } + } + + try withFixture("record-hardlink") { fixture in + _ = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let path = fixture.ledger.root + "/resource-admissions.json" + XCTAssertEqual(link(path, fixture.root + "/record-copy"), 0) + XCTAssertThrowsError(try fixture.ledger.snapshot()) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .invalidRecord + ) + } + } + } + + func testOptimisticLedgerRevisionAndActiveStorageReleaseFailClosed() throws { + try withFixture("optimistic") { fixture in + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + expectedLedgerRevision: 0 + ) + XCTAssertThrowsError(try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-b"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + expectedLedgerRevision: 0 + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .staleLedgerRevision(expected: 0, actual: 1) + ) + } + XCTAssertThrowsError(try fixture.ledger.releaseStorageReservation( + leaseID: reserved.leaseID, + expectedLeaseRevision: reserved.leaseRevision + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .invalidLeaseState(.starting) + ) + } + XCTAssertEqual(try fixture.ledger.snapshot().leases.count, 1) + } + } + + private func withFixture( + _ name: String, + body: (ResourceLedgerFixture) throws -> Void + ) throws { + let fixture = try ResourceLedgerFixture(name) + defer { fixture.cleanup() } + try body(fixture) + } +} + +private final class ResourceLedgerFixture { + static let gibibyte: UInt64 = 1_073_741_824 + + let root: String + let ledger: DoryVirtualMachineResourceAdmissionLedger + let host = DoryVMHostResources( + logicalCPUCount: 8, + physicalMemoryBytes: 16 * gibibyte, + freeStorageBytes: 256 * gibibyte + ) + let resources = DoryVMResourceRequest( + virtualCPUCount: 4, + memoryBytes: 4 * gibibyte, + diskBytes: 32 * gibibyte + ) + + init(_ name: String, clock: ResourceLedgerClock? = nil) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-resource-ledger-\(name)-\(UUID().uuidString)", + isDirectory: true + ).path + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root) + if let clock { + ledger = DoryVirtualMachineResourceAdmissionLedger( + root: root + "/ledger", + now: clock.read + ) + } else { + ledger = DoryVirtualMachineResourceAdmissionLedger(root: root + "/ledger") + } + } + + func binding(_ machineID: String) -> DoryVirtualMachineResourceAdmissionPlanBinding { + DoryVirtualMachineResourceAdmissionPlanBinding( + machineID: machineID, + definitionRevision: 3, + definitionSHA256: digest("1"), + plannedPlanRevision: 1 + ) + } + + func plan( + binding: DoryVirtualMachineResourceAdmissionPlanBinding, + evidence: DoryResolvedMachineResourceAdmissionEvidence + ) -> DoryResolvedMachinePlan { + let provenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "machine-store", + mediaIdentity: "\(binding.machineID)-disk", + revision: 1 + ) + let media = DoryBootMedia( + kind: .virtualDisk, + source: .userProvided, + mutableProvenance: provenance + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) + return DoryResolvedMachinePlan( + machineID: binding.machineID, + definitionRevision: binding.definitionRevision, + definitionSHA256: binding.definitionSHA256, + planRevision: binding.plannedPlanRevision, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000, + guest: guest, + backend: .appleVirtualizationFramework, + backendImplementationIdentifier: "dory.vz-linux.compatibility.v1", + backendRuntimeBuildIdentifier: "vz-runtime-1", + virtualHardwareABIVersion: 1, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: DoryVMResolverReference( + namespace: "machine", + identifier: "\(binding.machineID)-disk" + ), + media: media, + mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "disk-receipt-1", + provenance: provenance, + receiptSHA256: digest("7"), + resolverID: "machine-store", + resolverVersion: 1 + ) + ), + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-vmm", + buildIdentifier: "vz-runtime-1", + artifactSHA256: digest("d") + )], + devices: devices, + graphics: .software, + supportTier: .supported, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: DoryVirtualMachineBackendPlanRequest( + guest: guest, + bootMedia: media, + acceptableGraphics: [.software], + devices: devices, + backendPreferences: [.appleVirtualizationFramework], + backendPreferencePolicy: .required + ), + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + runtime: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: digest("c"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: nil, + mutableProvenance: provenance, + backend: .appleVirtualizationFramework, + backendRuntimeBuildID: "vz-runtime-1", + virtualHardwareABIVersion: 1, + graphics: .software, + devices: devices + ) + ), + resourceAdmission: evidence, + hostQualification: DoryResolvedHostQualificationEvidence( + qualificationIdentity: "host-qualification-1", + qualificationReportSHA256: digest("e"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .appleVirtualizationFramework, + backendRuntimeBuildIdentifier: "vz-runtime-1", + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) + ) + } + + func cleanup() { try? FileManager.default.removeItem(atPath: root) } +} + +private final class ResourceLedgerClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Int64 + + init(now: Int64) { value = now } + + var read: @Sendable () -> Int64 { + { [self] in lock.withLock { value } } + } + + func advance(by milliseconds: Int64) { + lock.withLock { value += milliseconds } + } +} + +private func digest(_ character: Character) -> String { + String(repeating: String(character), count: 64) +} From d537e9ee86b02f31a99267e4ebc935c5e18b9753 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 14:26:14 +0000 Subject: [PATCH 025/338] fix(vm): retain storage across admission recovery --- ...irtualMachineResourceAdmissionLedger.swift | 28 ++++++++++++------ ...lMachineResourceAdmissionLedgerTests.swift | 29 ++++++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift index 65555a5a..c75643d1 100644 --- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -119,6 +119,7 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: case staleLedgerRevision(expected: UInt64, actual: UInt64) case staleLeaseRevision(expected: UInt64, actual: UInt64) case invalidLeaseState(DoryVirtualMachineResourceLeaseState) + case storageReservationCannotShrink(existing: UInt64, requested: UInt64) case planAlreadyBound case planMismatch case hostFactsMismatch @@ -141,6 +142,8 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: "stale resource lease revision: expected \(expected), found \(actual)" case let .invalidLeaseState(state): "resource lease has invalid state: \(state.rawValue)" + case let .storageReservationCannotShrink(existing, requested): + "storage reservation cannot shrink from \(existing) to \(requested) bytes" case .planAlreadyBound: "resource admission lease is already bound to a plan" case .planMismatch: "resolved plan does not match the admitted resource lease" case .hostFactsMismatch: "current host facts do not match the admitted snapshot" @@ -206,6 +209,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl let stoppedLeaseIndex = record.leases.firstIndex { $0.binding.machineID == binding.machineID && $0.state == .stopped } + let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] } guard !record.leases.contains(where: { $0.binding.machineID == binding.machineID && $0.state != .stopped }) else { @@ -213,6 +217,15 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl binding.machineID ) } + if let stoppedLease, + resources.diskBytes < stoppedLease.resources.diskBytes { + if recovered { try persistRecoveredRecord(&record) } + throw DoryVirtualMachineResourceAdmissionLedgerError + .storageReservationCannotShrink( + existing: stoppedLease.resources.diskBytes, + requested: resources.diskBytes + ) + } // A restart atomically replaces the stopped reservation. Excluding that lease from // assessment prevents double-counting its disk while the lock prevents any interval // in which another admission could consume the released capacity. @@ -237,7 +250,6 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl if recovered { try persistRecoveredRecord(&record) } throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable(errors) } - let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] } let leaseID = stoppedLease?.leaseID ?? "resource-lease-\(UUID().uuidString.lowercased())" let evidence = Self.makeEvidence( @@ -641,12 +653,11 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl continue } changed = true - // The composition contract does not permit artifact creation before plan binding, so - // an abandoned pre-publication reservation owns no durable disk yet. - guard lease.boundPlanSHA256 != nil else { continue } - // A process may have crossed the launch boundary before daemon failure. Never release - // runtime capacity until a daemon-owned process observation resolves that ambiguity. - lease.state = .recoveryRequired + // Binding is the launch boundary. An unbound start cannot have launched, so CPU and + // memory are released, but storage is always durable and requires explicit release. + // A bound start may have launched before daemon failure and retains every commitment + // until a daemon-owned process observation resolves that ambiguity. + lease.state = lease.boundPlanSHA256 == nil ? .stopped : .recoveryRequired lease.startingExpiresAtUnixMilliseconds = nil lease.leaseRevision = try Self.incrementing(lease.leaseRevision) lease.updatedAtUnixMilliseconds = timestamp @@ -801,8 +812,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord } case .stopped: - guard lease.boundPlanSHA256 != nil, - lease.startingExpiresAtUnixMilliseconds == nil else { + guard lease.startingExpiresAtUnixMilliseconds == nil else { throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift index c3b15b2b..df7429bf 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -187,6 +187,27 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { expectedLeaseRevision: running.leaseRevision ) let beforeFailure = try fixture.ledger.snapshot() + let smallerDisk = DoryVMResourceRequest( + virtualCPUCount: fixture.resources.virtualCPUCount, + memoryBytes: fixture.resources.memoryBytes, + diskBytes: fixture.resources.diskBytes - ResourceLedgerFixture.gibibyte + ) + XCTAssertThrowsError(try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: smallerDisk + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .storageReservationCannotShrink( + existing: fixture.resources.diskBytes, + requested: smallerDisk.diskBytes + ) + ) + } + XCTAssertEqual(try fixture.ledger.snapshot(), beforeFailure) + let oversized = DoryVMResourceRequest( virtualCPUCount: fixture.host.logicalCPUCount, memoryBytes: fixture.resources.memoryBytes, @@ -266,7 +287,13 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { root: fixture.ledger.root, now: clock.read ) - XCTAssertTrue(try restarted.snapshot().leases.isEmpty) + let abandoned = try XCTUnwrap(restarted.snapshot().leases.first) + XCTAssertEqual(abandoned.state, .stopped) + XCTAssertNil(abandoned.boundPlanSHA256) + try restarted.releaseStorageReservation( + leaseID: abandoned.leaseID, + expectedLeaseRevision: abandoned.leaseRevision + ) let durable = try restarted.reserveStarting( binding: fixture.binding("durable"), From e3ff3451c104ce568cf235a0b8e7d06ebdf56deb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 15:05:14 +0000 Subject: [PATCH 026/338] feat(vm): journal machine lifecycle recovery --- .../DoryOperations/DoryOperationJournal.swift | 79 +- .../DoryOperationJournalPublication.swift | 15 +- .../DoryWorkspaceLifecycleOperation.swift | 43 +- .../Sources/DorydKit/MachineManager.swift | 1567 +++++++++++++++-- ...DoryWorkspaceLifecycleOperationTests.swift | 13 + ...agerLifecycleJournalIntegrationTests.swift | 611 +++++++ 6 files changed, 2170 insertions(+), 158 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift index db7bc936..7f5eb3c1 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift @@ -25,6 +25,13 @@ public struct DoryOperationJournalStore: Sendable, Equatable { root + "/" + id.uuidString.lowercased() + ".doryop" } + /// Creates and validates the private journal root before a later mutation needs to publish. + /// Daemons use this during startup so a transiently read-only managed-data directory cannot + /// strand an otherwise recoverable operation before its journal is written. + public func prepare(fileManager: FileManager = .default) throws { + try prepareRoot(fileManager: fileManager) + } + public func begin( _ plan: DoryOperationPlan, at date: Date = Date(), @@ -42,14 +49,46 @@ public struct DoryOperationJournalStore: Sendable, Equatable { public func acquire( _ id: UUID, fileManager: FileManager = .default + ) throws -> DoryOperationLease { + try acquireVerified(id, requestedMutationScope: nil, fileManager: fileManager) + } + + /// Reacquires a scoped operation only when the caller's expectation matches the scope + /// authenticated by the immutable journal plan. This overload is useful for callers that + /// already know which workspace they are recovering; it cannot select a different lock. + public func acquire( + _ id: UUID, + mutationScope: String, + fileManager: FileManager = .default + ) throws -> DoryOperationLease { + try acquireVerified( + id, + requestedMutationScope: mutationScope, + fileManager: fileManager + ) + } + + private func acquireVerified( + _ id: UUID, + requestedMutationScope: String?, + fileManager: FileManager ) throws -> DoryOperationLease { guard Self.pathEntryExists(root) else { throw DoryOperationJournalError.operationNotFound(id) } try validateRoot() - let lock = try acquireMutationLock() + let before = try readRecord(id) + let authenticatedScope = authenticatedMutationScope(for: before.plan) + if let requestedMutationScope, requestedMutationScope != authenticatedScope { + throw DoryOperationJournalError.invalidPlan("mutation scope mismatch") + } + let lock = try acquireMutationLock(scope: authenticatedScope) let lease = DoryOperationLease(store: self, operationID: id, lock: lock) - _ = try lease.read() + let after = try lease.read() + guard after.plan == before.plan, + authenticatedMutationScope(for: after.plan) == authenticatedScope else { + throw DoryOperationJournalError.invalidRecord(operationDirectory(for: id)) + } try lease.reconcileAuditLog() return lease } @@ -75,7 +114,9 @@ public struct DoryOperationJournalStore: Sendable, Equatable { } var records: [DoryOperationRecord] = [] for entry in entries.sorted() { - if entry == ".mutation.lock" || Self.isUnpublishedPartial(entry) { continue } + if entry == ".mutation.lock" + || (entry.hasPrefix(".mutation.") && entry.hasSuffix(".lock")) + || Self.isUnpublishedPartial(entry) { continue } guard entry.hasSuffix(".doryop"), let id = UUID(uuidString: String(entry.dropLast(".doryop".count))) else { throw DoryOperationJournalError.invalidRecord(root + "/" + entry) @@ -137,11 +178,14 @@ public struct DoryOperationJournalStore: Sendable, Equatable { } } - func acquireMutationLock() throws -> EngineStateDirectoryLock { + func acquireMutationLock(scope: String? = nil) throws -> EngineStateDirectoryLock { + if let scope, !Self.isToken(scope) { + throw DoryOperationJournalError.invalidPlan("mutation scope") + } do { return try EngineStateDirectoryLock( stateDirectory: root, - lockFileName: ".mutation.lock" + lockFileName: scope.map { ".mutation.\($0).lock" } ?? ".mutation.lock" ) } catch let error as EngineStateDirectoryLockError { switch error { @@ -153,6 +197,31 @@ public struct DoryOperationJournalStore: Sendable, Equatable { } } + /// A same-workspace lifecycle journal has one deterministic lock scope. Multi-workspace + /// operations (currently clone) retain the established global mutation fence. + func authenticatedMutationScope(for plan: DoryOperationPlan) -> String? { + guard Self.isWorkspaceLifecycleKind(plan.kind), + plan.source.kind == .workspace, + plan.target.kind == .workspace, + plan.source.id == plan.target.id, + Self.isToken(plan.source.id) else { + return nil + } + return plan.source.id + } + + private static func isWorkspaceLifecycleKind(_ kind: DoryOperationKind) -> Bool { + switch kind { + case .workspaceImport, .workspaceProvision, .workspaceResolve, .workspaceStart, + .workspaceStop, .workspacePause, .workspaceResume, .workspaceSuspend, + .workspaceRestore, .workspaceSnapshot, .workspaceClone, .workspaceUpdate, + .workspaceRepair, .workspaceDelete: + true + case .competitorImport, .driveBackup, .driveRestore, .driveRelocation, .driveUpgrade: + false + } + } + private static func isUnpublishedPartial(_ name: String) -> Bool { let components = name.split(separator: ".", omittingEmptySubsequences: false) guard components.count == 4, diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift index a419151d..9d8fbdd9 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift @@ -7,18 +7,19 @@ extension DoryOperationJournalStore { completenessPlanData: Data?, specifications: [DoryOperationSpecification], at date: Date, - fileManager: FileManager + fileManager: FileManager, + mutationScope: String? = nil ) throws -> DoryOperationLease { guard plan.isValid else { throw DoryOperationJournalError.invalidPlan(plan.id.uuidString.lowercased()) } try prepareRoot(fileManager: fileManager) - let lock = try acquireMutationLock() + let lock = try acquireMutationLock(scope: mutationScope) let destination = operationDirectory(for: plan.id) guard !Self.pathEntryExists(destination) else { throw DoryOperationJournalError.operationExists(plan.id) } - try requireNoUnfinishedOperation() + try requireNoUnfinishedOperation(mutationScope: mutationScope) let partial = root + "/." + plan.id.uuidString.lowercased() + "." + UUID().uuidString.lowercased() + ".partial" @@ -60,9 +61,13 @@ extension DoryOperationJournalStore { } } - private func requireNoUnfinishedOperation() throws { + private func requireNoUnfinishedOperation(mutationScope: String?) throws { if let unfinished = try list().first(where: { - $0.state.status != .completed && $0.state.status != .failed + guard $0.state.status != .completed && $0.state.status != .failed else { + return false + } + guard let mutationScope else { return true } + return $0.plan.source.id == mutationScope || $0.plan.target.id == mutationScope }) { throw DoryOperationJournalError.operationInUse( "Dory operation \(unfinished.plan.id.uuidString.lowercased()) requires recovery " diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift index 224abda1..d59d4f76 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift @@ -188,6 +188,24 @@ public struct DoryWorkspaceConfigurationAuthority: Codable, Sendable, Equatable } } +/// Content-only identity for a snapshot selected by a lifecycle operation. The descriptor digest +/// binds the complete persisted snapshot record while the evidence digest independently binds its +/// rootfs/kernel/firmware evidence. Daemon-local paths remain outside the journal contract. +public struct DoryWorkspaceSnapshotAuthority: Codable, Sendable, Equatable { + public var descriptorSHA256: String + public var artifactEvidenceSHA256: String + + public init(descriptorSHA256: String, artifactEvidenceSHA256: String) { + self.descriptorSHA256 = descriptorSHA256 + self.artifactEvidenceSHA256 = artifactEvidenceSHA256 + } + + fileprivate var isValid: Bool { + DoryOperationJournalStore.isDigest(descriptorSHA256) + && DoryOperationJournalStore.isDigest(artifactEvidenceSHA256) + } +} + public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable { private var persistenceSchemaVersion: UInt16 public var workspaceID: String @@ -456,6 +474,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { public var targetWorkspaceID: String? /// Stable snapshot or clone identity needed for deterministic recovery; never a host path. public var targetResourceID: String? + /// Exact content authority for snapshot/restore resources when supplied by the executor. + public var targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? public var createdAtUnixMilliseconds: Int64 public var deadlineUnixMilliseconds: Int64 public var steps: [DoryWorkspaceOperationStep] @@ -471,6 +491,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { target: DoryWorkspaceLifecycleCondition, targetWorkspaceID: String? = nil, targetResourceID: String? = nil, + targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? = nil, createdAtUnixMilliseconds: Int64, deadlineUnixMilliseconds: Int64, steps: [DoryWorkspaceOperationStep], @@ -487,6 +508,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { self.target = target self.targetWorkspaceID = targetWorkspaceID self.targetResourceID = targetResourceID + self.targetSnapshotAuthority = targetSnapshotAuthority self.createdAtUnixMilliseconds = createdAtUnixMilliseconds self.deadlineUnixMilliseconds = deadlineUnixMilliseconds self.steps = steps @@ -578,6 +600,17 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } else if targetResourceID != nil { add(.invalidTargetWorkspace, "targetResourceID") } + let requiresSnapshotAuthority = kind == .snapshotting || kind == .restoring + if sourceSchemaVersion == Self.schemaVersion, requiresSnapshotAuthority { + if targetSnapshotAuthority?.isValid != true || targetResourceID == nil { + add(.invalidCondition, "targetSnapshotAuthority") + } + } else if let targetSnapshotAuthority, + (!targetSnapshotAuthority.isValid + || !requiresSnapshotAuthority + || targetResourceID == nil) { + add(.invalidCondition, "targetSnapshotAuthority") + } if createdAtUnixMilliseconds < 0 || deadlineUnixMilliseconds <= createdAtUnixMilliseconds { add(.invalidDeadline, "deadlineUnixMilliseconds") @@ -624,6 +657,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { case target case targetWorkspaceID case targetResourceID + case targetSnapshotAuthority case createdAtUnixMilliseconds case deadlineUnixMilliseconds case steps @@ -667,6 +701,10 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } targetWorkspaceID = try container.decodeIfPresent(String.self, forKey: .targetWorkspaceID) targetResourceID = try container.decodeIfPresent(String.self, forKey: .targetResourceID) + targetSnapshotAuthority = try container.decodeIfPresent( + DoryWorkspaceSnapshotAuthority.self, + forKey: .targetSnapshotAuthority + ) createdAtUnixMilliseconds = try container.decode(Int64.self, forKey: .createdAtUnixMilliseconds) deadlineUnixMilliseconds = try container.decode(Int64.self, forKey: .deadlineUnixMilliseconds) steps = try container.decode([DoryWorkspaceOperationStep].self, forKey: .steps) @@ -697,6 +735,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { try container.encode(target, forKey: .target) try container.encodeIfPresent(targetWorkspaceID, forKey: .targetWorkspaceID) try container.encodeIfPresent(targetResourceID, forKey: .targetResourceID) + try container.encodeIfPresent(targetSnapshotAuthority, forKey: .targetSnapshotAuthority) try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds) try container.encode(deadlineUnixMilliseconds, forKey: .deadlineUnixMilliseconds) try container.encode(steps, forKey: .steps) @@ -877,6 +916,7 @@ extension DoryOperationJournalStore { "workspace lifecycle journal binding mismatch" ) } + let mutationScope = authenticatedMutationScope(for: binding.plan) return try begin( binding.plan, completenessPlanData: nil, @@ -884,7 +924,8 @@ extension DoryOperationJournalStore { at: Date( timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000 ), - fileManager: fileManager + fileManager: fileManager, + mutationScope: mutationScope ) } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7533357f..5ed13403 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -10,6 +10,9 @@ public struct MachineManagerConfiguration: Sendable, Equatable { public var acceleratedDesktopExecutablePath: String? public var stateDirectory: String public var runtimeDirectory: String + /// Durable mutation authority kept outside individual machine directories so interrupted + /// deletion can recover without deleting its own journal. + public var lifecycleJournalHome: String public var baseArguments: [String] public var acceleratedDesktopBaseArguments: [String] public var passMachineArguments: Bool @@ -35,6 +38,7 @@ public struct MachineManagerConfiguration: Sendable, Equatable { acceleratedDesktopExecutablePath: String? = nil, stateDirectory: String, runtimeDirectory: String? = nil, + lifecycleJournalHome: String? = nil, baseArguments: [String] = [], acceleratedDesktopBaseArguments: [String] = [], passMachineArguments: Bool = true, @@ -55,6 +59,8 @@ public struct MachineManagerConfiguration: Sendable, Equatable { self.acceleratedDesktopExecutablePath = acceleratedDesktopExecutablePath self.stateDirectory = stateDirectory self.runtimeDirectory = runtimeDirectory ?? stateDirectory + self.lifecycleJournalHome = lifecycleJournalHome + ?? "\(self.runtimeDirectory)/.lifecycle-journal" self.baseArguments = baseArguments self.acceleratedDesktopBaseArguments = acceleratedDesktopBaseArguments self.passMachineArguments = passMachineArguments @@ -679,6 +685,8 @@ public final class MachineManager: @unchecked Sendable { private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository private let launchPolicy: DoryMachineLaunchPolicy + private let lifecycleJournalStore: DoryOperationJournalStore? + private let lifecycleJournalInitializationError: String? private let operationLock = NSRecursiveLock() private let lock = NSLock() private var machines: [String: MachineEntry] = [:] @@ -690,6 +698,10 @@ public final class MachineManager: @unchecked Sendable { private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider? private var pendingResolvedStart: PendingResolvedMachineStart? private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:] + private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:] +#if DEBUG + private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)? +#endif public init( configuration: MachineManagerConfiguration, @@ -706,6 +718,17 @@ public final class MachineManager: @unchecked Sendable { self.agentConnector = agentConnector self.processStarter = processStarter self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory) + do { + let store = try DoryOperationJournalStore( + home: configuration.lifecycleJournalHome + ) + try store.prepare() + lifecycleJournalStore = store + lifecycleJournalInitializationError = nil + } catch { + lifecycleJournalStore = nil + lifecycleJournalInitializationError = String(describing: error) + } _ = HelperProcessJanitor.terminateStaleHelpers( executablePath: configuration.vmmExecutablePath, stateDirectory: configuration.stateDirectory, @@ -719,6 +742,10 @@ public final class MachineManager: @unchecked Sendable { includeDescendants: true ) } + let lifecycleRecoveryDiagnostics = Self.recoverInterruptedLifecycleOperations( + store: lifecycleJournalStore, + configuration: configuration + ) Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory) Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory) Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory) @@ -727,6 +754,9 @@ public final class MachineManager: @unchecked Sendable { configuration: configuration, launchPolicy: launchPolicy ) + for (machineID, diagnostic) in lifecycleRecoveryDiagnostics { + machines[machineID]?.lastError = diagnostic + } reconcileLoadedWorkspaceProjections() recoverInterruptedDesktopUpdates() } @@ -897,13 +927,38 @@ public final class MachineManager: @unchecked Sendable { public func start(id: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + return try startImplementation(id: id, journalLifecycle: true) + } + + private func startImplementation( + id: String, + journalLifecycle: Bool + ) throws -> DoryMachineStatus { switch launchPolicy { case .legacyCompatibility: - let prepared = try prepareMachineStart( + let prepared = try prepareMachineStartWithLifecycle( id: id, - requiresAuthoritativeDefinition: false + requiresAuthoritativeDefinition: false, + journalLifecycle: journalLifecycle ) - return try spawnPreparedMachine(prepared.machine, launchBinding: nil) + let identity = try currentRuntimeIdentity(id: id) + let lifecycle = try journalLifecycle + ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity) + : nil + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil) + if let lifecycle, status.state != .starting { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "running machine has an unfinished start journal" + ) + } + return status + } catch { + if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") } + throw error + } case .requireResolvedPlan: guard let registry = resolvedLaunchRegistry, let resolver = resolvedLaunchPlanResolver, @@ -918,7 +973,8 @@ public final class MachineManager: @unchecked Sendable { registry: registry, resolver: resolver, planStore: planStore, - revisionProvider: revisionProvider + revisionProvider: revisionProvider, + journalLifecycle: journalLifecycle ) } } @@ -928,9 +984,14 @@ public final class MachineManager: @unchecked Sendable { registry: BackendRegistry, resolver: any DoryDaemonVirtualMachineLaunchPlanResolving, planStore: any DoryResolvedMachinePlanStoring, - revisionProvider: ResolvedPlanRevisionProvider + revisionProvider: ResolvedPlanRevisionProvider, + journalLifecycle: Bool ) throws -> DoryMachineStatus { - let prepared = try prepareMachineStart(id: id, requiresAuthoritativeDefinition: true) + let prepared = try prepareMachineStartWithLifecycle( + id: id, + requiresAuthoritativeDefinition: true, + journalLifecycle: journalLifecycle + ) guard let definition = prepared.definition, let definitionData = prepared.canonicalDefinitionData else { throw MachineManagerError.persistence( @@ -972,39 +1033,54 @@ public final class MachineManager: @unchecked Sendable { resolvedPlan: resolved.resolvedPlan, planSHA256: resolved.resolvedPlanSHA256 ) + let lifecycle = try journalLifecycle + ? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity) + : nil - pendingResolvedStart = PendingResolvedMachineStart( - machine: prepared.machine, - backend: resolved.backendPlan.backend, - runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, - runtimeComponents: resolved.resolvedPlan.components, - planRevision: resolved.resolvedPlan.planRevision, - planSHA256: resolved.resolvedPlanSHA256 - ) - defer { pendingResolvedStart = nil } - let operation = registry.start(resolved.backendPlan) - guard operation.isSuccess, - operation.backend == resolved.resolvedPlan.backend, - operation.observation?.machineID == id else { - let failure = operation.failure?.message ?? "backend adapter returned no observation" - throw MachineManagerError.persistence("resolved backend start failed: \(failure)") - } - lock.lock() - if var entry = machines[id] { - entry.runtimeIdentity = runtimeIdentity - machines[id] = entry - } - lock.unlock() - guard let status = status(id: id), [.starting, .running].contains(status.state) else { - throw MachineManagerError.persistence( - "resolved backend did not enter MachineManager's prepared spawn path" + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + pendingResolvedStart = PendingResolvedMachineStart( + machine: prepared.machine, + backend: resolved.backendPlan.backend, + runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, + runtimeComponents: resolved.resolvedPlan.components, + planRevision: resolved.resolvedPlan.planRevision, + planSHA256: resolved.resolvedPlanSHA256 ) + defer { pendingResolvedStart = nil } + let operation = registry.start(resolved.backendPlan) + guard operation.isSuccess, + operation.backend == resolved.resolvedPlan.backend, + operation.observation?.machineID == id else { + let failure = operation.failure?.message ?? "backend adapter returned no observation" + throw MachineManagerError.persistence("resolved backend start failed: \(failure)") + } + lock.lock() + if var entry = machines[id] { + entry.runtimeIdentity = runtimeIdentity + machines[id] = entry + } + lock.unlock() + guard let status = status(id: id), [.starting, .running].contains(status.state) else { + throw MachineManagerError.persistence( + "resolved backend did not enter MachineManager's prepared spawn path" + ) + } + resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity( + plan: resolved.resolvedPlan, + planSHA256: resolved.resolvedPlanSHA256 + ) + if let lifecycle, status.state != .starting { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "running machine has an unfinished resolved-start journal" + ) + } + return status + } catch { + if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") } + throw error } - resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity( - plan: resolved.resolvedPlan, - planSHA256: resolved.resolvedPlanSHA256 - ) - return status } private func revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn( @@ -1228,6 +1304,48 @@ public final class MachineManager: @unchecked Sendable { ) } + private func prepareMachineStartWithLifecycle( + id: String, + requiresAuthoritativeDefinition: Bool, + journalLifecycle: Bool + ) throws -> PreparedMachineStart { + guard journalLifecycle else { + return try prepareMachineStart( + id: id, + requiresAuthoritativeDefinition: requiresAuthoritativeDefinition + ) + } + let lifecycle = try beginLifecycleStartPreparation(id: id) + do { + try advanceLifecycleToPublishing(lifecycle) + let prepared = try prepareMachineStart( + id: id, + requiresAuthoritativeDefinition: requiresAuthoritativeDefinition + ) +#if DEBUG + try injectLifecycleFault(.startAfterPreparation) +#endif + guard completeCommittedLifecycle( + lifecycle, + diagnostic: "start preparation committed; journal completion requires recovery" + ) else { + throw MachineLifecycleJournalCompletionPending() + } + return prepared + } catch { +#if DEBUG + if error is MachineLifecycleInjectedCrash { throw error } +#endif + if error is MachineLifecycleJournalCompletionPending { + throw MachineManagerError.persistence( + "start preparation committed but journal completion requires recovery" + ) + } + failLifecycle(lifecycle, stepID: "start-preparation.failed") + throw error + } + } + private func spawnPreparedMachine( _ preparedMachine: DoryMachineConfiguration, launchBinding: MachineBackendLaunchBinding? @@ -1275,7 +1393,16 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw error } - let process = HvProcess(configuration: processConfiguration) + let process = HvProcess( + configuration: processConfiguration, + unexpectedTerminationHandler: { [weak self] termination in + self?.handleUnexpectedMachineProcessTermination( + machineID: id, + launchID: launchID, + termination: termination + ) + } + ) let handoffReadyTimeout = handoffReadyTimeout(for: entry.configuration) entry.process = process entry.handoffServer = handoffServer @@ -1308,7 +1435,14 @@ public final class MachineManager: @unchecked Sendable { } private func startAndWaitUntilReady(id: String) throws -> DoryMachineStatus { - let started = try start(id: id) + try startAndWaitUntilReady(id: id, journalLifecycle: true) + } + + private func startAndWaitUntilReady( + id: String, + journalLifecycle: Bool + ) throws -> DoryMachineStatus { + let started = try startImplementation(id: id, journalLifecycle: journalLifecycle) guard started.state == .starting else { return started } let handoffReadyTimeout = handoffReadyTimeout( @@ -1375,33 +1509,106 @@ public final class MachineManager: @unchecked Sendable { entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s" self.machines[id] = entry self.lock.unlock() + self.operationLock.lock() + self.failActiveStartLifecycle(id: id, stepID: "start.readiness-timeout") + self.operationLock.unlock() process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) } } - public func stop(id: String) throws -> DoryMachineStatus { - operationLock.lock() - defer { operationLock.unlock() } + private func handleUnexpectedMachineProcessTermination( + machineID: String, + launchID: UUID, + termination: HvProcessTermination + ) { + var handoffServer: VmmHandoffServer? lock.lock() - guard var entry = machines[id] else { + guard var entry = machines[machineID], + entry.launchID == launchID, + [.starting, .running].contains(entry.state), + entry.process?.isRunningOrRestarting != true else { lock.unlock() - throw MachineManagerError.unknownMachine(id) + return } - let process = entry.process - let handoffServer = entry.handoffServer - entry.process = nil + handoffServer = entry.handoffServer entry.handoffServer = nil entry.handoff = nil entry.launchID = nil entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil - entry.state = .stopped - machines[id] = entry + entry.state = .failed + entry.lastError = "dory-vmm \(termination.description)" + machines[machineID] = entry lock.unlock() + operationLock.lock() + failActiveStartLifecycle(id: machineID, stepID: "start.helper-exited") + operationLock.unlock() handoffServer?.stop() - process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) - return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) + } + + public func stop(id: String) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-stop") + return try stopImplementation(id: id, journalLifecycle: true) + } + + private func stopImplementation( + id: String, + journalLifecycle: Bool + ) throws -> DoryMachineStatus { + lock.lock() + guard var entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + lock.unlock() + let lifecycle = try journalLifecycle && wasActive + ? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity) + : nil + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + lock.lock() + guard let current = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + entry = current + let process = entry.process + let handoffServer = entry.handoffServer + entry.process = nil + entry.handoffServer = nil + entry.handoff = nil + entry.launchID = nil + entry.runtimeAddress = nil + entry.currentBalloonTargetMB = nil + entry.state = .stopped + machines[id] = entry + lock.unlock() + + handoffServer?.stop() + process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) +#if DEBUG + try injectLifecycleFault(.stopAfterProcessStop) +#endif + if let lifecycle { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "stopped machine has an unfinished stop journal" + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) + } catch { +#if DEBUG + if error is MachineLifecycleInjectedCrash { throw error } +#endif + if let lifecycle { failLifecycle(lifecycle, stepID: "stop.failed") } + throw error + } } public func stopAll() { @@ -1436,52 +1643,97 @@ public final class MachineManager: @unchecked Sendable { guard Self.isValidID(id) else { throw MachineManagerError.invalidID(id) } + try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-delete") lock.lock() - guard let entry = machines.removeValue(forKey: id) else { + guard let sourceEntry = machines[id] else { lock.unlock() throw MachineManagerError.unknownMachine(id) } - deletingMachineIDs.insert(id) lock.unlock() + let lifecycle = try beginLifecycleDelete( + machine: sourceEntry.configuration, + runtimeIdentity: sourceEntry.runtimeIdentity, + sourceState: lifecycleState(for: sourceEntry.state) + ) + var deletionCommitted = false - entry.handoffServer?.stop() - entry.process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + do { + try advanceLifecycleToPublishing(lifecycle) + _ = try stopImplementation(id: id, journalLifecycle: false) + lock.lock() + guard machines.removeValue(forKey: id) != nil else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + deletingMachineIDs.insert(id) + lock.unlock() - let fileManager = FileManager.default - let statePath = machineStateDirectory(id: id) - var quarantinePath: String? - if fileManager.fileExists(atPath: statePath) { - let quarantine = "\(configuration.stateDirectory)/\(Self.deletionQuarantinePrefix)\(id)-\(UUID().uuidString)" - do { + let fileManager = FileManager.default + let statePath = machineStateDirectory(id: id) + var quarantinePath: String? + if fileManager.fileExists(atPath: statePath) { + let quarantine = lifecycleDeletionQuarantinePath( + machineID: id, + operationID: lifecycle.operation.operationID + ) try fileManager.moveItem(atPath: statePath, toPath: quarantine) quarantinePath = quarantine - } catch { - var restored = entry - restored.process = nil - restored.handoffServer = nil - restored.handoff = nil - restored.currentBalloonTargetMB = nil - restored.state = .stopped - restored.lastError = "delete failed: \(error)" - lock.lock() - deletingMachineIDs.remove(id) - if machines[id] == nil { - machines[id] = restored - } - lock.unlock() - throw MachineManagerError.persistence("could not delete \(id): \(error)") +#if DEBUG + try injectLifecycleFault(.deleteAfterQuarantine) +#endif } - } - lock.lock() - deletingMachineIDs.remove(id) - workspaceProjectionDiagnostics.removeValue(forKey: id) - lock.unlock() - resolvedLaunchIdentities.removeValue(forKey: id) + lock.lock() + deletingMachineIDs.remove(id) + workspaceProjectionDiagnostics.removeValue(forKey: id) + lock.unlock() + resolvedLaunchIdentities.removeValue(forKey: id) - try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id)) - if let quarantinePath { - try? fileManager.removeItem(atPath: quarantinePath) + try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id)) + if let quarantinePath { + try fileManager.removeItem(atPath: quarantinePath) + } + // Once both the authoritative state and its quarantine are gone, deletion is + // committed. A later journal fsync/transition failure must not resurrect an + // in-memory entry whose storage no longer exists. Recovery can deterministically + // finish the still-durable deleting operation on the next daemon start. + deletionCommitted = true + do { + try completeLifecycle(lifecycle) + } catch { + activeLifecycleOperations.removeValue(forKey: id) + lifecycle.releaseLease() + } + } catch { +#if DEBUG + if error is MachineLifecycleInjectedCrash { throw error } +#endif + if deletionCommitted { + activeLifecycleOperations.removeValue(forKey: id) + return + } + let quarantine = lifecycleDeletionQuarantinePath( + machineID: id, + operationID: lifecycle.operation.operationID + ) + let statePath = machineStateDirectory(id: id) + if Self.pathEntryExists(quarantine), !Self.pathEntryExists(statePath) { + try? FileManager.default.moveItem(atPath: quarantine, toPath: statePath) + } + var restored = sourceEntry + restored.process = nil + restored.handoffServer = nil + restored.handoff = nil + restored.currentBalloonTargetMB = nil + restored.state = .stopped + restored.lastError = "delete failed: \(error)" + lock.lock() + deletingMachineIDs.remove(id) + if machines[id] == nil { machines[id] = restored } + lock.unlock() + failLifecycle(lifecycle, stepID: "delete.failed", rolledBack: true) + if let error = error as? MachineManagerError { throw error } + throw MachineManagerError.persistence("could not delete \(id): \(error)") } } @@ -1631,28 +1883,79 @@ public final class MachineManager: @unchecked Sendable { !Self.pathEntryExists(nvramPath) else { throw MachineManagerError.duplicateSnapshot(snapshotID) } - + // Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for + // the snapshot can be hashed and bound before any snapshot artifact is published. if wasRunning { - _ = try stop(id: id) + _ = try stopImplementation(id: id, journalLifecycle: true) + } + let liveMachineIdentifierPath = machine.bootMode == .efi + ? machineFirmwareIdentifierPath(id: id) : nil + let liveNVRAMPath = machine.bootMode == .efi + ? machineFirmwareNVRAMPath(id: id) : nil + if machine.bootMode == .efi { + guard let liveMachineIdentifierPath, let liveNVRAMPath, + Self.isPrivateRegularFile(path: liveMachineIdentifierPath), + Self.isPrivateRegularFile(path: liveNVRAMPath) else { + throw MachineManagerError.persistence( + "EFI firmware state is unavailable; start the machine once before taking a snapshot" + ) + } + } + let artifactEvidence = try Self.snapshotArtifactEvidence( + rootfsPath: machine.rootfsPath, + kernelPath: machine.kernelPath, + machineIdentifierPath: liveMachineIdentifierPath, + nvramPath: liveNVRAMPath + ) + guard let snapshotSize = Int64(exactly: artifactEvidence.rootfs.byteCount) else { + throw MachineManagerError.persistence("machine snapshot disk is too large") } - let snapshot: DoryMachineSnapshot + let snapshot = DoryMachineSnapshot( + id: snapshotID, + machineID: id, + note: note, + createdISO: createdISO, + rootfsPath: rootfsPath, + sizeBytes: snapshotSize, + kernelPath: kernelPath, + architecture: configuration.guestArchitecture, + memoryMB: machine.memoryMB, + cpuCount: machine.cpuCount, + displayMode: machine.displayMode, + address: machine.address, + shares: machine.shares, + environment: machine.environment, + bootMode: machine.bootMode, + machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, + nvramPath: machine.bootMode == .efi ? nvramPath : nil, + runtimeIdentity: snapshotRuntimeIdentity, + artifactEvidence: artifactEvidence + ) + let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) + let lifecycle = try beginLifecycleSnapshot( + machine: machine, + runtimeIdentity: snapshotRuntimeIdentity, + sourceState: .stopped, + snapshotID: snapshotID, + snapshotAuthority: snapshotAuthority + ) + var publishedRootfs = false var publishedKernel = false var publishedMachineIdentifier = false var publishedNVRAM = false do { + try advanceLifecycleToPublishing(lifecycle) try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsPath) publishedRootfs = true +#if DEBUG + try injectLifecycleFault(.snapshotAfterRootfs) +#endif try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelPath) publishedKernel = true if machine.bootMode == .efi { - let liveMachineIdentifierPath = machineFirmwareIdentifierPath(id: id) - let liveNVRAMPath = machineFirmwareNVRAMPath(id: id) - guard Self.isPrivateRegularFile(path: liveMachineIdentifierPath), - Self.isPrivateRegularFile(path: liveNVRAMPath) else { - throw MachineManagerError.persistence( - "EFI firmware state is unavailable; start the machine once before taking a snapshot" - ) + guard let liveMachineIdentifierPath, let liveNVRAMPath else { + throw MachineManagerError.persistence("EFI firmware state is unavailable") } try Self.cloneOrCopyFile( source: liveMachineIdentifierPath, @@ -1662,35 +1965,13 @@ public final class MachineManager: @unchecked Sendable { try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath) publishedNVRAM = true } - let artifactEvidence = try Self.snapshotArtifactEvidence( - rootfsPath: rootfsPath, - kernelPath: kernelPath, - machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, - nvramPath: machine.bootMode == .efi ? nvramPath : nil - ) - snapshot = DoryMachineSnapshot( - id: snapshotID, - machineID: id, - note: note, - createdISO: createdISO, - rootfsPath: rootfsPath, - sizeBytes: Self.fileSize(path: rootfsPath), - kernelPath: kernelPath, - architecture: configuration.guestArchitecture, - memoryMB: machine.memoryMB, - cpuCount: machine.cpuCount, - displayMode: machine.displayMode, - address: machine.address, - shares: machine.shares, - environment: machine.environment, - bootMode: machine.bootMode, - machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, - nvramPath: machine.bootMode == .efi ? nvramPath : nil, - runtimeIdentity: snapshotRuntimeIdentity, - artifactEvidence: artifactEvidence - ) + // Validate the copies against the pre-publish authority before publishing metadata. + try Self.validateSnapshotArtifactEvidence(snapshot) try persistSnapshot(snapshot) } catch { +#if DEBUG + if error is MachineLifecycleInjectedCrash { throw error } +#endif if publishedRootfs { try? FileManager.default.removeItem(atPath: rootfsPath) } @@ -1703,8 +1984,9 @@ public final class MachineManager: @unchecked Sendable { if publishedNVRAM { try? FileManager.default.removeItem(atPath: nvramPath) } + failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true) if wasRunning { - _ = try? start(id: id) + _ = try? startImplementation(id: id, journalLifecycle: true) } if let error = error as? MachineManagerError { throw error @@ -1712,12 +1994,16 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("could not snapshot \(id): \(error)") } - if wasRunning { + let journalCompleted = completeCommittedLifecycle( + lifecycle, + diagnostic: "published snapshot has an unfinished snapshot journal" + ) + if wasRunning, journalCompleted { do { - _ = try start(id: id) + _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch let firstError { do { - _ = try start(id: id) + _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { throw MachineManagerError.persistence( "snapshot \(snapshotID) was created, but \(id) could not restart: \(firstError); retry: \(error)" @@ -2018,40 +2304,65 @@ public final class MachineManager: @unchecked Sendable { restoredMachine.address = address restoredMachine.shares = snapshot.shares restoredMachine.environment = snapshot.environment - if wasRunning { - _ = try stop(id: machineID) - } + let sourceIdentity = try currentRuntimeIdentity(id: machineID) + let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity) + let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) + let lifecycle = try beginLifecycleRestore( + sourceMachine: machine, + targetMachine: restoredMachine, + sourceIdentity: sourceIdentity, + targetIdentity: targetIdentity, + sourceState: wasRunning ? .running : .stopped, + targetState: wasRunning && launchPolicy == .legacyCompatibility ? .running : .stopped, + snapshotID: snapshotID, + snapshotAuthority: snapshotAuthority + ) do { - try restoreManagedArtifacts(machine: machine, snapshot: snapshot) { + try advanceLifecycleToPublishing(lifecycle) + if wasRunning { + _ = try stopImplementation(id: machineID, journalLifecycle: false) + } + try restoreManagedArtifacts( + machine: machine, + snapshot: snapshot, + operationID: lifecycle.operation.operationID + ) { try persist(restoredMachine) lock.lock() if var entry = machines[machineID] { entry.configuration = restoredMachine entry.currentBalloonTargetMB = nil - entry.runtimeIdentity = runtimeIdentityAfterSnapshotRestore( - snapshot.runtimeIdentity - ) + entry.runtimeIdentity = targetIdentity machines[machineID] = entry } lock.unlock() } let status: DoryMachineStatus if wasRunning, launchPolicy == .legacyCompatibility { - status = try start(id: machineID) + status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false) } else { status = self.status(id: machineID) ?? DoryMachineStatus(id: machineID, state: .stopped) } + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "restored machine has an unfinished restore journal" + ) return status } catch let error as MachineManagerError { if wasRunning { - _ = try? start(id: machineID) + _ = try? startImplementation(id: machineID, journalLifecycle: false) } + failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true) throw error } catch { +#if DEBUG + if error is MachineLifecycleInjectedCrash { throw error } +#endif if wasRunning { - _ = try? start(id: machineID) + _ = try? startImplementation(id: machineID, journalLifecycle: false) } + failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true) throw MachineManagerError.persistence("could not restore snapshot \(snapshotID): \(error)") } } @@ -3105,19 +3416,25 @@ public final class MachineManager: @unchecked Sendable { private func restoreManagedArtifacts( machine: DoryMachineConfiguration, snapshot: DoryMachineSnapshot, + operationID: UUID, commit: () throws -> Void ) throws { let directory = machineStateDirectory(id: machine.id) - let token = UUID().uuidString - let rootfsBackup = "\(directory)/.restore-rootfs-\(token)" - let kernelBackup = "\(directory)/.restore-kernel-\(token)" - let machineIdentifierBackup = "\(directory)/.restore-machine-identifier-\(token)" - let nvramBackup = "\(directory)/.restore-nvram-\(token)" + let token = operationID.uuidString.lowercased() + let rootfsBackup = "\(directory)/.restore-\(token)-rootfs" + let kernelBackup = "\(directory)/.restore-\(token)-kernel" + let machineIdentifierBackup = "\(directory)/.restore-\(token)-machine-identifier" + let nvramBackup = "\(directory)/.restore-\(token)-nvram" + let configurationBackup = "\(directory)/.restore-\(token)-machine-json" + var preserveBackupsForRecovery = false defer { - try? FileManager.default.removeItem(atPath: rootfsBackup) - try? FileManager.default.removeItem(atPath: kernelBackup) - try? FileManager.default.removeItem(atPath: machineIdentifierBackup) - try? FileManager.default.removeItem(atPath: nvramBackup) + if !preserveBackupsForRecovery { + try? FileManager.default.removeItem(atPath: rootfsBackup) + try? FileManager.default.removeItem(atPath: kernelBackup) + try? FileManager.default.removeItem(atPath: machineIdentifierBackup) + try? FileManager.default.removeItem(atPath: nvramBackup) + try? FileManager.default.removeItem(atPath: configurationBackup) + } } try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsBackup) do { @@ -3125,6 +3442,16 @@ public final class MachineManager: @unchecked Sendable { } catch { throw MachineManagerError.persistence("could not preserve live kernel before restore: \(error)") } + do { + try Self.cloneOrCopyFile( + source: machineConfigPath(id: machine.id), + destination: configurationBackup + ) + } catch { + throw MachineManagerError.persistence( + "could not preserve machine metadata before restore: \(error)" + ) + } let firmwareRestore: (identifier: String, nvram: String, snapshotIdentifier: String, snapshotNVRAM: String)? if snapshot.bootMode == .efi { let identifier = machineFirmwareIdentifierPath(id: machine.id) @@ -3145,6 +3472,14 @@ public final class MachineManager: @unchecked Sendable { } else { firmwareRestore = nil } +#if DEBUG + do { + try injectLifecycleFault(.restoreAfterBackups) + } catch { + preserveBackupsForRecovery = true + throw error + } +#endif do { try Self.cloneOrCopyFile( source: snapshot.rootfsPath, @@ -3168,6 +3503,15 @@ public final class MachineManager: @unchecked Sendable { replaceExisting: true ) } + guard Self.recoveredLiveArtifactsMatch( + snapshot: snapshot, + machineID: machine.id, + configuration: configuration + ) else { + throw MachineManagerError.persistence( + "restored machine artifacts do not match bound snapshot evidence" + ) + } try commit() } catch { var rollbackFailures: [String] = [] @@ -3189,6 +3533,15 @@ public final class MachineManager: @unchecked Sendable { } catch { rollbackFailures.append("kernel rollback failed: \(error)") } + do { + try Self.cloneOrCopyFile( + source: configurationBackup, + destination: machineConfigPath(id: machine.id), + replaceExisting: true + ) + } catch { + rollbackFailures.append("machine metadata rollback failed: \(error)") + } if let firmwareRestore { do { try Self.cloneOrCopyFile( @@ -3479,9 +3832,7 @@ public final class MachineManager: @unchecked Sendable { guard Self.isPrivateDirectory(path: directory) else { throw MachineManagerError.persistence("machine snapshot path is not a private directory") } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(snapshot) + let data = try Self.snapshotDescriptorData(snapshot) let path = snapshotMetadataPath(machineID: snapshot.machineID, snapshotID: snapshot.id) try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic) try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath) @@ -3545,6 +3896,34 @@ public final class MachineManager: @unchecked Sendable { return validated } + private static func snapshotDescriptorData(_ snapshot: DoryMachineSnapshot) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(snapshot) + } + + private static func snapshotEvidenceData( + _ evidence: DoryMachineSnapshotArtifactEvidence + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(evidence) + } + + private static func lifecycleSnapshotAuthority( + _ snapshot: DoryMachineSnapshot + ) throws -> DoryWorkspaceSnapshotAuthority { + guard let evidence = snapshot.artifactEvidence, evidence.isValid else { + throw MachineManagerError.persistence( + "machine snapshot lacks immutable lifecycle artifact evidence" + ) + } + return DoryWorkspaceSnapshotAuthority( + descriptorSHA256: sha256(data: try snapshotDescriptorData(snapshot)), + artifactEvidenceSHA256: sha256(data: try snapshotEvidenceData(evidence)) + ) + } + fileprivate static func validateSnapshotRuntimeIdentity( _ snapshot: DoryMachineSnapshot ) throws { @@ -3710,6 +4089,7 @@ public final class MachineManager: @unchecked Sendable { var handoffServer: VmmHandoffServer? var processToStop: HvProcess? var agentSocketPath: String? + var lifecycleReadinessSucceeded = false lock.lock() guard var entry = machines[machineID], entry.launchID == launchID else { lock.unlock() @@ -3732,6 +4112,7 @@ public final class MachineManager: @unchecked Sendable { entry.lastError = nil entry.process?.disableRestarts() agentSocketPath = handoff.ready.agentSocketPath + lifecycleReadinessSucceeded = true case let .failure(error): entry.state = .failed entry.lastError = "\(error)" @@ -3742,6 +4123,14 @@ public final class MachineManager: @unchecked Sendable { machines[machineID] = entry lock.unlock() + operationLock.lock() + if lifecycleReadinessSucceeded { + completeActiveStartLifecycle(id: machineID) + } else { + failActiveStartLifecycle(id: machineID, stepID: "start.readiness-failed") + } + operationLock.unlock() + handoffServer?.stop() processToStop?.stop() if let agentSocketPath { @@ -4263,6 +4652,831 @@ public final class MachineManager: @unchecked Sendable { return loaded } + private func currentRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity { + lock.lock() + defer { lock.unlock() } + guard let identity = machines[id]?.runtimeIdentity else { + throw MachineManagerError.unknownMachine(id) + } + guard identity.validate().isEmpty else { + throw MachineManagerError.persistence("machine runtime identity is invalid") + } + return identity + } + + private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState { + switch state { + case .starting, .running: .running + case .created, .stopped: .stopped + case .failed: .failed + } + } + + private func lifecycleCondition( + machine: DoryMachineConfiguration, + state: DoryWorkspaceLifecycleState, + runtimeIdentity: DoryMachineRuntimeIdentity + ) throws -> DoryWorkspaceLifecycleCondition { + let legacyData: Data + if let current = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)), + let decoded = try? JSONDecoder().decode(DoryMachineConfiguration.self, from: current), + decoded == machine { + legacyData = current + } else { + legacyData = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine) + } + let facts = try workspaceMigrationFacts(for: machine) + let definition = try DoryMachineConfigurationMigrationBridge.migrate( + machine, + facts: facts + ).definition + let definitionData = try Self.canonicalDefinitionData(definition) + return DoryWorkspaceLifecycleCondition( + workspaceID: machine.id, + state: state, + definitionRevision: definition.lifecycle.revision, + runtime: try lifecycleRuntimeBinding(runtimeIdentity), + configurationAuthority: DoryWorkspaceConfigurationAuthority( + legacyConfigurationSHA256: Self.sha256(data: legacyData), + canonicalDefinitionSHA256: Self.sha256(data: definitionData) + ) + ) + } + + private func lifecycleRuntimeBinding( + _ identity: DoryMachineRuntimeIdentity + ) throws -> DoryWorkspaceRuntimeBinding { + guard identity.validate().isEmpty else { + throw MachineManagerError.persistence("machine runtime identity is invalid") + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let digest = Self.sha256(data: try encoder.encode(identity)) + switch identity.mode { + case .legacyCompatibility: + return .legacyCompatibility( + virtualHardwareABIVersion: identity.virtualHardwareABIVersion, + runtimeIdentityDigest: digest + ) + case .requiresReplanning: + return .requiresReplanning( + virtualHardwareABIVersion: identity.virtualHardwareABIVersion, + runtimeIdentityDigest: digest + ) + case .resolvedPlan: + guard let plan = identity.resolvedPlan, + let planDigest = identity.resolvedPlanSHA256 else { + throw MachineManagerError.persistence("resolved runtime identity is incomplete") + } + return .resolvedPlan( + DoryWorkspaceResolvedCondition( + planRevision: plan.planRevision, + planDigest: planDigest, + backendID: plan.backend.rawValue, + backendRuntimeBuildID: plan.backendRuntimeBuildIdentifier, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ), + runtimeIdentityDigest: digest + ) + } + } + + private func beginLifecycleStart( + machine: DoryMachineConfiguration, + targetIdentity: DoryMachineRuntimeIdentity + ) throws -> MachineLifecycleJournalContext { + let sourceIdentity = try currentRuntimeIdentity(id: machine.id) + return try beginLifecycleOperation( + kind: .starting, + source: lifecycleCondition( + machine: machine, + state: .stopped, + runtimeIdentity: sourceIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .running, + runtimeIdentity: targetIdentity + ), + targetResourceID: nil, + readiness: true + ) + } + + private func beginLifecycleStartPreparation( + id: String + ) throws -> MachineLifecycleJournalContext { + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + lock.unlock() + + var condition = try lifecycleCondition( + machine: machine, + state: .stopped, + runtimeIdentity: runtimeIdentity + ) + // Boot-bundle and direct-boot materialization are derived from the unchanged authoritative + // legacy configuration. Their projection facts may legitimately become more specific, so + // this preparation boundary binds the exact raw authority and runtime identity while the + // subsequent start journal binds the reconciled canonical definition. + condition.configurationAuthority?.canonicalDefinitionSHA256 = nil + return try beginLifecycleOperation( + kind: .resolving, + source: condition, + target: condition, + targetResourceID: nil, + readiness: false + ) + } + + private func beginLifecycleStop( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .stopping, + source: lifecycleCondition( + machine: machine, + state: .running, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .stopped, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: nil, + readiness: false + ) + } + + private func beginLifecycleSnapshot( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity, + sourceState: DoryWorkspaceLifecycleState, + snapshotID: String, + snapshotAuthority: DoryWorkspaceSnapshotAuthority + ) throws -> MachineLifecycleJournalContext { + let condition = try lifecycleCondition( + machine: machine, + state: sourceState, + runtimeIdentity: runtimeIdentity + ) + return try beginLifecycleOperation( + kind: .snapshotting, + source: condition, + target: condition, + targetResourceID: snapshotID, + targetSnapshotAuthority: snapshotAuthority, + readiness: false + ) + } + + private func beginLifecycleRestore( + sourceMachine: DoryMachineConfiguration, + targetMachine: DoryMachineConfiguration, + sourceIdentity: DoryMachineRuntimeIdentity, + targetIdentity: DoryMachineRuntimeIdentity, + sourceState: DoryWorkspaceLifecycleState, + targetState: DoryWorkspaceLifecycleState, + snapshotID: String, + snapshotAuthority: DoryWorkspaceSnapshotAuthority + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .restoring, + source: lifecycleCondition( + machine: sourceMachine, + state: sourceState, + runtimeIdentity: sourceIdentity + ), + target: lifecycleCondition( + machine: targetMachine, + state: targetState, + runtimeIdentity: targetIdentity + ), + targetResourceID: snapshotID, + targetSnapshotAuthority: snapshotAuthority, + readiness: targetState == .running + ) + } + + private func beginLifecycleDelete( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity, + sourceState: DoryWorkspaceLifecycleState + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .deleting, + source: lifecycleCondition( + machine: machine, + state: sourceState, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .deleting, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: nil, + readiness: false + ) + } + + private func beginLifecycleOperation( + kind: DoryWorkspaceMutationKind, + source: DoryWorkspaceLifecycleCondition, + target: DoryWorkspaceLifecycleCondition, + targetResourceID: String?, + targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? = nil, + readiness: Bool + ) throws -> MachineLifecycleJournalContext { + let machineID = source.workspaceID + if kind == .snapshotting || kind == .restoring { + guard targetSnapshotAuthority != nil else { + throw MachineManagerError.persistence( + "snapshot lifecycle operation is missing immutable artifact authority" + ) + } + } + guard activeLifecycleOperations[machineID] == nil else { + throw MachineManagerError.persistence( + "machine \(machineID) already has an active lifecycle mutation" + ) + } + guard let store = lifecycleJournalStore else { + throw MachineManagerError.persistence( + "lifecycle journal is unavailable: \(lifecycleJournalInitializationError ?? "unknown error")" + ) + } + let now = Date() + let created = Int64(max(0, (now.timeIntervalSince1970 * 1_000).rounded())) + let deadlineDelta: Int64 = 15 * 60 * 1_000 + let deadline = created > Int64.max - deadlineDelta ? Int64.max : created + deadlineDelta + let operation = DoryWorkspaceLifecycleOperation( + kind: kind, + source: source, + target: target, + targetResourceID: targetResourceID, + targetSnapshotAuthority: targetSnapshotAuthority, + createdAtUnixMilliseconds: created, + deadlineUnixMilliseconds: deadline, + steps: [ + .init(id: "quiesce", stage: .quiesce, deadlineOffsetMilliseconds: 60_000), + .init(id: "stage", stage: .prepare, deadlineOffsetMilliseconds: 120_000), + .init(id: "verify", stage: .mutate, deadlineOffsetMilliseconds: 300_000), + .init(id: "publish", stage: .publish, deadlineOffsetMilliseconds: 600_000), + .init(id: "validate", stage: readiness ? .readiness : .cleanup, + deadlineOffsetMilliseconds: 840_000), + ], + readinessGates: readiness + ? [.init(kind: .backendRunning, deadlineOffsetMilliseconds: 840_000)] : [], + retryBudgets: [], + cancellationPolicy: kind == .deleting ? .prohibited : .rollbackRequired, + recovery: .init(disposition: .rollback, stepIDs: ["stage", "publish"]) + ) + let dependency = MachineLifecycleDependencyAuthority( + mutationKind: kind.rawValue, + workspaceID: machineID, + sourceLegacyConfigurationSHA256: + source.configurationAuthority?.legacyConfigurationSHA256, + sourceDefinitionSHA256: source.configurationAuthority?.canonicalDefinitionSHA256, + sourceRuntimeIdentitySHA256: source.runtime?.runtimeIdentityDigest, + targetLegacyConfigurationSHA256: + target.configurationAuthority?.legacyConfigurationSHA256, + targetDefinitionSHA256: target.configurationAuthority?.canonicalDefinitionSHA256, + targetRuntimeIdentitySHA256: target.runtime?.runtimeIdentityDigest, + targetResourceID: targetResourceID, + targetSnapshotDescriptorSHA256: + targetSnapshotAuthority?.descriptorSHA256, + targetSnapshotArtifactEvidenceSHA256: + targetSnapshotAuthority?.artifactEvidenceSHA256 + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let binding = try operation.journalBinding( + dependencyClosureDigest: Self.sha256(data: try encoder.encode(dependency)) + ) + let context = MachineLifecycleJournalContext( + operation: operation, + lease: try store.begin(binding) + ) + activeLifecycleOperations[machineID] = context + return context + } + + private func advanceLifecycleToPublishing( + _ context: MachineLifecycleJournalContext + ) throws { + for phase in [ + DoryOperationPhase.quiescing, + .staging, + .verifying, + .readyToPublish, + .publishing, + ] { + let current = try context.lease.read().state + if current.phase.indexForMachineLifecycle >= phase.indexForMachineLifecycle { continue } + _ = try context.lease.transition( + to: phase, + status: .running, + expectedRevision: current.revision, + stepID: "lifecycle.\(phase.rawValue)" + ) + } + } + + private func completeLifecycle(_ context: MachineLifecycleJournalContext) throws { + var current = try context.lease.read().state + if current.phase.indexForMachineLifecycle < DoryOperationPhase.validating.indexForMachineLifecycle { + current = try context.lease.transition( + to: .validating, + status: .running, + expectedRevision: current.revision, + stepID: "lifecycle.validated" + ) + } +#if DEBUG + // Persist the post-commit boundary before exercising the terminal journal write. Recovery + // can then distinguish a committed target from a pre-commit interruption even when the + // source and target machine.json bytes happen to be identical. + try injectLifecycleFault(.completionBeforeJournalWrite(context.operation.kind)) +#endif + if current.phase.indexForMachineLifecycle < DoryOperationPhase.completed.indexForMachineLifecycle { + _ = try context.lease.transition( + to: .completed, + status: .completed, + expectedRevision: current.revision, + stepID: "lifecycle.completed" + ) + } + activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) + context.releaseLease() + } + + @discardableResult + private func completeCommittedLifecycle( + _ context: MachineLifecycleJournalContext, + diagnostic: String + ) -> Bool { + do { + try completeLifecycle(context) + return true + } catch { + // The target side effect is already durable. Preserve the journal's last valid, + // nonterminal state so restart recovery can verify and finish it; claiming rollback + // or terminal failure here would contradict the machine/snapshot authority on disk. + activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) + context.releaseLease() + lock.lock() + if machines[context.operation.source.workspaceID] != nil { + machines[context.operation.source.workspaceID]?.lastError = + "\(diagnostic): \(error)" + } + lock.unlock() + return false + } + } + + private func failLifecycle( + _ context: MachineLifecycleJournalContext, + stepID: String, + rolledBack: Bool = false + ) { + defer { + activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) + context.releaseLease() + } + guard var current = try? context.lease.read().state, + current.status != .failed, current.status != .completed else { return } + if rolledBack, + let rollingBack = try? context.lease.transition( + to: current.phase, + status: .rollingBack, + expectedRevision: current.revision, + stepID: "lifecycle.rolling-back", + recoveryAction: "rollback" + ) { + current = rollingBack + } + _ = try? context.lease.transition( + to: current.phase, + status: .failed, + expectedRevision: current.revision, + stepID: stepID, + recoveryAction: rolledBack ? "rollback" : nil + ) + } + + private func completeActiveStartLifecycle(id: String) { + guard let context = activeLifecycleOperations[id], context.operation.kind == .starting else { + return + } + _ = completeCommittedLifecycle( + context, + diagnostic: "running machine has an unfinished readiness journal" + ) + } + + private func failActiveStartLifecycle(id: String, stepID: String) { + guard let context = activeLifecycleOperations[id], context.operation.kind == .starting else { + return + } + failLifecycle(context, stepID: stepID) + } + + private func cancelActiveStartLifecycleIfNeeded(id: String, reason: String) throws { + guard let context = activeLifecycleOperations[id] else { return } + guard context.operation.kind == .starting else { + throw MachineManagerError.persistence( + "machine \(id) already has an active lifecycle mutation" + ) + } + failLifecycle(context, stepID: reason) + } + + private func lifecycleDeletionQuarantinePath( + machineID: String, + operationID: UUID + ) -> String { + Self.lifecycleDeletionQuarantinePath( + configuration: configuration, + machineID: machineID, + operationID: operationID + ) + } + + private static func lifecycleDeletionQuarantinePath( + configuration: MachineManagerConfiguration, + machineID: String, + operationID: UUID + ) -> String { + "\(configuration.stateDirectory)/\(deletionQuarantinePrefix)\(machineID)-" + + operationID.uuidString.lowercased() + } + +#if DEBUG + func installLifecycleFaultInjectorForTesting( + _ injector: @escaping @Sendable (MachineLifecycleFaultPoint) throws -> Void + ) { + operationLock.lock() + lifecycleFaultInjector = injector + operationLock.unlock() + } + + private func injectLifecycleFault(_ point: MachineLifecycleFaultPoint) throws { + try lifecycleFaultInjector?(point) + } +#endif + + private static func recoverInterruptedLifecycleOperations( + store: DoryOperationJournalStore?, + configuration: MachineManagerConfiguration + ) -> [String: String] { + guard let store, let records = try? store.list() else { return [:] } + var diagnostics: [String: String] = [:] + for record in records where record.state.status != .completed + && record.state.status != .failed { + var lease: DoryOperationLease? + do { + guard Self.isValidID(record.plan.source.id) else { continue } + lease = try store.acquire(record.plan.id) + guard let lease else { continue } + let operation = try lease.readWorkspaceLifecycleOperation() + guard operation.source.workspaceID == record.plan.source.id, + operation.target.workspaceID == record.plan.source.id else { + throw MachineManagerError.persistence( + "lifecycle journal mutation scope changed during recovery" + ) + } + let id = operation.source.workspaceID + let recoveryState = try lease.read().state + switch operation.kind { + case .resolving: + if lifecycleConfigurationMatches( + operation.target.configurationAuthority, + machineID: id, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = + "interrupted start preparation was recovered; machine remains stopped" + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = + "interrupted start preparation authority changed; recovery failed closed" + } + case .starting: + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = "interrupted start was recovered as stopped" + case .stopping: + if lifecycleConfigurationMatches( + operation.target.configurationAuthority, + machineID: id, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = "interrupted stop completed during daemon recovery" + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = "interrupted stop authority changed; recovery failed closed" + } + case .snapshotting: + if recoveryState.phase.indexForMachineLifecycle + >= DoryOperationPhase.validating.indexForMachineLifecycle, + recoveredSnapshot( + machineID: id, + operation: operation, + configuration: configuration + ) != nil, + lifecycleConfigurationMatches( + operation.target.configurationAuthority, + machineID: id, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = + "published snapshot journal completed during daemon recovery" + } else { + if let snapshotID = operation.targetResourceID { + removeRecoveredSnapshot( + machineID: id, + snapshotID: snapshotID, + configuration: configuration + ) + } + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = "interrupted snapshot was rolled back" + } + case .restoring: + if recoveryState.phase.indexForMachineLifecycle + >= DoryOperationPhase.validating.indexForMachineLifecycle { + if let snapshot = recoveredSnapshot( + machineID: id, + operation: operation, + configuration: configuration + ), lifecycleConfigurationMatches( + operation.target.configurationAuthority, + machineID: id, + configuration: configuration + ), recoveredLiveArtifactsMatch( + snapshot: snapshot, + machineID: id, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = + "interrupted snapshot restore completed before daemon restart" + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = "committed snapshot restore authority requires repair" + } + } else { + let restored = restoreRecoveredMachineBackups( + machineID: id, + operationID: operation.operationID, + configuration: configuration + ) + if restored || lifecycleConfigurationMatches( + operation.source.configurationAuthority, + machineID: id, + configuration: configuration + ) { + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = "interrupted snapshot restore was rolled back" + } else if lifecycleConfigurationMatches( + operation.target.configurationAuthority, + machineID: id, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = + "interrupted snapshot restore completed before daemon restart" + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = "interrupted snapshot restore requires repair" + } + } + case .deleting: + let state = "\(configuration.stateDirectory)/\(id)" + let quarantine = lifecycleDeletionQuarantinePath( + configuration: configuration, + machineID: id, + operationID: operation.operationID + ) + if pathEntryExists(quarantine), !pathEntryExists(state) { + guard rename(quarantine, state) == 0 else { + throw MachineManagerError.persistence( + "could not roll back interrupted machine deletion" + ) + } + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = "interrupted deletion was rolled back" + } else if !pathEntryExists(quarantine), !pathEntryExists(state) { + try completeRecoveredLifecycle(lease) + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = "interrupted deletion preserved existing machine data" + } + case .importing, .provisioning, .pausing, .resuming, + .suspending, .cloning, .updating, .repairing: + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = "unsupported interrupted lifecycle mutation requires repair" + } + } catch { + if let id = try? lease?.readWorkspaceLifecycleOperation().source.workspaceID { + diagnostics[id] = "lifecycle recovery failed closed: \(error)" + } + } + lease = nil + } + return diagnostics + } + + private static func lifecycleConfigurationMatches( + _ authority: DoryWorkspaceConfigurationAuthority?, + machineID: String, + configuration: MachineManagerConfiguration + ) -> Bool { + guard let expected = authority?.legacyConfigurationSHA256, + let data = readPrivateMetadata( + path: "\(configuration.stateDirectory)/\(machineID)/machine.json" + ) else { return false } + return sha256(data: data) == expected + } + + private static func completeRecoveredLifecycle(_ lease: DoryOperationLease) throws { + var current = try lease.read().state + if current.status != .running { + current = try lease.transition( + to: current.phase, + status: .running, + expectedRevision: current.revision, + stepID: "recovery.resumed" + ) + } + for phase in DoryOperationPhase.allCases + where phase.indexForMachineLifecycle > current.phase.indexForMachineLifecycle { + current = try lease.transition( + to: phase, + status: phase == .completed ? .completed : .running, + expectedRevision: current.revision, + stepID: phase == .completed ? "recovery.completed" : "recovery.\(phase.rawValue)" + ) + } + } + + private static func failRecoveredLifecycle( + _ lease: DoryOperationLease, + rolledBack: Bool + ) throws { + var current = try lease.read().state + if rolledBack, current.status != .rollingBack { + current = try lease.transition( + to: current.phase, + status: .rollingBack, + expectedRevision: current.revision, + stepID: "recovery.rolling-back", + recoveryAction: "rollback" + ) + } + _ = try lease.transition( + to: current.phase, + status: .failed, + expectedRevision: current.revision, + stepID: "recovery.failed", + recoveryAction: rolledBack ? "rollback" : nil + ) + } + + private static func removeRecoveredSnapshot( + machineID: String, + snapshotID: String, + configuration: MachineManagerConfiguration + ) { + let directory = "\(configuration.stateDirectory)/\(machineID)/snapshots" + for suffix in ["json", "ext4", "kernel", "machine-identifier", "nvram"] { + try? FileManager.default.removeItem( + atPath: "\(directory)/\(snapshotID).\(suffix)" + ) + } + } + + private static func recoveredSnapshot( + machineID: String, + operation: DoryWorkspaceLifecycleOperation, + configuration: MachineManagerConfiguration + ) -> DoryMachineSnapshot? { + guard let snapshotID = operation.targetResourceID, + let authority = operation.targetSnapshotAuthority, + isValidID(machineID), isValidID(snapshotID) else { return nil } + let directory = "\(configuration.stateDirectory)/\(machineID)/snapshots" + let metadataPath = "\(directory)/\(snapshotID).json" + let rootfsPath = "\(directory)/\(snapshotID).ext4" + let kernelPath = "\(directory)/\(snapshotID).kernel" + let machineIdentifierPath = "\(directory)/\(snapshotID).machine-identifier" + let nvramPath = "\(directory)/\(snapshotID).nvram" + guard isPrivateDirectory(path: directory), + let metadata = readPrivateMetadata(path: metadataPath), + sha256(data: metadata) == authority.descriptorSHA256, + let snapshot = try? JSONDecoder().decode(DoryMachineSnapshot.self, from: metadata), + snapshot.id == snapshotID, + snapshot.machineID == machineID, + snapshot.rootfsPath == rootfsPath, + snapshot.kernelPath == kernelPath, + snapshot.architecture == configuration.guestArchitecture, + snapshot.sizeBytes > 0, + snapshot.runtimeIdentity.validate().isEmpty, + (try? validateResources( + memoryMB: snapshot.memoryMB, + cpuCount: snapshot.cpuCount + )) != nil, + let evidence = snapshot.artifactEvidence, + evidence.isValid, + (try? snapshotEvidenceData(evidence)).map(sha256(data:)) + == authority.artifactEvidenceSHA256, + isPrivateRegularFile(path: rootfsPath), + isPrivateRegularFile(path: kernelPath) else { return nil } + switch snapshot.bootMode { + case .linuxKernel: + guard snapshot.machineIdentifierPath == nil, + snapshot.nvramPath == nil else { return nil } + case .efi: + guard snapshot.machineIdentifierPath == machineIdentifierPath, + snapshot.nvramPath == nvramPath, + isPrivateRegularFile(path: machineIdentifierPath), + isPrivateRegularFile(path: nvramPath) else { return nil } + } + guard (try? validateSnapshotRuntimeIdentity(snapshot)) != nil, + (try? validateSnapshotArtifactEvidence(snapshot)) != nil else { return nil } + return snapshot + } + + private static func recoveredLiveArtifactsMatch( + snapshot: DoryMachineSnapshot, + machineID: String, + configuration: MachineManagerConfiguration + ) -> Bool { + guard let expected = snapshot.artifactEvidence else { return false } + let directory = "\(configuration.stateDirectory)/\(machineID)" + let rootfsPath = "\(directory)/rootfs.ext4" + let kernelPath = "\(directory)/kernel" + let machineIdentifierPath = snapshot.bootMode == .efi + ? "\(directory)/MachineIdentifier" : nil + let nvramPath = snapshot.bootMode == .efi ? "\(directory)/NVRAM" : nil + guard isPrivateDirectory(path: directory), + isPrivateRegularFile(path: rootfsPath), + isPrivateRegularFile(path: kernelPath), + machineIdentifierPath.map(isPrivateRegularFile(path:)) ?? true, + nvramPath.map(isPrivateRegularFile(path:)) ?? true, + let actual = try? snapshotArtifactEvidence( + rootfsPath: rootfsPath, + kernelPath: kernelPath, + machineIdentifierPath: machineIdentifierPath, + nvramPath: nvramPath + ) else { return false } + return actual == expected + } + + private static func restoreRecoveredMachineBackups( + machineID: String, + operationID: UUID, + configuration: MachineManagerConfiguration + ) -> Bool { + let directory = "\(configuration.stateDirectory)/\(machineID)" + let token = operationID.uuidString.lowercased() + let pairs = [ + (".restore-\(token)-rootfs", "rootfs.ext4"), + (".restore-\(token)-kernel", "kernel"), + (".restore-\(token)-machine-json", "machine.json"), + (".restore-\(token)-machine-identifier", "MachineIdentifier"), + (".restore-\(token)-nvram", "NVRAM"), + ] + let required = Array(pairs.prefix(3)) + guard required.allSatisfy({ pathEntryExists("\(directory)/\($0.0)") }) else { + return false + } + do { + for (backup, destination) in pairs where pathEntryExists("\(directory)/\(backup)") { + try cloneOrCopyFile( + source: "\(directory)/\(backup)", + destination: "\(directory)/\(destination)", + replaceExisting: true + ) + } + for (backup, _) in pairs { + try? FileManager.default.removeItem(atPath: "\(directory)/\(backup)") + } + return true + } catch { + return false + } + } + private static func removeStaleDeletionQuarantines(stateDirectory: String) { let fileManager = FileManager.default guard let entries = try? fileManager.contentsOfDirectory(atPath: stateDirectory) else { @@ -4883,6 +6097,65 @@ private struct WorkspaceMigrationAuthorityFacts: Codable { var lifecycle: DoryVMLifecycleMetadata } +private final class MachineLifecycleJournalContext: @unchecked Sendable { + let operation: DoryWorkspaceLifecycleOperation + private(set) var lease: DoryOperationLease! + + init(operation: DoryWorkspaceLifecycleOperation, lease: DoryOperationLease) { + self.operation = operation + self.lease = lease + } + + func releaseLease() { + lease = nil + } +} + +private struct MachineLifecycleDependencyAuthority: Codable { + var schemaVersion: UInt16 = 2 + var mutationKind: String + var workspaceID: String + var sourceLegacyConfigurationSHA256: String? + var sourceDefinitionSHA256: String? + var sourceRuntimeIdentitySHA256: String? + var targetLegacyConfigurationSHA256: String? + var targetDefinitionSHA256: String? + var targetRuntimeIdentitySHA256: String? + var targetResourceID: String? + var targetSnapshotDescriptorSHA256: String? + var targetSnapshotArtifactEvidenceSHA256: String? +} + +private extension DoryOperationPhase { + var indexForMachineLifecycle: Int { + switch self { + case .planned: 0 + case .quiescing: 1 + case .staging: 2 + case .verifying: 3 + case .readyToPublish: 4 + case .publishing: 5 + case .validating: 6 + case .completed: 7 + } + } +} + +#if DEBUG +enum MachineLifecycleFaultPoint: Sendable, Equatable { + case startAfterPreparation + case completionBeforeJournalWrite(DoryWorkspaceMutationKind) + case stopAfterProcessStop + case snapshotAfterRootfs + case restoreAfterBackups + case deleteAfterQuarantine +} + +struct MachineLifecycleInjectedCrash: Error, Sendable {} +#endif + +private struct MachineLifecycleJournalCompletionPending: Error, Sendable {} + private struct PreparedMachineStart { var machine: DoryMachineConfiguration var definition: DoryVirtualMachineDefinition? diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift index 638f71d4..c8ecd5e3 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift @@ -183,6 +183,7 @@ struct DoryWorkspaceLifecycleOperationTests { source: condition(.stopped, resolved: resolved), target: condition(.stopped, resolved: nil, requiresReplanning: true), targetResourceID: "snapshot-one", + targetSnapshotAuthority: snapshotAuthority(), createdAtUnixMilliseconds: 1_700_000_000_000, deadlineUnixMilliseconds: 1_700_000_060_000, steps: [step("restore", 50_000)], @@ -191,6 +192,10 @@ struct DoryWorkspaceLifecycleOperationTests { ) #expect(restoreStopped.validate().isEmpty) + var missingSnapshotAuthority = restoreStopped + missingSnapshotAuthority.targetSnapshotAuthority = nil + #expect(has(.invalidCondition, "targetSnapshotAuthority", missingSnapshotAuthority)) + var restoreRunning = restoreStopped restoreRunning.target.state = .running #expect(has(.invalidReadinessGates, "readinessGates", restoreRunning)) @@ -320,6 +325,7 @@ struct DoryWorkspaceLifecycleOperationTests { DoryWorkspaceLifecycleOperation.self, from: legacyRestoreData ) + #expect(legacyRestore.targetSnapshotAuthority == nil) #expect(legacyRestore.validate().isEmpty) #expect(legacyRestore.target.runtime?.authorizationState == .resolvedPlan) } @@ -437,6 +443,13 @@ struct DoryWorkspaceLifecycleOperationTests { ) } + private func snapshotAuthority() -> DoryWorkspaceSnapshotAuthority { + DoryWorkspaceSnapshotAuthority( + descriptorSHA256: String(repeating: "6", count: 64), + artifactEvidenceSHA256: String(repeating: "7", count: 64) + ) + } + private func step(_ id: String, _ deadline: UInt64) -> DoryWorkspaceOperationStep { DoryWorkspaceOperationStep( id: id, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift new file mode 100644 index 00000000..5fd3cdb2 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift @@ -0,0 +1,611 @@ +import Darwin +import DoryCore +import DoryOperations +@testable import DorydKit +import Foundation +import XCTest + +final class MachineManagerLifecycleJournalIntegrationTests: XCTestCase { + func testStartJournalRemainsActiveUntilReadyAndFencesConcurrentMutation() throws { + let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + + let starting = try manager.start(id: fixture.machineID) + XCTAssertEqual(starting.state, .starting) + let pending = try XCTUnwrap(try fixture.records().last) + XCTAssertEqual(pending.plan.kind, .workspaceStart) + XCTAssertEqual(pending.state.status, .running) + + let competingStore = try DoryOperationJournalStore(home: fixture.journal) + XCTAssertThrowsError( + try competingStore.acquire(pending.plan.id, mutationScope: "different-vm") + ) { error in + guard case DoryOperationJournalError.invalidPlan = error else { + return XCTFail("wrong scope was not rejected: \(error)") + } + } + XCTAssertThrowsError(try competingStore.acquire(pending.plan.id)) { error in + guard case DoryOperationJournalError.operationInUse = error else { + return XCTFail("derived scope did not preserve the active fence: \(error)") + } + } + + XCTAssertThrowsError( + try manager.snapshot(id: fixture.machineID, snapshotID: "while-starting") + ) { error in + XCTAssertTrue("\(error)".contains("active lifecycle mutation")) + } + + try sendVmmHandoff( + path: try XCTUnwrap(starting.handoffSocketPath), + ready: VmmReadyMessage(machineID: fixture.machineID), + fileDescriptors: [] + ) + XCTAssertEqual(try waitForState(manager, id: fixture.machineID, state: .running).state, .running) + let completed = try waitForJournal( + fixture, + kind: .workspaceStart, + status: .completed + ) + let lease = try fixture.store.acquire(completed.plan.id) + let operation = try lease.readWorkspaceLifecycleOperation() + XCTAssertEqual(operation.kind, .starting) + XCTAssertEqual(operation.source.runtime?.policy, .legacyCompatibility) + XCTAssertEqual(operation.source.runtime?.authorizationState, .legacyCompatibility) + XCTAssertEqual(operation.target.runtime?.policy, .legacyCompatibility) + XCTAssertEqual(operation.target.runtime?.authorizationState, .legacyCompatibility) + XCTAssertEqual( + operation.target.runtime?.virtualHardwareABIVersion, + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + } + + func testPerMachineFenceAllowsDifferentMachinesToReachReadinessConcurrently() throws { + let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + _ = try fixture.createMachine(manager, id: "lifecycle-vm-two") + + let first = try manager.start(id: fixture.machineID) + let second = try manager.start(id: "lifecycle-vm-two") + XCTAssertEqual(first.state, .starting) + XCTAssertEqual(second.state, .starting) + XCTAssertEqual( + try fixture.records().filter { + $0.plan.kind == .workspaceStart && $0.state.status == .running + }.count, + 2 + ) + + try sendVmmHandoff( + path: try XCTUnwrap(first.handoffSocketPath), + ready: VmmReadyMessage(machineID: fixture.machineID), + fileDescriptors: [] + ) + try sendVmmHandoff( + path: try XCTUnwrap(second.handoffSocketPath), + ready: VmmReadyMessage(machineID: "lifecycle-vm-two"), + fileDescriptors: [] + ) + _ = try waitForState(manager, id: fixture.machineID, state: .running) + _ = try waitForState(manager, id: "lifecycle-vm-two", state: .running) + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + if try fixture.records().filter({ + $0.plan.kind == .workspaceStart && $0.state.status == .completed + }).count == 2 { + break + } + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertEqual( + try fixture.records().filter { + $0.plan.kind == .workspaceStart && $0.state.status == .completed + }.count, + 2 + ) + } + + func testReadinessTimeoutFailsStartJournalAndNeverPublishesRunning() throws { + let fixture = try LifecycleFixture( + name: #function, + requiresReadyHandoff: true, + readyTimeout: 0.05 + ) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + + XCTAssertEqual(try manager.start(id: fixture.machineID).state, .starting) + let failed = try waitForState(manager, id: fixture.machineID, state: .failed) + XCTAssertTrue(failed.lastError?.contains("ready handoff timed out") == true) + _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed) + XCTAssertFalse(try fixture.records().contains { record in + record.plan.kind == .workspaceStart && record.state.status == .completed + }) + } + + func testTerminalHelperExitFailsStartJournalWithoutWaitingForReadinessTimeout() throws { + let fixture = try LifecycleFixture( + name: #function, + executable: "/usr/bin/false", + requiresReadyHandoff: true, + readyTimeout: 5, + restartPolicy: .none + ) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + + _ = try manager.start(id: fixture.machineID) + let failed = try waitForState(manager, id: fixture.machineID, state: .failed, timeout: 1) + XCTAssertTrue(failed.lastError?.contains("exited with status") == true) + _ = try waitForJournal( + fixture, + kind: .workspaceStart, + status: .failed, + timeout: 1 + ) + } + + func testInterruptedStartRecoversStoppedWithoutFalseRunningState() throws { + let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + XCTAssertEqual( + try XCTUnwrap(manager).start(id: fixture.machineID).state, + .starting + ) + manager = nil + + let recovered = fixture.makeManager() + let status = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(status.state, .stopped) + XCTAssertNil(status.pid) + XCTAssertTrue(status.lastError?.contains("interrupted start") == true) + _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed) + } + + func testCrashAfterDurableStartPreparationHasJournalButNeverStartsHelper() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .startAfterPreparation { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError(try XCTUnwrap(manager).start(id: fixture.machineID)) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + XCTAssertEqual(try XCTUnwrap(manager).status(id: fixture.machineID)?.state, .created) + XCTAssertEqual( + try fixture.records().filter { + $0.plan.kind == .workspaceResolve && $0.state.status == .running + }.count, + 1 + ) + XCTAssertFalse(try fixture.records().contains { $0.plan.kind == .workspaceStart }) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + _ = try waitForJournal(fixture, kind: .workspaceResolve, status: .completed) + XCTAssertFalse(try fixture.records().contains { $0.plan.kind == .workspaceStart }) + } + + func testReadinessCompletionWriteFailureKeepsRunningTargetAndNonterminalJournal() throws { + let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .completionBeforeJournalWrite(.starting) { + throw MachineLifecycleInjectedCrash() + } + } + + let starting = try XCTUnwrap(manager).start(id: fixture.machineID) + try sendVmmHandoff( + path: try XCTUnwrap(starting.handoffSocketPath), + ready: VmmReadyMessage(machineID: fixture.machineID), + fileDescriptors: [] + ) + let running = try waitForState( + try XCTUnwrap(manager), + id: fixture.machineID, + state: .running + ) + XCTAssertTrue(running.lastError?.contains("unfinished readiness journal") == true) + let unfinished = try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceStart + })) + XCTAssertEqual(unfinished.state.status, .running) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed) + } + + func testInterruptedStopCompletesFromPersistedTargetAuthorityOnRestart() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + XCTAssertEqual(try XCTUnwrap(manager).start(id: fixture.machineID).state, .running) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .stopAfterProcessStop { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError(try XCTUnwrap(manager).stop(id: fixture.machineID)) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + XCTAssertEqual(try XCTUnwrap(manager).status(id: fixture.machineID)?.state, .stopped) + manager = nil + + let recovered = fixture.makeManager() + let status = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(status.state, .stopped) + XCTAssertTrue(status.lastError?.contains("interrupted stop completed") == true) + _ = try waitForJournal(fixture, kind: .workspaceStop, status: .completed) + } + + func testInterruptedSnapshotRemovesOnlyOperationOwnedPartialArtifacts() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .snapshotAfterRootfs { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError( + try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "partial") + ) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.state + "/\(fixture.machineID)/snapshots/partial.ext4" + )) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + XCTAssertTrue(try recovered.listSnapshots(machineID: fixture.machineID).isEmpty) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.state + "/\(fixture.machineID)/snapshots/partial.ext4" + )) + _ = try waitForJournal(fixture, kind: .workspaceSnapshot, status: .failed) + } + + func testInterruptedRestoreRollsBackBackupsAndPreservesMachine() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "known-good") + let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4" + try Data("current-before-restore".utf8).write(to: URL(fileURLWithPath: managedDisk)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .restoreAfterBackups { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError( + try XCTUnwrap(manager).restoreSnapshot( + machineID: fixture.machineID, + snapshotID: "known-good" + ) + ) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + XCTAssertEqual( + try String(contentsOfFile: managedDisk, encoding: .utf8), + "current-before-restore" + ) + XCTAssertEqual( + try recovered.listSnapshots(machineID: fixture.machineID).map(\.id), + ["known-good"] + ) + _ = try waitForJournal(fixture, kind: .workspaceRestore, status: .failed) + } + + func testRestoreRejectsSnapshotMutationAfterAuthorityBindingAndRollsBack() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4" + try Data("snapshot-bound-state".utf8).write(to: URL(fileURLWithPath: managedDisk)) + let snapshot = try manager.snapshot(id: fixture.machineID, snapshotID: "bound") + try Data("original-live-state".utf8).write(to: URL(fileURLWithPath: managedDisk)) + manager.installLifecycleFaultInjectorForTesting { point in + if point == .restoreAfterBackups { + try Data("mutated-after-authority-binding".utf8).write( + to: URL(fileURLWithPath: snapshot.rootfsPath) + ) + } + } + + XCTAssertThrowsError( + try manager.restoreSnapshot(machineID: fixture.machineID, snapshotID: "bound") + ) { error in + XCTAssertTrue("\(error)".contains("bound snapshot evidence")) + } + XCTAssertEqual( + try String(contentsOfFile: managedDisk, encoding: .utf8), + "original-live-state" + ) + let restoreRecords = try fixture.records().filter { + $0.plan.kind == .workspaceRestore + } + XCTAssertEqual(restoreRecords.last?.state.status, .failed) + XCTAssertFalse(restoreRecords.contains { $0.state.status == .completed }) + } + + func testRestoreCompletionWriteFailurePreservesRestoredTargetForRecovery() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4" + try Data("snapshot-target".utf8).write(to: URL(fileURLWithPath: managedDisk)) + _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "target") + try Data("later-state".utf8).write(to: URL(fileURLWithPath: managedDisk)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .completionBeforeJournalWrite(.restoring) { + throw MachineLifecycleInjectedCrash() + } + } + + let restored = try XCTUnwrap(manager).restoreSnapshot( + machineID: fixture.machineID, + snapshotID: "target" + ) + XCTAssertEqual(restored.state, .created) + XCTAssertEqual( + try String(contentsOfFile: managedDisk, encoding: .utf8), + "snapshot-target" + ) + XCTAssertTrue( + try XCTUnwrap(manager).status(id: fixture.machineID)?.lastError? + .contains("unfinished restore journal") == true + ) + let unfinished = try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceRestore + })) + XCTAssertEqual(unfinished.state.status, .running) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + XCTAssertEqual( + try String(contentsOfFile: managedDisk, encoding: .utf8), + "snapshot-target" + ) + let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceRestore + })) + XCTAssertEqual( + recoveredJournal.state.status, + .completed, + "recovery diagnostic: \(recovered.status(id: fixture.machineID)?.lastError ?? "none")" + ) + } + + func testSnapshotCompletionRecoveryRejectsMissingBoundKernel() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .completionBeforeJournalWrite(.snapshotting) { + throw MachineLifecycleInjectedCrash() + } + } + + let snapshot = try XCTUnwrap(manager).snapshot( + id: fixture.machineID, + snapshotID: "bound-snapshot" + ) + XCTAssertEqual( + try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceSnapshot + })).state.status, + .running + ) + try FileManager.default.removeItem(atPath: snapshot.kernelPath) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceSnapshot + })) + XCTAssertEqual(recoveredJournal.state.status, .failed) + XCTAssertTrue( + recovered.status(id: fixture.machineID)?.lastError? + .contains("interrupted snapshot") == true + ) + XCTAssertFalse(try recovered.listSnapshots(machineID: fixture.machineID).contains { + $0.id == "bound-snapshot" + }) + } + + func testRestoreCompletionRecoveryRejectsTamperedLiveRootfs() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4" + try Data("snapshot-authority".utf8).write(to: URL(fileURLWithPath: managedDisk)) + _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "authority") + try Data("later-state".utf8).write(to: URL(fileURLWithPath: managedDisk)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .completionBeforeJournalWrite(.restoring) { + throw MachineLifecycleInjectedCrash() + } + } + + _ = try XCTUnwrap(manager).restoreSnapshot( + machineID: fixture.machineID, + snapshotID: "authority" + ) + try Data("tampered-after-commit".utf8).write(to: URL(fileURLWithPath: managedDisk)) + manager = nil + + let recovered = fixture.makeManager() + XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped) + XCTAssertEqual( + try String(contentsOfFile: managedDisk, encoding: .utf8), + "tampered-after-commit" + ) + let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: { + $0.plan.kind == .workspaceRestore + })) + XCTAssertEqual(recoveredJournal.state.status, .failed) + XCTAssertTrue( + recovered.status(id: fixture.machineID)?.lastError? + .contains("requires repair") == true + ) + } + + func testInterruptedDeleteRestoresQuarantinedMachineOnRestart() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + var manager: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try XCTUnwrap(manager)) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .deleteAfterQuarantine { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError(try XCTUnwrap(manager).delete(id: fixture.machineID)) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + XCTAssertNil(try XCTUnwrap(manager).status(id: fixture.machineID)) + manager = nil + + let recovered = fixture.makeManager() + let status = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(status.state, .stopped) + XCTAssertTrue(status.lastError?.contains("interrupted deletion") == true) + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.state + "/\(fixture.machineID)/machine.json" + )) + _ = try waitForJournal(fixture, kind: .workspaceDelete, status: .failed) + } +} + +private final class LifecycleFixture { + let machineID = "lifecycle-vm" + let base: String + let state: String + let journal: String + let store: DoryOperationJournalStore + private let configuration: MachineManagerConfiguration + + init( + name: String, + executable: String = "/bin/sleep", + requiresReadyHandoff: Bool = false, + readyTimeout: TimeInterval = 2, + restartPolicy: HvRestartPolicy = HvRestartPolicy( + maxRestarts: 4, + delaySeconds: 0.01, + maximumDelaySeconds: 0.02, + stableRunSeconds: 0 + ) + ) throws { + _ = name + base = "/tmp/dorylc-\(getpid())-\(UInt64.random(in: 0.. MachineManager { + MachineManager(configuration: configuration) + } + + @discardableResult + func createMachine( + _ manager: MachineManager, + id: String? = nil + ) throws -> DoryMachineStatus { + try manager.create(DoryMachineConfiguration( + id: id ?? machineID, + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath + )) + } + + func records() throws -> [DoryOperationRecord] { + try store.list().sorted { $0.plan.createdAt < $1.plan.createdAt } + } + + func cleanup() { + try? FileManager.default.removeItem(atPath: base) + } +} + +private func waitForState( + _ manager: MachineManager, + id: String, + state: DoryMachineState, + timeout: TimeInterval = 3 +) throws -> DoryMachineStatus { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let status = manager.status(id: id), status.state == state { return status } + Thread.sleep(forTimeInterval: 0.01) + } + throw NSError( + domain: "MachineManagerLifecycleJournalIntegrationTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "timed out waiting for machine state \(state)"] + ) +} + +private func waitForJournal( + _ fixture: LifecycleFixture, + kind: DoryOperationKind, + status: DoryOperationStatus, + timeout: TimeInterval = 3 +) throws -> DoryOperationRecord { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let record = try fixture.records().last(where: { + $0.plan.kind == kind && $0.state.status == status + }) { + return record + } + Thread.sleep(forTimeInterval: 0.01) + } + throw NSError( + domain: "MachineManagerLifecycleJournalIntegrationTests", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "timed out waiting for lifecycle journal \(kind.rawValue) \(status.rawValue)", + ] + ) +} From f5216638fde5d940cc235e72fa8a554c639690db Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 15:17:30 +0000 Subject: [PATCH 027/338] feat(diagnostics): report exact VM runtime evidence --- .../Sources/DorydKit/HealthReporter.swift | 163 +++++++++++++--- .../DorydKitTests/HealthReporterTests.swift | 181 +++++++++++++++++- 2 files changed, 311 insertions(+), 33 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 932ef168..94a90ea4 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -419,7 +419,8 @@ public final class HealthReporter: @unchecked Sendable { } public func doctorReport(now: Date = Date()) -> DoctorReport { - let checks = compatibilityChecks(now: now) + var checks = compatibilityChecks(now: now) + checks.append(contentsOf: machineChecks()) return DoctorReport( generatedAt: now, results: checks, @@ -1920,43 +1921,145 @@ public final class HealthReporter: @unchecked Sendable { "failed": String(failed.count), ] + let summary: HealthCheck if !failed.isEmpty { - return [ - HealthCheck( - id: "machine.local", - status: .fail, - code: "machine.failed", - title: "Local machine failed", - detail: failed.map { "\($0.id): \($0.lastError ?? "unknown failure")" }.joined(separator: "; "), - action: "Inspect the dory-vmm log for the failed machine.", - data: data - ), - ] - } - - if !starting.isEmpty { - return [ - HealthCheck( - id: "machine.local", - status: .warn, - code: "machine.starting", - title: "Local machine starting", - detail: starting.map(\.id).joined(separator: ", "), - data: data - ), - ] - } - - return [ - HealthCheck( + summary = HealthCheck( + id: "machine.local", + status: .fail, + code: "machine.failed", + title: "Local machine failed", + detail: failed.map(\.id).joined(separator: ", "), + action: "Inspect the machine evidence and operation journal for the failed workspace.", + data: data + ) + } else if !starting.isEmpty { + summary = HealthCheck( + id: "machine.local", + status: .warn, + code: "machine.starting", + title: "Local machine starting", + detail: starting.map(\.id).joined(separator: ", "), + data: data + ) + } else { + summary = HealthCheck( id: "machine.local", status: .pass, code: running.isEmpty ? "machine.configured" : "machine.running", title: running.isEmpty ? "Local machines configured" : "Local machine running", detail: statuses.map { "\($0.id)=\($0.state.rawValue)" }.joined(separator: ", "), data: data - ), + ) + } + return [summary] + statuses.map(Self.machineEvidenceCheck) + } + + /// Emits exact, non-secret launch evidence for one workspace. Environment values, host paths, + /// guest file contents, sockets, and clipboard data are deliberately excluded. + static func machineEvidenceCheck(_ status: DoryMachineStatus) -> HealthCheck { + let identity = status.runtimeIdentity + let issues = identity.validate() + var data: [String: String] = [ + "state": status.state.rawValue, + "runtime_identity_mode": identity.mode.rawValue, + "runtime_identity_schema": String(identity.schemaVersion), + "virtual_hardware_abi": String(identity.virtualHardwareABIVersion), + "runtime_identity_valid": issues.isEmpty ? "true" : "false", ] + if !issues.isEmpty { + data["runtime_identity_issues"] = issues + .map { "\($0.code.rawValue):\($0.field)" } + .sorted() + .joined(separator: ",") + } + if let reason = identity.invalidationReason { + data["replanning_reason"] = reason.rawValue + } + if issues.isEmpty, let plan = identity.resolvedPlan { + data["plan_sha256"] = identity.resolvedPlanSHA256 + data["plan_revision"] = String(plan.planRevision) + data["spec_revision"] = String(plan.definitionRevision) + data["spec_sha256"] = plan.definitionSHA256 + data["backend"] = plan.backend.rawValue + data["backend_implementation"] = plan.backendImplementationIdentifier + data["backend_runtime_build"] = plan.backendRuntimeBuildIdentifier + data["support_tier"] = plan.supportTier.rawValue + data["graphics"] = plan.graphics.rawValue + data["selection"] = plan.selectionEvidence?.disposition.rawValue + data["component_count"] = String(plan.components.count) + data["components"] = plan.components + .sorted { lhs, rhs in + if lhs.componentIdentifier == rhs.componentIdentifier { + return lhs.buildIdentifier < rhs.buildIdentifier + } + return lhs.componentIdentifier < rhs.componentIdentifier + } + .map { "\($0.componentIdentifier)@\($0.buildIdentifier):\($0.artifactSHA256)" } + .joined(separator: ",") + data["media_kind"] = plan.bootMedia.media.kind.rawValue + data["media_source"] = plan.bootMedia.media.source.rawValue + data["media_artifact_sha256"] = plan.bootMedia.media.artifactSHA256 + if let resolver = plan.bootMedia.resolverReference { + data["media_resolver"] = "\(resolver.namespace):\(resolver.identifier)" + } + if let provenance = plan.bootMedia.media.mutableProvenance { + data["media_repository_identity"] = provenance.repositoryIdentity + data["media_identity"] = provenance.mediaIdentity + data["media_revision"] = String(provenance.revision) + } + if let runtime = plan.qualificationEvidence.runtime { + data["runtime_qualification"] = runtime.qualificationIdentity + data["runtime_qualification_sha256"] = runtime.qualificationReportSHA256 + } + if let graphics = plan.qualificationEvidence.graphics { + data["graphics_qualification"] = graphics.manifestIdentity + data["graphics_manifest_sha256"] = graphics.manifestSHA256 + } + if let host = plan.hostQualification { + data["host_qualification"] = host.qualificationIdentity + data["host_qualification_sha256"] = host.qualificationReportSHA256 + data["host_hardware_model"] = host.hostHardwareModelIdentifier + data["host_os_build"] = host.hostOperatingSystemBuild + } + if let admission = plan.resourceAdmission { + data["resource_admission"] = admission.admissionIdentity + data["resource_admission_sha256"] = admission.admissionReportSHA256 + } + } + + let healthStatus: HealthCheckStatus + let code: String + let action: String? + if !issues.isEmpty { + healthStatus = .fail + code = "machine.runtime_identity_invalid" + action = "Repair or re-resolve this workspace before its next launch." + } else if identity.mode == .requiresReplanning { + healthStatus = .warn + code = "machine.requires_replanning" + action = "Resolve and approve a current launch plan before starting this workspace." + } else if status.state == .failed { + healthStatus = .fail + code = "machine.runtime_failed" + action = "Inspect the operation journal and backend evidence for this workspace." + } else { + healthStatus = .pass + code = identity.mode == .resolvedPlan + ? "machine.runtime_resolved" + : "machine.runtime_legacy_compatibility" + action = identity.mode == .legacyCompatibility + ? "Re-plan this workspace to move it onto evidence-bound launch policy." + : nil + } + return HealthCheck( + id: "machine.local.\(status.id)", + status: healthStatus, + code: code, + title: "Workspace runtime evidence", + detail: "\(status.id) is \(status.state.rawValue) using \(identity.mode.rawValue) ABI \(identity.virtualHardwareABIVersion)", + action: action, + data: data + ) } private func engineData(_ status: DockerTierStatus) -> [String: String] { diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index ecb32693..695f21aa 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -1,5 +1,6 @@ import Darwin import DoryCore +import DoryOperations @testable import DorydKit import Foundation import XCTest @@ -342,7 +343,7 @@ final class HealthReporterTests: XCTestCase { XCTAssertNil(tier.status().hvPID) } - func testReportIncludesLocalMachineHealthOutsideDoctorContract() throws { + func testReportAndDoctorIncludeNonSecretLocalMachineRuntimeEvidence() throws { let base = "/tmp/dory-health-machine-\(getpid())-\(UInt32.random(in: 0.. DoryResolvedMachinePlan { + let artifact = healthDigest("a") + let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: artifact + ) + return DoryResolvedMachinePlan( + machineID: "qualified", + definitionRevision: 7, + definitionSHA256: healthDigest("1"), + planRevision: 2, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000, + guest: guest, + backend: .doryHypervisor, + backendImplementationIdentifier: "dory.raw-hv-linux.v1", + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: DoryVMResolverReference( + namespace: "artifact", + identifier: "qualified-linux" + ), + media: media + ), + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: "raw-runtime-1", + artifactSHA256: healthDigest("d") + )], + devices: devices, + graphics: .hostAcceleratedDisplay, + supportTier: .supported, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: DoryVirtualMachineBackendPlanRequest( + guest: guest, + bootMedia: media, + acceptableGraphics: [.hostAcceleratedDisplay], + devices: devices, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ), + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: DorySignedArtifactQualificationEvidence( + manifestIdentity: "graphics-qualification-1", + artifactSHA256: artifact, + manifestSHA256: healthDigest("b"), + signingKeyID: "dory-release-1", + manifestFormatVersion: 1 + ), + runtime: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: healthDigest("c"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: artifact, + backend: .doryHypervisor, + backendRuntimeBuildID: "raw-runtime-1", + virtualHardwareABIVersion: 1, + graphics: .hostAcceleratedDisplay, + devices: devices + ) + ), + resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: 4, + admittedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + admittedStorageBytes: 64 * 1_024 * 1_024 * 1_024, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024, + existingVirtualCPUCommitment: 0, + existingMemoryCommitmentBytes: 0, + existingStorageReservationBytes: 0, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024, + admissionIdentity: "resource-admission-1", + admissionReportSHA256: healthDigest("e"), + assessorIdentifier: "dory-resource-policy", + assessorVersion: 1 + ), + hostQualification: DoryResolvedHostQualificationEvidence( + qualificationIdentity: "host-qualification-1", + qualificationReportSHA256: healthDigest("f"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) + ) +} + +private func healthDigest(_ character: Character) -> String { + String(repeating: String(character), count: 64) +} + private final class HealthFakeSSHKeyStore: SSHKeyStore, @unchecked Sendable { func privateKey(for identifier: String) throws -> String { throw SSHKeyStoreError.notFound(identifier) From d78a45ac276fd80c7005656810b44146a68edb76 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 15:20:06 +0000 Subject: [PATCH 028/338] feat(vm): type guest and clipboard intent --- .../DoryVirtualMachineDefinition.swift | 97 ++++++++++ .../DoryVirtualMachineGuestIntent.swift | 179 ++++++++++++++++++ .../DoryMachineConfigurationMigration.swift | 127 +++++++++++++ .../DoryVirtualMachineDefinitionTests.swift | 118 ++++++++++++ ...ryMachineConfigurationMigrationTests.swift | 134 ++++++++++++- 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 48d70d60..505bf86d 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -298,6 +298,9 @@ public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseItera case unsafeGuestMountPath = "unsafe-guest-mount-path" case duplicateIntegration = "duplicate-integration" case integrationRequiresDisplay = "integration-requires-display" + case invalidGuestIdentityIntent = "invalid-guest-identity-intent" + case guestIdentityIncompatibleWithGuest = "guest-identity-incompatible-with-guest" + case clipboardPolicyRequiresIntegration = "clipboard-policy-requires-integration" case legacyInstallerWorkload = "legacy-installer-workload" case invalidLifecycleMetadata = "invalid-lifecycle-metadata" } @@ -339,6 +342,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public var input: DoryVMInputConfiguration public var shares: [DoryVMShare] public var integrations: [DoryVMGuestIntegration] + public var guestIdentityIntent: DoryVMGuestIdentityIntent + public var clipboardPolicy: DoryVMClipboardPolicy public var lifecycle: DoryVMLifecycleMetadata public init( @@ -358,6 +363,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { input: DoryVMInputConfiguration = DoryVMInputConfiguration(), shares: [DoryVMShare] = [], integrations: [DoryVMGuestIntegration] = [], + guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, + clipboardPolicy: DoryVMClipboardPolicy? = nil, lifecycle: DoryVMLifecycleMetadata ) { self.schemaVersion = schemaVersion @@ -376,6 +383,10 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { self.input = input self.shares = shares self.integrations = integrations + self.guestIdentityIntent = guestIdentityIntent + self.clipboardPolicy = clipboardPolicy + ?? (integrations.contains(.clipboard) + ? .legacyDesktop(.bidirectional) : .disabled) self.lifecycle = lifecycle } @@ -397,6 +408,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { case input case shares case integrations + case guestIdentityIntent + case clipboardPolicy case lifecycle } @@ -511,6 +524,9 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { [DoryVMGuestIntegration].self, forKey: .integrations ) + guestIdentityIntent = .unspecified + clipboardPolicy = integrations.contains(.clipboard) + ? .legacyDesktop(.bidirectional) : .disabled lifecycle = DoryVMLifecycleMetadata( revision: legacyLifecycle.revision, createdAt: legacyLifecycle.createdAt, @@ -538,6 +554,15 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) shares = try container.decode([DoryVMShare].self, forKey: .shares) integrations = try container.decode([DoryVMGuestIntegration].self, forKey: .integrations) + guestIdentityIntent = try container.decodeIfPresent( + DoryVMGuestIdentityIntent.self, + forKey: .guestIdentityIntent + ) ?? .unspecified + clipboardPolicy = try container.decodeIfPresent( + DoryVMClipboardPolicy.self, + forKey: .clipboardPolicy + ) ?? (integrations.contains(.clipboard) + ? .legacyDesktop(.bidirectional) : .disabled) lifecycle = try container.decode(DoryVMLifecycleMetadata.self, forKey: .lifecycle) } @@ -559,6 +584,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { try container.encode(input, forKey: .input) try container.encode(shares, forKey: .shares) try container.encode(integrations, forKey: .integrations) + try container.encode(guestIdentityIntent, forKey: .guestIdentityIntent) + try container.encode(clipboardPolicy, forKey: .clipboardPolicy) try container.encode(lifecycle, forKey: .lifecycle) } @@ -610,6 +637,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { validateDisplay(into: &issues) validateShares(into: &issues) validateIntegrations(into: &issues) + validateGuestIdentityIntent(into: &issues) + validateClipboardPolicy(into: &issues) validateLifecycle(into: &issues) return issues } @@ -819,6 +848,74 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } } + private func validateGuestIdentityIntent( + into issues: inout [DoryVMDefinitionValidationIssue] + ) { + if !guestIdentityIntent.isEmpty, guest.family != .linux { + issues.append(issue( + .guestIdentityIncompatibleWithGuest, + "guestIdentityIntent" + )) + } + if let account = guestIdentityIntent.account, !account.isValidForPersistence { + issues.append(issue(.invalidGuestIdentityIntent, "guestIdentityIntent.account")) + } + if let username = guestIdentityIntent.account?.username, + !DoryVMGuestAccountIntent.isValidUsername(username) { + issues.append(issue( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.username" + )) + } + if let numericUserID = guestIdentityIntent.account?.numericUserID, + !DoryVMGuestAccountIntent.isValidNumericUserID(numericUserID) { + issues.append(issue( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.numericUserID" + )) + } + if let desktop = guestIdentityIntent.desktop { + if !desktop.isValidForPersistence { + issues.append(issue(.invalidGuestIdentityIntent, "guestIdentityIntent.desktop")) + } + if let identifier = desktop.distributionIdentifier, + !DoryVMDesktopIdentityIntent.isValidDistributionIdentifier(identifier) { + issues.append(issue( + .invalidGuestIdentityIntent, + "guestIdentityIntent.desktop.distributionIdentifier" + )) + } + for (field, value) in [ + ("displayName", desktop.displayName), + ("version", desktop.version), + ("desktopEnvironment", desktop.desktopEnvironment), + ] where value.map(DoryVMDesktopIdentityIntent.isValidLabel) == false { + issues.append(issue( + .invalidGuestIdentityIntent, + "guestIdentityIntent.desktop.\(field)" + )) + } + if !desktop.isEmpty, + (workload != .desktop || !display.enabled) { + issues.append(issue( + .guestIdentityIncompatibleWithGuest, + "guestIdentityIntent.desktop" + )) + } + } + } + + private func validateClipboardPolicy( + into issues: inout [DoryVMDefinitionValidationIssue] + ) { + if clipboardPolicy.isEnabled, !integrations.contains(.clipboard) { + issues.append(issue( + .clipboardPolicyRequiresIntegration, + "clipboardPolicy" + )) + } + } + private func validateLifecycle(into issues: inout [DoryVMDefinitionValidationIssue]) { if lifecycle.revision == 0 || lifecycle.createdAtUnixMilliseconds <= 0 diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift new file mode 100644 index 00000000..c1a3fdd1 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Direction in which one clipboard content class may cross the host/guest boundary. +public enum DoryVMClipboardDirection: String, Codable, Sendable, Equatable, CaseIterable { + case off + case hostToGuest = "host-to-guest" + case guestToHost = "guest-to-host" + case bidirectional + + public var allowsHostToGuest: Bool { + self == .hostToGuest || self == .bidirectional + } + + public var allowsGuestToHost: Bool { + self == .guestToHost || self == .bidirectional + } +} + +/// Clipboard intent is persisted per content class. Runtime negotiation may select only a +/// capability that preserves these directions; it must not silently widen them. +public struct DoryVMClipboardPolicy: Codable, Sendable, Equatable { + public var text: DoryVMClipboardDirection + public var image: DoryVMClipboardDirection + public var files: DoryVMClipboardDirection + + public init( + text: DoryVMClipboardDirection, + image: DoryVMClipboardDirection, + files: DoryVMClipboardDirection + ) { + self.text = text + self.image = image + self.files = files + } + + public static let disabled = DoryVMClipboardPolicy(text: .off, image: .off, files: .off) + + /// Current Linux compatibility runtimes apply one direction to text and images and do not + /// provide clipboard-backed file transfer. + public static func legacyDesktop(_ direction: DoryVMClipboardDirection) -> Self { + Self(text: direction, image: direction, files: .off) + } + + public var isEnabled: Bool { + text != .off || image != .off || files != .off + } +} + +/// Non-secret account provisioning intent. This is not a credential and never contains a +/// password, token, home-directory path, or host identity. +public struct DoryVMGuestAccountIntent: Codable, Sendable, Equatable { + public static let legacyUsernameEnvironmentKey = "DORY_GUEST_USER" + public static let legacyNumericUserIDEnvironmentKey = "DORY_GUEST_UID" + + public var username: String? + public var numericUserID: UInt32? + + public init(username: String? = nil, numericUserID: UInt32? = nil) { + self.username = username + self.numericUserID = numericUserID + } + + public var isEmpty: Bool { username == nil && numericUserID == nil } + + public var isValidForPersistence: Bool { + !isEmpty + && (username.map(Self.isValidUsername) ?? true) + && (numericUserID.map(Self.isValidNumericUserID) ?? true) + } + + public static func isValidUsername(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...32).contains(bytes.count), + (bytes[0] >= 97 && bytes[0] <= 122) || bytes[0] == 95 else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 95 + || byte == 45 + } + } + + public static func isValidNumericUserID(_ value: UInt32) -> Bool { + (100...60_000).contains(value) + } +} + +/// Display metadata for a provisioned guest desktop. Exact boot media and component provenance +/// remain separately bound by their artifact references and resolved launch evidence. +public struct DoryVMDesktopIdentityIntent: Codable, Sendable, Equatable { + public static let legacyDistributionEnvironmentKey = "DORY_DESKTOP_DISTRO" + public static let legacyDisplayNameEnvironmentKey = "DORY_DESKTOP_NAME" + public static let legacyVersionEnvironmentKey = "DORY_DESKTOP_VERSION" + public static let legacyDesktopEnvironmentKey = "DORY_DESKTOP_ENVIRONMENT" + + public var distributionIdentifier: String? + public var displayName: String? + public var version: String? + public var desktopEnvironment: String? + + public init( + distributionIdentifier: String? = nil, + displayName: String? = nil, + version: String? = nil, + desktopEnvironment: String? = nil + ) { + self.distributionIdentifier = distributionIdentifier + self.displayName = displayName + self.version = version + self.desktopEnvironment = desktopEnvironment + } + + public var isEmpty: Bool { + distributionIdentifier == nil + && displayName == nil + && version == nil + && desktopEnvironment == nil + } + + public var isValidForPersistence: Bool { + !isEmpty + && (distributionIdentifier.map(Self.isValidDistributionIdentifier) ?? true) + && (displayName.map(Self.isValidLabel) ?? true) + && (version.map(Self.isValidLabel) ?? true) + && (desktopEnvironment.map(Self.isValidLabel) ?? true) + } + + public static func isValidDistributionIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...64).contains(bytes.count), + (bytes[0] >= 97 && bytes[0] <= 122) || (bytes[0] >= 48 && bytes[0] <= 57) else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 95 + || byte == 46 + || byte == 45 + } + } + + public static func isValidLabel(_ value: String) -> Bool { + !value.isEmpty + && value.utf8.count <= 128 + && value == value.trimmingCharacters(in: .whitespacesAndNewlines) + && !value.hasPrefix("/") + && !value.hasPrefix("~") + && !value.contains("://") + && !value.contains("/") + && !value.contains("\\") + && !value.unicodeScalars.contains(where: { + CharacterSet.controlCharacters.contains($0) + }) + } +} + +/// Guest-visible identity intent owned by the workspace definition rather than an opaque +/// environment dictionary. +public struct DoryVMGuestIdentityIntent: Codable, Sendable, Equatable { + public var account: DoryVMGuestAccountIntent? + public var desktop: DoryVMDesktopIdentityIntent? + + public init( + account: DoryVMGuestAccountIntent? = nil, + desktop: DoryVMDesktopIdentityIntent? = nil + ) { + self.account = account + self.desktop = desktop + } + + public static let unspecified = DoryVMGuestIdentityIntent() + + public var isEmpty: Bool { + (account?.isEmpty ?? true) && (desktop?.isEmpty ?? true) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index 3df0347a..34edd429 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -235,6 +235,8 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { var environment = authoritativeLegacyConfiguration.environment try applyBackendPreference(to: &environment) try applyGraphicsPolicy(to: &environment) + try applyGuestIdentityIntent(to: &environment) + try applyClipboardPolicy(to: &environment) let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in guard let hostPath = hostPath(for: share.hostLocation) else { @@ -318,6 +320,79 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { environment[DoryDesktopGraphicsPreference.environmentKey] = preference.rawValue environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey) } + + private func applyGuestIdentityIntent(to environment: inout [String: String]) throws { + guard definition.guestIdentityIntent != baselineDefinition.guestIdentityIntent else { + return + } + guard definition.guest.family == .linux else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "guestIdentityIntent" + ) + } + Self.assignIfChanged( + definition.guestIdentityIntent.account?.username, + baseline: baselineDefinition.guestIdentityIntent.account?.username, + key: DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey, + to: &environment + ) + Self.assignIfChanged( + definition.guestIdentityIntent.account?.numericUserID.map(String.init), + baseline: baselineDefinition.guestIdentityIntent.account?.numericUserID.map(String.init), + key: DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey, + to: &environment + ) + Self.assignIfChanged( + definition.guestIdentityIntent.desktop?.distributionIdentifier, + baseline: baselineDefinition.guestIdentityIntent.desktop?.distributionIdentifier, + key: DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey, + to: &environment + ) + Self.assignIfChanged( + definition.guestIdentityIntent.desktop?.displayName, + baseline: baselineDefinition.guestIdentityIntent.desktop?.displayName, + key: DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey, + to: &environment + ) + Self.assignIfChanged( + definition.guestIdentityIntent.desktop?.version, + baseline: baselineDefinition.guestIdentityIntent.desktop?.version, + key: DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey, + to: &environment + ) + Self.assignIfChanged( + definition.guestIdentityIntent.desktop?.desktopEnvironment, + baseline: baselineDefinition.guestIdentityIntent.desktop?.desktopEnvironment, + key: DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey, + to: &environment + ) + } + + private func applyClipboardPolicy(to environment: inout [String: String]) throws { + guard definition.clipboardPolicy != baselineDefinition.clipboardPolicy else { return } + guard definition.clipboardPolicy.text == definition.clipboardPolicy.image, + definition.clipboardPolicy.files == .off else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "clipboardPolicy" + ) + } + environment[DoryDesktopClipboardPolicy.environmentKey] + = definition.clipboardPolicy.text.rawValue + } + + private static func assignIfChanged( + _ value: String?, + baseline: String?, + key: String, + to environment: inout [String: String] + ) { + guard value != baseline else { return } + if let value { + environment[key] = value + } else { + environment.removeValue(forKey: key) + } + } } /// Pure migration entry points. MachineManager remains the legacy persistence owner; this bridge @@ -503,6 +578,20 @@ public enum DoryMachineConfigurationMigrationBridge { } let isDesktop = configuration.displayMode == .desktop + let guestIdentityIntent = typedGuestIdentityIntent( + configuration.environment, + includeDesktop: isDesktop + ) + let clipboardPolicy: DoryVMClipboardPolicy + if isDesktop { + let legacyDirection = DoryVMClipboardDirection( + rawValue: configuration.environment[DoryDesktopClipboardPolicy.environmentKey] + ?? DoryVMClipboardDirection.bidirectional.rawValue + ) ?? .off + clipboardPolicy = .legacyDesktop(legacyDirection) + } else { + clipboardPolicy = .disabled + } let acceleratedBoot = bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot let backendPreference: DoryVMBackendPreference @@ -555,6 +644,8 @@ public enum DoryMachineConfigurationMigrationBridge { integrations: isDesktop ? [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown] : [.clockSynchronization, .gracefulShutdown], + guestIdentityIntent: guestIdentityIntent, + clipboardPolicy: clipboardPolicy, lifecycle: facts.lifecycle ) let issues = definition.validate() @@ -570,6 +661,42 @@ public enum DoryMachineConfigurationMigrationBridge { ) } + private static func typedGuestIdentityIntent( + _ environment: [String: String], + includeDesktop: Bool + ) -> DoryVMGuestIdentityIntent { + let username = environment[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] + .flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil } + let numericUserID = environment[DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey] + .flatMap(UInt32.init) + .flatMap { DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil } + let accountCandidate = DoryVMGuestAccountIntent( + username: username, + numericUserID: numericUserID + ) + let account = accountCandidate.isValidForPersistence ? accountCandidate : nil + + let distributionIdentifier = environment[ + DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey + ].flatMap { + DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil + } + func safeLabel(_ key: String) -> String? { + environment[key].flatMap { DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil } + } + let desktopCandidate = DoryVMDesktopIdentityIntent( + distributionIdentifier: distributionIdentifier, + displayName: safeLabel(DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey), + version: safeLabel(DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey), + desktopEnvironment: safeLabel( + DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey + ) + ) + let desktop = includeDesktop && desktopCandidate.isValidForPersistence + ? desktopCandidate : nil + return DoryVMGuestIdentityIntent(account: account, desktop: desktop) + } + private static func legacyBootContract( _ configuration: DoryMachineConfiguration, facts: DoryMachineConfigurationMigrationFacts diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 37625101..5fcfea08 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -23,11 +23,129 @@ struct DoryVirtualMachineDefinitionTests { #expect(json.contains("\"virtualHardwareABIVersion\":1")) #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000")) #expect(json.contains("\"namespace\":\"boot\"")) + #expect(json.contains("\"clipboardPolicy\"")) + #expect(json.contains("\"guestIdentityIntent\"")) #expect(!json.contains("\"bootMedia\"")) #expect(!json.contains("artifactID")) #expect(!json.contains("hostLocationID")) } + @Test("schema 2 records written before typed guest intent retain compatibility defaults") + func additiveSchemaTwoMigration() throws { + let encoder = JSONEncoder() + let data = try encoder.encode(linuxDefinition()) + var object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + object.removeValue(forKey: "guestIdentityIntent") + object.removeValue(forKey: "clipboardPolicy") + let oldSchemaTwo = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode( + DoryVirtualMachineDefinition.self, + from: oldSchemaTwo + ) + #expect(decoded.guestIdentityIntent == .unspecified) + #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(decoded.isValid) + } + + @Test("guest identity and clipboard policies are bounded typed intent") + func guestIdentityAndClipboardValidation() { + var definition = linuxDefinition() + definition.guestIdentityIntent = DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "developer", numericUserID: 1_000), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04 LTS", + desktopEnvironment: "GNOME" + ) + ) + definition.clipboardPolicy = DoryVMClipboardPolicy( + text: .hostToGuest, + image: .guestToHost, + files: .off + ) + #expect(definition.isValid) + #expect(definition.clipboardPolicy.text.allowsHostToGuest) + #expect(!definition.clipboardPolicy.text.allowsGuestToHost) + + definition.guestIdentityIntent.account?.username = "../host" + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.username", + in: definition.validate() + )) + definition.guestIdentityIntent.account?.username = "developer" + definition.guestIdentityIntent.account?.numericUserID = 99 + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.numericUserID", + in: definition.validate() + )) + definition.guestIdentityIntent.account?.numericUserID = 100 + #expect(!has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.numericUserID", + in: definition.validate() + )) + definition.guestIdentityIntent.account?.numericUserID = 60_000 + #expect(!has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.numericUserID", + in: definition.validate() + )) + definition.guestIdentityIntent.account?.numericUserID = 60_001 + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.account.numericUserID", + in: definition.validate() + )) + definition.guestIdentityIntent.account?.numericUserID = 1_000 + definition.guestIdentityIntent.desktop?.distributionIdentifier = "Ubuntu/../../host" + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.desktop.distributionIdentifier", + in: definition.validate() + )) + definition.guestIdentityIntent.desktop?.distributionIdentifier = "ubuntu" + definition.guestIdentityIntent.desktop?.version = "https://token.invalid/secret" + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.desktop.version", + in: definition.validate() + )) + + definition.guestIdentityIntent.desktop?.version = "a" + + String(repeating: "\u{0301}", count: 128) + #expect(has( + .invalidGuestIdentityIntent, + "guestIdentityIntent.desktop.version", + in: definition.validate() + )) + + definition.guestIdentityIntent.desktop?.version = "24.04" + definition.integrations.removeAll { $0 == .clipboard } + #expect(has( + .clipboardPolicyRequiresIntegration, + "clipboardPolicy", + in: definition.validate() + )) + + for family in [DoryGuestFamily.windows, .macOS] { + var nonLinux = installerDefinition(family: family) + nonLinux.guestIdentityIntent = DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "developer", numericUserID: 1_000) + ) + #expect(has( + .guestIdentityIncompatibleWithGuest, + "guestIdentityIntent", + in: nonLinux.validate() + )) + } + } + @Test("oldest schema 1 golden JSON migrates deterministically") func oldestSchemaMigration() throws { let golden = Data(#""" diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 177db8e6..569b4c6a 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -27,6 +27,12 @@ struct DoryMachineConfigurationMigrationTests { DoryDesktopVMMPreference.environmentKey: "accelerated", DoryDesktopGraphicsPreference.environmentKey: "virgl", "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_NAME": "Ubuntu", + "DORY_DESKTOP_VERSION": "24.04", + "DORY_DESKTOP_ENVIRONMENT": "GNOME", + "DORY_GUEST_USER": "developer", + "DORY_GUEST_UID": "1000", + "DORY_CLIPBOARD_POLICY": "host-to-guest", "PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace", ] ) @@ -46,6 +52,17 @@ struct DoryMachineConfigurationMigrationTests { #expect(migrated.definition.resources.memoryBytes == 8 * gibibyte) #expect(migrated.definition.resources.virtualCPUCount == 6) #expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte) + #expect(migrated.definition.guestIdentityIntent.account == DoryVMGuestAccountIntent( + username: "developer", + numericUserID: 1_000 + )) + #expect(migrated.definition.guestIdentityIntent.desktop == DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04", + desktopEnvironment: "GNOME" + )) + #expect(migrated.definition.clipboardPolicy == .legacyDesktop(.hostToGuest)) #expect(migrated.hostPath(for: migrated.definition.shares[0].hostLocation) == "/Users/developer/Source") #expect(migrated.artifactPath(for: migrated.definition.storage[0].artifact) @@ -56,6 +73,8 @@ struct DoryMachineConfigurationMigrationTests { #expect(!definitionJSON.contains("/Users/developer")) #expect(!definitionJSON.contains("PRIVATE_RUNTIME_VALUE")) #expect(!definitionJSON.contains("do-not-copy-into-workspace")) + #expect(!definitionJSON.contains("DORY_GUEST_USER")) + #expect(!definitionJSON.contains("DORY_CLIPBOARD_POLICY")) #expect(try migrated.legacyConfiguration() == legacy) #expect(try migrated.authoritativeLegacyData() @@ -69,6 +88,115 @@ struct DoryMachineConfigurationMigrationTests { == migrated.definition.shares[0].hostLocation) } + @Test("typed guest and clipboard edits back-project through legacy compatibility keys") + func typedGuestIntentBackProjection() throws { + let legacy = DoryMachineConfiguration( + id: "desktop-intent", + kernelPath: "/managed/desktop-intent/kernel", + rootfsPath: "/managed/desktop-intent/rootfs.ext4", + displayMode: .desktop, + environment: ["PRESERVE": "yes"] + ) + var migrated = try migrate(legacy, capacity: 64 * gibibyte) + migrated.definition.guestIdentityIntent = DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "builder", numericUserID: 1_001), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04", + desktopEnvironment: "GNOME" + ) + ) + migrated.definition.clipboardPolicy = .legacyDesktop(.guestToHost) + + let projected = try migrated.legacyConfiguration() + #expect(projected.environment["DORY_GUEST_USER"] == "builder") + #expect(projected.environment["DORY_GUEST_UID"] == "1001") + #expect(projected.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(projected.environment["DORY_DESKTOP_NAME"] == "Ubuntu") + #expect(projected.environment["DORY_DESKTOP_VERSION"] == "24.04") + #expect(projected.environment["DORY_DESKTOP_ENVIRONMENT"] == "GNOME") + #expect(projected.environment["DORY_CLIPBOARD_POLICY"] == "guest-to-host") + #expect(projected.environment["PRESERVE"] == "yes") + + migrated.definition.clipboardPolicy = DoryVMClipboardPolicy( + text: .hostToGuest, + image: .guestToHost, + files: .off + ) + #expect(throws: DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "clipboardPolicy" + )) { + try migrated.legacyConfiguration() + } + } + + @Test("unsafe legacy reserved values remain only in authoritative legacy bytes") + func unsafeReservedValuesDoNotLeak() throws { + let legacy = DoryMachineConfiguration( + id: "legacy-hostile", + kernelPath: "/managed/legacy-hostile/kernel", + rootfsPath: "/managed/legacy-hostile/rootfs.ext4", + displayMode: .desktop, + environment: [ + "DORY_GUEST_USER": "../../host", + "DORY_GUEST_UID": "not-a-uid", + "DORY_DESKTOP_DISTRO": "https://token.invalid/secret", + "DORY_DESKTOP_VERSION": "secret\nvalue", + "DORY_CLIPBOARD_POLICY": "invalid-widening-value", + ] + ) + let migrated = try migrate(legacy, capacity: 64 * gibibyte) + #expect(migrated.definition.guestIdentityIntent == .unspecified) + #expect(migrated.definition.clipboardPolicy == .legacyDesktop(.off)) + #expect(migrated.definition.isValid) + + let data = try JSONEncoder().encode(migrated.definition) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("../../host")) + #expect(!json.contains("token.invalid")) + #expect(!json.contains("secret\\nvalue")) + #expect(try migrated.legacyConfiguration() == legacy) + + var edited = migrated + edited.definition.guestIdentityIntent.desktop = DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04", + desktopEnvironment: "GNOME" + ) + let projected = try edited.legacyConfiguration() + #expect(projected.environment["DORY_GUEST_USER"] == "../../host") + #expect(projected.environment["DORY_GUEST_UID"] == "not-a-uid") + #expect(projected.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(projected.environment["DORY_DESKTOP_VERSION"] == "24.04") + } + + @Test("legacy UID migration accepts exactly the guest provisioning range") + func guestUIDMigrationBoundaries() throws { + for (raw, expected) in [ + ("99", nil), + ("100", UInt32(100)), + ("60000", UInt32(60_000)), + ("60001", nil), + (String(UInt32.max), nil), + ] { + let legacy = DoryMachineConfiguration( + id: "uid-\(raw)", + kernelPath: "/managed/uid-\(raw)/kernel", + rootfsPath: "/managed/uid-\(raw)/rootfs.ext4", + displayMode: .desktop, + environment: [ + "DORY_GUEST_USER": "developer", + "DORY_GUEST_UID": raw, + ] + ) + let migrated = try migrate(legacy, capacity: 64 * gibibyte) + #expect(migrated.definition.guestIdentityIntent.account?.numericUserID == expected) + #expect(try migrated.legacyConfiguration() == legacy) + } + } + @Test("headless Linux maps to an explicit non-graphical Virtualization.framework contract") func headlessRoundTrip() throws { let legacy = DoryMachineConfiguration( @@ -78,7 +206,10 @@ struct DoryMachineConfigurationMigrationTests { memoryMB: 4_096, cpuCount: 4, displayMode: .headless, - environment: ["SERVICE_TOKEN": "legacy-only-value"] + environment: [ + "SERVICE_TOKEN": "legacy-only-value", + "DORY_DESKTOP_DISTRO": "stale-desktop-metadata", + ] ) let migrated = try migrate(legacy, capacity: 48 * gibibyte) @@ -90,6 +221,7 @@ struct DoryMachineConfigurationMigrationTests { #expect(!migrated.definition.audio.outputEnabled) #expect(!migrated.definition.input.keyboardEnabled) #expect(!migrated.definition.input.pointerEnabled) + #expect(migrated.definition.guestIdentityIntent.desktop == nil) #expect(try migrated.legacyConfiguration() == legacy) } From b7e9310a3c175b4a0bcbe41ba39e69aff0553f80 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 15:23:25 +0000 Subject: [PATCH 029/338] test(vm): gate real scheduled backup recovery --- .../MachineBackupSchedulerTests.swift | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift index 3036812a..7b96c839 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift @@ -1,7 +1,127 @@ +import DoryOperations @testable import DorydKit import XCTest final class MachineBackupSchedulerTests: XCTestCase { + func testRealMachineManagerBackupRoundTripsBundleABIAndBootVerifies() throws { + let base = NSTemporaryDirectory() + + "dory-real-machine-backup-\(getpid())-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: base) } + let kernel = base + "/kernel" + let rootfs = base + "/rootfs.ext4" + try Data("kernel-v1".utf8).write(to: URL(fileURLWithPath: kernel)) + try Data("rootfs-v1".utf8).write(to: URL(fileURLWithPath: rootfs)) + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: base + "/machines", + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + )) + defer { try? manager.delete(id: "dev") } + let created = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: kernel, + rootfsPath: rootfs, + memoryMB: 2_048, + cpuCount: 2 + )) + XCTAssertEqual(created.runtimeIdentity.mode, .legacyCompatibility) + XCTAssertEqual( + created.runtimeIdentity.virtualHardwareABIVersion, + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + + let scheduler = try MachineBackupScheduler( + machines: manager, + rootDirectory: base + "/backups", + now: { Date(timeIntervalSince1970: 1_783_392_000) } + ) + _ = try scheduler.upsert(DoryMachineBackupSchedule( + machineID: "dev", + frequency: .daily, + keepLocal: 1, + verifyEveryRuns: 1 + )) + let completed = try scheduler.runNow(machineID: "dev") + + XCTAssertEqual(completed.successfulRuns, 1) + XCTAssertEqual(completed.retainedSnapshots, 1) + XCTAssertEqual(completed.retainedArchives, 1) + XCTAssertNotNil(completed.lastBootVerificationISO) + let archive = try XCTUnwrap(completed.lastArchivePath) + XCTAssertEqual( + try Data(contentsOf: URL(fileURLWithPath: archive)) + .prefix(Data("DORYMACHINE3\n".utf8).count), + Data("DORYMACHINE3\n".utf8) + ) + XCTAssertEqual(manager.list().map(\.id), ["dev"]) + let retained = try XCTUnwrap(manager.listSnapshots(machineID: "dev").first) + XCTAssertEqual(retained.runtimeIdentity, created.runtimeIdentity) + + let imported = try manager.importSnapshot(fromPath: archive) + defer { try? manager.deleteSnapshot(machineID: imported.machineID, snapshotID: imported.id) } + XCTAssertEqual(imported.runtimeIdentity, created.runtimeIdentity) + XCTAssertEqual( + imported.runtimeIdentity.virtualHardwareABIVersion, + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + XCTAssertEqual( + try String(contentsOfFile: imported.rootfsPath, encoding: .utf8), + "rootfs-v1" + ) + XCTAssertEqual( + try String(contentsOfFile: imported.kernelPath, encoding: .utf8), + "kernel-v1" + ) + } + + func testRealMachineManagerBackupRollsBackWhenDisposableBootFails() throws { + let base = NSTemporaryDirectory() + + "dory-real-machine-backup-failure-\(getpid())-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: base) } + let kernel = base + "/kernel" + let rootfs = base + "/rootfs.ext4" + try Data("kernel-v1".utf8).write(to: URL(fileURLWithPath: kernel)) + try Data("rootfs-v1".utf8).write(to: URL(fileURLWithPath: rootfs)) + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: base + "/missing-vmm", + stateDirectory: base + "/machines", + passMachineArguments: false, + requiresReadyHandoff: false + )) + defer { try? manager.delete(id: "dev") } + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: kernel, + rootfsPath: rootfs + )) + let scheduler = try MachineBackupScheduler( + machines: manager, + rootDirectory: base + "/backups", + now: { Date(timeIntervalSince1970: 1_783_392_000) } + ) + _ = try scheduler.upsert(DoryMachineBackupSchedule( + machineID: "dev", + keepLocal: 1, + verifyEveryRuns: 1 + )) + + XCTAssertThrowsError(try scheduler.runNow(machineID: "dev")) + let failed = try XCTUnwrap(scheduler.list().first) + XCTAssertEqual(failed.successfulRuns, 0) + XCTAssertEqual(failed.consecutiveFailures, 1) + XCTAssertNotNil(failed.lastError) + XCTAssertTrue(try manager.listSnapshots(machineID: "dev").isEmpty) + XCTAssertEqual(manager.list().map(\.id), ["dev"]) + let archiveDirectory = base + "/backups/archives/dev" + let archives = (try? FileManager.default.contentsOfDirectory(atPath: archiveDirectory)) ?? [] + XCTAssertTrue(archives.filter { $0.hasSuffix(".dorymachine") }.isEmpty) + XCTAssertFalse(archives.contains { $0.hasSuffix(".partial") }) + } + func testSchedulePersistsAndReloadsWithOwnerOnlyState() throws { let fixture = try Fixture() defer { fixture.cleanup() } From 4ab5f18f2de79d5bd0bf85e64ac7e7479bdc987b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 15:57:31 +0000 Subject: [PATCH 030/338] feat(vm): enforce production launch trust --- .../DoryInstalledLinuxBootBundle.swift | 41 + ...yVirtualMachineQualificationManifest.swift | 4 + ...emonVirtualMachineLaunchPlanResolver.swift | 101 +- ...yDaemonVirtualMachineProductionTrust.swift | 1159 +++++++++++++++++ ...yDaemonVirtualMachineRuntimePlanning.swift | 8 +- .../DorydKit/DoryResolvedMachinePlan.swift | 2 +- .../Sources/DorydKit/DorydXPCSecurity.swift | 30 + .../Sources/DorydKit/MachineManager.swift | 26 +- dory-core-swift/Sources/doryd/main.swift | 48 +- ...onVirtualMachineProductionTrustTests.swift | 861 ++++++++++++ ...eManagerResolvedPlanIntegrationTests.swift | 84 +- 11 files changed, 2334 insertions(+), 30 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift index 8cbdfdda..60d5bc89 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift @@ -94,6 +94,32 @@ public enum DoryInstalledLinuxBootBundle { return try readHeader(from: input).descriptor } + /// Verifies the complete immutable bundle without materializing guest boot files. Reading the + /// header alone is insufficient at a daemon trust boundary because the embedded kernel or + /// initrd may have changed independently of their declared lengths. + @discardableResult + public static func verifyContents( + atPath path: String + ) throws -> DoryInstalledLinuxBootDescriptor { + let input = try openForReading(path) + defer { try? input.close() } + let header = try readHeader(from: input) + try input.seek(toOffset: header.kernelOffset) + let kernelDigest = try hashExactly( + from: input, + byteCount: header.descriptor.kernelLength + ) + let initrdDigest = try hashExactly( + from: input, + byteCount: header.descriptor.initrdLength + ) + guard kernelDigest == header.kernelDigest, + initrdDigest == header.initrdDigest else { + throw DoryInstalledLinuxBootBundleError.digestMismatch + } + return header.descriptor + } + @discardableResult public static func materialize( fromPath path: String, @@ -245,6 +271,21 @@ public enum DoryInstalledLinuxBootBundle { return Data(hasher.finalize()) } + private static func hashExactly( + from input: FileHandle, + byteCount: UInt64 + ) throws -> Data { + var remaining = byteCount + var hasher = SHA256() + while remaining > 0 { + let count = Int(min(UInt64(copyChunkBytes), remaining)) + let data = try readExactly(from: input, count: count) + hasher.update(data: data) + remaining -= UInt64(data.count) + } + return Data(hasher.finalize()) + } + private static func readExactly(from input: FileHandle, count: Int) throws -> Data { var result = Data() while result.count < count { diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift index 9cd55f76..8267611f 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift @@ -140,6 +140,7 @@ public enum DoryVirtualMachineQualificationAuthorityError: public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { public let catalogDigest: String public let catalogReleaseVersion: String + public let catalogGeneratedAt: String public let componentIdentifier: String public let componentVersion: String public let manifestSHA256: String @@ -151,6 +152,7 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { fileprivate init( catalogDigest: String, catalogReleaseVersion: String, + catalogGeneratedAt: String, componentIdentifier: String, componentVersion: String, manifestSHA256: String, @@ -160,6 +162,7 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { ) { self.catalogDigest = catalogDigest self.catalogReleaseVersion = catalogReleaseVersion + self.catalogGeneratedAt = catalogGeneratedAt self.componentIdentifier = componentIdentifier self.componentVersion = componentVersion self.manifestSHA256 = manifestSHA256 @@ -344,6 +347,7 @@ public enum DoryVirtualMachineQualificationAuthorityResolver { return DoryVerifiedVirtualMachineQualificationAuthority( catalogDigest: catalogDigest, catalogReleaseVersion: cached.catalog.releaseVersion, + catalogGeneratedAt: cached.catalog.generatedAt, componentIdentifier: installed.id.rawValue, componentVersion: installed.version, manifestSHA256: declaredAsset.installedSHA256, diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift index e24ba4f2..a174d606 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift @@ -1,6 +1,46 @@ import DoryOperations import Foundation +public enum DoryDaemonVirtualMachinePreSpawnAuthorizationError: + Error, Sendable, Equatable +{ + case alreadyConsumed + case revalidationFailed +} + +/// Single-use daemon authorization for the last possible trust check before MachineManager reads +/// path-derived boot arguments or starts the helper. It is non-Codable and cannot be supplied as +/// API intent. +public final class DoryDaemonVirtualMachinePreSpawnAuthorization: @unchecked Sendable { + private let lock = NSLock() + private var consumed = false + private let revalidate: @Sendable () throws -> Void + + init(revalidate: @escaping @Sendable () throws -> Void) { + self.revalidate = revalidate + } + + public func authorize() throws { + lock.lock() + guard !consumed else { + lock.unlock() + throw DoryDaemonVirtualMachinePreSpawnAuthorizationError.alreadyConsumed + } + consumed = true + lock.unlock() + do { try revalidate() } + catch { + throw DoryDaemonVirtualMachinePreSpawnAuthorizationError.revalidationFailed + } + } +} + +protocol DoryDaemonVirtualMachinePreSpawnAuthorizationProviding: Sendable { + func preSpawnAuthorization( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachinePreSpawnAuthorization +} + public protocol DoryDaemonVirtualMachineExactCapabilityEvaluating: Sendable { func evaluate( _ request: DoryVirtualMachineCapabilityRequest, @@ -33,13 +73,26 @@ public struct DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator: public struct DoryDaemonVirtualMachineStartEvidenceCollection: Sendable, Equatable { public var capability: DoryVirtualMachineCapabilityDescriptor public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence + public var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? public init( capability: DoryVirtualMachineCapabilityDescriptor, - runtimeEvidence: DoryResolvedMachineRuntimeEvidence + runtimeEvidence: DoryResolvedMachineRuntimeEvidence, + preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil ) { self.capability = capability self.runtimeEvidence = runtimeEvidence + self.preSpawnAuthorization = preSpawnAuthorization + } + + public static func == ( + lhs: DoryDaemonVirtualMachineStartEvidenceCollection, + rhs: DoryDaemonVirtualMachineStartEvidenceCollection + ) -> Bool { + lhs.capability == rhs.capability + && lhs.runtimeEvidence == rhs.runtimeEvidence + && ((lhs.preSpawnAuthorization == nil) + == (rhs.preSpawnAuthorization == nil)) } } @@ -56,6 +109,7 @@ public enum DoryDaemonVirtualMachineStartEvidenceFailureCode: String, Sendable, case backendUnavailable = "backend-unavailable" case backendInventoryUnavailable = "backend-inventory-unavailable" case exactCapabilityUnavailable = "exact-capability-unavailable" + case preSpawnAuthorizationUnavailable = "pre-spawn-authorization-unavailable" } public struct DoryDaemonVirtualMachineStartEvidenceFailure: Error, Sendable, Equatable { @@ -103,15 +157,17 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector: devices: plan.devices, virtualHardwareABIVersion: plan.virtualHardwareABIVersion ) + let inventoryRequest = DoryDaemonVirtualMachineStartInventoryRequest( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + planRevision: plan.planRevision, + bootMediaReference: reference, + exactCapabilityRequest: persistedRequest, + resolvedPlan: plan + ) let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot do { - snapshot = try inventory.startInventory(for: DoryDaemonVirtualMachineStartInventoryRequest( - machineID: plan.machineID, - definitionRevision: plan.definitionRevision, - planRevision: plan.planRevision, - bootMediaReference: reference, - exactCapabilityRequest: persistedRequest - )) + snapshot = try inventory.startInventory(for: inventoryRequest) } catch { throw failure(.inventoryUnavailable, "Fresh trusted start inventory could not be resolved.") } @@ -170,9 +226,28 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector: hostQualification: runtime.hostQualification, experimentalAuthorization: plan.experimentalAuthorization ) + guard let authorizationProvider = inventory + as? any DoryDaemonVirtualMachinePreSpawnAuthorizationProviding else { + throw failure( + .preSpawnAuthorizationUnavailable, + "The trusted inventory cannot authorize final pre-spawn revalidation." + ) + } + let preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization + do { + preSpawnAuthorization = try authorizationProvider.preSpawnAuthorization( + for: inventoryRequest + ) + } catch { + throw failure( + .preSpawnAuthorizationUnavailable, + "Final pre-spawn revalidation could not be prepared." + ) + } return DoryDaemonVirtualMachineStartEvidenceCollection( capability: capability, - runtimeEvidence: runtimeEvidence + runtimeEvidence: runtimeEvidence, + preSpawnAuthorization: preSpawnAuthorization ) } @@ -210,17 +285,20 @@ public struct DoryDaemonVirtualMachineLaunchPlanResolution: Sendable { public var resolvedPlanSHA256: String public var revalidation: DoryResolvedMachinePlanRevalidationResult public var backendPlan: MachineBackendPlan + public var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? public init( resolvedPlan: DoryResolvedMachinePlan, resolvedPlanSHA256: String, revalidation: DoryResolvedMachinePlanRevalidationResult, - backendPlan: MachineBackendPlan + backendPlan: MachineBackendPlan, + preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil ) { self.resolvedPlan = resolvedPlan self.resolvedPlanSHA256 = resolvedPlanSHA256 self.revalidation = revalidation self.backendPlan = backendPlan + self.preSpawnAuthorization = preSpawnAuthorization } } @@ -358,7 +436,8 @@ public final class DoryDaemonVirtualMachineLaunchPlanResolver: resolvedPlan: plan, resolvedPlanSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan), revalidation: revalidation, - backendPlan: backendPlan + backendPlan: backendPlan, + preSpawnAuthorization: fresh.preSpawnAuthorization ) } diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift new file mode 100644 index 00000000..03862832 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -0,0 +1,1159 @@ +import CryptoKit +import Darwin +import DoryOperations +import Foundation +import Security + +public enum DoryDaemonVirtualMachineProductionTrustReadinessCode: + String, Sendable, Equatable +{ + case catalogUnavailable = "catalog-unavailable" + case catalogSchemaV1Migration = "catalog-schema-v1-migration" + case catalogSchemaUnsupported = "catalog-schema-unsupported" + case trustFloorViolated = "trust-floor-violated" + case planningTransactionUnavailable = "planning-transaction-unavailable" + case qualificationAuthorityUnavailable = "qualification-authority-unavailable" + case daemonSignatureUnavailable = "daemon-signature-unavailable" + case hostFactsUnavailable = "host-facts-unavailable" + case backendRuntimeUnavailable = "backend-runtime-unavailable" + case resourceAuthorityUnavailable = "resource-authority-unavailable" + case compositionFailed = "composition-failed" +} + +public struct DoryDaemonVirtualMachineProductionTrustUnavailable: + Error, Sendable, Equatable +{ + public var code: DoryDaemonVirtualMachineProductionTrustReadinessCode + public var message: String + private var legacyCompatibilityMigrationPermitted: Bool + + public init( + code: DoryDaemonVirtualMachineProductionTrustReadinessCode, + message: String + ) { + self.code = code + self.message = message + legacyCompatibilityMigrationPermitted = false + } + + init( + code: DoryDaemonVirtualMachineProductionTrustReadinessCode, + message: String, + permitsLegacyCompatibilityMigration: Bool + ) { + self.code = code + self.message = message + legacyCompatibilityMigrationPermitted = permitsLegacyCompatibilityMigration + } + + /// Only absence and the explicitly supported schema-v1 migration state may retain the + /// labeled compatibility path. Integrity, signer, runtime, host, ledger, and composition + /// failures must never turn into authorization to launch the rejected helper through legacy. + public var permitsLegacyCompatibilityMigration: Bool { + legacyCompatibilityMigrationPermitted + } +} + +/// A ready result owns the exact inventory and launch composition installed into MachineManager. +/// It is intentionally non-Codable: verified authorities, helper paths, and live ledger handles +/// are daemon state and must never become API intent or a persisted trust assertion. +public struct DoryDaemonVirtualMachineProductionTrustContext: Sendable { + public let machineManager: MachineManager + public let inventory: any DoryDaemonVirtualMachineTrustInventory + public let backendRuntimeBuildIdentifiers: [ + DoryVirtualizationBackendIdentity: String + ] + + init( + machineManager: MachineManager, + inventory: any DoryDaemonVirtualMachineTrustInventory, + backendRuntimeBuildIdentifiers: [ + DoryVirtualizationBackendIdentity: String + ] + ) { + self.machineManager = machineManager + self.inventory = inventory + self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers + } +} + +public enum DoryDaemonVirtualMachineProductionTrustReadiness: Sendable { + case ready(DoryDaemonVirtualMachineProductionTrustContext) + case unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable) +} + +private enum DoryDaemonVirtualMachineProductionTrustFloor { + private static let fileName = ".vm-production-trust-floor-v1" + private static let lockFileName = ".vm-production-trust-floor.lock" + + struct Record: Codable, Sendable, Equatable { + static let kind = "dev.dory.vm-production-trust-floor" + static let schemaVersion = 1 + + let kind: String + let schemaVersion: Int + let catalogReleaseVersion: String + let catalogGeneratedAt: String + let catalogDigest: String + + init( + catalogReleaseVersion: String, + catalogGeneratedAt: String, + catalogDigest: String + ) { + kind = Self.kind + schemaVersion = Self.schemaVersion + self.catalogReleaseVersion = catalogReleaseVersion + self.catalogGeneratedAt = catalogGeneratedAt + self.catalogDigest = catalogDigest.lowercased() + } + } + + static func read(stateDirectory: String) throws -> Record? { + let path = stateDirectory + "/" + fileName + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + if descriptor < 0 { + if errno == ENOENT { return nil } + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust-floor record cannot be opened." + ) + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_size > 0, info.st_size <= 1_024, + info.st_nlink == 1, + info.st_uid == geteuid(), + info.st_mode & (S_IRWXG | S_IRWXO) == 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust-floor record is insecure or corrupt." + ) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + guard data.count == info.st_size, + let record = try? JSONDecoder().decode(Record.self, from: data), + isValid(record) else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust-floor record is invalid." + ) + } + return record + } + + static func validate( + authority: DoryVerifiedVirtualMachineQualificationAuthority, + against record: Record? + ) throws { + let candidate = Record( + catalogReleaseVersion: authority.catalogReleaseVersion, + catalogGeneratedAt: authority.catalogGeneratedAt, + catalogDigest: authority.catalogDigest + ) + guard isValid(candidate) else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "Verified catalog identity cannot establish the trust floor." + ) + } + guard let record else { return } + guard let candidateTimestamp = timestamp(candidate.catalogGeneratedAt), + let recordTimestamp = timestamp(record.catalogGeneratedAt) else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "The accepted VM trust-floor timestamp is invalid." + ) + } + let versionOrder = candidate.catalogReleaseVersion.compare( + record.catalogReleaseVersion, + options: .numeric + ) + guard versionOrder != .orderedAscending, + candidateTimestamp >= recordTimestamp else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "The signed component catalog is older than the accepted VM trust floor." + ) + } + if versionOrder == .orderedSame { + guard candidate == record else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "The signed component catalog conflicts with the accepted VM trust floor." + ) + } + } + } + + static func activate( + stateDirectory: String, + authority: DoryVerifiedVirtualMachineQualificationAuthority, + synchronizeDirectory: @Sendable (Int32) -> Bool + ) throws { + let candidate = Record( + catalogReleaseVersion: authority.catalogReleaseVersion, + catalogGeneratedAt: authority.catalogGeneratedAt, + catalogDigest: authority.catalogDigest + ) + guard isValid(candidate) else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "Verified catalog identity cannot establish the trust floor." + ) + } + let lock = try EngineStateDirectoryLock( + stateDirectory: stateDirectory, + lockFileName: lockFileName + ) + defer { withExtendedLifetime(lock) {} } + let current = try read(stateDirectory: stateDirectory) + try validate(authority: authority, against: current) + if current == candidate { + return + } + let path = stateDirectory + "/" + fileName + let temporary = stateDirectory + + "/.\(fileName).\(UUID().uuidString.lowercased()).tmp" + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust floor cannot be persisted." + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(candidate) + Data("\n".utf8) + var descriptorOpen = true + do { + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, + bytes.baseAddress!.advanced(by: offset), + bytes.count - offset + ) + guard count > 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust floor cannot be written." + ) + } + offset += count + } + } + guard fsync(descriptor) == 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust floor cannot be synchronized." + ) + } + close(descriptor) + descriptorOpen = false + guard rename(temporary, path) == 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust floor cannot be published." + ) + } + let directory = open(stateDirectory, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard directory >= 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust-floor directory cannot be opened." + ) + } + guard synchronizeDirectory(directory) else { + close(directory) + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust-floor directory cannot be synchronized." + ) + } + close(directory) + } catch { + if descriptorOpen { close(descriptor) } + unlink(temporary) + throw error + } + } + + static func hasResolvedPlanState(stateDirectory: String) throws -> Bool { + let names: [String] + do { names = try FileManager.default.contentsOfDirectory(atPath: stateDirectory) } + catch { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM state cannot be inspected for existing resolved plans." + ) + } + for name in names { + guard name.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !name.hasPrefix(".") else { + continue + } + var directoryInfo = stat() + let directory = stateDirectory + "/" + name + guard lstat(directory, &directoryInfo) == 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM state entry cannot be inspected." + ) + } + guard directoryInfo.st_mode & S_IFMT == S_IFDIR else { continue } + var planInfo = stat() + let plan = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName + if lstat(plan, &planInfo) == 0 { return true } + guard errno == ENOENT else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "Existing resolved-plan state cannot be inspected." + ) + } + } + return false + } + + private static func isValid(_ record: Record) -> Bool { + record.kind == Record.kind + && record.schemaVersion == Record.schemaVersion + && validVersion(record.catalogReleaseVersion) + && timestamp(record.catalogGeneratedAt) != nil + && isSHA256(record.catalogDigest) + } + + private static func validVersion(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 64 && value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...90).contains(byte) + || (97...122).contains(byte) || byte == 43 || byte == 45 + || byte == 46 || byte == 95 + } + } + + private static func timestamp(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...70).contains(byte) + || (97...102).contains(byte) + } + } +} + +struct DoryDaemonVerifiedBackendRuntime: Sendable, Equatable { + var descriptor: MachineBackendDescriptor + var executablePath: String + var runtimeBuildIdentifier: String + var components: [DoryVirtualMachineQualifiedComponent] + + init( + descriptor: MachineBackendDescriptor, + executablePath: String, + runtimeBuildIdentifier: String, + components: [DoryVirtualMachineQualifiedComponent] + ) { + self.descriptor = descriptor + self.executablePath = executablePath + self.runtimeBuildIdentifier = runtimeBuildIdentifier + self.components = components.sorted { $0.componentIdentifier < $1.componentIdentifier } + } + + var componentEvidence: [DoryResolvedBackendComponentEvidence] { + components.map { + DoryResolvedBackendComponentEvidence( + componentIdentifier: $0.componentIdentifier, + buildIdentifier: $0.buildIdentifier, + artifactSHA256: $0.artifactSHA256 + ) + } + } +} + +struct DoryDaemonBackendRuntimeSpecification: Sendable, Equatable { + var descriptor: MachineBackendDescriptor + var executablePath: String + var componentIdentifier: String +} + +struct DoryDaemonProductionHostObservation: Sendable, Equatable { + var hardwareModelIdentifier: String + var operatingSystemBuild: String + var macOSMajorVersion: Int + var virtualizationFrameworkAvailable: Bool + var hypervisorFrameworkAvailable: Bool + var metalAvailable: Bool + var resources: DoryVMHostResources +} + +enum DoryDaemonProductionTrustInventoryError: + Error, Sendable, Equatable +{ + case planningRequiresAdmissionTransaction + case invalidRequest + case mediaUnavailable + case mediaInvalid + case backendUnavailable + case qualificationUnavailable + case resourceAdmissionUnavailable +} + +/// Production inventory backed exclusively by opaque verified authorities. It deliberately does +/// not implement admission reservation yet: the current coordinator has no atomic reserve -> plan +/// bind -> publish transaction. Start-time verification is complete for previously published and +/// bound plans; planning fails explicitly instead of returning invented admission evidence. +final class DoryProductionDaemonVirtualMachineTrustInventory: + DoryDaemonVirtualMachineTrustInventory, + DoryDaemonVirtualMachinePreSpawnAuthorizationProviding, + @unchecked Sendable +{ + private let qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority + private let artifactAuthority: DoryVirtualMachineArtifactAuthority + private let resourceLedger: DoryVirtualMachineResourceAdmissionLedger + private let stateDirectory: String + private let runtimeSpecifications: [ + DoryVirtualizationBackendIdentity: DoryDaemonBackendRuntimeSpecification + ] + private let runtimeVerifier: DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier + private let hostProbe: DoryDaemonVirtualMachineProductionTrustFactory.HostProbe + + init( + qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority, + artifactAuthority: DoryVirtualMachineArtifactAuthority, + resourceLedger: DoryVirtualMachineResourceAdmissionLedger, + stateDirectory: String, + runtimeSpecifications: [DoryDaemonBackendRuntimeSpecification], + runtimeVerifier: @escaping DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier, + hostProbe: @escaping DoryDaemonVirtualMachineProductionTrustFactory.HostProbe + ) { + self.qualificationAuthority = qualificationAuthority + self.artifactAuthority = artifactAuthority + self.resourceLedger = resourceLedger + self.stateDirectory = stateDirectory + self.runtimeSpecifications = Dictionary(uniqueKeysWithValues: runtimeSpecifications.map { + ($0.descriptor.identity, $0) + }) + self.runtimeVerifier = runtimeVerifier + self.hostProbe = hostProbe + } + + func planningInventory( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + _ = request + throw DoryDaemonProductionTrustInventoryError + .planningRequiresAdmissionTransaction + } + + func startInventory( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + let plan = request.resolvedPlan + guard request.machineID == plan.machineID, + request.definitionRevision == plan.definitionRevision, + request.planRevision == plan.planRevision, + request.bootMediaReference == plan.bootMedia.resolverReference, + request.exactCapabilityRequest == DoryVirtualMachineCapabilityRequest( + guest: plan.guest, + bootMedia: plan.bootMedia.media, + backend: plan.backend, + graphics: plan.graphics, + devices: plan.devices, + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ) else { + throw DoryDaemonProductionTrustInventoryError.invalidRequest + } + let host: DoryDaemonProductionHostObservation + do { host = try hostProbe(stateDirectory) } + catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest } + guard let runtimeSpecification = runtimeSpecifications[plan.backend] else { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + let runtime: DoryDaemonVerifiedBackendRuntime + do { + runtime = try runtimeVerifier( + runtimeSpecification.executablePath, + runtimeSpecification.descriptor, + runtimeSpecification.componentIdentifier + ) + } catch { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + guard runtime.descriptor.implementationIdentifier + == runtimeSpecification.descriptor.implementationIdentifier, + runtime.descriptor.implementationIdentifier + == plan.backendImplementationIdentifier, + runtime.runtimeBuildIdentifier + == plan.backendRuntimeBuildIdentifier else { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + + let artifact: DoryVerifiedVirtualMachineArtifact + do { + artifact = try artifactAuthority.resolve( + reference: request.bootMediaReference, + kind: plan.bootMedia.media.kind, + source: plan.bootMedia.media.source + ) + } catch { + throw DoryDaemonProductionTrustInventoryError.mediaUnavailable + } + guard artifact.media == request.exactCapabilityRequest.bootMedia else { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + + let qualification: DoryResolvedTrustedVirtualMachineQualification + do { + qualification = try qualificationAuthority.resolve( + request: request.exactCapabilityRequest, + backendImplementationIdentifier: + runtime.descriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + installedComponents: runtime.components + ) + } catch { + throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable + } + + let inspection: DoryTrustedBootMediaInspection? + switch artifact.media.kind { + case .installerISO: + do { + inspection = try DoryQualifiedBootMediaInspector.inspectInstallerISO( + atPath: artifact.path, + qualification: qualification + ).inspection + } catch { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + case .installedLinuxBootBundle: + do { + _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) + inspection = nil + } catch { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + case .virtualDisk: + guard artifact.mutableProvenance != nil else { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + inspection = nil + case .macOSRestoreImage: + // The current product has no daemon-owned VZMac restore-image inspector. + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + + let admission: DoryResolvedMachineResourceAdmissionEvidence + do { + let snapshot = try resourceLedger.snapshot() + let leases = snapshot.leases.filter { + $0.binding.machineID == plan.machineID + && $0.binding.definitionRevision == plan.definitionRevision + && $0.binding.definitionSHA256 == plan.definitionSHA256 + && $0.binding.plannedPlanRevision == plan.planRevision + && $0.state == .starting + } + guard leases.count == 1, let lease = leases.first else { + throw DoryDaemonProductionTrustInventoryError + .resourceAdmissionUnavailable + } + admission = try resourceLedger.revalidateForStart( + leaseID: lease.leaseID, + plan: plan, + hostFacts: host.resources + ) + } catch { + throw DoryDaemonProductionTrustInventoryError.resourceAdmissionUnavailable + } + + let record = qualification.record + let hostQualification = DoryResolvedHostQualificationEvidence( + qualificationIdentity: record.qualificationIdentity, + qualificationReportSHA256: Self.digest(Self.canonicalData(record)), + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + backend: record.backend, + backendRuntimeBuildIdentifier: record.backendRuntimeBuildIdentifier, + virtualHardwareABIVersion: record.virtualHardwareABIVersion, + qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification", + qualifierVersion: DoryVirtualMachineQualificationManifest.schemaVersion + ) + let runtimeInventory = DoryDaemonVirtualMachineBackendRuntimeInventory( + backend: plan.backend, + runtimeBuildIdentifier: runtime.runtimeBuildIdentifier, + components: runtime.componentEvidence, + hostQualification: hostQualification + ) + return DoryDaemonVirtualMachineTrustedInventorySnapshot( + hostFacts: hostFacts(host: host, runtime: runtime), + media: DoryDaemonVirtualMachineResolvedMedia( + reference: artifact.reference, + media: artifact.media, + guestGraphicsQualification: qualification.graphics, + bootInspection: inspection, + mutableProvenance: artifact.mutableProvenance + ), + backendRuntimes: [runtimeInventory], + resourceAdmission: admission, + exactStartRuntimeQualification: qualification.runtime + ) + } + + func preSpawnAuthorization( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachinePreSpawnAuthorization { + DoryDaemonVirtualMachinePreSpawnAuthorization { [weak self] in + guard let self else { + throw DoryDaemonProductionTrustInventoryError.invalidRequest + } + // Repeats artifact hashing, structural media verification, helper signature/digest, + // host/resource probing, signed exact qualification, and bound-ledger validation. + _ = try self.startInventory(for: request) + } + } + + private func hostFacts( + host: DoryDaemonProductionHostObservation, + runtime: DoryDaemonVerifiedBackendRuntime + ) -> DoryAppleSiliconHostFacts { + let rawBuild = runtime.descriptor.identity == .doryHypervisor + ? runtime.runtimeBuildIdentifier : "" + let vzBuild = runtime.descriptor.identity == .appleVirtualizationFramework + ? runtime.runtimeBuildIdentifier : "" + return DoryAppleSiliconHostFacts( + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: + host.virtualizationFrameworkAvailable && !vzBuild.isEmpty, + hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable, + doryHypervisorAvailable: !rawBuild.isEmpty, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, + networkAvailable: false, + displayAvailable: false, + inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: host.metalAvailable, + doryAcceleratedRendererAvailable: + host.metalAvailable && !rawBuild.isEmpty, + runtimeQualificationContext: + DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: rawBuild, + virtualizationFrameworkAdapterBuildID: vzBuild, + qemuRuntimeBuildID: "" + ) + ) + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +/// Resolves the production trust root before constructing a resolved-plan MachineManager. Every +/// default dependency probes daemon-owned state. Internal injected dependencies exist solely for +/// deterministic tests; the public entry point never accepts caller trust booleans. +public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { + typealias AuthorityResolver = @Sendable ( + DoryComponentStore, String, String, String + ) throws -> DoryVerifiedVirtualMachineQualificationAuthority + typealias RuntimeVerifier = @Sendable ( + String, MachineBackendDescriptor, String + ) throws -> DoryDaemonVerifiedBackendRuntime + typealias HostProbe = @Sendable (String) throws -> DoryDaemonProductionHostObservation + typealias DirectorySynchronizer = @Sendable (Int32) -> Bool + + private let authorityResolver: AuthorityResolver + private let runtimeVerifier: RuntimeVerifier + private let hostProbe: HostProbe + private let daemonIdentityVerifier: @Sendable () -> Bool + private let planningTransactionAvailable: @Sendable () -> Bool + private let synchronizeTrustFloorDirectory: DirectorySynchronizer + + /// Compiled into and covered by the daemon's code signature. Production catalog compatibility + /// must not trust an environment override or a mutable outer Info.plist. + public static let compiledDaemonVersion = "0.4.5" + + public init() { + authorityResolver = { store, publicKey, architecture, appVersion in + try DoryVirtualMachineQualificationAuthorityResolver.resolve( + store: store, + publicKey: publicKey, + expectedArchitecture: architecture, + appVersion: appVersion + ) + } + runtimeVerifier = Self.verifyProductionRuntime + hostProbe = Self.probeProductionHost + daemonIdentityVerifier = DorydXPCSecurity + .currentProcessSatisfiesProductionDaemonRequirement + planningTransactionAvailable = { false } + synchronizeTrustFloorDirectory = { fsync($0) == 0 } + } + + init( + authorityResolver: @escaping AuthorityResolver, + runtimeVerifier: @escaping RuntimeVerifier, + hostProbe: @escaping HostProbe, + daemonIdentityVerifier: @escaping @Sendable () -> Bool, + planningTransactionAvailable: @escaping @Sendable () -> Bool = { false }, + synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 } + ) { + self.authorityResolver = authorityResolver + self.runtimeVerifier = runtimeVerifier + self.hostProbe = hostProbe + self.daemonIdentityVerifier = daemonIdentityVerifier + self.planningTransactionAvailable = planningTransactionAvailable + self.synchronizeTrustFloorDirectory = synchronizeTrustFloorDirectory + } + + public func resolve( + store: DoryComponentStore, + machineConfiguration: MachineManagerConfiguration + ) -> DoryDaemonVirtualMachineProductionTrustReadiness { + resolve( + store: store, + machineConfiguration: machineConfiguration, + appVersion: Self.compiledDaemonVersion, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture + ) + } + + /// Test-only/internal trust seam. Production callers cannot replace the pinned catalog key or + /// current architecture through the public API. + func resolve( + store: DoryComponentStore, + machineConfiguration: MachineManagerConfiguration, + appVersion: String, + publicKey: String, + expectedArchitecture: String + ) -> DoryDaemonVirtualMachineProductionTrustReadiness { + let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record? + let hasResolvedPlanState: Bool + do { + trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor + .read(stateDirectory: machineConfiguration.stateDirectory) + hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor + .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory) + } catch { + return unavailable( + .trustFloorViolated, + "The durable VM production trust floor is invalid or unavailable." + ) + } + let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState + let authority: DoryVerifiedVirtualMachineQualificationAuthority + do { + authority = try authorityResolver( + store, publicKey, expectedArchitecture, appVersion + ) + } catch let error as DoryVirtualMachineQualificationAuthorityError { + let code: DoryDaemonVirtualMachineProductionTrustReadinessCode + switch error { + case .catalogUnavailable: + code = .catalogUnavailable + case let .catalogSchemaUnsupported(version): + code = version == DoryComponentCatalog.oldestSupportedSchemaVersion + ? .catalogSchemaV1Migration : .catalogSchemaUnsupported + default: + code = .qualificationAuthorityUnavailable + } + if code == .catalogUnavailable || code == .catalogSchemaV1Migration { + guard mayUseLegacyMigration else { + return unavailable( + .trustFloorViolated, + "VM production trust was previously activated; catalog authority cannot be removed or downgraded." + ) + } + return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: error.description, + permitsLegacyCompatibilityMigration: true + )) + } + return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: error.description + )) + } catch { + return unavailable( + .qualificationAuthorityUnavailable, + "VM qualification authority could not be verified." + ) + } + + do { + try DoryDaemonVirtualMachineProductionTrustFloor.validate( + authority: authority, + against: trustFloor + ) + } catch { + return unavailable( + .trustFloorViolated, + "The signed component catalog violates the accepted VM trust floor." + ) + } + + guard daemonIdentityVerifier() else { + return unavailable( + .daemonSignatureUnavailable, + "Resolved-plan launch requires a production-signed Dory daemon." + ) + } + do { _ = try hostProbe(machineConfiguration.stateDirectory) } + catch { + return unavailable( + .hostFactsUnavailable, + "Exact host model, OS build, framework, or resource facts are unavailable." + ) + } + + var runtimes: [DoryDaemonVerifiedBackendRuntime] = [] + do { + runtimes.append(try runtimeVerifier( + machineConfiguration.vmmExecutablePath, + VirtualizationFrameworkLinuxMachineBackend.backendDescriptor, + "dory-vmm" + )) + } catch { + return unavailable( + .backendRuntimeUnavailable, + "The signed Virtualization.framework helper failed exact verification." + ) + } + if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath { + if let runtime = try? runtimeVerifier( + rawPath, + RawHVLinuxMachineBackend.backendDescriptor, + "dory-hv" + ) { + runtimes.append(runtime) + } + } + + let artifactAuthority = DoryVirtualMachineArtifactAuthority( + root: machineConfiguration.stateDirectory + "/.artifact-authority" + ) + let resourceLedger = DoryVirtualMachineResourceAdmissionLedger( + root: machineConfiguration.stateDirectory + "/.resource-admissions" + ) + do { _ = try resourceLedger.snapshot() } + catch { + return unavailable( + .resourceAuthorityUnavailable, + "The durable VM resource-admission ledger is unavailable." + ) + } + guard planningTransactionAvailable() else { + return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .planningTransactionUnavailable, + message: "Resolved-plan reserve, bind, and publication are not yet installed in the production workspace workflow.", + permitsLegacyCompatibilityMigration: mayUseLegacyMigration + )) + } + let inventory = DoryProductionDaemonVirtualMachineTrustInventory( + qualificationAuthority: authority, + artifactAuthority: artifactAuthority, + resourceLedger: resourceLedger, + stateDirectory: machineConfiguration.stateDirectory, + runtimeSpecifications: runtimes.map { + DoryDaemonBackendRuntimeSpecification( + descriptor: $0.descriptor, + executablePath: $0.executablePath, + componentIdentifier: $0.components[0].componentIdentifier + ) + }, + runtimeVerifier: runtimeVerifier, + hostProbe: hostProbe + ) + + do { + try DoryDaemonVirtualMachineProductionTrustFloor.activate( + stateDirectory: machineConfiguration.stateDirectory, + authority: authority, + synchronizeDirectory: synchronizeTrustFloorDirectory + ) + let manager = MachineManager( + configuration: machineConfiguration, + launchPolicy: .requireResolvedPlan + ) + let backends: [any MachineBackend] = runtimes.map { runtime in + switch runtime.descriptor.identity { + case .appleVirtualizationFramework: + VirtualizationFrameworkLinuxMachineBackend( + executablePath: runtime.executablePath, + operations: manager.resolvedLaunchCompatibilityOperations( + for: runtime.descriptor.identity + ) + ) + case .doryHypervisor: + RawHVLinuxMachineBackend( + executablePath: runtime.executablePath, + operations: manager.resolvedLaunchCompatibilityOperations( + for: runtime.descriptor.identity + ) + ) + case .qemuHypervisorFramework: + // No production QEMU/Windows adapter exists in this milestone. + preconditionFailure("unreachable unverified backend runtime") + } + } + let registry = try BackendRegistry(backends: backends) + let plans = DoryResolvedMachinePlanRepository( + root: machineConfiguration.stateDirectory + ) + let collector = DoryDaemonVirtualMachineStartEvidenceCollector( + registry: registry, + inventory: inventory + ) + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: registry, + plans: plans, + evidenceCollector: collector + ) + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { machineID in + try? plans.read(id: machineID).planRevision + } + ) + return .ready(DoryDaemonVirtualMachineProductionTrustContext( + machineManager: manager, + inventory: inventory, + backendRuntimeBuildIdentifiers: Dictionary( + uniqueKeysWithValues: runtimes.map { + ($0.descriptor.identity, $0.runtimeBuildIdentifier) + } + ) + )) + } catch { + return unavailable( + .compositionFailed, + "Resolved-plan VM launch infrastructure could not be composed." + ) + } + } + + private func unavailable( + _ code: DoryDaemonVirtualMachineProductionTrustReadinessCode, + _ message: String + ) -> DoryDaemonVirtualMachineProductionTrustReadiness { + .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: message + )) + } + + private static func verifyProductionRuntime( + path: String, + descriptor: MachineBackendDescriptor, + componentIdentifier: String + ) throws -> DoryDaemonVerifiedBackendRuntime { + guard DorydXPCSecurity.currentTeamIdentifier() + == DorydXPCSecurity.productionTeamID else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .daemonSignatureUnavailable, + message: "The daemon is not production signed." + ) + } + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + guard canonical == path else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper path is not canonical." + ) + } + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath( + URL(fileURLWithPath: canonical) as CFURL, + SecCSFlags(), + &staticCode + ) == errSecSuccess, + let staticCode, + SecStaticCodeCheckValidity(staticCode, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), nil) + == errSecSuccess else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper signature is invalid." + ) + } + var signingInformation: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &signingInformation + ) == errSecSuccess, + let values = signingInformation as? [CFString: Any], + values[kSecCodeInfoTeamIdentifier] as? String + == DorydXPCSecurity.productionTeamID, + values[kSecCodeInfoIdentifier] as? String == componentIdentifier else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper signer does not match Dory." + ) + } + + let digest = try stableExecutableDigest(canonical) + let build = "sha256:\(digest)" + return DoryDaemonVerifiedBackendRuntime( + descriptor: descriptor, + executablePath: canonical, + runtimeBuildIdentifier: build, + components: [DoryVirtualMachineQualifiedComponent( + componentIdentifier: componentIdentifier, + buildIdentifier: build, + artifactSHA256: digest + )] + ) + } + + private static func stableExecutableDigest(_ path: String) throws -> String { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper cannot be opened." + ) + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_size > 0, + before.st_nlink == 1, + before.st_uid == 0 || before.st_uid == geteuid(), + before.st_mode & (S_IWGRP | S_IWOTH) == 0, + before.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH) != 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper file identity is insecure." + ) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + var hasher = SHA256() + while true { + let data = try handle.read(upToCount: 1_048_576) ?? Data() + if data.isEmpty { break } + hasher.update(data: data) + } + var after = stat() + guard fstat(descriptor, &after) == 0, + sameSnapshot(before, after) else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "Backend helper changed during verification." + ) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec + && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } + + private static func probeProductionHost( + stateDirectory: String + ) throws -> DoryDaemonProductionHostObservation { + guard let model = sysctlString("hw.model"), + let build = sysctlString("kern.osversion"), + !model.isEmpty, !build.isEmpty else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .hostFactsUnavailable, + message: "Host model or OS build is unavailable." + ) + } + let process = ProcessInfo.processInfo + guard process.operatingSystemVersion.majorVersion > 0, + process.activeProcessorCount > 0, + process.physicalMemory > 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .hostFactsUnavailable, + message: "Host CPU, memory, or OS facts are invalid." + ) + } + let values = try URL(fileURLWithPath: stateDirectory).resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + guard let free = values.volumeAvailableCapacityForImportantUsage, + free > 0 else { + throw DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .hostFactsUnavailable, + message: "Host storage capacity is unavailable." + ) + } + return DoryDaemonProductionHostObservation( + hardwareModelIdentifier: model, + operatingSystemBuild: build, + macOSMajorVersion: process.operatingSystemVersion.majorVersion, + virtualizationFrameworkAvailable: frameworkAvailable( + "/System/Library/Frameworks/Virtualization.framework/Virtualization" + ), + hypervisorFrameworkAvailable: frameworkAvailable( + "/System/Library/Frameworks/Hypervisor.framework/Hypervisor" + ), + metalAvailable: frameworkAvailable( + "/System/Library/Frameworks/Metal.framework/Metal" + ), + resources: DoryVMHostResources( + logicalCPUCount: UInt64(process.activeProcessorCount), + physicalMemoryBytes: process.physicalMemory, + freeStorageBytes: UInt64(free) + ) + ) + } + + private static func sysctlString(_ name: String) -> String? { + var length = 0 + guard sysctlbyname(name, nil, &length, nil, 0) == 0, length > 1 else { + return nil + } + var bytes = [CChar](repeating: 0, count: length) + guard sysctlbyname(name, &bytes, &length, nil, 0) == 0 else { return nil } + let payload = bytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: payload, as: UTF8.self) + } + + private static func frameworkAvailable(_ path: String) -> Bool { + guard let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) else { return false } + dlclose(handle) + return true + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 5791f382..a4ca9581 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -116,19 +116,25 @@ public struct DoryDaemonVirtualMachineStartInventoryRequest: Sendable, Equatable public var planRevision: UInt64 public var bootMediaReference: DoryVMResolverReference public var exactCapabilityRequest: DoryVirtualMachineCapabilityRequest + /// Exact already-persisted plan. This request is daemon-local and non-Codable; carrying the + /// plan lets the resource authority verify its one-shot plan binding rather than trusting a + /// caller-supplied digest or merely matching resource numbers. + public var resolvedPlan: DoryResolvedMachinePlan public init( machineID: String, definitionRevision: UInt64, planRevision: UInt64, bootMediaReference: DoryVMResolverReference, - exactCapabilityRequest: DoryVirtualMachineCapabilityRequest + exactCapabilityRequest: DoryVirtualMachineCapabilityRequest, + resolvedPlan: DoryResolvedMachinePlan ) { self.machineID = machineID self.definitionRevision = definitionRevision self.planRevision = planRevision self.bootMediaReference = bootMediaReference self.exactCapabilityRequest = exactCapabilityRequest + self.resolvedPlan = resolvedPlan } } diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 365c8039..0cc83325 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -1247,7 +1247,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { guard (1...256).contains(bytes.count) else { return false } return bytes.allSatisfy { byte in isAlphaNumeric(byte) || byte == 45 || byte == 46 || byte == 47 - || byte == 58 || byte == 64 || byte == 95 + || byte == 44 || byte == 58 || byte == 64 || byte == 95 } } diff --git a/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift b/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift index 3cd34039..b60af561 100644 --- a/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift +++ b/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift @@ -67,4 +67,34 @@ public enum DorydXPCSecurity { } return team } + + public static func isProductionDaemonIdentity( + teamIdentifier: String?, + signingIdentifier: String? + ) -> Bool { + teamIdentifier == productionTeamID && signingIdentifier == "doryd" + } + + /// Checks the complete signed daemon requirement, not only team membership. This prevents a + /// different binary signed by Dory's team from constructing production VM trust authority. + public static func currentProcessSatisfiesProductionDaemonRequirement() -> Bool { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else { + return false + } + var requirement: SecRequirement? + guard SecRequirementCreateWithString( + productionDaemonRequirement as CFString, + SecCSFlags(), + &requirement + ) == errSecSuccess, + let requirement else { + return false + } + return SecCodeCheckValidity( + code, + SecCSFlags(rawValue: kSecCSCheckAllArchitectures), + requirement + ) == errSecSuccess + } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 5ed13403..7b60e8bc 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1029,6 +1029,11 @@ public final class MachineManager: @unchecked Sendable { resolved, planStore: planStore ) + guard let preSpawnAuthorization = resolved.preSpawnAuthorization else { + throw MachineManagerError.persistence( + "resolved launch is missing final pre-spawn authorization" + ) + } let runtimeIdentity = try DoryMachineRuntimeIdentity( resolvedPlan: resolved.resolvedPlan, planSHA256: resolved.resolvedPlanSHA256 @@ -1045,7 +1050,8 @@ public final class MachineManager: @unchecked Sendable { runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, runtimeComponents: resolved.resolvedPlan.components, planRevision: resolved.resolvedPlan.planRevision, - planSHA256: resolved.resolvedPlanSHA256 + planSHA256: resolved.resolvedPlanSHA256, + preSpawnAuthorization: preSpawnAuthorization ) defer { pendingResolvedStart = nil } let operation = registry.start(resolved.backendPlan) @@ -1173,7 +1179,8 @@ public final class MachineManager: @unchecked Sendable { pendingResolvedStart = nil return MachineBackendRuntimeObservation(try spawnPreparedMachine( authorization.machine, - launchBinding: binding + launchBinding: binding, + preSpawnAuthorization: authorization.preSpawnAuthorization )) } @@ -1348,7 +1355,8 @@ public final class MachineManager: @unchecked Sendable { private func spawnPreparedMachine( _ preparedMachine: DoryMachineConfiguration, - launchBinding: MachineBackendLaunchBinding? + launchBinding: MachineBackendLaunchBinding?, + preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil ) throws -> DoryMachineStatus { lock.lock() guard var entry = machines[preparedMachine.id] else { @@ -1383,6 +1391,17 @@ public final class MachineManager: @unchecked Sendable { } let processConfiguration: HvProcessConfiguration do { + // This single-use daemon-owned check deliberately sits after all persisted + // machine/plan validation and immediately before any path-derived launch arguments + // are read. Failure consumes the token and cannot reach processStarter. + if launchBinding != nil { + guard let preSpawnAuthorization else { + throw MachineManagerError.persistence( + "resolved launch is missing final pre-spawn authorization" + ) + } + try preSpawnAuthorization.authorize() + } processConfiguration = try self.processConfiguration( for: entry.configuration, handoffPath: handoffPath, @@ -6170,6 +6189,7 @@ private struct PendingResolvedMachineStart { var runtimeComponents: [DoryResolvedBackendComponentEvidence] var planRevision: UInt64 var planSHA256: String + var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization } private struct MachineEntry { diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index cb9ad3dc..ea7e74c7 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -14,17 +14,18 @@ _ = signal(SIGPIPE, SIG_IGN) let env = ProcessInfo.processInfo.environment let dorydEnvironment = DorydEnvironment(values: env) let dataDriveSelectionAuthority: DoryDataDriveSelectionAuthority +let dataDrive: DoryDataDrive do { let requestedDrive = try dorydEnvironment.dataDriveConfiguration() let selectionStore = try DoryDataDriveSelectionStore(home: dorydEnvironment.home) dataDriveSelectionAuthority = try selectionStore.acquireAuthority() - let drive = try selectionStore.prepareSelection( + dataDrive = try selectionStore.prepareSelection( requestedRoot: requestedDrive.root, authority: dataDriveSelectionAuthority ) - let driveID = try drive.readManifest().id.uuidString.lowercased() + let driveID = try dataDrive.readManifest().id.uuidString.lowercased() FileHandle.standardError.write( - Data("doryd: data drive \(driveID) ready at \(drive.root)\n".utf8) + Data("doryd: data drive \(driveID) ready at \(dataDrive.root)\n".utf8) ) } catch { FileHandle.standardError.write(Data("doryd: data drive unavailable: \(error)\n".utf8)) @@ -63,17 +64,38 @@ let idleController = IdleController() let dockerTier = dorydEnvironment.dockerTierConfiguration().map { DockerTier(configuration: $0, idleController: idleController) } -let machineManager = dorydEnvironment.machineManagerConfiguration().map { configuration in - // Transitional and explicit: existing machines remain launchable until the production trusted - // inventory collector is installed. MachineManager's resolved-plan policy fails closed and is - // not enabled here without that evidence source. - FileHandle.standardError.write(Data( - "doryd: VM launch policy legacyCompatibility (trusted resolved-plan inventory unavailable)\n".utf8 - )) - return MachineManager( - configuration: configuration, - launchPolicy: .legacyCompatibility +let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in + let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve( + store: DoryComponentStore(drive: dataDrive), + machineConfiguration: configuration ) + switch readiness { + case let .ready(context): + FileHandle.standardError.write(Data( + "doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8 + )) + return context.machineManager + case let .unavailable(reason): + guard reason.permitsLegacyCompatibilityMigration else { + // Integrity and runtime verification failures disable VM launch. Falling through to + // legacy here would execute the same helper that production trust just rejected. + FileHandle.standardError.write(Data( + "doryd: VM launch unavailable " + .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8 + )) + return nil + } + // Explicit migration window: no catalog or a signature-verified schema-v1 catalog has no + // qualification authority yet, so the existing compatibility path stays labeled. + FileHandle.standardError.write(Data( + "doryd: VM launch policy legacyCompatibility " + .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8 + )) + return MachineManager( + configuration: configuration, + launchPolicy: .legacyCompatibility + ) + } } let sandboxTTLReconciler = machineManager.map { manager in SandboxTTLReconciler( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift new file mode 100644 index 00000000..0d2efe50 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -0,0 +1,861 @@ +import CryptoKit +@testable import DorydKit +import DoryOperations +import Foundation +import Testing + +@Suite("Production VM trust composition") +struct DoryDaemonVirtualMachineProductionTrustTests { + @Test("missing catalog remains explicitly unavailable") + func missingCatalog() throws { + let fixture = try ProductionTrustFixture(installCatalog: false) + defer { fixture.cleanup() } + + let result = fixture.factory.resolve( + store: fixture.store, + machineConfiguration: fixture.machineConfiguration, + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .catalogUnavailable) + #expect(reason.permitsLegacyCompatibilityMigration) + } + + @Test("schema-v1 catalog is not promoted to resolved-plan trust") + func schemaV1FailsClosed() throws { + let fixture = try ProductionTrustFixture(catalogSchemaVersion: 1) + defer { fixture.cleanup() } + + let result = fixture.resolve() + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .catalogSchemaV1Migration) + #expect(reason.permitsLegacyCompatibilityMigration) + } + + @Test("tampered cached signature is rejected before helper probing") + func tamperedSignature() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + try Data("invalid-signature\n".utf8).write( + to: URL(fileURLWithPath: fixture.store.root + "/catalog.sig") + ) + + let result = fixture.resolve() + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .qualificationAuthorityUnavailable) + } + + @Test("tampered installed qualification manifest is rejected") + func tamperedManifest() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + let path = try #require(fixture.store.assetPath( + component: .linuxMachines, + path: fixture.manifestPath + )) + try Data("{}\n".utf8).write(to: URL(fileURLWithPath: path)) + + let result = fixture.resolve() + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .qualificationAuthorityUnavailable) + } + + @Test("developer-signed daemon cannot enable resolved-plan production mode") + func developerDaemonRejected() throws { + let fixture = try ProductionTrustFixture(daemonTeamIdentifier: nil) + defer { fixture.cleanup() } + + let result = fixture.resolve() + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .daemonSignatureUnavailable) + } + + @Test("stale or unverified helper build blocks readiness") + func helperBuildRejected() throws { + let fixture = try ProductionTrustFixture(runtimeVerificationFails: true) + defer { fixture.cleanup() } + + let result = fixture.resolve() + guard case let .unavailable(reason) = result else { + Issue.record("Expected unavailable readiness") + return + } + #expect(reason.code == .backendRuntimeUnavailable) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("only explicit catalog migration states permit legacy compatibility") + func legacyMigrationClassification() { + for code in [ + DoryDaemonVirtualMachineProductionTrustReadinessCode.catalogUnavailable, + .catalogSchemaV1Migration, + .catalogSchemaUnsupported, + .trustFloorViolated, + .planningTransactionUnavailable, + .qualificationAuthorityUnavailable, + .daemonSignatureUnavailable, + .hostFactsUnavailable, + .backendRuntimeUnavailable, + .resourceAuthorityUnavailable, + .compositionFailed, + ] { + let unavailable = DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: "fixture" + ) + #expect(!unavailable.permitsLegacyCompatibilityMigration) + } + } + + @Test("verified v2 cannot activate resolved mode without production plan publication") + func v2WithoutPlanningTransactionStaysMigrationOnly() throws { + let fixture = try ProductionTrustFixture(planningTransactionAvailable: false) + defer { fixture.cleanup() } + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected unavailable production planning") + return + } + #expect(reason.code == .planningTransactionUnavailable) + #expect(reason.permitsLegacyCompatibilityMigration) + } + + @Test("accepted production trust cannot downgrade after catalog removal") + func trustFloorRejectsCatalogRemoval() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case .ready = fixture.resolve() else { + Issue.record("Expected initial production activation") + return + } + try FileManager.default.removeItem(atPath: fixture.store.root + "/catalog.json") + try FileManager.default.removeItem(atPath: fixture.store.root + "/catalog.sig") + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected trust-floor rejection") + return + } + #expect(reason.code == .trustFloorViolated) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("accepted production trust rejects an older signed v2 catalog") + func trustFloorRejectsSignedCatalogRollback() throws { + let fixture = try ProductionTrustFixture( + catalogReleaseVersion: "2.0.0", + catalogGeneratedAt: "2026-08-20T12:00:00.000Z" + ) + defer { fixture.cleanup() } + guard case .ready = fixture.resolve() else { + Issue.record("Expected initial production activation") + return + } + try fixture.installCatalogFixture( + schemaVersion: 2, + releaseVersion: "1.0.0", + generatedAt: "2026-08-19T12:00:00.000Z" + ) + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected signed catalog rollback rejection") + return + } + #expect(reason.code == .trustFloorViolated) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("trust-floor directory sync failure cannot return ready") + func trustFloorDirectorySyncFailureFailsClosed() throws { + let fixture = try ProductionTrustFixture(trustFloorDirectorySyncFails: true) + defer { fixture.cleanup() } + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected trust-floor persistence failure") + return + } + #expect(reason.code == .compositionFailed) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("uninspectable VM state never permits legacy migration") + func invalidStateDirectoryFailsClosed() throws { + let fixture = try ProductionTrustFixture(installCatalog: false) + defer { fixture.cleanup() } + try FileManager.default.removeItem( + atPath: fixture.machineConfiguration.stateDirectory + ) + try Data("not-a-directory".utf8).write(to: URL( + fileURLWithPath: fixture.machineConfiguration.stateDirectory + )) + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected VM state inspection failure") + return + } + #expect(reason.code == .trustFloorViolated) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("missing catalog cannot downgrade existing resolved-plan state") + func resolvedPlanStateRejectsMissingCatalogDowngrade() throws { + let fixture = try ProductionTrustFixture(installCatalog: false) + defer { fixture.cleanup() } + let machine = URL(fileURLWithPath: fixture.machineConfiguration.stateDirectory) + .appendingPathComponent("resolved-machine", isDirectory: true) + try FileManager.default.createDirectory(at: machine, withIntermediateDirectories: true) + try Data("{}\n".utf8).write(to: machine.appendingPathComponent( + DoryResolvedMachinePlanRepository.recordFileName + )) + + guard case let .unavailable(reason) = fixture.resolve() else { + Issue.record("Expected trust-floor rejection") + return + } + #expect(reason.code == .trustFloorViolated) + #expect(!reason.permitsLegacyCompatibilityMigration) + } + + @Test("same-team binary with wrong signing identifier is not doryd") + func exactDaemonSigningIdentity() { + #expect(DorydXPCSecurity.isProductionDaemonIdentity( + teamIdentifier: DorydXPCSecurity.productionTeamID, + signingIdentifier: "doryd" + )) + #expect(!DorydXPCSecurity.isProductionDaemonIdentity( + teamIdentifier: DorydXPCSecurity.productionTeamID, + signingIdentifier: "Dory" + )) + } + + @Test("pre-spawn authorization is single use and consumes failures") + func preSpawnAuthorizationIsSingleUse() throws { + let counter = ProductionCallCounter() + let authorization = DoryDaemonVirtualMachinePreSpawnAuthorization { + counter.increment() + } + try authorization.authorize() + #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) { + try authorization.authorize() + } + #expect(counter.value == 1) + + let failing = DoryDaemonVirtualMachinePreSpawnAuthorization { + throw ProductionTrustFixtureError.runtimeRejected + } + #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) { + try failing.authorize() + } + #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) { + try failing.authorize() + } + } + + @Test("start inventory refreshes volatile host resources") + func startInventoryRefreshesHostResources() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .ready(context) = fixture.resolve() else { + Issue.record("Expected ready test composition") + return + } + let request = try fixture.makeBoundStartRequest() + _ = try context.inventory.startInventory(for: request) + + var changed = fixture.host + changed = DoryDaemonProductionHostObservation( + hardwareModelIdentifier: changed.hardwareModelIdentifier, + operatingSystemBuild: changed.operatingSystemBuild, + macOSMajorVersion: changed.macOSMajorVersion, + virtualizationFrameworkAvailable: changed.virtualizationFrameworkAvailable, + hypervisorFrameworkAvailable: changed.hypervisorFrameworkAvailable, + metalAvailable: changed.metalAvailable, + resources: DoryVMHostResources( + logicalCPUCount: changed.resources.logicalCPUCount, + physicalMemoryBytes: changed.resources.physicalMemoryBytes, + freeStorageBytes: changed.resources.freeStorageBytes - 1 + ) + ) + fixture.hostState.set(changed) + #expect(throws: DoryDaemonProductionTrustInventoryError.self) { + _ = try context.inventory.startInventory(for: request) + } + } + + @Test("signed v2 authority composes exact resolved-plan infrastructure") + func signedV2Ready() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + + let result = fixture.resolve() + guard case let .ready(context) = result else { + Issue.record("Expected ready production trust") + return + } + #expect(context.backendRuntimeBuildIdentifiers[.doryHypervisor] + == fixture.runtimeBuildIdentifier) + #expect(context.backendRuntimeBuildIdentifiers[.appleVirtualizationFramework] + == fixture.runtimeBuildIdentifier) + } + + @Test("production inventory never fabricates an admission during planning") + func planningFailsUntilAtomicAdmissionBindingExists() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .ready(context) = fixture.resolve() else { + Issue.record("Expected ready production trust") + return + } + let reference = DoryVMResolverReference( + namespace: "artifact", + identifier: "qualified-linux-boot" + ) + #expect(throws: DoryDaemonProductionTrustInventoryError.self) { + _ = try context.inventory.planningInventory(for: + DoryDaemonVirtualMachineInventoryRequest( + machineID: "qualified-linux", + definitionRevision: 1, + guest: fixture.guest, + bootMedia: DoryVMBootMediaReference( + id: "system", + role: .system, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifact: reference, + removable: false + ), + resources: DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 2 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ), + devices: .minimumBootable, + acceptableGraphics: [.none], + virtualHardwareABIVersion: 1 + ) + ) + } + } + + @Test("installed Linux boot verification hashes the embedded kernel and initrd") + func installedBootBundleContentVerification() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-boot-content-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let path = root.appendingPathComponent("boot.bundle").path + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("kernel-payload".utf8), + initrd: Data("initrd-payload".utf8), + kernelISOPath: "/boot/kernel", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: path + ) + #expect(try DoryInstalledLinuxBootBundle.verifyContents(atPath: path).rootDevice + == "/dev/vda2") + + var bytes = try Data(contentsOf: URL(fileURLWithPath: path)) + bytes[bytes.index(before: bytes.endIndex)] ^= 0xff + try bytes.write(to: URL(fileURLWithPath: path)) + #expect(throws: DoryInstalledLinuxBootBundleError.self) { + _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: path) + } + } +} + +private enum ProductionTrustFixtureError: Error { + case runtimeRejected +} + +private final class ProductionCallCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } +} + +private final class ProductionHostState: @unchecked Sendable { + private let lock = NSLock() + private var observation: DoryDaemonProductionHostObservation + + init(_ observation: DoryDaemonProductionHostObservation) { + self.observation = observation + } + + func get() -> DoryDaemonProductionHostObservation { + lock.lock() + defer { lock.unlock() } + return observation + } + + func set(_ observation: DoryDaemonProductionHostObservation) { + lock.lock() + self.observation = observation + lock.unlock() + } +} + +private final class ProductionTrustFixture: @unchecked Sendable { + let root: URL + let drive: DoryDataDrive + let store: DoryComponentStore + let machineConfiguration: MachineManagerConfiguration + let appVersion = "1.0.0" + let manifestPath = "vm-qualifications.json" + let privateKey = Curve25519.Signing.PrivateKey() + let helperDigest: String + let runtimeBuildIdentifier: String + let mediaPath: String + let mediaDigest: String + let mediaReference = DoryVMResolverReference( + namespace: "artifact", + identifier: "qualified-linux-boot" + ) + let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) + let host = DoryDaemonProductionHostObservation( + hardwareModelIdentifier: "Mac16,1", + operatingSystemBuild: "26A5406c", + macOSMajorVersion: 26, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + metalAvailable: true, + resources: DoryVMHostResources( + logicalCPUCount: 12, + physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + freeStorageBytes: 512 * 1_024 * 1_024 * 1_024 + ) + ) + let hostState: ProductionHostState + var factory: DoryDaemonVirtualMachineProductionTrustFactory! + var publicKey: String { privateKey.publicKey.rawRepresentation.base64EncodedString() } + + init( + catalogSchemaVersion: Int = 2, + catalogReleaseVersion: String = "1.0.0", + catalogGeneratedAt: String = "2026-08-20T12:00:00.000Z", + installCatalog: Bool = true, + daemonTeamIdentifier: String? = DorydXPCSecurity.productionTeamID, + runtimeVerificationFails: Bool = false, + planningTransactionAvailable: Bool = true, + trustFloorDirectorySyncFails: Bool = false + ) throws { + helperDigest = Self.digest(Data("verified-helper".utf8)) + hostState = ProductionHostState(host) + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-production-trust-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + drive = try DoryDataDrive(home: root.path) + try drive.prepare() + store = DoryComponentStore(drive: drive) + try store.prepare() + let state = root.appendingPathComponent("machine-state", isDirectory: true) + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: state.path) + let vz = root.appendingPathComponent("dory-vmm").path + let raw = root.appendingPathComponent("dory-hv").path + for path in [vz, raw] { + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path) + } + machineConfiguration = MachineManagerConfiguration( + vmmExecutablePath: vz, + acceleratedDesktopExecutablePath: raw, + stateDirectory: state.path, + runtimeDirectory: root.appendingPathComponent("runtime").path, + requiresReadyHandoff: false + ) + runtimeBuildIdentifier = "sha256:\(helperDigest)" + mediaPath = root.appendingPathComponent("qualified-linux.boot").path + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("qualified-kernel".utf8), + initrd: Data("qualified-initrd".utf8), + kernelISOPath: "/boot/kernel", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: mediaPath + ) + mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath) + _ = try DoryVirtualMachineArtifactAuthority( + root: state.path + "/.artifact-authority" + ).publishImmutable( + reference: mediaReference, + path: mediaPath, + kind: .installedLinuxBootBundle, + source: .bundledByDory + ) + + if installCatalog { + try installCatalogFixture( + schemaVersion: catalogSchemaVersion, + releaseVersion: catalogReleaseVersion, + generatedAt: catalogGeneratedAt + ) + } + let digest = helperDigest + let build = runtimeBuildIdentifier + let observedHostState = hostState + factory = DoryDaemonVirtualMachineProductionTrustFactory( + authorityResolver: { store, key, architecture, appVersion in + try DoryVirtualMachineQualificationAuthorityResolver.resolve( + store: store, + publicKey: key, + expectedArchitecture: architecture, + appVersion: appVersion + ) + }, + runtimeVerifier: { path, descriptor, component in + if runtimeVerificationFails { throw ProductionTrustFixtureError.runtimeRejected } + return DoryDaemonVerifiedBackendRuntime( + descriptor: descriptor, + executablePath: path, + runtimeBuildIdentifier: build, + components: [DoryVirtualMachineQualifiedComponent( + componentIdentifier: component, + buildIdentifier: build, + artifactSHA256: digest + )] + ) + }, + hostProbe: { _ in observedHostState.get() }, + daemonIdentityVerifier: { + daemonTeamIdentifier == DorydXPCSecurity.productionTeamID + }, + planningTransactionAvailable: { planningTransactionAvailable }, + synchronizeTrustFloorDirectory: { descriptor in + !trustFloorDirectorySyncFails && fsync(descriptor) == 0 + } + ) + } + + func resolve() -> DoryDaemonVirtualMachineProductionTrustReadiness { + factory.resolve( + store: store, + machineConfiguration: machineConfiguration, + appVersion: appVersion, + publicKey: publicKey, + expectedArchitecture: "arm64" + ) + } + + func cleanup() { try? FileManager.default.removeItem(at: root) } + + func makeBoundStartRequest() throws -> DoryDaemonVirtualMachineStartInventoryRequest { + let resources = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 4 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ) + let definitionSHA = Self.digest(Data("definition".utf8)) + let binding = DoryVirtualMachineResourceAdmissionPlanBinding( + machineID: "qualified-linux", + definitionRevision: 1, + definitionSHA256: definitionSHA, + plannedPlanRevision: 1 + ) + let ledger = DoryVirtualMachineResourceAdmissionLedger( + root: machineConfiguration.stateDirectory + "/.resource-admissions" + ) + let lease = try ledger.reserveStarting( + binding: binding, + hostFacts: host.resources, + workload: .desktop, + resources: resources + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: mediaDigest + ) + let capabilityRequest = DoryVirtualMachineCapabilityRequest( + guest: guest, + bootMedia: media, + backend: .doryHypervisor, + graphics: .none, + devices: devices + ) + let authority = try DoryVirtualMachineQualificationAuthorityResolver.resolve( + store: store, + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: appVersion + ) + let runtimeComponent = DoryVirtualMachineQualifiedComponent( + componentIdentifier: "dory-hv", + buildIdentifier: runtimeBuildIdentifier, + artifactSHA256: helperDigest + ) + let qualification = try authority.resolve( + request: capabilityRequest, + backendImplementationIdentifier: + RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + installedComponents: [runtimeComponent] + ) + let hostFacts = DoryAppleSiliconHostFacts( + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, + networkAvailable: false, + displayAvailable: false, + inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: runtimeBuildIdentifier, + virtualizationFrameworkAdapterBuildID: "", + qemuRuntimeBuildID: "" + ) + ) + let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate( + capabilityRequest, + host: hostFacts, + trustedGuestImageGraphicsQualification: qualification.graphics, + trustedRuntimeQualification: qualification.runtime + ) + #expect(descriptor.availability.isUsable) + let plan = DoryResolvedMachinePlan( + machineID: binding.machineID, + definitionRevision: binding.definitionRevision, + definitionSHA256: definitionSHA, + planRevision: binding.plannedPlanRevision, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000, + guest: guest, + backend: .doryHypervisor, + backendImplementationIdentifier: + RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + virtualHardwareABIVersion: 1, + bootMedia: DoryResolvedMachineBootMedia( + resolverReference: mediaReference, + media: media + ), + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: runtimeBuildIdentifier, + artifactSHA256: helperDigest + )], + devices: devices, + graphics: .none, + supportTier: .supported, + selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( + disposition: .primary, + plannerRequest: DoryVirtualMachineBackendPlanRequest( + guest: guest, + bootMedia: media, + acceptableGraphics: [.none], + devices: devices, + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ), + selectedEvaluationIndex: 0, + rejectedCandidates: [] + ), + qualificationEvidence: DoryResolvedMachineQualificationEvidence( + graphics: descriptor.graphicsQualificationEvidence, + runtime: descriptor.runtimeQualificationEvidence + ), + resourceAdmission: lease.evidence, + hostQualification: DoryResolvedHostQualificationEvidence( + qualificationIdentity: qualification.record.qualificationIdentity, + qualificationReportSHA256: Self.digest( + try Self.canonicalData(qualification.record) + ), + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification", + qualifierVersion: 1 + ) + ) + #expect(plan.validate().isEmpty) + _ = try ledger.bind( + leaseID: lease.leaseID, + to: plan, + expectedLeaseRevision: lease.leaseRevision + ) + return DoryDaemonVirtualMachineStartInventoryRequest( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + planRevision: plan.planRevision, + bootMediaReference: mediaReference, + exactCapabilityRequest: capabilityRequest, + resolvedPlan: plan + ) + } + + func installCatalogFixture( + schemaVersion: Int, + releaseVersion: String, + generatedAt: String + ) throws { + let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let components = ["dory-hv", "dory-vmm"].map { + DoryVirtualMachineQualifiedComponent( + componentIdentifier: $0, + buildIdentifier: runtimeBuildIdentifier, + artifactSHA256: helperDigest + ) + } + let records = [ + (DoryVirtualizationBackendIdentity.doryHypervisor, + RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, + "dory-hv"), + (DoryVirtualizationBackendIdentity.appleVirtualizationFramework, + VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier, + "dory-vmm"), + ].map { backend, implementation, component in + DoryVirtualMachineQualificationRecord( + qualificationIdentity: "\(component)-qualification", + guest: guest, + bootMediaKind: .installedLinuxBootBundle, + bootMediaSource: .bundledByDory, + immutableArtifactSHA256: mediaDigest, + backend: backend, + backendImplementationIdentifier: implementation, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + components: [components.first { $0.componentIdentifier == component }!] + ) + } + let manifest = DoryVirtualMachineQualificationManifest( + manifestIdentity: "production-vm-qualification-1", + catalogReleaseVersion: releaseVersion, + architecture: "arm64", + signingKeyID: signingKeyID, + records: records + ) + let manifestData = try Self.encoded(manifest) + let asset = DoryComponentAsset( + path: manifestPath, + url: "https://example.invalid/qualification.json", + downloadBytes: UInt64(manifestData.count), + installedBytes: UInt64(manifestData.count), + sha256: Self.digest(manifestData), + installedSHA256: Self.digest(manifestData) + ) + let release = DoryComponentRelease( + id: .linuxMachines, + version: releaseVersion, + displayName: "Linux Machines", + summary: "Qualified VM runtime", + dependencies: [.dockerCore], + downloadBytes: UInt64(manifestData.count), + installedBytes: UInt64(manifestData.count), + assets: [asset] + ) + let core = DoryComponentRelease( + id: .dockerCore, + version: releaseVersion, + displayName: "Docker Core", + summary: "Bundled core", + dependencies: [], + downloadBytes: 1, + installedBytes: 1, + assets: [] + ) + let catalog = DoryComponentCatalog( + schemaVersion: schemaVersion, + releaseVersion: releaseVersion, + generatedAt: generatedAt, + minimumAppVersion: appVersion, + architecture: "arm64", + components: [core, release], + virtualMachineQualification: schemaVersion == 2 + ? DoryComponentVirtualMachineQualificationAsset( + component: .linuxMachines, + path: manifestPath, + manifestIdentity: manifest.manifestIdentity, + manifestFormatVersion: manifest.schemaVersion, + signingKeyID: signingKeyID + ) : nil + ) + let catalogData = try Self.encoded(catalog) + let signature = try privateKey.signature(for: catalogData).base64EncodedString() + _ = try store.cacheCatalog( + data: catalogData, + signature: signature, + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: appVersion + ) + let source = root.appendingPathComponent("qualification.json") + try manifestData.write(to: source) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: source.path) + _ = try store.install( + release, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + downloadedAssets: [manifestPath: source.path] + ) + } + + private static func encoded(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + Data("\n".utf8) + } + + private static func canonicalData(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index e1ae95d2..564dd2eb 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -380,6 +380,84 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("final media mutation is rejected by single-use authorization before process starter") + func preSpawnArtifactMutationIsRejected() throws { + try withHarness("pre-spawn-media-mutation") { manager, starter, state in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let mediaPath = state + "/resolved-media" + try Data("qualified-media".utf8).write( + to: URL(fileURLWithPath: mediaPath), + options: .atomic + ) + let expectedSHA256 = SHA256.hash( + data: try Data(contentsOf: URL(fileURLWithPath: mediaPath)) + ).map { String(format: "%02x", $0) }.joined() + let resolution = try exactResolution( + request: request, + preSpawnRevalidation: { + let current = try Data(contentsOf: URL(fileURLWithPath: mediaPath)) + let currentSHA256 = SHA256.hash(data: current) + .map { String(format: "%02x", $0) }.joined() + guard currentSHA256 == expectedSHA256 else { + throw DoryDaemonVirtualMachinePreSpawnAuthorizationError + .revalidationFailed + } + } + ) + plans.set(resolution.resolvedPlan) + try Data("mutated-after-evidence".utf8).write( + to: URL(fileURLWithPath: mediaPath), + options: .atomic + ) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + #expect(manager.status(id: "dev")?.state == .created) + } + } + + @Test("required resolved launch rejects a missing pre-spawn authorization") + func missingPreSpawnAuthorizationFailsClosed() throws { + try withHarness("missing-pre-spawn") { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + let resolver = ClosureLaunchResolver { request in + var resolution = try exactResolution(request: request) + plans.set(resolution.resolvedPlan) + resolution.preSpawnAuthorization = nil + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: try rawRegistry(operations: operations), + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + } + } + @Test("adapter-issued executable is launched instead of manager backend lookup") func adapterExecutableBindingIsUsed() throws { try withHarness( @@ -482,7 +560,8 @@ struct MachineManagerResolvedPlanIntegrationTests { private func exactResolution( request: DoryDaemonVirtualMachineLaunchPlanRequest, - componentSHA256: String? = nil + componentSHA256: String? = nil, + preSpawnRevalidation: @escaping @Sendable () throws -> Void = {} ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution { let definitionDigest = SHA256.hash(data: request.canonicalDefinitionData) .map { String(format: "%02x", $0) }.joined() @@ -614,6 +693,9 @@ struct MachineManagerResolvedPlanIntegrationTests { backend: RawHVLinuxMachineBackend.backendDescriptor, machine: request.machine, capability: capability + ), + preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization( + revalidate: preSpawnRevalidation ) ) } From f2a652778f74f3f6de915387834005d1898b2cfb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 16:02:54 +0000 Subject: [PATCH 031/338] feat(vm): persist typed machine settings --- Dory/Features/Machines/MachinesView.swift | 50 +- Dory/Features/Sheets/NewMachineSheet.swift | 38 +- Dory/Models/AppStore.swift | 63 +- Dory/Runtime/Doryd/DorydClient.swift | 280 ++++++- Dory/Runtime/Machines/MachineService.swift | 3 + DoryTests/DorydClientTests.swift | 343 ++++++++- DoryTests/NewMachineSettingsTests.swift | 44 +- .../DoryMachineTypedWriteAuthority.swift | 689 ++++++++++++++++++ .../Sources/DorydKit/DorydService.swift | 46 +- .../Sources/DorydKit/MachineManager.swift | 12 + dory-core-swift/Sources/dorydctl/main.swift | 69 +- .../DoryMachineTypedWriteAuthorityTests.swift | 285 ++++++++ .../DorydKitTests/DorydServiceTests.swift | 173 ++++- 13 files changed, 1915 insertions(+), 180 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 9bba664b..45cbc1fa 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -365,7 +365,7 @@ private struct MachineEditSheet: View { @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic - @State private var environment: [String: String] = [:] + @State private var typedSettings = DorydMachineTypedSettings() private struct MountRow: Identifiable, Hashable { let id = UUID() @@ -406,11 +406,22 @@ private struct MachineEditSheet: View { memoryGB = max(1, min(16, settings.memoryMB.map { $0 / 1024 } ?? 4)) address = settings.address ?? "" displayMode = settings.displayMode - environment = settings.env - guestUsername = settings.env["DORY_GUEST_USER"] ?? "dory" - clipboardPolicy = DoryDesktopClipboardPolicy(environment: settings.env) - runtimePreference = (try? DoryDesktopVMMPreference(environment: settings.env)) ?? .automatic - graphicsPreference = (try? DoryDesktopGraphicsPreference(environment: settings.env)) ?? .automatic + typedSettings = settings.virtualMachineSettings ?? DorydMachineTypedSettings( + legacyEnvironment: settings.env, + displayMode: settings.displayMode + ) + // Keep an unrepresentable legacy username visible and invalid until the user explicitly + // corrects that field. Hiding it behind a default would turn an unrelated edit into a + // destructive rewrite. + guestUsername = settings.env[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] + ?? typedSettings.guestIdentityIntent.account?.username + ?? "dory" + clipboardPolicy = typedSettings.clipboardPolicy.flatMap { + guard $0.text == $0.image, $0.files == .off else { return nil } + return DoryDesktopClipboardPolicy(rawValue: $0.text.rawValue) + } ?? .bidirectional + runtimePreference = typedSettings.runtimePreference ?? .automatic + graphicsPreference = typedSettings.graphicsPreference ?? .automatic mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) } } @@ -688,7 +699,7 @@ private struct MachineEditSheet: View { return MountPair(host: host, guest: guest, readOnly: row.readOnly) } if displayMode == .desktop, machine.bootMode != .efi { - let previousUsername = environment["DORY_GUEST_USER"] ?? "dory" + let previousUsername = typedSettings.guestIdentityIntent.account?.username ?? "dory" if previousUsername != normalizedGuestUsername { let previousHome = "/home/\(previousUsername)" let updatedHome = "/home/\(normalizedGuestUsername)" @@ -703,18 +714,29 @@ private struct MachineEditSheet: View { ) } } - environment["DORY_GUEST_USER"] = normalizedGuestUsername - environment["DORY_GUEST_UID"] = environment["DORY_GUEST_UID"] ?? String(getuid()) - environment[DoryDesktopClipboardPolicy.environmentKey] = clipboardPolicy.rawValue - environment[DoryDesktopVMMPreference.environmentKey] = runtimePreference.rawValue - environment[DoryDesktopGraphicsPreference.environmentKey] = graphicsPreference.rawValue - environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey) + typedSettings.guestIdentityIntent.account = DoryVMGuestAccountIntent( + username: normalizedGuestUsername, + numericUserID: typedSettings.guestIdentityIntent.account?.numericUserID + ) + if typedSettings.clipboardPolicy != nil || clipboardPolicy != .bidirectional { + typedSettings.clipboardPolicy = .legacyDesktop( + DoryVMClipboardDirection(rawValue: clipboardPolicy.rawValue) + ?? .bidirectional + ) + } + if typedSettings.runtimePreference != nil || runtimePreference != .automatic { + typedSettings.runtimePreference = runtimePreference + } + if typedSettings.graphicsPreference != nil || graphicsPreference != .automatic { + typedSettings.graphicsPreference = graphicsPreference + } } let settings = MachineSettings( cpus: cpus, memoryMB: memoryGB * 1024, mounts: mounts, - env: environment, + env: [:], + virtualMachineSettings: machine.bootMode == .efi ? nil : typedSettings, address: address.trimmingCharacters(in: .whitespacesAndNewlines), displayMode: displayMode, bootMode: machine.bootMode diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 2593fab4..b5cea12b 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -871,23 +871,34 @@ struct NewMachineSheet: View { guestUsername: String = "dory", guestUID: uid_t = getuid() ) -> MachineSettings { - var environment: [String: String] = [:] + let typedSettings: DorydMachineTypedSettings? if displayMode == .desktop { - environment["DORY_GUEST_USER"] = guestUsername - environment["DORY_GUEST_UID"] = String(guestUID) - environment["DORY_DESKTOP_DISTRO"] = desktopDistro.rawValue - environment["DORY_DESKTOP_NAME"] = desktopDistro.displayName - environment["DORY_DESKTOP_VERSION"] = desktopDistro.version - environment["DORY_DESKTOP_ENVIRONMENT"] = desktopDistro.desktopName - environment[DoryDesktopClipboardPolicy.environmentKey] = DoryDesktopClipboardPolicy.bidirectional.rawValue - environment[DoryDesktopVMMPreference.environmentKey] = DoryDesktopVMMPreference.automatic.rawValue - environment[DoryDesktopGraphicsPreference.environmentKey] = DoryDesktopGraphicsPreference.automatic.rawValue + typedSettings = DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent( + username: guestUsername, + numericUserID: UInt32(guestUID) + ), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: desktopDistro.rawValue, + displayName: desktopDistro.displayName, + version: desktopDistro.version, + desktopEnvironment: desktopDistro.desktopName + ) + ), + clipboardPolicy: .legacyDesktop(.bidirectional), + runtimePreference: .automatic, + graphicsPreference: .automatic + ) + } else { + typedSettings = nil } return MachineSettings( cpus: cpus, memoryMB: memoryGB * 1024, mounts: mounts, - env: environment, + env: [:], + virtualMachineSettings: typedSettings, address: address, displayMode: displayMode ) @@ -930,7 +941,10 @@ struct NewMachineSheet: View { settings.bootMode = .efi settings.installerISOPath = installerISOPath settings.diskSizeGB = diskSizeGB - settings.env = ["DORY_CUSTOM_LINUX": "1"] + // EFI boot/media authority is explicit. It must never be inferred from a reserved + // environment marker or carry managed-desktop provisioning intent. + settings.env = [:] + settings.virtualMachineSettings = nil } return settings } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index b6362224..751e2420 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5079,6 +5079,10 @@ final class AppStore { memoryMB: status.memoryMB.flatMap { Int(exactly: $0) }, mounts: status.shares.map(Self.mountPair(fromDoryd:)), env: status.environment, + virtualMachineSettings: DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ), address: status.configuredAddress, displayMode: status.displayMode, bootMode: status.bootMode @@ -5157,8 +5161,15 @@ final class AppStore { .first { !$0.isEmpty } ?? status.state let isDesktop = status.displayMode == .desktop let isCustomLinux = status.bootMode == .efi - let desktopDistro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"]) - let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root")) + let typedSettings = DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ) + let desktopDistro = DesktopMachineDistro.resolve( + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier + ) + let guestUsername = typedSettings.guestIdentityIntent.account?.username + ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root")) return Machine( name: status.id, distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"), @@ -5453,8 +5464,16 @@ final class AppStore { var results: [DorydDesktopUpdateResult] = [] for status in desktopStatuses { - guard status.environment["DORY_CUSTOM_LINUX"] != "1" else { continue } - let distro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"]) + // EFI machines are user-provided installer workspaces. Boot mode is authoritative; + // do not depend on the legacy DORY_CUSTOM_LINUX compatibility marker. + guard status.bootMode != .efi else { continue } + let typedSettings = DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ) + let distro = DesktopMachineDistro.resolve( + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier + ) guard runtimeChanged || components.contains(distro.componentID) else { continue } guard let distroRelease = try store.installedComponent(distro.componentID) else { if components.contains(distro.componentID) { @@ -5514,6 +5533,9 @@ final class AppStore { nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings { var copy = settings if copy.env.isEmpty { copy.env = existing.env } + if copy.virtualMachineSettings == nil { + copy.virtualMachineSettings = existing.virtualMachineSettings + } if copy.identity == nil { copy.identity = existing.identity } if copy.address == nil { copy.address = existing.address } return copy @@ -5608,7 +5630,13 @@ final class AppStore { address: address, displayMode: settings.displayMode, shares: dorydShares(from: settings.mounts), - environment: sanitizedNewMachineEnvironment(settings.env) + typedSettings: settings.virtualMachineSettings + ?? (settings.bootMode == .efi + ? DorydMachineTypedSettings() + : DorydMachineTypedSettings( + legacyEnvironment: sanitizedNewMachineEnvironment(settings.env), + displayMode: settings.displayMode + )) ) } @@ -5632,7 +5660,10 @@ final class AppStore { return message } } else if settings.displayMode == .desktop { - let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) + let distro = DesktopMachineDistro.resolve( + settings.virtualMachineSettings?.guestIdentityIntent.desktop? + .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"] + ) guard AppInfo.componentAvailable(.linuxDesktop), AppInfo.componentAvailable(distro.componentID) else { let message = "Install the Linux Desktop runtime and \(distro.displayName) in Components first." @@ -5719,7 +5750,10 @@ final class AppStore { appendMachineCreationLog("Importing the installer ISO and creating a thin-provisioned virtual disk…") desktopAssets = nil } else if settings.displayMode == .desktop { - let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) + let distro = DesktopMachineDistro.resolve( + settings.virtualMachineSettings?.guestIdentityIntent.desktop? + .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"] + ) appendMachineCreationLog("Preparing \(distro.displayName) \(distro.version) Desktop in the selected Dory data drive…") let home = environment["HOME"] ?? NSHomeDirectory() let resourceDirectory = Bundle.main.resourcePath @@ -5810,11 +5844,21 @@ final class AppStore { memoryMB: current?.memoryMB.flatMap { Int(exactly: $0) }, mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [], env: current?.environment ?? [:], + virtualMachineSettings: current.map { + DorydMachineTypedSettings( + legacyEnvironment: $0.environment, + displayMode: $0.displayMode + ) + }, address: current?.configuredAddress, displayMode: current?.displayMode ?? machine.displayMode, bootMode: current?.bootMode ?? machine.bootMode ) let effectiveSettings = Self.preservingHiddenMachineSettings(settings, existing: currentSettings) + let baselineTypedSettings = currentSettings.virtualMachineSettings + ?? DorydMachineTypedSettings() + let desiredTypedSettings = effectiveSettings.virtualMachineSettings + ?? baselineTypedSettings let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB let cpus = effectiveSettings.cpus ?? current?.cpuCount let address = Self.trimmedNonEmpty(effectiveSettings.address) @@ -5825,7 +5869,10 @@ final class AppStore { address: address, updatesAddress: true, shares: Self.dorydShares(from: effectiveSettings.mounts), - environment: effectiveSettings.env + typedSettingsPatch: DorydMachineTypedSettingsPatch( + baseline: baselineTypedSettings, + desired: desiredTypedSettings + ) ) appendMachineCreationLog("Settings applied to doryd VM definition.") activeSheet = nil diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 243bcf45..ee8904df 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -1,5 +1,6 @@ @preconcurrency import Foundation @preconcurrency import Security +import DoryOperations @objc(DorydHealthControl) nonisolated protocol DorydControlXPC { @@ -83,6 +84,256 @@ nonisolated struct DorydMachineShareConfiguration: Sendable, Equatable { } } +nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { + var guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified + var clipboardPolicy: DoryVMClipboardPolicy? = nil + var runtimePreference: DoryDesktopVMMPreference? = nil + var graphicsPreference: DoryDesktopGraphicsPreference? = nil + + init( + guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, + clipboardPolicy: DoryVMClipboardPolicy? = nil, + runtimePreference: DoryDesktopVMMPreference? = nil, + graphicsPreference: DoryDesktopGraphicsPreference? = nil + ) { + self.guestIdentityIntent = guestIdentityIntent + self.clipboardPolicy = clipboardPolicy + self.runtimePreference = runtimePreference + self.graphicsPreference = graphicsPreference + } + + init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) { + let username = legacyEnvironment[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] + .flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil } + let numericUserID = legacyEnvironment[ + DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey + ].flatMap(UInt32.init).flatMap { + DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil + } + let account = DoryVMGuestAccountIntent(username: username, numericUserID: numericUserID) + let desktop: DoryVMDesktopIdentityIntent? + if displayMode == .desktop { + func safeLabel(_ key: String) -> String? { + legacyEnvironment[key].flatMap { + DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil + } + } + let distribution = legacyEnvironment[ + DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey + ].flatMap { + DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil + } + let candidate = DoryVMDesktopIdentityIntent( + distributionIdentifier: distribution, + displayName: safeLabel(DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey), + version: safeLabel(DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey), + desktopEnvironment: safeLabel( + DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey + ) + ) + desktop = candidate.isValidForPersistence ? candidate : nil + } else { + desktop = nil + } + guestIdentityIntent = DoryVMGuestIdentityIntent( + account: account.isValidForPersistence ? account : nil, + desktop: desktop + ) + if displayMode == .desktop { + let effectiveClipboard = DoryDesktopClipboardPolicy( + environment: legacyEnvironment + ) + clipboardPolicy = DoryVMClipboardDirection( + rawValue: effectiveClipboard.rawValue + ).map(DoryVMClipboardPolicy.legacyDesktop) + runtimePreference = (try? DoryDesktopVMMPreference( + environment: legacyEnvironment + )) ?? .automatic + graphicsPreference = (try? DoryDesktopGraphicsPreference( + environment: legacyEnvironment + )) ?? .automatic + } else { + clipboardPolicy = nil + runtimePreference = nil + graphicsPreference = nil + } + } + + var isEmpty: Bool { + guestIdentityIntent.isEmpty + && clipboardPolicy == nil + && runtimePreference == nil + && graphicsPreference == nil + } + + var xpcDictionary: NSDictionary { + var result: [String: Any] = [:] + var identity: [String: Any] = [:] + if let account = guestIdentityIntent.account, !account.isEmpty { + var value: [String: Any] = [:] + if let username = account.username { value["username"] = username } + if let numericUserID = account.numericUserID { + value["numericUserID"] = numericUserID + } + identity["account"] = value as NSDictionary + } + if let desktop = guestIdentityIntent.desktop, !desktop.isEmpty { + var value: [String: Any] = [:] + if let distributionIdentifier = desktop.distributionIdentifier { + value["distributionIdentifier"] = distributionIdentifier + } + if let displayName = desktop.displayName { value["displayName"] = displayName } + if let version = desktop.version { value["version"] = version } + if let desktopEnvironment = desktop.desktopEnvironment { + value["desktopEnvironment"] = desktopEnvironment + } + identity["desktop"] = value as NSDictionary + } + if !identity.isEmpty { result["guestIdentityIntent"] = identity as NSDictionary } + if let clipboardPolicy { + result["clipboardPolicy"] = [ + "text": clipboardPolicy.text.rawValue, + "image": clipboardPolicy.image.rawValue, + "files": clipboardPolicy.files.rawValue, + ] as NSDictionary + } + if let runtimePreference { + result["desktopRuntimePreference"] = runtimePreference.rawValue + } + if let graphicsPreference { + result["desktopGraphicsPreference"] = graphicsPreference.rawValue + } + return result as NSDictionary + } + + func hash(into hasher: inout Hasher) { + hasher.combine(guestIdentityIntent.account?.username) + hasher.combine(guestIdentityIntent.account?.numericUserID) + hasher.combine(guestIdentityIntent.desktop?.distributionIdentifier) + hasher.combine(guestIdentityIntent.desktop?.displayName) + hasher.combine(guestIdentityIntent.desktop?.version) + hasher.combine(guestIdentityIntent.desktop?.desktopEnvironment) + hasher.combine(clipboardPolicy?.text.rawValue) + hasher.combine(clipboardPolicy?.image.rawValue) + hasher.combine(clipboardPolicy?.files.rawValue) + hasher.combine(runtimePreference?.rawValue) + hasher.combine(graphicsPreference?.rawValue) + } +} + +/// Leaf-level update authority. Comparing a safely migrated baseline to the desired typed state +/// means an unrelated edit never clears an opaque or invalid legacy value that was intentionally +/// omitted during migration. +nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { + var baseline: DorydMachineTypedSettings + var desired: DorydMachineTypedSettings + + var isEmpty: Bool { xpcDictionary.count == 0 } + + var xpcDictionary: NSDictionary { + var result: [String: Any] = [:] + var account: [String: Any] = [:] + Self.encode( + baseline.guestIdentityIntent.account?.username, + desired.guestIdentityIntent.account?.username, + key: "username", + into: &account + ) + Self.encode( + baseline.guestIdentityIntent.account?.numericUserID, + desired.guestIdentityIntent.account?.numericUserID, + key: "numericUserID", + into: &account + ) + var desktop: [String: Any] = [:] + Self.encode( + baseline.guestIdentityIntent.desktop?.distributionIdentifier, + desired.guestIdentityIntent.desktop?.distributionIdentifier, + key: "distributionIdentifier", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.displayName, + desired.guestIdentityIntent.desktop?.displayName, + key: "displayName", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.version, + desired.guestIdentityIntent.desktop?.version, + key: "version", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.desktopEnvironment, + desired.guestIdentityIntent.desktop?.desktopEnvironment, + key: "desktopEnvironment", + into: &desktop + ) + if !account.isEmpty || !desktop.isEmpty { + var identity: [String: Any] = [:] + if !account.isEmpty { identity["account"] = account as NSDictionary } + if !desktop.isEmpty { identity["desktop"] = desktop as NSDictionary } + result["guestIdentityIntent"] = identity as NSDictionary + } + Self.encodePolicy( + baseline.clipboardPolicy, + desired.clipboardPolicy, + into: &result + ) + Self.encodeEnum( + baseline.runtimePreference, + desired.runtimePreference, + key: "desktopRuntimePreference", + into: &result + ) + Self.encodeEnum( + baseline.graphicsPreference, + desired.graphicsPreference, + key: "desktopGraphicsPreference", + into: &result + ) + return result as NSDictionary + } + + private static func encode( + _ baseline: Value?, + _ desired: Value?, + key: String, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + dictionary[key] = desired ?? NSNull() + } + + private static func encodePolicy( + _ baseline: DoryVMClipboardPolicy?, + _ desired: DoryVMClipboardPolicy?, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + guard let desired else { + dictionary["clipboardPolicy"] = NSNull() + return + } + dictionary["clipboardPolicy"] = [ + "text": desired.text.rawValue, + "image": desired.image.rawValue, + "files": desired.files.rawValue, + ] as NSDictionary + } + + private static func encodeEnum( + _ baseline: Value?, + _ desired: Value?, + key: String, + into dictionary: inout [String: Any] + ) where Value.RawValue == String { + guard baseline != desired else { return } + dictionary[key] = desired?.rawValue ?? NSNull() + } +} + nonisolated struct DorydMachineConfiguration: Sendable, Equatable { var id: String var kernelPath: String @@ -95,7 +346,7 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { var address: String? = nil var displayMode: MachineDisplayMode = .headless var shares: [DorydMachineShareConfiguration] = [] - var environment: [String: String] = [:] + var typedSettings: DorydMachineTypedSettings = DorydMachineTypedSettings() var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ @@ -119,13 +370,8 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { if !shares.isEmpty { dictionary["shares"] = shares.map(\.xpcDictionary) } - if !environment.isEmpty { - dictionary["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in - [ - "key": key, - "value": value, - ] as NSDictionary - } + for (rawKey, value) in typedSettings.xpcDictionary { + if let key = rawKey as? String { dictionary[key] = value } } return dictionary as NSDictionary } @@ -920,7 +1166,8 @@ nonisolated final class DorydClient: @unchecked Sendable { address: String? = nil, updatesAddress: Bool = false, shares: [DorydMachineShareConfiguration]? = nil, - environment: [String: String]? = nil, + typedSettings: DorydMachineTypedSettings? = nil, + typedSettingsPatch: DorydMachineTypedSettingsPatch? = nil, installerMediaAttached: Bool? = nil ) async throws -> DorydMachineStatus { var config: [String: Any] = [:] @@ -938,12 +1185,15 @@ nonisolated final class DorydClient: @unchecked Sendable { if let shares { config["shares"] = shares.map(\.xpcDictionary) } - if let environment { - config["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in - [ - "key": key, - "value": value, - ] as NSDictionary + if let typedSettings { + for (rawKey, value) in typedSettings.xpcDictionary { + if let key = rawKey as? String { config[key] = value } + } + } + if let typedSettingsPatch { + precondition(typedSettings == nil, "use either full typed settings or a typed patch") + for (rawKey, value) in typedSettingsPatch.xpcDictionary { + if let key = rawKey as? String { config[key] = value } } } if let installerMediaAttached { diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index c09cb253..eed8562a 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -17,6 +17,9 @@ nonisolated struct MachineSettings: Sendable, Hashable { var ports: [PortPair] = [] var identity: MacIdentity? = nil var env: [String: String] = [:] + /// Closed, non-secret VM intent used by new doryd writes. `env` remains only so older + /// machine.json records and container recipes can be read without data loss. + var virtualMachineSettings: DorydMachineTypedSettings? = nil var address: String? = nil var displayMode: MachineDisplayMode = .headless var bootMode: MachineBootMode = .linuxKernel diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index c947ca60..4e4c897d 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1,9 +1,64 @@ import Foundation +import DoryOperations import Testing @testable import Dory @Suite(.serialized) struct DorydClientTests { + @Test func legacyDesktopDefaultsAndTogglesRemainFieldLocalInTypedEdits() throws { + let defaults = DorydMachineTypedSettings( + legacyEnvironment: [:], + displayMode: .desktop + ) + #expect(defaults.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(defaults.runtimePreference == .automatic) + #expect(defaults.graphicsPreference == .automatic) + + for (legacy, expected) in [("1", DoryDesktopGraphicsPreference.virgl), + ("0", DoryDesktopGraphicsPreference.virglVenus)] { + let settings = DorydMachineTypedSettings( + legacyEnvironment: ["DORY_VIRGL_CLASSIC_ONLY": legacy], + displayMode: .desktop + ) + #expect(settings.graphicsPreference == expected) + #expect(DorydMachineTypedSettingsPatch( + baseline: settings, + desired: settings + ).xpcDictionary["desktopGraphicsPreference"] == nil) + } + + let hostile = [ + "DORY_GUEST_USER": "../../unsafe-user", + "DORY_GUEST_UID": "not-a-uid", + "DORY_CLIPBOARD_POLICY": "invalid-and-must-remain", + "DORY_DESKTOP_VMM": "invalid-and-must-remain", + "DORY_DESKTOP_GRAPHICS": "invalid-and-must-remain", + ] + let baseline = DorydMachineTypedSettings( + legacyEnvironment: hostile, + displayMode: .desktop + ) + #expect(baseline.guestIdentityIntent.account == nil) + #expect(baseline.clipboardPolicy == .disabled) + #expect(baseline.runtimePreference == .automatic) + #expect(baseline.graphicsPreference == .automatic) + var desired = baseline + desired.guestIdentityIntent.account = DoryVMGuestAccountIntent( + username: "developer" + ) + let wire = DorydMachineTypedSettingsPatch( + baseline: baseline, + desired: desired + ).xpcDictionary + let identity = try #require(wire["guestIdentityIntent"] as? NSDictionary) + let account = try #require(identity["account"] as? NSDictionary) + #expect(account["username"] as? String == "developer") + #expect(account["numericUserID"] == nil) + #expect(wire["clipboardPolicy"] == nil) + #expect(wire["desktopRuntimePreference"] == nil) + #expect(wire["desktopGraphicsPreference"] == nil) + } + @Test func desktopAssetEnvironmentUsesSelectedDistribution() { for distro in DesktopMachineDistro.allCases { let environment = AppStore.desktopAssetEnvironment( @@ -144,7 +199,23 @@ struct DorydClientTests { shares: [ DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), ], - environment: ["FOO": "bar"] + typedSettings: DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent( + username: "developer", + numericUserID: 1_000 + ), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04", + desktopEnvironment: "GNOME" + ) + ), + clipboardPolicy: .legacyDesktop(.bidirectional), + runtimePreference: .accelerated, + graphicsPreference: .virglVenus + ) )) let startedMachine = try await client.machineStart("dev") let machineStats = try await client.machineStats("dev") @@ -178,7 +249,14 @@ struct DorydClientTests { memoryMB: 4096, cpuCount: 4, address: "192.168.215.41", - environment: ["BAR": "baz"] + typedSettings: DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "builder") + ), + clipboardPolicy: .legacyDesktop(.hostToGuest), + runtimePreference: .compatible, + graphicsPreference: .software + ) ) let machines = try await client.machineList() let deletedMachine = try await client.machineDelete("dev") @@ -247,7 +325,8 @@ struct DorydClientTests { #expect(startedMachine.shares == [ DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), ]) - #expect(startedMachine.environment == ["FOO": "bar"]) + #expect(startedMachine.environment["DORY_GUEST_USER"] == "developer") + #expect(startedMachine.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") #expect(startedMachine.displayMode == .desktop) #expect(execResult.stdout == "cargo 1.0\n") #expect(execResult.exitCode == 0) @@ -276,7 +355,10 @@ struct DorydClientTests { #expect(updatedMachine.memoryMB == 4096) #expect(updatedMachine.cpuCount == 4) #expect(updatedMachine.address == "192.168.215.41") - #expect(updatedMachine.environment == ["BAR": "baz"]) + #expect(updatedMachine.environment["DORY_GUEST_USER"] == "builder") + #expect(updatedMachine.environment["DORY_CLIPBOARD_POLICY"] == "host-to-guest") + #expect(updatedMachine.environment["DORY_DESKTOP_VMM"] == "compatible") + #expect(updatedMachine.environment["DORY_DESKTOP_GRAPHICS"] == "software") #expect(machines.map(\.id) == ["dev", "dev-copy"]) #expect(deletedMachine == DorydCommandResult(ok: true, message: "")) #expect(remoteInfo.agentBuild == "remote-agent") @@ -669,9 +751,7 @@ struct DorydClientTests { #expect(updateShares.first?["hostPath"] as? String == "/Users/me/app") #expect(updateShares.first?["guestPath"] as? String == "/workspace/app") #expect(updateShares.first?["readOnly"] as? Bool == false) - let updateEnv = try #require(service.latestMachineUpdateConfig?["env"] as? [NSDictionary]) - #expect(updateEnv.first?["key"] as? String == "ANTHROPIC_API_KEY") - #expect(updateEnv.first?["value"] as? String == "test-token") + #expect(service.latestMachineUpdateConfig?["env"] == nil) machine = try #require(store.machines.first { $0.name == "dev" }) let clearAddressResult = await store.editMachine( @@ -900,16 +980,10 @@ struct DorydClientTests { let createShares = try #require(config["shares"] as? [NSDictionary]) #expect(createShares.first?["hostPath"] as? String == "/Users/me/project") #expect(createShares.first?["guestPath"] as? String == "/workspace/project") - let createEnv = try #require(config["env"] as? [NSDictionary]) - let createEnvValues: [String: String] = Dictionary( - uniqueKeysWithValues: createEnv.compactMap { entry -> (String, String)? in - guard let key = entry["key"] as? String, - let value = entry["value"] as? String else { return nil } - return (key, value) - } - ) - #expect(createEnvValues["APP_ENV"] == "dev") - #expect(createEnvValues["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(config["env"] == nil) + let identity = try #require(config["guestIdentityIntent"] as? NSDictionary) + let desktop = try #require(identity["desktop"] as? NSDictionary) + #expect(desktop["distributionIdentifier"] as? String == "ubuntu") try await waitUntil { store.machines.first { $0.name == "vmdev" }?.status == .running @@ -919,6 +993,113 @@ struct DorydClientTests { #expect(store.machineCreationLog.contains("cargo 1.0")) } + @MainActor + @Test func appStoreEditsTypedLeavesWithoutRewritingLegacyEnvironment() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineEnvironment("dev", [ + "DORY_GUEST_USER": "dory", + "DORY_GUEST_UID": "not-a-uid", + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_NAME": "Ubuntu", + "DORY_DESKTOP_VERSION": "24.04 LTS", + "DORY_DESKTOP_ENVIRONMENT": "GNOME", + "DORY_CLIPBOARD_POLICY": "invalid-clipboard", + "DORY_DESKTOP_VMM": "invalid-runtime", + "DORY_DESKTOP_GRAPHICS": "invalid-graphics", + "OPAQUE_LEGACY": "preserve-exactly", + ]) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let store = AppStore( + dorydClient: DorydClient(endpoint: listener.endpoint), + useDorydEngine: true + ) + store.routeDockerCLI = false + await store.connectBackend() + let machine = try #require(store.machines.first { $0.name == "dev" }) + let baseline = await store.machineSettings("dev") + let desktop = try #require( + baseline.virtualMachineSettings?.guestIdentityIntent.desktop + ) + var desired = try #require(baseline.virtualMachineSettings) + desired.guestIdentityIntent = DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "builder"), + desktop: desktop + ) + + let result = await store.editMachine( + machine, + settings: MachineSettings( + cpus: baseline.cpus, + memoryMB: baseline.memoryMB, + mounts: baseline.mounts, + env: [:], + virtualMachineSettings: desired, + address: baseline.address, + displayMode: .desktop + ) + ) + + #expect(result == nil) + let update = try #require(service.latestMachineUpdateConfig) + #expect(update["env"] == nil) + let identity = try #require(update["guestIdentityIntent"] as? NSDictionary) + let account = try #require(identity["account"] as? NSDictionary) + #expect(account["username"] as? String == "builder") + #expect(account["numericUserID"] == nil) + #expect(identity["desktop"] == nil) + #expect(update["clipboardPolicy"] == nil) + #expect(update["desktopRuntimePreference"] == nil) + #expect(update["desktopGraphicsPreference"] == nil) + + let persisted = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()) + .first { $0.id == "dev" } + ) + #expect(persisted.environment["DORY_GUEST_USER"] == "builder") + #expect(persisted.environment["DORY_GUEST_UID"] == "not-a-uid") + #expect(persisted.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(persisted.environment["DORY_DESKTOP_NAME"] == "Ubuntu") + #expect(persisted.environment["DORY_DESKTOP_VERSION"] == "24.04 LTS") + #expect(persisted.environment["DORY_DESKTOP_ENVIRONMENT"] == "GNOME") + #expect(persisted.environment["DORY_CLIPBOARD_POLICY"] == "invalid-clipboard") + #expect(persisted.environment["DORY_DESKTOP_VMM"] == "invalid-runtime") + #expect(persisted.environment["DORY_DESKTOP_GRAPHICS"] == "invalid-graphics") + #expect(persisted.environment["OPAQUE_LEGACY"] == "preserve-exactly") + + let afterUsernameEdit = await store.machineSettings("dev") + var explicitRuntimeEdit = try #require( + afterUsernameEdit.virtualMachineSettings + ) + explicitRuntimeEdit.clipboardPolicy = .legacyDesktop(.hostToGuest) + explicitRuntimeEdit.runtimePreference = .compatible + explicitRuntimeEdit.graphicsPreference = .software + let secondResult = await store.editMachine( + machine, + settings: MachineSettings( + cpus: afterUsernameEdit.cpus, + memoryMB: afterUsernameEdit.memoryMB, + mounts: afterUsernameEdit.mounts, + env: [:], + virtualMachineSettings: explicitRuntimeEdit, + address: afterUsernameEdit.address, + displayMode: .desktop + ) + ) + #expect(secondResult == nil) + let explicitUpdate = try #require(service.latestMachineUpdateConfig) + #expect(explicitUpdate["desktopRuntimePreference"] as? String == "compatible") + #expect(explicitUpdate["desktopGraphicsPreference"] as? String == "software") + let clipboard = try #require( + explicitUpdate["clipboardPolicy"] as? NSDictionary + ) + #expect(clipboard["text"] as? String == "host-to-guest") + } + @MainActor @Test func failedRequiredMachineProvisioningRollsBackNewDefinition() async throws { let base = "/tmp/damc-provision-failure-\(getpid())-\(UInt32.random(in: 0.. (String, String)? in - guard let key = row["key"] as? String, let value = row["value"] as? String else { return nil } - return (key, value) - }) - #expect(env["ANTHROPIC_API_KEY"] == nil) - #expect(env["GH_TOKEN"] == nil) - #expect(env["EMPTY_TOKEN"] == nil) - #expect(env["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(config["env"] == nil) + #expect(config["guestIdentityIntent"] == nil) } @MainActor - @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() { + @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() throws { #expect(AppStore.dorydMachineConfiguration( name: "vmdev", settings: .default, @@ -1050,7 +1224,7 @@ struct DorydClientTests { rootfsPath: "/vm/rootfs.raw", memoryMB: 3072, cpuCount: 3, - environment: ["APP_ENV": "dev"] + typedSettings: DorydMachineTypedSettings() )) let invalidResources = AppStore.dorydMachineConfiguration( @@ -1078,6 +1252,26 @@ struct DorydClientTests { ) #expect(malformedResources?.memoryMB == 0) #expect(malformedResources?.cpuCount == 0) + + let customEFI = AppStore.dorydMachineConfiguration( + name: "omarchy", + settings: MachineSettings( + cpus: 4, + memoryMB: 4_096, + env: ["DORY_CUSTOM_LINUX": "1", "TOKEN": "must-not-cross"], + displayMode: .desktop, + bootMode: .efi, + installerISOPath: "/staged/omarchy.iso", + diskSizeGB: 64 + ), + environment: [:] + ) + let custom = try #require(customEFI) + #expect(custom.bootMode == .efi) + #expect(custom.installerISOPath == "/staged/omarchy.iso") + #expect(custom.typedSettings.isEmpty) + #expect(custom.xpcDictionary["env"] == nil) + #expect(custom.xpcDictionary["guestIdentityIntent"] == nil) } @MainActor @@ -1975,6 +2169,27 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.lock(); defer { lock.unlock() } return _machineStartCount } + + func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID] else { return } + machines[machineID] = Self.machineRow( + id: machineID, + state: current["state"] as? String ?? "stopped", + pid: (current["pid"] as? NSNumber)?.int32Value, + agentBuild: current["agentBuild"] as? String, + handoffFDCount: (current["handoffFDCount"] as? NSNumber)?.intValue ?? 0, + memoryMB: Self.uint64(current["memoryMB"]) ?? 2_048, + cpuCount: Self.int(current["cpuCount"]) ?? 2, + address: current["address"] as? String, + displayMode: current["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current["shares"]), + environment: environment.sorted { $0.key < $1.key }.map { + ["key": $0.key, "value": $0.value] as NSDictionary + } + ) + } var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount @@ -2183,7 +2398,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { address: config["address"] as? String, displayMode: config["displayMode"] as? String ?? "headless", shares: Self.shareRows(config["shares"]), - environment: Self.environmentRows(config["env"]) + environment: Self.typedEnvironment(config, baseline: []) ) lock.lock() _machineCreateCount += 1 @@ -2251,7 +2466,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ?? 2 let address = config["address"] == nil ? current["address"] as? String : config["address"] as? String let shares = config["shares"] == nil ? Self.shareRows(current["shares"]) : Self.shareRows(config["shares"]) - let environment = config["env"] == nil ? Self.environmentRows(current["env"]) : Self.environmentRows(config["env"]) + let environment = Self.typedEnvironment( + config, + baseline: Self.environmentRows(current["env"]) + ) let state = current["state"] as? String ?? "stopped" let row = Self.machineRow( id: machineID, @@ -2844,6 +3062,71 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return [] } + private static func typedEnvironment( + _ config: NSDictionary, + baseline: [NSDictionary] + ) -> [NSDictionary] { + var environment = Dictionary(uniqueKeysWithValues: baseline.compactMap { + row -> (String, String)? in + guard let key = row["key"] as? String, + let value = row["value"] as? String else { return nil } + return (key, value) + }) + if let identity = config["guestIdentityIntent"] as? NSDictionary { + if let account = identity["account"] as? NSDictionary { + apply(account["username"], key: "DORY_GUEST_USER", to: &environment) + let uid = (account["numericUserID"] as? NSNumber)?.stringValue + ?? (account["numericUserID"] as? UInt32).map(String.init) + if account["numericUserID"] != nil { + apply(uid ?? NSNull(), key: "DORY_GUEST_UID", to: &environment) + } + } + if let desktop = identity["desktop"] as? NSDictionary { + apply( + desktop["distributionIdentifier"], + key: "DORY_DESKTOP_DISTRO", + to: &environment + ) + apply(desktop["displayName"], key: "DORY_DESKTOP_NAME", to: &environment) + apply(desktop["version"], key: "DORY_DESKTOP_VERSION", to: &environment) + apply( + desktop["desktopEnvironment"], + key: "DORY_DESKTOP_ENVIRONMENT", + to: &environment + ) + } + } + if let clipboard = config["clipboardPolicy"] as? NSDictionary { + apply(clipboard["text"], key: "DORY_CLIPBOARD_POLICY", to: &environment) + } + apply( + config["desktopRuntimePreference"], + key: "DORY_DESKTOP_VMM", + to: &environment + ) + apply( + config["desktopGraphicsPreference"], + key: "DORY_DESKTOP_GRAPHICS", + to: &environment + ) + return environment.sorted { $0.key < $1.key }.map { key, value in + ["key": key, "value": value] as NSDictionary + } + } + + private static func apply( + _ raw: Any?, + key: String, + to environment: inout [String: String] + ) { + guard let raw else { return } + if raw is NSNull { + environment.removeValue(forKey: key) + } else if let value = raw as? String { + environment[key] = value + } + } + private static func uint64(_ value: Any?) -> UInt64? { if let number = value as? NSNumber { return number.uint64Value } return value as? UInt64 diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 6ca32090..073b6b17 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -41,13 +41,14 @@ struct NewMachineSettingsTests { #expect(s.mounts.count == 1) #expect(s.address == "192.168.215.40") #expect(s.displayMode == .desktop) - #expect(s.env["DORY_GUEST_USER"] == "dory") - #expect(s.env["DORY_GUEST_UID"] == String(getuid())) - #expect(s.env["DORY_DESKTOP_DISTRO"] == "debian") - #expect(s.env["DORY_DESKTOP_VERSION"] == "13") - #expect(s.env[DoryDesktopClipboardPolicy.environmentKey] == "bidirectional") - #expect(s.env[DoryDesktopVMMPreference.environmentKey] == "auto") - #expect(s.env[DoryDesktopGraphicsPreference.environmentKey] == "auto") + #expect(s.env.isEmpty) + #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.username == "dory") + #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == UInt32(getuid())) + #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "debian") + #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "13") + #expect(s.virtualMachineSettings?.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(s.virtualMachineSettings?.runtimePreference == .automatic) + #expect(s.virtualMachineSettings?.graphicsPreference == .automatic) #expect(s.ports.isEmpty) } @@ -62,12 +63,13 @@ struct NewMachineSettingsTests { guestUID: 1_001 ) - #expect(settings.env["DORY_DESKTOP_DISTRO"] == "kali") - #expect(settings.env["DORY_DESKTOP_NAME"] == "Kali Linux") - #expect(settings.env["DORY_DESKTOP_VERSION"] == "Rolling") - #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "Xfce") - #expect(settings.env["DORY_GUEST_USER"] == "analyst") - #expect(settings.env["DORY_GUEST_UID"] == "1001") + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "kali") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Kali Linux") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "Rolling") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "Xfce") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.username == "analyst") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == 1_001) } @Test func recordsUbuntuAsTheCanonicalGnomeDesktop() { @@ -81,10 +83,11 @@ struct NewMachineSettingsTests { guestUID: 1_002 ) - #expect(settings.env["DORY_DESKTOP_DISTRO"] == "ubuntu") - #expect(settings.env["DORY_DESKTOP_NAME"] == "Ubuntu") - #expect(settings.env["DORY_DESKTOP_VERSION"] == "24.04 LTS") - #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "GNOME") + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Ubuntu") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "24.04 LTS") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "GNOME") } @Test func headlessServersDoNotCarryDesktopMetadata() { @@ -95,10 +98,7 @@ struct NewMachineSettingsTests { displayMode: .headless ) - #expect(settings.env["DORY_DESKTOP_DISTRO"] == nil) - #expect(settings.env["DORY_GUEST_USER"] == nil) - #expect(settings.env[DoryDesktopClipboardPolicy.environmentKey] == nil) - #expect(settings.env[DoryDesktopVMMPreference.environmentKey] == nil) - #expect(settings.env[DoryDesktopGraphicsPreference.environmentKey] == nil) + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings == nil) } } diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift new file mode 100644 index 00000000..b7aaf3fc --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -0,0 +1,689 @@ +import DoryOperations +import Foundation + +public enum DoryMachineTypedWriteAuthorityError: Error, Sendable, Equatable, CustomStringConvertible { + case rawEnvironmentForbidden + case invalidField(String) + case unsupportedForDisplay(String) + case unsupportedByLegacyRuntime(String) + + public var description: String { + switch self { + case .rawEnvironmentForbidden: + "raw machine environment input is not accepted; use typed machine settings" + case let .invalidField(field): + "invalid typed machine setting: \(field)" + case let .unsupportedForDisplay(field): + "typed machine setting \(field) is not supported by this display mode" + case let .unsupportedByLegacyRuntime(field): + "typed machine setting \(field) cannot be represented by the current compatibility runtime" + } + } +} + +public enum DoryMachineTypedSettingUpdate: Sendable, Equatable { + case unchanged + case set(Value) + case clear + + public var isChanged: Bool { + if case .unchanged = self { return false } + return true + } +} + +/// Typed public write authority for the compatibility MachineManager. +/// +/// MachineManager still reads legacy `machine.json` environment values while M0 migration is in +/// progress. Public callers never provide that dictionary: this patch accepts only non-secret, +/// bounded intent and back-projects the seven explicitly owned compatibility keys. Unchanged +/// fields preserve their exact legacy bytes, including values too old or unsafe to migrate. +public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { + public var guestUsername: DoryMachineTypedSettingUpdate + public var guestNumericUserID: DoryMachineTypedSettingUpdate + public var desktopDistributionIdentifier: DoryMachineTypedSettingUpdate + public var desktopDisplayName: DoryMachineTypedSettingUpdate + public var desktopVersion: DoryMachineTypedSettingUpdate + public var desktopEnvironment: DoryMachineTypedSettingUpdate + public var clipboardPolicy: DoryMachineTypedSettingUpdate + public var runtimePreference: DoryMachineTypedSettingUpdate + public var graphicsPreference: DoryMachineTypedSettingUpdate + + public init( + guestUsername: DoryMachineTypedSettingUpdate = .unchanged, + guestNumericUserID: DoryMachineTypedSettingUpdate = .unchanged, + desktopDistributionIdentifier: DoryMachineTypedSettingUpdate = .unchanged, + desktopDisplayName: DoryMachineTypedSettingUpdate = .unchanged, + desktopVersion: DoryMachineTypedSettingUpdate = .unchanged, + desktopEnvironment: DoryMachineTypedSettingUpdate = .unchanged, + clipboardPolicy: DoryMachineTypedSettingUpdate = .unchanged, + runtimePreference: DoryMachineTypedSettingUpdate = .unchanged, + graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged + ) { + self.guestUsername = guestUsername + self.guestNumericUserID = guestNumericUserID + self.desktopDistributionIdentifier = desktopDistributionIdentifier + self.desktopDisplayName = desktopDisplayName + self.desktopVersion = desktopVersion + self.desktopEnvironment = desktopEnvironment + self.clipboardPolicy = clipboardPolicy + self.runtimePreference = runtimePreference + self.graphicsPreference = graphicsPreference + } + + public var isEmpty: Bool { + !guestUsername.isChanged + && !guestNumericUserID.isChanged + && !desktopDistributionIdentifier.isChanged + && !desktopDisplayName.isChanged + && !desktopVersion.isChanged + && !desktopEnvironment.isChanged + && !clipboardPolicy.isChanged + && !runtimePreference.isChanged + && !graphicsPreference.isChanged + } + + /// Consume the typed persistent-machine options shared by dorydctl create and update. Other + /// arguments remain in place for the command parser. This intentionally has no `--env` + /// escape hatch; execution-scoped command environments use a separate API. + public static func consumeCLIArguments( + _ arguments: inout [String], + allowsClears: Bool + ) throws -> Self { + var patch = Self() + if let value = try takeOption("--guest-user", from: &arguments) { + guard DoryVMGuestAccountIntent.isValidUsername(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--guest-user") + } + patch.guestUsername = .set(value) + } + if let raw = try takeOption("--guest-uid", from: &arguments) { + guard let value = UInt32(raw), + DoryVMGuestAccountIntent.isValidNumericUserID(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--guest-uid") + } + patch.guestNumericUserID = .set(value) + } + if let value = try takeOption("--desktop-distro", from: &arguments) { + guard DoryVMDesktopIdentityIntent.isValidDistributionIdentifier(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--desktop-distro") + } + patch.desktopDistributionIdentifier = .set(value) + } + let labelOptions: [(String, WritableKeyPath>)] = [ + ("--desktop-name", \Self.desktopDisplayName), + ("--desktop-version", \Self.desktopVersion), + ("--desktop-environment", \Self.desktopEnvironment), + ] + for (option, keyPath) in labelOptions { + if let value = try takeOption(option, from: &arguments) { + guard DoryVMDesktopIdentityIntent.isValidLabel(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(option) + } + patch[keyPath: keyPath] = .set(value) + } + } + if let raw = try takeOption("--clipboard", from: &arguments) { + guard let direction = DoryVMClipboardDirection(rawValue: raw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clipboard") + } + patch.clipboardPolicy = .set(.legacyDesktop(direction)) + } + if let raw = try takeOption("--runtime", from: &arguments) { + guard let preference = DoryDesktopVMMPreference(rawValue: raw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--runtime") + } + patch.runtimePreference = .set(preference) + } + if let raw = try takeOption("--graphics", from: &arguments) { + guard let preference = DoryDesktopGraphicsPreference(rawValue: raw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--graphics") + } + patch.graphicsPreference = .set(preference) + } + + let clearsAccount = takeFlag("--clear-guest-account", from: &arguments) + let clearsDesktop = takeFlag("--clear-desktop-identity", from: &arguments) + let clearsClipboard = takeFlag("--clear-clipboard", from: &arguments) + let clearsRuntime = takeFlag("--clear-runtime", from: &arguments) + let clearsGraphics = takeFlag("--clear-graphics", from: &arguments) + guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard + && !clearsRuntime && !clearsGraphics) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("clear options") + } + if clearsAccount { + guard !patch.guestUsername.isChanged, !patch.guestNumericUserID.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "--clear-guest-account" + ) + } + patch.guestUsername = .clear + patch.guestNumericUserID = .clear + } + if clearsDesktop { + guard !patch.desktopDistributionIdentifier.isChanged, + !patch.desktopDisplayName.isChanged, + !patch.desktopVersion.isChanged, + !patch.desktopEnvironment.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "--clear-desktop-identity" + ) + } + patch.desktopDistributionIdentifier = .clear + patch.desktopDisplayName = .clear + patch.desktopVersion = .clear + patch.desktopEnvironment = .clear + } + if clearsClipboard { + guard !patch.clipboardPolicy.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-clipboard") + } + patch.clipboardPolicy = .clear + } + if clearsRuntime { + guard !patch.runtimePreference.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-runtime") + } + patch.runtimePreference = .clear + } + if clearsGraphics { + guard !patch.graphicsPreference.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-graphics") + } + patch.graphicsPreference = .clear + } + return patch + } + + /// Decode the exact XPC write shape. Create requests disallow clears; update requests encode + /// a deliberate clear as `NSNull`. Unknown nested keys are rejected rather than ignored. + public init(xpcDictionary dictionary: NSDictionary, allowsClears: Bool) throws { + guard dictionary["env"] == nil else { + throw DoryMachineTypedWriteAuthorityError.rawEnvironmentForbidden + } + self.init() + if let rawGuestIdentity = dictionary["guestIdentityIntent"] { + try decodeGuestIdentity(rawGuestIdentity, allowsClears: allowsClears) + } + if let rawClipboard = dictionary["clipboardPolicy"] { + clipboardPolicy = try Self.decodeClipboardPolicy( + rawClipboard, + allowsClears: allowsClears + ) + } + runtimePreference = try Self.decodeEnum( + dictionary["desktopRuntimePreference"], + field: "desktopRuntimePreference", + allowsClears: allowsClears, + type: DoryDesktopVMMPreference.self + ) + graphicsPreference = try Self.decodeEnum( + dictionary["desktopGraphicsPreference"], + field: "desktopGraphicsPreference", + allowsClears: allowsClears, + type: DoryDesktopGraphicsPreference.self + ) + } + + /// Canonical XPC representation used by dorydctl and future typed clients. + public var xpcDictionary: NSDictionary { + var result: [String: Any] = [:] + var account: [String: Any] = [:] + Self.encode(guestUsername, key: "username", into: &account) + Self.encode(guestNumericUserID, key: "numericUserID", into: &account) + var desktop: [String: Any] = [:] + Self.encode( + desktopDistributionIdentifier, + key: "distributionIdentifier", + into: &desktop + ) + Self.encode(desktopDisplayName, key: "displayName", into: &desktop) + Self.encode(desktopVersion, key: "version", into: &desktop) + Self.encode(desktopEnvironment, key: "desktopEnvironment", into: &desktop) + if !account.isEmpty || !desktop.isEmpty { + var identity: [String: Any] = [:] + if !account.isEmpty { identity["account"] = account as NSDictionary } + if !desktop.isEmpty { identity["desktop"] = desktop as NSDictionary } + result["guestIdentityIntent"] = identity as NSDictionary + } + switch clipboardPolicy { + case .unchanged: + break + case .clear: + result["clipboardPolicy"] = NSNull() + case let .set(policy): + result["clipboardPolicy"] = [ + "text": policy.text.rawValue, + "image": policy.image.rawValue, + "files": policy.files.rawValue, + ] as NSDictionary + } + Self.encodeEnum( + runtimePreference, + key: "desktopRuntimePreference", + into: &result + ) + Self.encodeEnum( + graphicsPreference, + key: "desktopGraphicsPreference", + into: &result + ) + return result as NSDictionary + } + + public func applying( + to legacyEnvironment: [String: String], + displayMode: DoryMachineDisplayMode + ) throws -> [String: String] { + try validate(displayMode: displayMode) + var environment = legacyEnvironment + Self.apply( + guestUsername, + key: DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey, + to: &environment + ) + Self.apply( + guestNumericUserID.map(String.init), + key: DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey, + to: &environment + ) + Self.apply( + desktopDistributionIdentifier, + key: DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey, + to: &environment + ) + Self.apply( + desktopDisplayName, + key: DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey, + to: &environment + ) + Self.apply( + desktopVersion, + key: DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey, + to: &environment + ) + Self.apply( + desktopEnvironment, + key: DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey, + to: &environment + ) + switch clipboardPolicy { + case .unchanged: + break + case .clear: + environment.removeValue(forKey: DoryDesktopClipboardPolicy.environmentKey) + case let .set(policy): + environment[DoryDesktopClipboardPolicy.environmentKey] = policy.text.rawValue + } + Self.apply( + runtimePreference.map(\.rawValue), + key: DoryDesktopVMMPreference.environmentKey, + to: &environment + ) + Self.apply( + graphicsPreference.map(\.rawValue), + key: DoryDesktopGraphicsPreference.environmentKey, + to: &environment + ) + if graphicsPreference.isChanged { + environment.removeValue( + forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey + ) + } + return environment + } + + private mutating func decodeGuestIdentity(_ raw: Any, allowsClears: Bool) throws { + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField("guestIdentityIntent") + } + guestUsername = .clear + guestNumericUserID = .clear + desktopDistributionIdentifier = .clear + desktopDisplayName = .clear + desktopVersion = .clear + desktopEnvironment = .clear + return + } + guard let identity = Self.dictionary(raw), + Self.hasOnlyKeys(identity, allowed: ["account", "desktop"]), + identity.count > 0 else { + throw DoryMachineTypedWriteAuthorityError.invalidField("guestIdentityIntent") + } + if let rawAccount = identity["account"] { + if rawAccount is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.account" + ) + } + guestUsername = .clear + guestNumericUserID = .clear + } else { + guard let account = Self.dictionary(rawAccount), + Self.hasOnlyKeys(account, allowed: ["username", "numericUserID"]), + account.count > 0 else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.account" + ) + } + guestUsername = try Self.decodeString( + account, + key: "username", + field: "guestIdentityIntent.account.username", + allowsClears: allowsClears, + validator: DoryVMGuestAccountIntent.isValidUsername + ) + guestNumericUserID = try Self.decodeUInt32( + account, + key: "numericUserID", + field: "guestIdentityIntent.account.numericUserID", + allowsClears: allowsClears + ) + } + } + if let rawDesktop = identity["desktop"] { + if rawDesktop is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.desktop" + ) + } + desktopDistributionIdentifier = .clear + desktopDisplayName = .clear + desktopVersion = .clear + desktopEnvironment = .clear + } else { + guard let desktop = Self.dictionary(rawDesktop), + Self.hasOnlyKeys( + desktop, + allowed: [ + "distributionIdentifier", + "displayName", + "version", + "desktopEnvironment", + ] + ), + desktop.count > 0 else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.desktop" + ) + } + desktopDistributionIdentifier = try Self.decodeString( + desktop, + key: "distributionIdentifier", + field: "guestIdentityIntent.desktop.distributionIdentifier", + allowsClears: allowsClears, + validator: DoryVMDesktopIdentityIntent.isValidDistributionIdentifier + ) + desktopDisplayName = try Self.decodeString( + desktop, + key: "displayName", + field: "guestIdentityIntent.desktop.displayName", + allowsClears: allowsClears, + validator: DoryVMDesktopIdentityIntent.isValidLabel + ) + desktopVersion = try Self.decodeString( + desktop, + key: "version", + field: "guestIdentityIntent.desktop.version", + allowsClears: allowsClears, + validator: DoryVMDesktopIdentityIntent.isValidLabel + ) + desktopEnvironment = try Self.decodeString( + desktop, + key: "desktopEnvironment", + field: "guestIdentityIntent.desktop.desktopEnvironment", + allowsClears: allowsClears, + validator: DoryVMDesktopIdentityIntent.isValidLabel + ) + } + } + } + + private static func decodeClipboardPolicy( + _ raw: Any, + allowsClears: Bool + ) throws -> DoryMachineTypedSettingUpdate { + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField("clipboardPolicy") + } + return .clear + } + guard let dictionary = dictionary(raw), + hasOnlyKeys(dictionary, allowed: ["text", "image", "files"]), + dictionary.count == 3, + let text = direction(dictionary["text"]), + let image = direction(dictionary["image"]), + let files = direction(dictionary["files"]) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("clipboardPolicy") + } + return .set(DoryVMClipboardPolicy(text: text, image: image, files: files)) + } + + private func validate(displayMode: DoryMachineDisplayMode) throws { + if case let .set(value) = guestUsername, + !DoryVMGuestAccountIntent.isValidUsername(value) { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.account.username" + ) + } + if case let .set(value) = guestNumericUserID, + !DoryVMGuestAccountIntent.isValidNumericUserID(value) { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.account.numericUserID" + ) + } + let desktopFields: [(String, DoryMachineTypedSettingUpdate, (String) -> Bool)] = [ + ( + "distributionIdentifier", + desktopDistributionIdentifier, + DoryVMDesktopIdentityIntent.isValidDistributionIdentifier + ), + ("displayName", desktopDisplayName, DoryVMDesktopIdentityIntent.isValidLabel), + ("version", desktopVersion, DoryVMDesktopIdentityIntent.isValidLabel), + ( + "desktopEnvironment", + desktopEnvironment, + DoryVMDesktopIdentityIntent.isValidLabel + ), + ] + for (field, update, validator) in desktopFields { + if case let .set(value) = update, !validator(value) { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.desktop.\(field)" + ) + } + } + let setsDesktopValue = desktopFields.contains { _, update, _ in + if case .set = update { return true } + return false + } + if setsDesktopValue, displayMode != .desktop { + throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "guestIdentityIntent.desktop" + ) + } + if case let .set(policy) = clipboardPolicy { + guard policy.text == policy.image, policy.files == .off else { + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "clipboardPolicy" + ) + } + if policy.isEnabled, displayMode != .desktop { + throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "clipboardPolicy" + ) + } + } + if (runtimePreference.isSet || graphicsPreference.isSet), displayMode != .desktop { + throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "desktopRuntimePreference" + ) + } + } + + private static func dictionary(_ raw: Any) -> NSDictionary? { + if let dictionary = raw as? NSDictionary { return dictionary } + if let dictionary = raw as? [String: Any] { return dictionary as NSDictionary } + return nil + } + + private static func hasOnlyKeys(_ dictionary: NSDictionary, allowed: Set) -> Bool { + dictionary.allKeys.allSatisfy { key in + guard let key = key as? String else { return false } + return allowed.contains(key) + } + } + + private static func decodeString( + _ dictionary: NSDictionary, + key: String, + field: String, + allowsClears: Bool, + validator: (String) -> Bool + ) throws -> DoryMachineTypedSettingUpdate { + guard let raw = dictionary[key] else { return .unchanged } + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .clear + } + guard let value = raw as? String, validator(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .set(value) + } + + private static func decodeUInt32( + _ dictionary: NSDictionary, + key: String, + field: String, + allowsClears: Bool + ) throws -> DoryMachineTypedSettingUpdate { + guard let raw = dictionary[key] else { return .unchanged } + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .clear + } + guard !(raw is Bool), + let number = raw as? NSNumber, + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.doubleValue >= 0, + number.doubleValue <= Double(UInt32.max) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + let value = UInt32(number.uint64Value) + guard DoryVMGuestAccountIntent.isValidNumericUserID(value) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .set(value) + } + + private static func direction(_ raw: Any?) -> DoryVMClipboardDirection? { + guard let raw = raw as? String else { return nil } + return DoryVMClipboardDirection(rawValue: raw) + } + + private static func decodeEnum( + _ raw: Any?, + field: String, + allowsClears: Bool, + type: Value.Type + ) throws -> DoryMachineTypedSettingUpdate where Value.RawValue == String { + guard let raw else { return .unchanged } + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .clear + } + guard let raw = raw as? String, let value = Value(rawValue: raw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .set(value) + } + + private static func encodeEnum( + _ update: DoryMachineTypedSettingUpdate, + key: String, + into dictionary: inout [String: Any] + ) where Value.RawValue == String { + switch update { + case .unchanged: break + case .clear: dictionary[key] = NSNull() + case let .set(value): dictionary[key] = value.rawValue + } + } + + private static func takeOption( + _ name: String, + from arguments: inout [String] + ) throws -> String? { + guard let index = arguments.firstIndex(of: name) else { return nil } + guard index + 1 < arguments.count else { + throw DoryMachineTypedWriteAuthorityError.invalidField(name) + } + let value = arguments[index + 1] + arguments.removeSubrange(index...(index + 1)) + return value + } + + private static func takeFlag(_ name: String, from arguments: inout [String]) -> Bool { + guard let index = arguments.firstIndex(of: name) else { return false } + arguments.remove(at: index) + return true + } + + private static func encode( + _ update: DoryMachineTypedSettingUpdate, + key: String, + into dictionary: inout [String: Any] + ) { + switch update { + case .unchanged: + break + case .clear: + dictionary[key] = NSNull() + case let .set(value): + dictionary[key] = value + } + } + + private static func apply( + _ update: DoryMachineTypedSettingUpdate, + key: String, + to environment: inout [String: String] + ) { + switch update { + case .unchanged: + break + case .clear: + environment.removeValue(forKey: key) + case let .set(value): + environment[key] = value + } + } +} + +private extension DoryMachineTypedSettingUpdate { + var isSet: Bool { + if case .set = self { return true } + return false + } + + func map( + _ transform: (Value) -> Mapped + ) -> DoryMachineTypedSettingUpdate { + switch self { + case .unchanged: .unchanged + case .clear: .clear + case let .set(value): .set(transform(value)) + } + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 014db761..defa41f8 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -232,7 +232,15 @@ public final class DorydService: NSObject, DorydControl { return } do { - let machine = try DoryMachineConfiguration(xpcDictionary: config) + let typedSettings = try DoryMachineTypedSettingsPatch( + xpcDictionary: config, + allowsClears: false + ) + var machine = try DoryMachineConfiguration(xpcDictionary: config) + machine.environment = try typedSettings.applying( + to: [:], + displayMode: machine.displayMode + ) let status = try machineManager.create(machine) incidentWriter?.record(type: "machine.create", detail: machine.id) reply(true, status.xpcDictionary, "") @@ -279,8 +287,7 @@ public final class DorydService: NSObject, DorydControl { updatesAddress: update.updatesAddress, shares: update.shares, updatesShares: update.updatesShares, - environment: update.environment, - updatesEnvironment: update.updatesEnvironment, + typedSettingsPatch: update.typedSettings.isEmpty ? nil : update.typedSettings, installerMediaAttached: update.installerMediaAttached ) incidentWriter?.record(type: "machine.update", detail: machineID) @@ -1200,8 +1207,7 @@ private struct MachineUpdateRequest { var updatesAddress: Bool var shares: [DoryMachineShareConfiguration]? var updatesShares: Bool - var environment: [String: String]? - var updatesEnvironment: Bool + var typedSettings: DoryMachineTypedSettingsPatch var installerMediaAttached: Bool? init(xpcDictionary dictionary: NSDictionary) throws { @@ -1216,10 +1222,12 @@ private struct MachineUpdateRequest { } self.shares = dictionary["shares"] == nil ? nil : try dictionary.optionalMachineShares("shares") self.updatesShares = dictionary["shares"] != nil - self.environment = dictionary["env"] == nil ? nil : try dictionary.optionalEnvironmentDictionary("env") - self.updatesEnvironment = dictionary["env"] != nil + self.typedSettings = try DoryMachineTypedSettingsPatch( + xpcDictionary: dictionary, + allowsClears: true + ) self.installerMediaAttached = dictionary.optionalBool("installerMediaAttached") - if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment, + if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, typedSettings.isEmpty, installerMediaAttached == nil { throw XPCRemoteConfigError.invalid("config") } @@ -1288,7 +1296,7 @@ private extension DoryMachineConfiguration { address: dictionary.optionalString("address"), displayMode: displayMode, shares: try dictionary.optionalMachineShares("shares"), - environment: try dictionary.optionalEnvironmentDictionary("env") + environment: [:] ) } } @@ -1432,26 +1440,6 @@ private extension NSDictionary { } } - func optionalEnvironmentDictionary(_ key: String) throws -> [String: String] { - guard let raw = self[key] else { return [:] } - guard let rows = raw as? [NSDictionary] else { - throw XPCRemoteConfigError.invalid(key) - } - var result: [String: String] = [:] - for row in rows { - let key = try row.requiredString("key") - guard key.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil else { - throw XPCRemoteConfigError.invalid("env") - } - let value = row.optionalString("value") ?? "" - guard !value.contains("\0") else { - throw XPCRemoteConfigError.invalid("env") - } - result[key] = value - } - return result - } - func optionalString(_ key: String) -> String? { self[key] as? String } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7b60e8bc..fac1d2cd 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1766,10 +1766,16 @@ public final class MachineManager: @unchecked Sendable { updatesShares: Bool = false, environment: [String: String]? = nil, updatesEnvironment: Bool = false, + typedSettingsPatch: DoryMachineTypedSettingsPatch? = nil, installerMediaAttached: Bool? = nil ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + guard !(updatesEnvironment && typedSettingsPatch != nil) else { + throw MachineManagerError.persistence( + "raw environment replacement and typed machine settings are mutually exclusive" + ) + } let (current, wasRunning) = try configurationAndRunningState(id: id) var updated = current if let memoryMB { @@ -1787,6 +1793,12 @@ public final class MachineManager: @unchecked Sendable { if updatesEnvironment { updated.environment = environment ?? [:] } + if let typedSettingsPatch { + updated.environment = try typedSettingsPatch.applying( + to: current.environment, + displayMode: updated.displayMode + ) + } if let installerMediaAttached { guard updated.bootMode == .efi else { throw MachineManagerError.persistence("installer media is only available for EFI machines") diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 04e15936..9a034a1e 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -164,8 +164,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME @@ -504,20 +504,6 @@ func nonNegativeUInt64(_ raw: String, option: String) throws -> UInt64 { return value } -func parseEnvironmentRow(_ raw: String) throws -> NSDictionary { - guard let equals = raw.firstIndex(of: "="), equals != raw.startIndex else { - throw DorydCtlError.usage("--env must be KEY=VALUE") - } - let key = String(raw[.. [NSDictionary] { let data = FileHandle.standardInput.readDataToEndOfFile() guard !data.isEmpty, @@ -533,6 +519,30 @@ func readEnvironmentJSONFromStandardInput() throws -> [NSDictionary] { } } +func parseMachineTypedSettings( + cursor: inout ArgumentCursor, + allowsClears: Bool +) throws -> DoryMachineTypedSettingsPatch { + do { + return try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &cursor.values, + allowsClears: allowsClears + ) + } catch { + throw DorydCtlError.usage("\(error)") + } +} + +func mergeMachineTypedSettings( + _ patch: DoryMachineTypedSettingsPatch, + into configuration: inout [String: Any] +) { + for (rawKey, value) in patch.xpcDictionary { + guard let key = rawKey as? String else { continue } + configuration[key] = value + } +} + func machineExecControlTimeout(timeoutMs: UInt64) -> TimeInterval { let effectiveTimeoutMs: UInt64 switch timeoutMs { @@ -1090,7 +1100,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { throw DorydCtlError.usage("--display-mode must be headless or desktop") } let shares = try cursor.optionValues("--share").map { try DoryMachineShareConfiguration(argument: $0) } - let env = try cursor.optionValues("--env").map(parseEnvironmentRow) + let typedSettings = try parseMachineTypedSettings(cursor: &cursor, allowsClears: false) + let address = try cursor.optionValue("--dns-target") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("unexpected machine create argument: \(cursor.values[0])") + } var stagedInstallerISOPath: String? defer { if let stagedInstallerISOPath { @@ -1117,7 +1131,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { config["installerISOPath"] = stagedInstallerISOPath config["diskSizeBytes"] = diskSizeGB * 1024 * 1024 * 1024 } - if let address = try cursor.optionValue("--dns-target") { + if let address { config["address"] = address } if !shares.isEmpty { @@ -1130,9 +1144,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { ] as NSDictionary } } - if !env.isEmpty { - config["env"] = env - } + mergeMachineTypedSettings(typedSettings, into: &config) // Creating a machine can copy a multi-gigabyte root disk or installer ISO. // Keep the request alive long enough for that transactional mutation to // finish so the CLI cannot report a timeout after the daemon has succeeded. @@ -1301,7 +1313,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro } func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]" + let usage = "usage: dorydctl machine update NAME [resource/network options] [typed guest/desktop/clipboard options] [--attach-installer | --eject-installer]" let name = try cursor.take(usage) var config: [String: Any] = [:] if let memory = try cursor.optionValue("--memory-mb") { @@ -1342,17 +1354,8 @@ func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) thro cursor.values.removeAll { $0 == "--clear-shares" } config["shares"] = [] as [NSDictionary] } - let envValues = try cursor.optionValues("--env") - if !envValues.isEmpty { - config["env"] = try envValues.map(parseEnvironmentRow) - } - if cursor.values.contains("--clear-env") { - guard config["env"] == nil else { - throw DorydCtlError.usage("use either --env or --clear-env, not both") - } - cursor.values.removeAll { $0 == "--clear-env" } - config["env"] = [] as [NSDictionary] - } + let typedSettings = try parseMachineTypedSettings(cursor: &cursor, allowsClears: true) + mergeMachineTypedSettings(typedSettings, into: &config) let attachInstaller = cursor.values.contains("--attach-installer") let ejectInstaller = cursor.values.contains("--eject-installer") guard !attachInstaller || !ejectInstaller else { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift new file mode 100644 index 00000000..01296261 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -0,0 +1,285 @@ +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("Typed machine write authority") +struct DoryMachineTypedWriteAuthorityTests { + @Test("typed wire round trip is exact and contains no raw environment") + func wireRoundTrip() throws { + let source = DoryMachineTypedSettingsPatch( + guestUsername: .set("developer"), + guestNumericUserID: .set(1_000), + desktopDistributionIdentifier: .set("ubuntu"), + desktopDisplayName: .set("Ubuntu"), + desktopVersion: .set("24.04"), + desktopEnvironment: .set("GNOME"), + clipboardPolicy: .set(.legacyDesktop(.hostToGuest)), + runtimePreference: .set(.accelerated), + graphicsPreference: .set(.virglVenus) + ) + + let wire = source.xpcDictionary + #expect(wire["env"] == nil) + #expect(try DoryMachineTypedSettingsPatch( + xpcDictionary: wire, + allowsClears: false + ) == source) + } + + @Test("raw environment write authority is rejected even when empty") + func rejectsRawEnvironment() { + for raw: Any in [NSArray(), NSNull(), [["key": "SAFE", "value": "value"]]] { + let dictionary: NSDictionary = ["env": raw] + #expect(throws: DoryMachineTypedWriteAuthorityError.rawEnvironmentForbidden) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: dictionary, + allowsClears: true + ) + } + } + } + + @Test("update applies fields locally and preserves opaque legacy values") + func fieldLocalBackProjection() throws { + let legacy = [ + "DORY_GUEST_USER": "../../unsafe-old-user", + "DORY_GUEST_UID": "not-a-uid", + "DORY_DESKTOP_DISTRO": "unsafe://old", + "DORY_VIRGL_CLASSIC_ONLY": "1", + "PRIVATE_TOKEN": "must-remain-opaque", + ] + let patch = DoryMachineTypedSettingsPatch( + desktopDisplayName: .set("Ubuntu") + ) + + let projected = try patch.applying(to: legacy, displayMode: .desktop) + + #expect(projected["DORY_DESKTOP_NAME"] == "Ubuntu") + #expect(projected["DORY_GUEST_USER"] == "../../unsafe-old-user") + #expect(projected["DORY_GUEST_UID"] == "not-a-uid") + #expect(projected["DORY_DESKTOP_DISTRO"] == "unsafe://old") + #expect(projected["DORY_VIRGL_CLASSIC_ONLY"] == "1") + #expect(projected["PRIVATE_TOKEN"] == "must-remain-opaque") + + let runtime = try DoryMachineTypedSettingsPatch( + runtimePreference: .set(.compatible), + graphicsPreference: .set(.software) + ).applying(to: legacy, displayMode: .desktop) + #expect(runtime["DORY_DESKTOP_VMM"] == "compatible") + #expect(runtime["DORY_DESKTOP_GRAPHICS"] == "software") + } + + @Test("update clear is explicit and field scoped") + func explicitClear() throws { + let source = DoryMachineTypedSettingsPatch( + guestUsername: .clear, + desktopVersion: .clear, + clipboardPolicy: .clear + ) + let wire = source.xpcDictionary + let decoded = try DoryMachineTypedSettingsPatch( + xpcDictionary: wire, + allowsClears: true + ) + let projected = try decoded.applying( + to: [ + "DORY_GUEST_USER": "developer", + "DORY_GUEST_UID": "1000", + "DORY_DESKTOP_VERSION": "24.04", + "DORY_DESKTOP_NAME": "Ubuntu", + "DORY_CLIPBOARD_POLICY": "bidirectional", + ], + displayMode: .desktop + ) + + #expect(projected["DORY_GUEST_USER"] == nil) + #expect(projected["DORY_DESKTOP_VERSION"] == nil) + #expect(projected["DORY_CLIPBOARD_POLICY"] == nil) + #expect(projected["DORY_GUEST_UID"] == "1000") + #expect(projected["DORY_DESKTOP_NAME"] == "Ubuntu") + #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField( + "guestIdentityIntent.account.username" + )) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: wire, + allowsClears: false + ) + } + } + + @Test("hostile and out of range guest fields fail closed") + func hostileGuestFields() { + let cases: [NSDictionary] = [ + ["guestIdentityIntent": ["account": ["username": "../../host"]]], + ["guestIdentityIntent": ["account": ["numericUserID": 99]]], + ["guestIdentityIntent": ["account": ["numericUserID": 60_001]]], + ["guestIdentityIntent": ["desktop": ["displayName": "token\nvalue"]]], + ["guestIdentityIntent": ["desktop": ["displayName": "a" + String(repeating: "\u{301}", count: 128)]]], + ["guestIdentityIntent": ["unexpected": "value"]], + ["guestIdentityIntent": ["account": ["username": "developer", "secret": "x"]]], + ] + for dictionary in cases { + #expect(throws: (any Error).self) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: dictionary, + allowsClears: false + ) + } + } + } + + @Test("current compatibility runtime rejects widening clipboard or desktop on headless") + func runtimeRepresentability() throws { + let widened = DoryMachineTypedSettingsPatch(clipboardPolicy: .set( + DoryVMClipboardPolicy( + text: .hostToGuest, + image: .guestToHost, + files: .off + ) + )) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "clipboardPolicy" + )) { + try widened.applying(to: [:], displayMode: .desktop) + } + + let files = DoryMachineTypedSettingsPatch(clipboardPolicy: .set( + DoryVMClipboardPolicy(text: .off, image: .off, files: .hostToGuest) + )) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "clipboardPolicy" + )) { + try files.applying(to: [:], displayMode: .desktop) + } + + let desktop = DoryMachineTypedSettingsPatch(desktopDisplayName: .set("Ubuntu")) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "guestIdentityIntent.desktop" + )) { + try desktop.applying(to: [:], displayMode: .headless) + } + + let enabledClipboard = DoryMachineTypedSettingsPatch( + clipboardPolicy: .set(.legacyDesktop(.bidirectional)) + ) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "clipboardPolicy" + )) { + try enabledClipboard.applying(to: [:], displayMode: .headless) + } + #expect(try DoryMachineTypedSettingsPatch( + clipboardPolicy: .set(.disabled) + ).applying(to: [:], displayMode: .headless)["DORY_CLIPBOARD_POLICY"] == "off") + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "desktopRuntimePreference" + )) { + try DoryMachineTypedSettingsPatch( + runtimePreference: .set(.accelerated) + ).applying(to: [:], displayMode: .headless) + } + } + + @Test("clipboard wire shape is complete and exact") + func exactClipboardWireShape() { + let cases: [NSDictionary] = [ + ["clipboardPolicy": ["text": "off", "image": "off"]], + [ + "clipboardPolicy": [ + "text": "off", + "image": "off", + "files": "off", + "token": "secret", + ], + ], + [ + "clipboardPolicy": [ + "text": "invalid", + "image": "off", + "files": "off", + ], + ], + ] + for dictionary in cases { + #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField( + "clipboardPolicy" + )) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: dictionary, + allowsClears: false + ) + } + } + } + + @Test("CLI consumes typed persistence options and leaves raw env unconsumed") + func cliTypedOptions() throws { + var arguments = [ + "--memory-mb", "4096", + "--guest-user", "developer", + "--guest-uid", "1000", + "--desktop-distro", "ubuntu", + "--desktop-name", "Ubuntu", + "--desktop-version", "24.04", + "--desktop-environment", "GNOME", + "--clipboard", "guest-to-host", + "--runtime", "compatible", + "--graphics", "software", + "--env", "TOKEN=secret", + ] + + let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &arguments, + allowsClears: false + ) + + #expect(patch.guestUsername == .set("developer")) + #expect(patch.guestNumericUserID == .set(1_000)) + #expect(patch.desktopDistributionIdentifier == .set("ubuntu")) + #expect(patch.clipboardPolicy == .set(.legacyDesktop(.guestToHost))) + #expect(patch.runtimePreference == .set(.compatible)) + #expect(patch.graphicsPreference == .set(.software)) + #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"]) + #expect(patch.xpcDictionary["env"] == nil) + } + + @Test("CLI clear groups are explicit and conflict with replacement values") + func cliClearSemantics() throws { + var clearing = [ + "--clear-guest-account", + "--clear-desktop-identity", + "--clear-clipboard", + "--clear-runtime", + "--clear-graphics", + ] + let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &clearing, + allowsClears: true + ) + #expect(clearing.isEmpty) + #expect(patch.guestUsername == .clear) + #expect(patch.guestNumericUserID == .clear) + #expect(patch.desktopDistributionIdentifier == .clear) + #expect(patch.desktopDisplayName == .clear) + #expect(patch.clipboardPolicy == .clear) + #expect(patch.runtimePreference == .clear) + #expect(patch.graphicsPreference == .clear) + + var createClear = ["--clear-clipboard"] + #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("clear options")) { + try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &createClear, + allowsClears: false + ) + } + var conflict = ["--guest-user", "developer", "--clear-guest-account"] + #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField( + "--clear-guest-account" + )) { + try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &conflict, + allowsClears: true + ) + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 36b9bf35..ed488636 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -3,6 +3,16 @@ import DoryCore import XCTest final class DorydServiceTests: XCTestCase { + private static func environmentValues(_ body: NSDictionary) -> [String: String] { + let rows = body["env"] as? [NSDictionary] ?? [] + return Dictionary(uniqueKeysWithValues: rows.compactMap { row in + guard let key = row["key"] as? String, let value = row["value"] as? String else { + return nil + } + return (key, value) + }) + } + func testPublishedPortRepairDetailUsesValidatedGvproxyReceiptCounts() { let startedAt = Date() let receipt = PublishedPortReconcileReceipt( @@ -1076,19 +1086,25 @@ final class DorydServiceTests: XCTestCase { "memoryMB": 1024, "cpuCount": 2, "address": "192.168.215.40", - "env": [ - [ - "key": "APP_ENV", - "value": "dev", + "guestIdentityIntent": [ + "account": [ + "username": "developer", + "numericUserID": UInt32(1_000), ] as NSDictionary, - ], + ] as NSDictionary, + "clipboardPolicy": [ + "text": "off", + "image": "off", + "files": "off", + ] as NSDictionary, ]) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "created") XCTAssertEqual(body["address"] as? String, "192.168.215.40") - let env = body["env"] as? [NSDictionary] - XCTAssertEqual(env?.first?["key"] as? String, "APP_ENV") - XCTAssertEqual(env?.first?["value"] as? String, "dev") + let values = Self.environmentValues(body) + XCTAssertEqual(values["DORY_GUEST_USER"], "developer") + XCTAssertEqual(values["DORY_GUEST_UID"], "1000") + XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off") create.fulfill() } wait(for: [create], timeout: 5) @@ -1113,7 +1129,7 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(statuses?.first?["id"] as? String, "dev") XCTAssertEqual(statuses?.first?["address"] as? String, "192.168.215.40") let env = statuses?.first?["env"] as? [NSDictionary] - XCTAssertEqual(env?.first?["value"] as? String, "dev") + XCTAssertTrue(env?.contains { $0["value"] as? String == "developer" } == true) list.fulfill() } wait(for: [list], timeout: 5) @@ -1139,12 +1155,11 @@ final class DorydServiceTests: XCTestCase { "readOnly": true, ] as NSDictionary, ], - "env": [ - [ - "key": "NODE_ENV", - "value": "production", + "guestIdentityIntent": [ + "account": [ + "username": "builder", ] as NSDictionary, - ], + ] as NSDictionary, ]) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "stopped") @@ -1155,9 +1170,10 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(shares?.first?["hostPath"] as? String, share) XCTAssertEqual(shares?.first?["guestPath"] as? String, "/workspace/src") XCTAssertEqual(shares?.first?["readOnly"] as? Bool, true) - let env = body["env"] as? [NSDictionary] - XCTAssertEqual(env?.first?["key"] as? String, "NODE_ENV") - XCTAssertEqual(env?.first?["value"] as? String, "production") + let values = Self.environmentValues(body) + XCTAssertEqual(values["DORY_GUEST_USER"], "builder") + XCTAssertEqual(values["DORY_GUEST_UID"], "1000") + XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off") update.fulfill() } wait(for: [update], timeout: 5) @@ -1181,6 +1197,129 @@ final class DorydServiceTests: XCTestCase { wait(for: [delete], timeout: 5) } + func testMachineWritesRequireTypedIntentAndPreserveLegacyEnvironmentFieldLocally() throws { + let base = "/tmp/doryd-service-typed-write-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 16:39:07 +0000 Subject: [PATCH 032/338] feat(vm): add atomic planning transactions --- .../DoryVirtualMachineBackendPlanner.swift | 43 +- .../DoryVirtualMachineCapabilities.swift | 21 + ...yVirtualMachineQualificationManifest.swift | 6 + ...achinePlanningTransactionCoordinator.swift | 1313 +++++++++++++++++ ...yDaemonVirtualMachineProductionTrust.swift | 263 ++++ ...yDaemonVirtualMachineRuntimePlanning.swift | 27 +- ...irtualMachineResourceAdmissionLedger.swift | 87 ++ ...oryVirtualMachineBackendPlannerTests.swift | 66 + ...ePlanningTransactionCoordinatorTests.swift | 651 ++++++++ ...onVirtualMachineProductionTrustTests.swift | 97 ++ ...lMachineResourceAdmissionLedgerTests.swift | 81 + 11 files changed, 2646 insertions(+), 9 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift index d00b0d60..70ce50d2 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift @@ -158,7 +158,9 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner { trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil, trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil, trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil, - trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [] + trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [], + trustedCapabilityQualifications: + [DoryTrustedVirtualMachineCapabilityQualification] = [] ) -> DoryVirtualMachineBackendPlanResult { if request.acceptableGraphics.isEmpty { return invalidPreference( @@ -222,17 +224,27 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner { devices: request.devices, virtualHardwareABIVersion: request.virtualHardwareABIVersion ) + let exactQualification = matchingCapabilityQualification( + for: capabilityRequest, + host: host, + in: trustedCapabilityQualifications + ) evaluated.append(DoryAppleSiliconCapabilityEvaluator.evaluate( capabilityRequest, host: host, - trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification, + trustedGuestImageGraphicsQualification: + exactQualification?.graphics + ?? (trustedCapabilityQualifications.isEmpty + ? trustedGuestImageGraphicsQualification : nil), trustedBootMediaInspection: trustedBootMediaInspection, trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance, - trustedRuntimeQualification: matchingRuntimeQualification( - for: capabilityRequest, - host: host, - in: trustedRuntimeQualifications - ) + trustedRuntimeQualification: exactQualification?.runtime + ?? (trustedCapabilityQualifications.isEmpty + ? matchingRuntimeQualification( + for: capabilityRequest, + host: host, + in: trustedRuntimeQualifications + ) : nil) )) } } @@ -320,6 +332,23 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner { } } + private static func matchingCapabilityQualification( + for request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, + in qualifications: [DoryTrustedVirtualMachineCapabilityQualification] + ) -> DoryTrustedVirtualMachineCapabilityQualification? { + guard let hostContext = host.runtimeQualificationContext else { return nil } + let matches = qualifications.filter { qualification in + guard qualification.request == request else { return false } + let evidence = qualification.runtime.auditEvidence + return evidence.backendRuntimeBuildID + == hostContext.runtimeBuildID(for: request.backend) + && evidence.virtualHardwareABIVersion + == hostContext.virtualHardwareABIVersion + } + return matches.count == 1 ? matches[0] : nil + } + private static func invalidPreference( field: DoryVirtualMachineBackendPreferenceField, issue: DoryVirtualMachineBackendPreferenceIssue, diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index c03b3069..8f7b18a7 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -505,6 +505,27 @@ public struct DoryTrustedVirtualMachineRuntimeQualification: Sendable, Equatable } } +/// Opaque authority bundle for one exact capability request. Keeping runtime and guest-graphics +/// facts paired prevents inventory order from applying one signed image qualification to a +/// different backend, graphics level, device contract, or runtime build. +public struct DoryTrustedVirtualMachineCapabilityQualification: + Sendable, Equatable, Hashable +{ + let request: DoryVirtualMachineCapabilityRequest + let runtime: DoryTrustedVirtualMachineRuntimeQualification + let graphics: DoryTrustedGuestImageGraphicsQualification? + + init( + request: DoryVirtualMachineCapabilityRequest, + runtime: DoryTrustedVirtualMachineRuntimeQualification, + graphics: DoryTrustedGuestImageGraphicsQualification? + ) { + self.request = request + self.runtime = runtime + self.graphics = graphics + } +} + /// A request is deliberately free of detected host state so it can cross process and API /// boundaries and be evaluated by the daemon that owns the virtualization components. public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, Hashable { diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift index 8267611f..6c4d57f9 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift @@ -246,6 +246,11 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { record: record, runtime: runtime, graphics: graphics, + capabilityQualification: DoryTrustedVirtualMachineCapabilityQualification( + request: request, + runtime: runtime, + graphics: graphics + ), manifestIdentity: manifestIdentity, manifestSHA256: manifestSHA256, signingKeyID: signingKeyID @@ -267,6 +272,7 @@ public struct DoryResolvedTrustedVirtualMachineQualification: Sendable { public let record: DoryVirtualMachineQualificationRecord public let runtime: DoryTrustedVirtualMachineRuntimeQualification public let graphics: DoryTrustedGuestImageGraphicsQualification? + public let capabilityQualification: DoryTrustedVirtualMachineCapabilityQualification fileprivate let manifestIdentity: String fileprivate let manifestSHA256: String fileprivate let signingKeyID: String diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift new file mode 100644 index 00000000..01f6dea1 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -0,0 +1,1313 @@ +import CryptoKit +import Darwin +import DoryOperations +import Foundation + +public enum DoryDaemonVirtualMachineWorkspacePublication: Codable, Sendable, Equatable { + case create + case replace(expectedRevision: UInt64) + /// The exact canonical definition is already authoritative (including a facts-bound legacy + /// projection). Planning publishes only the resolved plan and never spuriously bumps it. + case retainExistingExact +} + +public struct DoryDaemonVirtualMachinePlanningTransactionRequest: Sendable { + public var planning: DoryDaemonVirtualMachinePlanningRequest + public var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication + public var resourceRequirements: [DoryVMResourceRequirement] + public var startingLeaseDurationMilliseconds: Int64 + + public init( + planning: DoryDaemonVirtualMachinePlanningRequest, + workspacePublication: DoryDaemonVirtualMachineWorkspacePublication, + resourceRequirements: [DoryVMResourceRequirement] = [], + startingLeaseDurationMilliseconds: Int64 = 120_000 + ) { + self.planning = planning + self.workspacePublication = workspacePublication + self.resourceRequirements = resourceRequirements + self.startingLeaseDurationMilliseconds = startingLeaseDurationMilliseconds + } +} + +/// One-use daemon authority. It re-resolves media, helper, host identity, and signed +/// qualification immediately before desired-state publication. Start obtains a separate token. +public final class DoryDaemonVirtualMachinePlanningPublicationAuthorization: + @unchecked Sendable +{ + private let lock = NSLock() + private var consumed = false + private let operation: @Sendable () throws -> Void + + init(operation: @escaping @Sendable () throws -> Void) { + self.operation = operation + } + + public func authorize() throws { + try lock.withLock { + guard !consumed else { + throw DoryDaemonVirtualMachinePlanningTransactionFailure( + code: .publicationAuthorizationRejected, + message: "Planning publication authorization was already consumed." + ) + } + consumed = true + try operation() + } + } +} + +/// Daemon-owned, non-Codable resolution. API/UI callers cannot supply trusted facts or tokens. +public struct DoryDaemonVirtualMachinePlanningTrustPreparation: Sendable { + public var hostResources: DoryVMHostResources + public var snapshot: @Sendable ( + DoryResolvedMachineResourceAdmissionEvidence + ) -> DoryDaemonVirtualMachineTrustedInventorySnapshot + public var publicationAuthorization: + DoryDaemonVirtualMachinePlanningPublicationAuthorization + + public init( + hostResources: DoryVMHostResources, + snapshot: @escaping @Sendable ( + DoryResolvedMachineResourceAdmissionEvidence + ) -> DoryDaemonVirtualMachineTrustedInventorySnapshot, + publicationAuthorization: + DoryDaemonVirtualMachinePlanningPublicationAuthorization + ) { + self.hostResources = hostResources + self.snapshot = snapshot + self.publicationAuthorization = publicationAuthorization + } +} + +public protocol DoryDaemonVirtualMachinePlanningTrustPreparing: Sendable { + func preparePlanningTrust( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation +} + +/// Held for the whole transaction. A MachineManager-backed implementation owns the lifecycle +/// operation fence and validates authoritative machine bytes/migration facts against `definition`. +/// The coordinator has no unsafe default: production composition must provide this authority. +public final class DoryDaemonVirtualMachinePlanningMutationFence: @unchecked Sendable { + public let authoritySHA256: String + private let validation: @Sendable () throws -> Void + private let retainedAuthority: any Sendable + + public init( + authoritySHA256: String, + retainedAuthority: any Sendable, + validation: @escaping @Sendable () throws -> Void + ) { + self.authoritySHA256 = authoritySHA256.lowercased() + self.retainedAuthority = retainedAuthority + self.validation = validation + } + + public func revalidate() throws { + _ = retainedAuthority + try validation() + } +} + +public protocol DoryDaemonVirtualMachinePlanningMutationAuthorizing: Sendable { + func acquirePlanningMutationFence( + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data + ) throws -> DoryDaemonVirtualMachinePlanningMutationFence +} + +public enum DoryDaemonVirtualMachinePlanningTransactionFailureCode: + String, Sendable, Equatable +{ + case invalidRequest = "invalid-request" + case mutationAuthorityRejected = "mutation-authority-rejected" + case transactionConflict = "transaction-conflict" + case transactionAborted = "transaction-aborted" + case recoveryRequired = "recovery-required" + case trustUnavailable = "trust-unavailable" + case resourceReservationRejected = "resource-reservation-rejected" + case planConstructionRejected = "plan-construction-rejected" + case planBindingRejected = "plan-binding-rejected" + case publicationAuthorizationRejected = "publication-authorization-rejected" + case workspacePublicationRejected = "workspace-publication-rejected" + case planPublicationRejected = "plan-publication-rejected" + case invalidJournal = "invalid-journal" + case filesystem = "filesystem" +} + +public struct DoryDaemonVirtualMachinePlanningTransactionFailure: + Error, Sendable, Equatable +{ + public var code: DoryDaemonVirtualMachinePlanningTransactionFailureCode + public var message: String + + public init( + code: DoryDaemonVirtualMachinePlanningTransactionFailureCode, + message: String + ) { + self.code = code + self.message = message + } +} + +public struct DoryDaemonVirtualMachinePlanningTransactionResult: Sendable { + public var planning: DoryDaemonVirtualMachinePlanningResult + public var lease: DoryVirtualMachineResourceAdmissionLease + public var transactionID: String + + public init( + planning: DoryDaemonVirtualMachinePlanningResult, + lease: DoryVirtualMachineResourceAdmissionLease, + transactionID: String + ) { + self.planning = planning + self.lease = lease + self.transactionID = transactionID + } +} + +public protocol DoryWorkspaceDefinitionStoring: Sendable { + func create(_ definition: DoryVirtualMachineDefinition) throws + func replace(_ definition: DoryVirtualMachineDefinition, expectedRevision: UInt64) throws + func read(id: String) throws -> DoryVirtualMachineDefinition + func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition? +} + +extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring { + public func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition? { + do { return try read(id: id) } + catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil } + } +} + +public protocol DoryPlanningTransactionResolvedPlanStoring: + DoryResolvedMachinePlanStoring +{ + func readIfPresent(id: String) throws -> DoryResolvedMachinePlan? +} + +extension DoryResolvedMachinePlanRepository: DoryPlanningTransactionResolvedPlanStoring { + public func readIfPresent(id: String) throws -> DoryResolvedMachinePlan? { + do { return try read(id: id) } + catch DoryResolvedMachinePlanRepositoryError.planNotFound { return nil } + } +} + +/// Crash-safe reserve -> exact-plan-bind -> desired-state publication transaction. +/// +/// `DoryWorkspaceRepository` and `DoryResolvedMachinePlanRepository` remain independently atomic. +/// This coordinator serializes all participating transaction writers with a per-workspace flock; +/// the durable journal makes the two publications recoverable and every intermediate state fails +/// closed because definition and plan digests/revisions cannot both match until completion. +public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: + @unchecked Sendable +{ + public enum PublicationStage: Sendable, Equatable { + case preparedJournalPublished + case resourceReservationCommitted + case reservedJournalPublished + case candidateJournalPublished + case planBindingCommitted + case boundJournalPublished + case publicationAuthorized + case workspacePublished + case workspaceJournalPublished + case planPublished + case planJournalPublished + case completeJournalPublished + } + + private enum Phase: String, Codable, Sendable { + case prepared + case reserved + case candidatePrepared = "candidate-prepared" + case bound + case workspacePublished = "workspace-published" + case planPublished = "plan-published" + case complete + case aborted + case recoveryRequired = "recovery-required" + } + + private struct Journal: Codable, Sendable, Equatable { + static let schemaVersion: UInt16 = 1 + var schemaVersion: UInt16 = schemaVersion + var transactionID: String + var phase: Phase + var requestSHA256: String + var machineAuthoritySHA256: String + var definition: DoryVirtualMachineDefinition + var definitionSHA256: String + var sourceDefinitionSHA256: String? + var sourceDefinitionRevision: UInt64? + var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication + var planPublication: PlanPublicationRecord + var sourcePlanSHA256: String? + var sourcePlanRevision: UInt64? + var plannedAtUnixMilliseconds: Int64 + var leaseID: String? + var leaseRevision: UInt64? + /// Exact sorted-key plan bytes. Recovery publishes these bytes, never a live re-plan. + var candidatePlanData: Data? + var candidatePlanSHA256: String? + var candidatePlannerRequest: DoryVirtualMachineBackendPlanRequest? + var candidatePlannerResult: DoryVirtualMachineBackendPlanResult? + } + + private enum PlanPublicationRecord: Codable, Sendable, Equatable { + case create + case replace(expectedRevision: UInt64) + + init(_ publication: DoryDaemonVirtualMachinePlanPublication) { + switch publication { + case .create: self = .create + case let .replace(expected): self = .replace(expectedRevision: expected) + } + } + } + + private struct JournalEnvelope: Codable { + var journalSHA256: String + var journal: Journal + } + + private final class CapturePlanStore: DoryResolvedMachinePlanStoring, + @unchecked Sendable + { + private let source: any DoryPlanningTransactionResolvedPlanStoring + private(set) var captured: DoryResolvedMachinePlan? + + init(source: any DoryPlanningTransactionResolvedPlanStoring) { self.source = source } + func create(_ plan: DoryResolvedMachinePlan) throws { captured = plan } + func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws { + _ = expectedPlanRevision + captured = plan + } + func read(id: String) throws -> DoryResolvedMachinePlan { try source.read(id: id) } + } + + private struct FixedInventory: DoryDaemonVirtualMachineTrustInventory { + var snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + func planningInventory( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + _ = request + return snapshot + } + func startInventory( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + _ = request + throw DoryDaemonVirtualMachinePlanningTransactionFailure( + code: .invalidRequest, + message: "Planning inventory cannot authorize start." + ) + } + } + + private static let journalFileName = "planning-transaction-v1.json" + private static let lockFileName = ".planning-transaction.lock" + private static let temporaryPrefix = ".planning-transaction." + private static let maximumJournalBytes = 16 * 1_024 * 1_024 + + private let stateDirectory: String + private let registry: BackendRegistry + private let trust: any DoryDaemonVirtualMachinePlanningTrustPreparing + private let mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing + private let workspaces: any DoryWorkspaceDefinitionStoring + private let plans: any DoryPlanningTransactionResolvedPlanStoring + private let ledger: DoryVirtualMachineResourceAdmissionLedger + private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning + private let now: @Sendable () -> Int64 + private let faultInjector: (@Sendable (PublicationStage) throws -> Void)? + private let instanceLock = NSLock() + + public init( + stateDirectory: String, + registry: BackendRegistry, + trust: any DoryDaemonVirtualMachinePlanningTrustPreparing, + mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing, + workspaces: any DoryWorkspaceDefinitionStoring, + plans: any DoryPlanningTransactionResolvedPlanStoring, + ledger: DoryVirtualMachineResourceAdmissionLedger, + capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning = + DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(), + now: @escaping @Sendable () -> Int64 = { + Int64((Date().timeIntervalSince1970 * 1_000).rounded(.towardZero)) + } + ) { + self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path + self.registry = registry + self.trust = trust + self.mutationAuthority = mutationAuthority + self.workspaces = workspaces + self.plans = plans + self.ledger = ledger + self.capabilityPlanner = capabilityPlanner + self.now = now + faultInjector = nil + } + + init( + stateDirectory: String, + registry: BackendRegistry, + trust: any DoryDaemonVirtualMachinePlanningTrustPreparing, + mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing, + workspaces: any DoryWorkspaceDefinitionStoring, + plans: any DoryPlanningTransactionResolvedPlanStoring, + ledger: DoryVirtualMachineResourceAdmissionLedger, + capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning, + now: @escaping @Sendable () -> Int64, + faultInjector: @escaping @Sendable (PublicationStage) throws -> Void + ) { + self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path + self.registry = registry + self.trust = trust + self.mutationAuthority = mutationAuthority + self.workspaces = workspaces + self.plans = plans + self.ledger = ledger + self.capabilityPlanner = capabilityPlanner + self.now = now + self.faultInjector = faultInjector + } + + public func resolveReserveAndPublish( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { + try instanceLock.withLock { + try withWorkspaceLock(machineID: request.planning.definition.identity.id) { + try execute(request) + } + } + } + + private func execute( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { + try validate(request) + let canonicalDefinition = DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(request.planning.definition) + let mutationFence: DoryDaemonVirtualMachinePlanningMutationFence + do { + mutationFence = try mutationAuthority.acquirePlanningMutationFence( + machine: request.planning.machine, + definition: request.planning.definition, + canonicalDefinitionData: canonicalDefinition + ) + try mutationFence.revalidate() + } catch { + throw failure( + .mutationAuthorityRejected, + "Authoritative machine state is live, stale, or inconsistent with the workspace." + ) + } + guard Self.isSHA256(mutationFence.authoritySHA256) else { + throw failure(.mutationAuthorityRejected, "Machine authority digest is invalid.") + } + let requestDigest = Self.requestDigest( + request, + canonicalDefinition: canonicalDefinition, + machineAuthoritySHA256: mutationFence.authoritySHA256 + ) + var journal = try loadOrPrepare( + request, + requestDigest: requestDigest, + machineAuthoritySHA256: mutationFence.authoritySHA256, + canonicalDefinition: canonicalDefinition + ) + switch journal.phase { + case .aborted: + throw failure(.transactionAborted, "The exact planning transaction was aborted.") + case .recoveryRequired: + try resumeRecoveryRequired(&journal) + case .complete: + return try completedResult(request, journal: journal) + default: break + } + + let inventoryRequest = try makeInventoryRequest(request.planning.definition) + let preparation: DoryDaemonVirtualMachinePlanningTrustPreparation + do { preparation = try trust.preparePlanningTrust(for: inventoryRequest) } + catch { + try abortOrRetainAfterTrustFailure(&journal) + throw failure(.trustUnavailable, "Exact daemon planning trust is unavailable.") + } + + var lease = try reserveOrAdopt( + request, + journal: &journal, + hostResources: preparation.hostResources, + canonicalDefinition: canonicalDefinition, + mutationFence: mutationFence + ) + + let planning: DoryDaemonVirtualMachinePlanningResult + do { + planning = try constructOrRecoverCandidate( + request, + journal: &journal, + admission: lease.evidence, + preparation: preparation + ) + } catch { + if lease.boundPlanSHA256 == nil { try? abortUnboundIfPresent(&journal) } + throw error + } + try faultInjector?(.candidateJournalPublished) + + if lease.boundPlanSHA256 == nil { + do { + lease = try ledger.bind( + leaseID: lease.leaseID, + to: planning.resolvedPlan, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + try? abortUnboundIfPresent(&journal) + throw failure(.planBindingRejected, "The resource lease could not bind exact plan bytes.") + } + try faultInjector?(.planBindingCommitted) + } else if lease.boundPlanSHA256 != planning.resolvedPlanSHA256 { + try markRecoveryRequired(&journal) + throw failure(.recoveryRequired, "The resource lease is bound to different plan bytes.") + } + journal.leaseRevision = lease.leaseRevision + journal.phase = .bound + try publishJournal(journal) + try faultInjector?(.boundJournalPublished) + + do { + try preparation.publicationAuthorization.authorize() + try mutationFence.revalidate() + } catch { + try markRecoveryRequired(&journal) + throw failure( + .publicationAuthorizationRejected, + "Fresh media, runtime, host, or qualification evidence changed before publication." + ) + } + try faultInjector?(.publicationAuthorized) + + try publishWorkspace(request, journal: &journal) + try publishPlan(request, planning: planning, journal: &journal) + journal.phase = .complete + try publishJournal(journal) + try faultInjector?(.completeJournalPublished) + return DoryDaemonVirtualMachinePlanningTransactionResult( + planning: planning, + lease: lease, + transactionID: journal.transactionID + ) + } + + private func loadOrPrepare( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + requestDigest: String, + machineAuthoritySHA256: String, + canonicalDefinition: Data + ) throws -> Journal { + if let existing = try readJournal(machineID: request.planning.definition.identity.id) { + if existing.requestSHA256 == requestDigest { return existing } + guard existing.phase == .complete || existing.phase == .aborted else { + throw failure(.transactionConflict, "Another planning transaction is incomplete.") + } + } + let timestamp = now() + guard timestamp > 0 else { throw failure(.invalidRequest, "Planning time is invalid.") } + let sourcePlan: DoryResolvedMachinePlan? + let existingDefinition = try workspaces.readIfPresent( + id: request.planning.definition.identity.id + ) + sourcePlan = try plans.readIfPresent(id: request.planning.definition.identity.id) + switch request.workspacePublication { + case .create: + guard existingDefinition == nil else { + throw failure(.transactionConflict, "Workspace already exists before create.") + } + case let .replace(expected): + guard let existingDefinition, + existingDefinition.lifecycle.revision == expected else { + throw failure(.transactionConflict, "Workspace replacement source is stale.") + } + case .retainExistingExact: + guard let existingDefinition, + existingDefinition == request.planning.definition else { + throw failure(.transactionConflict, "Retained workspace is not exact.") + } + } + switch request.planning.publication { + case .create: + guard sourcePlan == nil else { + throw failure(.transactionConflict, "Resolved plan already exists before create.") + } + case let .replace(expected): + guard sourcePlan?.planRevision == expected else { + throw failure(.transactionConflict, "Resolved-plan replacement source is stale.") + } + } + let journal = Journal( + transactionID: "planning-transaction-\(UUID().uuidString.lowercased())", + phase: .prepared, + requestSHA256: requestDigest, + machineAuthoritySHA256: machineAuthoritySHA256, + definition: request.planning.definition, + definitionSHA256: Self.sha256(canonicalDefinition), + sourceDefinitionSHA256: existingDefinition.map { + Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator.canonicalDefinitionData($0)) + }, + sourceDefinitionRevision: existingDefinition?.lifecycle.revision, + workspacePublication: request.workspacePublication, + planPublication: PlanPublicationRecord(request.planning.publication), + sourcePlanSHA256: sourcePlan.map(DoryDaemonVirtualMachinePlanningCoordinator.planSHA256), + sourcePlanRevision: sourcePlan?.planRevision, + plannedAtUnixMilliseconds: timestamp, + leaseID: nil, + leaseRevision: nil, + candidatePlanData: nil, + candidatePlanSHA256: nil, + candidatePlannerRequest: nil, + candidatePlannerResult: nil + ) + try publishJournal(journal) + try faultInjector?(.preparedJournalPublished) + return journal + } + + private func reserveOrAdopt( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + journal: inout Journal, + hostResources: DoryVMHostResources, + canonicalDefinition: Data, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence + ) throws -> DoryVirtualMachineResourceAdmissionLease { + let binding = DoryVirtualMachineResourceAdmissionPlanBinding( + machineID: request.planning.definition.identity.id, + definitionRevision: request.planning.definition.lifecycle.revision, + definitionSHA256: Self.sha256(canonicalDefinition), + plannedPlanRevision: Self.plannedPlanRevision(request.planning.publication) + ) + if let leaseID = journal.leaseID { + let candidates = try ledger.snapshot().leases.filter { $0.leaseID == leaseID } + guard candidates.count == 1, let lease = candidates.first, + lease.binding == binding, + Self.sameStableAdmissionHost(lease.hostFacts, hostResources), + lease.resources == request.planning.definition.resources, + lease.workload == request.planning.definition.workload, + lease.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder) else { + throw failure(.recoveryRequired, "The journaled resource lease cannot be adopted exactly.") + } + if lease.state == .stopped, lease.boundPlanSHA256 == nil { + let reactivated: DoryVirtualMachineResourceAdmissionLease + do { + reactivated = try ledger.reserveStarting( + binding: binding, + hostFacts: hostResources, + workload: request.planning.definition.workload, + requirements: request.resourceRequirements, + resources: request.planning.definition.resources, + startingLeaseDurationMilliseconds: + request.startingLeaseDurationMilliseconds + ) + } catch { + throw failure(.resourceReservationRejected, "Stopped planning lease cannot be reactivated.") + } + guard reactivated.leaseID == leaseID, + reactivated.boundPlanSHA256 == nil else { + throw failure(.recoveryRequired, "Reactivated lease identity changed.") + } + journal.leaseRevision = reactivated.leaseRevision + try publishJournal(journal) + return reactivated + } + if lease.state == .recoveryRequired, + let planData = journal.candidatePlanData, + let planDigest = journal.candidatePlanSHA256, + lease.boundPlanSHA256 == planDigest, + let plan = try? JSONDecoder().decode( + DoryResolvedMachinePlan.self, from: planData + ) { + do { + try mutationFence.revalidate() + let recovered = try ledger.recoverBoundPlanningLease( + leaseID: leaseID, + plan: plan, + authorization: + DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization( + machineID: plan.machineID, + planSHA256: planDigest + ), + startingLeaseDurationMilliseconds: + request.startingLeaseDurationMilliseconds, + expectedLeaseRevision: lease.leaseRevision + ) + journal.leaseRevision = recovered.leaseRevision + try publishJournal(journal) + return recovered + } catch { + try markRecoveryRequired(&journal) + throw failure(.recoveryRequired, "Expired bound planning lease cannot be proven unlaunched.") + } + } + guard lease.state == .starting else { + try markRecoveryRequired(&journal) + throw failure(.recoveryRequired, "The journaled resource lease is no longer starting.") + } + return lease + } + let existing = try ledger.snapshot().leases.filter { + $0.binding == binding && $0.state == .starting + && Self.sameStableAdmissionHost($0.hostFacts, hostResources) + && $0.resources == request.planning.definition.resources + && $0.workload == request.planning.definition.workload + && $0.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder) + } + let lease: DoryVirtualMachineResourceAdmissionLease + if existing.count == 1, let adopted = existing.first { + lease = adopted + } else if existing.isEmpty { + do { + lease = try ledger.reserveStarting( + binding: binding, + hostFacts: hostResources, + workload: request.planning.definition.workload, + requirements: request.resourceRequirements, + resources: request.planning.definition.resources, + startingLeaseDurationMilliseconds: + request.startingLeaseDurationMilliseconds + ) + } catch { + throw failure(.resourceReservationRejected, "Resources could not be reserved atomically.") + } + try faultInjector?(.resourceReservationCommitted) + } else { + throw failure(.recoveryRequired, "Resource lease adoption is ambiguous.") + } + journal.leaseID = lease.leaseID + journal.leaseRevision = lease.leaseRevision + journal.phase = .reserved + try publishJournal(journal) + try faultInjector?(.reservedJournalPublished) + return lease + } + + private func constructOrRecoverCandidate( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + journal: inout Journal, + admission: DoryResolvedMachineResourceAdmissionEvidence, + preparation: DoryDaemonVirtualMachinePlanningTrustPreparation + ) throws -> DoryDaemonVirtualMachinePlanningResult { + if journal.candidatePlanData != nil { + return try planningResultFromCandidate(request, journal: journal) + } + let capture = CapturePlanStore(source: plans) + let plannedAt = journal.plannedAtUnixMilliseconds + let coordinator = DoryDaemonVirtualMachinePlanningCoordinator( + registry: registry, + inventory: FixedInventory(snapshot: preparation.snapshot(admission)), + plans: capture, + capabilityPlanner: capabilityPlanner, + now: { plannedAt } + ) + let result: DoryDaemonVirtualMachinePlanningResult + do { result = try coordinator.resolveAndPersist(request.planning) } + catch { + throw failure(.planConstructionRejected, "Exact trusted facts could not construct a plan.") + } + let candidateData = Self.canonicalData(result.resolvedPlan) + guard !candidateData.isEmpty, Self.sha256(candidateData) == result.resolvedPlanSHA256 else { + throw failure(.planConstructionRejected, "Candidate plan encoding is not deterministic.") + } + journal.candidatePlanData = candidateData + journal.candidatePlanSHA256 = result.resolvedPlanSHA256 + journal.candidatePlannerRequest = result.plannerRequest + journal.candidatePlannerResult = result.plannerResult + journal.phase = .candidatePrepared + try publishJournal(journal) + return result + } + + private func planningResultFromCandidate( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + journal: Journal + ) throws -> DoryDaemonVirtualMachinePlanningResult { + guard let data = journal.candidatePlanData, + let digest = journal.candidatePlanSHA256, + let plan = try? JSONDecoder().decode(DoryResolvedMachinePlan.self, from: data), + let plannerRequest = journal.candidatePlannerRequest, + let plannerResult = journal.candidatePlannerResult, + let selected = plannerResult.selectedDescriptor else { + throw failure(.invalidJournal, "Journaled plan selection is incomplete.") + } + let backendResult = registry.plan(MachineBackendPlanRequest( + machine: request.planning.machine, + capabilityPlan: plannerResult + )) + guard let backendPlan = backendResult.plan, + backendResult.failure == nil, + backendPlan.machine == request.planning.machine, + backendPlan.capability == selected, + backendPlan.backend.identity == plan.backend, + backendPlan.backend.implementationIdentifier + == plan.backendImplementationIdentifier else { + throw failure(.planConstructionRejected, "Journaled plan no longer maps to its exact adapter.") + } + return DoryDaemonVirtualMachinePlanningResult( + plannerRequest: plannerRequest, + plannerResult: plannerResult, + resolvedPlan: plan, + resolvedPlanSHA256: digest, + backendPlan: backendPlan + ) + } + + private func publishWorkspace( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + journal: inout Journal + ) throws { + let target = request.planning.definition + let current = try workspaces.readIfPresent(id: target.identity.id) + if current != target { + do { + switch request.workspacePublication { + case .create: + guard current == nil else { throw failure(.transactionConflict, "Workspace differs from target.") } + try workspaces.create(target) + case let .replace(expected): + guard let current, + Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(current)) == journal.sourceDefinitionSHA256 else { + throw failure(.transactionConflict, "Workspace source changed during planning.") + } + try workspaces.replace(target, expectedRevision: expected) + case .retainExistingExact: + throw failure(.transactionConflict, "Retained workspace differs from target.") + } + } catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error } + catch { throw failure(.workspacePublicationRejected, "Workspace publication failed.") } + } + try faultInjector?(.workspacePublished) + journal.phase = .workspacePublished + try publishJournal(journal) + try faultInjector?(.workspaceJournalPublished) + } + + private func publishPlan( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + planning: DoryDaemonVirtualMachinePlanningResult, + journal: inout Journal + ) throws { + let current = try plans.readIfPresent(id: planning.resolvedPlan.machineID) + if current != planning.resolvedPlan { + do { + switch request.planning.publication { + case .create: + guard current == nil else { throw failure(.transactionConflict, "Resolved plan differs from target.") } + try plans.create(planning.resolvedPlan) + case let .replace(expected): + guard let current, + DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(current) + == journal.sourcePlanSHA256 else { + throw failure(.transactionConflict, "Resolved plan source changed during planning.") + } + try plans.replace(planning.resolvedPlan, expectedPlanRevision: expected) + } + } catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error } + catch { throw failure(.planPublicationRejected, "Resolved-plan publication failed.") } + } + try faultInjector?(.planPublished) + journal.phase = .planPublished + try publishJournal(journal) + try faultInjector?(.planJournalPublished) + } + + private func completedResult( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + journal: Journal + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { + let planning = try planningResultFromCandidate(request, journal: journal) + let plan = planning.resolvedPlan + guard + let currentDefinition = try workspaces.readIfPresent(id: plan.machineID), + currentDefinition == journal.definition, + let currentPlan = try plans.readIfPresent(id: plan.machineID), + currentPlan == plan, + let leaseID = journal.leaseID, + let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }), + lease.boundPlanSHA256 == journal.candidatePlanSHA256 else { + throw failure(.recoveryRequired, "Completed transaction authorities no longer match.") + } + return DoryDaemonVirtualMachinePlanningTransactionResult( + planning: planning, + lease: lease, + transactionID: journal.transactionID + ) + } + + /// A post-bind failure is retained, never aborted. Retrying through this coordinator is the + /// explicit daemon recovery operation: it reacquires the mutation fence in `execute`, proves + /// the same exact bound lease/candidate and source-or-target publication authorities here, + /// then obtains a newly collected, single-use publication authorization before resuming. + private func resumeRecoveryRequired(_ journal: inout Journal) throws { + guard let leaseID = journal.leaseID, + let candidateDigest = journal.candidatePlanSHA256, + let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }), + lease.binding.machineID == journal.definition.identity.id, + lease.boundPlanSHA256 == candidateDigest, + lease.state == .starting || lease.state == .recoveryRequired else { + throw failure(.recoveryRequired, "The retained bound lease cannot be recovered exactly.") + } + + let currentDefinition = try workspaces.readIfPresent( + id: journal.definition.identity.id + ) + let definitionIsTarget = currentDefinition == journal.definition + let definitionIsSource: Bool + switch journal.workspacePublication { + case .create: + definitionIsSource = currentDefinition == nil + case .replace: + definitionIsSource = currentDefinition.map { + Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData($0)) == journal.sourceDefinitionSHA256 + } ?? false + case .retainExistingExact: + definitionIsSource = definitionIsTarget + } + guard definitionIsSource || definitionIsTarget else { + throw failure(.recoveryRequired, "Workspace authority changed during bound recovery.") + } + + let currentPlan = try plans.readIfPresent(id: journal.definition.identity.id) + let planIsTarget = currentPlan.map { + DoryDaemonVirtualMachinePlanningCoordinator.planSHA256($0) == candidateDigest + } ?? false + let planIsSource: Bool + switch journal.planPublication { + case .create: + planIsSource = currentPlan == nil + case .replace: + planIsSource = currentPlan.map { + DoryDaemonVirtualMachinePlanningCoordinator.planSHA256($0) + == journal.sourcePlanSHA256 + } ?? false + } + guard planIsSource || planIsTarget, + !planIsTarget || definitionIsTarget else { + throw failure(.recoveryRequired, "Publication authorities cannot be resumed safely.") + } + + journal.leaseRevision = lease.leaseRevision + journal.phase = .bound + try publishJournal(journal) + } + + private func abortUnboundIfPresent(_ journal: inout Journal) throws { + if let leaseID = journal.leaseID, + let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }), + lease.state == .starting, lease.boundPlanSHA256 == nil { + _ = try ledger.cancelUnboundStarting( + leaseID: leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + } + journal.phase = .aborted + try publishJournal(journal) + } + + private func abortOrRetainAfterTrustFailure(_ journal: inout Journal) throws { + guard let leaseID = journal.leaseID, + let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }) else { + journal.phase = .aborted + try publishJournal(journal) + return + } + if lease.state == .starting, lease.boundPlanSHA256 == nil { + _ = try ledger.cancelUnboundStarting( + leaseID: leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + journal.phase = .aborted + } else if lease.boundPlanSHA256 != nil { + journal.phase = .recoveryRequired + } else { + journal.phase = .aborted + } + try publishJournal(journal) + } + + private func markRecoveryRequired(_ journal: inout Journal) throws { + journal.phase = .recoveryRequired + try publishJournal(journal) + } + + private func validate( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) throws { + guard request.planning.canonicalDefinitionData + == DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(request.planning.definition), + request.planning.machine.id == request.planning.definition.identity.id, + request.startingLeaseDurationMilliseconds > 0 else { + throw failure(.invalidRequest, "Planning transaction request is inconsistent.") + } + switch request.workspacePublication { + case .create: + guard request.planning.definition.lifecycle.revision == 1 else { + throw failure(.invalidRequest, "Created workspace must start at revision one.") + } + case let .replace(expected): + guard expected < .max, + request.planning.definition.lifecycle.revision == expected + 1 else { + throw failure(.invalidRequest, "Workspace replacement revision is invalid.") + } + case .retainExistingExact: break + } + } + + private func makeInventoryRequest( + _ definition: DoryVirtualMachineDefinition + ) throws -> DoryDaemonVirtualMachineInventoryRequest { + guard let boot = DoryDaemonVirtualMachinePlanningCoordinator + .primaryBootMedia(in: definition) else { + throw failure(.invalidRequest, "Workspace has no primary boot media.") + } + return DoryDaemonVirtualMachineInventoryRequest( + machineID: definition.identity.id, + definitionRevision: definition.lifecycle.revision, + guest: definition.guest, + bootMedia: boot, + resources: definition.resources, + devices: DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition), + acceptableGraphics: definition.graphics.acceptableLevels, + virtualHardwareABIVersion: definition.virtualHardwareABIVersion + ) + } + + private static func plannedPlanRevision( + _ publication: DoryDaemonVirtualMachinePlanPublication + ) -> UInt64 { + switch publication { + case .create: 1 + case let .replace(expected): expected == .max ? .max : expected + 1 + } + } + + private static func requestDigest( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + canonicalDefinition: Data, + machineAuthoritySHA256: String + ) -> String { + struct DigestInput: Codable { + var definitionSHA256: String + var machineSHA256: String + var machineAuthoritySHA256: String + var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication + var planPublication: PlanPublicationRecord + var requirements: [DoryVMResourceRequirement] + var leaseDurationMilliseconds: Int64 + var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? + var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + } + return sha256(canonicalData(DigestInput( + definitionSHA256: sha256(canonicalDefinition), + machineSHA256: sha256(canonicalData(request.planning.machine)), + machineAuthoritySHA256: machineAuthoritySHA256, + workspacePublication: request.workspacePublication, + planPublication: PlanPublicationRecord(request.planning.publication), + requirements: request.resourceRequirements.sorted(by: requirementOrder), + leaseDurationMilliseconds: request.startingLeaseDurationMilliseconds, + fallbackAuthorization: request.planning.fallbackAuthorization, + experimentalAuthorization: request.planning.experimentalAuthorization + ))) + } + + private static func requirementOrder( + _ lhs: DoryVMResourceRequirement, + _ rhs: DoryVMResourceRequirement + ) -> Bool { + canonicalData(lhs).lexicographicallyPrecedes(canonicalData(rhs)) + } + + private func journalDirectory(machineID: String) -> String { + stateDirectory + "/" + machineID + } + + private func journalPath(machineID: String) -> String { + journalDirectory(machineID: machineID) + "/" + Self.journalFileName + } + + private func withWorkspaceLock( + machineID: String, + _ operation: () throws -> T + ) throws -> T { + guard Self.isValidMachineID(machineID) else { + throw failure(.invalidRequest, "Machine identifier is invalid.") + } + try ensurePrivateDirectory(stateDirectory) + let directory = journalDirectory(machineID: machineID) + try ensurePrivateDirectory(directory) + let path = directory + "/" + Self.lockFileName + let descriptor = open( + path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600) + ) + guard descriptor >= 0 else { + throw failure(.filesystem, "Planning transaction lock cannot be opened.") + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == geteuid(), info.st_nlink == 1, + info.st_mode & 0o077 == 0 else { + throw failure(.filesystem, "Planning transaction lock is insecure.") + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { + throw failure(.filesystem, "Planning transaction lock cannot be acquired.") + } + } + defer { _ = flock(descriptor, LOCK_UN) } + return try operation() + } + + private func readJournal(machineID: String) throws -> Journal? { + let path = journalPath(machineID: machineID) + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + if descriptor < 0 { + if errno == ENOENT { return nil } + throw failure(.filesystem, "Planning journal cannot be opened.") + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == geteuid(), info.st_nlink == 1, + info.st_mode & 0o077 == 0, + info.st_size > 0, info.st_size <= Self.maximumJournalBytes else { + throw failure(.invalidJournal, "Planning journal metadata is invalid.") + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1_024) + while true { + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count < 0, errno == EINTR { continue } + guard count >= 0, data.count + count <= Self.maximumJournalBytes else { + throw failure(.invalidJournal, "Planning journal cannot be read safely.") + } + if count == 0 { break } + data.append(contentsOf: buffer.prefix(count)) + } + guard let envelope = try? JSONDecoder().decode(JournalEnvelope.self, from: data), + data == Self.canonicalData(envelope) + Data("\n".utf8), + envelope.journalSHA256 == Self.sha256(Self.canonicalData(envelope.journal)) else { + throw failure(.invalidJournal, "Planning journal integrity check failed.") + } + try validate(envelope.journal) + return envelope.journal + } + + private func publishJournal(_ journal: Journal) throws { + try validate(journal) + let directory = journalDirectory(machineID: journal.definition.identity.id) + let envelope = JournalEnvelope( + journalSHA256: Self.sha256(Self.canonicalData(journal)), + journal: journal + ) + let data = Self.canonicalData(envelope) + Data("\n".utf8) + guard data.count <= Self.maximumJournalBytes else { + throw failure(.invalidJournal, "Planning journal is too large.") + } + let temporary = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString)" + let descriptor = open( + temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw failure(.filesystem, "Planning journal cannot be created.") } + var openDescriptor = true + do { + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset + ) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw failure(.filesystem, "Planning journal cannot be written.") } + offset += count + } + } + guard fsync(descriptor) == 0 else { throw failure(.filesystem, "Planning journal cannot be synced.") } + close(descriptor) + openDescriptor = false + guard rename(temporary, journalPath(machineID: journal.definition.identity.id)) == 0 else { + throw failure(.filesystem, "Planning journal cannot be published.") + } + try syncDirectory(directory) + } catch { + if openDescriptor { close(descriptor) } + unlink(temporary) + throw error + } + } + + private func validate(_ journal: Journal) throws { + guard journal.schemaVersion == Journal.schemaVersion, + Self.isValidMachineID(journal.definition.identity.id), + journal.transactionID.wholeMatch( + of: /planning-transaction-[0-9a-f-]{36}/ + ) != nil, + Self.isSHA256(journal.requestSHA256), + Self.isSHA256(journal.machineAuthoritySHA256), + Self.isSHA256(journal.definitionSHA256), + journal.definitionSHA256 == Self.sha256( + DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(journal.definition) + ), + journal.plannedAtUnixMilliseconds > 0, + (journal.leaseID == nil) == (journal.leaseRevision == nil), + (journal.candidatePlanData == nil) == (journal.candidatePlanSHA256 == nil), + (journal.candidatePlanData == nil) + == (journal.candidatePlannerRequest == nil), + (journal.candidatePlanData == nil) + == (journal.candidatePlannerResult == nil) else { + throw failure(.invalidJournal, "Planning journal fields are invalid.") + } + if let data = journal.candidatePlanData, + let digest = journal.candidatePlanSHA256 { + guard Self.isSHA256(digest), Self.sha256(data) == digest, + let plan = try? JSONDecoder().decode(DoryResolvedMachinePlan.self, from: data), + Self.canonicalData(plan) == data, + plan.machineID == journal.definition.identity.id, + plan.definitionSHA256 == journal.definitionSHA256, + plan.planRevision == Self.plannedPlanRevision( + Self.planPublication(from: journal.planPublication) + ), + plan.validate().isEmpty else { + throw failure(.invalidJournal, "Journaled candidate plan is invalid.") + } + } + switch journal.workspacePublication { + case .create: + guard journal.sourceDefinitionSHA256 == nil, + journal.sourceDefinitionRevision == nil else { + throw failure(.invalidJournal, "Workspace create has a source authority.") + } + case let .replace(expected): + guard Self.optionalSHA256IsValid(journal.sourceDefinitionSHA256), + journal.sourceDefinitionRevision == expected else { + throw failure(.invalidJournal, "Workspace replacement source is invalid.") + } + case .retainExistingExact: + guard journal.sourceDefinitionSHA256 == journal.definitionSHA256, + journal.sourceDefinitionRevision + == journal.definition.lifecycle.revision else { + throw failure(.invalidJournal, "Retained workspace authority is not exact.") + } + } + switch journal.planPublication { + case .create: + guard journal.sourcePlanSHA256 == nil, journal.sourcePlanRevision == nil else { + throw failure(.invalidJournal, "Resolved-plan create has a source authority.") + } + case let .replace(expected): + guard Self.optionalSHA256IsValid(journal.sourcePlanSHA256), + journal.sourcePlanRevision == expected else { + throw failure(.invalidJournal, "Resolved-plan replacement source is invalid.") + } + } + switch journal.phase { + case .prepared: + guard journal.leaseID == nil, journal.candidatePlanData == nil else { + throw failure(.invalidJournal, "Prepared journal contains later-phase authority.") + } + case .reserved: + guard journal.leaseID != nil, journal.candidatePlanData == nil else { + throw failure(.invalidJournal, "Reserved journal fields are inconsistent.") + } + case .candidatePrepared, .bound, .workspacePublished, .planPublished, .complete, + .recoveryRequired: + guard journal.leaseID != nil, journal.candidatePlanData != nil else { + throw failure(.invalidJournal, "Plan-bearing journal fields are incomplete.") + } + case .aborted: break + } + } + + private func ensurePrivateDirectory(_ path: String) throws { + if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST { + throw failure(.filesystem, "Planning transaction directory cannot be created.") + } + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == geteuid(), + info.st_mode & 0o077 == 0 else { + throw failure(.filesystem, "Planning transaction directory is insecure.") + } + } + + private func syncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { throw failure(.filesystem, "Planning directory cannot be opened.") } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { throw failure(.filesystem, "Planning directory cannot be synced.") } + } + + private static func canonicalData(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data() + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + /// CPU and physical memory describe the capacity model under which a lease was admitted. + /// Free disk space and existing commitments are volatile observations; the durable ledger's + /// exact reservation is their authority after reserve commits and transaction recovery must + /// not attempt a second admission from a later free-space sample. + private static func sameStableAdmissionHost( + _ lhs: DoryVMHostResources, + _ rhs: DoryVMHostResources + ) -> Bool { + lhs.logicalCPUCount == rhs.logicalCPUCount + && lhs.physicalMemoryBytes == rhs.physicalMemoryBytes + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } + + private static func optionalSHA256IsValid(_ value: String?) -> Bool { + value.map(isSHA256) ?? false + } + + private static func planPublication( + from record: PlanPublicationRecord + ) -> DoryDaemonVirtualMachinePlanPublication { + switch record { + case .create: .create + case let .replace(expected): .replace(expectedPlanRevision: expected) + } + } + + private static func isValidMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private func failure( + _ code: DoryDaemonVirtualMachinePlanningTransactionFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachinePlanningTransactionFailure { + DoryDaemonVirtualMachinePlanningTransactionFailure(code: code, message: message) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift index 03862832..1a92adc9 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -410,12 +410,53 @@ enum DoryDaemonProductionTrustInventoryError: case resourceAdmissionUnavailable } +private struct DoryDaemonProductionPlanningHostIdentity: Sendable, Equatable { + var hardwareModelIdentifier: String + var operatingSystemBuild: String + var macOSMajorVersion: Int + var virtualizationFrameworkAvailable: Bool + var hypervisorFrameworkAvailable: Bool + var metalAvailable: Bool + var logicalCPUCount: UInt64 + var physicalMemoryBytes: UInt64 +} + +private struct DoryDaemonProductionPlanningMaterial: Sendable { + var host: DoryDaemonProductionHostObservation + var artifact: DoryVerifiedVirtualMachineArtifact + var runtimes: [DoryDaemonVerifiedBackendRuntime] + var qualifications: [DoryResolvedTrustedVirtualMachineQualification] + var media: DoryDaemonVirtualMachineResolvedMedia + var backendInventories: [DoryDaemonVirtualMachineBackendRuntimeInventory] + var hostFacts: DoryAppleSiliconHostFacts + + var stableHostIdentity: DoryDaemonProductionPlanningHostIdentity { + DoryDaemonProductionPlanningHostIdentity( + hardwareModelIdentifier: host.hardwareModelIdentifier, + operatingSystemBuild: host.operatingSystemBuild, + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable, + hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable, + metalAvailable: host.metalAvailable, + logicalCPUCount: host.resources.logicalCPUCount, + physicalMemoryBytes: host.resources.physicalMemoryBytes + ) + } + + var qualificationRecords: [DoryVirtualMachineQualificationRecord] { + qualifications.map(\.record).sorted { + $0.qualificationIdentity < $1.qualificationIdentity + } + } +} + /// Production inventory backed exclusively by opaque verified authorities. It deliberately does /// not implement admission reservation yet: the current coordinator has no atomic reserve -> plan /// bind -> publish transaction. Start-time verification is complete for previously published and /// bound plans; planning fails explicitly instead of returning invented admission evidence. final class DoryProductionDaemonVirtualMachineTrustInventory: DoryDaemonVirtualMachineTrustInventory, + DoryDaemonVirtualMachinePlanningTrustPreparing, DoryDaemonVirtualMachinePreSpawnAuthorizationProviding, @unchecked Sendable { @@ -457,6 +498,184 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: .planningRequiresAdmissionTransaction } + func preparePlanningTrust( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation { + let initial = try resolvePlanningMaterial(for: request) + return DoryDaemonVirtualMachinePlanningTrustPreparation( + hostResources: initial.host.resources, + snapshot: { admission in + DoryDaemonVirtualMachineTrustedInventorySnapshot( + hostFacts: initial.hostFacts, + media: initial.media, + backendRuntimes: initial.backendInventories, + resourceAdmission: admission, + runtimeQualifications: initial.qualifications.map(\.runtime), + capabilityQualifications: initial.qualifications.map( + \.capabilityQualification + ) + ) + }, + publicationAuthorization: + DoryDaemonVirtualMachinePlanningPublicationAuthorization { [weak self] in + guard let self else { + throw DoryDaemonProductionTrustInventoryError.invalidRequest + } + let current = try self.resolvePlanningMaterial(for: request) + guard current.stableHostIdentity == initial.stableHostIdentity, + current.artifact.reference == initial.artifact.reference, + current.artifact.media == initial.artifact.media, + current.artifact.authorityRevision + == initial.artifact.authorityRevision, + current.runtimes == initial.runtimes, + current.qualificationRecords == initial.qualificationRecords else { + throw DoryDaemonProductionTrustInventoryError.invalidRequest + } + } + ) + } + + private func resolvePlanningMaterial( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonProductionPlanningMaterial { + guard !request.acceptableGraphics.isEmpty, + request.machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + request.definitionRevision > 0 else { + throw DoryDaemonProductionTrustInventoryError.invalidRequest + } + let host: DoryDaemonProductionHostObservation + do { host = try hostProbe(stateDirectory) } + catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest } + let artifact: DoryVerifiedVirtualMachineArtifact + do { + artifact = try artifactAuthority.resolve( + reference: request.bootMedia.artifact, + kind: request.bootMedia.kind, + source: request.bootMedia.source + ) + } catch { + throw DoryDaemonProductionTrustInventoryError.mediaUnavailable + } + + var runtimes: [DoryDaemonVerifiedBackendRuntime] = [] + for specification in runtimeSpecifications.values.sorted(by: { + $0.descriptor.identity.rawValue < $1.descriptor.identity.rawValue + }) { + guard let runtime = try? runtimeVerifier( + specification.executablePath, + specification.descriptor, + specification.componentIdentifier + ), runtime.descriptor == specification.descriptor else { continue } + runtimes.append(runtime) + } + guard !runtimes.isEmpty else { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + + var qualifications: [DoryResolvedTrustedVirtualMachineQualification] = [] + for runtime in runtimes { + for graphics in request.acceptableGraphics { + let capability = DoryVirtualMachineCapabilityRequest( + guest: request.guest, + bootMedia: artifact.media, + backend: runtime.descriptor.identity, + graphics: graphics, + devices: request.devices, + virtualHardwareABIVersion: request.virtualHardwareABIVersion + ) + if let qualification = try? qualificationAuthority.resolve( + request: capability, + backendImplementationIdentifier: + runtime.descriptor.implementationIdentifier, + backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + installedComponents: runtime.components + ) { + qualifications.append(qualification) + } + } + } + guard !qualifications.isEmpty else { + throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable + } + + let inspection: DoryTrustedBootMediaInspection? + switch artifact.media.kind { + case .installerISO: + guard let qualification = qualifications.first else { + throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable + } + do { + inspection = try DoryQualifiedBootMediaInspector.inspectInstallerISO( + atPath: artifact.path, + qualification: qualification + ).inspection + } catch { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + case .installedLinuxBootBundle: + do { _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) } + catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid } + inspection = nil + case .virtualDisk: + guard artifact.mutableProvenance != nil else { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + inspection = nil + case .macOSRestoreImage: + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + + let runtimeByIdentity = Dictionary(uniqueKeysWithValues: runtimes.map { + ($0.descriptor.identity, $0) + }) + let inventories = qualifications.compactMap { qualification + -> DoryDaemonVirtualMachineBackendRuntimeInventory? in + let record = qualification.record + guard let runtime = runtimeByIdentity[record.backend], + runtime.runtimeBuildIdentifier == record.backendRuntimeBuildIdentifier else { + return nil + } + return DoryDaemonVirtualMachineBackendRuntimeInventory( + backend: record.backend, + runtimeBuildIdentifier: runtime.runtimeBuildIdentifier, + components: runtime.componentEvidence, + hostQualification: DoryResolvedHostQualificationEvidence( + qualificationIdentity: record.qualificationIdentity, + qualificationReportSHA256: Self.digest(Self.canonicalData(record)), + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + backend: record.backend, + backendRuntimeBuildIdentifier: record.backendRuntimeBuildIdentifier, + virtualHardwareABIVersion: record.virtualHardwareABIVersion, + qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification", + qualifierVersion: DoryVirtualMachineQualificationManifest.schemaVersion + ) + ) + } + guard inventories.count == qualifications.count else { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + return DoryDaemonProductionPlanningMaterial( + host: host, + artifact: artifact, + runtimes: runtimes, + qualifications: qualifications, + media: DoryDaemonVirtualMachineResolvedMedia( + reference: artifact.reference, + media: artifact.media, + // Graphics trust is capability-specific. Supplying one media-wide record here + // would let array order authorize a different backend/graphics candidate. + guestGraphicsQualification: nil, + bootInspection: inspection, + mutableProvenance: artifact.mutableProvenance + ), + backendInventories: inventories, + hostFacts: hostFacts(host: host, runtimes: runtimes) + ) + } + func startInventory( for request: DoryDaemonVirtualMachineStartInventoryRequest ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { @@ -668,6 +887,50 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: ) } + private func hostFacts( + host: DoryDaemonProductionHostObservation, + runtimes: [DoryDaemonVerifiedBackendRuntime] + ) -> DoryAppleSiliconHostFacts { + let rawBuild = runtimes.first { + $0.descriptor.identity == .doryHypervisor + }?.runtimeBuildIdentifier ?? "" + let vzBuild = runtimes.first { + $0.descriptor.identity == .appleVirtualizationFramework + }?.runtimeBuildIdentifier ?? "" + return DoryAppleSiliconHostFacts( + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: + host.virtualizationFrameworkAvailable && !vzBuild.isEmpty, + hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable, + doryHypervisorAvailable: !rawBuild.isEmpty, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, + networkAvailable: false, + displayAvailable: false, + inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: host.metalAvailable, + doryAcceleratedRendererAvailable: + host.metalAvailable && !rawBuild.isEmpty, + runtimeQualificationContext: + DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: rawBuild, + virtualizationFrameworkAdapterBuildID: vzBuild, + qemuRuntimeBuildID: "" + ) + ) + } + private static func canonicalData(_ value: T) -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index a4ca9581..2b6344ec 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -51,6 +51,8 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory] public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] + public var capabilityQualifications: + [DoryTrustedVirtualMachineCapabilityQualification] /// Resolver-selected record for one exact start request. The evaluator still verifies every /// media, backend-build, ABI, graphics, and device binding before treating it as authority. public var exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? @@ -61,6 +63,8 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory], resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [], + capabilityQualifications: + [DoryTrustedVirtualMachineCapabilityQualification] = [], exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil ) { self.hostFacts = hostFacts @@ -68,6 +72,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { self.backendRuntimes = backendRuntimes self.resourceAdmission = resourceAdmission self.runtimeQualifications = runtimeQualifications + self.capabilityQualifications = capabilityQualifications self.exactStartRuntimeQualification = exactStartRuntimeQualification } @@ -77,6 +82,23 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { let matches = backendRuntimes.filter { $0.backend == backend } return matches.count == 1 ? matches[0] : nil } + + /// Selects the runtime record bound to the exact qualification chosen by the planner. A + /// backend may have several signed media/graphics qualifications; array order is not trust. + public func backendRuntime( + for descriptor: DoryVirtualMachineCapabilityDescriptor + ) -> DoryDaemonVirtualMachineBackendRuntimeInventory? { + let candidates = backendRuntimes.filter { runtime in + guard runtime.backend == descriptor.request.backend else { return false } + guard let evidence = descriptor.runtimeQualificationEvidence else { return true } + return runtime.runtimeBuildIdentifier == evidence.backendRuntimeBuildID + && runtime.hostQualification.qualificationIdentity + == evidence.qualificationIdentity + && runtime.hostQualification.qualificationReportSHA256 + == evidence.qualificationReportSHA256 + } + return candidates.count == 1 ? candidates[0] : nil + } } public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable { @@ -172,7 +194,8 @@ public struct DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner: trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification, trustedBootMediaInspection: inventory.media.bootInspection, trustedMutableBootMediaProvenance: inventory.media.mutableProvenance, - trustedRuntimeQualifications: inventory.runtimeQualifications + trustedRuntimeQualifications: inventory.runtimeQualifications, + trustedCapabilityQualifications: inventory.capabilityQualifications ) } } @@ -353,7 +376,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda guard let backend = registry.backend(for: selected.request.backend) else { throw failure(.backendUnavailable, "The selected backend is not registered.") } - guard let runtime = snapshot.backendRuntime(for: selected.request.backend) else { + guard let runtime = snapshot.backendRuntime(for: selected) else { throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.") } let backendResult = registry.plan(MachineBackendPlanRequest( diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift index c75643d1..47a4f8fa 100644 --- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -107,6 +107,19 @@ public struct DoryVirtualMachineResourceAdmissionLedgerSnapshot: Sendable, Equat } } +/// Opaque daemon-only proof that a lifecycle fence observed no process using a bound planning +/// lease. It is not Codable and has no public initializer, so API/UI callers cannot recover an +/// ambiguous lease. +public struct DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization: Sendable { + let machineID: String + let planSHA256: String + + init(machineID: String, planSHA256: String) { + self.machineID = machineID + self.planSHA256 = planSHA256 + } +} + public enum DoryVirtualMachineResourceAdmissionLedgerError: Error, Sendable, Equatable, CustomStringConvertible { @@ -315,6 +328,33 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } } + /// Compensates a planning attempt that failed before its lease was bound to exact plan bytes. + /// CPU and memory are released, while the durable storage reservation is retained. A bound + /// lease is never cancelled here because a process may already have consumed its authority. + public func cancelUnboundStarting( + leaseID: String, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .starting, lease.boundPlanSHA256 == nil else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + lease.state = .stopped + lease.startingExpiresAtUnixMilliseconds = nil + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + /// Revalidates an already bound starting lease against exact plan bytes and the same daemon /// host snapshot. It never replans or silently refreshes evidence. public func revalidateForStart( @@ -343,6 +383,53 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } } + /// Renews a bound planning lease that expired before definition/plan publication. The caller + /// must hold a daemon lifecycle fence proving the exact plan never launched. Admission facts, + /// evidence, and bound plan bytes remain unchanged; no capacity is released or re-granted. + public func recoverBoundPlanningLease( + leaseID: String, + plan: DoryResolvedMachinePlan, + authorization: DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization, + startingLeaseDurationMilliseconds: Int64, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + guard startingLeaseDurationMilliseconds > 0, + startingLeaseDurationMilliseconds + <= Self.maximumStartingLeaseDurationMilliseconds, + timestamp > 0, + timestamp <= Int64.max - startingLeaseDurationMilliseconds else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration + } + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .recoveryRequired else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + let digest = Self.planSHA256(plan) + guard authorization.machineID == lease.binding.machineID, + authorization.machineID == plan.machineID, + authorization.planSHA256 == digest, + lease.boundPlanSHA256 == digest else { + throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch + } + try Self.validate(plan, against: lease, requireBoundDigest: true) + try Self.validateCapacity(record.leases, hostFacts: lease.hostFacts) + lease.state = .starting + lease.startingExpiresAtUnixMilliseconds = timestamp + + startingLeaseDurationMilliseconds + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + public func markRunning( leaseID: String, plan: DoryResolvedMachinePlan, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift index da290f8b..710e98d9 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift @@ -376,6 +376,72 @@ struct DoryVirtualMachineBackendPlannerTests { == "linux-runtime-qualification-v1") } + @Test("capability qualification order cannot authorize a different graphics candidate") + func capabilityQualificationSelectionIsExactAndOrderIndependent() throws { + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: Self.guestArtifactSHA256 + ) + let request = DoryVirtualMachineBackendPlanRequest( + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + bootMedia: media, + acceptableGraphics: [.software, .hardwareAccelerated3D], + backendPreferences: [.doryHypervisor], + backendPreferencePolicy: .required + ) + let runtimeByGraphics = Dictionary(uniqueKeysWithValues: + makeRuntimeQualifications(request: request, host: Self.host).filter { + $0.auditEvidence.backend == .doryHypervisor + }.map { + ($0.auditEvidence.graphics, $0) + } + ) + let unsupportedSoftware = DoryTrustedGuestImageGraphicsQualification( + auditEvidence: DorySignedArtifactQualificationEvidence( + manifestIdentity: "dory-linux-software-unqualified-v1", + artifactSHA256: Self.guestArtifactSHA256, + manifestSHA256: String(repeating: "3", count: 64), + signingKeyID: "dory-release-2026", + manifestFormatVersion: 1 + ), + virtioGPUKernelAndDeviceSupportQualified: false, + venusVulkanGuestRuntimeQualified: false + ) + func exact( + _ graphics: DoryGraphicsAccelerationLevel, + _ qualification: DoryTrustedGuestImageGraphicsQualification + ) throws -> DoryTrustedVirtualMachineCapabilityQualification { + let capabilityRequest = DoryVirtualMachineCapabilityRequest( + guest: request.guest, + bootMedia: media, + backend: .doryHypervisor, + graphics: graphics, + devices: request.devices + ) + return DoryTrustedVirtualMachineCapabilityQualification( + request: capabilityRequest, + runtime: try #require(runtimeByGraphics[graphics]), + graphics: qualification + ) + } + let software = try exact(.software, unsupportedSoftware) + let accelerated = try exact(.hardwareAccelerated3D, Self.qualifiedLinuxGraphics) + + for inventory in [[software, accelerated], [accelerated, software]] { + let result = DoryAppleSiliconVirtualMachineBackendPlanner.plan( + request, + host: Self.host, + trustedCapabilityQualifications: inventory + ) + #expect(result.selectedDescriptor?.request.graphics == .hardwareAccelerated3D) + #expect(result.evaluatedDescriptors.first?.availability.reason?.code + == .linuxVirtioGPUKernelDeviceUnqualified) + #expect(result.selectedDescriptor?.graphicsQualificationEvidence + == Self.qualifiedLinuxGraphics.auditEvidence) + } + } + @Test("runtime inventory selection is order independent for ISO digest and runtime build") func runtimeInventoryMatchesExactISORecordInEitherOrder() throws { let exact = runtimeQualification( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift new file mode 100644 index 00000000..c6613d76 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -0,0 +1,651 @@ +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Daemon VM planning publication transaction") +struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests { + @Test("reserve bind publish is exact and restart-idempotent") + func successAndIdempotence() throws { + let fixture = try TransactionFixture() + let first = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + let second = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + + #expect(first.transactionID == second.transactionID) + #expect(first.planning.resolvedPlan == second.planning.resolvedPlan) + #expect(try fixture.workspaces.read(id: fixture.definition.identity.id) + == fixture.definition) + #expect(try fixture.plans.read(id: fixture.definition.identity.id) + == first.planning.resolvedPlan) + let lease = try #require(try fixture.ledger.snapshot().leases.first) + #expect(lease.state == .starting) + #expect(lease.boundPlanSHA256 == first.planning.resolvedPlanSHA256) + } + + @Test("crash after workspace publication resumes exact candidate bytes") + func workspacePublicationRecovery() throws { + let fixture = try TransactionFixture() + let fault = TransactionFault(.workspacePublished) + do { + _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request()) + Issue.record("Expected injected crash") + } catch is TransactionInjectedFailure {} + + #expect(try fixture.workspaces.read(id: fixture.definition.identity.id) + == fixture.definition) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try fixture.plans.read(id: fixture.definition.identity.id) + } + let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + #expect(try fixture.plans.read(id: fixture.definition.identity.id) + == recovered.planning.resolvedPlan) + #expect(try #require(try fixture.ledger.snapshot().leases.first).boundPlanSHA256 + == recovered.planning.resolvedPlanSHA256) + } + + @Test("crash after bind adopts exact bound lease without a second reservation") + func bindRecovery() throws { + let fixture = try TransactionFixture() + let fault = TransactionFault(.planBindingCommitted) + do { + _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request()) + Issue.record("Expected injected crash") + } catch is TransactionInjectedFailure {} + let before = try #require(try fixture.ledger.snapshot().leases.first) + #expect(before.boundPlanSHA256 != nil) + + let result = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + let after = try #require(try fixture.ledger.snapshot().leases.first) + #expect(after.leaseID == before.leaseID) + #expect(after.boundPlanSHA256 == result.planning.resolvedPlanSHA256) + #expect(try fixture.ledger.snapshot().leases.count == 1) + } + + @Test("bound recovery ignores volatile free-storage samples without readmission") + func boundRecoveryWithChangedFreeStorage() throws { + let fixture = try TransactionFixture() + do { + _ = try fixture.coordinator(fault: TransactionFault(.planBindingCommitted)) + .resolveReserveAndPublish(fixture.request()) + Issue.record("Expected injected crash") + } catch is TransactionInjectedFailure {} + let before = try #require(try fixture.ledger.snapshot().leases.first) + fixture.trust.updateFreeStorage(before.hostFacts.freeStorageBytes / 2) + + let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + #expect(recovered.lease.leaseID == before.leaseID) + #expect(recovered.lease.leaseRevision == before.leaseRevision) + #expect(recovered.lease.hostFacts == before.hostFacts) + #expect(try fixture.ledger.snapshot().leases.count == 1) + } + + @Test("stale publication evidence retains bound capacity and publishes nothing") + func staleTrustFailsClosed() throws { + let fixture = try TransactionFixture(rejectPublication: true) + do { + _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + Issue.record("Expected stale trust rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .publicationAuthorizationRejected) + } + let lease = try #require(try fixture.ledger.snapshot().leases.first) + #expect(lease.state == .starting) + #expect(lease.boundPlanSHA256 != nil) + #expect(throws: DoryWorkspaceRepositoryError.self) { + _ = try fixture.workspaces.read(id: fixture.definition.identity.id) + } + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try fixture.plans.read(id: fixture.definition.identity.id) + } + fixture.trust.rejectPublication = false + let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + #expect(recovered.lease.leaseID == lease.leaseID) + #expect(recovered.lease.boundPlanSHA256 == lease.boundPlanSHA256) + #expect(try fixture.plans.read(id: fixture.definition.identity.id) + == recovered.planning.resolvedPlan) + } + + @Test("two coordinator instances serialize one workspace transaction") + func concurrentInstances() async throws { + let fixture = try TransactionFixture() + let results = TransactionResultBox() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<2 { + group.addTask { + do { + let value = try fixture.coordinator() + .resolveReserveAndPublish(fixture.request()) + results.append(.success(value.transactionID)) + } catch { + results.append(.failure(error)) + } + } + } + } + let values = results.values + #expect(values.count == 2) + let ids = try values.map { try $0.get() } + #expect(Set(ids).count == 1) + #expect(try fixture.ledger.snapshot().leases.count == 1) + } + + @Test("stale create source is rejected before resource reservation") + func staleSourceBeforeReserve() throws { + let fixture = try TransactionFixture() + try fixture.workspaces.create(fixture.definition) + do { + _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + Issue.record("Expected create conflict") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .transactionConflict) + } + #expect(try fixture.ledger.snapshot().leases.isEmpty) + } + + @Test("changed machine authority cannot join an existing transaction") + func changedMachineAuthority() throws { + let fixture = try TransactionFixture() + let fault = TransactionFault(.preparedJournalPublished) + do { + _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request()) + Issue.record("Expected injected crash") + } catch is TransactionInjectedFailure {} + fixture.mutationAuthority.authoritySHA256 = transactionDigest("f") + do { + _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + Issue.record("Expected transaction conflict") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .transactionConflict) + } + #expect(try fixture.ledger.snapshot().leases.isEmpty) + } + + @Test("every committed publication boundary recovers one byte-exact plan and lease") + func publicationBoundaryMatrix() throws { + let stages: [DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage] = [ + .preparedJournalPublished, + .resourceReservationCommitted, + .reservedJournalPublished, + .candidateJournalPublished, + .planBindingCommitted, + .boundJournalPublished, + .publicationAuthorized, + .workspacePublished, + .workspaceJournalPublished, + .planPublished, + .planJournalPublished, + .completeJournalPublished, + ] + for stage in stages { + let fixture = try TransactionFixture() + let fault = TransactionFault(stage) + do { + _ = try fixture.coordinator(fault: fault) + .resolveReserveAndPublish(fixture.request()) + Issue.record("Expected injected crash at \(stage)") + } catch is TransactionInjectedFailure {} + let recovered = try fixture.coordinator() + .resolveReserveAndPublish(fixture.request()) + #expect(try fixture.workspaces.read(id: fixture.definition.identity.id) + == fixture.definition) + #expect(try fixture.plans.read(id: fixture.definition.identity.id) + == recovered.planning.resolvedPlan) + let leases = try fixture.ledger.snapshot().leases + #expect(leases.count == 1) + #expect(leases.first?.boundPlanSHA256 + == recovered.planning.resolvedPlanSHA256) + } + } + + @Test("completed transaction rejects later plan authority substitution") + func completedTamper() throws { + let fixture = try TransactionFixture() + let completed = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + var replacement = completed.planning.resolvedPlan + replacement.planRevision = 2 + replacement.updatedAtUnixMilliseconds += 1 + try fixture.plans.replace(replacement, expectedPlanRevision: 1) + do { + _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request()) + Issue.record("Expected completed authority mismatch") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .recoveryRequired) + } + #expect(try fixture.ledger.snapshot().leases.count == 1) + } + + @Test("expired unbound and lifecycle-proven bound planning leases recover exactly") + func expiryRecovery() throws { + for stage in [ + DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage + .candidateJournalPublished, + .planBindingCommitted, + ] { + let clock = TransactionClock(50_000) + let fixture = try TransactionFixture(clock: clock) + do { + _ = try fixture.coordinator(fault: TransactionFault(stage)) + .resolveReserveAndPublish(fixture.request(leaseDuration: 100)) + Issue.record("Expected injected crash") + } catch is TransactionInjectedFailure {} + clock.advance(100) + let expired = try #require(try fixture.ledger.snapshot().leases.first) + #expect(expired.state == (stage == .planBindingCommitted + ? .recoveryRequired : .stopped)) + let recovered = try fixture.coordinator() + .resolveReserveAndPublish(fixture.request(leaseDuration: 100)) + #expect(recovered.lease.leaseID == expired.leaseID) + #expect(recovered.lease.state == .starting) + #expect(recovered.lease.boundPlanSHA256 + == recovered.planning.resolvedPlanSHA256) + } + } +} + +private final class TransactionFixture: @unchecked Sendable { + let root: String + let definition: DoryVirtualMachineDefinition + let machine: DoryMachineConfiguration + let media: DoryBootMedia + let capability: DoryVirtualMachineCapabilityDescriptor + let resources: DoryVMHostResources + let workspaces: DoryWorkspaceRepository + let plans: DoryResolvedMachinePlanRepository + let ledger: DoryVirtualMachineResourceAdmissionLedger + let registry: BackendRegistry + let trust: TransactionTrust + let mutationAuthority = TransactionMutationAuthority() + + init( + rejectPublication: Bool = false, + clock: TransactionClock? = nil + ) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-planning-tx-\(UUID().uuidString)").path + try FileManager.default.createDirectory( + atPath: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let bootArtifact = DoryVMResolverReference( + namespace: "artifact", identifier: "ubuntu-runtime" + ) + let diskArtifact = DoryVMResolverReference( + namespace: "artifact", identifier: "ubuntu-disk" + ) + let requested = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 8 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ) + definition = DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: "transaction-linux", name: "Linux"), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", role: .system, + kind: .installedLinuxBootBundle, source: .bundledByDory, + artifact: bootArtifact, removable: false + )], + order: ["system"] + ), + backendPreference: DoryVMBackendPreference( + mode: .required, backend: .doryHypervisor + ), + graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]), + resources: requested, + storage: [DoryVMStorageAttachment( + id: "system-disk", role: .system, artifact: diskArtifact, + capacityBytes: requested.diskBytes + )], + audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), + input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + machine = DoryMachineConfiguration( + id: definition.identity.id, + kernelPath: "/fixture/direct-kernel", + rootfsPath: "/fixture/linux.raw", + bootMode: .linuxKernel, + displayMode: .desktop + ) + media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: transactionDigest("a") + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let runtimeEvidence = DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: transactionDigest("b"), + signingKeyID: "dory-runtime-1", + qualificationFormatVersion: 1, + guest: definition.guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: media.artifactSHA256, + backend: .doryHypervisor, + backendRuntimeBuildID: "raw-runtime-1", + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices + ) + capability = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: DoryVirtualMachineCapabilityRequest( + guest: definition.guest, bootMedia: media, + backend: .doryHypervisor, graphics: .none, devices: devices + ), + availability: DoryCapabilityAvailability( + supportTier: .supported, state: .available + ), + resolvedDevices: devices, + runtimeQualificationEvidence: runtimeEvidence + ) + resources = DoryVMHostResources( + logicalCPUCount: 12, + physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + freeStorageBytes: 512 * 1_024 * 1_024 * 1_024 + ) + let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot( + hostFacts: transactionHostFacts(), + media: DoryDaemonVirtualMachineResolvedMedia( + reference: bootArtifact, media: media + ), + backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( + backend: .doryHypervisor, + runtimeBuildIdentifier: "raw-runtime-1", + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: "raw-runtime-1", + artifactSHA256: transactionDigest("c") + )], + hostQualification: transactionHostQualification() + )], + resourceAdmission: transactionDummyAdmission(requested) + ) + trust = TransactionTrust( + hostResources: resources, + snapshot: snapshot, + rejectPublication: rejectPublication + ) + workspaces = DoryWorkspaceRepository(root: root) + plans = DoryResolvedMachinePlanRepository(root: root) + if let clock { + ledger = DoryVirtualMachineResourceAdmissionLedger( + root: root + "/.resource-admissions", + now: clock.read + ) + } else { + ledger = DoryVirtualMachineResourceAdmissionLedger( + root: root + "/.resource-admissions" + ) + } + registry = try BackendRegistry(backends: [RawHVLinuxMachineBackend( + executablePath: "/fixture/dory-hv", + operations: MachineBackendCompatibilityOperations( + start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + ), + executableIsAvailable: { _ in true } + )]) + } + + deinit { try? FileManager.default.removeItem(atPath: root) } + + func request( + leaseDuration: Int64 = 120_000 + ) -> DoryDaemonVirtualMachinePlanningTransactionRequest { + DoryDaemonVirtualMachinePlanningTransactionRequest( + planning: DoryDaemonVirtualMachinePlanningRequest( + definition: definition, + canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(definition), + machine: machine, + publication: .create + ), + workspacePublication: .create, + startingLeaseDurationMilliseconds: leaseDuration + ) + } + + func coordinator( + fault: TransactionFault? = nil + ) -> DoryDaemonVirtualMachinePlanningTransactionCoordinator { + if let fault { + return DoryDaemonVirtualMachinePlanningTransactionCoordinator( + stateDirectory: root, + registry: registry, + trust: trust, + mutationAuthority: mutationAuthority, + workspaces: workspaces, + plans: plans, + ledger: ledger, + capabilityPlanner: TransactionPlanner(capability: capability), + now: { 1_700_000_000_100 }, + faultInjector: fault.inject + ) + } + return DoryDaemonVirtualMachinePlanningTransactionCoordinator( + stateDirectory: root, + registry: registry, + trust: trust, + mutationAuthority: mutationAuthority, + workspaces: workspaces, + plans: plans, + ledger: ledger, + capabilityPlanner: TransactionPlanner(capability: capability), + now: { 1_700_000_000_100 } + ) + } +} + +private final class TransactionTrust: + DoryDaemonVirtualMachinePlanningTrustPreparing, @unchecked Sendable +{ + private let lock = NSLock() + private var storedHostResources: DoryVMHostResources + let trustedSnapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + private var storedRejectPublication: Bool + + var rejectPublication: Bool { + get { lock.withLock { storedRejectPublication } } + set { lock.withLock { storedRejectPublication = newValue } } + } + + init( + hostResources: DoryVMHostResources, + snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot, + rejectPublication: Bool + ) { + storedHostResources = hostResources + trustedSnapshot = snapshot + storedRejectPublication = rejectPublication + } + + func updateFreeStorage(_ freeStorageBytes: UInt64) { + lock.withLock { + storedHostResources = DoryVMHostResources( + logicalCPUCount: storedHostResources.logicalCPUCount, + physicalMemoryBytes: storedHostResources.physicalMemoryBytes, + freeStorageBytes: freeStorageBytes, + admittedVirtualCPUCount: storedHostResources.admittedVirtualCPUCount, + admittedMemoryBytes: storedHostResources.admittedMemoryBytes, + reservedStorageBytes: storedHostResources.reservedStorageBytes + ) + } + } + + func preparePlanningTrust( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation { + _ = request + let currentHostResources = lock.withLock { storedHostResources } + return DoryDaemonVirtualMachinePlanningTrustPreparation( + hostResources: currentHostResources, + snapshot: { admission in + var snapshot = self.trustedSnapshot + snapshot.resourceAdmission = admission + return snapshot + }, + publicationAuthorization: + DoryDaemonVirtualMachinePlanningPublicationAuthorization { + if self.rejectPublication { throw TransactionInjectedFailure() } + } + ) + } +} + +private final class TransactionMutationAuthority: + DoryDaemonVirtualMachinePlanningMutationAuthorizing, @unchecked Sendable +{ + private let lock = NSLock() + private var storedAuthoritySHA256 = transactionDigest("e") + var authoritySHA256: String { + get { lock.withLock { storedAuthoritySHA256 } } + set { lock.withLock { storedAuthoritySHA256 = newValue } } + } + + func acquirePlanningMutationFence( + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data + ) throws -> DoryDaemonVirtualMachinePlanningMutationFence { + guard machine.id == definition.identity.id, !canonicalDefinitionData.isEmpty else { + throw TransactionInjectedFailure() + } + let digest = authoritySHA256 + return DoryDaemonVirtualMachinePlanningMutationFence( + authoritySHA256: digest, + retainedAuthority: digest, + validation: {} + ) + } +} + +private struct TransactionPlanner: DoryDaemonVirtualMachineCapabilityPlanning { + let capability: DoryVirtualMachineCapabilityDescriptor + func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineBackendPlanResult { + _ = request + _ = inventory + return DoryVirtualMachineBackendPlanResult( + selectedDescriptor: capability, + evaluatedDescriptors: [capability], + failure: nil + ) + } +} + +private final class TransactionFault: @unchecked Sendable { + private let lock = NSLock() + private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage? + init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) { + self.stage = stage + } + func inject( + _ value: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage + ) throws { + try lock.withLock { + if stage == value { + stage = nil + throw TransactionInjectedFailure() + } + } + } +} + +private final class TransactionResultBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Result] = [] + var values: [Result] { lock.withLock { storage } } + func append(_ value: Result) { lock.withLock { storage.append(value) } } +} + +private struct TransactionInjectedFailure: Error {} + +private final class TransactionClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Int64 + init(_ value: Int64) { self.value = value } + var read: @Sendable () -> Int64 { { [self] in lock.withLock { value } } } + func advance(_ milliseconds: Int64) { lock.withLock { value += milliseconds } } +} + +private func transactionHostFacts() -> DoryAppleSiliconHostFacts { + DoryAppleSiliconHostFacts( + macOSMajorVersion: 26, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, networkAvailable: false, + displayAvailable: false, inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: "raw-runtime-1", + virtualizationFrameworkAdapterBuildID: "vz-runtime-1", + qemuRuntimeBuildID: "" + ) + ) +} + +private func transactionHostQualification() -> DoryResolvedHostQualificationEvidence { + DoryResolvedHostQualificationEvidence( + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: transactionDigest("b"), + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-host-qualifier", + qualifierVersion: 1 + ) +} + +private func transactionDummyAdmission( + _ resources: DoryVMResourceRequest +) -> DoryResolvedMachineResourceAdmissionEvidence { + DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: resources.virtualCPUCount, + admittedMemoryBytes: resources.memoryBytes, + admittedStorageBytes: resources.diskBytes, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024, + existingVirtualCPUCommitment: 0, + existingMemoryCommitmentBytes: 0, + existingStorageReservationBytes: 0, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024, + admissionIdentity: "dummy-admission", + admissionReportSHA256: transactionDigest("d"), + assessorIdentifier: "dory.resource-admission-ledger", + assessorVersion: 1 + ) +} + +private func transactionDigest(_ value: Character) -> String { + String(repeating: String(value), count: 64) +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 0d2efe50..b8aa7ea8 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -348,6 +348,76 @@ struct DoryDaemonVirtualMachineProductionTrustTests { } } + @Test("production planning preparation resolves exact signed candidates before admission") + func planningPreparationUsesQualificationAuthority() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .ready(context) = fixture.resolve(), + let preparer = context.inventory + as? any DoryDaemonVirtualMachinePlanningTrustPreparing else { + Issue.record("Expected production planning trust preparer") + return + } + let start = try fixture.makeBoundStartRequest() + let request = productionPlanningRequest(start) + let preparation = try preparer.preparePlanningTrust(for: request) + let snapshot = preparation.snapshot(start.resolvedPlan.resourceAdmission!) + + #expect(snapshot.media.reference == start.bootMediaReference) + #expect(snapshot.backendRuntimes.count == 2) + #expect(snapshot.runtimeQualifications.count == 2) + #expect(snapshot.backendRuntime(for: .doryHypervisor) != nil) + } + + @Test("publication authorization refreshes immutable host identity but not volatile free bytes") + func planningPublicationFreshness() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .ready(context) = fixture.resolve(), + let preparer = context.inventory + as? any DoryDaemonVirtualMachinePlanningTrustPreparing else { + Issue.record("Expected production planning trust preparer") + return + } + let start = try fixture.makeBoundStartRequest() + let freeStoragePreparation = try preparer.preparePlanningTrust( + for: productionPlanningRequest(start) + ) + var host = fixture.host + host = DoryDaemonProductionHostObservation( + hardwareModelIdentifier: host.hardwareModelIdentifier, + operatingSystemBuild: host.operatingSystemBuild, + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable, + hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable, + metalAvailable: host.metalAvailable, + resources: DoryVMHostResources( + logicalCPUCount: host.resources.logicalCPUCount, + physicalMemoryBytes: host.resources.physicalMemoryBytes, + freeStorageBytes: host.resources.freeStorageBytes - 1 + ) + ) + fixture.hostState.set(host) + try freeStoragePreparation.publicationAuthorization.authorize() + + let staleIdentityPreparation = try preparer.preparePlanningTrust( + for: productionPlanningRequest(start) + ) + host = DoryDaemonProductionHostObservation( + hardwareModelIdentifier: host.hardwareModelIdentifier, + operatingSystemBuild: host.operatingSystemBuild + "-changed", + macOSMajorVersion: host.macOSMajorVersion, + virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable, + hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable, + metalAvailable: host.metalAvailable, + resources: host.resources + ) + fixture.hostState.set(host) + #expect(throws: DoryDaemonProductionTrustInventoryError.self) { + try staleIdentityPreparation.publicationAuthorization.authorize() + } + } + @Test("installed Linux boot verification hashes the embedded kernel and initrd") func installedBootBundleContentVerification() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -421,6 +491,33 @@ private final class ProductionHostState: @unchecked Sendable { } } +private func productionPlanningRequest( + _ start: DoryDaemonVirtualMachineStartInventoryRequest +) -> DoryDaemonVirtualMachineInventoryRequest { + let plan = start.resolvedPlan + return DoryDaemonVirtualMachineInventoryRequest( + machineID: plan.machineID, + definitionRevision: plan.definitionRevision, + guest: plan.guest, + bootMedia: DoryVMBootMediaReference( + id: "system", + role: .system, + kind: plan.bootMedia.media.kind, + source: plan.bootMedia.media.source, + artifact: plan.bootMedia.resolverReference!, + removable: false + ), + resources: DoryVMResourceRequest( + virtualCPUCount: plan.resourceAdmission!.admittedVirtualCPUCount, + memoryBytes: plan.resourceAdmission!.admittedMemoryBytes, + diskBytes: plan.resourceAdmission!.admittedStorageBytes + ), + devices: plan.devices, + acceptableGraphics: [plan.graphics], + virtualHardwareABIVersion: plan.virtualHardwareABIVersion + ) +} + private final class ProductionTrustFixture: @unchecked Sendable { let root: URL let drive: DoryDataDrive diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift index df7429bf..fe166483 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -344,6 +344,87 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { )) } + func testPlanningCompensationCancelsOnlyUnboundAndRetainsStorage() throws { + let fixture = try ResourceLedgerFixture("planning-cancel") + defer { fixture.cleanup() } + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let stopped = try fixture.ledger.cancelUnboundStarting( + leaseID: reserved.leaseID, + expectedLeaseRevision: reserved.leaseRevision + ) + XCTAssertEqual(stopped.state, .stopped) + XCTAssertNil(stopped.boundPlanSHA256) + XCTAssertEqual(stopped.resources.diskBytes, fixture.resources.diskBytes) + + let restarted = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let plan = fixture.plan(binding: restarted.binding, evidence: restarted.evidence) + let bound = try fixture.ledger.bind( + leaseID: restarted.leaseID, + to: plan, + expectedLeaseRevision: restarted.leaseRevision + ) + XCTAssertThrowsError(try fixture.ledger.cancelUnboundStarting( + leaseID: bound.leaseID, + expectedLeaseRevision: bound.leaseRevision + )) + } + + func testExpiredBoundPlanningLeaseRequiresExactUnlaunchedAuthorization() throws { + let clock = ResourceLedgerClock(now: 40_000) + let fixture = try ResourceLedgerFixture("bound-planning-recovery", clock: clock) + defer { fixture.cleanup() } + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources, + startingLeaseDurationMilliseconds: 100 + ) + let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence) + _ = try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: reserved.leaseRevision + ) + clock.advance(by: 100) + let expired = try XCTUnwrap(try fixture.ledger.snapshot().leases.first) + XCTAssertEqual(expired.state, .recoveryRequired) + XCTAssertThrowsError(try fixture.ledger.recoverBoundPlanningLease( + leaseID: expired.leaseID, + plan: plan, + authorization: DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization( + machineID: plan.machineID, + planSHA256: digest("f") + ), + startingLeaseDurationMilliseconds: 100, + expectedLeaseRevision: expired.leaseRevision + )) + let exact = DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization( + machineID: plan.machineID, + planSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan) + ) + let recovered = try fixture.ledger.recoverBoundPlanningLease( + leaseID: expired.leaseID, + plan: plan, + authorization: exact, + startingLeaseDurationMilliseconds: 100, + expectedLeaseRevision: expired.leaseRevision + ) + XCTAssertEqual(recovered.state, .starting) + XCTAssertEqual(recovered.boundPlanSHA256, + DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan)) + } + func testBoundPlanAndHostFactsAreExactStartGates() throws { try withFixture("exact-binding") { fixture in let reserved = try fixture.ledger.reserveStarting( From a29641208ae978d5bb567db4f051a07d4629e19a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 16:41:11 +0000 Subject: [PATCH 033/338] test(vm): bind exact runtime qualification fixtures --- .../DoryDaemonVirtualMachineRuntimePlanningTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 08014aa7..c4aab26e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -514,8 +514,8 @@ private func resourceAdmission( private func hostQualification() -> DoryResolvedHostQualificationEvidence { DoryResolvedHostQualificationEvidence( - qualificationIdentity: "host-qualification-1", - qualificationReportSHA256: digest("e"), + qualificationIdentity: "runtime-qualification-1", + qualificationReportSHA256: digest("b"), hostHardwareModelIdentifier: "Mac16.1", hostOperatingSystemBuild: "26A5406c", backend: .doryHypervisor, From 087040e790126317a5e1a62f1c2edbed21638029 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 17:05:07 +0000 Subject: [PATCH 034/338] feat(vm): verify installed desktop payloads --- Dory/Models/AppStore.swift | 92 ++- Dory/Runtime/Doryd/DorydClient.swift | 205 +++++- DoryTests/DorydClientTests.swift | 192 +++++- .../DoryInstalledDesktopPayloadReceipt.swift | 413 ++++++++++++ .../DoryMachineConfigurationMigration.swift | 4 +- .../Sources/DorydKit/DorydService.swift | 70 +- .../Sources/DorydKit/MachineManager.swift | 543 +++++++++++++-- dory-core-swift/Sources/doryd/main.swift | 8 +- dory-core-swift/Sources/dorydctl/main.swift | 14 +- ...yInstalledDesktopPayloadReceiptTests.swift | 273 ++++++++ ...ryMachineConfigurationMigrationTests.swift | 19 +- .../DorydKitTests/DorydServiceTests.swift | 120 ++++ .../DorydKitTests/MachineManagerTests.swift | 630 +++++++++++++++++- 13 files changed, 2471 insertions(+), 112 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 751e2420..c16278ac 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5459,8 +5459,6 @@ final class AppStore { throw DesktopMachineAssetError.missingAsset("runtime update") } _ = try store.verify(.linuxDesktop) - let home = environment["HOME"] ?? NSHomeDirectory() - var prepared: [DesktopMachineDistro: DesktopMachineAssets] = [:] var results: [DorydDesktopUpdateResult] = [] for status in desktopStatuses { @@ -5472,7 +5470,11 @@ final class AppStore { displayMode: status.displayMode ) let distro = DesktopMachineDistro.resolve( - typedSettings.guestIdentityIntent.desktop?.distributionIdentifier + Self.managedDesktopDistributionIdentifier( + configuredIdentifier: + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier, + receipt: status.installedDesktopPayloadReceipt + ) ) guard runtimeChanged || components.contains(distro.componentID) else { continue } guard let distroRelease = try store.installedComponent(distro.componentID) else { @@ -5485,25 +5487,32 @@ final class AppStore { } _ = try store.verify(distro.componentID) let targetVersion = distroRelease.version + "+runtime." + runtimeRelease.version - if status.environment["DORY_DESKTOP_RELEASE_VERSION"] == targetVersion { - continue - } - guard let bundlePath = DoryComponentStore.activeAssetPath( - component: distro.componentID, - path: "dory-desktop-" + distro.rawValue + "-update-arm64.tar" - ) else { + let bundleAssetIdentifier = "dory-desktop-" + distro.rawValue + + "-update-arm64.tar" + let kernelAssetIdentifier = "dory-desktop-kernel-arm64.lzfse" + guard let bundleAsset = distroRelease.assets.first(where: { + $0.path == bundleAssetIdentifier + }), + let kernelAsset = runtimeRelease.assets.first(where: { + $0.path == kernelAssetIdentifier + }) else { throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update") } - let assets: DesktopMachineAssets - if let cached = prepared[distro] { - assets = cached - } else { - assets = try await desktopMachineAssetPreparer( - home, - Self.desktopAssetEnvironment(processEnvironment: environment, distro: distro), - Bundle.main.resourcePath - ) - prepared[distro] = assets + if Self.desktopReceiptMatchesActiveComponents( + status.installedDesktopPayloadReceipt, + distributionIdentifier: distro.rawValue, + releaseVersion: targetVersion, + distributionComponentIdentifier: distro.componentID.rawValue, + distributionInstallationName: distroRelease.installationName, + distributionCatalogSHA256: distroRelease.catalogDigest, + bundleAssetIdentifier: bundleAsset.path, + bundleSHA256: bundleAsset.installedSHA256, + runtimeInstallationName: runtimeRelease.installationName, + runtimeCatalogSHA256: runtimeRelease.catalogDigest, + kernelAssetIdentifier: kernelAsset.path, + kernelSHA256: kernelAsset.installedSHA256 + ) { + continue } busyMachines.insert(status.id) @@ -5513,8 +5522,8 @@ final class AppStore { status.id, distro: distro.rawValue, version: targetVersion, - bundlePath: bundlePath, - kernelPath: assets.kernelPath + distributionInstallationName: distroRelease.installationName, + runtimeInstallationName: runtimeRelease.installationName ) results.append(result) } catch { @@ -5530,6 +5539,45 @@ final class AppStore { return results } + /// Portable snapshots intentionally omit raw environment. Preserve an explicit typed guest + /// identity when one exists; otherwise use the validated installed-payload observation. + nonisolated static func managedDesktopDistributionIdentifier( + configuredIdentifier: String?, + receipt: DorydInstalledDesktopPayloadReceipt? + ) -> String? { + configuredIdentifier ?? (receipt?.isValid == true ? receipt?.distributionIdentifier : nil) + } + + nonisolated static func desktopReceiptMatchesActiveComponents( + _ receipt: DorydInstalledDesktopPayloadReceipt?, + distributionIdentifier: String, + releaseVersion: String, + distributionComponentIdentifier: String, + distributionInstallationName: String, + distributionCatalogSHA256: String, + bundleAssetIdentifier: String, + bundleSHA256: String, + runtimeInstallationName: String, + runtimeCatalogSHA256: String, + kernelAssetIdentifier: String, + kernelSHA256: String + ) -> Bool { + guard let receipt, receipt.isValid else { return false } + return receipt.provenance == "verified-update-bundle" + && receipt.distributionIdentifier == distributionIdentifier + && receipt.releaseVersion == releaseVersion + && receipt.distributionComponentIdentifier == distributionComponentIdentifier + && receipt.distributionInstallationName == distributionInstallationName + && receipt.distributionCatalogSHA256 == distributionCatalogSHA256.lowercased() + && receipt.bundleAssetIdentifier == bundleAssetIdentifier + && receipt.bundleSHA256 == bundleSHA256.lowercased() + && receipt.runtimeComponentIdentifier == DoryComponentID.linuxDesktop.rawValue + && receipt.runtimeInstallationName == runtimeInstallationName + && receipt.runtimeCatalogSHA256 == runtimeCatalogSHA256.lowercased() + && receipt.kernelAssetIdentifier == kernelAssetIdentifier + && receipt.kernelSHA256 == kernelSHA256.lowercased() + } + nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings { var copy = settings if copy.env.isEmpty { copy.env = existing.env } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index ee8904df..0879e3e7 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -572,6 +572,109 @@ nonisolated private extension String { || byte == 58 || byte == 64 || byte == 95 } } + + var isSafeComponentInstallationName: Bool { + let bytes = Array(utf8) + guard (1...255).contains(bytes.count), + let first = bytes.first, + (first >= 48 && first <= 57) + || (first >= 65 && first <= 90) + || (first >= 97 && first <= 122) else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 43 || byte == 45 || byte == 46 || byte == 95 + } + } +} + +nonisolated struct DorydInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var provenance: String + var distributionIdentifier: String + var releaseVersion: String + var inputSHA256: String + var bundleSHA256: String? + var distributionComponentIdentifier: String? + var distributionInstallationName: String? + var distributionCatalogSHA256: String? + var bundleAssetIdentifier: String? + var runtimeComponentIdentifier: String? + var runtimeInstallationName: String? + var runtimeCatalogSHA256: String? + var kernelAssetIdentifier: String? + var kernelSHA256: String? + + var isValid: Bool { + guard schemaVersion == 1, + ["debian", "kali", "ubuntu"].contains(distributionIdentifier), + releaseVersion.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil, + inputSHA256.isLowercaseSHA256 else { + return false + } + switch provenance { + case "legacy-environment": + return verifiedAuthorityFieldsAreNil + case "legacy-snapshot-migration": + return verifiedAuthorityFieldsAreNil + case "verified-update-bundle": + return bundleSHA256?.isLowercaseSHA256 == true + && kernelSHA256?.isLowercaseSHA256 == true + && distributionComponentIdentifier == "desktop-" + distributionIdentifier + && distributionInstallationName?.isSafeComponentInstallationName == true + && distributionCatalogSHA256?.isLowercaseSHA256 == true + && bundleAssetIdentifier + == "dory-desktop-" + distributionIdentifier + "-update-arm64.tar" + && runtimeComponentIdentifier == "linux-desktop" + && runtimeInstallationName?.isSafeComponentInstallationName == true + && runtimeCatalogSHA256?.isLowercaseSHA256 == true + && kernelAssetIdentifier == "dory-desktop-kernel-arm64.lzfse" + default: + return false + } + } + + private var verifiedAuthorityFieldsAreNil: Bool { + bundleSHA256 == nil + && distributionComponentIdentifier == nil + && distributionInstallationName == nil + && distributionCatalogSHA256 == nil + && bundleAssetIdentifier == nil + && runtimeComponentIdentifier == nil + && runtimeInstallationName == nil + && runtimeCatalogSHA256 == nil + && kernelAssetIdentifier == nil + && kernelSHA256 == nil + } + + static func legacyEnvironment(_ environment: [String: String]) -> Self? { + guard let distributionIdentifier = environment["DORY_DESKTOP_DISTRO"], + let releaseVersion = environment["DORY_DESKTOP_RELEASE_VERSION"], + let inputSHA256 = environment["DORY_DESKTOP_INPUT_SHA256"] else { + return nil + } + let receipt = Self( + schemaVersion: 1, + provenance: "legacy-environment", + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256, + bundleSHA256: nil, + distributionComponentIdentifier: nil, + distributionInstallationName: nil, + distributionCatalogSHA256: nil, + bundleAssetIdentifier: nil, + runtimeComponentIdentifier: nil, + runtimeInstallationName: nil, + runtimeCatalogSHA256: nil, + kernelAssetIdentifier: nil, + kernelSHA256: nil + ) + return receipt.isValid ? receipt : nil + } } nonisolated private extension DorydMachineRuntimeBootMediaIdentity { @@ -660,6 +763,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil } nonisolated struct DorydMachineExecResult: Sendable, Equatable { @@ -713,6 +817,7 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var cpuCount: Int var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil + var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil } nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { @@ -1254,14 +1359,14 @@ nonisolated final class DorydClient: @unchecked Sendable { _ machineID: String, distro: String, version: String, - bundlePath: String, - kernelPath: String + distributionInstallationName: String, + runtimeInstallationName: String ) async throws -> DorydDesktopUpdateResult { let request: NSDictionary = [ "distro": distro, "version": version, - "bundlePath": bundlePath, - "kernelPath": kernelPath, + "distributionInstallationName": distributionInstallationName, + "runtimeInstallationName": runtimeInstallationName, ] return try await withTimeout(atLeast: 3_900).statusCommand { proxy, reply in proxy.machineDesktopUpdate(machineID, request: request, reply: reply) @@ -1702,6 +1807,13 @@ nonisolated final class DorydClient: @unchecked Sendable { let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else { return nil } + let environment = machineEnvironment(from: dictionary["env"]) + guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( + from: dictionary, + legacyEnvironment: environment + ) else { + return nil + } return DorydMachineStatus( id: id, state: state, @@ -1726,9 +1838,84 @@ nonisolated final class DorydClient: @unchecked Sendable { ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue ?? false, shares: machineShares(from: dictionary["shares"]), - environment: machineEnvironment(from: dictionary["env"]), - runtimeIdentity: runtimeIdentity + environment: environment, + runtimeIdentity: runtimeIdentity, + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value + ) + } + + private struct ParsedInstalledDesktopPayloadReceipt { + var value: DorydInstalledDesktopPayloadReceipt? + } + + /// Only absence gets legacy compatibility. A present typed receipt is an evidence claim and + /// malformed or future-schema values fail the entire row instead of downgrading to env. + nonisolated private static func machineInstalledDesktopPayloadReceipt( + from dictionary: NSDictionary, + legacyEnvironment: [String: String]? = nil + ) -> ParsedInstalledDesktopPayloadReceipt? { + guard let encoded = dictionary["installedDesktopPayloadReceipt"] else { + return ParsedInstalledDesktopPayloadReceipt( + value: legacyEnvironment.flatMap( + DorydInstalledDesktopPayloadReceipt.legacyEnvironment + ) + ) + } + guard let receiptDictionary = encoded as? NSDictionary, + let receipt = strictInstalledDesktopPayloadReceipt(from: receiptDictionary), + receipt.isValid else { + return nil + } + return ParsedInstalledDesktopPayloadReceipt(value: receipt) + } + + nonisolated private static func strictInstalledDesktopPayloadReceipt( + from dictionary: NSDictionary + ) -> DorydInstalledDesktopPayloadReceipt? { + let allowed: Set = [ + "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion", + "inputSHA256", "bundleSHA256", "distributionComponentIdentifier", + "distributionInstallationName", "distributionCatalogSHA256", + "bundleAssetIdentifier", "runtimeComponentIdentifier", "runtimeInstallationName", + "runtimeCatalogSHA256", "kernelAssetIdentifier", "kernelSHA256", + ] + guard let keys = dictionary.allKeys as? [String], Set(keys).isSubset(of: allowed), + keys.count == Set(keys).count, + let schemaNumber = dictionary["schemaVersion"] as? NSNumber, + CFGetTypeID(schemaNumber) != CFBooleanGetTypeID(), + schemaNumber.uint64Value == 1, + schemaNumber.doubleValue == 1, + let provenance = dictionary["provenance"] as? String, + let distributionIdentifier = dictionary["distributionIdentifier"] as? String, + let releaseVersion = dictionary["releaseVersion"] as? String, + let inputSHA256 = dictionary["inputSHA256"] as? String else { + return nil + } + let optionalKeys = allowed.subtracting([ + "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion", + "inputSHA256", + ]) + for key in optionalKeys where dictionary[key] != nil { + guard dictionary[key] is String else { return nil } + } + let receipt = DorydInstalledDesktopPayloadReceipt( + schemaVersion: 1, + provenance: provenance, + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256, + bundleSHA256: dictionary["bundleSHA256"] as? String, + distributionComponentIdentifier: dictionary["distributionComponentIdentifier"] as? String, + distributionInstallationName: dictionary["distributionInstallationName"] as? String, + distributionCatalogSHA256: dictionary["distributionCatalogSHA256"] as? String, + bundleAssetIdentifier: dictionary["bundleAssetIdentifier"] as? String, + runtimeComponentIdentifier: dictionary["runtimeComponentIdentifier"] as? String, + runtimeInstallationName: dictionary["runtimeInstallationName"] as? String, + runtimeCatalogSHA256: dictionary["runtimeCatalogSHA256"] as? String, + kernelAssetIdentifier: dictionary["kernelAssetIdentifier"] as? String, + kernelSHA256: dictionary["kernelSHA256"] as? String ) + return receipt.isValid ? receipt : nil } /// Only an absent key is compatible with an older daemon. A present identity is an evidence @@ -1893,6 +2080,9 @@ nonisolated final class DorydClient: @unchecked Sendable { let memoryMB = uint64(dictionary["memoryMB"]), let cpuCount = int(dictionary["cpuCount"]), let runtimeIdentity = machineRuntimeIdentity(from: dictionary), + let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( + from: dictionary + ), let artifactEvidence = machineSnapshotArtifactEvidence( from: dictionary, runtimeIdentity: runtimeIdentity @@ -1911,7 +2101,8 @@ nonisolated final class DorydClient: @unchecked Sendable { memoryMB: memoryMB, cpuCount: cpuCount, runtimeIdentity: runtimeIdentity, - artifactEvidence: artifactEvidence.value + artifactEvidence: artifactEvidence.value, + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value ) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 4e4c897d..c46d5f22 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -524,6 +524,169 @@ struct DorydClientTests { } } + @Test func installedDesktopPayloadReceiptUsesAbsentOnlyLegacyCompatibilityAndRejectsMalformedClaims() async throws { + let digest = String(repeating: "a", count: 64) + let valid: [String: Any] = [ + "schemaVersion": 1, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + "bundleSHA256": String(repeating: "b", count: 64), + "distributionComponentIdentifier": "desktop-ubuntu", + "distributionInstallationName": "ubuntu-installation", + "distributionCatalogSHA256": String(repeating: "c", count: 64), + "bundleAssetIdentifier": "dory-desktop-ubuntu-update-arm64.tar", + "runtimeComponentIdentifier": "linux-desktop", + "runtimeInstallationName": "runtime-installation", + "runtimeCatalogSHA256": String(repeating: "d", count: 64), + "kernelAssetIdentifier": "dory-desktop-kernel-arm64.lzfse", + "kernelSHA256": String(repeating: "e", count: 64), + ] + do { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + installedDesktopPayloadReceiptOverride: valid as NSDictionary + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let status = try #require(try await client.machineList().first) + #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.7") + #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == String(repeating: "b", count: 64)) + let snapshot = try await client.machineSnapshot( + "dev", + note: "receipt", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "receipt" + ) + #expect(snapshot.installedDesktopPayloadReceipt == status.installedDesktopPayloadReceipt) + } + + do { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineEnvironment("dev", [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.6", + "DORY_DESKTOP_INPUT_SHA256": digest, + ]) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let status = try #require( + try await DorydClient(endpoint: listener.endpoint).machineList().first + ) + #expect(status.installedDesktopPayloadReceipt?.provenance == "legacy-environment") + #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.6") + #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == nil) + } + + for malformed in [ + [ + "schemaVersion": 2, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + "bundleSHA256": String(repeating: "b", count: 64), + ], + [ + "schemaVersion": 1, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + ], + valid.merging(["unknownEvidence": "must-reject"]) { _, new in new }, + valid.merging(["schemaVersion": "1"]) { _, new in new }, + valid.merging(["bundleSHA256": NSNumber(value: 7)]) { _, new in new }, + valid.merging(["distributionInstallationName": "../../outside-store"]) { _, new in new }, + ] as [[String: Any]] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + installedDesktopPayloadReceiptOverride: malformed as NSDictionary + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + await #expect(throws: DorydClientError.self) { + _ = try await client.machineSnapshot( + "dev", + note: "invalid receipt", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-receipt" + ) + } + } + } + + @Test func desktopUpdateSkipRequiresExactVerifiedActiveComponentProvenance() { + let receipt = DorydInstalledDesktopPayloadReceipt( + schemaVersion: 1, + provenance: "verified-update-bundle", + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + inputSHA256: String(repeating: "a", count: 64), + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + let matches: (DorydInstalledDesktopPayloadReceipt?) -> Bool = { candidate in + AppStore.desktopReceiptMatchesActiveComponents( + candidate, + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + bundleSHA256: String(repeating: "b", count: 64), + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + } + #expect(matches(receipt)) + var wrongDistro = receipt + wrongDistro.distributionIdentifier = "kali" + #expect(!matches(wrongDistro)) + var staleBundle = receipt + staleBundle.bundleSHA256 = String(repeating: "f", count: 64) + #expect(!matches(staleBundle)) + var legacy = receipt + legacy.provenance = "legacy-environment" + #expect(!matches(legacy)) + #expect( + AppStore.managedDesktopDistributionIdentifier( + configuredIdentifier: nil, + receipt: receipt + ) == "ubuntu" + ) + #expect( + AppStore.managedDesktopDistributionIdentifier( + configuredIdentifier: "kali", + receipt: receipt + ) == "kali" + ) + } + private func validResolvedRuntimeIdentity() -> NSDictionary { [ "schemaVersion": 1, @@ -2147,6 +2310,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineProvisionRecipe: String? private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? + private var installedDesktopPayloadReceiptOverride: NSDictionary? private var snapshots: [String: [NSDictionary]] = [:] private var backupStatuses: [String: NSDictionary] = [:] var engineStartCount: Int { @@ -2251,15 +2415,22 @@ private final class FakeDorydService: NSObject, DorydControlXPC { socketPath: String = "/tmp/doryd-test.sock", engineShutdownReplyDelay: TimeInterval = 0, runtimeIdentityOverride: NSDictionary? = nil, - artifactEvidenceOverride: NSDictionary? = nil + artifactEvidenceOverride: NSDictionary? = nil, + installedDesktopPayloadReceiptOverride: NSDictionary? = nil ) { self.socketPath = socketPath self.engineShutdownReplyDelay = engineShutdownReplyDelay self.runtimeIdentityOverride = runtimeIdentityOverride self.artifactEvidenceOverride = artifactEvidenceOverride - if let runtimeIdentityOverride, - let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { - existing["runtimeIdentity"] = runtimeIdentityOverride + self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride + if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { + if let runtimeIdentityOverride { + existing["runtimeIdentity"] = runtimeIdentityOverride + } + if let installedDesktopPayloadReceiptOverride { + existing["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride + } machines["dev"] = existing.copy() as? NSDictionary } } @@ -2586,10 +2757,23 @@ private final class FakeDorydService: NSObject, DorydControlXPC { if let artifactEvidenceOverride { mutable["artifactEvidence"] = artifactEvidenceOverride } + if let installedDesktopPayloadReceiptOverride { + mutable["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride + } row = mutable.copy() as? NSDictionary ?? baseRow } else if let artifactEvidenceOverride, let mutable = baseRow.mutableCopy() as? NSMutableDictionary { mutable["artifactEvidence"] = artifactEvidenceOverride + if let installedDesktopPayloadReceiptOverride { + mutable["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride + } + row = mutable.copy() as? NSDictionary ?? baseRow + } else if let installedDesktopPayloadReceiptOverride, + let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + mutable["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride row = mutable.copy() as? NSDictionary ?? baseRow } else { row = baseRow diff --git a/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift new file mode 100644 index 00000000..45d74b1a --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift @@ -0,0 +1,413 @@ +import Foundation +import DoryOperations + +/// Identifies how an installed desktop payload receipt was established. +/// +/// Legacy receipts are a read-only compatibility projection of the two historical environment +/// fields. Newly installed payloads are always bound to the exact verified update bundle. +public enum DoryInstalledDesktopPayloadReceiptProvenance: String, Codable, Sendable, Equatable, Hashable { + case legacyEnvironment = "legacy-environment" + case legacySnapshotMigration = "legacy-snapshot-migration" + case verifiedUpdateBundle = "verified-update-bundle" +} + +/// Non-secret, versioned evidence describing the desktop payload installed in a Linux guest. +/// +/// This is observed durable state rather than desired VM intent. It deliberately contains no +/// host path, token, credential, or guest output beyond bounded identifiers and SHA-256 digests. +public struct DoryInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + public static let legacyReleaseVersionEnvironmentKey = "DORY_DESKTOP_RELEASE_VERSION" + public static let legacyInputSHA256EnvironmentKey = "DORY_DESKTOP_INPUT_SHA256" + + public var schemaVersion: UInt16 + public var provenance: DoryInstalledDesktopPayloadReceiptProvenance + public var distributionIdentifier: String + public var releaseVersion: String + public var inputSHA256: String + public var bundleSHA256: String? + public var distributionComponentIdentifier: String? + public var distributionInstallationName: String? + public var distributionCatalogSHA256: String? + public var bundleAssetIdentifier: String? + public var runtimeComponentIdentifier: String? + public var runtimeInstallationName: String? + public var runtimeCatalogSHA256: String? + public var kernelAssetIdentifier: String? + public var kernelSHA256: String? + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + provenance: DoryInstalledDesktopPayloadReceiptProvenance, + distributionIdentifier: String, + releaseVersion: String, + inputSHA256: String, + bundleSHA256: String? = nil, + distributionComponentIdentifier: String? = nil, + distributionInstallationName: String? = nil, + distributionCatalogSHA256: String? = nil, + bundleAssetIdentifier: String? = nil, + runtimeComponentIdentifier: String? = nil, + runtimeInstallationName: String? = nil, + runtimeCatalogSHA256: String? = nil, + kernelAssetIdentifier: String? = nil, + kernelSHA256: String? = nil + ) { + self.schemaVersion = schemaVersion + self.provenance = provenance + self.distributionIdentifier = distributionIdentifier + self.releaseVersion = releaseVersion + self.inputSHA256 = inputSHA256 + self.bundleSHA256 = bundleSHA256 + self.distributionComponentIdentifier = distributionComponentIdentifier + self.distributionInstallationName = distributionInstallationName + self.distributionCatalogSHA256 = distributionCatalogSHA256 + self.bundleAssetIdentifier = bundleAssetIdentifier + self.runtimeComponentIdentifier = runtimeComponentIdentifier + self.runtimeInstallationName = runtimeInstallationName + self.runtimeCatalogSHA256 = runtimeCatalogSHA256 + self.kernelAssetIdentifier = kernelAssetIdentifier + self.kernelSHA256 = kernelSHA256 + } + + public static func verifiedUpdate( + distributionIdentifier: String, + releaseVersion: String, + inputSHA256: String, + bundleSHA256: String, + distributionComponentIdentifier: String, + distributionInstallationName: String, + distributionCatalogSHA256: String, + bundleAssetIdentifier: String, + runtimeComponentIdentifier: String, + runtimeInstallationName: String, + runtimeCatalogSHA256: String, + kernelAssetIdentifier: String, + kernelSHA256: String + ) -> Self { + Self( + provenance: .verifiedUpdateBundle, + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256, + bundleSHA256: bundleSHA256, + distributionComponentIdentifier: distributionComponentIdentifier, + distributionInstallationName: distributionInstallationName, + distributionCatalogSHA256: distributionCatalogSHA256, + bundleAssetIdentifier: bundleAssetIdentifier, + runtimeComponentIdentifier: runtimeComponentIdentifier, + runtimeInstallationName: runtimeInstallationName, + runtimeCatalogSHA256: runtimeCatalogSHA256, + kernelAssetIdentifier: kernelAssetIdentifier, + kernelSHA256: kernelSHA256 + ) + } + + /// Projects an old machine's receipt without mutating or canonicalizing its raw metadata. + public static func legacyEnvironment(_ environment: [String: String]) -> Self? { + guard let distributionIdentifier = environment["DORY_DESKTOP_DISTRO"], + let releaseVersion = environment[legacyReleaseVersionEnvironmentKey], + let inputSHA256 = environment[legacyInputSHA256EnvironmentKey] else { + return nil + } + let receipt = Self( + provenance: .legacyEnvironment, + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256 + ) + return receipt.isValid ? receipt : nil + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + Self.isValidDistributionIdentifier(distributionIdentifier), + Self.isValidReleaseVersion(releaseVersion), + Self.isLowercaseSHA256(inputSHA256) else { + return false + } + switch provenance { + case .legacyEnvironment: + return allVerifiedAuthorityFieldsAreNil + case .legacySnapshotMigration: + return allVerifiedAuthorityFieldsAreNil + case .verifiedUpdateBundle: + guard bundleSHA256.map(Self.isLowercaseSHA256) == true, + kernelSHA256.map(Self.isLowercaseSHA256) == true, + distributionComponentIdentifier == Self.componentIdentifier(for: distributionIdentifier), + runtimeComponentIdentifier == DoryComponentID.linuxDesktop.rawValue, + distributionInstallationName.map(Self.isSafeIdentifier) == true, + runtimeInstallationName.map(Self.isSafeIdentifier) == true, + distributionCatalogSHA256.map(Self.isLowercaseSHA256) == true, + runtimeCatalogSHA256.map(Self.isLowercaseSHA256) == true, + bundleAssetIdentifier == Self.bundleAssetIdentifier(for: distributionIdentifier), + kernelAssetIdentifier == Self.kernelAssetIdentifier else { + return false + } + return true + } + } + + public func matchesLegacyEnvironment(_ environment: [String: String]) -> Bool { + provenance == .legacyEnvironment && Self.legacyEnvironment(environment) == self + } + + /// Converts a raw legacy projection into an explicitly historical, path-free snapshot claim. + /// It is never treated as verified active-component provenance. + public var archivedLegacySnapshotReceipt: Self? { + guard provenance == .legacyEnvironment, isValid else { return nil } + var copy = self + copy.provenance = .legacySnapshotMigration + return copy + } + + /// Portable bundles are integrity checked but are not signed component-store authority. + /// Preserve bounded historical identity while discarding any active-verification claim. + public var portableSnapshotReceipt: Self? { + guard isValid else { return nil } + if provenance == .legacySnapshotMigration { return self } + return Self( + provenance: .legacySnapshotMigration, + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256 + ) + } + + public func hasCoherentAuthority(environment: [String: String]) -> Bool { + guard isValid else { return false } + let hasLegacyRelease = environment[Self.legacyReleaseVersionEnvironmentKey] != nil + let hasLegacyInput = environment[Self.legacyInputSHA256EnvironmentKey] != nil + switch provenance { + case .legacyEnvironment: + return matchesLegacyEnvironment(environment) + case .legacySnapshotMigration: + return !hasLegacyRelease && !hasLegacyInput + && (environment["DORY_DESKTOP_DISTRO"] == nil + || environment["DORY_DESKTOP_DISTRO"] == distributionIdentifier) + case .verifiedUpdateBundle: + return !hasLegacyRelease && !hasLegacyInput + && (environment["DORY_DESKTOP_DISTRO"] == nil + || environment["DORY_DESKTOP_DISTRO"] == distributionIdentifier) + } + } + + public static func componentIdentifier(for distributionIdentifier: String) -> String? { + switch distributionIdentifier { + case "debian": DoryComponentID.desktopDebian.rawValue + case "ubuntu": DoryComponentID.desktopUbuntu.rawValue + case "kali": DoryComponentID.desktopKali.rawValue + default: nil + } + } + + public static func bundleAssetIdentifier(for distributionIdentifier: String) -> String { + "dory-desktop-\(distributionIdentifier)-update-arm64.tar" + } + + public static let kernelAssetIdentifier = "dory-desktop-kernel-arm64.lzfse" + + private var allVerifiedAuthorityFieldsAreNil: Bool { + bundleSHA256 == nil + && distributionComponentIdentifier == nil + && distributionInstallationName == nil + && distributionCatalogSHA256 == nil + && bundleAssetIdentifier == nil + && runtimeComponentIdentifier == nil + && runtimeInstallationName == nil + && runtimeCatalogSHA256 == nil + && kernelAssetIdentifier == nil + && kernelSHA256 == nil + } + + private static func isValidDistributionIdentifier(_ value: String) -> Bool { + ["debian", "kali", "ubuntu"].contains(value) + } + + private static func isValidReleaseVersion(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...255).contains(bytes.count), + let first = bytes.first, + (first >= 48 && first <= 57) + || (first >= 65 && first <= 90) + || (first >= 97 && first <= 122) else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 43 || byte == 45 || byte == 46 || byte == 95 + } + } +} + +/// Stable, caller-visible selection identity. Host paths never cross XPC. +public struct DoryDesktopUpdateRequest: Sendable, Equatable { + public var distro: String + public var version: String + public var distributionInstallationName: String + public var runtimeInstallationName: String + + public init( + distro: String, + version: String, + distributionInstallationName: String, + runtimeInstallationName: String + ) { + self.distro = distro + self.version = version + self.distributionInstallationName = distributionInstallationName + self.runtimeInstallationName = runtimeInstallationName + } +} + +public struct DoryDesktopUpdateArtifactAuthority: Sendable, Equatable { + public var receipt: DoryInstalledDesktopPayloadReceipt + public var bundlePath: String + public var bundleByteCount: UInt64 + public var kernelPath: String + public var kernelByteCount: UInt64 + + public init( + receipt: DoryInstalledDesktopPayloadReceipt, + bundlePath: String, + bundleByteCount: UInt64, + kernelPath: String, + kernelByteCount: UInt64 + ) { + self.receipt = receipt + self.bundlePath = bundlePath + self.bundleByteCount = bundleByteCount + self.kernelPath = kernelPath + self.kernelByteCount = kernelByteCount + } +} + +public protocol DoryDesktopUpdateArtifactResolving: Sendable { + func resolve( + _ request: DoryDesktopUpdateRequest, + guestArchitecture: String + ) throws -> DoryDesktopUpdateArtifactAuthority +} + +/// Resolves only the active, already signature-qualified component generations. This type never +/// accepts a caller-controlled path and re-verifies every byte immediately before returning it. +public struct DoryComponentStoreDesktopUpdateArtifactResolver: DoryDesktopUpdateArtifactResolving { + public let store: DoryComponentStore + public let publicKey: String + public let expectedArchitecture: String + public let appVersion: String + + /// Production construction is pinned to daemon-compiled trust inputs. The helper executable + /// must not derive qualification authority from its mutable bundle metadata. + public init(store: DoryComponentStore) { + self.init( + store: store, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: DoryDaemonVirtualMachineProductionTrustFactory.compiledDaemonVersion + ) + } + + /// Internal seam for signed-catalog fixtures; production callers use the pinned initializer. + init( + store: DoryComponentStore, + publicKey: String, + expectedArchitecture: String, + appVersion: String + ) { + self.store = store + self.publicKey = publicKey + self.expectedArchitecture = expectedArchitecture + self.appVersion = appVersion + } + + public func resolve( + _ request: DoryDesktopUpdateRequest, + guestArchitecture: String + ) throws -> DoryDesktopUpdateArtifactAuthority { + guard guestArchitecture == expectedArchitecture, + let rawComponentID = DoryInstalledDesktopPayloadReceipt.componentIdentifier( + for: request.distro + ), + let distributionID = DoryComponentID(rawValue: rawComponentID) else { + throw MachineManagerError.persistence("desktop update platform is unsupported") + } + let distribution = try store.verify(distributionID) + let runtime = try store.verify(.linuxDesktop) + guard let signed = try store.cachedCatalog( + publicKey: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) else { + throw MachineManagerError.persistence("signed desktop component catalog is unavailable") + } + let signedCatalogSHA256 = DoryComponentCatalogVerifier.digest(signed.data) + guard distribution.catalogDigest.lowercased() == signedCatalogSHA256, + runtime.catalogDigest.lowercased() == signedCatalogSHA256, + let signedDistribution = signed.catalog.components.first(where: { + $0.id == distributionID && $0.version == distribution.version + }), + let signedRuntime = signed.catalog.components.first(where: { + $0.id == .linuxDesktop && $0.version == runtime.version + }), + signedDistribution.assets == distribution.assets, + signedRuntime.assets == runtime.assets else { + throw MachineManagerError.persistence("active desktop components do not match the signed catalog") + } + guard distribution.installationName == request.distributionInstallationName, + runtime.installationName == request.runtimeInstallationName, + request.version == distribution.version + "+runtime." + runtime.version else { + throw MachineManagerError.persistence("desktop update component selection is stale") + } + let bundleAssetID = DoryInstalledDesktopPayloadReceipt.bundleAssetIdentifier( + for: request.distro + ) + let kernelAssetID = DoryInstalledDesktopPayloadReceipt.kernelAssetIdentifier + guard let bundle = distribution.assets.first(where: { $0.path == bundleAssetID }), + let kernel = runtime.assets.first(where: { $0.path == kernelAssetID }), + bundle.compression == .none, + kernel.installedBytes > 0, + let bundlePath = store.assetPath(component: distributionID, path: bundleAssetID), + let kernelPath = store.assetPath(component: .linuxDesktop, path: kernelAssetID) else { + throw MachineManagerError.persistence("desktop update component assets are invalid") + } + let placeholderInput = String(repeating: "0", count: 64) + let receipt = DoryInstalledDesktopPayloadReceipt.verifiedUpdate( + distributionIdentifier: request.distro, + releaseVersion: request.version, + inputSHA256: placeholderInput, + bundleSHA256: bundle.installedSHA256, + distributionComponentIdentifier: distribution.id.rawValue, + distributionInstallationName: distribution.installationName, + distributionCatalogSHA256: distribution.catalogDigest.lowercased(), + bundleAssetIdentifier: bundle.path, + runtimeComponentIdentifier: runtime.id.rawValue, + runtimeInstallationName: runtime.installationName, + runtimeCatalogSHA256: runtime.catalogDigest.lowercased(), + kernelAssetIdentifier: kernel.path, + kernelSHA256: kernel.installedSHA256 + ) + guard receipt.isValid else { + throw MachineManagerError.persistence("desktop update component evidence is invalid") + } + return DoryDesktopUpdateArtifactAuthority( + receipt: receipt, + bundlePath: bundlePath, + bundleByteCount: bundle.installedBytes, + kernelPath: kernelPath, + kernelByteCount: kernel.installedBytes + ) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index 34edd429..06733161 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -268,7 +268,9 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { address: authoritativeLegacyConfiguration.address, displayMode: authoritativeLegacyConfiguration.displayMode, shares: shares, - environment: environment + environment: environment, + installedDesktopPayloadReceipt: + authoritativeLegacyConfiguration.installedDesktopPayloadReceipt ) } diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index defa41f8..a44d42b9 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1191,12 +1191,26 @@ private struct MachineProvisionRequest { private extension DoryDesktopUpdateRequest { init(xpcDictionary dictionary: NSDictionary) throws { + let allowed: Set = [ + "distro", "version", "distributionInstallationName", "runtimeInstallationName", + ] + guard let keys = dictionary.allKeys as? [String], Set(keys) == allowed else { + throw XPCRemoteConfigError.invalid("desktopUpdateAuthority") + } self.init( distro: try dictionary.requiredString("distro"), version: try dictionary.requiredString("version"), - bundlePath: try dictionary.requiredString("bundlePath"), - kernelPath: try dictionary.requiredString("kernelPath") + distributionInstallationName: try dictionary.requiredString( + "distributionInstallationName" + ), + runtimeInstallationName: try dictionary.requiredString("runtimeInstallationName") ) + guard ["debian", "kali", "ubuntu"].contains(distro), + version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil, + distributionInstallationName.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,254}/) != nil, + runtimeInstallationName.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,254}/) != nil else { + throw XPCRemoteConfigError.invalid("desktopUpdateAuthority") + } } } @@ -1590,6 +1604,53 @@ private extension DoryMachineStatus { dictionary["bootMode"] = bootMode.rawValue dictionary["installerMediaAttached"] = installerMediaAttached dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary + if let installedDesktopPayloadReceipt { + dictionary["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceipt.xpcDictionary + } + return dictionary as NSDictionary + } +} + +private extension DoryInstalledDesktopPayloadReceipt { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "provenance": provenance.rawValue, + "distributionIdentifier": distributionIdentifier, + "releaseVersion": releaseVersion, + "inputSHA256": inputSHA256, + ] + if let bundleSHA256 { + dictionary["bundleSHA256"] = bundleSHA256 + } + if let distributionComponentIdentifier { + dictionary["distributionComponentIdentifier"] = distributionComponentIdentifier + } + if let distributionInstallationName { + dictionary["distributionInstallationName"] = distributionInstallationName + } + if let distributionCatalogSHA256 { + dictionary["distributionCatalogSHA256"] = distributionCatalogSHA256 + } + if let bundleAssetIdentifier { + dictionary["bundleAssetIdentifier"] = bundleAssetIdentifier + } + if let runtimeComponentIdentifier { + dictionary["runtimeComponentIdentifier"] = runtimeComponentIdentifier + } + if let runtimeInstallationName { + dictionary["runtimeInstallationName"] = runtimeInstallationName + } + if let runtimeCatalogSHA256 { + dictionary["runtimeCatalogSHA256"] = runtimeCatalogSHA256 + } + if let kernelAssetIdentifier { + dictionary["kernelAssetIdentifier"] = kernelAssetIdentifier + } + if let kernelSHA256 { + dictionary["kernelSHA256"] = kernelSHA256 + } return dictionary as NSDictionary } } @@ -1791,6 +1852,11 @@ private extension DoryMachineSnapshot { if let artifactEvidence { dictionary["artifactEvidence"] = artifactEvidence.xpcDictionary } + let receipt = installedDesktopPayloadReceipt + ?? DoryInstalledDesktopPayloadReceipt.legacyEnvironment(environment) + if let receipt { + dictionary["installedDesktopPayloadReceipt"] = receipt.xpcDictionary + } return dictionary as NSDictionary } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index fac1d2cd..7d643066 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -218,6 +218,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { public var displayMode: DoryMachineDisplayMode public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? public init( id: String, @@ -231,7 +232,8 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { address: String? = nil, displayMode: DoryMachineDisplayMode = .headless, shares: [DoryMachineShareConfiguration] = [], - environment: [String: String] = [:] + environment: [String: String] = [:], + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil ) { self.id = id self.kernelPath = kernelPath @@ -245,6 +247,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { self.displayMode = displayMode self.shares = shares self.environment = environment + self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } private enum CodingKeys: String, CodingKey { @@ -260,6 +263,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { case displayMode case shares case environment + case installedDesktopPayloadReceipt } public init(from decoder: Decoder) throws { @@ -276,9 +280,18 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { address: try container.decodeIfPresent(String.self, forKey: .address), displayMode: try container.decodeIfPresent(DoryMachineDisplayMode.self, forKey: .displayMode) ?? .headless, shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [], - environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] + environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:], + installedDesktopPayloadReceipt: try container.decodeIfPresent( + DoryInstalledDesktopPayloadReceipt.self, + forKey: .installedDesktopPayloadReceipt + ) ) } + + public var effectiveInstalledDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? { + installedDesktopPayloadReceipt + ?? DoryInstalledDesktopPayloadReceipt.legacyEnvironment(environment) + } } public enum DoryMachineState: String, Sendable, Equatable { @@ -315,6 +328,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] public var runtimeIdentity: DoryMachineRuntimeIdentity + public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? public init( id: String, @@ -342,7 +356,8 @@ public struct DoryMachineStatus: Sendable, Equatable { runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion - ) + ), + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil ) { self.id = id self.state = state @@ -367,6 +382,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.shares = shares self.environment = environment self.runtimeIdentity = runtimeIdentity + self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } } @@ -390,6 +406,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var nvramPath: String? public var runtimeIdentity: DoryMachineRuntimeIdentity public var artifactEvidence: DoryMachineSnapshotArtifactEvidence? + public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? public init( id: String, @@ -413,7 +430,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion ), - artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil + artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil, + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil ) { self.id = id self.machineID = machineID @@ -434,6 +452,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.nvramPath = nvramPath self.runtimeIdentity = runtimeIdentity self.artifactEvidence = artifactEvidence + self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } private enum CodingKeys: String, CodingKey { @@ -456,6 +475,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case nvramPath case runtimeIdentity case artifactEvidence + case installedDesktopPayloadReceipt } public init(from decoder: Decoder) throws { @@ -488,25 +508,15 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { artifactEvidence: try container.decodeIfPresent( DoryMachineSnapshotArtifactEvidence.self, forKey: .artifactEvidence + ), + installedDesktopPayloadReceipt: try container.decodeIfPresent( + DoryInstalledDesktopPayloadReceipt.self, + forKey: .installedDesktopPayloadReceipt ) ) } } -public struct DoryDesktopUpdateRequest: Sendable, Equatable { - public var distro: String - public var version: String - public var bundlePath: String - public var kernelPath: String - - public init(distro: String, version: String, bundlePath: String, kernelPath: String) { - self.distro = distro - self.version = version - self.bundlePath = bundlePath - self.kernelPath = kernelPath - } -} - public struct DoryDesktopUpdateResult: Sendable, Equatable { public var machineID: String public var distro: String @@ -538,6 +548,13 @@ public struct DoryDesktopUpdateResult: Sendable, Equatable { } } +private enum DesktopUpdateJournalStage: String, Codable, Sendable { + case snapshotReady = "snapshot-ready" + case installing + case qualifying + case committed +} + private struct DesktopUpdateJournal: Codable, Sendable, Equatable { var schema: Int var machineID: String @@ -545,7 +562,37 @@ private struct DesktopUpdateJournal: Codable, Sendable, Equatable { var version: String var snapshotID: String var originalWasRunning: Bool - var stage: String + var stage: DesktopUpdateJournalStage + var sourceConfigurationSHA256: String? + var updateAuthority: DoryInstalledDesktopPayloadReceipt? + + var isValid: Bool { + guard [1, 2].contains(schema), + Self.isValidIdentifier(machineID), + Self.isValidIdentifier(snapshotID), + ["debian", "kali", "ubuntu"].contains(distro), + version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else { + return false + } + if schema == 1 { + return sourceConfigurationSHA256 == nil && updateAuthority == nil + } + return sourceConfigurationSHA256.map(Self.isLowercaseSHA256) == true + && updateAuthority?.isValid == true + && updateAuthority?.provenance == .verifiedUpdateBundle + && updateAuthority?.distributionIdentifier == distro + && updateAuthority?.releaseVersion == version + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + private static func isValidIdentifier(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + } } public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvertible { @@ -668,6 +715,7 @@ public final class MachineManager: @unchecked Sendable { private static let snapshotNVRAMTemporaryMarker = ".nvram.tmp-" private static let snapshotMetadataTemporaryPrefix = ".dory-snapshot-metadata-" private static let desktopUpdateJournalName = "desktop-update.json" + private static let desktopUpdateStagingPrefix = ".desktop-update-stage-" private static let maximumPersistedMetadataBytes: Int64 = 16 * 1024 * 1024 /// Public Apple-Silicon machine resource contract. These match the app's steppers; enforcing /// them again in doryd prevents CLI/XPC callers from persisting values that the VMM would later @@ -699,6 +747,7 @@ public final class MachineManager: @unchecked Sendable { private var pendingResolvedStart: PendingResolvedMachineStart? private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:] private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:] + private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)? #if DEBUG private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)? #endif @@ -761,6 +810,16 @@ public final class MachineManager: @unchecked Sendable { recoverInterruptedDesktopUpdates() } + /// Installs the daemon-owned active-component resolver. Desktop update writes fail closed until + /// this is installed; caller-supplied filesystem paths are never accepted as update authority. + public func installDesktopUpdateArtifactResolver( + _ resolver: any DoryDesktopUpdateArtifactResolving + ) { + operationLock.lock() + desktopUpdateArtifactResolver = resolver + operationLock.unlock() + } + /// Enables the explicit plan-driven launch path exactly once. Machines keep using the /// labeled legacy compatibility path until this production trust infrastructure is injected; /// once installed, a start never falls back when plan validation or adapter dispatch fails. @@ -1960,7 +2019,9 @@ public final class MachineManager: @unchecked Sendable { machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, nvramPath: machine.bootMode == .efi ? nvramPath : nil, runtimeIdentity: snapshotRuntimeIdentity, - artifactEvidence: artifactEvidence + artifactEvidence: artifactEvidence, + installedDesktopPayloadReceipt: + machine.effectiveInstalledDesktopPayloadReceipt ) let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) let lifecycle = try beginLifecycleSnapshot( @@ -2046,7 +2107,8 @@ public final class MachineManager: @unchecked Sendable { } /// Applies a signed desktop component to an existing persistent guest. The caller provides - /// component-store paths, but doryd owns the entire transaction: a last-good disk/kernel + /// only stable active-component generation identifiers; doryd resolves and re-verifies the + /// exact component-store bytes and owns the entire transaction: a last-good disk/kernel /// snapshot is durable before the guest is mutated, and any failed install or post-reboot /// qualification restores that snapshot and the machine's original running state. public func updateDesktop( @@ -2063,23 +2125,37 @@ public final class MachineManager: @unchecked Sendable { guard original.displayMode == .desktop else { throw MachineManagerError.persistence("desktop updates require a graphical machine") } + let persistedDistro = original.environment["DORY_DESKTOP_DISTRO"] + ?? original.effectiveInstalledDesktopPayloadReceipt?.distributionIdentifier guard ["debian", "ubuntu", "kali"].contains(request.distro), - original.environment["DORY_DESKTOP_DISTRO"] == request.distro else { + persistedDistro == request.distro else { throw MachineManagerError.persistence("desktop update distribution does not match " + id) } guard request.version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else { throw MachineManagerError.persistence("desktop update version is invalid") } - guard Self.isRegularNonemptyFile(path: request.bundlePath), - Self.isRegularNonemptyFile(path: request.kernelPath) else { - throw MachineManagerError.persistence("desktop update assets are missing or invalid") - } - let bundleURL = URL(fileURLWithPath: request.bundlePath).standardizedFileURL - let bundleDirectory = bundleURL.deletingLastPathComponent().path - guard Self.isDirectory(path: bundleDirectory), bundleURL.lastPathComponent != "." else { - throw MachineManagerError.persistence("desktop update bundle directory is invalid") + guard let desktopUpdateArtifactResolver else { + throw MachineManagerError.persistence( + "desktop updates require daemon component-store authority" + ) } - let bundleSHA256 = try Self.sha256(path: request.bundlePath) + let authority = try desktopUpdateArtifactResolver.resolve( + request, + guestArchitecture: configuration.guestArchitecture + ) + guard authority.receipt.isValid, + authority.receipt.provenance == .verifiedUpdateBundle, + authority.receipt.distributionIdentifier == request.distro, + authority.receipt.releaseVersion == request.version, + authority.receipt.distributionInstallationName + == request.distributionInstallationName, + authority.receipt.runtimeInstallationName == request.runtimeInstallationName, + let bundleSHA256 = authority.receipt.bundleSHA256, + let kernelSHA256 = authority.receipt.kernelSHA256 else { + throw MachineManagerError.persistence("desktop update authority is inconsistent") + } + let staged = try stageDesktopUpdateAuthority(authority, machineID: id) + defer { try? FileManager.default.removeItem(atPath: staged.directory) } let snapshotID = Self.generatedSnapshotID(prefix: "du") let snapshot = try snapshot( id: id, @@ -2087,13 +2163,17 @@ public final class MachineManager: @unchecked Sendable { snapshotID: snapshotID ) var journal = DesktopUpdateJournal( - schema: 1, + schema: 2, machineID: id, distro: request.distro, version: request.version, snapshotID: snapshot.id, originalWasRunning: originallyRunning, - stage: "snapshot-ready" + stage: .snapshotReady, + sourceConfigurationSHA256: Self.sha256( + data: try DoryMachineConfigurationMigrationBridge.encodeLegacy(original) + ), + updateAuthority: authority.receipt ) try persistDesktopUpdateJournal(journal) @@ -2102,11 +2182,11 @@ public final class MachineManager: @unchecked Sendable { let guestStage = "/var/lib/dory/update-" + token let updateShare = DoryMachineShareConfiguration( tag: "dory-update-" + token, - hostPath: bundleDirectory, + hostPath: staged.directory, guestPath: mountPath, readOnly: true ) - let bundleGuestPath = mountPath + "/" + bundleURL.lastPathComponent + let bundleGuestPath = mountPath + "/payload.tar" do { var transientShares = original.shares.filter { $0.tag != updateShare.tag } @@ -2115,7 +2195,7 @@ public final class MachineManager: @unchecked Sendable { if status(id: id)?.state != .running { _ = try startAndWaitUntilReady(id: id) } - journal.stage = "installing" + journal.stage = .installing try persistDesktopUpdateJournal(journal) try requireSuccessfulDesktopUpdateExec( @@ -2134,6 +2214,11 @@ public final class MachineManager: @unchecked Sendable { timeoutMs: 120_000, stage: "extract signed payload" ) + guard try Self.sha256(path: staged.bundlePath) == bundleSHA256 else { + throw MachineManagerError.persistence( + "desktop update staged bundle changed while the guest read it" + ) + } let install = try requireSuccessfulDesktopUpdateExec( id: id, argv: [guestStage + "/apply.sh", request.distro, request.version], @@ -2168,22 +2253,24 @@ public final class MachineManager: @unchecked Sendable { stage: "clear applied payload" ) _ = try stop(id: id) - var updatedEnvironment = original.environment - updatedEnvironment["DORY_DESKTOP_RELEASE_VERSION"] = request.version - updatedEnvironment["DORY_DESKTOP_INPUT_SHA256"] = inputSHA256 - _ = try update( - id: id, - shares: original.shares, - updatesShares: true, - environment: updatedEnvironment, - updatesEnvironment: true + var installedReceipt = authority.receipt + installedReceipt.inputSHA256 = inputSHA256 + try publishInstalledDesktopPayloadReceipt( + original: original, + receipt: installedReceipt ) try Self.cloneOrCopyFile( - source: request.kernelPath, + source: staged.kernelPath, destination: original.kernelPath, replaceExisting: true ) - journal.stage = "qualifying" + guard try Self.sha256(path: staged.kernelPath) == kernelSHA256, + try Self.sha256(path: original.kernelPath) == kernelSHA256 else { + throw MachineManagerError.persistence( + "desktop update kernel authority changed before commit" + ) + } + journal.stage = .qualifying try persistDesktopUpdateJournal(journal) _ = try startAndWaitUntilReady(id: id) try qualifyUpdatedDesktop( @@ -2199,7 +2286,7 @@ public final class MachineManager: @unchecked Sendable { } else { finalStatus = try stop(id: id) } - journal.stage = "committed" + journal.stage = .committed try persistDesktopUpdateJournal(journal) try removeDesktopUpdateJournal(machineID: id) return DoryDesktopUpdateResult( @@ -2287,7 +2374,9 @@ public final class MachineManager: @unchecked Sendable { address: nil, displayMode: snapshot.displayMode, shares: snapshot.shares, - environment: snapshot.environment + environment: snapshot.environment, + installedDesktopPayloadReceipt: + Self.configurationReceipt(restoring: snapshot) ) _ = try create(machine) do { @@ -2335,6 +2424,8 @@ public final class MachineManager: @unchecked Sendable { restoredMachine.address = address restoredMachine.shares = snapshot.shares restoredMachine.environment = snapshot.environment + restoredMachine.installedDesktopPayloadReceipt = + Self.configurationReceipt(restoring: snapshot) let sourceIdentity = try currentRuntimeIdentity(id: machineID) let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity) let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) @@ -2452,7 +2543,20 @@ public final class MachineManager: @unchecked Sendable { public func exportSnapshot(machineID: String, snapshotID: String, toPath path: String) throws { operationLock.lock() defer { operationLock.unlock() } - let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) + var snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) + if snapshot.installedDesktopPayloadReceipt == nil { + snapshot.installedDesktopPayloadReceipt = + DoryInstalledDesktopPayloadReceipt.legacyEnvironment(snapshot.environment) + } + if let portable = snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt { + snapshot.installedDesktopPayloadReceipt = portable + snapshot.environment.removeValue( + forKey: DoryInstalledDesktopPayloadReceipt.legacyReleaseVersionEnvironmentKey + ) + snapshot.environment.removeValue( + forKey: DoryInstalledDesktopPayloadReceipt.legacyInputSHA256EnvironmentKey + ) + } do { try MachineSnapshotBundle.write(snapshot: snapshot, toPath: path) } catch let error as MachineManagerError { @@ -2483,6 +2587,8 @@ public final class MachineManager: @unchecked Sendable { } try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount) try Self.validateSnapshotRuntimeIdentity(snapshot) + snapshot.installedDesktopPayloadReceipt = + snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt snapshot.address = nil snapshot.shares = [] snapshot.environment = [:] @@ -2611,7 +2717,9 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment, - runtimeIdentity: entry.runtimeIdentity + runtimeIdentity: entry.runtimeIdentity, + installedDesktopPayloadReceipt: + entry.configuration.effectiveInstalledDesktopPayloadReceipt ) } return DoryMachineStatus( @@ -2637,7 +2745,9 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment, - runtimeIdentity: entry.runtimeIdentity + runtimeIdentity: entry.runtimeIdentity, + installedDesktopPayloadReceipt: + entry.configuration.effectiveInstalledDesktopPayloadReceipt ) } @@ -2832,7 +2942,126 @@ public final class MachineManager: @unchecked Sendable { "\(machineStateDirectory(id: machineID))/\(Self.desktopUpdateJournalName)" } + private struct StagedDesktopUpdateAuthority { + var directory: String + var bundlePath: String + var kernelPath: String + } + + private func stageDesktopUpdateAuthority( + _ authority: DoryDesktopUpdateArtifactAuthority, + machineID: String + ) throws -> StagedDesktopUpdateAuthority { + guard let bundleSHA256 = authority.receipt.bundleSHA256, + let kernelSHA256 = authority.receipt.kernelSHA256 else { + throw MachineManagerError.persistence("desktop update authority lacks artifact digests") + } + let directory = machineStateDirectory(id: machineID) + + "/" + Self.desktopUpdateStagingPrefix + UUID().uuidString.lowercased() + guard mkdir(directory, 0o700) == 0 else { + throw MachineManagerError.persistence("could not create private desktop update staging") + } + let bundlePath = directory + "/payload.tar" + let kernelPath = directory + "/kernel" + do { + try Self.copyExactDesktopUpdateArtifact( + source: authority.bundlePath, + destination: bundlePath, + expectedByteCount: authority.bundleByteCount, + expectedSHA256: bundleSHA256 + ) + try Self.copyExactDesktopUpdateArtifact( + source: authority.kernelPath, + destination: kernelPath, + expectedByteCount: authority.kernelByteCount, + expectedSHA256: kernelSHA256 + ) + try Self.syncDirectory(path: directory) + return StagedDesktopUpdateAuthority( + directory: directory, + bundlePath: bundlePath, + kernelPath: kernelPath + ) + } catch { + try? FileManager.default.removeItem(atPath: directory) + throw error + } + } + + private static func copyExactDesktopUpdateArtifact( + source: String, + destination: String, + expectedByteCount: UInt64, + expectedSHA256: String + ) throws { + let sourceFD = open(source, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard sourceFD >= 0 else { + throw MachineManagerError.persistence("could not open verified desktop update artifact") + } + defer { close(sourceFD) } + var before = stat() + guard fstat(sourceFD, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_size > 0, + UInt64(before.st_size) == expectedByteCount else { + throw MachineManagerError.persistence("verified desktop update artifact identity is invalid") + } + let destinationFD = open( + destination, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o400) + ) + guard destinationFD >= 0 else { + throw MachineManagerError.persistence("could not create private desktop update artifact") + } + var copied: UInt64 = 0 + var hasher = SHA256() + var copyError: Error? + do { + let input = FileHandle(fileDescriptor: sourceFD, closeOnDealloc: false) + let output = FileHandle(fileDescriptor: destinationFD, closeOnDealloc: false) + while true { + let chunk = try input.read(upToCount: 1024 * 1024) ?? Data() + if chunk.isEmpty { break } + copied = copied.addingReportingOverflow(UInt64(chunk.count)).partialValue + guard copied <= expectedByteCount else { + throw MachineManagerError.persistence("verified desktop update artifact grew while staging") + } + hasher.update(data: chunk) + try output.write(contentsOf: chunk) + } + guard fsync(destinationFD) == 0 else { + throw MachineManagerError.persistence("could not sync private desktop update artifact") + } + } catch { + copyError = error + } + close(destinationFD) + if let copyError { + try? FileManager.default.removeItem(atPath: destination) + throw copyError + } + var after = stat() + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + guard fstat(sourceFD, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec, + copied == expectedByteCount, + digest == expectedSHA256 else { + try? FileManager.default.removeItem(atPath: destination) + throw MachineManagerError.persistence("verified desktop update artifact changed while staging") + } + } + private func persistDesktopUpdateJournal(_ journal: DesktopUpdateJournal) throws { + guard journal.isValid else { + throw MachineManagerError.persistence("invalid desktop update journal") + } let directory = machineStateDirectory(id: journal.machineID) guard Self.isPrivateDirectory(path: directory) else { throw MachineManagerError.persistence("desktop update journal owner is not private") @@ -2842,13 +3071,13 @@ public final class MachineManager: @unchecked Sendable { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(journal) - try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath) + try Self.writeDurablePrivateData(data, toPath: temporaryPath) guard rename(temporaryPath, desktopUpdateJournalPath(machineID: journal.machineID)) == 0 else { throw MachineManagerError.persistence( "could not publish desktop update journal: \(String(cString: strerror(errno)))" ) } + try Self.syncDirectory(path: directory) } catch { try? FileManager.default.removeItem(atPath: temporaryPath) if let error = error as? MachineManagerError { throw error } @@ -2861,29 +3090,141 @@ public final class MachineManager: @unchecked Sendable { guard Self.pathEntryExists(path) else { return } do { try FileManager.default.removeItem(atPath: path) + try Self.syncDirectory(path: machineStateDirectory(id: machineID)) } catch { throw MachineManagerError.persistence("could not remove desktop update journal: \(error)") } } + /// Commits the verified installed-state receipt and removes only its two superseded legacy + /// fields. The update transaction's last-good snapshot owns rollback of this exact change. + private func publishInstalledDesktopPayloadReceipt( + original: DoryMachineConfiguration, + receipt: DoryInstalledDesktopPayloadReceipt + ) throws { + guard receipt.isValid, receipt.provenance == .verifiedUpdateBundle else { + throw MachineManagerError.persistence( + "desktop update produced an invalid installed payload receipt" + ) + } + var updated = original + updated.shares = original.shares + updated.environment.removeValue( + forKey: DoryInstalledDesktopPayloadReceipt.legacyReleaseVersionEnvironmentKey + ) + updated.environment.removeValue( + forKey: DoryInstalledDesktopPayloadReceipt.legacyInputSHA256EnvironmentKey + ) + updated.installedDesktopPayloadReceipt = receipt + try Self.validateLaunchConfiguration(updated) + try persist(updated) + try publishConfiguration(updated) + } + private func recoverInterruptedDesktopUpdates() { lock.lock() let machineIDs = Array(machines.keys) lock.unlock() for machineID in machineIDs { let path = desktopUpdateJournalPath(machineID: machineID) + guard Self.pathEntryExists(path) else { + continue + } guard let data = Self.readPrivateMetadata(path: path), let journal = try? JSONDecoder().decode(DesktopUpdateJournal.self, from: data), - journal.schema == 1, journal.machineID == machineID, - Self.isValidID(journal.snapshotID) else { + journal.isValid else { + lock.lock() + if var entry = machines[machineID] { + entry.state = .failed + entry.lastError = "Desktop update recovery is required: the durable update journal is invalid." + machines[machineID] = entry + } + lock.unlock() continue } - if journal.stage == "committed" { - try? removeDesktopUpdateJournal(machineID: machineID) + if journal.stage == .committed { + // Schema 1 was authored by the pre-receipt updater after its machine.json write + // had committed. It carried no component authority to revalidate, and historical + // recovery semantics treated this marker as cleanup-only. Preserve that exact + // upgrade behavior instead of relabeling a working legacy machine as failed. + if journal.schema == 1 { + do { + try removeDesktopUpdateJournal(machineID: machineID) + } catch { + lock.lock() + machines[machineID]?.lastError = "Legacy desktop update committed, but journal cleanup requires retry: \(error)" + lock.unlock() + } + continue + } + lock.lock() + let receipt = machines[machineID]?.configuration.installedDesktopPayloadReceipt + let environment = machines[machineID]?.configuration.environment + lock.unlock() + var expectedReceipt = journal.updateAuthority + expectedReceipt?.inputSHA256 = receipt?.inputSHA256 ?? "" + guard let receipt, + let environment, + receipt.provenance == .verifiedUpdateBundle, + receipt.hasCoherentAuthority(environment: environment), + receipt == expectedReceipt else { + lock.lock() + if var entry = machines[machineID] { + entry.state = .failed + entry.lastError = "Desktop update recovery is required: committed component evidence does not match machine state." + machines[machineID] = entry + } + lock.unlock() + continue + } + do { + try removeDesktopUpdateJournal(machineID: machineID) + } catch { + lock.lock() + machines[machineID]?.lastError = "Desktop update committed, but journal cleanup requires retry: \(error)" + lock.unlock() + } continue } do { + if journal.schema == 2 { + let snapshot = try loadSnapshot( + machineID: machineID, + snapshotID: journal.snapshotID + ) + lock.lock() + let current = machines[machineID]?.configuration + lock.unlock() + guard let current, + let expectedSourceSHA256 = journal.sourceConfigurationSHA256 else { + throw MachineManagerError.persistence( + "desktop update journal source authority is unavailable" + ) + } + let source = DoryMachineConfiguration( + id: current.id, + kernelPath: current.kernelPath, + rootfsPath: current.rootfsPath, + bootMode: snapshot.bootMode, + memoryMB: snapshot.memoryMB, + cpuCount: snapshot.cpuCount, + address: snapshot.address, + displayMode: snapshot.displayMode, + shares: snapshot.shares, + environment: snapshot.environment, + installedDesktopPayloadReceipt: + Self.configurationReceipt(restoring: snapshot) + ) + let actualSourceSHA256 = Self.sha256( + data: try DoryMachineConfigurationMigrationBridge.encodeLegacy(source) + ) + guard actualSourceSHA256 == expectedSourceSHA256 else { + throw MachineManagerError.persistence( + "desktop update journal does not match its last-good snapshot" + ) + } + } _ = try restoreSnapshot(machineID: machineID, snapshotID: journal.snapshotID) try removeDesktopUpdateJournal(machineID: machineID) lock.lock() @@ -3665,13 +4006,13 @@ public final class MachineManager: @unchecked Sendable { ) let data = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine) let path = machineConfigPath(id: machine.id) - try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic) - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath) + try Self.writeDurablePrivateData(data, toPath: temporaryPath) guard rename(temporaryPath, path) == 0 else { throw MachineManagerError.persistence( "could not publish machine metadata: \(String(cString: strerror(errno)))" ) } + try Self.syncDirectory(path: directory) authoritativeLegacyData = data } catch let error as MachineManagerError { try? fileManager.removeItem(atPath: temporaryPath) @@ -3958,6 +4299,22 @@ public final class MachineManager: @unchecked Sendable { fileprivate static func validateSnapshotRuntimeIdentity( _ snapshot: DoryMachineSnapshot ) throws { + guard snapshot.installedDesktopPayloadReceipt?.hasCoherentAuthority( + environment: snapshot.environment + ) ?? true else { + throw MachineManagerError.persistence( + "invalid installed desktop payload receipt" + ) + } + if let receipt = snapshot.installedDesktopPayloadReceipt, + receipt.provenance == .verifiedUpdateBundle { + guard let artifactEvidence = snapshot.artifactEvidence, + receipt.kernelSHA256 == artifactEvidence.kernel.sha256 else { + throw MachineManagerError.persistence( + "installed desktop kernel receipt does not match snapshot evidence" + ) + } + } guard snapshot.runtimeIdentity.validate().isEmpty, snapshot.runtimeIdentity.virtualHardwareABIVersion == DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion else { @@ -4298,6 +4655,22 @@ public final class MachineManager: @unchecked Sendable { } try validateShares(machine.shares) try validateEnvironment(machine.environment) + guard machine.installedDesktopPayloadReceipt?.hasCoherentAuthority( + environment: machine.environment + ) ?? true else { + throw MachineManagerError.persistence( + "installed desktop payload receipt is invalid" + ) + } + } + + /// A local legacy snapshot retains the original raw receipt fields byte-for-byte. Imported + /// snapshots have no environment authority, so their typed receipt becomes authoritative. + private static func configurationReceipt( + restoring snapshot: DoryMachineSnapshot + ) -> DoryInstalledDesktopPayloadReceipt? { + guard let receipt = snapshot.installedDesktopPayloadReceipt else { return nil } + return receipt.matchesLegacyEnvironment(snapshot.environment) ? nil : receipt } private static func validateResources(memoryMB: UInt64, cpuCount: Int) throws { @@ -4617,6 +4990,52 @@ public final class MachineManager: @unchecked Sendable { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } + private static func syncDirectory(path: String) throws { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_DIRECTORY) + guard descriptor >= 0 else { + throw MachineManagerError.persistence("could not open metadata directory for sync") + } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { + throw MachineManagerError.persistence("could not sync metadata directory") + } + } + + private static func writeDurablePrivateData(_ data: Data, toPath path: String) throws { + let descriptor = open( + path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw MachineManagerError.persistence("could not create durable private metadata") + } + var succeeded = false + defer { + close(descriptor) + if !succeeded { _ = unlink(path) } + } + try data.withUnsafeBytes { raw in + guard var address = raw.baseAddress else { return } + var remaining = raw.count + while remaining > 0 { + let written = Darwin.write(descriptor, address, remaining) + if written > 0 { + remaining -= written + address = address.advanced(by: written) + } else if written < 0, errno == EINTR { + continue + } else { + throw MachineManagerError.persistence("could not write durable private metadata") + } + } + } + guard fsync(descriptor) == 0 else { + throw MachineManagerError.persistence("could not sync durable private metadata") + } + succeeded = true + } + private static func isPrivateDirectory(info: stat) -> Bool { (info.st_mode & S_IFMT) == S_IFDIR && info.st_uid == getuid() @@ -4649,6 +5068,9 @@ public final class MachineManager: @unchecked Sendable { let machine = try? decoder.decode(DoryMachineConfiguration.self, from: data), machine.id == id, isValidID(machine.id), + machine.installedDesktopPayloadReceipt?.hasCoherentAuthority( + environment: machine.environment + ) ?? true, isPrivateDirectory(path: "\(root)/\(id)"), machine.rootfsPath == rootfsPath, machine.kernelPath == kernelPath, @@ -5531,6 +5953,7 @@ public final class MachineManager: @unchecked Sendable { for entry in entries where entry.hasPrefix(machineMetadataTemporaryPrefix) || entry.hasPrefix(machineDiskTemporaryPrefix) || entry.hasPrefix(machineKernelTemporaryPrefix) + || entry.hasPrefix(desktopUpdateStagingPrefix) || (entry.hasPrefix(".") && entry.contains(machineRestoreBackupMarker)) { try? fileManager.removeItem(atPath: "\(directory)/\(entry)") } diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index ea7e74c7..d28603f9 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -64,6 +64,9 @@ let idleController = IdleController() let dockerTier = dorydEnvironment.dockerTierConfiguration().map { DockerTier(configuration: $0, idleController: idleController) } +let desktopUpdateArtifactResolver = DoryComponentStoreDesktopUpdateArtifactResolver( + store: DoryComponentStore(drive: dataDrive) +) let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve( store: DoryComponentStore(drive: dataDrive), @@ -74,6 +77,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co FileHandle.standardError.write(Data( "doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8 )) + context.machineManager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver) return context.machineManager case let .unavailable(reason): guard reason.permitsLegacyCompatibilityMigration else { @@ -91,10 +95,12 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co "doryd: VM launch policy legacyCompatibility " .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8 )) - return MachineManager( + let manager = MachineManager( configuration: configuration, launchPolicy: .legacyCompatibility ) + manager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver) + return manager } } let sandboxTTLReconciler = machineManager.map { manager in diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 9a034a1e..f065238f 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -1412,23 +1412,27 @@ func runMachineProvision(cursor: inout ArgumentCursor, client: DorydCtlClient) t } func runMachineDesktopUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH" + let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --distribution-installation ID --runtime-installation ID" let name = try cursor.take(usage) let distro = try requiredOption("--distro", cursor: &cursor, usage: usage) guard ["debian", "ubuntu", "kali"].contains(distro) else { throw DorydCtlError.usage("--distro must be debian, ubuntu, or kali") } let version = try requiredOption("--version", cursor: &cursor, usage: usage) - let bundlePath = try requiredOption("--bundle", cursor: &cursor, usage: usage) - let kernelPath = try requiredOption("--kernel", cursor: &cursor, usage: usage) + let distributionInstallationName = try requiredOption( + "--distribution-installation", cursor: &cursor, usage: usage + ) + let runtimeInstallationName = try requiredOption( + "--runtime-installation", cursor: &cursor, usage: usage + ) guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected machine desktop-update argument: " + cursor.values[0]) } let request: NSDictionary = [ "distro": distro, "version": version, - "bundlePath": bundlePath, - "kernelPath": kernelPath, + "distributionInstallationName": distributionInstallationName, + "runtimeInstallationName": runtimeInstallationName, ] let result = try client.withTimeout(atLeast: 3_900).statusCommand { proxy, reply in proxy.machineDesktopUpdate(name, request: request, reply: reply) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift new file mode 100644 index 00000000..41f7c9cf --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift @@ -0,0 +1,273 @@ +@testable import DorydKit +import CryptoKit +import DoryOperations +import Foundation +import Testing + +@Suite("Installed desktop payload receipt") +struct DoryInstalledDesktopPayloadReceiptTests { + private let digest = String(repeating: "a", count: 64) + + @Test("verified receipts round trip with exact bundle evidence") + func verifiedRoundTrip() throws { + let receipt = DoryInstalledDesktopPayloadReceipt.verifiedUpdate( + distributionIdentifier: "ubuntu", + releaseVersion: "24.04.3+runtime.4.5.6", + inputSHA256: digest, + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + #expect(receipt.isValid) + #expect( + try JSONDecoder().decode( + DoryInstalledDesktopPayloadReceipt.self, + from: JSONEncoder().encode(receipt) + ) == receipt + ) + } + + @Test("legacy projection requires a complete valid receipt and never invents bundle evidence") + func legacyProjection() { + let receipt = DoryInstalledDesktopPayloadReceipt.legacyEnvironment([ + "DORY_DESKTOP_DISTRO": "debian", + "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7", + "DORY_DESKTOP_INPUT_SHA256": digest, + "OPAQUE": "preserve", + ]) + #expect(receipt?.provenance == .legacyEnvironment) + #expect(receipt?.bundleSHA256 == nil) + #expect(receipt?.matchesLegacyEnvironment([ + "DORY_DESKTOP_DISTRO": "debian", + "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7", + "DORY_DESKTOP_INPUT_SHA256": digest, + ]) == true) + #expect(DoryInstalledDesktopPayloadReceipt.legacyEnvironment([ + "DORY_DESKTOP_DISTRO": "debian", + "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7", + ]) == nil) + #expect(DoryInstalledDesktopPayloadReceipt.legacyEnvironment([ + "DORY_DESKTOP_DISTRO": "debian", + "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7", + "DORY_DESKTOP_INPUT_SHA256": String(repeating: "A", count: 64), + ]) == nil) + } + + @Test("receipt shapes fail closed") + func invalidShapes() { + let valid = DoryInstalledDesktopPayloadReceipt.verifiedUpdate( + distributionIdentifier: "kali", + releaseVersion: "rolling+runtime.1", + inputSHA256: digest, + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-kali", + distributionInstallationName: "kali-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-kali-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + var mutations: [DoryInstalledDesktopPayloadReceipt] = [] + var value = valid + value.schemaVersion = 2 + mutations.append(value) + value = valid + value.distributionIdentifier = "windows" + mutations.append(value) + value = valid + value.releaseVersion = "../unsafe" + mutations.append(value) + value = valid + value.inputSHA256 = String(repeating: "A", count: 64) + mutations.append(value) + value = valid + value.bundleSHA256 = nil + mutations.append(value) + value = valid + value.provenance = .legacyEnvironment + mutations.append(value) + value = valid + value.distributionInstallationName = ".." + mutations.append(value) + for mutation in mutations { + #expect(!mutation.isValid) + } + } + + @Test("typed and legacy authorities cannot conflict") + func authorityCoherence() { + let verified = DoryInstalledDesktopPayloadReceipt.verifiedUpdate( + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + inputSHA256: digest, + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + #expect(verified.hasCoherentAuthority(environment: [:])) + #expect(verified.hasCoherentAuthority(environment: ["DORY_DESKTOP_DISTRO": "ubuntu"])) + #expect(!verified.hasCoherentAuthority(environment: ["DORY_DESKTOP_DISTRO": "kali"])) + #expect(!verified.hasCoherentAuthority(environment: [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.7", + ])) + + let legacyEnvironment = [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.6", + "DORY_DESKTOP_INPUT_SHA256": digest, + ] + let legacy = DoryInstalledDesktopPayloadReceipt.legacyEnvironment(legacyEnvironment) + #expect(legacy?.hasCoherentAuthority(environment: legacyEnvironment) == true) + #expect(legacy?.hasCoherentAuthority(environment: [:]) == false) + #expect(legacy?.archivedLegacySnapshotReceipt?.hasCoherentAuthority(environment: [:]) == true) + } + + @Test("production resolver binds active signed component generations and rejects stale IDs") + func productionResolver() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-desktop-update-resolver-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let drive = try DoryDataDrive(home: root.path) + try drive.prepare() + let store = DoryComponentStore(drive: drive) + try store.prepare() + let bundleData = Data("signed ubuntu update".utf8) + let kernelData = Data("signed desktop kernel".utf8) + let digest: (Data) -> String = { data in + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + let asset: (String, Data) -> DoryComponentAsset = { path, data in + DoryComponentAsset( + path: path, + url: "https://components.invalid/" + path, + downloadBytes: UInt64(data.count), + installedBytes: UInt64(data.count), + sha256: digest(data), + installedSHA256: digest(data) + ) + } + let runtime = DoryComponentRelease( + id: .linuxDesktop, + version: "4.5.6", + displayName: "Linux Desktop", + summary: "runtime", + downloadBytes: UInt64(kernelData.count), + installedBytes: UInt64(kernelData.count), + assets: [asset(DoryInstalledDesktopPayloadReceipt.kernelAssetIdentifier, kernelData)] + ) + let bundleID = DoryInstalledDesktopPayloadReceipt.bundleAssetIdentifier( + for: "ubuntu" + ) + let ubuntu = DoryComponentRelease( + id: .desktopUbuntu, + version: "1.2.3", + displayName: "Ubuntu", + summary: "desktop", + dependencies: [.dockerCore, .linuxDesktop], + downloadBytes: UInt64(bundleData.count), + installedBytes: UInt64(bundleData.count), + assets: [asset(bundleID, bundleData)] + ) + let core = DoryComponentRelease( + id: .dockerCore, + version: "0.4.0", + displayName: "Docker Core", + summary: "Docker, Compose, Buildx, networking, and storage", + dependencies: [], + downloadBytes: 100, + installedBytes: 200, + assets: [] + ) + let catalog = DoryComponentCatalog( + releaseVersion: "0.4.0", + generatedAt: "2026-08-20T00:00:00Z", + minimumAppVersion: "0.4.5", + architecture: "arm64", + components: [core, runtime, ubuntu] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let catalogData = try encoder.encode(catalog) + Data("\n".utf8) + let signingKey = Curve25519.Signing.PrivateKey() + let publicKey = signingKey.publicKey.rawRepresentation.base64EncodedString() + _ = try store.cacheCatalog( + data: catalogData, + signature: try signingKey.signature(for: catalogData).base64EncodedString(), + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.5" + ) + let sourceDirectory = root.appendingPathComponent("sources") + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + let kernelSource = sourceDirectory.appendingPathComponent("kernel") + let bundleSource = sourceDirectory.appendingPathComponent("bundle") + try kernelData.write(to: kernelSource) + try bundleData.write(to: bundleSource) + let catalogSHA256 = DoryComponentCatalogVerifier.digest(catalogData) + let runtimeInstalled = try store.install( + runtime, + catalogDigest: catalogSHA256, + downloadedAssets: [runtime.assets[0].path: kernelSource.path] + ) + let ubuntuInstalled = try store.install( + ubuntu, + catalogDigest: catalogSHA256, + downloadedAssets: [ubuntu.assets[0].path: bundleSource.path] + ) + let resolver = DoryComponentStoreDesktopUpdateArtifactResolver( + store: store, + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.5" + ) + let productionResolver = DoryComponentStoreDesktopUpdateArtifactResolver(store: store) + #expect( + productionResolver.appVersion + == DoryDaemonVirtualMachineProductionTrustFactory.compiledDaemonVersion + ) + try DoryComponentCatalogVerifier.validate( + catalog, + expectedArchitecture: productionResolver.expectedArchitecture, + appVersion: productionResolver.appVersion + ) + let request = DoryDesktopUpdateRequest( + distro: "ubuntu", + version: "1.2.3+runtime.4.5.6", + distributionInstallationName: ubuntuInstalled.installationName, + runtimeInstallationName: runtimeInstalled.installationName + ) + let authority = try resolver.resolve(request, guestArchitecture: "arm64") + #expect(authority.receipt.distributionCatalogSHA256 == catalogSHA256) + #expect(authority.receipt.bundleSHA256 == digest(bundleData)) + #expect(authority.receipt.kernelSHA256 == digest(kernelData)) + + var stale = request + stale.runtimeInstallationName = "4.5.6-stale" + #expect(throws: (any Error).self) { + _ = try resolver.resolve(stale, guestArchitecture: "arm64") + } + try Data("tampered".utf8).write(to: URL(fileURLWithPath: authority.bundlePath)) + #expect(throws: (any Error).self) { + _ = try resolver.resolve(request, guestArchitecture: "arm64") + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 569b4c6a..358292fa 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -34,7 +34,22 @@ struct DoryMachineConfigurationMigrationTests { "DORY_GUEST_UID": "1000", "DORY_CLIPBOARD_POLICY": "host-to-guest", "PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace", - ] + ], + installedDesktopPayloadReceipt: .verifiedUpdate( + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + inputSHA256: String(repeating: "a", count: 64), + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) ) let migrated = try migrate(legacy, capacity: 96 * gibibyte) @@ -75,6 +90,8 @@ struct DoryMachineConfigurationMigrationTests { #expect(!definitionJSON.contains("do-not-copy-into-workspace")) #expect(!definitionJSON.contains("DORY_GUEST_USER")) #expect(!definitionJSON.contains("DORY_CLIPBOARD_POLICY")) + #expect(!definitionJSON.contains("24.04+runtime.7")) + #expect(!definitionJSON.contains(String(repeating: "b", count: 64))) #expect(try migrated.legacyConfiguration() == legacy) #expect(try migrated.authoritativeLegacyData() diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index ed488636..d8fb79ea 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1,5 +1,6 @@ import DoryCore @testable import DorydKit +import CryptoKit import XCTest final class DorydServiceTests: XCTestCase { @@ -1197,6 +1198,125 @@ final class DorydServiceTests: XCTestCase { wait(for: [delete], timeout: 5) } + func testMachineStatusAndSnapshotExposeOnlySafeTypedDesktopPayloadReceipt() throws { + let base = "/tmp/doryd-service-desktop-receipt-\(getpid())-\(UInt32.random(in: 0.. DoryDesktopUpdateArtifactAuthority { + let bundleData = try Data(contentsOf: URL(fileURLWithPath: bundlePath)) + let kernelData = try Data(contentsOf: URL(fileURLWithPath: kernelPath)) + let digest: (Data) -> String = { data in + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + return DoryDesktopUpdateArtifactAuthority( + receipt: .verifiedUpdate( + distributionIdentifier: request.distro, + releaseVersion: request.version, + inputSHA256: String(repeating: "0", count: 64), + bundleSHA256: digest(bundleData), + distributionComponentIdentifier: "desktop-" + request.distro, + distributionInstallationName: request.distributionInstallationName, + distributionCatalogSHA256: String(repeating: "b", count: 64), + bundleAssetIdentifier: "dory-desktop-" + request.distro + "-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: request.runtimeInstallationName, + runtimeCatalogSHA256: String(repeating: "c", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: digest(kernelData) + ), + bundlePath: bundlePath, + bundleByteCount: UInt64(bundleData.count), + kernelPath: kernelPath, + kernelByteCount: UInt64(kernelData.count) + ) + } +} + +private func testVerifiedDesktopReceipt() -> DoryInstalledDesktopPayloadReceipt { + .verifiedUpdate( + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + inputSHA256: String(repeating: "a", count: 64), + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) +} + +private struct RejectingDesktopUpdateArtifactResolver: DoryDesktopUpdateArtifactResolving { + func resolve( + _ request: DoryDesktopUpdateRequest, + guestArchitecture: String + ) throws -> DoryDesktopUpdateArtifactAuthority { + throw MachineManagerError.persistence("resolver reached") + } +} + private final class LockedResult: @unchecked Sendable { private let lock = NSLock() private var storage: Result? From 04a36f728d50ba990558a18c9fa7af56ccf756ad Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 17:43:38 +0000 Subject: [PATCH 035/338] feat(vm): fence planning mutations --- .../DoryOperationJournalPublication.swift | 17 +- .../DoryWorkspaceLifecycleOperation.swift | 41 ++ ...achinePlanningTransactionCoordinator.swift | 285 +++++++++++- .../DorydKit/DoryWorkspaceRepository.swift | 10 + .../Sources/DorydKit/MachineManager.swift | 432 +++++++++++++++++- ...ePlanningTransactionCoordinatorTests.swift | 252 +++++++++- ...anagerPlanningMutationAuthorityTests.swift | 325 +++++++++++++ 7 files changed, 1322 insertions(+), 40 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift index 9d8fbdd9..462e8819 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift @@ -8,13 +8,26 @@ extension DoryOperationJournalStore { specifications: [DoryOperationSpecification], at date: Date, fileManager: FileManager, - mutationScope: String? = nil + mutationScope: String? = nil, + heldMutationLock: EngineStateDirectoryLock? = nil ) throws -> DoryOperationLease { guard plan.isValid else { throw DoryOperationJournalError.invalidPlan(plan.id.uuidString.lowercased()) } try prepareRoot(fileManager: fileManager) - let lock = try acquireMutationLock(scope: mutationScope) + let lock: EngineStateDirectoryLock + if let heldMutationLock { + let expectedLockName = mutationScope.map { ".mutation.\($0).lock" } + ?? ".mutation.lock" + guard heldMutationLock.path == root + "/" + expectedLockName else { + throw DoryOperationJournalError.invalidPlan( + "held mutation lock does not match authenticated scope" + ) + } + lock = heldMutationLock + } else { + lock = try acquireMutationLock(scope: mutationScope) + } let destination = operationDirectory(for: plan.id) guard !Self.pathEntryExists(destination) else { throw DoryOperationJournalError.operationExists(plan.id) diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift index d59d4f76..4d24f1a6 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift @@ -928,6 +928,47 @@ extension DoryOperationJournalStore { mutationScope: mutationScope ) } + + /// Publishes a lifecycle journal while retaining an already-acquired, exact workspace + /// mutation fence. The lock path must match the scope authenticated by the immutable plan; + /// callers cannot use this overload to redirect an operation to another workspace lock. + public func begin( + _ binding: DoryWorkspaceLifecycleJournalBinding, + holdingMutationLock: EngineStateDirectoryLock, + fileManager: FileManager = .default + ) throws -> DoryOperationLease { + let operation: DoryWorkspaceLifecycleOperation + do { + operation = try JSONDecoder().decode( + DoryWorkspaceLifecycleOperation.self, + from: binding.specification.data + ) + } catch { + throw DoryOperationJournalError.invalidPlan( + "workspace lifecycle specification decoding" + ) + } + let expected = try operation.journalBinding( + dependencyClosureDigest: binding.plan.dependencyClosureDigest + ) + guard expected == binding else { + throw DoryOperationJournalError.invalidPlan( + "workspace lifecycle journal binding mismatch" + ) + } + let mutationScope = authenticatedMutationScope(for: binding.plan) + return try begin( + binding.plan, + completenessPlanData: nil, + specifications: [binding.specification], + at: Date( + timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000 + ), + fileManager: fileManager, + mutationScope: mutationScope, + heldMutationLock: holdingMutationLock + ) + } } extension DoryOperationLease { diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index 01f6dea1..928db557 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -86,28 +86,119 @@ public protocol DoryDaemonVirtualMachinePlanningTrustPreparing: Sendable { ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation } +/// Content-only authority captured while the MachineManager owns the workspace mutation fence. +/// Raw legacy bytes, host paths, and migration inputs stay daemon-local; their exact digests bind +/// the planning request and durable transaction journal without persisting secrets. +public struct DoryDaemonVirtualMachinePlanningMachineAuthority: + Codable, Sendable, Equatable +{ + public static let schemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var machineID: String + public var legacyConfigurationSHA256: String + public var migrationFactsSHA256: String + public var sourceDefinitionRevision: UInt64 + public var sourceDefinitionSHA256: String + public var runtimeIdentitySHA256: String + + public init( + machineID: String, + legacyConfigurationSHA256: String, + migrationFactsSHA256: String, + sourceDefinitionRevision: UInt64, + sourceDefinitionSHA256: String, + runtimeIdentitySHA256: String + ) { + schemaVersion = Self.schemaVersion + self.machineID = machineID + self.legacyConfigurationSHA256 = legacyConfigurationSHA256.lowercased() + self.migrationFactsSHA256 = migrationFactsSHA256.lowercased() + self.sourceDefinitionRevision = sourceDefinitionRevision + self.sourceDefinitionSHA256 = sourceDefinitionSHA256.lowercased() + self.runtimeIdentitySHA256 = runtimeIdentitySHA256.lowercased() + } + + public var isValid: Bool { + schemaVersion == Self.schemaVersion + && machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !machineID.hasPrefix(".") + && sourceDefinitionRevision > 0 + && Self.isSHA256(legacyConfigurationSHA256) + && Self.isSHA256(migrationFactsSHA256) + && Self.isSHA256(sourceDefinitionSHA256) + && Self.isSHA256(runtimeIdentitySHA256) + } + + public var authoritySHA256: String { + guard isValid else { return "" } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + guard let data = try? encoder.encode(self) else { return "" } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + /// Held for the whole transaction. A MachineManager-backed implementation owns the lifecycle /// operation fence and validates authoritative machine bytes/migration facts against `definition`. /// The coordinator has no unsafe default: production composition must provide this authority. public final class DoryDaemonVirtualMachinePlanningMutationFence: @unchecked Sendable { - public let authoritySHA256: String + public let authority: DoryDaemonVirtualMachinePlanningMachineAuthority + public var authoritySHA256: String { authority.authoritySHA256 } + + private let stateLock = NSLock() + private var finalized = false private let validation: @Sendable () throws -> Void + private let completion: @Sendable () throws -> Void + private let recoveryRelease: @Sendable () -> Void private let retainedAuthority: any Sendable public init( - authoritySHA256: String, + authority: DoryDaemonVirtualMachinePlanningMachineAuthority, retainedAuthority: any Sendable, - validation: @escaping @Sendable () throws -> Void + validation: @escaping @Sendable () throws -> Void, + completion: @escaping @Sendable () throws -> Void = {}, + recoveryRelease: @escaping @Sendable () -> Void = {} ) { - self.authoritySHA256 = authoritySHA256.lowercased() + self.authority = authority self.retainedAuthority = retainedAuthority self.validation = validation + self.completion = completion + self.recoveryRelease = recoveryRelease } public func revalidate() throws { _ = retainedAuthority try validation() } + + /// Marks both planning publication and its retained MachineManager lifecycle authority + /// complete. If this throws, the durable nonterminal lifecycle record remains recoverable. + public func complete() throws { + try stateLock.withLock { + guard !finalized else { return } + try completion() + finalized = true + } + } + + /// Releases only the live lock. Durable state is intentionally left nonterminal so daemon + /// restart can reacquire the exact transaction rather than fabricating completion. + public func releaseForRecovery() { + stateLock.withLock { + guard !finalized else { return } + finalized = true + recoveryRelease() + } + } + + deinit { releaseForRecovery() } } public protocol DoryDaemonVirtualMachinePlanningMutationAuthorizing: Sendable { @@ -173,6 +264,7 @@ public protocol DoryWorkspaceDefinitionStoring: Sendable { func replace(_ definition: DoryVirtualMachineDefinition, expectedRevision: UInt64) throws func read(id: String) throws -> DoryVirtualMachineDefinition func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition? + func readPersistedRecordIfPresent(id: String) throws -> DoryWorkspaceRepositoryRecord? } extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring { @@ -180,6 +272,13 @@ extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring { do { return try read(id: id) } catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil } } + + public func readPersistedRecordIfPresent( + id: String + ) throws -> DoryWorkspaceRepositoryRecord? { + do { return try readPersistedRecord(id: id) } + catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil } + } } public protocol DoryPlanningTransactionResolvedPlanStoring: @@ -404,7 +503,14 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: "Authoritative machine state is live, stale, or inconsistent with the workspace." ) } - guard Self.isSHA256(mutationFence.authoritySHA256) else { + defer { mutationFence.releaseForRecovery() } + guard mutationFence.authority.isValid, + mutationFence.authority.machineID == request.planning.definition.identity.id, + mutationFence.authority.sourceDefinitionRevision + == request.planning.definition.lifecycle.revision, + mutationFence.authority.sourceDefinitionSHA256 + == Self.sha256(canonicalDefinition), + Self.isSHA256(mutationFence.authoritySHA256) else { throw failure(.mutationAuthorityRejected, "Machine authority digest is invalid.") } let requestDigest = Self.requestDigest( @@ -416,15 +522,28 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: request, requestDigest: requestDigest, machineAuthoritySHA256: mutationFence.authoritySHA256, - canonicalDefinition: canonicalDefinition + canonicalDefinition: canonicalDefinition, + mutationFence: mutationFence ) switch journal.phase { case .aborted: throw failure(.transactionAborted, "The exact planning transaction was aborted.") case .recoveryRequired: - try resumeRecoveryRequired(&journal) + try resumeRecoveryRequired(&journal, mutationFence: mutationFence) case .complete: - return try completedResult(request, journal: journal) + let result = try completedResult( + request, + journal: journal, + mutationFence: mutationFence + ) + do { try mutationFence.complete() } + catch { + throw failure( + .recoveryRequired, + "Planning completed, but its workspace mutation journal requires recovery." + ) + } + return result default: break } @@ -491,23 +610,40 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: } try faultInjector?(.publicationAuthorized) - try publishWorkspace(request, journal: &journal) + try publishWorkspace(request, journal: &journal, mutationFence: mutationFence) + // Workspace publication is not the final machine-authority boundary. Re-prove the + // exact legacy bytes, migration facts, projection, and runtime identity immediately + // before publishing the independently durable plan record too. + try revalidateMutationFence( + mutationFence, + journal: &journal, + message: "Machine authority changed before resolved-plan publication." + ) try publishPlan(request, planning: planning, journal: &journal) journal.phase = .complete try publishJournal(journal) try faultInjector?(.completeJournalPublished) - return DoryDaemonVirtualMachinePlanningTransactionResult( + let result = DoryDaemonVirtualMachinePlanningTransactionResult( planning: planning, lease: lease, transactionID: journal.transactionID ) + do { try mutationFence.complete() } + catch { + throw failure( + .recoveryRequired, + "Planning completed, but its workspace mutation journal requires recovery." + ) + } + return result } private func loadOrPrepare( _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, requestDigest: String, machineAuthoritySHA256: String, - canonicalDefinition: Data + canonicalDefinition: Data, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence ) throws -> Journal { if let existing = try readJournal(machineID: request.planning.definition.identity.id) { if existing.requestSHA256 == requestDigest { return existing } @@ -518,9 +654,27 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: let timestamp = now() guard timestamp > 0 else { throw failure(.invalidRequest, "Planning time is invalid.") } let sourcePlan: DoryResolvedMachinePlan? - let existingDefinition = try workspaces.readIfPresent( + let existingRecord = try workspaces.readPersistedRecordIfPresent( id: request.planning.definition.identity.id ) + let existingDefinition = existingRecord?.definition + if request.workspacePublication == .retainExistingExact { + guard mutationFence.authority.sourceDefinitionRevision + == request.planning.definition.lifecycle.revision, + mutationFence.authority.sourceDefinitionSHA256 + == Self.sha256(canonicalDefinition), + let existingRecord, + retainedRecordMatchesMutationAuthority( + existingRecord, + target: request.planning.definition, + mutationFence: mutationFence + ) else { + throw failure( + .mutationAuthorityRejected, + "Retained workspace source authority is not exact." + ) + } + } sourcePlan = try plans.readIfPresent(id: request.planning.definition.identity.id) switch request.workspacePublication { case .create: @@ -765,10 +919,27 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: private func publishWorkspace( _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, - journal: inout Journal + journal: inout Journal, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence ) throws { let target = request.planning.definition - let current = try workspaces.readIfPresent(id: target.identity.id) + try revalidateMutationFence( + mutationFence, + journal: &journal, + message: "Machine authority changed before workspace publication." + ) + let currentRecord = try workspaces.readPersistedRecordIfPresent(id: target.identity.id) + let current = currentRecord?.definition + if request.workspacePublication == .retainExistingExact { + guard let currentRecord, + retainedRecordMatchesMutationAuthority( + currentRecord, + target: target, + mutationFence: mutationFence + ) else { + throw failure(.transactionConflict, "Retained workspace differs from target.") + } + } if current != target { do { switch request.workspacePublication { @@ -788,6 +959,19 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: } catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error } catch { throw failure(.workspacePublicationRejected, "Workspace publication failed.") } } + guard let published = try workspaces.readPersistedRecordIfPresent(id: target.identity.id), + published.definition == target, + request.workspacePublication != .retainExistingExact + || retainedRecordMatchesMutationAuthority( + published, + target: target, + mutationFence: mutationFence + ) else { + throw failure( + .workspacePublicationRejected, + "Workspace publication did not persist the exact target." + ) + } try faultInjector?(.workspacePublished) journal.phase = .workspacePublished try publishJournal(journal) @@ -825,12 +1009,28 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: private func completedResult( _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, - journal: Journal + journal: Journal, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { let planning = try planningResultFromCandidate(request, journal: journal) let plan = planning.resolvedPlan - guard - let currentDefinition = try workspaces.readIfPresent(id: plan.machineID), + do { try mutationFence.revalidate() } + catch { + throw failure(.recoveryRequired, "Completed machine authority no longer matches.") + } + let currentRecord = try workspaces.readPersistedRecordIfPresent(id: plan.machineID) + let currentDefinition = currentRecord?.definition + if request.workspacePublication == .retainExistingExact { + guard let currentRecord, + retainedRecordMatchesMutationAuthority( + currentRecord, + target: journal.definition, + mutationFence: mutationFence + ) else { + throw failure(.recoveryRequired, "Completed retained workspace is not exact.") + } + } + guard let currentDefinition, currentDefinition == journal.definition, let currentPlan = try plans.readIfPresent(id: plan.machineID), currentPlan == plan, @@ -850,7 +1050,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: /// explicit daemon recovery operation: it reacquires the mutation fence in `execute`, proves /// the same exact bound lease/candidate and source-or-target publication authorities here, /// then obtains a newly collected, single-use publication authorization before resuming. - private func resumeRecoveryRequired(_ journal: inout Journal) throws { + private func resumeRecoveryRequired( + _ journal: inout Journal, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence + ) throws { guard let leaseID = journal.leaseID, let candidateDigest = journal.candidatePlanSHA256, let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }), @@ -860,9 +1063,24 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: throw failure(.recoveryRequired, "The retained bound lease cannot be recovered exactly.") } - let currentDefinition = try workspaces.readIfPresent( + do { try mutationFence.revalidate() } + catch { + throw failure(.recoveryRequired, "Machine authority changed during bound recovery.") + } + let currentRecord = try workspaces.readPersistedRecordIfPresent( id: journal.definition.identity.id ) + let currentDefinition = currentRecord?.definition + if journal.workspacePublication == .retainExistingExact { + guard let currentRecord, + retainedRecordMatchesMutationAuthority( + currentRecord, + target: journal.definition, + mutationFence: mutationFence + ) else { + throw failure(.recoveryRequired, "Retained workspace is missing or substituted.") + } + } let definitionIsTarget = currentDefinition == journal.definition let definitionIsSource: Bool switch journal.workspacePublication { @@ -904,6 +1122,35 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: try publishJournal(journal) } + private func retainedRecordMatchesMutationAuthority( + _ record: DoryWorkspaceRepositoryRecord, + target: DoryVirtualMachineDefinition, + mutationFence: DoryDaemonVirtualMachinePlanningMutationFence + ) -> Bool { + guard record.definition == target else { return false } + switch (record.legacyConfigurationSHA256, record.legacyMigrationFactsSHA256) { + case (nil, nil): + return true + case let (legacyDigest?, factsDigest?): + return legacyDigest == mutationFence.authority.legacyConfigurationSHA256 + && factsDigest == mutationFence.authority.migrationFactsSHA256 + default: + return false + } + } + + private func revalidateMutationFence( + _ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence, + journal: inout Journal, + message: String + ) throws { + do { try mutationFence.revalidate() } + catch { + try? markRecoveryRequired(&journal) + throw failure(.mutationAuthorityRejected, message) + } + } + private func abortUnboundIfPresent(_ journal: inout Journal) throws { if let leaseID = journal.leaseID, let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }), diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift index 4bdb1307..d44ba858 100644 --- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift +++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift @@ -373,6 +373,16 @@ public final class DoryWorkspaceRepository: @unchecked Sendable { return record.definition } + /// Reads the validated durable repository envelope without promoting a legacy projection to + /// native desired-state authority. Transaction coordinators use this only to prove that the + /// repository still contains the exact record selected while the separate legacy mutation + /// authority remains held. + public func readPersistedRecord(id: String) throws -> DoryWorkspaceRepositoryRecord { + lock.lock() + defer { lock.unlock() } + return try readRecordUnlocked(id: id) + } + public func readLegacyProjection( id: String, authoritativeLegacyData: Data diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7d643066..9398d066 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -747,6 +747,9 @@ public final class MachineManager: @unchecked Sendable { private var pendingResolvedStart: PendingResolvedMachineStart? private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:] private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:] + private var activePlanningMutationIDs: Set = [] + private var activeDirectWorkspaceMutationLocks: + [String: MachineManagerDirectMutationRetention] = [:] private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)? #if DEBUG private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)? @@ -986,6 +989,9 @@ public final class MachineManager: @unchecked Sendable { public func start(id: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } return try startImplementation(id: id, journalLifecycle: true) } @@ -1628,7 +1634,10 @@ public final class MachineManager: @unchecked Sendable { public func stop(id: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-stop") + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } return try stopImplementation(id: id, journalLifecycle: true) } @@ -1721,7 +1730,10 @@ public final class MachineManager: @unchecked Sendable { guard Self.isValidID(id) else { throw MachineManagerError.invalidID(id) } + try requireNoActivePlanningMutation(id: id) try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-delete") + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } lock.lock() guard let sourceEntry = machines[id] else { lock.unlock() @@ -1830,6 +1842,9 @@ public final class MachineManager: @unchecked Sendable { ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } guard !(updatesEnvironment && typedSettingsPatch != nil) else { throw MachineManagerError.persistence( "raw environment replacement and typed machine settings are mutually exclusive" @@ -1892,7 +1907,7 @@ public final class MachineManager: @unchecked Sendable { return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } if wasRunning { - _ = try stop(id: id) + _ = try stopImplementation(id: id, journalLifecycle: true) } do { if current.bootMode == .efi, @@ -1905,7 +1920,7 @@ public final class MachineManager: @unchecked Sendable { } catch { if wasRunning { do { - _ = try startAndWaitUntilReady(id: id) + _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch let restartError { throw MachineManagerError.persistence( "could not update \(id): \(error); original configuration restart failed: \(restartError)" @@ -1919,10 +1934,10 @@ public final class MachineManager: @unchecked Sendable { return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } do { - return try startAndWaitUntilReady(id: id) + return try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { let updateError = error - _ = try? stop(id: id) + _ = try? stopImplementation(id: id, journalLifecycle: true) do { try persist(current) try publishConfiguration(current) @@ -1932,7 +1947,7 @@ public final class MachineManager: @unchecked Sendable { ) } do { - _ = try startAndWaitUntilReady(id: id) + _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { throw MachineManagerError.persistence( "could not start updated \(id): \(updateError); original configuration was restored but restart failed: \(error)" @@ -1952,6 +1967,9 @@ public final class MachineManager: @unchecked Sendable { ) throws -> DoryMachineSnapshot { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } let snapshotID = explicitSnapshotID ?? Self.generatedSnapshotID() guard Self.isValidID(snapshotID) else { throw MachineManagerError.invalidID(snapshotID) @@ -2117,6 +2135,9 @@ public final class MachineManager: @unchecked Sendable { ) throws -> DoryDesktopUpdateResult { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } let (original, originallyRunning) = try configurationAndRunningState(id: id) guard original.bootMode == .linuxKernel else { @@ -2407,6 +2428,14 @@ public final class MachineManager: @unchecked Sendable { public func restoreSnapshot(machineID: String, snapshotID: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: machineID) + let directMutation = try retainDirectWorkspaceMutationLock(id: machineID) + defer { + releaseDirectWorkspaceMutationLock( + id: machineID, + retention: directMutation + ) + } let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) let (machine, wasRunning) = try configurationAndRunningState(id: machineID) try validateSnapshotRuntimeCompatibility(snapshot, machine: machine, cloning: false) @@ -5117,6 +5146,317 @@ public final class MachineManager: @unchecked Sendable { return identity } + /// Acquires the same per-workspace cross-process mutation lock used by lifecycle operations, + /// then derives planning authority only from the exact persisted legacy bytes and migration + /// facts observed under that lock. This path never starts a helper or mutates guest state. + public func acquirePlanningMutationFence( + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data + ) throws -> DoryDaemonVirtualMachinePlanningMutationFence { + operationLock.lock() + defer { operationLock.unlock() } + + guard launchPolicy == .requireResolvedPlan else { + throw MachineManagerError.persistence( + "planning promotion requires the resolved-plan launch policy" + ) + } + guard Self.isValidID(machine.id), machine.id == definition.identity.id, + canonicalDefinitionData == (try Self.canonicalDefinitionData(definition)) else { + throw MachineManagerError.persistence("planning request identity is inconsistent") + } + guard activePlanningMutationIDs.insert(machine.id).inserted else { + throw MachineManagerError.persistence( + "machine \(machine.id) already has an active planning mutation" + ) + } + var shouldRemoveActiveID = true + defer { + if shouldRemoveActiveID { activePlanningMutationIDs.remove(machine.id) } + } + guard activeLifecycleOperations[machine.id] == nil else { + throw MachineManagerError.persistence( + "machine \(machine.id) already has an active lifecycle mutation" + ) + } + guard let store = lifecycleJournalStore else { + throw MachineManagerError.persistence( + "lifecycle journal is unavailable: " + + (lifecycleJournalInitializationError ?? "unknown error") + ) + } + + let workspaceLock: EngineStateDirectoryLock + do { + workspaceLock = try EngineStateDirectoryLock( + stateDirectory: store.root, + lockFileName: ".mutation.\(machine.id).lock" + ) + } catch { + throw MachineManagerError.persistence( + "machine \(machine.id) mutation authority is busy: \(error)" + ) + } + if let unfinished = try store.list().first(where: { + $0.state.status != .completed && $0.state.status != .failed + && ($0.plan.source.id == machine.id || $0.plan.target.id == machine.id) + }) { + throw MachineManagerError.persistence( + "machine \(machine.id) lifecycle operation " + + unfinished.plan.id.uuidString.lowercased() + " requires recovery" + ) + } + + let entry: MachineEntry + lock.lock() + guard let current = machines[machine.id], + !deletingMachineIDs.contains(machine.id) else { + lock.unlock() + throw MachineManagerError.unknownMachine(machine.id) + } + entry = current + lock.unlock() + guard entry.configuration == machine, + entry.process == nil, entry.handoffServer == nil, + entry.state == .created || entry.state == .stopped else { + throw MachineManagerError.persistence( + "machine \(machine.id) must be exactly stopped before planning" + ) + } + guard entry.runtimeIdentity.validate().isEmpty, + entry.runtimeIdentity.mode != .legacyCompatibility else { + throw MachineManagerError.persistence( + "machine \(machine.id) has no resolved-policy migration authority" + ) + } + + guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)), + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ), decoded == machine else { + throw MachineManagerError.persistence( + "authoritative machine metadata changed before planning" + ) + } + let facts = try workspaceMigrationFacts(for: machine) + let factsData = try Self.workspaceMigrationAuthorityData(facts) + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + machine, + facts: facts + ) + let reconciled = try workspaceRepository.reconcileLegacyProjection( + migration.definition, + authoritativeLegacyData: legacyData, + authoritativeMigrationFactsData: factsData + ).definition + let loaded = try workspaceRepository.readLegacyProjection( + id: machine.id, + authoritativeLegacyData: legacyData, + authoritativeMigrationFactsData: factsData + ) + guard reconciled == loaded, loaded == definition, + try Self.canonicalDefinitionData(loaded) == canonicalDefinitionData else { + throw MachineManagerError.persistence( + "planning request does not match the authoritative workspace projection" + ) + } + + let runtimeIdentityData = try Self.canonicalPlanningRuntimeAuthorityData( + entry.runtimeIdentity + ) + let authority = DoryDaemonVirtualMachinePlanningMachineAuthority( + machineID: machine.id, + legacyConfigurationSHA256: Self.sha256(data: legacyData), + migrationFactsSHA256: Self.sha256(data: factsData), + sourceDefinitionRevision: loaded.lifecycle.revision, + sourceDefinitionSHA256: Self.sha256(data: canonicalDefinitionData), + runtimeIdentitySHA256: Self.sha256(data: runtimeIdentityData) + ) + guard authority.isValid else { + throw MachineManagerError.persistence("planning mutation authority is invalid") + } + + let retention = MachineManagerPlanningMutationRetention( + manager: self, + machineID: machine.id, + workspaceLock: workspaceLock + ) + shouldRemoveActiveID = false + return DoryDaemonVirtualMachinePlanningMutationFence( + authority: authority, + retainedAuthority: retention, + validation: { [weak self] in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + try self.revalidatePlanningMutationAuthority( + machine: machine, + definition: definition, + canonicalDefinitionData: canonicalDefinitionData, + legacyData: legacyData, + migrationFactsData: factsData, + runtimeIdentitySHA256: authority.runtimeIdentitySHA256 + ) + }, + completion: { retention.release() }, + recoveryRelease: { retention.release() } + ) + } + + private func revalidatePlanningMutationAuthority( + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data, + legacyData: Data, + migrationFactsData: Data, + runtimeIdentitySHA256: String + ) throws { + operationLock.lock() + defer { operationLock.unlock() } + guard activePlanningMutationIDs.contains(machine.id) else { + throw MachineManagerError.persistence("planning mutation authority was released") + } + let entry: MachineEntry + lock.lock() + guard let current = machines[machine.id], + !deletingMachineIDs.contains(machine.id) else { + lock.unlock() + throw MachineManagerError.unknownMachine(machine.id) + } + entry = current + lock.unlock() + guard entry.configuration == machine, + entry.process == nil, entry.handoffServer == nil, + entry.state == .created || entry.state == .stopped, + entry.runtimeIdentity.validate().isEmpty, + Self.sha256( + data: try Self.canonicalPlanningRuntimeAuthorityData(entry.runtimeIdentity) + ) + == runtimeIdentitySHA256, + let currentLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: machine.id) + ), currentLegacyData == legacyData, + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: currentLegacyData + ), decoded == machine else { + throw MachineManagerError.persistence( + "authoritative machine state changed during planning" + ) + } + let currentFacts = try workspaceMigrationFacts(for: machine) + let currentFactsData = try Self.workspaceMigrationAuthorityData(currentFacts) + guard currentFactsData == migrationFactsData else { + throw MachineManagerError.persistence( + "authoritative migration facts changed during planning" + ) + } + let currentDefinition = try workspaceRepository.readLegacyProjection( + id: machine.id, + authoritativeLegacyData: currentLegacyData, + authoritativeMigrationFactsData: currentFactsData + ) + guard currentDefinition == definition, + try Self.canonicalDefinitionData(currentDefinition) + == canonicalDefinitionData else { + throw MachineManagerError.persistence( + "authoritative workspace projection changed during planning" + ) + } + } + + private static func canonicalPlanningRuntimeAuthorityData( + _ identity: DoryMachineRuntimeIdentity + ) throws -> Data { + let authority = MachinePlanningRuntimeAuthority(identity: identity) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(authority) + } + + private func requireNoActivePlanningMutation(id: String) throws { + guard !activePlanningMutationIDs.contains(id) else { + throw MachineManagerError.persistence( + "machine \(id) already has an active planning mutation" + ) + } + } + + private func acquireDirectWorkspaceMutationLock( + id: String + ) throws -> EngineStateDirectoryLock { + guard let store = lifecycleJournalStore else { + throw MachineManagerError.persistence( + "lifecycle journal is unavailable: " + + (lifecycleJournalInitializationError ?? "unknown error") + ) + } + let workspaceLock: EngineStateDirectoryLock + do { + workspaceLock = try EngineStateDirectoryLock( + stateDirectory: store.root, + lockFileName: ".mutation.\(id).lock" + ) + } catch { + throw MachineManagerError.persistence( + "machine \(id) mutation authority is busy: \(error)" + ) + } + if let unfinished = try store.list().first(where: { + $0.state.status != .completed && $0.state.status != .failed + && ($0.plan.source.id == id || $0.plan.target.id == id) + }) { + throw MachineManagerError.persistence( + "machine \(id) lifecycle operation " + + unfinished.plan.id.uuidString.lowercased() + " requires recovery" + ) + } + return workspaceLock + } + + private func retainDirectWorkspaceMutationLock( + id: String + ) throws -> MachineManagerDirectMutationRetention { + if let existing = activeDirectWorkspaceMutationLocks[id] { + existing.depth += 1 + return existing + } + guard activeLifecycleOperations[id] == nil else { + throw MachineManagerError.persistence( + "machine \(id) already has an active lifecycle mutation" + ) + } + let retention = MachineManagerDirectMutationRetention( + workspaceLock: try acquireDirectWorkspaceMutationLock(id: id) + ) + activeDirectWorkspaceMutationLocks[id] = retention + return retention + } + + private func releaseDirectWorkspaceMutationLock( + id: String, + retention: MachineManagerDirectMutationRetention + ) { + guard activeDirectWorkspaceMutationLocks[id] === retention, + retention.depth > 0 else { return } + retention.depth -= 1 + if retention.depth == 0 { + activeDirectWorkspaceMutationLocks.removeValue(forKey: id) + } + } + + fileprivate func releasePlanningMutation( + machineID: String, + releaseWorkspaceLock: () -> Void + ) { + operationLock.lock() + releaseWorkspaceLock() + activePlanningMutationIDs.remove(machineID) + operationLock.unlock() + } + private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState { switch state { case .starting, .running: .running @@ -5414,10 +5754,16 @@ public final class MachineManager: @unchecked Sendable { let binding = try operation.journalBinding( dependencyClosureDigest: Self.sha256(data: try encoder.encode(dependency)) ) - let context = MachineLifecycleJournalContext( - operation: operation, - lease: try store.begin(binding) - ) + let lease: DoryOperationLease + if let retained = activeDirectWorkspaceMutationLocks[machineID] { + lease = try store.begin( + binding, + holdingMutationLock: retained.workspaceLock + ) + } else { + lease = try store.begin(binding) + } + let context = MachineLifecycleJournalContext(operation: operation, lease: lease) activeLifecycleOperations[machineID] = context return context } @@ -6565,6 +6911,72 @@ private final class MachineLifecycleJournalContext: @unchecked Sendable { } } +private final class MachineManagerPlanningMutationRetention: @unchecked Sendable { + private let stateLock = NSLock() + private let manager: MachineManager + private let machineID: String + private var workspaceLock: EngineStateDirectoryLock? + + init( + manager: MachineManager, + machineID: String, + workspaceLock: EngineStateDirectoryLock + ) { + self.manager = manager + self.machineID = machineID + self.workspaceLock = workspaceLock + } + + func release() { + stateLock.withLock { + guard workspaceLock != nil else { return } + manager.releasePlanningMutation(machineID: machineID) { + workspaceLock = nil + } + } + } + + deinit { release() } +} + +private final class MachineManagerDirectMutationRetention: @unchecked Sendable { + let workspaceLock: EngineStateDirectoryLock + var depth = 1 + + init(workspaceLock: EngineStateDirectoryLock) { + self.workspaceLock = workspaceLock + } +} + +/// Planning recovery binds launch-relevant runtime authority, not the transient explanation for +/// why an unplanned machine needs a plan. This stays stable across daemon restart while resolved +/// identities retain the exact immutable plan digest and backend/runtime tuple. +private struct MachinePlanningRuntimeAuthority: Codable { + var schemaVersion: UInt16 + var mode: DoryMachineRuntimeIdentityMode + var virtualHardwareABIVersion: UInt16 + var resolvedPlanSHA256: String? + var planRevision: UInt64? + var definitionRevision: UInt64? + var definitionSHA256: String? + var backend: DoryVirtualizationBackendIdentity? + var backendImplementationIdentifier: String? + var backendRuntimeBuildIdentifier: String? + + init(identity: DoryMachineRuntimeIdentity) { + schemaVersion = identity.schemaVersion + mode = identity.mode + virtualHardwareABIVersion = identity.virtualHardwareABIVersion + resolvedPlanSHA256 = identity.resolvedPlanSHA256 + planRevision = identity.planRevision + definitionRevision = identity.definitionRevision + definitionSHA256 = identity.definitionSHA256 + backend = identity.backend + backendImplementationIdentifier = identity.backendImplementationIdentifier + backendRuntimeBuildIdentifier = identity.backendRuntimeBuildIdentifier + } +} + private struct MachineLifecycleDependencyAuthority: Codable { var schemaVersion: UInt16 = 2 var mutationKind: String @@ -6580,6 +6992,8 @@ private struct MachineLifecycleDependencyAuthority: Codable { var targetSnapshotArtifactEvidenceSHA256: String? } +extension MachineManager: DoryDaemonVirtualMachinePlanningMutationAuthorizing {} + private extension DoryOperationPhase { var indexForMachineLifecycle: Int { switch self { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index c6613d76..d5571bc2 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import DoryOperations @testable import DorydKit import Foundation @@ -5,6 +6,23 @@ import Testing @Suite("Daemon VM planning publication transaction") struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests { + @Test("transaction completion and interrupted recovery release the retained machine fence") + func transactionFinalizesMachineFenceExactlyOnce() throws { + let completed = try TransactionFixture() + _ = try completed.coordinator().resolveReserveAndPublish(completed.request()) + #expect(completed.mutationAuthority.completionCount == 1) + #expect(completed.mutationAuthority.recoveryReleaseCount == 0) + + let interrupted = try TransactionFixture() + #expect(throws: (any Error).self) { + _ = try interrupted.coordinator( + fault: TransactionFault(.preparedJournalPublished) + ).resolveReserveAndPublish(interrupted.request()) + } + #expect(interrupted.mutationAuthority.completionCount == 0) + #expect(interrupted.mutationAuthority.recoveryReleaseCount == 1) + } + @Test("reserve bind publish is exact and restart-idempotent") func successAndIdempotence() throws { let fixture = try TransactionFixture() @@ -105,6 +123,144 @@ struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests { == recovered.planning.resolvedPlan) } + @Test("create and replace revalidate machine authority at workspace publication") + func createAndReplaceRevalidateAtPublication() throws { + let createFixture = try TransactionFixture() + let createFault = TransactionFault( + .publicationAuthorized, + throwsInjectedFailure: false, + action: { createFixture.mutationAuthority.rejectFurtherValidation() } + ) + do { + _ = try createFixture.coordinator(fault: createFault) + .resolveReserveAndPublish(createFixture.request()) + Issue.record("Expected final create authority rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .mutationAuthorityRejected) + } + #expect(try createFixture.workspaces.readPersistedRecordIfPresent( + id: createFixture.definition.identity.id + ) == nil) + #expect(try createFixture.plans.readIfPresent( + id: createFixture.definition.identity.id + ) == nil) + let createLease = try #require(try createFixture.ledger.snapshot().leases.first) + #expect(createLease.state == .starting) + #expect(createLease.boundPlanSHA256 != nil) + + let replaceFixture = try TransactionFixture() + let first = try replaceFixture.coordinator() + .resolveReserveAndPublish(replaceFixture.request()) + _ = try replaceFixture.ledger.markStopped( + leaseID: first.lease.leaseID, + expectedLeaseRevision: first.lease.leaseRevision + ) + let replacement = replaceFixture.replacementDefinition() + let replacementRequest = replaceFixture.request( + definition: replacement, + workspacePublication: .replace(expectedRevision: 1), + planPublication: .replace(expectedPlanRevision: 1) + ) + let replaceFault = TransactionFault( + .publicationAuthorized, + throwsInjectedFailure: false, + action: { replaceFixture.mutationAuthority.rejectFurtherValidation() } + ) + do { + _ = try replaceFixture.coordinator(fault: replaceFault) + .resolveReserveAndPublish(replacementRequest) + Issue.record("Expected final replace authority rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .mutationAuthorityRejected) + } + #expect(try replaceFixture.workspaces.read(id: replacement.identity.id) + == replaceFixture.definition) + #expect(try replaceFixture.plans.read(id: replacement.identity.id) + == first.planning.resolvedPlan) + let replaceLease = try #require(try replaceFixture.ledger.snapshot().leases.first) + #expect(replaceLease.state == .starting) + #expect(replaceLease.boundPlanSHA256 != nil) + } + + @Test("completed retained transaction rejects missing and substituted durable workspace") + func completedRetainedWorkspaceMustRemainDurableAndExact() throws { + for removesWorkspace in [true, false] { + let fixture = try TransactionFixture() + try fixture.workspaces.create(fixture.definition) + let request = fixture.request(workspacePublication: .retainExistingExact) + _ = try fixture.coordinator().resolveReserveAndPublish(request) + if removesWorkspace { + try fixture.workspaces.remove(id: fixture.definition.identity.id) + } else { + try fixture.workspaces.replace( + fixture.replacementDefinition(), + expectedRevision: 1 + ) + } + do { + _ = try fixture.coordinator().resolveReserveAndPublish(request) + Issue.record("Expected completed retained workspace rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .recoveryRequired) + } + } + } + + @Test("retained planning compares exact facts-bound legacy repository authority") + func retainedLegacyProjectionAuthorityIsExact() throws { + let accepted = try TransactionFixture() + try accepted.installLegacyProjectionAuthority() + let request = accepted.request(workspacePublication: .retainExistingExact) + _ = try accepted.coordinator().resolveReserveAndPublish(request) + #expect(try accepted.plans.read(id: accepted.definition.identity.id).machineID + == accepted.definition.identity.id) + + let rejected = try TransactionFixture() + try rejected.installLegacyProjectionAuthority() + rejected.mutationAuthority.migrationFactsSHA256 = transactionDigest("0") + do { + _ = try rejected.coordinator().resolveReserveAndPublish( + rejected.request(workspacePublication: .retainExistingExact) + ) + Issue.record("Expected mismatched facts-bound workspace rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .mutationAuthorityRejected) + } + #expect(try rejected.ledger.snapshot().leases.isEmpty) + } + + @Test("bound retained recovery rejects missing and substituted durable workspace") + func recoveringRetainedWorkspaceMustRemainDurableAndExact() throws { + for removesWorkspace in [true, false] { + let fixture = try TransactionFixture(rejectPublication: true) + try fixture.workspaces.create(fixture.definition) + let request = fixture.request(workspacePublication: .retainExistingExact) + do { + _ = try fixture.coordinator().resolveReserveAndPublish(request) + Issue.record("Expected stale publication evidence") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .publicationAuthorizationRejected) + } + if removesWorkspace { + try fixture.workspaces.remove(id: fixture.definition.identity.id) + } else { + try fixture.workspaces.replace( + fixture.replacementDefinition(), + expectedRevision: 1 + ) + } + fixture.trust.rejectPublication = false + do { + _ = try fixture.coordinator().resolveReserveAndPublish(request) + Issue.record("Expected retained recovery workspace rejection") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .recoveryRequired) + } + let lease = try #require(try fixture.ledger.snapshot().leases.first) + #expect(lease.boundPlanSHA256 != nil) + } + } + @Test("two coordinator instances serialize one workspace transaction") func concurrentInstances() async throws { let fixture = try TransactionFixture() @@ -399,21 +555,47 @@ private final class TransactionFixture: @unchecked Sendable { deinit { try? FileManager.default.removeItem(atPath: root) } func request( + definition requestedDefinition: DoryVirtualMachineDefinition? = nil, + workspacePublication: DoryDaemonVirtualMachineWorkspacePublication = .create, + planPublication: DoryDaemonVirtualMachinePlanPublication = .create, leaseDuration: Int64 = 120_000 ) -> DoryDaemonVirtualMachinePlanningTransactionRequest { - DoryDaemonVirtualMachinePlanningTransactionRequest( + let requestedDefinition = requestedDefinition ?? definition + return DoryDaemonVirtualMachinePlanningTransactionRequest( planning: DoryDaemonVirtualMachinePlanningRequest( - definition: definition, + definition: requestedDefinition, canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator - .canonicalDefinitionData(definition), + .canonicalDefinitionData(requestedDefinition), machine: machine, - publication: .create + publication: planPublication ), - workspacePublication: .create, + workspacePublication: workspacePublication, startingLeaseDurationMilliseconds: leaseDuration ) } + func replacementDefinition() -> DoryVirtualMachineDefinition { + var replacement = definition + replacement.lifecycle = DoryVMLifecycleMetadata( + revision: 2, + createdAtUnixMilliseconds: definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: definition.lifecycle.updatedAtUnixMilliseconds + 1 + ) + return replacement + } + + func installLegacyProjectionAuthority() throws { + let legacy = Data("exact legacy machine bytes".utf8) + let facts = Data("exact canonical migration facts".utf8) + _ = try workspaces.reconcileLegacyProjection( + definition, + authoritativeLegacyData: legacy, + authoritativeMigrationFactsData: facts + ) + mutationAuthority.authoritySHA256 = transactionSHA256(legacy) + mutationAuthority.migrationFactsSHA256 = transactionSHA256(facts) + } + func coordinator( fault: TransactionFault? = nil ) -> DoryDaemonVirtualMachinePlanningTransactionCoordinator { @@ -506,10 +688,24 @@ private final class TransactionMutationAuthority: { private let lock = NSLock() private var storedAuthoritySHA256 = transactionDigest("e") + private var storedMigrationFactsSHA256 = transactionDigest("f") + private var storedCompletionCount = 0 + private var storedRecoveryReleaseCount = 0 + private var storedRejectValidation = false var authoritySHA256: String { get { lock.withLock { storedAuthoritySHA256 } } set { lock.withLock { storedAuthoritySHA256 = newValue } } } + var migrationFactsSHA256: String { + get { lock.withLock { storedMigrationFactsSHA256 } } + set { lock.withLock { storedMigrationFactsSHA256 = newValue } } + } + var completionCount: Int { lock.withLock { storedCompletionCount } } + var recoveryReleaseCount: Int { lock.withLock { storedRecoveryReleaseCount } } + + func rejectFurtherValidation() { + lock.withLock { storedRejectValidation = true } + } func acquirePlanningMutationFence( machine: DoryMachineConfiguration, @@ -520,10 +716,29 @@ private final class TransactionMutationAuthority: throw TransactionInjectedFailure() } let digest = authoritySHA256 + let definitionDigest = SHA256.hash(data: canonicalDefinitionData) + .map { String(format: "%02x", $0) }.joined() return DoryDaemonVirtualMachinePlanningMutationFence( - authoritySHA256: digest, + authority: DoryDaemonVirtualMachinePlanningMachineAuthority( + machineID: machine.id, + legacyConfigurationSHA256: digest, + migrationFactsSHA256: migrationFactsSHA256, + sourceDefinitionRevision: definition.lifecycle.revision, + sourceDefinitionSHA256: definitionDigest, + runtimeIdentitySHA256: transactionDigest("a") + ), retainedAuthority: digest, - validation: {} + validation: { + if self.lock.withLock({ self.storedRejectValidation }) { + throw TransactionInjectedFailure() + } + }, + completion: { + self.lock.withLock { self.storedCompletionCount += 1 } + }, + recoveryRelease: { + self.lock.withLock { self.storedRecoveryReleaseCount += 1 } + } ) } } @@ -547,17 +762,30 @@ private struct TransactionPlanner: DoryDaemonVirtualMachineCapabilityPlanning { private final class TransactionFault: @unchecked Sendable { private let lock = NSLock() private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage? - init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) { + private let throwsInjectedFailure: Bool + private let action: @Sendable () -> Void + init( + _ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage, + throwsInjectedFailure: Bool = true, + action: @escaping @Sendable () -> Void = {} + ) { self.stage = stage + self.throwsInjectedFailure = throwsInjectedFailure + self.action = action } func inject( _ value: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage ) throws { - try lock.withLock { + let triggered = lock.withLock { if stage == value { stage = nil - throw TransactionInjectedFailure() + return true } + return false + } + if triggered { + action() + if throwsInjectedFailure { throw TransactionInjectedFailure() } } } } @@ -649,3 +877,7 @@ private func transactionDummyAdmission( private func transactionDigest(_ value: Character) -> String { String(repeating: String(value), count: 64) } + +private func transactionSHA256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift new file mode 100644 index 00000000..9ebe90a1 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift @@ -0,0 +1,325 @@ +import CryptoKit +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("MachineManager planning mutation authority") +struct MachineManagerPlanningMutationAuthorityTests { + @Test("fence binds exact persisted machine and blocks every local mutation") + func exactAuthorityAndLocalMutationExclusion() throws { + try PlanningMutationFixture.withFixture("local") { fixture in + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + _ = try manager.snapshot(id: fixture.machineID, snapshotID: "before") + let input = try fixture.input() + + let fence = try manager.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + #expect(fence.authority.isValid) + #expect(fence.authority.machineID == fixture.machineID) + #expect(fence.authority.sourceDefinitionRevision + == input.definition.lifecycle.revision) + #expect(fence.authority.legacyConfigurationSHA256 + == Self.sha256(input.legacyData)) + #expect(fence.authority.sourceDefinitionSHA256 + == Self.sha256(input.canonicalDefinitionData)) + try fence.revalidate() + + try Self.expectFailure(containing: "active planning mutation") { + _ = try manager.start(id: fixture.machineID) + } + try Self.expectFailure(containing: "active planning mutation") { + _ = try manager.stop(id: fixture.machineID) + } + try Self.expectFailure(containing: "active planning mutation") { + _ = try manager.update(id: fixture.machineID, memoryMB: 4_096) + } + try Self.expectFailure(containing: "active planning mutation") { + _ = try manager.snapshot(id: fixture.machineID, snapshotID: "blocked") + } + try Self.expectFailure(containing: "active planning mutation") { + _ = try manager.restoreSnapshot( + machineID: fixture.machineID, + snapshotID: "before" + ) + } + try Self.expectFailure(containing: "active planning mutation") { + try manager.delete(id: fixture.machineID) + } + #expect(fixture.processStartCount == 0) + + try fence.complete() + _ = try manager.update(id: fixture.machineID, memoryMB: 4_096) + #expect(manager.status(id: fixture.machineID)?.memoryMB == 4_096) + } + } + + @Test("cross-process lifecycle and update mutations cannot bypass planning fence") + func crossProcessMutationExclusion() throws { + try PlanningMutationFixture.withFixture("cross-process") { fixture in + let owner = fixture.makeManager() + _ = try fixture.createMachine(owner) + _ = try owner.snapshot(id: fixture.machineID, snapshotID: "before") + let contender = fixture.makeManager(launchPolicy: .legacyCompatibility) + let input = try fixture.input() + let fence = try owner.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + defer { fence.releaseForRecovery() } + + try Self.expectFailure(containing: "already in use") { + _ = try contender.start(id: fixture.machineID) + } + try Self.expectFailure(containing: "mutation authority is busy") { + _ = try contender.update(id: fixture.machineID, memoryMB: 4_096) + } + try Self.expectFailure(containing: "already in use") { + _ = try contender.snapshot(id: fixture.machineID, snapshotID: "blocked") + } + try Self.expectFailure(containing: "already in use") { + _ = try contender.restoreSnapshot( + machineID: fixture.machineID, + snapshotID: "before" + ) + } + try Self.expectFailure(containing: "already in use") { + try contender.delete(id: fixture.machineID) + } + #expect(fixture.processStartCount == 0) + } + } + + @Test("raw legacy byte drift invalidates retained authority") + func rawLegacyByteDrift() throws { + try PlanningMutationFixture.withFixture("raw-drift") { fixture in + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + let input = try fixture.input() + let fence = try manager.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + defer { fence.releaseForRecovery() } + + var changed = input.legacyData + changed.append(0x0a) + try fixture.writePrivate(changed, path: fixture.machineJSONPath) + try Self.expectFailure(containing: "authoritative machine state changed") { + try fence.revalidate() + } + #expect(fixture.processStartCount == 0) + } + } + + @Test("migration-fact drift invalidates retained authority") + func migrationFactDrift() throws { + try PlanningMutationFixture.withFixture("facts-drift") { fixture in + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + let input = try fixture.input() + let fence = try manager.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + defer { fence.releaseForRecovery() } + + let rootfs = input.machine.rootfsPath + let original = try Data(contentsOf: URL(fileURLWithPath: rootfs)) + var changed = original + changed.append(0x51) + try fixture.writePrivate(changed, path: rootfs) + try Self.expectFailure(containing: "migration facts changed") { + try fence.revalidate() + } + #expect(fixture.processStartCount == 0) + } + } + + @Test("released interrupted authority is reacquired after daemon restart") + func restartReacquiresAuthority() throws { + try PlanningMutationFixture.withFixture("restart") { fixture in + var owner: MachineManager? = fixture.makeManager() + _ = try fixture.createMachine(try #require(owner)) + let input = try fixture.input() + let first = try #require(owner).acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + let firstAuthority = first.authority + first.releaseForRecovery() + owner = nil + + let recovered = fixture.makeManager() + let recoveredInput = try fixture.input() + let second = try recovered.acquirePlanningMutationFence( + machine: recoveredInput.machine, + definition: recoveredInput.definition, + canonicalDefinitionData: recoveredInput.canonicalDefinitionData + ) + #expect(second.authority == firstAuthority) + try second.revalidate() + try second.complete() + #expect(fixture.processStartCount == 0) + } + } + + @Test("legacy policy and unrepresentable migration facts fail closed") + func unsupportedPromotionFailsClosed() throws { + try PlanningMutationFixture.withFixture("unsupported") { fixture in + let legacy = fixture.makeManager(launchPolicy: .legacyCompatibility) + _ = try fixture.createMachine(legacy) + let input = try fixture.input() + try Self.expectFailure(containing: "resolved-plan launch policy") { + _ = try legacy.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + } + + let unsupported = fixture.makeManager( + launchPolicy: .requireResolvedPlan, + architecture: "mips64" + ) + try Self.expectFailure(containing: "unsupported guest architecture") { + _ = try unsupported.acquirePlanningMutationFence( + machine: input.machine, + definition: input.definition, + canonicalDefinitionData: input.canonicalDefinitionData + ) + } + #expect(fixture.processStartCount == 0) + } + } + + private static func expectFailure( + containing fragment: String, + _ operation: () throws -> Void + ) throws { + do { + try operation() + Issue.record("Expected operation to fail with \(fragment)") + } catch { + #expect(String(describing: error).contains(fragment)) + } + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +private final class PlanningMutationFixture: @unchecked Sendable { + struct Input { + var machine: DoryMachineConfiguration + var legacyData: Data + var definition: DoryVirtualMachineDefinition + var canonicalDefinitionData: Data + } + + let machineID = "planning-vm" + let base: String + let state: String + let runtime: String + let journal: String + private let countLock = NSLock() + private var _processStartCount = 0 + + var processStartCount: Int { countLock.withLock { _processStartCount } } + var machineJSONPath: String { state + "/\(machineID)/machine.json" } + + init(label: String) throws { + base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-planning-authority-\(label)-\(UUID().uuidString)") + .path + state = base + "/machines" + runtime = base + "/runtime" + journal = base + "/journal" + try FileManager.default.createDirectory( + atPath: base, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + + static func withFixture( + _ label: String, + _ body: (PlanningMutationFixture) throws -> Void + ) throws { + let fixture = try PlanningMutationFixture(label: label) + defer { try? FileManager.default.removeItem(atPath: fixture.base) } + try body(fixture) + } + + func makeManager( + launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan, + architecture: String = "arm64" + ) -> MachineManager { + MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: state, + runtimeDirectory: runtime, + lifecycleJournalHome: journal, + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false, + guestArchitecture: architecture + ), + launchPolicy: launchPolicy, + processStarter: { [weak self] _ in + self?.countLock.withLock { self?._processStartCount += 1 } + } + ) + } + + @discardableResult + func createMachine(_ manager: MachineManager) throws -> DoryMachineStatus { + try manager.create(DoryMachineConfiguration( + id: machineID, + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048, + cpuCount: 2 + )) + } + + func input() throws -> Input { + let legacyData = try Data(contentsOf: URL(fileURLWithPath: machineJSONPath)) + let machine = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ) + let recordData = try Data(contentsOf: URL( + fileURLWithPath: state + "/\(machineID)/" + DoryWorkspaceRepository.recordFileName + )) + let record = try JSONDecoder().decode( + DoryWorkspaceRepositoryRecord.self, + from: recordData + ) + return Input( + machine: machine, + legacyData: legacyData, + definition: record.definition, + canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(record.definition) + ) + } + + func writePrivate(_ data: Data, path: String) throws { + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + } +} From 72b7f041afecc40674e569e82fa6b357823b9cc8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 17:43:52 +0000 Subject: [PATCH 036/338] feat(vm): compose production planning authorities --- ...MachineProductionPlanningComposition.swift | 427 +++++++++++++ ...neProductionPlanningCompositionTests.swift | 559 ++++++++++++++++++ 2 files changed, 986 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift new file mode 100644 index 00000000..54980ba1 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift @@ -0,0 +1,427 @@ +import Darwin +import DoryOperations +import Foundation + +/// Supplies daemon-authoritative reconstruction inputs for a durable planning journal. The +/// provider is expected to be backed by MachineManager's exact raw machine metadata, migration +/// facts, and lifecycle fence; a journal is never recovered from its persisted plan bytes alone. +public protocol DoryDaemonVirtualMachinePlanningRecoveryProviding: Sendable { + func recoveryRequest( + for machineID: String + ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? +} + +public enum DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode: + String, Sendable, Equatable +{ + case mutationAuthorityUnavailable = "mutation-authority-unavailable" + case recoveryAuthorityUnavailable = "recovery-authority-unavailable" + case stateAuthorityUnavailable = "state-authority-unavailable" + case backendRegistryUnavailable = "backend-registry-unavailable" + case resourceAuthorityUnavailable = "resource-authority-unavailable" + case trustInventoryUnavailable = "trust-inventory-unavailable" + case recoveryRequestUnavailable = "recovery-request-unavailable" + case recoveryFailed = "recovery-failed" + case compositionFailed = "composition-failed" +} + +public struct DoryDaemonVirtualMachineProductionPlanningCompositionFailure: + Error, Sendable, Equatable +{ + public var code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode + public var machineID: String? + public var message: String + + public init( + code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode, + machineID: String? = nil, + message: String + ) { + self.code = code + self.machineID = machineID + self.message = message + } +} + +/// Canonical durable roots shared by planning and start. These paths are diagnostic identities, +/// not persisted caller intent; construction rejects aliases and derives every child from one +/// exact state root. +public struct DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity: + Sendable, Equatable, Hashable +{ + public var stateDirectory: String + public var workspaceRepositoryRoot: String + public var resolvedPlanRepositoryRoot: String + public var artifactAuthorityRoot: String + public var resourceAdmissionLedgerRoot: String + + public init(stateDirectory: String) { + let canonical = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path + self.stateDirectory = canonical + workspaceRepositoryRoot = canonical + resolvedPlanRepositoryRoot = canonical + artifactAuthorityRoot = canonical + "/.artifact-authority" + resourceAdmissionLedgerRoot = canonical + "/.resource-admissions" + } +} + +/// Non-Codable daemon composition. All handles below are the exact instances used by recovery, +/// subsequent planning, and start-time resolution. +public struct DoryDaemonVirtualMachineProductionPlanningContext: Sendable { + public let identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity + public let artifactAuthority: DoryVirtualMachineArtifactAuthority + public let resourceLedger: DoryVirtualMachineResourceAdmissionLedger + public let workspaces: DoryWorkspaceRepository + public let plans: DoryResolvedMachinePlanRepository + public let registry: BackendRegistry + public let inventory: any DoryDaemonVirtualMachineTrustInventory + public let coordinator: DoryDaemonVirtualMachinePlanningTransactionCoordinator + public let launchResolver: DoryDaemonVirtualMachineLaunchPlanResolver + public let recoveredTransactionIDs: [String: String] + + public var planningTransactionAvailable: Bool { true } + + init( + identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity, + artifactAuthority: DoryVirtualMachineArtifactAuthority, + resourceLedger: DoryVirtualMachineResourceAdmissionLedger, + workspaces: DoryWorkspaceRepository, + plans: DoryResolvedMachinePlanRepository, + registry: BackendRegistry, + inventory: any DoryDaemonVirtualMachineTrustInventory, + coordinator: DoryDaemonVirtualMachinePlanningTransactionCoordinator, + launchResolver: DoryDaemonVirtualMachineLaunchPlanResolver, + recoveredTransactionIDs: [String: String] + ) { + self.identity = identity + self.artifactAuthority = artifactAuthority + self.resourceLedger = resourceLedger + self.workspaces = workspaces + self.plans = plans + self.registry = registry + self.inventory = inventory + self.coordinator = coordinator + self.launchResolver = launchResolver + self.recoveredTransactionIDs = recoveredTransactionIDs + } +} + +public enum DoryDaemonVirtualMachineProductionPlanningReadiness: Sendable { + case ready(DoryDaemonVirtualMachineProductionPlanningContext) + case unavailable(DoryDaemonVirtualMachineProductionPlanningCompositionFailure) + + public var planningTransactionAvailable: Bool { + if case .ready = self { return true } + return false + } +} + +/// Constructs the exact production planning/start authorities and recovers every discovered +/// transaction before returning readiness. The public production trust factory will own this +/// boundary; its dependency-injected initializer remains internal so callers cannot provide a +/// fabricated trust inventory. +public final class DoryDaemonVirtualMachineProductionPlanningCompositionFactory: + @unchecked Sendable +{ + typealias TrustedInventory = any DoryDaemonVirtualMachineTrustInventory + & DoryDaemonVirtualMachinePlanningTrustPreparing + typealias InventoryBuilder = @Sendable ( + DoryVirtualMachineArtifactAuthority, + DoryVirtualMachineResourceAdmissionLedger + ) throws -> TrustedInventory + + private static let journalFileName = "planning-transaction-v1.json" + private static let compositionLockFileName = ".planning-composition.lock" + + private let identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity + private let backends: [any MachineBackend] + private let mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)? + private let recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)? + private let inventoryBuilder: InventoryBuilder + private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning + private let verifiedBackendDescriptors: [MachineBackendDescriptor]? + + init( + stateDirectory: String, + backends: [any MachineBackend], + mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)?, + recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)?, + capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning = + DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(), + verifiedBackendDescriptors: [MachineBackendDescriptor]? = nil, + inventoryBuilder: @escaping InventoryBuilder + ) { + identity = DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity( + stateDirectory: stateDirectory + ) + self.backends = backends + self.mutationAuthority = mutationAuthority + self.recoveryProvider = recoveryProvider + self.capabilityPlanner = capabilityPlanner + self.verifiedBackendDescriptors = verifiedBackendDescriptors?.sorted { + $0.identity.rawValue < $1.identity.rawValue + } + self.inventoryBuilder = inventoryBuilder + } + + convenience init( + stateDirectory: String, + backends: [any MachineBackend], + qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority, + runtimes: [DoryDaemonVerifiedBackendRuntime], + runtimeVerifier: @escaping DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier, + hostProbe: @escaping DoryDaemonVirtualMachineProductionTrustFactory.HostProbe, + mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)?, + recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)? + ) { + let specifications = runtimes.compactMap { runtime + -> DoryDaemonBackendRuntimeSpecification? in + guard let component = runtime.components.first else { return nil } + return DoryDaemonBackendRuntimeSpecification( + descriptor: runtime.descriptor, + executablePath: runtime.executablePath, + componentIdentifier: component.componentIdentifier + ) + } + self.init( + stateDirectory: stateDirectory, + backends: backends, + mutationAuthority: mutationAuthority, + recoveryProvider: recoveryProvider, + verifiedBackendDescriptors: runtimes.map(\.descriptor), + inventoryBuilder: { artifactAuthority, resourceLedger in + guard specifications.count == runtimes.count else { + throw DoryDaemonProductionTrustInventoryError.backendUnavailable + } + return DoryProductionDaemonVirtualMachineTrustInventory( + qualificationAuthority: qualificationAuthority, + artifactAuthority: artifactAuthority, + resourceLedger: resourceLedger, + stateDirectory: stateDirectory, + runtimeSpecifications: specifications, + runtimeVerifier: runtimeVerifier, + hostProbe: hostProbe + ) + } + ) + } + + public func resolve() -> DoryDaemonVirtualMachineProductionPlanningReadiness { + guard let mutationAuthority else { + return unavailable( + .mutationAuthorityUnavailable, + "Production planning has no lifecycle-bound mutation authority." + ) + } + guard let recoveryProvider else { + return unavailable( + .recoveryAuthorityUnavailable, + "Production planning has no authoritative journal recovery provider." + ) + } + guard identity.stateDirectory + == URL(fileURLWithPath: identity.stateDirectory).standardizedFileURL.path else { + return unavailable(.stateAuthorityUnavailable, "VM state root is not canonical.") + } + + do { + return try withCompositionLock { + let registry: BackendRegistry + do { registry = try BackendRegistry(backends: backends) } + catch { + throw failure( + .backendRegistryUnavailable, + "Production backend registry cannot be constructed." + ) + } + if let verifiedBackendDescriptors, + registry.descriptors.sorted(by: { + $0.identity.rawValue < $1.identity.rawValue + }) != verifiedBackendDescriptors { + throw failure( + .backendRegistryUnavailable, + "Backend adapters do not exactly match verified runtime descriptors." + ) + } + let artifactAuthority = DoryVirtualMachineArtifactAuthority( + root: identity.artifactAuthorityRoot + ) + let resourceLedger = DoryVirtualMachineResourceAdmissionLedger( + root: identity.resourceAdmissionLedgerRoot + ) + do { _ = try resourceLedger.snapshot() } + catch { + throw failure( + .resourceAuthorityUnavailable, + "Resource admission authority is corrupt or unavailable." + ) + } + let workspaces = DoryWorkspaceRepository( + root: identity.workspaceRepositoryRoot + ) + let plans = DoryResolvedMachinePlanRepository( + root: identity.resolvedPlanRepositoryRoot + ) + let inventory: TrustedInventory + do { inventory = try inventoryBuilder(artifactAuthority, resourceLedger) } + catch { + throw failure( + .trustInventoryUnavailable, + "Verified production planning trust cannot be constructed." + ) + } + let coordinator = DoryDaemonVirtualMachinePlanningTransactionCoordinator( + stateDirectory: identity.stateDirectory, + registry: registry, + trust: inventory, + mutationAuthority: mutationAuthority, + workspaces: workspaces, + plans: plans, + ledger: resourceLedger, + capabilityPlanner: capabilityPlanner + ) + + let pending = try pendingTransactionMachineIDs() + var recovered: [String: String] = [:] + for machineID in pending { + guard let request = try recoveryProvider.recoveryRequest( + for: machineID + ), request.planning.definition.identity.id == machineID, + request.planning.machine.id == machineID else { + throw failure( + .recoveryRequestUnavailable, + machineID: machineID, + "Exact authoritative recovery input is unavailable." + ) + } + do { + let result = try coordinator.resolveReserveAndPublish(request) + recovered[machineID] = result.transactionID + } catch { + throw failure( + .recoveryFailed, + machineID: machineID, + "Durable planning recovery failed closed." + ) + } + } + + // A second discovery prevents a concurrent writer from being silently omitted + // during startup composition. Existing complete journals remain in the set. + guard try pendingTransactionMachineIDs() == pending else { + throw failure( + .recoveryFailed, + "Planning journal inventory changed during recovery." + ) + } + let collector = DoryDaemonVirtualMachineStartEvidenceCollector( + registry: registry, + inventory: inventory + ) + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: registry, + plans: plans, + evidenceCollector: collector + ) + return .ready(DoryDaemonVirtualMachineProductionPlanningContext( + identity: identity, + artifactAuthority: artifactAuthority, + resourceLedger: resourceLedger, + workspaces: workspaces, + plans: plans, + registry: registry, + inventory: inventory, + coordinator: coordinator, + launchResolver: resolver, + recoveredTransactionIDs: recovered + )) + } + } catch let failure as DoryDaemonVirtualMachineProductionPlanningCompositionFailure { + return .unavailable(failure) + } catch { + return unavailable(.compositionFailed, "Production planning composition failed.") + } + } + + private func pendingTransactionMachineIDs() throws -> [String] { + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory( + atPath: identity.stateDirectory + ) + } catch { + throw failure(.stateAuthorityUnavailable, "VM state root cannot be enumerated.") + } + var pending: [String] = [] + for name in names { + let journal = identity.stateDirectory + "/" + name + "/" + Self.journalFileName + var info = stat() + if lstat(journal, &info) != 0 { + if errno == ENOENT || errno == ENOTDIR { continue } + throw failure(.stateAuthorityUnavailable, "Planning journal cannot be inspected.") + } + guard Self.isValidMachineID(name) else { + throw failure(.stateAuthorityUnavailable, "Planning journal has an unsafe workspace identity.") + } + pending.append(name) + } + return pending.sorted() + } + + private func withCompositionLock(_ operation: () throws -> T) throws -> T { + try ensurePrivateStateDirectory() + let path = identity.stateDirectory + "/" + Self.compositionLockFileName + let descriptor = open( + path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600) + ) + guard descriptor >= 0 else { + throw failure(.stateAuthorityUnavailable, "Planning composition lock cannot be opened.") + } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == geteuid(), info.st_nlink == 1, + info.st_mode & 0o077 == 0, + flock(descriptor, LOCK_EX) == 0 else { + throw failure(.stateAuthorityUnavailable, "Planning composition lock is insecure.") + } + defer { flock(descriptor, LOCK_UN) } + return try operation() + } + + private func ensurePrivateStateDirectory() throws { + var info = stat() + guard lstat(identity.stateDirectory, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == geteuid(), + info.st_mode & 0o077 == 0 else { + throw failure(.stateAuthorityUnavailable, "VM state root is missing or insecure.") + } + } + + private func unavailable( + _ code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachineProductionPlanningReadiness { + .unavailable(failure(code, message)) + } + + private func failure( + _ code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode, + machineID: String? = nil, + _ message: String + ) -> DoryDaemonVirtualMachineProductionPlanningCompositionFailure { + DoryDaemonVirtualMachineProductionPlanningCompositionFailure( + code: code, + machineID: machineID, + message: message + ) + } + + private static func isValidMachineID(_ value: String) -> Bool { + value.utf8.count <= 63 && value.wholeMatch( + of: /[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?/ + ) != nil + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift new file mode 100644 index 00000000..9523a0ce --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift @@ -0,0 +1,559 @@ +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Production VM planning composition") +struct DoryDaemonVirtualMachineProductionPlanningCompositionTests { + @Test("authorizer and recovery provider are both mandatory") + func dependenciesAreMandatory() throws { + let fixture = try CompositionFixture(ids: []) + for (hasAuthorizer, hasRecovery, expected) in [ + (false, true, + DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode + .mutationAuthorityUnavailable), + (true, false, + .recoveryAuthorityUnavailable), + ] { + let result = fixture.factory( + hasMutationAuthority: hasAuthorizer, + hasRecoveryProvider: hasRecovery + ).resolve() + guard case let .unavailable(failure) = result else { + Issue.record("Expected unavailable planning composition") + continue + } + #expect(failure.code == expected) + #expect(!result.planningTransactionAvailable) + } + } + + @Test("durable recovery completes before readiness is exposed") + func recoveryPrecedesReadiness() throws { + let fixture = try CompositionFixture(ids: ["recover-one"]) + try fixture.interrupt("recover-one", at: .planBindingCommitted) + + let readiness = fixture.factory().resolve() + let context = try readyContext(readiness) + #expect(fixture.events.values.contains("recovery:recover-one")) + #expect(fixture.events.values.contains("mutation:recover-one")) + #expect(context.recoveredTransactionIDs.keys.sorted() == ["recover-one"]) + #expect(try context.plans.read(id: "recover-one").machineID == "recover-one") + #expect(readiness.planningTransactionAvailable) + } + + @Test("corrupt planning journal prevents readiness") + func corruptJournalFailsClosed() throws { + let fixture = try CompositionFixture(ids: ["corrupt-one"]) + let directory = fixture.root + "/corrupt-one" + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try Data("not-json\n".utf8).write( + to: URL(fileURLWithPath: directory + "/planning-transaction-v1.json") + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: directory + "/planning-transaction-v1.json" + ) + + guard case let .unavailable(failure) = fixture.factory().resolve() else { + Issue.record("Expected corrupt recovery to fail closed") + return + } + #expect(failure.code == .recoveryFailed) + #expect(failure.machineID == "corrupt-one") + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try fixture.plans.read(id: "corrupt-one") + } + } + + @Test("recovery is isolated per workspace and uses exact repository roots") + func crossWorkspaceIsolationAndIdentity() throws { + let ids = ["recover-a", "recover-b"] + let fixture = try CompositionFixture(ids: ids) + try fixture.interrupt("recover-b", at: .workspacePublished) + try fixture.interrupt("recover-a", at: .candidateJournalPublished) + + let context = try readyContext(fixture.factory().resolve()) + #expect(context.recoveredTransactionIDs.keys.sorted() == ids) + #expect(fixture.recovery.requestedIDs == ids) + #expect(context.identity.stateDirectory == fixture.root) + #expect(context.workspaces.root == context.identity.workspaceRepositoryRoot) + #expect(context.plans.root == context.identity.resolvedPlanRepositoryRoot) + #expect(context.artifactAuthority.root == context.identity.artifactAuthorityRoot) + #expect(context.resourceLedger.root + == context.identity.resourceAdmissionLedgerRoot) + for id in ids { + #expect(try context.workspaces.read(id: id).identity.id == id) + #expect(try context.plans.read(id: id).machineID == id) + } + #expect(try context.resourceLedger.snapshot().leases.count == 2) + } + + private func readyContext( + _ readiness: DoryDaemonVirtualMachineProductionPlanningReadiness + ) throws -> DoryDaemonVirtualMachineProductionPlanningContext { + guard case let .ready(context) = readiness else { + Issue.record("Expected ready production planning composition") + throw CompositionTestError.notReady + } + return context + } +} + +private final class CompositionFixture: @unchecked Sendable { + let root: String + let events = CompositionEvents() + let registry: BackendRegistry + let backend: RawHVLinuxMachineBackend + let workspaces: DoryWorkspaceRepository + let plans: DoryResolvedMachinePlanRepository + let ledger: DoryVirtualMachineResourceAdmissionLedger + let trust: CompositionTrust + let authorizer: CompositionMutationAuthority + let recovery: CompositionRecovery + private let requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest] + + init(ids: [String]) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-production-planning-composition-\(UUID().uuidString)" + ).standardizedFileURL.path + try FileManager.default.createDirectory( + atPath: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + backend = RawHVLinuxMachineBackend( + executablePath: "/fixture/dory-hv", + operations: MachineBackendCompatibilityOperations( + start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + ), + executableIsAvailable: { _ in true } + ) + registry = try BackendRegistry(backends: [backend]) + workspaces = DoryWorkspaceRepository(root: root) + plans = DoryResolvedMachinePlanRepository(root: root) + ledger = DoryVirtualMachineResourceAdmissionLedger( + root: root + "/.resource-admissions" + ) + var requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest] = [:] + var snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot] = [:] + for id in ids { + let prepared = Self.requestAndSnapshot(id: id) + requests[id] = prepared.request + snapshots[id] = prepared.snapshot + } + self.requests = requests + trust = CompositionTrust(snapshots: snapshots) + authorizer = CompositionMutationAuthority(events: events) + recovery = CompositionRecovery(requests: requests, events: events) + } + + deinit { try? FileManager.default.removeItem(atPath: root) } + + func factory( + hasMutationAuthority: Bool = true, + hasRecoveryProvider: Bool = true + ) -> DoryDaemonVirtualMachineProductionPlanningCompositionFactory { + return DoryDaemonVirtualMachineProductionPlanningCompositionFactory( + stateDirectory: root, + backends: [backend], + mutationAuthority: hasMutationAuthority ? authorizer : nil, + recoveryProvider: hasRecoveryProvider ? recovery : nil, + capabilityPlanner: CompositionCapabilityPlanner(), + inventoryBuilder: { [trust] artifactAuthority, resourceLedger in + _ = artifactAuthority + _ = resourceLedger + return trust + } + ) + } + + func interrupt( + _ id: String, + at stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage + ) throws { + let fault = CompositionFault(stage) + let coordinator = DoryDaemonVirtualMachinePlanningTransactionCoordinator( + stateDirectory: root, + registry: registry, + trust: trust, + mutationAuthority: authorizer, + workspaces: workspaces, + plans: plans, + ledger: ledger, + capabilityPlanner: CompositionCapabilityPlanner(), + now: { 1_700_000_000_100 }, + faultInjector: fault.inject + ) + do { + _ = try coordinator.resolveReserveAndPublish(try #require(requests[id])) + Issue.record("Expected injected transaction interruption") + } catch is CompositionInjectedFailure {} + } + + private static func requestAndSnapshot( + id: String + ) -> ( + request: DoryDaemonVirtualMachinePlanningTransactionRequest, + snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) { + let mediaReference = DoryVMResolverReference( + namespace: "artifact", identifier: "\(id)-runtime" + ) + let diskReference = DoryVMResolverReference( + namespace: "artifact", identifier: "\(id)-disk" + ) + let resources = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 4 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ) + let definition = DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity(id: id, name: id), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", role: .system, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifact: mediaReference, + removable: false + )], + order: ["system"] + ), + backendPreference: DoryVMBackendPreference( + mode: .required, backend: .doryHypervisor + ), + graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]), + resources: resources, + storage: [DoryVMStorageAttachment( + id: "system-disk", role: .system, artifact: diskReference, + capacityBytes: resources.diskBytes + )], + audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), + input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + let machine = DoryMachineConfiguration( + id: id, + kernelPath: "/fixture/\(id)-kernel", + rootfsPath: "/fixture/\(id).raw", + bootMode: .linuxKernel, + displayMode: .desktop + ) + let media = DoryBootMedia( + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifactSHA256: compositionDigest(id.last ?? "a") + ) + let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let evidence = DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: compositionQualificationIdentity(media), + qualificationReportSHA256: compositionDigest("b"), + signingKeyID: "dory-test-key", + qualificationFormatVersion: 1, + guest: definition.guest, + bootMediaKind: media.kind, + immutableArtifactSHA256: media.artifactSHA256, + backend: .doryHypervisor, + backendRuntimeBuildID: "raw-runtime-1", + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices + ) + let hostQualification = DoryResolvedHostQualificationEvidence( + qualificationIdentity: evidence.qualificationIdentity, + qualificationReportSHA256: evidence.qualificationReportSHA256, + hostHardwareModelIdentifier: "Mac16.1", + hostOperatingSystemBuild: "26A5406c", + backend: .doryHypervisor, + backendRuntimeBuildIdentifier: "raw-runtime-1", + virtualHardwareABIVersion: 1, + qualifierIdentifier: "dory-test-qualifier", + qualifierVersion: 1 + ) + let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot( + hostFacts: compositionHostFacts(), + media: DoryDaemonVirtualMachineResolvedMedia( + reference: mediaReference, media: media + ), + backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( + backend: .doryHypervisor, + runtimeBuildIdentifier: "raw-runtime-1", + components: [DoryResolvedBackendComponentEvidence( + componentIdentifier: "dory-hv", + buildIdentifier: "raw-runtime-1", + artifactSHA256: compositionDigest("c") + )], + hostQualification: hostQualification + )], + resourceAdmission: compositionAdmission(resources) + ) + let planning = DoryDaemonVirtualMachinePlanningRequest( + definition: definition, + canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(definition), + machine: machine, + publication: .create + ) + return ( + DoryDaemonVirtualMachinePlanningTransactionRequest( + planning: planning, + workspacePublication: .create + ), + snapshot + ) + } +} + +private final class CompositionTrust: + DoryDaemonVirtualMachineTrustInventory, + DoryDaemonVirtualMachinePlanningTrustPreparing, + @unchecked Sendable +{ + let snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot] + init(snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot]) { + self.snapshots = snapshots + } + + func preparePlanningTrust( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation { + let base = try #require(snapshots[request.machineID]) + return DoryDaemonVirtualMachinePlanningTrustPreparation( + hostResources: compositionHostResources(), + snapshot: { admission in + var snapshot = base + snapshot.resourceAdmission = admission + return snapshot + }, + publicationAuthorization: + DoryDaemonVirtualMachinePlanningPublicationAuthorization {} + ) + } + + func planningInventory( + for request: DoryDaemonVirtualMachineInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + try #require(snapshots[request.machineID]) + } + + func startInventory( + for request: DoryDaemonVirtualMachineStartInventoryRequest + ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { + try #require(snapshots[request.machineID]) + } +} + +private final class CompositionRecovery: + DoryDaemonVirtualMachinePlanningRecoveryProviding, @unchecked Sendable +{ + private let lock = NSLock() + private let requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest] + private let events: CompositionEvents + private var storage: [String] = [] + + init( + requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest], + events: CompositionEvents + ) { + self.requests = requests + self.events = events + } + + var requestedIDs: [String] { lock.withLock { storage } } + + func recoveryRequest( + for machineID: String + ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? { + lock.withLock { storage.append(machineID) } + events.append("recovery:\(machineID)") + return requests[machineID] + } +} + +private final class CompositionMutationAuthority: + DoryDaemonVirtualMachinePlanningMutationAuthorizing, @unchecked Sendable +{ + private let events: CompositionEvents + init(events: CompositionEvents) { self.events = events } + + func acquirePlanningMutationFence( + machine: DoryMachineConfiguration, + definition: DoryVirtualMachineDefinition, + canonicalDefinitionData: Data + ) throws -> DoryDaemonVirtualMachinePlanningMutationFence { + guard machine.id == definition.identity.id, !canonicalDefinitionData.isEmpty else { + throw CompositionTestError.invalidAuthority + } + events.append("mutation:\(machine.id)") + return DoryDaemonVirtualMachinePlanningMutationFence( + authority: DoryDaemonVirtualMachinePlanningMachineAuthority( + machineID: machine.id, + legacyConfigurationSHA256: compositionDigest("e"), + migrationFactsSHA256: compositionDigest("f"), + sourceDefinitionRevision: definition.lifecycle.revision, + sourceDefinitionSHA256: + DoryDaemonVirtualMachinePlanningCoordinator.sha256( + canonicalDefinitionData + ), + runtimeIdentitySHA256: compositionDigest("a") + ), + retainedAuthority: machine.id, + validation: {} + ) + } +} + +private struct CompositionCapabilityPlanner: DoryDaemonVirtualMachineCapabilityPlanning { + func plan( + _ request: DoryVirtualMachineBackendPlanRequest, + inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot + ) -> DoryVirtualMachineBackendPlanResult { + let capabilityRequest = DoryVirtualMachineCapabilityRequest( + guest: request.guest, + bootMedia: request.bootMedia, + backend: .doryHypervisor, + graphics: request.acceptableGraphics.first ?? .none, + devices: request.devices, + virtualHardwareABIVersion: request.virtualHardwareABIVersion + ) + let descriptor = DoryVirtualMachineCapabilityDescriptor( + evaluatorVersion: + DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion, + request: capabilityRequest, + availability: DoryCapabilityAvailability( + supportTier: .supported, state: .available + ), + resolvedDevices: request.devices, + runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence( + qualificationIdentity: compositionQualificationIdentity(request.bootMedia), + qualificationReportSHA256: compositionDigest("b"), + signingKeyID: "dory-test-key", + qualificationFormatVersion: 1, + guest: request.guest, + bootMediaKind: request.bootMedia.kind, + immutableArtifactSHA256: request.bootMedia.artifactSHA256, + backend: .doryHypervisor, + backendRuntimeBuildID: "raw-runtime-1", + virtualHardwareABIVersion: request.virtualHardwareABIVersion, + graphics: capabilityRequest.graphics, + devices: request.devices + ) + ) + _ = inventory + return DoryVirtualMachineBackendPlanResult( + selectedDescriptor: descriptor, + evaluatedDescriptors: [descriptor], + failure: nil + ) + } +} + +private final class CompositionEvents: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + var values: [String] { lock.withLock { storage } } + func append(_ value: String) { lock.withLock { storage.append(value) } } +} + +private final class CompositionFault: @unchecked Sendable { + private let lock = NSLock() + private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage? + init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) { + self.stage = stage + } + func inject( + _ current: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage + ) throws { + try lock.withLock { + if stage == current { + stage = nil + throw CompositionInjectedFailure() + } + } + } +} + +private enum CompositionTestError: Error { case notReady, invalidAuthority } +private struct CompositionInjectedFailure: Error {} + +private func compositionHostResources() -> DoryVMHostResources { + DoryVMHostResources( + logicalCPUCount: 12, + physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + freeStorageBytes: 512 * 1_024 * 1_024 * 1_024 + ) +} + +private func compositionHostFacts() -> DoryAppleSiliconHostFacts { + DoryAppleSiliconHostFacts( + macOSMajorVersion: 26, + virtualizationFrameworkAvailable: true, + hypervisorFrameworkAvailable: true, + doryHypervisorAvailable: true, + qemuHypervisorFrameworkAvailable: false, + windowsUEFIFirmwareAvailable: false, + windowsSecureBootAvailable: false, + windowsSBSADeviceModelAvailable: false, + virtualTPM20Available: false, + windowsGuestDrivers: DoryWindowsGuestDriverFacts( + storageAvailable: false, networkAvailable: false, + displayAvailable: false, inputAvailable: false + ), + macOSGuestVirtualizationSupported: false, + macOSRestoreImageInstallationSupported: false, + doryMacOSBackendAvailable: false, + doryMacOSBackendQualified: false, + metalAvailable: true, + doryAcceleratedRendererAvailable: true, + runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( + virtualHardwareABIVersion: 1, + doryHypervisorRuntimeBuildID: "raw-runtime-1", + virtualizationFrameworkAdapterBuildID: "vz-runtime-1", + qemuRuntimeBuildID: "" + ) + ) +} + +private func compositionAdmission( + _ resources: DoryVMResourceRequest +) -> DoryResolvedMachineResourceAdmissionEvidence { + DoryResolvedMachineResourceAdmissionEvidence( + admittedVirtualCPUCount: resources.virtualCPUCount, + admittedMemoryBytes: resources.memoryBytes, + admittedStorageBytes: resources.diskBytes, + hostLogicalCPUCount: 12, + hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024, + existingVirtualCPUCommitment: 0, + existingMemoryCommitmentBytes: 0, + existingStorageReservationBytes: 0, + hostReservedLogicalCPUCount: 2, + hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024, + hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024, + admissionIdentity: "composition-dummy-admission", + admissionReportSHA256: compositionDigest("d"), + assessorIdentifier: DoryVirtualMachineResourceAdmissionLedger.assessorIdentifier, + assessorVersion: DoryVirtualMachineResourceAdmissionLedger.assessorVersion + ) +} + +private func compositionDigest(_ value: Character) -> String { + String(repeating: String(value), count: 64) +} + +private func compositionQualificationIdentity(_ media: DoryBootMedia) -> String { + "qualification-\((media.artifactSHA256 ?? "missing").prefix(12))" +} From 29adae04ca95e90fe166810d27d5ba307140fc18 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 18:23:36 +0000 Subject: [PATCH 037/338] feat(vm): enforce per-workspace launch authority --- .../DorydKit/DoryMachineRuntimeIdentity.swift | 1 + .../DoryMachineRuntimeIdentityStore.swift | 388 ++++++++++ .../Sources/DorydKit/MachineManager.swift | 714 ++++++++++++++++-- ...DoryMachineRuntimeIdentityStoreTests.swift | 151 ++++ ...eManagerResolvedPlanIntegrationTests.swift | 542 +++++++++++++ 5 files changed, 1723 insertions(+), 73 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift index 8014b3e6..f5e2c365 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift @@ -15,6 +15,7 @@ public enum DoryMachineRuntimeIdentityInvalidationReason: String, Codable, Senda case restoredSnapshot = "restored-snapshot" case planRecoveryFailed = "plan-recovery-failed" case planNotInstalled = "plan-not-installed" + case importedSnapshot = "imported-snapshot" } public enum DoryMachineRuntimeIdentityValidationCode: String, Codable, Sendable, Hashable { diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift new file mode 100644 index 00000000..28326116 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift @@ -0,0 +1,388 @@ +import CryptoKit +import Darwin +import Foundation + +enum DoryMachineRuntimeIdentityStoreError: Error, Equatable { + case invalidMachineID + case recordNotFound + case invalidRecord + case authorityMismatch + case filesystem(String) +} + +struct DoryPersistedMachineRuntimeIdentity: Codable, Sendable, Equatable { + static let schemaVersion: UInt16 = 2 + + var schemaVersion: UInt16 + var machineID: String + var authorityRevision: UInt64 + var previousRecordSHA256: String? + var legacyConfigurationSHA256: String + var identity: DoryMachineRuntimeIdentity + + init( + machineID: String, + authorityRevision: UInt64, + previousRecordSHA256: String?, + legacyConfigurationSHA256: String, + identity: DoryMachineRuntimeIdentity + ) { + schemaVersion = Self.schemaVersion + self.machineID = machineID + self.authorityRevision = authorityRevision + self.previousRecordSHA256 = previousRecordSHA256?.lowercased() + self.legacyConfigurationSHA256 = legacyConfigurationSHA256.lowercased() + self.identity = identity + } + + var isValid: Bool { + schemaVersion == Self.schemaVersion + && Self.isValidMachineID(machineID) + && authorityRevision > 0 + && ((authorityRevision == 1 && previousRecordSHA256 == nil) + || (authorityRevision > 1 + && previousRecordSHA256.map(Self.isSHA256) == true)) + && Self.isSHA256(legacyConfigurationSHA256) + && identity.validate().isEmpty + && (identity.resolvedPlan?.machineID == machineID + || identity.resolvedPlan == nil) + } + + private static func isValidMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + +private struct DoryMachineRuntimeIdentityHead: Codable, Sendable, Equatable { + static let schemaVersion: UInt16 = 1 + + var schemaVersion: UInt16 = Self.schemaVersion + var machineID: String + var authorityRevision: UInt64 + var recordSHA256: String + + var isValid: Bool { + schemaVersion == Self.schemaVersion + && authorityRevision > 0 + && machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !machineID.hasPrefix(".") + && recordSHA256.utf8.count == 64 + && recordSHA256.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + +/// Durable per-workspace launch authority. The record contains no paths or secrets and is bound +/// to the exact authoritative legacy machine bytes, so a machine.json change cannot retain stale +/// resolved or compatibility authority across daemon restart. +final class DoryMachineRuntimeIdentityStore: @unchecked Sendable { + static let recordFileName = "runtime-identity-v1.json" + static let headFileName = "runtime-identity-head-v1.json" + static let recordTemporaryPrefix = ".runtime-identity-v1.tmp-" + static let headTemporaryPrefix = ".runtime-head.tmp-" + private static let maximumRecordBytes: Int64 = 16 * 1_024 * 1_024 + + let root: String + private let lock = NSLock() + + init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + } + + func readIfPresent( + machineID: String, + authoritativeLegacyData: Data + ) throws -> DoryMachineRuntimeIdentity? { + lock.lock() + defer { lock.unlock() } + let path = try recordPath(machineID: machineID) + let headPath = try headPath(machineID: machineID) + let hasRecord = Self.pathExists(path) + let hasHead = Self.pathExists(headPath) + guard hasRecord || hasHead else { return nil } + guard hasRecord, hasHead else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let data = try Self.secureRead(path: path) + guard let record = try? JSONDecoder().decode( + DoryPersistedMachineRuntimeIdentity.self, + from: data + ), record.isValid, record.machineID == machineID else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let head = try decodeHead(path: headPath, machineID: machineID) + let recordSHA256 = Self.sha256(data) + if head.recordSHA256 != recordSHA256 + || head.authorityRevision != record.authorityRevision { + // Identity is published before its monotonic head. Complete that one permissible + // crash ordering; every rollback/substitution shape fails closed. + guard record.authorityRevision == head.authorityRevision + 1, + record.previousRecordSHA256 == head.recordSHA256 else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + try publishHead( + DoryMachineRuntimeIdentityHead( + machineID: machineID, + authorityRevision: record.authorityRevision, + recordSHA256: recordSHA256 + ), + path: headPath + ) + } + guard record.legacyConfigurationSHA256 == Self.sha256(authoritativeLegacyData) else { + throw DoryMachineRuntimeIdentityStoreError.authorityMismatch + } + return record.identity + } + + func publish( + _ identity: DoryMachineRuntimeIdentity, + machineID: String, + authoritativeLegacyData: Data + ) throws { + lock.lock() + defer { lock.unlock() } + let path = try recordPath(machineID: machineID) + let headPath = try headPath(machineID: machineID) + let previous: DoryMachineRuntimeIdentityHead? + if Self.pathExists(path) || Self.pathExists(headPath) { + guard Self.pathExists(path), Self.pathExists(headPath) else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let oldData = try Self.secureRead(path: path) + let oldHead = try decodeHead(path: headPath, machineID: machineID) + guard oldHead.recordSHA256 == Self.sha256(oldData), + let oldRecord = try? JSONDecoder().decode( + DoryPersistedMachineRuntimeIdentity.self, + from: oldData + ), oldRecord.isValid, + oldRecord.authorityRevision == oldHead.authorityRevision else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + previous = oldHead + } else { + previous = nil + } + guard previous?.authorityRevision != UInt64.max else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let authorityRevision = (previous?.authorityRevision ?? 0) + 1 + let record = DoryPersistedMachineRuntimeIdentity( + machineID: machineID, + authorityRevision: authorityRevision, + previousRecordSHA256: previous?.recordSHA256, + legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData), + identity: identity + ) + guard record.isValid else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path + guard Self.isPrivateDirectory(directory) else { + throw DoryMachineRuntimeIdentityStoreError.filesystem( + "runtime identity owner is not a private directory" + ) + } + let data = try Self.encoded(record) + guard !data.isEmpty, data.count <= Int(Self.maximumRecordBytes) else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let temporary = directory + "/\(Self.recordTemporaryPrefix)\(UUID().uuidString.lowercased())" + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw Self.filesystem("create runtime identity temporary record") + } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var remaining = bytes.count + var pointer = bytes.baseAddress! + while remaining > 0 { + let count = Darwin.write(descriptor, pointer, remaining) + if count > 0 { + remaining -= count + pointer = pointer.advanced(by: count) + } else if count < 0, errno == EINTR { + continue + } else { + throw Self.filesystem("write runtime identity temporary record") + } + } + } + guard fsync(descriptor) == 0, fchmod(descriptor, mode_t(0o600)) == 0 else { + throw Self.filesystem("sync runtime identity temporary record") + } + guard rename(temporary, path) == 0 else { + throw Self.filesystem("publish runtime identity record") + } + removeTemporary = false + try Self.syncDirectory(directory) + try publishHead( + DoryMachineRuntimeIdentityHead( + machineID: machineID, + authorityRevision: authorityRevision, + recordSHA256: Self.sha256(data) + ), + path: headPath + ) + } + + private func recordPath(machineID: String) throws -> String { + guard machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !machineID.hasPrefix(".") else { + throw DoryMachineRuntimeIdentityStoreError.invalidMachineID + } + return root + "/" + machineID + "/" + Self.recordFileName + } + + private func headPath(machineID: String) throws -> String { + _ = try recordPath(machineID: machineID) + return root + "/" + machineID + "/" + Self.headFileName + } + + private func decodeHead( + path: String, + machineID: String + ) throws -> DoryMachineRuntimeIdentityHead { + let data = try Self.secureRead(path: path) + guard let head = try? JSONDecoder().decode( + DoryMachineRuntimeIdentityHead.self, + from: data + ), head.isValid, head.machineID == machineID else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + return head + } + + private func publishHead( + _ head: DoryMachineRuntimeIdentityHead, + path: String + ) throws { + guard head.isValid else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + try Self.publishData(Self.encoded(head), path: path, prefix: Self.headTemporaryPrefix) + } + + private static func encoded(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + } + + private static func secureRead(path: String) throws -> Data { + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw filesystem("open runtime identity record") } + defer { _ = close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size > 0, + info.st_size <= maximumRecordBytes else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + guard data.count == Int(info.st_size) else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + return data + } + + private static func publishData( + _ data: Data, + path: String, + prefix: String + ) throws { + guard !data.isEmpty, data.count <= Int(maximumRecordBytes) else { + throw DoryMachineRuntimeIdentityStoreError.invalidRecord + } + let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path + guard isPrivateDirectory(directory) else { + throw DoryMachineRuntimeIdentityStoreError.filesystem( + "runtime identity owner is not a private directory" + ) + } + let temporary = directory + "/\(prefix)\(UUID().uuidString.lowercased())" + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw filesystem("create runtime authority temporary") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var remaining = bytes.count + var pointer = bytes.baseAddress! + while remaining > 0 { + let written = Darwin.write(descriptor, pointer, remaining) + if written > 0 { + remaining -= written + pointer = pointer.advanced(by: written) + } else if written < 0, errno == EINTR { + continue + } else { + throw filesystem("write runtime authority temporary") + } + } + } + guard fsync(descriptor) == 0, fchmod(descriptor, mode_t(0o600)) == 0 else { + throw filesystem("sync runtime authority temporary") + } + guard rename(temporary, path) == 0 else { + throw filesystem("publish runtime authority") + } + removeTemporary = false + try syncDirectory(directory) + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private static func pathExists(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { throw filesystem("open runtime identity directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { throw filesystem("sync runtime identity directory") } + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func filesystem(_ action: String) -> DoryMachineRuntimeIdentityStoreError { + .filesystem(action + ": " + String(cString: strerror(errno))) + } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 9398d066..421b330b 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -694,6 +694,9 @@ public enum DoryMachineLaunchPolicy: String, Sendable, Equatable { case legacyCompatibility /// A start is rejected unless the complete persisted-plan trust path is installed. case requireResolvedPlan + /// Each workspace is dispatched solely from its durable runtime identity. Existing machines + /// may retain explicit legacy compatibility while new and invalidated machines require a plan. + case perWorkspaceAuthority } public final class MachineManager: @unchecked Sendable { @@ -707,6 +710,12 @@ public final class MachineManager: @unchecked Sendable { private static let installedLinuxKernelName = "direct-kernel" private static let installedLinuxInitrdName = "direct-initrd" private static let machineMetadataTemporaryPrefix = ".dory-machine-metadata-" + private static let interruptedNativeCreationQuarantinePrefix = + ".dory-native-create-recovery-" + private static let nativeCreationPrecommitMarkerName = + ".dory-native-create-precommit-v1" + private static let nativeCreationCommittedMarkerName = + ".dory-native-create-committed-v1" private static let machineRestoreBackupMarker = ".restore-" private static let snapshotDeletionQuarantinePrefix = ".dory-snapshot-delete-" private static let snapshotDiskTemporaryMarker = ".ext4.tmp-" @@ -732,6 +741,7 @@ public final class MachineManager: @unchecked Sendable { private let balloonController: any MachineBalloonControlling private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository + private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore private let launchPolicy: DoryMachineLaunchPolicy private let lifecycleJournalStore: DoryOperationJournalStore? private let lifecycleJournalInitializationError: String? @@ -770,6 +780,9 @@ public final class MachineManager: @unchecked Sendable { self.agentConnector = agentConnector self.processStarter = processStarter self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory) + self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore( + root: configuration.stateDirectory + ) do { let store = try DoryOperationJournalStore( home: configuration.lifecycleJournalHome @@ -802,9 +815,17 @@ public final class MachineManager: @unchecked Sendable { Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory) Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory) Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory) + Self.recoverCompletedNativeCreationMarkers( + stateDirectory: configuration.stateDirectory, + runtimeIdentityStore: runtimeIdentityStore + ) + Self.removeInterruptedNativeCreations( + stateDirectory: configuration.stateDirectory + ) self.machines = Self.loadPersistedMachines( configuration: configuration, - launchPolicy: launchPolicy + launchPolicy: launchPolicy, + runtimeIdentityStore: runtimeIdentityStore ) for (machineID, diagnostic) in lifecycleRecoveryDiagnostics { machines[machineID]?.lastError = diagnostic @@ -813,6 +834,10 @@ public final class MachineManager: @unchecked Sendable { recoverInterruptedDesktopUpdates() } + public var configuredLaunchPolicy: DoryMachineLaunchPolicy { launchPolicy } + + public var managedStateDirectory: String { configuration.stateDirectory } + /// Installs the daemon-owned active-component resolver. Desktop update writes fail closed until /// this is installed; caller-supplied filesystem paths are never accepted as update authority. public func installDesktopUpdateArtifactResolver( @@ -834,9 +859,10 @@ public final class MachineManager: @unchecked Sendable { ) throws { operationLock.lock() defer { operationLock.unlock() } - guard launchPolicy == .requireResolvedPlan else { + guard launchPolicy == .requireResolvedPlan + || launchPolicy == .perWorkspaceAuthority else { throw MachineManagerError.persistence( - "resolved launch infrastructure requires the requireResolvedPlan policy" + "resolved launch infrastructure requires a resolved-plan-capable policy" ) } guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil, @@ -957,18 +983,48 @@ public final class MachineManager: @unchecked Sendable { } } + let nativeCreationMarker = statePath + "/" + + Self.nativeCreationPrecommitMarkerName + try Self.writeDurablePrivateData( + Self.nativeCreationPrecommitMarkerData(machineID: machine.id), + toPath: nativeCreationMarker + ) + try Self.syncDirectory(path: statePath) + let preparedMachine = try prepareMachineArtifacts(machine) try validateManagedMachineArtifacts(preparedMachine) + let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine() + let authoritativeLegacyData = try DoryMachineConfigurationMigrationBridge + .encodeLegacy(preparedMachine) + // The launch authority is durable before machine.json becomes discoverable. A crash in + // this window leaves an incomplete, non-loadable directory, never a newly created VM that + // startup can mistake for pre-companion legacy state. + try runtimeIdentityStore.publish( + initialRuntimeIdentity, + machineID: preparedMachine.id, + authoritativeLegacyData: authoritativeLegacyData + ) try persist(preparedMachine) + // machine.json is now durable. Preserve this exact state on a marker-transition error so + // restart can complete it; never report success until the committed marker is directory- + // durable. Keeping the committed marker also distinguishes later metadata loss from an + // interrupted precommit and prevents destructive cleanup. + committed = true + let committedMarker = statePath + "/" + + Self.nativeCreationCommittedMarkerName + guard rename(nativeCreationMarker, committedMarker) == 0 else { + throw MachineManagerError.persistence( + "could not commit native creation authority marker" + ) + } + try Self.syncDirectory(path: statePath) lock.lock() - let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine() machines[machine.id] = MachineEntry( configuration: preparedMachine, state: .created, runtimeIdentity: initialRuntimeIdentity ) lock.unlock() - committed = true return DoryMachineStatus( id: preparedMachine.id, state: .created, @@ -1001,29 +1057,11 @@ public final class MachineManager: @unchecked Sendable { ) throws -> DoryMachineStatus { switch launchPolicy { case .legacyCompatibility: - let prepared = try prepareMachineStartWithLifecycle( + return try startLegacyMachine( id: id, - requiresAuthoritativeDefinition: false, - journalLifecycle: journalLifecycle + journalLifecycle: journalLifecycle, + expectedDurableIdentity: nil ) - let identity = try currentRuntimeIdentity(id: id) - let lifecycle = try journalLifecycle - ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity) - : nil - do { - if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } - let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil) - if let lifecycle, status.state != .starting { - _ = completeCommittedLifecycle( - lifecycle, - diagnostic: "running machine has an unfinished start journal" - ) - } - return status - } catch { - if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") } - throw error - } case .requireResolvedPlan: guard let registry = resolvedLaunchRegistry, let resolver = resolvedLaunchPlanResolver, @@ -1039,8 +1077,83 @@ public final class MachineManager: @unchecked Sendable { resolver: resolver, planStore: planStore, revisionProvider: revisionProvider, - journalLifecycle: journalLifecycle + journalLifecycle: journalLifecycle, + expectedRuntimeIdentity: nil ) + case .perWorkspaceAuthority: + let identity = try currentDurableRuntimeIdentity(id: id) + switch identity.mode { + case .legacyCompatibility: + return try startLegacyMachine( + id: id, + journalLifecycle: journalLifecycle, + expectedDurableIdentity: identity + ) + case .requiresReplanning: + throw MachineManagerError.persistence( + "machine \(id) requires a resolved plan before launch" + ) + case .resolvedPlan: + guard let registry = resolvedLaunchRegistry, + let resolver = resolvedLaunchPlanResolver, + let planStore = resolvedLaunchPlanStore, + let revisionProvider = resolvedPlanRevisionProvider else { + throw MachineManagerError.persistence( + "resolved launch infrastructure is not installed" + ) + } + return try startResolvedMachine( + id: id, + registry: registry, + resolver: resolver, + planStore: planStore, + revisionProvider: revisionProvider, + journalLifecycle: journalLifecycle, + expectedRuntimeIdentity: identity + ) + } + } + } + + private func startLegacyMachine( + id: String, + journalLifecycle: Bool, + expectedDurableIdentity: DoryMachineRuntimeIdentity? + ) throws -> DoryMachineStatus { + let prepared = try prepareMachineStartWithLifecycle( + id: id, + requiresAuthoritativeDefinition: false, + journalLifecycle: journalLifecycle + ) + let identity = try currentRuntimeIdentity(id: id) + guard identity.mode == .legacyCompatibility else { + throw MachineManagerError.persistence( + "legacy launch requires explicit compatibility authority" + ) + } + if let expectedDurableIdentity { + try revalidateDurableRuntimeIdentity( + id: id, + expected: expectedDurableIdentity, + expectedMachine: prepared.machine + ) + } + let lifecycle = try journalLifecycle + ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity) + : nil + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil) + if let lifecycle, status.state != .starting { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "running machine has an unfinished start journal" + ) + } + return status + } catch { + if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") } + throw error } } @@ -1050,7 +1163,8 @@ public final class MachineManager: @unchecked Sendable { resolver: any DoryDaemonVirtualMachineLaunchPlanResolving, planStore: any DoryResolvedMachinePlanStoring, revisionProvider: ResolvedPlanRevisionProvider, - journalLifecycle: Bool + journalLifecycle: Bool, + expectedRuntimeIdentity: DoryMachineRuntimeIdentity? ) throws -> DoryMachineStatus { let prepared = try prepareMachineStartWithLifecycle( id: id, @@ -1103,6 +1217,18 @@ public final class MachineManager: @unchecked Sendable { resolvedPlan: resolved.resolvedPlan, planSHA256: resolved.resolvedPlanSHA256 ) + if let expectedRuntimeIdentity, runtimeIdentity != expectedRuntimeIdentity { + throw MachineManagerError.persistence( + "resolved launch does not match the workspace's durable runtime authority" + ) + } + if let expectedRuntimeIdentity { + try revalidateDurableRuntimeIdentity( + id: id, + expected: expectedRuntimeIdentity, + expectedMachine: prepared.machine + ) + } let lifecycle = try journalLifecycle ? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity) : nil @@ -1891,6 +2017,21 @@ public final class MachineManager: @unchecked Sendable { guard updated != current else { return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } + if launchPolicy == .perWorkspaceAuthority { + if wasRunning { + _ = try stopImplementation(id: id, journalLifecycle: true) + } + if current.bootMode == .efi, + current.installerISOPath != nil, + updated.installerISOPath == nil { + try ensureInstalledLinuxBootBundleIfNeeded(updated) + } + try persist(updated) + try publishConfiguration(updated) + // Every desired-state mutation invalidates the former plan. The workspace remains + // stopped until planning publishes a replacement exact runtime identity. + return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) + } let requiresRestart = updated.memoryMB != current.memoryMB || updated.cpuCount != current.cpuCount || updated.shares != current.shares @@ -1975,9 +2116,7 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.invalidID(snapshotID) } let (machine, wasRunning) = try configurationAndRunningState(id: id) - guard let snapshotRuntimeIdentity = runtimeIdentity(id: id) else { - throw MachineManagerError.unknownMachine(id) - } + let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id) try Self.validateLaunchConfiguration(machine) try ensurePrivateSnapshotDirectory(machineID: id) let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID) @@ -2399,7 +2538,7 @@ public final class MachineManager: @unchecked Sendable { installedDesktopPayloadReceipt: Self.configurationReceipt(restoring: snapshot) ) - _ = try create(machine) + let created = try create(machine) do { if snapshot.bootMode == .efi { guard let snapshotNVRAMPath = snapshot.nvramPath else { @@ -2412,6 +2551,11 @@ public final class MachineManager: @unchecked Sendable { destination: machineFirmwareNVRAMPath(id: newID) ) } + if launchPolicy == .perWorkspaceAuthority { + // A clone has new mutable storage and a new machine identity. Its source runtime + // evidence remains useful history, but can never authorize the clone's launch. + return status(id: newID) ?? created + } return try start(id: newID) } catch { do { @@ -2456,7 +2600,10 @@ public final class MachineManager: @unchecked Sendable { restoredMachine.installedDesktopPayloadReceipt = Self.configurationReceipt(restoring: snapshot) let sourceIdentity = try currentRuntimeIdentity(id: machineID) - let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity) + let targetIdentity = runtimeIdentityAfterSnapshotRestore( + snapshot.runtimeIdentity, + sourceIdentity: sourceIdentity + ) let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) let lifecycle = try beginLifecycleRestore( sourceMachine: machine, @@ -2464,7 +2611,8 @@ public final class MachineManager: @unchecked Sendable { sourceIdentity: sourceIdentity, targetIdentity: targetIdentity, sourceState: wasRunning ? .running : .stopped, - targetState: wasRunning && launchPolicy == .legacyCompatibility ? .running : .stopped, + targetState: wasRunning && shouldRestartAfterSnapshotRestore(targetIdentity) + ? .running : .stopped, snapshotID: snapshotID, snapshotAuthority: snapshotAuthority ) @@ -2479,6 +2627,10 @@ public final class MachineManager: @unchecked Sendable { operationID: lifecycle.operation.operationID ) { try persist(restoredMachine) + try persistRuntimeIdentity( + targetIdentity, + configuration: restoredMachine + ) lock.lock() if var entry = machines[machineID] { entry.configuration = restoredMachine @@ -2489,7 +2641,7 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() } let status: DoryMachineStatus - if wasRunning, launchPolicy == .legacyCompatibility { + if wasRunning, shouldRestartAfterSnapshotRestore(targetIdentity) { status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false) } else { status = self.status(id: machineID) @@ -2616,6 +2768,13 @@ public final class MachineManager: @unchecked Sendable { } try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount) try Self.validateSnapshotRuntimeIdentity(snapshot) + if launchPolicy == .perWorkspaceAuthority { + snapshot.runtimeIdentity = .requiresReplanning( + virtualHardwareABIVersion: + snapshot.runtimeIdentity.virtualHardwareABIVersion, + reason: .importedSnapshot + ) + } snapshot.installedDesktopPayloadReceipt = snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt snapshot.address = nil @@ -3654,6 +3813,16 @@ public final class MachineManager: @unchecked Sendable { } private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws { + let identity = runtimeIdentityForUnplannedMachine(reason: .definitionChanged) + var identityPersistenceError: String? + do { + try persistRuntimeIdentity(identity, configuration: configuration) + } catch { + // machine.json is already the durable authority at every call site. Never leave the + // in-memory configuration behind that commit; a missing/stale companion fails closed + // to requires-replanning on the next restart. + identityPersistenceError = "runtime identity publication requires recovery: \(error)" + } lock.lock() defer { lock.unlock() } guard var entry = machines[configuration.id] else { @@ -3661,9 +3830,8 @@ public final class MachineManager: @unchecked Sendable { } entry.configuration = configuration entry.currentBalloonTargetMB = nil - entry.runtimeIdentity = runtimeIdentityForUnplannedMachine( - reason: .definitionChanged - ) + entry.runtimeIdentity = identity + if let identityPersistenceError { entry.lastError = identityPersistenceError } machines[configuration.id] = entry } @@ -3676,7 +3844,7 @@ public final class MachineManager: @unchecked Sendable { virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion ) - case .requireResolvedPlan: + case .requireResolvedPlan, .perWorkspaceAuthority: return .requiresReplanning( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, @@ -3686,7 +3854,8 @@ public final class MachineManager: @unchecked Sendable { } private func runtimeIdentityAfterSnapshotRestore( - _ snapshotIdentity: DoryMachineRuntimeIdentity + _ snapshotIdentity: DoryMachineRuntimeIdentity, + sourceIdentity: DoryMachineRuntimeIdentity ) -> DoryMachineRuntimeIdentity { switch launchPolicy { case .legacyCompatibility: @@ -3698,6 +3867,30 @@ public final class MachineManager: @unchecked Sendable { virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion, reason: .restoredSnapshot ) + case .perWorkspaceAuthority: + if sourceIdentity.mode == .legacyCompatibility, + snapshotIdentity.mode == .legacyCompatibility { + return .legacyCompatibility( + virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion + ) + } + return .requiresReplanning( + virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion, + reason: .restoredSnapshot + ) + } + } + + private func shouldRestartAfterSnapshotRestore( + _ targetIdentity: DoryMachineRuntimeIdentity + ) -> Bool { + switch launchPolicy { + case .legacyCompatibility: + return true + case .requireResolvedPlan: + return false + case .perWorkspaceAuthority: + return targetIdentity.mode == .legacyCompatibility } } @@ -3708,45 +3901,130 @@ public final class MachineManager: @unchecked Sendable { plans: any DoryResolvedMachinePlanStoring, expectedPlanRevision: ResolvedPlanRevisionProvider ) { - let entries: [(String, DoryMachineConfiguration)] = { + let entries: [(String, DoryMachineConfiguration, DoryMachineRuntimeIdentity)] = { lock.lock() defer { lock.unlock() } - return machines.map { ($0.key, $0.value.configuration) } + return machines.map { + ($0.key, $0.value.configuration, $0.value.runtimeIdentity) + } }() - for (id, machine) in entries { - var recovered = DoryMachineRuntimeIdentity.requiresReplanning( + for (id, machine, existingIdentity) in entries { + if launchPolicy == .perWorkspaceAuthority { + switch existingIdentity.mode { + case .legacyCompatibility, .requiresReplanning: + // Only an acquired planning mutation fence may cross either authority + // boundary. An old definition-exact plan cannot cover a restored/imported or + // otherwise changed mutable disk. + continue + case .resolvedPlan: + break + } + } + var recovered = exactResolvedRuntimeIdentity( + machine: machine, + plans: plans, + expectedPlanRevision: expectedPlanRevision + ) ?? .requiresReplanning( virtualHardwareABIVersion: - DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + existingIdentity.virtualHardwareABIVersion, reason: .planRecoveryFailed ) - if let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)), - let definition = reconcileWorkspaceProjection( - machine: machine, - authoritativeLegacyData: legacyData - ), - let canonicalData = try? Self.canonicalDefinitionData(definition), - let plan = try? plans.read(id: id), - expectedPlanRevision(id) == plan.planRevision, - plan.machineID == id, - plan.definitionRevision == definition.lifecycle.revision, - plan.definitionSHA256 == Self.sha256(data: canonicalData), - plan.migrationDisposition == .current, - plan.validate().isEmpty, - let identity = try? DoryMachineRuntimeIdentity( - resolvedPlan: plan, - planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan) - ) { - recovered = identity + if launchPolicy == .perWorkspaceAuthority, + recovered != existingIdentity { + recovered = .requiresReplanning( + virtualHardwareABIVersion: existingIdentity.virtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + } + var persistenceError: String? + var effectiveIdentity = recovered + if launchPolicy == .perWorkspaceAuthority { + do { + if recovered != existingIdentity { + try persistRuntimeIdentity(recovered, configuration: machine) + } + } + catch { + persistenceError = "runtime identity recovery could not be persisted: \(error)" + effectiveIdentity = .requiresReplanning( + virtualHardwareABIVersion: recovered.virtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + } } lock.lock() if var entry = machines[id] { - entry.runtimeIdentity = recovered + entry.runtimeIdentity = effectiveIdentity + if let persistenceError { entry.lastError = persistenceError } machines[id] = entry } lock.unlock() } } + private func exactResolvedRuntimeIdentity( + machine: DoryMachineConfiguration, + plans: any DoryResolvedMachinePlanStoring, + expectedPlanRevision: ResolvedPlanRevisionProvider + ) -> DoryMachineRuntimeIdentity? { + guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)), + let definition = reconcileWorkspaceProjection( + machine: machine, + authoritativeLegacyData: legacyData + ), + let canonicalData = try? Self.canonicalDefinitionData(definition), + let plan = try? plans.read(id: machine.id), + expectedPlanRevision(machine.id) == plan.planRevision, + plan.machineID == machine.id, + plan.definitionRevision == definition.lifecycle.revision, + plan.definitionSHA256 == Self.sha256(data: canonicalData), + plan.migrationDisposition == .current, + plan.validate().isEmpty else { + return nil + } + return try? DoryMachineRuntimeIdentity( + resolvedPlan: plan, + planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan) + ) + } + + private func completePlanningRuntimeIdentity(machineID: String) throws { + // Recovery completes planning before production activation installs the launch graph. + // Both planning and start deliberately use the canonical manager state root, so reading + // the exact durable plan here does not invent evidence or select a backend. + let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore + ?? DoryResolvedMachinePlanRepository(root: configuration.stateDirectory) + let expectedPlanRevision: ResolvedPlanRevisionProvider = + resolvedPlanRevisionProvider ?? { machineID in + try? plans.read(id: machineID).planRevision + } + lock.lock() + let machine = machines[machineID]?.configuration + lock.unlock() + guard let machine, + let identity = exactResolvedRuntimeIdentity( + machine: machine, + plans: plans, + expectedPlanRevision: expectedPlanRevision + ) else { + throw MachineManagerError.persistence( + "published plan does not match current workspace authority" + ) + } + try persistRuntimeIdentity(identity, configuration: machine) + lock.lock() + guard var entry = machines[machineID], entry.configuration == machine else { + lock.unlock() + throw MachineManagerError.persistence( + "machine authority changed while completing planning" + ) + } + entry.runtimeIdentity = identity + entry.lastError = nil + machines[machineID] = entry + lock.unlock() + } + private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration { let rootfsDestination = machineRootfsPath(id: machine.id) let kernelDestination = machineKernelPath(id: machine.id) @@ -4061,6 +4339,36 @@ public final class MachineManager: @unchecked Sendable { } } + private func persistRuntimeIdentity( + _ identity: DoryMachineRuntimeIdentity, + configuration machine: DoryMachineConfiguration + ) throws { + guard identity.validate().isEmpty, + let authoritativeLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: machine.id) + ), + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: authoritativeLegacyData + ), + decoded == machine else { + throw MachineManagerError.persistence( + "runtime identity does not match authoritative machine metadata" + ) + } + do { + try runtimeIdentityStore.publish( + identity, + machineID: machine.id, + authoritativeLegacyData: authoritativeLegacyData + ) + } catch { + throw MachineManagerError.persistence( + "could not publish runtime identity for \(machine.id): \(error)" + ) + } + } + private func reconcileLoadedWorkspaceProjections() { let configurations = machines.values.map(\.configuration) .sorted { $0.id < $1.id } @@ -4450,18 +4758,21 @@ public final class MachineManager: @unchecked Sendable { try Self.validateSnapshotRuntimeIdentity(snapshot) switch snapshot.runtimeIdentity.mode { case .legacyCompatibility: - guard launchPolicy == .legacyCompatibility else { + guard launchPolicy == .legacyCompatibility + || launchPolicy == .perWorkspaceAuthority else { throw MachineManagerError.persistence( "legacy snapshot requires explicit legacy compatibility launch policy" ) } case .resolvedPlan: - guard !cloning else { + guard !cloning || launchPolicy == .perWorkspaceAuthority else { throw MachineManagerError.persistence( "resolved snapshot identity is machine-bound and must be replanned before cloning" ) } - guard launchPolicy == .requireResolvedPlan, + if cloning, launchPolicy == .perWorkspaceAuthority { return } + guard launchPolicy == .requireResolvedPlan + || launchPolicy == .perWorkspaceAuthority, let plan = snapshot.runtimeIdentity.resolvedPlan, let registry = resolvedLaunchRegistry, let planStore = resolvedLaunchPlanStore, @@ -4490,7 +4801,8 @@ public final class MachineManager: @unchecked Sendable { ) } case .requiresReplanning: - guard launchPolicy == .requireResolvedPlan, !cloning else { + guard (launchPolicy == .requireResolvedPlan && !cloning) + || launchPolicy == .perWorkspaceAuthority else { throw MachineManagerError.persistence( "snapshot requires a new resolved plan before it can launch" ) @@ -5080,7 +5392,8 @@ public final class MachineManager: @unchecked Sendable { private static func loadPersistedMachines( configuration: MachineManagerConfiguration, - launchPolicy: DoryMachineLaunchPolicy + launchPolicy: DoryMachineLaunchPolicy, + runtimeIdentityStore: DoryMachineRuntimeIdentityStore ) -> [String: MachineEntry] { let root = configuration.stateDirectory guard let ids = try? FileManager.default.contentsOfDirectory(atPath: root) else { @@ -5124,6 +5437,12 @@ public final class MachineManager: @unchecked Sendable { DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, reason: .planRecoveryFailed ) + case .perWorkspaceAuthority: + loadOrMigratePerWorkspaceRuntimeIdentity( + machine: machine, + authoritativeLegacyData: data, + store: runtimeIdentityStore + ) } loaded[id] = MachineEntry( configuration: machine, @@ -5134,18 +5453,258 @@ public final class MachineManager: @unchecked Sendable { return loaded } + private static func removeInterruptedNativeCreations( + stateDirectory: String + ) { + guard let ids = try? FileManager.default.contentsOfDirectory( + atPath: stateDirectory + ) else { return } + let allowed = Set([ + "rootfs.ext4", + "kernel", + "installer.iso", + Self.nativeCreationPrecommitMarkerName, + DoryMachineRuntimeIdentityStore.recordFileName, + DoryMachineRuntimeIdentityStore.headFileName, + ]) + for id in ids where Self.isValidID(id) { + let directory = stateDirectory + "/" + id + let markerPath = directory + "/" + Self.nativeCreationPrecommitMarkerName + guard Self.isPrivateDirectory(path: directory), + !Self.pathEntryExists(directory + "/machine.json"), + let entries = try? FileManager.default.contentsOfDirectory( + atPath: directory + ) else { + continue + } + let expectedMarker = Self.nativeCreationPrecommitMarkerData(machineID: id) + let isMarkerInitializationCrash = entries.isEmpty + || (entries.count == 1 + && entries[0] == Self.nativeCreationPrecommitMarkerName + && Self.isPrivatePartialNativeCreationMarker( + path: markerPath, + expectedByteCount: expectedMarker.count + )) + let containsOnlyCreationEntries = entries.allSatisfy { entry in + allowed.contains(entry) + || entry.hasPrefix( + DoryMachineRuntimeIdentityStore.recordTemporaryPrefix + ) + || entry.hasPrefix( + DoryMachineRuntimeIdentityStore.headTemporaryPrefix + ) + } + let containsOnlyPrivateRuntimeTemporaries = entries.filter { entry in + entry.hasPrefix( + DoryMachineRuntimeIdentityStore.recordTemporaryPrefix + ) || entry.hasPrefix( + DoryMachineRuntimeIdentityStore.headTemporaryPrefix + ) + }.allSatisfy { entry in + Self.isPrivateRegularFile(path: directory + "/" + entry) + } + let hasValidManagedArtifacts = + (!entries.contains("rootfs.ext4") + || Self.isPrivateRegularFile(path: directory + "/rootfs.ext4")) + && (!entries.contains("kernel") + || Self.isPrivateRegularFile(path: directory + "/kernel")) + && (!entries.contains("installer.iso") + || Self.isPrivateRegularFile(path: directory + "/installer.iso")) + let isAuthenticatedInterruptedCreation = + Self.readPrivateMetadata(path: markerPath) == expectedMarker + && containsOnlyCreationEntries + && containsOnlyPrivateRuntimeTemporaries + && hasValidManagedArtifacts + guard isMarkerInitializationCrash || isAuthenticatedInterruptedCreation else { + continue + } + let quarantine = stateDirectory + "/" + + Self.interruptedNativeCreationQuarantinePrefix + + id + "-" + UUID().uuidString.lowercased() + guard rename(directory, quarantine) == 0 else { continue } + try? FileManager.default.removeItem(atPath: quarantine) + } + } + + private static func isPrivatePartialNativeCreationMarker( + path: String, + expectedByteCount: Int + ) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFREG + && info.st_uid == getuid() + && info.st_nlink == 1 + && (info.st_mode & 0o077) == 0 + && info.st_size >= 0 + && info.st_size < Int64(expectedByteCount) + } + + private static func recoverCompletedNativeCreationMarkers( + stateDirectory: String, + runtimeIdentityStore: DoryMachineRuntimeIdentityStore + ) { + guard let ids = try? FileManager.default.contentsOfDirectory( + atPath: stateDirectory + ) else { return } + for id in ids where Self.isValidID(id) { + let directory = stateDirectory + "/" + id + let preparing = directory + "/" + Self.nativeCreationPrecommitMarkerName + let committed = directory + "/" + Self.nativeCreationCommittedMarkerName + guard Self.isPrivateDirectory(path: directory), + !Self.pathEntryExists(committed), + Self.readPrivateMetadata(path: preparing) + == Self.nativeCreationPrecommitMarkerData(machineID: id), + let legacyData = Self.readPrivateMetadata( + path: directory + "/machine.json" + ), + let machine = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ), machine.id == id, + (try? runtimeIdentityStore.readIfPresent( + machineID: id, + authoritativeLegacyData: legacyData + )) != nil else { + continue + } + guard rename(preparing, committed) == 0 else { continue } + try? Self.syncDirectory(path: directory) + } + } + + private static func nativeCreationPrecommitMarkerData(machineID: String) -> Data { + Data("DORY-NATIVE-CREATE-PRECOMMIT-V1:\(machineID)\n".utf8) + } + + private static func loadOrMigratePerWorkspaceRuntimeIdentity( + machine: DoryMachineConfiguration, + authoritativeLegacyData: Data, + store: DoryMachineRuntimeIdentityStore + ) -> DoryMachineRuntimeIdentity { + do { + if let persisted = try store.readIfPresent( + machineID: machine.id, + authoritativeLegacyData: authoritativeLegacyData + ) { + return persisted + } + let machineDirectory = store.root + "/" + machine.id + if Self.readPrivateMetadata( + path: machineDirectory + "/" + Self.nativeCreationCommittedMarkerName + ) == Self.nativeCreationPrecommitMarkerData(machineID: machine.id) { + let unresolved = DoryMachineRuntimeIdentity.requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + try store.publish( + unresolved, + machineID: machine.id, + authoritativeLegacyData: authoritativeLegacyData + ) + return unresolved + } + if Self.pathEntryExists( + machineDirectory + "/" + DoryResolvedMachinePlanRepository.recordFileName + ) || Self.pathEntryExists( + machineDirectory + "/planning-transaction-v1.json" + ) { + let unresolved = DoryMachineRuntimeIdentity.requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + try store.publish( + unresolved, + machineID: machine.id, + authoritativeLegacyData: authoritativeLegacyData + ) + return unresolved + } + // Absence is the one compatibility migration case: this exact machine predates the + // per-workspace authority record. Persist the migration decision before allowing a + // compatibility launch; malformed or stale records never receive this fallback. + let migrated = DoryMachineRuntimeIdentity.legacyCompatibility( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + ) + try store.publish( + migrated, + machineID: machine.id, + authoritativeLegacyData: authoritativeLegacyData + ) + return migrated + } catch { + return .requiresReplanning( + virtualHardwareABIVersion: + DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion, + reason: .planRecoveryFailed + ) + } + } + private func currentRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity { lock.lock() - defer { lock.unlock() } - guard let identity = machines[id]?.runtimeIdentity else { + guard let entry = machines[id] else { + lock.unlock() throw MachineManagerError.unknownMachine(id) } + let identity = entry.runtimeIdentity + let machine = entry.configuration + lock.unlock() guard identity.validate().isEmpty else { throw MachineManagerError.persistence("machine runtime identity is invalid") } + if launchPolicy == .perWorkspaceAuthority { + try revalidateDurableRuntimeIdentity( + id: id, + expected: identity, + expectedMachine: machine + ) + } return identity } + private func currentDurableRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity { + try currentRuntimeIdentity(id: id) + } + + private func revalidateDurableRuntimeIdentity( + id: String, + expected: DoryMachineRuntimeIdentity, + expectedMachine: DoryMachineConfiguration + ) throws { + guard expected.validate().isEmpty, + let authoritativeLegacyData = Self.readPrivateMetadata( + path: machineConfigPath(id: id) + ), + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: authoritativeLegacyData + ), decoded == expectedMachine else { + throw MachineManagerError.persistence( + "durable machine authority changed before launch" + ) + } + let durable: DoryMachineRuntimeIdentity? + do { + durable = try runtimeIdentityStore.readIfPresent( + machineID: id, + authoritativeLegacyData: authoritativeLegacyData + ) + } catch { + throw MachineManagerError.persistence( + "durable runtime identity is unavailable before launch: \(error)" + ) + } + guard durable == expected else { + throw MachineManagerError.persistence( + "durable runtime identity changed before launch" + ) + } + } + /// Acquires the same per-workspace cross-process mutation lock used by lifecycle operations, /// then derives planning authority only from the exact persisted legacy bytes and migration /// facts observed under that lock. This path never starts a helper or mutates guest state. @@ -5157,7 +5716,8 @@ public final class MachineManager: @unchecked Sendable { operationLock.lock() defer { operationLock.unlock() } - guard launchPolicy == .requireResolvedPlan else { + guard launchPolicy == .requireResolvedPlan + || launchPolicy == .perWorkspaceAuthority else { throw MachineManagerError.persistence( "planning promotion requires the resolved-plan launch policy" ) @@ -5300,7 +5860,15 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentitySHA256: authority.runtimeIdentitySHA256 ) }, - completion: { retention.release() }, + completion: { [weak self] in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + if self.launchPolicy == .perWorkspaceAuthority { + try self.completePlanningRuntimeIdentity(machineID: machine.id) + } + retention.release() + }, recoveryRelease: { retention.release() } ) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift new file mode 100644 index 00000000..f76ea052 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +@testable import DorydKit + +@Suite("Per-workspace runtime identity store", .serialized) +struct DoryMachineRuntimeIdentityStoreTests { + @Test("identity authority is monotonic across A to B to A configuration history") + func rejectsRolledBackIdentityRecord() throws { + try withStore("rollback") { store, directory in + let dataA = Data("configuration-a".utf8) + let dataB = Data("configuration-b".utf8) + try store.publish( + .legacyCompatibility(virtualHardwareABIVersion: 1), + machineID: "dev", + authoritativeLegacyData: dataA + ) + let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName + let oldRecord = try Data(contentsOf: URL(fileURLWithPath: recordPath)) + + try store.publish( + .requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .definitionChanged + ), + machineID: "dev", + authoritativeLegacyData: dataB + ) + try store.publish( + .requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .definitionChanged + ), + machineID: "dev", + authoritativeLegacyData: dataA + ) + + try oldRecord.write(to: URL(fileURLWithPath: recordPath), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: recordPath + ) + #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) { + _ = try store.readIfPresent( + machineID: "dev", + authoritativeLegacyData: dataA + ) + } + } + } + + @Test("missing or corrupt identity beside a durable head never becomes absent legacy state") + func partialAuthorityFailsClosed() throws { + try withStore("missing") { store, directory in + let legacyData = Data("configuration".utf8) + try store.publish( + .requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .planNotInstalled + ), + machineID: "dev", + authoritativeLegacyData: legacyData + ) + let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName + try FileManager.default.removeItem(atPath: recordPath) + #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) { + _ = try store.readIfPresent( + machineID: "dev", + authoritativeLegacyData: legacyData + ) + } + } + try withStore("corrupt") { store, directory in + let legacyData = Data("configuration".utf8) + try store.publish( + .legacyCompatibility(virtualHardwareABIVersion: 1), + machineID: "dev", + authoritativeLegacyData: legacyData + ) + let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName + try Data("not-json".utf8).write( + to: URL(fileURLWithPath: recordPath), + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: recordPath + ) + #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) { + _ = try store.readIfPresent( + machineID: "dev", + authoritativeLegacyData: legacyData + ) + } + } + } + + @Test("restart completes the one valid identity-before-head crash ordering") + func healsForwardPublication() throws { + try withStore("forward") { store, directory in + let dataA = Data("configuration-a".utf8) + let dataB = Data("configuration-b".utf8) + try store.publish( + .legacyCompatibility(virtualHardwareABIVersion: 1), + machineID: "dev", + authoritativeLegacyData: dataA + ) + let headPath = directory + "/" + DoryMachineRuntimeIdentityStore.headFileName + let oldHead = try Data(contentsOf: URL(fileURLWithPath: headPath)) + let expected = DoryMachineRuntimeIdentity.requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .definitionChanged + ) + try store.publish( + expected, + machineID: "dev", + authoritativeLegacyData: dataB + ) + try oldHead.write(to: URL(fileURLWithPath: headPath), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: headPath + ) + + #expect(try store.readIfPresent( + machineID: "dev", + authoritativeLegacyData: dataB + ) == expected) + #expect(try store.readIfPresent( + machineID: "dev", + authoritativeLegacyData: dataB + ) == expected) + } + } + + private func withStore( + _ label: String, + _ body: (DoryMachineRuntimeIdentityStore, String) throws -> Void + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-runtime-identity-\(label)-\(UUID().uuidString)") + .path + let directory = root + "/dev" + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + defer { try? FileManager.default.removeItem(atPath: root) } + try body(DoryMachineRuntimeIdentityStore(root: root), directory) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 564dd2eb..cc0ebf62 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -509,6 +509,548 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("per-workspace policy blocks new machines until planning") + func perWorkspaceNewMachineRequiresPlanning() throws { + try withHarness("per-workspace-new", launchPolicy: .perWorkspaceAuthority) { + manager, starter, _ in + let status = try #require(manager.status(id: "dev")) + #expect(status.runtimeIdentity.mode == .requiresReplanning) + #expect(status.runtimeIdentity.invalidationReason == .planNotInstalled) + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + } + } + + @Test("native creation authority is durable before machine metadata becomes discoverable") + func nativeCreationCrashOrderingNeverMintsLegacy() throws { + let state = try makeState("native-create-order") + defer { try? FileManager.default.removeItem(atPath: state) } + let directory = state + "/native" + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let rootfs = directory + "/rootfs.ext4" + let kernel = directory + "/kernel" + try FileManager.default.copyItem(atPath: doryTestRootfsPath, toPath: rootfs) + try FileManager.default.copyItem(atPath: doryTestKernelPath, toPath: kernel) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: rootfs + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: kernel + ) + let machine = DoryMachineConfiguration( + id: "native", + kernelPath: kernel, + rootfsPath: rootfs, + memoryMB: 2_048, + cpuCount: 2, + displayMode: .desktop + ) + let legacyData = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine) + let expected = DoryMachineRuntimeIdentity.requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .planNotInstalled + ) + let markerPath = directory + "/.dory-native-create-precommit-v1" + try Data("DORY-NATIVE-CREATE-PRECOMMIT-V1:native\n".utf8).write( + to: URL(fileURLWithPath: markerPath), + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: markerPath + ) + try DoryMachineRuntimeIdentityStore(root: state).publish( + expected, + machineID: "native", + authoritativeLegacyData: legacyData + ) + let interruptedStoreTemporary = directory + + "/.runtime-identity-v1.tmp-interrupted" + try Data("partial".utf8).write( + to: URL(fileURLWithPath: interruptedStoreTemporary), + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: interruptedStoreTemporary + ) + + // Crash before machine.json publication: startup does not discover a machine at all. + let recovered = makeManager( + state: state, + policy: .perWorkspaceAuthority + ) + #expect(recovered.status(id: "native") == nil) + #expect(!FileManager.default.fileExists(atPath: directory)) + let retried = try createMachine(id: "native", manager: recovered) + #expect(retried.runtimeIdentity == expected) + + let completedRootfs = state + "/native/rootfs.ext4" + let completedKernel = state + "/native/kernel" + let committedMarker = state + + "/native/.dory-native-create-committed-v1" + let preparingMarker = state + + "/native/.dory-native-create-precommit-v1" + #expect(FileManager.default.fileExists(atPath: committedMarker)) + // Simulate a crash after durable machine.json but before marker transition. Restart + // completes the exact authority rather than deleting or widening it. + try FileManager.default.moveItem( + atPath: committedMarker, + toPath: preparingMarker + ) + let markerRecovered = makeManager( + state: state, + policy: .perWorkspaceAuthority + ) + #expect(markerRecovered.status(id: "native")?.runtimeIdentity == expected) + #expect(FileManager.default.fileExists(atPath: committedMarker)) + #expect(!FileManager.default.fileExists(atPath: preparingMarker)) + + try FileManager.default.removeItem(atPath: state + "/native/machine.json") + let metadataLost = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(metadataLost.status(id: "native") == nil) + #expect(FileManager.default.fileExists(atPath: completedRootfs)) + #expect(FileManager.default.fileExists(atPath: completedKernel)) + #expect(FileManager.default.fileExists(atPath: state + "/native")) + } + + @Test("native creation recovers crashes before its durable marker is complete") + func nativeCreationInitialMarkerCrashCanRetry() throws { + for shape in ["empty-directory", "partial-marker"] { + let state = try makeState("native-create-initial-\(shape)") + defer { try? FileManager.default.removeItem(atPath: state) } + let directory = state + "/native" + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + if shape == "partial-marker" { + let marker = directory + "/.dory-native-create-precommit-v1" + try Data("DORY-NATIVE-CREATE".utf8).write( + to: URL(fileURLWithPath: marker) + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: marker + ) + } + + let recovered = makeManager( + state: state, + policy: .perWorkspaceAuthority + ) + #expect(recovered.status(id: "native") == nil) + #expect(!FileManager.default.fileExists(atPath: directory)) + let retried = try createMachine(id: "native", manager: recovered) + #expect(retried.runtimeIdentity.mode == .requiresReplanning) + #expect(retried.runtimeIdentity.invalidationReason == .planNotInstalled) + } + } + + @Test("legacy compatibility migration is exact-byte bound and cannot be widened") + func perWorkspaceLegacyMigrationRejectsRawByteDrift() throws { + let state = try makeState("legacy-byte-authority") + defer { try? FileManager.default.removeItem(atPath: state) } + let legacy = makeManager(state: state, policy: .legacyCompatibility) + _ = try createMachine(id: "legacy", manager: legacy) + + let migrated = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(migrated.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility) + let path = state + "/legacy/machine.json" + var bytes = try Data(contentsOf: URL(fileURLWithPath: path)) + bytes.append(0x0A) + try bytes.write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + + let changed = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(changed.status(id: "legacy")?.runtimeIdentity.mode == .requiresReplanning) + #expect(changed.status(id: "legacy")?.runtimeIdentity.invalidationReason == .planRecoveryFailed) + } + + @Test("per-workspace policy dispatches legacy and resolved identities independently") + func perWorkspaceMixedAuthorityAndRestart() throws { + let state = try makeState("per-workspace-mixed") + defer { try? FileManager.default.removeItem(atPath: state) } + let legacyManager = makeManager(state: state, policy: .legacyCompatibility) + _ = try createMachine(id: "legacy", manager: legacyManager) + + let firstPerWorkspace = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(firstPerWorkspace.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility) + _ = try createMachine(id: "planned", manager: firstPerWorkspace) + #expect(firstPerWorkspace.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning) + + let plans = MutablePlanStore() + let plannedIdentity = try persistResolvedIdentity( + machineID: "planned", + state: state, + plans: plans + ) + #expect(plannedIdentity.mode == .resolvedPlan) + + let starter = CountingProcessStarter() + let restarted = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: starter + ) + let operations = restarted.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let resolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try restarted.installResolvedLaunchInfrastructure( + registry: rawRegistry(operations: operations), + resolver: resolver, + plans: plans, + expectedPlanRevision: { $0 == "planned" ? 1 : nil } + ) + #expect(restarted.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility) + #expect(restarted.status(id: "planned")?.runtimeIdentity == plannedIdentity) + + #expect(try restarted.start(id: "legacy").state == .running) + _ = try restarted.stop(id: "legacy") + #expect(try restarted.start(id: "planned").state == .running) + #expect(resolver.callCount == 1) + #expect(starter.count == 2) + _ = try restarted.stop(id: "planned") + + let secondRestart = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(secondRestart.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility) + #expect(secondRestart.status(id: "planned")?.runtimeIdentity == plannedIdentity) + } + + @Test("missing or changed resolved authority never falls back to legacy") + func perWorkspaceResolvedAuthorityFailsClosed() throws { + let state = try makeState("per-workspace-no-fallback") + defer { try? FileManager.default.removeItem(atPath: state) } + let bootstrap = makeManager(state: state, policy: .perWorkspaceAuthority) + _ = try createMachine(id: "planned", manager: bootstrap) + let plans = MutablePlanStore() + _ = try persistResolvedIdentity(machineID: "planned", state: state, plans: plans) + + let withoutInfrastructureStarter = CountingProcessStarter() + let withoutInfrastructure = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: withoutInfrastructureStarter + ) + #expect(withoutInfrastructure.status(id: "planned")?.runtimeIdentity.mode == .resolvedPlan) + #expect(throws: MachineManagerError.self) { + _ = try withoutInfrastructure.start(id: "planned") + } + #expect(withoutInfrastructureStarter.count == 0) + + plans.set(nil) + let missingPlanStarter = CountingProcessStarter() + let missingPlan = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: missingPlanStarter + ) + let resolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try missingPlan.installResolvedLaunchInfrastructure( + registry: rawRegistry( + operations: missingPlan.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + ), + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + #expect(missingPlan.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning) + #expect(throws: MachineManagerError.self) { + _ = try missingPlan.start(id: "planned") + } + #expect(resolver.callCount == 0) + #expect(missingPlanStarter.count == 0) + + let recordPath = state + "/planned/" + + DoryMachineRuntimeIdentityStore.recordFileName + try FileManager.default.removeItem(atPath: recordPath) + let missingCompanionStarter = CountingProcessStarter() + let missingCompanion = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: missingCompanionStarter + ) + #expect(missingCompanion.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning) + #expect(throws: MachineManagerError.self) { + _ = try missingCompanion.start(id: "planned") + } + #expect(missingCompanionStarter.count == 0) + } + + @Test("live durable identity deletion or substitution rejects launch and snapshot") + func perWorkspaceLiveIdentityTamperFailsClosed() throws { + let legacyState = try makeState("live-legacy-tamper") + defer { try? FileManager.default.removeItem(atPath: legacyState) } + let legacyBootstrap = makeManager( + state: legacyState, + policy: .legacyCompatibility + ) + _ = try createMachine(id: "legacy", manager: legacyBootstrap) + let legacyStarter = CountingProcessStarter() + let legacy = makeManager( + state: legacyState, + policy: .perWorkspaceAuthority, + starter: legacyStarter + ) + try FileManager.default.removeItem( + atPath: legacyState + "/legacy/" + + DoryMachineRuntimeIdentityStore.recordFileName + ) + #expect(throws: MachineManagerError.self) { + _ = try legacy.start(id: "legacy") + } + #expect(throws: MachineManagerError.self) { + _ = try legacy.snapshot(id: "legacy", snapshotID: "must-not-publish") + } + #expect(legacyStarter.count == 0) + #expect(!FileManager.default.fileExists( + atPath: legacyState + "/legacy/snapshots/must-not-publish.json" + )) + + let resolvedState = try makeState("live-resolved-tamper") + defer { try? FileManager.default.removeItem(atPath: resolvedState) } + let bootstrap = makeManager( + state: resolvedState, + policy: .perWorkspaceAuthority + ) + _ = try createMachine(id: "planned", manager: bootstrap) + let plans = MutablePlanStore() + _ = try persistResolvedIdentity( + machineID: "planned", + state: resolvedState, + plans: plans + ) + let resolvedStarter = CountingProcessStarter() + let resolved = makeManager( + state: resolvedState, + policy: .perWorkspaceAuthority, + starter: resolvedStarter + ) + let resolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try resolved.installResolvedLaunchInfrastructure( + registry: rawRegistry( + operations: resolved.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + ), + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + let machineData = try Data(contentsOf: URL( + fileURLWithPath: resolvedState + "/planned/machine.json" + )) + try DoryMachineRuntimeIdentityStore(root: resolvedState).publish( + .legacyCompatibility(virtualHardwareABIVersion: 1), + machineID: "planned", + authoritativeLegacyData: machineData + ) + #expect(throws: MachineManagerError.self) { + _ = try resolved.start(id: "planned") + } + #expect(resolver.callCount == 0) + #expect(resolvedStarter.count == 0) + } + + @Test("restoring historical legacy snapshot cannot revive compatibility after planning") + func resolvedWorkspaceRestoreOfLegacySnapshotRequiresReplanning() throws { + let state = try makeState("resolved-restore-legacy") + defer { try? FileManager.default.removeItem(atPath: state) } + let legacy = makeManager(state: state, policy: .legacyCompatibility) + _ = try createMachine(id: "dev", manager: legacy) + _ = try legacy.snapshot(id: "dev", snapshotID: "historical") + + let migrated = makeManager(state: state, policy: .perWorkspaceAuthority) + #expect(migrated.status(id: "dev")?.runtimeIdentity.mode == .legacyCompatibility) + let plans = MutablePlanStore() + _ = try persistResolvedIdentity(machineID: "dev", state: state, plans: plans) + let starter = CountingProcessStarter() + let resolved = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: starter + ) + let resolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try resolved.installResolvedLaunchInfrastructure( + registry: rawRegistry( + operations: resolved.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + ), + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + #expect(resolved.status(id: "dev")?.runtimeIdentity.mode == .resolvedPlan) + + let restored = try resolved.restoreSnapshot( + machineID: "dev", + snapshotID: "historical" + ) + #expect(restored.state == .stopped) + #expect(restored.runtimeIdentity.mode == .requiresReplanning) + #expect(restored.runtimeIdentity.invalidationReason == .restoredSnapshot) + #expect(throws: MachineManagerError.self) { + _ = try resolved.start(id: "dev") + } + #expect(starter.count == 0) + + let restartedStarter = CountingProcessStarter() + let restarted = makeManager( + state: state, + policy: .perWorkspaceAuthority, + starter: restartedStarter + ) + let restartedResolver = ClosureLaunchResolver { request in + try exactResolution(request: request) + } + try restarted.installResolvedLaunchInfrastructure( + registry: rawRegistry( + operations: restarted.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + ), + resolver: restartedResolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .requiresReplanning) + #expect(throws: MachineManagerError.self) { + _ = try restarted.start(id: "dev") + } + #expect(restartedResolver.callCount == 0) + #expect(restartedStarter.count == 0) + } + + @Test("per-workspace clone and portable import require a fresh plan") + func perWorkspaceCloneAndImportInvalidateAuthority() throws { + let state = try makeState("per-workspace-copy") + defer { try? FileManager.default.removeItem(atPath: state) } + let legacy = makeManager(state: state, policy: .legacyCompatibility) + _ = try createMachine(id: "source", manager: legacy) + let sourceSnapshot = try legacy.snapshot(id: "source", snapshotID: "base") + let bundle = state + "/source.dorymachine" + try legacy.exportSnapshot( + machineID: "source", + snapshotID: sourceSnapshot.id, + toPath: bundle + ) + + let manager = makeManager(state: state, policy: .perWorkspaceAuthority) + let clone = try manager.cloneSnapshot( + machineID: "source", + snapshotID: "base", + newID: "clone" + ) + #expect(clone.state == .created) + #expect(clone.runtimeIdentity.mode == .requiresReplanning) + #expect(clone.runtimeIdentity.invalidationReason == .planNotInstalled) + + let imported = try manager.importSnapshot(fromPath: bundle) + #expect(imported.runtimeIdentity.mode == .requiresReplanning) + #expect(imported.runtimeIdentity.invalidationReason == .importedSnapshot) + } + + private func makeState(_ label: String) throws -> String { + let state = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-authority-\(label)-\(UUID().uuidString)") + .path + try FileManager.default.createDirectory( + atPath: state, + withIntermediateDirectories: true + ) + return state + } + + private func makeManager( + state: String, + policy: DoryMachineLaunchPolicy, + starter: CountingProcessStarter = CountingProcessStarter() + ) -> MachineManager { + MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + acceleratedDesktopExecutablePath: "/bin/sleep", + stateDirectory: state, + baseArguments: ["30"], + acceleratedDesktopBaseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + launchPolicy: policy, + processStarter: { process in try starter.start(process) } + ) + } + + @discardableResult + private func createMachine(id: String, manager: MachineManager) throws -> DoryMachineStatus { + try manager.create(DoryMachineConfiguration( + id: id, + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048, + cpuCount: 2, + displayMode: .desktop + )) + } + + private func persistResolvedIdentity( + machineID: String, + state: String, + plans: MutablePlanStore + ) throws -> DoryMachineRuntimeIdentity { + let definition = try DoryWorkspaceRepository(root: state) + .readPersistedRecord(id: machineID).definition + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let definitionData = try encoder.encode(definition) + let legacyData = try Data( + contentsOf: URL(fileURLWithPath: state + "/\(machineID)/machine.json") + ) + let machine = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ) + let resolution = try exactResolution(request: .init( + definition: definition, + canonicalDefinitionData: definitionData, + machine: machine, + expectedPlanRevision: 1 + )) + plans.set(resolution.resolvedPlan) + let identity = try DoryMachineRuntimeIdentity( + resolvedPlan: resolution.resolvedPlan, + planSHA256: resolution.resolvedPlanSHA256 + ) + try DoryMachineRuntimeIdentityStore(root: state).publish( + identity, + machineID: machineID, + authoritativeLegacyData: legacyData + ) + return identity + } + private func withHarness( _ label: String, launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan, From ae26a430e1ff8a8844e8c80e403255ad62b282e8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 18:23:43 +0000 Subject: [PATCH 038/338] feat(vm): activate production planning safely --- ...onVirtualMachineProductionActivation.swift | 245 +++++++++++++++ ...yDaemonVirtualMachineProductionTrust.swift | 294 +++++++++++------- ...onVirtualMachineProductionTrustTests.swift | 123 +++++++- 3 files changed, 555 insertions(+), 107 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift new file mode 100644 index 00000000..4c47eb5d --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift @@ -0,0 +1,245 @@ +import DoryOperations +import Foundation + +public enum DoryDaemonVirtualMachineProductionActivationFailureCode: + String, Sendable, Equatable +{ + case trustUnavailable = "trust-unavailable" + case stateAuthorityUnavailable = "state-authority-unavailable" + case backendCompositionUnavailable = "backend-composition-unavailable" + case planningUnavailable = "planning-unavailable" + case installationRejected = "installation-rejected" + case trustFloorActivationRejected = "trust-floor-activation-rejected" +} + +public struct DoryDaemonVirtualMachineProductionActivationFailure: + Error, Sendable, Equatable +{ + public var code: DoryDaemonVirtualMachineProductionActivationFailureCode + public var message: String + public var trustFailure: DoryDaemonVirtualMachineProductionTrustUnavailable? + public var planningFailure: + DoryDaemonVirtualMachineProductionPlanningCompositionFailure? + + public init( + code: DoryDaemonVirtualMachineProductionActivationFailureCode, + message: String, + trustFailure: DoryDaemonVirtualMachineProductionTrustUnavailable? = nil, + planningFailure: + DoryDaemonVirtualMachineProductionPlanningCompositionFailure? = nil + ) { + self.code = code + self.message = message + self.trustFailure = trustFailure + self.planningFailure = planningFailure + } +} + +/// The successfully installed production graph. This is deliberately non-Codable: every member +/// is live daemon authority, not caller intent or a persisted assertion of trust. +public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable { + public let machineManager: MachineManager + public let planning: DoryDaemonVirtualMachineProductionPlanningContext + public let backendRuntimeBuildIdentifiers: [ + DoryVirtualizationBackendIdentity: String + ] + + public var inventory: any DoryDaemonVirtualMachineTrustInventory { + planning.inventory + } + + init( + machineManager: MachineManager, + planning: DoryDaemonVirtualMachineProductionPlanningContext, + backendRuntimeBuildIdentifiers: [ + DoryVirtualizationBackendIdentity: String + ] + ) { + self.machineManager = machineManager + self.planning = planning + self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers + } +} + +public enum DoryDaemonVirtualMachineProductionActivationResult: Sendable { + case activated(DoryDaemonVirtualMachineProductionActivationContext) + case unavailable(DoryDaemonVirtualMachineProductionActivationFailure) + + public var isActivated: Bool { + if case .activated = self { return true } + return false + } +} + +extension DoryDaemonVirtualMachineProductionTrustFactory { + /// Verifies production trust, recovers durable planning transactions, installs the exact + /// recovered launch graph into a production-owned manager, and only then advances the trust + /// floor. No manager is returned on failure, so partially installed in-memory authority cannot + /// escape this boundary or poison a retry. + public func activate( + store: DoryComponentStore, + machineConfiguration: MachineManagerConfiguration, + recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding + ) -> DoryDaemonVirtualMachineProductionActivationResult { + activate( + store: store, + machineConfiguration: machineConfiguration, + recoveryProvider: recoveryProvider, + appVersion: Self.compiledDaemonVersion, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture + ) + } + + /// Internal deterministic seam for signed-catalog and failure-order tests. Production callers + /// cannot replace the compiled version, pinned catalog key, or architecture. + func activate( + store: DoryComponentStore, + machineConfiguration: MachineManagerConfiguration, + recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding, + appVersion: String, + publicKey: String, + expectedArchitecture: String + ) -> DoryDaemonVirtualMachineProductionActivationResult { + let canonicalStateDirectory = URL( + fileURLWithPath: machineConfiguration.stateDirectory + ).standardizedFileURL.path + guard canonicalStateDirectory == machineConfiguration.stateDirectory else { + return unavailableActivation( + .stateAuthorityUnavailable, + "Production planning requires one canonical state authority." + ) + } + + let material: DoryDaemonVirtualMachineVerifiedTrustMaterial + switch verifiedTrustMaterial( + store: store, + machineConfiguration: machineConfiguration, + appVersion: appVersion, + publicKey: publicKey, + expectedArchitecture: expectedArchitecture + ) { + case let .success(verified): + material = verified + case let .failure(reason): + return .unavailable(DoryDaemonVirtualMachineProductionActivationFailure( + code: .trustUnavailable, + message: "Production VM trust preparation failed.", + trustFailure: reason + )) + } + + // This is the only manager admitted to the activated context. Public activation has no + // manager/dependency injection point: executable paths, runtime/log roots, lifecycle + // services, process launching, and guest architecture all come from the verified + // production configuration and MachineManager's production defaults. + let machineManager = MachineManager( + configuration: machineConfiguration, + launchPolicy: .perWorkspaceAuthority + ) + + let backends: [any MachineBackend] + do { + backends = try material.runtimes.map { runtime -> any MachineBackend in + switch runtime.descriptor.identity { + case .appleVirtualizationFramework: + return VirtualizationFrameworkLinuxMachineBackend( + executablePath: runtime.executablePath, + operations: machineManager.resolvedLaunchCompatibilityOperations( + for: runtime.descriptor.identity + ) + ) + case .doryHypervisor: + return RawHVLinuxMachineBackend( + executablePath: runtime.executablePath, + operations: machineManager.resolvedLaunchCompatibilityOperations( + for: runtime.descriptor.identity + ) + ) + case .qemuHypervisorFramework: + throw DoryDaemonVirtualMachineProductionActivationFailure( + code: .backendCompositionUnavailable, + message: "No verified production adapter exists for the runtime." + ) + } + } + } catch let failure as DoryDaemonVirtualMachineProductionActivationFailure { + return .unavailable(failure) + } catch { + return unavailableActivation( + .backendCompositionUnavailable, + "Verified backend adapters could not be composed." + ) + } + + let composition = DoryDaemonVirtualMachineProductionPlanningCompositionFactory( + stateDirectory: canonicalStateDirectory, + backends: backends, + qualificationAuthority: material.authority, + runtimes: material.runtimes, + runtimeVerifier: material.runtimeVerifier, + hostProbe: material.hostProbe, + mutationAuthority: machineManager, + recoveryProvider: recoveryProvider + ) + let planning: DoryDaemonVirtualMachineProductionPlanningContext + switch composition.resolve() { + case let .ready(context): + planning = context + case let .unavailable(reason): + return .unavailable(DoryDaemonVirtualMachineProductionActivationFailure( + code: .planningUnavailable, + message: "Production planning recovery or composition failed.", + planningFailure: reason + )) + } + + do { + try machineManager.installResolvedLaunchInfrastructure( + registry: planning.registry, + resolver: planning.launchResolver, + plans: planning.plans, + expectedPlanRevision: { machineID in + try? planning.plans.read(id: machineID).planRevision + } + ) + } catch { + return unavailableActivation( + .installationRejected, + "MachineManager rejected the exact recovered launch infrastructure." + ) + } + + do { + try activateVerifiedTrustFloor( + stateDirectory: canonicalStateDirectory, + material: material + ) + } catch { + return unavailableActivation( + .trustFloorActivationRejected, + "Recovered launch infrastructure was installed, but the trust floor could not be durably advanced." + ) + } + + return .activated(DoryDaemonVirtualMachineProductionActivationContext( + machineManager: machineManager, + planning: planning, + backendRuntimeBuildIdentifiers: Dictionary( + uniqueKeysWithValues: material.runtimes.map { + ($0.descriptor.identity, $0.runtimeBuildIdentifier) + } + ) + )) + } + + private func unavailableActivation( + _ code: DoryDaemonVirtualMachineProductionActivationFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachineProductionActivationResult { + .unavailable(DoryDaemonVirtualMachineProductionActivationFailure( + code: code, + message: message + )) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift index 1a92adc9..40bb098a 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -382,6 +382,19 @@ struct DoryDaemonVerifiedBackendRuntime: Sendable, Equatable { } } +/// Opaque daemon-only output from signature, catalog, host, and installed-runtime verification. +/// It deliberately carries no MachineManager or repository objects: production activation must +/// compose those once around the already-constructed manager and the planning composition seam. +struct DoryDaemonVirtualMachineVerifiedTrustMaterial: Sendable { + var authority: DoryVerifiedVirtualMachineQualificationAuthority + var runtimes: [DoryDaemonVerifiedBackendRuntime] + var permitsLegacyCompatibilityMigration: Bool + var runtimeVerifier: @Sendable ( + String, MachineBackendDescriptor, String + ) throws -> DoryDaemonVerifiedBackendRuntime + var hostProbe: @Sendable (String) throws -> DoryDaemonProductionHostObservation +} + struct DoryDaemonBackendRuntimeSpecification: Sendable, Equatable { var descriptor: MachineBackendDescriptor var executablePath: String @@ -954,6 +967,11 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { ) throws -> DoryDaemonVerifiedBackendRuntime typealias HostProbe = @Sendable (String) throws -> DoryDaemonProductionHostObservation typealias DirectorySynchronizer = @Sendable (Int32) -> Bool + typealias TrustFloorActivator = @Sendable ( + String, + DoryVerifiedVirtualMachineQualificationAuthority, + @escaping DirectorySynchronizer + ) throws -> Void private let authorityResolver: AuthorityResolver private let runtimeVerifier: RuntimeVerifier @@ -961,6 +979,7 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { private let daemonIdentityVerifier: @Sendable () -> Bool private let planningTransactionAvailable: @Sendable () -> Bool private let synchronizeTrustFloorDirectory: DirectorySynchronizer + private let trustFloorActivator: TrustFloorActivator /// Compiled into and covered by the daemon's code signature. Production catalog compatibility /// must not trust an environment override or a mutable outer Info.plist. @@ -981,6 +1000,13 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { .currentProcessSatisfiesProductionDaemonRequirement planningTransactionAvailable = { false } synchronizeTrustFloorDirectory = { fsync($0) == 0 } + trustFloorActivator = { stateDirectory, authority, synchronizeDirectory in + try DoryDaemonVirtualMachineProductionTrustFloor.activate( + stateDirectory: stateDirectory, + authority: authority, + synchronizeDirectory: synchronizeDirectory + ) + } } init( @@ -989,7 +1015,8 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { hostProbe: @escaping HostProbe, daemonIdentityVerifier: @escaping @Sendable () -> Bool, planningTransactionAvailable: @escaping @Sendable () -> Bool = { false }, - synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 } + synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 }, + trustFloorActivator: TrustFloorActivator? = nil ) { self.authorityResolver = authorityResolver self.runtimeVerifier = runtimeVerifier @@ -997,6 +1024,14 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { self.daemonIdentityVerifier = daemonIdentityVerifier self.planningTransactionAvailable = planningTransactionAvailable self.synchronizeTrustFloorDirectory = synchronizeTrustFloorDirectory + self.trustFloorActivator = trustFloorActivator ?? { + stateDirectory, authority, synchronizeDirectory in + try DoryDaemonVirtualMachineProductionTrustFloor.activate( + stateDirectory: stateDirectory, + authority: authority, + synchronizeDirectory: synchronizeDirectory + ) + } } public func resolve( @@ -1021,108 +1056,22 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { publicKey: String, expectedArchitecture: String ) -> DoryDaemonVirtualMachineProductionTrustReadiness { - let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record? - let hasResolvedPlanState: Bool - do { - trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor - .read(stateDirectory: machineConfiguration.stateDirectory) - hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor - .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory) - } catch { - return unavailable( - .trustFloorViolated, - "The durable VM production trust floor is invalid or unavailable." - ) - } - let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState - let authority: DoryVerifiedVirtualMachineQualificationAuthority - do { - authority = try authorityResolver( - store, publicKey, expectedArchitecture, appVersion - ) - } catch let error as DoryVirtualMachineQualificationAuthorityError { - let code: DoryDaemonVirtualMachineProductionTrustReadinessCode - switch error { - case .catalogUnavailable: - code = .catalogUnavailable - case let .catalogSchemaUnsupported(version): - code = version == DoryComponentCatalog.oldestSupportedSchemaVersion - ? .catalogSchemaV1Migration : .catalogSchemaUnsupported - default: - code = .qualificationAuthorityUnavailable - } - if code == .catalogUnavailable || code == .catalogSchemaV1Migration { - guard mayUseLegacyMigration else { - return unavailable( - .trustFloorViolated, - "VM production trust was previously activated; catalog authority cannot be removed or downgraded." - ) - } - return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( - code: code, - message: error.description, - permitsLegacyCompatibilityMigration: true - )) - } - return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable( - code: code, - message: error.description - )) - } catch { - return unavailable( - .qualificationAuthorityUnavailable, - "VM qualification authority could not be verified." - ) - } - - do { - try DoryDaemonVirtualMachineProductionTrustFloor.validate( - authority: authority, - against: trustFloor - ) - } catch { - return unavailable( - .trustFloorViolated, - "The signed component catalog violates the accepted VM trust floor." - ) - } - - guard daemonIdentityVerifier() else { - return unavailable( - .daemonSignatureUnavailable, - "Resolved-plan launch requires a production-signed Dory daemon." - ) - } - do { _ = try hostProbe(machineConfiguration.stateDirectory) } - catch { - return unavailable( - .hostFactsUnavailable, - "Exact host model, OS build, framework, or resource facts are unavailable." - ) - } - - var runtimes: [DoryDaemonVerifiedBackendRuntime] = [] - do { - runtimes.append(try runtimeVerifier( - machineConfiguration.vmmExecutablePath, - VirtualizationFrameworkLinuxMachineBackend.backendDescriptor, - "dory-vmm" - )) - } catch { - return unavailable( - .backendRuntimeUnavailable, - "The signed Virtualization.framework helper failed exact verification." - ) - } - if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath { - if let runtime = try? runtimeVerifier( - rawPath, - RawHVLinuxMachineBackend.backendDescriptor, - "dory-hv" - ) { - runtimes.append(runtime) - } + let material: DoryDaemonVirtualMachineVerifiedTrustMaterial + switch verifiedTrustMaterial( + store: store, + machineConfiguration: machineConfiguration, + appVersion: appVersion, + publicKey: publicKey, + expectedArchitecture: expectedArchitecture + ) { + case let .success(verified): + material = verified + case let .failure(reason): + return .unavailable(reason) } + let authority = material.authority + let runtimes = material.runtimes + let mayUseLegacyMigration = material.permitsLegacyCompatibilityMigration let artifactAuthority = DoryVirtualMachineArtifactAuthority( root: machineConfiguration.stateDirectory + "/.artifact-authority" @@ -1161,10 +1110,9 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { ) do { - try DoryDaemonVirtualMachineProductionTrustFloor.activate( + try activateVerifiedTrustFloor( stateDirectory: machineConfiguration.stateDirectory, - authority: authority, - synchronizeDirectory: synchronizeTrustFloorDirectory + material: material ) let manager = MachineManager( configuration: machineConfiguration, @@ -1229,6 +1177,142 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { } } + /// Shared preparation boundary for the legacy composition bridge and the per-workspace + /// activation path. Verification is performed once and yields only opaque daemon authority; + /// neither path may independently rebuild or reinterpret the catalog/runtime trust graph. + func verifiedTrustMaterial( + store: DoryComponentStore, + machineConfiguration: MachineManagerConfiguration, + appVersion: String, + publicKey: String, + expectedArchitecture: String + ) -> Result< + DoryDaemonVirtualMachineVerifiedTrustMaterial, + DoryDaemonVirtualMachineProductionTrustUnavailable + > { + let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record? + let hasResolvedPlanState: Bool + do { + trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor + .read(stateDirectory: machineConfiguration.stateDirectory) + hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor + .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory) + } catch { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "The durable VM production trust floor is invalid or unavailable." + )) + } + let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState + let authority: DoryVerifiedVirtualMachineQualificationAuthority + do { + authority = try authorityResolver( + store, publicKey, expectedArchitecture, appVersion + ) + } catch let error as DoryVirtualMachineQualificationAuthorityError { + let code: DoryDaemonVirtualMachineProductionTrustReadinessCode + switch error { + case .catalogUnavailable: + code = .catalogUnavailable + case let .catalogSchemaUnsupported(version): + code = version == DoryComponentCatalog.oldestSupportedSchemaVersion + ? .catalogSchemaV1Migration : .catalogSchemaUnsupported + default: + code = .qualificationAuthorityUnavailable + } + if code == .catalogUnavailable || code == .catalogSchemaV1Migration { + guard mayUseLegacyMigration else { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "VM production trust was previously activated; catalog authority cannot be removed or downgraded." + )) + } + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: error.description, + permitsLegacyCompatibilityMigration: true + )) + } + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: code, + message: error.description + )) + } catch { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .qualificationAuthorityUnavailable, + message: "VM qualification authority could not be verified." + )) + } + + do { + try DoryDaemonVirtualMachineProductionTrustFloor.validate( + authority: authority, + against: trustFloor + ) + } catch { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .trustFloorViolated, + message: "The signed component catalog violates the accepted VM trust floor." + )) + } + + guard daemonIdentityVerifier() else { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .daemonSignatureUnavailable, + message: "Resolved-plan launch requires a production-signed Dory daemon." + )) + } + do { _ = try hostProbe(machineConfiguration.stateDirectory) } + catch { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .hostFactsUnavailable, + message: "Exact host model, OS build, framework, or resource facts are unavailable." + )) + } + + var runtimes: [DoryDaemonVerifiedBackendRuntime] = [] + do { + runtimes.append(try runtimeVerifier( + machineConfiguration.vmmExecutablePath, + VirtualizationFrameworkLinuxMachineBackend.backendDescriptor, + "dory-vmm" + )) + } catch { + return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable( + code: .backendRuntimeUnavailable, + message: "The signed Virtualization.framework helper failed exact verification." + )) + } + if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath, + let runtime = try? runtimeVerifier( + rawPath, + RawHVLinuxMachineBackend.backendDescriptor, + "dory-hv" + ) { + runtimes.append(runtime) + } + return .success(DoryDaemonVirtualMachineVerifiedTrustMaterial( + authority: authority, + runtimes: runtimes, + permitsLegacyCompatibilityMigration: mayUseLegacyMigration, + runtimeVerifier: runtimeVerifier, + hostProbe: hostProbe + )) + } + + /// Advances the monotonic catalog floor only after the caller has recovered planning state + /// and installed the exact resulting launch graph into its MachineManager. + func activateVerifiedTrustFloor( + stateDirectory: String, + material: DoryDaemonVirtualMachineVerifiedTrustMaterial + ) throws { + try trustFloorActivator( + stateDirectory, + material.authority, + synchronizeTrustFloorDirectory + ) + } + private func unavailable( _ code: DoryDaemonVirtualMachineProductionTrustReadinessCode, _ message: String diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index b8aa7ea8..e027d1d2 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -309,6 +309,77 @@ struct DoryDaemonVirtualMachineProductionTrustTests { == fixture.runtimeBuildIdentifier) } + @Test("activation owns the exact production manager and graph") + func activationOrderAndExactGraph() throws { + let trustFloor = ProductionTrustFloorActivationState() + let fixture = try ProductionTrustFixture( + trustFloorActivationState: trustFloor + ) + defer { fixture.cleanup() } + + let result = fixture.factory.activate( + store: fixture.store, + machineConfiguration: fixture.machineConfiguration, + recoveryProvider: ProductionEmptyPlanningRecovery(), + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) + guard case let .activated(context) = result else { + Issue.record("Expected production activation") + return + } + #expect(context.machineManager.configuredLaunchPolicy == .perWorkspaceAuthority) + #expect(context.machineManager.managedStateDirectory + == fixture.machineConfiguration.stateDirectory) + #expect(context.planning.identity.stateDirectory + == context.machineManager.managedStateDirectory) + #expect(context.planning.plans.root + == context.machineManager.managedStateDirectory) + #expect(context.planning.workspaces.root + == context.machineManager.managedStateDirectory) + #expect(context.inventory is DoryProductionDaemonVirtualMachineTrustInventory) + #expect(trustFloor.activationCount == 1) + } + + @Test("trust-floor failure exposes no manager and a fresh activation can retry") + func activationFloorFailureIsTyped() throws { + let trustFloor = ProductionTrustFloorActivationState(remainingFailures: 1) + let fixture = try ProductionTrustFixture( + trustFloorActivationState: trustFloor + ) + defer { fixture.cleanup() } + let recovery = ProductionEmptyPlanningRecovery() + + guard case let .unavailable(failure) = fixture.factory.activate( + store: fixture.store, + machineConfiguration: fixture.machineConfiguration, + recoveryProvider: recovery, + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) else { + Issue.record("Expected trust-floor activation failure") + return + } + #expect(failure.code == .trustFloorActivationRejected) + #expect(trustFloor.activationCount == 1) + + guard case let .activated(context) = fixture.factory.activate( + store: fixture.store, + machineConfiguration: fixture.machineConfiguration, + recoveryProvider: recovery, + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) else { + Issue.record("Expected a fresh production-owned manager to activate") + return + } + #expect(context.machineManager.configuredLaunchPolicy == .perWorkspaceAuthority) + #expect(trustFloor.activationCount == 2) + } + @Test("production inventory never fabricates an admission during planning") func planningFailsUntilAtomicAdmissionBindingExists() throws { let fixture = try ProductionTrustFixture() @@ -453,6 +524,17 @@ private enum ProductionTrustFixtureError: Error { case runtimeRejected } +private struct ProductionEmptyPlanningRecovery: + DoryDaemonVirtualMachinePlanningRecoveryProviding +{ + func recoveryRequest( + for machineID: String + ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? { + _ = machineID + return nil + } +} + private final class ProductionCallCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -470,6 +552,32 @@ private final class ProductionCallCounter: @unchecked Sendable { } } +private final class ProductionTrustFloorActivationState: @unchecked Sendable { + private let lock = NSLock() + private var remainingFailures: Int + private var count = 0 + + init(remainingFailures: Int = 0) { + self.remainingFailures = remainingFailures + } + + var activationCount: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func activate() throws { + lock.lock() + defer { lock.unlock() } + count += 1 + if remainingFailures > 0 { + remainingFailures -= 1 + throw ProductionTrustFixtureError.runtimeRejected + } + } +} + private final class ProductionHostState: @unchecked Sendable { private let lock = NSLock() private var observation: DoryDaemonProductionHostObservation @@ -560,7 +668,8 @@ private final class ProductionTrustFixture: @unchecked Sendable { daemonTeamIdentifier: String? = DorydXPCSecurity.productionTeamID, runtimeVerificationFails: Bool = false, planningTransactionAvailable: Bool = true, - trustFloorDirectorySyncFails: Bool = false + trustFloorDirectorySyncFails: Bool = false, + trustFloorActivationState: ProductionTrustFloorActivationState? = nil ) throws { helperDigest = Self.digest(Data("verified-helper".utf8)) hostState = ProductionHostState(host) @@ -621,6 +730,15 @@ private final class ProductionTrustFixture: @unchecked Sendable { let digest = helperDigest let build = runtimeBuildIdentifier let observedHostState = hostState + let floorActivator: + DoryDaemonVirtualMachineProductionTrustFactory.TrustFloorActivator? + if let trustFloorActivationState { + floorActivator = { _, _, _ in + try trustFloorActivationState.activate() + } + } else { + floorActivator = nil + } factory = DoryDaemonVirtualMachineProductionTrustFactory( authorityResolver: { store, key, architecture, appVersion in try DoryVirtualMachineQualificationAuthorityResolver.resolve( @@ -650,7 +768,8 @@ private final class ProductionTrustFixture: @unchecked Sendable { planningTransactionAvailable: { planningTransactionAvailable }, synchronizeTrustFloorDirectory: { descriptor in !trustFloorDirectorySyncFails && fsync(descriptor) == 0 - } + }, + trustFloorActivator: floorActivator ) } From fd2f8818e8e83edb33bc8f1cf3eedc284cec4ba7 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 19:28:36 +0000 Subject: [PATCH 039/338] feat(vm): bind exact launch artifacts and devices --- .../Sources/dory-hv/DesktopMode.swift | 63 ++- .../Sources/dory-hv/main.swift | 22 +- .../DoryVirtualMachineArtifactAuthority.swift | 2 +- .../DoryVirtualMachineCapabilities.swift | 6 + .../DoryVirtualMachineDefinition.swift | 39 +- .../Sources/DoryVMMKit/DoryVMM.swift | 141 +++++-- .../DoryVMMDesktopApplication.swift | 57 +-- ...emonVirtualMachineLaunchPlanResolver.swift | 1 + ...achinePlanningTransactionCoordinator.swift | 106 ++++- ...onVirtualMachineProductionActivation.swift | 16 +- ...MachineProductionPlanningComposition.swift | 47 ++- ...lMachineProductionPlanningController.swift | 362 ++++++++++++++++++ ...yDaemonVirtualMachineProductionTrust.swift | 68 +++- ...yDaemonVirtualMachineRuntimePlanning.swift | 118 ++++++ .../DorydKit/DoryResolvedMachinePlan.swift | 185 ++++++++- .../LinuxMachineBackendAdapters.swift | 4 +- .../Sources/DorydKit/MachineBackend.swift | 10 +- .../Sources/DorydKit/MachineManager.swift | 129 ++++++- ...VirtualMachineArtifactAuthorityTests.swift | 27 ++ .../DoryVirtualMachineDefinitionTests.swift | 17 +- ...ePlanningTransactionCoordinatorTests.swift | 12 + ...neProductionPlanningCompositionTests.swift | 97 ++++- ...ineProductionPlanningControllerTests.swift | 237 ++++++++++++ ...onVirtualMachineProductionTrustTests.swift | 98 ++++- ...onVirtualMachineRuntimePlanningTests.swift | 42 ++ ...esolvedMachineLaunchArtifactFixtures.swift | 55 +++ .../DoryResolvedMachinePlanTests.swift | 43 +++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 85 ++++ ...lMachineResourceAdmissionLedgerTests.swift | 14 + .../DorydKitTests/HealthReporterTests.swift | 6 + .../DorydKitTests/MachineBackendTests.swift | 20 +- ...eManagerResolvedPlanIntegrationTests.swift | 201 +++++++++- .../DorydKitTests/MachineManagerTests.swift | 2 +- 33 files changed, 2202 insertions(+), 130 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index d3cedac5..cc0739ca 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -25,6 +25,8 @@ enum DesktopMode { var cpuCount: Int var shares: [DoryMachineShareConfiguration] var environment: [String: String] + var resolvedGraphics: DoryGraphicsAccelerationLevel? + var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? } private struct ResolvedGraphics { @@ -73,9 +75,34 @@ enum DesktopMode { self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log") let resolvedGraphics = try Self.resolveGraphics( environment: configuration.environment, - requireVulkan: configuration.genericGuest + requireVulkan: configuration.genericGuest, + exactLevel: configuration.resolvedGraphics ) self.graphicsBackend = resolvedGraphics.backend + if let devices = configuration.resolvedDevices { + guard devices.networkAttachment == .sharedNAT, + !devices.clockSynchronization, + !devices.gracefulShutdown else { + throw VMError.bootFailure( + "resolved device contract contains a device not implemented by raw-HV" + ) + } + guard devices.keyboard == devices.pointer else { + throw VMError.bootFailure( + "raw-HV input is a combined keyboard/pointer device" + ) + } + guard devices.audioInput == devices.audioOutput else { + throw VMError.bootFailure( + "raw-HV audio is a combined input/output device" + ) + } + guard devices.directorySharing == !configuration.shares.isEmpty else { + throw VMError.bootFailure( + "resolved directory-sharing contract does not match the launch shares" + ) + } + } self.machine = try Machine(configuration: MachineConfiguration( kernelPath: configuration.kernelPath, initrdPath: configuration.initrdPath, @@ -153,13 +180,19 @@ enum DesktopMode { var backends: [VirtioDeviceBackend] = [ try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), gpu, - input, VirtioRng(), balloon, vsock, - sound, ] - for share in configuration.shares { + if configuration.resolvedDevices?.keyboard != false { + backends.append(input) + } + if configuration.resolvedDevices?.audioInput != false { + backends.append(sound) + } + let attachedShares = configuration.resolvedDevices?.directorySharing == false + ? [] : configuration.shares + for share in attachedShares { let rawShare = try VirtioFSShareConfiguration( tag: share.tag, path: share.hostPath, @@ -173,7 +206,7 @@ enum DesktopMode { backends.append(network) for (slot, backend) in backends.enumerated() { let transport = Self.attachBackend(backend, to: machine, slot: slot) - if backend === gpu { + if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in guard let gpu, let transport else { return } gpu.updateScanoutSize(width: width, height: height, transport: transport) @@ -209,7 +242,7 @@ enum DesktopMode { } else { self.sshAgentBridge = nil } - if configuration.genericGuest { + if configuration.genericGuest || configuration.resolvedDevices?.clipboard == false { self.clipboard = nil } else { let clipboardControl = DorydKit.AgentControl(configuration: .init( @@ -535,7 +568,8 @@ enum DesktopMode { private static func resolveGraphics( environment: [String: String], - requireVulkan: Bool + requireVulkan: Bool, + exactLevel: DoryGraphicsAccelerationLevel? ) throws -> ResolvedGraphics { let preference = try DoryDesktopGraphicsPreference(environment: environment) var rendererEnvironment = ProcessInfo.processInfo.environment @@ -560,6 +594,21 @@ enum DesktopMode { ) } + if let exactLevel { + switch exactLevel { + case .hardwareAccelerated3D: + return try accelerated(classicOnly: false) + case .hostAcceleratedDisplay: + return try accelerated(classicOnly: true) + case .software: + return ResolvedGraphics(backend: .software, renderer: nil) + case .none: + throw VMError.bootFailure( + "raw-HV desktop cannot satisfy a no-graphics resolved plan" + ) + } + } + switch preference { case .automatic: if requireVulkan { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index 3c7a1abe..e2b9a492 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -436,6 +436,8 @@ case "desktop": var cpus = 6 var shares = [DoryMachineShareConfiguration]() var environment = [String: String]() + var resolvedGraphics: DoryGraphicsAccelerationLevel? + var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? var iterator = arguments.dropFirst().makeIterator() while let argument = iterator.next() { switch argument { @@ -463,6 +465,22 @@ case "desktop": fail("desktop --env requires KEY=VALUE") } environment[String(value[.. 0 else { + guard Self.isSHA256(sha256), byteCount > 0 else { throw DoryVirtualMachineArtifactAuthorityError.invalidRecord } case let .mutable(provenance, sha256, byteCount, _, _, _, _, _, _): diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 8f7b18a7..dc2f6d10 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -406,6 +406,12 @@ public struct DoryMutableBootMediaProvenanceAuditEvidence: Codable, Sendable, Eq public struct DoryTrustedMutableBootMediaProvenance: Sendable, Equatable, Hashable { let auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence + /// Non-secret durable receipt reference. The opaque trusted wrapper remains daemon-owned; + /// persisted plans carry only this audit evidence and must resolve it again before launch. + public var persistedAuditEvidence: DoryMutableBootMediaProvenanceAuditEvidence { + auditEvidence + } + init(auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence) { self.auditEvidence = auditEvidence } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 505bf86d..1354ac71 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -129,6 +129,9 @@ public struct DoryVMStorageAttachment: Codable, Sendable, Equatable { public var id: String public var role: DoryVMStorageRole public var artifact: DoryVMResolverReference + /// Provenance intent for the virtual disk. Storage kind is always `.virtualDisk`; writable + /// attachments require mutable daemon provenance and read-only attachments are immutable. + public var source: DoryBootMediaSource public var capacityBytes: UInt64 public var readOnly: Bool @@ -136,15 +139,38 @@ public struct DoryVMStorageAttachment: Codable, Sendable, Equatable { id: String, role: DoryVMStorageRole, artifact: DoryVMResolverReference, + source: DoryBootMediaSource = .userProvided, capacityBytes: UInt64, readOnly: Bool = false ) { self.id = id self.role = role self.artifact = artifact + self.source = source self.capacityBytes = capacityBytes self.readOnly = readOnly } + + private enum CodingKeys: String, CodingKey { + case id, role, artifact, source, capacityBytes, readOnly + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try container.decode(String.self, forKey: .id), + role: try container.decode(DoryVMStorageRole.self, forKey: .role), + artifact: try container.decode(DoryVMResolverReference.self, forKey: .artifact), + // Schema 2 did not bind storage provenance. Conservatively migrate it as imported; + // never manufacture Dory distribution/qualification authority. + source: try container.decodeIfPresent( + DoryBootMediaSource.self, + forKey: .source + ) ?? .userProvided, + capacityBytes: try container.decode(UInt64.self, forKey: .capacityBytes), + readOnly: try container.decode(Bool.self, forKey: .readOnly) + ) + } } public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable { @@ -323,7 +349,7 @@ public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable { /// passwords, tokens, host filesystem paths, or volatile process state. public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public static let oldestSupportedSchemaVersion: UInt16 = 1 - public static let currentSchemaVersion: UInt16 = 2 + public static let currentSchemaVersion: UInt16 = 3 public static let currentVirtualHardwareABIVersion: UInt16 = 1 public var schemaVersion: UInt16 @@ -535,7 +561,16 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { return } - schemaVersion = persistedSchema + guard persistedSchema == 2 || persistedSchema == Self.currentSchemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Unsupported VM definition schema \(persistedSchema)." + ) + } + // Schema 2 is structurally migrated by the attachment decoder above. Its missing storage + // source becomes `.userProvided`; encoding always publishes current schema 3. + schemaVersion = Self.currentSchemaVersion virtualHardwareABIVersion = try container.decode( UInt16.self, forKey: .virtualHardwareABIVersion diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index d091c62e..4460c37a 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -45,6 +45,8 @@ public struct DoryVMMArguments: Sendable, Equatable { public var holdSeconds: UInt32? public var shares: [DoryMachineShareConfiguration] = [] public var environment: [String: String] = [:] + public var resolvedGraphics: DoryGraphicsAccelerationLevel? + public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? public init() {} @@ -69,6 +71,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case invalidDisplayMode(String) case invalidMachineBootMode(String) case invalidEnvironment(String) + case invalidResolvedGraphics(String) + case invalidResolvedDevices(String) public var description: String { switch self { @@ -96,6 +100,10 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid --boot-mode (expected linux-kernel or efi): \(mode)" case let .invalidEnvironment(value): return "invalid --env value: \(value)" + case let .invalidResolvedGraphics(value): + return "invalid --resolved-graphics value: \(value)" + case let .invalidResolvedDevices(value): + return "invalid --resolved-devices value: \(value)" } } } @@ -175,6 +183,22 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { throw DoryVMMArgumentError.invalidEnvironment(key) } parsed.environment[key] = String(rawValue[rawValue.index(after: equals)...]) + case "--resolved-graphics": + let rawValue = try value(after: argument, from: raw, index: &index) + guard let graphics = DoryGraphicsAccelerationLevel(rawValue: rawValue) else { + throw DoryVMMArgumentError.invalidResolvedGraphics(rawValue) + } + parsed.resolvedGraphics = graphics + case "--resolved-devices": + let rawValue = try value(after: argument, from: raw, index: &index) + do { + parsed.resolvedDevices = try JSONDecoder().decode( + DoryVirtualMachineDeviceCapabilityRequest.self, + from: Data(rawValue.utf8) + ) + } catch { + throw DoryVMMArgumentError.invalidResolvedDevices(rawValue) + } case "--exit-after-handoff": parsed.exitAfterHandoff = true case "--handoff-only": @@ -224,6 +248,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { public var kernelCommandLine: String? public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var resolvedGraphics: DoryGraphicsAccelerationLevel? + public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? public var dockerDataDiskPath: String? public var nativeIPv6: Bool public var sourcePreservingLAN: Bool @@ -242,6 +268,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { kernelCommandLine: String? = nil, shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], + resolvedGraphics: DoryGraphicsAccelerationLevel? = nil, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil, dockerDataDiskPath: String? = nil, nativeIPv6: Bool = false, sourcePreservingLAN: Bool = false, @@ -259,6 +287,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable { self.kernelCommandLine = kernelCommandLine self.shares = shares self.environment = environment + self.resolvedGraphics = resolvedGraphics + self.resolvedDevices = resolvedDevices self.dockerDataDiskPath = dockerDataDiskPath self.nativeIPv6 = nativeIPv6 self.sourcePreservingLAN = sourcePreservingLAN @@ -333,6 +363,38 @@ public enum DoryVZConfigurationBuilder { throw DoryVZMachineError.missingFile(spec.rootfsPath) } + if let graphics = spec.resolvedGraphics { + let expected: DoryGraphicsAccelerationLevel = spec.displayMode == .desktop + ? .hostAcceleratedDisplay : .none + guard graphics == expected else { + throw DoryVZMachineError.validation( + "resolved graphics \(graphics.rawValue) cannot be represented by this VZ launch" + ) + } + } + if let devices = spec.resolvedDevices { + guard devices.networkAttachment == .sharedNAT, + !devices.clockSynchronization, + !devices.dynamicDisplay, + !devices.gracefulShutdown else { + throw DoryVZMachineError.validation( + "resolved device contract contains a device not implemented by this VZ launch" + ) + } + guard devices.directorySharing == !spec.shares.isEmpty else { + throw DoryVZMachineError.validation( + "resolved directory-sharing contract does not match the launch shares" + ) + } + if spec.displayMode != .desktop, + devices.audioInput || devices.audioOutput || devices.keyboard + || devices.pointer || devices.clipboard { + throw DoryVZMachineError.validation( + "resolved desktop devices cannot be attached to a headless VZ launch" + ) + } + } + let configuration = VZVirtualMachineConfiguration() switch spec.bootMode { case .linuxKernel: @@ -376,38 +438,51 @@ public enum DoryVZConfigurationBuilder { configuration.networkDevices = [network] if spec.displayMode == .desktop { + let devices = spec.resolvedDevices let graphics = VZVirtioGraphicsDeviceConfiguration() graphics.scanouts = [VZVirtioGraphicsScanoutConfiguration( widthInPixels: DoryVMMDisplayDefaults.widthInPixels, heightInPixels: DoryVMMDisplayDefaults.heightInPixels )] configuration.graphicsDevices = [graphics] - configuration.keyboards = [VZUSBKeyboardConfiguration()] - configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] - - let output = VZVirtioSoundDeviceOutputStreamConfiguration() - output.sink = VZHostAudioOutputStreamSink() - let outputSound = VZVirtioSoundDeviceConfiguration() - outputSound.streams = [output] - - let input = VZVirtioSoundDeviceInputStreamConfiguration() - input.source = VZHostAudioInputStreamSource() - let inputSound = VZVirtioSoundDeviceConfiguration() - inputSound.streams = [input] - configuration.audioDevices = [outputSound, inputSound] - - let console = VZVirtioConsoleDeviceConfiguration() - let spiceAgent = VZVirtioConsolePortConfiguration() - spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName - let spiceAttachment = VZSpiceAgentPortAttachment() - // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient - // bidirectional default; directional policies use Dory's agent-backed bridge instead. - spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy( - environment: spec.environment - ) == .bidirectional - spiceAgent.attachment = spiceAttachment - console.ports[0] = spiceAgent - configuration.consoleDevices = [console] + if devices?.keyboard != false { + configuration.keyboards = [VZUSBKeyboardConfiguration()] + } + if devices?.pointer != false { + configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] + } + + var audioDevices = [VZVirtioSoundDeviceConfiguration]() + if devices?.audioOutput != false { + let output = VZVirtioSoundDeviceOutputStreamConfiguration() + output.sink = VZHostAudioOutputStreamSink() + let outputSound = VZVirtioSoundDeviceConfiguration() + outputSound.streams = [output] + audioDevices.append(outputSound) + } + if devices?.audioInput != false { + let input = VZVirtioSoundDeviceInputStreamConfiguration() + input.source = VZHostAudioInputStreamSource() + let inputSound = VZVirtioSoundDeviceConfiguration() + inputSound.streams = [input] + audioDevices.append(inputSound) + } + configuration.audioDevices = audioDevices + + if devices?.clipboard != false { + let console = VZVirtioConsoleDeviceConfiguration() + let spiceAgent = VZVirtioConsolePortConfiguration() + spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName + let spiceAttachment = VZSpiceAgentPortAttachment() + // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient + // bidirectional default; directional policies use Dory's agent-backed bridge instead. + spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy( + environment: spec.environment + ) == .bidirectional + spiceAgent.attachment = spiceAttachment + console.ports[0] = spiceAgent + configuration.consoleDevices = [console] + } } var dockerDataDiskPath: String? @@ -442,7 +517,10 @@ public enum DoryVZConfigurationBuilder { ) directoryShares = [bootConfigShare] + spec.shares } - configuration.directorySharingDevices = try directoryShares.map { share in + let attachedDirectoryShares = spec.resolvedDevices?.directorySharing == false + ? directoryShares.filter { $0.tag == bootConfigTag } + : directoryShares + configuration.directorySharingDevices = try attachedDirectoryShares.map { share in try share.validate() var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: share.hostPath, isDirectory: &isDirectory), @@ -836,6 +914,8 @@ public enum DoryVMMMain { readyTimeoutSeconds: arguments.readyTimeoutSeconds, shares: arguments.shares, environment: arguments.environment, + resolvedGraphics: arguments.resolvedGraphics, + resolvedDevices: arguments.resolvedDevices, gvproxyPath: arguments.gvproxyPath, sshAgentSocketPath: arguments.sshAgentSocketPath, publishHost: arguments.publishHost, @@ -866,7 +946,8 @@ public enum DoryVMMMain { try DoryVMMDesktopApplication.run( runtime: runtime, machineID: machineID, - environment: arguments.environment + environment: arguments.environment, + resolvedDevices: arguments.resolvedDevices ) } } else { @@ -923,6 +1004,8 @@ public enum DoryVMMMain { readyTimeoutSeconds: TimeInterval, shares: [DoryMachineShareConfiguration], environment: [String: String], + resolvedGraphics: DoryGraphicsAccelerationLevel?, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, gvproxyPath: String?, sshAgentSocketPath: String?, publishHost: String, @@ -1014,6 +1097,8 @@ public enum DoryVMMMain { kernelCommandLine: kernelCommandLine, shares: shares, environment: environment, + resolvedGraphics: resolvedGraphics, + resolvedDevices: resolvedDevices, dockerDataDiskPath: dataDrive?.engineDataDiskPath, nativeIPv6: gvproxyNetwork != nil, sourcePreservingLAN: sourcePreservingLANClient != nil, diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index d4b53c5d..9e7291f3 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -9,14 +9,21 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow private let runtime: DoryVMMRuntime private let machineView: VZVirtualMachineView private let window: NSWindow - private let clipboard: DoryDesktopClipboardCoordinator + private let clipboard: DoryDesktopClipboardCoordinator? + private let dynamicDisplayEnabled: Bool private var pendingDisplayResize: DispatchWorkItem? private var requestedPixelSize: CGSize? private var stopError: String? - private init(runtime: DoryVMMRuntime, machineID: String, environment: [String: String]) { + private init( + runtime: DoryVMMRuntime, + machineID: String, + environment: [String: String], + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + ) { self.application = NSApplication.shared self.runtime = runtime + dynamicDisplayEnabled = resolvedDevices?.dynamicDisplay ?? true let windowSize = NSSize(width: 1_280, height: 800) let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize)) @@ -35,21 +42,21 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow let coordinatorPolicy: DoryDesktopClipboardPolicy = requestedPolicy == .bidirectional ? .off : requestedPolicy - self.clipboard = DoryDesktopClipboardCoordinator( - policy: coordinatorPolicy, - execute: { argv, stdin, timeoutMs, outputLimitBytes in - try runtime.executeDesktopIntegration( - argv: argv, - stdin: stdin, - timeoutMs: timeoutMs, - outputLimitBytes: outputLimitBytes - ) - }, - sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) }, - log: { message in - FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8)) - } - ) + clipboard = resolvedDevices?.clipboard == false ? nil : DoryDesktopClipboardCoordinator( + policy: coordinatorPolicy, + execute: { argv, stdin, timeoutMs, outputLimitBytes in + try runtime.executeDesktopIntegration( + argv: argv, + stdin: stdin, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + }, + sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) }, + log: { message in + FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8)) + } + ) self.window = NSWindow( contentRect: NSRect(origin: .zero, size: windowSize), @@ -73,12 +80,14 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow static func run( runtime: DoryVMMRuntime, machineID: String, - environment: [String: String] + environment: [String: String], + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil ) throws { let controller = DoryVMMDesktopApplication( runtime: runtime, machineID: machineID, - environment: environment + environment: environment, + resolvedDevices: resolvedDevices ) try controller.runUntilStopped() } @@ -86,11 +95,11 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow private func runUntilStopped() throws { application.setActivationPolicy(.regular) application.delegate = self - clipboard.start() - clipboard.markGuestReady() + clipboard?.start() + clipboard?.markGuestReady() window.makeKeyAndOrderFront(nil) application.activate() - reconfigureDisplayNow() + if dynamicDisplayEnabled { reconfigureDisplayNow() } let runtime = self.runtime DispatchQueue.global(qos: .userInitiated).async { [weak self] in @@ -107,7 +116,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow } application.run() - clipboard.stop() + clipboard?.stop() if let stopError { throw DoryVZMachineError.stoppedWithError(stopError) } @@ -177,6 +186,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow } private func scheduleDisplayReconfiguration() { + guard dynamicDisplayEnabled else { return } pendingDisplayResize?.cancel() let work = DispatchWorkItem { [weak self] in self?.reconfigureDisplayNow() @@ -186,6 +196,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow } private func reconfigureDisplayNow() { + guard dynamicDisplayEnabled else { return } pendingDisplayResize?.cancel() pendingDisplayResize = nil let size = Self.targetPixelSize( diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift index a174d606..af50ff08 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift @@ -211,6 +211,7 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector: inspectionEvidence: capability.bootMediaInspectionEvidence, mutableProvenanceEvidence: capability.mutableBootMediaProvenanceEvidence ), + launchArtifacts: snapshot.launchArtifacts, components: runtime.components.sorted { $0.componentIdentifier < $1.componentIdentifier }, diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index 928db557..ffa50fb4 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -30,6 +30,63 @@ public struct DoryDaemonVirtualMachinePlanningTransactionRequest: Sendable { } } +/// Validated, daemon-local reconstruction authority decoded from the durable transaction journal. +/// It carries no raw machine configuration, filesystem path, or environment value. A recovery +/// provider must re-read those values from its own MachineManager authority and prove that the +/// resulting request matches every digest and publication decision below. +public struct DoryDaemonVirtualMachinePlanningRecoveryDescriptor: Sendable, Equatable { + public var machineID: String + public var definition: DoryVirtualMachineDefinition + public var definitionSHA256: String + public var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication + public var planPublication: DoryDaemonVirtualMachinePlanPublication + public var machineSHA256: String + public var resourceRequirements: [DoryVMResourceRequirement] + public var startingLeaseDurationMilliseconds: Int64 + public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? + public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? + public var requestSHA256: String + public var machineAuthoritySHA256: String + + public func matches( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) -> Bool { + let definitionData = DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(request.planning.definition) + return request.planning.definition == definition + && request.planning.canonicalDefinitionData == definitionData + && DoryDaemonVirtualMachinePlanningCoordinator.sha256(definitionData) + == definitionSHA256 + && Self.sha256(request.planning.machine) == machineSHA256 + && request.workspacePublication == workspacePublication + && request.planning.publication == planPublication + && request.resourceRequirements.sorted(by: Self.requirementOrder) + == resourceRequirements + && request.startingLeaseDurationMilliseconds + == startingLeaseDurationMilliseconds + && request.planning.fallbackAuthorization == fallbackAuthorization + && request.planning.experimentalAuthorization == experimentalAuthorization + } + + private static func sha256(_ value: T) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + guard let data = try? encoder.encode(value) else { return "" } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func requirementOrder( + _ lhs: DoryVMResourceRequirement, + _ rhs: DoryVMResourceRequirement + ) -> Bool { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let left = (try? encoder.encode(lhs)) ?? Data() + let right = (try? encoder.encode(rhs)) ?? Data() + return left.lexicographicallyPrecedes(right) + } +} + /// One-use daemon authority. It re-resolves media, helper, host identity, and signed /// qualification immediately before desired-state publication. Start obtains a separate token. public final class DoryDaemonVirtualMachinePlanningPublicationAuthorization: @@ -337,6 +394,11 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: var phase: Phase var requestSHA256: String var machineAuthoritySHA256: String + var machineSHA256: String + var resourceRequirements: [DoryVMResourceRequirement] + var startingLeaseDurationMilliseconds: Int64 + var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? + var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? var definition: DoryVirtualMachineDefinition var definitionSHA256: String var sourceDefinitionSHA256: String? @@ -483,6 +545,33 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: } } + /// Reads and integrity-validates a durable journal before any external recovery authority is + /// consulted. Callers cannot select publication modes or authorization policy from live state. + public func recoveryDescriptor( + for machineID: String + ) throws -> DoryDaemonVirtualMachinePlanningRecoveryDescriptor? { + try instanceLock.withLock { + try withWorkspaceLock(machineID: machineID) { + guard let journal = try readJournal(machineID: machineID) else { return nil } + return DoryDaemonVirtualMachinePlanningRecoveryDescriptor( + machineID: journal.definition.identity.id, + definition: journal.definition, + definitionSHA256: journal.definitionSHA256, + workspacePublication: journal.workspacePublication, + planPublication: Self.planPublication(from: journal.planPublication), + machineSHA256: journal.machineSHA256, + resourceRequirements: journal.resourceRequirements, + startingLeaseDurationMilliseconds: + journal.startingLeaseDurationMilliseconds, + fallbackAuthorization: journal.fallbackAuthorization, + experimentalAuthorization: journal.experimentalAuthorization, + requestSHA256: journal.requestSHA256, + machineAuthoritySHA256: journal.machineAuthoritySHA256 + ) + } + } + } + private func execute( _ request: DoryDaemonVirtualMachinePlanningTransactionRequest ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { @@ -707,6 +796,14 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: phase: .prepared, requestSHA256: requestDigest, machineAuthoritySHA256: machineAuthoritySHA256, + machineSHA256: Self.sha256(Self.canonicalData(request.planning.machine)), + resourceRequirements: request.resourceRequirements.sorted( + by: Self.requirementOrder + ), + startingLeaseDurationMilliseconds: + request.startingLeaseDurationMilliseconds, + fallbackAuthorization: request.planning.fallbackAuthorization, + experimentalAuthorization: request.planning.experimentalAuthorization, definition: request.planning.definition, definitionSHA256: Self.sha256(canonicalDefinition), sourceDefinitionSHA256: existingDefinition.map { @@ -1218,7 +1315,9 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: _ definition: DoryVirtualMachineDefinition ) throws -> DoryDaemonVirtualMachineInventoryRequest { guard let boot = DoryDaemonVirtualMachinePlanningCoordinator - .primaryBootMedia(in: definition) else { + .primaryBootMedia(in: definition), + let launchArtifacts = DoryDaemonVirtualMachinePlanningCoordinator + .launchArtifactRequirements(for: definition) else { throw failure(.invalidRequest, "Workspace has no primary boot media.") } return DoryDaemonVirtualMachineInventoryRequest( @@ -1226,6 +1325,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: definitionRevision: definition.lifecycle.revision, guest: definition.guest, bootMedia: boot, + launchArtifacts: launchArtifacts, resources: definition.resources, devices: DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition), acceptableGraphics: definition.graphics.acceptableLevels, @@ -1410,6 +1510,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: ) != nil, Self.isSHA256(journal.requestSHA256), Self.isSHA256(journal.machineAuthoritySHA256), + Self.isSHA256(journal.machineSHA256), + journal.startingLeaseDurationMilliseconds > 0, + journal.resourceRequirements + == journal.resourceRequirements.sorted(by: Self.requirementOrder), Self.isSHA256(journal.definitionSHA256), journal.definitionSHA256 == Self.sha256( DoryDaemonVirtualMachinePlanningCoordinator diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift index 4c47eb5d..eabf5cdf 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift @@ -40,6 +40,8 @@ public struct DoryDaemonVirtualMachineProductionActivationFailure: public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable { public let machineManager: MachineManager public let planning: DoryDaemonVirtualMachineProductionPlanningContext + public let planningController: + DoryDaemonVirtualMachineProductionPlanningController public let backendRuntimeBuildIdentifiers: [ DoryVirtualizationBackendIdentity: String ] @@ -51,12 +53,15 @@ public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable { init( machineManager: MachineManager, planning: DoryDaemonVirtualMachineProductionPlanningContext, + planningController: + DoryDaemonVirtualMachineProductionPlanningController, backendRuntimeBuildIdentifiers: [ DoryVirtualizationBackendIdentity: String ] ) { self.machineManager = machineManager self.planning = planning + self.planningController = planningController self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers } } @@ -78,13 +83,11 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { /// escape this boundary or poison a retry. public func activate( store: DoryComponentStore, - machineConfiguration: MachineManagerConfiguration, - recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding + machineConfiguration: MachineManagerConfiguration ) -> DoryDaemonVirtualMachineProductionActivationResult { activate( store: store, machineConfiguration: machineConfiguration, - recoveryProvider: recoveryProvider, appVersion: Self.compiledDaemonVersion, publicKey: DoryComponentDefaults.publicKey, expectedArchitecture: DoryComponentDefaults.architecture @@ -96,7 +99,6 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { func activate( store: DoryComponentStore, machineConfiguration: MachineManagerConfiguration, - recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding, appVersion: String, publicKey: String, expectedArchitecture: String @@ -180,7 +182,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { runtimeVerifier: material.runtimeVerifier, hostProbe: material.hostProbe, mutationAuthority: machineManager, - recoveryProvider: recoveryProvider + recoveryProvider: DoryDaemonVirtualMachineProductionRecoveryProvider( + stateDirectory: canonicalStateDirectory + ) ) let planning: DoryDaemonVirtualMachineProductionPlanningContext switch composition.resolve() { @@ -225,6 +229,8 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { return .activated(DoryDaemonVirtualMachineProductionActivationContext( machineManager: machineManager, planning: planning, + planningController: + DoryDaemonVirtualMachineProductionPlanningController(planning: planning), backendRuntimeBuildIdentifiers: Dictionary( uniqueKeysWithValues: material.runtimes.map { ($0.descriptor.identity, $0.runtimeBuildIdentifier) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift index 54980ba1..d7ace0e8 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift @@ -7,7 +7,7 @@ import Foundation /// facts, and lifecycle fence; a journal is never recovered from its persisted plan bytes alone. public protocol DoryDaemonVirtualMachinePlanningRecoveryProviding: Sendable { func recoveryRequest( - for machineID: String + for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? } @@ -284,10 +284,47 @@ public final class DoryDaemonVirtualMachineProductionPlanningCompositionFactory: let pending = try pendingTransactionMachineIDs() var recovered: [String: String] = [:] for machineID in pending { - guard let request = try recoveryProvider.recoveryRequest( - for: machineID - ), request.planning.definition.identity.id == machineID, - request.planning.machine.id == machineID else { + let descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor + do { + guard let recoveredDescriptor = try coordinator.recoveryDescriptor( + for: machineID + ), recoveredDescriptor.machineID == machineID else { + throw failure( + .recoveryRequestUnavailable, + machineID: machineID, + "The durable planning recovery descriptor is unavailable." + ) + } + descriptor = recoveredDescriptor + } catch { + throw failure( + .recoveryFailed, + machineID: machineID, + "The durable planning recovery descriptor failed validation." + ) + } + let request: DoryDaemonVirtualMachinePlanningTransactionRequest + do { + guard let recoveredRequest = try recoveryProvider.recoveryRequest( + for: descriptor + ) else { + throw failure( + .recoveryRequestUnavailable, + machineID: machineID, + "Exact authoritative recovery input is unavailable." + ) + } + request = recoveredRequest + } catch { + throw failure( + .recoveryFailed, + machineID: machineID, + "Authoritative recovery input failed validation." + ) + } + guard request.planning.definition.identity.id == machineID, + request.planning.machine.id == machineID, + descriptor.matches(request) else { throw failure( .recoveryRequestUnavailable, machineID: machineID, diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift new file mode 100644 index 00000000..5b78662d --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift @@ -0,0 +1,362 @@ +import Darwin +import DoryOperations +import Foundation + +public enum DoryDaemonVirtualMachineProductionPlanningControllerFailureCode: + String, Sendable, Equatable +{ + case invalidRequest = "invalid-request" + case mediaAuthorityMissing = "media-authority-missing" + case mediaAuthorityConflict = "media-authority-conflict" + case transactionRejected = "transaction-rejected" + case publicationMismatch = "publication-mismatch" +} + +public struct DoryDaemonVirtualMachineProductionPlanningControllerFailure: + Error, Sendable, Equatable +{ + public var code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode + public var message: String + + public init( + code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode, + message: String + ) { + self.code = code + self.message = message + } +} + +/// Daemon-local path binding supplied only after MachineManager has staged the artifact into its +/// private workspace. This value is intentionally non-Codable: host paths never become API or +/// desired-state authority. +public struct DoryDaemonVirtualMachinePlanningArtifactPublication: Sendable, Equatable { + public enum Mutability: Sendable, Equatable { + case immutable + case mutable + } + + public var reference: DoryVMResolverReference + public var path: String + public var kind: DoryBootMediaKind + public var source: DoryBootMediaSource + public var mutability: Mutability + public var expectedAuthorityRevision: UInt64? + + public init( + reference: DoryVMResolverReference, + path: String, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + mutability: Mutability, + expectedAuthorityRevision: UInt64? = nil + ) { + self.reference = reference + self.path = path + self.kind = kind + self.source = source + self.mutability = mutability + self.expectedAuthorityRevision = expectedAuthorityRevision + } +} + +public protocol DoryDaemonVirtualMachinePlanningTransactionCoordinating: Sendable { + func resolveReserveAndPublish( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult +} + +extension DoryDaemonVirtualMachinePlanningTransactionCoordinator: + DoryDaemonVirtualMachinePlanningTransactionCoordinating {} + +/// The sole production entry for a manager-owned native create/update/replan transaction. It +/// first establishes exact artifact authority, then delegates reserve -> bind -> publication to +/// the crash-safe coordinator, and finally proves the published plan and workspace are the exact +/// result. It never derives intent from compatibility environment variables or calls MachineManager +/// create/update recursively. +public final class DoryDaemonVirtualMachineProductionPlanningController: + @unchecked Sendable +{ + private let artifactAuthority: DoryVirtualMachineArtifactAuthority + private let coordinator: any DoryDaemonVirtualMachinePlanningTransactionCoordinating + private let workspaces: DoryWorkspaceRepository + private let plans: DoryResolvedMachinePlanRepository + + init( + planning: DoryDaemonVirtualMachineProductionPlanningContext + ) { + artifactAuthority = planning.artifactAuthority + coordinator = planning.coordinator + workspaces = planning.workspaces + plans = planning.plans + } + + init( + artifactAuthority: DoryVirtualMachineArtifactAuthority, + coordinator: any DoryDaemonVirtualMachinePlanningTransactionCoordinating, + workspaces: DoryWorkspaceRepository, + plans: DoryResolvedMachinePlanRepository + ) { + self.artifactAuthority = artifactAuthority + self.coordinator = coordinator + self.workspaces = workspaces + self.plans = plans + } + + public func resolveReserveAndPublish( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + artifacts publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { + guard request.planning.machine.id == request.planning.definition.identity.id, + let requirements = DoryDaemonVirtualMachinePlanningCoordinator + .launchArtifactRequirements(for: request.planning.definition), + requirements.count == publications.count, + Set(publications.map(\.reference)).count == publications.count else { + throw failure(.invalidRequest, "Planning artifacts do not match desired-state authority.") + } + let publicationsByReference = Dictionary( + uniqueKeysWithValues: publications.map { ($0.reference, $0) } + ) + for requirement in requirements { + guard let publication = publicationsByReference[requirement.reference], + publication.kind == requirement.kind, + publication.source == requirement.source, + (publication.mutability == .mutable) == requirement.mutable, + let launchPath = exactLaunchPath( + for: requirement, + definition: request.planning.definition, + machine: request.planning.machine + ), + publication.path == launchPath else { + throw failure( + .invalidRequest, + "Planning artifact path, kind, provenance, or mutability is not exact." + ) + } + try establishArtifactAuthority(publication) + } + + let result: DoryDaemonVirtualMachinePlanningTransactionResult + do { result = try coordinator.resolveReserveAndPublish(request) } + catch { + throw failure(.transactionRejected, "Production planning transaction failed closed.") + } + let workspace: DoryVirtualMachineDefinition + let plan: DoryResolvedMachinePlan + do { + workspace = try workspaces.readPersistedRecord( + id: request.planning.definition.identity.id + ).definition + plan = try plans.read(id: request.planning.definition.identity.id) + } catch { + throw failure(.publicationMismatch, "Published planning authority is unavailable.") + } + guard workspace == request.planning.definition, + plan == result.planning.resolvedPlan, + DoryMachineRuntimeIdentity.planSHA256(plan) + == result.planning.resolvedPlanSHA256 else { + throw failure(.publicationMismatch, "Published planning authority is not exact.") + } + return result + } + + /// Compatibility launch paths remain daemon-local, but they are still launch authority. A + /// resolver publication must describe the exact path the selected adapter will consume; a + /// definition reference cannot authorize different bytes at an unrelated machine path. + private func exactLaunchPath( + for requirement: DoryDaemonVirtualMachineLaunchArtifactRequirement, + definition: DoryVirtualMachineDefinition, + machine: DoryMachineConfiguration + ) -> String? { + var paths = Set() + for usage in requirement.usages { + switch usage.kind { + case .boot: + guard let boot = definition.boot.devices.first(where: { + $0.id == usage.identifier && $0.artifact == requirement.reference + }) else { return nil } + switch boot.kind { + case .installedLinuxBootBundle: + paths.insert(machine.kernelPath) + case .installerISO: + guard let installerISOPath = machine.installerISOPath else { return nil } + paths.insert(installerISOPath) + case .virtualDisk: + paths.insert(machine.rootfsPath) + case .macOSRestoreImage: + // MachineManager has no macOS restore-media launch contract yet. + return nil + } + case .storage: + guard let storage = definition.storage.first(where: { + $0.id == usage.identifier && $0.artifact == requirement.reference + }), storage.role == .system else { + // Compatibility MachineManager cannot launch auxiliary typed disks yet. + return nil + } + paths.insert(machine.rootfsPath) + case .firmware: + // Firmware resolver paths are not represented by DoryMachineConfiguration yet. + return nil + } + } + guard paths.count == 1, let path = paths.first, !path.isEmpty, + path.hasPrefix("/"), + URL(fileURLWithPath: path).standardizedFileURL.path == path else { + return nil + } + return path + } + + private func establishArtifactAuthority( + _ publication: DoryDaemonVirtualMachinePlanningArtifactPublication + ) throws { + let current: DoryVirtualMachineArtifactAuthorityRecord? + do { current = try artifactAuthority.authorityRecord(reference: publication.reference) } + catch { throw failure(.mediaAuthorityConflict, "Media authority cannot be inspected.") } + + if let current { + if current.path == URL(fileURLWithPath: publication.path).standardizedFileURL.path, + current.kind == publication.kind, current.source == publication.source, + (try? artifactAuthority.resolve( + reference: publication.reference, + kind: publication.kind, + source: publication.source + )) != nil { + return + } + guard publication.mutability == .mutable, + publication.expectedAuthorityRevision == current.authorityRevision else { + throw failure(.mediaAuthorityConflict, "Existing media authority is not exact.") + } + } else if publication.expectedAuthorityRevision != nil { + throw failure(.mediaAuthorityMissing, "Expected media authority does not exist.") + } + + do { + switch publication.mutability { + case .immutable: + _ = try artifactAuthority.publishImmutable( + reference: publication.reference, + path: publication.path, + kind: publication.kind, + source: publication.source, + expectedAuthorityRevision: publication.expectedAuthorityRevision + ) + case .mutable: + _ = try artifactAuthority.publishMutable( + reference: publication.reference, + path: publication.path, + kind: publication.kind, + source: publication.source, + expectedAuthorityRevision: publication.expectedAuthorityRevision + ) + } + } catch { + throw failure(.mediaAuthorityConflict, "Exact media authority could not be published.") + } + } + + private func failure( + _ code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode, + _ message: String + ) -> DoryDaemonVirtualMachineProductionPlanningControllerFailure { + DoryDaemonVirtualMachineProductionPlanningControllerFailure( + code: code, + message: message + ) + } +} + +enum DoryDaemonVirtualMachineProductionRecoveryError: Error, Sendable, Equatable { + case invalidDescriptor + case machineUnavailable + case insecureMachineAuthority + case machineAuthorityChanged + case requestMismatch +} + +/// Production-owned recovery bridge. It reconstructs raw compatibility state only from the +/// manager's canonical private root and only after the coordinator has validated the journal +/// descriptor. No public activation argument can substitute a recovery request. +final class DoryDaemonVirtualMachineProductionRecoveryProvider: + DoryDaemonVirtualMachinePlanningRecoveryProviding, @unchecked Sendable +{ + private static let maximumMachineBytes = 16 * 1_024 * 1_024 + private let stateDirectory: String + + init(stateDirectory: String) { + self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path + } + + func recoveryRequest( + for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor + ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? { + guard descriptor.machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !descriptor.machineID.hasPrefix("."), + descriptor.definition.identity.id == descriptor.machineID else { + throw DoryDaemonVirtualMachineProductionRecoveryError.invalidDescriptor + } + let machine = try readMachine(id: descriptor.machineID) + let request = DoryDaemonVirtualMachinePlanningTransactionRequest( + planning: DoryDaemonVirtualMachinePlanningRequest( + definition: descriptor.definition, + canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(descriptor.definition), + machine: machine, + publication: descriptor.planPublication, + fallbackAuthorization: descriptor.fallbackAuthorization, + experimentalAuthorization: descriptor.experimentalAuthorization + ), + workspacePublication: descriptor.workspacePublication, + resourceRequirements: descriptor.resourceRequirements, + startingLeaseDurationMilliseconds: + descriptor.startingLeaseDurationMilliseconds + ) + guard descriptor.matches(request) else { + throw DoryDaemonVirtualMachineProductionRecoveryError.requestMismatch + } + return request + } + + private func readMachine(id: String) throws -> DoryMachineConfiguration { + let path = stateDirectory + "/" + id + "/machine.json" + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { + throw DoryDaemonVirtualMachineProductionRecoveryError.machineUnavailable + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_uid == geteuid(), before.st_nlink == 1, + before.st_mode & 0o077 == 0, + before.st_size > 0, + before.st_size <= Self.maximumMachineBytes else { + throw DoryDaemonVirtualMachineProductionRecoveryError.insecureMachineAuthority + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + var after = stat() + guard data.count == before.st_size, fstat(descriptor, &after) == 0, + Self.sameSnapshot(before, after) else { + throw DoryDaemonVirtualMachineProductionRecoveryError.machineAuthorityChanged + } + guard let machine = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: data + ), machine.id == id else { + throw DoryDaemonVirtualMachineProductionRecoveryError.machineUnavailable + } + return machine + } + + private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec + && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift index 40bb098a..508e2a2e 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -437,6 +437,7 @@ private struct DoryDaemonProductionPlanningHostIdentity: Sendable, Equatable { private struct DoryDaemonProductionPlanningMaterial: Sendable { var host: DoryDaemonProductionHostObservation var artifact: DoryVerifiedVirtualMachineArtifact + var launchArtifacts: [DoryResolvedMachineLaunchArtifact] var runtimes: [DoryDaemonVerifiedBackendRuntime] var qualifications: [DoryResolvedTrustedVirtualMachineQualification] var media: DoryDaemonVirtualMachineResolvedMedia @@ -521,6 +522,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: DoryDaemonVirtualMachineTrustedInventorySnapshot( hostFacts: initial.hostFacts, media: initial.media, + launchArtifacts: initial.launchArtifacts, backendRuntimes: initial.backendInventories, resourceAdmission: admission, runtimeQualifications: initial.qualifications.map(\.runtime), @@ -540,6 +542,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: current.artifact.media == initial.artifact.media, current.artifact.authorityRevision == initial.artifact.authorityRevision, + current.launchArtifacts == initial.launchArtifacts, current.runtimes == initial.runtimes, current.qualificationRecords == initial.qualificationRecords else { throw DoryDaemonProductionTrustInventoryError.invalidRequest @@ -560,11 +563,20 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: do { host = try hostProbe(stateDirectory) } catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest } let artifact: DoryVerifiedVirtualMachineArtifact + let launchArtifacts: [DoryResolvedMachineLaunchArtifact] do { + launchArtifacts = try resolveLaunchArtifacts(request.launchArtifacts) + guard let primary = launchArtifacts.first(where: { + $0.resolverReference == request.bootMedia.artifact + && $0.media.kind == request.bootMedia.kind + && $0.media.source == request.bootMedia.source + }) else { + throw DoryDaemonProductionTrustInventoryError.mediaUnavailable + } artifact = try artifactAuthority.resolve( - reference: request.bootMedia.artifact, - kind: request.bootMedia.kind, - source: request.bootMedia.source + reference: primary.resolverReference, + kind: primary.media.kind, + source: primary.media.source ) } catch { throw DoryDaemonProductionTrustInventoryError.mediaUnavailable @@ -673,6 +685,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: return DoryDaemonProductionPlanningMaterial( host: host, artifact: artifact, + launchArtifacts: launchArtifacts, runtimes: runtimes, qualifications: qualifications, media: DoryDaemonVirtualMachineResolvedMedia( @@ -733,7 +746,12 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: } let artifact: DoryVerifiedVirtualMachineArtifact + let launchArtifacts: [DoryResolvedMachineLaunchArtifact] do { + launchArtifacts = try resolveLaunchArtifacts(plan.launchArtifacts) + guard launchArtifacts == plan.launchArtifacts else { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } artifact = try artifactAuthority.resolve( reference: request.bootMediaReference, kind: plan.bootMedia.media.kind, @@ -839,6 +857,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: bootInspection: inspection, mutableProvenance: artifact.mutableProvenance ), + launchArtifacts: launchArtifacts, backendRuntimes: [runtimeInventory], resourceAdmission: admission, exactStartRuntimeQualification: qualification.runtime @@ -858,6 +877,49 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: } } + private func resolveLaunchArtifacts( + _ requirements: [DoryDaemonVirtualMachineLaunchArtifactRequirement] + ) throws -> [DoryResolvedMachineLaunchArtifact] { + try requirements.map { requirement in + let artifact = try artifactAuthority.resolve( + reference: requirement.reference, + kind: requirement.kind, + source: requirement.source + ) + guard (artifact.media.mutableProvenance != nil) == requirement.mutable else { + throw DoryDaemonProductionTrustInventoryError.mediaInvalid + } + return DoryResolvedMachineLaunchArtifact( + resolverReference: artifact.reference, + media: artifact.media, + authorityRevision: artifact.authorityRevision, + usages: requirement.usages, + mutableProvenanceEvidence: + artifact.mutableProvenance?.persistedAuditEvidence + ) + } + } + + private func resolveLaunchArtifacts( + _ planned: [DoryResolvedMachineLaunchArtifact] + ) throws -> [DoryResolvedMachineLaunchArtifact] { + try planned.map { expected in + let artifact = try artifactAuthority.resolve( + reference: expected.resolverReference, + kind: expected.media.kind, + source: expected.media.source + ) + return DoryResolvedMachineLaunchArtifact( + resolverReference: artifact.reference, + media: artifact.media, + authorityRevision: artifact.authorityRevision, + usages: expected.usages, + mutableProvenanceEvidence: + artifact.mutableProvenance?.persistedAuditEvidence + ) + } + } + private func hostFacts( host: DoryDaemonProductionHostObservation, runtime: DoryDaemonVerifiedBackendRuntime diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 2b6344ec..92308cc1 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -26,6 +26,28 @@ public struct DoryDaemonVirtualMachineResolvedMedia: Sendable { } } +public struct DoryDaemonVirtualMachineLaunchArtifactRequirement: Sendable, Equatable { + public var reference: DoryVMResolverReference + public var kind: DoryBootMediaKind + public var source: DoryBootMediaSource + public var mutable: Bool + public var usages: [DoryResolvedMachineLaunchArtifactUsage] + + public init( + reference: DoryVMResolverReference, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + mutable: Bool, + usages: [DoryResolvedMachineLaunchArtifactUsage] + ) { + self.reference = reference + self.kind = kind + self.source = source + self.mutable = mutable + self.usages = usages + } +} + public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatable { public var backend: DoryVirtualizationBackendIdentity public var runtimeBuildIdentifier: String @@ -48,6 +70,7 @@ public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatab public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { public var hostFacts: DoryAppleSiliconHostFacts public var media: DoryDaemonVirtualMachineResolvedMedia + public var launchArtifacts: [DoryResolvedMachineLaunchArtifact] public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory] public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] @@ -60,6 +83,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { public init( hostFacts: DoryAppleSiliconHostFacts, media: DoryDaemonVirtualMachineResolvedMedia, + launchArtifacts: [DoryResolvedMachineLaunchArtifact] = [], backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory], resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [], @@ -69,6 +93,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable { ) { self.hostFacts = hostFacts self.media = media + self.launchArtifacts = launchArtifacts self.backendRuntimes = backendRuntimes self.resourceAdmission = resourceAdmission self.runtimeQualifications = runtimeQualifications @@ -106,6 +131,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable { public var definitionRevision: UInt64 public var guest: DoryGuestPlatform public var bootMedia: DoryVMBootMediaReference + public var launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement] public var resources: DoryVMResourceRequest public var devices: DoryVirtualMachineDeviceCapabilityRequest public var acceptableGraphics: [DoryGraphicsAccelerationLevel] @@ -116,6 +142,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable { definitionRevision: UInt64, guest: DoryGuestPlatform, bootMedia: DoryVMBootMediaReference, + launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement], resources: DoryVMResourceRequest, devices: DoryVirtualMachineDeviceCapabilityRequest, acceptableGraphics: [DoryGraphicsAccelerationLevel], @@ -125,6 +152,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable { self.definitionRevision = definitionRevision self.guest = guest self.bootMedia = bootMedia + self.launchArtifacts = launchArtifacts self.resources = resources self.devices = devices self.acceptableGraphics = acceptableGraphics @@ -335,11 +363,17 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda throw failure(.bootMediaUnavailable, "The workspace has no primary boot device.") } let devices = Self.devices(for: definition) + guard let launchArtifactRequirements = Self.launchArtifactRequirements( + for: definition + ) else { + throw failure(.invalidDefinition, "Launch artifact requirements conflict.") + } let inventoryRequest = DoryDaemonVirtualMachineInventoryRequest( machineID: definition.identity.id, definitionRevision: definition.lifecycle.revision, guest: definition.guest, bootMedia: bootReference, + launchArtifacts: launchArtifactRequirements, resources: definition.resources, devices: devices, acceptableGraphics: definition.graphics.acceptableLevels, @@ -356,6 +390,19 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda snapshot.media.media.source == bootReference.source else { throw failure(.mediaInventoryMismatch, "Resolved media does not match the boot reference.") } + guard snapshot.launchArtifacts.map(\.resolverReference) + == launchArtifactRequirements.map(\.reference), + zip(snapshot.launchArtifacts, launchArtifactRequirements).allSatisfy({ artifact, requirement in + artifact.media.kind == requirement.kind + && artifact.media.source == requirement.source + && artifact.usages == requirement.usages + && (artifact.media.mutableProvenance != nil) == requirement.mutable + }) else { + throw failure( + .mediaInventoryMismatch, + "Resolved launch artifacts do not match desired-state references." + ) + } guard snapshot.resourceAdmission.admittedVirtualCPUCount == definition.resources.virtualCPUCount, snapshot.resourceAdmission.admittedMemoryBytes == definition.resources.memoryBytes, @@ -418,6 +465,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda backendDescriptor: backend.descriptor, backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, resolverReference: snapshot.media.reference, + launchArtifacts: snapshot.launchArtifacts, components: runtime.components.sorted { $0.componentIdentifier < $1.componentIdentifier }, @@ -479,6 +527,76 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda ) } + static func launchArtifactRequirements( + for definition: DoryVirtualMachineDefinition + ) -> [DoryDaemonVirtualMachineLaunchArtifactRequirement]? { + var requirements: [DoryVMResolverReference: + DoryDaemonVirtualMachineLaunchArtifactRequirement] = [:] + func insert( + reference: DoryVMResolverReference, + kind: DoryBootMediaKind, + source: DoryBootMediaSource, + mutable: Bool, + usage: DoryResolvedMachineLaunchArtifactUsage + ) -> Bool { + if var existing = requirements[reference] { + guard existing.kind == kind, existing.source == source, + existing.mutable == mutable else { return false } + existing.usages.append(usage) + requirements[reference] = existing + } else { + requirements[reference] = DoryDaemonVirtualMachineLaunchArtifactRequirement( + reference: reference, + kind: kind, + source: source, + mutable: mutable, + usages: [usage] + ) + } + return true + } + for boot in definition.boot.devices { + let mutable = boot.kind == .virtualDisk + guard insert( + reference: boot.artifact, + kind: boot.kind, + source: boot.source, + mutable: mutable, + usage: DoryResolvedMachineLaunchArtifactUsage( + kind: .boot, + identifier: boot.id, + readOnly: !mutable + ) + ) else { return nil } + } + for storage in definition.storage { + guard insert( + reference: storage.artifact, + kind: .virtualDisk, + source: storage.source, + mutable: !storage.readOnly, + usage: DoryResolvedMachineLaunchArtifactUsage( + kind: .storage, + identifier: storage.id, + readOnly: storage.readOnly + ) + ) else { return nil } + } + return requirements.values.map { requirement in + var requirement = requirement + requirement.usages.sort { + let left = $0.kind.rawValue + "\0" + $0.identifier + let right = $1.kind.rawValue + "\0" + $1.identifier + return left < right + } + return requirement + }.sorted { + let left = $0.reference.namespace + "\0" + $0.reference.identifier + let right = $1.reference.namespace + "\0" + $1.reference.identifier + return left < right + } + } + private static func plannerRequest( definition: DoryVirtualMachineDefinition, media: DoryBootMedia, diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 0cc83325..6f958e56 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -12,7 +12,7 @@ public struct DoryResolvedBackendComponentEvidence: Codable, Sendable, Equatable public var buildIdentifier: String public var artifactSHA256: String - public init( + init( componentIdentifier: String, buildIdentifier: String, artifactSHA256: String @@ -31,7 +31,7 @@ public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashab public var inspectionEvidence: DoryBootMediaInspectionAuditEvidence? public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? - public init( + init( resolverReference: DoryVMResolverReference?, media: DoryBootMedia, inspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil, @@ -44,6 +44,61 @@ public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashab } } +public enum DoryResolvedMachineLaunchArtifactUsageKind: + String, Codable, Sendable, Equatable, Hashable +{ + case boot + case storage + case firmware +} + +/// One launch-time use of an artifact. Keeping the stable desired-state identifier makes a plan +/// auditable without persisting any host path. +public struct DoryResolvedMachineLaunchArtifactUsage: + Codable, Sendable, Equatable, Hashable +{ + public var kind: DoryResolvedMachineLaunchArtifactUsageKind + public var identifier: String + public var readOnly: Bool + + public init( + kind: DoryResolvedMachineLaunchArtifactUsageKind, + identifier: String, + readOnly: Bool + ) { + self.kind = kind + self.identifier = identifier + self.readOnly = readOnly + } +} + +/// Exact, path-free authority for every artifact whose bytes can influence launch. Immutable +/// artifacts carry a content digest in `media`; mutable disks carry a daemon-issued provenance +/// revision and receipt. `authorityRevision` binds the exact private resolver publication. +public struct DoryResolvedMachineLaunchArtifact: + Codable, Sendable, Equatable, Hashable +{ + public var resolverReference: DoryVMResolverReference + public var media: DoryBootMedia + public var authorityRevision: UInt64 + public var usages: [DoryResolvedMachineLaunchArtifactUsage] + public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? + + public init( + resolverReference: DoryVMResolverReference, + media: DoryBootMedia, + authorityRevision: UInt64, + usages: [DoryResolvedMachineLaunchArtifactUsage], + mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil + ) { + self.resolverReference = resolverReference + self.media = media + self.authorityRevision = authorityRevision + self.usages = usages + self.mutableProvenanceEvidence = mutableProvenanceEvidence + } +} + /// Persisted audit references only. Signature verification and qualification decisions remain /// daemon-owned; these references let the daemon resolve and verify the exact evidence again. public struct DoryResolvedMachineQualificationEvidence: Codable, Sendable, Equatable, Hashable { @@ -401,6 +456,7 @@ public enum DoryResolvedMachinePlanValidationCode: String, Codable, Sendable, Ha case invalidResolverReference = "invalid-resolver-reference" case invalidMediaBinding = "invalid-media-binding" case invalidMediaEvidence = "invalid-media-evidence" + case invalidLaunchArtifactEvidence = "invalid-launch-artifact-evidence" case unsupportedRuntimeCombination = "unsupported-runtime-combination" case duplicateComponent = "duplicate-component" case unorderedComponents = "unordered-components" @@ -435,7 +491,7 @@ public struct DoryResolvedMachinePlanValidationIssue: Codable, Sendable, Equatab /// decision or non-secret audit reference and is replaced whenever any bound evidence changes. public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { public static let oldestSupportedSchemaVersion: UInt16 = 1 - public static let currentSchemaVersion: UInt16 = 2 + public static let currentSchemaVersion: UInt16 = 3 public var schemaVersion: UInt16 public var sourceSchemaVersion: UInt16 @@ -452,6 +508,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { public var backendRuntimeBuildIdentifier: String public var virtualHardwareABIVersion: UInt16 public var bootMedia: DoryResolvedMachineBootMedia + public var launchArtifacts: [DoryResolvedMachineLaunchArtifact] public var components: [DoryResolvedBackendComponentEvidence] public var devices: DoryVirtualMachineDeviceCapabilityRequest public var graphics: DoryGraphicsAccelerationLevel @@ -475,6 +532,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { backendRuntimeBuildIdentifier: String, virtualHardwareABIVersion: UInt16, bootMedia: DoryResolvedMachineBootMedia, + launchArtifacts: [DoryResolvedMachineLaunchArtifact], components: [DoryResolvedBackendComponentEvidence], devices: DoryVirtualMachineDeviceCapabilityRequest, graphics: DoryGraphicsAccelerationLevel, @@ -500,6 +558,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier self.virtualHardwareABIVersion = virtualHardwareABIVersion self.bootMedia = bootMedia + self.launchArtifacts = launchArtifacts self.components = components self.devices = devices self.graphics = graphics @@ -523,6 +582,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { backendDescriptor: MachineBackendDescriptor, backendRuntimeBuildIdentifier: String, resolverReference: DoryVMResolverReference?, + launchArtifacts: [DoryResolvedMachineLaunchArtifact], components: [DoryResolvedBackendComponentEvidence], resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, hostQualification: DoryResolvedHostQualificationEvidence, @@ -570,6 +630,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { inspectionEvidence: selectedCapability.bootMediaInspectionEvidence, mutableProvenanceEvidence: selectedCapability.mutableBootMediaProvenanceEvidence ), + launchArtifacts: launchArtifacts, components: components, devices: selectedCapability.request.devices, graphics: selectedCapability.request.graphics, @@ -605,6 +666,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { case backendRuntimeBuildID case virtualHardwareABIVersion case bootMedia + case launchArtifacts case components case componentDigests case devices @@ -652,6 +714,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { resolverReference: nil, media: try container.decode(DoryBootMedia.self, forKey: .bootMedia) ) + launchArtifacts = [] let legacyDigests = try container.decode( [String: String].self, forKey: .componentDigests @@ -674,16 +737,14 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { resourceAdmission = nil hostQualification = nil experimentalAuthorization = nil - case Self.currentSchemaVersion: - schemaVersion = persistedSchema - sourceSchemaVersion = try container.decodeIfPresent( - UInt16.self, - forKey: .sourceSchemaVersion - ) ?? persistedSchema - migrationDisposition = try container.decode( - DoryResolvedMachinePlanMigrationDisposition.self, - forKey: .migrationDisposition - ) + case 2, Self.currentSchemaVersion: + schemaVersion = Self.currentSchemaVersion + sourceSchemaVersion = persistedSchema + migrationDisposition = persistedSchema == Self.currentSchemaVersion + ? try container.decode( + DoryResolvedMachinePlanMigrationDisposition.self, + forKey: .migrationDisposition + ) : .requiresReplanning machineID = try container.decode(String.self, forKey: .machineID) definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision) definitionSHA256 = try container.decodeIfPresent(String.self, forKey: .definitionSHA256) @@ -711,6 +772,11 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { forKey: .virtualHardwareABIVersion ) bootMedia = try container.decode(DoryResolvedMachineBootMedia.self, forKey: .bootMedia) + launchArtifacts = persistedSchema == Self.currentSchemaVersion + ? try container.decode( + [DoryResolvedMachineLaunchArtifact].self, + forKey: .launchArtifacts + ) : [] components = try container.decode( [DoryResolvedBackendComponentEvidence].self, forKey: .components @@ -767,6 +833,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { try container.encode(backendRuntimeBuildIdentifier, forKey: .backendRuntimeBuildIdentifier) try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion) try container.encode(bootMedia, forKey: .bootMedia) + try container.encode(launchArtifacts, forKey: .launchArtifacts) try container.encode(components, forKey: .components) try container.encode(devices, forKey: .devices) try container.encode(graphics, forKey: .graphics) @@ -812,6 +879,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { } validateBootMedia(into: &issues) + validateLaunchArtifacts(into: &issues) validateComponents(into: &issues) validateQualifications(into: &issues) validateResourceAdmission(into: &issues) @@ -889,6 +957,85 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { } } + private func validateLaunchArtifacts( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + func add(_ field: String) { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidLaunchArtifactEvidence, + field: field + )) + } + guard !launchArtifacts.isEmpty else { + add("launchArtifacts") + return + } + let ordered = launchArtifacts.sorted { lhs, rhs in + let left = lhs.resolverReference.namespace + "\0" + + lhs.resolverReference.identifier + let right = rhs.resolverReference.namespace + "\0" + + rhs.resolverReference.identifier + return left < right + } + if ordered != launchArtifacts { add("launchArtifacts") } + + var references: Set = [] + var bootBindingIsPresent = false + for (index, artifact) in launchArtifacts.enumerated() { + let field = "launchArtifacts[\(index)]" + guard Self.isSafeResolverReference(artifact.resolverReference), + references.insert(artifact.resolverReference).inserted, + artifact.authorityRevision > 0, + !artifact.usages.isEmpty else { + add(field) + continue + } + let usages = artifact.usages.sorted { + ($0.kind.rawValue, $0.identifier) < ($1.kind.rawValue, $1.identifier) + } + if usages != artifact.usages + || Set(usages.map { $0.kind.rawValue + "\0" + $0.identifier }).count + != usages.count + || usages.contains(where: { !Self.isSafeIdentifier($0.identifier) }) { + add("\(field).usages") + } + + let immutable = artifact.media.artifactSHA256 + let mutable = artifact.media.mutableProvenance + let identityIsValid = (immutable.map(Self.isSHA256) ?? false) + != (mutable != nil) + if !identityIsValid { add("\(field).media") } + if let evidence = artifact.mutableProvenanceEvidence { + if mutable == nil + || evidence.provenance != mutable + || !Self.isSafeEvidenceIdentifier(evidence.receiptIdentity) + || !Self.isSHA256(evidence.receiptSHA256) + || !Self.isSafeEvidenceIdentifier(evidence.resolverID) + || evidence.resolverVersion == 0 { + add("\(field).mutableProvenanceEvidence") + } + } else if mutable != nil { + add("\(field).mutableProvenanceEvidence") + } + if mutable != nil && artifact.media.kind != .virtualDisk { + add("\(field).media.kind") + } + for usage in artifact.usages where usage.kind == .storage { + if artifact.media.kind != .virtualDisk + || (usage.readOnly && immutable == nil) + || (!usage.readOnly && mutable == nil) { + add("\(field).usages") + } + } + if artifact.usages.contains(where: { $0.kind == .boot }), + artifact.resolverReference == bootMedia.resolverReference, + artifact.media == bootMedia.media { + bootBindingIsPresent = true + } + } + if !bootBindingIsPresent { add("bootMedia.resolverReference") } + } + private func validateComponents( into issues: inout [DoryResolvedMachinePlanValidationIssue] ) { @@ -1276,6 +1423,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, public var backendRuntimeBuildIdentifier: String public var virtualHardwareABIVersion: UInt16 public var bootMedia: DoryResolvedMachineBootMedia + public var launchArtifacts: [DoryResolvedMachineLaunchArtifact] public var components: [DoryResolvedBackendComponentEvidence] public var devices: DoryVirtualMachineDeviceCapabilityRequest public var graphics: DoryGraphicsAccelerationLevel @@ -1293,6 +1441,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, backendRuntimeBuildIdentifier: String, virtualHardwareABIVersion: UInt16, bootMedia: DoryResolvedMachineBootMedia, + launchArtifacts: [DoryResolvedMachineLaunchArtifact], components: [DoryResolvedBackendComponentEvidence], devices: DoryVirtualMachineDeviceCapabilityRequest, graphics: DoryGraphicsAccelerationLevel, @@ -1309,6 +1458,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier self.virtualHardwareABIVersion = virtualHardwareABIVersion self.bootMedia = bootMedia + self.launchArtifacts = launchArtifacts self.components = components self.devices = devices self.graphics = graphics @@ -1327,6 +1477,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier virtualHardwareABIVersion = plan.virtualHardwareABIVersion bootMedia = plan.bootMedia + launchArtifacts = plan.launchArtifacts components = plan.components devices = plan.devices graphics = plan.graphics @@ -1337,6 +1488,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, hostQualification = plan.hostQualification experimentalAuthorization = plan.experimentalAuthorization } + } public struct DoryResolvedMachinePlanStartRevalidationInput: Codable, Sendable, Equatable, Hashable { @@ -1373,6 +1525,7 @@ public enum DoryResolvedMachinePlanRevalidationCode: String, Codable, Sendable, case backendRuntimeBuildMismatch = "backend-runtime-build-mismatch" case virtualHardwareABIMismatch = "virtual-hardware-abi-mismatch" case bootMediaEvidenceMismatch = "boot-media-evidence-mismatch" + case launchArtifactEvidenceMismatch = "launch-artifact-evidence-mismatch" case componentEvidenceMismatch = "component-evidence-mismatch" case deviceContractMismatch = "device-contract-mismatch" case graphicsMismatch = "graphics-mismatch" @@ -1478,6 +1631,12 @@ public enum DoryResolvedMachinePlanStartValidator { field: "virtualHardwareABIVersion" ) compare(runtime.bootMedia, plan.bootMedia, code: .bootMediaEvidenceMismatch, field: "bootMedia") + compare( + runtime.launchArtifacts, + plan.launchArtifacts, + code: .launchArtifactEvidenceMismatch, + field: "launchArtifacts" + ) compare(runtime.components, plan.components, code: .componentEvidenceMismatch, field: "components") compare(runtime.devices, plan.devices, code: .deviceContractMismatch, field: "devices") compare(runtime.graphics, plan.graphics, code: .graphicsMismatch, field: "graphics") diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index f7876170..9835c5a1 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -213,7 +213,9 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { machineID: plan.machine.id, backend: descriptor, componentIdentifier: componentIdentifier, - executablePath: executablePath + executablePath: executablePath, + graphics: plan.capability.request.graphics, + devices: plan.capability.request.devices )) } diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index 0d2ff36e..603365a3 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -210,17 +210,25 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { public var backend: MachineBackendDescriptor public var componentIdentifier: String public var executablePath: String + /// Exact capability contract selected and revalidated by this adapter. These values must be + /// carried into process construction; a launcher may not reinterpret them as "automatic". + public var graphics: DoryGraphicsAccelerationLevel + public var devices: DoryVirtualMachineDeviceCapabilityRequest public init( machineID: String, backend: MachineBackendDescriptor, componentIdentifier: String, - executablePath: String + executablePath: String, + graphics: DoryGraphicsAccelerationLevel, + devices: DoryVirtualMachineDeviceCapabilityRequest ) { self.machineID = machineID self.backend = backend self.componentIdentifier = componentIdentifier self.executablePath = executablePath + self.graphics = graphics + self.devices = devices } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 421b330b..d0f630ed 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1240,6 +1240,8 @@ public final class MachineManager: @unchecked Sendable { backend: resolved.backendPlan.backend, runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, runtimeComponents: resolved.resolvedPlan.components, + graphics: resolved.resolvedPlan.graphics, + devices: resolved.resolvedPlan.devices, planRevision: resolved.resolvedPlan.planRevision, planSHA256: resolved.resolvedPlanSHA256, preSpawnAuthorization: preSpawnAuthorization @@ -1340,9 +1342,12 @@ public final class MachineManager: @unchecked Sendable { authorization.machine.id == binding.machineID, authorization.backend == binding.backend, binding.backend.identity == expectedBackend, + authorization.graphics == binding.graphics, + authorization.devices == binding.devices, !binding.executablePath.isEmpty else { + pendingResolvedStart = nil throw MachineManagerError.persistence( - "backend start lacks a validated resolved-plan authorization" + "backend start does not match the exact resolved-plan launch contract" ) } let executableSHA256: String @@ -1585,13 +1590,17 @@ public final class MachineManager: @unchecked Sendable { // This single-use daemon-owned check deliberately sits after all persisted // machine/plan validation and immediately before any path-derived launch arguments // are read. Failure consumes the token and cannot reach processStarter. - if launchBinding != nil { + if let launchBinding { guard let preSpawnAuthorization else { throw MachineManagerError.persistence( "resolved launch is missing final pre-spawn authorization" ) } try preSpawnAuthorization.authorize() + try revalidateMaterializedInstalledLinuxBootRuntimeImmediatelyBeforeSpawn( + entry.configuration, + launchBinding: launchBinding + ) } processConfiguration = try self.processConfiguration( for: entry.configuration, @@ -2078,6 +2087,10 @@ public final class MachineManager: @unchecked Sendable { return try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { let updateError = error + // The handoff callback publishes `.failed` before it can acquire operationLock to + // terminalize the readiness journal. update() already owns operationLock here, so it + // must settle that failed start before opening the rollback stop/start transaction. + failActiveStartLifecycle(id: id, stepID: "start.readiness-failed") _ = try? stopImplementation(id: id, journalLifecycle: true) do { try persist(current) @@ -2954,7 +2967,8 @@ public final class MachineManager: @unchecked Sendable { for: machine, handoffPath: handoffPath, baseArguments: target.baseArguments, - acceleratedDesktop: target.acceleratedDesktop + acceleratedDesktop: target.acceleratedDesktop, + resolvedLaunchBinding: resolvedLaunchBinding ), logPath: "\(configuration.logDirectory)/\(machine.id).log", restartPolicy: configuration.requiresReadyHandoff @@ -3054,7 +3068,8 @@ public final class MachineManager: @unchecked Sendable { for machine: DoryMachineConfiguration, handoffPath: String?, baseArguments: [String], - acceleratedDesktop: Bool + acceleratedDesktop: Bool, + resolvedLaunchBinding: MachineBackendLaunchBinding? ) throws -> [String] { guard configuration.passMachineArguments else { return baseArguments @@ -3094,10 +3109,64 @@ public final class MachineManager: @unchecked Sendable { if let handoffPath { arguments.append(contentsOf: ["--handoff-sock", handoffPath]) } + if let resolvedLaunchBinding { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let deviceContract = String( + decoding: try encoder.encode(resolvedLaunchBinding.devices), + as: UTF8.self + ) + arguments.append(contentsOf: [ + "--resolved-graphics", resolvedLaunchBinding.graphics.rawValue, + "--resolved-devices", deviceContract, + ]) + } for share in machine.shares { arguments.append(contentsOf: ["--share", share.argumentValue]) } - for (key, value) in machine.environment.sorted(by: { $0.key < $1.key }) { + var launchEnvironment = machine.environment + if let resolvedLaunchBinding { + launchEnvironment.removeValue(forKey: DoryDesktopVMMPreference.environmentKey) + launchEnvironment.removeValue(forKey: DoryDesktopGraphicsPreference.environmentKey) + launchEnvironment.removeValue( + forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey + ) + switch resolvedLaunchBinding.backend.identity { + case .doryHypervisor: + let preference: DoryDesktopGraphicsPreference + switch resolvedLaunchBinding.graphics { + case .hardwareAccelerated3D: + preference = .virglVenus + case .hostAcceleratedDisplay: + preference = .virgl + case .software: + preference = .software + case .none: + throw MachineManagerError.persistence( + "raw-Hypervisor desktop cannot satisfy a no-graphics resolved plan" + ) + } + launchEnvironment[DoryDesktopGraphicsPreference.environmentKey] + = preference.rawValue + launchEnvironment[DoryDesktopVMMPreference.environmentKey] + = DoryDesktopVMMPreference.accelerated.rawValue + case .appleVirtualizationFramework: + guard resolvedLaunchBinding.graphics == .hostAcceleratedDisplay + || (machine.displayMode == .headless + && resolvedLaunchBinding.graphics == .none) else { + throw MachineManagerError.persistence( + "Virtualization.framework cannot satisfy the exact resolved graphics contract" + ) + } + launchEnvironment[DoryDesktopVMMPreference.environmentKey] + = DoryDesktopVMMPreference.compatible.rawValue + default: + throw MachineManagerError.persistence( + "resolved backend has no exact graphics argument contract" + ) + } + } + for (key, value) in launchEnvironment.sorted(by: { $0.key < $1.key }) { arguments.append(contentsOf: ["--env", "\(key)=\(value)"]) } let isSandbox = machine.environment["DORY_SANDBOX"] == "1" @@ -3745,6 +3814,44 @@ public final class MachineManager: @unchecked Sendable { } } + /// Installed-Linux acceleration launches materialized kernel/initrd paths rather than the + /// authority-bound bundle itself. Re-materialize from the fully verified bundle only after + /// the single-use pre-spawn authorization has succeeded so stale or replaced derived files + /// can never reach process construction. + private func revalidateMaterializedInstalledLinuxBootRuntimeImmediatelyBeforeSpawn( + _ machine: DoryMachineConfiguration, + launchBinding: MachineBackendLaunchBinding + ) throws { + guard launchBinding.backend.identity == .doryHypervisor, + machine.bootMode == .efi, + machine.installerISOPath == nil, + DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return + } + do { + try DoryInstalledLinuxBootBundle.materialize( + fromPath: machine.kernelPath, + kernelPath: machineInstalledLinuxKernelPath(id: machine.id), + initrdPath: machineInstalledLinuxInitrdPath(id: machine.id) + ) + guard Self.isPrivateRegularFile( + path: machineInstalledLinuxKernelPath(id: machine.id) + ), Self.isPrivateRegularFile( + path: machineInstalledLinuxInitrdPath(id: machine.id) + ) else { + throw MachineManagerError.persistence( + "materialized installed-Linux launch artifacts are not private regular files" + ) + } + } catch let error as MachineManagerError { + throw error + } catch { + throw MachineManagerError.persistence( + "installed-Linux launch artifacts failed final verification: \(error)" + ) + } + } + private func machineFirmwareIdentifierPath(id: String) -> String { "\(machineStateDirectory(id: id))/MachineIdentifier" } @@ -5991,6 +6098,16 @@ public final class MachineManager: @unchecked Sendable { existing.depth += 1 return existing } + // A handoff publishes the runtime state before its callback can acquire operationLock to + // settle the readiness journal. If the very next user mutation wins that race, it owns + // operationLock already and can deterministically finish the committed start itself + // instead of rejecting a healthy running machine as an active lifecycle conflict. + if activeLifecycleOperations[id]?.operation.kind == .starting { + lock.lock() + let readinessCommitted = machines[id]?.state == .running + lock.unlock() + if readinessCommitted { completeActiveStartLifecycle(id: id) } + } guard activeLifecycleOperations[id] == nil else { throw MachineManagerError.persistence( "machine \(id) already has an active lifecycle mutation" @@ -7604,6 +7721,8 @@ private struct PendingResolvedMachineStart { var backend: MachineBackendDescriptor var runtimeBuildIdentifier: String var runtimeComponents: [DoryResolvedBackendComponentEvidence] + var graphics: DoryGraphicsAccelerationLevel + var devices: DoryVirtualMachineDeviceCapabilityRequest var planRevision: UInt64 var planSHA256: String var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift index b3c05d01..b7cd4061 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift @@ -42,6 +42,33 @@ final class DoryVirtualMachineArtifactAuthorityTests: XCTestCase { } } + func testReadOnlyVirtualDiskCanBePublishedAsImmutable() throws { + try withFixture("immutable-read-only-disk") { fixture in + let disk = try fixture.file( + "tools.raw", + data: Data(repeating: 0x5a, count: 4_096) + ) + let published = try fixture.authority.publishImmutable( + reference: fixture.reference, + path: disk, + kind: .virtualDisk, + source: .bundledByDory + ) + + XCTAssertEqual(published.media.kind, .virtualDisk) + XCTAssertNotNil(published.media.artifactSHA256) + XCTAssertNil(published.media.mutableProvenance) + XCTAssertEqual( + try fixture.authority.resolve( + reference: fixture.reference, + kind: .virtualDisk, + source: .bundledByDory + ).media, + published.media + ) + } + } + func testMutableArtifactRequiresExplicitRevisionPublication() throws { try withFixture("mutable") { fixture in let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096)) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 5fcfea08..b3fc58b9 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -7,7 +7,7 @@ struct DoryVirtualMachineDefinitionTests { private let gibibyte: UInt64 = 1_073_741_824 private let nowMilliseconds: Int64 = 1_787_200_000_000 - @Test("schema 2 round trips with stable resolver and timestamp representations") + @Test("schema 3 round trips with stable resolver and timestamp representations") func currentRoundTrip() throws { let original = linuxDefinition() #expect(original.isValid) @@ -19,7 +19,7 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded == original) let json = try #require(String(data: data, encoding: .utf8)) - #expect(json.contains("\"schemaVersion\":2")) + #expect(json.contains("\"schemaVersion\":3")) #expect(json.contains("\"virtualHardwareABIVersion\":1")) #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000")) #expect(json.contains("\"namespace\":\"boot\"")) @@ -30,7 +30,7 @@ struct DoryVirtualMachineDefinitionTests { #expect(!json.contains("hostLocationID")) } - @Test("schema 2 records written before typed guest intent retain compatibility defaults") + @Test("schema 2 records migrate storage provenance and typed-intent defaults") func additiveSchemaTwoMigration() throws { let encoder = JSONEncoder() let data = try encoder.encode(linuxDefinition()) @@ -39,6 +39,10 @@ struct DoryVirtualMachineDefinitionTests { ) object.removeValue(forKey: "guestIdentityIntent") object.removeValue(forKey: "clipboardPolicy") + object["schemaVersion"] = 2 + var storage = try #require(object["storage"] as? [[String: Any]]) + for index in storage.indices { storage[index].removeValue(forKey: "source") } + object["storage"] = storage let oldSchemaTwo = try JSONSerialization.data(withJSONObject: object) let decoded = try JSONDecoder().decode( @@ -47,6 +51,8 @@ struct DoryVirtualMachineDefinitionTests { ) #expect(decoded.guestIdentityIntent == .unspecified) #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(decoded.schemaVersion == 3) + #expect(decoded.storage.allSatisfy { $0.source == .userProvided }) #expect(decoded.isValid) } @@ -176,19 +182,20 @@ struct DoryVirtualMachineDefinitionTests { """#.utf8) let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden) - #expect(migrated.schemaVersion == 2) + #expect(migrated.schemaVersion == 3) #expect(migrated.virtualHardwareABIVersion == 1) #expect(migrated.workload == .desktop) #expect(migrated.boot.phase == .install) #expect(migrated.boot.order == ["installer"]) #expect(migrated.boot.devices[0].artifact == reference("install", "ubuntu-24.04")) #expect(migrated.graphics.acceptableLevels == [.hostAcceleratedDisplay, .software]) + #expect(migrated.storage[0].source == .userProvided) #expect(migrated.lifecycle.createdAtUnixMilliseconds == 1_700_000_000_000) #expect(migrated.isValid) let upgraded = try JSONEncoder().encode(migrated) let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8)) - #expect(upgradedJSON.contains("\"schemaVersion\":2")) + #expect(upgradedJSON.contains("\"schemaVersion\":3")) #expect(upgradedJSON.contains("createdAtUnixMilliseconds")) #expect(!upgradedJSON.contains("\"bootMedia\"")) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index d5571bc2..dddf11dd 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -513,6 +513,18 @@ private final class TransactionFixture: @unchecked Sendable { media: DoryDaemonVirtualMachineResolvedMedia( reference: bootArtifact, media: media ), + launchArtifacts: [ + resolvedMutableStorageLaunchArtifact( + reference: diskArtifact, + source: .userProvided, + identifier: "system-disk" + ), + resolvedBootLaunchArtifacts( + reference: bootArtifact, + media: media, + identifier: "system" + )[0], + ], backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( backend: .doryHypervisor, runtimeBuildIdentifier: "raw-runtime-1", diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift index 9523a0ce..3bfb8d6c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift @@ -93,6 +93,49 @@ struct DoryDaemonVirtualMachineProductionPlanningCompositionTests { #expect(try context.resourceLedger.snapshot().leases.count == 2) } + @Test("production recovery replays only the exact private machine authority") + func productionRecoveryReplaysExactMachineAuthority() throws { + let machineID = "production-recovery-a" + let fixture = try CompositionFixture(ids: [machineID]) + try fixture.interrupt(machineID, at: .planBindingCommitted) + try fixture.writeMachineAuthority(machineID) + + let provider = DoryDaemonVirtualMachineProductionRecoveryProvider( + stateDirectory: fixture.root + ) + let context = try readyContext(fixture.factory( + recoveryProvider: provider + ).resolve()) + + #expect(context.recoveredTransactionIDs.keys.sorted() == [machineID]) + #expect(try context.plans.read(id: machineID).machineID == machineID) + } + + @Test("stale private machine authority cannot replay a durable journal") + func staleProductionMachineAuthorityFailsClosed() throws { + let machineID = "stale-production-recovery-b" + let fixture = try CompositionFixture(ids: [machineID]) + try fixture.interrupt(machineID, at: .planBindingCommitted) + try fixture.writeMachineAuthority(machineID) { machine in + machine.cpuCount += 1 + } + + let provider = DoryDaemonVirtualMachineProductionRecoveryProvider( + stateDirectory: fixture.root + ) + guard case let .unavailable(failure) = fixture.factory( + recoveryProvider: provider + ).resolve() else { + Issue.record("Expected stale private authority to fail closed") + return + } + #expect(failure.code == .recoveryFailed) + #expect(failure.machineID == machineID) + #expect(throws: DoryResolvedMachinePlanRepositoryError.self) { + _ = try fixture.plans.read(id: machineID) + } + } + private func readyContext( _ readiness: DoryDaemonVirtualMachineProductionPlanningReadiness ) throws -> DoryDaemonVirtualMachineProductionPlanningContext { @@ -157,13 +200,21 @@ private final class CompositionFixture: @unchecked Sendable { func factory( hasMutationAuthority: Bool = true, - hasRecoveryProvider: Bool = true + hasRecoveryProvider: Bool = true, + recoveryProvider: + (any DoryDaemonVirtualMachinePlanningRecoveryProviding)? = nil ) -> DoryDaemonVirtualMachineProductionPlanningCompositionFactory { + let selectedRecovery: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)? + if hasRecoveryProvider { + selectedRecovery = recoveryProvider ?? recovery + } else { + selectedRecovery = nil + } return DoryDaemonVirtualMachineProductionPlanningCompositionFactory( stateDirectory: root, backends: [backend], mutationAuthority: hasMutationAuthority ? authorizer : nil, - recoveryProvider: hasRecoveryProvider ? recovery : nil, + recoveryProvider: selectedRecovery, capabilityPlanner: CompositionCapabilityPlanner(), inventoryBuilder: { [trust] artifactAuthority, resourceLedger in _ = artifactAuthority @@ -173,6 +224,28 @@ private final class CompositionFixture: @unchecked Sendable { ) } + func writeMachineAuthority( + _ id: String, + mutate: (inout DoryMachineConfiguration) -> Void = { _ in } + ) throws { + var machine = try #require(requests[id]).planning.machine + mutate(&machine) + let directory = root + "/" + id + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let path = directory + "/machine.json" + try encoder.encode(machine).write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + } + func interrupt( _ id: String, at stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage @@ -288,6 +361,18 @@ private final class CompositionFixture: @unchecked Sendable { media: DoryDaemonVirtualMachineResolvedMedia( reference: mediaReference, media: media ), + launchArtifacts: [ + resolvedMutableStorageLaunchArtifact( + reference: diskReference, + source: .userProvided, + identifier: "system-disk" + ), + resolvedBootLaunchArtifacts( + reference: mediaReference, + media: media, + identifier: "system" + )[0], + ], backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( backend: .doryHypervisor, runtimeBuildIdentifier: "raw-runtime-1", @@ -375,11 +460,15 @@ private final class CompositionRecovery: var requestedIDs: [String] { lock.withLock { storage } } func recoveryRequest( - for machineID: String + for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? { + let machineID = descriptor.machineID lock.withLock { storage.append(machineID) } events.append("recovery:\(machineID)") - return requests[machineID] + guard let request = requests[machineID], descriptor.matches(request) else { + return nil + } + return request } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift new file mode 100644 index 00000000..3d33c750 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift @@ -0,0 +1,237 @@ +import DoryOperations +@testable import DorydKit +import Foundation +import Testing + +@Suite("Production VM planning controller") +struct DoryDaemonVirtualMachineProductionPlanningControllerTests { + @Test("controller publishes every exact boot and storage authority before planning") + func publishesClosedArtifactSet() throws { + let fixture = try ControllerFixture() + defer { fixture.cleanup() } + + do { + _ = try fixture.controller.resolveReserveAndPublish( + fixture.request, + artifacts: fixture.publications + ) + Issue.record("Expected the fixture transaction to stop after artifact publication") + } catch let failure + as DoryDaemonVirtualMachineProductionPlanningControllerFailure { + #expect(failure.code == .transactionRejected) + } + #expect(fixture.coordinator.callCount == 1) + #expect(try fixture.authority.resolve( + reference: fixture.bootReference, + kind: .installedLinuxBootBundle, + source: .bundledByDory + ).media.artifactSHA256 != nil) + #expect(try fixture.authority.resolve( + reference: fixture.diskReference, + kind: .virtualDisk, + source: .userProvided + ).media.mutableProvenance != nil) + } + + @Test("missing extra duplicate and mismatched artifact publications fail before planning") + func rejectsNonExactPublicationSets() throws { + let fixture = try ControllerFixture() + defer { fixture.cleanup() } + var cases: [[DoryDaemonVirtualMachinePlanningArtifactPublication]] = [ + [fixture.publications[0]], + fixture.publications + [fixture.publications[0]], + ] + var wrongSource = fixture.publications + wrongSource[1].source = .bundledByDory + cases.append(wrongSource) + var wrongMutability = fixture.publications + wrongMutability[1].mutability = .immutable + cases.append(wrongMutability) + var swappedPaths = fixture.publications + swappedPaths[0].path = fixture.diskPath + swappedPaths[1].path = fixture.bootPath + cases.append(swappedPaths) + + for publications in cases { + do { + _ = try fixture.controller.resolveReserveAndPublish( + fixture.request, + artifacts: publications + ) + Issue.record("Expected non-exact publication rejection") + } catch let failure + as DoryDaemonVirtualMachineProductionPlanningControllerFailure { + #expect(failure.code == .invalidRequest) + } + } + #expect(fixture.coordinator.callCount == 0) + } + + @Test("changed immutable boot bytes cannot reuse a published authority") + func changedPublishedArtifactFailsClosed() throws { + let fixture = try ControllerFixture() + defer { fixture.cleanup() } + _ = try? fixture.controller.resolveReserveAndPublish( + fixture.request, + artifacts: fixture.publications + ) + try Data("changed-boot".utf8).write( + to: URL(fileURLWithPath: fixture.bootPath) + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: fixture.bootPath + ) + + do { + _ = try fixture.controller.resolveReserveAndPublish( + fixture.request, + artifacts: fixture.publications + ) + Issue.record("Expected changed immutable authority rejection") + } catch let failure + as DoryDaemonVirtualMachineProductionPlanningControllerFailure { + #expect(failure.code == .mediaAuthorityConflict) + } + #expect(fixture.coordinator.callCount == 1) + } +} + +private final class ControllerFixture: @unchecked Sendable { + let root: String + let authority: DoryVirtualMachineArtifactAuthority + let coordinator = RejectingPlanningCoordinator() + let controller: DoryDaemonVirtualMachineProductionPlanningController + let request: DoryDaemonVirtualMachinePlanningTransactionRequest + let bootReference = DoryVMResolverReference( + namespace: "artifact", identifier: "controller-boot" + ) + let diskReference = DoryVMResolverReference( + namespace: "artifact", identifier: "controller-disk" + ) + let bootPath: String + let diskPath: String + let publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-production-planning-controller-\(UUID().uuidString)" + ).standardizedFileURL.path + try FileManager.default.createDirectory( + atPath: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + bootPath = root + "/boot.bundle" + diskPath = root + "/system.raw" + try Data("boot-bundle".utf8).write(to: URL(fileURLWithPath: bootPath)) + try Data(repeating: 0x31, count: 4_096).write(to: URL(fileURLWithPath: diskPath)) + for path in [bootPath, diskPath] { + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: path + ) + } + let resources = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 2 * 1_024 * 1_024 * 1_024, + diskBytes: 32 * 1_024 * 1_024 * 1_024 + ) + let definition = DoryVirtualMachineDefinition( + identity: DoryVirtualMachineIdentity( + id: "controller-vm", name: "Controller VM" + ), + guest: DoryGuestPlatform(family: .linux, architecture: .arm64), + workload: .desktop, + boot: DoryVMBootConfiguration( + phase: .normal, + devices: [DoryVMBootMediaReference( + id: "system", + role: .system, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + artifact: bootReference, + removable: false + )], + order: ["system"] + ), + backendPreference: DoryVMBackendPreference( + mode: .required, backend: .doryHypervisor + ), + graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]), + resources: resources, + storage: [DoryVMStorageAttachment( + id: "system-disk", + role: .system, + artifact: diskReference, + source: .userProvided, + capacityBytes: resources.diskBytes + )], + audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), + input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + let machine = DoryMachineConfiguration( + id: definition.identity.id, + kernelPath: bootPath, + rootfsPath: diskPath, + bootMode: .linuxKernel, + displayMode: .desktop + ) + request = DoryDaemonVirtualMachinePlanningTransactionRequest( + planning: DoryDaemonVirtualMachinePlanningRequest( + definition: definition, + canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator + .canonicalDefinitionData(definition), + machine: machine, + publication: .create + ), + workspacePublication: .create + ) + authority = DoryVirtualMachineArtifactAuthority(root: root + "/authority") + controller = DoryDaemonVirtualMachineProductionPlanningController( + artifactAuthority: authority, + coordinator: coordinator, + workspaces: DoryWorkspaceRepository(root: root), + plans: DoryResolvedMachinePlanRepository(root: root) + ) + publications = [ + DoryDaemonVirtualMachinePlanningArtifactPublication( + reference: bootReference, + path: bootPath, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + mutability: .immutable + ), + DoryDaemonVirtualMachinePlanningArtifactPublication( + reference: diskReference, + path: diskPath, + kind: .virtualDisk, + source: .userProvided, + mutability: .mutable + ), + ] + } + + func cleanup() { try? FileManager.default.removeItem(atPath: root) } +} + +private final class RejectingPlanningCoordinator: + DoryDaemonVirtualMachinePlanningTransactionCoordinating, @unchecked Sendable +{ + private let lock = NSLock() + private var calls = 0 + var callCount: Int { lock.withLock { calls } } + + func resolveReserveAndPublish( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest + ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult { + _ = request + lock.withLock { calls += 1 } + throw ControllerFixtureError.expectedRejection + } +} + +private enum ControllerFixtureError: Error { case expectedRejection } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index e027d1d2..cc9c312e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -262,6 +262,28 @@ struct DoryDaemonVirtualMachineProductionTrustTests { } } + @Test("mutable storage changed after planning is rejected before spawn") + func changedStorageFailsPreSpawnAuthorization() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .ready(context) = fixture.resolve(), + let provider = context.inventory + as? any DoryDaemonVirtualMachinePreSpawnAuthorizationProviding else { + Issue.record("Expected production pre-spawn authority") + return + } + let request = try fixture.makeBoundStartRequest() + let authorization = try provider.preSpawnAuthorization(for: request) + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: fixture.storagePath)) + try handle.write(contentsOf: Data("changed-storage".utf8)) + try handle.synchronize() + try handle.close() + + #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) { + try authorization.authorize() + } + } + @Test("start inventory refreshes volatile host resources") func startInventoryRefreshesHostResources() throws { let fixture = try ProductionTrustFixture() @@ -320,7 +342,6 @@ struct DoryDaemonVirtualMachineProductionTrustTests { let result = fixture.factory.activate( store: fixture.store, machineConfiguration: fixture.machineConfiguration, - recoveryProvider: ProductionEmptyPlanningRecovery(), appVersion: fixture.appVersion, publicKey: fixture.publicKey, expectedArchitecture: "arm64" @@ -349,12 +370,9 @@ struct DoryDaemonVirtualMachineProductionTrustTests { trustFloorActivationState: trustFloor ) defer { fixture.cleanup() } - let recovery = ProductionEmptyPlanningRecovery() - guard case let .unavailable(failure) = fixture.factory.activate( store: fixture.store, machineConfiguration: fixture.machineConfiguration, - recoveryProvider: recovery, appVersion: fixture.appVersion, publicKey: fixture.publicKey, expectedArchitecture: "arm64" @@ -368,7 +386,6 @@ struct DoryDaemonVirtualMachineProductionTrustTests { guard case let .activated(context) = fixture.factory.activate( store: fixture.store, machineConfiguration: fixture.machineConfiguration, - recoveryProvider: recovery, appVersion: fixture.appVersion, publicKey: fixture.publicKey, expectedArchitecture: "arm64" @@ -406,6 +423,15 @@ struct DoryDaemonVirtualMachineProductionTrustTests { artifact: reference, removable: false ), + launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement( + reference: reference, + kind: .installedLinuxBootBundle, + source: .bundledByDory, + mutable: false, + usages: [DoryResolvedMachineLaunchArtifactUsage( + kind: .boot, identifier: "system", readOnly: true + )] + )], resources: DoryVMResourceRequest( virtualCPUCount: 2, memoryBytes: 2 * 1_024 * 1_024 * 1_024, @@ -524,17 +550,6 @@ private enum ProductionTrustFixtureError: Error { case runtimeRejected } -private struct ProductionEmptyPlanningRecovery: - DoryDaemonVirtualMachinePlanningRecoveryProviding -{ - func recoveryRequest( - for machineID: String - ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? { - _ = machineID - return nil - } -} - private final class ProductionCallCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -615,6 +630,15 @@ private func productionPlanningRequest( artifact: plan.bootMedia.resolverReference!, removable: false ), + launchArtifacts: plan.launchArtifacts.map { artifact in + DoryDaemonVirtualMachineLaunchArtifactRequirement( + reference: artifact.resolverReference, + kind: artifact.media.kind, + source: artifact.media.source, + mutable: artifact.media.mutableProvenance != nil, + usages: artifact.usages + ) + }, resources: DoryVMResourceRequest( virtualCPUCount: plan.resourceAdmission!.admittedVirtualCPUCount, memoryBytes: plan.resourceAdmission!.admittedMemoryBytes, @@ -642,6 +666,11 @@ private final class ProductionTrustFixture: @unchecked Sendable { namespace: "artifact", identifier: "qualified-linux-boot" ) + let storagePath: String + let storageReference = DoryVMResolverReference( + namespace: "artifact", + identifier: "qualified-linux-disk" + ) let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) let host = DoryDaemonProductionHostObservation( hardwareModelIdentifier: "Mac16,1", @@ -711,14 +740,28 @@ private final class ProductionTrustFixture: @unchecked Sendable { toPath: mediaPath ) mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath) - _ = try DoryVirtualMachineArtifactAuthority( + storagePath = root.appendingPathComponent("qualified-linux.raw").path + try Data(repeating: 0x5a, count: 4_096).write( + to: URL(fileURLWithPath: storagePath) + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: storagePath + ) + let artifactAuthority = DoryVirtualMachineArtifactAuthority( root: state.path + "/.artifact-authority" - ).publishImmutable( + ) + _ = try artifactAuthority.publishImmutable( reference: mediaReference, path: mediaPath, kind: .installedLinuxBootBundle, source: .bundledByDory ) + _ = try artifactAuthority.publishMutable( + reference: storageReference, + path: storagePath, + source: .userProvided + ) if installCatalog { try installCatalogFixture( @@ -876,6 +919,13 @@ private final class ProductionTrustFixture: @unchecked Sendable { trustedRuntimeQualification: qualification.runtime ) #expect(descriptor.availability.isUsable) + let storageArtifact = try DoryVirtualMachineArtifactAuthority( + root: machineConfiguration.stateDirectory + "/.artifact-authority" + ).resolve( + reference: storageReference, + kind: .virtualDisk, + source: .userProvided + ) let plan = DoryResolvedMachinePlan( machineID: binding.machineID, definitionRevision: binding.definitionRevision, @@ -893,6 +943,18 @@ private final class ProductionTrustFixture: @unchecked Sendable { resolverReference: mediaReference, media: media ), + launchArtifacts: resolvedBootLaunchArtifacts( + reference: mediaReference, media: media, identifier: "system" + ) + [DoryResolvedMachineLaunchArtifact( + resolverReference: storageArtifact.reference, + media: storageArtifact.media, + authorityRevision: storageArtifact.authorityRevision, + usages: [DoryResolvedMachineLaunchArtifactUsage( + kind: .storage, identifier: "system-disk", readOnly: false + )], + mutableProvenanceEvidence: + storageArtifact.mutableProvenance?.persistedAuditEvidence + )], components: [DoryResolvedBackendComponentEvidence( componentIdentifier: "dory-hv", buildIdentifier: runtimeBuildIdentifier, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index c4aab26e..4e735789 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -14,6 +14,9 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(result.resolvedPlan.definitionSHA256 == DoryDaemonVirtualMachinePlanningCoordinator.sha256(fixture.canonicalDefinition)) #expect(result.resolvedPlanSHA256.count == 64) + #expect(result.resolvedPlan.launchArtifacts.count == 2) + #expect(result.resolvedPlan.launchArtifacts.flatMap(\.usages).map(\.kind) + == [.storage, .boot]) #expect(result.backendPlan.backend.identity == .doryHypervisor) #expect(try fixture.store.read(id: fixture.definition.identity.id) == result.resolvedPlan) } @@ -124,6 +127,33 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { } } + @Test("changed storage authority rejects start evidence") + func storageEvidenceMismatch() throws { + let fixture = try Fixture() + let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest()) + var evidence = DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan) + evidence.launchArtifacts[0].authorityRevision += 1 + let collector = FixtureEvidenceCollector(collection: + DoryDaemonVirtualMachineStartEvidenceCollection( + capability: planned.plannerResult.selectedDescriptor!, + runtimeEvidence: evidence + )) + let resolver = DoryDaemonVirtualMachineLaunchPlanResolver( + registry: fixture.registry, + plans: fixture.store, + evidenceCollector: collector + ) + + #expect(throws: DoryDaemonVirtualMachineLaunchPlanFailure.self) { + _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest( + definition: fixture.definition, + canonicalDefinitionData: fixture.canonicalDefinition, + machine: fixture.machine, + expectedPlanRevision: planned.resolvedPlan.planRevision + )) + } + } + @Test("missing durable plan has a typed launch failure") func missingPlan() throws { let fixture = try Fixture() @@ -262,6 +292,18 @@ private struct Fixture { let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot( hostFacts: hostFacts(), media: DoryDaemonVirtualMachineResolvedMedia(reference: bootArtifact, media: media), + launchArtifacts: [ + resolvedMutableStorageLaunchArtifact( + reference: diskArtifact, + source: .userProvided, + identifier: "system-disk" + ), + resolvedBootLaunchArtifacts( + reference: bootArtifact, + media: media, + identifier: "system" + )[0], + ], backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory( backend: .doryHypervisor, runtimeBuildIdentifier: "raw-runtime-1", diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift new file mode 100644 index 00000000..d95aa8cb --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift @@ -0,0 +1,55 @@ +import DoryOperations +@testable import DorydKit + +func resolvedBootLaunchArtifacts( + reference: DoryVMResolverReference, + media: DoryBootMedia, + mutableEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil, + identifier: String = "primary" +) -> [DoryResolvedMachineLaunchArtifact] { + [DoryResolvedMachineLaunchArtifact( + resolverReference: reference, + media: media, + authorityRevision: 1, + usages: [DoryResolvedMachineLaunchArtifactUsage( + kind: .boot, + identifier: identifier, + readOnly: media.mutableProvenance == nil + )], + mutableProvenanceEvidence: mutableEvidence + )] +} + +func resolvedMutableStorageLaunchArtifact( + reference: DoryVMResolverReference, + source: DoryBootMediaSource, + identifier: String, + digestCharacter: Character = "9" +) -> DoryResolvedMachineLaunchArtifact { + let provenance = DoryMutableBootMediaProvenanceReference( + repositoryIdentity: "fixture-artifact-authority", + mediaIdentity: reference.namespace + "-" + reference.identifier, + revision: 1 + ) + return DoryResolvedMachineLaunchArtifact( + resolverReference: reference, + media: DoryBootMedia( + kind: .virtualDisk, + source: source, + mutableProvenance: provenance + ), + authorityRevision: 1, + usages: [DoryResolvedMachineLaunchArtifactUsage( + kind: .storage, + identifier: identifier, + readOnly: false + )], + mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "fixture-storage-receipt-1", + provenance: provenance, + receiptSHA256: String(repeating: String(digestCharacter), count: 64), + resolverID: "fixture-artifact-authority", + resolverVersion: 1 + ) + ) +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift index be75d5cc..a67e02ed 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift @@ -39,6 +39,28 @@ struct DoryResolvedMachinePlanTests { #expect(result.issues.contains { $0.code == .storedPlanInvalid }) } + @Test("schema v2 without launch-artifact evidence requires replanning") + func schemaV2LaunchArtifactMigration() throws { + let encoded = try JSONEncoder().encode(supportedPlan()) + var object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + object["schemaVersion"] = 2 + object["sourceSchemaVersion"] = 2 + object.removeValue(forKey: "launchArtifacts") + + let migrated = try JSONDecoder().decode( + DoryResolvedMachinePlan.self, + from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + #expect(migrated.schemaVersion == DoryResolvedMachinePlan.currentSchemaVersion) + #expect(migrated.sourceSchemaVersion == 2) + #expect(migrated.migrationDisposition == .requiresReplanning) + #expect(migrated.launchArtifacts.isEmpty) + #expect(migrated.validate().contains { $0.code == .legacyPlanRequiresReplanning }) + #expect(migrated.validate().contains { $0.code == .invalidLaunchArtifactEvidence }) + } + @Test("exact fresh evidence authorizes start") func exactStartEvidence() { let plan = supportedPlan() @@ -180,6 +202,7 @@ struct DoryResolvedMachinePlanTests { backendDescriptor: VirtualizationFrameworkLinuxMachineBackend.backendDescriptor, backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier, resolverReference: plan.bootMedia.resolverReference, + launchArtifacts: plan.launchArtifacts, components: plan.components, resourceAdmission: plan.resourceAdmission!, hostQualification: plan.hostQualification!, @@ -208,6 +231,7 @@ struct DoryResolvedMachinePlanTests { backendDescriptor: RawHVLinuxMachineBackend.backendDescriptor, backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier, resolverReference: plan.bootMedia.resolverReference, + launchArtifacts: plan.launchArtifacts, components: plan.components, resourceAdmission: plan.resourceAdmission!, hostQualification: plan.hostQualification!, @@ -708,6 +732,12 @@ private func supportedPlan() -> DoryResolvedMachinePlan { ), media: media ), + launchArtifacts: resolvedBootLaunchArtifacts( + reference: DoryVMResolverReference( + namespace: "artifact", identifier: "ubuntu-desktop-1" + ), + media: media + ), components: [ DoryResolvedBackendComponentEvidence( componentIdentifier: "dory-hv", @@ -792,6 +822,19 @@ private func mutableVZPlan() -> DoryResolvedMachinePlan { resolverVersion: 1 ) ), + launchArtifacts: resolvedBootLaunchArtifacts( + reference: DoryVMResolverReference( + namespace: "machine", identifier: "workspace-one-disk" + ), + media: media, + mutableEvidence: DoryMutableBootMediaProvenanceAuditEvidence( + receiptIdentity: "disk-receipt-7", + provenance: provenance, + receiptSHA256: digest("7"), + resolverID: "machine-store", + resolverVersion: 1 + ) + ), components: [DoryResolvedBackendComponentEvidence( componentIdentifier: "dory-vmm", buildIdentifier: "vz-runtime-1", diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index d2ba6144..856244a5 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -240,6 +240,30 @@ final class DoryVMMKitTests: XCTestCase { } } + func testParsesExactResolvedLaunchContract() throws { + let devices = DoryVirtualMachineDeviceCapabilityRequest( + audioOutput: true, + keyboard: true, + pointer: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encodedDevices = String(decoding: try encoder.encode(devices), as: UTF8.self) + let arguments = try parseDoryVMMArguments([ + "--resolved-graphics", "host-accelerated-display", + "--resolved-devices", encodedDevices, + ]) + + XCTAssertEqual(arguments.resolvedGraphics, .hostAcceleratedDisplay) + XCTAssertEqual(arguments.resolvedDevices, devices) + XCTAssertThrowsError(try parseDoryVMMArguments([ + "--resolved-graphics", "auto", + ])) + XCTAssertThrowsError(try parseDoryVMMArguments([ + "--resolved-devices", "{}", + ])) + } + func testRejectsInvalidDisplayModeArgument() throws { XCTAssertThrowsError(try parseDoryVMMArguments([ "--display-mode", "gui", @@ -577,6 +601,67 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: "\(base)/dorycfg")) } + func testResolvedVZContractControlsAttachedDesktopDevices() throws { + let base = "/tmp/dory-vmm-resolved-devices-\(getpid())-\(UInt32.random(in: 0.. DoryResolvedMachinePlan { ), media: media ), + launchArtifacts: resolvedBootLaunchArtifacts( + reference: DoryVMResolverReference( + namespace: "artifact", identifier: "qualified-linux" + ), + media: media + ), components: [DoryResolvedBackendComponentEvidence( componentIdentifier: "dory-hv", buildIdentifier: "raw-runtime-1", diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 4b46ca3e..44c4f420 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -96,6 +96,8 @@ final class MachineBackendTests: XCTestCase { let started = registry.start(plan) XCTAssertTrue(started.isSuccess) XCTAssertEqual(started.observation?.state, .running) + XCTAssertEqual(recorder.launchBindings.first?.graphics, .hostAcceleratedDisplay) + XCTAssertEqual(recorder.launchBindings.first?.devices, .minimumBootable) let stopped = registry.stop(MachineBackendRuntimeRequest( machineID: plan.machine.id, @@ -317,16 +319,21 @@ final class MachineBackendTests: XCTestCase { private final class OperationRecorder: @unchecked Sendable { private let lock = NSLock() private var recordedEvents: [String] = [] + private var recordedLaunchBindings: [MachineBackendLaunchBinding] = [] var events: [String] { lock.withLock { recordedEvents } } + var launchBindings: [MachineBackendLaunchBinding] { + lock.withLock { recordedLaunchBindings } + } + lazy var operations = MachineBackendCompatibilityOperations( - start: { [weak self] id in - self?.append("start:\(id)") + authorizedStart: { [weak self] binding in + self?.append(binding) return MachineBackendRuntimeObservation( - machineID: id, + machineID: binding.machineID, state: .running, processIdentifier: 42 ) @@ -340,4 +347,11 @@ private final class OperationRecorder: @unchecked Sendable { private func append(_ event: String) { lock.withLock { recordedEvents.append(event) } } + + private func append(_ binding: MachineBackendLaunchBinding) { + lock.withLock { + recordedLaunchBindings.append(binding) + recordedEvents.append("start:\(binding.machineID)") + } + } } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index cc0ebf62..8a7ac144 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -87,6 +87,194 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("adapter cannot substitute resolved graphics or devices") + func adapterCannotSubstituteLaunchContract() throws { + enum Mutation: CaseIterable, Sendable { case graphics, devices } + + for mutation in Mutation.allCases { + try withHarness("binding-substitution-\(mutation)") { manager, starter, _ in + let plans = MutablePlanStore() + let managerOperations = manager.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + let mutatingOperations = MachineBackendCompatibilityOperations( + authorizedStart: { binding in + var changed = binding + switch mutation { + case .graphics: + changed.graphics = .software + case .devices: + changed.devices.keyboard.toggle() + } + return try managerOperations.authorizedStart(changed) + }, + stop: managerOperations.stop + ) + let registry = try rawRegistry(operations: mutatingOperations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + #expect(throws: MachineManagerError.self) { + _ = try manager.start(id: "dev") + } + #expect(starter.count == 0) + } + } + } + + @Test("resolved raw-HV launch carries explicit graphics and device arguments") + func resolvedLaunchUsesExactHelperArguments() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-resolved-arguments-\(UUID().uuidString)" + ).path + let helper = root + "/helper.sh" + let capture = root + "/arguments.txt" + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: root) } + try writeExecutable( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '\(capture)'\nsleep 30\n", + path: helper + ) + + try withHarness( + "exact-arguments", + acceleratedExecutablePath: helper, + passMachineArguments: true + ) { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations, executablePath: helper) + let helperSHA256 = try fileSHA256(path: helper) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution( + request: request, + componentSHA256: helperSHA256 + ) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + _ = try manager.start(id: "dev") + #expect(starter.count == 1) + let deadline = Date().addingTimeInterval(2) + while !FileManager.default.fileExists(atPath: capture), Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + let arguments = try String(contentsOfFile: capture, encoding: .utf8) + .split(separator: "\n").map(String.init) + func value(after flag: String) throws -> String { + let index = try #require(arguments.firstIndex(of: flag)) + return arguments[index + 1] + } + #expect(try value(after: "--resolved-graphics") == "host-accelerated-display") + let deviceData = Data(try value(after: "--resolved-devices").utf8) + #expect(try JSONDecoder().decode( + DoryVirtualMachineDeviceCapabilityRequest.self, + from: deviceData + ) == .minimumBootable) + #expect(arguments.contains("DORY_DESKTOP_GRAPHICS=virgl")) + #expect(arguments.contains("DORY_DESKTOP_VMM=accelerated")) + #expect(!arguments.contains("DORY_DESKTOP_GRAPHICS=auto")) + } + } + + @Test("resolved installed-Linux launch rematerializes verified boot outputs after authorization") + func resolvedInstalledLinuxRevalidatesMaterializedOutputs() throws { + let root = try makeState("resolved-installed-linux") + defer { try? FileManager.default.removeItem(atPath: root) } + let sourceBundle = root + "/source.boot" + let sourceDisk = root + "/source.raw" + let kernel = Data(repeating: 0x41, count: 8_192) + let initrd = Data(repeating: 0x42, count: 16_384) + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: kernel, + initrd: initrd, + kernelISOPath: "casper/vmlinuz", + initrdISOPath: "casper/initrd" + ), + rootDevice: "/dev/vda2", + toPath: sourceBundle + ) + try Data(repeating: 0x31, count: 4_096).write( + to: URL(fileURLWithPath: sourceDisk) + ) + let starter = CountingProcessStarter() + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + acceleratedDesktopExecutablePath: "/bin/sleep", + stateDirectory: root + "/machines", + baseArguments: ["30"], + acceleratedDesktopBaseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + launchPolicy: .requireResolvedPlan, + processStarter: { process in try starter.start(process) } + ) + defer { + _ = try? manager.stop(id: "installed") + _ = try? manager.delete(id: "installed") + } + _ = try manager.create(DoryMachineConfiguration( + id: "installed", + kernelPath: sourceBundle, + rootfsPath: sourceDisk, + bootMode: .efi, + memoryMB: 4_096, + cpuCount: 4, + displayMode: .desktop + )) + let state = root + "/machines/installed" + let directKernel = state + "/direct-kernel" + let directInitrd = state + "/direct-initrd" + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution( + request: request, + preSpawnRevalidation: { + try Data("stale-kernel".utf8).write( + to: URL(fileURLWithPath: directKernel) + ) + try Data("stale-initrd".utf8).write( + to: URL(fileURLWithPath: directInitrd) + ) + } + ) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + _ = try manager.start(id: "installed") + #expect(starter.count == 1) + #expect(try Data(contentsOf: URL(fileURLWithPath: directKernel)) == kernel) + #expect(try Data(contentsOf: URL(fileURLWithPath: directInitrd)) == initrd) + } + @Test("resolved snapshot disk capacity is bound to resource admission") func resolvedSnapshotRejectsSelfConsistentDifferentCapacityDisk() throws { try withHarness("snapshot-storage-evidence") { manager, _, state in @@ -1055,6 +1243,7 @@ struct MachineManagerResolvedPlanIntegrationTests { _ label: String, launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan, acceleratedExecutablePath: String? = "/bin/sleep", + passMachineArguments: Bool = false, _ body: (MachineManager, CountingProcessStarter, String) throws -> Void ) throws { let state = FileManager.default.temporaryDirectory @@ -1068,7 +1257,7 @@ struct MachineManagerResolvedPlanIntegrationTests { stateDirectory: state, baseArguments: ["30"], acceleratedDesktopBaseArguments: ["30"], - passMachineArguments: false, + passMachineArguments: passMachineArguments, requiresReadyHandoff: false ), launchPolicy: launchPolicy, @@ -1134,13 +1323,19 @@ struct MachineManagerResolvedPlanIntegrationTests { RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, backendRuntimeBuildIdentifier: runtime, virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion, - bootMedia: DoryResolvedMachineBootMedia( + bootMedia: DoryResolvedMachineBootMedia( resolverReference: DoryVMResolverReference( namespace: "machine", identifier: "dev-kernel" ), - media: media + media: media + ), + launchArtifacts: resolvedBootLaunchArtifacts( + reference: DoryVMResolverReference( + namespace: "machine", identifier: "dev-kernel" ), + media: media + ), components: [DoryResolvedBackendComponentEvidence( componentIdentifier: "dory-hv", buildIdentifier: runtime, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 1a91c79c..9db4c23e 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -4333,7 +4333,7 @@ private func waitForMachineStatus( } return try XCTUnwrap( manager.status(id: id).flatMap { predicate($0) ? $0 : nil }, - "timed out waiting for matching machine status for \(id)" + "timed out waiting for matching machine status for \(id); current=\(String(describing: manager.status(id: id)))" ) } From baeafd35f1ee8d9d71f3e8a75f3bfff0b108d387 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 19:55:31 +0000 Subject: [PATCH 040/338] feat(vm): activate production planning in doryd --- .../Sources/dory-hv/DesktopMode.swift | 4 +- .../DoryVirtualMachineCapabilities.swift | 36 ++-- .../Sources/DoryVMMKit/DoryVMM.swift | 4 +- ...achinePlanningTransactionCoordinator.swift | 8 +- ...lMachineProductionPlanningController.swift | 38 +++- ...yDaemonVirtualMachineRuntimePlanning.swift | 8 +- .../DorydKit/DoryResolvedMachinePlan.swift | 2 + .../Sources/DorydKit/DorydService.swift | 31 +++- .../LinuxMachineBackendAdapters.swift | 17 +- .../Sources/DorydKit/MachineManager.swift | 167 ++++++++++++++++++ dory-core-swift/Sources/doryd/main.swift | 20 ++- .../DoryVirtualMachineCapabilitiesTests.swift | 20 +++ ...onVirtualMachineProductionTrustTests.swift | 118 +++++++++++-- .../DorydKitTests/DorydServiceTests.swift | 99 +++++++++++ .../DorydKitTests/MachineBackendTests.swift | 43 ++++- 15 files changed, 559 insertions(+), 56 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index cc0739ca..74db508a 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -80,9 +80,7 @@ enum DesktopMode { ) self.graphicsBackend = resolvedGraphics.backend if let devices = configuration.resolvedDevices { - guard devices.networkAttachment == .sharedNAT, - !devices.clockSynchronization, - !devices.gracefulShutdown else { + guard devices.networkAttachment == .sharedNAT else { throw VMError.bootFailure( "resolved device contract contains a device not implemented by raw-HV" ) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index dc2f6d10..b9ea570f 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -928,8 +928,11 @@ public enum DoryAppleSiliconCapabilityEvaluator { switch (request.guest.family, request.backend) { case (.linux, .doryHypervisor): return request.bootMedia.kind == .installedLinuxBootBundle - case (.linux, .appleVirtualizationFramework), - (.linux, .qemuHypervisorFramework), + case (.linux, .appleVirtualizationFramework): + return request.bootMedia.kind == .installerISO + || request.bootMedia.kind == .virtualDisk + || request.bootMedia.kind == .installedLinuxBootBundle + case (.linux, .qemuHypervisorFramework), (.windows, .qemuHypervisorFramework): return request.bootMedia.kind == .installerISO || request.bootMedia.kind == .virtualDisk case (.macOS, .appleVirtualizationFramework): @@ -1385,11 +1388,15 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } if devices.clockSynchronization { - return unavailable( - tier: tier, - code: .clockSynchronizationUnsupported, - message: "Clock synchronization is not yet part of a qualified backend contract." - ) + guard request.guest.family == .linux, + request.backend == .doryHypervisor + || request.backend == .appleVirtualizationFramework else { + return unavailable( + tier: tier, + code: .clockSynchronizationUnsupported, + message: "The selected guest/backend contract does not implement clock synchronization." + ) + } } if devices.dynamicDisplay { return unavailable( @@ -1399,12 +1406,15 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } if devices.gracefulShutdown { - return unavailable( - tier: tier, - code: .gracefulShutdownUnsupported, - message: "Graceful shutdown requires a digest-bound guest-integration " - + "qualification that is not yet modeled." - ) + guard request.guest.family == .linux, + request.backend == .doryHypervisor + || request.backend == .appleVirtualizationFramework else { + return unavailable( + tier: tier, + code: .gracefulShutdownUnsupported, + message: "The selected guest/backend contract does not implement graceful shutdown." + ) + } } return nil } diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 4460c37a..68473565 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -374,9 +374,7 @@ public enum DoryVZConfigurationBuilder { } if let devices = spec.resolvedDevices { guard devices.networkAttachment == .sharedNAT, - !devices.clockSynchronization, - !devices.dynamicDisplay, - !devices.gracefulShutdown else { + !devices.dynamicDisplay else { throw DoryVZMachineError.validation( "resolved device contract contains a device not implemented by this VZ launch" ) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index ffa50fb4..1a5e5e2b 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -964,7 +964,13 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: ) let result: DoryDaemonVirtualMachinePlanningResult do { result = try coordinator.resolveAndPersist(request.planning) } - catch { + catch let planning as DoryDaemonVirtualMachinePlanningFailure { + throw failure( + .planConstructionRejected, + "Exact trusted facts could not construct a plan " + + "(\(planning.code.rawValue): \(planning.message))" + ) + } catch { throw failure(.planConstructionRejected, "Exact trusted facts could not construct a plan.") } let candidateData = Self.canonicalData(result.resolvedPlan) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift index 5b78662d..f30a9c28 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift @@ -69,6 +69,17 @@ public protocol DoryDaemonVirtualMachinePlanningTransactionCoordinating: Sendabl extension DoryDaemonVirtualMachinePlanningTransactionCoordinator: DoryDaemonVirtualMachinePlanningTransactionCoordinating {} +/// Narrow product boundary used by MachineManager and XPC. The concrete controller retains the +/// trusted artifact/repository graph; tests can inject only an observer/rejector and therefore +/// cannot manufacture a resolved runtime identity. +public protocol DoryDaemonVirtualMachineProductionPlanningControlling: Sendable { + func authorityRevision(for reference: DoryVMResolverReference) throws -> UInt64? + func publishResolvedPlan( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication] + ) throws +} + /// The sole production entry for a manager-owned native create/update/replan transaction. It /// first establishes exact artifact authority, then delegates reserve -> bind -> publication to /// the crash-safe coordinator, and finally proves the published plan and workspace are the exact @@ -138,7 +149,13 @@ public final class DoryDaemonVirtualMachineProductionPlanningController: let result: DoryDaemonVirtualMachinePlanningTransactionResult do { result = try coordinator.resolveReserveAndPublish(request) } - catch { + catch let transaction as DoryDaemonVirtualMachinePlanningTransactionFailure { + throw failure( + .transactionRejected, + "Production planning transaction failed closed " + + "(\(transaction.code.rawValue): \(transaction.message))" + ) + } catch { throw failure(.transactionRejected, "Production planning transaction failed closed.") } let workspace: DoryVirtualMachineDefinition @@ -160,6 +177,22 @@ public final class DoryDaemonVirtualMachineProductionPlanningController: return result } + /// Returns the exact current artifact-authority revision so a manager-owned replan can make + /// mutable-media replacement an explicit compare-and-swap. Corrupt authority is never + /// collapsed into a missing record. + public func authorityRevision( + for reference: DoryVMResolverReference + ) throws -> UInt64? { + try artifactAuthority.authorityRecord(reference: reference)?.authorityRevision + } + + public func publishResolvedPlan( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication] + ) throws { + _ = try resolveReserveAndPublish(request, artifacts: artifacts) + } + /// Compatibility launch paths remain daemon-local, but they are still launch authority. A /// resolver publication must describe the exact path the selected adapter will consume; a /// definition reference cannot authorize different bytes at an unrelated machine path. @@ -268,6 +301,9 @@ public final class DoryDaemonVirtualMachineProductionPlanningController: } } +extension DoryDaemonVirtualMachineProductionPlanningController: + DoryDaemonVirtualMachineProductionPlanningControlling {} + enum DoryDaemonVirtualMachineProductionRecoveryError: Error, Sendable, Equatable { case invalidDescriptor case machineUnavailable diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 92308cc1..e42e53ce 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -434,7 +434,13 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda backendPlan.backend == backend.descriptor, backendPlan.machine == input.machine, backendPlan.capability == selected else { - throw failure(.backendPlanRejected, "The selected adapter rejected the machine plan.") + let detail = backendResult.failure.map { + " (\($0.code.rawValue): \($0.message))" + } ?? "" + throw failure( + .backendPlanRejected, + "The selected adapter rejected the machine plan.\(detail)" + ) } let timing: (revision: UInt64, created: Int64, updated: Int64) diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 6f958e56..6b718233 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -920,6 +920,8 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { (.windows, .qemuHypervisorFramework): runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO || bootMedia.media.kind == .virtualDisk + || (backend == .appleVirtualizationFramework + && bootMedia.media.kind == .installedLinuxBootBundle) case (.macOS, .appleVirtualizationFramework): runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage || bootMedia.media.kind == .virtualDisk diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index a44d42b9..8481d791 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -8,6 +8,8 @@ public final class DorydService: NSObject, DorydControl { private let home: String private let dockerTier: DockerTier? private let machineManager: MachineManager? + private let productionPlanningController: + (any DoryDaemonVirtualMachineProductionPlanningControlling)? private let machineBackupScheduler: MachineBackupScheduler? private let remoteManager: RemoteMachineManager? private let networkingController: NetworkingController? @@ -26,6 +28,8 @@ public final class DorydService: NSObject, DorydControl { home: String = NSHomeDirectory(), dockerTier: DockerTier? = nil, machineManager: MachineManager? = nil, + productionPlanningController: + (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil, machineBackupScheduler: MachineBackupScheduler? = nil, remoteManager: RemoteMachineManager? = nil, networkingController: NetworkingController? = nil, @@ -42,6 +46,7 @@ public final class DorydService: NSObject, DorydControl { self.home = home self.dockerTier = dockerTier self.machineManager = machineManager + self.productionPlanningController = productionPlanningController self.machineBackupScheduler = machineBackupScheduler self.remoteManager = remoteManager self.networkingController = networkingController @@ -241,7 +246,18 @@ public final class DorydService: NSObject, DorydControl { to: [:], displayMode: machine.displayMode ) - let status = try machineManager.create(machine) + var status = try machineManager.create(machine) + if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority { + guard let productionPlanningController else { + throw MachineManagerError.persistence( + "production planning controller is not configured" + ) + } + status = try machineManager.resolveAndPublishProductionPlan( + id: machine.id, + controller: productionPlanningController + ) + } incidentWriter?.record(type: "machine.create", detail: machine.id) reply(true, status.xpcDictionary, "") } catch { @@ -279,7 +295,7 @@ public final class DorydService: NSObject, DorydControl { } do { let update = try MachineUpdateRequest(xpcDictionary: config) - let status = try machineManager.update( + var status = try machineManager.update( id: machineID, memoryMB: update.memoryMB, cpuCount: update.cpuCount, @@ -290,6 +306,17 @@ public final class DorydService: NSObject, DorydControl { typedSettingsPatch: update.typedSettings.isEmpty ? nil : update.typedSettings, installerMediaAttached: update.installerMediaAttached ) + if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority { + guard let productionPlanningController else { + throw MachineManagerError.persistence( + "production planning controller is not configured" + ) + } + status = try machineManager.resolveAndPublishProductionPlan( + id: machineID, + controller: productionPlanningController + ) + } incidentWriter?.record(type: "machine.update", detail: machineID) reply(true, status.xpcDictionary, "") } catch { diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index 9835c5a1..c5feed06 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -423,7 +423,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ implementationIdentifier: "dory.vz-linux.compatibility.v1", guestFamilies: [.linux], guestArchitectures: [.arm64], - bootMediaKinds: [.installerISO, .virtualDisk], + bootMediaKinds: [.installedLinuxBootBundle, .installerISO, .virtualDisk], lifecycle: .currentMachineManager ) @@ -445,16 +445,21 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ executableIsAvailable: executableIsAvailable, operations: operations, validateMachine: { machine, capability in - guard machine.bootMode == .efi, machine.displayMode == .desktop else { - return "The current Virtualization.framework media path requires an EFI desktop machine." - } switch capability.request.bootMedia.kind { + case .installedLinuxBootBundle: + guard machine.bootMode == .linuxKernel, + machine.installerISOPath == nil, + DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return "A direct Linux VZ plan requires an installed-Linux boot bundle without installer media." + } case .installerISO: - guard machine.installerISOPath?.isEmpty == false else { + guard machine.bootMode == .efi, machine.displayMode == .desktop, + machine.installerISOPath?.isEmpty == false else { return "An installer capability requires attached installer media." } case .virtualDisk: - guard machine.installerISOPath == nil else { + guard machine.bootMode == .efi, machine.displayMode == .desktop, + machine.installerISOPath == nil else { return "A virtual-disk capability cannot retain attached installer media." } if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) { diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d0f630ed..fa089da2 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1041,6 +1041,173 @@ public final class MachineManager: @unchecked Sendable { ) } + /// Resolves and publishes the exact current compatibility projection through the production + /// planning transaction. This is invoked by the product create/update boundary only after + /// machine metadata and managed artifacts are durable. Starts remain fail-closed throughout: + /// the workspace is `requires-replanning` until the coordinator completes its mutation fence + /// and publishes the matching runtime identity. + @discardableResult + public func resolveAndPublishProductionPlan( + id: String, + controller: any DoryDaemonVirtualMachineProductionPlanningControlling + ) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + guard launchPolicy == .perWorkspaceAuthority else { + guard let current = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + return current + } + try requireNoActivePlanningMutation(id: id) + + let entry: MachineEntry + lock.lock() + guard let current = machines[id], !deletingMachineIDs.contains(id) else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + entry = current + lock.unlock() + guard entry.process == nil, entry.handoffServer == nil, + entry.state == .created || entry.state == .stopped else { + throw MachineManagerError.persistence( + "machine \(id) must be stopped before production planning" + ) + } + switch entry.runtimeIdentity.mode { + case .legacyCompatibility, .resolvedPlan: + guard let current = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + return current + case .requiresReplanning: + break + } + + guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)), + let decoded = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: legacyData + ), decoded == entry.configuration else { + throw MachineManagerError.persistence( + "authoritative machine metadata is unavailable for production planning" + ) + } + let facts = try workspaceMigrationFacts(for: entry.configuration) + let factsData = try Self.workspaceMigrationAuthorityData(facts) + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + entry.configuration, + facts: facts + ) + let definition: DoryVirtualMachineDefinition + do { + definition = try workspaceRepository.reconcileLegacyProjection( + migration.definition, + authoritativeLegacyData: legacyData, + authoritativeMigrationFactsData: factsData + ).definition + let persisted = try workspaceRepository.readLegacyProjection( + id: id, + authoritativeLegacyData: legacyData, + authoritativeMigrationFactsData: factsData + ) + guard persisted == definition else { + throw MachineManagerError.persistence( + "workspace projection changed during production planning" + ) + } + } catch let error as MachineManagerError { + throw error + } catch { + throw MachineManagerError.persistence( + "workspace projection is unavailable for production planning: \(error)" + ) + } + let canonicalDefinitionData = try Self.canonicalDefinitionData(definition) + + let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore + ?? DoryResolvedMachinePlanRepository(root: configuration.stateDirectory) + let planPublication: DoryDaemonVirtualMachinePlanPublication + do { + let existing = try plans.read(id: id) + planPublication = .replace(expectedPlanRevision: existing.planRevision) + } catch let error as DoryResolvedMachinePlanRepositoryError { + switch error { + case .planNotFound: + planPublication = .create + default: + throw MachineManagerError.persistence( + "resolved-plan authority cannot be inspected: \(error)" + ) + } + } catch { + throw MachineManagerError.persistence( + "resolved-plan authority cannot be inspected: \(error)" + ) + } + + guard let requirements = DoryDaemonVirtualMachinePlanningCoordinator + .launchArtifactRequirements(for: definition) else { + throw MachineManagerError.persistence( + "workspace launch artifacts are not representable" + ) + } + let bindings = Dictionary(grouping: migration.artifactBindings, by: \.reference) + var publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] = [] + publications.reserveCapacity(requirements.count) + for requirement in requirements { + guard let matches = bindings[requirement.reference], matches.count == 1, + let binding = matches.first else { + throw MachineManagerError.persistence( + "workspace launch artifact has no exact managed path" + ) + } + let revision: UInt64? + do { revision = try controller.authorityRevision(for: requirement.reference) } + catch { + throw MachineManagerError.persistence( + "workspace artifact authority cannot be inspected: \(error)" + ) + } + publications.append(DoryDaemonVirtualMachinePlanningArtifactPublication( + reference: requirement.reference, + path: binding.path, + kind: requirement.kind, + source: requirement.source, + mutability: requirement.mutable ? .mutable : .immutable, + expectedAuthorityRevision: revision + )) + } + + let request = DoryDaemonVirtualMachinePlanningTransactionRequest( + planning: DoryDaemonVirtualMachinePlanningRequest( + definition: definition, + canonicalDefinitionData: canonicalDefinitionData, + machine: entry.configuration, + publication: planPublication + ), + workspacePublication: .retainExistingExact + ) + do { + try controller.publishResolvedPlan( + request, + artifacts: publications + ) + } catch { + throw MachineManagerError.persistence( + "production planning failed closed: \(error)" + ) + } + guard let current = status(id: id), + current.runtimeIdentity.mode == .resolvedPlan else { + throw MachineManagerError.persistence( + "production planning did not publish resolved runtime authority" + ) + } + return current + } + @discardableResult public func start(id: String) throws -> DoryMachineStatus { operationLock.lock() diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index d28603f9..ef044da2 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -67,25 +67,30 @@ let dockerTier = dorydEnvironment.dockerTierConfiguration().map { let desktopUpdateArtifactResolver = DoryComponentStoreDesktopUpdateArtifactResolver( store: DoryComponentStore(drive: dataDrive) ) +var productionPlanningController: + (any DoryDaemonVirtualMachineProductionPlanningControlling)? let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in - let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve( + let readiness = DoryDaemonVirtualMachineProductionTrustFactory().activate( store: DoryComponentStore(drive: dataDrive), machineConfiguration: configuration ) switch readiness { - case let .ready(context): + case let .activated(context): FileHandle.standardError.write(Data( - "doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8 + "doryd: VM launch policy perWorkspaceAuthority " + .appending("(production planning recovered and activated)\n").utf8 )) + productionPlanningController = context.planningController context.machineManager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver) return context.machineManager - case let .unavailable(reason): - guard reason.permitsLegacyCompatibilityMigration else { + case let .unavailable(failure): + guard let trustFailure = failure.trustFailure, + trustFailure.permitsLegacyCompatibilityMigration else { // Integrity and runtime verification failures disable VM launch. Falling through to // legacy here would execute the same helper that production trust just rejected. FileHandle.standardError.write(Data( "doryd: VM launch unavailable " - .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8 + .appending("(\(failure.code.rawValue): \(failure.message))\n").utf8 )) return nil } @@ -93,7 +98,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co // qualification authority yet, so the existing compatibility path stays labeled. FileHandle.standardError.write(Data( "doryd: VM launch policy legacyCompatibility " - .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8 + .appending("(\(trustFailure.code.rawValue): \(trustFailure.message))\n").utf8 )) let manager = MachineManager( configuration: configuration, @@ -246,6 +251,7 @@ let service = DorydService( home: dorydEnvironment.home, dockerTier: dockerTier, machineManager: machineManager, + productionPlanningController: productionPlanningController, machineBackupScheduler: machineBackupScheduler, remoteManager: remoteManager, networkingController: networkingController, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index c8d9c0d7..7c37a918 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -287,6 +287,26 @@ struct VirtualMachineCapabilitiesTests { #expect(linuxRestore.availability.reason?.code == .bootMediaDoesNotSupportGuest) } + @Test("VZ direct-kernel lifecycle devices remain exact pending qualification") + func vzDirectKernelLifecycleDevices() { + let devices = DoryVirtualMachineDeviceCapabilityRequest( + clockSynchronization: true, + gracefulShutdown: true + ) + let descriptor = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .none, + devices: devices + ) + + #expect(descriptor.availability.isUsable) + #expect(descriptor.resolvedDevices == devices) + #expect(descriptor.availability.reason?.code == .runtimeQualificationUnavailable) + } + @Test("missing renderer blocks accelerated native graphics but not software graphics") func rendererAvailabilityIsExact() { var host = Self.provisionedHost diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index cc9c312e..c4ab10d5 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -363,6 +363,58 @@ struct DoryDaemonVirtualMachineProductionTrustTests { #expect(trustFloor.activationCount == 1) } + @Test("activated production graph publishes a headless create plan through XPC authority") + func activatedGraphPlansHeadlessCreate() throws { + let fixture = try ProductionTrustFixture() + defer { fixture.cleanup() } + guard case let .activated(context) = fixture.factory.activate( + store: fixture.store, + machineConfiguration: fixture.machineConfiguration, + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) else { + Issue.record("Expected production activation") + return + } + let service = DorydService( + socketPath: fixture.root.appendingPathComponent("doryd.sock").path, + machineManager: context.machineManager, + productionPlanningController: context.planningController + ) + let qualifiedDisk = fixture.root.appendingPathComponent("qualified-headless.raw").path + FileManager.default.createFile( + atPath: qualifiedDisk, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) + let diskHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: qualifiedDisk)) + try diskHandle.truncate(atOffset: 16 * 1_024 * 1_024 * 1_024) + try diskHandle.synchronize() + try diskHandle.close() + let completed = LockedPlanningCreateReply() + service.machineCreate([ + "id": "qualified-headless", + "kernelPath": fixture.mediaPath, + "rootfsPath": qualifiedDisk, + "displayMode": "headless", + "memoryMB": UInt64(2_048), + "cpuCount": 2, + ]) { ok, body, message in + completed.set(ok: ok, body: body, message: message) + } + let reply = completed.value + #expect(reply.ok, Comment(rawValue: reply.message)) + #expect(reply.body["runtimeIdentity"] != nil) + #expect(context.machineManager.status(id: "qualified-headless")? + .runtimeIdentity.mode == .resolvedPlan) + let plan = try context.planning.plans.read(id: "qualified-headless") + #expect(plan.machineID == "qualified-headless") + #expect(plan.launchArtifacts.count == 2) + #expect(plan.devices.clockSynchronization) + #expect(plan.devices.gracefulShutdown) + } + @Test("trust-floor failure exposes no manager and a fresh activation can retry") func activationFloorFailureIsTyped() throws { let trustFloor = ProductionTrustFloorActivationState(remainingFailures: 1) @@ -550,6 +602,29 @@ private enum ProductionTrustFixtureError: Error { case runtimeRejected } +private final class LockedPlanningCreateReply: @unchecked Sendable { + struct Value { + var ok = false + var body: NSDictionary = [:] + var message = "callback was not invoked" + } + + private let lock = NSLock() + private var stored = Value() + + var value: Value { + lock.lock() + defer { lock.unlock() } + return stored + } + + func set(ok: Bool, body: NSDictionary, message: String) { + lock.lock() + stored = Value(ok: ok, body: body, message: message) + lock.unlock() + } +} + private final class ProductionCallCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -1018,6 +1093,10 @@ private final class ProductionTrustFixture: @unchecked Sendable { ) throws { let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation) let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let headlessDevices = DoryVirtualMachineDeviceCapabilityRequest( + clockSynchronization: true, + gracefulShutdown: true + ) let components = ["dory-hv", "dory-vmm"].map { DoryVirtualMachineQualifiedComponent( componentIdentifier: $0, @@ -1025,30 +1104,33 @@ private final class ProductionTrustFixture: @unchecked Sendable { artifactSHA256: helperDigest ) } - let records = [ + let backends = [ (DoryVirtualizationBackendIdentity.doryHypervisor, RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier, "dory-hv"), (DoryVirtualizationBackendIdentity.appleVirtualizationFramework, VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier, "dory-vmm"), - ].map { backend, implementation, component in - DoryVirtualMachineQualificationRecord( - qualificationIdentity: "\(component)-qualification", - guest: guest, - bootMediaKind: .installedLinuxBootBundle, - bootMediaSource: .bundledByDory, - immutableArtifactSHA256: mediaDigest, - backend: backend, - backendImplementationIdentifier: implementation, - backendRuntimeBuildIdentifier: runtimeBuildIdentifier, - virtualHardwareABIVersion: 1, - graphics: .none, - devices: devices, - hostHardwareModelIdentifier: host.hardwareModelIdentifier, - hostOperatingSystemBuild: host.operatingSystemBuild, - components: [components.first { $0.componentIdentifier == component }!] - ) + ] + let records = backends.flatMap { backend, implementation, component in + [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in + DoryVirtualMachineQualificationRecord( + qualificationIdentity: "\(component)-\(suffix)-qualification", + guest: guest, + bootMediaKind: .installedLinuxBootBundle, + bootMediaSource: .bundledByDory, + immutableArtifactSHA256: mediaDigest, + backend: backend, + backendImplementationIdentifier: implementation, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + components: [components.first { $0.componentIdentifier == component }!] + ) + } } let manifest = DoryVirtualMachineQualificationManifest( manifestIdentity: "production-vm-qualification-1", diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index d8fb79ea..8fb3258a 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1,6 +1,7 @@ import DoryCore @testable import DorydKit import CryptoKit +import DoryOperations import XCTest final class DorydServiceTests: XCTestCase { @@ -1440,6 +1441,63 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(manager.status(id: "legacy")?.environment["PRIVATE_TOKEN"], "opaque-legacy-value") } + func testPerWorkspaceCreateInvokesProductionPlanningAndFailsClosed() throws { + let base = "/tmp/doryd-service-production-plan-\(getpid())-\(UInt32.random(in: 0.. UInt64? { + _ = reference + return nil + } + + func publishResolvedPlan( + _ request: DoryDaemonVirtualMachinePlanningTransactionRequest, + artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication] + ) throws { + lock.lock() + stored.append(Capture(request: request, artifacts: artifacts)) + lock.unlock() + throw ServiceProductionPlanningTestError.rejected + } +} + +private enum ServiceProductionPlanningTestError: Error { case rejected } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 44c4f420..93e09ed2 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -15,7 +15,10 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(vz.identity, .appleVirtualizationFramework) XCTAssertEqual(vz.guestFamilies, [.linux]) XCTAssertEqual(vz.guestArchitectures, [.arm64]) - XCTAssertEqual(vz.bootMediaKinds, [.installerISO, .virtualDisk]) + XCTAssertEqual( + vz.bootMediaKinds, + [.installedLinuxBootBundle, .installerISO, .virtualDisk] + ) XCTAssertFalse(vz.lifecycle.pause) XCTAssertFalse(vz.lifecycle.resume) } @@ -125,6 +128,44 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installerISO) } + func testRegistryPlansVZInstalledLinuxBundleWithPlannerSelection() throws { + let bundle = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-vz-direct-\(UUID().uuidString).boot" + ) + defer { try? FileManager.default.removeItem(at: bundle) } + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("kernel".utf8), + initrd: Data("initrd".utf8), + kernelISOPath: "/boot/kernel", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: bundle.path + ) + let machine = DoryMachineConfiguration( + id: "vz-direct-linux", + kernelPath: bundle.path, + rootfsPath: "/fixture/linux.raw", + bootMode: .linuxKernel, + displayMode: .headless + ) + let registry = try BackendRegistry(backends: [ + availableVZBackend(operations: recordingOperations().operations), + ]) + let result = registry.plan(MachineBackendPlanRequest( + machine: machine, + capabilityPlan: capabilityPlan( + backend: .appleVirtualizationFramework, + media: .installedLinuxBootBundle + ) + )) + + XCTAssertTrue(result.isSuccess) + XCTAssertEqual(result.plan?.backend.identity, .appleVirtualizationFramework) + XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installedLinuxBootBundle) + } + func testPlanningFailsClosedWhenProductPlannerHasNoSelection() throws { let registry = try BackendRegistry(backends: [ availableRawBackend(operations: recordingOperations().operations), From dcf69b13a28be9aee88fb9fe4aabc8652dfbe194 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 20:29:10 +0000 Subject: [PATCH 041/338] feat(vm): bind resource admission to lifecycle --- ...onVirtualMachineProductionActivation.swift | 10 +- ...irtualMachineResourceAdmissionLedger.swift | 40 ++ .../Sources/DorydKit/MachineManager.swift | 508 +++++++++++++++++- ...onVirtualMachineProductionTrustTests.swift | 44 +- ...lMachineResourceAdmissionLedgerTests.swift | 57 ++ 5 files changed, 638 insertions(+), 21 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift index eabf5cdf..76132f02 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift @@ -198,6 +198,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { )) } + let planningController = DoryDaemonVirtualMachineProductionPlanningController( + planning: planning + ) do { try machineManager.installResolvedLaunchInfrastructure( registry: planning.registry, @@ -205,7 +208,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { plans: planning.plans, expectedPlanRevision: { machineID in try? planning.plans.read(id: machineID).planRevision - } + }, + productionPlanningController: planningController, + resourceAdmissionLedger: planning.resourceLedger ) } catch { return unavailableActivation( @@ -229,8 +234,7 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { return .activated(DoryDaemonVirtualMachineProductionActivationContext( machineManager: machineManager, planning: planning, - planningController: - DoryDaemonVirtualMachineProductionPlanningController(planning: planning), + planningController: planningController, backendRuntimeBuildIdentifiers: Dictionary( uniqueKeysWithValues: material.runtimes.map { ($0.descriptor.identity, $0.runtimeBuildIdentifier) diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift index 47a4f8fa..c0370b14 100644 --- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -463,6 +463,46 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } } + /// Reopens the same exact running admission for a daemon-controlled restart that did not + /// change guest or plan authority (for example, quiescing a VM to take a snapshot). Runtime + /// capacity remains reserved throughout the stop/restart window; this does not re-admit a + /// stopped VM or authorize a changed plan. + func prepareRetainedRunningForRestart( + leaseID: String, + plan: DoryResolvedMachinePlan, + startingLeaseDurationMilliseconds: Int64 = 120_000, + expectedLeaseRevision: UInt64 + ) throws -> DoryVirtualMachineResourceAdmissionLease { + try withExclusiveAccess { + let timestamp = now() + guard startingLeaseDurationMilliseconds > 0, + startingLeaseDurationMilliseconds + <= Self.maximumStartingLeaseDurationMilliseconds, + timestamp > 0, + timestamp <= Int64.max - startingLeaseDurationMilliseconds else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration + } + var record = try readRecord() + _ = try recoverExpired(in: &record, at: timestamp) + let index = try leaseIndex(leaseID, in: record) + var lease = record.leases[index] + try Self.validateLeaseRevision(expectedLeaseRevision, lease) + guard lease.state == .running else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state) + } + try Self.validate(plan, against: lease, requireBoundDigest: true) + try Self.validateCapacity(record.leases, hostFacts: lease.hostFacts) + lease.state = .starting + lease.startingExpiresAtUnixMilliseconds = timestamp + + startingLeaseDurationMilliseconds + lease.leaseRevision = try Self.incrementing(lease.leaseRevision) + lease.updatedAtUnixMilliseconds = timestamp + record.leases[index] = lease + try advanceAndPublish(&record) + return lease + } + } + /// Releases CPU and RAM after stop while intentionally retaining the disk reservation. public func markStopped( leaseID: String, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index fa089da2..9ce542f1 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -754,6 +754,10 @@ public final class MachineManager: @unchecked Sendable { private var resolvedLaunchPlanResolver: (any DoryDaemonVirtualMachineLaunchPlanResolving)? private var resolvedLaunchPlanStore: (any DoryResolvedMachinePlanStoring)? private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider? + private var productionPlanningController: + (any DoryDaemonVirtualMachineProductionPlanningControlling)? + private var productionResourceAdmissionLedger: + DoryVirtualMachineResourceAdmissionLedger? private var pendingResolvedStart: PendingResolvedMachineStart? private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:] private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:] @@ -855,7 +859,10 @@ public final class MachineManager: @unchecked Sendable { registry: BackendRegistry, resolver: any DoryDaemonVirtualMachineLaunchPlanResolving, plans: any DoryResolvedMachinePlanStoring, - expectedPlanRevision: @escaping ResolvedPlanRevisionProvider + expectedPlanRevision: @escaping ResolvedPlanRevisionProvider, + productionPlanningController: + (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil, + resourceAdmissionLedger: DoryVirtualMachineResourceAdmissionLedger? = nil ) throws { operationLock.lock() defer { operationLock.unlock() } @@ -865,9 +872,16 @@ public final class MachineManager: @unchecked Sendable { "resolved launch infrastructure requires a resolved-plan-capable policy" ) } + guard (productionPlanningController == nil) == (resourceAdmissionLedger == nil) else { + throw MachineManagerError.persistence( + "production planning and resource-admission lifecycle must be installed together" + ) + } guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil, resolvedLaunchPlanStore == nil, resolvedPlanRevisionProvider == nil, + self.productionPlanningController == nil, + productionResourceAdmissionLedger == nil, pendingResolvedStart == nil else { throw MachineManagerError.persistence( "resolved launch infrastructure is already installed" @@ -877,10 +891,13 @@ public final class MachineManager: @unchecked Sendable { resolvedLaunchPlanResolver = resolver resolvedLaunchPlanStore = plans resolvedPlanRevisionProvider = expectedPlanRevision + self.productionPlanningController = productionPlanningController + productionResourceAdmissionLedger = resourceAdmissionLedger recoverResolvedRuntimeIdentities( plans: plans, expectedPlanRevision: expectedPlanRevision ) + try reconcileResourceAdmissionsAfterDaemonRestart() } /// Operations for the current Linux compatibility adapters. Start is protected by a @@ -1076,13 +1093,38 @@ public final class MachineManager: @unchecked Sendable { ) } switch entry.runtimeIdentity.mode { - case .legacyCompatibility, .resolvedPlan: + case .legacyCompatibility: guard let current = status(id: id) else { throw MachineManagerError.unknownMachine(id) } return current case .requiresReplanning: break + case .resolvedPlan: + guard let plan = entry.runtimeIdentity.resolvedPlan else { + throw MachineManagerError.persistence( + "resolved runtime identity has no exact plan" + ) + } + guard let ledger = productionResourceAdmissionLedger else { + guard let current = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + return current + } + switch try exactResourceAdmissionLease(for: plan, ledger: ledger).state { + case .starting, .running: + guard let current = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + return current + case .stopped: + break + case .recoveryRequired: + throw MachineManagerError.persistence( + "machine \(id) resource admission requires restart reconciliation" + ) + } } guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)), @@ -1213,6 +1255,7 @@ public final class MachineManager: @unchecked Sendable { operationLock.lock() defer { operationLock.unlock() } try requireNoActivePlanningMutation(id: id) + try refreshResolvedAdmissionForStartIfNeeded(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } return try startImplementation(id: id, journalLifecycle: true) @@ -1404,6 +1447,7 @@ public final class MachineManager: @unchecked Sendable { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } pendingResolvedStart = PendingResolvedMachineStart( machine: prepared.machine, + plan: resolved.resolvedPlan, backend: resolved.backendPlan.backend, runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier, runtimeComponents: resolved.resolvedPlan.components, @@ -1543,7 +1587,8 @@ public final class MachineManager: @unchecked Sendable { return MachineBackendRuntimeObservation(try spawnPreparedMachine( authorization.machine, launchBinding: binding, - preSpawnAuthorization: authorization.preSpawnAuthorization + preSpawnAuthorization: authorization.preSpawnAuthorization, + resolvedPlan: authorization.plan )) } @@ -1597,6 +1642,255 @@ public final class MachineManager: @unchecked Sendable { return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } + private func exactResourceAdmissionLease( + for plan: DoryResolvedMachinePlan, + ledger: DoryVirtualMachineResourceAdmissionLedger + ) throws -> DoryVirtualMachineResourceAdmissionLease { + guard let admission = plan.resourceAdmission, + let definitionSHA256 = plan.definitionSHA256 else { + throw MachineManagerError.persistence( + "resolved plan has no exact resource-admission authority" + ) + } + let snapshot: DoryVirtualMachineResourceAdmissionLedgerSnapshot + do { snapshot = try ledger.snapshot() } + catch { + throw MachineManagerError.persistence( + "resource-admission ledger cannot be inspected: \(error)" + ) + } + let matches = snapshot.leases.filter { $0.leaseID == admission.admissionIdentity } + guard matches.count == 1, let lease = matches.first, + lease.binding.machineID == plan.machineID, + lease.binding.definitionRevision == plan.definitionRevision, + lease.binding.definitionSHA256.lowercased() == definitionSHA256.lowercased(), + lease.binding.plannedPlanRevision == plan.planRevision, + lease.boundPlanSHA256?.lowercased() + == (try Self.canonicalResolvedPlanSHA256(plan)), + lease.evidence == admission else { + throw MachineManagerError.persistence( + "resolved plan does not match its durable resource-admission lease" + ) + } + return lease + } + + private func refreshResolvedAdmissionForStartIfNeeded(id: String) throws { + guard launchPolicy == .perWorkspaceAuthority, + let controller = productionPlanningController, + let ledger = productionResourceAdmissionLedger else { return } + let identity = try currentDurableRuntimeIdentity(id: id) + guard identity.mode == .resolvedPlan, let plan = identity.resolvedPlan else { return } + var lease = try exactResourceAdmissionLease(for: plan, ledger: ledger) + switch lease.state { + case .starting: + return + case .running: + guard status(id: id)?.state != .running else { return } + do { + lease = try ledger.markStopped( + leaseID: lease.leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + throw MachineManagerError.persistence( + "stale running resource admission could not be reconciled: \(error)" + ) + } + case .recoveryRequired: + guard status(id: id)?.state != .running else { + throw MachineManagerError.persistence( + "running machine has an ambiguous expired resource admission" + ) + } + do { + lease = try ledger.reconcileExpiredStart( + leaseID: lease.leaseID, + observedRuntimeState: .stopped, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + throw MachineManagerError.persistence( + "expired resource admission could not be reconciled: \(error)" + ) + } + case .stopped: + break + } + guard lease.state == .stopped else { + throw MachineManagerError.persistence( + "resource admission did not settle to stopped before replanning" + ) + } + _ = try resolveAndPublishProductionPlan(id: id, controller: controller) + } + + private func reconcileResourceAdmissionsAfterDaemonRestart() throws { + guard let ledger = productionResourceAdmissionLedger else { return } + let snapshot: DoryVirtualMachineResourceAdmissionLedgerSnapshot + do { snapshot = try ledger.snapshot() } + catch { + throw MachineManagerError.persistence( + "resource-admission restart reconciliation failed: \(error)" + ) + } + let loadedMachineIDs: Set + lock.lock() + loadedMachineIDs = Set(machines.keys) + lock.unlock() + for lease in snapshot.leases { + let machineIsLoaded = loadedMachineIDs.contains(lease.binding.machineID) + let machinePathExists = Self.pathEntryExists( + machineStateDirectory(id: lease.binding.machineID) + ) + do { + if !machineIsLoaded, !machinePathExists, lease.state == .stopped { + try ledger.releaseStorageReservation( + leaseID: lease.leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + continue + } + guard machineIsLoaded else { continue } + switch lease.state { + case .running: + _ = try ledger.markStopped( + leaseID: lease.leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + case .recoveryRequired: + _ = try ledger.reconcileExpiredStart( + leaseID: lease.leaseID, + observedRuntimeState: .stopped, + expectedLeaseRevision: lease.leaseRevision + ) + case .starting, .stopped: + break + } + } catch { + throw MachineManagerError.persistence( + "resource admission for \(lease.binding.machineID) could not be reconciled: \(error)" + ) + } + } + } + + private func markResolvedAdmissionRunning( + plan: DoryResolvedMachinePlan + ) throws { + guard let ledger = productionResourceAdmissionLedger else { return } + let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger) + guard lease.state == .starting else { + throw MachineManagerError.persistence( + "resolved start requires a starting resource-admission lease" + ) + } + do { + _ = try ledger.markRunning( + leaseID: lease.leaseID, + plan: plan, + hostFacts: lease.hostFacts, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + throw MachineManagerError.persistence( + "resource admission could not commit running state: \(error)" + ) + } + } + + private func markResolvedAdmissionStopped( + plan: DoryResolvedMachinePlan? + ) throws { + guard let plan, let ledger = productionResourceAdmissionLedger else { return } + let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger) + do { + switch lease.state { + case .starting, .running: + _ = try ledger.markStopped( + leaseID: lease.leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + case .recoveryRequired: + _ = try ledger.reconcileExpiredStart( + leaseID: lease.leaseID, + observedRuntimeState: .stopped, + expectedLeaseRevision: lease.leaseRevision + ) + case .stopped: + break + } + } catch { + throw MachineManagerError.persistence( + "resource admission could not commit stopped state: \(error)" + ) + } + } + + private func prepareRetainedResolvedAdmissionForRestart( + plan: DoryResolvedMachinePlan? + ) throws { + guard let plan, let ledger = productionResourceAdmissionLedger else { return } + let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger) + switch lease.state { + case .running: + do { + _ = try ledger.prepareRetainedRunningForRestart( + leaseID: lease.leaseID, + plan: plan, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + throw MachineManagerError.persistence( + "retained resource admission could not authorize restart: \(error)" + ) + } + case .starting: + return + case .stopped, .recoveryRequired: + throw MachineManagerError.persistence( + "retained resource admission is unavailable for restart" + ) + } + } + + private func releaseResolvedAdmissionStorage( + machineID: String, + plan: DoryResolvedMachinePlan? + ) throws { + guard let ledger = productionResourceAdmissionLedger else { return } + let lease: DoryVirtualMachineResourceAdmissionLease + if let plan { + lease = try exactResourceAdmissionLease(for: plan, ledger: ledger) + } else { + let matches = try ledger.snapshot().leases.filter { + $0.binding.machineID == machineID + } + guard matches.count <= 1 else { + throw MachineManagerError.persistence( + "machine has ambiguous resource storage reservations" + ) + } + guard let retained = matches.first else { return } + lease = retained + } + guard lease.state == .stopped else { + throw MachineManagerError.persistence( + "resource storage can be released only after the machine is stopped" + ) + } + do { + try ledger.releaseStorageReservation( + leaseID: lease.leaseID, + expectedLeaseRevision: lease.leaseRevision + ) + } catch { + throw MachineManagerError.persistence( + "resource storage reservation could not be released: \(error)" + ) + } + } + private static func fileSHA256(path: String) throws -> String { guard let handle = FileHandle(forReadingAtPath: path) else { throw MachineManagerError.persistence("cannot open runtime executable at \(path)") @@ -1719,7 +2013,8 @@ public final class MachineManager: @unchecked Sendable { private func spawnPreparedMachine( _ preparedMachine: DoryMachineConfiguration, launchBinding: MachineBackendLaunchBinding?, - preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil + preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil, + resolvedPlan: DoryResolvedMachinePlan? = nil ) throws -> DoryMachineStatus { lock.lock() guard var entry = machines[preparedMachine.id] else { @@ -1777,6 +2072,13 @@ public final class MachineManager: @unchecked Sendable { } catch { handoffServer?.stop() lock.unlock() + do { try markResolvedAdmissionStopped(plan: resolvedPlan) } + catch let settlementError { + throw MachineManagerError.persistence( + "resolved launch failed before spawn: \(error); " + + "resource settlement failed: \(settlementError)" + ) + } throw error } let process = HvProcess( @@ -1796,7 +2098,11 @@ public final class MachineManager: @unchecked Sendable { entry.launchID = launchID entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil - entry.state = configuration.requiresReadyHandoff ? .starting : .running + entry.activeResolvedPlan = resolvedPlan + let requiresAdmissionCommit = resolvedPlan != nil + && productionResourceAdmissionLedger != nil + entry.state = configuration.requiresReadyHandoff || requiresAdmissionCommit + ? .starting : .running entry.lastError = nil machines[id] = entry lock.unlock() @@ -1809,13 +2115,42 @@ public final class MachineManager: @unchecked Sendable { machines[id]?.handoffServer = nil machines[id]?.launchID = nil machines[id]?.runtimeAddress = nil + machines[id]?.activeResolvedPlan = nil machines[id]?.state = .failed machines[id]?.lastError = "\(error)" lock.unlock() + process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + do { try markResolvedAdmissionStopped(plan: resolvedPlan) } + catch let settlementError { + throw MachineManagerError.persistence( + "resolved process start failed: \(error); " + + "resource settlement failed: \(settlementError)" + ) + } throw error } if configuration.requiresReadyHandoff { scheduleHandoffTimeout(id: id, process: process, timeout: handoffReadyTimeout) + } else if requiresAdmissionCommit, let resolvedPlan { + do { + try markResolvedAdmissionRunning(plan: resolvedPlan) + lock.lock() + if machines[id]?.launchID == launchID { + machines[id]?.state = .running + } + lock.unlock() + } catch { + process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + lock.lock() + if machines[id]?.launchID == launchID { + machines[id]?.state = .failed + machines[id]?.lastError = "\(error)" + machines[id]?.activeResolvedPlan = nil + } + lock.unlock() + try? markResolvedAdmissionStopped(plan: resolvedPlan) + throw error + } } return status(id: id) ?? DoryMachineStatus(id: id, state: .running) } @@ -1893,12 +2228,15 @@ public final class MachineManager: @unchecked Sendable { entry.currentBalloonTargetMB = nil entry.state = .failed entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s" + let admissionPlan = entry.activeResolvedPlan + entry.activeResolvedPlan = nil self.machines[id] = entry self.lock.unlock() + process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) self.operationLock.lock() + try? self.markResolvedAdmissionStopped(plan: admissionPlan) self.failActiveStartLifecycle(id: id, stepID: "start.readiness-timeout") self.operationLock.unlock() - process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) } } @@ -1908,6 +2246,7 @@ public final class MachineManager: @unchecked Sendable { termination: HvProcessTermination ) { var handoffServer: VmmHandoffServer? + var admissionPlan: DoryResolvedMachinePlan? lock.lock() guard var entry = machines[machineID], entry.launchID == launchID, @@ -1917,17 +2256,20 @@ public final class MachineManager: @unchecked Sendable { return } handoffServer = entry.handoffServer + admissionPlan = entry.activeResolvedPlan entry.handoffServer = nil entry.handoff = nil entry.launchID = nil entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil + entry.activeResolvedPlan = nil entry.state = .failed entry.lastError = "dory-vmm \(termination.description)" machines[machineID] = entry lock.unlock() operationLock.lock() + try? markResolvedAdmissionStopped(plan: admissionPlan) failActiveStartLifecycle(id: machineID, stepID: "start.helper-exited") operationLock.unlock() handoffServer?.stop() @@ -1945,7 +2287,8 @@ public final class MachineManager: @unchecked Sendable { private func stopImplementation( id: String, - journalLifecycle: Bool + journalLifecycle: Bool, + preserveResolvedAdmissionForRestart: Bool = false ) throws -> DoryMachineStatus { lock.lock() guard var entry = machines[id] else { @@ -1955,10 +2298,12 @@ public final class MachineManager: @unchecked Sendable { let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil let machine = entry.configuration let runtimeIdentity = entry.runtimeIdentity + let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan lock.unlock() let lifecycle = try journalLifecycle && wasActive ? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity) : nil + var stopCommitted = false do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } lock.lock() @@ -1975,12 +2320,17 @@ public final class MachineManager: @unchecked Sendable { entry.launchID = nil entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil + entry.activeResolvedPlan = nil entry.state = .stopped machines[id] = entry lock.unlock() handoffServer?.stop() process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + stopCommitted = true + if !preserveResolvedAdmissionForRestart { + try markResolvedAdmissionStopped(plan: admissionPlan) + } #if DEBUG try injectLifecycleFault(.stopAfterProcessStop) #endif @@ -1995,6 +2345,16 @@ public final class MachineManager: @unchecked Sendable { #if DEBUG if error is MachineLifecycleInjectedCrash { throw error } #endif + if stopCommitted { + if let lifecycle { + activeLifecycleOperations.removeValue(forKey: id) + lifecycle.releaseLease() + } + lock.lock() + machines[id]?.lastError = "machine stopped but resource settlement requires recovery: \(error)" + lock.unlock() + throw error + } if let lifecycle { failLifecycle(lifecycle, stepID: "stop.failed") } throw error } @@ -2005,7 +2365,12 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } lock.lock() let runningEntries = machines.map { id, entry in - (id: id, process: entry.process, handoffServer: entry.handoffServer) + ( + id: id, + process: entry.process, + handoffServer: entry.handoffServer, + admissionPlan: entry.activeResolvedPlan ?? entry.runtimeIdentity.resolvedPlan + ) } for id in machines.keys { machines[id]?.process = nil @@ -2014,6 +2379,7 @@ public final class MachineManager: @unchecked Sendable { machines[id]?.launchID = nil machines[id]?.runtimeAddress = nil machines[id]?.currentBalloonTargetMB = nil + machines[id]?.activeResolvedPlan = nil machines[id]?.state = .stopped } lock.unlock() @@ -2021,6 +2387,7 @@ public final class MachineManager: @unchecked Sendable { for entry in runningEntries { entry.handoffServer?.stop() entry.process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + try? markResolvedAdmissionStopped(plan: entry.admissionPlan) } } @@ -2090,6 +2457,10 @@ public final class MachineManager: @unchecked Sendable { // in-memory entry whose storage no longer exists. Recovery can deterministically // finish the still-durable deleting operation on the next daemon start. deletionCommitted = true + try releaseResolvedAdmissionStorage( + machineID: id, + plan: sourceEntry.runtimeIdentity.resolvedPlan + ) do { try completeLifecycle(lifecycle) } catch { @@ -2102,6 +2473,7 @@ public final class MachineManager: @unchecked Sendable { #endif if deletionCommitted { activeLifecycleOperations.removeValue(forKey: id) + lifecycle.releaseLease() return } let quarantine = lifecycleDeletionQuarantinePath( @@ -2313,7 +2685,20 @@ public final class MachineManager: @unchecked Sendable { // Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for // the snapshot can be hashed and bound before any snapshot artifact is published. if wasRunning { - _ = try stopImplementation(id: id, journalLifecycle: true) + _ = try stopImplementation( + id: id, + journalLifecycle: true, + preserveResolvedAdmissionForRestart: true + ) + } + var snapshotLifecycleStarted = false + defer { + if wasRunning, !snapshotLifecycleStarted { + try? prepareRetainedResolvedAdmissionForRestart( + plan: snapshotRuntimeIdentity.resolvedPlan + ) + _ = try? startImplementation(id: id, journalLifecycle: true) + } } let liveMachineIdentifierPath = machine.bootMode == .efi ? machineFirmwareIdentifierPath(id: id) : nil @@ -2368,6 +2753,7 @@ public final class MachineManager: @unchecked Sendable { snapshotID: snapshotID, snapshotAuthority: snapshotAuthority ) + snapshotLifecycleStarted = true var publishedRootfs = false var publishedKernel = false @@ -2415,6 +2801,9 @@ public final class MachineManager: @unchecked Sendable { } failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true) if wasRunning { + try? prepareRetainedResolvedAdmissionForRestart( + plan: snapshotRuntimeIdentity.resolvedPlan + ) _ = try? startImplementation(id: id, journalLifecycle: true) } if let error = error as? MachineManagerError { @@ -2429,9 +2818,15 @@ public final class MachineManager: @unchecked Sendable { ) if wasRunning, journalCompleted { do { + try prepareRetainedResolvedAdmissionForRestart( + plan: snapshotRuntimeIdentity.resolvedPlan + ) _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch let firstError { do { + try prepareRetainedResolvedAdmissionForRestart( + plan: snapshotRuntimeIdentity.resolvedPlan + ) _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { throw MachineManagerError.persistence( @@ -2799,7 +3194,11 @@ public final class MachineManager: @unchecked Sendable { do { try advanceLifecycleToPublishing(lifecycle) if wasRunning { - _ = try stopImplementation(id: machineID, journalLifecycle: false) + _ = try stopImplementation( + id: machineID, + journalLifecycle: false, + preserveResolvedAdmissionForRestart: true + ) } try restoreManagedArtifacts( machine: machine, @@ -2822,8 +3221,24 @@ public final class MachineManager: @unchecked Sendable { } let status: DoryMachineStatus if wasRunning, shouldRestartAfterSnapshotRestore(targetIdentity) { + try prepareRetainedResolvedAdmissionForRestart( + plan: sourceIdentity.resolvedPlan + ) status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false) } else { + if wasRunning { + do { + try markResolvedAdmissionStopped(plan: sourceIdentity.resolvedPlan) + } catch { + // The restored target is already committed and its old plan is no longer + // launch authority. Keep the conservative running reservation for daemon + // restart reconciliation instead of falsely rolling back the restore. + lock.lock() + machines[machineID]?.lastError = + "snapshot restored; resource settlement requires recovery: \(error)" + lock.unlock() + } + } status = self.status(id: machineID) ?? DoryMachineStatus(id: machineID, state: .stopped) } @@ -2834,6 +3249,9 @@ public final class MachineManager: @unchecked Sendable { return status } catch let error as MachineManagerError { if wasRunning { + try? prepareRetainedResolvedAdmissionForRestart( + plan: sourceIdentity.resolvedPlan + ) _ = try? startImplementation(id: machineID, journalLifecycle: false) } failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true) @@ -2843,6 +3261,9 @@ public final class MachineManager: @unchecked Sendable { if error is MachineLifecycleInjectedCrash { throw error } #endif if wasRunning { + try? prepareRetainedResolvedAdmissionForRestart( + plan: sourceIdentity.resolvedPlan + ) _ = try? startImplementation(id: machineID, journalLifecycle: false) } failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true) @@ -5093,6 +5514,8 @@ public final class MachineManager: @unchecked Sendable { var processToStop: HvProcess? var agentSocketPath: String? var lifecycleReadinessSucceeded = false + var admissionPlan: DoryResolvedMachinePlan? + var requiresAdmissionCommit = false lock.lock() guard var entry = machines[machineID], entry.launchID == launchID else { lock.unlock() @@ -5100,6 +5523,7 @@ public final class MachineManager: @unchecked Sendable { } handoffServer = entry.handoffServer entry.handoffServer = nil + admissionPlan = entry.activeResolvedPlan switch result { case let .success(handoff): guard handoff.ready.machineID == machineID else { @@ -5107,34 +5531,84 @@ public final class MachineManager: @unchecked Sendable { entry.lastError = "handoff machine id mismatch: \(handoff.ready.machineID)" entry.launchID = nil entry.runtimeAddress = nil + entry.activeResolvedPlan = nil processToStop = entry.process break } entry.handoff = handoff - entry.state = .running entry.lastError = nil - entry.process?.disableRestarts() - agentSocketPath = handoff.ready.agentSocketPath - lifecycleReadinessSucceeded = true + requiresAdmissionCommit = admissionPlan != nil + && productionResourceAdmissionLedger != nil + if requiresAdmissionCommit { + entry.state = .starting + } else { + entry.state = .running + entry.process?.disableRestarts() + agentSocketPath = handoff.ready.agentSocketPath + lifecycleReadinessSucceeded = true + } case let .failure(error): entry.state = .failed entry.lastError = "\(error)" entry.launchID = nil entry.runtimeAddress = nil + entry.activeResolvedPlan = nil processToStop = entry.process } machines[machineID] = entry lock.unlock() + handoffServer?.stop() + processToStop?.stop() + operationLock.lock() - if lifecycleReadinessSucceeded { + if requiresAdmissionCommit, let admissionPlan { + do { + try markResolvedAdmissionRunning(plan: admissionPlan) + lock.lock() + if var current = machines[machineID], current.launchID == launchID, + current.state == .starting { + current.state = .running + current.lastError = nil + current.process?.disableRestarts() + agentSocketPath = current.handoff?.ready.agentSocketPath + machines[machineID] = current + lifecycleReadinessSucceeded = true + } + lock.unlock() + if lifecycleReadinessSucceeded { + completeActiveStartLifecycle(id: machineID) + } else { + try markResolvedAdmissionStopped(plan: admissionPlan) + failActiveStartLifecycle( + id: machineID, + stepID: "start.readiness-state-changed" + ) + } + } catch { + lock.lock() + if var current = machines[machineID], current.launchID == launchID { + processToStop = current.process + current.state = .failed + current.lastError = "resource admission rejected readiness: \(error)" + current.handoff = nil + current.launchID = nil + current.runtimeAddress = nil + current.activeResolvedPlan = nil + machines[machineID] = current + } + lock.unlock() + try? markResolvedAdmissionStopped(plan: admissionPlan) + failActiveStartLifecycle(id: machineID, stepID: "start.admission-failed") + } + } else if lifecycleReadinessSucceeded { completeActiveStartLifecycle(id: machineID) } else { + try? markResolvedAdmissionStopped(plan: admissionPlan) failActiveStartLifecycle(id: machineID, stepID: "start.readiness-failed") } operationLock.unlock() - handoffServer?.stop() processToStop?.stop() if let agentSocketPath { DispatchQueue.global(qos: .utility).async { [weak self] in @@ -7885,6 +8359,7 @@ private struct PreparedMachineStart { private struct PendingResolvedMachineStart { var machine: DoryMachineConfiguration + var plan: DoryResolvedMachinePlan var backend: MachineBackendDescriptor var runtimeBuildIdentifier: String var runtimeComponents: [DoryResolvedBackendComponentEvidence] @@ -7905,6 +8380,7 @@ private struct MachineEntry { var runtimeAddress: String? var currentBalloonTargetMB: UInt64? var lastError: String? + var activeResolvedPlan: DoryResolvedMachinePlan? var runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index c4ab10d5..b79a4767 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -413,6 +413,45 @@ struct DoryDaemonVirtualMachineProductionTrustTests { #expect(plan.launchArtifacts.count == 2) #expect(plan.devices.clockSynchronization) #expect(plan.devices.gracefulShutdown) + let started = try context.machineManager.start(id: "qualified-headless") + #expect(started.state == .running) + #expect(try context.planning.resourceLedger.snapshot().leases.first { + $0.binding.machineID == "qualified-headless" + }?.state == .running) + let stopped = try context.machineManager.stop(id: "qualified-headless") + #expect(stopped.state == .stopped) + #expect(try context.planning.resourceLedger.snapshot().leases.first { + $0.binding.machineID == "qualified-headless" + }?.state == .stopped) + let restarted = try context.machineManager.start(id: "qualified-headless") + #expect(restarted.state == .running) + #expect(try context.planning.plans.read(id: "qualified-headless").planRevision == 2) + #expect(try context.planning.resourceLedger.snapshot().leases.first { + $0.binding.machineID == "qualified-headless" + }?.state == .running) + let snapshot = try context.machineManager.snapshot( + id: "qualified-headless", + snapshotID: "running-admission" + ) + #expect(snapshot.machineID == "qualified-headless") + #expect(context.machineManager.status(id: "qualified-headless")?.state == .running) + #expect(try context.planning.plans.read(id: "qualified-headless").planRevision == 2) + #expect(try context.planning.resourceLedger.snapshot().leases.first { + $0.binding.machineID == "qualified-headless" + }?.state == .running) + let restored = try context.machineManager.restoreSnapshot( + machineID: "qualified-headless", + snapshotID: snapshot.id + ) + #expect(restored.state == .stopped) + #expect(restored.runtimeIdentity.mode == .requiresReplanning) + #expect(try context.planning.resourceLedger.snapshot().leases.first { + $0.binding.machineID == "qualified-headless" + }?.state == .stopped) + try context.machineManager.delete(id: "qualified-headless") + #expect(try context.planning.resourceLedger.snapshot().leases.contains { + $0.binding.machineID == "qualified-headless" + } == false) } @Test("trust-floor failure exposes no manager and a fresh activation can retry") @@ -775,7 +814,8 @@ private final class ProductionTrustFixture: @unchecked Sendable { trustFloorDirectorySyncFails: Bool = false, trustFloorActivationState: ProductionTrustFloorActivationState? = nil ) throws { - helperDigest = Self.digest(Data("verified-helper".utf8)) + let helperData = Data("#!/bin/sh\nexec /bin/sleep 30\n".utf8) + helperDigest = Self.digest(helperData) hostState = ProductionHostState(host) root = FileManager.default.temporaryDirectory.appendingPathComponent( "dory-production-trust-\(UUID().uuidString)", @@ -792,7 +832,7 @@ private final class ProductionTrustFixture: @unchecked Sendable { let vz = root.appendingPathComponent("dory-vmm").path let raw = root.appendingPathComponent("dory-hv").path for path in [vz, raw] { - try Data("#!/bin/sh\nexit 0\n".utf8).write(to: URL(fileURLWithPath: path)) + try helperData.write(to: URL(fileURLWithPath: path)) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path) } machineConfiguration = MachineManagerConfiguration( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift index 4fa8b16c..1e60270c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -106,6 +106,63 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { } } + func testRunningLeaseCanBeReopenedOnlyForTheSameRetainedPlan() throws { + try withFixture("retained-running-restart") { fixture in + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence) + let bound = try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: plan, + expectedLeaseRevision: reserved.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + + var changedPlan = plan + changedPlan.planRevision += 1 + XCTAssertThrowsError(try fixture.ledger.prepareRetainedRunningForRestart( + leaseID: running.leaseID, + plan: changedPlan, + expectedLeaseRevision: running.leaseRevision + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .planMismatch + ) + } + XCTAssertEqual(try fixture.ledger.snapshot().leases.first, running) + + let restarting = try fixture.ledger.prepareRetainedRunningForRestart( + leaseID: running.leaseID, + plan: plan, + expectedLeaseRevision: running.leaseRevision + ) + XCTAssertEqual(restarting.state, .starting) + XCTAssertEqual(restarting.leaseRevision, running.leaseRevision + 1) + XCTAssertEqual(restarting.boundPlanSHA256, running.boundPlanSHA256) + XCTAssertEqual(restarting.evidence, running.evidence) + XCTAssertThrowsError(try fixture.ledger.prepareRetainedRunningForRestart( + leaseID: restarting.leaseID, + plan: plan, + expectedLeaseRevision: restarting.leaseRevision + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .invalidLeaseState(.starting) + ) + } + } + } + func testStoppedMachineRestartAtomicallyReplacesItsStorageReservation() throws { try withFixture("stopped-restart") { fixture in let first = try fixture.ledger.reserveStarting( From c1c4ee8cf596bb9826ab7c4d722c558398ad87d1 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:09:32 +0000 Subject: [PATCH 042/338] feat(vm): make typed settings workspace-authoritative --- Dory/Models/AppStore.swift | 9 +- Dory/Runtime/Doryd/DorydClient.swift | 128 +++++ DoryTests/DorydClientTests.swift | 52 ++ .../DoryMachineTypedWriteAuthority.swift | 208 ++++++- .../DorydKit/DoryWorkspaceRepository.swift | 3 +- .../Sources/DorydKit/DorydService.swift | 12 +- .../Sources/DorydKit/MachineManager.swift | 518 ++++++++++++++---- .../DorydKitTests/DorydServiceTests.swift | 31 ++ ...eManagerResolvedPlanIntegrationTests.swift | 124 +++++ 9 files changed, 964 insertions(+), 121 deletions(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index c16278ac..300937e7 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5079,10 +5079,11 @@ final class AppStore { memoryMB: status.memoryMB.flatMap { Int(exactly: $0) }, mounts: status.shares.map(Self.mountPair(fromDoryd:)), env: status.environment, - virtualMachineSettings: DorydMachineTypedSettings( - legacyEnvironment: status.environment, - displayMode: status.displayMode - ), + virtualMachineSettings: status.typedSettings + ?? DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ), address: status.configuredAddress, displayMode: status.displayMode, bootMode: status.bootMode diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 0879e3e7..0eb4a454 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -762,6 +762,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var installerMediaAttached: Bool = false var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] + var typedSettings: DorydMachineTypedSettings? = nil var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil } @@ -1808,6 +1809,9 @@ nonisolated final class DorydClient: @unchecked Sendable { return nil } let environment = machineEnvironment(from: dictionary["env"]) + guard let typedSettings = machineTypedSettings(from: dictionary) else { + return nil + } guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( from: dictionary, legacyEnvironment: environment @@ -1839,11 +1843,135 @@ nonisolated final class DorydClient: @unchecked Sendable { ?? false, shares: machineShares(from: dictionary["shares"]), environment: environment, + typedSettings: typedSettings.value, runtimeIdentity: runtimeIdentity, installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value ) } + private struct ParsedMachineTypedSettings { + var value: DorydMachineTypedSettings? + } + + nonisolated private static func machineTypedSettings( + from dictionary: NSDictionary + ) -> ParsedMachineTypedSettings? { + guard let encoded = dictionary["typedSettings"] else { + return ParsedMachineTypedSettings(value: nil) + } + guard let value = encoded as? NSDictionary, + let keys = value.allKeys as? [String], + Set(keys).isSubset(of: [ + "guestIdentityIntent", "clipboardPolicy", + "desktopRuntimePreference", "desktopGraphicsPreference", + ]), keys.count == Set(keys).count else { + return nil + } + + var identity = DoryVMGuestIdentityIntent.unspecified + if let encodedIdentity = value["guestIdentityIntent"] { + guard let rawIdentity = encodedIdentity as? NSDictionary, + let identityKeys = rawIdentity.allKeys as? [String], + Set(identityKeys).isSubset(of: ["account", "desktop"]), + identityKeys.count == Set(identityKeys).count else { return nil } + if let encodedAccount = rawIdentity["account"] { + guard let raw = encodedAccount as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys).isSubset(of: ["username", "numericUserID"]), + rawKeys.count == Set(rawKeys).count else { return nil } + let username: String? + if let encoded = raw["username"] { + guard let string = encoded as? String, + DoryVMGuestAccountIntent.isValidUsername(string) else { return nil } + username = string + } else { username = nil } + let numericUserID: UInt32? + if let encoded = raw["numericUserID"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue == Double(number.uint32Value), + DoryVMGuestAccountIntent.isValidNumericUserID(number.uint32Value) + else { return nil } + numericUserID = number.uint32Value + } else { numericUserID = nil } + let account = DoryVMGuestAccountIntent( + username: username, + numericUserID: numericUserID + ) + guard account.isValidForPersistence else { return nil } + identity.account = account + } + if let encodedDesktop = rawIdentity["desktop"] { + guard let raw = encodedDesktop as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys).isSubset(of: [ + "distributionIdentifier", "displayName", "version", + "desktopEnvironment", + ]), rawKeys.count == Set(rawKeys).count else { return nil } + func string(_ key: String, validating: (String) -> Bool) -> String? { + guard let encoded = raw[key] else { return nil } + guard let value = encoded as? String, validating(value) else { return nil } + return value + } + let distribution = string( + "distributionIdentifier", + validating: DoryVMDesktopIdentityIntent.isValidDistributionIdentifier + ) + if raw["distributionIdentifier"] != nil, distribution == nil { return nil } + let displayName = string( + "displayName", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["displayName"] != nil, displayName == nil { return nil } + let version = string( + "version", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["version"] != nil, version == nil { return nil } + let desktopEnvironment = string( + "desktopEnvironment", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["desktopEnvironment"] != nil, desktopEnvironment == nil { return nil } + let desktop = DoryVMDesktopIdentityIntent( + distributionIdentifier: distribution, + displayName: displayName, + version: version, + desktopEnvironment: desktopEnvironment + ) + guard desktop.isValidForPersistence else { return nil } + identity.desktop = desktop + } + } + + let clipboard: DoryVMClipboardPolicy? + if let encoded = value["clipboardPolicy"] { + guard let raw = encoded as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys) == ["text", "image", "files"], + let text = (raw["text"] as? String).flatMap(DoryVMClipboardDirection.init), + let image = (raw["image"] as? String).flatMap(DoryVMClipboardDirection.init), + let files = (raw["files"] as? String).flatMap(DoryVMClipboardDirection.init) + else { return nil } + clipboard = DoryVMClipboardPolicy(text: text, image: image, files: files) + } else { clipboard = nil } + let runtime: DoryDesktopVMMPreference? + if let encoded = value["desktopRuntimePreference"] { + guard let raw = encoded as? String, + let parsed = DoryDesktopVMMPreference(rawValue: raw) else { return nil } + runtime = parsed + } else { runtime = nil } + let graphics: DoryDesktopGraphicsPreference? + if let encoded = value["desktopGraphicsPreference"] { + guard let raw = encoded as? String, + let parsed = DoryDesktopGraphicsPreference(rawValue: raw) else { return nil } + graphics = parsed + } else { graphics = nil } + return ParsedMachineTypedSettings(value: DorydMachineTypedSettings( + guestIdentityIntent: identity, + clipboardPolicy: clipboard, + runtimePreference: runtime, + graphicsPreference: graphics + )) + } + private struct ParsedInstalledDesktopPayloadReceipt { var value: DorydInstalledDesktopPayloadReceipt? } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index c46d5f22..0a307db3 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -168,6 +168,46 @@ struct DorydClientTests { #expect(try await client.engineSleep() == DorydCommandResult(ok: true, message: "")) } + @Test func machineListPrefersExactTypedSettingsAndRejectsMalformedClaims() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineTypedSettings("dev", [ + "guestIdentityIntent": [ + "account": [ + "username": "developer", + "numericUserID": UInt32(1_000), + ] as NSDictionary, + "desktop": [ + "distributionIdentifier": "ubuntu", + "displayName": "Ubuntu", + ] as NSDictionary, + ] as NSDictionary, + "clipboardPolicy": [ + "text": "bidirectional", "image": "bidirectional", "files": "off", + ] as NSDictionary, + "desktopRuntimePreference": "accelerated", + "desktopGraphicsPreference": "virgl-venus", + ]) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint) + let status = try #require((try await client.machineList()).first { $0.id == "dev" }) + #expect(status.environment.isEmpty) + #expect(status.typedSettings?.guestIdentityIntent.account?.username == "developer") + #expect(status.typedSettings?.guestIdentityIntent.desktop?.distributionIdentifier + == "ubuntu") + #expect(status.typedSettings?.runtimePreference == .accelerated) + #expect(status.typedSettings?.graphicsPreference == .virglVenus) + + service.setMachineTypedSettings("dev", ["unknown": "claim"]) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + @MainActor @Test func readsDoctorJSONAndIncidentsOverXPC() async throws { let listener = NSXPCListener.anonymous() @@ -2354,6 +2394,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } ) } + + func setMachineTypedSettings(_ machineID: String, _ typedSettings: NSDictionary) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current["typedSettings"] = typedSettings + current["env"] = [] as [NSDictionary] + current["displayMode"] = "desktop" + machines[machineID] = current.copy() as? NSDictionary + } var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index b7aaf3fc..d78ce8d7 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -32,12 +32,122 @@ public enum DoryMachineTypedSettingUpdate: Sendable } } -/// Typed public write authority for the compatibility MachineManager. +public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Hashable { + public var guestIdentityIntent: DoryVMGuestIdentityIntent + public var clipboardPolicy: DoryVMClipboardPolicy? + public var runtimePreference: DoryDesktopVMMPreference? + public var graphicsPreference: DoryDesktopGraphicsPreference? + + public init(definition: DoryVirtualMachineDefinition) throws { + guestIdentityIntent = definition.guestIdentityIntent + guard definition.display.enabled else { + clipboardPolicy = nil + runtimePreference = nil + graphicsPreference = nil + return + } + clipboardPolicy = definition.clipboardPolicy + switch (definition.backendPreference.mode, definition.backendPreference.backend) { + case (.automatic, nil): + runtimePreference = .automatic + case (.preferred, .doryHypervisor?): + runtimePreference = .accelerated + case (.preferred, .appleVirtualizationFramework?): + runtimePreference = .compatible + default: + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "desktopRuntimePreference" + ) + } + switch definition.graphics.acceptableLevels { + case [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]: + graphicsPreference = .automatic + case [.hardwareAccelerated3D]: + graphicsPreference = .virglVenus + case [.software]: + graphicsPreference = .software + default: + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "desktopGraphicsPreference" + ) + } + } + + public var xpcDictionary: NSDictionary { + DoryMachineTypedSettingsPatch( + guestUsername: update(guestIdentityIntent.account?.username), + guestNumericUserID: update(guestIdentityIntent.account?.numericUserID), + desktopDistributionIdentifier: update( + guestIdentityIntent.desktop?.distributionIdentifier + ), + desktopDisplayName: update(guestIdentityIntent.desktop?.displayName), + desktopVersion: update(guestIdentityIntent.desktop?.version), + desktopEnvironment: update( + guestIdentityIntent.desktop?.desktopEnvironment + ), + clipboardPolicy: update(clipboardPolicy), + runtimePreference: update(runtimePreference), + graphicsPreference: update(graphicsPreference) + ).xpcDictionary + } + + public func applyingAsReplacement( + to definition: DoryVirtualMachineDefinition, + displayMode: DoryMachineDisplayMode + ) throws -> DoryVirtualMachineDefinition { + try replacementPatch.applying(to: definition, displayMode: displayMode) + } + + public var replacementPatch: DoryMachineTypedSettingsPatch { + DoryMachineTypedSettingsPatch( + guestUsername: replacement(guestIdentityIntent.account?.username), + guestNumericUserID: replacement(guestIdentityIntent.account?.numericUserID), + desktopDistributionIdentifier: replacement( + guestIdentityIntent.desktop?.distributionIdentifier + ), + desktopDisplayName: replacement(guestIdentityIntent.desktop?.displayName), + desktopVersion: replacement(guestIdentityIntent.desktop?.version), + desktopEnvironment: replacement( + guestIdentityIntent.desktop?.desktopEnvironment + ), + clipboardPolicy: replacement(clipboardPolicy), + runtimePreference: replacement(runtimePreference), + graphicsPreference: replacement(graphicsPreference) + ) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(guestIdentityIntent.account?.username) + hasher.combine(guestIdentityIntent.account?.numericUserID) + hasher.combine(guestIdentityIntent.desktop?.distributionIdentifier) + hasher.combine(guestIdentityIntent.desktop?.displayName) + hasher.combine(guestIdentityIntent.desktop?.version) + hasher.combine(guestIdentityIntent.desktop?.desktopEnvironment) + hasher.combine(clipboardPolicy?.text.rawValue) + hasher.combine(clipboardPolicy?.image.rawValue) + hasher.combine(clipboardPolicy?.files.rawValue) + hasher.combine(runtimePreference?.rawValue) + hasher.combine(graphicsPreference?.rawValue) + } + + private func update( + _ value: Value? + ) -> DoryMachineTypedSettingUpdate { + value.map(DoryMachineTypedSettingUpdate.set) ?? .unchanged + } + + private func replacement( + _ value: Value? + ) -> DoryMachineTypedSettingUpdate { + value.map(DoryMachineTypedSettingUpdate.set) ?? .clear + } +} + +/// Typed public write authority shared by native workspace records and the legacy bridge. /// -/// MachineManager still reads legacy `machine.json` environment values while M0 migration is in -/// progress. Public callers never provide that dictionary: this patch accepts only non-secret, -/// bounded intent and back-projects the seven explicitly owned compatibility keys. Unchanged -/// fields preserve their exact legacy bytes, including values too old or unsafe to migrate. +/// Native workspaces apply this patch directly to their versioned definition. Legacy workspaces +/// back-project only the explicitly owned compatibility keys. Public callers never provide the +/// persisted environment dictionary, and unchanged legacy fields retain their exact bytes. public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { public var guestUsername: DoryMachineTypedSettingUpdate public var guestNumericUserID: DoryMachineTypedSettingUpdate @@ -333,6 +443,80 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return environment } + public func applying( + to source: DoryVirtualMachineDefinition, + displayMode: DoryMachineDisplayMode + ) throws -> DoryVirtualMachineDefinition { + try validate(displayMode: displayMode) + var definition = source + var account = definition.guestIdentityIntent.account ?? DoryVMGuestAccountIntent() + Self.apply(guestUsername, to: &account.username) + Self.apply(guestNumericUserID, to: &account.numericUserID) + definition.guestIdentityIntent.account = account.isEmpty ? nil : account + + var desktop = definition.guestIdentityIntent.desktop + ?? DoryVMDesktopIdentityIntent() + Self.apply( + desktopDistributionIdentifier, + to: &desktop.distributionIdentifier + ) + Self.apply(desktopDisplayName, to: &desktop.displayName) + Self.apply(desktopVersion, to: &desktop.version) + Self.apply(desktopEnvironment, to: &desktop.desktopEnvironment) + definition.guestIdentityIntent.desktop = desktop.isEmpty ? nil : desktop + + switch clipboardPolicy { + case .unchanged: + break + case .clear: + definition.clipboardPolicy = displayMode == .desktop + ? .legacyDesktop(.bidirectional) : .disabled + case let .set(policy): + definition.clipboardPolicy = policy + } + switch runtimePreference { + case .unchanged: + break + case .clear, .set(.automatic): + definition.backendPreference = DoryVMBackendPreference() + case .set(.accelerated): + definition.backendPreference = DoryVMBackendPreference( + mode: .preferred, + backend: .doryHypervisor + ) + case .set(.compatible): + definition.backendPreference = DoryVMBackendPreference( + mode: .preferred, + backend: .appleVirtualizationFramework + ) + } + switch graphicsPreference { + case .unchanged: + break + case .clear, .set(.automatic): + definition.graphics = DoryVMGraphicsPolicy( + acceptableLevels: [ + .hardwareAccelerated3D, + .hostAcceleratedDisplay, + .software, + ] + ) + case .set(.virgl), .set(.virglVenus): + definition.graphics = DoryVMGraphicsPolicy( + acceptableLevels: [.hardwareAccelerated3D] + ) + case .set(.software): + definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software]) + } + let issues = definition.validate() + guard issues.isEmpty else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + issues.first?.field ?? "definition" + ) + } + return definition + } + private mutating func decodeGuestIdentity(_ raw: Any, allowsClears: Bool) throws { if raw is NSNull { guard allowsClears else { @@ -531,6 +715,20 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return nil } + private static func apply( + _ update: DoryMachineTypedSettingUpdate, + to value: inout Value? + ) { + switch update { + case .unchanged: + break + case let .set(replacement): + value = replacement + case .clear: + value = nil + } + } + private static func hasOnlyKeys(_ dictionary: NSDictionary, allowed: Set) -> Bool { dictionary.allKeys.allSatisfy { key in guard let key = key as? String else { return false } diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift index d44ba858..fec2aff8 100644 --- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift +++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift @@ -136,8 +136,9 @@ public struct DoryWorkspaceLegacyProjectionReconcileResult: Sendable, Equatable /// paths or credentials because `DoryVirtualMachineDefinition` contains resolver references only. public final class DoryWorkspaceRepository: @unchecked Sendable { public static let recordFileName = "workspace-v2.json" + public static let recordTemporaryPrefix = ".workspace-v2." - private static let temporaryPrefix = ".workspace-v2." + private static let temporaryPrefix = recordTemporaryPrefix private static let maximumRecordBytes = 16 * 1_024 * 1_024 public let root: String diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 8481d791..ded030ef 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -241,12 +241,11 @@ public final class DorydService: NSObject, DorydControl { xpcDictionary: config, allowsClears: false ) - var machine = try DoryMachineConfiguration(xpcDictionary: config) - machine.environment = try typedSettings.applying( - to: [:], - displayMode: machine.displayMode + let machine = try DoryMachineConfiguration(xpcDictionary: config) + var status = try machineManager.create( + machine, + typedSettings: typedSettings.isEmpty ? nil : typedSettings ) - var status = try machineManager.create(machine) if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority { guard let productionPlanningController else { throw MachineManagerError.persistence( @@ -1630,6 +1629,9 @@ private extension DoryMachineStatus { dictionary["displayMode"] = displayMode.rawValue dictionary["bootMode"] = bootMode.rawValue dictionary["installerMediaAttached"] = installerMediaAttached + if let typedSettings { + dictionary["typedSettings"] = typedSettings.xpcDictionary + } dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary if let installedDesktopPayloadReceipt { dictionary["installedDesktopPayloadReceipt"] = diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 9ce542f1..8be92f52 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -327,6 +327,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var installerMediaAttached: Bool public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var typedSettings: DoryMachineTypedSettingsSnapshot? public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? @@ -353,6 +354,7 @@ public struct DoryMachineStatus: Sendable, Equatable { installerMediaAttached: Bool = false, shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], + typedSettings: DoryMachineTypedSettingsSnapshot? = nil, runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion @@ -381,6 +383,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.installerMediaAttached = installerMediaAttached self.shares = shares self.environment = environment + self.typedSettings = typedSettings self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } @@ -401,6 +404,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var address: String? public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] + public var typedSettings: DoryMachineTypedSettingsSnapshot? public var bootMode: DoryMachineBootMode public var machineIdentifierPath: String? public var nvramPath: String? @@ -423,6 +427,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { address: String? = nil, shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], + typedSettings: DoryMachineTypedSettingsSnapshot? = nil, bootMode: DoryMachineBootMode = .linuxKernel, machineIdentifierPath: String? = nil, nvramPath: String? = nil, @@ -447,6 +452,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.address = address self.shares = shares self.environment = environment + self.typedSettings = typedSettings self.bootMode = bootMode self.machineIdentifierPath = machineIdentifierPath self.nvramPath = nvramPath @@ -470,6 +476,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case address case shares case environment + case typedSettings case bootMode case machineIdentifierPath case nvramPath @@ -495,6 +502,10 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { address: try container.decodeIfPresent(String.self, forKey: .address), shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [], environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:], + typedSettings: try container.decodeIfPresent( + DoryMachineTypedSettingsSnapshot.self, + forKey: .typedSettings + ), bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel, machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath), nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath), @@ -926,7 +937,10 @@ public final class MachineManager: @unchecked Sendable { } @discardableResult - public func create(_ machine: DoryMachineConfiguration) throws -> DoryMachineStatus { + public func create( + _ machine: DoryMachineConfiguration, + typedSettings: DoryMachineTypedSettingsPatch? = nil + ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } guard Self.isValidID(machine.id) else { @@ -934,6 +948,18 @@ public final class MachineManager: @unchecked Sendable { } var machine = machine machine.address = try Self.normalizedAddress(machine.address) + if launchPolicy == .perWorkspaceAuthority { + guard machine.environment.isEmpty else { + throw MachineManagerError.persistence( + "native workspace creation does not accept persisted environment values" + ) + } + } else if let typedSettings { + machine.environment = try typedSettings.applying( + to: machine.environment, + displayMode: machine.displayMode + ) + } try Self.validateLaunchConfiguration(machine) lock.lock() let exists = machines[machine.id] != nil || deletingMachineIDs.contains(machine.id) @@ -1013,6 +1039,20 @@ public final class MachineManager: @unchecked Sendable { let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine() let authoritativeLegacyData = try DoryMachineConfigurationMigrationBridge .encodeLegacy(preparedMachine) + var nativeDefinition: DoryVirtualMachineDefinition? + if launchPolicy == .perWorkspaceAuthority { + let facts = try workspaceMigrationFacts(for: preparedMachine) + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + preparedMachine, + facts: facts + ) + let definition = try (typedSettings ?? DoryMachineTypedSettingsPatch()).applying( + to: migration.definition, + displayMode: preparedMachine.displayMode + ) + try workspaceRepository.create(definition) + nativeDefinition = definition + } // The launch authority is durable before machine.json becomes discoverable. A crash in // this window leaves an incomplete, non-loadable directory, never a newly created VM that // startup can mistake for pre-companion legacy state. @@ -1021,7 +1061,10 @@ public final class MachineManager: @unchecked Sendable { machineID: preparedMachine.id, authoritativeLegacyData: authoritativeLegacyData ) - try persist(preparedMachine) + try persist( + preparedMachine, + reconcilesLegacyProjection: launchPolicy != .perWorkspaceAuthority + ) // machine.json is now durable. Preserve this exact state on a marker-transition error so // restart can complete it; never report success until the committed marker is directory- // durable. Keeping the committed marker also distinguishes later metadata loss from an @@ -1054,6 +1097,7 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: preparedMachine.installerISOPath != nil, shares: preparedMachine.shares, environment: preparedMachine.environment, + typedSettings: try nativeDefinition.map(DoryMachineTypedSettingsSnapshot.init), runtimeIdentity: initialRuntimeIdentity ) } @@ -1136,36 +1180,18 @@ public final class MachineManager: @unchecked Sendable { "authoritative machine metadata is unavailable for production planning" ) } - let facts = try workspaceMigrationFacts(for: entry.configuration) - let factsData = try Self.workspaceMigrationAuthorityData(facts) - let migration = try DoryMachineConfigurationMigrationBridge.migrate( - entry.configuration, - facts: facts - ) - let definition: DoryVirtualMachineDefinition + let authority: MachineWorkspaceAuthority do { - definition = try workspaceRepository.reconcileLegacyProjection( - migration.definition, - authoritativeLegacyData: legacyData, - authoritativeMigrationFactsData: factsData - ).definition - let persisted = try workspaceRepository.readLegacyProjection( - id: id, - authoritativeLegacyData: legacyData, - authoritativeMigrationFactsData: factsData + authority = try workspaceAuthority( + machine: entry.configuration, + authoritativeLegacyData: legacyData ) - guard persisted == definition else { - throw MachineManagerError.persistence( - "workspace projection changed during production planning" - ) - } - } catch let error as MachineManagerError { - throw error } catch { throw MachineManagerError.persistence( - "workspace projection is unavailable for production planning: \(error)" + "workspace authority is unavailable for production planning: \(error)" ) } + let definition = authority.definition let canonicalDefinitionData = try Self.canonicalDefinitionData(definition) let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore @@ -1195,7 +1221,7 @@ public final class MachineManager: @unchecked Sendable { "workspace launch artifacts are not representable" ) } - let bindings = Dictionary(grouping: migration.artifactBindings, by: \.reference) + let bindings = Dictionary(grouping: authority.migration.artifactBindings, by: \.reference) var publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] = [] publications.reserveCapacity(requirements.count) for requirement in requirements { @@ -1226,7 +1252,7 @@ public final class MachineManager: @unchecked Sendable { planning: DoryDaemonVirtualMachinePlanningRequest( definition: definition, canonicalDefinitionData: canonicalDefinitionData, - machine: entry.configuration, + machine: authority.runtimeMachine, publication: planPublication ), workspacePublication: .retainExistingExact @@ -1345,11 +1371,14 @@ public final class MachineManager: @unchecked Sendable { try revalidateDurableRuntimeIdentity( id: id, expected: expectedDurableIdentity, - expectedMachine: prepared.machine + expectedMachine: prepared.authoritativeMachine ) } let lifecycle = try journalLifecycle - ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity) + ? beginLifecycleStart( + machine: prepared.authoritativeMachine, + targetIdentity: identity + ) : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } @@ -1436,11 +1465,14 @@ public final class MachineManager: @unchecked Sendable { try revalidateDurableRuntimeIdentity( id: id, expected: expectedRuntimeIdentity, - expectedMachine: prepared.machine + expectedMachine: prepared.authoritativeMachine ) } let lifecycle = try journalLifecycle - ? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity) + ? beginLifecycleStart( + machine: prepared.authoritativeMachine, + targetIdentity: runtimeIdentity + ) : nil do { @@ -1522,16 +1554,16 @@ public final class MachineManager: @unchecked Sendable { ) throws { guard let preparedLegacyData = prepared.authoritativeLegacyData, let currentLegacyData = Self.readPrivateMetadata( - path: machineConfigPath(id: prepared.machine.id) + path: machineConfigPath(id: prepared.authoritativeMachine.id) ), currentLegacyData == preparedLegacyData, let decoded = try? JSONDecoder().decode( DoryMachineConfiguration.self, from: currentLegacyData ), - decoded == prepared.machine, + decoded == prepared.authoritativeMachine, let currentDefinition = reconcileWorkspaceProjection( - machine: prepared.machine, + machine: prepared.authoritativeMachine, authoritativeLegacyData: currentLegacyData ), currentDefinition == expectedDefinition, @@ -1920,19 +1952,20 @@ public final class MachineManager: @unchecked Sendable { } lock.unlock() - let machine = entry.configuration - try ensureInstalledLinuxBootBundleIfNeeded(machine) - try materializeInstalledLinuxBootRuntimeIfNeeded(machine) + let authoritativeMachine = entry.configuration + try ensureInstalledLinuxBootBundleIfNeeded(authoritativeMachine) + try materializeInstalledLinuxBootRuntimeIfNeeded(authoritativeMachine) let authoritativeLegacyData = Self.readPrivateMetadata( - path: machineConfigPath(id: machine.id) + path: machineConfigPath(id: authoritativeMachine.id) ) var authoritativeDefinition: DoryVirtualMachineDefinition? + var runtimeMachine = authoritativeMachine if let authoritativeLegacyData { if requiresAuthoritativeDefinition { guard let decoded = try? JSONDecoder().decode( DoryMachineConfiguration.self, from: authoritativeLegacyData - ), decoded == machine else { + ), decoded == authoritativeMachine else { throw MachineManagerError.persistence( "authoritative machine metadata changed before resolved launch" ) @@ -1941,18 +1974,30 @@ public final class MachineManager: @unchecked Sendable { // Boot-bundle materialization can change authoritative inspection facts without // changing machine.json. Reconcile those facts immediately before launch. authoritativeDefinition = reconcileWorkspaceProjection( - machine: machine, + machine: authoritativeMachine, authoritativeLegacyData: authoritativeLegacyData ) + if authoritativeDefinition != nil { + do { + runtimeMachine = try workspaceAuthority( + machine: authoritativeMachine, + authoritativeLegacyData: authoritativeLegacyData + ).runtimeMachine + } catch { + throw MachineManagerError.persistence( + "workspace runtime projection is unavailable: \(error)" + ) + } + } } else if requiresAuthoritativeDefinition { throw MachineManagerError.persistence( "authoritative machine metadata is unavailable for resolved launch" ) } - try Self.validateLaunchConfiguration(machine) - try validateManagedMachineArtifacts(machine) + try Self.validateLaunchConfiguration(runtimeMachine) + try validateManagedMachineArtifacts(runtimeMachine) if !requiresAuthoritativeDefinition { - try validateRuntimeAvailability(machine) + try validateRuntimeAvailability(runtimeMachine) } if requiresAuthoritativeDefinition, authoritativeDefinition == nil { throw MachineManagerError.persistence( @@ -1961,7 +2006,8 @@ public final class MachineManager: @unchecked Sendable { } let definitionData = try authoritativeDefinition.map(Self.canonicalDefinitionData) return PreparedMachineStart( - machine: machine, + machine: runtimeMachine, + authoritativeMachine: authoritativeMachine, definition: authoritativeDefinition, canonicalDefinitionData: definitionData, authoritativeLegacyData: authoritativeLegacyData @@ -2525,6 +2571,19 @@ public final class MachineManager: @unchecked Sendable { ) } let (current, wasRunning) = try configurationAndRunningState(id: id) + let nativeRecord: DoryWorkspaceRepositoryRecord? + if launchPolicy == .perWorkspaceAuthority { + let record = try workspaceRepository.readPersistedRecord(id: id) + nativeRecord = record.legacyConfigurationSHA256 == nil + && record.legacyMigrationFactsSHA256 == nil ? record : nil + } else { + nativeRecord = nil + } + if nativeRecord != nil, updatesEnvironment { + throw MachineManagerError.persistence( + "native workspace updates do not accept persisted environment values" + ) + } var updated = current if let memoryMB { updated.memoryMB = memoryMB @@ -2541,7 +2600,7 @@ public final class MachineManager: @unchecked Sendable { if updatesEnvironment { updated.environment = environment ?? [:] } - if let typedSettingsPatch { + if let typedSettingsPatch, nativeRecord == nil { updated.environment = try typedSettingsPatch.applying( to: current.environment, displayMode: updated.displayMode @@ -2562,7 +2621,54 @@ public final class MachineManager: @unchecked Sendable { } } try Self.validateLaunchConfiguration(updated) - guard updated != current else { + var nativeDefinition: DoryVirtualMachineDefinition? + if let nativeRecord { + let facts = try workspaceMigrationFacts(for: updated) + var migration = try DoryMachineConfigurationMigrationBridge.migrate( + updated, + facts: facts + ) + var candidate = migration.definition + candidate.lifecycle = nativeRecord.definition.lifecycle + candidate.backendPreference = nativeRecord.definition.backendPreference + candidate.graphics = nativeRecord.definition.graphics + candidate.guestIdentityIntent = nativeRecord.definition.guestIdentityIntent + candidate.clipboardPolicy = nativeRecord.definition.clipboardPolicy + if let typedSettingsPatch { + candidate = try typedSettingsPatch.applying( + to: candidate, + displayMode: updated.displayMode + ) + } + if updated == current, candidate == nativeRecord.definition { + return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) + } + guard nativeRecord.definition.lifecycle.revision < UInt64.max else { + throw MachineManagerError.persistence("workspace revision is exhausted") + } + guard nativeRecord.definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else { + throw MachineManagerError.persistence("workspace timestamp is exhausted") + } + let now = Int64(max(0, Date().timeIntervalSince1970 * 1_000)) + candidate.lifecycle = DoryVMLifecycleMetadata( + revision: nativeRecord.definition.lifecycle.revision + 1, + createdAtUnixMilliseconds: + nativeRecord.definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: max( + now, + nativeRecord.definition.lifecycle.updatedAtUnixMilliseconds + 1 + ) + ) + guard Self.nativeDefinition(candidate, isCompatibleWith: migration.definition) else { + throw MachineManagerError.persistence( + "native workspace update is not representable by the compatibility runtime" + ) + } + migration.definition = candidate + _ = try migration.legacyConfiguration() + nativeDefinition = candidate + } + guard updated != current || nativeDefinition != nil else { return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } if launchPolicy == .perWorkspaceAuthority { @@ -2574,7 +2680,21 @@ public final class MachineManager: @unchecked Sendable { updated.installerISOPath == nil { try ensureInstalledLinuxBootBundleIfNeeded(updated) } - try persist(updated) + try persist( + updated, + reconcilesLegacyProjection: nativeRecord == nil + ) + if let nativeRecord, let nativeDefinition { + do { + try workspaceRepository.replace( + nativeDefinition, + expectedRevision: nativeRecord.definition.lifecycle.revision + ) + } catch { + try? persist(current, reconcilesLegacyProjection: false) + throw error + } + } try publishConfiguration(updated) // Every desired-state mutation invalidates the former plan. The workspace remains // stopped until planning publishes a replacement exact runtime identity. @@ -2737,6 +2857,7 @@ public final class MachineManager: @unchecked Sendable { address: machine.address, shares: machine.shares, environment: machine.environment, + typedSettings: nativeTypedSettingsSnapshot(id: id), bootMode: machine.bootMode, machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, nvramPath: machine.bootMode == .efi ? nvramPath : nil, @@ -3113,7 +3234,10 @@ public final class MachineManager: @unchecked Sendable { installedDesktopPayloadReceipt: Self.configurationReceipt(restoring: snapshot) ) - let created = try create(machine) + let created = try create( + machine, + typedSettings: snapshot.typedSettings?.replacementPatch + ) do { if snapshot.bootMode == .efi { guard let snapshotNVRAMPath = snapshot.nvramPath else { @@ -3174,6 +3298,70 @@ public final class MachineManager: @unchecked Sendable { restoredMachine.environment = snapshot.environment restoredMachine.installedDesktopPayloadReceipt = Self.configurationReceipt(restoring: snapshot) + let restoredNativeWorkspace: ( + definition: DoryVirtualMachineDefinition, + expectedRevision: UInt64 + )? + if launchPolicy == .perWorkspaceAuthority { + let record = try workspaceRepository.readPersistedRecord(id: machineID) + if record.legacyConfigurationSHA256 == nil, + record.legacyMigrationFactsSHA256 == nil { + // A native workspace never regains the legacy environment as desired-state + // authority. Older or imported snapshot metadata may still contain compatibility + // keys (or secrets); typed snapshot intent is restored through the definition + // below and the raw dictionary remains read-only historical evidence. + restoredMachine.environment = [:] + let facts = try workspaceMigrationFacts(for: restoredMachine) + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + restoredMachine, + facts: facts + ) + var candidate = migration.definition + candidate.lifecycle = record.definition.lifecycle + candidate.backendPreference = record.definition.backendPreference + candidate.graphics = record.definition.graphics + candidate.guestIdentityIntent = record.definition.guestIdentityIntent + candidate.clipboardPolicy = record.definition.clipboardPolicy + if let typedSettings = snapshot.typedSettings { + candidate = try typedSettings.applyingAsReplacement( + to: candidate, + displayMode: restoredMachine.displayMode + ) + } + guard record.definition.lifecycle.revision < UInt64.max, + record.definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else { + throw MachineManagerError.persistence( + "workspace lifecycle authority is exhausted" + ) + } + let now = Int64(max(0, Date().timeIntervalSince1970 * 1_000)) + candidate.lifecycle = DoryVMLifecycleMetadata( + revision: record.definition.lifecycle.revision + 1, + createdAtUnixMilliseconds: + record.definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: max( + now, + record.definition.lifecycle.updatedAtUnixMilliseconds + 1 + ) + ) + guard Self.nativeDefinition( + candidate, + isCompatibleWith: migration.definition + ) else { + throw MachineManagerError.persistence( + "snapshot typed settings do not match the restored machine" + ) + } + restoredNativeWorkspace = ( + candidate, + record.definition.lifecycle.revision + ) + } else { + restoredNativeWorkspace = nil + } + } else { + restoredNativeWorkspace = nil + } let sourceIdentity = try currentRuntimeIdentity(id: machineID) let targetIdentity = runtimeIdentityAfterSnapshotRestore( snapshot.runtimeIdentity, @@ -3205,7 +3393,21 @@ public final class MachineManager: @unchecked Sendable { snapshot: snapshot, operationID: lifecycle.operation.operationID ) { - try persist(restoredMachine) + try persist( + restoredMachine, + reconcilesLegacyProjection: restoredNativeWorkspace == nil + ) + if let restoredNativeWorkspace { + do { + try workspaceRepository.replace( + restoredNativeWorkspace.definition, + expectedRevision: restoredNativeWorkspace.expectedRevision + ) + } catch { + try? persist(machine, reconcilesLegacyProjection: false) + throw error + } + } try persistRuntimeIdentity( targetIdentity, configuration: restoredMachine @@ -3492,6 +3694,7 @@ public final class MachineManager: @unchecked Sendable { } private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { + let typedSettings = nativeTypedSettingsSnapshot(id: id) if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( id: id, @@ -3506,6 +3709,7 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment, + typedSettings: typedSettings, runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt @@ -3534,12 +3738,25 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: entry.configuration.installerISOPath != nil, shares: entry.configuration.shares, environment: entry.configuration.environment, + typedSettings: typedSettings, runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt ) } + private func nativeTypedSettingsSnapshot( + id: String + ) -> DoryMachineTypedSettingsSnapshot? { + guard launchPolicy == .perWorkspaceAuthority, + let record = try? workspaceRepository.readPersistedRecord(id: id), + record.legacyConfigurationSHA256 == nil, + record.legacyMigrationFactsSHA256 == nil else { + return nil + } + return try? DoryMachineTypedSettingsSnapshot(definition: record.definition) + } + private func processConfiguration( for machine: DoryMachineConfiguration, handoffPath: String?, @@ -4996,7 +5213,10 @@ public final class MachineManager: @unchecked Sendable { _ = rmdir(owner) } - private func persist(_ machine: DoryMachineConfiguration) throws { + private func persist( + _ machine: DoryMachineConfiguration, + reconcilesLegacyProjection: Bool = true + ) throws { let fileManager = FileManager.default let directory = machineStateDirectory(id: machine.id) let temporaryPath = "\(directory)/\(Self.machineMetadataTemporaryPrefix)\(UUID().uuidString)" @@ -5026,7 +5246,7 @@ public final class MachineManager: @unchecked Sendable { // The rename above is the commit point. Projection is intentionally best-effort and // ordered afterwards: a v2 failure must never roll back or hide working legacy metadata. resolvedLaunchIdentities.removeValue(forKey: machine.id) - if let authoritativeLegacyData { + if reconcilesLegacyProjection, let authoritativeLegacyData { _ = reconcileWorkspaceProjection( machine: machine, authoritativeLegacyData: authoritativeLegacyData @@ -5088,23 +5308,18 @@ public final class MachineManager: @unchecked Sendable { authoritativeLegacyData: Data ) -> DoryVirtualMachineDefinition? { do { - let facts = try workspaceMigrationFacts(for: machine) - let migration = try DoryMachineConfigurationMigrationBridge.migrate( - machine, - facts: facts - ) - let result = try workspaceRepository.reconcileLegacyProjection( - migration.definition, - authoritativeLegacyData: authoritativeLegacyData, - authoritativeMigrationFactsData: try Self.workspaceMigrationAuthorityData(facts) + let authority = try workspaceAuthority( + machine: machine, + authoritativeLegacyData: authoritativeLegacyData ) setWorkspaceProjectionDiagnostic( DoryWorkspaceProjectionDiagnostic( - state: result.state == .unchanged ? .current : .regenerated + state: authority.reconcileState == .unchanged + ? .current : .regenerated ), id: machine.id ) - return result.definition + return authority.definition } catch let error as DoryMachineConfigurationMigrationError { setWorkspaceProjectionDiagnostic( DoryWorkspaceProjectionDiagnostic( @@ -5138,6 +5353,84 @@ public final class MachineManager: @unchecked Sendable { } } + private func workspaceAuthority( + machine: DoryMachineConfiguration, + authoritativeLegacyData: Data + ) throws -> MachineWorkspaceAuthority { + let facts = try workspaceMigrationFacts(for: machine) + var migration = try DoryMachineConfigurationMigrationBridge.migrate( + machine, + facts: facts + ) + let factsData = try Self.workspaceMigrationAuthorityData(facts) + let currentRecord: DoryWorkspaceRepositoryRecord? + do { + currentRecord = try workspaceRepository.readPersistedRecord(id: machine.id) + } catch let error as DoryWorkspaceRepositoryError { + if case .workspaceNotFound = error { + currentRecord = nil + } else { + throw error + } + } + + let definition: DoryVirtualMachineDefinition + let isNative: Bool + let reconcileState: DoryWorkspaceLegacyProjectionReconcileState + if let currentRecord, + currentRecord.legacyConfigurationSHA256 == nil, + currentRecord.legacyMigrationFactsSHA256 == nil { + guard Self.nativeDefinition( + currentRecord.definition, + isCompatibleWith: migration.definition + ) else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id) + } + definition = currentRecord.definition + isNative = true + reconcileState = .unchanged + } else { + let result = try workspaceRepository.reconcileLegacyProjection( + migration.definition, + authoritativeLegacyData: authoritativeLegacyData, + authoritativeMigrationFactsData: factsData + ) + let persisted = try workspaceRepository.readLegacyProjection( + id: machine.id, + authoritativeLegacyData: authoritativeLegacyData, + authoritativeMigrationFactsData: factsData + ) + guard result.definition == persisted else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id) + } + definition = persisted + isNative = false + reconcileState = result.state + } + migration.definition = definition + return MachineWorkspaceAuthority( + definition: definition, + migration: migration, + migrationFactsData: factsData, + runtimeMachine: try migration.legacyConfiguration(), + isNative: isNative, + reconcileState: reconcileState + ) + } + + private static func nativeDefinition( + _ definition: DoryVirtualMachineDefinition, + isCompatibleWith compatibility: DoryVirtualMachineDefinition + ) -> Bool { + var expected = compatibility + expected.lifecycle = definition.lifecycle + expected.backendPreference = definition.backendPreference + expected.graphics = definition.graphics + expected.guestIdentityIntent = definition.guestIdentityIntent + expected.clipboardPolicy = definition.clipboardPolicy + return expected == definition && definition.validate().isEmpty + } + private static func canonicalDefinitionData( _ definition: DoryVirtualMachineDefinition ) throws -> Data { @@ -6214,6 +6507,7 @@ public final class MachineManager: @unchecked Sendable { Self.nativeCreationPrecommitMarkerName, DoryMachineRuntimeIdentityStore.recordFileName, DoryMachineRuntimeIdentityStore.headFileName, + DoryWorkspaceRepository.recordFileName, ]) for id in ids where Self.isValidID(id) { let directory = stateDirectory + "/" + id @@ -6241,6 +6535,7 @@ public final class MachineManager: @unchecked Sendable { || entry.hasPrefix( DoryMachineRuntimeIdentityStore.headTemporaryPrefix ) + || entry.hasPrefix(DoryWorkspaceRepository.recordTemporaryPrefix) } let containsOnlyPrivateRuntimeTemporaries = entries.filter { entry in entry.hasPrefix( @@ -6248,9 +6543,15 @@ public final class MachineManager: @unchecked Sendable { ) || entry.hasPrefix( DoryMachineRuntimeIdentityStore.headTemporaryPrefix ) + || entry.hasPrefix(DoryWorkspaceRepository.recordTemporaryPrefix) }.allSatisfy { entry in Self.isPrivateRegularFile(path: directory + "/" + entry) } + let hasValidWorkspaceAuthority = !entries.contains( + DoryWorkspaceRepository.recordFileName + ) || Self.isPrivateRegularFile( + path: directory + "/" + DoryWorkspaceRepository.recordFileName + ) let hasValidManagedArtifacts = (!entries.contains("rootfs.ext4") || Self.isPrivateRegularFile(path: directory + "/rootfs.ext4")) @@ -6263,6 +6564,7 @@ public final class MachineManager: @unchecked Sendable { && containsOnlyCreationEntries && containsOnlyPrivateRuntimeTemporaries && hasValidManagedArtifacts + && hasValidWorkspaceAuthority guard isMarkerInitializationCrash || isAuthenticatedInterruptedCreation else { continue } @@ -6525,8 +6827,8 @@ public final class MachineManager: @unchecked Sendable { } entry = current lock.unlock() - guard entry.configuration == machine, - entry.process == nil, entry.handoffServer == nil, + let authoritativeMachine = entry.configuration + guard entry.process == nil, entry.handoffServer == nil, entry.state == .created || entry.state == .stopped else { throw MachineManagerError.persistence( "machine \(machine.id) must be exactly stopped before planning" @@ -6543,31 +6845,21 @@ public final class MachineManager: @unchecked Sendable { let decoded = try? JSONDecoder().decode( DoryMachineConfiguration.self, from: legacyData - ), decoded == machine else { + ), decoded == authoritativeMachine else { throw MachineManagerError.persistence( "authoritative machine metadata changed before planning" ) } - let facts = try workspaceMigrationFacts(for: machine) - let factsData = try Self.workspaceMigrationAuthorityData(facts) - let migration = try DoryMachineConfigurationMigrationBridge.migrate( - machine, - facts: facts - ) - let reconciled = try workspaceRepository.reconcileLegacyProjection( - migration.definition, - authoritativeLegacyData: legacyData, - authoritativeMigrationFactsData: factsData - ).definition - let loaded = try workspaceRepository.readLegacyProjection( - id: machine.id, - authoritativeLegacyData: legacyData, - authoritativeMigrationFactsData: factsData + let workspace = try workspaceAuthority( + machine: authoritativeMachine, + authoritativeLegacyData: legacyData ) - guard reconciled == loaded, loaded == definition, - try Self.canonicalDefinitionData(loaded) == canonicalDefinitionData else { + guard workspace.runtimeMachine == machine, + workspace.definition == definition, + try Self.canonicalDefinitionData(workspace.definition) + == canonicalDefinitionData else { throw MachineManagerError.persistence( - "planning request does not match the authoritative workspace projection" + "planning request does not match authoritative workspace state" ) } @@ -6577,8 +6869,8 @@ public final class MachineManager: @unchecked Sendable { let authority = DoryDaemonVirtualMachinePlanningMachineAuthority( machineID: machine.id, legacyConfigurationSHA256: Self.sha256(data: legacyData), - migrationFactsSHA256: Self.sha256(data: factsData), - sourceDefinitionRevision: loaded.lifecycle.revision, + migrationFactsSHA256: Self.sha256(data: workspace.migrationFactsData), + sourceDefinitionRevision: workspace.definition.lifecycle.revision, sourceDefinitionSHA256: Self.sha256(data: canonicalDefinitionData), runtimeIdentitySHA256: Self.sha256(data: runtimeIdentityData) ) @@ -6600,11 +6892,12 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("machine manager is unavailable") } try self.revalidatePlanningMutationAuthority( - machine: machine, + authoritativeMachine: authoritativeMachine, + runtimeMachine: machine, definition: definition, canonicalDefinitionData: canonicalDefinitionData, legacyData: legacyData, - migrationFactsData: factsData, + migrationFactsData: workspace.migrationFactsData, runtimeIdentitySHA256: authority.runtimeIdentitySHA256 ) }, @@ -6622,7 +6915,8 @@ public final class MachineManager: @unchecked Sendable { } private func revalidatePlanningMutationAuthority( - machine: DoryMachineConfiguration, + authoritativeMachine: DoryMachineConfiguration, + runtimeMachine: DoryMachineConfiguration, definition: DoryVirtualMachineDefinition, canonicalDefinitionData: Data, legacyData: Data, @@ -6631,19 +6925,19 @@ public final class MachineManager: @unchecked Sendable { ) throws { operationLock.lock() defer { operationLock.unlock() } - guard activePlanningMutationIDs.contains(machine.id) else { + guard activePlanningMutationIDs.contains(authoritativeMachine.id) else { throw MachineManagerError.persistence("planning mutation authority was released") } let entry: MachineEntry lock.lock() - guard let current = machines[machine.id], - !deletingMachineIDs.contains(machine.id) else { + guard let current = machines[authoritativeMachine.id], + !deletingMachineIDs.contains(authoritativeMachine.id) else { lock.unlock() - throw MachineManagerError.unknownMachine(machine.id) + throw MachineManagerError.unknownMachine(authoritativeMachine.id) } entry = current lock.unlock() - guard entry.configuration == machine, + guard entry.configuration == authoritativeMachine, entry.process == nil, entry.handoffServer == nil, entry.state == .created || entry.state == .stopped, entry.runtimeIdentity.validate().isEmpty, @@ -6652,30 +6946,32 @@ public final class MachineManager: @unchecked Sendable { ) == runtimeIdentitySHA256, let currentLegacyData = Self.readPrivateMetadata( - path: machineConfigPath(id: machine.id) + path: machineConfigPath(id: authoritativeMachine.id) ), currentLegacyData == legacyData, let decoded = try? JSONDecoder().decode( DoryMachineConfiguration.self, from: currentLegacyData - ), decoded == machine else { + ), decoded == authoritativeMachine else { throw MachineManagerError.persistence( "authoritative machine state changed during planning" ) } - let currentFacts = try workspaceMigrationFacts(for: machine) - let currentFactsData = try Self.workspaceMigrationAuthorityData(currentFacts) - guard currentFactsData == migrationFactsData else { + let currentWorkspace = try workspaceAuthority( + machine: authoritativeMachine, + authoritativeLegacyData: currentLegacyData + ) + guard currentWorkspace.migrationFactsData == migrationFactsData else { throw MachineManagerError.persistence( "authoritative migration facts changed during planning" ) } - let currentDefinition = try workspaceRepository.readLegacyProjection( - id: machine.id, - authoritativeLegacyData: currentLegacyData, - authoritativeMigrationFactsData: currentFactsData - ) - guard currentDefinition == definition, - try Self.canonicalDefinitionData(currentDefinition) + guard currentWorkspace.runtimeMachine == runtimeMachine else { + throw MachineManagerError.persistence( + "authoritative machine projection changed during planning" + ) + } + guard currentWorkspace.definition == definition, + try Self.canonicalDefinitionData(currentWorkspace.definition) == canonicalDefinitionData else { throw MachineManagerError.persistence( "authoritative workspace projection changed during planning" @@ -8352,11 +8648,21 @@ private struct MachineLifecycleJournalCompletionPending: Error, Sendable {} private struct PreparedMachineStart { var machine: DoryMachineConfiguration + var authoritativeMachine: DoryMachineConfiguration var definition: DoryVirtualMachineDefinition? var canonicalDefinitionData: Data? var authoritativeLegacyData: Data? } +private struct MachineWorkspaceAuthority { + var definition: DoryVirtualMachineDefinition + var migration: DoryMachineConfigurationMigrationResult + var migrationFactsData: Data + var runtimeMachine: DoryMachineConfiguration + var isNative: Bool + var reconcileState: DoryWorkspaceLegacyProjectionReconcileState +} + private struct PendingResolvedMachineStart { var machine: DoryMachineConfiguration var plan: DoryResolvedMachinePlan diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 8fb3258a..a5422905 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1466,6 +1466,9 @@ final class DorydServiceTests: XCTestCase { "id": "planned", "kernelPath": doryTestKernelPath, "rootfsPath": doryTestRootfsPath, + "guestIdentityIntent": [ + "account": ["username": "developer"] as NSDictionary, + ] as NSDictionary, ]) { ok, _, message in XCTAssertFalse(ok) XCTAssertTrue(message.contains("production planning failed closed"), message) @@ -1475,11 +1478,39 @@ final class DorydServiceTests: XCTestCase { let captured = try XCTUnwrap(controller.captured) XCTAssertEqual(captured.request.planning.machine.id, "planned") + XCTAssertEqual(captured.request.planning.machine.environment["DORY_GUEST_USER"], "developer") XCTAssertEqual(captured.request.planning.definition.identity.id, "planned") + XCTAssertEqual( + captured.request.planning.definition.guestIdentityIntent.account?.username, + "developer" + ) XCTAssertEqual(captured.request.workspacePublication, .retainExistingExact) XCTAssertEqual(captured.artifacts.count, 2) XCTAssertTrue(captured.artifacts.allSatisfy { $0.path.hasPrefix(base + "/planned/") }) XCTAssertEqual(manager.status(id: "planned")?.runtimeIdentity.mode, .requiresReplanning) + XCTAssertEqual( + manager.status(id: "planned")?.typedSettings? + .guestIdentityIntent.account?.username, + "developer" + ) + XCTAssertTrue(manager.status(id: "planned")?.environment.isEmpty == true) + let persisted = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: Data(contentsOf: URL(fileURLWithPath: base + "/planned/machine.json")) + ) + XCTAssertTrue(persisted.environment.isEmpty) + let listReply = expectation(description: "native typed status projection") + service.machineList { rows, message in + XCTAssertEqual(message, "") + let row = (rows as? [NSDictionary])?.first { $0["id"] as? String == "planned" } + XCTAssertEqual((row?["env"] as? [NSDictionary])?.count, 0) + let typed = row?["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + XCTAssertEqual(account?["username"] as? String, "developer") + listReply.fulfill() + } + wait(for: [listReply], timeout: 5) XCTAssertThrowsError(try manager.start(id: "planned")) let updateReply = expectation(description: "production update planning rejection") diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 8a7ac144..5723de20 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -711,10 +711,118 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("native typed settings persist outside machine environment") + func nativeTypedSettingsAreWorkspaceAuthority() throws { + let state = try makeState("native-typed-settings") + defer { try? FileManager.default.removeItem(atPath: state) } + let manager = makeManager(state: state, policy: .perWorkspaceAuthority) + let created = try manager.create( + DoryMachineConfiguration( + id: "typed", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + memoryMB: 2_048, + cpuCount: 2, + displayMode: .desktop + ), + typedSettings: DoryMachineTypedSettingsPatch( + guestUsername: .set("developer"), + guestNumericUserID: .set(1_000), + desktopDistributionIdentifier: .set("ubuntu"), + desktopDisplayName: .set("Ubuntu"), + clipboardPolicy: .set(.legacyDesktop(.bidirectional)), + runtimePreference: .set(.accelerated), + graphicsPreference: .set(.virglVenus) + ) + ) + #expect(created.environment.isEmpty) + #expect(created.typedSettings?.guestIdentityIntent.account?.username == "developer") + #expect(created.typedSettings?.runtimePreference == .accelerated) + + let machineData = try Data(contentsOf: URL( + fileURLWithPath: state + "/typed/machine.json" + )) + let persistedMachine = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: machineData + ) + #expect(persistedMachine.environment.isEmpty) + let repository = DoryWorkspaceRepository(root: state) + let createdRecord = try repository.readPersistedRecord(id: "typed") + #expect(createdRecord.legacyConfigurationSHA256 == nil) + #expect(createdRecord.legacyMigrationFactsSHA256 == nil) + #expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer") + #expect(createdRecord.definition.backendPreference.backend == .doryHypervisor) + #expect(createdRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline") + #expect(snapshot.typedSettings == created.typedSettings) + + let updated = try manager.update( + id: "typed", + typedSettingsPatch: DoryMachineTypedSettingsPatch( + guestUsername: .set("builder"), + desktopDisplayName: .set("Ubuntu Builder"), + graphicsPreference: .set(.software) + ) + ) + #expect(updated.environment.isEmpty) + #expect(updated.typedSettings?.guestIdentityIntent.account?.username == "builder") + #expect(updated.typedSettings?.guestIdentityIntent.account?.numericUserID == 1_000) + #expect(updated.typedSettings?.graphicsPreference == .software) + let updatedRecord = try repository.readPersistedRecord(id: "typed") + #expect(updatedRecord.definition.lifecycle.revision == 2) + #expect(updatedRecord.definition.guestIdentityIntent.desktop?.displayName + == "Ubuntu Builder") + #expect(updatedRecord.definition.guestIdentityIntent.desktop?.distributionIdentifier + == "ubuntu") + #expect(updatedRecord.definition.graphics.acceptableLevels == [.software]) + _ = try manager.update(id: "typed") + #expect(try repository.readPersistedRecord(id: "typed").definition.lifecycle.revision == 2) + + let clone = try manager.cloneSnapshot( + machineID: "typed", + snapshotID: snapshot.id, + newID: "typed-clone" + ) + #expect(clone.environment.isEmpty) + #expect(clone.typedSettings == created.typedSettings) + let cloneRecord = try repository.readPersistedRecord(id: "typed-clone") + #expect(cloneRecord.legacyConfigurationSHA256 == nil) + #expect(cloneRecord.definition.guestIdentityIntent.account?.username == "developer") + + var snapshotWithLegacyEnvironment = snapshot + snapshotWithLegacyEnvironment.environment = ["SHOULD_NOT_PERSIST": "opaque-secret"] + try JSONEncoder().encode(snapshotWithLegacyEnvironment).write( + to: URL(fileURLWithPath: state + "/typed/snapshots/typed-baseline.json") + ) + + let restored = try manager.restoreSnapshot( + machineID: "typed", + snapshotID: snapshot.id + ) + #expect(restored.environment.isEmpty) + #expect(restored.typedSettings == created.typedSettings) + #expect(restored.runtimeIdentity.mode == .requiresReplanning) + let restoredRecord = try repository.readPersistedRecord(id: "typed") + #expect(restoredRecord.definition.lifecycle.revision == 3) + #expect(restoredRecord.definition.guestIdentityIntent.account?.username == "developer") + #expect(restoredRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + + let restarted = makeManager(state: state, policy: .perWorkspaceAuthority) + let restartedStatus = try #require(restarted.status(id: "typed")) + #expect(restartedStatus.environment.isEmpty) + #expect(restartedStatus.typedSettings == created.typedSettings) + #expect(restartedStatus.runtimeIdentity.mode == .requiresReplanning) + } + @Test("native creation authority is durable before machine metadata becomes discoverable") func nativeCreationCrashOrderingNeverMintsLegacy() throws { let state = try makeState("native-create-order") defer { try? FileManager.default.removeItem(atPath: state) } + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: state + ) let directory = state + "/native" try FileManager.default.createDirectory( atPath: directory, @@ -755,6 +863,22 @@ struct MachineManagerResolvedPlanIntegrationTests { [.posixPermissions: 0o600], ofItemAtPath: markerPath ) + let rootfsBytes = try #require( + (try FileManager.default.attributesOfItem(atPath: rootfs)[.size] as? NSNumber)? + .uint64Value + ) + let interruptedDefinition = try DoryMachineConfigurationMigrationBridge.migrate( + machine, + facts: DoryMachineConfigurationMigrationFacts( + guestArchitecture: .arm64, + systemDiskCapacityBytes: rootfsBytes, + lifecycle: DoryVMLifecycleMetadata( + createdAtUnixMilliseconds: 1, + updatedAtUnixMilliseconds: 1 + ) + ) + ).definition + try DoryWorkspaceRepository(root: state).create(interruptedDefinition) try DoryMachineRuntimeIdentityStore(root: state).publish( expected, machineID: "native", From d17b405ea0601c86e68e5e1c70b86292513e65ce Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:11:41 +0000 Subject: [PATCH 043/338] fix(vm): preserve exact desktop graphics preference --- .../DoryMachineConfigurationMigration.swift | 6 +++++- .../DorydKit/DoryMachineTypedWriteAuthority.swift | 8 +++++++- .../DoryMachineConfigurationMigrationTests.swift | 2 +- ...MachineManagerResolvedPlanIntegrationTests.swift | 13 +++++++------ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index 06733161..ed59bfdb 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -314,6 +314,8 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { preference = .automatic case [.hardwareAccelerated3D]: preference = .virglVenus + case [.hostAcceleratedDisplay]: + preference = .virgl case [.software]: preference = .software default: @@ -769,7 +771,9 @@ public enum DoryMachineConfigurationMigrationBridge { DoryVMGraphicsPolicy( acceptableLevels: [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software] ) - case .virgl, .virglVenus: + case .virgl: + DoryVMGraphicsPolicy(acceptableLevels: [.hostAcceleratedDisplay]) + case .virglVenus: DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D]) case .software: DoryVMGraphicsPolicy(acceptableLevels: [.software]) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index d78ce8d7..a0e0f5dc 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -64,6 +64,8 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha graphicsPreference = .automatic case [.hardwareAccelerated3D]: graphicsPreference = .virglVenus + case [.hostAcceleratedDisplay]: + graphicsPreference = .virgl case [.software]: graphicsPreference = .software default: @@ -501,7 +503,11 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { .software, ] ) - case .set(.virgl), .set(.virglVenus): + case .set(.virgl): + definition.graphics = DoryVMGraphicsPolicy( + acceptableLevels: [.hostAcceleratedDisplay] + ) + case .set(.virglVenus): definition.graphics = DoryVMGraphicsPolicy( acceptableLevels: [.hardwareAccelerated3D] ) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 358292fa..2e2c2c93 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -63,7 +63,7 @@ struct DoryMachineConfigurationMigrationTests { mode: .preferred, backend: .doryHypervisor )) - #expect(migrated.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + #expect(migrated.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) #expect(migrated.definition.resources.memoryBytes == 8 * gibibyte) #expect(migrated.definition.resources.virtualCPUCount == 6) #expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 5723de20..6370b548 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -732,7 +732,7 @@ struct MachineManagerResolvedPlanIntegrationTests { desktopDisplayName: .set("Ubuntu"), clipboardPolicy: .set(.legacyDesktop(.bidirectional)), runtimePreference: .set(.accelerated), - graphicsPreference: .set(.virglVenus) + graphicsPreference: .set(.virgl) ) ) #expect(created.environment.isEmpty) @@ -753,7 +753,8 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(createdRecord.legacyMigrationFactsSHA256 == nil) #expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer") #expect(createdRecord.definition.backendPreference.backend == .doryHypervisor) - #expect(createdRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + #expect(created.typedSettings?.graphicsPreference == .virgl) + #expect(createdRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline") #expect(snapshot.typedSettings == created.typedSettings) @@ -762,20 +763,20 @@ struct MachineManagerResolvedPlanIntegrationTests { typedSettingsPatch: DoryMachineTypedSettingsPatch( guestUsername: .set("builder"), desktopDisplayName: .set("Ubuntu Builder"), - graphicsPreference: .set(.software) + graphicsPreference: .set(.virglVenus) ) ) #expect(updated.environment.isEmpty) #expect(updated.typedSettings?.guestIdentityIntent.account?.username == "builder") #expect(updated.typedSettings?.guestIdentityIntent.account?.numericUserID == 1_000) - #expect(updated.typedSettings?.graphicsPreference == .software) + #expect(updated.typedSettings?.graphicsPreference == .virglVenus) let updatedRecord = try repository.readPersistedRecord(id: "typed") #expect(updatedRecord.definition.lifecycle.revision == 2) #expect(updatedRecord.definition.guestIdentityIntent.desktop?.displayName == "Ubuntu Builder") #expect(updatedRecord.definition.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu") - #expect(updatedRecord.definition.graphics.acceptableLevels == [.software]) + #expect(updatedRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) _ = try manager.update(id: "typed") #expect(try repository.readPersistedRecord(id: "typed").definition.lifecycle.revision == 2) @@ -806,7 +807,7 @@ struct MachineManagerResolvedPlanIntegrationTests { let restoredRecord = try repository.readPersistedRecord(id: "typed") #expect(restoredRecord.definition.lifecycle.revision == 3) #expect(restoredRecord.definition.guestIdentityIntent.account?.username == "developer") - #expect(restoredRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D]) + #expect(restoredRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) let restarted = makeManager(state: state, policy: .perWorkspaceAuthority) let restartedStatus = try #require(restarted.status(id: "typed")) From 72205d85a714afd1858636da40fb94bb36726a92 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:15:51 +0000 Subject: [PATCH 044/338] feat(vm): qualify exact desktop device contracts --- .../Sources/dory-hv/DesktopMode.swift | 3 +- .../DoryVirtualMachineCapabilities.swift | 26 ++++++++----- .../Sources/DoryVMMKit/DoryVMM.swift | 3 +- .../DoryMachineConfigurationMigration.swift | 2 +- .../DoryVirtualMachineCapabilitiesTests.swift | 37 ++++++++++++++++++- ...ryMachineConfigurationMigrationTests.swift | 2 + .../Tests/DorydKitTests/DoryVMMKitTests.swift | 2 +- 7 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 74db508a..66054434 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -240,7 +240,8 @@ enum DesktopMode { } else { self.sshAgentBridge = nil } - if configuration.genericGuest || configuration.resolvedDevices?.clipboard == false { + if configuration.resolvedDevices?.clipboard == false + || (configuration.resolvedDevices == nil && configuration.genericGuest) { self.clipboard = nil } else { let clipboardControl = DorydKit.AgentControl(configuration: .init( diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index b9ea570f..f75c80d1 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -1332,14 +1332,19 @@ public enum DoryAppleSiliconCapabilityEvaluator { let isGraphical = request.graphics != .none let isLinuxVZ = request.guest.family == .linux && request.backend == .appleVirtualizationFramework - if devices.audioInput, !(isLinuxVZ && isGraphical) { + let isLinuxRawHV = request.guest.family == .linux + && request.backend == .doryHypervisor + let isLinuxDesktopRuntime = isGraphical && (isLinuxVZ || isLinuxRawHV) + let audioIsImplemented = isLinuxVZ && isGraphical + || isLinuxRawHV && isGraphical && devices.audioInput == devices.audioOutput + if devices.audioInput, !audioIsImplemented { return unavailable( tier: tier, code: .audioInputUnsupported, message: "The selected guest/backend contract does not implement host audio input." ) } - if devices.audioOutput, !(isLinuxVZ && isGraphical) { + if devices.audioOutput, !audioIsImplemented { return unavailable( tier: tier, code: .audioOutputUnsupported, @@ -1371,20 +1376,21 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } - if devices.directorySharing { + if devices.directorySharing, + !(request.guest.family == .linux + && (request.backend == .doryHypervisor + || request.backend == .appleVirtualizationFramework)) { return unavailable( tier: tier, code: .directorySharingUnsupported, - message: "Directory sharing requires a digest-bound guest-integration " - + "qualification that is not yet modeled." + message: "The selected guest/backend contract does not implement directory sharing." ) } - if devices.clipboard { + if devices.clipboard, !isLinuxDesktopRuntime { return unavailable( tier: tier, code: .clipboardIntegrationUnsupported, - message: "Clipboard integration requires a digest-bound guest-integration " - + "qualification that is not yet modeled." + message: "The selected guest/backend contract does not implement clipboard integration." ) } if devices.clockSynchronization { @@ -1398,11 +1404,11 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } } - if devices.dynamicDisplay { + if devices.dynamicDisplay, !isLinuxDesktopRuntime { return unavailable( tier: tier, code: .dynamicDisplayUnsupported, - message: "Dynamic display resizing is not yet part of a qualified backend contract." + message: "The selected guest/backend contract does not implement dynamic display resizing." ) } if devices.gracefulShutdown { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 68473565..f645a290 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -373,8 +373,7 @@ public enum DoryVZConfigurationBuilder { } } if let devices = spec.resolvedDevices { - guard devices.networkAttachment == .sharedNAT, - !devices.dynamicDisplay else { + guard devices.networkAttachment == .sharedNAT else { throw DoryVZMachineError.validation( "resolved device contract contains a device not implemented by this VZ launch" ) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index ed59bfdb..b5d82d34 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -639,7 +639,7 @@ public enum DoryMachineConfigurationMigrationBridge { networkMode: .sharedNAT, display: isDesktop ? DoryVMDisplayConfiguration() : .disabled, audio: isDesktop - ? DoryVMAudioConfiguration() + ? DoryVMAudioConfiguration(inputEnabled: true, outputEnabled: true) : DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), input: isDesktop ? DoryVMInputConfiguration() diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 7c37a918..48749748 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -818,7 +818,7 @@ struct VirtualMachineCapabilitiesTests { graphics: .software, devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected) ) - let spoofedGuestTools = evaluate( + let qualifiedVZSharing = evaluate( family: .linux, media: .virtualDisk, source: .userProvided, @@ -826,10 +826,43 @@ struct VirtualMachineCapabilitiesTests { graphics: .software, devices: DoryVirtualMachineDeviceCapabilityRequest(directorySharing: true) ) + let qualifiedRawDesktopDevices = DoryVirtualMachineDeviceCapabilityRequest( + audioInput: true, + audioOutput: true, + keyboard: true, + pointer: true, + directorySharing: true, + clipboard: true, + clockSynchronization: true, + dynamicDisplay: true, + gracefulShutdown: true + ) + let qualifiedRawDesktop = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: qualifiedRawDesktopDevices + ) + let unsupportedAudioShape = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: DoryVirtualMachineDeviceCapabilityRequest(audioOutput: true) + ) #expect(resolved.resolvedDevices == .minimumBootable) #expect(disconnected.availability.reason?.code == .networkAttachmentUnsupported) - #expect(spoofedGuestTools.availability.reason?.code == .directorySharingUnsupported) + #expect(qualifiedVZSharing.availability.isUsable) + #expect(qualifiedVZSharing.resolvedDevices?.directorySharing == true) + #expect(qualifiedRawDesktop.availability.isUsable) + #expect(qualifiedRawDesktop.resolvedDevices == qualifiedRawDesktopDevices) + #expect(unsupportedAudioShape.availability.reason?.code == .audioOutputUnsupported) } @Test("version-one descriptor JSON remains readable with conservative device defaults") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 2e2c2c93..89acb085 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -64,6 +64,8 @@ struct DoryMachineConfigurationMigrationTests { backend: .doryHypervisor )) #expect(migrated.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) + #expect(migrated.definition.audio.inputEnabled) + #expect(migrated.definition.audio.outputEnabled) #expect(migrated.definition.resources.memoryBytes == 8 * gibibyte) #expect(migrated.definition.resources.virtualCPUCount == 6) #expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 856244a5..014b8a63 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -621,7 +621,7 @@ final class DoryVMMKitTests: XCTestCase { directorySharing: false, clipboard: false, clockSynchronization: false, - dynamicDisplay: false, + dynamicDisplay: true, gracefulShutdown: false ) let configuration = try DoryVZConfigurationBuilder.makeConfiguration( From d1eb2d203baa7d5b60bd8a199c1cb74af21fef58 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:24:18 +0000 Subject: [PATCH 045/338] feat(components): require VM qualification manifests --- scripts/build-components.py | 249 ++++++++++++++++++++++++++++++- scripts/test-build-components.sh | 112 +++++++++++++- 2 files changed, 356 insertions(+), 5 deletions(-) diff --git a/scripts/build-components.py b/scripts/build-components.py index ac6c7694..751faebc 100755 --- a/scripts/build-components.py +++ b/scripts/build-components.py @@ -5,6 +5,7 @@ import argparse import base64 +import binascii import datetime as dt import hashlib import json @@ -19,8 +20,14 @@ CATALOG_KIND = "dev.dory.component-catalog" -CATALOG_SCHEMA = 1 +CATALOG_SCHEMA = 2 ARCHITECTURE = "arm64" +QUALIFICATION_KIND = "dev.dory.virtual-machine-qualification-manifest" +QUALIFICATION_SCHEMA = 1 +QUALIFICATION_COMPONENT = "linux-desktop" +QUALIFICATION_PATH = "virtual-machine-qualification.json" +DEFAULT_CATALOG_PUBLIC_KEY = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" +MAX_QUALIFICATION_BYTES = 2 * 1024 * 1024 def fail(message: str) -> NoReturn: @@ -126,7 +133,223 @@ def generated_at(value: str | None) -> str: return parsed.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") -def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[dict]: +def unique_json_object(pairs: list[tuple[str, object]]) -> dict: + result: dict = {} + for key, value in pairs: + if key in result: + fail(f"qualification manifest contains duplicate key: {key}") + result[key] = value + return result + + +def exact_keys(value: object, required: set[str], optional: set[str], label: str) -> dict: + if not isinstance(value, dict): + fail(f"{label} must be an object") + actual = set(value) + if not required <= actual or not actual <= required | optional: + fail(f"{label} has missing or unknown fields") + return value + + +def nonempty_string(value: object, label: str, maximum_bytes: int = 256) -> str: + if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum_bytes: + fail(f"{label} must be a bounded non-empty string") + return value + + +def sha256_value(value: object, label: str) -> str: + text = nonempty_string(value, label, 64) + if len(text) != 64 or any(character not in "0123456789abcdef" for character in text): + fail(f"{label} must be a lowercase SHA-256 digest") + return text + + +def positive_integer_value(value: object, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + fail(f"{label} must be a positive integer") + return value + + +def boolean_value(value: object, label: str) -> bool: + if not isinstance(value, bool): + fail(f"{label} must be a boolean") + return value + + +def validate_qualification_record(value: object, index: int) -> dict: + label = f"qualification record {index}" + required = { + "qualificationIdentity", "guest", "bootMediaKind", "bootMediaSource", + "backend", "backendImplementationIdentifier", "backendRuntimeBuildIdentifier", + "virtualHardwareABIVersion", "graphics", "devices", + "hostHardwareModelIdentifier", "hostOperatingSystemBuild", "components", + "virtioGPUKernelAndDeviceSupportQualified", "venusVulkanGuestRuntimeQualified", + } + record = exact_keys( + value, + required, + {"immutableArtifactSHA256", "mutableProvenance"}, + label, + ) + nonempty_string(record["qualificationIdentity"], f"{label} identity") + + guest = exact_keys(record["guest"], {"family", "architecture"}, set(), f"{label} guest") + if guest["family"] not in {"linux", "windows", "macos"}: + fail(f"{label} guest family is unsupported") + if guest["architecture"] not in {"arm64", "x86_64"}: + fail(f"{label} guest architecture is unsupported") + if record["bootMediaKind"] not in { + "installer-iso", "virtual-disk", "installed-linux-boot-bundle", + "macos-restore-image", + }: + fail(f"{label} boot media kind is unsupported") + if record["bootMediaSource"] not in { + "dory-bundled", "vendor-download", "user-provided", + }: + fail(f"{label} boot media source is unsupported") + if record["backend"] not in { + "dory-hypervisor", "apple-virtualization-framework", "qemu-hvf", + }: + fail(f"{label} backend is unsupported") + if record["graphics"] not in { + "none", "software", "host-accelerated-display", "hardware-accelerated-3d", + }: + fail(f"{label} graphics contract is unsupported") + nonempty_string(record["backendImplementationIdentifier"], f"{label} implementation") + nonempty_string(record["backendRuntimeBuildIdentifier"], f"{label} runtime build") + positive_integer_value(record["virtualHardwareABIVersion"], f"{label} ABI") + nonempty_string(record["hostHardwareModelIdentifier"], f"{label} host model") + nonempty_string(record["hostOperatingSystemBuild"], f"{label} host build") + + immutable = record.get("immutableArtifactSHA256") + mutable = record.get("mutableProvenance") + if (immutable is None) == (mutable is None): + fail(f"{label} must have exactly one immutable or mutable media identity") + if immutable is not None: + sha256_value(immutable, f"{label} immutable artifact") + if mutable is not None: + provenance = exact_keys( + mutable, + {"repositoryIdentity", "mediaIdentity", "revision"}, + set(), + f"{label} mutable provenance", + ) + nonempty_string(provenance["repositoryIdentity"], f"{label} repository identity") + nonempty_string(provenance["mediaIdentity"], f"{label} media identity") + positive_integer_value(provenance["revision"], f"{label} media revision") + + devices = exact_keys( + record["devices"], + { + "networkAttachment", "audioInput", "audioOutput", "keyboard", "pointer", + "directorySharing", "clipboard", "clockSynchronization", "dynamicDisplay", + "gracefulShutdown", + }, + set(), + f"{label} devices", + ) + if devices["networkAttachment"] not in { + "disconnected", "shared-nat", "bridged", "isolated", + }: + fail(f"{label} network attachment is unsupported") + for key in set(devices) - {"networkAttachment"}: + boolean_value(devices[key], f"{label} device {key}") + + components = record["components"] + if not isinstance(components, list) or not components: + fail(f"{label} components must be a non-empty array") + identifiers: list[str] = [] + for component_index, value in enumerate(components): + component = exact_keys( + value, + {"componentIdentifier", "buildIdentifier", "artifactSHA256"}, + set(), + f"{label} component {component_index}", + ) + identifiers.append(nonempty_string( + component["componentIdentifier"], f"{label} component identifier" + )) + nonempty_string(component["buildIdentifier"], f"{label} component build") + sha256_value(component["artifactSHA256"], f"{label} component digest") + if identifiers != sorted(identifiers) or len(set(identifiers)) != len(identifiers): + fail(f"{label} components must be uniquely sorted by identifier") + boolean_value( + record["virtioGPUKernelAndDeviceSupportQualified"], + f"{label} VirtIO GPU qualification", + ) + boolean_value( + record["venusVulkanGuestRuntimeQualified"], + f"{label} Venus qualification", + ) + return record + + +def load_qualification_manifest( + path: pathlib.Path, + *, + release_version: str, + public_key_base64: str, +) -> dict: + path = regular_file(path.resolve(), "VM qualification manifest") + if path.stat().st_size > MAX_QUALIFICATION_BYTES: + fail("VM qualification manifest exceeds the maximum size") + try: + public_key = base64.b64decode(public_key_base64, validate=True) + except (ValueError, binascii.Error): + fail("catalog public key is malformed") + if len(public_key) != 32: + fail("catalog public key must be a 32-byte Ed25519 key") + try: + manifest = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=unique_json_object, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + fail(f"VM qualification manifest is unreadable: {error}") + manifest = exact_keys( + manifest, + { + "kind", "schemaVersion", "manifestIdentity", "catalogReleaseVersion", + "architecture", "signingKeyID", "records", + }, + set(), + "VM qualification manifest", + ) + expected_key_id = hashlib.sha256(public_key).hexdigest() + if manifest["kind"] != QUALIFICATION_KIND: + fail("VM qualification manifest kind is invalid") + schema_version = manifest["schemaVersion"] + if ( + isinstance(schema_version, bool) + or not isinstance(schema_version, int) + or schema_version != QUALIFICATION_SCHEMA + ): + fail("VM qualification manifest schema is unsupported") + nonempty_string(manifest["manifestIdentity"], "VM qualification manifest identity") + if manifest["catalogReleaseVersion"] != release_version: + fail("VM qualification manifest release does not match the catalog") + if manifest["architecture"] != ARCHITECTURE: + fail("VM qualification manifest architecture is unsupported") + if manifest["signingKeyID"] != expected_key_id: + fail("VM qualification manifest signing key does not match the catalog trust root") + records = manifest["records"] + if not isinstance(records, list) or not records: + fail("VM qualification manifest must contain records") + validated = [ + validate_qualification_record(record, index) + for index, record in enumerate(records) + ] + identities = [record["qualificationIdentity"] for record in validated] + if len(set(identities)) != len(identities): + fail("VM qualification identities must be unique") + return manifest + + +def component_specs( + source_root: pathlib.Path, + kubectl: pathlib.Path, + qualification_manifest: pathlib.Path, +) -> list[dict]: desktop_specs = [] for distro, display, summary in ( ( @@ -232,6 +455,12 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di "delivery": "none", "executable": False, }, + { + "path": QUALIFICATION_PATH, + "source": qualification_manifest, + "delivery": "none", + "executable": False, + }, ], }, *desktop_specs, @@ -361,6 +590,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--minimum-app-version") parser.add_argument("--generated-at") parser.add_argument("--signer", type=pathlib.Path) + parser.add_argument("--qualification-manifest", required=True, type=pathlib.Path) + parser.add_argument("--catalog-public-key", default=DEFAULT_CATALOG_PUBLIC_KEY) parser.add_argument("--skip-source-verification", action="store_true") return parser.parse_args() @@ -374,6 +605,11 @@ def main() -> None: kubectl = regular_file(args.kubectl.resolve(), "kubectl") compression_tool = pathlib.Path("/usr/bin/compression_tool") regular_file(compression_tool, "macOS compression_tool") + qualification_manifest = load_qualification_manifest( + args.qualification_manifest, + release_version=args.version, + public_key_base64=args.catalog_public_key, + ) if not args.skip_source_verification: validate_sources(repo, source_root, kubectl) @@ -398,7 +634,7 @@ def main() -> None: "assets": [], } ] - for spec in component_specs(source_root, kubectl): + for spec in component_specs(source_root, kubectl, args.qualification_manifest.resolve()): assets = [ materialize_asset( version=args.version, @@ -431,6 +667,13 @@ def main() -> None: "minimumAppVersion": args.minimum_app_version or args.version, "architecture": ARCHITECTURE, "components": releases, + "virtualMachineQualification": { + "component": QUALIFICATION_COMPONENT, + "path": QUALIFICATION_PATH, + "manifestIdentity": qualification_manifest["manifestIdentity"], + "manifestFormatVersion": qualification_manifest["schemaVersion"], + "signingKeyID": qualification_manifest["signingKeyID"], + }, } catalog_path = staging / "catalog.json" catalog_path.write_text( diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh index 5ae3265c..9483d4cd 100755 --- a/scripts/test-build-components.sh +++ b/scripts/test-build-components.sh @@ -8,6 +8,7 @@ trap 'rm -rf "$TMP"' EXIT SOURCE="$TMP/source" CORE_APP="$TMP/Dory.app" OUTPUT="$TMP/components/arm64" +QUALIFICATION="$TMP/virtual-machine-qualification.json" mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" write_fixture() { @@ -38,6 +39,62 @@ for distro in debian ubuntu kali; do done printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp" +python3 - "$QUALIFICATION" <<'PY' +import base64 +import hashlib +import json +import pathlib +import sys + +key = base64.b64decode("AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=") +digest = lambda byte: byte * 64 +manifest = { + "kind": "dev.dory.virtual-machine-qualification-manifest", + "schemaVersion": 1, + "manifestIdentity": "dory-release-9.8.7-apple-silicon", + "catalogReleaseVersion": "9.8.7", + "architecture": "arm64", + "signingKeyID": hashlib.sha256(key).hexdigest(), + "records": [{ + "qualificationIdentity": "linux-desktop-arm64-mac16-1-25a1", + "guest": {"family": "linux", "architecture": "arm64"}, + "bootMediaKind": "installed-linux-boot-bundle", + "bootMediaSource": "dory-bundled", + "immutableArtifactSHA256": digest("a"), + "backend": "dory-hypervisor", + "backendImplementationIdentifier": "dory.raw-hv-linux", + "backendRuntimeBuildIdentifier": "9.8.7", + "virtualHardwareABIVersion": 1, + "graphics": "hardware-accelerated-3d", + "devices": { + "networkAttachment": "shared-nat", + "audioInput": True, + "audioOutput": True, + "keyboard": True, + "pointer": True, + "directorySharing": True, + "clipboard": True, + "clockSynchronization": True, + "dynamicDisplay": True, + "gracefulShutdown": True, + }, + "hostHardwareModelIdentifier": "Mac16,1", + "hostOperatingSystemBuild": "25A1", + "components": [{ + "componentIdentifier": "dory-hv", + "buildIdentifier": "9.8.7", + "artifactSHA256": digest("b"), + }], + "virtioGPUKernelAndDeviceSupportQualified": True, + "venusVulkanGuestRuntimeQualified": True, + }], +} +pathlib.Path(sys.argv[1]).write_text( + json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", +) +PY + build() { "$ROOT/scripts/build-components.py" \ --version 9.8.7 \ @@ -48,13 +105,14 @@ build() { --output "$OUTPUT" \ --asset-base-url https://example.invalid/dory \ --generated-at 2026-07-16T00:00:00Z \ + --qualification-manifest "$QUALIFICATION" \ --skip-source-verification } build build -python3 - "$OUTPUT" "$ROOT" <<'PY' +python3 - "$OUTPUT" "$ROOT" "$QUALIFICATION" <<'PY' import hashlib import importlib.util import json @@ -65,9 +123,10 @@ import tempfile root = pathlib.Path(sys.argv[1]) repo = pathlib.Path(sys.argv[2]) +qualification_source = pathlib.Path(sys.argv[3]) catalog = json.loads((root / "catalog.json").read_text()) assert catalog["kind"] == "dev.dory.component-catalog" -assert catalog["schemaVersion"] == 1 +assert catalog["schemaVersion"] == 2 assert catalog["architecture"] == "arm64" assert [item["id"] for item in catalog["components"]] == [ "docker-core", @@ -81,6 +140,20 @@ assert [item["id"] for item in catalog["components"]] == [ assert catalog["components"][0]["assets"] == [] assert catalog["components"][0]["downloadBytes"] == 4096 assert catalog["components"][0]["installedBytes"] == 8192 +qualification = catalog["virtualMachineQualification"] +assert qualification["component"] == "linux-desktop" +assert qualification["path"] == "virtual-machine-qualification.json" +assert qualification["manifestIdentity"] == "dory-release-9.8.7-apple-silicon" +assert qualification["manifestFormatVersion"] == 1 +linux_desktop = next(item for item in catalog["components"] if item["id"] == "linux-desktop") +qualification_assets = [ + item for item in linux_desktop["assets"] + if item["path"] == "virtual-machine-qualification.json" +] +assert len(qualification_assets) == 1 +assert qualification_assets[0]["installedSHA256"] == hashlib.sha256( + qualification_source.read_bytes() +).hexdigest() for component in catalog["components"][1:]: assert component["downloadBytes"] == sum( @@ -138,4 +211,39 @@ else: assert (repo / ".git").is_dir() PY +cp "$QUALIFICATION" "$TMP/invalid-qualification.json" +python3 - "$TMP/invalid-qualification.json" <<'PY' +import json +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +manifest = json.loads(path.read_text(encoding="utf-8")) +manifest["catalogReleaseVersion"] = "9.8.6" +path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") +PY +if "$ROOT/scripts/build-components.py" \ + --version 9.8.7 \ + --core-artifact "$TMP/Dory-test.dmg" \ + --core-app "$CORE_APP" \ + --kubectl "$TMP/kubectl" \ + --source-root "$SOURCE" \ + --output "$TMP/rejected-components" \ + --qualification-manifest "$TMP/invalid-qualification.json" \ + --skip-source-verification 2>/dev/null; then + echo "component packaging accepted a qualification manifest for another release" >&2 + exit 1 +fi + +if "$ROOT/scripts/build-components.py" \ + --version 9.8.7 \ + --core-artifact "$TMP/Dory-test.dmg" \ + --core-app "$CORE_APP" \ + --kubectl "$TMP/kubectl" \ + --source-root "$SOURCE" \ + --output "$TMP/missing-qualification-components" \ + --skip-source-verification 2>/dev/null; then + echo "component packaging accepted a catalog without qualification evidence" >&2 + exit 1 +fi + echo "component packaging test passed" From 312a79608e2b59e718308e636ddac3122359d83b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:41:36 +0000 Subject: [PATCH 046/338] fix(vm): distinguish direct kernels from boot bundles --- .../DoryVirtualMachineBackendPlanner.swift | 2 +- .../DoryVirtualMachineCapabilities.swift | 12 +++- .../DoryVirtualMachineDefinition.swift | 5 +- ...lMachineProductionPlanningController.swift | 3 + ...yDaemonVirtualMachineProductionTrust.swift | 4 ++ .../DoryMachineConfigurationMigration.swift | 2 +- .../DorydKit/DoryResolvedMachinePlan.swift | 12 ++-- .../LinuxMachineBackendAdapters.swift | 35 +++++++---- .../Sources/DorydKit/MachineManager.swift | 58 ++++++++++++++++++- ...oryVirtualMachineBackendPlannerTests.swift | 4 ++ .../DoryVirtualMachineCapabilitiesTests.swift | 2 +- ...onVirtualMachineProductionTrustTests.swift | 53 +++++++++++------ ...ryMachineConfigurationMigrationTests.swift | 2 +- .../DorydKitTests/MachineBackendTests.swift | 10 ++-- ...eManagerResolvedPlanIntegrationTests.swift | 41 ++++++++++++- scripts/build-components.py | 2 +- scripts/test-build-components.sh | 2 +- 17 files changed, 196 insertions(+), 53 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift index 70ce50d2..0bb5f256 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift @@ -281,7 +281,7 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner { bootMedia: DoryBootMediaKind ) -> [DoryVirtualizationBackendIdentity] { switch (guest.family, bootMedia) { - case (.linux, .installedLinuxBootBundle): + case (.linux, .linuxKernel), (.linux, .installedLinuxBootBundle): return [.doryHypervisor] case (.linux, _): return [.appleVirtualizationFramework, .qemuHypervisorFramework] diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index f75c80d1..457c267c 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -23,6 +23,9 @@ public struct DoryGuestPlatform: Codable, Sendable, Equatable, Hashable { /// The format presented to the virtual machine's firmware or boot loader. public enum DoryBootMediaKind: String, Codable, Sendable, CaseIterable, Hashable { + /// One immutable Linux kernel image used by the existing direct-kernel launch path. This is + /// distinct from the portable kernel+initrd+root-device bundle created after an EFI install. + case linuxKernel = "linux-kernel" case installerISO = "installer-iso" case virtualDisk = "virtual-disk" case installedLinuxBootBundle = "installed-linux-boot-bundle" @@ -914,7 +917,8 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) -> Bool { switch family { case .linux: - media == .installerISO || media == .virtualDisk || media == .installedLinuxBootBundle + media == .linuxKernel || media == .installerISO || media == .virtualDisk + || media == .installedLinuxBootBundle case .windows: media == .installerISO || media == .virtualDisk case .macOS: @@ -927,9 +931,11 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) -> Bool { switch (request.guest.family, request.backend) { case (.linux, .doryHypervisor): - return request.bootMedia.kind == .installedLinuxBootBundle + return request.bootMedia.kind == .linuxKernel + || request.bootMedia.kind == .installedLinuxBootBundle case (.linux, .appleVirtualizationFramework): - return request.bootMedia.kind == .installerISO + return request.bootMedia.kind == .linuxKernel + || request.bootMedia.kind == .installerISO || request.bootMedia.kind == .virtualDisk || request.bootMedia.kind == .installedLinuxBootBundle case (.linux, .qemuHypervisorFramework), diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 1354ac71..033be09b 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -701,7 +701,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { let roleMatchesKind: Bool switch device.kind { - case .virtualDisk, .installedLinuxBootBundle: + case .linuxKernel, .virtualDisk, .installedLinuxBootBundle: roleMatchesKind = device.role == .system case .installerISO, .macOSRestoreImage: roleMatchesKind = device.role == .installer || device.role == .recovery @@ -987,7 +987,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { ) -> Bool { switch family { case .linux: - kind == .installerISO || kind == .virtualDisk || kind == .installedLinuxBootBundle + kind == .linuxKernel || kind == .installerISO || kind == .virtualDisk + || kind == .installedLinuxBootBundle case .windows: kind == .installerISO || kind == .virtualDisk case .macOS: diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift index f30a9c28..92e04203 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift @@ -209,6 +209,9 @@ public final class DoryDaemonVirtualMachineProductionPlanningController: $0.id == usage.identifier && $0.artifact == requirement.reference }) else { return nil } switch boot.kind { + case .linuxKernel: + guard machine.bootMode == .linuxKernel else { return nil } + paths.insert(machine.kernelPath) case .installedLinuxBootBundle: paths.insert(machine.kernelPath) case .installerISO: diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift index 508e2a2e..b9e7cd4f 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -639,6 +639,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: } catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid } + case .linuxKernel: + inspection = nil case .installedLinuxBootBundle: do { _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) } catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid } @@ -790,6 +792,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: } catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid } + case .linuxKernel: + inspection = nil case .installedLinuxBootBundle: do { _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index b5d82d34..2966257e 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -516,7 +516,7 @@ public enum DoryMachineConfigurationMigrationBridge { switch bootContract { case .managedDirectKernel: boot = normalBoot( - kind: .installedLinuxBootBundle, + kind: .linuxKernel, source: configuration.environment["DORY_CUSTOM_LINUX"] == "1" ? .userProvided : .bundledByDory, artifact: kernelReference diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 6b718233..58172452 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -914,14 +914,18 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { let runtimeCombinationIsImplemented: Bool switch (guest.family, backend) { case (.linux, .doryHypervisor): - runtimeCombinationIsImplemented = bootMedia.media.kind == .installedLinuxBootBundle + runtimeCombinationIsImplemented = bootMedia.media.kind == .linuxKernel + || bootMedia.media.kind == .installedLinuxBootBundle case (.linux, .appleVirtualizationFramework), - (.linux, .qemuHypervisorFramework), - (.windows, .qemuHypervisorFramework): - runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO + (.linux, .qemuHypervisorFramework): + runtimeCombinationIsImplemented = bootMedia.media.kind == .linuxKernel + || bootMedia.media.kind == .installerISO || bootMedia.media.kind == .virtualDisk || (backend == .appleVirtualizationFramework && bootMedia.media.kind == .installedLinuxBootBundle) + case (.windows, .qemuHypervisorFramework): + runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO + || bootMedia.media.kind == .virtualDisk case (.macOS, .appleVirtualizationFramework): runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage || bootMedia.media.kind == .virtualDisk diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index c5feed06..f1abeb52 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -361,7 +361,7 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable implementationIdentifier: "dory.raw-hv-linux.compatibility.v1", guestFamilies: [.linux], guestArchitectures: [.arm64], - bootMediaKinds: [.installedLinuxBootBundle], + bootMediaKinds: [.linuxKernel, .installedLinuxBootBundle], lifecycle: .currentMachineManager ) @@ -382,15 +382,26 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable executablePath: executablePath, executableIsAvailable: executableIsAvailable, operations: operations, - validateMachine: { machine, _ in + validateMachine: { machine, capability in guard machine.displayMode == .desktop else { return "The current raw-HV machine path is implemented only for desktop Linux." } guard machine.installerISOPath == nil else { return "The raw-HV machine path cannot boot attached installer media." } - guard machine.bootMode == .linuxKernel || machine.bootMode == .efi else { - return "The machine boot mode is not implemented by the raw-HV adapter." + switch capability.request.bootMedia.kind { + case .linuxKernel: + guard machine.bootMode == .linuxKernel, + !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return "A raw-HV Linux-kernel plan requires one raw direct-boot kernel." + } + case .installedLinuxBootBundle: + guard machine.bootMode == .efi, + DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return "A raw-HV installed-Linux plan requires a verified boot bundle." + } + default: + return "The selected media is not implemented by the raw-HV adapter." } do { guard try DoryDesktopVMMPreference(environment: machine.environment) != .compatible else { @@ -399,10 +410,6 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable } catch { return "The machine contains an invalid desktop backend preference." } - if machine.bootMode == .efi, - !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) { - return "An EFI-installed raw-HV machine requires a verified installed-Linux boot bundle." - } return nil } ) @@ -423,7 +430,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ implementationIdentifier: "dory.vz-linux.compatibility.v1", guestFamilies: [.linux], guestArchitectures: [.arm64], - bootMediaKinds: [.installedLinuxBootBundle, .installerISO, .virtualDisk], + bootMediaKinds: [.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk], lifecycle: .currentMachineManager ) @@ -446,11 +453,17 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ operations: operations, validateMachine: { machine, capability in switch capability.request.bootMedia.kind { - case .installedLinuxBootBundle: + case .linuxKernel: guard machine.bootMode == .linuxKernel, + machine.installerISOPath == nil, + !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { + return "A direct Linux kernel plan requires one raw kernel without installer media." + } + case .installedLinuxBootBundle: + guard machine.bootMode == .efi, machine.installerISOPath == nil, DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else { - return "A direct Linux VZ plan requires an installed-Linux boot bundle without installer media." + return "An installed Linux VZ plan requires a verified boot bundle without installer media." } case .installerISO: guard machine.bootMode == .efi, machine.displayMode == .desktop, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 8be92f52..7c0df1c6 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5380,15 +5380,29 @@ public final class MachineManager: @unchecked Sendable { if let currentRecord, currentRecord.legacyConfigurationSHA256 == nil, currentRecord.legacyMigrationFactsSHA256 == nil { - guard Self.nativeDefinition( + let nativeDefinition: DoryVirtualMachineDefinition + if let migrated = try migrateNativeDirectKernelMediaIfNeeded( currentRecord.definition, + compatibility: migration.definition + ) { + try workspaceRepository.replace( + migrated, + expectedRevision: currentRecord.definition.lifecycle.revision + ) + nativeDefinition = migrated + reconcileState = .published + } else { + nativeDefinition = currentRecord.definition + reconcileState = .unchanged + } + guard Self.nativeDefinition( + nativeDefinition, isCompatibleWith: migration.definition ) else { throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id) } - definition = currentRecord.definition + definition = nativeDefinition isNative = true - reconcileState = .unchanged } else { let result = try workspaceRepository.reconcileLegacyProjection( migration.definition, @@ -5418,6 +5432,44 @@ public final class MachineManager: @unchecked Sendable { ) } + /// Native records written before the raw direct-kernel media kind existed used the portable + /// installed-bundle label for both shapes. The compatibility machine still proves which bytes + /// are actually launched, so upgrade only that one exact alias and advance desired-state + /// authority instead of continuing to misclassify a raw kernel at production trust time. + private func migrateNativeDirectKernelMediaIfNeeded( + _ definition: DoryVirtualMachineDefinition, + compatibility: DoryVirtualMachineDefinition + ) throws -> DoryVirtualMachineDefinition? { + guard compatibility.boot.devices.count == 1, + compatibility.boot.devices[0].kind == .linuxKernel, + definition.boot.devices.count == 1, + definition.boot.devices[0].kind == .installedLinuxBootBundle else { + return nil + } + var legacyCompatibility = compatibility + legacyCompatibility.boot.devices[0].kind = .installedLinuxBootBundle + guard Self.nativeDefinition( + definition, + isCompatibleWith: legacyCompatibility + ), definition.lifecycle.revision < UInt64.max, + definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else { + return nil + } + var migrated = definition + migrated.boot = compatibility.boot + migrated.lifecycle = DoryVMLifecycleMetadata( + revision: definition.lifecycle.revision + 1, + createdAtUnixMilliseconds: definition.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: definition.lifecycle.updatedAtUnixMilliseconds + 1 + ) + guard migrated.validate().isEmpty else { + throw DoryWorkspaceRepositoryError.staleLegacyProjection( + definition.identity.id + ) + } + return migrated + } + private static func nativeDefinition( _ definition: DoryVirtualMachineDefinition, isCompatibleWith compatibility: DoryVirtualMachineDefinition diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift index 710e98d9..fef9c11f 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift @@ -68,6 +68,10 @@ struct DoryVirtualMachineBackendPlannerTests { for: linux, bootMedia: .installedLinuxBootBundle ) == [.doryHypervisor]) + #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( + for: linux, + bootMedia: .linuxKernel + ) == [.doryHypervisor]) #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends( for: macOS, bootMedia: .macOSRestoreImage diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 48749748..be26b2f0 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -150,7 +150,7 @@ struct VirtualMachineCapabilitiesTests { == .trustedGuestImageGraphicsQualificationUnavailable) } - @Test("native hypervisor only accepts the post-install direct-boot bundle") + @Test("native hypervisor rejects installer media while VZ accepts it") func nativeHypervisorRejectsInstallerISO() { let native = evaluate( family: .linux, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index b79a4767..280b1656 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -395,7 +395,7 @@ struct DoryDaemonVirtualMachineProductionTrustTests { let completed = LockedPlanningCreateReply() service.machineCreate([ "id": "qualified-headless", - "kernelPath": fixture.mediaPath, + "kernelPath": fixture.directKernelPath, "rootfsPath": qualifiedDisk, "displayMode": "headless", "memoryMB": UInt64(2_048), @@ -776,6 +776,8 @@ private final class ProductionTrustFixture: @unchecked Sendable { let runtimeBuildIdentifier: String let mediaPath: String let mediaDigest: String + let directKernelPath: String + let directKernelDigest: String let mediaReference = DoryVMResolverReference( namespace: "artifact", identifier: "qualified-linux-boot" @@ -855,6 +857,15 @@ private final class ProductionTrustFixture: @unchecked Sendable { toPath: mediaPath ) mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath) + directKernelPath = root.appendingPathComponent("qualified-direct-kernel").path + try Data("qualified-direct-kernel".utf8).write( + to: URL(fileURLWithPath: directKernelPath) + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: directKernelPath + ) + directKernelDigest = try DoryComponentCatalogVerifier.fileDigest(directKernelPath) storagePath = root.appendingPathComponent("qualified-linux.raw").path try Data(repeating: 0x5a, count: 4_096).write( to: URL(fileURLWithPath: storagePath) @@ -1152,24 +1163,30 @@ private final class ProductionTrustFixture: @unchecked Sendable { VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier, "dory-vmm"), ] + let media = [ + (DoryBootMediaKind.installedLinuxBootBundle, mediaDigest, "bundle"), + (DoryBootMediaKind.linuxKernel, directKernelDigest, "kernel"), + ] let records = backends.flatMap { backend, implementation, component in - [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in - DoryVirtualMachineQualificationRecord( - qualificationIdentity: "\(component)-\(suffix)-qualification", - guest: guest, - bootMediaKind: .installedLinuxBootBundle, - bootMediaSource: .bundledByDory, - immutableArtifactSHA256: mediaDigest, - backend: backend, - backendImplementationIdentifier: implementation, - backendRuntimeBuildIdentifier: runtimeBuildIdentifier, - virtualHardwareABIVersion: 1, - graphics: .none, - devices: devices, - hostHardwareModelIdentifier: host.hardwareModelIdentifier, - hostOperatingSystemBuild: host.operatingSystemBuild, - components: [components.first { $0.componentIdentifier == component }!] - ) + media.flatMap { mediaKind, mediaDigest, mediaSuffix in + [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in + DoryVirtualMachineQualificationRecord( + qualificationIdentity: "\(component)-\(mediaSuffix)-\(suffix)-qualification", + guest: guest, + bootMediaKind: mediaKind, + bootMediaSource: .bundledByDory, + immutableArtifactSHA256: mediaDigest, + backend: backend, + backendImplementationIdentifier: implementation, + backendRuntimeBuildIdentifier: runtimeBuildIdentifier, + virtualHardwareABIVersion: 1, + graphics: .none, + devices: devices, + hostHardwareModelIdentifier: host.hardwareModelIdentifier, + hostOperatingSystemBuild: host.operatingSystemBuild, + components: [components.first { $0.componentIdentifier == component }!] + ) + } } } let manifest = DoryVirtualMachineQualificationManifest( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 89acb085..323b5f0a 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -57,7 +57,7 @@ struct DoryMachineConfigurationMigrationTests { #expect(migrated.bootContract == .managedDirectKernel) #expect(migrated.definition.workload == .desktop) #expect(migrated.definition.boot.phase == .normal) - #expect(migrated.definition.boot.devices[0].kind == .installedLinuxBootBundle) + #expect(migrated.definition.boot.devices[0].kind == .linuxKernel) #expect(migrated.definition.boot.devices[0].source == .bundledByDory) #expect(migrated.definition.backendPreference == DoryVMBackendPreference( mode: .preferred, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 93e09ed2..d3c7af94 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -8,7 +8,7 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(raw.identity, .doryHypervisor) XCTAssertEqual(raw.guestFamilies, [.linux]) XCTAssertEqual(raw.guestArchitectures, [.arm64]) - XCTAssertEqual(raw.bootMediaKinds, [.installedLinuxBootBundle]) + XCTAssertEqual(raw.bootMediaKinds, [.linuxKernel, .installedLinuxBootBundle]) XCTAssertEqual(raw.lifecycle, .currentMachineManager) let vz = VirtualizationFrameworkLinuxMachineBackend.backendDescriptor @@ -17,7 +17,7 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(vz.guestArchitectures, [.arm64]) XCTAssertEqual( vz.bootMediaKinds, - [.installedLinuxBootBundle, .installerISO, .virtualDisk] + [.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk] ) XCTAssertFalse(vz.lifecycle.pause) XCTAssertFalse(vz.lifecycle.resume) @@ -86,7 +86,7 @@ final class MachineBackendTests: XCTestCase { machine: rawMachine(), capabilityPlan: capabilityPlan( backend: .doryHypervisor, - media: .installedLinuxBootBundle + media: .linuxKernel ) ) @@ -147,7 +147,7 @@ final class MachineBackendTests: XCTestCase { id: "vz-direct-linux", kernelPath: bundle.path, rootfsPath: "/fixture/linux.raw", - bootMode: .linuxKernel, + bootMode: .efi, displayMode: .headless ) let registry = try BackendRegistry(backends: [ @@ -228,7 +228,7 @@ final class MachineBackendTests: XCTestCase { machine: machine, capabilityPlan: capabilityPlan( backend: .doryHypervisor, - media: .installedLinuxBootBundle + media: .linuxKernel ) )) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 6370b548..4f7455ad 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -753,6 +753,7 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(createdRecord.legacyMigrationFactsSHA256 == nil) #expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer") #expect(createdRecord.definition.backendPreference.backend == .doryHypervisor) + #expect(createdRecord.definition.boot.devices.first?.kind == .linuxKernel) #expect(created.typedSettings?.graphicsPreference == .virgl) #expect(createdRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline") @@ -816,6 +817,44 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(restartedStatus.runtimeIdentity.mode == .requiresReplanning) } + @Test("native direct-kernel records upgrade the historical bundle alias exactly once") + func nativeDirectKernelAliasMigrates() throws { + let state = try makeState("native-kernel-media-upgrade") + defer { try? FileManager.default.removeItem(atPath: state) } + do { + let manager = makeManager(state: state, policy: .perWorkspaceAuthority) + _ = try createMachine(id: "native", manager: manager) + } + + let repository = DoryWorkspaceRepository(root: state) + let current = try repository.readPersistedRecord(id: "native").definition + #expect(current.boot.devices.first?.kind == .linuxKernel) + var historicalAlias = current + historicalAlias.boot.devices[0].kind = .installedLinuxBootBundle + historicalAlias.lifecycle = DoryVMLifecycleMetadata( + revision: current.lifecycle.revision + 1, + createdAtUnixMilliseconds: current.lifecycle.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: current.lifecycle.updatedAtUnixMilliseconds + 1 + ) + try repository.replace( + historicalAlias, + expectedRevision: current.lifecycle.revision + ) + + let machineBytes = try Data(contentsOf: URL( + fileURLWithPath: state + "/native/machine.json" + )) + let restarted = makeManager(state: state, policy: .perWorkspaceAuthority) + let status = try #require(restarted.status(id: "native")) + #expect(status.runtimeIdentity.mode == .requiresReplanning) + let upgraded = try repository.readPersistedRecord(id: "native").definition + #expect(upgraded.boot.devices.first?.kind == .linuxKernel) + #expect(upgraded.lifecycle.revision == historicalAlias.lifecycle.revision + 1) + #expect(try Data(contentsOf: URL( + fileURLWithPath: state + "/native/machine.json" + )) == machineBytes) + } + @Test("native creation authority is durable before machine metadata becomes discoverable") func nativeCreationCrashOrderingNeverMintsLegacy() throws { let state = try makeState("native-create-order") @@ -1424,7 +1463,7 @@ struct MachineManagerResolvedPlanIntegrationTests { let artifact = digest("a") let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable let media = DoryBootMedia( - kind: .installedLinuxBootBundle, + kind: request.definition.boot.devices[0].kind, source: .userProvided, artifactSHA256: artifact ) diff --git a/scripts/build-components.py b/scripts/build-components.py index 751faebc..cab3ac95 100755 --- a/scripts/build-components.py +++ b/scripts/build-components.py @@ -199,7 +199,7 @@ def validate_qualification_record(value: object, index: int) -> dict: if guest["architecture"] not in {"arm64", "x86_64"}: fail(f"{label} guest architecture is unsupported") if record["bootMediaKind"] not in { - "installer-iso", "virtual-disk", "installed-linux-boot-bundle", + "linux-kernel", "installer-iso", "virtual-disk", "installed-linux-boot-bundle", "macos-restore-image", }: fail(f"{label} boot media kind is unsupported") diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh index 9483d4cd..c65b4345 100755 --- a/scripts/test-build-components.sh +++ b/scripts/test-build-components.sh @@ -58,7 +58,7 @@ manifest = { "records": [{ "qualificationIdentity": "linux-desktop-arm64-mac16-1-25a1", "guest": {"family": "linux", "architecture": "arm64"}, - "bootMediaKind": "installed-linux-boot-bundle", + "bootMediaKind": "linux-kernel", "bootMediaSource": "dory-bundled", "immutableArtifactSHA256": digest("a"), "backend": "dory-hypervisor", From a2e500be5195e549569cbf436932b2cf288ffa04 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:44:50 +0000 Subject: [PATCH 047/338] fix(components): bind qualifications to candidate bytes --- scripts/build-components.py | 76 ++++++++++++++++++++++++++++++++ scripts/test-build-components.sh | 69 +++++++++++++++++++++++++---- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/scripts/build-components.py b/scripts/build-components.py index cab3ac95..1646f82e 100755 --- a/scripts/build-components.py +++ b/scripts/build-components.py @@ -345,6 +345,77 @@ def load_qualification_manifest( return manifest +def validate_qualification_bindings( + manifest: dict, + *, + source_root: pathlib.Path, + core_app: pathlib.Path, +) -> None: + """Bind catalog-authenticated qualification claims to the exact shipped candidate bytes.""" + runtime_contracts = { + "dory-hypervisor": ( + "dory.raw-hv-linux.compatibility.v1", + "dory-hv", + ), + "apple-virtualization-framework": ( + "dory.vz-linux.compatibility.v1", + "dory-vmm", + ), + } + runtime_evidence: dict[str, tuple[str, str]] = {} + for backend in {record["backend"] for record in manifest["records"]}: + if backend not in runtime_contracts: + fail(f"qualification backend is not shipped by the current Apple Silicon product: {backend}") + implementation, component = runtime_contracts[backend] + helper = regular_file( + core_app / "Contents" / "Helpers" / component, + f"{component} candidate helper", + ) + if helper.stat().st_mode & 0o111 == 0: + fail(f"{component} candidate helper is not executable") + digest = sha256(helper) + runtime_evidence[backend] = (implementation, digest) + + bundled_media = { + "linux-kernel": { + sha256(regular_file(source_root / "Image", "headless Linux kernel")), + sha256(regular_file(source_root / "Image-desktop", "desktop Linux kernel")), + }, + "virtual-disk": { + sha256(regular_file(source_root / "initfs-arm64.ext4", "headless Linux rootfs")), + *{ + sha256(regular_file( + source_root / f"dory-desktop-{distro}-rootfs-arm64.ext4", + f"{distro} desktop rootfs", + )) + for distro in ("debian", "ubuntu", "kali") + }, + }, + } + + for index, record in enumerate(manifest["records"]): + label = f"qualification record {index}" + implementation, runtime_digest = runtime_evidence[record["backend"]] + if record["backendImplementationIdentifier"] != implementation: + fail(f"{label} implementation does not match the shipped adapter") + expected_build = f"sha256:{runtime_digest}" + if record["backendRuntimeBuildIdentifier"] != expected_build: + fail(f"{label} runtime build does not match the shipped helper") + expected_components = [{ + "componentIdentifier": runtime_contracts[record["backend"]][1], + "buildIdentifier": expected_build, + "artifactSHA256": runtime_digest, + }] + if record["components"] != expected_components: + fail(f"{label} component evidence does not match the shipped helper") + + if record["bootMediaSource"] == "dory-bundled": + accepted = bundled_media.get(record["bootMediaKind"]) + digest = record.get("immutableArtifactSHA256") + if accepted is None or digest not in accepted: + fail(f"{label} bundled media digest is not an exact packaged artifact") + + def component_specs( source_root: pathlib.Path, kubectl: pathlib.Path, @@ -610,6 +681,11 @@ def main() -> None: release_version=args.version, public_key_base64=args.catalog_public_key, ) + validate_qualification_bindings( + qualification_manifest, + source_root=source_root, + core_app=core_app, + ) if not args.skip_source_verification: validate_sources(repo, source_root, kubectl) diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh index c65b4345..e49f08fd 100755 --- a/scripts/test-build-components.sh +++ b/scripts/test-build-components.sh @@ -9,7 +9,7 @@ SOURCE="$TMP/source" CORE_APP="$TMP/Dory.app" OUTPUT="$TMP/components/arm64" QUALIFICATION="$TMP/virtual-machine-qualification.json" -mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" +mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" "$CORE_APP/Contents/Helpers" write_fixture() { local path="$1" bytes="$2" @@ -19,6 +19,9 @@ write_fixture() { write_fixture "$TMP/Dory-test.dmg" 4096 write_fixture "$CORE_APP/Contents/MacOS/Dory" 8192 +write_fixture "$CORE_APP/Contents/Helpers/dory-hv" 4096 +write_fixture "$CORE_APP/Contents/Helpers/dory-vmm" 4096 +chmod 0755 "$CORE_APP/Contents/Helpers/dory-hv" "$CORE_APP/Contents/Helpers/dory-vmm" write_fixture "$TMP/kubectl" 16384 chmod 0755 "$TMP/kubectl" write_fixture "$SOURCE/Image" 131072 @@ -39,7 +42,7 @@ for distro in debian ubuntu kali; do done printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp" -python3 - "$QUALIFICATION" <<'PY' +python3 - "$QUALIFICATION" "$SOURCE" "$CORE_APP" <<'PY' import base64 import hashlib import json @@ -48,6 +51,10 @@ import sys key = base64.b64decode("AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=") digest = lambda byte: byte * 64 +source = pathlib.Path(sys.argv[2]) +app = pathlib.Path(sys.argv[3]) +file_digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() +runtime_digest = file_digest(app / "Contents/Helpers/dory-hv") manifest = { "kind": "dev.dory.virtual-machine-qualification-manifest", "schemaVersion": 1, @@ -60,10 +67,10 @@ manifest = { "guest": {"family": "linux", "architecture": "arm64"}, "bootMediaKind": "linux-kernel", "bootMediaSource": "dory-bundled", - "immutableArtifactSHA256": digest("a"), + "immutableArtifactSHA256": file_digest(source / "Image-desktop"), "backend": "dory-hypervisor", - "backendImplementationIdentifier": "dory.raw-hv-linux", - "backendRuntimeBuildIdentifier": "9.8.7", + "backendImplementationIdentifier": "dory.raw-hv-linux.compatibility.v1", + "backendRuntimeBuildIdentifier": f"sha256:{runtime_digest}", "virtualHardwareABIVersion": 1, "graphics": "hardware-accelerated-3d", "devices": { @@ -82,8 +89,8 @@ manifest = { "hostOperatingSystemBuild": "25A1", "components": [{ "componentIdentifier": "dory-hv", - "buildIdentifier": "9.8.7", - "artifactSHA256": digest("b"), + "buildIdentifier": f"sha256:{runtime_digest}", + "artifactSHA256": runtime_digest, }], "virtioGPUKernelAndDeviceSupportQualified": True, "venusVulkanGuestRuntimeQualified": True, @@ -139,7 +146,7 @@ assert [item["id"] for item in catalog["components"]] == [ ] assert catalog["components"][0]["assets"] == [] assert catalog["components"][0]["downloadBytes"] == 4096 -assert catalog["components"][0]["installedBytes"] == 8192 +assert catalog["components"][0]["installedBytes"] == 16384 qualification = catalog["virtualMachineQualification"] assert qualification["component"] == "linux-desktop" assert qualification["path"] == "virtual-machine-qualification.json" @@ -234,6 +241,52 @@ if "$ROOT/scripts/build-components.py" \ exit 1 fi +cp "$QUALIFICATION" "$TMP/mismatched-helper-qualification.json" +python3 - "$TMP/mismatched-helper-qualification.json" <<'PY' +import json +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +manifest = json.loads(path.read_text(encoding="utf-8")) +manifest["records"][0]["components"][0]["artifactSHA256"] = "c" * 64 +path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") +PY +if "$ROOT/scripts/build-components.py" \ + --version 9.8.7 \ + --core-artifact "$TMP/Dory-test.dmg" \ + --core-app "$CORE_APP" \ + --kubectl "$TMP/kubectl" \ + --source-root "$SOURCE" \ + --output "$TMP/mismatched-helper-components" \ + --qualification-manifest "$TMP/mismatched-helper-qualification.json" \ + --skip-source-verification 2>/dev/null; then + echo "component packaging accepted qualification evidence for different helper bytes" >&2 + exit 1 +fi + +cp "$QUALIFICATION" "$TMP/mismatched-media-qualification.json" +python3 - "$TMP/mismatched-media-qualification.json" <<'PY' +import json +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +manifest = json.loads(path.read_text(encoding="utf-8")) +manifest["records"][0]["immutableArtifactSHA256"] = "d" * 64 +path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") +PY +if "$ROOT/scripts/build-components.py" \ + --version 9.8.7 \ + --core-artifact "$TMP/Dory-test.dmg" \ + --core-app "$CORE_APP" \ + --kubectl "$TMP/kubectl" \ + --source-root "$SOURCE" \ + --output "$TMP/mismatched-media-components" \ + --qualification-manifest "$TMP/mismatched-media-qualification.json" \ + --skip-source-verification 2>/dev/null; then + echo "component packaging accepted qualification evidence for different bundled media" >&2 + exit 1 +fi + if "$ROOT/scripts/build-components.py" \ --version 9.8.7 \ --core-artifact "$TMP/Dory-test.dmg" \ From 27fa6b3b92b5bd2acfb8a478b8b82c7dddd7f670 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 21:52:45 +0000 Subject: [PATCH 048/338] feat(ui): surface machine runtime evidence --- Dory/Features/Machines/MachinesView.swift | 42 +++++++ Dory/Models/AppStore.swift | 1 + Dory/Models/Models.swift | 103 ++++++++++++++++++ Dory/Runtime/Doryd/DorydClient.swift | 5 + DoryTests/DorydClientTests.swift | 38 +++++++ .../Sources/DorydKit/DorydService.swift | 1 + 6 files changed, 190 insertions(+) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 45cbc1fa..643f4722 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -165,6 +165,9 @@ private struct MachineCard: View { .padding(.bottom, 12) } + runtimeEvidence + .padding(.bottom, 12) + if let command = store.machineTerminalCommand(machine) { HStack(spacing: 6) { Image(systemName: "terminal").font(.system(size: 11)).foregroundStyle(p.text3) @@ -313,6 +316,45 @@ private struct MachineCard: View { } } + private var runtimeEvidence: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 104, maximum: 190), spacing: 6)], + alignment: .leading, + spacing: 6 + ) { + ForEach(machine.runtimeEvidence) { evidence in + HStack(spacing: 4) { + Image(systemName: evidence.systemImage) + .font(.system(size: 9, weight: .semibold)) + Text(evidence.label) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(runtimeEvidenceColor(evidence.tone)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(runtimeEvidenceBackground(evidence.tone), in: Capsule()) + .help(evidence.detail) + } + } + } + + private func runtimeEvidenceColor(_ tone: MachineRuntimeEvidenceTone) -> Color { + switch tone { + case .standard: p.text2 + case .positive: p.green + case .warning: p.amber + } + } + + private func runtimeEvidenceBackground(_ tone: MachineRuntimeEvidenceTone) -> Color { + switch tone { + case .standard: p.pill + case .positive: p.greenWeak + case .warning: p.amberWeak + } + } + private func actionButton(_ systemImage: String, _ title: String, prominent: Bool, enabled: Bool = true, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 300937e7..be0055fa 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5192,6 +5192,7 @@ final class AppStore { bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, runtimeIdentity: status.runtimeIdentity, + agentBuild: status.agentBuild, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 07022f82..bc50ab9d 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -302,12 +302,115 @@ struct Machine: Identifiable, Hashable, Sendable { var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var agentBuild: String? = nil var mounts: [MountPair] = [] var id: String { name } var badgeColor: Color { Color(hex: badgeHex) } var actionLabel: String { status == .running ? "Stop" : "Start" } var isEmulated: Bool { !arch.isEmpty && arch != MachineArch.host.rawValue } + + var runtimeEvidence: [MachineRuntimeEvidence] { + var evidence: [MachineRuntimeEvidence] = [] + switch runtimeIdentity.mode { + case "resolved-plan": + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: runtimeIdentity.supportTier == "supported" ? "Supported" : "Preview", + systemImage: runtimeIdentity.supportTier == "supported" + ? "checkmark.seal.fill" : "exclamationmark.triangle.fill", + tone: runtimeIdentity.supportTier == "supported" ? .positive : .warning, + detail: runtimeIdentity.runtimeQualification?.qualificationIdentity + ?? "Resolved plan \(runtimeIdentity.planRevision ?? 0)" + )) + if let backend = runtimeIdentity.backend { + evidence.append(MachineRuntimeEvidence( + id: "backend", + label: Self.backendLabel(backend), + systemImage: "cpu", + tone: .standard, + detail: runtimeIdentity.backendRuntimeBuildIdentifier ?? backend + )) + } + if displayMode == .desktop { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: Self.graphicsLabel(runtimeIdentity.graphics), + systemImage: "display", + tone: runtimeIdentity.graphics == "hardware-accelerated-3d" + ? .positive : .standard, + detail: runtimeIdentity.graphicsQualification?.manifestIdentity + ?? "No separate signed graphics qualification" + )) + } + if runtimeIdentity.selectionDisposition == "approved-fallback" { + evidence.append(MachineRuntimeEvidence( + id: "fallback", + label: "Approved fallback", + systemImage: "arrow.triangle.branch", + tone: .warning, + detail: runtimeIdentity.fallbackAuthorizationIdentity ?? "Approved alternative" + )) + } + case "requires-replanning": + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: "Needs planning", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: runtimeIdentity.invalidationReason ?? "No current launch plan" + )) + default: + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: "Compatibility", + systemImage: "arrow.triangle.2.circlepath", + tone: .standard, + detail: "Legacy compatibility launch authority" + )) + } + evidence.append(MachineRuntimeEvidence( + id: "tools", + label: agentBuild == nil ? "Tools unavailable" : "Tools ready", + systemImage: agentBuild == nil ? "wrench.and.screwdriver" : "wrench.and.screwdriver.fill", + tone: agentBuild == nil ? .warning : .positive, + detail: agentBuild ?? "The guest has not reported a Dory Tools build" + )) + return evidence + } + + private static func backendLabel(_ backend: String) -> String { + switch backend { + case "dory-hypervisor": "Raw HV" + case "apple-virtualization-framework": "Virtualization.framework" + case "qemu-hvf": "QEMU/HVF" + default: backend + } + } + + private static func graphicsLabel(_ graphics: String?) -> String { + switch graphics { + case "hardware-accelerated-3d": "Qualified 3D" + case "host-accelerated-display": "Accelerated display" + case "software": "Software graphics" + case "none": "No graphics" + default: "Graphics unknown" + } + } +} + +enum MachineRuntimeEvidenceTone: Hashable, Sendable { + case standard + case positive + case warning +} + +struct MachineRuntimeEvidence: Identifiable, Hashable, Sendable { + var id: String + var label: String + var systemImage: String + var tone: MachineRuntimeEvidenceTone + var detail: String } enum LogLevel: String, Sendable { diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 0eb4a454..ac925b68 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -450,6 +450,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha var backendImplementationIdentifier: String? = nil var backendRuntimeBuildIdentifier: String? = nil var supportTier: String? = nil + var graphics: String? = nil var selectionDisposition: String? = nil var fallbackAuthorizationIdentity: String? = nil var experimentalAuthorizationIdentity: String? = nil @@ -492,6 +493,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha && backendImplementationIdentifier == nil && backendRuntimeBuildIdentifier == nil && supportTier == nil + && graphics == nil && selectionDisposition == nil && fallbackAuthorizationIdentity == nil && experimentalAuthorizationIdentity == nil @@ -512,6 +514,9 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha backendImplementationIdentifier?.isSafeEvidenceIdentifier == true, backendRuntimeBuildIdentifier?.isSafeEvidenceIdentifier == true, let supportTier, + let graphics, + ["none", "software", "host-accelerated-display", "hardware-accelerated-3d"] + .contains(graphics), let selectionDisposition, ["primary", "explicit-alternative", "approved-fallback"] .contains(selectionDisposition), diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 0a307db3..fc557fea 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -469,6 +469,10 @@ struct DorydClientTests { "artifactSHA256": String(repeating: "6", count: 64), "provenanceReceiptIdentity": "orphan-receipt", ] + let unsupportedGraphics = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + unsupportedGraphics["graphics"] = "automatic" for identity in [ [ "schemaVersion": 2, @@ -488,6 +492,7 @@ struct DorydClientTests { resolvedWithoutComponentsOrMedia, mixedQualification, orphanProvenance, + unsupportedGraphics, ] { let listener = NSXPCListener.anonymous() let service = FakeDorydService(runtimeIdentityOverride: identity) @@ -518,6 +523,38 @@ struct DorydClientTests { } } + @Test func machineRuntimeEvidenceSurfacesPlanGraphicsBackendAndGuestTools() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity()) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") + #expect(machine.runtimeEvidence.map(\.label) == [ + "Supported", "Raw HV", "Qualified 3D", "Tools ready", + ]) + #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.detail + == "runtime-qualification-1") + + var replanning = machine + replanning.runtimeIdentity = DorydMachineRuntimeIdentity( + schemaVersion: 1, + mode: "requires-replanning", + virtualHardwareABIVersion: 1, + invalidationReason: "restored-snapshot" + ) + replanning.agentBuild = nil + #expect(replanning.runtimeEvidence.map(\.label) == [ + "Needs planning", "Tools unavailable", + ]) + } + @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws { let malformedEvidence = [ "schemaVersion": 1, @@ -740,6 +777,7 @@ struct DorydClientTests { "backendImplementationIdentifier": "dev.dory.raw-hv-linux", "backendRuntimeBuildIdentifier": "runtime-1", "supportTier": "supported", + "graphics": "hardware-accelerated-3d", "selectionDisposition": "primary", "runtimeQualification": [ "qualificationIdentity": "runtime-qualification-1", diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index ded030ef..25ed532b 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1707,6 +1707,7 @@ private extension DoryMachineRuntimeIdentity { dictionary["backendImplementationIdentifier"] = plan.backendImplementationIdentifier dictionary["backendRuntimeBuildIdentifier"] = plan.backendRuntimeBuildIdentifier dictionary["supportTier"] = plan.supportTier.rawValue + dictionary["graphics"] = plan.graphics.rawValue if let selectionDisposition = plan.selectionEvidence?.disposition { dictionary["selectionDisposition"] = selectionDisposition.rawValue } From 31f2451c1b774f2981b781337a49640fb912288e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 22:13:47 +0000 Subject: [PATCH 049/338] feat(components): bind catalog v2 authority --- .../DoryOperations/DoryComponents.swift | 232 +++++++++++++++++- .../DoryComponentsTests.swift | 164 +++++++++++++ ...ualMachineQualificationManifestTests.swift | 30 ++- ...onVirtualMachineProductionTrustTests.swift | 30 ++- ...yInstalledDesktopPayloadReceiptTests.swift | 1 + scripts/build-components.py | 121 ++++++++- scripts/test-build-components.sh | 31 +++ 7 files changed, 593 insertions(+), 16 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift index a1769191..4f610444 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift @@ -2,6 +2,7 @@ import Compression import CryptoKit import Darwin import Foundation +import Security public enum DoryComponentID: String, Codable, CaseIterable, Hashable, Sendable { case dockerCore = "docker-core" @@ -88,6 +89,49 @@ public enum DoryComponentCompression: String, Codable, Sendable { case lzfse } +public enum DoryComponentArtifactRole: String, Codable, Sendable { + case hostHelper = "host-helper" + case hostCLI = "host-cli" + case guestKernel = "guest-kernel" + case guestDisk = "guest-disk" + case guestUpdate = "guest-update" + case guestPackageManifest = "guest-package-manifest" + case qualificationEvidence = "qualification-evidence" + case buildMetadata = "build-metadata" +} + +public struct DoryComponentHostRequirements: Codable, Sendable, Equatable { + public let platform: String + public let minimumVersion: String + + public init(platform: String, minimumVersion: String) { + self.platform = platform + self.minimumVersion = minimumVersion + } +} + +public struct DoryComponentProvenance: Codable, Sendable, Equatable { + public let sourceCommit: String + public let builder: String + public let recipeDigest: String + public let sbomDigest: String + public let attestationDigest: String + + public init( + sourceCommit: String, + builder: String, + recipeDigest: String, + sbomDigest: String, + attestationDigest: String + ) { + self.sourceCommit = sourceCommit + self.builder = builder + self.recipeDigest = recipeDigest.lowercased() + self.sbomDigest = sbomDigest.lowercased() + self.attestationDigest = attestationDigest.lowercased() + } +} + public struct DoryComponentAsset: Codable, Sendable, Equatable { public let path: String public let url: String @@ -97,6 +141,8 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable { public let sha256: String public let installedSHA256: String public let executable: Bool + public let role: DoryComponentArtifactRole? + public let codeRequirement: String? public init( path: String, @@ -106,7 +152,9 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable { installedBytes: UInt64, sha256: String, installedSHA256: String, - executable: Bool = false + executable: Bool = false, + role: DoryComponentArtifactRole? = nil, + codeRequirement: String? = nil ) { self.path = path self.url = url @@ -116,6 +164,8 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable { self.sha256 = sha256.lowercased() self.installedSHA256 = installedSHA256.lowercased() self.executable = executable + self.role = role + self.codeRequirement = codeRequirement } } @@ -128,6 +178,12 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable { public let downloadBytes: UInt64 public let installedBytes: UInt64 public let assets: [DoryComponentAsset] + public let architectures: [String]? + public let hostRequirements: DoryComponentHostRequirements? + public let provides: [String]? + public let requires: [String]? + public let provenance: DoryComponentProvenance? + public let qualification: [String]? public init( id: DoryComponentID, @@ -137,7 +193,13 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable { dependencies: [DoryComponentID] = [.dockerCore], downloadBytes: UInt64, installedBytes: UInt64, - assets: [DoryComponentAsset] + assets: [DoryComponentAsset], + architectures: [String]? = nil, + hostRequirements: DoryComponentHostRequirements? = nil, + provides: [String]? = nil, + requires: [String]? = nil, + provenance: DoryComponentProvenance? = nil, + qualification: [String]? = nil ) { self.id = id self.version = version @@ -147,6 +209,12 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable { self.downloadBytes = downloadBytes self.installedBytes = installedBytes self.assets = assets + self.architectures = architectures + self.hostRequirements = hostRequirements + self.provides = provides + self.requires = requires + self.provenance = provenance + self.qualification = qualification } } @@ -313,7 +381,12 @@ public enum DoryComponentCatalogVerifier { throw DoryComponentError.invalidCatalog("Docker Core must be the first, unique, payload-free entry") } for component in catalog.components { - try validate(component, available: Set(ids)) + try validate( + component, + available: Set(ids), + schemaVersion: catalog.schemaVersion, + catalogArchitecture: catalog.architecture + ) } if catalog.schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion, catalog.virtualMachineQualification != nil { @@ -322,14 +395,16 @@ public enum DoryComponentCatalogVerifier { ) } if let qualification = catalog.virtualMachineQualification { + let qualificationComponent = catalog.component(qualification.component) guard catalog.schemaVersion == DoryComponentCatalog.schemaVersion, qualification.component.isRemovable, safeRelativePath(qualification.path), !qualification.manifestIdentity.isEmpty, qualification.manifestFormatVersion > 0, !qualification.signingKeyID.isEmpty, - catalog.component(qualification.component)?.assets.contains(where: { - $0.path == qualification.path + qualificationComponent?.qualification?.isEmpty == false, + qualificationComponent?.assets.contains(where: { + $0.path == qualification.path && $0.role == .qualificationEvidence }) == true else { throw DoryComponentError.invalidCatalog( "VM qualification authority is incomplete or is not a declared component asset" @@ -358,7 +433,12 @@ public enum DoryComponentCatalogVerifier { return hasher.finalize().map { String(format: "%02x", $0) }.joined() } - private static func validate(_ component: DoryComponentRelease, available: Set) throws { + private static func validate( + _ component: DoryComponentRelease, + available: Set, + schemaVersion: Int, + catalogArchitecture: String + ) throws { guard validVersion(component.version), !component.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !component.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -367,6 +447,24 @@ public enum DoryComponentCatalogVerifier { component.dependencies.allSatisfy(available.contains) else { throw DoryComponentError.invalidCatalog("invalid metadata for \(component.id.rawValue)") } + if schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion { + guard component.architectures == nil, + component.hostRequirements == nil, + component.provides == nil, + component.requires == nil, + component.provenance == nil, + component.qualification == nil, + component.assets.allSatisfy({ $0.role == nil && $0.codeRequirement == nil }) else { + throw DoryComponentError.invalidCatalog( + "schema-v1 component contains schema-v2 authority" + ) + } + } else { + try validateVersionTwoMetadata( + component, + catalogArchitecture: catalogArchitecture + ) + } if component.id == .dockerCore { guard component.dependencies.isEmpty, component.downloadBytes > 0, @@ -402,6 +500,57 @@ public enum DoryComponentCatalogVerifier { } } + private static func validateVersionTwoMetadata( + _ component: DoryComponentRelease, + catalogArchitecture: String + ) throws { + guard component.architectures == [catalogArchitecture], + let host = component.hostRequirements, + host.platform == "macos", + validVersion(host.minimumVersion), + let provides = component.provides, + !provides.isEmpty, + Set(provides).count == provides.count, + provides.allSatisfy(validContractReference), + let requires = component.requires, + Set(requires).count == requires.count, + requires.allSatisfy(validContractReference), + let provenance = component.provenance, + validSourceCommit(provenance.sourceCommit), + validBoundedIdentifier(provenance.builder), + validDigest(provenance.recipeDigest), + validDigest(provenance.sbomDigest), + validDigest(provenance.attestationDigest), + let qualification = component.qualification, + Set(qualification).count == qualification.count, + qualification.allSatisfy(validBoundedIdentifier) else { + throw DoryComponentError.invalidCatalog( + "incomplete schema-v2 authority for \(component.id.rawValue)" + ) + } + for asset in component.assets { + guard let role = asset.role else { + throw DoryComponentError.invalidCatalog( + "schema-v2 asset role is missing for \(asset.path)" + ) + } + if asset.executable { + guard role == .hostHelper || role == .hostCLI, + let requirement = asset.codeRequirement, + validCodeRequirement(requirement) else { + throw DoryComponentError.invalidCatalog( + "executable authority is incomplete for \(asset.path)" + ) + } + } else if asset.codeRequirement != nil + || role == .hostHelper || role == .hostCLI { + throw DoryComponentError.invalidCatalog( + "non-executable asset has executable authority for \(asset.path)" + ) + } + } + } + private static func validateDependencyGraph(_ components: [DoryComponentRelease]) throws { let byID = Dictionary(uniqueKeysWithValues: components.map { ($0.id, $0) }) func visit(_ id: DoryComponentID, path: Set) throws { @@ -418,7 +567,43 @@ public enum DoryComponentCatalogVerifier { } private static func validDigest(_ value: String) -> Bool { - value.count == 64 && value.allSatisfy { $0.isHexDigit } + value.utf8.count == 64 && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + } + } + + private static func validSourceCommit(_ value: String) -> Bool { + (value.utf8.count == 40 || value.utf8.count == 64) && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + } + } + + private static func validBoundedIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) + || ($0 >= 65 && $0 <= 90) + || ($0 >= 97 && $0 <= 122) + || ".,_:+@/-".utf8.contains($0) + } + } + + private static func validContractReference(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) + || ($0 >= 65 && $0 <= 90) + || ($0 >= 97 && $0 <= 122) + || ".:_@/+<>=-".utf8.contains($0) + } + } + + private static func validCodeRequirement(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count <= 4_096 else { return false } + var requirement: SecRequirement? + return SecRequirementCreateWithString( + value as CFString, + SecCSFlags(), + &requirement + ) == errSecSuccess && requirement != nil } private static func validVersion(_ value: String) -> Bool { @@ -745,6 +930,7 @@ public struct DoryComponentStore: Sendable { ofItemAtPath: output ) try verifyFile(output, bytes: asset.installedBytes, digest: asset.installedSHA256) + try Self.verifyCodeRequirement(output, requirement: asset.codeRequirement) try Self.syncFile(output) } let record = DoryInstalledComponent( @@ -798,6 +984,10 @@ public struct DoryComponentStore: Sendable { bytes: asset.installedBytes, digest: asset.installedSHA256 ) + try Self.verifyCodeRequirement( + payload + "/" + asset.path, + requirement: asset.codeRequirement + ) } } @@ -1045,6 +1235,34 @@ public struct DoryComponentStore: Sendable { ) } + private static func verifyCodeRequirement( + _ path: String, + requirement requirementText: String? + ) throws { + guard let requirementText else { return } + var staticCode: SecStaticCode? + var requirement: SecRequirement? + guard SecStaticCodeCreateWithPath( + URL(fileURLWithPath: path) as CFURL, + SecCSFlags(), + &staticCode + ) == errSecSuccess, + let staticCode, + SecRequirementCreateWithString( + requirementText as CFString, + SecCSFlags(), + &requirement + ) == errSecSuccess, + let requirement, + SecStaticCodeCheckValidity( + staticCode, + SecCSFlags(rawValue: kSecCSCheckAllArchitectures), + requirement + ) == errSecSuccess else { + throw DoryComponentError.invalidAsset(path) + } + } + private func readRecord(_ type: T.Type, at path: String) throws -> T { guard let data = try? PrivateRecordFile.read(at: path, maximumBytes: 2 * 1_024 * 1_024), let value = try? JSONDecoder().decode(type, from: data) else { diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift index 9f597bc6..bd05154d 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift @@ -101,6 +101,130 @@ final class DoryComponentsTests: XCTestCase { } } + func testSchemaTwoBindsCapabilitiesHostArtifactsProvenanceAndQualification() throws { + let provenance = DoryComponentProvenance( + sourceCommit: String(repeating: "a", count: 40), + builder: "dory.release.apple-silicon", + recipeDigest: String(repeating: "b", count: 64), + sbomDigest: String(repeating: "c", count: 64), + attestationDigest: String(repeating: "d", count: 64) + ) + let host = DoryComponentHostRequirements(platform: "macos", minimumVersion: "15.0") + let core = DoryComponentRelease( + id: .dockerCore, + version: "0.5.0", + displayName: "Docker Core", + summary: "Signed Dory application", + dependencies: [], + downloadBytes: 100, + installedBytes: 200, + assets: [], + architectures: ["arm64"], + hostRequirements: host, + provides: ["app.dory-core@0.5.0", "backend.rawhv-linux@1"], + requires: [], + provenance: provenance, + qualification: [] + ) + let payload = Data("kernel".utf8) + let asset = DoryComponentAsset( + path: "dory-desktop-kernel-arm64.lzfse", + url: "https://example.invalid/kernel", + downloadBytes: UInt64(payload.count), + installedBytes: UInt64(payload.count), + sha256: digest(payload), + installedSHA256: digest(payload), + role: .guestKernel + ) + let desktop = DoryComponentRelease( + id: .linuxDesktop, + version: "0.5.0", + displayName: "Linux Desktop Runtime", + summary: "Qualified accelerated desktop runtime", + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset], + architectures: ["arm64"], + hostRequirements: host, + provides: ["device.virtio-gpu.venus@1"], + requires: ["app.dory-core>=0.5.0"], + provenance: provenance, + qualification: ["linux-desktop-arm64.mac16-1.25a1"] + ) + let catalog = DoryComponentCatalog( + releaseVersion: "0.5.0", + generatedAt: "2026-07-16T12:00:00Z", + minimumAppVersion: "0.5.0", + architecture: "arm64", + components: [core, desktop] + ) + + XCTAssertNoThrow(try DoryComponentCatalogVerifier.validate( + catalog, + expectedArchitecture: "arm64", + appVersion: "0.5.0" + )) + XCTAssertEqual( + try JSONDecoder().decode(DoryComponentCatalog.self, from: encoded(catalog)), + catalog + ) + + let incomplete = DoryComponentRelease( + id: .linuxDesktop, + version: "0.5.0", + displayName: "Linux Desktop Runtime", + summary: "Missing capability authority", + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset], + architectures: ["arm64"], + hostRequirements: host, + requires: ["app.dory-core>=0.5.0"], + provenance: provenance, + qualification: [] + ) + XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate( + DoryComponentCatalog( + releaseVersion: "0.5.0", + generatedAt: "2026-07-16T12:00:00Z", + minimumAppVersion: "0.5.0", + architecture: "arm64", + components: [core, incomplete] + ), + expectedArchitecture: "arm64", + appVersion: "0.5.0" + )) + } + + func testSchemaOneDecodesWithoutVersionTwoAuthorityAndCannotSmuggleIt() throws { + let legacy = catalog(components: [core()]) + let decoded = try JSONDecoder().decode(DoryComponentCatalog.self, from: encoded(legacy)) + XCTAssertNil(decoded.components[0].architectures) + XCTAssertNil(decoded.components[0].provenance) + XCTAssertNoThrow(try DoryComponentCatalogVerifier.validate( + decoded, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) + + let smuggled = DoryComponentRelease( + id: .dockerCore, + version: "0.4.0", + displayName: "Docker Core", + summary: "Docker, Compose, Buildx, networking, and storage", + dependencies: [], + downloadBytes: 100, + installedBytes: 200, + assets: [], + architectures: ["arm64"] + ) + XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate( + catalog(components: [smuggled]), + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) + } + func testCatalogRejectsDuplicatePathsCyclesAndDisagreeingSizes() throws { let payload = Data("kubectl".utf8) let asset = try plainAsset(path: "kubectl", data: payload) @@ -388,6 +512,45 @@ final class DoryComponentsTests: XCTestCase { XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o700) } + func testExecutableInstallRejectsMismatchedCodeRequirement() throws { + let fixture = try Fixture(name: "code-requirement") + defer { fixture.cleanup() } + let payload = try Data(contentsOf: URL(fileURLWithPath: "/usr/bin/true")) + let source = try fixture.write(payload, name: "signed-tool") + let asset = DoryComponentAsset( + path: "signed-tool", + url: source.absoluteString, + downloadBytes: UInt64(payload.count), + installedBytes: UInt64(payload.count), + sha256: digest(payload), + installedSHA256: digest(payload), + executable: true, + role: .hostCLI, + codeRequirement: #"identifier "dev.dory.not-the-installed-tool""# + ) + let component = DoryComponentRelease( + id: .kubernetes, + version: "1.0.0", + displayName: "Kubernetes", + summary: "Signed executable requirement regression", + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset] + ) + + XCTAssertThrowsError(try fixture.store.install( + component, + catalogDigest: String(repeating: "a", count: 64), + downloadedAssets: [asset.path: source.path] + )) { error in + guard case let .invalidAsset(path) = error as? DoryComponentError else { + return XCTFail("expected invalid executable asset, got \(error)") + } + XCTAssertTrue(path.hasSuffix("/signed-tool")) + } + XCTAssertNil(try fixture.store.installedComponent(.kubernetes)) + } + func testCachedCatalogIsReverifiedEveryTime() throws { let fixture = try Fixture(name: "catalog-cache") defer { fixture.cleanup() } @@ -481,6 +644,7 @@ final class DoryComponentsTests: XCTestCase { private func catalog(components: [DoryComponentRelease]) -> DoryComponentCatalog { DoryComponentCatalog( + schemaVersion: 1, releaseVersion: "0.4.0", generatedAt: "2026-07-16T12:00:00Z", minimumAppVersion: "0.4.0", diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift index 71bcf487..b072e60c 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift @@ -245,13 +245,25 @@ private final class QualificationFixture { records: [record] ) manifestData = try Self.encoded(manifest) + let isVersionTwo = catalogSchemaVersion == DoryComponentCatalog.schemaVersion + let provenance = isVersionTwo ? DoryComponentProvenance( + sourceCommit: String(repeating: "1", count: 40), + builder: "dory.qualification.fixture", + recipeDigest: String(repeating: "2", count: 64), + sbomDigest: String(repeating: "3", count: 64), + attestationDigest: Self.digest(manifestData) + ) : nil + let hostRequirements = isVersionTwo + ? DoryComponentHostRequirements(platform: "macos", minimumVersion: "14.0") + : nil let asset = DoryComponentAsset( path: manifestPath, url: "https://example.invalid/\(manifestPath)", downloadBytes: UInt64(manifestData.count), installedBytes: UInt64(manifestData.count), sha256: Self.digest(manifestData), - installedSHA256: Self.digest(manifestData) + installedSHA256: Self.digest(manifestData), + role: isVersionTwo ? .qualificationEvidence : nil ) let core = DoryComponentRelease( id: .dockerCore, @@ -261,7 +273,13 @@ private final class QualificationFixture { dependencies: [], downloadBytes: 1, installedBytes: 1, - assets: [] + assets: [], + architectures: isVersionTwo ? ["arm64"] : nil, + hostRequirements: hostRequirements, + provides: isVersionTwo ? ["app.dory-core@1.0.0"] : nil, + requires: isVersionTwo ? [] : nil, + provenance: provenance, + qualification: isVersionTwo ? [] : nil ) let machines = DoryComponentRelease( id: .linuxMachines, @@ -271,7 +289,13 @@ private final class QualificationFixture { dependencies: [.dockerCore], downloadBytes: UInt64(manifestData.count), installedBytes: UInt64(manifestData.count), - assets: [asset] + assets: [asset], + architectures: isVersionTwo ? ["arm64"] : nil, + hostRequirements: hostRequirements, + provides: isVersionTwo ? ["guest.linux-headless.arm64@1"] : nil, + requires: isVersionTwo ? ["app.dory-core>=1.0.0"] : nil, + provenance: provenance, + qualification: isVersionTwo ? [record.qualificationIdentity] : nil ) catalog = DoryComponentCatalog( schemaVersion: catalogSchemaVersion, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 280b1656..82e69206 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -1197,13 +1197,25 @@ private final class ProductionTrustFixture: @unchecked Sendable { records: records ) let manifestData = try Self.encoded(manifest) + let isVersionTwo = schemaVersion == DoryComponentCatalog.schemaVersion + let provenance = isVersionTwo ? DoryComponentProvenance( + sourceCommit: String(repeating: "1", count: 40), + builder: "dory.production-trust.fixture", + recipeDigest: String(repeating: "2", count: 64), + sbomDigest: String(repeating: "3", count: 64), + attestationDigest: Self.digest(manifestData) + ) : nil + let hostRequirements = isVersionTwo + ? DoryComponentHostRequirements(platform: "macos", minimumVersion: "14.0") + : nil let asset = DoryComponentAsset( path: manifestPath, url: "https://example.invalid/qualification.json", downloadBytes: UInt64(manifestData.count), installedBytes: UInt64(manifestData.count), sha256: Self.digest(manifestData), - installedSHA256: Self.digest(manifestData) + installedSHA256: Self.digest(manifestData), + role: isVersionTwo ? .qualificationEvidence : nil ) let release = DoryComponentRelease( id: .linuxMachines, @@ -1213,7 +1225,13 @@ private final class ProductionTrustFixture: @unchecked Sendable { dependencies: [.dockerCore], downloadBytes: UInt64(manifestData.count), installedBytes: UInt64(manifestData.count), - assets: [asset] + assets: [asset], + architectures: isVersionTwo ? ["arm64"] : nil, + hostRequirements: hostRequirements, + provides: isVersionTwo ? ["guest.linux-headless.arm64@1"] : nil, + requires: isVersionTwo ? ["app.dory-core>=\(appVersion)"] : nil, + provenance: provenance, + qualification: isVersionTwo ? records.map(\.qualificationIdentity) : nil ) let core = DoryComponentRelease( id: .dockerCore, @@ -1223,7 +1241,13 @@ private final class ProductionTrustFixture: @unchecked Sendable { dependencies: [], downloadBytes: 1, installedBytes: 1, - assets: [] + assets: [], + architectures: isVersionTwo ? ["arm64"] : nil, + hostRequirements: hostRequirements, + provides: isVersionTwo ? ["app.dory-core@\(releaseVersion)"] : nil, + requires: isVersionTwo ? [] : nil, + provenance: provenance, + qualification: isVersionTwo ? [] : nil ) let catalog = DoryComponentCatalog( schemaVersion: schemaVersion, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift index 41f7c9cf..9e6e857e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift @@ -198,6 +198,7 @@ struct DoryInstalledDesktopPayloadReceiptTests { assets: [] ) let catalog = DoryComponentCatalog( + schemaVersion: 1, releaseVersion: "0.4.0", generatedAt: "2026-08-20T00:00:00Z", minimumAppVersion: "0.4.5", diff --git a/scripts/build-components.py b/scripts/build-components.py index 1646f82e..935f2392 100755 --- a/scripts/build-components.py +++ b/scripts/build-components.py @@ -121,6 +121,49 @@ def validate_sources(repo: pathlib.Path, source_root: pathlib.Path, kubectl: pat fail(f"kubectl does not contain arm64 code: {' '.join(archs)}") +def designated_code_requirement(path: pathlib.Path) -> str: + completed = subprocess.run( + ["codesign", "-d", "-r-", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + fail(f"could not read the designated requirement for {path}") + for line in (completed.stdout + "\n" + completed.stderr).splitlines(): + if "designated =>" in line: + requirement = line.split("designated =>", 1)[1].strip() + if requirement and len(requirement.encode("utf-8")) <= 4096: + return requirement + fail(f"codesign returned no designated requirement for {path}") + + +def source_commit(repo: pathlib.Path, explicit: str | None) -> str: + value = explicit or run(["git", "rev-parse", "HEAD"], cwd=repo) + value = value.strip().lower() + if len(value) not in {40, 64} or any(character not in "0123456789abcdef" for character in value): + fail("source commit must be an exact lowercase Git digest") + return value + + +def recipe_digest(repo: pathlib.Path) -> str: + inputs = [ + repo / "scripts/build-components.py", + repo / "guest/kernel/build.sh", + repo / "guest/initfs/build.sh", + repo / "guest/desktop/build.sh", + ] + digest = hashlib.sha256() + for path in inputs: + file = regular_file(path, "component build recipe") + relative = str(file.relative_to(repo)).encode("utf-8") + digest.update(len(relative).to_bytes(4, "big")) + digest.update(relative) + digest.update(file.read_bytes()) + return digest.hexdigest() + + def generated_at(value: str | None) -> str: if value: parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -420,6 +463,7 @@ def component_specs( source_root: pathlib.Path, kubectl: pathlib.Path, qualification_manifest: pathlib.Path, + minimum_app_version: str, ) -> list[dict]: desktop_specs = [] for distro, display, summary in ( @@ -445,30 +489,39 @@ def component_specs( "displayName": display, "summary": summary, "dependencies": ["docker-core", "linux-desktop"], + "provides": [f"guest.linux-desktop.{distro}.arm64@1"], + "requires": [ + f"app.dory-core>={minimum_app_version}", + "guest.linux-desktop-runtime.arm64@1", + ], "assets": [ { "path": f"dory-desktop-{distro}-rootfs-arm64.ext4.lzfse", "source": source_root / f"dory-desktop-{distro}-rootfs-arm64.ext4", "delivery": "lzfse-stored", "executable": False, + "role": "guest-disk", }, { "path": f"dory-desktop-{distro}-build-arm64.stamp", "source": source_root / f"dory-desktop-{distro}-build-arm64.stamp", "delivery": "none", "executable": False, + "role": "build-metadata", }, { "path": f"dory-desktop-{distro}-packages-arm64.txt", "source": source_root / f"dory-desktop-{distro}-packages-arm64.txt", "delivery": "none", "executable": False, + "role": "guest-package-manifest", }, { "path": f"dory-desktop-{distro}-update-arm64.tar", "source": source_root / f"dory-desktop-{distro}-update-arm64.tar", "delivery": "none", "executable": False, + "role": "guest-update", }, ], } @@ -479,12 +532,15 @@ def component_specs( "displayName": "Kubernetes", "summary": "kubectl and Dory's local k3s workflow. The selected k3s image downloads when you create the cluster.", "dependencies": ["docker-core"], + "provides": ["cli.kubectl@1"], + "requires": [f"app.dory-core>={minimum_app_version}"], "assets": [ { "path": "kubectl", "source": kubectl, "delivery": "none", "executable": True, + "role": "host-cli", } ], }, @@ -493,18 +549,22 @@ def component_specs( "displayName": "Linux Machines", "summary": "Headless VPS-style Linux machines with terminals, services, and persistent disks.", "dependencies": ["docker-core"], + "provides": ["guest.linux-headless.arm64@1"], + "requires": [f"app.dory-core>={minimum_app_version}"], "assets": [ { "path": "dory-hv-kernel-arm64", "source": source_root / "Image", "delivery": "lzfse-expanded", "executable": False, + "role": "guest-kernel", }, { "path": "dory-machine-rootfs-arm64.ext4", "source": source_root / "initfs-arm64.ext4", "delivery": "lzfse-expanded", "executable": False, + "role": "guest-disk", }, ], }, @@ -513,24 +573,33 @@ def component_specs( "displayName": "Linux Desktop Runtime", "summary": "The graphical VM kernel shared by independently installable desktop distributions.", "dependencies": ["docker-core"], + "provides": [ + "guest.linux-desktop-runtime.arm64@1", + "device.virtio-gpu.virgl2@1", + "device.virtio-gpu.venus@1", + ], + "requires": [f"app.dory-core>={minimum_app_version}"], "assets": [ { "path": "dory-desktop-kernel-arm64.lzfse", "source": source_root / "Image-desktop", "delivery": "lzfse-stored", "executable": False, + "role": "guest-kernel", }, { "path": "kernel-build-arm64-desktop.stamp", "source": source_root / "kernel-build-arm64-desktop.stamp", "delivery": "none", "executable": False, + "role": "build-metadata", }, { "path": QUALIFICATION_PATH, "source": qualification_manifest, "delivery": "none", "executable": False, + "role": "qualification-evidence", }, ], }, @@ -555,6 +624,7 @@ def materialize_asset( output: pathlib.Path, asset_base_url: str, compression_tool: pathlib.Path, + allow_test_code_requirement: bool, ) -> dict: source = regular_file(pathlib.Path(asset["source"]), f"{component_id} source") delivery = asset["delivery"] @@ -588,8 +658,9 @@ def materialize_asset( compression = "none" installed_bytes = download_bytes installed_digest = download_digest - return { + result = { "path": asset["path"], + "role": asset["role"], "url": f"{asset_base_url.rstrip('/')}/{artifact_name}", "compression": compression, "downloadBytes": download_bytes, @@ -598,6 +669,13 @@ def materialize_asset( "installedSHA256": installed_digest, "executable": bool(asset["executable"]), } + if asset["executable"]: + result["codeRequirement"] = ( + f'identifier "dev.dory.test.{installed_digest[:16]}"' + if allow_test_code_requirement + else designated_code_requirement(source) + ) + return result def sign_catalog(catalog_path: pathlib.Path, signer: pathlib.Path) -> str: @@ -662,6 +740,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--generated-at") parser.add_argument("--signer", type=pathlib.Path) parser.add_argument("--qualification-manifest", required=True, type=pathlib.Path) + parser.add_argument("--sbom", required=True, type=pathlib.Path) + parser.add_argument("--source-commit") + parser.add_argument("--builder-identity", default="dory.build-components.v2") parser.add_argument("--catalog-public-key", default=DEFAULT_CATALOG_PUBLIC_KEY) parser.add_argument("--skip-source-verification", action="store_true") return parser.parse_args() @@ -686,6 +767,15 @@ def main() -> None: source_root=source_root, core_app=core_app, ) + sbom = regular_file(args.sbom.resolve(), "component SBOM") + minimum_app_version = args.minimum_app_version or args.version + provenance = { + "sourceCommit": source_commit(repo, args.source_commit), + "builder": nonempty_string(args.builder_identity, "builder identity"), + "recipeDigest": recipe_digest(repo), + "sbomDigest": sha256(sbom), + "attestationDigest": sha256(args.qualification_manifest.resolve()), + } if not args.skip_source_verification: validate_sources(repo, source_root, kubectl) @@ -708,9 +798,27 @@ def main() -> None: "downloadBytes": byte_size(core_artifact), "installedBytes": tree_size(core_app), "assets": [], + "architectures": [ARCHITECTURE], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": [ + f"app.dory-core@{args.version}", + "backend.rawhv-linux@1", + "backend.vz-linux@1", + ], + "requires": [], + "provenance": provenance, + "qualification": [], } ] - for spec in component_specs(source_root, kubectl, args.qualification_manifest.resolve()): + qualification_ids = [ + record["qualificationIdentity"] for record in qualification_manifest["records"] + ] + for spec in component_specs( + source_root, + kubectl, + args.qualification_manifest.resolve(), + minimum_app_version, + ): assets = [ materialize_asset( version=args.version, @@ -719,6 +827,7 @@ def main() -> None: output=staging, asset_base_url=asset_base_url, compression_tool=compression_tool, + allow_test_code_requirement=args.skip_source_verification, ) for asset in spec["assets"] ] @@ -732,6 +841,12 @@ def main() -> None: "downloadBytes": sum(asset["downloadBytes"] for asset in assets), "installedBytes": sum(asset["installedBytes"] for asset in assets), "assets": assets, + "architectures": [ARCHITECTURE], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": spec["provides"], + "requires": spec["requires"], + "provenance": provenance, + "qualification": qualification_ids if spec["id"] == "linux-desktop" else [], } ) @@ -740,7 +855,7 @@ def main() -> None: "schemaVersion": CATALOG_SCHEMA, "releaseVersion": args.version, "generatedAt": generated_at(args.generated_at), - "minimumAppVersion": args.minimum_app_version or args.version, + "minimumAppVersion": minimum_app_version, "architecture": ARCHITECTURE, "components": releases, "virtualMachineQualification": { diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh index e49f08fd..5de07130 100755 --- a/scripts/test-build-components.sh +++ b/scripts/test-build-components.sh @@ -9,6 +9,7 @@ SOURCE="$TMP/source" CORE_APP="$TMP/Dory.app" OUTPUT="$TMP/components/arm64" QUALIFICATION="$TMP/virtual-machine-qualification.json" +SBOM="$TMP/Dory-test.cdx.json" mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" "$CORE_APP/Contents/Helpers" write_fixture() { @@ -24,6 +25,7 @@ write_fixture "$CORE_APP/Contents/Helpers/dory-vmm" 4096 chmod 0755 "$CORE_APP/Contents/Helpers/dory-hv" "$CORE_APP/Contents/Helpers/dory-vmm" write_fixture "$TMP/kubectl" 16384 chmod 0755 "$TMP/kubectl" +printf '{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}\n' > "$SBOM" write_fixture "$SOURCE/Image" 131072 write_fixture "$SOURCE/initfs-arm64.ext4" 262144 write_fixture "$SOURCE/Image-desktop" 196608 @@ -113,6 +115,7 @@ build() { --asset-base-url https://example.invalid/dory \ --generated-at 2026-07-16T00:00:00Z \ --qualification-manifest "$QUALIFICATION" \ + --sbom "$SBOM" \ --skip-source-verification } @@ -147,6 +150,21 @@ assert [item["id"] for item in catalog["components"]] == [ assert catalog["components"][0]["assets"] == [] assert catalog["components"][0]["downloadBytes"] == 4096 assert catalog["components"][0]["installedBytes"] == 16384 +for component in catalog["components"]: + assert component["architectures"] == ["arm64"] + assert component["hostRequirements"] == { + "platform": "macos", "minimumVersion": "14.0", + } + assert component["provides"] + assert isinstance(component["requires"], list) + assert component["provenance"]["sourceCommit"] + assert component["provenance"]["builder"] == "dory.build-components.v2" + assert len(component["provenance"]["recipeDigest"]) == 64 + assert component["provenance"]["sbomDigest"] == hashlib.sha256( + pathlib.Path(sys.argv[3]).with_name("Dory-test.cdx.json").read_bytes() + ).hexdigest() + assert len(component["provenance"]["attestationDigest"]) == 64 + assert isinstance(component["qualification"], list) qualification = catalog["virtualMachineQualification"] assert qualification["component"] == "linux-desktop" assert qualification["path"] == "virtual-machine-qualification.json" @@ -158,6 +176,7 @@ qualification_assets = [ if item["path"] == "virtual-machine-qualification.json" ] assert len(qualification_assets) == 1 +assert qualification_assets[0]["role"] == "qualification-evidence" assert qualification_assets[0]["installedSHA256"] == hashlib.sha256( qualification_source.read_bytes() ).hexdigest() @@ -170,6 +189,14 @@ for component in catalog["components"][1:]: item["installedBytes"] for item in component["assets"] ) for asset in component["assets"]: + assert asset["role"] in { + "host-cli", "guest-kernel", "guest-disk", "guest-update", + "guest-package-manifest", "qualification-evidence", "build-metadata", + } + if asset["executable"]: + assert asset["codeRequirement"].startswith('identifier "dev.dory.test.') + else: + assert "codeRequirement" not in asset artifact = root / asset["url"].rsplit("/", 1)[-1] assert artifact.is_file() assert artifact.stat().st_size == asset["downloadBytes"] @@ -236,6 +263,7 @@ if "$ROOT/scripts/build-components.py" \ --source-root "$SOURCE" \ --output "$TMP/rejected-components" \ --qualification-manifest "$TMP/invalid-qualification.json" \ + --sbom "$SBOM" \ --skip-source-verification 2>/dev/null; then echo "component packaging accepted a qualification manifest for another release" >&2 exit 1 @@ -259,6 +287,7 @@ if "$ROOT/scripts/build-components.py" \ --source-root "$SOURCE" \ --output "$TMP/mismatched-helper-components" \ --qualification-manifest "$TMP/mismatched-helper-qualification.json" \ + --sbom "$SBOM" \ --skip-source-verification 2>/dev/null; then echo "component packaging accepted qualification evidence for different helper bytes" >&2 exit 1 @@ -282,6 +311,7 @@ if "$ROOT/scripts/build-components.py" \ --source-root "$SOURCE" \ --output "$TMP/mismatched-media-components" \ --qualification-manifest "$TMP/mismatched-media-qualification.json" \ + --sbom "$SBOM" \ --skip-source-verification 2>/dev/null; then echo "component packaging accepted qualification evidence for different bundled media" >&2 exit 1 @@ -294,6 +324,7 @@ if "$ROOT/scripts/build-components.py" \ --kubectl "$TMP/kubectl" \ --source-root "$SOURCE" \ --output "$TMP/missing-qualification-components" \ + --sbom "$SBOM" \ --skip-source-verification 2>/dev/null; then echo "component packaging accepted a catalog without qualification evidence" >&2 exit 1 From 14b17b4d0b0a5ed54606ec9eee9170b99784e46e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 22:26:46 +0000 Subject: [PATCH 050/338] fix(security): hide machine environment from status --- Dory/Models/AppStore.swift | 6 +- DoryTests/DorydClientTests.swift | 4 +- .../DoryMachineTypedWriteAuthority.swift | 68 ++++++++++++++++ .../Sources/DorydKit/DorydService.swift | 6 -- .../Sources/DorydKit/MachineManager.swift | 12 ++- .../DoryMachineTypedWriteAuthorityTests.swift | 37 +++++++++ .../DorydKitTests/DorydServiceTests.swift | 77 ++++++++++--------- 7 files changed, 162 insertions(+), 48 deletions(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index be0055fa..d5d1a39e 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5078,7 +5078,7 @@ final class AppStore { cpus: status.cpuCount, memoryMB: status.memoryMB.flatMap { Int(exactly: $0) }, mounts: status.shares.map(Self.mountPair(fromDoryd:)), - env: status.environment, + env: [:], virtualMachineSettings: status.typedSettings ?? DorydMachineTypedSettings( legacyEnvironment: status.environment, @@ -5162,7 +5162,7 @@ final class AppStore { .first { !$0.isEmpty } ?? status.state let isDesktop = status.displayMode == .desktop let isCustomLinux = status.bootMode == .efi - let typedSettings = DorydMachineTypedSettings( + let typedSettings = status.typedSettings ?? DorydMachineTypedSettings( legacyEnvironment: status.environment, displayMode: status.displayMode ) @@ -5467,7 +5467,7 @@ final class AppStore { // EFI machines are user-provided installer workspaces. Boot mode is authoritative; // do not depend on the legacy DORY_CUSTOM_LINUX compatibility marker. guard status.bootMode != .efi else { continue } - let typedSettings = DorydMachineTypedSettings( + let typedSettings = status.typedSettings ?? DorydMachineTypedSettings( legacyEnvironment: status.environment, displayMode: status.displayMode ) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index fc557fea..ec1bb4db 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -955,7 +955,7 @@ struct DorydClientTests { #expect(currentSettings.address == "192.168.215.40") #expect(currentSettings.displayMode == .desktop) #expect(currentSettings.mounts == [MountPair(host: "/Users/me/src", guest: "/workspace/src", readOnly: true)]) - #expect(currentSettings.env == ["ANTHROPIC_API_KEY": "test-token"]) + #expect(currentSettings.env.isEmpty) store.toggleMachine(machine) try await waitUntil { @@ -2440,7 +2440,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return } current["typedSettings"] = typedSettings - current["env"] = [] as [NSDictionary] + current.removeObject(forKey: "env") current["displayMode"] = "desktop" machines[machineID] = current.copy() as? NSDictionary } diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index a0e0f5dc..34f6e6a6 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -75,6 +75,74 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha } } + /// Projects only the bounded, non-secret compatibility fields understood by the typed + /// machine-settings contract. Opaque legacy environment entries remain daemon-private and + /// are never copied into status/XPC diagnostics. + public init( + legacyEnvironment: [String: String], + displayMode: DoryMachineDisplayMode + ) { + let username = legacyEnvironment[ + DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey + ].flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil } + let numericUserID = legacyEnvironment[ + DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey + ].flatMap(UInt32.init).flatMap { + DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil + } + let account = DoryVMGuestAccountIntent( + username: username, + numericUserID: numericUserID + ) + let desktop: DoryVMDesktopIdentityIntent? + if displayMode == .desktop { + func safeLabel(_ key: String) -> String? { + legacyEnvironment[key].flatMap { + DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil + } + } + let distributionIdentifier = legacyEnvironment[ + DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey + ].flatMap { + DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil + } + let candidate = DoryVMDesktopIdentityIntent( + distributionIdentifier: distributionIdentifier, + displayName: safeLabel( + DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey + ), + version: safeLabel( + DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey + ), + desktopEnvironment: safeLabel( + DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey + ) + ) + desktop = candidate.isValidForPersistence ? candidate : nil + } else { + desktop = nil + } + guestIdentityIntent = DoryVMGuestIdentityIntent( + account: account.isValidForPersistence ? account : nil, + desktop: desktop + ) + if displayMode == .desktop { + let clipboard = DoryDesktopClipboardPolicy(environment: legacyEnvironment) + clipboardPolicy = DoryVMClipboardDirection(rawValue: clipboard.rawValue) + .map(DoryVMClipboardPolicy.legacyDesktop) + runtimePreference = (try? DoryDesktopVMMPreference( + environment: legacyEnvironment + )) ?? .automatic + graphicsPreference = (try? DoryDesktopGraphicsPreference( + environment: legacyEnvironment + )) ?? .automatic + } else { + clipboardPolicy = nil + runtimePreference = nil + graphicsPreference = nil + } + } + public var xpcDictionary: NSDictionary { DoryMachineTypedSettingsPatch( guestUsername: update(guestIdentityIntent.account?.username), diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 25ed532b..01bf2c25 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1616,12 +1616,6 @@ private extension DoryMachineStatus { dictionary["runtimeAddress"] = runtimeAddress } dictionary["shares"] = shares.map(\.xpcDictionary) - dictionary["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in - [ - "key": key, - "value": value, - ] as NSDictionary - } dictionary["handoffFDCount"] = handoffFDCount dictionary["memoryMB"] = memoryMB dictionary["currentBalloonTargetMB"] = currentBalloonTargetMB diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7c0df1c6..ac5e0f1a 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1085,6 +1085,12 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentity: initialRuntimeIdentity ) lock.unlock() + let typedSettingsSnapshot = try nativeDefinition.map( + DoryMachineTypedSettingsSnapshot.init + ) ?? DoryMachineTypedSettingsSnapshot( + legacyEnvironment: preparedMachine.environment, + displayMode: preparedMachine.displayMode + ) return DoryMachineStatus( id: preparedMachine.id, state: .created, @@ -1097,7 +1103,7 @@ public final class MachineManager: @unchecked Sendable { installerMediaAttached: preparedMachine.installerISOPath != nil, shares: preparedMachine.shares, environment: preparedMachine.environment, - typedSettings: try nativeDefinition.map(DoryMachineTypedSettingsSnapshot.init), + typedSettings: typedSettingsSnapshot, runtimeIdentity: initialRuntimeIdentity ) } @@ -3695,6 +3701,10 @@ public final class MachineManager: @unchecked Sendable { private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { let typedSettings = nativeTypedSettingsSnapshot(id: id) + ?? DoryMachineTypedSettingsSnapshot( + legacyEnvironment: entry.configuration.environment, + displayMode: entry.configuration.displayMode + ) if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( id: id, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 01296261..a6fcd1ad 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -70,6 +70,43 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(runtime["DORY_DESKTOP_GRAPHICS"] == "software") } + @Test("legacy status projection exposes only bounded typed settings") + func safeLegacyStatusProjection() { + let snapshot = DoryMachineTypedSettingsSnapshot( + legacyEnvironment: [ + "DORY_GUEST_USER": "developer", + "DORY_GUEST_UID": "1000", + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_NAME": "Ubuntu", + "DORY_CLIPBOARD_POLICY": "host-to-guest", + "DORY_DESKTOP_VMM": "accelerated", + "DORY_DESKTOP_GRAPHICS": "virgl-venus", + "PRIVATE_TOKEN": "must-never-cross-xpc", + ], + displayMode: .desktop + ) + + #expect(snapshot.guestIdentityIntent.account?.username == "developer") + #expect(snapshot.guestIdentityIntent.account?.numericUserID == 1_000) + #expect(snapshot.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu") + #expect(snapshot.guestIdentityIntent.desktop?.displayName == "Ubuntu") + #expect(snapshot.clipboardPolicy?.text == .hostToGuest) + #expect(snapshot.runtimePreference == .accelerated) + #expect(snapshot.graphicsPreference == .virglVenus) + #expect(snapshot.xpcDictionary.description.contains("must-never-cross-xpc") == false) + + let invalid = DoryMachineTypedSettingsSnapshot( + legacyEnvironment: [ + "DORY_GUEST_USER": "../../unsafe", + "DORY_GUEST_UID": "not-a-uid", + "PRIVATE_TOKEN": "opaque", + ], + displayMode: .desktop + ) + #expect(invalid.guestIdentityIntent.account == nil) + #expect(invalid.xpcDictionary.description.contains("opaque") == false) + } + @Test("update clear is explicit and field scoped") func explicitClear() throws { let source = DoryMachineTypedSettingsPatch( diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index a5422905..15cc171a 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -5,16 +5,6 @@ import DoryOperations import XCTest final class DorydServiceTests: XCTestCase { - private static func environmentValues(_ body: NSDictionary) -> [String: String] { - let rows = body["env"] as? [NSDictionary] ?? [] - return Dictionary(uniqueKeysWithValues: rows.compactMap { row in - guard let key = row["key"] as? String, let value = row["value"] as? String else { - return nil - } - return (key, value) - }) - } - func testPublishedPortRepairDetailUsesValidatedGvproxyReceiptCounts() { let startedAt = Date() let receipt = PublishedPortReconcileReceipt( @@ -1103,10 +1093,13 @@ final class DorydServiceTests: XCTestCase { XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "created") XCTAssertEqual(body["address"] as? String, "192.168.215.40") - let values = Self.environmentValues(body) - XCTAssertEqual(values["DORY_GUEST_USER"], "developer") - XCTAssertEqual(values["DORY_GUEST_UID"], "1000") - XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off") + XCTAssertNil(body["env"]) + let typed = body["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + XCTAssertEqual(account?["username"] as? String, "developer") + XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000) + XCTAssertNil(typed?["clipboardPolicy"]) create.fulfill() } wait(for: [create], timeout: 5) @@ -1130,8 +1123,11 @@ final class DorydServiceTests: XCTestCase { let statuses = body as? [NSDictionary] XCTAssertEqual(statuses?.first?["id"] as? String, "dev") XCTAssertEqual(statuses?.first?["address"] as? String, "192.168.215.40") - let env = statuses?.first?["env"] as? [NSDictionary] - XCTAssertTrue(env?.contains { $0["value"] as? String == "developer" } == true) + XCTAssertNil(statuses?.first?["env"]) + let typed = statuses?.first?["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + XCTAssertEqual(account?["username"] as? String, "developer") list.fulfill() } wait(for: [list], timeout: 5) @@ -1172,10 +1168,12 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(shares?.first?["hostPath"] as? String, share) XCTAssertEqual(shares?.first?["guestPath"] as? String, "/workspace/src") XCTAssertEqual(shares?.first?["readOnly"] as? Bool, true) - let values = Self.environmentValues(body) - XCTAssertEqual(values["DORY_GUEST_USER"], "builder") - XCTAssertEqual(values["DORY_GUEST_UID"], "1000") - XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off") + XCTAssertNil(body["env"]) + let typed = body["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + XCTAssertEqual(account?["username"] as? String, "builder") + XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000) update.fulfill() } wait(for: [update], timeout: 5) @@ -1395,14 +1393,17 @@ final class DorydServiceTests: XCTestCase { "desktopGraphicsPreference": "virgl-venus", ]) { ok, body, message in XCTAssertTrue(ok, message) - let values = Self.environmentValues(body) - XCTAssertEqual(values["DORY_GUEST_USER"], "developer") - XCTAssertEqual(values["DORY_GUEST_UID"], "1000") - XCTAssertEqual(values["DORY_DESKTOP_DISTRO"], "ubuntu") - XCTAssertEqual(values["DORY_DESKTOP_NAME"], "Ubuntu") - XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "bidirectional") - XCTAssertEqual(values["DORY_DESKTOP_VMM"], "accelerated") - XCTAssertEqual(values["DORY_DESKTOP_GRAPHICS"], "virgl-venus") + XCTAssertNil(body["env"]) + let typed = body["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + let desktop = identity?["desktop"] as? NSDictionary + XCTAssertEqual(account?["username"] as? String, "developer") + XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000) + XCTAssertEqual(desktop?["distributionIdentifier"] as? String, "ubuntu") + XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu") + XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "accelerated") + XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "virgl-venus") typedCreate.fulfill() } wait(for: [typedCreate], timeout: 5) @@ -1418,13 +1419,17 @@ final class DorydServiceTests: XCTestCase { "desktopGraphicsPreference": "software", ]) { ok, body, message in XCTAssertTrue(ok, message) - let values = Self.environmentValues(body) - XCTAssertEqual(values["DORY_DESKTOP_NAME"], "Ubuntu Legacy") - XCTAssertEqual(values["DORY_GUEST_USER"], "../../unsafe-old-user") - XCTAssertEqual(values["DORY_GUEST_UID"], "not-a-uid") - XCTAssertEqual(values["PRIVATE_TOKEN"], "opaque-legacy-value") - XCTAssertEqual(values["DORY_DESKTOP_VMM"], "compatible") - XCTAssertEqual(values["DORY_DESKTOP_GRAPHICS"], "software") + XCTAssertNil(body["env"]) + XCTAssertFalse(body.description.contains("opaque-legacy-value")) + let typed = body["typedSettings"] as? NSDictionary + let identity = typed?["guestIdentityIntent"] as? NSDictionary + let account = identity?["account"] as? NSDictionary + let desktop = identity?["desktop"] as? NSDictionary + XCTAssertNil(account?["username"]) + XCTAssertNil(account?["numericUserID"]) + XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu Legacy") + XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "compatible") + XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "software") typedUpdate.fulfill() } wait(for: [typedUpdate], timeout: 5) @@ -1503,7 +1508,7 @@ final class DorydServiceTests: XCTestCase { service.machineList { rows, message in XCTAssertEqual(message, "") let row = (rows as? [NSDictionary])?.first { $0["id"] as? String == "planned" } - XCTAssertEqual((row?["env"] as? [NSDictionary])?.count, 0) + XCTAssertNil(row?["env"]) let typed = row?["typedSettings"] as? NSDictionary let identity = typed?["guestIdentityIntent"] as? NSDictionary let account = identity?["account"] as? NSDictionary From 2f95f4e45de53cbb322c5fb1d35cbbd807ba1abc Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 22:35:49 +0000 Subject: [PATCH 051/338] feat(vm): add durable pause and resume lifecycle --- .../LinuxMachineBackendAdapters.swift | 25 +- .../Sources/DorydKit/MachineBackend.swift | 7 +- .../Sources/DorydKit/MachineManager.swift | 314 +++++++++++++++++- ...ePlanningTransactionCoordinatorTests.swift | 4 +- ...neProductionPlanningCompositionTests.swift | 4 +- ...onVirtualMachineRuntimePlanningTests.swift | 4 +- .../DorydKitTests/MachineBackendTests.swift | 25 +- ...agerLifecycleJournalIntegrationTests.swift | 26 ++ ...eManagerResolvedPlanIntegrationTests.swift | 4 +- .../DorydKitTests/MachineManagerTests.swift | 43 +++ 10 files changed, 429 insertions(+), 27 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index f1abeb52..b0bdd18e 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -12,19 +12,27 @@ public struct MachineBackendCompatibilityOperations: Sendable { public var start: MachineBackendLifecycleHandler public var authorizedStart: MachineBackendAuthorizedStartHandler public var stop: MachineBackendLifecycleHandler + public var pause: MachineBackendLifecycleHandler + public var resume: MachineBackendLifecycleHandler public init( start: @escaping MachineBackendLifecycleHandler, - stop: @escaping MachineBackendLifecycleHandler + stop: @escaping MachineBackendLifecycleHandler, + pause: @escaping MachineBackendLifecycleHandler, + resume: @escaping MachineBackendLifecycleHandler ) { self.start = start authorizedStart = { binding in try start(binding.machineID) } self.stop = stop + self.pause = pause + self.resume = resume } public init( authorizedStart: @escaping MachineBackendAuthorizedStartHandler, - stop: @escaping MachineBackendLifecycleHandler + stop: @escaping MachineBackendLifecycleHandler, + pause: @escaping MachineBackendLifecycleHandler, + resume: @escaping MachineBackendLifecycleHandler ) { start = { _ in throw MachineBackendFailure( @@ -34,6 +42,8 @@ public struct MachineBackendCompatibilityOperations: Sendable { } self.authorizedStart = authorizedStart self.stop = stop + self.pause = pause + self.resume = resume } } @@ -55,6 +65,7 @@ private extension MachineBackendRuntimeState { case .created: self = .created case .starting: self = .starting case .running: self = .running + case .paused: self = .paused case .stopped: self = .stopped case .failed: self = .failed } @@ -241,7 +252,10 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { message: "The runtime request belongs to a different backend adapter." ) } - return unsupportedOperation(.pause) + guard descriptor.lifecycle.pause else { + return unsupportedOperation(.pause) + } + return perform(.pause, machineID: request.machineID, operation: operations.pause) } func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { @@ -252,7 +266,10 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { message: "The runtime request belongs to a different backend adapter." ) } - return unsupportedOperation(.resume) + guard descriptor.lifecycle.resume else { + return unsupportedOperation(.resume) + } + return perform(.resume, machineID: request.machineID, operation: operations.resume) } private func unavailableProbe( diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index 603365a3..b137da6b 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -16,12 +16,13 @@ public struct MachineBackendLifecycleCapabilities: Codable, Sendable, Equatable, self.resume = resume } - /// `MachineManager` currently exposes start and stop, but not pause and resume. + /// Lifecycle operations implemented by the current supervised helper boundary. Pause keeps + /// the helper and its admitted resources resident; resume continues that exact process. public static let currentMachineManager = MachineBackendLifecycleCapabilities( start: true, stop: true, - pause: false, - resume: false + pause: true, + resume: true ) } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index ac5e0f1a..9d757a9e 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -298,6 +298,7 @@ public enum DoryMachineState: String, Sendable, Equatable { case created case starting case running + case paused case stopped case failed } @@ -932,6 +933,28 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("machine manager is unavailable") } return MachineBackendRuntimeObservation(try self.stop(id: id)) + }, + pause: { [weak self] id in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + return MachineBackendRuntimeObservation( + try self.performAuthorizedResolvedBackendPause( + id: id, + expectedBackend: backend + ) + ) + }, + resume: { [weak self] id in + guard let self else { + throw MachineManagerError.persistence("machine manager is unavailable") + } + return MachineBackendRuntimeObservation( + try self.performAuthorizedResolvedBackendResume( + id: id, + expectedBackend: backend + ) + ) } ) } @@ -2302,7 +2325,7 @@ public final class MachineManager: @unchecked Sendable { lock.lock() guard var entry = machines[machineID], entry.launchID == launchID, - [.starting, .running].contains(entry.state), + [.starting, .running, .paused].contains(entry.state), entry.process?.isRunningOrRestarting != true else { lock.unlock() return @@ -2337,6 +2360,200 @@ public final class MachineManager: @unchecked Sendable { return try stopImplementation(id: id, journalLifecycle: true) } + public func pause(id: String) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + let identity = try currentRuntimeIdentity(id: id) + switch identity.mode { + case .legacyCompatibility: + return try pauseImplementation(id: id, journalLifecycle: true) + case .requiresReplanning: + throw MachineManagerError.persistence( + "machine \(id) requires replanning and cannot be paused" + ) + case .resolvedPlan: + guard let backend = identity.resolvedPlan?.backend, + let registry = resolvedLaunchRegistry else { + throw MachineManagerError.persistence( + "resolved pause infrastructure is not installed" + ) + } + let result = registry.pause(.init(machineID: id, backend: backend)) + guard result.isSuccess, result.observation?.machineID == id, + result.observation?.state == .paused else { + throw MachineManagerError.persistence( + "resolved backend pause failed: " + + (result.failure?.message ?? "backend returned no paused observation") + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .paused) + } + } + + public func resume(id: String) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + let identity = try currentRuntimeIdentity(id: id) + switch identity.mode { + case .legacyCompatibility: + return try resumeImplementation(id: id, journalLifecycle: true) + case .requiresReplanning: + throw MachineManagerError.persistence( + "machine \(id) requires replanning and cannot be resumed" + ) + case .resolvedPlan: + guard let backend = identity.resolvedPlan?.backend, + let registry = resolvedLaunchRegistry else { + throw MachineManagerError.persistence( + "resolved resume infrastructure is not installed" + ) + } + let result = registry.resume(.init(machineID: id, backend: backend)) + guard result.isSuccess, result.observation?.machineID == id, + result.observation?.state == .running else { + throw MachineManagerError.persistence( + "resolved backend resume failed: " + + (result.failure?.message ?? "backend returned no running observation") + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .running) + } + } + + private func performAuthorizedResolvedBackendPause( + id: String, + expectedBackend: DoryVirtualizationBackendIdentity + ) throws -> DoryMachineStatus { + let identity = try currentRuntimeIdentity(id: id) + guard identity.mode == .resolvedPlan, + identity.resolvedPlan?.backend == expectedBackend else { + throw MachineManagerError.persistence( + "resolved pause adapter does not match durable runtime authority" + ) + } + return try pauseImplementation(id: id, journalLifecycle: true) + } + + private func performAuthorizedResolvedBackendResume( + id: String, + expectedBackend: DoryVirtualizationBackendIdentity + ) throws -> DoryMachineStatus { + let identity = try currentRuntimeIdentity(id: id) + guard identity.mode == .resolvedPlan, + identity.resolvedPlan?.backend == expectedBackend else { + throw MachineManagerError.persistence( + "resolved resume adapter does not match durable runtime authority" + ) + } + return try resumeImplementation(id: id, journalLifecycle: true) + } + + private func pauseImplementation( + id: String, + journalLifecycle: Bool + ) throws -> DoryMachineStatus { + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + guard entry.state == .running, let process = entry.process else { + lock.unlock() + throw MachineManagerError.persistence("machine \(id) is not running") + } + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + lock.unlock() + let lifecycle = try journalLifecycle + ? beginLifecyclePause(machine: machine, runtimeIdentity: runtimeIdentity) + : nil + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + guard process.suspend() else { + throw MachineManagerError.persistence("could not pause machine \(id)") + } + lock.lock() + guard var current = machines[id], current.process === process, + current.state == .running else { + lock.unlock() + _ = process.resume() + throw MachineManagerError.persistence( + "machine \(id) changed while pause was being committed" + ) + } + current.state = .paused + current.lastError = nil + machines[id] = current + lock.unlock() + if let lifecycle { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "paused machine has an unfinished pause journal" + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .paused) + } catch { + if let lifecycle { failLifecycle(lifecycle, stepID: "pause.failed") } + throw error + } + } + + private func resumeImplementation( + id: String, + journalLifecycle: Bool + ) throws -> DoryMachineStatus { + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + guard entry.state == .paused, let process = entry.process else { + lock.unlock() + throw MachineManagerError.persistence("machine \(id) is not paused") + } + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + lock.unlock() + let lifecycle = try journalLifecycle + ? beginLifecycleResume(machine: machine, runtimeIdentity: runtimeIdentity) + : nil + do { + if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + guard process.resume() else { + throw MachineManagerError.persistence("could not resume machine \(id)") + } + lock.lock() + guard var current = machines[id], current.process === process, + current.state == .paused else { + lock.unlock() + _ = process.suspend() + throw MachineManagerError.persistence( + "machine \(id) changed while resume was being committed" + ) + } + current.state = .running + current.lastError = nil + machines[id] = current + lock.unlock() + if let lifecycle { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "resumed machine has an unfinished resume journal" + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .running) + } catch { + if let lifecycle { failLifecycle(lifecycle, stepID: "resume.failed") } + throw error + } + } + private func stopImplementation( id: String, journalLifecycle: Bool, @@ -2347,13 +2564,19 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw MachineManagerError.unknownMachine(id) } - let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil + let sourceState = lifecycleState(for: entry.state) + let wasActive = [.starting, .running, .paused].contains(entry.state) + && entry.process != nil let machine = entry.configuration let runtimeIdentity = entry.runtimeIdentity let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan lock.unlock() let lifecycle = try journalLifecycle && wasActive - ? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity) + ? beginLifecycleStop( + machine: machine, + runtimeIdentity: runtimeIdentity, + sourceState: sourceState + ) : nil var stopCommitted = false do { @@ -2793,7 +3016,9 @@ public final class MachineManager: @unchecked Sendable { guard Self.isValidID(snapshotID) else { throw MachineManagerError.invalidID(snapshotID) } - let (machine, wasRunning) = try configurationAndRunningState(id: id) + let (machine, sourceMachineState) = try configurationAndPowerState(id: id) + let wasRunning = [.starting, .running].contains(sourceMachineState) + let wasPaused = sourceMachineState == .paused let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id) try Self.validateLaunchConfiguration(machine) try ensurePrivateSnapshotDirectory(machineID: id) @@ -2876,7 +3101,7 @@ public final class MachineManager: @unchecked Sendable { let lifecycle = try beginLifecycleSnapshot( machine: machine, runtimeIdentity: snapshotRuntimeIdentity, - sourceState: .stopped, + sourceState: wasPaused ? .paused : .stopped, snapshotID: snapshotID, snapshotAuthority: snapshotAuthority ) @@ -3705,7 +3930,8 @@ public final class MachineManager: @unchecked Sendable { legacyEnvironment: entry.configuration.environment, displayMode: entry.configuration.displayMode ) - if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true { + if [.starting, .running, .paused].contains(entry.state), + entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( id: id, state: .failed, @@ -4730,10 +4956,27 @@ public final class MachineManager: @unchecked Sendable { // Process.isRunning can briefly read false at the spawn/termination callback boundary. // The supervisor lifecycle is authoritative: a running/starting entry with an active or // scheduled helper must be stopped and restarted for a configuration transaction. + guard entry.state != .paused else { + throw MachineManagerError.persistence( + "machine \(id) must be resumed or stopped before this mutation" + ) + } let active = [.starting, .running].contains(entry.state) && entry.process != nil return (entry.configuration, active) } + private func configurationAndPowerState( + id: String + ) throws -> (DoryMachineConfiguration, DoryMachineState) { + lock.lock() + defer { lock.unlock() } + guard let entry = machines[id] else { + throw MachineManagerError.unknownMachine(id) + } + try validateManagedMachineArtifacts(entry.configuration) + return (entry.configuration, entry.state) + } + private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws { let identity = runtimeIdentityForUnplannedMachine(reason: .definitionChanged) var identityPersistenceError: String? @@ -7144,6 +7387,7 @@ public final class MachineManager: @unchecked Sendable { private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState { switch state { case .starting, .running: .running + case .paused: .paused case .created, .stopped: .stopped case .failed: .failed } @@ -7273,13 +7517,14 @@ public final class MachineManager: @unchecked Sendable { private func beginLifecycleStop( machine: DoryMachineConfiguration, - runtimeIdentity: DoryMachineRuntimeIdentity + runtimeIdentity: DoryMachineRuntimeIdentity, + sourceState: DoryWorkspaceLifecycleState ) throws -> MachineLifecycleJournalContext { try beginLifecycleOperation( kind: .stopping, source: lifecycleCondition( machine: machine, - state: .running, + state: sourceState, runtimeIdentity: runtimeIdentity ), target: lifecycleCondition( @@ -7292,6 +7537,48 @@ public final class MachineManager: @unchecked Sendable { ) } + private func beginLifecyclePause( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .pausing, + source: lifecycleCondition( + machine: machine, + state: .running, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .paused, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: nil, + readiness: false + ) + } + + private func beginLifecycleResume( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .resuming, + source: lifecycleCondition( + machine: machine, + state: .paused, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .running, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: nil, + readiness: true + ) + } + private func beginLifecycleSnapshot( machine: DoryMachineConfiguration, runtimeIdentity: DoryMachineRuntimeIdentity, @@ -7765,8 +8052,15 @@ public final class MachineManager: @unchecked Sendable { try failRecoveredLifecycle(lease, rolledBack: false) diagnostics[id] = "interrupted deletion preserved existing machine data" } - case .importing, .provisioning, .pausing, .resuming, - .suspending, .cloning, .updating, .repairing: + case .pausing: + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = + "interrupted pause was recovered as stopped after helper cleanup" + case .resuming: + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = + "interrupted resume was recovered as stopped after helper cleanup" + case .importing, .provisioning, .suspending, .cloning, .updating, .repairing: try failRecoveredLifecycle(lease, rolledBack: false) diagnostics[id] = "unsupported interrupted lifecycle mutation requires repair" } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index dddf11dd..f64c5f35 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -558,7 +558,9 @@ private final class TransactionFixture: @unchecked Sendable { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, + pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, + resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } ), executableIsAvailable: { _ in true } )]) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift index 3bfb8d6c..f84c9f81 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift @@ -173,7 +173,9 @@ private final class CompositionFixture: @unchecked Sendable { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, + pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, + resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } ), executableIsAvailable: { _ in true } ) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 4e735789..97cc9442 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -321,7 +321,9 @@ private struct Fixture { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) } + stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, + pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, + resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } ), executableIsAvailable: { _ in true } )]) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index d3c7af94..0b07a36a 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -19,8 +19,8 @@ final class MachineBackendTests: XCTestCase { vz.bootMediaKinds, [.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk] ) - XCTAssertFalse(vz.lifecycle.pause) - XCTAssertFalse(vz.lifecycle.resume) + XCTAssertTrue(vz.lifecycle.pause) + XCTAssertTrue(vz.lifecycle.resume) } func testProbeFailsClosedForMissingAndUnavailableComponents() { @@ -250,7 +250,7 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible) } - func testPauseAndResumeAreExplicitlyUnsupportedAndDoNotInvokeLauncher() { + func testPauseAndResumeDispatchThroughTheSelectedBackend() { let recorder = recordingOperations() let backend = availableRawBackend(operations: recorder.operations) let request = MachineBackendRuntimeRequest( @@ -258,9 +258,14 @@ final class MachineBackendTests: XCTestCase { backend: .doryHypervisor ) - XCTAssertEqual(backend.pause(request).failure?.code, .lifecycleOperationUnsupported) - XCTAssertEqual(backend.resume(request).failure?.code, .lifecycleOperationUnsupported) - XCTAssertEqual(recorder.events, []) + let paused = backend.pause(request) + XCTAssertTrue(paused.isSuccess) + XCTAssertEqual(paused.observation?.state, .paused) + + let resumed = backend.resume(request) + XCTAssertTrue(resumed.isSuccess) + XCTAssertEqual(resumed.observation?.state, .running) + XCTAssertEqual(recorder.events, ["pause:raw-linux", "resume:raw-linux"]) } func testBackendPlanRoundTripsWithPlannerCapabilityEvidence() throws { @@ -382,6 +387,14 @@ private final class OperationRecorder: @unchecked Sendable { stop: { [weak self] id in self?.append("stop:\(id)") return MachineBackendRuntimeObservation(machineID: id, state: .stopped) + }, + pause: { [weak self] id in + self?.append("pause:\(id)") + return MachineBackendRuntimeObservation(machineID: id, state: .paused) + }, + resume: { [weak self] id in + self?.append("resume:\(id)") + return MachineBackendRuntimeObservation(machineID: id, state: .running) } ) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift index 5fd3cdb2..5636fd7d 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift @@ -6,6 +6,32 @@ import Foundation import XCTest final class MachineManagerLifecycleJournalIntegrationTests: XCTestCase { + func testPauseAndResumePublishExactLifecycleTransitions() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + _ = try fixture.createMachine(manager) + XCTAssertEqual(try manager.start(id: fixture.machineID).state, .running) + + let paused = try manager.pause(id: fixture.machineID) + XCTAssertEqual(paused.state, .paused) + let pauseRecord = try waitForJournal(fixture, kind: .workspacePause, status: .completed) + let pauseOperation = try fixture.store.acquire(pauseRecord.plan.id) + .readWorkspaceLifecycleOperation() + XCTAssertEqual(pauseOperation.kind, .pausing) + XCTAssertEqual(pauseOperation.source.state, .running) + XCTAssertEqual(pauseOperation.target.state, .paused) + + let resumed = try manager.resume(id: fixture.machineID) + XCTAssertEqual(resumed.state, .running) + let resumeRecord = try waitForJournal(fixture, kind: .workspaceResume, status: .completed) + let resumeOperation = try fixture.store.acquire(resumeRecord.plan.id) + .readWorkspaceLifecycleOperation() + XCTAssertEqual(resumeOperation.kind, .resuming) + XCTAssertEqual(resumeOperation.source.state, .paused) + XCTAssertEqual(resumeOperation.target.state, .running) + } + func testStartJournalRemainsActiveUntilReadyAndFencesConcurrentMutation() throws { let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true) defer { fixture.cleanup() } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 4f7455ad..475c8d7a 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -108,7 +108,9 @@ struct MachineManagerResolvedPlanIntegrationTests { } return try managerOperations.authorizedStart(changed) }, - stop: managerOperations.stop + stop: managerOperations.stop, + pause: managerOperations.pause, + resume: managerOperations.resume ) let registry = try rawRegistry(operations: mutatingOperations) let resolver = ClosureLaunchResolver { request in diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 9db4c23e..7785c40f 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -82,6 +82,49 @@ final class MachineManagerTests: XCTestCase { XCTAssertTrue(manager.list().isEmpty) } + func testPauseResumePreservesTheRunningHelperAndStopAcceptsPausedMachine() throws { + let base = "/tmp/dory-machine-pause-resume-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 22:39:48 +0000 Subject: [PATCH 052/338] feat(vm): expose pause and resume controls --- Dory/Features/Machines/MachinesView.swift | 17 ++++- Dory/Models/AppStore.swift | 33 +++++++-- Dory/Runtime/Doryd/DorydClient.swift | 18 +++++ DoryTests/DorydClientTests.swift | 74 +++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 2 + .../Sources/DorydKit/DorydService.swift | 18 +++++ dory-core-swift/Sources/dorydctl/main.swift | 10 ++- .../DorydKitTests/DorydServiceTests.swift | 18 +++++ 8 files changed, 180 insertions(+), 10 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 643f4722..bff341b6 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -125,6 +125,8 @@ private struct MachineCard: View { @State private var confirmingDelete = false private var isRunning: Bool { machine.status == .running } + private var isPaused: Bool { machine.status == .paused } + private var isActive: Bool { isRunning || isPaused } private var hasAssignedAddress: Bool { DoryDNS.ipv4Bytes(machine.ip) != nil } var body: some View { @@ -150,10 +152,10 @@ private struct MachineCard: View { HStack(alignment: .top, spacing: 0) { metric("CPU", isRunning ? String(format: "%.1f%%", machine.cpuPercent) : "—") - metric("MEMORY", isRunning ? machine.memoryDisplay : "—") + metric("MEMORY", isActive ? machine.memoryDisplay : "—") VStack(alignment: .leading, spacing: 3) { Text(hasAssignedAddress ? "ADDRESS" : "DNS NAME").font(.system(size: 10, weight: .semibold)).foregroundStyle(p.text3).tracking(0.4) - Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isRunning ? p.accentText : p.text3).lineLimit(1) + Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isActive ? p.accentText : p.text3).lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -191,9 +193,18 @@ private struct MachineCard: View { Divider().overlay(p.border) HStack(spacing: 8) { - actionButton(isRunning ? "stop.fill" : "play.fill", isRunning ? "Stop" : "Start", prominent: !isRunning) { + actionButton( + isRunning ? "stop.fill" : "play.fill", + isRunning ? "Stop" : (isPaused ? "Resume" : "Start"), + prominent: !isRunning + ) { store.toggleMachine(machine) } + if isRunning { + actionButton("pause.fill", "Pause", prominent: false) { + store.pauseMachine(machine) + } + } if machine.displayMode == .desktop { actionButton("display", "Desktop", prominent: false, enabled: store.canOpenMachineDesktop(machine)) { store.openMachineDesktop(machine) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index d5d1a39e..2945effb 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5152,7 +5152,7 @@ final class AppStore { switch status.state { case "running": runState = .running - case "starting": + case "paused", "starting": runState = .paused default: runState = .stopped @@ -5361,19 +5361,42 @@ final class AppStore { func toggleMachine(_ machine: Machine) { guard requireDorydMachines() else { return } guard let idx = machines.firstIndex(where: { $0.id == machine.id }) else { return } - let wasRunning = machines[idx].status == .running + let previousState = machines[idx].status let name = machine.name busyMachines.insert(name) Task { defer { busyMachines.remove(name) } do { - if wasRunning { + switch previousState { + case .running: _ = try await dorydClient.machineStop(name) - } else { + case .paused: + _ = try await dorydClient.machineResume(name) + case .stopped: _ = try await dorydClient.machineStart(name) } } catch { - actionError = "Could not \(wasRunning ? "stop" : "start") \(name): \(error)" + let action = switch previousState { + case .running: "stop" + case .paused: "resume" + case .stopped: "start" + } + actionError = "Could not \(action) \(name): \(error)" + } + await refreshMachines() + } + } + + func pauseMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status == .running else { return } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machinePause(machine.name) + } catch { + actionError = "Could not pause \(machine.name): \(error)" } await refreshMachines() } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index ac925b68..a942e661 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -17,6 +17,8 @@ nonisolated protocol DorydControlXPC { func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) @@ -1270,6 +1272,22 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machinePause(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machinePause(machineID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineResume(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machineResume(machineID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + func machineUpdate( _ machineID: String, memoryMB: UInt64? = nil, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index ec1bb4db..3f1e1f74 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -258,6 +258,8 @@ struct DorydClientTests { ) )) let startedMachine = try await client.machineStart("dev") + let pausedMachine = try await client.machinePause("dev") + let resumedMachine = try await client.machineResume("dev") let machineStats = try await client.machineStats("dev") let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) let provisionedMachine = try await client.machineProvision("dev", recipe: "rust") @@ -368,6 +370,10 @@ struct DorydClientTests { #expect(startedMachine.environment["DORY_GUEST_USER"] == "developer") #expect(startedMachine.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") #expect(startedMachine.displayMode == .desktop) + #expect(pausedMachine.state == "paused") + #expect(pausedMachine.pid == startedMachine.pid) + #expect(resumedMachine.state == "running") + #expect(resumedMachine.pid == startedMachine.pid) #expect(execResult.stdout == "cargo 1.0\n") #expect(execResult.exitCode == 0) #expect(machineStats.cpuPercent == 12.5) @@ -970,6 +976,20 @@ struct DorydClientTests { } #expect(service.machineStartCount == 1) + machine = try #require(store.machines.first { $0.name == "dev" }) + store.pauseMachine(machine) + try await waitUntil { + store.machines.first { $0.name == "dev" }?.status == .paused + } + #expect(service.machinePauseCount == 1) + + machine = try #require(store.machines.first { $0.name == "dev" }) + store.toggleMachine(machine) + try await waitUntil { + store.machines.first { $0.name == "dev" }?.status == .running + } + #expect(service.machineResumeCount == 1) + machine = try #require(store.machines.first { $0.name == "dev" }) let editResult = await store.editMachine( machine, @@ -2368,6 +2388,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] private var _machineStartCount = 0 private var _machineStopCount = 0 + private var _machinePauseCount = 0 + private var _machineResumeCount = 0 private var _machineDeleteCount = 0 private var _machineDeleteOK = true private var _machineDeleteMessage = "" @@ -2448,6 +2470,14 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.lock(); defer { lock.unlock() } return _machineStopCount } + var machinePauseCount: Int { + lock.lock(); defer { lock.unlock() } + return _machinePauseCount + } + var machineResumeCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineResumeCount + } var machineDeleteCount: Int { lock.lock(); defer { lock.unlock() } return _machineDeleteCount @@ -2710,6 +2740,50 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "paused", + pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machinePauseCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "running", + pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machineResumeCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() _machineUpdateCount += 1 diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index ff247df2..aa6c3343 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -17,6 +17,8 @@ import Foundation func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 01bf2c25..5d663604 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -283,6 +283,24 @@ public final class DorydService: NSObject, DorydControl { } } + public func machinePause( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + machineControl(machineID, action: "pause", reply: reply) { manager, id in + try manager.pause(id: id) + } + } + + public func machineResume( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + machineControl(machineID, action: "resume", reply: reply) { manager, id in + try manager.resume(id: id) + } + } + public func machineUpdate( _ machineID: String, config: NSDictionary, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index f065238f..cd28c1f7 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -166,7 +166,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine stats NAME dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer] - dorydctl [global] machine start|stop|delete NAME + dorydctl [global] machine start|stop|pause|resume|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE @@ -1050,7 +1050,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1160,6 +1160,12 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { $0.machineStop(name, reply: $1) }) + case "pause": + let name = try cursor.take("usage: dorydctl machine pause NAME") + try emitJSON(try client.statusCommand { $0.machinePause(name, reply: $1) }) + case "resume": + let name = try cursor.take("usage: dorydctl machine resume NAME") + try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) }) case "update": try runMachineUpdate(cursor: &cursor, client: client) case "delete", "rm": diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 15cc171a..02cf86ff 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1117,6 +1117,24 @@ final class DorydServiceTests: XCTestCase { } wait(for: [start], timeout: 5) + let pause = expectation(description: "machinePause reply") + proxy.machinePause("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["state"] as? String, "paused") + XCTAssertNotNil(body["pid"]) + pause.fulfill() + } + wait(for: [pause], timeout: 5) + + let resume = expectation(description: "machineResume reply") + proxy.machineResume("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["state"] as? String, "running") + XCTAssertNotNil(body["pid"]) + resume.fulfill() + } + wait(for: [resume], timeout: 5) + let list = expectation(description: "machineList reply") proxy.machineList { body, message in XCTAssertEqual(message, "") From b0202439f85e7550cd2855579ecce3287c03e8c8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 22:41:11 +0000 Subject: [PATCH 053/338] feat(vm): add fenced machine restart --- .../Sources/DorydKit/MachineManager.swift | 25 +++++++++++++ .../DorydKitTests/MachineManagerTests.swift | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 9d757a9e..b44f346c 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -2426,6 +2426,31 @@ public final class MachineManager: @unchecked Sendable { } } + public func restart(id: String) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + guard [.running, .paused].contains(entry.state), entry.process != nil else { + lock.unlock() + throw MachineManagerError.persistence( + "machine \(id) must be running or paused before restart" + ) + } + lock.unlock() + + _ = try stopImplementation(id: id, journalLifecycle: true) + try refreshResolvedAdmissionForStartIfNeeded(id: id) + return try startImplementation(id: id, journalLifecycle: true) + } + private func performAuthorizedResolvedBackendPause( id: String, expectedBackend: DoryVirtualizationBackendIdentity diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 7785c40f..3253caa2 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -125,6 +125,43 @@ final class MachineManagerTests: XCTestCase { XCTAssertNil(stopped.pid) } + func testRestartReplacesTheHelperFromRunningAndPausedStates() throws { + let base = "/tmp/dory-machine-restart-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 22:42:55 +0000 Subject: [PATCH 054/338] feat(vm): expose machine restart controls --- Dory/Features/Machines/MachinesView.swift | 6 ++++ Dory/Models/AppStore.swift | 15 ++++++++ Dory/Runtime/Doryd/DorydClient.swift | 9 +++++ DoryTests/DorydClientTests.swift | 35 +++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 9 +++++ dory-core-swift/Sources/dorydctl/main.swift | 9 +++-- .../DorydKitTests/DorydServiceTests.swift | 9 +++++ 8 files changed, 91 insertions(+), 2 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index bff341b6..340ca8cc 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -246,6 +246,12 @@ private struct MachineCard: View { private var overflowMenu: some View { Menu { + if isActive { + Button { store.restartMachine(machine) } label: { + Label("Restart", systemImage: "arrow.clockwise") + } + Divider() + } Button { store.takeSnapshot(machine, note: "") } label: { Label("Snapshot", systemImage: "camera.aperture") } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 2945effb..0ee230e6 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5402,6 +5402,21 @@ final class AppStore { } } + func restartMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status != .stopped else { return } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machineRestart(machine.name) + } catch { + actionError = "Could not restart \(machine.name): \(error)" + } + await refreshMachines() + } + } + func setMachineInstallerMedia(_ machine: Machine, attached: Bool) { guard requireDorydMachines(), machine.bootMode == .efi else { return } guard !busyMachines.contains(machine.name) else { return } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index a942e661..540cd9d9 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -19,6 +19,7 @@ nonisolated protocol DorydControlXPC { func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) @@ -1288,6 +1289,14 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineRestart(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineRestart(machineID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + func machineUpdate( _ machineID: String, memoryMB: UInt64? = nil, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 3f1e1f74..4654848e 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -260,6 +260,7 @@ struct DorydClientTests { let startedMachine = try await client.machineStart("dev") let pausedMachine = try await client.machinePause("dev") let resumedMachine = try await client.machineResume("dev") + let restartedMachine = try await client.machineRestart("dev") let machineStats = try await client.machineStats("dev") let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) let provisionedMachine = try await client.machineProvision("dev", recipe: "rust") @@ -374,6 +375,8 @@ struct DorydClientTests { #expect(pausedMachine.pid == startedMachine.pid) #expect(resumedMachine.state == "running") #expect(resumedMachine.pid == startedMachine.pid) + #expect(restartedMachine.state == "running") + #expect(restartedMachine.pid == 1235) #expect(execResult.stdout == "cargo 1.0\n") #expect(execResult.exitCode == 0) #expect(machineStats.cpuPercent == 12.5) @@ -990,6 +993,11 @@ struct DorydClientTests { } #expect(service.machineResumeCount == 1) + machine = try #require(store.machines.first { $0.name == "dev" }) + store.restartMachine(machine) + try await waitUntil { service.machineRestartCount == 1 } + #expect(store.machines.first { $0.name == "dev" }?.status == .running) + machine = try #require(store.machines.first { $0.name == "dev" }) let editResult = await store.editMachine( machine, @@ -2390,6 +2398,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineStopCount = 0 private var _machinePauseCount = 0 private var _machineResumeCount = 0 + private var _machineRestartCount = 0 private var _machineDeleteCount = 0 private var _machineDeleteOK = true private var _machineDeleteMessage = "" @@ -2478,6 +2487,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.lock(); defer { lock.unlock() } return _machineResumeCount } + var machineRestartCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineRestartCount + } var machineDeleteCount: Int { lock.lock(); defer { lock.unlock() } return _machineDeleteCount @@ -2784,6 +2797,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "running", + pid: ((current?["pid"] as? NSNumber)?.int32Value ?? 1234) + 1, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machineRestartCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() _machineUpdateCount += 1 diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index aa6c3343..dc7df97c 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -19,6 +19,7 @@ import Foundation func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 5d663604..fe8095fe 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -301,6 +301,15 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineRestart( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + machineControl(machineID, action: "restart", reply: reply) { manager, id in + try manager.restart(id: id) + } + } + public func machineUpdate( _ machineID: String, config: NSDictionary, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index cd28c1f7..81506c92 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -166,7 +166,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine stats NAME dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer] - dorydctl [global] machine start|stop|pause|resume|delete NAME + dorydctl [global] machine start|stop|pause|resume|restart|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE @@ -1050,7 +1050,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1166,6 +1166,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { case "resume": let name = try cursor.take("usage: dorydctl machine resume NAME") try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) }) + case "restart": + let name = try cursor.take("usage: dorydctl machine restart NAME") + try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { + $0.machineRestart(name, reply: $1) + }) case "update": try runMachineUpdate(cursor: &cursor, client: client) case "delete", "rm": diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 02cf86ff..cc5784ba 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1135,6 +1135,15 @@ final class DorydServiceTests: XCTestCase { } wait(for: [resume], timeout: 5) + let restart = expectation(description: "machineRestart reply") + proxy.machineRestart("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["state"] as? String, "running") + XCTAssertNotNil(body["pid"]) + restart.fulfill() + } + wait(for: [restart], timeout: 5) + let list = expectation(description: "machineList reply") proxy.machineList { body, message in XCTAssertEqual(message, "") From 5f93bf704ace87cfacec45aa0c7668cbaca5bd1d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 22:45:19 +0000 Subject: [PATCH 055/338] fix(vm): account for paused machine resources --- .../Sources/DorydKit/HealthReporter.swift | 11 +++++++++++ .../Sources/DorydKit/MachineManager.swift | 16 ++++++++++++++++ .../DorydKitTests/HealthReporterTests.swift | 10 ++++++++++ .../DorydKitTests/MachineManagerTests.swift | 12 +++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 94a90ea4..4ce88e95 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -1912,10 +1912,12 @@ public final class HealthReporter: @unchecked Sendable { let failed = statuses.filter { $0.state == .failed } let starting = statuses.filter { $0.state == .starting } let running = statuses.filter { $0.state == .running } + let paused = statuses.filter { $0.state == .paused } let stopped = statuses.filter { $0.state == .stopped || $0.state == .created } let data = [ "total": String(statuses.count), "running": String(running.count), + "paused": String(paused.count), "starting": String(starting.count), "stopped": String(stopped.count), "failed": String(failed.count), @@ -1941,6 +1943,15 @@ public final class HealthReporter: @unchecked Sendable { detail: starting.map(\.id).joined(separator: ", "), data: data ) + } else if !paused.isEmpty && running.isEmpty { + summary = HealthCheck( + id: "machine.local", + status: .pass, + code: "machine.paused", + title: "Local machine paused", + detail: paused.map(\.id).joined(separator: ", "), + data: data + ) } else { summary = HealthCheck( id: "machine.local", diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index b44f346c..65f36f5c 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -4708,6 +4708,22 @@ public final class MachineManager: @unchecked Sendable { public func memorySnapshots() -> [GuestMemorySnapshot] { list().compactMap { status in + if status.state == .paused { + let residentMB = max(1, status.currentBalloonTargetMB ?? status.memoryMB) + return GuestMemorySnapshot( + id: "machine.\(status.id)", + kind: .virtualMachine, + telemetry: DoryTelemetry( + memTotalKB: residentMB * 1024, + memAvailableKB: 0, + psiSomeAvg10: 0, + psiFullAvg10: 0 + ), + currentTargetMB: residentMB, + maximumTargetMB: status.memoryMB, + canBalloon: false + ) + } guard status.state == .running, status.agentSocketPath != nil else { return nil } diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index a1f66705..10c4eacb 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -389,6 +389,16 @@ final class HealthReporterTests: XCTestCase { XCTAssertTrue(doctor.results.contains { $0.id == "machine.local" }) XCTAssertTrue(doctor.results.contains { $0.id == "machine.local.dev" }) XCTAssertNil(try doctor.jsonString().range(of: "sk-opaque-value")) + + _ = try manager.pause(id: "dev") + let pausedHealth = reporter.report() + let pausedSummary = try XCTUnwrap( + pausedHealth.results.first { $0.id == "machine.local" } + ) + XCTAssertEqual(pausedSummary.status, .pass) + XCTAssertEqual(pausedSummary.code, "machine.paused") + XCTAssertEqual(pausedSummary.data["running"], "0") + XCTAssertEqual(pausedSummary.data["paused"], "1") } func testResolvedMachineEvidencePinsPlanBackendMediaComponentsAndQualifications() throws { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 3253caa2..dab19f83 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3702,7 +3702,7 @@ final class MachineManagerTests: XCTestCase { ]) } - func testMemorySnapshotsIncludeRunningMachineTelemetry() throws { + func testMemorySnapshotsIncludeRunningTelemetryAndResidentPausedMemory() throws { let base = "/tmp/dory-machine-memory-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 22:51:24 +0000 Subject: [PATCH 056/338] feat(vm): preserve workspaces across host sleep --- .../DorydKit/HostWakeCoordinator.swift | 30 +- .../DorydKit/MachineHostPowerController.swift | 274 ++++++++++++++++++ .../Sources/DorydKit/MachineManager.swift | 2 +- dory-core-swift/Sources/doryd/main.swift | 5 + .../HostWakeCoordinatorTests.swift | 28 ++ .../DorydKitTests/MachineManagerTests.swift | 53 ++++ 6 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift diff --git a/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift b/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift index 1e734736..7b8685b3 100644 --- a/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift @@ -45,6 +45,10 @@ public protocol WakeClockSyncing: Sendable { func syncAgentClock(now: Date) -> AgentClockSyncResult } +public protocol HostWakeHandling: Sendable { + func recoverAfterHostWake(now: Date) -> HostWakeActionResult +} + public struct HostSleepActionResult: Sendable, Equatable { public var name: String public var attempted: Bool @@ -73,6 +77,20 @@ public struct AgentClockSyncResult: Sendable, Equatable { } } +public struct HostWakeActionResult: Sendable, Equatable { + public var name: String + public var attempted: Bool + public var recovered: Bool + public var detail: String? + + public init(name: String, attempted: Bool, recovered: Bool, detail: String? = nil) { + self.name = name + self.attempted = attempted + self.recovered = recovered + self.detail = detail + } +} + public struct DNSProbeTarget: Sendable, Equatable { public var host: String public var port: UInt16 @@ -170,6 +188,7 @@ public final class SystemDNSProbe: DNSProbing, @unchecked Sendable { public struct HostWakeResult: Sendable, Equatable { public var at: Date + public var actions: [HostWakeActionResult] public var clockSyncs: [AgentClockSyncResult] public var dnsProbes: [DNSProbeResult] public var networkReconciliations: [String] @@ -183,6 +202,7 @@ public struct HostSleepResult: Sendable, Equatable { public final class HostWakeCoordinator: @unchecked Sendable { private let powerSource: PowerEventSource private let sleepHandlers: [HostSleepHandling] + private let wakeHandlers: [HostWakeHandling] private let clockSyncers: [WakeClockSyncing] private let dnsProbe: DNSProbing private let networkReconcilers: [WakeNetworkReconciling] @@ -194,6 +214,7 @@ public final class HostWakeCoordinator: @unchecked Sendable { public init( powerSource: PowerEventSource = IOKitPowerEventSource(), sleepHandlers: [HostSleepHandling] = [], + wakeHandlers: [HostWakeHandling] = [], clockSyncers: [WakeClockSyncing] = [], dnsProbe: DNSProbing = SystemDNSProbe(), networkReconcilers: [WakeNetworkReconciling] = [], @@ -201,6 +222,7 @@ public final class HostWakeCoordinator: @unchecked Sendable { ) { self.powerSource = powerSource self.sleepHandlers = sleepHandlers + self.wakeHandlers = wakeHandlers self.clockSyncers = clockSyncers self.dnsProbe = dnsProbe self.networkReconcilers = networkReconcilers @@ -237,6 +259,9 @@ public final class HostWakeCoordinator: @unchecked Sendable { @discardableResult public func handleWake(now: Date = Date()) -> HostWakeResult { + // Resume daemon-owned workspaces before contacting their guest agents. A machine that Dory + // paused for host sleep must be running before clock synchronization can succeed. + let actions = wakeHandlers.map { $0.recoverAfterHostWake(now: now) } let clockResults = clockSyncers.map { $0.syncAgentClock(now: now) } let dnsResults = dnsProbe.probe() // Reconcile after clock and baseline DNS recovery so PAC expiry, DHCP resolver changes, @@ -244,6 +269,7 @@ public final class HostWakeCoordinator: @unchecked Sendable { let networkResults = networkReconcilers.map { $0.reconcileAfterWake(now: now) } let result = HostWakeResult( at: now, + actions: actions, clockSyncs: clockResults, dnsProbes: dnsResults, networkReconciliations: networkResults @@ -278,9 +304,11 @@ public final class HostWakeCoordinator: @unchecked Sendable { } private func incidentDetail(_ result: HostWakeResult) -> String { + let wakeAttempts = result.actions.filter(\.attempted).count + let wakeFailures = result.actions.filter { $0.attempted && !$0.recovered }.count let attempted = result.clockSyncs.filter(\.attempted).count let failed = result.clockSyncs.filter { $0.error != nil }.count let dnsOK = result.dnsProbes.filter(\.resolved).count - return "clock_syncs=\(attempted) clock_errors=\(failed) dns_ok=\(dnsOK)/\(result.dnsProbes.count) network_reconciles=\(result.networkReconciliations.count)" + return "wake_actions=\(wakeAttempts) wake_errors=\(wakeFailures) clock_syncs=\(attempted) clock_errors=\(failed) dns_ok=\(dnsOK)/\(result.dnsProbes.count) network_reconciles=\(result.networkReconciliations.count)" } } diff --git a/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift b/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift new file mode 100644 index 00000000..7116ea88 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift @@ -0,0 +1,274 @@ +import Darwin +import Foundation + +/// Pauses running workspaces before host sleep and resumes only the workspaces paused by this +/// controller. The intent is durable so recreating doryd between the sleep and wake callbacks does +/// not turn a manually paused workspace into a running one or lose an automatic resume. +public final class MachineHostPowerController: HostSleepHandling, HostWakeHandling, @unchecked Sendable { + private struct Record: Codable, Equatable { + var schemaVersion: Int + var machineIDs: [String] + } + + private static let schemaVersion = 1 + private static let recordName = ".host-sleep-paused-v1.json" + private let manager: MachineManager + private let stateDirectory: String + private let lock = NSLock() + + public init(manager: MachineManager, stateDirectory: String? = nil) { + self.manager = manager + self.stateDirectory = stateDirectory ?? manager.managedStateDirectory + } + + public func prepareForHostSleep(now: Date) -> HostSleepActionResult { + _ = now + lock.lock() + defer { lock.unlock() } + + let previous: Set + do { + previous = Set(try readRecord()?.machineIDs ?? []) + } catch { + return HostSleepActionResult( + name: "machines", + attempted: true, + slept: false, + detail: "could not read durable host-sleep authority: \(error)" + ) + } + + let newlyRunning = Set(manager.list().filter { $0.state == .running }.map(\.id)) + var pending = previous.union(newlyRunning) + guard !pending.isEmpty else { + return HostSleepActionResult( + name: "machines", + attempted: false, + slept: false, + detail: "no running machines" + ) + } + + // Publish intent before sending SIGSTOP. A crash after this point may leave an ID whose + // process is still running; wake recovery treats that as already recovered and never starts + // a stopped workspace. + do { + try writeRecord(machineIDs: pending) + } catch { + return HostSleepActionResult( + name: "machines", + attempted: true, + slept: false, + detail: "could not persist host-sleep intent: \(error)" + ) + } + + var failures: [String] = [] + for id in pending.sorted() { + guard let status = manager.status(id: id) else { + pending.remove(id) + continue + } + if status.state == .paused { + continue + } + guard status.state == .running else { + pending.remove(id) + continue + } + do { + guard try manager.pause(id: id).state == .paused else { + failures.append("\(id): pause returned a non-paused state") + pending.remove(id) + continue + } + } catch { + failures.append("\(id): \(error)") + pending.remove(id) + } + } + + do { + try writeRecord(machineIDs: pending) + } catch { + failures.append("could not commit paused-machine authority: \(error)") + } + return HostSleepActionResult( + name: "machines", + attempted: true, + slept: failures.isEmpty, + detail: "paused=\(pending.count)/\(newlyRunning.count)" + + (failures.isEmpty ? "" : " errors=\(failures.joined(separator: "; "))") + ) + } + + public func recoverAfterHostWake(now: Date) -> HostWakeActionResult { + _ = now + lock.lock() + defer { lock.unlock() } + + var pending: Set + do { + pending = Set(try readRecord()?.machineIDs ?? []) + } catch { + return HostWakeActionResult( + name: "machines", + attempted: true, + recovered: false, + detail: "could not read durable host-sleep authority: \(error)" + ) + } + guard !pending.isEmpty else { + return HostWakeActionResult( + name: "machines", + attempted: false, + recovered: true, + detail: "no host-paused machines" + ) + } + + let attemptedCount = pending.count + var failures: [String] = [] + for id in pending.sorted() { + guard let status = manager.status(id: id) else { + pending.remove(id) + continue + } + if status.state == .running { + pending.remove(id) + continue + } + guard status.state == .paused else { + // Never start a stopped, failed, or otherwise non-resident workspace merely because + // it was running before the host went to sleep. + pending.remove(id) + continue + } + do { + guard try manager.resume(id: id).state == .running else { + failures.append("\(id): resume returned a non-running state") + continue + } + pending.remove(id) + } catch { + failures.append("\(id): \(error)") + } + } + + do { + try writeRecord(machineIDs: pending) + } catch { + failures.append("could not retire host-sleep authority: \(error)") + } + return HostWakeActionResult( + name: "machines", + attempted: true, + recovered: failures.isEmpty && pending.isEmpty, + detail: "resumed=\(attemptedCount - pending.count)/\(attemptedCount)" + + (failures.isEmpty ? "" : " errors=\(failures.joined(separator: "; "))") + ) + } + + private var recordPath: String { stateDirectory + "/" + Self.recordName } + + private func readRecord() throws -> Record? { + var statBuffer = stat() + if lstat(recordPath, &statBuffer) != 0 { + if errno == ENOENT { return nil } + throw filesystem("inspect host-sleep record") + } + guard (statBuffer.st_mode & S_IFMT) == S_IFREG, + statBuffer.st_uid == geteuid(), + statBuffer.st_nlink == 1, + statBuffer.st_mode & 0o077 == 0 else { + throw filesystem("reject unsafe host-sleep record") + } + let data = try Data(contentsOf: URL(fileURLWithPath: recordPath), options: .mappedIfSafe) + let record = try JSONDecoder().decode(Record.self, from: data) + guard record.schemaVersion == Self.schemaVersion, + record.machineIDs == Array(Set(record.machineIDs)).sorted(), + record.machineIDs.allSatisfy(Self.isValidMachineID) else { + throw filesystem("reject invalid host-sleep record") + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard try encoder.encode(record) == data else { + throw filesystem("reject non-canonical host-sleep record") + } + return record + } + + private func writeRecord(machineIDs: Set) throws { + let sortedIDs = machineIDs.sorted() + guard sortedIDs.allSatisfy(Self.isValidMachineID) else { + throw filesystem("refuse invalid machine identifier") + } + if sortedIDs.isEmpty { + if unlink(recordPath) != 0, errno != ENOENT { + throw filesystem("remove host-sleep record") + } + try synchronizeDirectory() + return + } + + try FileManager.default.createDirectory( + atPath: stateDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(Record( + schemaVersion: Self.schemaVersion, + machineIDs: sortedIDs + )) + let temporaryPath = stateDirectory + "/.host-sleep-paused-v1.tmp-" + UUID().uuidString + let descriptor = open(temporaryPath, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw filesystem("create host-sleep temporary record") } + var shouldRemoveTemporary = true + defer { + _ = close(descriptor) + if shouldRemoveTemporary { _ = unlink(temporaryPath) } + } + let wrote = data.withUnsafeBytes { bytes -> Bool in + guard let base = bytes.baseAddress else { return data.isEmpty } + var offset = 0 + while offset < bytes.count { + let result = write(descriptor, base.advanced(by: offset), bytes.count - offset) + if result < 0 { + if errno == EINTR { continue } + return false + } + offset += result + } + return true + } + guard wrote, fsync(descriptor) == 0 else { + throw filesystem("write host-sleep record") + } + guard rename(temporaryPath, recordPath) == 0 else { + throw filesystem("publish host-sleep record") + } + shouldRemoveTemporary = false + try synchronizeDirectory() + } + + private func synchronizeDirectory() throws { + let descriptor = open(stateDirectory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) + guard descriptor >= 0 else { throw filesystem("open host-sleep record directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { throw filesystem("sync host-sleep record directory") } + } + + private static func isValidMachineID(_ id: String) -> Bool { + id.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + } + + private func filesystem(_ action: String) -> NSError { + NSError( + domain: "DoryMachineHostPowerController", + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: action + ": " + String(cString: strerror(errno))] + ) + } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 65f36f5c..a81e3439 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -4709,7 +4709,7 @@ public final class MachineManager: @unchecked Sendable { public func memorySnapshots() -> [GuestMemorySnapshot] { list().compactMap { status in if status.state == .paused { - let residentMB = max(1, status.currentBalloonTargetMB ?? status.memoryMB) + let residentMB = max(1, status.currentBalloonTargetMB) return GuestMemorySnapshot( id: "machine.\(status.id)", kind: .virtualMachine, diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index ef044da2..7858b97c 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -201,6 +201,7 @@ let idlePolicyStore = IdlePolicyStore(home: dorydEnvironment.home, environment: dockerTier?.containerSummariesForIdle() ?? .ok([]) } var sleepHandlers: [HostSleepHandling] = [] +var wakeHandlers: [HostWakeHandling] = [] var clockSyncers: [WakeClockSyncing] = [] if let dockerTier { sleepHandlers.append(PolicyAwareHostSleepHandler( @@ -213,10 +214,14 @@ if let dockerTier { clockSyncers.append(dockerTier) } if let machineManager { + let machinePowerController = MachineHostPowerController(manager: machineManager) + sleepHandlers.append(machinePowerController) + wakeHandlers.append(machinePowerController) clockSyncers.append(machineManager) } let wakeCoordinator = HostWakeCoordinator( sleepHandlers: sleepHandlers, + wakeHandlers: wakeHandlers, clockSyncers: clockSyncers, dnsProbe: SystemDNSProbe(targets: dnsTargets), networkReconcilers: [corporateConnectivity], diff --git a/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift index 696b4814..76458a90 100644 --- a/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift @@ -43,8 +43,10 @@ final class HostWakeCoordinatorTests: XCTestCase { ]) let writer = IncidentWriter(path: base + "/incidents.jsonl") let network = TestNetworkReconciler() + let wake = TestWakeHandler() let coordinator = HostWakeCoordinator( powerSource: source, + wakeHandlers: [wake], clockSyncers: [clock], dnsProbe: dns, networkReconcilers: [network], @@ -54,6 +56,8 @@ final class HostWakeCoordinatorTests: XCTestCase { try coordinator.start() source.emitWake() + XCTAssertEqual(wake.wakeDates.count, 1) + XCTAssertEqual(coordinator.lastWake?.actions.first?.recovered, true) XCTAssertEqual(clock.syncDates.count, 1) XCTAssertEqual(dns.probeCount, 1) XCTAssertEqual(network.wakeDates.count, 1) @@ -62,6 +66,7 @@ final class HostWakeCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.lastWake?.networkReconciliations, ["network reconciled"]) let incident = try XCTUnwrap(writer.read(limit: 1).first) XCTAssertEqual(incident.type, "host.wake") + XCTAssertTrue(incident.detail?.contains("wake_actions=1") ?? false) XCTAssertTrue(incident.detail?.contains("dns_ok=1/1") ?? false) } @@ -163,6 +168,29 @@ private final class TestClockSyncer: WakeClockSyncing, @unchecked Sendable { } } +private final class TestWakeHandler: HostWakeHandling, @unchecked Sendable { + private let lock = NSLock() + private var dates: [Date] = [] + + var wakeDates: [Date] { + lock.lock() + defer { lock.unlock() } + return dates + } + + func recoverAfterHostWake(now: Date) -> HostWakeActionResult { + lock.lock() + dates.append(now) + lock.unlock() + return HostWakeActionResult( + name: "test", + attempted: true, + recovered: true, + detail: "recovered" + ) + } +} + private final class TestDNSProbe: DNSProbing, @unchecked Sendable { private let lock = NSLock() private let results: [DNSProbeResult] diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index dab19f83..22091369 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -125,6 +125,59 @@ final class MachineManagerTests: XCTestCase { XCTAssertNil(stopped.pid) } + func testHostPowerControllerDurablyResumesOnlyMachinesItPaused() throws { + let base = "/tmp/dory-machine-host-power-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 22:57:05 +0000 Subject: [PATCH 057/338] feat(guest): version agent capabilities --- .../Sources/DoryCore/DoryCore.swift | 30 +++++++++++++++-- .../DorydKitTests/AgentControlTests.swift | 4 ++- dory-core/agent/src/dispatch.rs | 32 +++++++++++++++++++ dory-core/ffi/src/agent_forward.rs | 11 ++++++- dory-core/ffi/src/remote.rs | 15 +++++++++ dory-core/pb/proto/agent.proto | 7 ++++ dory-core/remote/src/agent_client.rs | 7 ++++ dory-core/remote/src/ssh.rs | 7 ++++ 8 files changed, 108 insertions(+), 5 deletions(-) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index 28b9e08d..5b10c5c8 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -122,17 +122,35 @@ public final class DoryDataplaneHandle: @unchecked Sendable { } } +public struct DoryAgentCapability: Sendable, Equatable, Hashable { + public var id: String + public var version: UInt32 + + public init(id: String, version: UInt32) { + self.id = id + self.version = version + } +} + public struct DoryAgentInfo: Sendable, Equatable { public var protocolVersion: UInt32 public var kernel: String public var agentBuild: String public var uptimeSeconds: UInt64 + public var capabilities: [DoryAgentCapability] - public init(protocolVersion: UInt32, kernel: String, agentBuild: String, uptimeSeconds: UInt64) { + public init( + protocolVersion: UInt32, + kernel: String, + agentBuild: String, + uptimeSeconds: UInt64, + capabilities: [DoryAgentCapability] = [] + ) { self.protocolVersion = protocolVersion self.kernel = kernel self.agentBuild = agentBuild self.uptimeSeconds = uptimeSeconds + self.capabilities = capabilities } } @@ -334,7 +352,10 @@ public final class DoryAgentControlHandle: @unchecked Sendable { protocolVersion: raw.protoVersion, kernel: raw.kernel, agentBuild: raw.agentBuild, - uptimeSeconds: raw.uptimeSecs + uptimeSeconds: raw.uptimeSecs, + capabilities: raw.capabilities.map { + DoryAgentCapability(id: $0.id, version: $0.version) + } ) } @@ -441,7 +462,10 @@ public final class DoryRemoteAgentHandle: @unchecked Sendable { protocolVersion: raw.protoVersion, kernel: raw.kernel, agentBuild: raw.agentBuild, - uptimeSeconds: raw.uptimeSecs + uptimeSeconds: raw.uptimeSecs, + capabilities: raw.capabilities.map { + DoryAgentCapability(id: $0.id, version: $0.version) + } ) } diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index bfaf6862..a7bcc293 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -17,6 +17,7 @@ final class AgentControlTests: XCTestCase { XCTAssertEqual(counter.value, 0) let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") + XCTAssertEqual(info.capabilities, [DoryAgentCapability(id: "exec", version: 1)]) XCTAssertEqual(counter.value, 1) XCTAssertFalse(try control.clockSync(now: Date(timeIntervalSince1970: 1.5))) @@ -58,7 +59,8 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda protocolVersion: 1, kernel: "Linux fake", agentBuild: "fake-agent", - uptimeSeconds: 42 + uptimeSeconds: 42, + capabilities: [DoryAgentCapability(id: "exec", version: 1)] ) } diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index 8ad70016..ca1d43b7 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -19,6 +19,23 @@ pub fn agent_build() -> String { concat!("dory-agent/", env!("CARGO_PKG_VERSION")).to_string() } +pub fn agent_capabilities() -> Vec { + [ + ("clock-sync", 1), + ("exec", 1), + ("exec-stdin", 1), + ("ports-watch", 1), + ("sync-push", 1), + ("telemetry", 1), + ] + .into_iter() + .map(|(id, version)| agent::AgentCapability { + id: id.to_string(), + version, + }) + .collect() +} + pub fn err(code: i32, message: &str) -> agent::AgentResponse { agent::AgentResponse { result: Some(Res::Error(agent::RpcError { @@ -53,6 +70,7 @@ fn info() -> agent::InfoResponse { kernel: kernel(), agent_build: agent_build(), uptime_secs: uptime_secs(), + capabilities: agent_capabilities(), } } @@ -172,6 +190,20 @@ mod tests { assert_eq!(i.proto_version, dory_proto::handshake::PROTO_VERSION); assert!(i.agent_build.starts_with("dory-agent/")); assert!(!i.kernel.is_empty()); + assert_eq!( + i.capabilities + .iter() + .map(|capability| (capability.id.as_str(), capability.version)) + .collect::>(), + vec![ + ("clock-sync", 1), + ("exec", 1), + ("exec-stdin", 1), + ("ports-watch", 1), + ("sync-push", 1), + ("telemetry", 1), + ] + ); } other => panic!("expected Info, got {other:?}"), } diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 6f63e5ab..262d10e1 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -14,7 +14,8 @@ use dory_remote::AgentClient; use tokio::net::UnixStream; use crate::remote::{ - exec_result, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, RemoteFfiError, TelemetryFfi, + exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, RemoteFfiError, + TelemetryFfi, }; #[derive(uniffi::Record)] @@ -114,6 +115,14 @@ impl AgentControl { kernel: i.kernel, agent_build: i.agent_build, uptime_secs: i.uptime_secs, + capabilities: i + .capabilities + .into_iter() + .map(|capability| AgentCapabilityFfi { + id: capability.id, + version: capability.version, + }) + .collect(), }) } diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs index b17f5807..4414c932 100644 --- a/dory-core/ffi/src/remote.rs +++ b/dory-core/ffi/src/remote.rs @@ -62,12 +62,19 @@ pub struct RemoteConfig { pub build: String, } +#[derive(uniffi::Record)] +pub struct AgentCapabilityFfi { + pub id: String, + pub version: u32, +} + #[derive(uniffi::Record)] pub struct AgentInfoFfi { pub proto_version: u32, pub kernel: String, pub agent_build: String, pub uptime_secs: u64, + pub capabilities: Vec, } #[derive(uniffi::Record)] @@ -167,6 +174,14 @@ impl RemoteAgent { kernel: i.kernel, agent_build: i.agent_build, uptime_secs: i.uptime_secs, + capabilities: i + .capabilities + .into_iter() + .map(|capability| AgentCapabilityFfi { + id: capability.id, + version: capability.version, + }) + .collect(), }) } diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 7fe17388..451b2567 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -13,11 +13,18 @@ message ClockSyncResponse { } message InfoRequest {} +message AgentCapability { + string id = 1; + uint32 version = 2; +} message InfoResponse { uint32 proto_version = 1; string kernel = 2; string agent_build = 3; uint64 uptime_secs = 4; + // Independently versioned guest-tool capabilities. New optional entries do not require a + // transport protocol bump; callers must require the exact capability/version they consume. + repeated AgentCapability capabilities = 5; } message ListenPort { diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 02ba3209..cd269dca 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -266,6 +266,10 @@ mod tests { kernel: "Linux fake 6.12".into(), agent_build: "fake-agent".into(), uptime_secs: 42, + capabilities: vec![agent::AgentCapability { + id: "exec".into(), + version: 1, + }], }), Some(Method::ClockSync(_)) => Res::ClockSync(agent::ClockSyncResponse { synced: true }), Some(Method::PortsWatch(_)) => Res::PortsWatch(agent::PortsWatchResponse::default()), @@ -299,6 +303,9 @@ mod tests { let info = client.info().await.unwrap(); assert_eq!(info.proto_version, dory_proto::handshake::PROTO_VERSION); assert_eq!(info.agent_build, "fake-agent"); + assert_eq!(info.capabilities.len(), 1); + assert_eq!(info.capabilities[0].id, "exec"); + assert_eq!(info.capabilities[0].version, 1); assert_eq!(info.uptime_secs, 42); let clock = client.clock_sync(1_700_000_000_000_000_000).await.unwrap(); diff --git a/dory-core/remote/src/ssh.rs b/dory-core/remote/src/ssh.rs index 2cc4851b..fe1bb521 100644 --- a/dory-core/remote/src/ssh.rs +++ b/dory-core/remote/src/ssh.rs @@ -167,6 +167,10 @@ mod tests { kernel: "Linux vps 6.12".into(), agent_build: "fake-remote-agent".into(), uptime_secs: 7, + capabilities: vec![agent::AgentCapability { + id: "exec".into(), + version: 1, + }], }), _ => Res::Error(agent::RpcError { code: 400, @@ -277,6 +281,9 @@ mod tests { let info = agent.client.info().await.expect("info rpc over ssh"); assert_eq!(info.proto_version, dory_proto::handshake::PROTO_VERSION); assert_eq!(info.agent_build, "fake-remote-agent"); + assert_eq!(info.capabilities.len(), 1); + assert_eq!(info.capabilities[0].id, "exec"); + assert_eq!(info.capabilities[0].version, 1); assert_eq!(info.uptime_secs, 7); } From 9152e13634cc48460dabc82a0030a1006e393bc1 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:04:00 +0000 Subject: [PATCH 058/338] feat(guest): expose tools capability health --- .../Sources/dory-hv/DesktopMode.swift | 2 + .../Sources/DoryCore/DoryCore.swift | 17 +++- .../Sources/DoryVMMKit/DoryVMM.swift | 6 ++ .../Sources/DorydKit/DorydService.swift | 8 ++ .../Sources/DorydKit/HealthReporter.swift | 95 ++++++++++++++++++- .../Sources/DorydKit/MachineManager.swift | 8 ++ .../Sources/DorydKit/VmmHandoff.swift | 7 ++ .../DorydKitTests/DorydServiceTests.swift | 16 ++++ .../DorydKitTests/HealthReporterTests.swift | 40 ++++++++ .../Tests/DorydKitTests/VmmHandoffTests.swift | 8 ++ 10 files changed, 205 insertions(+), 2 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 66054434..48f0143e 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -366,6 +366,8 @@ enum DesktopMode { ready: VmmReadyMessage( machineID: configuration.machineID, agentBuild: info.agentBuild, + agentProtocolVersion: info.protocolVersion, + agentCapabilities: info.capabilities, agentSocketPath: configuration.agentSocketPath, shellSocketPath: configuration.shellSocketPath, detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index 5b10c5c8..6e4cd425 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -122,7 +122,7 @@ public final class DoryDataplaneHandle: @unchecked Sendable { } } -public struct DoryAgentCapability: Sendable, Equatable, Hashable { +public struct DoryAgentCapability: Sendable, Equatable, Hashable, Codable { public var id: String public var version: UInt32 @@ -130,6 +130,11 @@ public struct DoryAgentCapability: Sendable, Equatable, Hashable { self.id = id self.version = version } + + public var isValid: Bool { + version > 0 && id.utf8.count <= 63 + && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil + } } public struct DoryAgentInfo: Sendable, Equatable { @@ -152,6 +157,16 @@ public struct DoryAgentInfo: Sendable, Equatable { self.uptimeSeconds = uptimeSeconds self.capabilities = capabilities } + + public var capabilitiesAreCanonical: Bool { + capabilities.allSatisfy(\.isValid) + && capabilities == capabilities.sorted { $0.id < $1.id } + && Set(capabilities.map(\.id)).count == capabilities.count + } + + public func supports(_ id: String, minimumVersion: UInt32 = 1) -> Bool { + capabilities.contains { $0.id == id && $0.version >= minimumVersion } + } } public struct DoryListenPort: Sendable, Equatable, Hashable { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index f645a290..ef321a92 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -962,6 +962,8 @@ public enum DoryVMMMain { machineID: String, handoffSocketPath: String, agentBuild: String?, + agentProtocolVersion: UInt32? = nil, + agentCapabilities: [DoryAgentCapability] = [], agentSocketPath: String?, dockerdSocketPath: String?, shellSocketPath: String?, @@ -973,6 +975,8 @@ public enum DoryVMMMain { ready: VmmReadyMessage( machineID: machineID, agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + agentCapabilities: agentCapabilities, agentSocketPath: agentSocketPath, dockerdSocketPath: dockerdSocketPath, shellSocketPath: shellSocketPath, @@ -1211,6 +1215,8 @@ public enum DoryVMMMain { machineID: machineID, handoffSocketPath: handoffSocketPath, agentBuild: agentInfo.agentBuild, + agentProtocolVersion: agentInfo.protocolVersion, + agentCapabilities: agentInfo.capabilities, agentSocketPath: agentSocketPath, dockerdSocketPath: machineID == "docker" ? dockerdSocketPath : nil, shellSocketPath: shellSocketPath, diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index fe8095fe..0e2228b3 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1621,6 +1621,14 @@ private extension DoryMachineStatus { if let agentBuild { dictionary["agentBuild"] = agentBuild } + if let agentProtocolVersion { + dictionary["agentProtocolVersion"] = agentProtocolVersion + } + if !agentCapabilities.isEmpty { + dictionary["agentCapabilities"] = agentCapabilities.map { + ["id": $0.id, "version": $0.version] as NSDictionary + } + } if let agentSocketPath { dictionary["agentSocketPath"] = agentSocketPath } diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 4ce88e95..365550a1 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -1962,7 +1962,100 @@ public final class HealthReporter: @unchecked Sendable { data: data ) } - return [summary] + statuses.map(Self.machineEvidenceCheck) + return [summary] + statuses.flatMap { + [Self.machineEvidenceCheck($0), Self.machineToolsCheck($0)] + } + } + + static func machineToolsCheck(_ status: DoryMachineStatus) -> HealthCheck { + var data = ["state": status.state.rawValue] + guard [.running, .paused].contains(status.state) else { + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .skip, + code: "machine.tools.inactive", + title: "Dory Tools inactive", + detail: "\(status.id) is not resident", + data: data + ) + } + guard let build = status.agentBuild, !build.isEmpty else { + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .warn, + code: "machine.tools.unavailable", + title: "Dory Tools unavailable", + detail: "\(status.id) has not reported a guest-tools handshake", + action: "Install or repair the qualified Dory Tools pack in this workspace.", + data: data + ) + } + data["build"] = build + guard let protocolVersion = status.agentProtocolVersion else { + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .warn, + code: "machine.tools.unversioned", + title: "Dory Tools unversioned", + detail: "\(status.id) reported \(build) without a protocol contract", + action: "Update the workspace's Dory Tools pack.", + data: data + ) + } + data["protocol_version"] = String(protocolVersion) + let capabilities = status.agentCapabilities + let canonical = capabilities.allSatisfy(\.isValid) + && capabilities == capabilities.sorted { $0.id < $1.id } + && Set(capabilities.map(\.id)).count == capabilities.count + guard protocolVersion == DoryCore.protocolVersion(), canonical else { + data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" } + .joined(separator: ",") + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .fail, + code: "machine.tools.invalid_handshake", + title: "Dory Tools handshake invalid", + detail: "\(status.id) reported an incompatible or malformed tools contract", + action: "Stop the workspace and repair its Dory Tools pack before using integrations.", + data: data + ) + } + guard !capabilities.isEmpty else { + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .warn, + code: "machine.tools.legacy_handshake", + title: "Dory Tools need an update", + detail: "\(status.id) reported protocol \(protocolVersion) without versioned capabilities", + action: "Update the workspace's Dory Tools pack to enable integration health.", + data: data + ) + } + + let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"] + let versions = Dictionary(uniqueKeysWithValues: capabilities.map { ($0.id, $0.version) }) + let missing = required.filter { (versions[$0] ?? 0) < 1 } + data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" }.joined(separator: ",") + if !missing.isEmpty { + data["missing_capabilities"] = missing.joined(separator: ",") + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .warn, + code: "machine.tools.partial", + title: "Dory Tools integrations are partial", + detail: "\(status.id) is missing \(missing.joined(separator: ", "))", + action: "Update or repair the workspace's Dory Tools pack.", + data: data + ) + } + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .pass, + code: "machine.tools.ready", + title: "Dory Tools ready", + detail: "\(status.id) reported \(build) with \(capabilities.count) versioned capabilities", + data: data + ) } /// Emits exact, non-secret launch evidence for one workspace. Environment values, host paths, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index a81e3439..95a016cc 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -310,6 +310,8 @@ public struct DoryMachineStatus: Sendable, Equatable { public var lastError: String? public var handoffSocketPath: String? public var agentBuild: String? + public var agentProtocolVersion: UInt32? + public var agentCapabilities: [DoryAgentCapability] public var agentSocketPath: String? public var dockerdSocketPath: String? public var shellSocketPath: String? @@ -339,6 +341,8 @@ public struct DoryMachineStatus: Sendable, Equatable { lastError: String? = nil, handoffSocketPath: String? = nil, agentBuild: String? = nil, + agentProtocolVersion: UInt32? = nil, + agentCapabilities: [DoryAgentCapability] = [], agentSocketPath: String? = nil, dockerdSocketPath: String? = nil, shellSocketPath: String? = nil, @@ -368,6 +372,8 @@ public struct DoryMachineStatus: Sendable, Equatable { self.lastError = lastError self.handoffSocketPath = handoffSocketPath self.agentBuild = agentBuild + self.agentProtocolVersion = agentProtocolVersion + self.agentCapabilities = agentCapabilities self.agentSocketPath = agentSocketPath self.dockerdSocketPath = dockerdSocketPath self.shellSocketPath = shellSocketPath @@ -3983,6 +3989,8 @@ public final class MachineManager: @unchecked Sendable { lastError: entry.lastError, handoffSocketPath: entry.handoffServer?.path, agentBuild: entry.handoff?.ready.agentBuild, + agentProtocolVersion: entry.handoff?.ready.agentProtocolVersion, + agentCapabilities: entry.handoff?.ready.agentCapabilities ?? [], agentSocketPath: entry.handoff?.ready.agentSocketPath, dockerdSocketPath: entry.handoff?.ready.dockerdSocketPath, shellSocketPath: entry.handoff?.ready.shellSocketPath, diff --git a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift index 2d8809d8..73e07f07 100644 --- a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift +++ b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift @@ -1,9 +1,12 @@ import Darwin +import DoryCore import Foundation public struct VmmReadyMessage: Sendable, Equatable, Codable { public var machineID: String public var agentBuild: String? + public var agentProtocolVersion: UInt32? + public var agentCapabilities: [DoryAgentCapability] public var agentSocketPath: String? public var dockerdSocketPath: String? public var shellSocketPath: String? @@ -13,6 +16,8 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable { public init( machineID: String, agentBuild: String? = nil, + agentProtocolVersion: UInt32? = nil, + agentCapabilities: [DoryAgentCapability] = [], agentSocketPath: String? = nil, dockerdSocketPath: String? = nil, shellSocketPath: String? = nil, @@ -21,6 +26,8 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable { ) { self.machineID = machineID self.agentBuild = agentBuild + self.agentProtocolVersion = agentProtocolVersion + self.agentCapabilities = agentCapabilities self.agentSocketPath = agentSocketPath self.dockerdSocketPath = dockerdSocketPath self.shellSocketPath = shellSocketPath diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index cc5784ba..3c96025c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -723,6 +723,8 @@ final class DorydServiceTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", controlSocketPath: "/run/control.sock" @@ -741,6 +743,20 @@ final class DorydServiceTests: XCTestCase { pressure: .critical ))) ) + let machineStatus = expectation(description: "versioned machine tools status") + service.machineList { rows, message in + XCTAssertEqual(message, "") + let status = (rows as? [NSDictionary])?.first + XCTAssertEqual( + (status?["agentProtocolVersion"] as? NSNumber)?.uint32Value, + DoryCore.protocolVersion() + ) + let capabilities = status?["agentCapabilities"] as? [NSDictionary] + XCTAssertEqual(capabilities?.first?["id"] as? String, "exec") + XCTAssertEqual((capabilities?.first?["version"] as? NSNumber)?.uint32Value, 1) + machineStatus.fulfill() + } + wait(for: [machineStatus], timeout: 5) let listener = makeAnonymousListener(service: service) listener.resume() defer { listener.invalidate() } diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index 10c4eacb..7cd6cfc2 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -434,6 +434,46 @@ final class HealthReporterTests: XCTestCase { ) == true) } + func testMachineToolsHealthRequiresCanonicalVersionedCapabilities() { + let capabilities = [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry", + ].map { DoryAgentCapability(id: $0, version: 1) } + let ready = HealthReporter.machineToolsCheck(DoryMachineStatus( + id: "ready", + state: .running, + agentBuild: "dory-agent/1.0", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: capabilities + )) + XCTAssertEqual(ready.status, .pass) + XCTAssertEqual(ready.code, "machine.tools.ready") + XCTAssertEqual(ready.data["capabilities"], capabilities.map { + "\($0.id)@\($0.version)" + }.joined(separator: ",")) + + let legacy = HealthReporter.machineToolsCheck(DoryMachineStatus( + id: "legacy", + state: .running, + agentBuild: "dory-agent/0.9", + agentProtocolVersion: DoryCore.protocolVersion() + )) + XCTAssertEqual(legacy.status, .warn) + XCTAssertEqual(legacy.code, "machine.tools.legacy_handshake") + + let invalid = HealthReporter.machineToolsCheck(DoryMachineStatus( + id: "invalid", + state: .running, + agentBuild: "dory-agent/tampered", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "exec", version: 2), + ] + )) + XCTAssertEqual(invalid.status, .fail) + XCTAssertEqual(invalid.code, "machine.tools.invalid_handshake") + } + func testInvalidRuntimeIdentityFailsClosedWithoutProjectingUntrustedPlanFields() { let plan = healthResolvedPlan() let identity = DoryMachineRuntimeIdentity( diff --git a/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift b/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift index 1e2802b9..adee75b3 100644 --- a/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift @@ -1,4 +1,5 @@ import Darwin +import DoryCore @testable import DorydKit import XCTest @@ -26,6 +27,8 @@ final class VmmHandoffTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: 1, + agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", shellSocketPath: "/run/shell.sock", @@ -38,6 +41,11 @@ final class VmmHandoffTests: XCTestCase { let handoff = try resultBox.get() XCTAssertEqual(handoff.ready.machineID, "dev") XCTAssertEqual(handoff.ready.agentBuild, "dory-agent/test") + XCTAssertEqual(handoff.ready.agentProtocolVersion, 1) + XCTAssertEqual( + handoff.ready.agentCapabilities, + [DoryAgentCapability(id: "exec", version: 1)] + ) XCTAssertEqual(handoff.ready.agentSocketPath, "/run/agent.sock") XCTAssertEqual(handoff.ready.shellSocketPath, "/run/shell.sock") XCTAssertEqual(handoff.fileDescriptors.count, 1) From c13f1d2fb40abb7bde94227772b254627f37de91 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:09:37 +0000 Subject: [PATCH 059/338] feat(app): show guest tools capability state --- Dory/Models/AppStore.swift | 2 + Dory/Models/Models.swift | 68 +++++++++++++++++--- Dory/Runtime/Doryd/DorydClient.swift | 76 +++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 93 ++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 7 deletions(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 0ee230e6..12147c08 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5193,6 +5193,8 @@ final class AppStore { installerMediaAttached: status.installerMediaAttached, runtimeIdentity: status.runtimeIdentity, agentBuild: status.agentBuild, + agentProtocolVersion: status.agentProtocolVersion, + agentCapabilities: status.agentCapabilities, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index bc50ab9d..0eadc18c 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -303,6 +303,8 @@ struct Machine: Identifiable, Hashable, Sendable { var installerMediaAttached: Bool = false var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var agentBuild: String? = nil + var agentProtocolVersion: UInt32? = nil + var agentCapabilities: [DorydAgentCapability] = [] var mounts: [MountPair] = [] var id: String { name } @@ -369,16 +371,68 @@ struct Machine: Identifiable, Hashable, Sendable { detail: "Legacy compatibility launch authority" )) } - evidence.append(MachineRuntimeEvidence( - id: "tools", - label: agentBuild == nil ? "Tools unavailable" : "Tools ready", - systemImage: agentBuild == nil ? "wrench.and.screwdriver" : "wrench.and.screwdriver.fill", - tone: agentBuild == nil ? .warning : .positive, - detail: agentBuild ?? "The guest has not reported a Dory Tools build" - )) + evidence.append(toolsRuntimeEvidence) return evidence } + private var toolsRuntimeEvidence: MachineRuntimeEvidence { + guard let agentBuild, !agentBuild.isEmpty else { + return MachineRuntimeEvidence( + id: "tools", + label: "Tools unavailable", + systemImage: "wrench.and.screwdriver", + tone: .warning, + detail: "The guest has not reported a Dory Tools handshake" + ) + } + guard let agentProtocolVersion else { + return MachineRuntimeEvidence( + id: "tools", + label: "Tools unversioned", + systemImage: "wrench.and.screwdriver", + tone: .warning, + detail: "\(agentBuild) does not report a protocol contract" + ) + } + guard agentProtocolVersion == 1 else { + return MachineRuntimeEvidence( + id: "tools", + label: "Tools incompatible", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: "\(agentBuild) uses unsupported protocol \(agentProtocolVersion)" + ) + } + guard !agentCapabilities.isEmpty else { + return MachineRuntimeEvidence( + id: "tools", + label: "Tools need an update", + systemImage: "arrow.triangle.2.circlepath", + tone: .warning, + detail: "\(agentBuild) does not report versioned capabilities" + ) + } + let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) }) + let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"] + let missing = required.filter { (versions[$0] ?? 0) < 1 } + guard missing.isEmpty else { + return MachineRuntimeEvidence( + id: "tools", + label: "Tools partially ready", + systemImage: "wrench.and.screwdriver", + tone: .warning, + detail: "\(agentBuild) is missing \(missing.joined(separator: ", "))" + ) + } + return MachineRuntimeEvidence( + id: "tools", + label: "Tools ready", + systemImage: "wrench.and.screwdriver.fill", + tone: .positive, + detail: "\(agentBuild) · \(agentCapabilities.count) versioned capabilities" + ) + } + private static func backendLabel(_ backend: String) -> String { switch backend { case "dory-hypervisor": "Raw HV" diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 540cd9d9..2dfa94cd 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -747,6 +747,16 @@ nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equa } } +nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable { + var id: String + var version: UInt32 + + var isValid: Bool { + version > 0 && id.utf8.count <= 63 + && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil + } +} + nonisolated struct DorydMachineStatus: Sendable, Equatable { var id: String var state: String @@ -754,6 +764,8 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var lastError: String? var handoffSocketPath: String? var agentBuild: String? + var agentProtocolVersion: UInt32? = nil + var agentCapabilities: [DorydAgentCapability] = [] var agentSocketPath: String? var dockerdSocketPath: String? var shellSocketPath: String? @@ -1850,6 +1862,9 @@ nonisolated final class DorydClient: @unchecked Sendable { ) else { return nil } + guard let agentHandshake = machineAgentHandshake(from: dictionary) else { + return nil + } return DorydMachineStatus( id: id, state: state, @@ -1857,6 +1872,8 @@ nonisolated final class DorydClient: @unchecked Sendable { lastError: nonEmptyString(dictionary["lastError"]), handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]), agentBuild: nonEmptyString(dictionary["agentBuild"]), + agentProtocolVersion: agentHandshake.protocolVersion, + agentCapabilities: agentHandshake.capabilities, agentSocketPath: nonEmptyString(dictionary["agentSocketPath"]), dockerdSocketPath: nonEmptyString(dictionary["dockerdSocketPath"]), shellSocketPath: nonEmptyString(dictionary["shellSocketPath"]), @@ -1881,6 +1898,65 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + private struct ParsedMachineAgentHandshake { + var protocolVersion: UInt32? + var capabilities: [DorydAgentCapability] + } + + nonisolated private static func machineAgentHandshake( + from dictionary: NSDictionary + ) -> ParsedMachineAgentHandshake? { + let encodedProtocol = dictionary["agentProtocolVersion"] + let encodedCapabilities = dictionary["agentCapabilities"] + guard encodedProtocol != nil || encodedCapabilities != nil else { + return ParsedMachineAgentHandshake(protocolVersion: nil, capabilities: []) + } + guard let encodedProtocol, + dictionary["agentBuild"] is String, + let number = encodedProtocol as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.uint64Value > 0, + number.uint64Value <= UInt64(UInt32.max) else { + return nil + } + let protocolVersion = UInt32(number.uint64Value) + guard let encodedCapabilities else { + return ParsedMachineAgentHandshake(protocolVersion: protocolVersion, capabilities: []) + } + guard let rawCapabilities = encodedCapabilities as? NSArray else { return nil } + var capabilities: [DorydAgentCapability] = [] + capabilities.reserveCapacity(rawCapabilities.count) + for encoded in rawCapabilities { + guard let raw = encoded as? NSDictionary, + let keys = raw.allKeys as? [String], + Set(keys) == ["id", "version"], + keys.count == 2, + let id = raw["id"] as? String, + let versionNumber = raw["version"] as? NSNumber, + CFGetTypeID(versionNumber) != CFBooleanGetTypeID(), + versionNumber.doubleValue.rounded(.towardZero) == versionNumber.doubleValue, + versionNumber.uint64Value > 0, + versionNumber.uint64Value <= UInt64(UInt32.max) else { + return nil + } + let capability = DorydAgentCapability( + id: id, + version: UInt32(versionNumber.uint64Value) + ) + guard capability.isValid else { return nil } + capabilities.append(capability) + } + guard capabilities == capabilities.sorted(by: { $0.id < $1.id }), + Set(capabilities.map(\.id)).count == capabilities.count else { + return nil + } + return ParsedMachineAgentHandshake( + protocolVersion: protocolVersion, + capabilities: capabilities + ) + } + private struct ParsedMachineTypedSettings { var value: DorydMachineTypedSettings? } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 4654848e..39263b05 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -545,6 +545,10 @@ struct DorydClientTests { ) let machine = AppStore.machine(fromDoryd: status) #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") + #expect(machine.agentProtocolVersion == 1) + #expect(machine.agentCapabilities.map(\.id) == [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry", + ]) #expect(machine.runtimeEvidence.map(\.label) == [ "Supported", "Raw HV", "Qualified 3D", "Tools ready", ]) @@ -559,9 +563,62 @@ struct DorydClientTests { invalidationReason: "restored-snapshot" ) replanning.agentBuild = nil + replanning.agentProtocolVersion = nil + replanning.agentCapabilities = [] #expect(replanning.runtimeEvidence.map(\.label) == [ "Needs planning", "Tools unavailable", ]) + + var legacyHandshake = machine + legacyHandshake.agentProtocolVersion = nil + legacyHandshake.agentCapabilities = [] + #expect(legacyHandshake.runtimeEvidence.last?.label == "Tools unversioned") + + var partialHandshake = machine + partialHandshake.agentCapabilities = [DorydAgentCapability(id: "exec", version: 1)] + #expect(partialHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(partialHandshake.runtimeEvidence.last?.detail.contains("clock-sync") == true) + + var incompatibleHandshake = machine + incompatibleHandshake.agentProtocolVersion = 2 + #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") + } + + @Test func machineCapabilityHandshakeRejectsMalformedPresentClaims() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = try #require((try await client.machineList()).first) + #expect(valid.agentProtocolVersion == 1) + #expect(valid.agentCapabilities.count == 6) + + let malformed: [(Any?, Any?)] = [ + (nil, [["id": "exec", "version": 1] as NSDictionary]), + (true, nil), + (1, [["id": "exec", "version": 1, "unknown": "claim"] as NSDictionary]), + (1, [ + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ]), + ] + for (protocolVersion, capabilities) in malformed { + service.setMachineAgentHandshake( + "dev", + protocolVersion: protocolVersion, + capabilities: capabilities + ) + do { + _ = try await client.machineList() + Issue.record("present malformed capability handshake must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + } } @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws { @@ -2475,6 +2532,27 @@ private final class FakeDorydService: NSObject, DorydControlXPC { current["displayMode"] = "desktop" machines[machineID] = current.copy() as? NSDictionary } + + func setMachineAgentHandshake( + _ machineID: String, + protocolVersion: Any?, + capabilities: Any? + ) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current.removeObject(forKey: "agentProtocolVersion") + current.removeObject(forKey: "agentCapabilities") + if let protocolVersion { + current["agentProtocolVersion"] = protocolVersion + } + if let capabilities { + current["agentCapabilities"] = capabilities + } + machines[machineID] = current + } var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount @@ -3391,6 +3469,15 @@ private final class FakeDorydService: NSObject, DorydControlXPC { state: String, pid: Int32? = nil, agentBuild: String? = nil, + agentProtocolVersion: UInt32? = 1, + agentCapabilities: [NSDictionary] = [ + ["id": "clock-sync", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec-stdin", "version": 1] as NSDictionary, + ["id": "ports-watch", "version": 1] as NSDictionary, + ["id": "sync-push", "version": 1] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], handoffFDCount: Int = 0, memoryMB: UInt64 = 2048, cpuCount: Int = 2, @@ -3411,6 +3498,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { if let pid { row["pid"] = pid } if let agentBuild { row["agentBuild"] = agentBuild + if let agentProtocolVersion { + row["agentProtocolVersion"] = agentProtocolVersion + if !agentCapabilities.isEmpty { + row["agentCapabilities"] = agentCapabilities + } + } row["handoffSocketPath"] = "/tmp/handoff.sock" row["agentSocketPath"] = "/tmp/agent.sock" row["dockerdSocketPath"] = "/tmp/dockerd.sock" From e7e1e47e08c2fddf913d3ea17d5dcbdcfd3e7589 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:17:06 +0000 Subject: [PATCH 060/338] feat(guest): enforce declared agent capabilities --- .../Sources/DorydKit/MachineManager.swift | 56 ++++++++++- .../DorydKitTests/DorydServiceTests.swift | 14 ++- .../DorydKitTests/MachineManagerTests.swift | 96 ++++++++++++++++++- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 95a016cc..1b656263 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -621,6 +621,7 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert case unknownSnapshot(String) case alreadyRunning(String) case agentUnavailable(String) + case agentCapabilityUnavailable(String, String) case balloonUnavailable(String) case balloonApplyFailed(String, String) case invalidAddress(String) @@ -644,6 +645,8 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert return "machine is already running: \(id)" case let .agentUnavailable(id): return "machine agent is unavailable: \(id)" + case let .agentCapabilityUnavailable(id, capability): + return "machine agent capability is unavailable for \(id): \(capability)" case let .balloonUnavailable(id): return "machine balloon control is unavailable: \(id)" case let .balloonApplyFailed(id, message): @@ -4709,7 +4712,7 @@ public final class MachineManager: @unchecked Sendable { } public func telemetry(id: String) throws -> DoryTelemetry { - try withAgentClient(id: id) { client in + try withAgentClient(id: id, requiredCapability: "telemetry") { client in try client.telemetry() } } @@ -4800,7 +4803,7 @@ public final class MachineManager: @unchecked Sendable { guard !argv.isEmpty else { throw MachineManagerError.agentUnavailable(id) } - return try withAgentClient(id: id) { client in + return try withAgentClient(id: id, requiredCapability: "exec") { client in try client.exec( argv: argv, cwd: cwd, @@ -4813,6 +4816,7 @@ public final class MachineManager: @unchecked Sendable { private func withAgentClient( id: String, + requiredCapability: String? = nil, _ operation: (any AgentControlClient) throws -> T ) throws -> T { guard let status = status(id: id) else { @@ -4821,6 +4825,10 @@ public final class MachineManager: @unchecked Sendable { guard status.state == .running, let socketPath = status.agentSocketPath else { throw MachineManagerError.agentUnavailable(id) } + if let requiredCapability, + !status.supportsAgentCapability(requiredCapability) { + throw MachineManagerError.agentCapabilityUnavailable(id, requiredCapability) + } let client = try agentConnector(socketPath) defer { client.close() } return try operation(client) @@ -6273,6 +6281,16 @@ public final class MachineManager: @unchecked Sendable { launchID: UUID, agentSocketPath: String ) { + lock.lock() + let mayExecute = machines[machineID].map { entry in + entry.launchID == launchID + && entry.state == .running + && entry.handoff?.ready.agentSocketPath == agentSocketPath + && entry.handoff?.ready.supportsAgentCapability("exec") == true + } ?? false + lock.unlock() + guard mayExecute else { return } + let address: String do { let client = try agentConnector(agentSocketPath) @@ -9100,11 +9118,11 @@ private struct MachineEntry { extension MachineManager: WakeClockSyncing { public func syncAgentClock(now: Date) -> AgentClockSyncResult { - let runningAgents = list().compactMap { status -> (id: String, socketPath: String)? in + let runningAgents = list().compactMap { status -> (id: String, socketPath: String, supported: Bool)? in guard status.state == .running, let socketPath = status.agentSocketPath else { return nil } - return (status.id, socketPath) + return (status.id, socketPath, status.supportsAgentCapability("clock-sync")) } guard !runningAgents.isEmpty else { return AgentClockSyncResult(name: "machines", attempted: false, synced: false) @@ -9114,6 +9132,10 @@ extension MachineManager: WakeClockSyncing { var failures: [String] = [] var syncedCount = 0 for agent in runningAgents { + guard agent.supported else { + failures.append("\(agent.id): clock-sync capability unavailable") + continue + } do { let client = try agentConnector(agent.socketPath) defer { client.close() } @@ -9135,3 +9157,29 @@ extension MachineManager: WakeClockSyncing { ) } } + +private extension DoryMachineStatus { + func supportsAgentCapability(_ id: String, minimumVersion: UInt32 = 1) -> Bool { + guard agentBuild?.isEmpty == false, + agentProtocolVersion == DoryCore.protocolVersion(), + agentCapabilities.allSatisfy(\.isValid), + agentCapabilities == agentCapabilities.sorted(by: { $0.id < $1.id }), + Set(agentCapabilities.map(\.id)).count == agentCapabilities.count else { + return false + } + return agentCapabilities.contains { $0.id == id && $0.version >= minimumVersion } + } +} + +private extension VmmReadyMessage { + func supportsAgentCapability(_ id: String, minimumVersion: UInt32 = 1) -> Bool { + guard agentBuild?.isEmpty == false, + agentProtocolVersion == DoryCore.protocolVersion(), + agentCapabilities.allSatisfy(\.isValid), + agentCapabilities == agentCapabilities.sorted(by: { $0.id < $1.id }), + Set(agentCapabilities.map(\.id)).count == agentCapabilities.count else { + return false + } + return agentCapabilities.contains { $0.id == id && $0.version >= minimumVersion } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 3c96025c..747fe0ca 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -724,7 +724,10 @@ final class DorydServiceTests: XCTestCase { machineID: "dev", agentBuild: "dory-agent/test", agentProtocolVersion: DoryCore.protocolVersion(), - agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)], + agentCapabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", controlSocketPath: "/run/control.sock" @@ -813,6 +816,11 @@ final class DorydServiceTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", controlSocketPath: "/run/control.sock" @@ -1628,6 +1636,8 @@ final class DorydServiceTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", shellSocketPath: "/run/shell.sock" @@ -1704,6 +1714,8 @@ final class DorydServiceTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)], agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock" ), diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 22091369..ad24d8cc 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -6,6 +6,10 @@ import DoryOperations import XCTest final class MachineManagerTests: XCTestCase { + private static func agentCapabilities(_ ids: String...) -> [DoryAgentCapability] { + ids.sorted().map { DoryAgentCapability(id: $0, version: 1) } + } + func testShareArgumentsRoundTripDelimiterHeavyPathsAndJSON() throws { let shares = [ DoryMachineShareConfiguration( @@ -3167,6 +3171,10 @@ final class MachineManagerTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities( + "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry" + ), agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", shellSocketPath: "/run/shell.sock" @@ -3251,7 +3259,13 @@ final class MachineManagerTests: XCTestCase { let starting = try manager.start(id: "dev") try sendVmmHandoff( path: try XCTUnwrap(starting.handoffSocketPath), - ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"), + ready: VmmReadyMessage( + machineID: "dev", + agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities("exec"), + agentSocketPath: "/run/agent.sock" + ), fileDescriptors: [] ) @@ -3296,7 +3310,13 @@ final class MachineManagerTests: XCTestCase { let starting = try manager.start(id: "dev") try sendVmmHandoff( path: try XCTUnwrap(starting.handoffSocketPath), - ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"), + ready: VmmReadyMessage( + machineID: "dev", + agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities("exec"), + agentSocketPath: "/run/agent.sock" + ), fileDescriptors: [] ) @@ -3342,7 +3362,13 @@ final class MachineManagerTests: XCTestCase { let starting = try manager.start(id: "dev") try sendVmmHandoff( path: try XCTUnwrap(starting.handoffSocketPath), - ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"), + ready: VmmReadyMessage( + machineID: "dev", + agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities("exec"), + agentSocketPath: "/run/agent.sock" + ), fileDescriptors: [] ) _ = try waitForRecordedExecs(connector, count: 1) @@ -3671,6 +3697,8 @@ final class MachineManagerTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities("clock-sync", "exec"), agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", controlSocketPath: "/run/control.sock" @@ -3717,6 +3745,8 @@ final class MachineManagerTests: XCTestCase { ready: VmmReadyMessage( machineID: "dev", agentBuild: "dory-agent/test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: Self.agentCapabilities("exec"), agentSocketPath: "/run/agent.sock", dockerdSocketPath: "/run/docker.sock", controlSocketPath: "/run/control.sock" @@ -3755,6 +3785,58 @@ final class MachineManagerTests: XCTestCase { ]) } + func testAgentOperationsRequireAdvertisedCapabilitiesBeforeConnecting() throws { + let base = "/tmp/dory-machine-capability-gate-\(getpid())-\(UInt32.random(in: 0.. Date: Thu, 20 Aug 2026 23:19:30 +0000 Subject: [PATCH 061/338] feat(agent): negotiate capabilities before control calls --- .../Sources/DorydKit/AgentControl.swift | 66 +++++++++++++-- .../DorydKitTests/AgentControlTests.swift | 81 ++++++++++++++++++- .../DorydKitTests/DorydServiceTests.swift | 11 ++- 3 files changed, 145 insertions(+), 13 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index 8ff581f9..46bb6efd 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -72,6 +72,7 @@ public final class AgentControl: @unchecked Sendable { private let connector: Connector private let lock = NSLock() private var client: (any AgentControlClient)? + private var negotiatedInfo: DoryAgentInfo? public init( configuration: AgentControlConfiguration, @@ -97,7 +98,8 @@ public final class AgentControl: @unchecked Sendable { } public func info() throws -> DoryAgentInfo { - try connectedClient().info() + let client = try connectedClient() + return try cachedInfo(from: client) } public func clockSync(now: Date = Date()) throws -> Bool { @@ -105,15 +107,16 @@ public final class AgentControl: @unchecked Sendable { } public func clockSync(hostEpochNs: Int64) throws -> Bool { - try connectedClient().clockSync(hostEpochNs: hostEpochNs) + let client = try client(requiring: "clock-sync") + return try client.clockSync(hostEpochNs: hostEpochNs) } public func portsWatch() throws -> DoryPortsSnapshot { - try connectedClient().portsWatch() + try client(requiring: "ports-watch").portsWatch() } public func telemetry() throws -> DoryTelemetry { - try connectedClient().telemetry() + try client(requiring: "telemetry").telemetry() } public func exec( @@ -123,7 +126,7 @@ public final class AgentControl: @unchecked Sendable { timeoutMs: UInt64 = 30_000, outputLimitBytes: UInt64 = 1024 * 1024 ) throws -> DoryExecResult { - try connectedClient().exec( + try client(requiring: "exec").exec( argv: argv, cwd: cwd, env: env, @@ -140,7 +143,9 @@ public final class AgentControl: @unchecked Sendable { timeoutMs: UInt64 = 30_000, outputLimitBytes: UInt64 = 1024 * 1024 ) throws -> DoryExecResult { - try connectedClient().execWithInput( + let client = try client(requiring: "exec") + try requireCapability("exec-stdin", from: client) + return try client.execWithInput( argv: argv, stdin: stdin, cwd: cwd, @@ -154,6 +159,7 @@ public final class AgentControl: @unchecked Sendable { lock.lock() let current = client client = nil + negotiatedInfo = nil lock.unlock() current?.close() } @@ -179,13 +185,59 @@ public final class AgentControl: @unchecked Sendable { return existing } + private func client(requiring capability: String) throws -> any AgentControlClient { + let client = try connectedClient() + try requireCapability(capability, from: client) + return client + } + + private func requireCapability( + _ capability: String, + from client: any AgentControlClient + ) throws { + let info = try cachedInfo(from: client) + guard info.protocolVersion == DoryCore.protocolVersion() else { + throw AgentControlError.incompatibleProtocol( + expected: DoryCore.protocolVersion(), + actual: info.protocolVersion + ) + } + guard info.capabilitiesAreCanonical else { + throw AgentControlError.invalidCapabilities + } + guard info.supports(capability) else { + throw AgentControlError.capabilityUnavailable(capability) + } + } + + private func cachedInfo(from client: any AgentControlClient) throws -> DoryAgentInfo { + lock.lock() + if let negotiatedInfo { + lock.unlock() + return negotiatedInfo + } + lock.unlock() + + let fresh = try client.info() + lock.lock() + if negotiatedInfo == nil { + negotiatedInfo = fresh + } + let selected = negotiatedInfo! + lock.unlock() + return selected + } + deinit { disconnect() } } -public enum AgentControlError: Error, Sendable { +public enum AgentControlError: Error, Sendable, Equatable { case standardInputUnsupported + case incompatibleProtocol(expected: UInt32, actual: UInt32) + case invalidCapabilities + case capabilityUnavailable(String) } public enum LocalAgentControlError: Error, Sendable, Equatable, CustomStringConvertible { diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index a7bcc293..e36ca6cc 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -17,11 +17,14 @@ final class AgentControlTests: XCTestCase { XCTAssertEqual(counter.value, 0) let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") - XCTAssertEqual(info.capabilities, [DoryAgentCapability(id: "exec", version: 1)]) + XCTAssertEqual(info.capabilities.map(\.id), [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry", + ]) XCTAssertEqual(counter.value, 1) XCTAssertFalse(try control.clockSync(now: Date(timeIntervalSince1970: 1.5))) XCTAssertEqual(fake.clockSyncInputs, [1_500_000_000]) + XCTAssertEqual(try control.portsWatch().ports.first?.port, 8080) XCTAssertEqual(try control.telemetry().memTotalKB, 1024) let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp") XCTAssertEqual(exec.exitCode, 0) @@ -35,12 +38,73 @@ final class AgentControlTests: XCTestCase { control.disconnect() XCTAssertEqual(fake.closeCount, 1) } + + func testAgentControlRejectsMissingInvalidAndIncompatibleCapabilitiesBeforeCallingOperation() throws { + let missing = FakeAgentControlClient( + capabilities: [DoryAgentCapability(id: "exec", version: 1)] + ) + let missingControl = AgentControl( + configuration: AgentControlConfiguration(directSocketPath: "/tmp/missing.sock"), + connector: { _ in missing } + ) + XCTAssertThrowsError(try missingControl.telemetry()) { error in + XCTAssertEqual(error as? AgentControlError, .capabilityUnavailable("telemetry")) + } + XCTAssertEqual(missing.telemetryCalls, 0) + + let invalid = FakeAgentControlClient(capabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "exec", version: 1), + ]) + let invalidControl = AgentControl( + configuration: AgentControlConfiguration(directSocketPath: "/tmp/invalid.sock"), + connector: { _ in invalid } + ) + XCTAssertThrowsError(try invalidControl.exec(argv: ["/bin/true"])) { error in + XCTAssertEqual(error as? AgentControlError, .invalidCapabilities) + } + + let incompatible = FakeAgentControlClient( + protocolVersion: DoryCore.protocolVersion() + 1 + ) + let incompatibleControl = AgentControl( + configuration: AgentControlConfiguration(directSocketPath: "/tmp/incompatible.sock"), + connector: { _ in incompatible } + ) + XCTAssertThrowsError(try incompatibleControl.clockSync()) { error in + XCTAssertEqual( + error as? AgentControlError, + .incompatibleProtocol( + expected: DoryCore.protocolVersion(), + actual: DoryCore.protocolVersion() + 1 + ) + ) + } + XCTAssertTrue(incompatible.clockSyncInputs.isEmpty) + } } private final class FakeAgentControlClient: AgentControlClient, @unchecked Sendable { private let lock = NSLock() + private let protocolVersion: UInt32 + private let capabilities: [DoryAgentCapability] private var inputs: [Int64] = [] private var closes = 0 + private var telemetryCallCount = 0 + + init( + protocolVersion: UInt32 = DoryCore.protocolVersion(), + capabilities: [DoryAgentCapability] = [ + DoryAgentCapability(id: "clock-sync", version: 1), + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "exec-stdin", version: 1), + DoryAgentCapability(id: "ports-watch", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ] + ) { + self.protocolVersion = protocolVersion + self.capabilities = capabilities + } var clockSyncInputs: [Int64] { lock.lock() @@ -54,13 +118,19 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return closes } + var telemetryCalls: Int { + lock.lock() + defer { lock.unlock() } + return telemetryCallCount + } + func info() throws -> DoryAgentInfo { DoryAgentInfo( - protocolVersion: 1, + protocolVersion: protocolVersion, kernel: "Linux fake", agentBuild: "fake-agent", uptimeSeconds: 42, - capabilities: [DoryAgentCapability(id: "exec", version: 1)] + capabilities: capabilities ) } @@ -80,7 +150,10 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda } func telemetry() throws -> DoryTelemetry { - DoryTelemetry( + lock.lock() + telemetryCallCount += 1 + lock.unlock() + return DoryTelemetry( memTotalKB: 1024, memAvailableKB: 512, psiSomeAvg10: 0, diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 747fe0ca..e5a9ac84 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1941,10 +1941,17 @@ private final class ServiceFakeAgentControlClient: AgentControlClient, @unchecke func info() throws -> DoryAgentInfo { DoryAgentInfo( - protocolVersion: 1, + protocolVersion: DoryCore.protocolVersion(), kernel: "Linux docker", agentBuild: "docker-agent", - uptimeSeconds: 9 + uptimeSeconds: 9, + capabilities: [ + DoryAgentCapability(id: "clock-sync", version: 1), + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "exec-stdin", version: 1), + DoryAgentCapability(id: "ports-watch", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ] ) } From d4f68f2242cf7c0859cf5a87703773259cfcc505 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:21:02 +0000 Subject: [PATCH 062/338] feat(remote): enforce agent capability contracts --- .../DorydKit/RemoteMachineManager.swift | 37 +++++++- .../DorydKitTests/DorydServiceTests.swift | 9 +- .../RemoteMachineManagerTests.swift | 89 ++++++++++++++++++- 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift b/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift index cf9c9b64..cdaf418a 100644 --- a/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift @@ -111,6 +111,9 @@ public struct RemoteMachineStatus: Sendable, Equatable { public enum RemoteMachineError: Error, Sendable, Equatable { case unknownMachine(String) case notConnected(String) + case incompatibleProtocol(expected: UInt32, actual: UInt32) + case invalidCapabilities(String) + case capabilityUnavailable(String, String) } public final class RemoteMachineManager: @unchecked Sendable { @@ -157,6 +160,15 @@ public final class RemoteMachineManager: @unchecked Sendable { // The FFI call carries its own deadline (Rust AgentClient enforces 30s control / // 2min sync timeouts), so there is no host-side timeout to add here. info = try agent.info() + guard info.protocolVersion == DoryCore.protocolVersion() else { + throw RemoteMachineError.incompatibleProtocol( + expected: DoryCore.protocolVersion(), + actual: info.protocolVersion + ) + } + guard info.capabilitiesAreCanonical else { + throw RemoteMachineError.invalidCapabilities(configuration.id) + } } catch { // connector() already handed us a live handle; close it before we drop it so a // failing info() can't leak an fd/SSH session per retry. @@ -206,13 +218,21 @@ public final class RemoteMachineManager: @unchecked Sendable { localRoot: String, remoteRoot: String? = nil ) throws -> DoryPushStats { - let (agent, root) = try connectedAgent(id: id, remoteRoot: remoteRoot) + let (agent, root) = try connectedAgent( + id: id, + remoteRoot: remoteRoot, + requiredCapability: "sync-push" + ) // Per-call deadline is enforced in the Rust AgentClient; no host-side timeout to add. return try agent.push(localRoot: localRoot, remoteRoot: root) } public func telemetry(id: String) throws -> DoryTelemetry { - let (agent, _) = try connectedAgent(id: id, remoteRoot: nil) + let (agent, _) = try connectedAgent( + id: id, + remoteRoot: nil, + requiredCapability: "telemetry" + ) // Per-call deadline is enforced in the Rust AgentClient; no host-side timeout to add. let telemetry = try agent.telemetry() lock.lock() @@ -229,7 +249,11 @@ public final class RemoteMachineManager: @unchecked Sendable { timeoutMs: UInt64 = 30_000, outputLimitBytes: UInt64 = 1024 * 1024 ) throws -> DoryExecResult { - let (agent, _) = try connectedAgent(id: id, remoteRoot: nil) + let (agent, _) = try connectedAgent( + id: id, + remoteRoot: nil, + requiredCapability: "exec" + ) return try agent.exec( argv: argv, cwd: cwd, @@ -282,7 +306,8 @@ public final class RemoteMachineManager: @unchecked Sendable { private func connectedAgent( id: String, - remoteRoot: String? + remoteRoot: String?, + requiredCapability: String ) throws -> (any RemoteAgentClient, String) { lock.lock() guard let entry = machines[id] else { @@ -293,6 +318,10 @@ public final class RemoteMachineManager: @unchecked Sendable { lock.unlock() throw RemoteMachineError.notConnected(id) } + guard entry.info?.supports(requiredCapability) == true else { + lock.unlock() + throw RemoteMachineError.capabilityUnavailable(id, requiredCapability) + } let root = remoteRoot ?? entry.configuration.remoteRoot lock.unlock() return (agent, root) diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index e5a9ac84..9dd43469 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -2022,10 +2022,15 @@ private final class ServiceFakeRemoteAgentClient: RemoteAgentClient, @unchecked func info() throws -> DoryAgentInfo { DoryAgentInfo( - protocolVersion: 1, + protocolVersion: DoryCore.protocolVersion(), kernel: "Linux remote", agentBuild: "remote-agent", - uptimeSeconds: 7 + uptimeSeconds: 7, + capabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "sync-push", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ] ) } diff --git a/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift index 519b894c..c2ac30bd 100644 --- a/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift @@ -74,6 +74,64 @@ final class RemoteMachineManagerTests: XCTestCase { XCTAssertEqual(error as? RemoteMachineError, .unknownMachine("missing")) } } + + func testRemoteOperationsRequireAdvertisedCapabilitiesBeforeCallingAgent() throws { + let fake = FakeRemoteAgentClient(capabilities: [ + DoryAgentCapability(id: "exec", version: 1), + ]) + let manager = RemoteMachineManager( + keyStore: FakeSSHKeyStore(keys: ["primary": "OPENSSH-PRIVATE-KEY"]), + connector: { _ in fake } + ) + _ = try manager.connect(testConfiguration()) + + XCTAssertThrowsError(try manager.push(id: "prod", localRoot: "/tmp/local")) { error in + XCTAssertEqual( + error as? RemoteMachineError, + .capabilityUnavailable("prod", "sync-push") + ) + } + XCTAssertThrowsError(try manager.telemetry(id: "prod")) { error in + XCTAssertEqual( + error as? RemoteMachineError, + .capabilityUnavailable("prod", "telemetry") + ) + } + XCTAssertTrue(fake.pushes.isEmpty) + XCTAssertEqual(fake.telemetryCalls, 0) + } + + func testRemoteConnectRejectsInvalidOrIncompatibleHandshakeAndClosesAgent() { + for fixture in [ + FakeRemoteAgentClient( + capabilities: [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "exec", version: 1), + ] + ), + FakeRemoteAgentClient(protocolVersion: DoryCore.protocolVersion() + 1), + ] { + let manager = RemoteMachineManager( + keyStore: FakeSSHKeyStore(keys: ["primary": "OPENSSH-PRIVATE-KEY"]), + connector: { _ in fixture } + ) + XCTAssertThrowsError(try manager.connect(testConfiguration())) + XCTAssertEqual(fixture.closeCount, 1) + XCTAssertEqual(manager.status(id: "prod")?.state, .failed) + } + } + + private func testConfiguration() -> RemoteMachineConfiguration { + RemoteMachineConfiguration( + id: "prod", + host: "vps.example.com", + user: "dory", + privateKeyID: "primary", + hostKey: .pinned(opensshPublicKey: "ssh-ed25519 AAAA fake"), + endpoint: .unixSocket(path: "/run/dory/agent.sock"), + remoteRoot: "/srv/app" + ) + } } private enum FakeConnectError: Error { @@ -104,10 +162,23 @@ private final class FakeRemoteAgentClient: RemoteAgentClient, @unchecked Sendabl private let lock = NSLock() private var storedPushes: [Push] = [] private var closes = 0 + private var storedTelemetryCalls = 0 private let infoError: Error? + private let protocolVersion: UInt32 + private let capabilities: [DoryAgentCapability] - init(infoError: Error? = nil) { + init( + infoError: Error? = nil, + protocolVersion: UInt32 = DoryCore.protocolVersion(), + capabilities: [DoryAgentCapability] = [ + DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "sync-push", version: 1), + DoryAgentCapability(id: "telemetry", version: 1), + ] + ) { self.infoError = infoError + self.protocolVersion = protocolVersion + self.capabilities = capabilities } var pushes: [Push] { @@ -122,20 +193,30 @@ private final class FakeRemoteAgentClient: RemoteAgentClient, @unchecked Sendabl return closes } + var telemetryCalls: Int { + lock.lock() + defer { lock.unlock() } + return storedTelemetryCalls + } + func info() throws -> DoryAgentInfo { if let infoError { throw infoError } return DoryAgentInfo( - protocolVersion: 1, + protocolVersion: protocolVersion, kernel: "Linux remote", agentBuild: "remote-agent", - uptimeSeconds: 99 + uptimeSeconds: 99, + capabilities: capabilities ) } func telemetry() throws -> DoryTelemetry { - DoryTelemetry( + lock.lock() + storedTelemetryCalls += 1 + lock.unlock() + return DoryTelemetry( memTotalKB: 2048, memAvailableKB: 1024, psiSomeAvg10: 0.5, From 37b48bb287c40960df34cedaf493f798d05119d5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:23:48 +0000 Subject: [PATCH 063/338] feat(agent): surface capabilities across xpc --- Dory/Runtime/Doryd/DorydClient.swift | 26 +++++++++++++++---- DoryTests/DorydClientTests.swift | 16 ++++++++++++ .../Sources/DorydKit/DorydService.swift | 3 +++ .../DorydKitTests/DorydServiceTests.swift | 8 ++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 2dfa94cd..36bdaa5f 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -928,6 +928,7 @@ nonisolated struct DorydAgentInfo: Sendable, Equatable { var kernel: String var agentBuild: String var uptimeSeconds: UInt64 + var capabilities: [DorydAgentCapability] = [] } nonisolated struct DorydTelemetry: Sendable, Equatable { @@ -1924,6 +1925,16 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let encodedCapabilities else { return ParsedMachineAgentHandshake(protocolVersion: protocolVersion, capabilities: []) } + guard let capabilities = agentCapabilities(from: encodedCapabilities) else { return nil } + return ParsedMachineAgentHandshake( + protocolVersion: protocolVersion, + capabilities: capabilities + ) + } + + nonisolated private static func agentCapabilities( + from encodedCapabilities: Any + ) -> [DorydAgentCapability]? { guard let rawCapabilities = encodedCapabilities as? NSArray else { return nil } var capabilities: [DorydAgentCapability] = [] capabilities.reserveCapacity(rawCapabilities.count) @@ -1951,10 +1962,7 @@ nonisolated final class DorydClient: @unchecked Sendable { Set(capabilities.map(\.id)).count == capabilities.count else { return nil } - return ParsedMachineAgentHandshake( - protocolVersion: protocolVersion, - capabilities: capabilities - ) + return capabilities } private struct ParsedMachineTypedSettings { @@ -2427,11 +2435,19 @@ nonisolated final class DorydClient: @unchecked Sendable { let uptimeSeconds = uint64(dictionary["uptimeSeconds"]) else { return nil } + let capabilities: [DorydAgentCapability] + if let encodedCapabilities = dictionary["capabilities"] { + guard let parsed = agentCapabilities(from: encodedCapabilities) else { return nil } + capabilities = parsed + } else { + capabilities = [] + } return DorydAgentInfo( protocolVersion: protocolVersion, kernel: kernel, agentBuild: agentBuild, - uptimeSeconds: uptimeSeconds + uptimeSeconds: uptimeSeconds, + capabilities: capabilities ) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 39263b05..406a95e0 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -353,6 +353,9 @@ struct DorydClientTests { #expect(slept == DorydCommandResult(ok: true, message: "")) #expect(woke == DorydCommandResult(ok: true, message: "")) #expect(dockerAgentInfo.agentBuild == "docker-agent") + #expect(dockerAgentInfo.capabilities.map(\.id) == [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry", + ]) #expect(dockerAgentPorts.ports == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentPorts.added == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentTelemetry.memTotalKB == 2048) @@ -411,6 +414,7 @@ struct DorydClientTests { #expect(machines.map(\.id) == ["dev", "dev-copy"]) #expect(deletedMachine == DorydCommandResult(ok: true, message: "")) #expect(remoteInfo.agentBuild == "remote-agent") + #expect(remoteInfo.capabilities.map(\.id) == ["exec", "sync-push", "telemetry"]) #expect(pushStats == DorydPushStats(filesSent: 2, bytesSent: 30, filesDeleted: 1)) #expect(remoteStatus.telemetry?.memAvailableKB == 512) #expect(replacedRoutes == DorydCommandResult(ok: true, message: "")) @@ -3434,6 +3438,13 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "kernel": "Linux docker", "agentBuild": "docker-agent", "uptimeSeconds": 11, + "capabilities": [ + ["id": "clock-sync", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec-stdin", "version": 1] as NSDictionary, + ["id": "ports-watch", "version": 1] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], ] } @@ -3452,6 +3463,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "kernel": "Linux test", "agentBuild": "remote-agent", "uptimeSeconds": 9, + "capabilities": [ + ["id": "exec", "version": 1] as NSDictionary, + ["id": "sync-push", "version": 1] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], ] } diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 0e2228b3..2e5097c2 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1835,6 +1835,9 @@ private extension DoryAgentInfo { "kernel": kernel, "agentBuild": agentBuild, "uptimeSeconds": uptimeSeconds, + "capabilities": capabilities.map { + ["id": $0.id, "version": $0.version] as NSDictionary + }, ] } } diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 9dd43469..00b3736e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -444,6 +444,10 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(message, "") XCTAssertEqual(body["agentBuild"] as? String, "docker-agent") XCTAssertEqual(body["protocolVersion"] as? UInt32, 1) + XCTAssertEqual( + (body["capabilities"] as? [NSDictionary])?.compactMap { $0["id"] as? String }, + ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"] + ) infoReply.fulfill() } wait(for: [infoReply], timeout: 5) @@ -537,6 +541,10 @@ final class DorydServiceTests: XCTestCase { wait(for: [connect], timeout: 5) XCTAssertTrue(connectOK, connectMessage) XCTAssertEqual(info["agentBuild"] as? String, "remote-agent") + XCTAssertEqual( + (info["capabilities"] as? [NSDictionary])?.compactMap { $0["id"] as? String }, + ["exec", "sync-push", "telemetry"] + ) XCTAssertEqual(captured.value?.opensshPrivateKey, "PRIVATE") let push = expectation(description: "remotePush reply") From d23846df82da0db88a0c4daa1b34858dda9b408b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:41:39 +0000 Subject: [PATCH 064/338] feat(snapshot): quiesce capable guests --- .../Sources/DoryCore/DoryCore.swift | 8 ++ .../Sources/DorydKit/AgentControl.swift | 18 +++ .../Sources/DorydKit/MachineManager.swift | 131 ++++++++++++++---- .../DorydKitTests/MachineManagerTests.swift | 126 +++++++++++++++++ dory-core/agent/src/dispatch.rs | 42 +++--- dory-core/agent/src/handler.rs | 4 + dory-core/agent/src/lib.rs | 1 + dory-core/agent/src/snapshot_quiesce.rs | 77 ++++++++++ dory-core/ffi/src/agent_forward.rs | 29 ++++ dory-core/pb/proto/agent.proto | 18 +++ dory-core/remote/src/agent_client.rs | 21 ++- 11 files changed, 430 insertions(+), 45 deletions(-) create mode 100644 dory-core/agent/src/snapshot_quiesce.rs diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index 6e4cd425..f03e37c3 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -397,6 +397,14 @@ public final class DoryAgentControlHandle: @unchecked Sendable { ) } + public func snapshotFreeze() throws { + try withControl { try $0.snapshotFreeze() } + } + + public func snapshotThaw() throws { + try withControl { try $0.snapshotThaw() } + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index 46bb6efd..cbc827f3 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -25,6 +25,8 @@ public protocol AgentControlClient: Sendable { func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot func telemetry() throws -> DoryTelemetry + func snapshotFreeze() throws + func snapshotThaw() throws func exec( argv: [String], cwd: String, @@ -44,6 +46,14 @@ public protocol AgentControlClient: Sendable { } public extension AgentControlClient { + func snapshotFreeze() throws { + throw AgentControlError.capabilityUnavailable("snapshot-quiesce") + } + + func snapshotThaw() throws { + throw AgentControlError.capabilityUnavailable("snapshot-quiesce") + } + func execWithInput( argv: [String], stdin: Data, @@ -119,6 +129,14 @@ public final class AgentControl: @unchecked Sendable { try client(requiring: "telemetry").telemetry() } + public func snapshotFreeze() throws { + try client(requiring: "snapshot-quiesce").snapshotFreeze() + } + + public func snapshotThaw() throws { + try client(requiring: "snapshot-quiesce").snapshotThaw() + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 1b656263..8cf640f0 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -396,6 +396,11 @@ public struct DoryMachineStatus: Sendable, Equatable { } } +public enum DoryMachineSnapshotConsistency: String, Sendable, Equatable, Hashable, Codable { + case coldStopped = "cold-stopped" + case guestQuiesced = "guest-quiesced" +} + public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var id: String public var machineID: String @@ -418,6 +423,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var runtimeIdentity: DoryMachineRuntimeIdentity public var artifactEvidence: DoryMachineSnapshotArtifactEvidence? public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? + public var consistency: DoryMachineSnapshotConsistency public init( id: String, @@ -443,7 +449,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion ), artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil, - installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil, + consistency: DoryMachineSnapshotConsistency = .coldStopped ) { self.id = id self.machineID = machineID @@ -466,6 +473,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.runtimeIdentity = runtimeIdentity self.artifactEvidence = artifactEvidence self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt + self.consistency = consistency } private enum CodingKeys: String, CodingKey { @@ -490,6 +498,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case runtimeIdentity case artifactEvidence case installedDesktopPayloadReceipt + case consistency } public init(from decoder: Decoder) throws { @@ -530,7 +539,11 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { installedDesktopPayloadReceipt: try container.decodeIfPresent( DoryInstalledDesktopPayloadReceipt.self, forKey: .installedDesktopPayloadReceipt - ) + ), + consistency: try container.decodeIfPresent( + DoryMachineSnapshotConsistency.self, + forKey: .consistency + ) ?? .coldStopped ) } } @@ -3053,6 +3066,7 @@ public final class MachineManager: @unchecked Sendable { let (machine, sourceMachineState) = try configurationAndPowerState(id: id) let wasRunning = [.starting, .running].contains(sourceMachineState) let wasPaused = sourceMachineState == .paused + let wasResident = wasRunning || wasPaused let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id) try Self.validateLaunchConfiguration(machine) try ensurePrivateSnapshotDirectory(machineID: id) @@ -3067,22 +3081,38 @@ public final class MachineManager: @unchecked Sendable { !Self.pathEntryExists(nvramPath) else { throw MachineManagerError.duplicateSnapshot(snapshotID) } - // Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for - // the snapshot can be hashed and bound before any snapshot artifact is published. - if wasRunning { - _ = try stopImplementation( - id: id, - journalLifecycle: true, - preserveResolvedAdmissionForRestart: true - ) + // Prefer a negotiated guest freeze before the durable cold-stop boundary. A missing tools + // capability degrades to the existing cold snapshot; an advertised capability that fails + // is not silently ignored because the guest may already be partially frozen. + let guestQuiesced = sourceMachineState == .running + ? try freezeGuestForSnapshotIfSupported(id: id) : false + if wasResident { + do { + _ = try stopImplementation( + id: id, + journalLifecycle: true, + preserveResolvedAdmissionForRestart: true + ) + } catch { + if guestQuiesced { + do { try thawGuestAfterFailedSnapshotStop(id: id) } + catch let thawError { + throw MachineManagerError.persistence( + "could not stop \(id) for snapshot: \(error); guest thaw failed: \(thawError)" + ) + } + } + throw error + } } var snapshotLifecycleStarted = false defer { - if wasRunning, !snapshotLifecycleStarted { - try? prepareRetainedResolvedAdmissionForRestart( - plan: snapshotRuntimeIdentity.resolvedPlan + if wasResident, !snapshotLifecycleStarted { + try? restoreSnapshotSourcePowerState( + id: id, + wasPaused: wasPaused, + resolvedPlan: snapshotRuntimeIdentity.resolvedPlan ) - _ = try? startImplementation(id: id, journalLifecycle: true) } } let liveMachineIdentifierPath = machine.bootMode == .efi @@ -3129,7 +3159,8 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentity: snapshotRuntimeIdentity, artifactEvidence: artifactEvidence, installedDesktopPayloadReceipt: - machine.effectiveInstalledDesktopPayloadReceipt + machine.effectiveInstalledDesktopPayloadReceipt, + consistency: guestQuiesced ? .guestQuiesced : .coldStopped ) let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) let lifecycle = try beginLifecycleSnapshot( @@ -3186,11 +3217,12 @@ public final class MachineManager: @unchecked Sendable { try? FileManager.default.removeItem(atPath: nvramPath) } failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true) - if wasRunning { - try? prepareRetainedResolvedAdmissionForRestart( - plan: snapshotRuntimeIdentity.resolvedPlan + if wasResident { + try? restoreSnapshotSourcePowerState( + id: id, + wasPaused: wasPaused, + resolvedPlan: snapshotRuntimeIdentity.resolvedPlan ) - _ = try? startImplementation(id: id, journalLifecycle: true) } if let error = error as? MachineManagerError { throw error @@ -3202,18 +3234,20 @@ public final class MachineManager: @unchecked Sendable { lifecycle, diagnostic: "published snapshot has an unfinished snapshot journal" ) - if wasRunning, journalCompleted { + if wasResident, journalCompleted { do { - try prepareRetainedResolvedAdmissionForRestart( - plan: snapshotRuntimeIdentity.resolvedPlan + try restoreSnapshotSourcePowerState( + id: id, + wasPaused: wasPaused, + resolvedPlan: snapshotRuntimeIdentity.resolvedPlan ) - _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch let firstError { do { - try prepareRetainedResolvedAdmissionForRestart( - plan: snapshotRuntimeIdentity.resolvedPlan + try restoreSnapshotSourcePowerState( + id: id, + wasPaused: wasPaused, + resolvedPlan: snapshotRuntimeIdentity.resolvedPlan ) - _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) } catch { throw MachineManagerError.persistence( "snapshot \(snapshotID) was created, but \(id) could not restart: \(firstError); retry: \(error)" @@ -3224,6 +3258,51 @@ public final class MachineManager: @unchecked Sendable { return snapshot } + private func freezeGuestForSnapshotIfSupported(id: String) throws -> Bool { + guard status(id: id)?.supportsAgentCapability("snapshot-quiesce") == true else { + return false + } + do { + try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in + try client.snapshotFreeze() + } + } catch { + let freezeError = error + do { try thawGuestAfterFailedSnapshotStop(id: id) } + catch let thawError { + throw MachineManagerError.persistence( + "guest snapshot freeze failed for \(id): \(freezeError); thaw failed: \(thawError)" + ) + } + throw MachineManagerError.persistence( + "guest snapshot freeze failed for \(id): \(freezeError)" + ) + } + return true + } + + private func thawGuestAfterFailedSnapshotStop(id: String) throws { + try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in + try client.snapshotThaw() + } + } + + private func restoreSnapshotSourcePowerState( + id: String, + wasPaused: Bool, + resolvedPlan: DoryResolvedMachinePlan? + ) throws { + try prepareRetainedResolvedAdmissionForRestart(plan: resolvedPlan) + _ = try startAndWaitUntilReady(id: id, journalLifecycle: true) + if wasPaused { + // Readiness publishes `.running` before its callback can reacquire operationLock to + // terminalize the start journal. snapshot() already owns that recursive lock, so + // settle the committed start here before opening the pause lifecycle transaction. + completeActiveStartLifecycle(id: id) + _ = try pauseImplementation(id: id, journalLifecycle: true) + } + } + /// Applies a signed desktop component to an existing persistent guest. The caller provides /// only stable active-component generation identifiers; doryd resolves and re-verifies the /// exact component-store bytes and owns the entire transaction: a last-good disk/kernel diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index ad24d8cc..aa8ea66f 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3715,6 +3715,57 @@ final class MachineManagerTests: XCTestCase { XCTAssertEqual(connector.clockSyncs, [1_234_500_000_000]) } + func testSnapshotsQuiesceCapableGuestsAndColdStopPausedMachines() throws { + let base = "/tmp/dory-machine-snapshot-quiesce-\(getpid())-\(UInt32.random(in: 0.. DoryMachineSnapshot { + let result = LockedResult() + DispatchQueue.global(qos: .userInitiated).async { + result.store(Result { + try manager.snapshot(id: machineID, snapshotID: snapshotID) + }) + } + let deadline = Date().addingTimeInterval(15) + var handedOffPIDs: Set = [] + while Date() < deadline, result.value == nil { + if let status = manager.status(id: machineID), + status.state == .starting, + let pid = status.pid, + !handedOffPIDs.contains(pid), + let handoffPath = status.handoffSocketPath { + try sendSnapshotQuiesceHandoff(path: handoffPath, machineID: machineID) + handedOffPIDs.insert(pid) + } + Thread.sleep(forTimeInterval: 0.01) + } + return try XCTUnwrap(result.value, "snapshot timed out").get() +} + private func runDesktopUpdate( manager: MachineManager, id: String, @@ -4211,6 +4303,8 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable { private let lock = NSLock() private var paths: [String] = [] private var syncs: [Int64] = [] + private var freezes = 0 + private var thaws = 0 private var recordedExecs: [Exec] = [] private let telemetry: DoryTelemetry private let execResult: DoryExecResult @@ -4259,6 +4353,18 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable { return recordedExecs } + var snapshotFreezes: Int { + lock.lock() + defer { lock.unlock() } + return freezes + } + + var snapshotThaws: Int { + lock.lock() + defer { lock.unlock() } + return thaws + } + func connect(socketPath: String) throws -> any AgentControlClient { lock.lock() paths.append(socketPath) @@ -4282,6 +4388,18 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable { recordedExecs.append(exec) lock.unlock() } + + func recordSnapshotFreeze() { + lock.lock() + freezes += 1 + lock.unlock() + } + + func recordSnapshotThaw() { + lock.lock() + thaws += 1 + lock.unlock() + } } private final class RecordingMachineAgentClient: AgentControlClient, @unchecked Sendable { @@ -4326,6 +4444,14 @@ private final class RecordingMachineAgentClient: AgentControlClient, @unchecked telemetryValue } + func snapshotFreeze() throws { + recorder.recordSnapshotFreeze() + } + + func snapshotThaw() throws { + recorder.recordSnapshotThaw() + } + func exec( argv: [String], cwd: String, diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index ca1d43b7..a1881096 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -20,20 +20,25 @@ pub fn agent_build() -> String { } pub fn agent_capabilities() -> Vec { - [ + let mut capabilities = vec![ ("clock-sync", 1), ("exec", 1), ("exec-stdin", 1), ("ports-watch", 1), ("sync-push", 1), ("telemetry", 1), - ] - .into_iter() - .map(|(id, version)| agent::AgentCapability { - id: id.to_string(), - version, - }) - .collect() + ]; + if crate::snapshot_quiesce::available() { + capabilities.push(("snapshot-quiesce", 1)); + } + capabilities.sort_unstable_by_key(|(id, _)| *id); + capabilities + .into_iter() + .map(|(id, version)| agent::AgentCapability { + id: id.to_string(), + version, + }) + .collect() } pub fn err(code: i32, message: &str) -> agent::AgentResponse { @@ -190,19 +195,24 @@ mod tests { assert_eq!(i.proto_version, dory_proto::handshake::PROTO_VERSION); assert!(i.agent_build.starts_with("dory-agent/")); assert!(!i.kernel.is_empty()); + let mut expected_capabilities = vec![ + ("clock-sync", 1), + ("exec", 1), + ("exec-stdin", 1), + ("ports-watch", 1), + ("sync-push", 1), + ("telemetry", 1), + ]; + if crate::snapshot_quiesce::available() { + expected_capabilities.push(("snapshot-quiesce", 1)); + expected_capabilities.sort_unstable(); + } assert_eq!( i.capabilities .iter() .map(|capability| (capability.id.as_str(), capability.version)) .collect::>(), - vec![ - ("clock-sync", 1), - ("exec", 1), - ("exec-stdin", 1), - ("ports-watch", 1), - ("sync-push", 1), - ("telemetry", 1), - ] + expected_capabilities ); } other => panic!("expected Info, got {other:?}"), diff --git a/dory-core/agent/src/handler.rs b/dory-core/agent/src/handler.rs index 30907ee5..af71a4ee 100644 --- a/dory-core/agent/src/handler.rs +++ b/dory-core/agent/src/handler.rs @@ -10,6 +10,7 @@ use prost::Message; use crate::dispatch::{err, handle_method}; use crate::exec::{self, ExecError}; +use crate::snapshot_quiesce; use crate::sync_apply::{self, SyncError}; pub async fn handle(req_bytes: &[u8]) -> Vec { @@ -26,6 +27,9 @@ pub async fn handle(req_bytes: &[u8]) -> Vec { Some(Method::SyncPutChunk(r)) => wrap(sync_apply::put_chunk(r).await, Res::SyncPutChunk), Some(Method::SyncDelete(r)) => wrap(sync_apply::delete(r).await, Res::SyncDelete), Some(Method::Exec(r)) => wrap_exec(exec::run(r).await), + Some(Method::SnapshotQuiesce(r)) => AgentResponse { + result: Some(Res::SnapshotQuiesce(snapshot_quiesce::run(r).await)), + }, other => handle_method(other), }; response.encode_to_vec() diff --git a/dory-core/agent/src/lib.rs b/dory-core/agent/src/lib.rs index d0bd1bf2..1f20148c 100644 --- a/dory-core/agent/src/lib.rs +++ b/dory-core/agent/src/lib.rs @@ -16,6 +16,7 @@ pub mod fsevents; pub mod handler; pub mod proc_net; pub mod reaper; +pub mod snapshot_quiesce; pub mod sync_apply; pub mod telemetry; diff --git a/dory-core/agent/src/snapshot_quiesce.rs b/dory-core/agent/src/snapshot_quiesce.rs new file mode 100644 index 00000000..e1b72040 --- /dev/null +++ b/dory-core/agent/src/snapshot_quiesce.rs @@ -0,0 +1,77 @@ +use std::path::Path; +use std::time::Duration; + +use dory_pb::agent::{ + snapshot_quiesce_request::Action, SnapshotQuiesceRequest, SnapshotQuiesceResponse, +}; + +const QUIESCE_TIMEOUT: Duration = Duration::from_secs(20); + +pub fn available() -> bool { + binary().is_some() +} + +pub async fn run(request: SnapshotQuiesceRequest) -> SnapshotQuiesceResponse { + let action = match Action::try_from(request.action) { + Ok(Action::Freeze) => "-f", + Ok(Action::Thaw) => "-u", + _ => return response(false, "snapshot quiesce action is invalid"), + }; + let Some(binary) = binary() else { + return response(false, "filesystem freeze is unavailable in this guest"); + }; + + let mut command = tokio::process::Command::new(binary); + command.args([action, "/"]).kill_on_drop(true); + match tokio::time::timeout(QUIESCE_TIMEOUT, command.output()).await { + Ok(Ok(output)) if output.status.success() => response(true, ""), + Ok(Ok(output)) => { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + response( + false, + if detail.is_empty() { + "filesystem freeze command failed" + } else { + detail.as_str() + }, + ) + } + Ok(Err(error)) => response(false, &format!("filesystem freeze command failed: {error}")), + Err(_) => response(false, "filesystem freeze command timed out"), + } +} + +fn response(completed: bool, detail: &str) -> SnapshotQuiesceResponse { + SnapshotQuiesceResponse { + completed, + detail: detail.chars().take(512).collect(), + } +} + +#[cfg(target_os = "linux")] +fn binary() -> Option<&'static str> { + ["/usr/sbin/fsfreeze", "/sbin/fsfreeze"] + .into_iter() + .find(|candidate| Path::new(candidate).is_file()) +} + +#[cfg(not(target_os = "linux"))] +fn binary() -> Option<&'static str> { + let _ = Path::new("/"); + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_unspecified_action_without_touching_the_host() { + let result = run(SnapshotQuiesceRequest { + action: Action::Unspecified as i32, + }) + .await; + assert!(!result.completed); + assert!(result.detail.contains("invalid")); + } +} diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 262d10e1..fb75783f 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -164,6 +164,17 @@ impl AgentControl { }) } + pub fn snapshot_freeze(&self) -> Result<(), RemoteFfiError> { + snapshot_quiesce( + self, + dory_pb::agent::snapshot_quiesce_request::Action::Freeze, + ) + } + + pub fn snapshot_thaw(&self) -> Result<(), RemoteFfiError> { + snapshot_quiesce(self, dory_pb::agent::snapshot_quiesce_request::Action::Thaw) + } + pub fn exec( &self, argv: Vec, @@ -207,6 +218,24 @@ impl AgentControl { } } +fn snapshot_quiesce( + control: &AgentControl, + action: dory_pb::agent::snapshot_quiesce_request::Action, +) -> Result<(), RemoteFfiError> { + let guard = control.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let result = runtime.block_on(control.client.snapshot_quiesce(action))?; + if result.completed { + Ok(()) + } else { + Err(failed(if result.detail.is_empty() { + "guest declined snapshot quiesce" + } else { + result.detail.as_str() + })) + } +} + impl Drop for AgentControl { fn drop(&mut self) { if let Some(runtime) = self.runtime.lock().unwrap().take() { diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 451b2567..0e81fab4 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -58,6 +58,22 @@ message TelemetryResponse { double psi_full_avg10 = 4; // memory-pressure "full" 10s average, percent (0..100) } +// Coordinate application-consistent snapshots. The agent advertises `snapshot-quiesce@1` only +// when the guest contains a usable filesystem-freeze implementation. Freeze and thaw are one +// negotiated capability because callers must always be able to undo a failed snapshot attempt. +message SnapshotQuiesceRequest { + enum Action { + ACTION_UNSPECIFIED = 0; + FREEZE = 1; + THAW = 2; + } + Action action = 1; +} +message SnapshotQuiesceResponse { + bool completed = 1; + string detail = 2; +} + // Run a bounded non-interactive command in the guest. This is the primitive doryd uses for // VM-machine provisioning and smoke checks; streaming terminals/debug shells can layer on a // separate channel later. @@ -147,6 +163,7 @@ message AgentRequest { SyncDeleteRequest sync_delete = 7; TelemetryRequest telemetry = 8; ExecRequest exec = 9; + SnapshotQuiesceRequest snapshot_quiesce = 10; } } @@ -162,5 +179,6 @@ message AgentResponse { SyncDeleteResponse sync_delete = 8; TelemetryResponse telemetry = 9; ExecResponse exec = 10; + SnapshotQuiesceResponse snapshot_quiesce = 11; } } diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index cd269dca..240218ec 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -12,9 +12,9 @@ use std::time::Duration; use dory_pb::agent::{ self, agent_request::Method, agent_response::Result as Res, AgentRequest, AgentResponse, ClockSyncRequest, ExecEnv, ExecRequest, ExecResponse, InfoRequest, PortsWatchRequest, - SyncDeleteRequest, SyncDeleteResponse, SyncFileStatusRequest, SyncFileStatusResponse, - SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse, - TelemetryRequest, TelemetryResponse, + SnapshotQuiesceRequest, SnapshotQuiesceResponse, SyncDeleteRequest, SyncDeleteResponse, + SyncFileStatusRequest, SyncFileStatusResponse, SyncManifestRequest, SyncManifestResponse, + SyncPutChunkRequest, SyncPutChunkResponse, TelemetryRequest, TelemetryResponse, }; use dory_proto::handshake::{handshake, Hello}; use dory_proto::mux::Mux; @@ -126,6 +126,21 @@ impl AgentClient { } } + pub async fn snapshot_quiesce( + &self, + action: agent::snapshot_quiesce_request::Action, + ) -> Result { + match self + .call(Method::SnapshotQuiesce(SnapshotQuiesceRequest { + action: action as i32, + })) + .await? + { + Res::SnapshotQuiesce(r) => Ok(r), + _ => Err(RemoteError::UnexpectedVariant), + } + } + pub async fn exec( &self, argv: Vec, From 7588ce378ff0eeb2da094bc3db061430ed9da359 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:44:49 +0000 Subject: [PATCH 065/338] feat(snapshot): surface consistency evidence --- Dory/Features/Machines/SnapshotsSheet.swift | 6 ++ Dory/Models/AppStore.swift | 3 +- Dory/Runtime/Doryd/DorydClient.swift | 20 ++++- Dory/Runtime/Machines/MachineSnapshot.swift | 5 +- DoryTests/DorydClientTests.swift | 78 +++++++++++++------ .../Sources/DorydKit/DorydService.swift | 1 + .../DorydKitTests/DorydServiceTests.swift | 5 ++ 7 files changed, 91 insertions(+), 27 deletions(-) diff --git a/Dory/Features/Machines/SnapshotsSheet.swift b/Dory/Features/Machines/SnapshotsSheet.swift index 1eb34840..abd193cd 100644 --- a/Dory/Features/Machines/SnapshotsSheet.swift +++ b/Dory/Features/Machines/SnapshotsSheet.swift @@ -238,6 +238,12 @@ struct SnapshotsSheet: View { Text(relativeTime(snapshot.createdISO)).font(.system(size: 11)).foregroundStyle(p.text3) Text("·").font(.system(size: 11)).foregroundStyle(p.text3) Text(DockerFormat.bytes(snapshot.sizeBytes)).font(.mono(11)).foregroundStyle(p.text3) + if let consistency = snapshot.consistency { + Text("·").font(.system(size: 11)).foregroundStyle(p.text3) + Text(consistency == .guestQuiesced ? "Guest quiesced" : "Cold stopped") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(consistency == .guestQuiesced ? p.green : p.text3) + } } } Spacer(minLength: 8) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 12147c08..90df43a1 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -6148,7 +6148,8 @@ final class AppStore { boot: "vz", recipe: "doryd", runtimeIdentity: snapshot.runtimeIdentity, - artifactEvidence: snapshot.artifactEvidence + artifactEvidence: snapshot.artifactEvidence, + consistency: snapshot.consistency ) } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 36bdaa5f..1e2868e4 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -747,6 +747,11 @@ nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equa } } +nonisolated enum DorydMachineSnapshotConsistency: String, Sendable, Equatable, Hashable { + case coldStopped = "cold-stopped" + case guestQuiesced = "guest-quiesced" +} + nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable { var id: String var version: UInt32 @@ -839,6 +844,7 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil + var consistency: DorydMachineSnapshotConsistency = .coldStopped } nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { @@ -2327,6 +2333,7 @@ nonisolated final class DorydClient: @unchecked Sendable { let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( from: dictionary ), + let consistency = machineSnapshotConsistency(from: dictionary), let artifactEvidence = machineSnapshotArtifactEvidence( from: dictionary, runtimeIdentity: runtimeIdentity @@ -2346,10 +2353,21 @@ nonisolated final class DorydClient: @unchecked Sendable { cpuCount: cpuCount, runtimeIdentity: runtimeIdentity, artifactEvidence: artifactEvidence.value, - installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, + consistency: consistency ) } + /// Absence preserves compatibility with older daemons. Once the field is present, its type + /// and value are closed so invented consistency claims cannot be displayed as trusted facts. + nonisolated private static func machineSnapshotConsistency( + from dictionary: NSDictionary + ) -> DorydMachineSnapshotConsistency? { + guard let encoded = dictionary["consistency"] else { return .coldStopped } + guard let rawValue = encoded as? String else { return nil } + return DorydMachineSnapshotConsistency(rawValue: rawValue) + } + private struct ParsedMachineSnapshotArtifactEvidence { var value: DorydMachineSnapshotArtifactEvidence? } diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift index aee8238c..c1a8f166 100644 --- a/Dory/Runtime/Machines/MachineSnapshot.swift +++ b/Dory/Runtime/Machines/MachineSnapshot.swift @@ -18,13 +18,15 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { let loginShell: String let runtimeIdentity: DorydMachineRuntimeIdentity let artifactEvidence: DorydMachineSnapshotArtifactEvidence? + let consistency: DorydMachineSnapshotConsistency? nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String, sizeBytes: Int64, distro: String, version: String, arch: String, boot: String, recipe: String, username: String = "root", uid: Int? = nil, homePath: String? = nil, loginShell: String = "/bin/sh", runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility, - artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil) { + artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil, + consistency: DorydMachineSnapshotConsistency? = nil) { self.id = id self.imageRef = imageRef self.machineName = machineName @@ -42,6 +44,7 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { self.loginShell = loginShell self.runtimeIdentity = runtimeIdentity self.artifactEvidence = artifactEvidence + self.consistency = consistency } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 406a95e0..62cf9e43 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -391,6 +391,7 @@ struct DorydClientTests { #expect(snapshot.id == "s1") #expect(snapshot.machineID == "dev") #expect(snapshot.runtimeIdentity == .legacyCompatibility) + #expect(snapshot.consistency == .coldStopped) #expect(snapshots.map(\.id).contains("s1")) #expect(clonedSnapshot.id == "dev-copy") #expect(restoredSnapshot.id == "dev") @@ -671,6 +672,43 @@ struct DorydClientTests { } } + @Test func snapshotConsistencyDefaultsOnlyWhenAbsentAndRejectsMalformedClaims() async throws { + let validService = FakeDorydService(snapshotConsistencyOverride: "guest-quiesced") + let validListener = NSXPCListener.anonymous() + let validDelegate = FakeDorydListenerDelegate(service: validService) + validListener.delegate = validDelegate + validListener.resume() + defer { validListener.invalidate() } + let valid = try await DorydClient(endpoint: validListener.endpoint).machineSnapshot( + "dev", + note: "consistent", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "consistent" + ) + #expect(valid.consistency == .guestQuiesced) + + for malformed in ["crash-consistent" as Any, 1 as Any] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(snapshotConsistencyOverride: malformed) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + let client = DorydClient(endpoint: listener.endpoint) + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid consistency", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-consistency" + ) + Issue.record("present malformed snapshot consistency must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + listener.invalidate() + } + } + @Test func installedDesktopPayloadReceiptUsesAbsentOnlyLegacyCompatibilityAndRejectsMalformedClaims() async throws { let digest = String(repeating: "a", count: 64) let valid: [String: Any] = [ @@ -1157,6 +1195,7 @@ struct DorydClientTests { let snapshot = try #require(store.machineSnapshots.first) #expect(snapshot.note == "before upgrade") #expect(snapshot.imageRef.hasPrefix("doryd://dev/")) + #expect(snapshot.consistency == .coldStopped) service.setMachineCloneSnapshotDuplicateFailures(1) store.cloneSnapshot(snapshot) @@ -2481,6 +2520,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? private var installedDesktopPayloadReceiptOverride: NSDictionary? + private var snapshotConsistencyOverride: Any? private var snapshots: [String: [NSDictionary]] = [:] private var backupStatuses: [String: NSDictionary] = [:] var engineStartCount: Int { @@ -2631,13 +2671,15 @@ private final class FakeDorydService: NSObject, DorydControlXPC { engineShutdownReplyDelay: TimeInterval = 0, runtimeIdentityOverride: NSDictionary? = nil, artifactEvidenceOverride: NSDictionary? = nil, - installedDesktopPayloadReceiptOverride: NSDictionary? = nil + installedDesktopPayloadReceiptOverride: NSDictionary? = nil, + snapshotConsistencyOverride: Any? = nil ) { self.socketPath = socketPath self.engineShutdownReplyDelay = engineShutdownReplyDelay self.runtimeIdentityOverride = runtimeIdentityOverride self.artifactEvidenceOverride = artifactEvidenceOverride self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride + self.snapshotConsistencyOverride = snapshotConsistencyOverride if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { if let runtimeIdentityOverride { existing["runtimeIdentity"] = runtimeIdentityOverride @@ -3031,34 +3073,22 @@ private final class FakeDorydService: NSObject, DorydControlXPC { createdISO: request["createdISO"] as? String ?? "2026-07-07T00:00:00Z" ) lock.lock() - let row: NSDictionary - if let runtimeIdentityOverride, - let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + let mutable = baseRow.mutableCopy() as? NSMutableDictionary + ?? NSMutableDictionary(dictionary: baseRow) + if let runtimeIdentityOverride { mutable["runtimeIdentity"] = runtimeIdentityOverride - if let artifactEvidenceOverride { - mutable["artifactEvidence"] = artifactEvidenceOverride - } - if let installedDesktopPayloadReceiptOverride { - mutable["installedDesktopPayloadReceipt"] = - installedDesktopPayloadReceiptOverride - } - row = mutable.copy() as? NSDictionary ?? baseRow - } else if let artifactEvidenceOverride, - let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + } + if let artifactEvidenceOverride { mutable["artifactEvidence"] = artifactEvidenceOverride - if let installedDesktopPayloadReceiptOverride { - mutable["installedDesktopPayloadReceipt"] = - installedDesktopPayloadReceiptOverride - } - row = mutable.copy() as? NSDictionary ?? baseRow - } else if let installedDesktopPayloadReceiptOverride, - let mutable = baseRow.mutableCopy() as? NSMutableDictionary { + } + if let installedDesktopPayloadReceiptOverride { mutable["installedDesktopPayloadReceipt"] = installedDesktopPayloadReceiptOverride - row = mutable.copy() as? NSDictionary ?? baseRow - } else { - row = baseRow } + if let snapshotConsistencyOverride { + mutable["consistency"] = snapshotConsistencyOverride + } + let row = mutable.copy() as? NSDictionary ?? baseRow _machineSnapshotCount += 1 snapshots[machineID, default: []].insert(row, at: 0) lock.unlock() diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 2e5097c2..e9f627f9 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1909,6 +1909,7 @@ private extension DoryMachineSnapshot { "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode.rawValue, + "consistency": consistency.rawValue, "runtimeIdentity": runtimeIdentity.xpcDictionary, ] if let artifactEvidence { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 00b3736e..1a8ef360 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1799,6 +1799,7 @@ final class DorydServiceTests: XCTestCase { let runtime = body["runtimeIdentity"] as? NSDictionary XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility") XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1) + XCTAssertEqual(body["consistency"] as? String, "cold-stopped") let artifacts = body["artifactEvidence"] as? NSDictionary XCTAssertEqual( ((artifacts?["rootfs"] as? NSDictionary)?["sha256"] as? String)?.count, @@ -1812,6 +1813,10 @@ final class DorydServiceTests: XCTestCase { proxy.machineSnapshots("dev") { rows, message in XCTAssertEqual(message, "") XCTAssertEqual((rows as? [NSDictionary])?.first?["id"] as? String, "s1") + XCTAssertEqual( + (rows as? [NSDictionary])?.first?["consistency"] as? String, + "cold-stopped" + ) listReply.fulfill() } wait(for: [listReply], timeout: 5) From 247679b0ccae79259e2ff0cfeff86d50282afaab Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Thu, 20 Aug 2026 23:48:01 +0000 Subject: [PATCH 066/338] feat(tools): require snapshot quiesce health --- Dory/Models/Models.swift | 9 ++++++++- DoryTests/DorydClientTests.swift | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 0eadc18c..7fde2b39 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -413,7 +413,14 @@ struct Machine: Identifiable, Hashable, Sendable { ) } let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) }) - let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"] + let required = [ + "clock-sync", + "exec", + "exec-stdin", + "ports-watch", + "snapshot-quiesce", + "telemetry", + ] let missing = required.filter { (versions[$0] ?? 0) < 1 } guard missing.isEmpty else { return MachineRuntimeEvidence( diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 62cf9e43..5809abc1 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -552,7 +552,8 @@ struct DorydClientTests { #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") #expect(machine.agentProtocolVersion == 1) #expect(machine.agentCapabilities.map(\.id) == [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry", + "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push", + "telemetry", ]) #expect(machine.runtimeEvidence.map(\.label) == [ "Supported", "Raw HV", "Qualified 3D", "Tools ready", @@ -600,7 +601,7 @@ struct DorydClientTests { let valid = try #require((try await client.machineList()).first) #expect(valid.agentProtocolVersion == 1) - #expect(valid.agentCapabilities.count == 6) + #expect(valid.agentCapabilities.count == 7) let malformed: [(Any?, Any?)] = [ (nil, [["id": "exec", "version": 1] as NSDictionary]), @@ -3521,6 +3522,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ["id": "exec", "version": 1] as NSDictionary, ["id": "exec-stdin", "version": 1] as NSDictionary, ["id": "ports-watch", "version": 1] as NSDictionary, + ["id": "snapshot-quiesce", "version": 1] as NSDictionary, ["id": "sync-push", "version": 1] as NSDictionary, ["id": "telemetry", "version": 1] as NSDictionary, ], From c63c681c75b4fefbe48d658157f4060cb6f248a5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:04:30 +0000 Subject: [PATCH 067/338] feat(snapshot): bind guest quiesce receipts --- .../Sources/DoryCore/DoryCore.swift | 8 +- .../Sources/DorydKit/AgentControl.swift | 30 ++-- .../Sources/DorydKit/MachineManager.swift | 163 +++++++++++++++--- .../DorydKitTests/AgentControlTests.swift | 52 +++++- .../DorydKitTests/MachineManagerTests.swift | 38 +++- dory-core/agent/src/dispatch.rs | 4 +- dory-core/agent/src/snapshot_quiesce.rs | 156 +++++++++++++++-- dory-core/ffi/src/agent_forward.rs | 19 +- dory-core/pb/proto/agent.proto | 5 + dory-core/remote/src/agent_client.rs | 2 + 10 files changed, 410 insertions(+), 67 deletions(-) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index f03e37c3..bf323546 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -397,12 +397,12 @@ public final class DoryAgentControlHandle: @unchecked Sendable { ) } - public func snapshotFreeze() throws { - try withControl { try $0.snapshotFreeze() } + public func snapshotFreeze(receiptID: String) throws -> String { + try withControl { try $0.snapshotFreeze(receiptId: receiptID) } } - public func snapshotThaw() throws { - try withControl { try $0.snapshotThaw() } + public func snapshotThaw(receiptID: String) throws { + try withControl { try $0.snapshotThaw(receiptId: receiptID) } } public func exec( diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index cbc827f3..46658d1a 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -25,8 +25,8 @@ public protocol AgentControlClient: Sendable { func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot func telemetry() throws -> DoryTelemetry - func snapshotFreeze() throws - func snapshotThaw() throws + func snapshotFreeze(receiptID: String) throws -> String + func snapshotThaw(receiptID: String) throws func exec( argv: [String], cwd: String, @@ -46,11 +46,13 @@ public protocol AgentControlClient: Sendable { } public extension AgentControlClient { - func snapshotFreeze() throws { + func snapshotFreeze(receiptID: String) throws -> String { + _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") } - func snapshotThaw() throws { + func snapshotThaw(receiptID: String) throws { + _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") } @@ -129,12 +131,14 @@ public final class AgentControl: @unchecked Sendable { try client(requiring: "telemetry").telemetry() } - public func snapshotFreeze() throws { - try client(requiring: "snapshot-quiesce").snapshotFreeze() + public func snapshotFreeze(receiptID: String) throws -> String { + try client(requiring: "snapshot-quiesce", minimumVersion: 2) + .snapshotFreeze(receiptID: receiptID) } - public func snapshotThaw() throws { - try client(requiring: "snapshot-quiesce").snapshotThaw() + public func snapshotThaw(receiptID: String) throws { + try client(requiring: "snapshot-quiesce", minimumVersion: 2) + .snapshotThaw(receiptID: receiptID) } public func exec( @@ -203,14 +207,18 @@ public final class AgentControl: @unchecked Sendable { return existing } - private func client(requiring capability: String) throws -> any AgentControlClient { + private func client( + requiring capability: String, + minimumVersion: UInt32 = 1 + ) throws -> any AgentControlClient { let client = try connectedClient() - try requireCapability(capability, from: client) + try requireCapability(capability, minimumVersion: minimumVersion, from: client) return client } private func requireCapability( _ capability: String, + minimumVersion: UInt32 = 1, from client: any AgentControlClient ) throws { let info = try cachedInfo(from: client) @@ -223,7 +231,7 @@ public final class AgentControl: @unchecked Sendable { guard info.capabilitiesAreCanonical else { throw AgentControlError.invalidCapabilities } - guard info.supports(capability) else { + guard info.supports(capability, minimumVersion: minimumVersion) else { throw AgentControlError.capabilityUnavailable(capability) } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 8cf640f0..eb963d65 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -401,6 +401,41 @@ public enum DoryMachineSnapshotConsistency: String, Sendable, Equatable, Hashabl case guestQuiesced = "guest-quiesced" } +public struct DoryMachineSnapshotQuiesceReceipt: Sendable, Equatable, Hashable, Codable { + public var schemaVersion: UInt16 + public var receiptID: String + public var agentBuild: String + public var agentProtocolVersion: UInt32 + public var capabilityVersion: UInt32 + + public init( + schemaVersion: UInt16 = 1, + receiptID: String, + agentBuild: String, + agentProtocolVersion: UInt32, + capabilityVersion: UInt32 + ) { + self.schemaVersion = schemaVersion + self.receiptID = receiptID + self.agentBuild = agentBuild + self.agentProtocolVersion = agentProtocolVersion + self.capabilityVersion = capabilityVersion + } + + public var isValid: Bool { + schemaVersion == 1 + && receiptID.utf8.count == 32 + && receiptID.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66) + } + && !agentBuild.isEmpty + && agentBuild.utf8.count <= 128 + && agentBuild.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7e } + && agentProtocolVersion == DoryCore.protocolVersion() + && capabilityVersion >= 2 + } +} + public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var id: String public var machineID: String @@ -424,6 +459,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var artifactEvidence: DoryMachineSnapshotArtifactEvidence? public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? public var consistency: DoryMachineSnapshotConsistency + public var guestQuiesceReceipt: DoryMachineSnapshotQuiesceReceipt? public init( id: String, @@ -450,7 +486,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { ), artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil, installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil, - consistency: DoryMachineSnapshotConsistency = .coldStopped + consistency: DoryMachineSnapshotConsistency = .coldStopped, + guestQuiesceReceipt: DoryMachineSnapshotQuiesceReceipt? = nil ) { self.id = id self.machineID = machineID @@ -474,6 +511,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.artifactEvidence = artifactEvidence self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt self.consistency = consistency + self.guestQuiesceReceipt = guestQuiesceReceipt } private enum CodingKeys: String, CodingKey { @@ -499,10 +537,27 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case artifactEvidence case installedDesktopPayloadReceipt case consistency + case guestQuiesceReceipt } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let consistency = try container.decodeIfPresent( + DoryMachineSnapshotConsistency.self, + forKey: .consistency + ) ?? .coldStopped + let guestQuiesceReceipt = try container.decodeIfPresent( + DoryMachineSnapshotQuiesceReceipt.self, + forKey: .guestQuiesceReceipt + ) + guard guestQuiesceReceipt?.isValid ?? true, + (consistency == .guestQuiesced) == (guestQuiesceReceipt != nil) else { + throw DecodingError.dataCorruptedError( + forKey: .guestQuiesceReceipt, + in: container, + debugDescription: "snapshot consistency and guest quiesce receipt disagree" + ) + } self.init( id: try container.decode(String.self, forKey: .id), machineID: try container.decode(String.self, forKey: .machineID), @@ -540,10 +595,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { DoryInstalledDesktopPayloadReceipt.self, forKey: .installedDesktopPayloadReceipt ), - consistency: try container.decodeIfPresent( - DoryMachineSnapshotConsistency.self, - forKey: .consistency - ) ?? .coldStopped + consistency: consistency, + guestQuiesceReceipt: guestQuiesceReceipt ) } } @@ -3084,8 +3137,11 @@ public final class MachineManager: @unchecked Sendable { // Prefer a negotiated guest freeze before the durable cold-stop boundary. A missing tools // capability degrades to the existing cold snapshot; an advertised capability that fails // is not silently ignored because the guest may already be partially frozen. - let guestQuiesced = sourceMachineState == .running - ? try freezeGuestForSnapshotIfSupported(id: id) : false + let guestQuiesceReceipt = sourceMachineState == .running + ? try freezeGuestForSnapshotIfSupported( + id: id, + resolvedPlan: snapshotRuntimeIdentity.resolvedPlan + ) : nil if wasResident { do { _ = try stopImplementation( @@ -3094,8 +3150,13 @@ public final class MachineManager: @unchecked Sendable { preserveResolvedAdmissionForRestart: true ) } catch { - if guestQuiesced { - do { try thawGuestAfterFailedSnapshotStop(id: id) } + if let guestQuiesceReceipt { + do { + try thawGuestAfterFailedSnapshotStop( + id: id, + receiptID: guestQuiesceReceipt.receiptID + ) + } catch let thawError { throw MachineManagerError.persistence( "could not stop \(id) for snapshot: \(error); guest thaw failed: \(thawError)" @@ -3160,7 +3221,8 @@ public final class MachineManager: @unchecked Sendable { artifactEvidence: artifactEvidence, installedDesktopPayloadReceipt: machine.effectiveInstalledDesktopPayloadReceipt, - consistency: guestQuiesced ? .guestQuiesced : .coldStopped + consistency: guestQuiesceReceipt == nil ? .coldStopped : .guestQuiesced, + guestQuiesceReceipt: guestQuiesceReceipt ) let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot) let lifecycle = try beginLifecycleSnapshot( @@ -3258,32 +3320,83 @@ public final class MachineManager: @unchecked Sendable { return snapshot } - private func freezeGuestForSnapshotIfSupported(id: String) throws -> Bool { - guard status(id: id)?.supportsAgentCapability("snapshot-quiesce") == true else { - return false + private func freezeGuestForSnapshotIfSupported( + id: String, + resolvedPlan: DoryResolvedMachinePlan? + ) throws -> DoryMachineSnapshotQuiesceReceipt? { + guard let status = status(id: id), + status.supportsAgentCapability("snapshot-quiesce", minimumVersion: 2), + let agentBuild = status.agentBuild, + let protocolVersion = status.agentProtocolVersion, + let capabilityVersion = status.agentCapabilities.first(where: { + $0.id == "snapshot-quiesce" + })?.version else { + return nil } + let requestedReceiptID = UUID().uuidString + .replacingOccurrences(of: "-", with: "") + .lowercased() do { - try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in - try client.snapshotFreeze() + let receiptID = try withAgentClient( + id: id, + requiredCapability: "snapshot-quiesce", + minimumCapabilityVersion: 2 + ) { client in + try client.snapshotFreeze(receiptID: requestedReceiptID) + } + let receipt = DoryMachineSnapshotQuiesceReceipt( + receiptID: receiptID, + agentBuild: agentBuild, + agentProtocolVersion: protocolVersion, + capabilityVersion: capabilityVersion + ) + guard receipt.isValid, receiptID == requestedReceiptID else { + throw MachineManagerError.persistence( + "guest snapshot freeze returned an invalid receipt for \(id)" + ) } + return receipt } catch { let freezeError = error - do { try thawGuestAfterFailedSnapshotStop(id: id) } - catch let thawError { + do { + try thawGuestAfterFailedSnapshotStop( + id: id, + receiptID: requestedReceiptID + ) + } catch let thawError { + do { + _ = try stopImplementation( + id: id, + journalLifecycle: true, + preserveResolvedAdmissionForRestart: true + ) + try restoreSnapshotSourcePowerState( + id: id, + wasPaused: false, + resolvedPlan: resolvedPlan + ) + } catch let recoveryError { + throw MachineManagerError.persistence( + "guest snapshot freeze failed for \(id): \(freezeError); recovery thaw failed: \(thawError); forced restart failed: \(recoveryError)" + ) + } throw MachineManagerError.persistence( - "guest snapshot freeze failed for \(id): \(freezeError); thaw failed: \(thawError)" + "guest snapshot freeze failed for \(id): \(freezeError); recovery thaw failed: \(thawError); the guest was restarted" ) } throw MachineManagerError.persistence( "guest snapshot freeze failed for \(id): \(freezeError)" ) } - return true } - private func thawGuestAfterFailedSnapshotStop(id: String) throws { - try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in - try client.snapshotThaw() + private func thawGuestAfterFailedSnapshotStop(id: String, receiptID: String) throws { + try withAgentClient( + id: id, + requiredCapability: "snapshot-quiesce", + minimumCapabilityVersion: 2 + ) { client in + try client.snapshotThaw(receiptID: receiptID) } } @@ -4896,6 +5009,7 @@ public final class MachineManager: @unchecked Sendable { private func withAgentClient( id: String, requiredCapability: String? = nil, + minimumCapabilityVersion: UInt32 = 1, _ operation: (any AgentControlClient) throws -> T ) throws -> T { guard let status = status(id: id) else { @@ -4905,7 +5019,10 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.agentUnavailable(id) } if let requiredCapability, - !status.supportsAgentCapability(requiredCapability) { + !status.supportsAgentCapability( + requiredCapability, + minimumVersion: minimumCapabilityVersion + ) { throw MachineManagerError.agentCapabilityUnavailable(id, requiredCapability) } let client = try agentConnector(socketPath) diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index e36ca6cc..70e3a21c 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -18,7 +18,7 @@ final class AgentControlTests: XCTestCase { let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") XCTAssertEqual(info.capabilities.map(\.id), [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry", + "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "telemetry", ]) XCTAssertEqual(counter.value, 1) @@ -26,6 +26,11 @@ final class AgentControlTests: XCTestCase { XCTAssertEqual(fake.clockSyncInputs, [1_500_000_000]) XCTAssertEqual(try control.portsWatch().ports.first?.port, 8080) XCTAssertEqual(try control.telemetry().memTotalKB, 1024) + let receiptID = String(repeating: "a", count: 32) + XCTAssertEqual(try control.snapshotFreeze(receiptID: receiptID), receiptID) + try control.snapshotThaw(receiptID: receiptID) + XCTAssertEqual(fake.snapshotFreezeReceipts, [receiptID]) + XCTAssertEqual(fake.snapshotThawReceipts, [receiptID]) let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp") XCTAssertEqual(exec.exitCode, 0) XCTAssertEqual(String(data: exec.stdout, encoding: .utf8), "ok\n") @@ -52,6 +57,23 @@ final class AgentControlTests: XCTestCase { } XCTAssertEqual(missing.telemetryCalls, 0) + let oldSnapshotCapability = FakeAgentControlClient(capabilities: [ + DoryAgentCapability(id: "snapshot-quiesce", version: 1), + ]) + let oldSnapshotControl = AgentControl( + configuration: AgentControlConfiguration(directSocketPath: "/tmp/old-snapshot.sock"), + connector: { _ in oldSnapshotCapability } + ) + XCTAssertThrowsError(try oldSnapshotControl.snapshotFreeze( + receiptID: String(repeating: "b", count: 32) + )) { error in + XCTAssertEqual( + error as? AgentControlError, + .capabilityUnavailable("snapshot-quiesce") + ) + } + XCTAssertTrue(oldSnapshotCapability.snapshotFreezeReceipts.isEmpty) + let invalid = FakeAgentControlClient(capabilities: [ DoryAgentCapability(id: "exec", version: 1), DoryAgentCapability(id: "exec", version: 1), @@ -91,6 +113,8 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda private var inputs: [Int64] = [] private var closes = 0 private var telemetryCallCount = 0 + private var freezeReceipts: [String] = [] + private var thawReceipts: [String] = [] init( protocolVersion: UInt32 = DoryCore.protocolVersion(), @@ -99,6 +123,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda DoryAgentCapability(id: "exec", version: 1), DoryAgentCapability(id: "exec-stdin", version: 1), DoryAgentCapability(id: "ports-watch", version: 1), + DoryAgentCapability(id: "snapshot-quiesce", version: 2), DoryAgentCapability(id: "telemetry", version: 1), ] ) { @@ -124,6 +149,18 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return telemetryCallCount } + var snapshotFreezeReceipts: [String] { + lock.lock() + defer { lock.unlock() } + return freezeReceipts + } + + var snapshotThawReceipts: [String] { + lock.lock() + defer { lock.unlock() } + return thawReceipts + } + func info() throws -> DoryAgentInfo { DoryAgentInfo( protocolVersion: protocolVersion, @@ -161,6 +198,19 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda ) } + func snapshotFreeze(receiptID: String) throws -> String { + lock.lock() + freezeReceipts.append(receiptID) + lock.unlock() + return receiptID + } + + func snapshotThaw(receiptID: String) throws { + lock.lock() + thawReceipts.append(receiptID) + lock.unlock() + } + func exec( argv: [String], cwd: String, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index aa8ea66f..d6d66363 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3751,6 +3751,11 @@ final class MachineManagerTests: XCTestCase { snapshotID: "running" ) XCTAssertEqual(runningSnapshot.consistency, .guestQuiesced) + let receipt = try XCTUnwrap(runningSnapshot.guestQuiesceReceipt) + XCTAssertTrue(receipt.isValid) + XCTAssertEqual(receipt.agentBuild, "dory-agent/snapshot-test") + XCTAssertEqual(receipt.agentProtocolVersion, DoryCore.protocolVersion()) + XCTAssertEqual(receipt.capabilityVersion, 2) XCTAssertEqual(connector.snapshotFreezes, 1) XCTAssertEqual(connector.snapshotThaws, 0) XCTAssertEqual(manager.status(id: "dev")?.state, .running) @@ -3762,8 +3767,33 @@ final class MachineManagerTests: XCTestCase { snapshotID: "paused" ) XCTAssertEqual(pausedSnapshot.consistency, .coldStopped) + XCTAssertNil(pausedSnapshot.guestQuiesceReceipt) XCTAssertEqual(connector.snapshotFreezes, 1) XCTAssertEqual(manager.status(id: "dev")?.state, .paused) + + var missingReceipt = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(runningSnapshot)) + as? [String: Any] + ) + missingReceipt.removeValue(forKey: "guestQuiesceReceipt") + XCTAssertThrowsError(try JSONDecoder().decode( + DoryMachineSnapshot.self, + from: JSONSerialization.data(withJSONObject: missingReceipt) + )) + + var historicalColdSnapshot = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(pausedSnapshot)) + as? [String: Any] + ) + historicalColdSnapshot.removeValue(forKey: "consistency") + historicalColdSnapshot.removeValue(forKey: "guestQuiesceReceipt") + XCTAssertEqual( + try JSONDecoder().decode( + DoryMachineSnapshot.self, + from: JSONSerialization.data(withJSONObject: historicalColdSnapshot) + ).consistency, + .coldStopped + ) } func testExecRunsThroughRunningMachineAgent() throws { @@ -4109,7 +4139,7 @@ private func sendSnapshotQuiesceHandoff(path: String, machineID: String) throws machineID: machineID, agentBuild: "dory-agent/snapshot-test", agentProtocolVersion: DoryCore.protocolVersion(), - agentCapabilities: [DoryAgentCapability(id: "snapshot-quiesce", version: 1)], + agentCapabilities: [DoryAgentCapability(id: "snapshot-quiesce", version: 2)], agentSocketPath: "/run/dory-snapshot-agent.sock" ), fileDescriptors: [] @@ -4444,11 +4474,13 @@ private final class RecordingMachineAgentClient: AgentControlClient, @unchecked telemetryValue } - func snapshotFreeze() throws { + func snapshotFreeze(receiptID: String) throws -> String { recorder.recordSnapshotFreeze() + return receiptID } - func snapshotThaw() throws { + func snapshotThaw(receiptID: String) throws { + XCTAssertEqual(receiptID.utf8.count, 32) recorder.recordSnapshotThaw() } diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index a1881096..46f92c59 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -29,7 +29,7 @@ pub fn agent_capabilities() -> Vec { ("telemetry", 1), ]; if crate::snapshot_quiesce::available() { - capabilities.push(("snapshot-quiesce", 1)); + capabilities.push(("snapshot-quiesce", 2)); } capabilities.sort_unstable_by_key(|(id, _)| *id); capabilities @@ -204,7 +204,7 @@ mod tests { ("telemetry", 1), ]; if crate::snapshot_quiesce::available() { - expected_capabilities.push(("snapshot-quiesce", 1)); + expected_capabilities.push(("snapshot-quiesce", 2)); expected_capabilities.sort_unstable(); } assert_eq!( diff --git a/dory-core/agent/src/snapshot_quiesce.rs b/dory-core/agent/src/snapshot_quiesce.rs index e1b72040..79c48a52 100644 --- a/dory-core/agent/src/snapshot_quiesce.rs +++ b/dory-core/agent/src/snapshot_quiesce.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; use dory_pb::agent::{ @@ -7,44 +8,131 @@ use dory_pb::agent::{ const QUIESCE_TIMEOUT: Duration = Duration::from_secs(20); +#[derive(Clone, Debug, Eq, PartialEq)] +enum QuiesceState { + Freezing(String), + Frozen(String), + Thawing(String), +} + +fn state() -> &'static Mutex> { + static STATE: OnceLock>> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(None)) +} + pub fn available() -> bool { binary().is_some() } pub async fn run(request: SnapshotQuiesceRequest) -> SnapshotQuiesceResponse { - let action = match Action::try_from(request.action) { - Ok(Action::Freeze) => "-f", - Ok(Action::Thaw) => "-u", - _ => return response(false, "snapshot quiesce action is invalid"), + match Action::try_from(request.action) { + Ok(Action::Freeze) => freeze(request.receipt_id).await, + Ok(Action::Thaw) => thaw(request.receipt_id).await, + _ => response(false, "snapshot quiesce action is invalid", ""), + } +} + +async fn freeze(request_receipt: String) -> SnapshotQuiesceResponse { + if !valid_receipt(&request_receipt) { + return response(false, "freeze request has an invalid receipt", ""); + } + let Some(binary) = binary() else { + return response(false, "filesystem freeze is unavailable in this guest", ""); }; + let receipt = request_receipt; + { + let mut current = state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if current.is_some() { + return response(false, "a snapshot quiesce operation is already active", ""); + } + *current = Some(QuiesceState::Freezing(receipt.clone())); + } + + let result = command(binary, "-f").await; + let mut current = state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match result { + Ok(()) => { + *current = Some(QuiesceState::Frozen(receipt.clone())); + response(true, "", &receipt) + } + Err(detail) => { + if *current == Some(QuiesceState::Freezing(receipt.clone())) { + *current = None; + } + response(false, &detail, "") + } + } +} + +async fn thaw(receipt: String) -> SnapshotQuiesceResponse { + if !valid_receipt(&receipt) { + return response(false, "thaw request has an invalid receipt", ""); + } let Some(binary) = binary() else { - return response(false, "filesystem freeze is unavailable in this guest"); + return response(false, "filesystem freeze is unavailable in this guest", ""); }; + { + let mut current = state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if *current != Some(QuiesceState::Frozen(receipt.clone())) { + return response(false, "thaw receipt does not match the active freeze", ""); + } + *current = Some(QuiesceState::Thawing(receipt.clone())); + } + let result = command(binary, "-u").await; + let mut current = state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match result { + Ok(()) => { + *current = None; + response(true, "", "") + } + Err(detail) => { + if *current == Some(QuiesceState::Thawing(receipt.clone())) { + *current = Some(QuiesceState::Frozen(receipt)); + } + response(false, &detail, "") + } + } +} + +async fn command(binary: &str, action: &str) -> Result<(), String> { let mut command = tokio::process::Command::new(binary); command.args([action, "/"]).kill_on_drop(true); match tokio::time::timeout(QUIESCE_TIMEOUT, command.output()).await { - Ok(Ok(output)) if output.status.success() => response(true, ""), + Ok(Ok(output)) if output.status.success() => Ok(()), Ok(Ok(output)) => { let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); - response( - false, - if detail.is_empty() { - "filesystem freeze command failed" - } else { - detail.as_str() - }, - ) + Err(if detail.is_empty() { + "filesystem freeze command failed".to_string() + } else { + detail + }) } - Ok(Err(error)) => response(false, &format!("filesystem freeze command failed: {error}")), - Err(_) => response(false, "filesystem freeze command timed out"), + Ok(Err(error)) => Err(format!("filesystem freeze command failed: {error}")), + Err(_) => Err("filesystem freeze command timed out".to_string()), } } -fn response(completed: bool, detail: &str) -> SnapshotQuiesceResponse { +fn valid_receipt(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn response(completed: bool, detail: &str, receipt_id: &str) -> SnapshotQuiesceResponse { SnapshotQuiesceResponse { completed, detail: detail.chars().take(512).collect(), + receipt_id: receipt_id.to_string(), } } @@ -69,9 +157,43 @@ mod tests { async fn rejects_unspecified_action_without_touching_the_host() { let result = run(SnapshotQuiesceRequest { action: Action::Unspecified as i32, + receipt_id: String::new(), }) .await; assert!(!result.completed); assert!(result.detail.contains("invalid")); } + + #[tokio::test] + async fn rejects_missing_and_malformed_thaw_receipts_without_touching_the_host() { + for receipt_id in [String::new(), "A".repeat(32), "a".repeat(31)] { + let result = run(SnapshotQuiesceRequest { + action: Action::Thaw as i32, + receipt_id, + }) + .await; + assert!(!result.completed); + assert!(result.detail.contains("receipt")); + } + } + + #[tokio::test] + async fn rejects_missing_and_malformed_freeze_receipts_without_touching_the_host() { + for receipt_id in [String::new(), "A".repeat(32), "a".repeat(31)] { + let result = run(SnapshotQuiesceRequest { + action: Action::Freeze as i32, + receipt_id, + }) + .await; + assert!(!result.completed); + assert!(result.detail.contains("receipt")); + } + } + + #[test] + fn receipt_shape_is_exact_lowercase_hex() { + assert!(valid_receipt("0123456789abcdef0123456789abcdef")); + assert!(!valid_receipt("0123456789ABCDEF0123456789ABCDEF")); + assert!(!valid_receipt("0123456789abcdef0123456789abcdeg")); + } } diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index fb75783f..721f8475 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -164,15 +164,21 @@ impl AgentControl { }) } - pub fn snapshot_freeze(&self) -> Result<(), RemoteFfiError> { + pub fn snapshot_freeze(&self, receipt_id: String) -> Result { snapshot_quiesce( self, dory_pb::agent::snapshot_quiesce_request::Action::Freeze, + receipt_id, ) } - pub fn snapshot_thaw(&self) -> Result<(), RemoteFfiError> { - snapshot_quiesce(self, dory_pb::agent::snapshot_quiesce_request::Action::Thaw) + pub fn snapshot_thaw(&self, receipt_id: String) -> Result<(), RemoteFfiError> { + snapshot_quiesce( + self, + dory_pb::agent::snapshot_quiesce_request::Action::Thaw, + receipt_id, + ) + .map(|_| ()) } pub fn exec( @@ -221,12 +227,13 @@ impl AgentControl { fn snapshot_quiesce( control: &AgentControl, action: dory_pb::agent::snapshot_quiesce_request::Action, -) -> Result<(), RemoteFfiError> { + receipt_id: String, +) -> Result { let guard = control.runtime.lock().unwrap(); let runtime = guard.as_ref().ok_or_else(shutdown_error)?; - let result = runtime.block_on(control.client.snapshot_quiesce(action))?; + let result = runtime.block_on(control.client.snapshot_quiesce(action, receipt_id))?; if result.completed { - Ok(()) + Ok(result.receipt_id) } else { Err(failed(if result.detail.is_empty() { "guest declined snapshot quiesce" diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 0e81fab4..5a936730 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -68,10 +68,15 @@ message SnapshotQuiesceRequest { THAW = 2; } Action action = 1; + // A host-generated 128-bit lowercase-hex operation receipt. FREEZE and THAW must carry the + // same value so the host can recover even when a successful freeze reply is lost in transit. + string receipt_id = 2; } message SnapshotQuiesceResponse { bool completed = 1; string detail = 2; + // A single-use 128-bit lowercase-hex receipt. Present only after a successful FREEZE. + string receipt_id = 3; } // Run a bounded non-interactive command in the guest. This is the primitive doryd uses for diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 240218ec..ec3b6938 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -129,10 +129,12 @@ impl AgentClient { pub async fn snapshot_quiesce( &self, action: agent::snapshot_quiesce_request::Action, + receipt_id: String, ) -> Result { match self .call(Method::SnapshotQuiesce(SnapshotQuiesceRequest { action: action as i32, + receipt_id, })) .await? { From 931c686bdcf4d6b0c7bdd724b2e6c4a275718a03 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:09:19 +0000 Subject: [PATCH 068/338] feat(snapshot): expose quiesce receipts --- Dory/Features/Machines/SnapshotsSheet.swift | 3 + Dory/Models/AppStore.swift | 3 +- Dory/Models/Models.swift | 19 ++--- Dory/Runtime/Doryd/DorydClient.swift | 70 ++++++++++++++++++- Dory/Runtime/Machines/MachineSnapshot.swift | 5 +- DoryTests/DorydClientTests.swift | 60 ++++++++++++++-- .../Sources/DorydKit/DorydService.swift | 15 ++++ .../DorydKitTests/DorydServiceTests.swift | 1 + 8 files changed, 160 insertions(+), 16 deletions(-) diff --git a/Dory/Features/Machines/SnapshotsSheet.swift b/Dory/Features/Machines/SnapshotsSheet.swift index abd193cd..190b32c9 100644 --- a/Dory/Features/Machines/SnapshotsSheet.swift +++ b/Dory/Features/Machines/SnapshotsSheet.swift @@ -243,6 +243,9 @@ struct SnapshotsSheet: View { Text(consistency == .guestQuiesced ? "Guest quiesced" : "Cold stopped") .font(.system(size: 11, weight: .medium)) .foregroundStyle(consistency == .guestQuiesced ? p.green : p.text3) + .help(snapshot.guestQuiesceReceipt.map { + "Quiesced by \($0.agentBuild) using snapshot-quiesce@\($0.capabilityVersion)" + } ?? "The machine was stopped before its disks were copied") } } } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 90df43a1..6ed2c243 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -6149,7 +6149,8 @@ final class AppStore { recipe: "doryd", runtimeIdentity: snapshot.runtimeIdentity, artifactEvidence: snapshot.artifactEvidence, - consistency: snapshot.consistency + consistency: snapshot.consistency, + guestQuiesceReceipt: snapshot.guestQuiesceReceipt ) } diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 7fde2b39..7dc1e0de 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -413,15 +413,18 @@ struct Machine: Identifiable, Hashable, Sendable { ) } let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) }) - let required = [ - "clock-sync", - "exec", - "exec-stdin", - "ports-watch", - "snapshot-quiesce", - "telemetry", + let required: [(id: String, version: UInt32)] = [ + ("clock-sync", 1), + ("exec", 1), + ("exec-stdin", 1), + ("ports-watch", 1), + ("snapshot-quiesce", 2), + ("telemetry", 1), ] - let missing = required.filter { (versions[$0] ?? 0) < 1 } + let missing = required.compactMap { requirement in + (versions[requirement.id] ?? 0) < requirement.version + ? "\(requirement.id)@\(requirement.version)" : nil + } guard missing.isEmpty else { return MachineRuntimeEvidence( id: "tools", diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 1e2868e4..6f865e84 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -752,6 +752,27 @@ nonisolated enum DorydMachineSnapshotConsistency: String, Sendable, Equatable, H case guestQuiesced = "guest-quiesced" } +nonisolated struct DorydMachineSnapshotQuiesceReceipt: Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var receiptID: String + var agentBuild: String + var agentProtocolVersion: UInt32 + var capabilityVersion: UInt32 + + var isValid: Bool { + schemaVersion == 1 + && receiptID.utf8.count == 32 + && receiptID.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66) + } + && !agentBuild.isEmpty + && agentBuild.utf8.count <= 128 + && agentBuild.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7e } + && agentProtocolVersion == 1 + && capabilityVersion >= 2 + } +} + nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable { var id: String var version: UInt32 @@ -845,6 +866,7 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil var consistency: DorydMachineSnapshotConsistency = .coldStopped + var guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? = nil } nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { @@ -2334,6 +2356,10 @@ nonisolated final class DorydClient: @unchecked Sendable { from: dictionary ), let consistency = machineSnapshotConsistency(from: dictionary), + let guestQuiesceReceipt = machineSnapshotQuiesceReceipt( + from: dictionary, + consistency: consistency + ), let artifactEvidence = machineSnapshotArtifactEvidence( from: dictionary, runtimeIdentity: runtimeIdentity @@ -2354,7 +2380,8 @@ nonisolated final class DorydClient: @unchecked Sendable { runtimeIdentity: runtimeIdentity, artifactEvidence: artifactEvidence.value, installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, - consistency: consistency + consistency: consistency, + guestQuiesceReceipt: guestQuiesceReceipt.value ) } @@ -2368,6 +2395,47 @@ nonisolated final class DorydClient: @unchecked Sendable { return DorydMachineSnapshotConsistency(rawValue: rawValue) } + private struct ParsedMachineSnapshotQuiesceReceipt { + var value: DorydMachineSnapshotQuiesceReceipt? + } + + nonisolated private static func machineSnapshotQuiesceReceipt( + from dictionary: NSDictionary, + consistency: DorydMachineSnapshotConsistency + ) -> ParsedMachineSnapshotQuiesceReceipt? { + guard let encoded = dictionary["guestQuiesceReceipt"] else { + guard consistency == .coldStopped else { return nil } + return ParsedMachineSnapshotQuiesceReceipt(value: nil) + } + guard consistency == .guestQuiesced, + let raw = encoded as? NSDictionary, + let keys = raw.allKeys as? [String], + Set(keys) == [ + "schemaVersion", + "receiptID", + "agentBuild", + "agentProtocolVersion", + "capabilityVersion", + ], + keys.count == 5, + let schemaVersion = uint16(raw["schemaVersion"]), + let receiptID = raw["receiptID"] as? String, + let agentBuild = raw["agentBuild"] as? String, + let agentProtocolVersion = uint32(raw["agentProtocolVersion"]), + let capabilityVersion = uint32(raw["capabilityVersion"]) else { + return nil + } + let receipt = DorydMachineSnapshotQuiesceReceipt( + schemaVersion: schemaVersion, + receiptID: receiptID, + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + capabilityVersion: capabilityVersion + ) + guard receipt.isValid else { return nil } + return ParsedMachineSnapshotQuiesceReceipt(value: receipt) + } + private struct ParsedMachineSnapshotArtifactEvidence { var value: DorydMachineSnapshotArtifactEvidence? } diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift index c1a8f166..2c5a3fbf 100644 --- a/Dory/Runtime/Machines/MachineSnapshot.swift +++ b/Dory/Runtime/Machines/MachineSnapshot.swift @@ -19,6 +19,7 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { let runtimeIdentity: DorydMachineRuntimeIdentity let artifactEvidence: DorydMachineSnapshotArtifactEvidence? let consistency: DorydMachineSnapshotConsistency? + let guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String, sizeBytes: Int64, distro: String, version: String, arch: String, boot: String, @@ -26,7 +27,8 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { loginShell: String = "/bin/sh", runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility, artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil, - consistency: DorydMachineSnapshotConsistency? = nil) { + consistency: DorydMachineSnapshotConsistency? = nil, + guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? = nil) { self.id = id self.imageRef = imageRef self.machineName = machineName @@ -45,6 +47,7 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { self.runtimeIdentity = runtimeIdentity self.artifactEvidence = artifactEvidence self.consistency = consistency + self.guestQuiesceReceipt = guestQuiesceReceipt } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 5809abc1..a7c5d0c9 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -392,6 +392,7 @@ struct DorydClientTests { #expect(snapshot.machineID == "dev") #expect(snapshot.runtimeIdentity == .legacyCompatibility) #expect(snapshot.consistency == .coldStopped) + #expect(snapshot.guestQuiesceReceipt == nil) #expect(snapshots.map(\.id).contains("s1")) #expect(clonedSnapshot.id == "dev-copy") #expect(restoredSnapshot.id == "dev") @@ -585,6 +586,14 @@ struct DorydClientTests { #expect(partialHandshake.runtimeEvidence.last?.label == "Tools partially ready") #expect(partialHandshake.runtimeEvidence.last?.detail.contains("clock-sync") == true) + var oldQuiesceHandshake = machine + oldQuiesceHandshake.agentCapabilities = machine.agentCapabilities.map { + $0.id == "snapshot-quiesce" + ? DorydAgentCapability(id: $0.id, version: 1) : $0 + } + #expect(oldQuiesceHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(oldQuiesceHandshake.runtimeEvidence.last?.detail.contains("snapshot-quiesce@2") == true) + var incompatibleHandshake = machine incompatibleHandshake.agentProtocolVersion = 2 #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") @@ -674,7 +683,17 @@ struct DorydClientTests { } @Test func snapshotConsistencyDefaultsOnlyWhenAbsentAndRejectsMalformedClaims() async throws { - let validService = FakeDorydService(snapshotConsistencyOverride: "guest-quiesced") + let receipt = [ + "schemaVersion": 1, + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + ] as NSDictionary + let validService = FakeDorydService( + snapshotConsistencyOverride: "guest-quiesced", + snapshotQuiesceReceiptOverride: receipt + ) let validListener = NSXPCListener.anonymous() let validDelegate = FakeDorydListenerDelegate(service: validService) validListener.delegate = validDelegate @@ -687,10 +706,34 @@ struct DorydClientTests { snapshotID: "consistent" ) #expect(valid.consistency == .guestQuiesced) + #expect(valid.guestQuiesceReceipt?.agentBuild == "dory-agent/test") - for malformed in ["crash-consistent" as Any, 1 as Any] { + for fixture in [ + (consistency: "crash-consistent" as Any, receipt: nil as NSDictionary?), + (consistency: 1 as Any, receipt: nil as NSDictionary?), + (consistency: "guest-quiesced" as Any, receipt: nil as NSDictionary?), + (consistency: "cold-stopped" as Any, receipt: receipt), + (consistency: "guest-quiesced" as Any, receipt: [ + "schemaVersion": "1", + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + ] as NSDictionary), + (consistency: "guest-quiesced" as Any, receipt: [ + "schemaVersion": 1, + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + "unknown": true, + ] as NSDictionary), + ] { let listener = NSXPCListener.anonymous() - let service = FakeDorydService(snapshotConsistencyOverride: malformed) + let service = FakeDorydService( + snapshotConsistencyOverride: fixture.consistency, + snapshotQuiesceReceiptOverride: fixture.receipt + ) let delegate = FakeDorydListenerDelegate(service: service) listener.delegate = delegate listener.resume() @@ -1197,6 +1240,7 @@ struct DorydClientTests { #expect(snapshot.note == "before upgrade") #expect(snapshot.imageRef.hasPrefix("doryd://dev/")) #expect(snapshot.consistency == .coldStopped) + #expect(snapshot.guestQuiesceReceipt == nil) service.setMachineCloneSnapshotDuplicateFailures(1) store.cloneSnapshot(snapshot) @@ -2522,6 +2566,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var artifactEvidenceOverride: NSDictionary? private var installedDesktopPayloadReceiptOverride: NSDictionary? private var snapshotConsistencyOverride: Any? + private var snapshotQuiesceReceiptOverride: NSDictionary? private var snapshots: [String: [NSDictionary]] = [:] private var backupStatuses: [String: NSDictionary] = [:] var engineStartCount: Int { @@ -2673,7 +2718,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { runtimeIdentityOverride: NSDictionary? = nil, artifactEvidenceOverride: NSDictionary? = nil, installedDesktopPayloadReceiptOverride: NSDictionary? = nil, - snapshotConsistencyOverride: Any? = nil + snapshotConsistencyOverride: Any? = nil, + snapshotQuiesceReceiptOverride: NSDictionary? = nil ) { self.socketPath = socketPath self.engineShutdownReplyDelay = engineShutdownReplyDelay @@ -2681,6 +2727,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { self.artifactEvidenceOverride = artifactEvidenceOverride self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride self.snapshotConsistencyOverride = snapshotConsistencyOverride + self.snapshotQuiesceReceiptOverride = snapshotQuiesceReceiptOverride if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { if let runtimeIdentityOverride { existing["runtimeIdentity"] = runtimeIdentityOverride @@ -3089,6 +3136,9 @@ private final class FakeDorydService: NSObject, DorydControlXPC { if let snapshotConsistencyOverride { mutable["consistency"] = snapshotConsistencyOverride } + if let snapshotQuiesceReceiptOverride { + mutable["guestQuiesceReceipt"] = snapshotQuiesceReceiptOverride + } let row = mutable.copy() as? NSDictionary ?? baseRow _machineSnapshotCount += 1 snapshots[machineID, default: []].insert(row, at: 0) @@ -3522,7 +3572,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ["id": "exec", "version": 1] as NSDictionary, ["id": "exec-stdin", "version": 1] as NSDictionary, ["id": "ports-watch", "version": 1] as NSDictionary, - ["id": "snapshot-quiesce", "version": 1] as NSDictionary, + ["id": "snapshot-quiesce", "version": 2] as NSDictionary, ["id": "sync-push", "version": 1] as NSDictionary, ["id": "telemetry", "version": 1] as NSDictionary, ], diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index e9f627f9..28f29798 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1816,6 +1816,18 @@ private extension DoryMachineSnapshotArtifactEvidence { } } +private extension DoryMachineSnapshotQuiesceReceipt { + var xpcDictionary: NSDictionary { + [ + "schemaVersion": schemaVersion, + "receiptID": receiptID, + "agentBuild": agentBuild, + "agentProtocolVersion": agentProtocolVersion, + "capabilityVersion": capabilityVersion, + ] + } +} + private extension DoryMachineShareConfiguration { var xpcDictionary: NSDictionary { [ @@ -1915,6 +1927,9 @@ private extension DoryMachineSnapshot { if let artifactEvidence { dictionary["artifactEvidence"] = artifactEvidence.xpcDictionary } + if let guestQuiesceReceipt { + dictionary["guestQuiesceReceipt"] = guestQuiesceReceipt.xpcDictionary + } let receipt = installedDesktopPayloadReceipt ?? DoryInstalledDesktopPayloadReceipt.legacyEnvironment(environment) if let receipt { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 1a8ef360..297027e1 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1800,6 +1800,7 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility") XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1) XCTAssertEqual(body["consistency"] as? String, "cold-stopped") + XCTAssertNil(body["guestQuiesceReceipt"]) let artifacts = body["artifactEvidence"] as? NSDictionary XCTAssertEqual( ((artifacts?["rootfs"] as? NSDictionary)?["sha256"] as? String)?.count, From 5b2c2a96967315c24850ad8dfc6b1de3f7660d5e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:13:25 +0000 Subject: [PATCH 069/338] feat(agent): expose local sync push --- .../Sources/DoryCore/DoryCore.swift | 11 ++++ .../Sources/DorydKit/AgentControl.swift | 14 ++++++ .../DorydKitTests/AgentControlTests.swift | 14 +++++- dory-core/ffi/src/agent_forward.rs | 50 +++++++++++++++++-- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index bf323546..f5752adb 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -397,6 +397,17 @@ public final class DoryAgentControlHandle: @unchecked Sendable { ) } + public func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + let raw = try withControl { + try $0.push(localRoot: localRoot, remoteRoot: remoteRoot) + } + return DoryPushStats( + filesSent: raw.filesSent, + bytesSent: raw.bytesSent, + filesDeleted: raw.filesDeleted + ) + } + public func snapshotFreeze(receiptID: String) throws -> String { try withControl { try $0.snapshotFreeze(receiptId: receiptID) } } diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index 46658d1a..d7c5ee5b 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -25,6 +25,7 @@ public protocol AgentControlClient: Sendable { func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot func telemetry() throws -> DoryTelemetry + func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats func snapshotFreeze(receiptID: String) throws -> String func snapshotThaw(receiptID: String) throws func exec( @@ -51,6 +52,12 @@ public extension AgentControlClient { throw AgentControlError.capabilityUnavailable("snapshot-quiesce") } + func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + _ = localRoot + _ = remoteRoot + throw AgentControlError.capabilityUnavailable("sync-push") + } + func snapshotThaw(receiptID: String) throws { _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") @@ -131,6 +138,13 @@ public final class AgentControl: @unchecked Sendable { try client(requiring: "telemetry").telemetry() } + public func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + try client(requiring: "sync-push").push( + localRoot: localRoot, + remoteRoot: remoteRoot + ) + } + public func snapshotFreeze(receiptID: String) throws -> String { try client(requiring: "snapshot-quiesce", minimumVersion: 2) .snapshotFreeze(receiptID: receiptID) diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index 70e3a21c..14e02519 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -18,7 +18,8 @@ final class AgentControlTests: XCTestCase { let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") XCTAssertEqual(info.capabilities.map(\.id), [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "telemetry", + "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push", + "telemetry", ]) XCTAssertEqual(counter.value, 1) @@ -26,6 +27,10 @@ final class AgentControlTests: XCTestCase { XCTAssertEqual(fake.clockSyncInputs, [1_500_000_000]) XCTAssertEqual(try control.portsWatch().ports.first?.port, 8080) XCTAssertEqual(try control.telemetry().memTotalKB, 1024) + XCTAssertEqual( + try control.push(localRoot: "/tmp/local", remoteRoot: "/tmp/remote"), + DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) + ) let receiptID = String(repeating: "a", count: 32) XCTAssertEqual(try control.snapshotFreeze(receiptID: receiptID), receiptID) try control.snapshotThaw(receiptID: receiptID) @@ -124,6 +129,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda DoryAgentCapability(id: "exec-stdin", version: 1), DoryAgentCapability(id: "ports-watch", version: 1), DoryAgentCapability(id: "snapshot-quiesce", version: 2), + DoryAgentCapability(id: "sync-push", version: 1), DoryAgentCapability(id: "telemetry", version: 1), ] ) { @@ -205,6 +211,12 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return receiptID } + func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + XCTAssertEqual(localRoot, "/tmp/local") + XCTAssertEqual(remoteRoot, "/tmp/remote") + return DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) + } + func snapshotThaw(receiptID: String) throws { lock.lock() thawReceipts.append(receiptID) diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 721f8475..1430c913 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -10,12 +10,12 @@ use std::sync::Arc; use dory_proto::channels::PORT_CONTROL; use dory_proto::preamble::{write_preamble, Direction, Preamble}; -use dory_remote::AgentClient; +use dory_remote::{push, AgentClient}; use tokio::net::UnixStream; use crate::remote::{ - exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, RemoteFfiError, - TelemetryFfi, + exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, PushStatsFfi, + RemoteFfiError, TelemetryFfi, }; #[derive(uniffi::Record)] @@ -164,6 +164,27 @@ impl AgentControl { }) } + /// Push a host directory through the local VM agent transport. File bytes stay inside Rust; + /// Swift supplies only the two roots and receives bounded aggregate statistics. + pub fn push( + &self, + local_root: String, + remote_root: String, + ) -> Result { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let stats = runtime.block_on(push( + std::path::Path::new(&local_root), + &remote_root, + &self.client, + ))?; + Ok(PushStatsFfi { + files_sent: stats.files_sent, + bytes_sent: stats.bytes_sent, + files_deleted: stats.files_deleted, + }) + } + pub fn snapshot_freeze(&self, receipt_id: String) -> Result { snapshot_quiesce( self, @@ -351,6 +372,29 @@ mod tests { .expect("exec with input"); assert_eq!(echoed.exit_code, 0); assert_eq!(echoed.stdout, input); + let transfer_root = base.join(format!( + "dory-ffi-agent-push-{}-{}", + std::process::id(), + unique_suffix() + )); + let local_root = transfer_root.join("local"); + let remote_root = transfer_root.join("remote"); + std::fs::create_dir_all(&local_root).unwrap(); + std::fs::create_dir_all(&remote_root).unwrap(); + std::fs::write(local_root.join("payload.txt"), b"agent-push").unwrap(); + let stats = control + .push( + local_root.to_string_lossy().into_owned(), + remote_root.to_string_lossy().into_owned(), + ) + .expect("push"); + assert_eq!(stats.files_sent, 1); + assert_eq!(stats.bytes_sent, 10); + assert_eq!( + std::fs::read(remote_root.join("payload.txt")).unwrap(), + b"agent-push" + ); + let _ = std::fs::remove_dir_all(&transfer_root); let _ = std::fs::remove_file(&path); } From 061f7890083ec806fe43e518d6034b48531ae920 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:18:38 +0000 Subject: [PATCH 070/338] feat(sync): stream file pushes --- dory-core/remote/src/error.rs | 2 + dory-core/remote/src/sync_push.rs | 73 ++++++++++++++++++++++++------- dory-core/sync/src/hash.rs | 20 +++++++++ dory-core/sync/src/lib.rs | 2 +- dory-core/sync/src/manifest.rs | 8 ++-- 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/dory-core/remote/src/error.rs b/dory-core/remote/src/error.rs index 4eb8ecf3..04de8e45 100644 --- a/dory-core/remote/src/error.rs +++ b/dory-core/remote/src/error.rs @@ -19,6 +19,8 @@ pub enum RemoteError { Rpc { code: i32, message: String }, #[error("agent returned an unexpected response variant")] UnexpectedVariant, + #[error("local source changed while the transfer was in progress")] + SourceChanged, #[error(transparent)] Io(#[from] std::io::Error), } diff --git a/dory-core/remote/src/sync_push.rs b/dory-core/remote/src/sync_push.rs index 6bc8dc2c..1999524b 100644 --- a/dory-core/remote/src/sync_push.rs +++ b/dory-core/remote/src/sync_push.rs @@ -6,12 +6,14 @@ //! The driver is generic over [`SyncTarget`] so its chunking/resume logic is unit-tested against a //! fake, while [`crate::AgentClient`] is the production target over the real transport. +use std::io::SeekFrom; use std::path::Path; use dory_pb::agent::{ SyncDeleteRequest, SyncFileStatusRequest, SyncManifestRequest, SyncPutChunkRequest, }; use dory_sync::{plan, walk_manifest, Hash, Manifest, CHUNK_BYTES, HASH_LEN}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; use crate::agent_client::AgentClient; use crate::error::RemoteError; @@ -73,21 +75,21 @@ pub async fn push( let entry = local .get(rel) .expect("transfer paths are drawn from the local manifest"); - let bytes = tokio::fs::read(local_root.join(rel)).await?; + let mut file = tokio::fs::File::open(local_root.join(rel)).await?; // Resume: pick up where the remote left off, unless its staged size is past our file (stale). let staged = target.staged_bytes(remote_root, rel, &entry.hash).await?; - let mut offset: usize = if staged <= bytes.len() as u64 { - staged as usize - } else { - 0 - }; + let mut offset = if staged <= entry.size { staged } else { 0 }; let mut conflict_recoveries = 0usize; loop { - let end = (offset + CHUNK_BYTES).min(bytes.len()); - let chunk = bytes[offset..end].to_vec(); - let last = end == bytes.len(); + file.seek(SeekFrom::Start(offset)).await?; + let remaining = entry.size.checked_sub(offset).ok_or(RemoteError::Decode)?; + let chunk_len = remaining.min(CHUNK_BYTES as u64) as usize; + let mut chunk = vec![0u8; chunk_len]; + file.read_exact(&mut chunk).await?; + let end = offset + chunk_len as u64; + let last = end == entry.size; let sent = chunk.len() as u64; // The peer's returned next_offset is NOT trusted for indexing: a buggy/hostile agent // could return an out-of-bounds value and panic the slice below (panic=abort => doryd @@ -97,7 +99,7 @@ pub async fn push( root: remote_root.to_string(), path: rel.clone(), hash: entry.hash.to_vec(), - offset: offset as u64, + offset, data: chunk, last, mode: entry.mode, @@ -111,11 +113,7 @@ pub async fn push( { conflict_recoveries += 1; let staged = target.staged_bytes(remote_root, rel, &entry.hash).await?; - offset = if staged <= bytes.len() as u64 { - staged as usize - } else { - 0 - }; + offset = if staged <= entry.size { staged } else { 0 }; continue; } Err(error) => return Err(error), @@ -126,7 +124,7 @@ pub async fn push( // one exception where it affects control flow, so require proof that the peer claims // the complete local length. The final request must likewise be acknowledged as a // complete commit; inconsistent responses are protocol decode failures, not success. - if committed && next_offset != bytes.len() as u64 { + if committed && next_offset != entry.size { return Err(RemoteError::Decode); } if last && !committed { @@ -142,6 +140,17 @@ pub async fn push( if !plan.delete.is_empty() { stats.files_deleted += target.delete(remote_root, &plan.delete).await? as u64; } + + // The manifest is a point-in-time source authority. Re-read it after all remote mutations so a + // concurrently changed, truncated, or appended source cannot be reported as an exact replica. + // Retrying is safe: the protocol is content-addressed and resumable. + let root = local_root.to_path_buf(); + let final_local = tokio::task::spawn_blocking(move || walk_manifest(&root)) + .await + .map_err(|e| RemoteError::Io(std::io::Error::other(e)))??; + if final_local != local { + return Err(RemoteError::SourceChanged); + } Ok(stats) } @@ -247,6 +256,8 @@ mod tests { suppress_final_commit: bool, conflict_once_at_offset: Option, conflict_fired: Mutex, + mutate_source_after_first_chunk: Option<(std::path::PathBuf, Vec)>, + source_mutation_fired: Mutex, rec: Mutex, } impl FakeTarget { @@ -260,6 +271,8 @@ mod tests { suppress_final_commit: false, conflict_once_at_offset: None, conflict_fired: Mutex::new(false), + mutate_source_after_first_chunk: None, + source_mutation_fired: Mutex::new(false), rec: Mutex::new(Recorded::default()), } } @@ -290,6 +303,13 @@ mod tests { Ok(self.preset_staged.get(path).copied().unwrap_or(0)) } async fn put_chunk(&self, req: SyncPutChunkRequest) -> Result<(u64, bool), RemoteError> { + if let Some((path, replacement)) = &self.mutate_source_after_first_chunk { + let mut fired = self.source_mutation_fired.lock().unwrap(); + if !*fired { + std::fs::write(path, replacement).unwrap(); + *fired = true; + } + } if self.conflict_once_at_offset == Some(req.offset) { let mut fired = self.conflict_fired.lock().unwrap(); if !*fired { @@ -336,6 +356,12 @@ mod tests { assert_eq!(target.assembled("dir/b.bin"), big); // The final chunk of each file is flagged `last`. let rec = target.rec.lock().unwrap(); + assert!( + rec.chunks + .iter() + .all(|chunk| chunk.data.len() <= CHUNK_BYTES), + "the push driver must never allocate a whole-file transport buffer" + ); assert!( rec.chunks .iter() @@ -489,4 +515,19 @@ mod tests { assert_eq!(chunks.len(), 1, "empty file is one last chunk"); assert!(chunks[0].last && chunks[0].data.is_empty()); } + + #[tokio::test] + async fn push_fails_closed_when_the_local_source_changes_mid_transfer() { + let t = TempTree::new("source-drift"); + let original = vec![0x11; CHUNK_BYTES + 50]; + let replacement = vec![0x22; CHUNK_BYTES + 50]; + t.write("f", &original); + + let mut target = FakeTarget::new(Manifest::default()); + target.mutate_source_after_first_chunk = Some((t.root.join("f"), replacement)); + + let error = push(&t.root, "/remote", &target).await.unwrap_err(); + assert!(matches!(error, RemoteError::SourceChanged)); + assert!(*target.source_mutation_fired.lock().unwrap()); + } } diff --git a/dory-core/sync/src/hash.rs b/dory-core/sync/src/hash.rs index c9ef9046..3851af91 100644 --- a/dory-core/sync/src/hash.rs +++ b/dory-core/sync/src/hash.rs @@ -1,4 +1,6 @@ use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::Path; pub const HASH_LEN: usize = 32; @@ -11,3 +13,21 @@ pub fn hash_bytes(bytes: &[u8]) -> Hash { hasher.update(bytes); hasher.finalize().into() } + +/// Hash a file without loading it into memory. Callers that need a stable snapshot must compare +/// the surrounding file metadata or re-hash at their transaction boundary; this function only +/// guarantees bounded-memory SHA-256 calculation for the bytes observed by this open file. +pub fn hash_file(path: &Path) -> std::io::Result { + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::with_capacity(crate::CHUNK_BYTES, file); + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; crate::CHUNK_BYTES]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().into()) +} diff --git a/dory-core/sync/src/lib.rs b/dory-core/sync/src/lib.rs index de5aa345..90283759 100644 --- a/dory-core/sync/src/lib.rs +++ b/dory-core/sync/src/lib.rs @@ -7,7 +7,7 @@ mod hash; mod manifest; mod plan; -pub use hash::{hash_bytes, Hash, HASH_LEN}; +pub use hash::{hash_bytes, hash_file, Hash, HASH_LEN}; pub use manifest::{walk_manifest, walk_manifest_excluding, FileEntry, Manifest}; pub use plan::{plan, SyncPlan}; diff --git a/dory-core/sync/src/manifest.rs b/dory-core/sync/src/manifest.rs index 1e38bdf0..b0694c42 100644 --- a/dory-core/sync/src/manifest.rs +++ b/dory-core/sync/src/manifest.rs @@ -1,4 +1,4 @@ -use crate::hash::{hash_bytes, Hash}; +use crate::hash::{hash_file, Hash}; use std::path::Path; /// One file in a sync tree. `path` is relative to the sync root, forward-slash separated, so a host @@ -100,8 +100,8 @@ fn walk_into( walk_into(root, &path, excluded_root_children, tolerate_not_found, out)?; } else if file_type.is_file() { let rel = relative_slash(root, &path); - let contents = match std::fs::read(&path) { - Ok(contents) => contents, + let hash = match hash_file(&path) { + Ok(hash) => hash, Err(e) if tolerate_not_found && e.kind() == std::io::ErrorKind::NotFound => { continue } @@ -112,7 +112,7 @@ fn walk_into( size: meta.len(), mtime_ns: mtime_ns(&meta), mode: mode_of(&meta), - hash: hash_bytes(&contents), + hash, }); } // Anything else (symlink, socket, fifo, device) is skipped. From be25c74a0c91f3d229e52caf19912c249f260097 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:26:06 +0000 Subject: [PATCH 071/338] feat(vm): add safe guest file transfer --- .../DorydKit/DoryMachineFileTransfer.swift | 57 ++++ .../Sources/DorydKit/MachineManager.swift | 174 +++++++++++ .../MachineManagerFileTransferTests.swift | 275 ++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift new file mode 100644 index 00000000..17d413a0 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift @@ -0,0 +1,57 @@ +import DoryCore +import Foundation + +/// Safe evidence returned after doryd has copied one private staging tree into a managed guest. +/// Host source paths are deliberately absent from this public/status-safe result. +public struct DoryMachineFileTransferResult: Sendable, Equatable { + public var transferID: String + public var guestDestination: String + public var filesSent: UInt64 + public var bytesSent: UInt64 + + public init( + transferID: String, + guestDestination: String, + filesSent: UInt64, + bytesSent: UInt64 + ) { + self.transferID = transferID + self.guestDestination = guestDestination + self.filesSent = filesSent + self.bytesSent = bytesSent + } +} + +public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStringConvertible { + case invalidPrivateStagingRoot + case guestAccountUnavailable(String) + case guestPreparationFailed(String) + case transferFailed(String) + case guestFinalizationFailed(String) + + public var description: String { + switch self { + case .invalidPrivateStagingRoot: + "file transfer staging root is invalid or not private" + case let .guestAccountUnavailable(machineID): + "managed guest account is unavailable for machine: \(machineID)" + case let .guestPreparationFailed(machineID): + "could not prepare the guest transfer directory for machine: \(machineID)" + case let .transferFailed(machineID): + "file transfer failed for machine: \(machineID)" + case let .guestFinalizationFailed(machineID): + "could not finalize guest file ownership for machine: \(machineID)" + } + } +} + +extension DoryMachineFileTransferResult { + init(transferID: String, guestDestination: String, stats: DoryPushStats) { + self.init( + transferID: transferID, + guestDestination: guestDestination, + filesSent: stats.filesSent, + bytesSent: stats.bytesSent + ) + } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index eb963d65..81b822fe 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5006,6 +5006,180 @@ public final class MachineManager: @unchecked Sendable { } } + /// Copies a daemon-private staging tree into a fresh guest-owned Downloads subdirectory. + /// The destination is intentionally derived here rather than accepted from XPC: `sync-push` + /// makes its target an exact replica, so pointing it at an existing user directory could erase + /// unrelated files. The caller remains responsible for deleting its staging root afterwards. + public func transferStagedFiles( + id: String, + privateStagingRoot: String + ) throws -> DoryMachineFileTransferResult { + guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + guard let machineStatus = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + guard machineStatus.state == .running, + machineStatus.agentSocketPath != nil else { + throw MachineManagerError.agentUnavailable(id) + } + for capability in ["exec", "sync-push"] where + !machineStatus.supportsAgentCapability(capability) { + throw MachineManagerError.agentCapabilityUnavailable(id, capability) + } + + guard let account = machineStatus.typedSettings?.guestIdentityIntent.account, + let username = account.username, + DoryVMGuestAccountIntent.isValidUsername(username), + username != "root" else { + throw DoryMachineFileTransferError.guestAccountUnavailable(id) + } + + let transferID = UUID().uuidString + .replacingOccurrences(of: "-", with: "") + .lowercased() + let guestHome = "/home/\(username)" + let downloads = guestHome + "/Downloads" + let guestDestination = downloads + "/Dory Transfer " + transferID + + return try withAgentClient(id: id) { client in + let uid = try Self.guestNumericIdentity( + client: client, + program: "/usr/bin/id", + argument: "-u", + username: username, + machineID: id + ) + let gid = try Self.guestNumericIdentity( + client: client, + program: "/usr/bin/id", + argument: "-g", + username: username, + machineID: id + ) + if let expectedUID = account.numericUserID, expectedUID != uid { + throw DoryMachineFileTransferError.guestAccountUnavailable(id) + } + let guestIdentityEnvironment = [ + DoryExecEnvironment(key: "DORY_AGENT_RUN_UID", value: String(uid)), + DoryExecEnvironment(key: "DORY_AGENT_RUN_GID", value: String(gid)), + ] + try Self.requireSuccessfulTransferCommand( + client.exec( + argv: ["/bin/mkdir", "-p", "--", downloads], + cwd: guestHome, + env: guestIdentityEnvironment, + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), + error: .guestPreparationFailed(id) + ) + // No `-p`: a collision or pre-existing attacker-controlled directory must fail rather + // than becoming the exact-replica target. + try Self.requireSuccessfulTransferCommand( + client.exec( + argv: ["/bin/mkdir", "--", guestDestination], + cwd: downloads, + env: guestIdentityEnvironment, + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), + error: .guestPreparationFailed(id) + ) + + var completed = false + defer { + if !completed { + _ = try? client.exec( + argv: ["/bin/rm", "-rf", "--", guestDestination], + cwd: downloads, + env: [], + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ) + } + } + let stats: DoryPushStats + do { + stats = try client.push( + localRoot: privateStagingRoot, + remoteRoot: guestDestination + ) + } catch { + throw DoryMachineFileTransferError.transferFailed(id) + } + guard stats.filesDeleted == 0 else { + throw DoryMachineFileTransferError.transferFailed(id) + } + try Self.requireSuccessfulTransferCommand( + client.exec( + argv: [ + "/bin/chown", "-R", "--", "\(uid):\(gid)", guestDestination, + ], + cwd: downloads, + env: [], + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), + error: .guestFinalizationFailed(id) + ) + completed = true + return DoryMachineFileTransferResult( + transferID: transferID, + guestDestination: guestDestination, + stats: stats + ) + } + } + + private static func guestNumericIdentity( + client: any AgentControlClient, + program: String, + argument: String, + username: String, + machineID: String + ) throws -> UInt32 { + let result = try client.exec( + argv: [program, argument, "--", username], + cwd: "/", + env: [], + timeoutMs: 10_000, + outputLimitBytes: 4 * 1024 + ) + guard result.exitCode == 0, + !result.timedOut, + !result.stdoutTruncated, + !result.stderrTruncated, + let value = UInt32( + String(decoding: result.stdout, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + ), + DoryVMGuestAccountIntent.isValidNumericUserID(value) else { + throw DoryMachineFileTransferError.guestAccountUnavailable(machineID) + } + return value + } + + private static func requireSuccessfulTransferCommand( + _ result: DoryExecResult, + error: DoryMachineFileTransferError + ) throws { + guard result.exitCode == 0, !result.timedOut else { throw error } + } + + private static func isPrivateTransferStagingRoot(_ path: String) -> Bool { + guard path.hasPrefix("/"), !path.contains("\0") else { return false } + var info = stat() + guard lstat(path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == geteuid(), + (info.st_mode & 0o077) == 0 else { + return false + } + return true + } + private func withAgentClient( id: String, requiredCapability: String? = nil, diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift new file mode 100644 index 00000000..46c15d70 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -0,0 +1,275 @@ +import Darwin +import DoryCore +import DoryOperations +@testable import DorydKit +import Foundation +import XCTest + +final class MachineManagerFileTransferTests: XCTestCase { + func testTransferUsesUniqueDerivedDestinationAndGuestOwnership() throws { + let fixture = try makeRunningFixture(tag: "success") + defer { fixture.cleanup() } + + let result = try fixture.manager.transferStagedFiles( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + ) + + XCTAssertEqual(result.transferID.utf8.count, 32) + XCTAssertTrue(result.transferID.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + XCTAssertEqual( + result.guestDestination, + "/home/alice/Downloads/Dory Transfer \(result.transferID)" + ) + XCTAssertEqual(result.filesSent, 2) + XCTAssertEqual(result.bytesSent, 12) + XCTAssertEqual(fixture.agent.pushes, [ + .init(localRoot: fixture.stagingRoot, remoteRoot: result.guestDestination), + ]) + + let mkdirs = fixture.agent.execs.filter { $0.argv.first == "/bin/mkdir" } + XCTAssertEqual(mkdirs.count, 2) + XCTAssertEqual(mkdirs[0].argv, ["/bin/mkdir", "-p", "--", "/home/alice/Downloads"]) + XCTAssertEqual( + mkdirs[1].argv, + ["/bin/mkdir", "--", result.guestDestination] + ) + XCTAssertEqual( + Set(mkdirs[1].env), + Set([ + DoryExecEnvironment(key: "DORY_AGENT_RUN_UID", value: "1000"), + DoryExecEnvironment(key: "DORY_AGENT_RUN_GID", value: "1000"), + ]) + ) + XCTAssertTrue(fixture.agent.execs.contains { + $0.argv == [ + "/bin/chown", "-R", "--", "1000:1000", result.guestDestination, + ] + }) + XCTAssertFalse(fixture.agent.execs.contains { $0.argv.first == "/bin/rm" }) + } + + func testTransferFailureRemovesOnlyTheUniqueGuestDirectory() throws { + let fixture = try makeRunningFixture(tag: "failure", failPush: true) + defer { fixture.cleanup() } + + XCTAssertThrowsError(try fixture.manager.transferStagedFiles( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .transferFailed("desktop") + ) + } + XCTAssertEqual(fixture.agent.pushes.count, 1) + let destination = try XCTUnwrap(fixture.agent.pushes.first?.remoteRoot) + XCTAssertTrue(fixture.agent.execs.contains { + $0.argv == ["/bin/rm", "-rf", "--", destination] + }) + XCTAssertFalse(fixture.agent.execs.contains { $0.argv.first == "/bin/chown" }) + } + + func testTransferRejectsNonPrivateSourceBeforeContactingGuest() throws { + let fixture = try makeRunningFixture(tag: "public-source") + defer { fixture.cleanup() } + XCTAssertEqual(chmod(fixture.stagingRoot, 0o755), 0) + let baselineExecCount = fixture.agent.execs.count + + XCTAssertThrowsError(try fixture.manager.transferStagedFiles( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .invalidPrivateStagingRoot + ) + } + XCTAssertEqual(fixture.agent.execs.count, baselineExecCount) + XCTAssertTrue(fixture.agent.pushes.isEmpty) + } + + private func makeRunningFixture( + tag: String, + failPush: Bool = false + ) throws -> TransferFixture { + let suffix = "\(getpid())-\(UInt32.random(in: 0.. any AgentControlClient { + _ = socketPath + return TransferAgentClient(owner: self) + } + + func execute(argv: [String], cwd: String, env: [DoryExecEnvironment]) -> DoryExecResult { + lock.lock() + recordedExecs.append(Exec(argv: argv, cwd: cwd, env: env)) + lock.unlock() + let output = argv.prefix(2) == ["/usr/bin/id", "-u"] + || argv.prefix(2) == ["/usr/bin/id", "-g"] ? "1000\n" : "" + return DoryExecResult( + exitCode: 0, + stdout: Data(output.utf8), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } + + func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + lock.lock() + recordedPushes.append(Push(localRoot: localRoot, remoteRoot: remoteRoot)) + lock.unlock() + if failPush { + throw AgentControlError.capabilityUnavailable("injected-transfer-failure") + } + return DoryPushStats(filesSent: 2, bytesSent: 12, filesDeleted: 0) + } +} + +private final class TransferAgentClient: AgentControlClient, @unchecked Sendable { + private let owner: TransferAgentRecorder + + init(owner: TransferAgentRecorder) { + self.owner = owner + } + + func info() throws -> DoryAgentInfo { + DoryAgentInfo( + protocolVersion: DoryCore.protocolVersion(), + kernel: "Linux transfer-test", + agentBuild: "dory-agent/transfer-test", + uptimeSeconds: 1 + ) + } + + func clockSync(hostEpochNs: Int64) throws -> Bool { true } + func portsWatch() throws -> DoryPortsSnapshot { .init(ports: [], added: [], removed: []) } + func telemetry() throws -> DoryTelemetry { + .init(memTotalKB: 1024, memAvailableKB: 512, psiSomeAvg10: 0, psiFullAvg10: 0) + } + func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { + try owner.push(localRoot: localRoot, remoteRoot: remoteRoot) + } + func exec( + argv: [String], + cwd: String, + env: [DoryExecEnvironment], + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult { + _ = timeoutMs + _ = outputLimitBytes + return owner.execute(argv: argv, cwd: cwd, env: env) + } + func close() {} +} From 64bbbe4e89c716fc56d837bf0480842a43d40f57 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:28:57 +0000 Subject: [PATCH 072/338] feat(vm): expose file transfer over xpc --- .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 69 +++++++++ .../DorydKitTests/DorydServiceTests.swift | 132 +++++++++++++++++- 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index dc7df97c..aceb5d18 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -25,6 +25,7 @@ import Foundation func machineList(reply: @escaping (NSArray, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 28f29798..17e7a904 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -417,6 +417,44 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineTransfer( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + let parsedRequest: MachineTransferRequest + do { + parsedRequest = try MachineTransferRequest(xpcDictionary: request) + } catch { + reply(false, [:], String(describing: error)) + return + } + let reply = StatusReply(reply) + DispatchQueue.global(qos: .utility).async { [incidentWriter] in + do { + let result = try machineManager.transferStagedFiles( + id: machineID, + privateStagingRoot: parsedRequest.privateStagingRoot + ) + incidentWriter?.record( + type: "machine.file_transfer", + detail: "\(machineID) \(result.transferID) files=\(result.filesSent) bytes=\(result.bytesSent)" + ) + reply.reply(true, result.xpcDictionary, "") + } catch { + incidentWriter?.record( + type: "machine.file_transfer_failed", + detail: "\(machineID): \(error)" + ) + reply.reply(false, [:], String(describing: error)) + } + } + } + public func machineProvision( _ machineID: String, request: NSDictionary, @@ -1231,6 +1269,25 @@ private struct MachineExecRequest { } } +private struct MachineTransferRequest: Sendable { + var privateStagingRoot: String + + init(xpcDictionary dictionary: NSDictionary) throws { + guard let keys = dictionary.allKeys as? [String], + Set(keys) == ["schema", "privateStagingRoot"], + let schema = dictionary["schema"] as? NSNumber, + CFGetTypeID(schema) != CFBooleanGetTypeID(), + schema.uint16Value == 1, + schema.doubleValue == 1, + let root = dictionary["privateStagingRoot"] as? String, + root.hasPrefix("/"), + !root.contains("\0") else { + throw XPCRemoteConfigError.invalid("machineTransfer") + } + privateStagingRoot = root + } +} + private struct MachineProvisionRequest { var recipeID: String @@ -1368,6 +1425,18 @@ private extension DoryMachineConfiguration { } } +private extension DoryMachineFileTransferResult { + var xpcDictionary: NSDictionary { + [ + "schema": UInt16(1), + "transferID": transferID, + "guestDestination": guestDestination, + "filesSent": filesSent, + "bytesSent": bytesSent, + ] + } +} + private extension DomainRoute { init(xpcDictionary dictionary: NSDictionary) throws { guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 297027e1..9ae2991f 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1671,6 +1671,127 @@ final class DorydServiceTests: XCTestCase { wait(for: [exec], timeout: 5) } + func testMachineTransferOverXPCUsesExactShapeAndOmitsHostPath() throws { + let suffix = "\(getpid())-\(UInt32.random(in: 0.. DoryPushStats { + _ = localRoot + _ = remoteRoot + return DoryPushStats(filesSent: 2, bytesSent: 7, filesDeleted: 0) + } + func exec( argv: [String], cwd: String, @@ -1999,7 +2126,10 @@ private final class ServiceFakeAgentControlClient: AgentControlClient, @unchecke ) throws -> DoryExecResult { let command = argv.joined(separator: " ") let output: String - if command.contains("apk add --no-cache cargo rust") { + if argv.prefix(2) == ["/usr/bin/id", "-u"] + || argv.prefix(2) == ["/usr/bin/id", "-g"] { + output = "1000\n" + } else if command.contains("apk add --no-cache cargo rust") { output = "installed rust\n" } else if command.contains("cargo --version") { output = "cargo 1.0\n" From f352e893fca74db5c85217e7d4d4013df7451cc1 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:31:51 +0000 Subject: [PATCH 073/338] feat(vm): stage selected transfer files --- .../DoryMachineFileTransferStager.swift | 262 ++++++++++++++++++ .../DoryMachineFileTransferStagerTests.swift | 121 ++++++++ 2 files changed, 383 insertions(+) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift new file mode 100644 index 00000000..b507cbfc --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift @@ -0,0 +1,262 @@ +import Darwin +import Foundation + +public struct DoryStagedMachineFileTransfer: Sendable, Equatable { + public let rootPath: String + public let fileCount: UInt64 + public let byteCount: UInt64 + + fileprivate init(rootPath: String, fileCount: UInt64, byteCount: UInt64) { + self.rootPath = rootPath + self.fileCount = fileCount + self.byteCount = byteCount + } + + public func remove() throws { + try FileManager.default.removeItem(atPath: rootPath) + } +} + +public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, + CustomStringConvertible { + case emptySelection + case tooManyFiles + case duplicateFileName(String) + case unsupportedFile(String) + case fileTooLarge(String) + case transferTooLarge + case sourceChanged(String) + case unsafeStagingDirectory + case io(String, Int32) + + public var description: String { + switch self { + case .emptySelection: + "no files were selected" + case .tooManyFiles: + "too many files were selected" + case let .duplicateFileName(name): + "more than one selected file is named \(name)" + case let .unsupportedFile(name): + "selected item is not a supported regular file: \(name)" + case let .fileTooLarge(name): + "selected file is too large: \(name)" + case .transferTooLarge: + "selected files exceed the transfer size limit" + case let .sourceChanged(name): + "selected file changed while it was being staged: \(name)" + case .unsafeStagingDirectory: + "file transfer staging directory is not private" + case let .io(operation, code): + "file transfer staging \(operation) failed: \(String(cString: strerror(code)))" + } + } +} + +/// Copies user-selected files into a private, flat handoff tree that doryd can consume after the +/// selecting app's security-scoped access ends. Version 1 intentionally accepts regular files only: +/// directory transfer needs an explicit directory manifest so empty directories are not silently +/// lost by the file-only sync protocol. +public enum DoryMachineFileTransferStager { + public static let maximumFileCount = 10_000 + public static let maximumFileBytes: UInt64 = 16 * 1024 * 1024 * 1024 + public static let maximumTransferBytes: UInt64 = 64 * 1024 * 1024 * 1024 + private static let copyBufferBytes = 1024 * 1024 + private static let reservedRootName = ".dory-sync-tmp" + + public static func stage( + fileURLs: [URL], + stagingDirectory requestedDirectory: URL? = nil + ) throws -> DoryStagedMachineFileTransfer { + guard !fileURLs.isEmpty else { + throw DoryMachineFileTransferStagingError.emptySelection + } + guard fileURLs.count <= maximumFileCount else { + throw DoryMachineFileTransferStagingError.tooManyFiles + } + let names = fileURLs.map(\.lastPathComponent) + var uniqueNames = Set() + for name in names where !uniqueNames.insert(name).inserted { + throw DoryMachineFileTransferStagingError.duplicateFileName(name) + } + for name in names where !isValidFileName(name) { + throw DoryMachineFileTransferStagingError.unsupportedFile(name) + } + + let stagingDirectory = requestedDirectory + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("dory-machine-transfer-imports", isDirectory: true) + if mkdir(stagingDirectory.path, mode_t(0o700)) != 0, errno != EEXIST { + throw DoryMachineFileTransferStagingError.io("directory creation", errno) + } + let baseDescriptor = open( + stagingDirectory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard baseDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("directory open", errno) + } + defer { close(baseDescriptor) } + guard isPrivateDirectory(descriptor: baseDescriptor) else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + + let transferName = "transfer-" + UUID().uuidString.lowercased() + guard mkdirat(baseDescriptor, transferName, mode_t(0o700)) == 0 else { + throw DoryMachineFileTransferStagingError.io("transfer creation", errno) + } + let transferURL = stagingDirectory.appendingPathComponent( + transferName, + isDirectory: true + ) + var completed = false + defer { + if !completed { + try? FileManager.default.removeItem(at: transferURL) + } + } + let transferDescriptor = openat( + baseDescriptor, + transferName, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard transferDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("transfer open", errno) + } + defer { close(transferDescriptor) } + guard isPrivateDirectory(descriptor: transferDescriptor) else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + + var totalBytes: UInt64 = 0 + for (sourceURL, name) in zip(fileURLs, names) { + let securityScope = sourceURL.startAccessingSecurityScopedResource() + defer { + if securityScope { sourceURL.stopAccessingSecurityScopedResource() } + } + let sourceDescriptor = open( + sourceURL.path, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard sourceDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("source open", errno) + } + defer { close(sourceDescriptor) } + var before = stat() + guard fstat(sourceDescriptor, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_size >= 0 else { + throw DoryMachineFileTransferStagingError.unsupportedFile(name) + } + let size = UInt64(before.st_size) + guard size <= maximumFileBytes else { + throw DoryMachineFileTransferStagingError.fileTooLarge(name) + } + let (nextTotal, overflow) = totalBytes.addingReportingOverflow(size) + guard !overflow, nextTotal <= maximumTransferBytes else { + throw DoryMachineFileTransferStagingError.transferTooLarge + } + + let destinationDescriptor = openat( + transferDescriptor, + name, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard destinationDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("destination open", errno) + } + do { + try copy( + sourceDescriptor: sourceDescriptor, + destinationDescriptor: destinationDescriptor + ) + guard fsync(destinationDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("file sync", errno) + } + } catch { + close(destinationDescriptor) + throw error + } + close(destinationDescriptor) + + var after = stat() + guard fstat(sourceDescriptor, &after) == 0 else { + throw DoryMachineFileTransferStagingError.io("source revalidation", errno) + } + guard sameSnapshot(before, after) else { + throw DoryMachineFileTransferStagingError.sourceChanged(name) + } + totalBytes = nextTotal + } + guard fsync(transferDescriptor) == 0, fsync(baseDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("directory sync", errno) + } + completed = true + return DoryStagedMachineFileTransfer( + rootPath: transferURL.path, + fileCount: UInt64(fileURLs.count), + byteCount: totalBytes + ) + } + + private static func copy( + sourceDescriptor: Int32, + destinationDescriptor: Int32 + ) throws { + var buffer = [UInt8](repeating: 0, count: copyBufferBytes) + while true { + let count = buffer.withUnsafeMutableBytes { bytes in + Darwin.read(sourceDescriptor, bytes.baseAddress, bytes.count) + } + if count == 0 { return } + if count < 0 { + if errno == EINTR { continue } + throw DoryMachineFileTransferStagingError.io("source read", errno) + } + var written = 0 + while written < count { + let result = buffer.withUnsafeBytes { bytes in + Darwin.write( + destinationDescriptor, + bytes.baseAddress?.advanced(by: written), + count - written + ) + } + if result < 0 { + if errno == EINTR { continue } + throw DoryMachineFileTransferStagingError.io("destination write", errno) + } + written += result + } + } + } + + private static func isValidFileName(_ name: String) -> Bool { + let count = name.utf8.count + return (1...255).contains(count) + && name != "." + && name != ".." + && name != reservedRootName + && !name.contains("/") + && !name.contains("\0") + } + + private static func isPrivateDirectory(descriptor: Int32) -> Bool { + var info = stat() + return fstat(descriptor, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == geteuid() + && (info.st_mode & 0o077) == 0 + } + + private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec + && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift new file mode 100644 index 00000000..9b7be12f --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift @@ -0,0 +1,121 @@ +import Darwin +@testable import DoryOperations +import Foundation +import XCTest + +final class DoryMachineFileTransferStagerTests: XCTestCase { + func testStagesSelectedFilesIntoPrivateFlatHandoff() throws { + let fixture = try StagerFixture(tag: "success") + defer { fixture.cleanup() } + let first = fixture.source.appendingPathComponent("hello.txt") + let second = fixture.source.appendingPathComponent("empty.bin") + try Data("hello".utf8).write(to: first) + try Data().write(to: second) + + let staged = try DoryMachineFileTransferStager.stage( + fileURLs: [first, second], + stagingDirectory: fixture.staging + ) + defer { try? staged.remove() } + + XCTAssertEqual(staged.fileCount, 2) + XCTAssertEqual(staged.byteCount, 5) + XCTAssertEqual( + try Data(contentsOf: URL(fileURLWithPath: staged.rootPath + "/hello.txt")), + Data("hello".utf8) + ) + XCTAssertEqual( + try Data(contentsOf: URL(fileURLWithPath: staged.rootPath + "/empty.bin")), + Data() + ) + XCTAssertEqual(try permissions(staged.rootPath) & 0o777, 0o700) + XCTAssertEqual(try permissions(staged.rootPath + "/hello.txt") & 0o777, 0o600) + } + + func testRejectsDirectoriesSymlinksAndDuplicateNames() throws { + let fixture = try StagerFixture(tag: "shapes") + defer { fixture.cleanup() } + let directory = fixture.source.appendingPathComponent("folder", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( + fileURLs: [directory], + stagingDirectory: fixture.staging + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .unsupportedFile("folder") + ) + } + + let regular = fixture.source.appendingPathComponent("regular") + let symlinkURL = fixture.source.appendingPathComponent("link") + try Data("x".utf8).write(to: regular) + XCTAssertEqual(Darwin.symlink(regular.path, symlinkURL.path), 0) + XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( + fileURLs: [symlinkURL], + stagingDirectory: fixture.staging + )) + + let otherRoot = fixture.root.appendingPathComponent("other", isDirectory: true) + try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: false) + let duplicate = otherRoot.appendingPathComponent("regular") + try Data("y".utf8).write(to: duplicate) + XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( + fileURLs: [regular, duplicate], + stagingDirectory: fixture.staging + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .duplicateFileName("regular") + ) + } + } + + func testRejectsPublicExistingStagingDirectory() throws { + let fixture = try StagerFixture(tag: "public") + defer { fixture.cleanup() } + let source = fixture.source.appendingPathComponent("file") + try Data("x".utf8).write(to: source) + XCTAssertEqual(chmod(fixture.staging.path, 0o755), 0) + + XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( + fileURLs: [source], + stagingDirectory: fixture.staging + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .unsafeStagingDirectory + ) + } + } + + private func permissions(_ path: String) throws -> mode_t { + var info = stat() + guard lstat(path, &info) == 0 else { + throw DoryMachineFileTransferStagingError.io("test stat", errno) + } + return info.st_mode + } +} + +private struct StagerFixture { + var root: URL + var source: URL + var staging: URL + + init(tag: String) throws { + root = URL(fileURLWithPath: "/tmp/dory-transfer-stager-\(tag)-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 00:36:05 +0000 Subject: [PATCH 074/338] feat(vm): add app file transfer client --- Dory/Runtime/Doryd/DorydClient.swift | 94 ++++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 105 +++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 6f865e84..70868ac4 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -25,6 +25,7 @@ nonisolated protocol DorydControlXPC { func machineList(reply: @escaping (NSArray, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -983,6 +984,13 @@ nonisolated struct DorydPushStats: Sendable, Equatable { var filesDeleted: UInt64 } +nonisolated struct DorydMachineFileTransferResult: Sendable, Equatable { + var transferID: String + var guestDestination: String + var filesSent: UInt64 + var bytesSent: UInt64 +} + nonisolated struct DorydRemoteMachineStatus: Sendable, Equatable { var id: String var state: String @@ -1413,6 +1421,28 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineTransfer( + _ machineID: String, + staged: DoryStagedMachineFileTransfer + ) async throws -> DorydMachineFileTransferResult { + let request: NSDictionary = [ + "schema": UInt16(1), + "privateStagingRoot": staged.rootPath, + ] + return try await withTimeout( + atLeast: Self.machineTransferControlTimeout(byteCount: staged.byteCount) + ).statusCommand { proxy, reply in + proxy.machineTransfer(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let result = Self.machineFileTransferResult(from: dictionary), + result.filesSent == staged.fileCount, + result.bytesSent == staged.byteCount else { + return nil + } + return result + } + } + func machineStats(_ machineID: String) async throws -> DorydMachineStats { try await withTimeout(atLeast: 10).statusCommand { proxy, reply in proxy.machineStats(machineID, reply: reply) @@ -2586,6 +2616,52 @@ nonisolated final class DorydClient: @unchecked Sendable { return DorydPushStats(filesSent: filesSent, bytesSent: bytesSent, filesDeleted: filesDeleted) } + nonisolated private static func machineFileTransferResult( + from dictionary: NSDictionary + ) -> DorydMachineFileTransferResult? { + guard Set(dictionary.allKeys.compactMap { $0 as? String }) + == ["schema", "transferID", "guestDestination", "filesSent", "bytesSent"], + dictionary.allKeys.count == 5, + strictUInt64(dictionary["schema"]) == 1, + let transferID = dictionary["transferID"] as? String, + transferID.utf8.count == 32, + transferID.utf8.allSatisfy({ byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + }), + let guestDestination = dictionary["guestDestination"] as? String, + guestDestination.utf8.count <= 4_096, + !guestDestination.contains("\0"), + let filesSent = strictUInt64(dictionary["filesSent"]), + let bytesSent = strictUInt64(dictionary["bytesSent"]) else { + return nil + } + let suffix = "/Downloads/Dory Transfer " + transferID + guard guestDestination.hasPrefix("/home/"), + guestDestination.hasSuffix(suffix) else { + return nil + } + let usernameStart = guestDestination.index( + guestDestination.startIndex, + offsetBy: "/home/".count + ) + let usernameEnd = guestDestination.index( + guestDestination.endIndex, + offsetBy: -suffix.count + ) + guard usernameStart < usernameEnd, + DoryVMGuestAccountIntent.isValidUsername( + String(guestDestination[usernameStart.. DorydRemoteMachineStatus? { guard let id = dictionary["id"] as? String, let state = dictionary["state"] as? String else { @@ -2767,6 +2843,14 @@ nonisolated final class DorydClient: @unchecked Sendable { machineExecControlTimeout(timeoutMs: 600_000) * 2 } + nonisolated private static func machineTransferControlTimeout( + byteCount: UInt64 + ) -> TimeInterval { + // Allow two hours at the 64 GiB staging ceiling, with a two-minute fixed setup budget. + let transferSeconds = Double(byteCount) / (10 * 1024 * 1024) + return min(2 * 60 * 60, 120 + transferSeconds) + } + nonisolated private static func machineExecControlTimeout(timeoutMs: UInt64) -> TimeInterval { let effectiveTimeoutMs: UInt64 = timeoutMs == 0 ? 30_000 : min(timeoutMs, 600_000) return TimeInterval(effectiveTimeoutMs) / 1000 + 10 @@ -2782,6 +2866,16 @@ nonisolated final class DorydClient: @unchecked Sendable { return value as? UInt64 } + nonisolated private static func strictUInt64(_ value: Any?) -> UInt64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let decoded = number.uint64Value + guard number.stringValue == String(decoded) else { return nil } + return decoded + } + nonisolated private static func uint32(_ value: Any?) -> UInt32? { if let number = value as? NSNumber { return number.uint32Value diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index a7c5d0c9..ca18a4a4 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -5,6 +5,76 @@ import Testing @Suite(.serialized) struct DorydClientTests { + @MainActor + @Test func machineTransferUsesPrivateStageAndRejectsMalformedEvidence() async throws { + let root = URL(fileURLWithPath: "/tmp/dory-client-transfer-\(getpid())-\(UInt32.random(in: 0.. Void + ) { + lock.lock() + _latestMachineTransferRequest = request + let override = _machineTransferResponseOverride + lock.unlock() + if let override { + reply(true, override, "") + return + } + let transferID = String(repeating: "a", count: 32) + reply(true, [ + "schema": UInt16(1), + "transferID": transferID, + "guestDestination": "/home/developer/Downloads/Dory Transfer " + transferID, + "filesSent": UInt64(1), + "bytesSent": UInt64(5), + ], "") + } + func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let recipe = request["recipe"] as? String ?? "rust" lock.lock() From 41337f64b92283c7c73990572a3e858af0a7b7c1 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:40:20 +0000 Subject: [PATCH 075/338] feat(vm): send files from machine view --- Dory/Features/Machines/MachinesView.swift | 24 +++++++++ Dory/Models/AppStore.swift | 61 +++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 24 +++++++++ 3 files changed, 109 insertions(+) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 340ca8cc..55a11e3a 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1,3 +1,4 @@ +import AppKit import Darwin import DoryOperations import SwiftUI @@ -250,6 +251,13 @@ private struct MachineCard: View { Button { store.restartMachine(machine) } label: { Label("Restart", systemImage: "arrow.clockwise") } + if isRunning { + Button { selectAndSendFiles() } label: { + Label("Send Files\u{2026}", systemImage: "paperplane") + } + .disabled(!store.canTransferFiles(to: machine)) + .help("Copy selected files into this machine's Downloads folder") + } Divider() } Button { store.takeSnapshot(machine, note: "") } label: { @@ -290,6 +298,22 @@ private struct MachineCard: View { .disabled(store.isMachineBusy(machine.name) || !store.canUseMachineArtifacts(machine)) } + private func selectAndSendFiles() { + guard store.canTransferFiles(to: machine) else { return } + let panel = NSOpenPanel() + panel.title = "Send files to \(machine.name)" + panel.message = "Files are copied into a new folder in the machine's Downloads folder." + panel.prompt = "Send" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.resolvesAliases = false + guard panel.runModal() == .OK else { return } + let selected = panel.urls + guard !selected.isEmpty else { return } + Task { await store.transferFiles(selected, to: machine) } + } + private var statusPill: some View { HStack(spacing: 5) { Circle().fill(machine.status.dotColor(p)).frame(width: 6, height: 6) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 6ed2c243..d6b8ba22 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5260,6 +5260,67 @@ final class AppStore { runtimeOwnedByDoryd } + func canTransferFiles(to machine: Machine) -> Bool { + guard runtimeOwnedByDoryd, + machine.status == .running, + machine.agentProtocolVersion == 1 else { + return false + } + func supports(_ id: String) -> Bool { + machine.agentCapabilities.contains { $0.id == id && $0.version >= 1 } + } + return supports("exec") && supports("sync-push") + } + + @discardableResult + func transferFiles( + _ fileURLs: [URL], + to machine: Machine + ) async -> DorydMachineFileTransferResult? { + guard canTransferFiles(to: machine) else { + actionError = "Start \(machine.name) and update Dory Tools before sending files." + return nil + } + guard !busyMachines.contains(machine.name) else { return nil } + busyMachines.insert(machine.name) + defer { busyMachines.remove(machine.name) } + actionError = nil + + var staged: DoryStagedMachineFileTransfer? + do { + staged = try await Task.detached(priority: .userInitiated) { + try DoryMachineFileTransferStager.stage(fileURLs: fileURLs) + }.value + guard let staged else { return nil } + let result = try await dorydClient.machineTransfer(machine.name, staged: staged) + let fileLabel = result.filesSent == 1 ? "file" : "files" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesSent), + countStyle: .file + ) + do { + try await Task.detached(priority: .utility) { + try staged.remove() + }.value + } catch { + actionError = "Files reached \(machine.name), but Dory could not remove its private staging copy." + return result + } + showSettingsSuccess( + "Sent \(result.filesSent) \(fileLabel) (\(bytes)) to \(result.guestDestination)." + ) + return result + } catch { + if let staged { + try? await Task.detached(priority: .utility) { + try staged.remove() + }.value + } + actionError = "Could not send files to \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + func syncMachineStats() { guard !machines.isEmpty else { return } for index in machines.indices { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index ca18a4a4..9286470a 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1170,6 +1170,30 @@ struct DorydClientTests { #expect(machine.containerID.isEmpty) #expect(store.machineTerminalCommand(machine) == "dory machine shell dev") #expect(store.canUseMachineArtifacts(machine)) + #expect(store.canTransferFiles(to: machine)) + + let transferRoot = URL( + fileURLWithPath: "/tmp/dory-store-transfer-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 00:43:42 +0000 Subject: [PATCH 076/338] feat(sync): capture directory topology --- dory-core/sync/src/lib.rs | 5 +- dory-core/sync/src/manifest.rs | 90 +++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/dory-core/sync/src/lib.rs b/dory-core/sync/src/lib.rs index 90283759..af2d0057 100644 --- a/dory-core/sync/src/lib.rs +++ b/dory-core/sync/src/lib.rs @@ -8,7 +8,10 @@ mod manifest; mod plan; pub use hash::{hash_bytes, hash_file, Hash, HASH_LEN}; -pub use manifest::{walk_manifest, walk_manifest_excluding, FileEntry, Manifest}; +pub use manifest::{ + walk_manifest, walk_manifest_excluding, walk_tree, DirectoryEntry, FileEntry, Manifest, + TreeSnapshot, +}; pub use plan::{plan, SyncPlan}; /// Chunk size for streamed file transfer. A create/build body is tiny; source trees are many small diff --git a/dory-core/sync/src/manifest.rs b/dory-core/sync/src/manifest.rs index b0694c42..e31025ff 100644 --- a/dory-core/sync/src/manifest.rs +++ b/dory-core/sync/src/manifest.rs @@ -12,6 +12,16 @@ pub struct FileEntry { pub hash: Hash, } +/// One directory in a sync tree. The root itself is implicit; every entry is a non-empty relative +/// path. Recording directories separately preserves empty folders without pretending they are +/// files or adding sentinel data to the user's tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryEntry { + pub path: String, + pub mtime_ns: i64, + pub mode: u32, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Manifest { pub entries: Vec, @@ -23,11 +33,22 @@ impl Manifest { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TreeSnapshot { + pub manifest: Manifest, + pub directories: Vec, +} + /// Walk `root` recursively and build a manifest of every regular file, with a content hash. Symlinks /// and special files are skipped (only regular files replicate). Entries are sorted by path so a /// host and remote produce byte-identical ordering and the reconciler diff is stable. pub fn walk_manifest(root: &Path) -> std::io::Result { - walk_manifest_impl(root, &[], false) + Ok(walk_tree(root)?.manifest) +} + +/// Walk `root` once and capture both regular-file content authority and directory topology. +pub fn walk_tree(root: &Path) -> std::io::Result { + walk_tree_impl(root, &[], false) } /// Walk a tree while skipping named direct children of `root` before reading their metadata or @@ -40,24 +61,30 @@ pub fn walk_manifest_excluding( root: &Path, excluded_root_children: &[&str], ) -> std::io::Result { - walk_manifest_impl(root, excluded_root_children, true) + Ok(walk_tree_impl(root, excluded_root_children, true)?.manifest) } -fn walk_manifest_impl( +fn walk_tree_impl( root: &Path, excluded_root_children: &[&str], tolerate_not_found: bool, -) -> std::io::Result { - let mut entries = Vec::new(); +) -> std::io::Result { + let mut files = Vec::new(); + let mut directories = Vec::new(); walk_into( root, root, excluded_root_children, tolerate_not_found, - &mut entries, + &mut files, + &mut directories, )?; - entries.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(Manifest { entries }) + files.sort_by(|a, b| a.path.cmp(&b.path)); + directories.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(TreeSnapshot { + manifest: Manifest { entries: files }, + directories, + }) } fn walk_into( @@ -65,7 +92,8 @@ fn walk_into( dir: &Path, excluded_root_children: &[&str], tolerate_not_found: bool, - out: &mut Vec, + files: &mut Vec, + directories: &mut Vec, ) -> std::io::Result<()> { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, @@ -97,7 +125,19 @@ fn walk_into( }; let file_type = meta.file_type(); if file_type.is_dir() { - walk_into(root, &path, excluded_root_children, tolerate_not_found, out)?; + directories.push(DirectoryEntry { + path: relative_slash(root, &path), + mtime_ns: mtime_ns(&meta), + mode: mode_of(&meta), + }); + walk_into( + root, + &path, + excluded_root_children, + tolerate_not_found, + files, + directories, + )?; } else if file_type.is_file() { let rel = relative_slash(root, &path); let hash = match hash_file(&path) { @@ -107,7 +147,7 @@ fn walk_into( } Err(e) => return Err(e), }; - out.push(FileEntry { + files.push(FileEntry { path: rel, size: meta.len(), mtime_ns: mtime_ns(&meta), @@ -197,6 +237,34 @@ mod tests { assert_eq!(inner.hash, crate::hash::hash_bytes(b"world!!")); } + #[test] + fn tree_snapshot_preserves_empty_directories_in_stable_order() { + let t = TempTree::new("directories"); + fs::create_dir_all(t.root.join("z-empty/deep")).unwrap(); + fs::create_dir_all(t.root.join("a-empty")).unwrap(); + t.write("middle/file.txt", "data"); + + let snapshot = walk_tree(&t.root).unwrap(); + assert_eq!( + snapshot + .directories + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["a-empty", "middle", "z-empty", "z-empty/deep"] + ); + assert_eq!( + snapshot + .manifest + .entries + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["middle/file.txt"] + ); + assert!(snapshot.directories.iter().all(|entry| entry.mode != 0)); + } + #[test] fn identical_trees_walk_to_an_empty_plan() { let a = TempTree::new("ident-a"); From 7e9f6e35778794f912a1950939e9f9410c90593a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:48:21 +0000 Subject: [PATCH 077/338] feat(agent): reconcile sync directory trees --- dory-core/agent/src/dispatch.rs | 4 +- dory-core/agent/src/handler.rs | 1 + dory-core/agent/src/sync_apply.rs | 328 ++++++++++++++++++++++++++- dory-core/pb/proto/agent.proto | 21 ++ dory-core/remote/src/agent_client.rs | 13 ++ 5 files changed, 364 insertions(+), 3 deletions(-) diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index 46f92c59..479dfbff 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -25,7 +25,7 @@ pub fn agent_capabilities() -> Vec { ("exec", 1), ("exec-stdin", 1), ("ports-watch", 1), - ("sync-push", 1), + ("sync-push", 2), ("telemetry", 1), ]; if crate::snapshot_quiesce::available() { @@ -200,7 +200,7 @@ mod tests { ("exec", 1), ("exec-stdin", 1), ("ports-watch", 1), - ("sync-push", 1), + ("sync-push", 2), ("telemetry", 1), ]; if crate::snapshot_quiesce::available() { diff --git a/dory-core/agent/src/handler.rs b/dory-core/agent/src/handler.rs index af71a4ee..0738d2d7 100644 --- a/dory-core/agent/src/handler.rs +++ b/dory-core/agent/src/handler.rs @@ -26,6 +26,7 @@ pub async fn handle(req_bytes: &[u8]) -> Vec { } Some(Method::SyncPutChunk(r)) => wrap(sync_apply::put_chunk(r).await, Res::SyncPutChunk), Some(Method::SyncDelete(r)) => wrap(sync_apply::delete(r).await, Res::SyncDelete), + Some(Method::SyncTree(r)) => wrap(sync_apply::tree(r).await, Res::SyncTree), Some(Method::Exec(r)) => wrap_exec(exec::run(r).await), Some(Method::SnapshotQuiesce(r)) => AgentResponse { result: Some(Res::SnapshotQuiesce(snapshot_quiesce::run(r).await)), diff --git a/dory-core/agent/src/sync_apply.rs b/dory-core/agent/src/sync_apply.rs index 09aa7505..ffe402e4 100644 --- a/dory-core/agent/src/sync_apply.rs +++ b/dory-core/agent/src/sync_apply.rs @@ -11,7 +11,7 @@ use std::sync::OnceLock; use dory_pb::agent::{ SyncDeleteRequest, SyncDeleteResponse, SyncFileEntry, SyncFileStatusRequest, SyncFileStatusResponse, SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, - SyncPutChunkResponse, + SyncPutChunkResponse, SyncTreeRequest, SyncTreeResponse, }; const STAGING_DIR: &str = ".dory-sync-tmp"; @@ -22,6 +22,8 @@ const ROOT_LOCK_STRIPES: usize = 64; // The hash is deliberately NOT part of this key: conflicting-content commits must linearize around // destination hash checks, chmod, and rename. Stripe collisions only reduce concurrency. const PATH_LOCK_STRIPES: usize = 64; +const MAX_TREE_ENTRIES: usize = 100_000; +const MAX_TREE_PATH_BYTES: usize = 8 * 1024 * 1024; #[derive(Debug, thiserror::Error)] pub enum SyncError { @@ -33,6 +35,8 @@ pub enum SyncError { HashMismatch, #[error("chunk range overflows uint64")] ChunkRangeOverflow, + #[error("invalid sync tree authority")] + InvalidTree, #[error(transparent)] Io(#[from] std::io::Error), } @@ -45,6 +49,7 @@ impl SyncError { SyncError::OffsetMismatch { .. } => 409, SyncError::HashMismatch => 422, SyncError::ChunkRangeOverflow => 400, + SyncError::InvalidTree => 400, SyncError::Io(_) => 500, } } @@ -293,6 +298,231 @@ pub async fn delete(req: SyncDeleteRequest) -> Result Result { + let root = canonical_root(&req.root).await?; + let _root_guard = root_lock(&root).write().await; + let desired = validate_tree_request(&root, &req).await?; + let scan_root = root.clone(); + let current = tokio::task::spawn_blocking(move || scan_topology(&scan_root)) + .await + .map_err(|error| SyncError::Io(std::io::Error::other(error)))??; + + let mut paths_deleted = 0u32; + for leaf in current.leaves { + if !leaf.regular || !desired.files.contains(&leaf.path) { + tokio::fs::remove_file(root.join(&leaf.path)).await?; + paths_deleted = paths_deleted.checked_add(1).ok_or(SyncError::InvalidTree)?; + } + } + + let mut current_directories = current.directories; + current_directories.sort_by(|lhs, rhs| { + path_depth(rhs) + .cmp(&path_depth(lhs)) + .then_with(|| rhs.cmp(lhs)) + }); + for path in current_directories { + if !desired.directories.contains_key(&path) { + tokio::fs::remove_dir(root.join(&path)).await?; + paths_deleted = paths_deleted.checked_add(1).ok_or(SyncError::InvalidTree)?; + } + } + + let mut directory_paths = desired.directories.keys().cloned().collect::>(); + directory_paths.sort_by(|lhs, rhs| { + path_depth(lhs) + .cmp(&path_depth(rhs)) + .then_with(|| lhs.cmp(rhs)) + }); + let mut directories_created = 0u32; + for path in &directory_paths { + let destination = root.join(path); + match tokio::fs::symlink_metadata(&destination).await { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + if !req.finalize { + // A previously finalized source directory may be read-only. The preparation + // phase temporarily grants only its owner access so following file puts can + // reconcile children; the final pass restores the exact source mode. + apply_mode(&destination, 0o700).await?; + } + } + Ok(_) => return Err(SyncError::InvalidTree), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::create_dir(&destination).await?; + apply_mode(&destination, 0o700).await?; + directories_created = directories_created + .checked_add(1) + .ok_or(SyncError::InvalidTree)?; + } + Err(error) => return Err(SyncError::Io(error)), + } + } + + if req.finalize { + for path in &desired.files { + let metadata = tokio::fs::symlink_metadata(root.join(path)).await?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(SyncError::InvalidTree); + } + } + } + + // Persist every surviving namespace link. Desired directories include every non-root parent; + // syncing them plus the root covers creations and removals at every depth. + for path in directory_paths.iter().rev() { + sync_directory(&root.join(path)).await?; + } + sync_directory(&root).await?; + if req.finalize { + for path in directory_paths.iter().rev() { + apply_directory_mode_and_sync( + &root.join(path), + *desired + .directories + .get(path) + .ok_or(SyncError::InvalidTree)?, + ) + .await?; + } + } + Ok(SyncTreeResponse { + paths_deleted, + directories_created, + }) +} + +struct DesiredTree { + files: std::collections::HashSet, + directories: std::collections::HashMap, +} + +async fn validate_tree_request( + root: &Path, + req: &SyncTreeRequest, +) -> Result { + let count = req + .files + .len() + .checked_add(req.directories.len()) + .ok_or(SyncError::InvalidTree)?; + if count > MAX_TREE_ENTRIES { + return Err(SyncError::InvalidTree); + } + let mut path_bytes = 0usize; + let mut files = std::collections::HashSet::with_capacity(req.files.len()); + let mut directories = std::collections::HashMap::with_capacity(req.directories.len()); + for path in &req.files { + path_bytes = path_bytes + .checked_add(path.len()) + .ok_or(SyncError::InvalidTree)?; + validate_tree_path(root, path).await?; + if !files.insert(path.clone()) { + return Err(SyncError::InvalidTree); + } + } + for entry in &req.directories { + path_bytes = path_bytes + .checked_add(entry.path.len()) + .ok_or(SyncError::InvalidTree)?; + validate_tree_path(root, &entry.path).await?; + if directories.insert(entry.path.clone(), entry.mode).is_some() + || files.contains(&entry.path) + { + return Err(SyncError::InvalidTree); + } + } + if path_bytes > MAX_TREE_PATH_BYTES { + return Err(SyncError::InvalidTree); + } + for path in files.iter().chain(directories.keys()) { + let mut parent = Path::new(path).parent(); + while let Some(value) = parent { + if value.as_os_str().is_empty() { + break; + } + let parent_text = value.to_string_lossy(); + if files.contains(parent_text.as_ref()) + || !directories.contains_key(parent_text.as_ref()) + { + return Err(SyncError::InvalidTree); + } + parent = value.parent(); + } + } + Ok(DesiredTree { files, directories }) +} + +async fn validate_tree_path(root: &Path, path: &str) -> Result<(), SyncError> { + if path == STAGING_DIR || path.starts_with(&format!("{STAGING_DIR}/")) { + return Err(SyncError::InvalidTree); + } + safe_join(root, path).await.map(|_| ()) +} + +#[derive(Default)] +struct CurrentTopology { + leaves: Vec, + directories: Vec, +} + +struct TopologyLeaf { + path: String, + regular: bool, +} + +fn scan_topology(root: &Path) -> Result { + fn walk( + root: &Path, + directory: &Path, + topology: &mut CurrentTopology, + ) -> Result<(), SyncError> { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if directory == root && entry.file_name() == STAGING_DIR { + continue; + } + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path)?; + let relative = path + .strip_prefix(root) + .map_err(|_| SyncError::PathEscape)? + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + if metadata.is_dir() && !metadata.file_type().is_symlink() { + topology.directories.push(relative); + walk(root, &path, topology)?; + } else { + topology.leaves.push(TopologyLeaf { + path: relative, + regular: metadata.is_file() && !metadata.file_type().is_symlink(), + }); + } + } + Ok(()) + } + + let mut topology = CurrentTopology::default(); + walk(root, root, &mut topology)?; + Ok(topology) +} + +fn path_depth(path: &str) -> usize { + path.bytes().filter(|byte| *byte == b'/').count() + 1 +} + +async fn apply_directory_mode_and_sync(path: &Path, mode: u32) -> Result<(), SyncError> { + // Open before chmod so even a source mode such as 0000 cannot prevent the durability sync. + let directory = tokio::fs::File::open(path).await?; + apply_mode(path, mode).await?; + directory.sync_all().await?; + Ok(()) +} + #[cfg(unix)] async fn apply_mode(path: &Path, mode: u32) -> Result<(), SyncError> { if mode == 0 { @@ -594,6 +824,7 @@ fn staging_path(root: &Path, path: &str, hash: &[u8]) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use dory_pb::agent::SyncDirectoryEntry; use dory_sync::hash_bytes; use std::fs; @@ -618,6 +849,101 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn tree_reconciles_type_conflicts_extras_and_empty_directories() { + use std::os::unix::fs::PermissionsExt; + + let t = TempRoot::new("tree-topology"); + fs::write(t.path.join("extra.txt"), b"remove").unwrap(); + fs::write(t.path.join("folder"), b"file-to-directory").unwrap(); + fs::create_dir_all(t.path.join("replace.txt")).unwrap(); + fs::write(t.path.join("replace.txt/old"), b"directory-to-file").unwrap(); + fs::create_dir(t.path.join("old-empty")).unwrap(); + let request = |finalize| SyncTreeRequest { + root: t.root(), + files: vec!["folder/a.txt".into(), "replace.txt".into()], + directories: vec![ + SyncDirectoryEntry { + path: "empty".into(), + mode: 0o711, + }, + SyncDirectoryEntry { + path: "folder".into(), + mode: 0o700, + }, + ], + finalize, + }; + + let prepared = tree(request(false)).await.unwrap(); + assert_eq!(prepared.paths_deleted, 5); + assert_eq!(prepared.directories_created, 2); + assert!(t.path.join("empty").is_dir()); + assert!(t.path.join("folder").is_dir()); + assert!(!t.path.join("extra.txt").exists()); + assert!(!t.path.join("replace.txt").exists()); + + fs::write(t.path.join("folder/a.txt"), b"a").unwrap(); + fs::write(t.path.join("replace.txt"), b"replacement").unwrap(); + let finalized = tree(request(true)).await.unwrap(); + assert_eq!(finalized.paths_deleted, 0); + assert_eq!(finalized.directories_created, 0); + assert_eq!( + fs::metadata(t.path.join("empty")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o711 + ); + assert_eq!( + fs::metadata(t.path.join("folder")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + + #[tokio::test] + async fn tree_rejects_incomplete_duplicate_reserved_and_missing_final_authority() { + let t = TempRoot::new("tree-invalid"); + let cases = [ + SyncTreeRequest { + root: t.root(), + files: vec!["missing-parent/file".into()], + directories: vec![], + finalize: false, + }, + SyncTreeRequest { + root: t.root(), + files: vec!["same".into(), "same".into()], + directories: vec![], + finalize: false, + }, + SyncTreeRequest { + root: t.root(), + files: vec![format!("{STAGING_DIR}/poison")], + directories: vec![], + finalize: false, + }, + ]; + for request in cases { + assert!(matches!(tree(request).await, Err(SyncError::InvalidTree))); + } + + let missing = tree(SyncTreeRequest { + root: t.root(), + files: vec!["not-sent".into()], + directories: vec![], + finalize: true, + }) + .await; + assert!(matches!(missing, Err(SyncError::Io(_)))); + } + #[tokio::test] async fn single_chunk_commits_atomically_with_content() { let t = TempRoot::new("single"); diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 5a936730..be460244 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -157,6 +157,25 @@ message SyncDeleteRequest { } message SyncDeleteResponse { uint32 deleted = 1; } +// Reconcile the complete file/directory namespace before and after streamed file puts. The first +// pass (`finalize=false`) removes conflicts/extras and creates missing directories. The final pass +// additionally requires every desired file to exist and applies directory modes, closing races and +// preserving empty directories without sentinel files. +message SyncDirectoryEntry { + string path = 1; + uint32 mode = 2; +} +message SyncTreeRequest { + string root = 1; + repeated string files = 2; + repeated SyncDirectoryEntry directories = 3; + bool finalize = 4; +} +message SyncTreeResponse { + uint32 paths_deleted = 1; + uint32 directories_created = 2; +} + message AgentRequest { oneof method { ClockSyncRequest clock_sync = 1; @@ -169,6 +188,7 @@ message AgentRequest { TelemetryRequest telemetry = 8; ExecRequest exec = 9; SnapshotQuiesceRequest snapshot_quiesce = 10; + SyncTreeRequest sync_tree = 11; } } @@ -185,5 +205,6 @@ message AgentResponse { TelemetryResponse telemetry = 9; ExecResponse exec = 10; SnapshotQuiesceResponse snapshot_quiesce = 11; + SyncTreeResponse sync_tree = 12; } } diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index ec3b6938..3295cf70 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -245,6 +245,19 @@ impl AgentClient { _ => Err(RemoteError::UnexpectedVariant), } } + + pub async fn sync_tree( + &self, + req: dory_pb::agent::SyncTreeRequest, + ) -> Result { + match self + .call_with_deadline(Method::SyncTree(req), SYNC_IO_DEADLINE) + .await? + { + Res::SyncTree(r) => Ok(r), + _ => Err(RemoteError::UnexpectedVariant), + } + } } #[cfg(test)] From 0e711ceadba205693c01ff441c0724571063874c Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:53:11 +0000 Subject: [PATCH 078/338] feat(sync): preserve remote directory trees --- dory-core/remote/src/error.rs | 2 + dory-core/remote/src/sync_push.rs | 170 +++++++++++++++++++++++- dory-core/remote/tests/sync_push_e2e.rs | 37 ++++-- dory-core/sync/src/lib.rs | 4 +- dory-core/sync/src/manifest.rs | 10 +- 5 files changed, 204 insertions(+), 19 deletions(-) diff --git a/dory-core/remote/src/error.rs b/dory-core/remote/src/error.rs index 04de8e45..f9ef090a 100644 --- a/dory-core/remote/src/error.rs +++ b/dory-core/remote/src/error.rs @@ -21,6 +21,8 @@ pub enum RemoteError { UnexpectedVariant, #[error("local source changed while the transfer was in progress")] SourceChanged, + #[error("remote agent capability unavailable: {0}")] + CapabilityUnavailable(&'static str), #[error(transparent)] Io(#[from] std::io::Error), } diff --git a/dory-core/remote/src/sync_push.rs b/dory-core/remote/src/sync_push.rs index 1999524b..e472449d 100644 --- a/dory-core/remote/src/sync_push.rs +++ b/dory-core/remote/src/sync_push.rs @@ -10,9 +10,12 @@ use std::io::SeekFrom; use std::path::Path; use dory_pb::agent::{ - SyncDeleteRequest, SyncFileStatusRequest, SyncManifestRequest, SyncPutChunkRequest, + SyncDeleteRequest, SyncDirectoryEntry, SyncFileStatusRequest, SyncManifestRequest, + SyncPutChunkRequest, SyncTreeRequest, +}; +use dory_sync::{ + plan, walk_tree, DirectoryEntry, Hash, Manifest, TreeSnapshot, CHUNK_BYTES, HASH_LEN, }; -use dory_sync::{plan, walk_manifest, Hash, Manifest, CHUNK_BYTES, HASH_LEN}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use crate::agent_client::AgentClient; @@ -56,6 +59,15 @@ pub trait SyncTarget { root: &str, paths: &[String], ) -> impl std::future::Future> + Send; + /// Reconcile complete directory topology. `Ok(false)` means the peer only implements the v1 + /// file protocol; callers may retain v1 behavior only when no empty directory would be lost. + fn reconcile_tree( + &self, + root: &str, + files: &[String], + directories: &[DirectoryEntry], + finalize: bool, + ) -> impl std::future::Future> + Send; } pub async fn push( @@ -64,15 +76,28 @@ pub async fn push( target: &T, ) -> Result { let root = local_root.to_path_buf(); - let local = tokio::task::spawn_blocking(move || walk_manifest(&root)) + let local = tokio::task::spawn_blocking(move || walk_tree(&root)) .await .map_err(|e| RemoteError::Io(std::io::Error::other(e)))??; let remote = target.remote_manifest(remote_root).await?; - let plan = plan(&local, &remote); + let plan = plan(&local.manifest, &remote); + let file_paths = local + .manifest + .entries + .iter() + .map(|entry| entry.path.clone()) + .collect::>(); + let directory_protocol = target + .reconcile_tree(remote_root, &file_paths, &local.directories, false) + .await?; + if !directory_protocol && has_empty_directory(&local) { + return Err(RemoteError::CapabilityUnavailable("sync-push@2")); + } let mut stats = PushStats::default(); for rel in &plan.transfer { let entry = local + .manifest .get(rel) .expect("transfer paths are drawn from the local manifest"); let mut file = tokio::fs::File::open(local_root.join(rel)).await?; @@ -137,7 +162,15 @@ pub async fn push( stats.files_sent += 1; } - if !plan.delete.is_empty() { + if directory_protocol { + stats.files_deleted = plan.delete.len() as u64; + let finalized = target + .reconcile_tree(remote_root, &file_paths, &local.directories, true) + .await?; + if !finalized { + return Err(RemoteError::CapabilityUnavailable("sync-push@2")); + } + } else if !plan.delete.is_empty() { stats.files_deleted += target.delete(remote_root, &plan.delete).await? as u64; } @@ -145,7 +178,7 @@ pub async fn push( // concurrently changed, truncated, or appended source cannot be reported as an exact replica. // Retrying is safe: the protocol is content-addressed and resumable. let root = local_root.to_path_buf(); - let final_local = tokio::task::spawn_blocking(move || walk_manifest(&root)) + let final_local = tokio::task::spawn_blocking(move || walk_tree(&root)) .await .map_err(|e| RemoteError::Io(std::io::Error::other(e)))??; if final_local != local { @@ -154,6 +187,17 @@ pub async fn push( Ok(stats) } +fn has_empty_directory(snapshot: &TreeSnapshot) -> bool { + snapshot.directories.iter().any(|directory| { + let prefix = format!("{}/", directory.path); + !snapshot + .manifest + .entries + .iter() + .any(|file| file.path.starts_with(&prefix)) + }) +} + impl SyncTarget for AgentClient { async fn remote_manifest(&self, root: &str) -> Result { let resp = self @@ -204,6 +248,40 @@ impl SyncTarget for AgentClient { .await?; Ok(resp.deleted) } + + async fn reconcile_tree( + &self, + root: &str, + files: &[String], + directories: &[DirectoryEntry], + finalize: bool, + ) -> Result { + let info = self.info().await?; + let supported = info + .capabilities + .iter() + .any(|capability| capability.id == "sync-push" && capability.version >= 2); + if !supported { + return Ok(false); + } + AgentClient::sync_tree( + self, + SyncTreeRequest { + root: root.to_string(), + files: files.to_vec(), + directories: directories + .iter() + .map(|directory| SyncDirectoryEntry { + path: directory.path.clone(), + mode: directory.mode, + }) + .collect(), + finalize, + }, + ) + .await?; + Ok(true) + } } const _: () = assert!(HASH_LEN == 32); @@ -231,6 +309,9 @@ mod tests { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, contents).unwrap(); } + fn mkdir(&self, rel: &str) { + std::fs::create_dir_all(self.root.join(rel)).unwrap(); + } } impl Drop for TempTree { fn drop(&mut self) { @@ -242,6 +323,7 @@ mod tests { struct Recorded { chunks: Vec, deleted: Vec, + tree_calls: Vec<(Vec, Vec, bool)>, } /// A fake remote that records everything, returns a preset manifest, and can pretend a file is @@ -258,6 +340,7 @@ mod tests { conflict_fired: Mutex, mutate_source_after_first_chunk: Option<(std::path::PathBuf, Vec)>, source_mutation_fired: Mutex, + directory_protocol: bool, rec: Mutex, } impl FakeTarget { @@ -273,6 +356,7 @@ mod tests { conflict_fired: Mutex::new(false), mutate_source_after_first_chunk: None, source_mutation_fired: Mutex::new(false), + directory_protocol: true, rec: Mutex::new(Recorded::default()), } } @@ -337,6 +421,74 @@ mod tests { self.rec.lock().unwrap().deleted.extend_from_slice(paths); Ok(paths.len() as u32) } + async fn reconcile_tree( + &self, + _root: &str, + files: &[String], + directories: &[DirectoryEntry], + finalize: bool, + ) -> Result { + if self.directory_protocol { + self.rec.lock().unwrap().tree_calls.push(( + files.to_vec(), + directories.to_vec(), + finalize, + )); + } + Ok(self.directory_protocol) + } + } + + #[tokio::test] + async fn push_binds_empty_directories_in_prepare_and_finalize_passes() { + let t = TempTree::new("empty-directories"); + t.mkdir("project/empty/deep"); + t.write("project/src/main.rs", b"fn main() {}"); + + let target = FakeTarget::new(Manifest::default()); + let stats = push(&t.root, "/remote", &target).await.unwrap(); + assert_eq!(stats.files_sent, 1); + let calls = &target.rec.lock().unwrap().tree_calls; + assert_eq!(calls.len(), 2); + assert!(!calls[0].2); + assert!(calls[1].2); + assert_eq!(calls[0].0, vec!["project/src/main.rs"]); + assert_eq!( + calls[0] + .1 + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec![ + "project", + "project/empty", + "project/empty/deep", + "project/src" + ] + ); + assert_eq!(calls[0].0, calls[1].0); + assert_eq!(calls[0].1, calls[1].1); + } + + #[tokio::test] + async fn v1_peer_rejects_empty_directories_but_keeps_file_only_compatibility() { + let with_empty = TempTree::new("v1-empty"); + with_empty.mkdir("empty"); + let mut target = FakeTarget::new(Manifest::default()); + target.directory_protocol = false; + let error = push(&with_empty.root, "/remote", &target) + .await + .unwrap_err(); + assert!(matches!( + error, + RemoteError::CapabilityUnavailable("sync-push@2") + )); + + let files_only = TempTree::new("v1-files"); + files_only.write("nested/file.txt", b"compatible"); + let stats = push(&files_only.root, "/remote", &target).await.unwrap(); + assert_eq!(stats.files_sent, 1); + assert_eq!(target.assembled("nested/file.txt"), b"compatible"); } #[tokio::test] @@ -433,7 +585,11 @@ mod tests { rec.chunks.iter().all(|c| c.path == "changed.txt"), "same.txt must not be re-sent" ); - assert_eq!(rec.deleted, vec!["gone.txt".to_string()]); + assert!(rec.deleted.is_empty(), "v2 topology pass owns deletion"); + assert_eq!( + &rec.tree_calls[0].0, + &["changed.txt".to_string(), "same.txt".to_string()] + ); } #[tokio::test] diff --git a/dory-core/remote/tests/sync_push_e2e.rs b/dory-core/remote/tests/sync_push_e2e.rs index 76549d49..ebea1918 100644 --- a/dory-core/remote/tests/sync_push_e2e.rs +++ b/dory-core/remote/tests/sync_push_e2e.rs @@ -7,7 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; use dory_remote::{push, AgentClient}; -use dory_sync::{plan, walk_manifest}; +use dory_sync::{plan, walk_manifest, walk_tree, walk_tree_excluding}; use tokio::net::{TcpListener, TcpStream}; struct Tmp { @@ -37,7 +37,19 @@ fn converged(local: &Path, remote: &Path) -> bool { let l = walk_manifest(local).unwrap(); let r = walk_manifest(remote).unwrap(); let p = plan(&l, &r); - p.transfer.is_empty() && p.delete.is_empty() + let local_directories = walk_tree(local) + .unwrap() + .directories + .into_iter() + .map(|entry| entry.path) + .collect::>(); + let remote_directories = walk_tree_excluding(remote, &[".dory-sync-tmp"]) + .unwrap() + .directories + .into_iter() + .map(|entry| entry.path) + .collect::>(); + p.transfer.is_empty() && p.delete.is_empty() && local_directories == remote_directories } async fn connect_agent() -> AgentClient { @@ -61,10 +73,18 @@ async fn push_makes_the_remote_a_byte_exact_replica_and_converges_on_edits() { local.write("README.md", b"# project"); local.write("src/main.rs", b"fn main() { println!(\"hi\"); }"); local.write("assets/big.bin", &vec![42u8; 700 * 1024]); + fs::create_dir_all(local.path.join("docs/empty/deep")).unwrap(); + // Exercise both namespace type-conflict directions and an extra empty remote directory. + remote.write("assets", b"file where a directory belongs"); + remote.write("README.md/old", b"directory where a file belongs"); + fs::create_dir_all(remote.path.join("obsolete-empty")).unwrap(); let stats = push(&local.path, &remote_root, &client).await.unwrap(); assert_eq!(stats.files_sent, 3); - assert_eq!(stats.files_deleted, 0); + assert_eq!( + stats.files_deleted, 2, + "removed both type-conflicting remote files" + ); assert!( converged(&local.path, &remote.path), "remote must be an exact replica" @@ -73,6 +93,8 @@ async fn push_makes_the_remote_a_byte_exact_replica_and_converges_on_edits() { fs::read(remote.path.join("assets/big.bin")).unwrap(), vec![42u8; 700 * 1024] ); + assert!(remote.path.join("docs/empty/deep").is_dir()); + assert!(!remote.path.join("obsolete-empty").exists()); // Re-push with no changes: nothing sent or deleted. let noop = push(&local.path, &remote_root, &client).await.unwrap(); @@ -204,7 +226,9 @@ async fn manifest_waits_for_a_staggered_concurrent_delete_and_update() { let remote_root = remote.path.to_string_lossy().into_owned(); // The first push updates one multi-chunk file, then removes a large obsolete tree. Start the - // second push only after the delete has demonstrably begun but before its tail is removed. + // second push only after the delete has demonstrably begun. The topology RPC may finish the + // whole removal between scheduler polls; either way its write lock must serialize the second + // manifest with the namespace mutation. let updated = vec![0xA5; 700 * 1024]; local.write("current.bin", &updated); remote.write("current.bin", b"old"); @@ -212,7 +236,6 @@ async fn manifest_waits_for_a_staggered_concurrent_delete_and_update() { remote.write(&format!("obsolete/{i:05}.txt"), b"x"); } let first_deleted = remote.path.join("obsolete/00000.txt"); - let last_deleted = remote.path.join("obsolete/19999.txt"); let first = push(&local.path, &remote_root, &client); let second = async { @@ -221,10 +244,6 @@ async fn manifest_waits_for_a_staggered_concurrent_delete_and_update() { assert!(tokio::time::Instant::now() < deadline, "delete never began"); tokio::time::sleep(std::time::Duration::from_millis(1)).await; } - assert!( - last_deleted.exists(), - "the stagger must observe deletion in progress, not after completion" - ); push(&local.path, &remote_root, &client).await }; diff --git a/dory-core/sync/src/lib.rs b/dory-core/sync/src/lib.rs index af2d0057..217937c8 100644 --- a/dory-core/sync/src/lib.rs +++ b/dory-core/sync/src/lib.rs @@ -9,8 +9,8 @@ mod plan; pub use hash::{hash_bytes, hash_file, Hash, HASH_LEN}; pub use manifest::{ - walk_manifest, walk_manifest_excluding, walk_tree, DirectoryEntry, FileEntry, Manifest, - TreeSnapshot, + walk_manifest, walk_manifest_excluding, walk_tree, walk_tree_excluding, DirectoryEntry, + FileEntry, Manifest, TreeSnapshot, }; pub use plan::{plan, SyncPlan}; diff --git a/dory-core/sync/src/manifest.rs b/dory-core/sync/src/manifest.rs index e31025ff..19177fcd 100644 --- a/dory-core/sync/src/manifest.rs +++ b/dory-core/sync/src/manifest.rs @@ -61,7 +61,15 @@ pub fn walk_manifest_excluding( root: &Path, excluded_root_children: &[&str], ) -> std::io::Result { - Ok(walk_tree_impl(root, excluded_root_children, true)?.manifest) + Ok(walk_tree_excluding(root, excluded_root_children)?.manifest) +} + +/// Directory-aware counterpart to [`walk_manifest_excluding`]. +pub fn walk_tree_excluding( + root: &Path, + excluded_root_children: &[&str], +) -> std::io::Result { + walk_tree_impl(root, excluded_root_children, true) } fn walk_tree_impl( From fb959e3800023bcef21d2e05c95d15c05db304c2 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 00:58:08 +0000 Subject: [PATCH 079/338] feat(vm): stage selected folders --- .../DoryMachineFileTransferStager.swift | 321 +++++++++++++++--- .../DoryMachineFileTransferStagerTests.swift | 70 +++- 2 files changed, 329 insertions(+), 62 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift index b507cbfc..15b7a424 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift @@ -4,11 +4,18 @@ import Foundation public struct DoryStagedMachineFileTransfer: Sendable, Equatable { public let rootPath: String public let fileCount: UInt64 + public let directoryCount: UInt64 public let byteCount: UInt64 - fileprivate init(rootPath: String, fileCount: UInt64, byteCount: UInt64) { + fileprivate init( + rootPath: String, + fileCount: UInt64, + directoryCount: UInt64, + byteCount: UInt64 + ) { self.rootPath = rootPath self.fileCount = fileCount + self.directoryCount = directoryCount self.byteCount = byteCount } @@ -21,6 +28,8 @@ public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, CustomStringConvertible { case emptySelection case tooManyFiles + case tooManyEntries + case directoryTooDeep(String) case duplicateFileName(String) case unsupportedFile(String) case fileTooLarge(String) @@ -32,17 +41,21 @@ public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, public var description: String { switch self { case .emptySelection: - "no files were selected" + "no files or folders were selected" case .tooManyFiles: - "too many files were selected" + "selected folders contain too many files" + case .tooManyEntries: + "selected folders contain too many files and directories" + case let .directoryTooDeep(name): + "selected folder is nested too deeply: \(name)" case let .duplicateFileName(name): "more than one selected file is named \(name)" case let .unsupportedFile(name): - "selected item is not a supported regular file: \(name)" + "selected item is not a supported regular file or directory: \(name)" case let .fileTooLarge(name): "selected file is too large: \(name)" case .transferTooLarge: - "selected files exceed the transfer size limit" + "selected files and folders exceed the transfer size limit" case let .sourceChanged(name): "selected file changed while it was being staged: \(name)" case .unsafeStagingDirectory: @@ -53,17 +66,49 @@ public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, } } -/// Copies user-selected files into a private, flat handoff tree that doryd can consume after the -/// selecting app's security-scoped access ends. Version 1 intentionally accepts regular files only: -/// directory transfer needs an explicit directory manifest so empty directories are not silently -/// lost by the file-only sync protocol. +/// Copies user-selected regular files and directory trees into a private handoff that doryd can +/// consume after the selecting app's security-scoped access ends. Traversal never follows symlinks +/// and every opened source is revalidated after its bytes or children have been copied. public enum DoryMachineFileTransferStager { public static let maximumFileCount = 10_000 + public static let maximumEntryCount = 100_000 public static let maximumFileBytes: UInt64 = 16 * 1024 * 1024 * 1024 public static let maximumTransferBytes: UInt64 = 64 * 1024 * 1024 * 1024 private static let copyBufferBytes = 1024 * 1024 + private static let maximumDirectoryDepth = 128 private static let reservedRootName = ".dory-sync-tmp" + private struct StageState { + var fileCount = 0 + var directoryCount = 0 + var byteCount: UInt64 = 0 + + mutating func reserveFile(name: String, bytes: UInt64) throws { + guard fileCount < maximumFileCount else { + throw DoryMachineFileTransferStagingError.tooManyFiles + } + guard fileCount + directoryCount < maximumEntryCount else { + throw DoryMachineFileTransferStagingError.tooManyEntries + } + guard bytes <= maximumFileBytes else { + throw DoryMachineFileTransferStagingError.fileTooLarge(name) + } + let (total, overflow) = byteCount.addingReportingOverflow(bytes) + guard !overflow, total <= maximumTransferBytes else { + throw DoryMachineFileTransferStagingError.transferTooLarge + } + fileCount += 1 + byteCount = total + } + + mutating func reserveDirectory() throws { + guard fileCount + directoryCount < maximumEntryCount else { + throw DoryMachineFileTransferStagingError.tooManyEntries + } + directoryCount += 1 + } + } + public static func stage( fileURLs: [URL], stagingDirectory requestedDirectory: URL? = nil @@ -79,7 +124,7 @@ public enum DoryMachineFileTransferStager { for name in names where !uniqueNames.insert(name).inserted { throw DoryMachineFileTransferStagingError.duplicateFileName(name) } - for name in names where !isValidFileName(name) { + for name in names where !isValidFileName(name, atRoot: true) { throw DoryMachineFileTransferStagingError.unsupportedFile(name) } @@ -128,7 +173,7 @@ public enum DoryMachineFileTransferStager { throw DoryMachineFileTransferStagingError.unsafeStagingDirectory } - var totalBytes: UInt64 = 0 + var state = StageState() for (sourceURL, name) in zip(fileURLs, names) { let securityScope = sourceURL.startAccessingSecurityScopedResource() defer { @@ -143,61 +188,229 @@ public enum DoryMachineFileTransferStager { } defer { close(sourceDescriptor) } var before = stat() - guard fstat(sourceDescriptor, &before) == 0, - (before.st_mode & S_IFMT) == S_IFREG, - before.st_size >= 0 else { + guard fstat(sourceDescriptor, &before) == 0 else { throw DoryMachineFileTransferStagingError.unsupportedFile(name) } - let size = UInt64(before.st_size) - guard size <= maximumFileBytes else { - throw DoryMachineFileTransferStagingError.fileTooLarge(name) + switch before.st_mode & S_IFMT { + case S_IFREG: + try stageFile( + sourceDescriptor: sourceDescriptor, + sourceSnapshot: before, + destinationDirectory: transferDescriptor, + name: name, + displayPath: name, + state: &state + ) + case S_IFDIR: + try stageDirectory( + sourceDescriptor: sourceDescriptor, + sourceSnapshot: before, + destinationDirectory: transferDescriptor, + name: name, + displayPath: name, + depth: 1, + state: &state + ) + default: + throw DoryMachineFileTransferStagingError.unsupportedFile(name) } - let (nextTotal, overflow) = totalBytes.addingReportingOverflow(size) - guard !overflow, nextTotal <= maximumTransferBytes else { - throw DoryMachineFileTransferStagingError.transferTooLarge + } + guard fsync(transferDescriptor) == 0, fsync(baseDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("directory sync", errno) + } + completed = true + return DoryStagedMachineFileTransfer( + rootPath: transferURL.path, + fileCount: UInt64(state.fileCount), + directoryCount: UInt64(state.directoryCount), + byteCount: state.byteCount + ) + } + + private static func stageFile( + sourceDescriptor: Int32, + sourceSnapshot: stat, + destinationDirectory: Int32, + name: String, + displayPath: String, + state: inout StageState + ) throws { + guard sourceSnapshot.st_size >= 0 else { + throw DoryMachineFileTransferStagingError.unsupportedFile(displayPath) + } + let size = UInt64(sourceSnapshot.st_size) + try state.reserveFile(name: displayPath, bytes: size) + let destinationDescriptor = openat( + destinationDirectory, + name, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard destinationDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("destination open", errno) + } + do { + try copy( + sourceDescriptor: sourceDescriptor, + destinationDescriptor: destinationDescriptor + ) + guard fsync(destinationDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("file sync", errno) } + } catch { + close(destinationDescriptor) + throw error + } + close(destinationDescriptor) + try revalidateSource( + descriptor: sourceDescriptor, + expected: sourceSnapshot, + displayPath: displayPath + ) + } - let destinationDescriptor = openat( - transferDescriptor, - name, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, - mode_t(0o600) + private static func stageDirectory( + sourceDescriptor: Int32, + sourceSnapshot: stat, + destinationDirectory: Int32, + name: String, + displayPath: String, + depth: Int, + state: inout StageState + ) throws { + guard depth <= maximumDirectoryDepth else { + throw DoryMachineFileTransferStagingError.directoryTooDeep(displayPath) + } + try state.reserveDirectory() + guard mkdirat(destinationDirectory, name, mode_t(0o700)) == 0 else { + throw DoryMachineFileTransferStagingError.io("directory creation", errno) + } + let destinationDescriptor = openat( + destinationDirectory, + name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard destinationDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("directory open", errno) + } + defer { close(destinationDescriptor) } + + for childName in try directoryEntryNames(descriptor: sourceDescriptor) { + let childPath = displayPath + "/" + childName + guard isValidFileName(childName, atRoot: false) else { + throw DoryMachineFileTransferStagingError.unsupportedFile(childPath) + } + let childDescriptor = openat( + sourceDescriptor, + childName, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK ) - guard destinationDescriptor >= 0 else { - throw DoryMachineFileTransferStagingError.io("destination open", errno) + guard childDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("source open", errno) } - do { - try copy( - sourceDescriptor: sourceDescriptor, - destinationDescriptor: destinationDescriptor + defer { close(childDescriptor) } + var childSnapshot = stat() + guard fstat(childDescriptor, &childSnapshot) == 0 else { + throw DoryMachineFileTransferStagingError.io("source stat", errno) + } + switch childSnapshot.st_mode & S_IFMT { + case S_IFREG: + try stageFile( + sourceDescriptor: childDescriptor, + sourceSnapshot: childSnapshot, + destinationDirectory: destinationDescriptor, + name: childName, + displayPath: childPath, + state: &state ) - guard fsync(destinationDescriptor) == 0 else { - throw DoryMachineFileTransferStagingError.io("file sync", errno) - } - } catch { - close(destinationDescriptor) - throw error + case S_IFDIR: + try stageDirectory( + sourceDescriptor: childDescriptor, + sourceSnapshot: childSnapshot, + destinationDirectory: destinationDescriptor, + name: childName, + displayPath: childPath, + depth: depth + 1, + state: &state + ) + default: + throw DoryMachineFileTransferStagingError.unsupportedFile(childPath) } - close(destinationDescriptor) + } + try revalidateSource( + descriptor: sourceDescriptor, + expected: sourceSnapshot, + displayPath: displayPath + ) + guard fsync(destinationDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("directory sync", errno) + } + } - var after = stat() - guard fstat(sourceDescriptor, &after) == 0 else { - throw DoryMachineFileTransferStagingError.io("source revalidation", errno) + private static func directoryEntryNames(descriptor: Int32) throws -> [String] { + let duplicate = dup(descriptor) + guard duplicate >= 0 else { + throw DoryMachineFileTransferStagingError.io("directory duplicate", errno) + } + guard let stream = fdopendir(duplicate) else { + let code = errno + close(duplicate) + throw DoryMachineFileTransferStagingError.io("directory enumeration", code) + } + defer { closedir(stream) } + var names: [String] = [] + while true { + errno = 0 + guard let entry = readdir(stream) else { + guard errno == 0 else { + throw DoryMachineFileTransferStagingError.io( + "directory enumeration", + errno + ) + } + break + } + let name = try withUnsafePointer(to: &entry.pointee.d_name) { pointer in + try pointer.withMemoryRebound( + to: CChar.self, + capacity: Int(MAXNAMLEN) + 1 + ) { characters in + let length = strnlen(characters, Int(MAXNAMLEN) + 1) + guard length <= Int(MAXNAMLEN), + let name = String( + bytes: UnsafeRawBufferPointer( + start: characters, + count: length + ), + encoding: .utf8 + ) else { + throw DoryMachineFileTransferStagingError.unsupportedFile( + "non-UTF-8 directory entry" + ) + } + return name + } } - guard sameSnapshot(before, after) else { - throw DoryMachineFileTransferStagingError.sourceChanged(name) + if name != ".", name != ".." { + names.append(name) } - totalBytes = nextTotal } - guard fsync(transferDescriptor) == 0, fsync(baseDescriptor) == 0 else { - throw DoryMachineFileTransferStagingError.io("directory sync", errno) + names.sort() + return names + } + + private static func revalidateSource( + descriptor: Int32, + expected: stat, + displayPath: String + ) throws { + var actual = stat() + guard fstat(descriptor, &actual) == 0 else { + throw DoryMachineFileTransferStagingError.io("source revalidation", errno) + } + guard sameSnapshot(expected, actual) else { + throw DoryMachineFileTransferStagingError.sourceChanged(displayPath) } - completed = true - return DoryStagedMachineFileTransfer( - rootPath: transferURL.path, - fileCount: UInt64(fileURLs.count), - byteCount: totalBytes - ) } private static func copy( @@ -232,12 +445,12 @@ public enum DoryMachineFileTransferStager { } } - private static func isValidFileName(_ name: String) -> Bool { + private static func isValidFileName(_ name: String, atRoot: Bool) -> Bool { let count = name.utf8.count return (1...255).contains(count) && name != "." && name != ".." - && name != reservedRootName + && (!atRoot || name != reservedRootName) && !name.contains("/") && !name.contains("\0") } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift index 9b7be12f..d81e85e9 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift @@ -19,6 +19,7 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { defer { try? staged.remove() } XCTAssertEqual(staged.fileCount, 2) + XCTAssertEqual(staged.directoryCount, 0) XCTAssertEqual(staged.byteCount, 5) XCTAssertEqual( try Data(contentsOf: URL(fileURLWithPath: staged.rootPath + "/hello.txt")), @@ -32,29 +33,82 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { XCTAssertEqual(try permissions(staged.rootPath + "/hello.txt") & 0o777, 0o600) } - func testRejectsDirectoriesSymlinksAndDuplicateNames() throws { + func testStagesNestedFoldersIncludingEmptyDirectories() throws { let fixture = try StagerFixture(tag: "shapes") defer { fixture.cleanup() } + let directory = fixture.source.appendingPathComponent("folder", isDirectory: true) + let nested = directory.appendingPathComponent("nested", isDirectory: true) + let empty = directory.appendingPathComponent("empty/deep", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + try Data("nested".utf8).write(to: nested.appendingPathComponent("hello.txt")) + let topLevel = fixture.source.appendingPathComponent("top-level.txt") + try Data("top".utf8).write(to: topLevel) + + let staged = try DoryMachineFileTransferStager.stage( + fileURLs: [topLevel, directory], + stagingDirectory: fixture.staging + ) + defer { try? staged.remove() } + + XCTAssertEqual(staged.fileCount, 2) + XCTAssertEqual(staged.directoryCount, 4) + XCTAssertEqual(staged.byteCount, 9) + XCTAssertEqual( + try Data(contentsOf: URL(fileURLWithPath: staged.rootPath + "/folder/nested/hello.txt")), + Data("nested".utf8) + ) + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists( + atPath: staged.rootPath + "/folder/empty/deep", + isDirectory: &isDirectory + )) + XCTAssertTrue(isDirectory.boolValue) + XCTAssertEqual(try permissions(staged.rootPath + "/folder") & 0o777, 0o700) + XCTAssertEqual( + try permissions(staged.rootPath + "/folder/nested/hello.txt") & 0o777, + 0o600 + ) + } + + func testRejectsSymlinksSpecialFilesAndDuplicateTopLevelNames() throws { + let fixture = try StagerFixture(tag: "rejections") + defer { fixture.cleanup() } + + let regular = fixture.source.appendingPathComponent("regular") + let symlinkURL = fixture.source.appendingPathComponent("link") + try Data("x".utf8).write(to: regular) + XCTAssertEqual(Darwin.symlink(regular.path, symlinkURL.path), 0) + XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( + fileURLs: [symlinkURL], + stagingDirectory: fixture.staging + )) + let directory = fixture.source.appendingPathComponent("folder", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + let nestedLink = directory.appendingPathComponent("nested-link") + XCTAssertEqual(Darwin.symlink(regular.path, nestedLink.path), 0) XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( fileURLs: [directory], stagingDirectory: fixture.staging )) { error in XCTAssertEqual( error as? DoryMachineFileTransferStagingError, - .unsupportedFile("folder") + .io("source open", ELOOP) ) } - let regular = fixture.source.appendingPathComponent("regular") - let symlinkURL = fixture.source.appendingPathComponent("link") - try Data("x".utf8).write(to: regular) - XCTAssertEqual(Darwin.symlink(regular.path, symlinkURL.path), 0) + let fifo = fixture.source.appendingPathComponent("fifo") + XCTAssertEqual(mkfifo(fifo.path, mode_t(0o600)), 0) XCTAssertThrowsError(try DoryMachineFileTransferStager.stage( - fileURLs: [symlinkURL], + fileURLs: [fifo], stagingDirectory: fixture.staging - )) + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .unsupportedFile("fifo") + ) + } let otherRoot = fixture.root.appendingPathComponent("other", isDirectory: true) try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: false) From 7ddec9f26d100a5696e908a23a330c61caa596d9 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:02:54 +0000 Subject: [PATCH 080/338] feat(vm): send folders from machine view --- Dory/Features/Machines/MachinesView.swift | 23 +++++++++--- Dory/Models/AppStore.swift | 34 ++++++++++++++---- Dory/Models/Models.swift | 1 + DoryTests/DorydClientTests.swift | 43 ++++++++++++++++++++++- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 55a11e3a..d4465fd3 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -253,10 +253,18 @@ private struct MachineCard: View { } if isRunning { Button { selectAndSendFiles() } label: { - Label("Send Files\u{2026}", systemImage: "paperplane") + Label( + store.canTransferFolders(to: machine) + ? "Send Files or Folders\u{2026}" : "Send Files\u{2026}", + systemImage: "paperplane" + ) } .disabled(!store.canTransferFiles(to: machine)) - .help("Copy selected files into this machine's Downloads folder") + .help( + store.canTransferFolders(to: machine) + ? "Copy selected files and folders into this machine's Downloads folder" + : "Copy selected files into this machine's Downloads folder" + ) } Divider() } @@ -300,12 +308,17 @@ private struct MachineCard: View { private func selectAndSendFiles() { guard store.canTransferFiles(to: machine) else { return } + let supportsFolders = store.canTransferFolders(to: machine) let panel = NSOpenPanel() - panel.title = "Send files to \(machine.name)" - panel.message = "Files are copied into a new folder in the machine's Downloads folder." + panel.title = supportsFolders + ? "Send files or folders to \(machine.name)" + : "Send files to \(machine.name)" + panel.message = supportsFolders + ? "Files and folders are copied into a new folder in the machine's Downloads folder." + : "Files are copied into a new folder in the machine's Downloads folder. Update Dory Tools to send folders." panel.prompt = "Send" panel.canChooseFiles = true - panel.canChooseDirectories = false + panel.canChooseDirectories = supportsFolders panel.allowsMultipleSelection = true panel.resolvesAliases = false guard panel.runModal() == .OK else { return } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index d6b8ba22..07cce7d5 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5272,6 +5272,13 @@ final class AppStore { return supports("exec") && supports("sync-push") } + func canTransferFolders(to machine: Machine) -> Bool { + canTransferFiles(to: machine) + && machine.agentCapabilities.contains { + $0.id == "sync-push" && $0.version >= 2 + } + } + @discardableResult func transferFiles( _ fileURLs: [URL], @@ -5291,24 +5298,39 @@ final class AppStore { staged = try await Task.detached(priority: .userInitiated) { try DoryMachineFileTransferStager.stage(fileURLs: fileURLs) }.value - guard let staged else { return nil } - let result = try await dorydClient.machineTransfer(machine.name, staged: staged) + guard let preparedStage = staged else { return nil } + guard preparedStage.directoryCount == 0 || canTransferFolders(to: machine) else { + do { + try await Task.detached(priority: .utility) { + try preparedStage.remove() + }.value + staged = nil + } catch { + actionError = "Dory could not remove its private staging copy." + return nil + } + actionError = "Update Dory Tools in \(machine.name) before sending folders." + return nil + } + let result = try await dorydClient.machineTransfer(machine.name, staged: preparedStage) let fileLabel = result.filesSent == 1 ? "file" : "files" + let folderLabel = preparedStage.directoryCount == 1 ? "folder" : "folders" let bytes = ByteCountFormatter.string( fromByteCount: Int64(clamping: result.bytesSent), countStyle: .file ) do { try await Task.detached(priority: .utility) { - try staged.remove() + try preparedStage.remove() }.value } catch { actionError = "Files reached \(machine.name), but Dory could not remove its private staging copy." return result } - showSettingsSuccess( - "Sent \(result.filesSent) \(fileLabel) (\(bytes)) to \(result.guestDestination)." - ) + let transferredItems = preparedStage.directoryCount == 0 + ? "\(result.filesSent) \(fileLabel)" + : "\(result.filesSent) \(fileLabel) and \(preparedStage.directoryCount) \(folderLabel)" + showSettingsSuccess("Sent \(transferredItems) (\(bytes)) to \(result.guestDestination).") return result } catch { if let staged { diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 7dc1e0de..d0364744 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -419,6 +419,7 @@ struct Machine: Identifiable, Hashable, Sendable { ("exec-stdin", 1), ("ports-watch", 1), ("snapshot-quiesce", 2), + ("sync-push", 2), ("telemetry", 1), ] let missing = required.compactMap { requirement in diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 9286470a..3c6ff592 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -664,6 +664,14 @@ struct DorydClientTests { #expect(oldQuiesceHandshake.runtimeEvidence.last?.label == "Tools partially ready") #expect(oldQuiesceHandshake.runtimeEvidence.last?.detail.contains("snapshot-quiesce@2") == true) + var oldSyncHandshake = machine + oldSyncHandshake.agentCapabilities = machine.agentCapabilities.map { + $0.id == "sync-push" + ? DorydAgentCapability(id: $0.id, version: 1) : $0 + } + #expect(oldSyncHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(oldSyncHandshake.runtimeEvidence.last?.detail.contains("sync-push@2") == true) + var incompatibleHandshake = machine incompatibleHandshake.agentProtocolVersion = 2 #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") @@ -1171,6 +1179,7 @@ struct DorydClientTests { #expect(store.machineTerminalCommand(machine) == "dory machine shell dev") #expect(store.canUseMachineArtifacts(machine)) #expect(store.canTransferFiles(to: machine)) + #expect(store.canTransferFolders(to: machine)) let transferRoot = URL( fileURLWithPath: "/tmp/dory-store-transfer-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 01:04:38 +0000 Subject: [PATCH 081/338] feat(vm): accept file drops on machine cards --- Dory/Features/Machines/MachinesView.swift | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index d4465fd3..6c0b608a 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -124,6 +124,7 @@ private struct MachineCard: View { @Environment(\.openWindow) private var openWindow let machine: Machine @State private var confirmingDelete = false + @State private var isTransferDropTargeted = false private var isRunning: Bool { machine.status == .running } private var isPaused: Bool { machine.status == .paused } @@ -220,7 +221,46 @@ private struct MachineCard: View { } .padding(16) .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(p.border)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder( + isTransferDropTargeted ? p.accent : p.border, + lineWidth: isTransferDropTargeted ? 2 : 1 + ) + ) + .overlay { + if isTransferDropTargeted { + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(p.accentSoft.opacity(0.96)) + VStack(spacing: 8) { + Image(systemName: "arrow.down.doc.fill") + .font(.system(size: 24, weight: .semibold)) + Text(store.canTransferFolders(to: machine) + ? "Send files or folders" + : "Send files") + .font(.system(size: 13, weight: .semibold)) + Text("Copies into a new Downloads folder") + .font(.system(size: 11)) + .foregroundStyle(p.text2) + } + .foregroundStyle(p.accentText) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .dropDestination(for: URL.self) { urls, _ in + guard store.canTransferFiles(to: machine), + !urls.isEmpty, + urls.allSatisfy(\.isFileURL) else { + return false + } + Task { await store.transferFiles(urls, to: machine) } + return true + } isTargeted: { targeted in + isTransferDropTargeted = targeted && store.canTransferFiles(to: machine) + } .confirmationDialog("Delete machine \(machine.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { Button("Delete", role: .destructive) { store.deleteMachine(machine) } Button("Cancel", role: .cancel) {} From a21a4cb45bf980a9325c0270bdd230bf599580ad Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:07:52 +0000 Subject: [PATCH 082/338] feat(sync): report push progress and cancellation --- dory-core/remote/src/error.rs | 2 + dory-core/remote/src/lib.rs | 4 +- dory-core/remote/src/sync_push.rs | 205 ++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/dory-core/remote/src/error.rs b/dory-core/remote/src/error.rs index f9ef090a..a3e9028b 100644 --- a/dory-core/remote/src/error.rs +++ b/dory-core/remote/src/error.rs @@ -21,6 +21,8 @@ pub enum RemoteError { UnexpectedVariant, #[error("local source changed while the transfer was in progress")] SourceChanged, + #[error("file transfer cancelled")] + Cancelled, #[error("remote agent capability unavailable: {0}")] CapabilityUnavailable(&'static str), #[error(transparent)] diff --git a/dory-core/remote/src/lib.rs b/dory-core/remote/src/lib.rs index 5a2da0e7..78321a20 100644 --- a/dory-core/remote/src/lib.rs +++ b/dory-core/remote/src/lib.rs @@ -19,4 +19,6 @@ pub use agent_client::AgentClient; pub use error::RemoteError; pub use keys::{private_key_from_openssh, public_key_from_openssh}; pub use ssh::{AgentEndpoint, HostKeyPolicy, SshAgent, SshConfig}; -pub use sync_push::{push, PushStats, SyncTarget}; +pub use sync_push::{ + push, push_observed, PushObserver, PushPhase, PushProgress, PushStats, SyncTarget, +}; diff --git a/dory-core/remote/src/sync_push.rs b/dory-core/remote/src/sync_push.rs index e472449d..4eb82a0b 100644 --- a/dory-core/remote/src/sync_push.rs +++ b/dory-core/remote/src/sync_push.rs @@ -33,6 +33,57 @@ pub struct PushStats { pub files_deleted: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushPhase { + Preparing, + Transferring, + Finalizing, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PushProgress { + pub phase: PushPhase, + pub files_total: u64, + pub files_completed: u64, + pub bytes_total: u64, + pub bytes_completed: u64, + pub current_path: Option, +} + +impl Default for PushProgress { + fn default() -> Self { + Self { + phase: PushPhase::Preparing, + files_total: 0, + files_completed: 0, + bytes_total: 0, + bytes_completed: 0, + current_path: None, + } + } +} + +/// A control-plane observer for a push. Implementations must return quickly: callbacks execute on +/// the transfer task between bounded data-plane operations. Progress is absolute (not a delta), so +/// polling consumers can safely coalesce updates. Cancellation is cooperative and checked before +/// and after every remote mutation and every file chunk. +pub trait PushObserver: Send + Sync { + fn update(&self, progress: &PushProgress); + fn is_cancelled(&self) -> bool; +} + +struct IgnorePushProgress; + +impl PushObserver for IgnorePushProgress { + fn update(&self, _progress: &PushProgress) {} + + fn is_cancelled(&self) -> bool { + false + } +} + /// The remote endpoint of a push. Modeled as a trait so the driver is testable without a transport. /// Futures are `Send` so `doryd` can drive a push from a spawned task. pub trait SyncTarget { @@ -75,12 +126,33 @@ pub async fn push( remote_root: &str, target: &T, ) -> Result { + push_observed(local_root, remote_root, target, &IgnorePushProgress).await +} + +pub async fn push_observed( + local_root: &Path, + remote_root: &str, + target: &T, + observer: &O, +) -> Result { + let mut progress = PushProgress::default(); + observer.update(&progress); + require_not_cancelled(observer, &mut progress)?; let root = local_root.to_path_buf(); let local = tokio::task::spawn_blocking(move || walk_tree(&root)) .await .map_err(|e| RemoteError::Io(std::io::Error::other(e)))??; + require_not_cancelled(observer, &mut progress)?; let remote = target.remote_manifest(remote_root).await?; + require_not_cancelled(observer, &mut progress)?; let plan = plan(&local.manifest, &remote); + progress.files_total = u64::try_from(plan.transfer.len()).map_err(|_| RemoteError::Decode)?; + progress.bytes_total = plan.transfer.iter().try_fold(0u64, |total, path| { + let size = local.manifest.get(path).ok_or(RemoteError::Decode)?.size; + total.checked_add(size).ok_or(RemoteError::Decode) + })?; + progress.phase = PushPhase::Transferring; + observer.update(&progress); let file_paths = local .manifest .entries @@ -90,12 +162,15 @@ pub async fn push( let directory_protocol = target .reconcile_tree(remote_root, &file_paths, &local.directories, false) .await?; + require_not_cancelled(observer, &mut progress)?; if !directory_protocol && has_empty_directory(&local) { return Err(RemoteError::CapabilityUnavailable("sync-push@2")); } let mut stats = PushStats::default(); + let mut completed_file_bytes = 0u64; for rel in &plan.transfer { + require_not_cancelled(observer, &mut progress)?; let entry = local .manifest .get(rel) @@ -106,8 +181,12 @@ pub async fn push( let staged = target.staged_bytes(remote_root, rel, &entry.hash).await?; let mut offset = if staged <= entry.size { staged } else { 0 }; let mut conflict_recoveries = 0usize; + progress.current_path = Some(rel.clone()); + progress.bytes_completed = completed_file_bytes + offset; + observer.update(&progress); loop { + require_not_cancelled(observer, &mut progress)?; file.seek(SeekFrom::Start(offset)).await?; let remaining = entry.size.checked_sub(offset).ok_or(RemoteError::Decode)?; let chunk_len = remaining.min(CHUNK_BYTES as u64) as usize; @@ -131,6 +210,7 @@ pub async fn push( mtime_ns: entry.mtime_ns, }) .await; + require_not_cancelled(observer, &mut progress)?; let (next_offset, committed) = match outcome { Ok(outcome) => outcome, Err(RemoteError::Rpc { code: 409, .. }) @@ -139,12 +219,16 @@ pub async fn push( conflict_recoveries += 1; let staged = target.staged_bytes(remote_root, rel, &entry.hash).await?; offset = if staged <= entry.size { staged } else { 0 }; + progress.bytes_completed = completed_file_bytes + offset; + observer.update(&progress); continue; } Err(error) => return Err(error), }; stats.bytes_sent += sent; offset = end; + progress.bytes_completed = completed_file_bytes + offset; + observer.update(&progress); // We never use the peer's offset as a local slice index. A committed response is the // one exception where it affects control flow, so require proof that the peer claims // the complete local length. The final request must likewise be acknowledged as a @@ -160,18 +244,31 @@ pub async fn push( } } stats.files_sent += 1; + completed_file_bytes = completed_file_bytes + .checked_add(entry.size) + .ok_or(RemoteError::Decode)?; + progress.files_completed = stats.files_sent; + progress.bytes_completed = completed_file_bytes; + progress.current_path = None; + observer.update(&progress); } + progress.phase = PushPhase::Finalizing; + progress.current_path = None; + observer.update(&progress); + require_not_cancelled(observer, &mut progress)?; if directory_protocol { stats.files_deleted = plan.delete.len() as u64; let finalized = target .reconcile_tree(remote_root, &file_paths, &local.directories, true) .await?; + require_not_cancelled(observer, &mut progress)?; if !finalized { return Err(RemoteError::CapabilityUnavailable("sync-push@2")); } } else if !plan.delete.is_empty() { stats.files_deleted += target.delete(remote_root, &plan.delete).await? as u64; + require_not_cancelled(observer, &mut progress)?; } // The manifest is a point-in-time source authority. Re-read it after all remote mutations so a @@ -184,9 +281,28 @@ pub async fn push( if final_local != local { return Err(RemoteError::SourceChanged); } + require_not_cancelled(observer, &mut progress)?; + progress.phase = PushPhase::Completed; + progress.files_completed = progress.files_total; + progress.bytes_completed = progress.bytes_total; + progress.current_path = None; + observer.update(&progress); Ok(stats) } +fn require_not_cancelled( + observer: &O, + progress: &mut PushProgress, +) -> Result<(), RemoteError> { + if !observer.is_cancelled() { + return Ok(()); + } + progress.phase = PushPhase::Cancelled; + progress.current_path = None; + observer.update(progress); + Err(RemoteError::Cancelled) +} + fn has_empty_directory(snapshot: &TreeSnapshot) -> bool { snapshot.directories.iter().any(|directory| { let prefix = format!("{}/", directory.path); @@ -439,6 +555,37 @@ mod tests { } } + struct RecordingObserver { + updates: Mutex>, + cancel_after_bytes: Option, + } + + impl RecordingObserver { + fn new(cancel_after_bytes: Option) -> Self { + Self { + updates: Mutex::new(Vec::new()), + cancel_after_bytes, + } + } + } + + impl PushObserver for RecordingObserver { + fn update(&self, progress: &PushProgress) { + self.updates.lock().unwrap().push(progress.clone()); + } + + fn is_cancelled(&self) -> bool { + let Some(limit) = self.cancel_after_bytes else { + return false; + }; + self.updates + .lock() + .unwrap() + .last() + .is_some_and(|progress| progress.bytes_completed >= limit) + } + } + #[tokio::test] async fn push_binds_empty_directories_in_prepare_and_finalize_passes() { let t = TempTree::new("empty-directories"); @@ -523,6 +670,64 @@ mod tests { ); } + #[tokio::test] + async fn observed_push_reports_absolute_bounded_progress() { + let t = TempTree::new("progress"); + let big = vec![7u8; CHUNK_BYTES + 123]; + t.write("a.txt", b"hello"); + t.write("dir/b.bin", &big); + + let target = FakeTarget::new(Manifest::default()); + let observer = RecordingObserver::new(None); + let stats = push_observed(&t.root, "/remote", &target, &observer) + .await + .unwrap(); + + assert_eq!(stats.files_sent, 2); + let updates = observer.updates.lock().unwrap(); + assert_eq!(updates.first().unwrap().phase, PushPhase::Preparing); + assert_eq!(updates.last().unwrap().phase, PushPhase::Completed); + assert_eq!(updates.last().unwrap().files_total, 2); + assert_eq!(updates.last().unwrap().files_completed, 2); + assert_eq!(updates.last().unwrap().bytes_total, (5 + big.len()) as u64); + assert_eq!( + updates.last().unwrap().bytes_completed, + (5 + big.len()) as u64 + ); + assert!(updates.iter().all(|progress| { + progress.files_completed <= progress.files_total + && progress.bytes_completed <= progress.bytes_total + })); + assert!(updates.windows(2).all(|window| { + window[0].files_completed <= window[1].files_completed + && window[0].bytes_completed <= window[1].bytes_completed + })); + assert!(updates + .iter() + .any(|progress| progress.current_path.as_deref() == Some("dir/b.bin"))); + } + + #[tokio::test] + async fn observed_push_cancels_between_chunks_before_finalization() { + let t = TempTree::new("cancel"); + t.write("large.bin", &vec![3u8; CHUNK_BYTES * 2 + 1]); + let target = FakeTarget::new(Manifest::default()); + let observer = RecordingObserver::new(Some(CHUNK_BYTES as u64)); + + let error = push_observed(&t.root, "/remote", &target, &observer) + .await + .unwrap_err(); + + assert!(matches!(error, RemoteError::Cancelled)); + assert_eq!(target.rec.lock().unwrap().chunks.len(), 1); + let updates = observer.updates.lock().unwrap(); + let final_progress = updates.last().unwrap(); + assert_eq!(final_progress.phase, PushPhase::Cancelled); + assert_eq!(final_progress.files_completed, 0); + assert_eq!(final_progress.bytes_completed, CHUNK_BYTES as u64); + assert_eq!(final_progress.bytes_total, (CHUNK_BYTES * 2 + 1) as u64); + } + #[tokio::test] async fn push_resumes_from_the_remote_staged_offset() { let t = TempTree::new("resume"); From 0e5519823a050383a344b1267bfbbb0305ce13c3 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:09:11 +0000 Subject: [PATCH 083/338] fix(sync): terminalize failed push progress --- dory-core/remote/src/sync_push.rs | 73 +++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/dory-core/remote/src/sync_push.rs b/dory-core/remote/src/sync_push.rs index 4eb82a0b..d59fe0d4 100644 --- a/dory-core/remote/src/sync_push.rs +++ b/dory-core/remote/src/sync_push.rs @@ -40,6 +40,7 @@ pub enum PushPhase { Finalizing, Completed, Cancelled, + Failed, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -137,14 +138,31 @@ pub async fn push_observed( ) -> Result { let mut progress = PushProgress::default(); observer.update(&progress); - require_not_cancelled(observer, &mut progress)?; + let result = + push_observed_inner(local_root, remote_root, target, observer, &mut progress).await; + if result.is_err() && progress.phase != PushPhase::Cancelled { + progress.phase = PushPhase::Failed; + progress.current_path = None; + observer.update(&progress); + } + result +} + +async fn push_observed_inner( + local_root: &Path, + remote_root: &str, + target: &T, + observer: &O, + progress: &mut PushProgress, +) -> Result { + require_not_cancelled(observer, progress)?; let root = local_root.to_path_buf(); let local = tokio::task::spawn_blocking(move || walk_tree(&root)) .await .map_err(|e| RemoteError::Io(std::io::Error::other(e)))??; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; let remote = target.remote_manifest(remote_root).await?; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; let plan = plan(&local.manifest, &remote); progress.files_total = u64::try_from(plan.transfer.len()).map_err(|_| RemoteError::Decode)?; progress.bytes_total = plan.transfer.iter().try_fold(0u64, |total, path| { @@ -152,7 +170,7 @@ pub async fn push_observed( total.checked_add(size).ok_or(RemoteError::Decode) })?; progress.phase = PushPhase::Transferring; - observer.update(&progress); + observer.update(progress); let file_paths = local .manifest .entries @@ -162,7 +180,7 @@ pub async fn push_observed( let directory_protocol = target .reconcile_tree(remote_root, &file_paths, &local.directories, false) .await?; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; if !directory_protocol && has_empty_directory(&local) { return Err(RemoteError::CapabilityUnavailable("sync-push@2")); } @@ -170,7 +188,7 @@ pub async fn push_observed( let mut stats = PushStats::default(); let mut completed_file_bytes = 0u64; for rel in &plan.transfer { - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; let entry = local .manifest .get(rel) @@ -183,10 +201,10 @@ pub async fn push_observed( let mut conflict_recoveries = 0usize; progress.current_path = Some(rel.clone()); progress.bytes_completed = completed_file_bytes + offset; - observer.update(&progress); + observer.update(progress); loop { - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; file.seek(SeekFrom::Start(offset)).await?; let remaining = entry.size.checked_sub(offset).ok_or(RemoteError::Decode)?; let chunk_len = remaining.min(CHUNK_BYTES as u64) as usize; @@ -210,7 +228,7 @@ pub async fn push_observed( mtime_ns: entry.mtime_ns, }) .await; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; let (next_offset, committed) = match outcome { Ok(outcome) => outcome, Err(RemoteError::Rpc { code: 409, .. }) @@ -220,7 +238,7 @@ pub async fn push_observed( let staged = target.staged_bytes(remote_root, rel, &entry.hash).await?; offset = if staged <= entry.size { staged } else { 0 }; progress.bytes_completed = completed_file_bytes + offset; - observer.update(&progress); + observer.update(progress); continue; } Err(error) => return Err(error), @@ -228,7 +246,7 @@ pub async fn push_observed( stats.bytes_sent += sent; offset = end; progress.bytes_completed = completed_file_bytes + offset; - observer.update(&progress); + observer.update(progress); // We never use the peer's offset as a local slice index. A committed response is the // one exception where it affects control flow, so require proof that the peer claims // the complete local length. The final request must likewise be acknowledged as a @@ -250,25 +268,25 @@ pub async fn push_observed( progress.files_completed = stats.files_sent; progress.bytes_completed = completed_file_bytes; progress.current_path = None; - observer.update(&progress); + observer.update(progress); } progress.phase = PushPhase::Finalizing; progress.current_path = None; - observer.update(&progress); - require_not_cancelled(observer, &mut progress)?; + observer.update(progress); + require_not_cancelled(observer, progress)?; if directory_protocol { stats.files_deleted = plan.delete.len() as u64; let finalized = target .reconcile_tree(remote_root, &file_paths, &local.directories, true) .await?; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; if !finalized { return Err(RemoteError::CapabilityUnavailable("sync-push@2")); } } else if !plan.delete.is_empty() { stats.files_deleted += target.delete(remote_root, &plan.delete).await? as u64; - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; } // The manifest is a point-in-time source authority. Re-read it after all remote mutations so a @@ -281,12 +299,12 @@ pub async fn push_observed( if final_local != local { return Err(RemoteError::SourceChanged); } - require_not_cancelled(observer, &mut progress)?; + require_not_cancelled(observer, progress)?; progress.phase = PushPhase::Completed; progress.files_completed = progress.files_total; progress.bytes_completed = progress.bytes_total; progress.current_path = None; - observer.update(&progress); + observer.update(progress); Ok(stats) } @@ -728,6 +746,25 @@ mod tests { assert_eq!(final_progress.bytes_total, (CHUNK_BYTES * 2 + 1) as u64); } + #[tokio::test] + async fn observed_push_reports_a_terminal_protocol_failure() { + let t = TempTree::new("failed-progress"); + t.write("file", b"contents"); + let mut target = FakeTarget::new(Manifest::default()); + target.suppress_final_commit = true; + let observer = RecordingObserver::new(None); + + let error = push_observed(&t.root, "/remote", &target, &observer) + .await + .unwrap_err(); + + assert!(matches!(error, RemoteError::Decode)); + let updates = observer.updates.lock().unwrap(); + assert_eq!(updates.last().unwrap().phase, PushPhase::Failed); + assert_eq!(updates.last().unwrap().current_path, None); + assert_eq!(updates.last().unwrap().files_completed, 0); + } + #[tokio::test] async fn push_resumes_from_the_remote_staged_offset() { let t = TempTree::new("resume"); From d4a1b03b7a971ef89db5b7f4eab72c6fbb47e2f4 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:11:07 +0000 Subject: [PATCH 084/338] feat(sync): expose controlled pushes over ffi --- dory-core/ffi/src/agent_forward.rs | 51 +++++++++++- dory-core/ffi/src/remote.rs | 129 ++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 1430c913..9fd44106 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -10,12 +10,12 @@ use std::sync::Arc; use dory_proto::channels::PORT_CONTROL; use dory_proto::preamble::{write_preamble, Direction, Preamble}; -use dory_remote::{push, AgentClient}; +use dory_remote::{push, push_observed, AgentClient}; use tokio::net::UnixStream; use crate::remote::{ - exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, PushStatsFfi, - RemoteFfiError, TelemetryFfi, + exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, PushControl, + PushStatsFfi, RemoteFfiError, TelemetryFfi, }; #[derive(uniffi::Record)] @@ -185,6 +185,28 @@ impl AgentControl { }) } + pub fn push_controlled( + &self, + local_root: String, + remote_root: String, + control: Arc, + ) -> Result { + control.claim()?; + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let stats = runtime.block_on(push_observed( + std::path::Path::new(&local_root), + &remote_root, + &self.client, + control.as_ref(), + ))?; + Ok(PushStatsFfi { + files_sent: stats.files_sent, + bytes_sent: stats.bytes_sent, + files_deleted: stats.files_deleted, + }) + } + pub fn snapshot_freeze(&self, receipt_id: String) -> Result { snapshot_quiesce( self, @@ -382,10 +404,12 @@ mod tests { std::fs::create_dir_all(&local_root).unwrap(); std::fs::create_dir_all(&remote_root).unwrap(); std::fs::write(local_root.join("payload.txt"), b"agent-push").unwrap(); + let transfer_control = crate::remote::new_push_control(); let stats = control - .push( + .push_controlled( local_root.to_string_lossy().into_owned(), remote_root.to_string_lossy().into_owned(), + transfer_control.clone(), ) .expect("push"); assert_eq!(stats.files_sent, 1); @@ -394,6 +418,25 @@ mod tests { std::fs::read(remote_root.join("payload.txt")).unwrap(), b"agent-push" ); + let progress = transfer_control.progress(); + assert!(matches!( + progress.phase, + crate::remote::PushPhaseFfi::Completed + )); + assert_eq!(progress.files_total, 1); + assert_eq!(progress.files_completed, 1); + assert_eq!(progress.bytes_total, 10); + assert_eq!(progress.bytes_completed, 10); + assert!(progress.current_path.is_none()); + let reuse_error = control + .push_controlled( + local_root.to_string_lossy().into_owned(), + remote_root.to_string_lossy().into_owned(), + transfer_control, + ) + .err() + .expect("single-use control must reject reuse"); + assert!(reuse_error.to_string().contains("already used")); let _ = std::fs::remove_dir_all(&transfer_root); let _ = std::fs::remove_file(&path); } diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs index 4414c932..745f4c44 100644 --- a/dory-core/ffi/src/remote.rs +++ b/dory-core/ffi/src/remote.rs @@ -9,11 +9,12 @@ //! drop panics inside an async context). use std::path::PathBuf; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use dory_remote::{ - private_key_from_openssh, public_key_from_openssh, push, AgentEndpoint, HostKeyPolicy, - SshAgent, SshConfig, + private_key_from_openssh, public_key_from_openssh, push, push_observed, AgentEndpoint, + HostKeyPolicy, PushObserver, PushPhase, PushProgress, SshAgent, SshConfig, }; #[derive(Debug, uniffi::Error, thiserror::Error)] @@ -84,6 +85,106 @@ pub struct PushStatsFfi { pub files_deleted: u64, } +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum PushPhaseFfi { + Preparing, + Transferring, + Finalizing, + Completed, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct PushProgressFfi { + pub phase: PushPhaseFfi, + pub files_total: u64, + pub files_completed: u64, + pub bytes_total: u64, + pub bytes_completed: u64, + pub current_path: Option, +} + +/// Pollable control-plane state for one transfer. File contents never cross this object: Rust +/// updates only bounded counters/a relative path, while Swift may poll and request cancellation +/// from another thread. A control is deliberately single-use so concurrent pushes cannot merge +/// progress or cancellation authority. +#[derive(uniffi::Object)] +pub struct PushControl { + claimed: AtomicBool, + cancelled: AtomicBool, + progress: Mutex, +} + +#[uniffi::export] +pub fn new_push_control() -> Arc { + Arc::new(PushControl { + claimed: AtomicBool::new(false), + cancelled: AtomicBool::new(false), + progress: Mutex::new(PushProgress::default()), + }) +} + +#[uniffi::export] +impl PushControl { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + pub fn progress(&self) -> PushProgressFfi { + let progress = self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + progress.into() + } +} + +impl PushControl { + pub(crate) fn claim(&self) -> Result<(), RemoteFfiError> { + self.claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| RemoteFfiError::Failed { + message: "file transfer control was already used".into(), + }) + } +} + +impl PushObserver for PushControl { + fn update(&self, progress: &PushProgress) { + *self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = progress.clone(); + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +impl From for PushProgressFfi { + fn from(progress: PushProgress) -> Self { + Self { + phase: match progress.phase { + PushPhase::Preparing => PushPhaseFfi::Preparing, + PushPhase::Transferring => PushPhaseFfi::Transferring, + PushPhase::Finalizing => PushPhaseFfi::Finalizing, + PushPhase::Completed => PushPhaseFfi::Completed, + PushPhase::Cancelled => PushPhaseFfi::Cancelled, + PushPhase::Failed => PushPhaseFfi::Failed, + }, + files_total: progress.files_total, + files_completed: progress.files_completed, + bytes_total: progress.bytes_total, + bytes_completed: progress.bytes_completed, + current_path: progress.current_path, + } + } +} + #[derive(uniffi::Record)] pub struct TelemetryFfi { pub mem_total_kb: u64, @@ -261,6 +362,28 @@ impl RemoteAgent { files_deleted: stats.files_deleted, }) } + + pub fn push_controlled( + &self, + local_root: String, + remote_root: String, + control: Arc, + ) -> Result { + control.claim()?; + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let stats = runtime.block_on(push_observed( + std::path::Path::new(&local_root), + &remote_root, + &self.agent.client, + control.as_ref(), + ))?; + Ok(PushStatsFfi { + files_sent: stats.files_sent, + bytes_sent: stats.bytes_sent, + files_deleted: stats.files_deleted, + }) + } } pub(crate) fn exec_result(out: dory_pb::agent::ExecResponse) -> ExecResultFfi { From ec428a86fa431a9e1cfaa7ef4f8a4ae0535be7cb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:14:04 +0000 Subject: [PATCH 085/338] feat(sync): expose push control in Swift --- .../Sources/DoryCore/DoryCore.swift | 144 ++++++++++++++++++ .../Tests/DoryCoreTests/DoryCoreTests.swift | 54 +++++++ 2 files changed, 198 insertions(+) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index f5752adb..faee91a8 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -284,6 +284,112 @@ public struct DoryPushStats: Sendable, Equatable { } } +public enum DoryPushPhase: String, Sendable, Equatable, Hashable { + case preparing + case transferring + case finalizing + case completed + case cancelled + case failed + + public var isTerminal: Bool { + switch self { + case .completed, .cancelled, .failed: + true + case .preparing, .transferring, .finalizing: + false + } + } +} + +public struct DoryPushProgress: Sendable, Equatable, Hashable { + public var phase: DoryPushPhase + public var filesTotal: UInt64 + public var filesCompleted: UInt64 + public var bytesTotal: UInt64 + public var bytesCompleted: UInt64 + public var currentPath: String? + + public init( + phase: DoryPushPhase, + filesTotal: UInt64, + filesCompleted: UInt64, + bytesTotal: UInt64, + bytesCompleted: UInt64, + currentPath: String? + ) { + self.phase = phase + self.filesTotal = filesTotal + self.filesCompleted = filesCompleted + self.bytesTotal = bytesTotal + self.bytesCompleted = bytesCompleted + self.currentPath = currentPath + } + + /// A bounded best-effort fraction for display. Byte progress wins when content has a + /// measurable size; zero-byte transfers fall back to file progress. + public var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } + + fileprivate init(_ raw: PushProgressFfi) { + self.init( + phase: DoryPushPhase(raw.phase), + filesTotal: raw.filesTotal, + filesCompleted: raw.filesCompleted, + bytesTotal: raw.bytesTotal, + bytesCompleted: raw.bytesCompleted, + currentPath: raw.currentPath + ) + } +} + +/// Single-use control for one push. The same instance may be polled or cancelled from a thread +/// other than the thread executing the push. +public final class DoryPushControl: @unchecked Sendable { + fileprivate let raw: PushControl + + public init() { + raw = newPushControl() + } + + public func cancel() { + raw.cancel() + } + + public func progress() -> DoryPushProgress { + DoryPushProgress(raw.progress()) + } +} + +private extension DoryPushPhase { + init(_ raw: PushPhaseFfi) { + switch raw { + case .preparing: + self = .preparing + case .transferring: + self = .transferring + case .finalizing: + self = .finalizing + case .completed: + self = .completed + case .cancelled: + self = .cancelled + case .failed: + self = .failed + } + } +} + public enum DoryRemoteHostKey: Sendable, Equatable, Hashable { case pinned(opensshPublicKey: String) case knownHosts(path: String, host: String, port: UInt16) @@ -408,6 +514,25 @@ public final class DoryAgentControlHandle: @unchecked Sendable { ) } + public func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + let raw = try withControl { + try $0.pushControlled( + localRoot: localRoot, + remoteRoot: remoteRoot, + control: control.raw + ) + } + return DoryPushStats( + filesSent: raw.filesSent, + bytesSent: raw.bytesSent, + filesDeleted: raw.filesDeleted + ) + } + public func snapshotFreeze(receiptID: String) throws -> String { try withControl { try $0.snapshotFreeze(receiptId: receiptID) } } @@ -522,6 +647,25 @@ public final class DoryRemoteAgentHandle: @unchecked Sendable { ) } + public func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + let raw = try withRemote { + try $0.pushControlled( + localRoot: localRoot, + remoteRoot: remoteRoot, + control: control.raw + ) + } + return DoryPushStats( + filesSent: raw.filesSent, + bytesSent: raw.bytesSent, + filesDeleted: raw.filesDeleted + ) + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift index 89a1daaa..a55a9192 100644 --- a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift @@ -10,4 +10,58 @@ final class DoryCoreTests: XCTestCase { func testConnectAgentControlOverFDRejectsInvalidFD() { XCTAssertThrowsError(try DoryCore.connectAgentControlOverFD(-1)) } + + func testPushControlStartsWithBoundedPreparingProgress() { + let control = DoryPushControl() + + XCTAssertEqual( + control.progress(), + DoryPushProgress( + phase: .preparing, + filesTotal: 0, + filesCompleted: 0, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ) + ) + XCTAssertFalse(control.progress().phase.isTerminal) + XCTAssertEqual(control.progress().fractionCompleted, 0) + } + + func testPushProgressFractionPrefersBytesAndIsBounded() { + XCTAssertEqual( + DoryPushProgress( + phase: .transferring, + filesTotal: 4, + filesCompleted: 3, + bytesTotal: 100, + bytesCompleted: 25, + currentPath: "src/main.swift" + ).fractionCompleted, + 0.25 + ) + XCTAssertEqual( + DoryPushProgress( + phase: .transferring, + filesTotal: 4, + filesCompleted: 5, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ).fractionCompleted, + 1 + ) + XCTAssertEqual( + DoryPushProgress( + phase: .completed, + filesTotal: 0, + filesCompleted: 0, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ).fractionCompleted, + 1 + ) + } } From c6163413b6d77496424d9f0d40696a64216de099 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:24:02 +0000 Subject: [PATCH 086/338] feat(vm): manage cancellable file transfers --- .../Sources/DorydKit/AgentControl.swift | 28 +++ .../DorydKit/DoryMachineFileTransfer.swift | 224 ++++++++++++++++++ .../Sources/DorydKit/MachineManager.swift | 217 ++++++++++++++++- .../DorydKitTests/AgentControlTests.swift | 28 +++ .../MachineManagerFileTransferTests.swift | 190 ++++++++++++++- 5 files changed, 677 insertions(+), 10 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index d7c5ee5b..b4600807 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -26,6 +26,11 @@ public protocol AgentControlClient: Sendable { func portsWatch() throws -> DoryPortsSnapshot func telemetry() throws -> DoryTelemetry func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats func snapshotFreeze(receiptID: String) throws -> String func snapshotThaw(receiptID: String) throws func exec( @@ -58,6 +63,17 @@ public extension AgentControlClient { throw AgentControlError.capabilityUnavailable("sync-push") } + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + _ = localRoot + _ = remoteRoot + _ = control + throw AgentControlError.capabilityUnavailable("sync-push-control") + } + func snapshotThaw(receiptID: String) throws { _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") @@ -145,6 +161,18 @@ public final class AgentControl: @unchecked Sendable { ) } + public func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + try client(requiring: "sync-push").push( + localRoot: localRoot, + remoteRoot: remoteRoot, + control: control + ) + } + public func snapshotFreeze(receiptID: String) throws -> String { try client(requiring: "snapshot-quiesce", minimumVersion: 2) .snapshotFreeze(receiptID: receiptID) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift index 17d413a0..0daf51a9 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift @@ -22,8 +22,101 @@ public struct DoryMachineFileTransferResult: Sendable, Equatable { } } +public enum DoryMachineFileTransferPhase: String, Sendable, Equatable, Hashable { + case preparing + case transferring + case finalizing + case cancelling + case completed + case cancelled + case failed + + public var isTerminal: Bool { + switch self { + case .completed, .cancelled, .failed: + true + case .preparing, .transferring, .finalizing, .cancelling: + false + } + } +} + +public enum DoryMachineFileTransferFailureCode: String, Sendable, Equatable, Hashable { + case guestUnavailable = "guest-unavailable" + case guestPreparationFailed = "guest-preparation-failed" + case transferFailed = "transfer-failed" + case guestFinalizationFailed = "guest-finalization-failed" +} + +public struct DoryMachineFileTransferFailure: Sendable, Equatable, Hashable { + public var code: DoryMachineFileTransferFailureCode + public var message: String + + public init(code: DoryMachineFileTransferFailureCode, message: String) { + self.code = code + self.message = message + } +} + +/// A path-safe snapshot of one daemon-owned transfer operation. Host staging paths are never +/// included. Counters are absolute and may be polled at any cadence. +public struct DoryMachineFileTransferOperationStatus: Sendable, Equatable { + public var operationID: String + public var machineID: String + public var phase: DoryMachineFileTransferPhase + public var filesTotal: UInt64 + public var filesCompleted: UInt64 + public var bytesTotal: UInt64 + public var bytesCompleted: UInt64 + public var currentPath: String? + public var guestDestination: String? + public var result: DoryMachineFileTransferResult? + public var failure: DoryMachineFileTransferFailure? + + public init( + operationID: String, + machineID: String, + phase: DoryMachineFileTransferPhase, + filesTotal: UInt64, + filesCompleted: UInt64, + bytesTotal: UInt64, + bytesCompleted: UInt64, + currentPath: String?, + guestDestination: String?, + result: DoryMachineFileTransferResult?, + failure: DoryMachineFileTransferFailure? + ) { + self.operationID = operationID + self.machineID = machineID + self.phase = phase + self.filesTotal = filesTotal + self.filesCompleted = filesCompleted + self.bytesTotal = bytesTotal + self.bytesCompleted = bytesCompleted + self.currentPath = currentPath + self.guestDestination = guestDestination + self.result = result + self.failure = failure + } + + public var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStringConvertible { case invalidPrivateStagingRoot + case transferAlreadyInProgress(String) + case unknownTransfer(String, String) case guestAccountUnavailable(String) case guestPreparationFailed(String) case transferFailed(String) @@ -33,6 +126,10 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri switch self { case .invalidPrivateStagingRoot: "file transfer staging root is invalid or not private" + case let .transferAlreadyInProgress(machineID): + "a file transfer is already in progress for machine: \(machineID)" + case let .unknownTransfer(machineID, operationID): + "unknown file transfer \(operationID) for machine: \(machineID)" case let .guestAccountUnavailable(machineID): "managed guest account is unavailable for machine: \(machineID)" case let .guestPreparationFailed(machineID): @@ -55,3 +152,130 @@ extension DoryMachineFileTransferResult { ) } } + +final class MachineFileTransferOperation: @unchecked Sendable { + let operationID: String + let machineID: String + let control = DoryPushControl() + + private let lock = NSLock() + private var phase: DoryMachineFileTransferPhase = .preparing + private var cancellationRequested = false + private var guestDestination: String? + private var result: DoryMachineFileTransferResult? + private var failure: DoryMachineFileTransferFailure? + private var finishedAt: Date? + + init(operationID: String, machineID: String) { + self.operationID = operationID + self.machineID = machineID + } + + var isCancellationRequested: Bool { + lock.lock() + defer { lock.unlock() } + return cancellationRequested + } + + var isTerminal: Bool { + lock.lock() + defer { lock.unlock() } + return phase.isTerminal + } + + var terminalDate: Date? { + lock.lock() + defer { lock.unlock() } + return finishedAt + } + + func requestCancellation() { + lock.lock() + if !phase.isTerminal { + cancellationRequested = true + phase = .cancelling + } + lock.unlock() + control.cancel() + } + + func setGuestDestination(_ guestDestination: String) { + lock.lock() + if !phase.isTerminal { + self.guestDestination = guestDestination + } + lock.unlock() + } + + func setTransferring() { + lock.lock() + if !phase.isTerminal { + phase = cancellationRequested ? .cancelling : .transferring + } + lock.unlock() + } + + func setFinalizing() { + lock.lock() + if !phase.isTerminal { + phase = cancellationRequested ? .cancelling : .finalizing + } + lock.unlock() + } + + func complete(_ result: DoryMachineFileTransferResult) { + lock.lock() + self.result = result + guestDestination = result.guestDestination + phase = .completed + finishedAt = Date() + lock.unlock() + } + + func cancel() { + lock.lock() + cancellationRequested = true + phase = .cancelled + finishedAt = Date() + lock.unlock() + } + + func fail(_ failure: DoryMachineFileTransferFailure) { + lock.lock() + self.failure = failure + phase = .failed + finishedAt = Date() + lock.unlock() + } + + func status() -> DoryMachineFileTransferOperationStatus { + let pushProgress = control.progress() + lock.lock() + defer { lock.unlock() } + let filesCompleted = result?.filesSent ?? pushProgress.filesCompleted + let bytesCompleted = result?.bytesSent ?? pushProgress.bytesCompleted + return DoryMachineFileTransferOperationStatus( + operationID: operationID, + machineID: machineID, + phase: phase, + filesTotal: max(pushProgress.filesTotal, result?.filesSent ?? 0), + filesCompleted: filesCompleted, + bytesTotal: max(pushProgress.bytesTotal, result?.bytesSent ?? 0), + bytesCompleted: bytesCompleted, + currentPath: phase.isTerminal ? nil : pushProgress.currentPath, + guestDestination: guestDestination, + result: result, + failure: failure + ) + } +} + +enum DoryMachineFileTransferCancellation: Error { + case cancelled +} + +enum MachineFileTransferTerminalOutcome { + case completed(DoryMachineFileTransferResult) + case cancelled + case failed(DoryMachineFileTransferFailure) +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 81b822fe..d373f772 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -834,7 +834,15 @@ public final class MachineManager: @unchecked Sendable { private let lifecycleJournalInitializationError: String? private let operationLock = NSRecursiveLock() private let lock = NSLock() + private let fileTransferLock = NSLock() + private let fileTransferQueue = DispatchQueue( + label: "dev.dory.machine-file-transfer", + qos: .utility, + attributes: .concurrent + ) private var machines: [String: MachineEntry] = [:] + private var fileTransferOperations: [String: MachineFileTransferOperation] = [:] + private var activeFileTransferByMachine: [String: String] = [:] private var deletingMachineIDs: Set = [] private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:] private var resolvedLaunchRegistry: BackendRegistry? @@ -5013,6 +5021,142 @@ public final class MachineManager: @unchecked Sendable { public func transferStagedFiles( id: String, privateStagingRoot: String + ) throws -> DoryMachineFileTransferResult { + guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + let transferID = Self.makeMachineFileTransferID() + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + if activeFileTransferByMachine[id] != nil { + fileTransferLock.unlock() + throw DoryMachineFileTransferError.transferAlreadyInProgress(id) + } + activeFileTransferByMachine[id] = transferID + fileTransferLock.unlock() + defer { + fileTransferLock.lock() + if activeFileTransferByMachine[id] == transferID { + activeFileTransferByMachine.removeValue(forKey: id) + } + fileTransferLock.unlock() + } + return try performStagedFileTransfer( + id: id, + privateStagingRoot: privateStagingRoot, + transferID: transferID, + operation: nil + ) + } + + /// Starts a cancellable transfer without retaining an XPC reply block for the duration of the + /// data-plane operation. Only one active transfer per machine is allowed; terminal records are + /// retained briefly for polling and bounded globally. + public func beginStagedFileTransfer( + id: String, + privateStagingRoot: String + ) throws -> DoryMachineFileTransferOperationStatus { + guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + guard let machineStatus = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + guard machineStatus.state == .running, + machineStatus.agentSocketPath != nil else { + throw MachineManagerError.agentUnavailable(id) + } + for capability in ["exec", "sync-push"] where + !machineStatus.supportsAgentCapability(capability) { + throw MachineManagerError.agentCapabilityUnavailable(id, capability) + } + + let transferID = Self.makeMachineFileTransferID() + let operation = MachineFileTransferOperation( + operationID: transferID, + machineID: id + ) + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + if activeFileTransferByMachine[id] != nil { + fileTransferLock.unlock() + throw DoryMachineFileTransferError.transferAlreadyInProgress(id) + } + fileTransferOperations[transferID] = operation + activeFileTransferByMachine[id] = transferID + fileTransferLock.unlock() + + fileTransferQueue.async { [self, operation] in + let outcome: MachineFileTransferTerminalOutcome + do { + let result = try performStagedFileTransfer( + id: id, + privateStagingRoot: privateStagingRoot, + transferID: transferID, + operation: operation + ) + if operation.isCancellationRequested { + outcome = .cancelled + } else { + outcome = .completed(result) + } + } catch { + if operation.isCancellationRequested { + outcome = .cancelled + } else { + outcome = .failed(Self.fileTransferFailure(error, machineID: id)) + } + } + fileTransferLock.lock() + switch outcome { + case let .completed(result): + operation.complete(result) + case .cancelled: + operation.cancel() + case let .failed(failure): + operation.fail(failure) + } + if activeFileTransferByMachine[id] == transferID { + activeFileTransferByMachine.removeValue(forKey: id) + } + fileTransferLock.unlock() + } + return operation.status() + } + + public func stagedFileTransferStatus( + id: String, + operationID: String + ) throws -> DoryMachineFileTransferOperationStatus { + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + let operation = fileTransferOperations[operationID] + fileTransferLock.unlock() + guard let operation, operation.machineID == id else { + throw DoryMachineFileTransferError.unknownTransfer(id, operationID) + } + return operation.status() + } + + public func cancelStagedFileTransfer( + id: String, + operationID: String + ) throws -> DoryMachineFileTransferOperationStatus { + fileTransferLock.lock() + let operation = fileTransferOperations[operationID] + fileTransferLock.unlock() + guard let operation, operation.machineID == id else { + throw DoryMachineFileTransferError.unknownTransfer(id, operationID) + } + operation.requestCancellation() + return operation.status() + } + + private func performStagedFileTransfer( + id: String, + privateStagingRoot: String, + transferID: String, + operation: MachineFileTransferOperation? ) throws -> DoryMachineFileTransferResult { guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { throw DoryMachineFileTransferError.invalidPrivateStagingRoot @@ -5036,14 +5180,14 @@ public final class MachineManager: @unchecked Sendable { throw DoryMachineFileTransferError.guestAccountUnavailable(id) } - let transferID = UUID().uuidString - .replacingOccurrences(of: "-", with: "") - .lowercased() let guestHome = "/home/\(username)" let downloads = guestHome + "/Downloads" let guestDestination = downloads + "/Dory Transfer " + transferID + operation?.setGuestDestination(guestDestination) + try Self.requireTransferNotCancelled(operation) return try withAgentClient(id: id) { client in + try Self.requireTransferNotCancelled(operation) let uid = try Self.guestNumericIdentity( client: client, program: "/usr/bin/id", @@ -5065,6 +5209,7 @@ public final class MachineManager: @unchecked Sendable { DoryExecEnvironment(key: "DORY_AGENT_RUN_UID", value: String(uid)), DoryExecEnvironment(key: "DORY_AGENT_RUN_GID", value: String(gid)), ] + try Self.requireTransferNotCancelled(operation) try Self.requireSuccessfulTransferCommand( client.exec( argv: ["/bin/mkdir", "-p", "--", downloads], @@ -5075,6 +5220,7 @@ public final class MachineManager: @unchecked Sendable { ), error: .guestPreparationFailed(id) ) + try Self.requireTransferNotCancelled(operation) // No `-p`: a collision or pre-existing attacker-controlled directory must fail rather // than becoming the exact-replica target. try Self.requireSuccessfulTransferCommand( @@ -5102,16 +5248,27 @@ public final class MachineManager: @unchecked Sendable { } let stats: DoryPushStats do { - stats = try client.push( - localRoot: privateStagingRoot, - remoteRoot: guestDestination - ) + if let operation { + operation.setTransferring() + stats = try client.push( + localRoot: privateStagingRoot, + remoteRoot: guestDestination, + control: operation.control + ) + } else { + stats = try client.push( + localRoot: privateStagingRoot, + remoteRoot: guestDestination + ) + } } catch { throw DoryMachineFileTransferError.transferFailed(id) } + try Self.requireTransferNotCancelled(operation) guard stats.filesDeleted == 0 else { throw DoryMachineFileTransferError.transferFailed(id) } + operation?.setFinalizing() try Self.requireSuccessfulTransferCommand( client.exec( argv: [ @@ -5124,6 +5281,7 @@ public final class MachineManager: @unchecked Sendable { ), error: .guestFinalizationFailed(id) ) + try Self.requireTransferNotCancelled(operation) completed = true return DoryMachineFileTransferResult( transferID: transferID, @@ -5133,6 +5291,51 @@ public final class MachineManager: @unchecked Sendable { } } + private static func makeMachineFileTransferID() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + + private static func requireTransferNotCancelled( + _ operation: MachineFileTransferOperation? + ) throws { + if operation?.isCancellationRequested == true { + throw DoryMachineFileTransferCancellation.cancelled + } + } + + private static func fileTransferFailure( + _ error: Error, + machineID: String + ) -> DoryMachineFileTransferFailure { + let transferError = error as? DoryMachineFileTransferError + switch transferError { + case .guestAccountUnavailable: + return .init(code: .guestUnavailable, message: "Guest account is unavailable.") + case .guestPreparationFailed: + return .init(code: .guestPreparationFailed, message: "Could not prepare the guest destination.") + case .guestFinalizationFailed: + return .init(code: .guestFinalizationFailed, message: "Could not finalize guest file ownership.") + case .invalidPrivateStagingRoot, .transferAlreadyInProgress, .unknownTransfer, + .transferFailed, nil: + return .init(code: .transferFailed, message: "File transfer failed for \(machineID).") + } + } + + private func pruneFileTransferOperationsLocked(now: Date) { + let expiration = now.addingTimeInterval(-60 * 60) + fileTransferOperations = fileTransferOperations.filter { _, operation in + guard let finishedAt = operation.terminalDate else { return true } + return finishedAt >= expiration + } + if fileTransferOperations.count <= 128 { return } + let removable = fileTransferOperations + .filter { $0.value.isTerminal } + .sorted { ($0.value.terminalDate ?? .distantFuture) < ($1.value.terminalDate ?? .distantFuture) } + for (operationID, _) in removable.prefix(fileTransferOperations.count - 128) { + fileTransferOperations.removeValue(forKey: operationID) + } + } + private static func guestNumericIdentity( client: any AgentControlClient, program: String, diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index 14e02519..3519bfc4 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -31,6 +31,15 @@ final class AgentControlTests: XCTestCase { try control.push(localRoot: "/tmp/local", remoteRoot: "/tmp/remote"), DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) ) + XCTAssertEqual( + try control.push( + localRoot: "/tmp/local", + remoteRoot: "/tmp/remote", + control: DoryPushControl() + ), + DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) + ) + XCTAssertEqual(fake.controlledPushes, 1) let receiptID = String(repeating: "a", count: 32) XCTAssertEqual(try control.snapshotFreeze(receiptID: receiptID), receiptID) try control.snapshotThaw(receiptID: receiptID) @@ -118,6 +127,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda private var inputs: [Int64] = [] private var closes = 0 private var telemetryCallCount = 0 + private var controlledPushCallCount = 0 private var freezeReceipts: [String] = [] private var thawReceipts: [String] = [] @@ -155,6 +165,12 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return telemetryCallCount } + var controlledPushes: Int { + lock.lock() + defer { lock.unlock() } + return controlledPushCallCount + } + var snapshotFreezeReceipts: [String] { lock.lock() defer { lock.unlock() } @@ -217,6 +233,18 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) } + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + _ = control + lock.lock() + controlledPushCallCount += 1 + lock.unlock() + return try push(localRoot: localRoot, remoteRoot: remoteRoot) + } + func snapshotThaw(receiptID: String) throws { lock.lock() thawReceipts.append(receiptID) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift index 46c15d70..5bee3f2f 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -89,9 +89,144 @@ final class MachineManagerFileTransferTests: XCTestCase { XCTAssertTrue(fixture.agent.pushes.isEmpty) } + func testAsynchronousTransferPublishesTerminalResult() throws { + let fixture = try makeRunningFixture(tag: "async-success") + defer { fixture.cleanup() } + + let started = try fixture.manager.beginStagedFileTransfer( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + ) + XCTAssertEqual(started.machineID, "desktop") + XCTAssertEqual(started.operationID.utf8.count, 32) + + let completed = try waitForTransfer( + manager: fixture.manager, + machineID: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(completed.phase, .completed) + XCTAssertEqual(completed.result?.transferID, started.operationID) + XCTAssertEqual(completed.result?.filesSent, 2) + XCTAssertEqual(completed.result?.bytesSent, 12) + XCTAssertEqual(completed.filesCompleted, 2) + XCTAssertEqual(completed.bytesCompleted, 12) + XCTAssertEqual(completed.fractionCompleted, 1) + XCTAssertNil(completed.currentPath) + XCTAssertEqual(fixture.agent.controlledPushCount, 1) + XCTAssertThrowsError(try fixture.manager.stagedFileTransferStatus( + id: "another-machine", + operationID: started.operationID + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .unknownTransfer("another-machine", started.operationID) + ) + } + } + + func testAsynchronousTransferCancellationCleansDestinationAndFencesSecondTransfer() throws { + let fixture = try makeRunningFixture( + tag: "async-cancel", + blockControlledPush: true + ) + defer { + fixture.agent.releaseControlledPush() + fixture.cleanup() + } + + let started = try fixture.manager.beginStagedFileTransfer( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + ) + XCTAssertTrue(fixture.agent.waitForControlledPush()) + XCTAssertThrowsError(try fixture.manager.beginStagedFileTransfer( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .transferAlreadyInProgress("desktop") + ) + } + XCTAssertThrowsError(try fixture.manager.transferStagedFiles( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .transferAlreadyInProgress("desktop") + ) + } + + let cancelling = try fixture.manager.cancelStagedFileTransfer( + id: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(cancelling.phase, .cancelling) + fixture.agent.releaseControlledPush() + + let cancelled = try waitForTransfer( + manager: fixture.manager, + machineID: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(cancelled.phase, .cancelled) + XCTAssertNil(cancelled.result) + XCTAssertNil(cancelled.failure) + let destination = try XCTUnwrap(cancelled.guestDestination) + XCTAssertTrue(fixture.agent.execs.contains { + $0.argv == ["/bin/rm", "-rf", "--", destination] + }) + XCTAssertFalse(fixture.agent.execs.contains { $0.argv.first == "/bin/chown" }) + } + + func testAsynchronousTransferFailureUsesStableSafeEvidence() throws { + let fixture = try makeRunningFixture(tag: "async-failure", failPush: true) + defer { fixture.cleanup() } + + let started = try fixture.manager.beginStagedFileTransfer( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + ) + let failed = try waitForTransfer( + manager: fixture.manager, + machineID: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(failed.phase, .failed) + XCTAssertEqual(failed.failure?.code, .transferFailed) + XCTAssertEqual(failed.failure?.message, "File transfer failed for desktop.") + XCTAssertNil(failed.result) + XCTAssertFalse(failed.failure?.message.contains(fixture.stagingRoot) == true) + } + + private func waitForTransfer( + manager: MachineManager, + machineID: String, + operationID: String + ) throws -> DoryMachineFileTransferOperationStatus { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let status = try manager.stagedFileTransferStatus( + id: machineID, + operationID: operationID + ) + if status.phase.isTerminal { + return status + } + Thread.sleep(forTimeInterval: 0.01) + } + return try manager.stagedFileTransferStatus( + id: machineID, + operationID: operationID + ) + } + private func makeRunningFixture( tag: String, - failPush: Bool = false + failPush: Bool = false, + blockControlledPush: Bool = false ) throws -> TransferFixture { let suffix = "\(getpid())-\(UInt32.random(in: 0.. Bool { + controlledPushStarted.wait(timeout: .now() + 2) == .success + } + + func releaseControlledPush() { + controlledPushRelease.signal() + } + func connect(socketPath: String) throws -> any AgentControlClient { _ = socketPath return TransferAgentClient(owner: self) @@ -234,6 +391,22 @@ private final class TransferAgentRecorder: @unchecked Sendable { } return DoryPushStats(filesSent: 2, bytesSent: 12, filesDeleted: 0) } + + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + _ = control + lock.lock() + recordedControlledPushCount += 1 + lock.unlock() + controlledPushStarted.signal() + if blockControlledPush { + _ = controlledPushRelease.wait(timeout: .now() + 2) + } + return try push(localRoot: localRoot, remoteRoot: remoteRoot) + } } private final class TransferAgentClient: AgentControlClient, @unchecked Sendable { @@ -260,6 +433,17 @@ private final class TransferAgentClient: AgentControlClient, @unchecked Sendable func push(localRoot: String, remoteRoot: String) throws -> DoryPushStats { try owner.push(localRoot: localRoot, remoteRoot: remoteRoot) } + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + try owner.push( + localRoot: localRoot, + remoteRoot: remoteRoot, + control: control + ) + } func exec( argv: [String], cwd: String, From 1f0c715d066373090d8da3784c4bce8887b30a35 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:26:39 +0000 Subject: [PATCH 087/338] feat(vm): expose transfer operations over xpc --- .../Sources/DorydKit/DorydControl.swift | 3 + .../Sources/DorydKit/DorydService.swift | 131 +++++++++++++++++- .../DorydKitTests/DorydServiceTests.swift | 91 ++++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index aceb5d18..119c8521 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -26,6 +26,9 @@ import Foundation func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 17e7a904..c9e97b1b 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -455,6 +455,90 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineTransferStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let parsedRequest = try MachineTransferRequest( + xpcDictionary: request, + expectedSchema: 2 + ) + let status = try machineManager.beginStagedFileTransfer( + id: machineID, + privateStagingRoot: parsedRequest.privateStagingRoot + ) + incidentWriter?.record( + type: "machine.file_transfer_started", + detail: "\(machineID) \(status.operationID)" + ) + reply(true, status.xpcDictionary, "") + } catch { + incidentWriter?.record( + type: "machine.file_transfer_start_failed", + detail: "\(machineID): \(error)" + ) + reply(false, [:], String(describing: error)) + } + } + + public func machineTransferStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + guard MachineTransferRequest.isValidOperationID(operationID) else { + reply(false, [:], "invalid machine transfer operation identifier") + return + } + do { + let status = try machineManager.stagedFileTransferStatus( + id: machineID, + operationID: operationID + ) + reply(true, status.xpcDictionary, "") + } catch { + reply(false, [:], String(describing: error)) + } + } + + public func machineTransferCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + guard MachineTransferRequest.isValidOperationID(operationID) else { + reply(false, [:], "invalid machine transfer operation identifier") + return + } + do { + let status = try machineManager.cancelStagedFileTransfer( + id: machineID, + operationID: operationID + ) + incidentWriter?.record( + type: "machine.file_transfer_cancel", + detail: "\(machineID) \(operationID)" + ) + reply(true, status.xpcDictionary, "") + } catch { + reply(false, [:], String(describing: error)) + } + } + public func machineProvision( _ machineID: String, request: NSDictionary, @@ -1272,13 +1356,16 @@ private struct MachineExecRequest { private struct MachineTransferRequest: Sendable { var privateStagingRoot: String - init(xpcDictionary dictionary: NSDictionary) throws { + init( + xpcDictionary dictionary: NSDictionary, + expectedSchema: UInt16 = 1 + ) throws { guard let keys = dictionary.allKeys as? [String], Set(keys) == ["schema", "privateStagingRoot"], let schema = dictionary["schema"] as? NSNumber, CFGetTypeID(schema) != CFBooleanGetTypeID(), - schema.uint16Value == 1, - schema.doubleValue == 1, + schema.uint16Value == expectedSchema, + schema.doubleValue == Double(expectedSchema), let root = dictionary["privateStagingRoot"] as? String, root.hasPrefix("/"), !root.contains("\0") else { @@ -1286,6 +1373,12 @@ private struct MachineTransferRequest: Sendable { } privateStagingRoot = root } + + static func isValidOperationID(_ value: String) -> Bool { + value.utf8.count == 32 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } } private struct MachineProvisionRequest { @@ -1437,6 +1530,38 @@ private extension DoryMachineFileTransferResult { } } +private extension DoryMachineFileTransferOperationStatus { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase.rawValue, + "filesTotal": filesTotal, + "filesCompleted": filesCompleted, + "bytesTotal": bytesTotal, + "bytesCompleted": bytesCompleted, + ] + if let currentPath { + dictionary["currentPath"] = currentPath + } + if let guestDestination { + dictionary["guestDestination"] = guestDestination + } + if let result { + dictionary["result"] = result.xpcDictionary + } + if let failure { + dictionary["failure"] = [ + "schema": UInt16(1), + "code": failure.code.rawValue, + "message": failure.message, + ] as NSDictionary + } + return dictionary as NSDictionary + } +} + private extension DomainRoute { init(xpcDictionary dictionary: NSDictionary) throws { guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 9ae2991f..1f873954 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1790,6 +1790,88 @@ final class DorydServiceTests: XCTestCase { transfer.fulfill() } wait(for: [transfer], timeout: 5) + + let malformedStart = expectation(description: "malformed machineTransferStart rejected") + proxy.machineTransferStart("dev", request: [ + "schema": UInt16(1), + "privateStagingRoot": staging, + ]) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(body.isEqual(to: [:])) + XCTAssertTrue(message.contains("machineTransfer"), message) + malformedStart.fulfill() + } + wait(for: [malformedStart], timeout: 5) + + let asyncStart = expectation(description: "machineTransferStart reply") + var operationID = "" + proxy.machineTransferStart("dev", request: [ + "schema": UInt16(2), + "privateStagingRoot": staging, + ]) { ok, body, message in + XCTAssertTrue(ok, message) + operationID = body["operationID"] as? String ?? "" + XCTAssertEqual(operationID.utf8.count, 32) + XCTAssertEqual(body["machineID"] as? String, "dev") + XCTAssertEqual((body["schema"] as? NSNumber)?.uint16Value, 1) + XCTAssertFalse(body.description.contains(staging)) + asyncStart.fulfill() + } + wait(for: [asyncStart], timeout: 5) + + var terminalBody: NSDictionary? + let deadline = Date().addingTimeInterval(5) + while terminalBody == nil, Date() < deadline { + let statusReply = expectation(description: "machineTransferStatus reply") + proxy.machineTransferStatus("dev", operationID: operationID) { ok, body, message in + XCTAssertTrue(ok, message) + if let phase = body["phase"] as? String, + ["completed", "cancelled", "failed"].contains(phase) { + terminalBody = body + } + statusReply.fulfill() + } + wait(for: [statusReply], timeout: 5) + if terminalBody == nil { + Thread.sleep(forTimeInterval: 0.01) + } + } + let completedBody = try XCTUnwrap(terminalBody) + XCTAssertEqual(completedBody["phase"] as? String, "completed") + XCTAssertEqual( + Set(completedBody.allKeys.compactMap { $0 as? String }), + [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", "guestDestination", + "result", + ] + ) + XCTAssertEqual(completedBody["filesTotal"] as? UInt64, 2) + XCTAssertEqual(completedBody["filesCompleted"] as? UInt64, 2) + XCTAssertEqual(completedBody["bytesTotal"] as? UInt64, 7) + XCTAssertEqual(completedBody["bytesCompleted"] as? UInt64, 7) + let asyncResult = try XCTUnwrap(completedBody["result"] as? NSDictionary) + XCTAssertEqual(asyncResult["transferID"] as? String, operationID) + XCTAssertEqual(asyncResult["filesSent"] as? UInt64, 2) + XCTAssertEqual(asyncResult["bytesSent"] as? UInt64, 7) + XCTAssertFalse(completedBody.description.contains(staging)) + + let cancelCompleted = expectation(description: "machineTransferCancel terminal reply") + proxy.machineTransferCancel("dev", operationID: operationID) { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["phase"] as? String, "completed") + cancelCompleted.fulfill() + } + wait(for: [cancelCompleted], timeout: 5) + + let invalidStatus = expectation(description: "invalid transfer status rejected") + proxy.machineTransferStatus("dev", operationID: "NOT-AN-OPERATION") { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(body.isEqual(to: [:])) + XCTAssertEqual(message, "invalid machine transfer operation identifier") + invalidStatus.fulfill() + } + wait(for: [invalidStatus], timeout: 5) } func testMachineProvisionOverXPCInstallsRecipeThroughMachineAgent() throws { @@ -2117,6 +2199,15 @@ private final class ServiceFakeAgentControlClient: AgentControlClient, @unchecke return DoryPushStats(filesSent: 2, bytesSent: 7, filesDeleted: 0) } + func push( + localRoot: String, + remoteRoot: String, + control: DoryPushControl + ) throws -> DoryPushStats { + _ = control + return try push(localRoot: localRoot, remoteRoot: remoteRoot) + } + func exec( argv: [String], cwd: String, From 14a28d16377696a3f2c925d88cc85b94d37d7b8a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:31:56 +0000 Subject: [PATCH 088/338] feat(vm): decode transfer operations in app --- Dory/Runtime/Doryd/DorydClient.swift | 259 +++++++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 178 ++++++++++++++++++ 2 files changed, 437 insertions(+) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 70868ac4..fae19b39 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -26,6 +26,9 @@ nonisolated protocol DorydControlXPC { func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -991,6 +994,64 @@ nonisolated struct DorydMachineFileTransferResult: Sendable, Equatable { var bytesSent: UInt64 } +nonisolated enum DorydMachineFileTransferPhase: String, Sendable, Equatable { + case preparing + case transferring + case finalizing + case cancelling + case completed + case cancelled + case failed + + var isTerminal: Bool { + switch self { + case .completed, .cancelled, .failed: + true + case .preparing, .transferring, .finalizing, .cancelling: + false + } + } +} + +nonisolated enum DorydMachineFileTransferFailureCode: String, Sendable, Equatable { + case guestUnavailable = "guest-unavailable" + case guestPreparationFailed = "guest-preparation-failed" + case transferFailed = "transfer-failed" + case guestFinalizationFailed = "guest-finalization-failed" +} + +nonisolated struct DorydMachineFileTransferFailure: Sendable, Equatable { + var code: DorydMachineFileTransferFailureCode + var message: String +} + +nonisolated struct DorydMachineFileTransferOperation: Sendable, Equatable { + var operationID: String + var machineID: String + var phase: DorydMachineFileTransferPhase + var filesTotal: UInt64 + var filesCompleted: UInt64 + var bytesTotal: UInt64 + var bytesCompleted: UInt64 + var currentPath: String? + var guestDestination: String? + var result: DorydMachineFileTransferResult? + var failure: DorydMachineFileTransferFailure? + + var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + nonisolated struct DorydRemoteMachineStatus: Sendable, Equatable { var id: String var state: String @@ -1443,6 +1504,57 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineTransferStart( + _ machineID: String, + staged: DoryStagedMachineFileTransfer + ) async throws -> DorydMachineFileTransferOperation { + let request: NSDictionary = [ + "schema": UInt16(2), + "privateStagingRoot": staged.rootPath, + ] + return try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferStart(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID else { + return nil + } + return operation + } + } + + func machineTransferStatus( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineFileTransferOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferStatus(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation + } + } + + func machineTransferCancel( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineFileTransferOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferCancel(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation + } + } + func machineStats(_ machineID: String) async throws -> DorydMachineStats { try await withTimeout(atLeast: 10).statusCommand { proxy, reply in proxy.machineStats(machineID, reply: reply) @@ -2662,6 +2774,153 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineFileTransferOperation( + from dictionary: NSDictionary + ) -> DorydMachineFileTransferOperation? { + let requiredKeys: Set = [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", + ] + let optionalKeys: Set = [ + "currentPath", "guestDestination", "result", "failure", + ] + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys).isSuperset(of: requiredKeys), + Set(rawKeys).isSubset(of: requiredKeys.union(optionalKeys)), + strictUInt64(dictionary["schema"]) == 1, + let operationID = dictionary["operationID"] as? String, + isValidMachineTransferOperationID(operationID), + let machineID = dictionary["machineID"] as? String, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + let rawPhase = dictionary["phase"] as? String, + let phase = DorydMachineFileTransferPhase(rawValue: rawPhase), + let filesTotal = strictUInt64(dictionary["filesTotal"]), + let filesCompleted = strictUInt64(dictionary["filesCompleted"]), + filesCompleted <= filesTotal, + let bytesTotal = strictUInt64(dictionary["bytesTotal"]), + let bytesCompleted = strictUInt64(dictionary["bytesCompleted"]), + bytesCompleted <= bytesTotal else { + return nil + } + let currentPath: String? + if let rawCurrentPath = dictionary["currentPath"] { + guard let value = rawCurrentPath as? String, + isSafeMachineTransferRelativePath(value), + !phase.isTerminal else { + return nil + } + currentPath = value + } else { + currentPath = nil + } + let guestDestination: String? + if let rawGuestDestination = dictionary["guestDestination"] { + guard let value = rawGuestDestination as? String, + isValidMachineTransferDestination(value, transferID: operationID) else { + return nil + } + guestDestination = value + } else { + guestDestination = nil + } + let result: DorydMachineFileTransferResult? + if let rawResult = dictionary["result"] { + guard let row = rawResult as? NSDictionary, + let decoded = machineFileTransferResult(from: row), + decoded.transferID == operationID else { + return nil + } + result = decoded + } else { + result = nil + } + let failure: DorydMachineFileTransferFailure? + if let rawFailure = dictionary["failure"] { + guard let row = rawFailure as? NSDictionary, + Set(row.allKeys.compactMap { $0 as? String }) + == ["schema", "code", "message"], + row.allKeys.count == 3, + strictUInt64(row["schema"]) == 1, + let rawCode = row["code"] as? String, + let code = DorydMachineFileTransferFailureCode(rawValue: rawCode), + let message = row["message"] as? String, + !message.isEmpty, + message.utf8.count <= 1_024, + !message.contains("\0") else { + return nil + } + failure = DorydMachineFileTransferFailure(code: code, message: message) + } else { + failure = nil + } + + switch phase { + case .completed: + guard let result, + failure == nil, + guestDestination == result.guestDestination, + filesTotal == result.filesSent, + filesCompleted == result.filesSent, + bytesTotal == result.bytesSent, + bytesCompleted == result.bytesSent else { + return nil + } + case .failed: + guard failure != nil, result == nil else { return nil } + case .cancelled: + guard result == nil, failure == nil else { return nil } + case .preparing, .transferring, .finalizing, .cancelling: + guard result == nil, failure == nil else { return nil } + } + return DorydMachineFileTransferOperation( + operationID: operationID, + machineID: machineID, + phase: phase, + filesTotal: filesTotal, + filesCompleted: filesCompleted, + bytesTotal: bytesTotal, + bytesCompleted: bytesCompleted, + currentPath: currentPath, + guestDestination: guestDestination, + result: result, + failure: failure + ) + } + + nonisolated private static func isValidMachineTransferOperationID(_ value: String) -> Bool { + value.utf8.count == 32 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + nonisolated private static func isSafeMachineTransferRelativePath(_ value: String) -> Bool { + guard !value.isEmpty, + value.utf8.count <= 4_096, + !value.hasPrefix("/"), + !value.contains("\0") else { + return false + } + return value.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { + !$0.isEmpty && $0 != "." && $0 != ".." + } + } + + nonisolated private static func isValidMachineTransferDestination( + _ value: String, + transferID: String + ) -> Bool { + guard value.utf8.count <= 4_096, !value.contains("\0") else { return false } + let suffix = "/Downloads/Dory Transfer " + transferID + guard value.hasPrefix("/home/"), value.hasSuffix(suffix) else { return false } + let usernameStart = value.index(value.startIndex, offsetBy: "/home/".count) + let usernameEnd = value.index(value.endIndex, offsetBy: -suffix.count) + return usernameStart < usernameEnd + && DoryVMGuestAccountIntent.isValidUsername( + String(value[usernameStart.. DorydRemoteMachineStatus? { guard let id = dictionary["id"] as? String, let state = dictionary["state"] as? String else { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 3c6ff592..5cee7a13 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -73,6 +73,59 @@ struct DorydClientTests { await #expect(throws: (any Error).self) { _ = try await client.machineTransfer("dev", staged: staged) } + + service.setMachineTransferResponse(nil) + let started = try await client.machineTransferStart("dev", staged: staged) + #expect(started.machineID == "dev") + #expect(started.phase == .preparing) + #expect(started.fractionCompleted == 0) + #expect(service.latestMachineTransferStartRequest?["privateStagingRoot"] as? String == staged.rootPath) + #expect(service.latestMachineTransferStartRequest?["schema"] as? UInt16 == 2) + + let completed = try await client.machineTransferStatus( + "dev", + operationID: started.operationID + ) + #expect(completed.phase == .completed) + #expect(completed.phase.isTerminal) + #expect(completed.result?.transferID == started.operationID) + #expect(completed.result?.filesSent == 1) + #expect(completed.result?.bytesSent == 5) + #expect(completed.fractionCompleted == 1) + + let cancelled = try await client.machineTransferCancel( + "dev", + operationID: started.operationID + ) + #expect(cancelled.phase == .cancelled) + #expect(cancelled.result == nil) + #expect(cancelled.failure == nil) + + let baseOperation = service.machineTransferOperationResponse( + operationID: started.operationID, + phase: "transferring" + ) + for malformed in [ + baseOperation.adding("unexpected", true), + baseOperation.adding("currentPath", "../host-secret"), + baseOperation.replacing("filesCompleted", with: UInt64(2)), + baseOperation + .replacing("phase", with: "failed") + .adding("failure", [ + "schema": UInt16(1), + "code": "unknown-code", + "message": "failed", + ] as NSDictionary), + ] { + service.setMachineTransferOperationResponse(malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineTransferStatus( + "dev", + operationID: started.operationID + ) + } + } + service.setMachineTransferOperationResponse(nil) } @Test func legacyDesktopDefaultsAndTogglesRemainFieldLocalInTypedEdits() throws { @@ -2698,7 +2751,9 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineUpdateConfig: NSDictionary? private var _latestMachineProvisionRecipe: String? private var _latestMachineTransferRequest: NSDictionary? + private var _latestMachineTransferStartRequest: NSDictionary? private var _machineTransferResponseOverride: NSDictionary? + private var _machineTransferOperationResponseOverride: NSDictionary? private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? private var installedDesktopPayloadReceiptOverride: NSDictionary? @@ -2716,10 +2771,27 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return _latestMachineTransferRequest } + var latestMachineTransferStartRequest: NSDictionary? { + lock.lock(); defer { lock.unlock() } + return _latestMachineTransferStartRequest + } + func setMachineTransferResponse(_ response: NSDictionary?) { lock.lock(); defer { lock.unlock() } _machineTransferResponseOverride = response } + + func setMachineTransferOperationResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineTransferOperationResponseOverride = response + } + + func machineTransferOperationResponse( + operationID: String, + phase: String + ) -> NSDictionary { + Self.transferOperationRow(operationID: operationID, phase: phase) + } var engineStopCount: Int { lock.lock(); defer { lock.unlock() } return _engineStopCount @@ -3243,6 +3315,61 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ], "") } + func machineTransferStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineTransferStartRequest = request + let override = _machineTransferOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.transferOperationRow( + operationID: String(repeating: "b", count: 32), + machineID: machineID, + phase: "preparing" + ), + "" + ) + } + + func machineTransferStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let override = _machineTransferOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.transferOperationRow( + operationID: operationID, + machineID: machineID, + phase: "completed" + ), + "" + ) + } + + func machineTransferCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + reply( + true, + Self.transferOperationRow( + operationID: operationID, + machineID: machineID, + phase: "cancelled" + ), + "" + ) + } + func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let recipe = request["recipe"] as? String ?? "rust" lock.lock() @@ -3881,6 +4008,35 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return value as? Int } + private static func transferOperationRow( + operationID: String, + machineID: String = "dev", + phase: String + ) -> NSDictionary { + var row: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase, + "filesTotal": UInt64(phase == "completed" ? 1 : 0), + "filesCompleted": UInt64(phase == "completed" ? 1 : 0), + "bytesTotal": UInt64(phase == "completed" ? 5 : 0), + "bytesCompleted": UInt64(phase == "completed" ? 5 : 0), + ] + if phase == "completed" { + let destination = "/home/developer/Downloads/Dory Transfer " + operationID + row["guestDestination"] = destination + row["result"] = [ + "schema": UInt16(1), + "transferID": operationID, + "guestDestination": destination, + "filesSent": UInt64(1), + "bytesSent": UInt64(5), + ] as NSDictionary + } + return row as NSDictionary + } + private static func execRow(stdout: String = "", stderr: String = "", exitCode: Int32 = 0) -> NSDictionary { [ "exitCode": exitCode, @@ -3970,6 +4126,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } } +private extension NSDictionary { + func adding(_ key: String, _ value: Any) -> NSDictionary { + var copy = stringKeyedCopy + copy[key] = value + return copy as NSDictionary + } + + func replacing(_ key: String, with value: Any) -> NSDictionary { + adding(key, value) + } + + var stringKeyedCopy: [String: Any] { + var copy: [String: Any] = [:] + for (key, value) in self { + if let key = key as? String { + copy[key] = value + } + } + return copy + } +} + @MainActor private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws { for _ in 0..<80 { From e2278a89d05d11312a4d52608f9a68ecc2924f53 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:35:28 +0000 Subject: [PATCH 089/338] feat(vm): track cancellable file transfers --- Dory/Models/AppStore.swift | 103 ++++++++++++++++++++++++++++++- DoryTests/DorydClientTests.swift | 48 +++++++++++--- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 07cce7d5..206d946f 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -16,6 +16,23 @@ struct SettingsNotice: Identifiable, Equatable, Sendable { var message: String } +private enum DoryMachineFileTransferUIError: LocalizedError { + case authorityChanged + case invalidCompletion + case remoteFailure(String) + + var errorDescription: String? { + switch self { + case .authorityChanged: + "The file transfer identity changed unexpectedly." + case .invalidCompletion: + "The guest returned incomplete file transfer evidence." + case .remoteFailure(let message): + message + } + } +} + enum ContainerFilter: String, CaseIterable, Sendable { case running, all, stopped var label: String { @@ -5095,8 +5112,12 @@ final class AppStore { } private(set) var busyMachines: Set = [] + private(set) var machineFileTransfers: [String: DorydMachineFileTransferOperation] = [:] var machineBusy: Bool { !busyMachines.isEmpty } func isMachineBusy(_ name: String) -> Bool { busyMachines.contains(name) } + func machineFileTransfer(for name: String) -> DorydMachineFileTransferOperation? { + machineFileTransfers[name] + } static let importBusyKey = "__dory_import__" var machineCreationTitle = "" var machineCreationLog = "" @@ -5279,6 +5300,25 @@ final class AppStore { } } + func cancelFileTransfer(to machine: Machine) async { + guard let operation = machineFileTransfers[machine.name], + !operation.phase.isTerminal else { + return + } + do { + let updated = try await dorydClient.machineTransferCancel( + machine.name, + operationID: operation.operationID + ) + guard machineFileTransfers[machine.name]?.operationID == operation.operationID else { + return + } + machineFileTransfers[machine.name] = updated + } catch { + actionError = "Could not cancel the file transfer to \(machine.name): \(Self.userFacingError(error))" + } + } + @discardableResult func transferFiles( _ fileURLs: [URL], @@ -5290,10 +5330,14 @@ final class AppStore { } guard !busyMachines.contains(machine.name) else { return nil } busyMachines.insert(machine.name) - defer { busyMachines.remove(machine.name) } + defer { + busyMachines.remove(machine.name) + machineFileTransfers.removeValue(forKey: machine.name) + } actionError = nil var staged: DoryStagedMachineFileTransfer? + var operationID: String? do { staged = try await Task.detached(priority: .userInitiated) { try DoryMachineFileTransferStager.stage(fileURLs: fileURLs) @@ -5312,7 +5356,42 @@ final class AppStore { actionError = "Update Dory Tools in \(machine.name) before sending folders." return nil } - let result = try await dorydClient.machineTransfer(machine.name, staged: preparedStage) + var operation = try await dorydClient.machineTransferStart( + machine.name, + staged: preparedStage + ) + operationID = operation.operationID + machineFileTransfers[machine.name] = operation + while !operation.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + operation = try await dorydClient.machineTransferStatus( + machine.name, + operationID: operation.operationID + ) + guard operationID == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineFileTransfers[machine.name] = operation + } + + guard operation.phase == .completed, + let result = operation.result else { + if operation.phase == .failed, let failure = operation.failure { + throw DoryMachineFileTransferUIError.remoteFailure(failure.message) + } + if operation.phase == .cancelled { + showSettingsSuccess("Cancelled the file transfer to \(machine.name).") + if let staged { + try? await Task.detached(priority: .utility) { try staged.remove() }.value + } + return nil + } + throw DoryMachineFileTransferUIError.invalidCompletion + } + guard result.filesSent == preparedStage.fileCount, + result.bytesSent == preparedStage.byteCount else { + throw DoryMachineFileTransferUIError.invalidCompletion + } let fileLabel = result.filesSent == 1 ? "file" : "files" let folderLabel = preparedStage.directoryCount == 1 ? "folder" : "folders" let bytes = ByteCountFormatter.string( @@ -5332,7 +5411,27 @@ final class AppStore { : "\(result.filesSent) \(fileLabel) and \(preparedStage.directoryCount) \(folderLabel)" showSettingsSuccess("Sent \(transferredItems) (\(bytes)) to \(result.guestDestination).") return result + } catch is CancellationError { + if let operationID { + _ = try? await dorydClient.machineTransferCancel( + machine.name, + operationID: operationID + ) + } + if let staged { + try? await Task.detached(priority: .utility) { + try staged.remove() + }.value + } + return nil } catch { + if let operationID, + machineFileTransfers[machine.name]?.phase.isTerminal == false { + _ = try? await dorydClient.machineTransferCancel( + machine.name, + operationID: operationID + ) + } if let staged { try? await Task.detached(priority: .utility) { try staged.remove() diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 5cee7a13..f307bd77 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1247,9 +1247,10 @@ struct DorydClientTests { #expect(transferred.bytesSent == 5) #expect(store.settingsNotice?.message.contains(transferred.guestDestination) == true) let stagedRoot = try #require( - service.latestMachineTransferRequest?["privateStagingRoot"] as? String + service.latestMachineTransferStartRequest?["privateStagingRoot"] as? String ) #expect(!FileManager.default.fileExists(atPath: stagedRoot)) + #expect(store.machineFileTransfer(for: machine.name) == nil) let transferFolder = transferRoot.appendingPathComponent("project", isDirectory: true) try FileManager.default.createDirectory( @@ -1266,7 +1267,7 @@ struct DorydClientTests { #expect(transferredFolder.bytesSent == 5) #expect(store.settingsNotice?.message.contains("1 file and 3 folders") == true) let stagedFolderRoot = try #require( - service.latestMachineTransferRequest?["privateStagingRoot"] as? String + service.latestMachineTransferStartRequest?["privateStagingRoot"] as? String ) #expect(!FileManager.default.fileExists(atPath: stagedFolderRoot)) @@ -1279,10 +1280,30 @@ struct DorydClientTests { #expect(await store.transferFiles([transferFolder], to: fileOnlyTransfer) == nil) #expect(store.actionError?.contains("Update Dory Tools") == true) #expect( - service.latestMachineTransferRequest?["privateStagingRoot"] as? String + service.latestMachineTransferStartRequest?["privateStagingRoot"] as? String == stagedFolderRoot ) + let cancellingID = String(repeating: "c", count: 32) + service.setMachineTransferOperationResponse( + service.machineTransferOperationResponse( + operationID: cancellingID, + phase: "transferring" + ) + ) + let cancellingTransfer = Task { + await store.transferFiles([transferFile], to: machine) + } + try await waitUntil { + store.machineFileTransfer(for: machine.name)?.phase == .transferring + } + await store.cancelFileTransfer(to: machine) + #expect(await cancellingTransfer.value == nil) + #expect(service.machineTransferCancelCount == 1) + #expect(store.machineFileTransfer(for: machine.name) == nil) + #expect(store.settingsNotice?.message.contains("Cancelled") == true) + service.setMachineTransferOperationResponse(nil) + var transferUnavailable = machine transferUnavailable.agentCapabilities = transferUnavailable.agentCapabilities.filter { $0.id != "sync-push" @@ -2754,6 +2775,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineTransferStartRequest: NSDictionary? private var _machineTransferResponseOverride: NSDictionary? private var _machineTransferOperationResponseOverride: NSDictionary? + private var _machineTransferCancelCount = 0 private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? private var installedDesktopPayloadReceiptOverride: NSDictionary? @@ -2776,6 +2798,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return _latestMachineTransferStartRequest } + var machineTransferCancelCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineTransferCancelCount + } + func setMachineTransferResponse(_ response: NSDictionary?) { lock.lock(); defer { lock.unlock() } _machineTransferResponseOverride = response @@ -3359,13 +3386,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { + let cancelled = Self.transferOperationRow( + operationID: operationID, + machineID: machineID, + phase: "cancelled" + ) + lock.lock() + _machineTransferCancelCount += 1 + _machineTransferOperationResponseOverride = cancelled + lock.unlock() reply( true, - Self.transferOperationRow( - operationID: operationID, - machineID: machineID, - phase: "cancelled" - ), + cancelled, "" ) } From 38596d7617fc16acaa925d416c93fc7d44a4c372 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:36:29 +0000 Subject: [PATCH 090/338] feat(vm): show file transfer progress --- Dory/Features/Machines/MachinesView.swift | 92 ++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 6c0b608a..8dbee54d 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -130,6 +130,9 @@ private struct MachineCard: View { private var isPaused: Bool { machine.status == .paused } private var isActive: Bool { isRunning || isPaused } private var hasAssignedAddress: Bool { DoryDNS.ipv4Bytes(machine.ip) != nil } + private var fileTransfer: DorydMachineFileTransferOperation? { + store.machineFileTransfer(for: machine.name) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -172,6 +175,11 @@ private struct MachineCard: View { runtimeEvidence .padding(.bottom, 12) + if let fileTransfer { + fileTransferProgress(fileTransfer) + .padding(.bottom, 12) + } + if let command = store.machineTerminalCommand(machine) { HStack(spacing: 6) { Image(systemName: "terminal").font(.system(size: 11)).foregroundStyle(p.text3) @@ -252,6 +260,7 @@ private struct MachineCard: View { } .dropDestination(for: URL.self) { urls, _ in guard store.canTransferFiles(to: machine), + !store.isMachineBusy(machine.name), !urls.isEmpty, urls.allSatisfy(\.isFileURL) else { return false @@ -259,7 +268,9 @@ private struct MachineCard: View { Task { await store.transferFiles(urls, to: machine) } return true } isTargeted: { targeted in - isTransferDropTargeted = targeted && store.canTransferFiles(to: machine) + isTransferDropTargeted = targeted + && store.canTransferFiles(to: machine) + && !store.isMachineBusy(machine.name) } .confirmationDialog("Delete machine \(machine.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { Button("Delete", role: .destructive) { store.deleteMachine(machine) } @@ -285,6 +296,85 @@ private struct MachineCard: View { .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(p.border)) } + private func fileTransferProgress(_ operation: DorydMachineFileTransferOperation) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: operation.phase == .cancelling ? "xmark.circle" : "paperplane.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.accentText) + Text(fileTransferTitle(operation)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + Spacer(minLength: 8) + Text(operation.fractionCompleted, format: .percent.precision(.fractionLength(0))) + .font(.mono(10.5, weight: .semibold)) + .foregroundStyle(p.text2) + } + + ProgressView(value: operation.fractionCompleted) + .progressViewStyle(.linear) + .tint(p.accent) + + HStack(spacing: 8) { + Text(fileTransferDetail(operation)) + .font(.system(size: 10.5)) + .foregroundStyle(p.text3) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if !operation.phase.isTerminal { + Button(operation.phase == .cancelling ? "Cancelling…" : "Cancel") { + Task { await store.cancelFileTransfer(to: machine) } + } + .buttonStyle(.borderless) + .font(.system(size: 10.5, weight: .semibold)) + .disabled(operation.phase == .cancelling) + .accessibilityIdentifier("machine-transfer-cancel-\(machine.name)") + } + } + } + .padding(10) + .background(p.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.accent.opacity(0.24))) + .accessibilityElement(children: .contain) + .accessibilityLabel("File transfer to \(machine.name)") + .accessibilityValue(fileTransferDetail(operation)) + } + + private func fileTransferTitle(_ operation: DorydMachineFileTransferOperation) -> String { + switch operation.phase { + case .preparing: "Preparing files" + case .transferring: "Sending files" + case .finalizing: "Finishing transfer" + case .cancelling: "Cancelling transfer" + case .completed: "Files sent" + case .cancelled: "Transfer cancelled" + case .failed: "Transfer failed" + } + } + + private func fileTransferDetail(_ operation: DorydMachineFileTransferOperation) -> String { + if let currentPath = operation.currentPath { + return currentPath + } + if operation.bytesTotal > 0 { + let completed = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesCompleted), + countStyle: .file + ) + let total = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesTotal), + countStyle: .file + ) + return "\(completed) of \(total)" + } + if operation.filesTotal > 0 { + return "\(operation.filesCompleted) of \(operation.filesTotal) files" + } + return fileTransferTitle(operation) + } + private var overflowMenu: some View { Menu { if isActive { From d4c4a2fc1cdc23c9a1aa87a99887503164ab4e19 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 01:41:39 +0000 Subject: [PATCH 091/338] feat(vm): add Dory Tools repair action --- Dory/Features/Machines/MachinesView.swift | 16 +++++++ Dory/Models/AppStore.swift | 51 +++++++++++++++++++++-- DoryTests/DorydClientTests.swift | 9 ++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 8dbee54d..8c25eb5f 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -124,6 +124,7 @@ private struct MachineCard: View { @Environment(\.openWindow) private var openWindow let machine: Machine @State private var confirmingDelete = false + @State private var confirmingToolsRepair = false @State private var isTransferDropTargeted = false private var isRunning: Bool { machine.status == .running } @@ -278,6 +279,16 @@ private struct MachineCard: View { } message: { Text("This permanently deletes the Linux machine and its disk. This cannot be undone.") } + .confirmationDialog( + "Repair Dory Tools in \(machine.name)?", + isPresented: $confirmingToolsRepair, + titleVisibility: .visible + ) { + Button("Repair Dory Tools") { store.repairMachineTools(machine) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Dory will create a last-good snapshot, reinstall the active signed desktop and tools payload, restart the machine, and roll back automatically if verification fails.") + } } private var distroBadge: some View { @@ -414,6 +425,11 @@ private struct MachineCard: View { Button { store.openMachineEdit(machine) } label: { Label("Edit…", systemImage: "slider.horizontal.3") } + if store.canRepairMachineTools(machine) { + Button { confirmingToolsRepair = true } label: { + Label("Repair Dory Tools…", systemImage: "wrench.and.screwdriver") + } + } if machine.bootMode == .efi { Divider() Button { diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 206d946f..60942fb2 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5300,6 +5300,40 @@ final class AppStore { } } + func canRepairMachineTools(_ machine: Machine) -> Bool { + runtimeOwnedByDoryd + && machine.displayMode == .desktop + && machine.bootMode == .linuxKernel + } + + func repairMachineTools(_ machine: Machine) { + guard canRepairMachineTools(machine), !busyMachines.contains(machine.name) else { return } + Task { + do { + let updates = try await updateManagedDesktops( + affectedBy: [ + .linuxDesktop, + .desktopDebian, + .desktopUbuntu, + .desktopKali, + ], + force: true, + machineID: machine.name + ) + guard let update = updates.first, updates.count == 1 else { + throw DesktopMachineAssetError.missingAsset( + "a verified Dory Tools update for \(machine.name)" + ) + } + showSettingsSuccess( + "Repaired Dory Tools in \(machine.name) and verified \(update.version)." + ) + } catch { + actionError = "Could not repair Dory Tools in \(machine.name): \(Self.userFacingError(error))" + } + } + } + func cancelFileTransfer(to machine: Machine) async { guard let operation = machineFileTransfers[machine.name], !operation.phase.isTerminal else { @@ -5672,9 +5706,15 @@ final class AppStore { /// Brings persistent desktop machines forward after a signed desktop component activation. /// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the /// last-good snapshot, reboot qualification, and automatic rollback transaction. - func updateManagedDesktops(affectedBy components: Set) async throws -> [DorydDesktopUpdateResult] { + func updateManagedDesktops( + affectedBy components: Set, + force: Bool = false, + machineID: String? = nil + ) async throws -> [DorydDesktopUpdateResult] { let statuses = try await dorydClient.machineList() - let desktopStatuses = statuses.filter { $0.displayMode == .desktop } + let desktopStatuses = statuses.filter { + $0.displayMode == .desktop && (machineID == nil || $0.id == machineID) + } guard !desktopStatuses.isEmpty else { return [] } let runtimeChanged = components.contains(.linuxDesktop) @@ -5722,7 +5762,7 @@ final class AppStore { }) else { throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update") } - if Self.desktopReceiptMatchesActiveComponents( + if !force && Self.desktopReceiptMatchesActiveComponents( status.installedDesktopPayloadReceipt, distributionIdentifier: distro.rawValue, releaseVersion: targetVersion, @@ -5739,6 +5779,11 @@ final class AppStore { continue } + guard !busyMachines.contains(status.id) else { + throw DesktopMachineAssetError.filesystem( + "\(status.id) is busy with another machine operation" + ) + } busyMachines.insert(status.id) defer { busyMachines.remove(status.id) } do { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index f307bd77..02976b22 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1233,6 +1233,15 @@ struct DorydClientTests { #expect(store.canUseMachineArtifacts(machine)) #expect(store.canTransferFiles(to: machine)) #expect(store.canTransferFolders(to: machine)) + #expect(store.canRepairMachineTools(machine)) + + var customInstaller = machine + customInstaller.bootMode = .efi + #expect(!store.canRepairMachineTools(customInstaller)) + + var headlessMachine = machine + headlessMachine.displayMode = .headless + #expect(!store.canRepairMachineTools(headlessMachine)) let transferRoot = URL( fileURLWithPath: "/tmp/dory-store-transfer-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 01:58:41 +0000 Subject: [PATCH 092/338] fix(vm): clean daemon-owned transfer staging --- .../DoryMachineFileTransferStager.swift | 201 +++++++++++++++++- .../Sources/DorydKit/MachineManager.swift | 59 ++--- .../DoryMachineFileTransferStagerTests.swift | 2 + .../DorydKitTests/DorydServiceTests.swift | 12 +- .../MachineManagerFileTransferTests.swift | 73 ++++++- 5 files changed, 314 insertions(+), 33 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift index 15b7a424..edd4de39 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift @@ -20,7 +20,12 @@ public struct DoryStagedMachineFileTransfer: Sendable, Equatable { } public func remove() throws { - try FileManager.default.removeItem(atPath: rootPath) + do { + try FileManager.default.removeItem(atPath: rootPath) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // An asynchronous daemon transfer atomically claims the handoff before replying. + // Client cleanup is therefore deliberately idempotent when ownership has moved. + } } } @@ -77,6 +82,15 @@ public enum DoryMachineFileTransferStager { private static let copyBufferBytes = 1024 * 1024 private static let maximumDirectoryDepth = 128 private static let reservedRootName = ".dory-sync-tmp" + private static let stagingDirectoryName = "dory-machine-transfer-imports" + private static let clientStagePrefix = "transfer-" + private static let daemonStagePrefix = "owned-" + + public static var defaultStagingDirectory: URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(stagingDirectoryName, isDirectory: true) + .standardizedFileURL + } private struct StageState { var fileCount = 0 @@ -128,9 +142,7 @@ public enum DoryMachineFileTransferStager { throw DoryMachineFileTransferStagingError.unsupportedFile(name) } - let stagingDirectory = requestedDirectory - ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("dory-machine-transfer-imports", isDirectory: true) + let stagingDirectory = requestedDirectory ?? defaultStagingDirectory if mkdir(stagingDirectory.path, mode_t(0o700)) != 0, errno != EEXIST { throw DoryMachineFileTransferStagingError.io("directory creation", errno) } @@ -146,7 +158,7 @@ public enum DoryMachineFileTransferStager { throw DoryMachineFileTransferStagingError.unsafeStagingDirectory } - let transferName = "transfer-" + UUID().uuidString.lowercased() + let transferName = clientStagePrefix + UUID().uuidString.lowercased() guard mkdirat(baseDescriptor, transferName, mode_t(0o700)) == 0 else { throw DoryMachineFileTransferStagingError.io("transfer creation", errno) } @@ -227,6 +239,185 @@ public enum DoryMachineFileTransferStager { ) } + /// Returns true only for an app-authored handoff in Dory's exact shared staging namespace. + /// Arbitrary owner-private directories are intentionally not accepted as transfer authority. + package static func isClientStagingRoot(_ path: String) -> Bool { + guard let root = managedRoot(path), root.kind == .client else { return false } + return isPrivateManagedRoot(root) + } + + /// Returns true for either a client handoff or a handoff atomically claimed by doryd. + package static func isManagedStagingRoot(_ path: String) -> Bool { + guard let root = managedRoot(path) else { return false } + return isPrivateManagedRoot(root) + } + + /// Moves one client handoff to an operation-specific daemon-owned name without opening an + /// app/daemon cleanup race. Once this succeeds, the daemon is solely responsible for removal. + package static func claimForDaemon( + _ path: String, + operationID: String, + ownerProcessID: pid_t = getpid() + ) throws -> String { + guard let source = managedRoot(path), source.kind == .client, + isValidOperationID(operationID), ownerProcessID > 0 else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + let baseDescriptor = try openPrivateStagingDirectory() + defer { close(baseDescriptor) } + let sourceDescriptor = openat( + baseDescriptor, + source.name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard sourceDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("claim source open", errno) + } + let sourceIsPrivate = isPrivateDirectory(descriptor: sourceDescriptor) + close(sourceDescriptor) + guard sourceIsPrivate else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + + let destinationName = daemonStagePrefix + String(ownerProcessID) + "-" + operationID + let result = source.name.withCString { sourceName in + destinationName.withCString { destinationName in + renameatx_np( + baseDescriptor, + sourceName, + baseDescriptor, + destinationName, + UInt32(RENAME_EXCL) + ) + } + } + guard result == 0 else { + throw DoryMachineFileTransferStagingError.io("claim rename", errno) + } + let claimedPath = defaultStagingDirectory + .appendingPathComponent(destinationName, isDirectory: true).path + guard fsync(baseDescriptor) == 0 else { + let code = errno + try? removeManagedStagingRoot(claimedPath) + throw DoryMachineFileTransferStagingError.io("claim sync", code) + } + return claimedPath + } + + /// Removes a syntactically exact managed handoff. Missing roots are already clean and succeed. + package static func removeManagedStagingRoot(_ path: String) throws { + guard managedRoot(path) != nil else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + var status = stat() + guard lstat(path, &status) == 0 else { + if errno == ENOENT { return } + throw DoryMachineFileTransferStagingError.io("cleanup stat", errno) + } + guard (status.st_mode & S_IFMT) == S_IFDIR, + status.st_uid == geteuid(), + (status.st_mode & 0o077) == 0 else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + try FileManager.default.removeItem(atPath: path) + let baseDescriptor = try openPrivateStagingDirectory() + defer { close(baseDescriptor) } + guard fsync(baseDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("cleanup sync", errno) + } + } + + /// Reclaims handoffs owned by daemon processes that no longer exist. Live process names are + /// left untouched, allowing multiple test/manager instances in one process to coexist safely. + package static func removeAbandonedDaemonStages() { + let base = defaultStagingDirectory + guard let names = try? FileManager.default.contentsOfDirectory(atPath: base.path) else { + return + } + for name in names { + guard let owner = daemonOwnerProcessID(name), !processExists(owner) else { continue } + try? removeManagedStagingRoot( + base.appendingPathComponent(name, isDirectory: true).path + ) + } + } + + private enum ManagedStageKind { + case client + case daemon + } + + private struct ManagedStageRoot { + var name: String + var kind: ManagedStageKind + } + + private static func managedRoot(_ path: String) -> ManagedStageRoot? { + guard path.hasPrefix("/"), !path.contains("\0") else { return nil } + let url = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + guard url.path == path, + url.deletingLastPathComponent().path == defaultStagingDirectory.path else { + return nil + } + let name = url.lastPathComponent + if name.hasPrefix(clientStagePrefix) { + let suffix = String(name.dropFirst(clientStagePrefix.count)) + guard let uuid = UUID(uuidString: suffix), + uuid.uuidString.lowercased() == suffix else { return nil } + return ManagedStageRoot(name: name, kind: .client) + } + guard daemonOwnerProcessID(name) != nil else { return nil } + return ManagedStageRoot(name: name, kind: .daemon) + } + + private static func daemonOwnerProcessID(_ name: String) -> pid_t? { + guard name.hasPrefix(daemonStagePrefix) else { return nil } + let fields = name.dropFirst(daemonStagePrefix.count).split(separator: "-", maxSplits: 1) + guard fields.count == 2, + let owner = pid_t(fields[0]), owner > 0, + isValidOperationID(String(fields[1])) else { return nil } + return owner + } + + private static func isValidOperationID(_ value: String) -> Bool { + value.utf8.count == 32 && value.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66) + } + } + + private static func isPrivateManagedRoot(_ root: ManagedStageRoot) -> Bool { + guard let baseDescriptor = try? openPrivateStagingDirectory() else { return false } + defer { close(baseDescriptor) } + let descriptor = openat( + baseDescriptor, + root.name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { return false } + defer { close(descriptor) } + return isPrivateDirectory(descriptor: descriptor) + } + + private static func openPrivateStagingDirectory() throws -> Int32 { + let descriptor = open( + defaultStagingDirectory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("directory open", errno) + } + guard isPrivateDirectory(descriptor: descriptor) else { + close(descriptor) + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + return descriptor + } + + private static func processExists(_ processID: pid_t) -> Bool { + if kill(processID, 0) == 0 { return true } + return errno != ESRCH + } + private static func stageFile( sourceDescriptor: Int32, sourceSnapshot: stat, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d373f772..03e1471f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -906,6 +906,7 @@ public final class MachineManager: @unchecked Sendable { includeDescendants: true ) } + DoryMachineFileTransferStager.removeAbandonedDaemonStages() let lifecycleRecoveryDiagnostics = Self.recoverInterruptedLifecycleOperations( store: lifecycleJournalStore, configuration: configuration @@ -5017,14 +5018,12 @@ public final class MachineManager: @unchecked Sendable { /// Copies a daemon-private staging tree into a fresh guest-owned Downloads subdirectory. /// The destination is intentionally derived here rather than accepted from XPC: `sync-push` /// makes its target an exact replica, so pointing it at an existing user directory could erase - /// unrelated files. The caller remains responsible for deleting its staging root afterwards. + /// unrelated files. Synchronous compatibility callers remain responsible for deleting their + /// staging root afterwards. public func transferStagedFiles( id: String, privateStagingRoot: String ) throws -> DoryMachineFileTransferResult { - guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { - throw DoryMachineFileTransferError.invalidPrivateStagingRoot - } let transferID = Self.makeMachineFileTransferID() fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) @@ -5041,6 +5040,9 @@ public final class MachineManager: @unchecked Sendable { } fileTransferLock.unlock() } + guard DoryMachineFileTransferStager.isClientStagingRoot(privateStagingRoot) else { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } return try performStagedFileTransfer( id: id, privateStagingRoot: privateStagingRoot, @@ -5056,9 +5058,6 @@ public final class MachineManager: @unchecked Sendable { id: String, privateStagingRoot: String ) throws -> DoryMachineFileTransferOperationStatus { - guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { - throw DoryMachineFileTransferError.invalidPrivateStagingRoot - } guard let machineStatus = status(id: id) else { throw MachineManagerError.unknownMachine(id) } @@ -5072,26 +5071,36 @@ public final class MachineManager: @unchecked Sendable { } let transferID = Self.makeMachineFileTransferID() - let operation = MachineFileTransferOperation( - operationID: transferID, - machineID: id - ) fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) if activeFileTransferByMachine[id] != nil { fileTransferLock.unlock() throw DoryMachineFileTransferError.transferAlreadyInProgress(id) } + let claimedStagingRoot: String + do { + claimedStagingRoot = try DoryMachineFileTransferStager.claimForDaemon( + privateStagingRoot, + operationID: transferID + ) + } catch { + fileTransferLock.unlock() + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + let operation = MachineFileTransferOperation( + operationID: transferID, + machineID: id + ) fileTransferOperations[transferID] = operation activeFileTransferByMachine[id] = transferID fileTransferLock.unlock() fileTransferQueue.async { [self, operation] in - let outcome: MachineFileTransferTerminalOutcome + var outcome: MachineFileTransferTerminalOutcome do { let result = try performStagedFileTransfer( id: id, - privateStagingRoot: privateStagingRoot, + privateStagingRoot: claimedStagingRoot, transferID: transferID, operation: operation ) @@ -5107,6 +5116,16 @@ public final class MachineManager: @unchecked Sendable { outcome = .failed(Self.fileTransferFailure(error, machineID: id)) } } + do { + try DoryMachineFileTransferStager.removeManagedStagingRoot( + claimedStagingRoot + ) + } catch { + outcome = .failed(.init( + code: .transferFailed, + message: "File transfer cleanup failed for \(id)." + )) + } fileTransferLock.lock() switch outcome { case let .completed(result): @@ -5158,7 +5177,7 @@ public final class MachineManager: @unchecked Sendable { transferID: String, operation: MachineFileTransferOperation? ) throws -> DoryMachineFileTransferResult { - guard Self.isPrivateTransferStagingRoot(privateStagingRoot) else { + guard DoryMachineFileTransferStager.isManagedStagingRoot(privateStagingRoot) else { throw DoryMachineFileTransferError.invalidPrivateStagingRoot } guard let machineStatus = status(id: id) else { @@ -5371,18 +5390,6 @@ public final class MachineManager: @unchecked Sendable { guard result.exitCode == 0, !result.timedOut else { throw error } } - private static func isPrivateTransferStagingRoot(_ path: String) -> Bool { - guard path.hasPrefix("/"), !path.contains("\0") else { return false } - var info = stat() - guard lstat(path, &info) == 0, - (info.st_mode & S_IFMT) == S_IFDIR, - info.st_uid == geteuid(), - (info.st_mode & 0o077) == 0 else { - return false - } - return true - } - private func withAgentClient( id: String, requiredCapability: String? = nil, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift index d81e85e9..ddb52ff3 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift @@ -31,6 +31,8 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { ) XCTAssertEqual(try permissions(staged.rootPath) & 0o777, 0o700) XCTAssertEqual(try permissions(staged.rootPath + "/hello.txt") & 0o777, 0o600) + try staged.remove() + try staged.remove() } func testStagesNestedFoldersIncludingEmptyDirectories() throws { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 1f873954..0b9eee02 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1674,7 +1674,17 @@ final class DorydServiceTests: XCTestCase { func testMachineTransferOverXPCUsesExactShapeAndOmitsHostPath() throws { let suffix = "\(getpid())-\(UInt32.random(in: 0.. TransferFixture { let suffix = "\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 02:06:28 +0000 Subject: [PATCH 093/338] feat(vm): expose active transfer recovery --- Dory/Runtime/Doryd/DorydClient.swift | 43 ++++++++++++++++ DoryTests/DorydClientTests.swift | 51 +++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 19 +++++++ .../Sources/DorydKit/MachineManager.swift | 18 +++++++ .../DorydKitTests/DorydServiceTests.swift | 13 +++++ .../MachineManagerFileTransferTests.swift | 5 ++ 7 files changed, 150 insertions(+) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index fae19b39..702f4c8a 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -27,6 +27,7 @@ nonisolated protocol DorydControlXPC { func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -1052,6 +1053,10 @@ nonisolated struct DorydMachineFileTransferOperation: Sendable, Equatable { } } +nonisolated private struct DorydMachineFileTransferCurrent: Sendable { + var operation: DorydMachineFileTransferOperation? +} + nonisolated struct DorydRemoteMachineStatus: Sendable, Equatable { var id: String var state: String @@ -1539,6 +1544,19 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineTransferCurrent( + _ machineID: String + ) async throws -> DorydMachineFileTransferOperation? { + let current: DorydMachineFileTransferCurrent = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineTransferCurrent(machineID, reply: reply) + } decode: { dictionary in + Self.machineFileTransferCurrent(from: dictionary, machineID: machineID) + } + return current.operation + } + func machineTransferCancel( _ machineID: String, operationID: String @@ -2888,6 +2906,31 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineFileTransferCurrent( + from dictionary: NSDictionary, + machineID: String + ) -> DorydMachineFileTransferCurrent? { + guard let keys = dictionary.allKeys as? [String], + keys.count == dictionary.allKeys.count, + strictUInt64(dictionary["schema"]) == 1, + let activeNumber = dictionary["active"] as? NSNumber, + CFGetTypeID(activeNumber) == CFBooleanGetTypeID() else { + return nil + } + if activeNumber.boolValue { + guard Set(keys) == ["schema", "active", "operation"], + let row = dictionary["operation"] as? NSDictionary, + let operation = machineFileTransferOperation(from: row), + operation.machineID == machineID, + !operation.phase.isTerminal else { + return nil + } + return DorydMachineFileTransferCurrent(operation: operation) + } + guard Set(keys) == ["schema", "active"] else { return nil } + return DorydMachineFileTransferCurrent(operation: nil) + } + nonisolated private static func isValidMachineTransferOperationID(_ value: String) -> Bool { value.utf8.count == 32 && value.utf8.allSatisfy { byte in (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 02976b22..1a9b9514 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -82,6 +82,36 @@ struct DorydClientTests { #expect(service.latestMachineTransferStartRequest?["privateStagingRoot"] as? String == staged.rootPath) #expect(service.latestMachineTransferStartRequest?["schema"] as? UInt16 == 2) + service.setMachineTransferCurrentResponse([ + "schema": UInt16(1), + "active": true, + "operation": service.machineTransferOperationResponse( + operationID: started.operationID, + phase: "transferring" + ), + ]) + let current = try #require(try await client.machineTransferCurrent("dev")) + #expect(current.operationID == started.operationID) + #expect(current.phase == .transferring) + + service.setMachineTransferCurrentResponse([ + "schema": UInt16(1), + "active": false, + ]) + #expect(try await client.machineTransferCurrent("dev") == nil) + service.setMachineTransferCurrentResponse([ + "schema": UInt16(1), + "active": false, + "operation": service.machineTransferOperationResponse( + operationID: started.operationID, + phase: "transferring" + ), + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineTransferCurrent("dev") + } + service.setMachineTransferCurrentResponse(nil) + let completed = try await client.machineTransferStatus( "dev", operationID: started.operationID @@ -2784,6 +2814,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineTransferStartRequest: NSDictionary? private var _machineTransferResponseOverride: NSDictionary? private var _machineTransferOperationResponseOverride: NSDictionary? + private var _machineTransferCurrentResponseOverride: NSDictionary? private var _machineTransferCancelCount = 0 private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? @@ -2822,6 +2853,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { _machineTransferOperationResponseOverride = response } + func setMachineTransferCurrentResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineTransferCurrentResponseOverride = response + } + func machineTransferOperationResponse( operationID: String, phase: String @@ -3390,6 +3426,21 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) } + func machineTransferCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + lock.lock() + let override = _machineTransferCurrentResponseOverride + lock.unlock() + reply( + true, + override ?? ["schema": UInt16(1), "active": false], + "" + ) + } + func machineTransferCancel( _ machineID: String, operationID: String, diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 119c8521..ef584601 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -27,6 +27,7 @@ import Foundation func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index c9e97b1b..5b066ebb 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -511,6 +511,25 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineTransferCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + let operation = machineManager.currentStagedFileTransferStatus(id: machineID) + var body: [String: Any] = [ + "schema": UInt16(1), + "active": operation != nil, + ] + if let operation { + body["operation"] = operation.xpcDictionary + } + reply(true, body as NSDictionary, "") + } + public func machineTransferCancel( _ machineID: String, operationID: String, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 03e1471f..11efdc95 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5157,6 +5157,24 @@ public final class MachineManager: @unchecked Sendable { return operation.status() } + /// Returns the one active transfer for a machine so a reconnecting app can resume polling + /// without retaining an operation identifier across process lifetime. Terminal history is not + /// rediscovered: it remains available only through the exact operation-ID status endpoint. + public func currentStagedFileTransferStatus( + id: String + ) -> DoryMachineFileTransferOperationStatus? { + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + guard let operationID = activeFileTransferByMachine[id], + let operation = fileTransferOperations[operationID] else { + activeFileTransferByMachine.removeValue(forKey: id) + fileTransferLock.unlock() + return nil + } + fileTransferLock.unlock() + return operation.status() + } + public func cancelStagedFileTransfer( id: String, operationID: String diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 0b9eee02..bbb572e1 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1866,6 +1866,19 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(asyncResult["bytesSent"] as? UInt64, 7) XCTAssertFalse(completedBody.description.contains(staging)) + let currentCompleted = expectation(description: "machineTransferCurrent inactive reply") + proxy.machineTransferCurrent("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual( + Set(body.allKeys.compactMap { $0 as? String }), + ["schema", "active"] + ) + XCTAssertEqual((body["schema"] as? NSNumber)?.uint16Value, 1) + XCTAssertEqual(body["active"] as? Bool, false) + currentCompleted.fulfill() + } + wait(for: [currentCompleted], timeout: 5) + let cancelCompleted = expectation(description: "machineTransferCancel terminal reply") proxy.machineTransferCancel("dev", operationID: operationID) { ok, body, message in XCTAssertTrue(ok, message) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift index 0837dd62..67f09aa2 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -171,6 +171,10 @@ final class MachineManagerFileTransferTests: XCTestCase { privateStagingRoot: fixture.stagingRoot ) XCTAssertTrue(fixture.agent.waitForControlledPush()) + XCTAssertEqual( + fixture.manager.currentStagedFileTransferStatus(id: "desktop")?.operationID, + started.operationID + ) XCTAssertThrowsError(try fixture.manager.beginStagedFileTransfer( id: "desktop", privateStagingRoot: fixture.stagingRoot @@ -212,6 +216,7 @@ final class MachineManagerFileTransferTests: XCTestCase { XCTAssertFalse(fixture.agent.execs.contains { $0.argv.first == "/bin/chown" }) let claimedRoot = try XCTUnwrap(fixture.agent.pushes.first?.localRoot) XCTAssertFalse(FileManager.default.fileExists(atPath: claimedRoot)) + XCTAssertNil(fixture.manager.currentStagedFileTransferStatus(id: "desktop")) } func testAsynchronousTransferFailureUsesStableSafeEvidence() throws { From 1fde99c486ddc9d1e87e757c2233ccaa6c11a1cf Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 02:11:33 +0000 Subject: [PATCH 094/338] feat(vm): recover active transfers in app --- Dory/Models/AppStore.swift | 86 +++++++++++++++++++++++++++++++- DoryTests/DorydClientTests.swift | 47 +++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 60942fb2..d388b652 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5113,6 +5113,7 @@ final class AppStore { private(set) var busyMachines: Set = [] private(set) var machineFileTransfers: [String: DorydMachineFileTransferOperation] = [:] + private var recoveringMachineFileTransfers: Set = [] var machineBusy: Bool { !busyMachines.isEmpty } func isMachineBusy(_ name: String) -> Bool { busyMachines.contains(name) } func machineFileTransfer(for name: String) -> DorydMachineFileTransferOperation? { @@ -5149,6 +5150,7 @@ final class AppStore { self.machines[index].cpuPercent = stats.cpuPercent self.machines[index].memoryDisplay = Self.machineMemoryDisplay(stats) } + recoverActiveFileTransfer(for: machine) } return machines } catch { @@ -5353,6 +5355,85 @@ final class AppStore { } } + private func recoverActiveFileTransfer(for machine: Machine) { + guard machineFileTransfers[machine.name] == nil, + !busyMachines.contains(machine.name), + recoveringMachineFileTransfers.insert(machine.name).inserted else { + return + } + Task { [weak self] in + await self?.recoverActiveFileTransfer(machineID: machine.name) + } + } + + private func recoverActiveFileTransfer(machineID: String) async { + defer { recoveringMachineFileTransfers.remove(machineID) } + let discovered: DorydMachineFileTransferOperation? + do { + discovered = try await dorydClient.machineTransferCurrent(machineID) + } catch { + return + } + guard let operation = discovered, + !operation.phase.isTerminal, + machineFileTransfers[machineID] == nil, + !busyMachines.contains(machineID) else { + return + } + + let operationID = operation.operationID + busyMachines.insert(machineID) + machineFileTransfers[machineID] = operation + defer { + if machineFileTransfers[machineID]?.operationID == operationID { + machineFileTransfers.removeValue(forKey: machineID) + busyMachines.remove(machineID) + } + } + + do { + var current = operation + while !current.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + current = try await dorydClient.machineTransferStatus( + machineID, + operationID: operationID + ) + guard current.operationID == operationID, + machineFileTransfers[machineID]?.operationID == operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineFileTransfers[machineID] = current + } + + switch current.phase { + case .completed: + guard let result = current.result else { + throw DoryMachineFileTransferUIError.invalidCompletion + } + let fileLabel = result.filesSent == 1 ? "file" : "files" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesSent), + countStyle: .file + ) + showSettingsSuccess( + "Sent \(result.filesSent) \(fileLabel) (\(bytes)) to \(result.guestDestination)." + ) + case .cancelled: + showSettingsSuccess("Cancelled the file transfer to \(machineID).") + case .failed: + let message = current.failure?.message ?? "The daemon reported a failed transfer." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + return + } catch { + actionError = "Could not restore the file transfer to \(machineID): \(Self.userFacingError(error))" + } + } + @discardableResult func transferFiles( _ fileURLs: [URL], @@ -5362,7 +5443,10 @@ final class AppStore { actionError = "Start \(machine.name) and update Dory Tools before sending files." return nil } - guard !busyMachines.contains(machine.name) else { return nil } + guard !busyMachines.contains(machine.name), + !recoveringMachineFileTransfers.contains(machine.name) else { + return nil + } busyMachines.insert(machine.name) defer { busyMachines.remove(machine.name) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 1a9b9514..bc4eb41a 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1213,6 +1213,53 @@ struct DorydClientTests { #expect(!store.shimRunning) } + @MainActor + @Test func appStoreRecoversAnActiveDaemonFileTransferAfterReconnect() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let operationID = String(repeating: "r", count: 32) + let active = service.machineTransferOperationResponse( + operationID: operationID, + phase: "transferring" + ) + service.setMachineTransferCurrentResponse([ + "schema": UInt16(1), + "active": true, + "operation": active, + ]) + service.setMachineTransferOperationResponse(active) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let store = AppStore( + dorydClient: DorydClient(endpoint: listener.endpoint), + useDorydEngine: true + ) + store.routeDockerCLI = false + await store.connectBackend() + store.loadMachines() + + try await waitUntil { + store.machineFileTransfer(for: "dev")?.operationID == operationID + && store.isMachineBusy("dev") + } + #expect(store.machineFileTransfer(for: "dev")?.phase == .transferring) + + service.setMachineTransferOperationResponse( + service.machineTransferOperationResponse( + operationID: operationID, + phase: "completed" + ) + ) + try await waitUntil { + store.machineFileTransfer(for: "dev") == nil + && !store.isMachineBusy("dev") + } + #expect(store.settingsNotice?.message.contains("Sent 1 file") == true) + } + @MainActor @Test func appStoreRoutesMachineLifecycleToDorydVMs() async throws { let base = "/tmp/dam-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 02:20:32 +0000 Subject: [PATCH 095/338] feat(guest): define cross-platform tools packages --- .../DoryGuestIntegrationPackage.swift | 306 ++++++++++++++++++ .../DoryGuestIntegrationPackageTests.swift | 157 +++++++++ 2 files changed, 463 insertions(+) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationPackageTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift new file mode 100644 index 00000000..916ad7ed --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift @@ -0,0 +1,306 @@ +import Foundation + +/// Stable capability names shared by guest-integration packages and qualification evidence. +/// A package may implement only a subset; callers must negotiate the exact version they need. +public enum DoryGuestIntegrationCapabilityID: String, Codable, Sendable, CaseIterable, Hashable { + case readiness + case gracefulShutdown = "graceful-shutdown" + case reboot + case clockSynchronization = "clock-sync" + case health + case displayTopology = "display-topology" + case displayResize = "display-resize" + case clipboardText = "clipboard-text" + case clipboardImage = "clipboard-image" + case sharedFolderDiscovery = "shared-folder-discovery" + case sharedFolderMountStatus = "shared-folder-mount-status" + case fileTransferPush = "sync-push" + case fileTransferPull = "sync-pull" + case networkIdentity = "network-identity" + case processLaunch = "exec" + case processInput = "exec-stdin" + case listenPorts = "ports-watch" + case telemetry + case snapshotQuiesce = "snapshot-quiesce" + case packageUpdate = "package-update" +} + +public struct DoryGuestIntegrationCapabilityDeclaration: Codable, Sendable, Equatable, Hashable { + public var id: DoryGuestIntegrationCapabilityID + public var version: UInt16 + + public init(id: DoryGuestIntegrationCapabilityID, version: UInt16) { + self.id = id + self.version = version + } +} + +/// The guest-native payload shape. These roles do not grant product support or driver trust. +public enum DoryGuestIntegrationArtifactRole: String, Codable, Sendable, CaseIterable, Hashable { + case linuxToolsArchive = "linux-tools-archive" + case windowsService = "windows-service" + case windowsDriver = "windows-driver" + case macOSPackage = "macos-package" +} + +public enum DoryGuestIntegrationSignatureKind: String, Codable, Sendable, CaseIterable, Hashable { + case doryEd25519 = "dory-ed25519" + case microsoftAuthenticode = "microsoft-authenticode" + case appleDeveloperID = "apple-developer-id" +} + +/// Non-secret signature identity recorded by packaging. A resolver must still verify the actual +/// bytes and signature chain before constructing trusted runtime facts. +public struct DoryGuestIntegrationSignatureEvidence: Codable, Sendable, Equatable, Hashable { + public var kind: DoryGuestIntegrationSignatureKind + public var identity: String + public var signatureSHA256: String + + public init( + kind: DoryGuestIntegrationSignatureKind, + identity: String, + signatureSHA256: String + ) { + self.kind = kind + self.identity = identity + self.signatureSHA256 = signatureSHA256.lowercased() + } +} + +public struct DoryGuestIntegrationArtifact: Codable, Sendable, Equatable, Hashable { + public var id: String + public var role: DoryGuestIntegrationArtifactRole + public var artifact: DoryVMResolverReference + public var sha256: String + public var byteCount: UInt64 + public var signature: DoryGuestIntegrationSignatureEvidence + + public init( + id: String, + role: DoryGuestIntegrationArtifactRole, + artifact: DoryVMResolverReference, + sha256: String, + byteCount: UInt64, + signature: DoryGuestIntegrationSignatureEvidence + ) { + self.id = id + self.role = role + self.artifact = artifact + self.sha256 = sha256.lowercased() + self.byteCount = byteCount + self.signature = signature + } +} + +/// Package maturity remains distinct from guest/backend support. `contract-only` is the required +/// state for Windows and macOS declarations until their authorization and physical qualification +/// gates pass. +public enum DoryGuestIntegrationPackageState: String, Codable, Sendable, CaseIterable, Hashable { + case contractOnly = "contract-only" + case qualified +} + +/// Audit evidence retained by a structurally qualified manifest. It is intentionally declarative; +/// only a signature-verifying daemon authority may turn it into trusted capability facts. +public struct DoryGuestIntegrationQualificationEvidence: Codable, Sendable, Equatable, Hashable { + public var manifestIdentity: String + public var manifestSHA256: String + public var suiteSHA256: String + public var sbomSHA256: String + public var attestationSHA256: String + public var qualifiedCapabilities: [DoryGuestIntegrationCapabilityDeclaration] + + public init( + manifestIdentity: String, + manifestSHA256: String, + suiteSHA256: String, + sbomSHA256: String, + attestationSHA256: String, + qualifiedCapabilities: [DoryGuestIntegrationCapabilityDeclaration] + ) { + self.manifestIdentity = manifestIdentity + self.manifestSHA256 = manifestSHA256.lowercased() + self.suiteSHA256 = suiteSHA256.lowercased() + self.sbomSHA256 = sbomSHA256.lowercased() + self.attestationSHA256 = attestationSHA256.lowercased() + self.qualifiedCapabilities = qualifiedCapabilities + } +} + +public enum DoryGuestIntegrationPackageValidationCode: String, Codable, Sendable, Hashable { + case unsupportedSchema = "unsupported-schema" + case invalidIdentity = "invalid-identity" + case invalidVersion = "invalid-version" + case unsupportedPlatform = "unsupported-platform" + case invalidCapabilities = "invalid-capabilities" + case invalidArtifacts = "invalid-artifacts" + case invalidSignature = "invalid-signature" + case incompleteWindowsPackage = "incomplete-windows-package" + case invalidQualification = "invalid-qualification" +} + +public struct DoryGuestIntegrationPackageValidationIssue: Sendable, Equatable, Hashable { + public var code: DoryGuestIntegrationPackageValidationCode + public var field: String + + public init(code: DoryGuestIntegrationPackageValidationCode, field: String) { + self.code = code + self.field = field + } +} + +/// Versioned packaging contract for Dory Tools across guest families. This type deliberately does +/// not participate in capability resolution by itself: parsing a manifest is never a trust event. +public struct DoryGuestIntegrationPackageManifest: Codable, Sendable, Equatable { + public static let schemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var manifestIdentity: String + public var packageVersion: String + public var guest: DoryGuestPlatform + public var protocolVersion: UInt16 + public var state: DoryGuestIntegrationPackageState + public var capabilities: [DoryGuestIntegrationCapabilityDeclaration] + public var artifacts: [DoryGuestIntegrationArtifact] + public var qualification: DoryGuestIntegrationQualificationEvidence? + + public init( + manifestIdentity: String, + packageVersion: String, + guest: DoryGuestPlatform, + protocolVersion: UInt16, + state: DoryGuestIntegrationPackageState, + capabilities: [DoryGuestIntegrationCapabilityDeclaration], + artifacts: [DoryGuestIntegrationArtifact], + qualification: DoryGuestIntegrationQualificationEvidence? = nil + ) { + schemaVersion = Self.schemaVersion + self.manifestIdentity = manifestIdentity + self.packageVersion = packageVersion + self.guest = guest + self.protocolVersion = protocolVersion + self.state = state + self.capabilities = capabilities + self.artifacts = artifacts + self.qualification = qualification + } + + public var isValidForPersistence: Bool { validationIssues().isEmpty } + + public func validationIssues() -> [DoryGuestIntegrationPackageValidationIssue] { + var issues: [DoryGuestIntegrationPackageValidationIssue] = [] + func add(_ code: DoryGuestIntegrationPackageValidationCode, _ field: String) { + issues.append(.init(code: code, field: field)) + } + + if schemaVersion != Self.schemaVersion { add(.unsupportedSchema, "schemaVersion") } + if !Self.isIdentifier(manifestIdentity) { add(.invalidIdentity, "manifestIdentity") } + if !Self.isBoundedLabel(packageVersion, maximumBytes: 64) { + add(.invalidVersion, "packageVersion") + } + if protocolVersion == 0 { add(.invalidVersion, "protocolVersion") } + if guest.family != .linux, guest.architecture != .arm64 { + add(.unsupportedPlatform, "guest.architecture") + } + + let capabilityOrder = capabilities.map { $0.id.rawValue } + if capabilities.isEmpty + || capabilityOrder != capabilityOrder.sorted() + || Set(capabilityOrder).count != capabilityOrder.count + || capabilities.contains(where: { $0.version == 0 }) + || !capabilities.contains(where: { $0.id == .readiness }) { + add(.invalidCapabilities, "capabilities") + } + + let artifactOrder = artifacts.map(\.id) + if artifacts.isEmpty + || artifactOrder != artifactOrder.sorted() + || Set(artifactOrder).count != artifactOrder.count + || artifacts.contains(where: { + !Self.isIdentifier($0.id) + || !$0.artifact.isValidForPersistence + || !Self.isSHA256($0.sha256) + || $0.byteCount == 0 + }) { + add(.invalidArtifacts, "artifacts") + } + + let allowedRoles: Set + let requiredSignature: DoryGuestIntegrationSignatureKind + switch guest.family { + case .linux: + allowedRoles = [.linuxToolsArchive] + requiredSignature = .doryEd25519 + case .windows: + allowedRoles = [.windowsService, .windowsDriver] + requiredSignature = .microsoftAuthenticode + let roles = Set(artifacts.map(\.role)) + if !roles.contains(.windowsService) || !roles.contains(.windowsDriver) { + add(.incompleteWindowsPackage, "artifacts") + } + case .macOS: + allowedRoles = [.macOSPackage] + requiredSignature = .appleDeveloperID + } + if artifacts.contains(where: { !allowedRoles.contains($0.role) }) { + add(.invalidArtifacts, "artifacts.role") + } + if artifacts.contains(where: { + $0.signature.kind != requiredSignature + || !Self.isBoundedLabel($0.signature.identity, maximumBytes: 256) + || !Self.isSHA256($0.signature.signatureSHA256) + }) { + add(.invalidSignature, "artifacts.signature") + } + + switch state { + case .contractOnly: + if qualification != nil { add(.invalidQualification, "qualification") } + case .qualified: + guard let qualification else { + add(.invalidQualification, "qualification") + return issues + } + let qualified = qualification.qualifiedCapabilities + if qualification.manifestIdentity != manifestIdentity + || !Self.isSHA256(qualification.manifestSHA256) + || !Self.isSHA256(qualification.suiteSHA256) + || !Self.isSHA256(qualification.sbomSHA256) + || !Self.isSHA256(qualification.attestationSHA256) + || qualified != capabilities { + add(.invalidQualification, "qualification") + } + } + return issues + } + + private static func isIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...96).contains(bytes.count), let first = bytes.first, + isASCIIAlphaNumeric(first) else { return false } + return bytes.dropFirst().allSatisfy { + isASCIIAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 + } + } + + private static func isBoundedLabel(_ value: String, maximumBytes: Int) -> Bool { + let bytes = Array(value.utf8) + return (1...maximumBytes).contains(bytes.count) + && value.unicodeScalars.allSatisfy { + !CharacterSet.controlCharacters.contains($0) + } + && value == value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isSHA256(_ value: String) -> Bool { + value.count == 64 && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + } + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationPackageTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationPackageTests.swift new file mode 100644 index 00000000..0c44699a --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationPackageTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Guest integration package contracts") +struct DoryGuestIntegrationPackageTests { + private let digest = String(repeating: "a", count: 64) + + @Test("Linux tools declare a canonical independently versioned capability set") + func linuxToolsPackage() throws { + let manifest = package( + family: .linux, + roles: [.linuxToolsArchive], + signature: .doryEd25519 + ) + + #expect(manifest.isValidForPersistence) + #expect(manifest.state == .contractOnly) + let decoded = try JSONDecoder().decode( + DoryGuestIntegrationPackageManifest.self, + from: JSONEncoder().encode(manifest) + ) + #expect(decoded == manifest) + } + + @Test("Windows contract requires an Authenticode service and driver without claiming support") + func windowsServiceAndDriverContract() { + let complete = package( + family: .windows, + roles: [.windowsDriver, .windowsService], + signature: .microsoftAuthenticode + ) + let missingDriver = package( + family: .windows, + roles: [.windowsService], + signature: .microsoftAuthenticode + ) + + #expect(complete.isValidForPersistence) + #expect(complete.state == .contractOnly) + #expect(missingDriver.validationIssues().contains { + $0.code == .incompleteWindowsPackage + }) + } + + @Test("macOS contract permits only Developer ID packages on ARM64") + func macOSPackageContract() { + let valid = package( + family: .macOS, + roles: [.macOSPackage], + signature: .appleDeveloperID + ) + var wrongArchitecture = valid + wrongArchitecture.guest.architecture = .x86_64 + var wrongPayload = valid + wrongPayload.artifacts[0].role = .windowsDriver + + #expect(valid.isValidForPersistence) + #expect(wrongArchitecture.validationIssues().contains { + $0.code == .unsupportedPlatform + }) + #expect(wrongPayload.validationIssues().contains { + $0.code == .invalidArtifacts + }) + } + + @Test("signature systems cannot be substituted across guest families") + func signatureSubstitutionRejected() { + let windowsWithAppleSignature = package( + family: .windows, + roles: [.windowsDriver, .windowsService], + signature: .appleDeveloperID + ) + #expect(windowsWithAppleSignature.validationIssues().contains { + $0.code == .invalidSignature + }) + } + + @Test("qualification binds the exact manifest and complete capability set") + func qualificationBinding() { + var manifest = package( + family: .linux, + roles: [.linuxToolsArchive], + signature: .doryEd25519 + ) + manifest.state = .qualified + #expect(manifest.validationIssues().contains { $0.code == .invalidQualification }) + + manifest.qualification = DoryGuestIntegrationQualificationEvidence( + manifestIdentity: manifest.manifestIdentity, + manifestSHA256: digest, + suiteSHA256: digest, + sbomSHA256: digest, + attestationSHA256: digest, + qualifiedCapabilities: manifest.capabilities + ) + #expect(manifest.isValidForPersistence) + + manifest.qualification?.qualifiedCapabilities.removeLast() + #expect(manifest.validationIssues().contains { $0.code == .invalidQualification }) + } + + @Test("canonical ordering, versions, resolver keys, and digests fail closed") + func malformedAuthorityRejected() { + var manifest = package( + family: .linux, + roles: [.linuxToolsArchive], + signature: .doryEd25519 + ) + manifest.capabilities.reverse() + manifest.artifacts[0].artifact = .init(namespace: "path", identifier: "/tmp/payload") + manifest.artifacts[0].sha256 = String(repeating: "A", count: 64) + manifest.artifacts[0].signature.signatureSHA256 = "bad" + + let codes = Set(manifest.validationIssues().map(\.code)) + #expect(codes.contains(.invalidCapabilities)) + #expect(codes.contains(.invalidArtifacts)) + #expect(codes.contains(.invalidSignature)) + } + + private func package( + family: DoryGuestFamily, + roles: [DoryGuestIntegrationArtifactRole], + signature: DoryGuestIntegrationSignatureKind + ) -> DoryGuestIntegrationPackageManifest { + let capabilities = [ + DoryGuestIntegrationCapabilityDeclaration(id: .readiness, version: 1), + DoryGuestIntegrationCapabilityDeclaration(id: .telemetry, version: 1), + ].sorted { $0.id.rawValue < $1.id.rawValue } + let artifacts = roles.enumerated().map { index, role in + DoryGuestIntegrationArtifact( + id: "payload-\(index)-\(role.rawValue)", + role: role, + artifact: .init( + namespace: "guest-tools", + identifier: "\(family.rawValue)-arm64-\(index)" + ), + sha256: digest, + byteCount: 1024, + signature: .init( + kind: signature, + identity: "Dory Guest Tools Test Identity", + signatureSHA256: digest + ) + ) + }.sorted { $0.id < $1.id } + return DoryGuestIntegrationPackageManifest( + manifestIdentity: "dory-tools-\(family.rawValue)-arm64-v1", + packageVersion: "1.0.0", + guest: .init(family: family, architecture: .arm64), + protocolVersion: 1, + state: .contractOnly, + capabilities: capabilities, + artifacts: artifacts + ) + } +} From e24215af13f56b2f68d23b55d758c9ec17c6eae5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 02:26:47 +0000 Subject: [PATCH 096/338] test(vm): repair runtime planning fixture --- ...onVirtualMachineRuntimePlanningTests.swift | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 97cc9442..01dd77ca 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -176,7 +176,8 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { } } -private struct Fixture { +private final class Fixture { + private let root: URL let definition: DoryVirtualMachineDefinition let canonicalDefinition: Data let machine: DoryMachineConfiguration @@ -189,6 +190,26 @@ private struct Fixture { let coordinator: DoryDaemonVirtualMachinePlanningCoordinator init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-runtime-planning-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let bootBundle = root.appendingPathComponent("installed-linux.boot") + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("kernel".utf8), + initrd: Data("initrd".utf8), + kernelISOPath: "/boot/kernel", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: bootBundle.path + ) let bootArtifact = DoryVMResolverReference( namespace: "artifact", identifier: "ubuntu-runtime" @@ -242,9 +263,9 @@ private struct Fixture { .canonicalDefinitionData(definition) machine = DoryMachineConfiguration( id: definition.identity.id, - kernelPath: "/fixture/direct-kernel", + kernelPath: bootBundle.path, rootfsPath: "/fixture/linux.raw", - bootMode: .linuxKernel, + bootMode: .efi, displayMode: .desktop ) media = DoryBootMedia( @@ -336,6 +357,10 @@ private struct Fixture { ) } + deinit { + try? FileManager.default.removeItem(at: root) + } + func planningRequest() -> DoryDaemonVirtualMachinePlanningRequest { DoryDaemonVirtualMachinePlanningRequest( definition: definition, From 4d98ade0aa0100e1b0fead1f3dbe10be085f0dc5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 02:27:41 +0000 Subject: [PATCH 097/338] feat(vm): support disconnected VZ networking --- .../DoryVirtualMachineCapabilities.swift | 11 ++++++- .../DoryVirtualMachineDefinition.swift | 1 + .../Sources/DoryVMMKit/DoryVMM.swift | 22 +++++++++---- ...yDaemonVirtualMachineRuntimePlanning.swift | 1 + .../DoryVirtualMachineCapabilitiesTests.swift | 3 +- .../DoryVirtualMachineDefinitionTests.swift | 13 ++++++++ ...onVirtualMachineRuntimePlanningTests.swift | 15 +++++++++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 31 +++++++++++++++++++ 8 files changed, 89 insertions(+), 8 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 457c267c..9593dbab 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -1327,7 +1327,16 @@ public enum DoryAppleSiliconCapabilityEvaluator { switch devices.networkAttachment { case .sharedNAT: break - case .disconnected, .bridged, .isolated: + case .disconnected: + guard request.guest.family == .linux, + request.backend == .appleVirtualizationFramework else { + return unavailable( + tier: tier, + code: .networkAttachmentUnsupported, + message: "The selected backend does not implement disconnected networking." + ) + } + case .bridged, .isolated: return unavailable( tier: tier, code: .networkAttachmentUnsupported, diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 033be09b..9f10db20 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -174,6 +174,7 @@ public struct DoryVMStorageAttachment: Codable, Sendable, Equatable { } public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable { + case disconnected case sharedNAT = "shared-nat" case bridged case isolated diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index ef321a92..d939af67 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -373,11 +373,18 @@ public enum DoryVZConfigurationBuilder { } } if let devices = spec.resolvedDevices { - guard devices.networkAttachment == .sharedNAT else { + guard devices.networkAttachment == .sharedNAT + || devices.networkAttachment == .disconnected else { throw DoryVZMachineError.validation( "resolved device contract contains a device not implemented by this VZ launch" ) } + if devices.networkAttachment == .disconnected, + networkAttachment != nil || spec.nativeIPv6 { + throw DoryVZMachineError.validation( + "disconnected networking cannot attach a host network backend" + ) + } guard devices.directorySharing == !spec.shares.isEmpty else { throw DoryVZMachineError.validation( "resolved directory-sharing contract does not match the launch shares" @@ -427,12 +434,15 @@ public enum DoryVZConfigurationBuilder { configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] configuration.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()] configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()] - let network = VZVirtioNetworkDeviceConfiguration() - network.attachment = networkAttachment ?? VZNATNetworkDeviceAttachment() - if networkAttachment != nil, let macAddress = VZMACAddress(string: DoryVMMNativeIPv6Plan.guestMAC) { - network.macAddress = macAddress + if spec.resolvedDevices?.networkAttachment != .disconnected { + let network = VZVirtioNetworkDeviceConfiguration() + network.attachment = networkAttachment ?? VZNATNetworkDeviceAttachment() + if networkAttachment != nil, + let macAddress = VZMACAddress(string: DoryVMMNativeIPv6Plan.guestMAC) { + network.macAddress = macAddress + } + configuration.networkDevices = [network] } - configuration.networkDevices = [network] if spec.displayMode == .desktop { let devices = spec.resolvedDevices diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index e42e53ce..9adac5fb 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -515,6 +515,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda ) -> DoryVirtualMachineDeviceCapabilityRequest { let networkAttachment: DoryVirtualMachineNetworkAttachmentMode switch definition.networkMode { + case .disconnected: networkAttachment = .disconnected case .sharedNAT: networkAttachment = .sharedNAT case .bridged: networkAttachment = .bridged case .isolated: networkAttachment = .isolated diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index be26b2f0..62c49b29 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -857,7 +857,8 @@ struct VirtualMachineCapabilitiesTests { ) #expect(resolved.resolvedDevices == .minimumBootable) - #expect(disconnected.availability.reason?.code == .networkAttachmentUnsupported) + #expect(disconnected.availability.isUsable) + #expect(disconnected.resolvedDevices?.networkAttachment == .disconnected) #expect(qualifiedVZSharing.availability.isUsable) #expect(qualifiedVZSharing.resolvedDevices?.directorySharing == true) #expect(qualifiedRawDesktop.availability.isUsable) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index b3fc58b9..04317a95 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -56,6 +56,19 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.isValid) } + @Test("disconnected networking is durable typed intent") + func disconnectedNetworkRoundTrip() throws { + var original = linuxDefinition() + original.networkMode = .disconnected + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: data) + + #expect(decoded.networkMode == .disconnected) + #expect(decoded == original) + #expect(decoded.isValid) + } + @Test("guest identity and clipboard policies are bounded typed intent") func guestIdentityAndClipboardValidation() { var definition = linuxDefinition() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 01dd77ca..06373b8d 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -5,6 +5,21 @@ import Testing @Suite("Daemon virtual-machine runtime planning") struct DoryDaemonVirtualMachineRuntimePlanningTests { + @Test("definition networking maps exactly into capability planning") + func definitionNetworkingMapsExactly() throws { + let fixture = try Fixture() + var definition = fixture.definition + definition.networkMode = .disconnected + + let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) + + #expect(devices.networkAttachment == .disconnected) + #expect(devices.audioInput == definition.audio.inputEnabled) + #expect(devices.audioOutput == definition.audio.outputEnabled) + #expect(devices.keyboard == definition.input.keyboardEnabled) + #expect(devices.pointer == definition.input.pointerEnabled) + } + @Test("planning constructs persists and exposes an exact plan digest") func planningSuccess() throws { let fixture = try Fixture() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 014b8a63..60ccaf3c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -455,6 +455,37 @@ final class DoryVMMKitTests: XCTestCase { try assertShellSyntax("\(base)/dorycfg/boot.sh") } + func testResolvedDisconnectedVZConfigurationOmitsEveryNetworkDevice() throws { + let base = "/tmp/dory-vmm-disconnected-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 02:32:14 +0000 Subject: [PATCH 098/338] feat(vm): support disconnected raw-hv networking --- Packages/ContainerizationEngine/Package.swift | 1 + .../Sources/dory-hv/DesktopMode.swift | 114 +++++++++++++----- .../DoryHVTests/DesktopNetworkPlanTests.swift | 42 +++++++ .../DoryVirtualMachineCapabilities.swift | 3 +- .../DoryVirtualMachineCapabilitiesTests.swift | 13 ++ 5 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift index 8a75d2ce..3e972078 100644 --- a/Packages/ContainerizationEngine/Package.swift +++ b/Packages/ContainerizationEngine/Package.swift @@ -61,6 +61,7 @@ let package = Package( "DoryHV", "dory-hv", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DoryOperations", package: "dory-core-swift"), ] ), ] diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 48f0143e..f1eb4407 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -29,6 +29,27 @@ enum DesktopMode { var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? } + enum NetworkPlan: Equatable { + case sharedNAT + case disconnected + + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { + switch resolvedDevices?.networkAttachment ?? .sharedNAT { + case .sharedNAT: + self = .sharedNAT + case .disconnected: + self = .disconnected + case .bridged, .isolated: + throw VMError.bootFailure( + "resolved device contract contains a network mode not implemented by raw-HV" + ) + } + } + + var startsGVProxy: Bool { self == .sharedNAT } + var attachesNetworkDevice: Bool { self == .sharedNAT } + } + private struct ResolvedGraphics { var backend: DoryDesktopGraphicsBackend var renderer: VirglRenderer? @@ -54,7 +75,7 @@ enum DesktopMode { private let window: NSWindow private let vsock: VirtioVsock private let audio: DoryMacAudioBackend - private let gvproxy: Process + private let gvproxy: Process? private let networkSocketPaths: [String] private let agentBridge: GuestVsockSocketBridge private let shellBridge: GuestVsockSocketBridge @@ -79,12 +100,8 @@ enum DesktopMode { exactLevel: configuration.resolvedGraphics ) self.graphicsBackend = resolvedGraphics.backend + let networkPlan = try NetworkPlan(resolvedDevices: configuration.resolvedDevices) if let devices = configuration.resolvedDevices { - guard devices.networkAttachment == .sharedNAT else { - throw VMError.bootFailure( - "resolved device contract contains a device not implemented by raw-HV" - ) - } guard devices.keyboard == devices.pointer else { throw VMError.bootFailure( "raw-HV input is a combined keyboard/pointer device" @@ -155,26 +172,15 @@ enum DesktopMode { let runtimeDirectory = (configuration.agentSocketPath as NSString).deletingLastPathComponent try FileManager.default.createDirectory(atPath: runtimeDirectory, withIntermediateDirectories: true) let token = String(configuration.machineID.prefix(12)) - let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" - let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" - let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" - self.networkSocketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] - for path in networkSocketPaths { unlink(path) } - - let gvproxy = Process() - gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath) - gvproxy.arguments = GVProxyDesktopLaunchPlan.arguments( - mtu: DoryNetworkMTU.resolved(), - datapathSocket: gvproxySocket, - apiSocket: apiSocket + let networkRuntime = try Self.prepareNetwork( + plan: networkPlan, + gvproxyPath: configuration.gvproxyPath, + runtimeDirectory: runtimeDirectory, + token: token ) - gvproxy.standardOutput = FileHandle.standardError - gvproxy.standardError = FileHandle.standardError - try gvproxy.run() - self.gvproxy = gvproxy + self.gvproxy = networkRuntime.process + self.networkSocketPaths = networkRuntime.socketPaths do { - try Self.waitForSocket(path: gvproxySocket, process: gvproxy) - let network = try VirtioNet(socketPath: vmNetworkSocket, remotePath: gvproxySocket) var backends: [VirtioDeviceBackend] = [ try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), gpu, @@ -201,7 +207,9 @@ enum DesktopMode { requestQueueCount: min(8, max(1, configuration.cpuCount)) )) } - backends.append(network) + if let network = networkRuntime.backend { + backends.append(network) + } for (slot, backend) in backends.enumerated() { let transport = Self.attachBackend(backend, to: machine, slot: slot) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { @@ -213,7 +221,9 @@ enum DesktopMode { } try machine.loadBootPayload() } catch { - ChildProcessTerminator.terminateAndReap(gvproxy) + if let gvproxy = networkRuntime.process { + ChildProcessTerminator.terminateAndReap(gvproxy) + } for path in networkSocketPaths { unlink(path) } throw error } @@ -438,7 +448,9 @@ enum DesktopMode { clipboard?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() - ChildProcessTerminator.terminateAndReap(gvproxy) + if let gvproxy { + ChildProcessTerminator.terminateAndReap(gvproxy) + } unlink(configuration.agentSocketPath) unlink(configuration.shellSocketPath) for path in networkSocketPaths { unlink(path) } @@ -539,6 +551,54 @@ enum DesktopMode { throw VMError.bootFailure("gvproxy did not publish its network socket") } + private struct NetworkRuntime { + let process: Process? + let socketPaths: [String] + let backend: VirtioNet? + } + + private static func prepareNetwork( + plan: NetworkPlan, + gvproxyPath: String, + runtimeDirectory: String, + token: String + ) throws -> NetworkRuntime { + guard plan.startsGVProxy, plan.attachesNetworkDevice else { + return NetworkRuntime(process: nil, socketPaths: [], backend: nil) + } + let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" + let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" + let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" + let socketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] + for path in socketPaths { unlink(path) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: gvproxyPath) + process.arguments = GVProxyDesktopLaunchPlan.arguments( + mtu: DoryNetworkMTU.resolved(), + datapathSocket: gvproxySocket, + apiSocket: apiSocket + ) + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + do { + try process.run() + try waitForSocket(path: gvproxySocket, process: process) + return NetworkRuntime( + process: process, + socketPaths: socketPaths, + backend: try VirtioNet( + socketPath: vmNetworkSocket, + remotePath: gvproxySocket + ) + ) + } catch { + ChildProcessTerminator.terminateAndReap(process) + for path in socketPaths { unlink(path) } + throw error + } + } + @discardableResult private static func attachBackend( _ backend: VirtioDeviceBackend, diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift new file mode 100644 index 00000000..a90d1c4f --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift @@ -0,0 +1,42 @@ +import DoryHV +import DoryOperations +import Testing +@testable import dory_hv + +@Suite struct DesktopNetworkPlanTests { + @Test func legacyAndSharedNATLaunchGVProxyAndVirtioNet() throws { + for devices in [ + Optional.none, + DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .sharedNAT), + ] { + let plan = try DesktopMode.NetworkPlan(resolvedDevices: devices) + #expect(plan == .sharedNAT) + #expect(plan.startsGVProxy) + #expect(plan.attachesNetworkDevice) + } + } + + @Test func disconnectedLaunchesNoNetworkProcessOrDevice() throws { + let devices = DoryVirtualMachineDeviceCapabilityRequest( + networkAttachment: .disconnected + ) + let plan = try DesktopMode.NetworkPlan(resolvedDevices: devices) + + #expect(plan == .disconnected) + #expect(!plan.startsGVProxy) + #expect(!plan.attachesNetworkDevice) + } + + @Test func unimplementedNetworkModesFailClosed() { + for mode in [ + DoryVirtualMachineNetworkAttachmentMode.bridged, + .isolated, + ] { + #expect(throws: VMError.self) { + _ = try DesktopMode.NetworkPlan( + resolvedDevices: .init(networkAttachment: mode) + ) + } + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 9593dbab..b1241a67 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -1329,7 +1329,8 @@ public enum DoryAppleSiliconCapabilityEvaluator { break case .disconnected: guard request.guest.family == .linux, - request.backend == .appleVirtualizationFramework else { + request.backend == .appleVirtualizationFramework + || request.backend == .doryHypervisor else { return unavailable( tier: tier, code: .networkAttachmentUnsupported, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 62c49b29..f255cf2b 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -837,6 +837,8 @@ struct VirtualMachineCapabilitiesTests { dynamicDisplay: true, gracefulShutdown: true ) + var qualifiedRawDisconnectedDevices = qualifiedRawDesktopDevices + qualifiedRawDisconnectedDevices.networkAttachment = .disconnected let qualifiedRawDesktop = evaluate( family: .linux, media: .installedLinuxBootBundle, @@ -846,6 +848,15 @@ struct VirtualMachineCapabilitiesTests { mediaArtifactSHA256: Self.guestArtifactSHA256, devices: qualifiedRawDesktopDevices ) + let qualifiedRawDisconnected = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: qualifiedRawDisconnectedDevices + ) let unsupportedAudioShape = evaluate( family: .linux, media: .installedLinuxBootBundle, @@ -863,6 +874,8 @@ struct VirtualMachineCapabilitiesTests { #expect(qualifiedVZSharing.resolvedDevices?.directorySharing == true) #expect(qualifiedRawDesktop.availability.isUsable) #expect(qualifiedRawDesktop.resolvedDevices == qualifiedRawDesktopDevices) + #expect(qualifiedRawDisconnected.availability.isUsable) + #expect(qualifiedRawDisconnected.resolvedDevices == qualifiedRawDisconnectedDevices) #expect(unsupportedAudioShape.availability.reason?.code == .audioOutputUnsupported) } From 1d736f98ba1bf4b2ba5c5db5781af7a3e28b8433 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 02:44:29 +0000 Subject: [PATCH 099/338] feat(vm): expose typed network modes --- Dory/Features/Machines/MachinesView.swift | 23 ++++- Dory/Features/Sheets/NewMachineSheet.swift | 36 +++++-- Dory/Runtime/Doryd/DorydClient.swift | 28 +++++- DoryTests/DorydClientTests.swift | 18 +++- DoryTests/NewMachineSettingsTests.swift | 12 ++- .../DoryMachineTypedWriteAuthority.swift | 95 ++++++++++++++++++- .../Sources/DorydKit/MachineManager.swift | 28 +++++- dory-core-swift/Sources/dorydctl/main.swift | 4 +- .../DoryMachineTypedWriteAuthorityTests.swift | 51 +++++++++- .../DorydKitTests/DorydServiceTests.swift | 13 ++- 10 files changed, 282 insertions(+), 26 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 8c25eb5f..382c9c17 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -607,6 +607,7 @@ private struct MachineEditSheet: View { @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic + @State private var networkMode = DoryVMNetworkMode.sharedNAT @State private var typedSettings = DorydMachineTypedSettings() private struct MountRow: Identifiable, Hashable { @@ -626,6 +627,7 @@ private struct MachineEditSheet: View { VStack(alignment: .leading, spacing: 18) { warning machineTypeBlock + networkBlock runtimeBlock clipboardBlock resourceRow @@ -664,6 +666,7 @@ private struct MachineEditSheet: View { } ?? .bidirectional runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic + networkMode = typedSettings.networkMode ?? .sharedNAT mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) } } @@ -756,6 +759,23 @@ private struct MachineEditSheet: View { } } + private var networkBlock: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("NETWORK") + Picker("Network", selection: $networkMode) { + Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Disconnected").tag(DoryVMNetworkMode.disconnected) + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-network-mode") + Text(networkMode == .disconnected + ? "Disconnected attaches no virtual network device." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + @ViewBuilder private var clipboardBlock: some View { if displayMode == .desktop, machine.bootMode != .efi { VStack(alignment: .leading, spacing: 8) { @@ -940,6 +960,7 @@ private struct MachineEditSheet: View { guard !host.isEmpty, !guest.isEmpty else { return nil } return MountPair(host: host, guest: guest, readOnly: row.readOnly) } + typedSettings.networkMode = networkMode if displayMode == .desktop, machine.bootMode != .efi { let previousUsername = typedSettings.guestIdentityIntent.account?.username ?? "dory" if previousUsername != normalizedGuestUsername { @@ -978,7 +999,7 @@ private struct MachineEditSheet: View { memoryMB: memoryGB * 1024, mounts: mounts, env: [:], - virtualMachineSettings: machine.bootMode == .efi ? nil : typedSettings, + virtualMachineSettings: typedSettings, address: address.trimmingCharacters(in: .whitespacesAndNewlines), displayMode: displayMode, bootMode: machine.bootMode diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index b5cea12b..76bd9d99 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -17,6 +17,7 @@ struct NewMachineSheet: View { @State private var installerISOPath = "" @State private var installerISOCheck: InstallerISOCheck = .none @State private var diskSizeGB = 64 + @State private var networkMode = DoryVMNetworkMode.sharedNAT private enum InstallerISOCheck: Equatable { case none @@ -88,6 +89,7 @@ struct NewMachineSheet: View { machineKindSection devEnvironmentSection identitySection + networkBlock optionsRow advancedSection } @@ -513,6 +515,23 @@ struct NewMachineSheet: View { } } + private var networkBlock: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("NETWORK") + Picker("Network", selection: $networkMode) { + Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Disconnected").tag(DoryVMNetworkMode.disconnected) + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("new-machine-network-mode") + Text(networkMode == .disconnected + ? "Disconnected attaches no virtual network device." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + private var advancedSection: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -869,9 +888,10 @@ struct NewMachineSheet: View { displayMode: MachineDisplayMode = .desktop, desktopDistro: DesktopMachineDistro = .debian, guestUsername: String = "dory", - guestUID: uid_t = getuid() + guestUID: uid_t = getuid(), + networkMode: DoryVMNetworkMode = .sharedNAT ) -> MachineSettings { - let typedSettings: DorydMachineTypedSettings? + let typedSettings: DorydMachineTypedSettings if displayMode == .desktop { typedSettings = DorydMachineTypedSettings( guestIdentityIntent: DoryVMGuestIdentityIntent( @@ -888,10 +908,11 @@ struct NewMachineSheet: View { ), clipboardPolicy: .legacyDesktop(.bidirectional), runtimePreference: .automatic, - graphicsPreference: .automatic + graphicsPreference: .automatic, + networkMode: networkMode ) } else { - typedSettings = nil + typedSettings = DorydMachineTypedSettings(networkMode: networkMode) } return MachineSettings( cpus: cpus, @@ -935,7 +956,8 @@ struct NewMachineSheet: View { address: trimmedAddress, displayMode: displayMode, desktopDistro: desktopDistro, - guestUsername: normalizedGuestUsername + guestUsername: normalizedGuestUsername, + networkMode: networkMode ) if customISOInstall { settings.bootMode = .efi @@ -944,7 +966,9 @@ struct NewMachineSheet: View { // EFI boot/media authority is explicit. It must never be inferred from a reserved // environment marker or carry managed-desktop provisioning intent. settings.env = [:] - settings.virtualMachineSettings = nil + settings.virtualMachineSettings = DorydMachineTypedSettings( + networkMode: networkMode + ) } return settings } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 702f4c8a..19c40843 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -97,17 +97,20 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { var clipboardPolicy: DoryVMClipboardPolicy? = nil var runtimePreference: DoryDesktopVMMPreference? = nil var graphicsPreference: DoryDesktopGraphicsPreference? = nil + var networkMode: DoryVMNetworkMode? = nil init( guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, clipboardPolicy: DoryVMClipboardPolicy? = nil, runtimePreference: DoryDesktopVMMPreference? = nil, - graphicsPreference: DoryDesktopGraphicsPreference? = nil + graphicsPreference: DoryDesktopGraphicsPreference? = nil, + networkMode: DoryVMNetworkMode? = nil ) { self.guestIdentityIntent = guestIdentityIntent self.clipboardPolicy = clipboardPolicy self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference + self.networkMode = networkMode } init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) { @@ -147,6 +150,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { account: account.isValidForPersistence ? account : nil, desktop: desktop ) + networkMode = .sharedNAT if displayMode == .desktop { let effectiveClipboard = DoryDesktopClipboardPolicy( environment: legacyEnvironment @@ -172,6 +176,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { && clipboardPolicy == nil && runtimePreference == nil && graphicsPreference == nil + && networkMode == nil } var xpcDictionary: NSDictionary { @@ -211,6 +216,9 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { if let graphicsPreference { result["desktopGraphicsPreference"] = graphicsPreference.rawValue } + if let networkMode { + result["networkMode"] = networkMode.rawValue + } return result as NSDictionary } @@ -226,6 +234,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { hasher.combine(clipboardPolicy?.files.rawValue) hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) + hasher.combine(networkMode?.rawValue) } } @@ -301,6 +310,12 @@ nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { key: "desktopGraphicsPreference", into: &result ) + Self.encodeEnum( + baseline.networkMode, + desired.networkMode, + key: "networkMode", + into: &result + ) return result as NSDictionary } @@ -2167,7 +2182,7 @@ nonisolated final class DorydClient: @unchecked Sendable { let keys = value.allKeys as? [String], Set(keys).isSubset(of: [ "guestIdentityIntent", "clipboardPolicy", - "desktopRuntimePreference", "desktopGraphicsPreference", + "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", ]), keys.count == Set(keys).count else { return nil } @@ -2268,11 +2283,18 @@ nonisolated final class DorydClient: @unchecked Sendable { let parsed = DoryDesktopGraphicsPreference(rawValue: raw) else { return nil } graphics = parsed } else { graphics = nil } + let networkMode: DoryVMNetworkMode? + if let encoded = value["networkMode"] { + guard let raw = encoded as? String, + let parsed = DoryVMNetworkMode(rawValue: raw) else { return nil } + networkMode = parsed + } else { networkMode = nil } return ParsedMachineTypedSettings(value: DorydMachineTypedSettings( guestIdentityIntent: identity, clipboardPolicy: clipboard, runtimePreference: runtime, - graphicsPreference: graphics + graphicsPreference: graphics, + networkMode: networkMode )) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index bc4eb41a..07462cf8 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -166,6 +166,16 @@ struct DorydClientTests { #expect(defaults.clipboardPolicy == .legacyDesktop(.bidirectional)) #expect(defaults.runtimePreference == .automatic) #expect(defaults.graphicsPreference == .automatic) + #expect(defaults.networkMode == .sharedNAT) + + var disconnected = defaults + disconnected.networkMode = .disconnected + let networkWire = DorydMachineTypedSettingsPatch( + baseline: defaults, + desired: disconnected + ).xpcDictionary + #expect(networkWire.count == 1) + #expect(networkWire["networkMode"] as? String == "disconnected") for (legacy, expected) in [("1", DoryDesktopGraphicsPreference.virgl), ("0", DoryDesktopGraphicsPreference.virglVenus)] { @@ -340,6 +350,7 @@ struct DorydClientTests { ] as NSDictionary, "desktopRuntimePreference": "accelerated", "desktopGraphicsPreference": "virgl-venus", + "networkMode": "disconnected", ]) let delegate = FakeDorydListenerDelegate(service: service) listener.delegate = delegate @@ -354,6 +365,7 @@ struct DorydClientTests { == "ubuntu") #expect(status.typedSettings?.runtimePreference == .accelerated) #expect(status.typedSettings?.graphicsPreference == .virglVenus) + #expect(status.typedSettings?.networkMode == .disconnected) service.setMachineTypedSettings("dev", ["unknown": "claim"]) await #expect(throws: (any Error).self) { @@ -407,7 +419,8 @@ struct DorydClientTests { ), clipboardPolicy: .legacyDesktop(.bidirectional), runtimePreference: .accelerated, - graphicsPreference: .virglVenus + graphicsPreference: .virglVenus, + networkMode: .disconnected ) )) let startedMachine = try await client.machineStart("dev") @@ -451,7 +464,8 @@ struct DorydClientTests { ), clipboardPolicy: .legacyDesktop(.hostToGuest), runtimePreference: .compatible, - graphicsPreference: .software + graphicsPreference: .software, + networkMode: .sharedNAT ) ) let machines = try await client.machineList() diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 073b6b17..ea362b8b 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -49,6 +49,7 @@ struct NewMachineSettingsTests { #expect(s.virtualMachineSettings?.clipboardPolicy == .legacyDesktop(.bidirectional)) #expect(s.virtualMachineSettings?.runtimePreference == .automatic) #expect(s.virtualMachineSettings?.graphicsPreference == .automatic) + #expect(s.virtualMachineSettings?.networkMode == .sharedNAT) #expect(s.ports.isEmpty) } @@ -90,15 +91,20 @@ struct NewMachineSettingsTests { #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "GNOME") } - @Test func headlessServersDoNotCarryDesktopMetadata() { + @Test func headlessServersCarryOnlyTypedNetworkIntent() { let settings = NewMachineSheet.buildSettings( cpus: 2, memoryGB: 2, mounts: [], - displayMode: .headless + displayMode: .headless, + networkMode: .disconnected ) #expect(settings.env.isEmpty) - #expect(settings.virtualMachineSettings == nil) + #expect(settings.virtualMachineSettings?.guestIdentityIntent == .unspecified) + #expect(settings.virtualMachineSettings?.clipboardPolicy == nil) + #expect(settings.virtualMachineSettings?.runtimePreference == nil) + #expect(settings.virtualMachineSettings?.graphicsPreference == nil) + #expect(settings.virtualMachineSettings?.networkMode == .disconnected) } } diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index 34f6e6a6..8a506fe2 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -37,9 +37,52 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha public var clipboardPolicy: DoryVMClipboardPolicy? public var runtimePreference: DoryDesktopVMMPreference? public var graphicsPreference: DoryDesktopGraphicsPreference? + public var networkMode: DoryVMNetworkMode + + private enum CodingKeys: String, CodingKey { + case guestIdentityIntent + case clipboardPolicy + case runtimePreference + case graphicsPreference + case networkMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guestIdentityIntent = try container.decode( + DoryVMGuestIdentityIntent.self, + forKey: .guestIdentityIntent + ) + clipboardPolicy = try container.decodeIfPresent( + DoryVMClipboardPolicy.self, + forKey: .clipboardPolicy + ) + runtimePreference = try container.decodeIfPresent( + DoryDesktopVMMPreference.self, + forKey: .runtimePreference + ) + graphicsPreference = try container.decodeIfPresent( + DoryDesktopGraphicsPreference.self, + forKey: .graphicsPreference + ) + networkMode = try container.decodeIfPresent( + DoryVMNetworkMode.self, + forKey: .networkMode + ) ?? .sharedNAT + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(guestIdentityIntent, forKey: .guestIdentityIntent) + try container.encodeIfPresent(clipboardPolicy, forKey: .clipboardPolicy) + try container.encodeIfPresent(runtimePreference, forKey: .runtimePreference) + try container.encodeIfPresent(graphicsPreference, forKey: .graphicsPreference) + try container.encode(networkMode, forKey: .networkMode) + } public init(definition: DoryVirtualMachineDefinition) throws { guestIdentityIntent = definition.guestIdentityIntent + networkMode = definition.networkMode guard definition.display.enabled else { clipboardPolicy = nil runtimePreference = nil @@ -126,6 +169,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha account: account.isValidForPersistence ? account : nil, desktop: desktop ) + networkMode = .sharedNAT if displayMode == .desktop { let clipboard = DoryDesktopClipboardPolicy(environment: legacyEnvironment) clipboardPolicy = DoryVMClipboardDirection(rawValue: clipboard.rawValue) @@ -157,7 +201,8 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha ), clipboardPolicy: update(clipboardPolicy), runtimePreference: update(runtimePreference), - graphicsPreference: update(graphicsPreference) + graphicsPreference: update(graphicsPreference), + networkMode: .set(networkMode) ).xpcDictionary } @@ -182,7 +227,8 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha ), clipboardPolicy: replacement(clipboardPolicy), runtimePreference: replacement(runtimePreference), - graphicsPreference: replacement(graphicsPreference) + graphicsPreference: replacement(graphicsPreference), + networkMode: .set(networkMode) ) } @@ -198,6 +244,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha hasher.combine(clipboardPolicy?.files.rawValue) hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) + hasher.combine(networkMode.rawValue) } private func update( @@ -228,6 +275,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { public var clipboardPolicy: DoryMachineTypedSettingUpdate public var runtimePreference: DoryMachineTypedSettingUpdate public var graphicsPreference: DoryMachineTypedSettingUpdate + public var networkMode: DoryMachineTypedSettingUpdate public init( guestUsername: DoryMachineTypedSettingUpdate = .unchanged, @@ -238,7 +286,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { desktopEnvironment: DoryMachineTypedSettingUpdate = .unchanged, clipboardPolicy: DoryMachineTypedSettingUpdate = .unchanged, runtimePreference: DoryMachineTypedSettingUpdate = .unchanged, - graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged + graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged, + networkMode: DoryMachineTypedSettingUpdate = .unchanged ) { self.guestUsername = guestUsername self.guestNumericUserID = guestNumericUserID @@ -249,6 +298,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { self.clipboardPolicy = clipboardPolicy self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference + self.networkMode = networkMode } public var isEmpty: Bool { @@ -261,6 +311,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { && !clipboardPolicy.isChanged && !runtimePreference.isChanged && !graphicsPreference.isChanged + && !networkMode.isChanged } /// Consume the typed persistent-machine options shared by dorydctl create and update. Other @@ -321,14 +372,21 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.graphicsPreference = .set(preference) } + if let raw = try takeOption("--network", from: &arguments) { + guard let mode = DoryVMNetworkMode(rawValue: raw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--network") + } + patch.networkMode = .set(mode) + } let clearsAccount = takeFlag("--clear-guest-account", from: &arguments) let clearsDesktop = takeFlag("--clear-desktop-identity", from: &arguments) let clearsClipboard = takeFlag("--clear-clipboard", from: &arguments) let clearsRuntime = takeFlag("--clear-runtime", from: &arguments) let clearsGraphics = takeFlag("--clear-graphics", from: &arguments) + let clearsNetwork = takeFlag("--clear-network", from: &arguments) guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard - && !clearsRuntime && !clearsGraphics) else { + && !clearsRuntime && !clearsGraphics && !clearsNetwork) else { throw DoryMachineTypedWriteAuthorityError.invalidField("clear options") } if clearsAccount { @@ -372,6 +430,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.graphicsPreference = .clear } + if clearsNetwork { + guard !patch.networkMode.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-network") + } + patch.networkMode = .clear + } return patch } @@ -403,6 +467,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { allowsClears: allowsClears, type: DoryDesktopGraphicsPreference.self ) + networkMode = try Self.decodeEnum( + dictionary["networkMode"], + field: "networkMode", + allowsClears: allowsClears, + type: DoryVMNetworkMode.self + ) } /// Canonical XPC representation used by dorydctl and future typed clients. @@ -448,6 +518,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { key: "desktopGraphicsPreference", into: &result ) + Self.encodeEnum(networkMode, key: "networkMode", into: &result) return result as NSDictionary } @@ -510,6 +581,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey ) } + switch networkMode { + case .unchanged, .clear, .set(.sharedNAT): + break + case .set: + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "networkMode" + ) + } return environment } @@ -582,6 +661,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { case .set(.software): definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software]) } + switch networkMode { + case .unchanged: + break + case .clear: + definition.networkMode = .sharedNAT + case let .set(mode): + definition.networkMode = mode + } let issues = definition.validate() guard issues.isEmpty else { throw DoryMachineTypedWriteAuthorityError.invalidField( diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 11efdc95..fa674eff 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -3002,7 +3002,10 @@ public final class MachineManager: @unchecked Sendable { "native workspace update is not representable by the compatibility runtime" ) } - migration.definition = candidate + migration.definition = Self.compatibilityRuntimeDefinition( + candidate, + compatibility: migration.definition + ) _ = try migration.legacyConfiguration() nativeDefinition = candidate } @@ -6329,12 +6332,20 @@ public final class MachineManager: @unchecked Sendable { isNative = false reconcileState = result.state } + let compatibilityDefinition = migration.definition + var runtimeMigration = migration + runtimeMigration.definition = isNative + ? Self.compatibilityRuntimeDefinition( + definition, + compatibility: compatibilityDefinition + ) + : definition migration.definition = definition return MachineWorkspaceAuthority( definition: definition, migration: migration, migrationFactsData: factsData, - runtimeMachine: try migration.legacyConfiguration(), + runtimeMachine: try runtimeMigration.legacyConfiguration(), isNative: isNative, reconcileState: reconcileState ) @@ -6388,9 +6399,22 @@ public final class MachineManager: @unchecked Sendable { expected.graphics = definition.graphics expected.guestIdentityIntent = definition.guestIdentityIntent expected.clipboardPolicy = definition.clipboardPolicy + expected.networkMode = definition.networkMode return expected == definition && definition.validate().isEmpty } + /// The compatibility helper has no persisted network-mode field. Native desired state keeps + /// that authority in WorkspaceSpec while the exact resolved device contract is supplied at + /// launch; only the transient legacy projection retains its historical shared-NAT value. + private static func compatibilityRuntimeDefinition( + _ definition: DoryVirtualMachineDefinition, + compatibility: DoryVirtualMachineDefinition + ) -> DoryVirtualMachineDefinition { + var projected = definition + projected.networkMode = compatibility.networkMode + return projected + } + private static func canonicalDefinitionData( _ definition: DoryVirtualMachineDefinition ) throws -> Data { diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 81506c92..4936f779 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -164,8 +164,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|disconnected|isolated|bridged] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|resume|restart|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index a6fcd1ad..5e756650 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -16,7 +16,8 @@ struct DoryMachineTypedWriteAuthorityTests { desktopEnvironment: .set("GNOME"), clipboardPolicy: .set(.legacyDesktop(.hostToGuest)), runtimePreference: .set(.accelerated), - graphicsPreference: .set(.virglVenus) + graphicsPreference: .set(.virglVenus), + networkMode: .set(.disconnected) ) let wire = source.xpcDictionary @@ -71,7 +72,7 @@ struct DoryMachineTypedWriteAuthorityTests { } @Test("legacy status projection exposes only bounded typed settings") - func safeLegacyStatusProjection() { + func safeLegacyStatusProjection() throws { let snapshot = DoryMachineTypedSettingsSnapshot( legacyEnvironment: [ "DORY_GUEST_USER": "developer", @@ -93,6 +94,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(snapshot.clipboardPolicy?.text == .hostToGuest) #expect(snapshot.runtimePreference == .accelerated) #expect(snapshot.graphicsPreference == .virglVenus) + #expect(snapshot.networkMode == .sharedNAT) #expect(snapshot.xpcDictionary.description.contains("must-never-cross-xpc") == false) let invalid = DoryMachineTypedSettingsSnapshot( @@ -105,6 +107,17 @@ struct DoryMachineTypedWriteAuthorityTests { ) #expect(invalid.guestIdentityIntent.account == nil) #expect(invalid.xpcDictionary.description.contains("opaque") == false) + + var historical = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) + as? [String: Any] + ) + historical.removeValue(forKey: "networkMode") + let decoded = try JSONDecoder().decode( + DoryMachineTypedSettingsSnapshot.self, + from: JSONSerialization.data(withJSONObject: historical) + ) + #expect(decoded.networkMode == .sharedNAT) } @Test("update clear is explicit and field scoped") @@ -215,6 +228,36 @@ struct DoryMachineTypedWriteAuthorityTests { runtimePreference: .set(.accelerated) ).applying(to: [:], displayMode: .headless) } + + let disconnected = DoryMachineTypedSettingsPatch( + networkMode: .set(.disconnected) + ) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "networkMode" + )) { + try disconnected.applying(to: [:], displayMode: .desktop) + } + let migrated = try DoryMachineConfigurationMigrationBridge.migrate( + DoryMachineConfiguration( + id: "offline", + kernelPath: "/fixture/kernel", + rootfsPath: "/fixture/rootfs", + displayMode: .desktop + ), + facts: DoryMachineConfigurationMigrationFacts( + guestArchitecture: .arm64, + systemDiskCapacityBytes: 32 * 1_024 * 1_024 * 1_024, + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + ) + #expect(try disconnected.applying( + to: migrated.definition, + displayMode: .desktop + ).networkMode == .disconnected) } @Test("clipboard wire shape is complete and exact") @@ -262,6 +305,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--clipboard", "guest-to-host", "--runtime", "compatible", "--graphics", "software", + "--network", "disconnected", "--env", "TOKEN=secret", ] @@ -276,6 +320,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.clipboardPolicy == .set(.legacyDesktop(.guestToHost))) #expect(patch.runtimePreference == .set(.compatible)) #expect(patch.graphicsPreference == .set(.software)) + #expect(patch.networkMode == .set(.disconnected)) #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"]) #expect(patch.xpcDictionary["env"] == nil) } @@ -288,6 +333,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--clear-clipboard", "--clear-runtime", "--clear-graphics", + "--clear-network", ] let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( &clearing, @@ -301,6 +347,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.clipboardPolicy == .clear) #expect(patch.runtimePreference == .clear) #expect(patch.graphicsPreference == .clear) + #expect(patch.networkMode == .clear) var createClear = ["--clear-clipboard"] #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("clear options")) { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index bbb572e1..74051ca9 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1463,6 +1463,7 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu") XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "accelerated") XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "virgl-venus") + XCTAssertEqual(typed?["networkMode"] as? String, "shared-nat") typedCreate.fulfill() } wait(for: [typedCreate], timeout: 5) @@ -1533,6 +1534,7 @@ final class DorydServiceTests: XCTestCase { "guestIdentityIntent": [ "account": ["username": "developer"] as NSDictionary, ] as NSDictionary, + "networkMode": "disconnected", ]) { ok, _, message in XCTAssertFalse(ok) XCTAssertTrue(message.contains("production planning failed closed"), message) @@ -1548,6 +1550,7 @@ final class DorydServiceTests: XCTestCase { captured.request.planning.definition.guestIdentityIntent.account?.username, "developer" ) + XCTAssertEqual(captured.request.planning.definition.networkMode, .disconnected) XCTAssertEqual(captured.request.workspacePublication, .retainExistingExact) XCTAssertEqual(captured.artifacts.count, 2) XCTAssertTrue(captured.artifacts.allSatisfy { $0.path.hasPrefix(base + "/planned/") }) @@ -1572,13 +1575,17 @@ final class DorydServiceTests: XCTestCase { let identity = typed?["guestIdentityIntent"] as? NSDictionary let account = identity?["account"] as? NSDictionary XCTAssertEqual(account?["username"] as? String, "developer") + XCTAssertEqual(typed?["networkMode"] as? String, "disconnected") listReply.fulfill() } wait(for: [listReply], timeout: 5) XCTAssertThrowsError(try manager.start(id: "planned")) let updateReply = expectation(description: "production update planning rejection") - service.machineUpdate("planned", config: ["memoryMB": UInt64(4_096)]) { + service.machineUpdate("planned", config: [ + "memoryMB": UInt64(4_096), + "networkMode": "shared-nat", + ]) { ok, _, message in XCTAssertFalse(ok) XCTAssertTrue(message.contains("production planning failed closed"), message) @@ -1590,6 +1597,10 @@ final class DorydServiceTests: XCTestCase { controller.captures.last?.request.planning.definition.resources.memoryBytes, UInt64(4_096 * 1_024 * 1_024) ) + XCTAssertEqual( + controller.captures.last?.request.planning.definition.networkMode, + .sharedNAT + ) XCTAssertEqual(manager.status(id: "planned")?.runtimeIdentity.mode, .requiresReplanning) } From 4a5e87fb6336cd6d1dfdf70dbf8fd3bab4cf1b47 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 02:49:19 +0000 Subject: [PATCH 100/338] fix(vm): retain disconnected network devices --- Dory/Features/Machines/MachinesView.swift | 2 +- Dory/Features/Sheets/NewMachineSheet.swift | 2 +- .../Sources/DoryHV/VirtioNet.swift | 27 +++++++++++ .../Sources/dory-hv/DesktopMode.swift | 11 ++++- .../DoryHVTests/DesktopNetworkPlanTests.swift | 4 +- .../Tests/DoryHVTests/VirtioNetTests.swift | 48 +++++++++++++++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 28 ++++++++--- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 20 +++++++- 8 files changed, 128 insertions(+), 14 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 382c9c17..706e3eb2 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -770,7 +770,7 @@ private struct MachineEditSheet: View { .pickerStyle(.segmented) .accessibilityIdentifier("edit-machine-network-mode") Text(networkMode == .disconnected - ? "Disconnected attaches no virtual network device." + ? "Disconnected keeps the virtual adapter present with its link down." : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") .font(.system(size: 11)).foregroundStyle(p.text3) } diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 76bd9d99..b4e61dc2 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -526,7 +526,7 @@ struct NewMachineSheet: View { .pickerStyle(.segmented) .accessibilityIdentifier("new-machine-network-mode") Text(networkMode == .disconnected - ? "Disconnected attaches no virtual network device." + ? "Disconnected keeps the virtual adapter present with its link down." : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") .font(.system(size: 11)).foregroundStyle(p.text3) } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift index eff15e69..f772304c 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift @@ -309,3 +309,30 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { } } } + +/// A virtio-net function that remains visible to the guest while its carrier is down. This is +/// deliberately a device, rather than an omitted backend: persistent interface identity and guest +/// configuration survive a later reconnect without accidentally granting host connectivity. +public final class VirtioDisconnectedNet: VirtioDeviceBackend, @unchecked Sendable { + public let deviceID: UInt32 = 1 + public let queueCount = 2 + public let deviceFeatures: UInt64 = (1 << 5) | (1 << 16) // MAC + STATUS + public let configSpace: [UInt8] + + public init(macAddress: [UInt8] = VirtioNet.guestMAC) { + precondition(macAddress.count == 6, "a virtio-net MAC address must contain six bytes") + // virtio_net_config.mac followed by little-endian status. A zero status keeps LINK_UP + // clear, which Linux reports as NO-CARRIER while retaining the interface. + configSpace = macAddress + [0, 0] + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 1 else { return } + let virtqueue = transport.queues[1] + var interrupt = false + while let chain = (try? virtqueue.pop()) ?? nil { + interrupt = ((try? virtqueue.push(chain, written: 0)) ?? false) || interrupt + } + if interrupt { transport.notifyUsed() } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index f1eb4407..943923a5 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -47,7 +47,7 @@ enum DesktopMode { } var startsGVProxy: Bool { self == .sharedNAT } - var attachesNetworkDevice: Bool { self == .sharedNAT } + var attachesNetworkDevice: Bool { true } } private struct ResolvedGraphics { @@ -554,7 +554,7 @@ enum DesktopMode { private struct NetworkRuntime { let process: Process? let socketPaths: [String] - let backend: VirtioNet? + let backend: (any VirtioDeviceBackend)? } private static func prepareNetwork( @@ -563,6 +563,13 @@ enum DesktopMode { runtimeDirectory: String, token: String ) throws -> NetworkRuntime { + if plan == .disconnected { + return NetworkRuntime( + process: nil, + socketPaths: [], + backend: VirtioDisconnectedNet() + ) + } guard plan.startsGVProxy, plan.attachesNetworkDevice else { return NetworkRuntime(process: nil, socketPaths: [], backend: nil) } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift index a90d1c4f..2e79751c 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift @@ -16,7 +16,7 @@ import Testing } } - @Test func disconnectedLaunchesNoNetworkProcessOrDevice() throws { + @Test func disconnectedRetainsTheNetworkDeviceWithoutLaunchingGVProxy() throws { let devices = DoryVirtualMachineDeviceCapabilityRequest( networkAttachment: .disconnected ) @@ -24,7 +24,7 @@ import Testing #expect(plan == .disconnected) #expect(!plan.startsGVProxy) - #expect(!plan.attachesNetworkDevice) + #expect(plan.attachesNetworkDevice) } @Test func unimplementedNetworkModesFailClosed() { diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift index ae8dcf48..66fb4efc 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift @@ -4,6 +4,54 @@ import Testing @testable import DoryHV @Suite struct VirtioNetTests { + @Test func disconnectedDeviceAdvertisesStableMACWithCarrierDown() { + let device = VirtioDisconnectedNet() + + #expect(device.queueCount == 2) + #expect(device.deviceFeatures & (1 << 5) != 0) + #expect(device.deviceFeatures & (1 << 16) != 0) + #expect(Array(device.configSpace.prefix(6)) == VirtioNet.guestMAC) + #expect(Array(device.configSpace.suffix(2)) == [0, 0]) + } + + @Test func disconnectedDeviceConsumesTransmitDescriptorsWithoutAHostBackend() throws { + let device = VirtioDisconnectedNet() + let guestBase: UInt64 = 0xA000_0000 + let memory = try GuestMemory(guestBase: guestBase, size: 1 << 20) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: device, + memory: memory + ) {} + let descriptorTable = guestBase + 0x1_0000 + let availableRing = guestBase + 0x1_1000 + let usedRing = guestBase + 0x1_2000 + let frameAddress = guestBase + 0x1_3000 + let frame = [UInt8](repeating: 0xA5, count: 64) + + transport.queues[1].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[1].setReady(true) + try memory.write(frame, at: frameAddress) + try memory.write(frameAddress, at: descriptorTable) + try memory.write(UInt32(frame.count), at: descriptorTable + 8) + try memory.write(UInt16(0), at: descriptorTable + 12) + try memory.write(UInt16(0), at: descriptorTable + 14) + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(1), at: availableRing + 2) + try memory.write(UInt16(0), at: availableRing + 4) + try memory.write(UInt16(0), at: usedRing + 2) + + device.handleKick(queue: 1, transport: transport) + + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1) + #expect(try memory.read(UInt32.self, at: usedRing + 8) == 0) + } + @Test func receiveFrameWaitsForGuestBufferAndDrainsOnReceiveKick() throws { // sockaddr_un paths are capped at 103 bytes on Darwin; /var/folders/.../T is often already // long enough that a descriptive UUID path overflows it. diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index d939af67..2d919751 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -434,15 +434,18 @@ public enum DoryVZConfigurationBuilder { configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] configuration.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()] configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()] + let network = VZVirtioNetworkDeviceConfiguration() if spec.resolvedDevices?.networkAttachment != .disconnected { - let network = VZVirtioNetworkDeviceConfiguration() network.attachment = networkAttachment ?? VZNATNetworkDeviceAttachment() - if networkAttachment != nil, - let macAddress = VZMACAddress(string: DoryVMMNativeIPv6Plan.guestMAC) { - network.macAddress = macAddress - } - configuration.networkDevices = [network] } + let macAddressString = networkAttachment != nil + ? DoryVMMNativeIPv6Plan.guestMAC + : stableNetworkMACAddress(machineID: spec.machineID) + guard let macAddress = VZMACAddress(string: macAddressString) else { + throw DoryVZMachineError.validation("could not derive stable network identity") + } + network.macAddress = macAddress + configuration.networkDevices = [network] if spec.displayMode == .desktop { let devices = spec.resolvedDevices @@ -621,6 +624,19 @@ public enum DoryVZConfigurationBuilder { return configuration } + static func stableNetworkMACAddress(machineID: String) -> String { + // FNV-1a provides a deterministic identity without introducing a cryptographic trust + // claim. Set the locally administered bit and clear multicast. + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in machineID.utf8 { + hash ^= UInt64(byte) + hash &*= 0x0000_0100_0000_01B3 + } + var bytes = (0..<6).map { UInt8(truncatingIfNeeded: hash >> UInt64($0 * 8)) } + bytes[0] = (bytes[0] | 0x02) & 0xFE + return bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + private static func persistentMachineIdentifier(at path: String) throws -> VZGenericMachineIdentifier { let url = URL(fileURLWithPath: path) if FileManager.default.fileExists(atPath: path) { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 60ccaf3c..093a29a2 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -404,6 +404,10 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(configuration.memorySize, 2048 * 1024 * 1024) let network = try XCTUnwrap(configuration.networkDevices.first as? VZVirtioNetworkDeviceConfiguration) XCTAssertTrue(network.attachment is VZNATNetworkDeviceAttachment) + XCTAssertEqual( + network.macAddress.string, + DoryVZConfigurationBuilder.stableNetworkMACAddress(machineID: "dev") + ) XCTAssertEqual(configuration.memoryBalloonDevices.count, 1) XCTAssertTrue(configuration.memoryBalloonDevices.first is VZVirtioTraditionalMemoryBalloonDeviceConfiguration) XCTAssertEqual(configuration.entropyDevices.count, 1) @@ -455,7 +459,7 @@ final class DoryVMMKitTests: XCTestCase { try assertShellSyntax("\(base)/dorycfg/boot.sh") } - func testResolvedDisconnectedVZConfigurationOmitsEveryNetworkDevice() throws { + func testResolvedDisconnectedVZConfigurationRetainsAStableDetachedNetworkDevice() throws { let base = "/tmp/dory-vmm-disconnected-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 02:53:55 +0000 Subject: [PATCH 101/338] fix(cli): stop importing host credentials into machines --- .github/scripts/test-dory-machine-create.sh | 21 +++++++++++- scripts/dory | 36 +++------------------ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/.github/scripts/test-dory-machine-create.sh b/.github/scripts/test-dory-machine-create.sh index 3fa0d5cf..257f639b 100755 --- a/.github/scripts/test-dory-machine-create.sh +++ b/.github/scripts/test-dory-machine-create.sh @@ -36,7 +36,9 @@ DORY_MACHINE_CREATE_CAPTURE="$capture" \ DORYDCTL_BIN="$TMP/dorydctl" \ DORY_SANDBOX_KERNEL="$TMP/kernel" \ DORY_SANDBOX_ROOTFS="$TMP/rootfs" \ -DORY_MACHINE_ENV_ALLOW_LIST= \ +DORY_MACHINE_ENV_ALLOW_LIST='ANTHROPIC_API_KEY,GH_TOKEN' \ +ANTHROPIC_API_KEY='opaque-anthropic-secret' \ +GH_TOKEN='opaque-github-secret' \ HOME="$TMP/home" \ /bin/bash "$ROOT/scripts/dory" machine create test @@ -51,4 +53,21 @@ printf '%s\n' \ "$TMP/rootfs" > "$expected" cmp "$expected" "$capture" +rm -f "$capture" +if DORY_MACHINE_CREATE_CAPTURE="$capture" \ + DORYDCTL_BIN="$TMP/dorydctl" \ + DORY_SANDBOX_KERNEL="$TMP/kernel" \ + DORY_SANDBOX_ROOTFS="$TMP/rootfs" \ + HOME="$TMP/home" \ + /bin/bash "$ROOT/scripts/dory" machine create rejected --env API_TOKEN=opaque-value \ + >"$TMP/rejected.out" 2>"$TMP/rejected.err"; then + echo "persistent machine environment was accepted" >&2 + exit 1 +fi +grep -F "machine create no longer accepts persistent environment values" "$TMP/rejected.err" >/dev/null +[ ! -e "$capture" ] || { + echo "dorydctl was invoked for rejected persistent environment" >&2 + exit 1 +} + echo "dory machine create regression test: PASS" diff --git a/scripts/dory b/scripts/dory index 600960b0..bb566d5d 100755 --- a/scripts/dory +++ b/scripts/dory @@ -1468,33 +1468,6 @@ PY } } -dory_machine_env_allow_list() { - local raw - if [ "${DORY_MACHINE_ENV_ALLOW_LIST+x}" = "x" ]; then - printf '%s\n' "$DORY_MACHINE_ENV_ALLOW_LIST" - return 0 - fi - if raw="$(defaults read com.pythonxi.Dory dory.machineEnvAllowList 2>/dev/null)"; then - printf '%s\n' "$raw" - else - printf '%s\n' "ANTHROPIC_API_KEY" - fi -} - -dory_machine_env_rows() { - local raw token key value seen=" " - raw="$(dory_machine_env_allow_list)" - for token in $(printf '%s\n' "$raw" | tr ',\n\t' ' '); do - key="$(printf '%s' "$token" | tr '[:lower:]' '[:upper:]')" - [[ "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue - case "$seen" in *" $key "*) continue ;; esac - seen="$seen$key " - value="${!key-}" - [ -n "$value" ] || continue - printf '%s=%s\n' "$key" "$value" - done -} - DORY_CONFIRM_REMAINDER=() require_exact_confirmation() { @@ -1606,7 +1579,7 @@ doryd_machine_cmd() { ;; create) shift - name="${1:?usage: dory machine create [--kernel PATH] [--rootfs PATH] [--image-manifest PATH --image-signature PATH --image-allowed-signers PATH --image-signer ID] [--memory-mb N] [--cpus N] [--share TAG=HOST:GUEST[:ro|rw] or JSON] [--env KEY=VALUE]}" + name="${1:?usage: dory machine create [--kernel PATH] [--rootfs PATH] [--image-manifest PATH --image-signature PATH --image-allowed-signers PATH --image-signer ID] [--memory-mb N] [--cpus N] [--share TAG=HOST:GUEST[:ro|rw] or JSON]}" shift local image_manifest="" image_signature="" image_allowed_signers="" image_signer="" forwarded=() while [ "$#" -gt 0 ]; do @@ -1619,6 +1592,10 @@ doryd_machine_cmd() { echo "machine image trust options require a separate value, not --option=value" >&2 exit 2 ;; + --env|--env=*) + echo "machine create no longer accepts persistent environment values; pass command-scoped values to 'dory machine exec --env'" >&2 + exit 2 + ;; *) forwarded+=("$1"); shift ;; esac done @@ -1644,9 +1621,6 @@ doryd_machine_cmd() { [ -n "$rootfs" ] || { echo "Linux Machines is not installed. Run: dory component install linux-machines" >&2; exit 1; } args+=(--rootfs "$rootfs") fi - while IFS= read -r env_row; do - [ -n "$env_row" ] && args+=(--env "$env_row") - done < <(dory_machine_env_rows) if [ -n "$image_manifest$image_signature$image_allowed_signers$image_signer" ]; then [ -n "$image_manifest" ] && [ -n "$image_signature" ] && [ -n "$image_allowed_signers" ] && [ -n "$image_signer" ] || { echo "signed machine images require --image-manifest, --image-signature, --image-allowed-signers, and --image-signer together" >&2 From 18d86478b68072febfe3504549c79aecb02ddf29 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:03:32 +0000 Subject: [PATCH 102/338] feat(vm): define typed sandbox policy --- .../DoryVirtualMachineDefinition.swift | 32 +++- .../DoryVirtualMachineSandboxPolicy.swift | 154 ++++++++++++++++++ .../DoryMachineConfigurationMigration.swift | 49 ++++++ .../DoryVirtualMachineDefinitionTests.swift | 47 ++++++ ...ryMachineConfigurationMigrationTests.swift | 71 ++++++++ 5 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 9f10db20..d37c20df 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -328,6 +328,8 @@ public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseItera case invalidGuestIdentityIntent = "invalid-guest-identity-intent" case guestIdentityIncompatibleWithGuest = "guest-identity-incompatible-with-guest" case clipboardPolicyRequiresIntegration = "clipboard-policy-requires-integration" + case invalidSandboxPolicy = "invalid-sandbox-policy" + case sandboxPolicyIncompatibleWithGuest = "sandbox-policy-incompatible-with-guest" case legacyInstallerWorkload = "legacy-installer-workload" case invalidLifecycleMetadata = "invalid-lifecycle-metadata" } @@ -371,6 +373,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public var integrations: [DoryVMGuestIntegration] public var guestIdentityIntent: DoryVMGuestIdentityIntent public var clipboardPolicy: DoryVMClipboardPolicy + public var sandboxPolicy: DoryVMSandboxPolicy? public var lifecycle: DoryVMLifecycleMetadata public init( @@ -392,6 +395,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { integrations: [DoryVMGuestIntegration] = [], guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, clipboardPolicy: DoryVMClipboardPolicy? = nil, + sandboxPolicy: DoryVMSandboxPolicy? = nil, lifecycle: DoryVMLifecycleMetadata ) { self.schemaVersion = schemaVersion @@ -414,6 +418,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { self.clipboardPolicy = clipboardPolicy ?? (integrations.contains(.clipboard) ? .legacyDesktop(.bidirectional) : .disabled) + self.sandboxPolicy = sandboxPolicy self.lifecycle = lifecycle } @@ -437,6 +442,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { case integrations case guestIdentityIntent case clipboardPolicy + case sandboxPolicy case lifecycle } @@ -554,6 +560,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { guestIdentityIntent = .unspecified clipboardPolicy = integrations.contains(.clipboard) ? .legacyDesktop(.bidirectional) : .disabled + sandboxPolicy = nil lifecycle = DoryVMLifecycleMetadata( revision: legacyLifecycle.revision, createdAt: legacyLifecycle.createdAt, @@ -562,7 +569,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { return } - guard persistedSchema == 2 || persistedSchema == Self.currentSchemaVersion else { + guard (2...Self.currentSchemaVersion).contains(persistedSchema) else { throw DecodingError.dataCorruptedError( forKey: .schemaVersion, in: container, @@ -570,7 +577,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { ) } // Schema 2 is structurally migrated by the attachment decoder above. Its missing storage - // source becomes `.userProvided`; encoding always publishes current schema 3. + // source becomes `.userProvided`. Sandbox policy is an optional schema-3 extension. schemaVersion = Self.currentSchemaVersion virtualHardwareABIVersion = try container.decode( UInt16.self, @@ -599,6 +606,10 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { forKey: .clipboardPolicy ) ?? (integrations.contains(.clipboard) ? .legacyDesktop(.bidirectional) : .disabled) + sandboxPolicy = try container.decodeIfPresent( + DoryVMSandboxPolicy.self, + forKey: .sandboxPolicy + ) lifecycle = try container.decode(DoryVMLifecycleMetadata.self, forKey: .lifecycle) } @@ -622,6 +633,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { try container.encode(integrations, forKey: .integrations) try container.encode(guestIdentityIntent, forKey: .guestIdentityIntent) try container.encode(clipboardPolicy, forKey: .clipboardPolicy) + try container.encodeIfPresent(sandboxPolicy, forKey: .sandboxPolicy) try container.encode(lifecycle, forKey: .lifecycle) } @@ -675,6 +687,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { validateIntegrations(into: &issues) validateGuestIdentityIntent(into: &issues) validateClipboardPolicy(into: &issues) + validateSandboxPolicy(into: &issues) validateLifecycle(into: &issues) return issues } @@ -952,6 +965,21 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } } + private func validateSandboxPolicy( + into issues: inout [DoryVMDefinitionValidationIssue] + ) { + guard let sandboxPolicy else { return } + if !sandboxPolicy.isValidForPersistence { + issues.append(issue(.invalidSandboxPolicy, "sandboxPolicy")) + } + if guest.family != .linux || display.enabled { + issues.append(issue( + .sandboxPolicyIncompatibleWithGuest, + "sandboxPolicy" + )) + } + } + private func validateLifecycle(into issues: inout [DoryVMDefinitionValidationIssue]) { if lifecycle.revision == 0 || lifecycle.createdAtUnixMilliseconds <= 0 diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift new file mode 100644 index 00000000..6f467f6d --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift @@ -0,0 +1,154 @@ +import Foundation + +public enum DoryVMSandboxSSHAgentAccess: String, Codable, Sendable, CaseIterable { + case denied + case granted +} + +public enum DoryVMSandboxProfile: String, Codable, Sendable, CaseIterable { + case standard + case agentReady = "agent-ready" +} + +public enum DoryVMSandboxTool: String, Codable, Sendable, CaseIterable, Comparable { + case agentCore = "agent-core" + case node + case pythonML = "python-ml" + case go + case rust + case java + case ruby + case devops + case dockerHost = "docker-host" + case k8sLab = "k8s-lab" + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// Non-secret, durable lifecycle and credential-grant intent for a temporary workspace. +/// +/// Network destinations, command environments, host paths, and secret values deliberately live +/// outside this definition. They are scoped operation inputs and must never become VM metadata. +public struct DoryVMSandboxPolicy: Codable, Sendable, Equatable { + public static let currentSchemaVersion: UInt16 = 1 + + public static let legacyMarkerEnvironmentKey = "DORY_SANDBOX" + public static let legacyExpirationEnvironmentKey = "DORY_SANDBOX_EXPIRES_AT" + public static let legacySSHAgentEnvironmentKey = "DORY_SANDBOX_SSH_AGENT" + public static let legacyProfileEnvironmentKey = "DORY_SANDBOX_PROFILE" + public static let legacyToolsEnvironmentKey = "DORY_SANDBOX_TOOLS" + public static let legacyBaselineEnvironmentKey = "DORY_SANDBOX_BASELINE" + + public var schemaVersion: UInt16 + /// Nil means retained until an explicit delete. A non-nil value is an absolute Unix epoch. + public var expiresAtUnixSeconds: UInt64? + public var sshAgentAccess: DoryVMSandboxSSHAgentAccess + public var profile: DoryVMSandboxProfile + public var tools: [DoryVMSandboxTool] + public var baselineSnapshotID: String? + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + expiresAtUnixSeconds: UInt64? = nil, + sshAgentAccess: DoryVMSandboxSSHAgentAccess = .denied, + profile: DoryVMSandboxProfile = .standard, + tools: [DoryVMSandboxTool] = [], + baselineSnapshotID: String? = nil + ) { + self.schemaVersion = schemaVersion + self.expiresAtUnixSeconds = expiresAtUnixSeconds + self.sshAgentAccess = sshAgentAccess + self.profile = profile + self.tools = tools + self.baselineSnapshotID = baselineSnapshotID + } + + public var isValidForPersistence: Bool { + guard schemaVersion == Self.currentSchemaVersion, + expiresAtUnixSeconds.map({ $0 > 0 }) ?? true, + tools.count <= DoryVMSandboxTool.allCases.count, + tools == Array(Set(tools)).sorted() else { + return false + } + switch profile { + case .standard: + return tools.isEmpty && baselineSnapshotID == nil + case .agentReady: + return tools.contains(.agentCore) + && baselineSnapshotID.map(Self.isValidIdentifier) == true + } + } + + public static func legacyEnvironment(_ environment: [String: String]) -> Self? { + guard environment[legacyMarkerEnvironmentKey] == "1" else { return nil } + + let expiration: UInt64? + switch environment[legacyExpirationEnvironmentKey] { + case nil, "0"?: + expiration = nil + case let raw?: + guard let value = UInt64(raw), value > 0 else { return nil } + expiration = value + } + + let sshAgentAccess: DoryVMSandboxSSHAgentAccess + switch environment[legacySSHAgentEnvironmentKey] { + case nil, "0"?: sshAgentAccess = .denied + case "1"?: sshAgentAccess = .granted + default: return nil + } + + let profile: DoryVMSandboxProfile + let tools: [DoryVMSandboxTool] + let baseline: String? + switch environment[legacyProfileEnvironmentKey] { + case nil: + guard environment[legacyToolsEnvironmentKey] == nil, + environment[legacyBaselineEnvironmentKey] == nil else { return nil } + profile = .standard + tools = [] + baseline = nil + case DoryVMSandboxProfile.agentReady.rawValue?: + guard let rawTools = environment[legacyToolsEnvironmentKey], + let rawBaseline = environment[legacyBaselineEnvironmentKey] else { return nil } + let parts = rawTools.split(separator: ",", omittingEmptySubsequences: false) + guard !parts.isEmpty else { return nil } + let parsed = parts.compactMap { DoryVMSandboxTool(rawValue: String($0)) } + guard parsed.count == parts.count, Set(parsed).count == parsed.count else { + return nil + } + profile = .agentReady + tools = parsed.sorted() + baseline = rawBaseline + default: + return nil + } + + let candidate = Self( + expiresAtUnixSeconds: expiration, + sshAgentAccess: sshAgentAccess, + profile: profile, + tools: tools, + baselineSnapshotID: baseline + ) + return candidate.isValidForPersistence ? candidate : nil + } + + private static func isValidIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...128).contains(bytes.count), isASCIIAlphaNumeric(bytes[0]) else { + return false + } + return bytes.dropFirst().allSatisfy { + isASCIIAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 + } + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index 2966257e..e7a78693 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -237,6 +237,7 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { try applyGraphicsPolicy(to: &environment) try applyGuestIdentityIntent(to: &environment) try applyClipboardPolicy(to: &environment) + try applySandboxPolicy(to: &environment) let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in guard let hostPath = hostPath(for: share.hostLocation) else { @@ -384,6 +385,50 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { = definition.clipboardPolicy.text.rawValue } + private func applySandboxPolicy(to environment: inout [String: String]) throws { + guard definition.sandboxPolicy != baselineDefinition.sandboxPolicy else { return } + guard definition.guest.family == .linux, !definition.display.enabled else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "sandboxPolicy" + ) + } + let keys = [ + DoryVMSandboxPolicy.legacyMarkerEnvironmentKey, + DoryVMSandboxPolicy.legacyExpirationEnvironmentKey, + DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey, + DoryVMSandboxPolicy.legacyProfileEnvironmentKey, + DoryVMSandboxPolicy.legacyToolsEnvironmentKey, + DoryVMSandboxPolicy.legacyBaselineEnvironmentKey, + ] + guard let policy = definition.sandboxPolicy else { + for key in keys { environment.removeValue(forKey: key) } + return + } + guard policy.isValidForPersistence else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "sandboxPolicy" + ) + } + environment[DoryVMSandboxPolicy.legacyMarkerEnvironmentKey] = "1" + environment[DoryVMSandboxPolicy.legacyExpirationEnvironmentKey] + = policy.expiresAtUnixSeconds.map(String.init) ?? "0" + environment[DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey] + = policy.sshAgentAccess == .granted ? "1" : "0" + switch policy.profile { + case .standard: + environment.removeValue(forKey: DoryVMSandboxPolicy.legacyProfileEnvironmentKey) + environment.removeValue(forKey: DoryVMSandboxPolicy.legacyToolsEnvironmentKey) + environment.removeValue(forKey: DoryVMSandboxPolicy.legacyBaselineEnvironmentKey) + case .agentReady: + environment[DoryVMSandboxPolicy.legacyProfileEnvironmentKey] + = DoryVMSandboxProfile.agentReady.rawValue + environment[DoryVMSandboxPolicy.legacyToolsEnvironmentKey] + = policy.tools.map(\.rawValue).joined(separator: ",") + environment[DoryVMSandboxPolicy.legacyBaselineEnvironmentKey] + = policy.baselineSnapshotID + } + } + private static func assignIfChanged( _ value: String?, baseline: String?, @@ -596,6 +641,9 @@ public enum DoryMachineConfigurationMigrationBridge { } else { clipboardPolicy = .disabled } + let sandboxPolicy = DoryVMSandboxPolicy.legacyEnvironment( + configuration.environment + ) let acceleratedBoot = bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot let backendPreference: DoryVMBackendPreference @@ -650,6 +698,7 @@ public enum DoryMachineConfigurationMigrationBridge { : [.clockSynchronization, .gracefulShutdown], guestIdentityIntent: guestIdentityIntent, clipboardPolicy: clipboardPolicy, + sandboxPolicy: sandboxPolicy, lifecycle: facts.lifecycle ) let issues = definition.validate() diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 04317a95..041fd940 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -69,6 +69,53 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.isValid) } + @Test("sandbox lifecycle and credential grants are closed bounded intent") + func sandboxPolicyValidation() throws { + var definition = linuxDefinition() + definition.workload = .server + definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.none]) + definition.display = .disabled + definition.audio = DoryVMAudioConfiguration( + inputEnabled: false, + outputEnabled: false + ) + definition.input = DoryVMInputConfiguration( + keyboardEnabled: false, + pointerEnabled: false + ) + definition.integrations = [.clockSynchronization, .gracefulShutdown] + definition.clipboardPolicy = .disabled + definition.sandboxPolicy = DoryVMSandboxPolicy( + expiresAtUnixSeconds: 1_900_000_000, + sshAgentAccess: .granted, + profile: .agentReady, + tools: [.agentCore, .node], + baselineSnapshotID: "dory-agent-ready-baseline-v1" + ) + #expect(definition.isValid) + + let encoded = try JSONEncoder().encode(definition) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineDefinition.self, + from: encoded + ) + #expect(decoded == definition) + #expect(decoded.sandboxPolicy?.sshAgentAccess == .granted) + + definition.sandboxPolicy?.tools = [.node, .agentCore] + #expect(has(.invalidSandboxPolicy, "sandboxPolicy", in: definition.validate())) + definition.sandboxPolicy?.tools = [.agentCore, .node] + definition.sandboxPolicy?.baselineSnapshotID = "../host" + #expect(has(.invalidSandboxPolicy, "sandboxPolicy", in: definition.validate())) + definition.sandboxPolicy?.baselineSnapshotID = "dory-agent-ready-baseline-v1" + definition.display = DoryVMDisplayConfiguration() + #expect(has( + .sandboxPolicyIncompatibleWithGuest, + "sandboxPolicy", + in: definition.validate() + )) + } + @Test("guest identity and clipboard policies are bounded typed intent") func guestIdentityAndClipboardValidation() { var definition = linuxDefinition() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 323b5f0a..80dca5cd 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -244,6 +244,77 @@ struct DoryMachineConfigurationMigrationTests { #expect(try migrated.legacyConfiguration() == legacy) } + @Test("sandbox lifecycle and credential grants migrate without leaking compatibility keys") + func sandboxPolicyMigration() throws { + let legacy = DoryMachineConfiguration( + id: "sandbox", + kernelPath: "/managed/sandbox/kernel", + rootfsPath: "/managed/sandbox/rootfs.ext4", + environment: [ + DoryVMSandboxPolicy.legacyMarkerEnvironmentKey: "1", + DoryVMSandboxPolicy.legacyExpirationEnvironmentKey: "1900000000", + DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey: "1", + "PRESERVE": "yes", + ] + ) + var migrated = try migrate(legacy, capacity: 32 * gibibyte) + #expect(migrated.definition.sandboxPolicy == DoryVMSandboxPolicy( + expiresAtUnixSeconds: 1_900_000_000, + sshAgentAccess: .granted + )) + #expect(try migrated.legacyConfiguration() == legacy) + + let data = try JSONEncoder().encode(migrated.definition) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("DORY_SANDBOX")) + #expect(!json.contains("PRESERVE")) + + migrated.definition.sandboxPolicy = DoryVMSandboxPolicy( + expiresAtUnixSeconds: 1_900_000_001, + sshAgentAccess: .denied, + profile: .agentReady, + tools: [.agentCore, .node], + baselineSnapshotID: "dory-agent-ready-baseline-v1" + ) + let projected = try migrated.legacyConfiguration() + #expect(projected.environment[DoryVMSandboxPolicy.legacyMarkerEnvironmentKey] == "1") + #expect(projected.environment[DoryVMSandboxPolicy.legacyExpirationEnvironmentKey] + == "1900000001") + #expect(projected.environment[DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey] == "0") + #expect(projected.environment[DoryVMSandboxPolicy.legacyProfileEnvironmentKey] + == "agent-ready") + #expect(projected.environment[DoryVMSandboxPolicy.legacyToolsEnvironmentKey] + == "agent-core,node") + #expect(projected.environment[DoryVMSandboxPolicy.legacyBaselineEnvironmentKey] + == "dory-agent-ready-baseline-v1") + #expect(projected.environment["PRESERVE"] == "yes") + } + + @Test("malformed legacy sandbox metadata remains compatibility-only and byte-lossless") + func malformedSandboxPolicyDoesNotLeakOrErase() throws { + let legacy = DoryMachineConfiguration( + id: "malformed-sandbox", + kernelPath: "/managed/malformed-sandbox/kernel", + rootfsPath: "/managed/malformed-sandbox/rootfs.ext4", + environment: [ + DoryVMSandboxPolicy.legacyMarkerEnvironmentKey: "1", + DoryVMSandboxPolicy.legacyExpirationEnvironmentKey: "tomorrow", + DoryVMSandboxPolicy.legacyToolsEnvironmentKey: "secret-tool", + ] + ) + var migrated = try migrate(legacy, capacity: 32 * gibibyte) + #expect(migrated.definition.sandboxPolicy == nil) + #expect(try migrated.legacyConfiguration() == legacy) + + migrated.definition.resources = DoryVMResourceRequest( + virtualCPUCount: 3, + memoryBytes: migrated.definition.resources.memoryBytes, + diskBytes: migrated.definition.resources.diskBytes + ) + let projected = try migrated.legacyConfiguration() + #expect(projected.environment == legacy.environment) + } + @Test("custom EFI ISO maps installer media and preserves blank-disk intent") func customEFIInstallerRoundTrip() throws { let legacy = DoryMachineConfiguration( From 1838a730e90b4d0ac3b40b3998748ccfa106da7f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:09:43 +0000 Subject: [PATCH 103/338] test(vm): align planning fixtures with launch contracts --- ...oryVirtualMachineBackendPlannerTests.swift | 9 ++++---- ...ePlanningTransactionCoordinatorTests.swift | 15 +++++++++++-- ...neProductionPlanningCompositionTests.swift | 22 ++++++++++++++----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift index fef9c11f..319cdb6f 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift @@ -535,7 +535,7 @@ struct DoryVirtualMachineBackendPlannerTests { } } - @Test("planner carries the exact device request and rejects unsupported modes") + @Test("planner carries the exact supported device request") func plannerNegotiatesDevices() throws { let minimum = plan( family: .linux, @@ -550,10 +550,9 @@ struct DoryVirtualMachineBackendPlannerTests { ) #expect(try #require(minimum.selectedDescriptor).resolvedDevices == .minimumBootable) - #expect(disconnected.failure?.code == .noCandidate) - #expect(disconnected.evaluatedDescriptors.allSatisfy { - $0.availability.reason?.code == .networkAttachmentUnsupported - }) + #expect(disconnected.failure == nil) + #expect(try #require(disconnected.selectedDescriptor).resolvedDevices + == DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected)) } @Test("legacy planner requests decode with conservative policy and device defaults") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index f64c5f35..f099f758 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -464,11 +464,22 @@ private final class TransactionFixture: @unchecked Sendable { updatedAtUnixMilliseconds: 1_700_000_000_000 ) ) + let bootBundlePath = root + "/installed-linux.boot" + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("transaction-kernel".utf8), + initrd: Data("transaction-initrd".utf8), + kernelISOPath: "/boot/vmlinuz", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: bootBundlePath + ) machine = DoryMachineConfiguration( id: definition.identity.id, - kernelPath: "/fixture/direct-kernel", + kernelPath: bootBundlePath, rootfsPath: "/fixture/linux.raw", - bootMode: .linuxKernel, + bootMode: .efi, displayMode: .desktop ) media = DoryBootMedia( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift index f84c9f81..f26ab775 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift @@ -188,7 +188,7 @@ private final class CompositionFixture: @unchecked Sendable { var requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest] = [:] var snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot] = [:] for id in ids { - let prepared = Self.requestAndSnapshot(id: id) + let prepared = try Self.requestAndSnapshot(id: id, root: root) requests[id] = prepared.request snapshots[id] = prepared.snapshot } @@ -272,8 +272,9 @@ private final class CompositionFixture: @unchecked Sendable { } private static func requestAndSnapshot( - id: String - ) -> ( + id: String, + root: String + ) throws -> ( request: DoryDaemonVirtualMachinePlanningTransactionRequest, snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot ) { @@ -320,11 +321,22 @@ private final class CompositionFixture: @unchecked Sendable { updatedAtUnixMilliseconds: 1_700_000_000_000 ) ) + let bootBundlePath = root + "/\(id).installed-linux.boot" + try DoryInstalledLinuxBootBundle.write( + assets: DoryLinuxInstallerBootAssets( + kernel: Data("composition-kernel-\(id)".utf8), + initrd: Data("composition-initrd-\(id)".utf8), + kernelISOPath: "/boot/vmlinuz", + initrdISOPath: "/boot/initrd" + ), + rootDevice: "/dev/vda2", + toPath: bootBundlePath + ) let machine = DoryMachineConfiguration( id: id, - kernelPath: "/fixture/\(id)-kernel", + kernelPath: bootBundlePath, rootfsPath: "/fixture/\(id).raw", - bootMode: .linuxKernel, + bootMode: .efi, displayMode: .desktop ) let media = DoryBootMedia( From 287508c4df7eac51ed9aaceb0224aff2380197cb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:28:20 +0000 Subject: [PATCH 104/338] feat(vm): persist typed sandbox policy --- .github/scripts/test-dory-machine-create.sh | 15 + .../DoryVirtualMachineSandboxPolicy.swift | 8 +- ...ryMachineSandboxPolicyWriteAuthority.swift | 268 ++++++++++++++++++ .../Sources/DorydKit/DorydService.swift | 17 +- .../Sources/DorydKit/MachineManager.swift | 134 ++++++++- .../DorydKit/SandboxTTLReconciler.swift | 15 +- dory-core-swift/Sources/dorydctl/main.swift | 19 +- ...hineSandboxPolicyWriteAuthorityTests.swift | 114 ++++++++ .../DorydKitTests/DorydServiceTests.swift | 92 ++++++ .../DorydKitTests/MachineManagerTests.swift | 57 ++++ .../SandboxTTLReconcilerTests.swift | 22 +- scripts/dory | 33 ++- 12 files changed, 750 insertions(+), 44 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineSandboxPolicyWriteAuthority.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineSandboxPolicyWriteAuthorityTests.swift diff --git a/.github/scripts/test-dory-machine-create.sh b/.github/scripts/test-dory-machine-create.sh index 257f639b..1888dcc2 100755 --- a/.github/scripts/test-dory-machine-create.sh +++ b/.github/scripts/test-dory-machine-create.sh @@ -70,4 +70,19 @@ grep -F "machine create no longer accepts persistent environment values" "$TMP/r exit 1 } +# Sandbox lifecycle/credential grants use the typed machine-create contract. The shell must not +# resurrect the retired persistent environment escape hatch, and retained status must consume the +# daemon's safe first-class projection rather than a raw environment payload. +grep -F 'create_args+=(--sandbox --sandbox-ssh-agent' "$ROOT/scripts/dory" >/dev/null +grep -F 'policy = status.get("sandboxPolicy")' "$ROOT/scripts/dory" >/dev/null +if grep -F 'create_args+=(--env "DORY_SANDBOX' "$ROOT/scripts/dory" >/dev/null; then + echo "sandbox creation still persists lifecycle authority through raw environment" >&2 + exit 1 +fi +if grep -F 'environment = {row["key"]: row["value"] for row in status.get("env", [])}' \ + "$ROOT/scripts/dory" >/dev/null; then + echo "sandbox status still depends on raw environment disclosure" >&2 + exit 1 +fi + echo "dory machine create regression test: PASS" diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift index 6f467f6d..20de605d 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift @@ -1,16 +1,16 @@ import Foundation -public enum DoryVMSandboxSSHAgentAccess: String, Codable, Sendable, CaseIterable { +public enum DoryVMSandboxSSHAgentAccess: String, Codable, Sendable, CaseIterable, Hashable { case denied case granted } -public enum DoryVMSandboxProfile: String, Codable, Sendable, CaseIterable { +public enum DoryVMSandboxProfile: String, Codable, Sendable, CaseIterable, Hashable { case standard case agentReady = "agent-ready" } -public enum DoryVMSandboxTool: String, Codable, Sendable, CaseIterable, Comparable { +public enum DoryVMSandboxTool: String, Codable, Sendable, CaseIterable, Comparable, Hashable { case agentCore = "agent-core" case node case pythonML = "python-ml" @@ -31,7 +31,7 @@ public enum DoryVMSandboxTool: String, Codable, Sendable, CaseIterable, Comparab /// /// Network destinations, command environments, host paths, and secret values deliberately live /// outside this definition. They are scoped operation inputs and must never become VM metadata. -public struct DoryVMSandboxPolicy: Codable, Sendable, Equatable { +public struct DoryVMSandboxPolicy: Codable, Sendable, Equatable, Hashable { public static let currentSchemaVersion: UInt16 = 1 public static let legacyMarkerEnvironmentKey = "DORY_SANDBOX" diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineSandboxPolicyWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineSandboxPolicyWriteAuthority.swift new file mode 100644 index 00000000..e761b7ab --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineSandboxPolicyWriteAuthority.swift @@ -0,0 +1,268 @@ +import CoreFoundation +import DoryOperations +import Foundation + +public enum DoryMachineSandboxPolicyWriteAuthorityError: + Error, Sendable, Equatable, CustomStringConvertible +{ + case invalidField(String) + + public var description: String { + switch self { + case let .invalidField(field): + "invalid sandbox policy field: \(field)" + } + } +} + +/// Exact public write boundary for non-secret sandbox lifecycle and credential-grant intent. +/// +/// Command environments, host paths, network destinations, and secret values are deliberately +/// excluded. They remain operation-scoped inputs and never become machine metadata. +public enum DoryMachineSandboxPolicyWriteAuthority { + public static let xpcKey = "sandboxPolicy" + + public static func consumeCLIArguments( + _ arguments: inout [String] + ) throws -> DoryVMSandboxPolicy? { + let requested = takeFlag("--sandbox", from: &arguments) + let expiration = try takeOption("--sandbox-expires-at", from: &arguments) + let rawAccess = try takeOption("--sandbox-ssh-agent", from: &arguments) + let rawProfile = try takeOption("--sandbox-profile", from: &arguments) + let rawTools = try takeOptions("--sandbox-tool", from: &arguments) + let baseline = try takeOption("--sandbox-baseline", from: &arguments) + + let hasPolicyFields = expiration != nil || rawAccess != nil || rawProfile != nil + || !rawTools.isEmpty || baseline != nil + guard requested || !hasPolicyFields else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField("--sandbox") + } + guard requested else { return nil } + + let expiresAt: UInt64? + if let expiration { + guard let value = UInt64(expiration), value > 0 else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "--sandbox-expires-at" + ) + } + expiresAt = value + } else { + expiresAt = nil + } + let access: DoryVMSandboxSSHAgentAccess + if let rawAccess { + guard let value = DoryVMSandboxSSHAgentAccess(rawValue: rawAccess) else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "--sandbox-ssh-agent" + ) + } + access = value + } else { + access = .denied + } + let profile: DoryVMSandboxProfile + if let rawProfile { + guard let value = DoryVMSandboxProfile(rawValue: rawProfile) else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "--sandbox-profile" + ) + } + profile = value + } else { + profile = .standard + } + let tools = try rawTools.map { raw -> DoryVMSandboxTool in + guard let tool = DoryVMSandboxTool(rawValue: raw) else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "--sandbox-tool" + ) + } + return tool + }.sorted() + let policy = DoryVMSandboxPolicy( + expiresAtUnixSeconds: expiresAt, + sshAgentAccess: access, + profile: profile, + tools: tools, + baselineSnapshotID: baseline + ) + guard policy.isValidForPersistence else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(xpcKey) + } + return policy + } + + public static func decodeXPC( + _ dictionary: NSDictionary + ) throws -> DoryVMSandboxPolicy? { + guard let raw = dictionary[xpcKey] else { return nil } + guard let policy = normalizedDictionary(raw), + hasExactlyRequiredAndOptionalKeys(policy), + let schema = exactUInt64(policy["schemaVersion"]), + schema == UInt64(DoryVMSandboxPolicy.currentSchemaVersion), + let rawAccess = policy["sshAgentAccess"] as? String, + let access = DoryVMSandboxSSHAgentAccess(rawValue: rawAccess), + let rawProfile = policy["profile"] as? String, + let profile = DoryVMSandboxProfile(rawValue: rawProfile), + let rawTools = policy["tools"] as? NSArray else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(xpcKey) + } + let tools = try rawTools.map { raw -> DoryVMSandboxTool in + guard let value = raw as? String, + let tool = DoryVMSandboxTool(rawValue: value) else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "\(xpcKey).tools" + ) + } + return tool + } + let expiresAt: UInt64? + if let rawExpiration = policy["expiresAtUnixSeconds"] { + guard let value = exactUInt64(rawExpiration), value > 0 else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "\(xpcKey).expiresAtUnixSeconds" + ) + } + expiresAt = value + } else { + expiresAt = nil + } + let baseline: String? + if let rawBaseline = policy["baselineSnapshotID"] { + guard let value = rawBaseline as? String else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField( + "\(xpcKey).baselineSnapshotID" + ) + } + baseline = value + } else { + baseline = nil + } + let result = DoryVMSandboxPolicy( + schemaVersion: UInt16(schema), + expiresAtUnixSeconds: expiresAt, + sshAgentAccess: access, + profile: profile, + tools: tools, + baselineSnapshotID: baseline + ) + guard result.isValidForPersistence else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(xpcKey) + } + return result + } + + public static func xpcDictionary( + _ policy: DoryVMSandboxPolicy + ) throws -> NSDictionary { + guard policy.isValidForPersistence else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(xpcKey) + } + var result: [String: Any] = [ + "schemaVersion": policy.schemaVersion, + "sshAgentAccess": policy.sshAgentAccess.rawValue, + "profile": policy.profile.rawValue, + "tools": policy.tools.map(\.rawValue), + ] + if let expiresAtUnixSeconds = policy.expiresAtUnixSeconds { + result["expiresAtUnixSeconds"] = expiresAtUnixSeconds + } + if let baselineSnapshotID = policy.baselineSnapshotID { + result["baselineSnapshotID"] = baselineSnapshotID + } + return result as NSDictionary + } + + public static func legacyEnvironment( + for policy: DoryVMSandboxPolicy + ) throws -> [String: String] { + guard policy.isValidForPersistence else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(xpcKey) + } + var environment = [ + DoryVMSandboxPolicy.legacyMarkerEnvironmentKey: "1", + DoryVMSandboxPolicy.legacyExpirationEnvironmentKey: + policy.expiresAtUnixSeconds.map(String.init) ?? "0", + DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey: + policy.sshAgentAccess == .granted ? "1" : "0", + ] + if policy.profile == .agentReady { + environment[DoryVMSandboxPolicy.legacyProfileEnvironmentKey] = + policy.profile.rawValue + environment[DoryVMSandboxPolicy.legacyToolsEnvironmentKey] = + policy.tools.map(\.rawValue).joined(separator: ",") + environment[DoryVMSandboxPolicy.legacyBaselineEnvironmentKey] = + policy.baselineSnapshotID + } + return environment + } + + private static func hasExactlyRequiredAndOptionalKeys( + _ dictionary: [String: Any] + ) -> Bool { + let required: Set = [ + "schemaVersion", "sshAgentAccess", "profile", "tools", + ] + let allowed = required.union(["expiresAtUnixSeconds", "baselineSnapshotID"]) + let actual = Set(dictionary.keys) + return required.isSubset(of: actual) && actual.isSubset(of: allowed) + } + + private static func normalizedDictionary(_ raw: Any) -> [String: Any]? { + if let value = raw as? [String: Any] { return value } + guard let value = raw as? NSDictionary, + let keys = value.allKeys as? [String] else { return nil } + return Dictionary(uniqueKeysWithValues: keys.compactMap { key in + value[key].map { (key, $0) } + }) + } + + private static func exactUInt64(_ raw: Any?) -> UInt64? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, + number.doubleValue >= 0, + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.doubleValue <= Double(UInt64.max) else { return nil } + let value = number.uint64Value + return number.doubleValue == Double(value) ? value : nil + } + + private static func takeFlag(_ option: String, from arguments: inout [String]) -> Bool { + guard let index = arguments.firstIndex(of: option) else { return false } + arguments.remove(at: index) + return true + } + + private static func takeOption( + _ option: String, + from arguments: inout [String] + ) throws -> String? { + guard let index = arguments.firstIndex(of: option) else { return nil } + guard index + 1 < arguments.count else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(option) + } + let value = arguments[index + 1] + arguments.removeSubrange(index...(index + 1)) + guard !arguments.contains(option) else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(option) + } + return value + } + + private static func takeOptions( + _ option: String, + from arguments: inout [String] + ) throws -> [String] { + var result: [String] = [] + while let index = arguments.firstIndex(of: option) { + guard index + 1 < arguments.count else { + throw DoryMachineSandboxPolicyWriteAuthorityError.invalidField(option) + } + result.append(arguments[index + 1]) + arguments.removeSubrange(index...(index + 1)) + } + return result + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 5b066ebb..a4897b5e 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -241,10 +241,14 @@ public final class DorydService: NSObject, DorydControl { xpcDictionary: config, allowsClears: false ) + let sandboxPolicy = try DoryMachineSandboxPolicyWriteAuthority.decodeXPC( + config + ) let machine = try DoryMachineConfiguration(xpcDictionary: config) var status = try machineManager.create( machine, - typedSettings: typedSettings.isEmpty ? nil : typedSettings + typedSettings: typedSettings.isEmpty ? nil : typedSettings, + sandboxPolicy: sandboxPolicy ) if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority { guard let productionPlanningController else { @@ -1447,6 +1451,11 @@ private struct MachineUpdateRequest { var installerMediaAttached: Bool? init(xpcDictionary dictionary: NSDictionary) throws { + guard dictionary[DoryMachineSandboxPolicyWriteAuthority.xpcKey] == nil else { + throw XPCRemoteConfigError.invalid( + DoryMachineSandboxPolicyWriteAuthority.xpcKey + ) + } self.memoryMB = try dictionary.optionalUInt64("memoryMB") self.cpuCount = try dictionary.optionalInt("cpuCount") self.updatesAddress = dictionary["address"] != nil @@ -1874,6 +1883,12 @@ private extension DoryMachineStatus { if let typedSettings { dictionary["typedSettings"] = typedSettings.xpcDictionary } + if let sandboxPolicy, + let encoded = try? DoryMachineSandboxPolicyWriteAuthority.xpcDictionary( + sandboxPolicy + ) { + dictionary[DoryMachineSandboxPolicyWriteAuthority.xpcKey] = encoded + } dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary if let installedDesktopPayloadReceipt { dictionary["installedDesktopPayloadReceipt"] = diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index fa674eff..e7a03b51 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -30,7 +30,8 @@ public struct MachineManagerConfiguration: Sendable, Equatable { public var startupRestartPolicy: HvRestartPolicy public var guestArchitecture: String /// Host SSH agent made available to ordinary machines. Sandboxes only receive it when their - /// persisted policy contains the explicit DORY_SANDBOX_SSH_AGENT=1 grant. + /// typed policy contains an explicit `.granted` credential grant. Legacy records are decoded + /// through the bounded compatibility parser. public var sshAgentSocketPath: String? public init( @@ -331,6 +332,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] public var typedSettings: DoryMachineTypedSettingsSnapshot? + public var sandboxPolicy: DoryVMSandboxPolicy? public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? @@ -360,6 +362,7 @@ public struct DoryMachineStatus: Sendable, Equatable { shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], typedSettings: DoryMachineTypedSettingsSnapshot? = nil, + sandboxPolicy: DoryVMSandboxPolicy? = nil, runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion @@ -391,6 +394,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.shares = shares self.environment = environment self.typedSettings = typedSettings + self.sandboxPolicy = sandboxPolicy self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } @@ -452,6 +456,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] public var typedSettings: DoryMachineTypedSettingsSnapshot? + public var sandboxPolicy: DoryVMSandboxPolicy? public var bootMode: DoryMachineBootMode public var machineIdentifierPath: String? public var nvramPath: String? @@ -477,6 +482,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], typedSettings: DoryMachineTypedSettingsSnapshot? = nil, + sandboxPolicy: DoryVMSandboxPolicy? = nil, bootMode: DoryMachineBootMode = .linuxKernel, machineIdentifierPath: String? = nil, nvramPath: String? = nil, @@ -504,6 +510,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { self.shares = shares self.environment = environment self.typedSettings = typedSettings + self.sandboxPolicy = sandboxPolicy self.bootMode = bootMode self.machineIdentifierPath = machineIdentifierPath self.nvramPath = nvramPath @@ -530,6 +537,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { case shares case environment case typedSettings + case sandboxPolicy case bootMode case machineIdentifierPath case nvramPath @@ -577,6 +585,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { DoryMachineTypedSettingsSnapshot.self, forKey: .typedSettings ), + sandboxPolicy: try Self.decodeSandboxPolicy(from: container), bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel, machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath), nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath), @@ -598,6 +607,30 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { consistency: consistency, guestQuiesceReceipt: guestQuiesceReceipt ) + guard sandboxPolicy == nil || displayMode == .headless else { + throw DecodingError.dataCorruptedError( + forKey: .sandboxPolicy, + in: container, + debugDescription: "sandbox policy requires a headless Linux snapshot" + ) + } + } + + private static func decodeSandboxPolicy( + from container: KeyedDecodingContainer + ) throws -> DoryVMSandboxPolicy? { + let policy = try container.decodeIfPresent( + DoryVMSandboxPolicy.self, + forKey: .sandboxPolicy + ) + guard policy?.isValidForPersistence ?? true else { + throw DecodingError.dataCorruptedError( + forKey: .sandboxPolicy, + in: container, + debugDescription: "snapshot sandbox policy is invalid" + ) + } + return policy } } @@ -1046,7 +1079,8 @@ public final class MachineManager: @unchecked Sendable { @discardableResult public func create( _ machine: DoryMachineConfiguration, - typedSettings: DoryMachineTypedSettingsPatch? = nil + typedSettings: DoryMachineTypedSettingsPatch? = nil, + sandboxPolicy: DoryVMSandboxPolicy? = nil ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } @@ -1061,10 +1095,38 @@ public final class MachineManager: @unchecked Sendable { "native workspace creation does not accept persisted environment values" ) } - } else if let typedSettings { - machine.environment = try typedSettings.applying( - to: machine.environment, - displayMode: machine.displayMode + } else { + if let typedSettings { + machine.environment = try typedSettings.applying( + to: machine.environment, + displayMode: machine.displayMode + ) + } + if let sandboxPolicy { + for key in [ + DoryVMSandboxPolicy.legacyMarkerEnvironmentKey, + DoryVMSandboxPolicy.legacyExpirationEnvironmentKey, + DoryVMSandboxPolicy.legacySSHAgentEnvironmentKey, + DoryVMSandboxPolicy.legacyProfileEnvironmentKey, + DoryVMSandboxPolicy.legacyToolsEnvironmentKey, + DoryVMSandboxPolicy.legacyBaselineEnvironmentKey, + ] { + machine.environment.removeValue(forKey: key) + } + machine.environment.merge( + try DoryMachineSandboxPolicyWriteAuthority.legacyEnvironment( + for: sandboxPolicy + ), + uniquingKeysWith: { _, new in new } + ) + } + } + guard sandboxPolicy?.isValidForPersistence ?? true else { + throw MachineManagerError.persistence("sandbox policy is invalid") + } + guard sandboxPolicy == nil || machine.displayMode == .headless else { + throw MachineManagerError.persistence( + "sandbox policy is supported only for headless Linux machines" ) } try Self.validateLaunchConfiguration(machine) @@ -1153,10 +1215,16 @@ public final class MachineManager: @unchecked Sendable { preparedMachine, facts: facts ) - let definition = try (typedSettings ?? DoryMachineTypedSettingsPatch()).applying( + var definition = try (typedSettings ?? DoryMachineTypedSettingsPatch()).applying( to: migration.definition, displayMode: preparedMachine.displayMode ) + definition.sandboxPolicy = sandboxPolicy + guard definition.validate().isEmpty else { + throw MachineManagerError.persistence( + "sandbox policy is incompatible with the machine definition" + ) + } try workspaceRepository.create(definition) nativeDefinition = definition } @@ -1211,6 +1279,8 @@ public final class MachineManager: @unchecked Sendable { shares: preparedMachine.shares, environment: preparedMachine.environment, typedSettings: typedSettingsSnapshot, + sandboxPolicy: sandboxPolicy + ?? DoryVMSandboxPolicy.legacyEnvironment(preparedMachine.environment), runtimeIdentity: initialRuntimeIdentity ) } @@ -2972,6 +3042,7 @@ public final class MachineManager: @unchecked Sendable { candidate.graphics = nativeRecord.definition.graphics candidate.guestIdentityIntent = nativeRecord.definition.guestIdentityIntent candidate.clipboardPolicy = nativeRecord.definition.clipboardPolicy + candidate.sandboxPolicy = nativeRecord.definition.sandboxPolicy if let typedSettingsPatch { candidate = try typedSettingsPatch.applying( to: candidate, @@ -3226,6 +3297,10 @@ public final class MachineManager: @unchecked Sendable { shares: machine.shares, environment: machine.environment, typedSettings: nativeTypedSettingsSnapshot(id: id), + sandboxPolicy: try effectiveSandboxPolicy( + id: id, + configuration: machine + ), bootMode: machine.bootMode, machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil, nvramPath: machine.bootMode == .efi ? nvramPath : nil, @@ -3705,7 +3780,8 @@ public final class MachineManager: @unchecked Sendable { ) let created = try create( machine, - typedSettings: snapshot.typedSettings?.replacementPatch + typedSettings: snapshot.typedSettings?.replacementPatch, + sandboxPolicy: snapshot.sandboxPolicy ) do { if snapshot.bootMode == .efi { @@ -3791,6 +3867,7 @@ public final class MachineManager: @unchecked Sendable { candidate.graphics = record.definition.graphics candidate.guestIdentityIntent = record.definition.guestIdentityIntent candidate.clipboardPolicy = record.definition.clipboardPolicy + candidate.sandboxPolicy = snapshot.sandboxPolicy if let typedSettings = snapshot.typedSettings { candidate = try typedSettings.applyingAsReplacement( to: candidate, @@ -4184,6 +4261,10 @@ public final class MachineManager: @unchecked Sendable { shares: entry.configuration.shares, environment: entry.configuration.environment, typedSettings: typedSettings, + sandboxPolicy: try? effectiveSandboxPolicy( + id: id, + configuration: entry.configuration + ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt @@ -4215,6 +4296,10 @@ public final class MachineManager: @unchecked Sendable { shares: entry.configuration.shares, environment: entry.configuration.environment, typedSettings: typedSettings, + sandboxPolicy: try? effectiveSandboxPolicy( + id: id, + configuration: entry.configuration + ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt @@ -4233,6 +4318,30 @@ public final class MachineManager: @unchecked Sendable { return try? DoryMachineTypedSettingsSnapshot(definition: record.definition) } + private func effectiveSandboxPolicy( + id: String, + configuration: DoryMachineConfiguration + ) throws -> DoryVMSandboxPolicy? { + if launchPolicy == .perWorkspaceAuthority { + let record = try workspaceRepository.readPersistedRecord(id: id) + if record.legacyConfigurationSHA256 == nil, + record.legacyMigrationFactsSHA256 == nil { + guard record.definition.sandboxPolicy?.isValidForPersistence ?? true else { + throw MachineManagerError.persistence( + "native sandbox policy is invalid" + ) + } + return record.definition.sandboxPolicy + } + } + let policy = DoryVMSandboxPolicy.legacyEnvironment(configuration.environment) + if configuration.environment[DoryVMSandboxPolicy.legacyMarkerEnvironmentKey] == "1", + policy == nil { + throw MachineManagerError.persistence("legacy sandbox policy is invalid") + } + return policy + } + private func processConfiguration( for machine: DoryMachineConfiguration, handoffPath: String?, @@ -4450,8 +4559,12 @@ public final class MachineManager: @unchecked Sendable { for (key, value) in launchEnvironment.sorted(by: { $0.key < $1.key }) { arguments.append(contentsOf: ["--env", "\(key)=\(value)"]) } - let isSandbox = machine.environment["DORY_SANDBOX"] == "1" - let sandboxSSHAgentGranted = machine.environment["DORY_SANDBOX_SSH_AGENT"] == "1" + let sandboxPolicy = try effectiveSandboxPolicy( + id: machine.id, + configuration: machine + ) + let isSandbox = sandboxPolicy != nil + let sandboxSSHAgentGranted = sandboxPolicy?.sshAgentAccess == .granted if let sshAgentSocketPath = configuration.sshAgentSocketPath, !sshAgentSocketPath.isEmpty, !isSandbox || sandboxSSHAgentGranted { @@ -6399,6 +6512,7 @@ public final class MachineManager: @unchecked Sendable { expected.graphics = definition.graphics expected.guestIdentityIntent = definition.guestIdentityIntent expected.clipboardPolicy = definition.clipboardPolicy + expected.sandboxPolicy = definition.sandboxPolicy expected.networkMode = definition.networkMode return expected == definition && definition.validate().isEmpty } diff --git a/dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift b/dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift index 7e1e4118..cf5095a3 100644 --- a/dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift +++ b/dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift @@ -9,12 +9,10 @@ public protocol SandboxMachineManaging: Sendable { extension MachineManager: SandboxMachineManaging {} -/// Daemon-owned expiry for sandbox machines. Expiration lives in persisted machine metadata, so a -/// CLI crash, logout, sleep, or doryd restart cannot orphan a temporary VM indefinitely. +/// Daemon-owned expiry for sandbox machines. Expiration lives in typed workspace authority, so a +/// CLI crash, logout, sleep, or doryd restart cannot orphan a temporary VM indefinitely. Legacy +/// environment records are decoded by MachineManager into the same bounded status projection. public final class SandboxTTLReconciler: @unchecked Sendable { - public static let sandboxMarkerKey = "DORY_SANDBOX" - public static let expiresAtKey = "DORY_SANDBOX_EXPIRES_AT" - private let machines: any SandboxMachineManaging private let interval: TimeInterval private let manifestDirectory: String? @@ -42,10 +40,9 @@ public final class SandboxTTLReconciler: @unchecked Sendable { public func reconcileNow() -> [String] { let epoch = UInt64(max(0, now().timeIntervalSince1970.rounded(.down))) let expired = machines.list().filter { status in - guard status.environment[Self.sandboxMarkerKey] == "1", - let raw = status.environment[Self.expiresAtKey], - let expiration = UInt64(raw), - expiration > 0 else { + guard let policy = status.sandboxPolicy, + policy.isValidForPersistence, + let expiration = policy.expiresAtUnixSeconds else { return false } return expiration <= epoch diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 4936f779..600eac0a 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -164,7 +164,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|disconnected|isolated|bridged] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|disconnected|isolated|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|resume|restart|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] @@ -543,6 +543,19 @@ func mergeMachineTypedSettings( } } +func parseMachineSandboxPolicyDictionary( + cursor: inout ArgumentCursor +) throws -> NSDictionary? { + do { + guard let policy = try DoryMachineSandboxPolicyWriteAuthority.consumeCLIArguments( + &cursor.values + ) else { return nil } + return try DoryMachineSandboxPolicyWriteAuthority.xpcDictionary(policy) + } catch { + throw DorydCtlError.usage("\(error)") + } +} + func machineExecControlTimeout(timeoutMs: UInt64) -> TimeInterval { let effectiveTimeoutMs: UInt64 switch timeoutMs { @@ -1101,6 +1114,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } let shares = try cursor.optionValues("--share").map { try DoryMachineShareConfiguration(argument: $0) } let typedSettings = try parseMachineTypedSettings(cursor: &cursor, allowsClears: false) + let sandboxPolicy = try parseMachineSandboxPolicyDictionary(cursor: &cursor) let address = try cursor.optionValue("--dns-target") guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected machine create argument: \(cursor.values[0])") @@ -1145,6 +1159,9 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } } mergeMachineTypedSettings(typedSettings, into: &config) + if let sandboxPolicy { + config[DoryMachineSandboxPolicyWriteAuthority.xpcKey] = sandboxPolicy + } // Creating a machine can copy a multi-gigabyte root disk or installer ISO. // Keep the request alive long enough for that transactional mutation to // finish so the CLI cannot report a timeout after the daemon has succeeded. diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineSandboxPolicyWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineSandboxPolicyWriteAuthorityTests.swift new file mode 100644 index 00000000..a91dcaf8 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineSandboxPolicyWriteAuthorityTests.swift @@ -0,0 +1,114 @@ +import DoryOperations +import Foundation +import Testing +@testable import DorydKit + +@Suite("Sandbox policy write authority") +struct DoryMachineSandboxPolicyWriteAuthorityTests { + @Test("CLI and XPC round trip one exact non-secret policy") + func cliAndXPCRoundTrip() throws { + var arguments = [ + "--sandbox", + "--sandbox-expires-at", "2000", + "--sandbox-ssh-agent", "granted", + "--sandbox-profile", "agent-ready", + "--sandbox-tool", "node", + "--sandbox-tool", "agent-core", + "--sandbox-baseline", "baseline-v1", + "--kernel", "/private/staged/kernel", + ] + let decoded = try DoryMachineSandboxPolicyWriteAuthority + .consumeCLIArguments(&arguments) + let policy = try #require(decoded) + + #expect(arguments == ["--kernel", "/private/staged/kernel"]) + #expect(policy.expiresAtUnixSeconds == 2_000) + #expect(policy.sshAgentAccess == .granted) + #expect(policy.profile == .agentReady) + #expect(policy.tools == [.agentCore, .node]) + #expect(policy.baselineSnapshotID == "baseline-v1") + + let wire: NSDictionary = [ + DoryMachineSandboxPolicyWriteAuthority.xpcKey: + try DoryMachineSandboxPolicyWriteAuthority.xpcDictionary(policy), + ] + #expect( + try DoryMachineSandboxPolicyWriteAuthority.decodeXPC(wire) == policy + ) + #expect(wire.description.contains("/private/staged/kernel") == false) + } + + @Test("sandbox fields require the explicit marker and a canonical profile") + func rejectsImplicitOrNoncanonicalPolicy() { + var implicit = ["--sandbox-expires-at", "2000"] + #expect(throws: (any Error).self) { + try DoryMachineSandboxPolicyWriteAuthority.consumeCLIArguments(&implicit) + } + + var incomplete = [ + "--sandbox", + "--sandbox-profile", "agent-ready", + "--sandbox-tool", "agent-core", + ] + #expect(throws: (any Error).self) { + try DoryMachineSandboxPolicyWriteAuthority.consumeCLIArguments(&incomplete) + } + + var duplicate = [ + "--sandbox", + "--sandbox-profile", "agent-ready", + "--sandbox-tool", "agent-core", + "--sandbox-tool", "agent-core", + "--sandbox-baseline", "baseline-v1", + ] + #expect(throws: (any Error).self) { + try DoryMachineSandboxPolicyWriteAuthority.consumeCLIArguments(&duplicate) + } + } + + @Test("XPC sandbox policy rejects unknown keys, wrong types, and future schemas") + func rejectsMalformedXPC() { + let valid: [String: Any] = [ + "schemaVersion": UInt16(1), + "sshAgentAccess": "denied", + "profile": "standard", + "tools": [] as [String], + ] + let malformed: [[String: Any]] = [ + valid.merging(["secret": "opaque"]) { _, new in new }, + valid.merging(["schemaVersion": UInt16(2)]) { _, new in new }, + valid.merging(["schemaVersion": true]) { _, new in new }, + valid.merging(["expiresAtUnixSeconds": "tomorrow"]) { _, new in new }, + valid.merging(["sshAgentAccess": "implicit"]) { _, new in new }, + valid.merging(["tools": ["unknown"]]) { _, new in new }, + ] + for policy in malformed { + let wire: NSDictionary = [ + DoryMachineSandboxPolicyWriteAuthority.xpcKey: + policy as NSDictionary, + ] + #expect(throws: (any Error).self) { + try DoryMachineSandboxPolicyWriteAuthority.decodeXPC(wire) + } + } + } + + @Test("legacy projection is bounded to sandbox compatibility keys") + func legacyProjection() throws { + let policy = DoryVMSandboxPolicy( + expiresAtUnixSeconds: 2_000, + sshAgentAccess: .granted, + profile: .agentReady, + tools: [.agentCore, .pythonML], + baselineSnapshotID: "baseline-v1" + ) + let environment = try DoryMachineSandboxPolicyWriteAuthority + .legacyEnvironment(for: policy) + + #expect(environment.count == 6) + #expect(environment["DORY_SANDBOX"] == "1") + #expect(environment["DORY_SANDBOX_EXPIRES_AT"] == "2000") + #expect(environment["DORY_SANDBOX_SSH_AGENT"] == "1") + #expect(DoryVMSandboxPolicy.legacyEnvironment(environment) == policy) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 74051ca9..bc6a5409 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1506,6 +1506,83 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(manager.status(id: "legacy")?.environment["PRIVATE_TOKEN"], "opaque-legacy-value") } + func testMachineCreateAcceptsExactTypedSandboxPolicyAndProjectsSafeStatus() throws { + let base = "/tmp/doryd-service-sandbox-policy-\(getpid())-\(UInt32.random(in: 0.. DoryMachineStatus { + private func status(_ id: String, expiration: UInt64?) -> DoryMachineStatus { DoryMachineStatus( id: id, state: .running, - environment: [ - SandboxTTLReconciler.sandboxMarkerKey: "1", - SandboxTTLReconciler.expiresAtKey: expiration, - ] + sandboxPolicy: DoryVMSandboxPolicy( + expiresAtUnixSeconds: expiration + ) ) } } diff --git a/scripts/dory b/scripts/dory index bb566d5d..83eb6165 100755 --- a/scripts/dory +++ b/scripts/dory @@ -2490,9 +2490,16 @@ PY elif [ "$keep" -eq 0 ]; then expires_epoch=$((now_epoch + wall_seconds + 300)) fi - create_args+=(--env "DORY_SANDBOX=1" --env "DORY_SANDBOX_EXPIRES_AT=$expires_epoch" --env "DORY_SANDBOX_SSH_AGENT=$ssh_agent") + create_args+=(--sandbox --sandbox-ssh-agent "$([ "$ssh_agent" -eq 1 ] && printf granted || printf denied)") + if [ "$expires_epoch" -gt 0 ]; then + create_args+=(--sandbox-expires-at "$expires_epoch") + fi if [ "$agent_ready" -eq 1 ]; then - create_args+=(--env "DORY_SANDBOX_PROFILE=$profile_kind" --env "DORY_SANDBOX_TOOLS=$profile_tools_csv" --env "DORY_SANDBOX_BASELINE=$baseline_snapshot_id") + create_args+=(--sandbox-profile "$profile_kind" --sandbox-baseline "$baseline_snapshot_id") + local persisted_tool + for persisted_tool in "${profile_tools[@]}"; do + create_args+=(--sandbox-tool "$persisted_tool") + done if ! sandbox_prepare_agent_rootfs "$rootfs" "$tmp/agent-rootfs.ext4" 8192; then echo "Dory could not prepare sparse storage for the Agent-ready Sandbox." >&2 rm -rf "$tmp" @@ -2521,13 +2528,13 @@ requested_ssh = sys.argv[3] == "1" ssh_was_explicit = sys.argv[4] == "1" now = int(sys.argv[5]) network_was_explicit = sys.argv[6] == "1" -environment = {row["key"]: row["value"] for row in status.get("env", [])} -if environment.get("DORY_SANDBOX") != "1": +policy = status.get("sandboxPolicy") +if not isinstance(policy, dict) or policy.get("schemaVersion") != 1: raise SystemExit("refusing to reuse a machine that was not created as a sandbox") -expires = int(environment.get("DORY_SANDBOX_EXPIRES_AT", "0")) +expires = int(policy.get("expiresAtUnixSeconds") or 0) if expires and expires <= now: raise SystemExit("sandbox TTL has expired; wait for daemon cleanup or kill it explicitly") -granted_ssh = environment.get("DORY_SANDBOX_SSH_AGENT") == "1" +granted_ssh = policy.get("sshAgentAccess") == "granted" if ssh_was_explicit and granted_ssh != requested_ssh: raise SystemExit("--ssh-agent must exactly match the retained sandbox credential grant") expected_mounts = manifest.get("mounts", []) @@ -2538,6 +2545,12 @@ if expected != actual: raise SystemExit("retained sandbox mounts differ from its manifest; refusing reuse") limits = manifest.get("limits", {}) profile = manifest.get("profile") or {} +if str(policy.get("profile") or "") != str(profile.get("kind") or "standard"): + raise SystemExit("retained sandbox profile differs from its manifest; refusing reuse") +if sorted(str(tool) for tool in policy.get("tools", [])) != sorted(str(tool) for tool in profile.get("tools", [])): + raise SystemExit("retained sandbox tools differ from its manifest; refusing reuse") +if str(policy.get("baselineSnapshotID") or "") != str(profile.get("baselineSnapshotID") or ""): + raise SystemExit("retained sandbox baseline differs from its manifest; refusing reuse") persisted_network = str(manifest.get("network", {}).get("mode") or "none") if persisted_network not in ("none", "outbound", "full"): raise SystemExit("retained sandbox manifest has an invalid network policy") @@ -2821,9 +2834,13 @@ if profile.get("kind") != "agent-ready": baseline = str(profile.get("baselineSnapshotID") or "") if not baseline: raise SystemExit("Agent-ready Sandbox has no prepared baseline") -environment = {row["key"]: row["value"] for row in status.get("env", [])} -if environment.get("DORY_SANDBOX") != "1" or environment.get("DORY_SANDBOX_PROFILE") != "agent-ready": +policy = status.get("sandboxPolicy") +if not isinstance(policy, dict) or policy.get("schemaVersion") != 1 or policy.get("profile") != "agent-ready": raise SystemExit("machine identity does not match the Agent-ready Sandbox manifest") +if str(policy.get("baselineSnapshotID") or "") != baseline: + raise SystemExit("machine baseline does not match the Agent-ready Sandbox manifest") +if sorted(str(tool) for tool in policy.get("tools", [])) != sorted(str(tool) for tool in profile.get("tools", [])): + raise SystemExit("machine tools do not match the Agent-ready Sandbox manifest") expected = sorted((m["tag"], m["hostPath"], m["guestPath"], bool(m["readOnly"])) for m in manifest.get("mounts", [])) actual = sorted((m["tag"], m["hostPath"], m["guestPath"], bool(m["readOnly"])) for m in status.get("shares", [])) if expected != actual: From 04cde6d60f7aae2d02c2290fe2d5ab96729baa47 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:33:31 +0000 Subject: [PATCH 105/338] feat(vm): report diagnostic overrides safely --- .../DoryMachineDiagnosticOverride.swift | 37 +++++++++++++++++++ .../Sources/DorydKit/DorydService.swift | 3 ++ .../Sources/DorydKit/HealthReporter.swift | 5 +++ .../Sources/DorydKit/MachineManager.swift | 12 ++++++ .../DoryMachineDiagnosticOverrideTests.swift | 32 ++++++++++++++++ .../DorydKitTests/DorydServiceTests.swift | 6 +++ .../DorydKitTests/HealthReporterTests.swift | 11 +++++- 7 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticOverride.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticOverrideTests.swift diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticOverride.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticOverride.swift new file mode 100644 index 00000000..131be624 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticOverride.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Closed, non-secret diagnostic overrides honored by the compatibility raw-HV runtime. +/// +/// Status and support evidence expose only these semantic identifiers. The corresponding values +/// may contain host paths and are never projected outside the daemon. +public enum DoryMachineDiagnosticOverride: + String, Codable, Sendable, CaseIterable, Comparable, Hashable +{ + case gpuResourceTracing = "gpu-resource-tracing" + case virglSyncMode = "virgl-sync-mode" + case virglRendererPath = "virgl-renderer-path" + case moltenVKICD = "moltenvk-icd" + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public static func configured( + in environment: [String: String] + ) -> [Self] { + allCases.filter { override in + switch override { + case .gpuResourceTracing: + return environment["DORY_GPU_TRACE_RESOURCES"] == "1" + case .virglSyncMode: + return ["client-wait", "server-wait", "finish"].contains( + environment["DORY_VIRGL_SYNC_MODE"]?.lowercased() ?? "" + ) + case .virglRendererPath: + return environment["DORY_VIRGLRENDERER_PATH"]?.isEmpty == false + case .moltenVKICD: + return environment["DORY_MOLTENVK_ICD"]?.isEmpty == false + } + }.sorted() + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index a4897b5e..069d6ef9 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1889,6 +1889,9 @@ private extension DoryMachineStatus { ) { dictionary[DoryMachineSandboxPolicyWriteAuthority.xpcKey] = encoded } + if !diagnosticOverrides.isEmpty { + dictionary["diagnosticOverrides"] = diagnosticOverrides.map(\.rawValue) + } dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary if let installedDesktopPayloadReceipt { dictionary["installedDesktopPayloadReceipt"] = diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 365550a1..d48b2e26 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -2070,6 +2070,11 @@ public final class HealthReporter: @unchecked Sendable { "virtual_hardware_abi": String(identity.virtualHardwareABIVersion), "runtime_identity_valid": issues.isEmpty ? "true" : "false", ] + if !status.diagnosticOverrides.isEmpty { + data["diagnostic_overrides"] = status.diagnosticOverrides + .map(\.rawValue) + .joined(separator: ",") + } if !issues.isEmpty { data["runtime_identity_issues"] = issues .map { "\($0.code.rawValue):\($0.field)" } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index e7a03b51..7e522f20 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -333,6 +333,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var environment: [String: String] public var typedSettings: DoryMachineTypedSettingsSnapshot? public var sandboxPolicy: DoryVMSandboxPolicy? + public var diagnosticOverrides: [DoryMachineDiagnosticOverride] public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? @@ -363,6 +364,7 @@ public struct DoryMachineStatus: Sendable, Equatable { environment: [String: String] = [:], typedSettings: DoryMachineTypedSettingsSnapshot? = nil, sandboxPolicy: DoryVMSandboxPolicy? = nil, + diagnosticOverrides: [DoryMachineDiagnosticOverride] = [], runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion @@ -395,6 +397,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.environment = environment self.typedSettings = typedSettings self.sandboxPolicy = sandboxPolicy + self.diagnosticOverrides = diagnosticOverrides self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } @@ -1281,6 +1284,9 @@ public final class MachineManager: @unchecked Sendable { typedSettings: typedSettingsSnapshot, sandboxPolicy: sandboxPolicy ?? DoryVMSandboxPolicy.legacyEnvironment(preparedMachine.environment), + diagnosticOverrides: DoryMachineDiagnosticOverride.configured( + in: preparedMachine.environment + ), runtimeIdentity: initialRuntimeIdentity ) } @@ -4265,6 +4271,9 @@ public final class MachineManager: @unchecked Sendable { id: id, configuration: entry.configuration ), + diagnosticOverrides: DoryMachineDiagnosticOverride.configured( + in: entry.configuration.environment + ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt @@ -4300,6 +4309,9 @@ public final class MachineManager: @unchecked Sendable { id: id, configuration: entry.configuration ), + diagnosticOverrides: DoryMachineDiagnosticOverride.configured( + in: entry.configuration.environment + ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticOverrideTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticOverrideTests.swift new file mode 100644 index 00000000..f4a53e14 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticOverrideTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import DorydKit + +@Suite("Machine diagnostic override projection") +struct DoryMachineDiagnosticOverrideTests { + @Test("only effective closed overrides are projected without values") + func configuredProjection() { + let overrides = DoryMachineDiagnosticOverride.configured(in: [ + "DORY_GPU_TRACE_RESOURCES": "1", + "DORY_VIRGL_SYNC_MODE": "CLIENT-WAIT", + "DORY_VIRGLRENDERER_PATH": "/private/renderer.dylib", + "DORY_MOLTENVK_ICD": "", + "PRIVATE_TOKEN": "opaque", + ]) + + #expect(overrides == [ + .gpuResourceTracing, + .virglRendererPath, + .virglSyncMode, + ]) + #expect(overrides.map(\.rawValue).joined().contains("/private") == false) + #expect(overrides.map(\.rawValue).joined().contains("opaque") == false) + } + + @Test("invalid compatibility values are not reported as active") + func invalidValuesAreInactive() { + #expect(DoryMachineDiagnosticOverride.configured(in: [ + "DORY_GPU_TRACE_RESOURCES": "true", + "DORY_VIRGL_SYNC_MODE": "invented", + ]).isEmpty) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index bc6a5409..10a96a69 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1397,6 +1397,7 @@ final class DorydServiceTests: XCTestCase { environment: [ "DORY_GUEST_USER": "../../unsafe-old-user", "DORY_GUEST_UID": "not-a-uid", + "DORY_VIRGLRENDERER_PATH": "/private/opaque-renderer.dylib", "PRIVATE_TOKEN": "opaque-legacy-value", ] )) @@ -1490,6 +1491,11 @@ final class DorydServiceTests: XCTestCase { XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu Legacy") XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "compatible") XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "software") + XCTAssertEqual( + body["diagnosticOverrides"] as? [String], + ["virgl-renderer-path"] + ) + XCTAssertFalse(body.description.contains("/private/opaque-renderer.dylib")) typedUpdate.fulfill() } wait(for: [typedUpdate], timeout: 5) diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index 7cd6cfc2..f59e56b1 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -358,7 +358,11 @@ final class HealthReporterTests: XCTestCase { id: "dev", kernelPath: doryTestKernelPath, rootfsPath: doryTestRootfsPath, - environment: ["OPAQUE_SECRET": "sk-opaque-value"] + environment: [ + "OPAQUE_SECRET": "sk-opaque-value", + "DORY_GPU_TRACE_RESOURCES": "1", + "DORY_VIRGLRENDERER_PATH": "/private/opaque-renderer.dylib", + ] )) _ = try manager.start(id: "dev") @@ -383,12 +387,17 @@ final class HealthReporterTests: XCTestCase { XCTAssertEqual(runtime.code, "machine.runtime_legacy_compatibility") XCTAssertEqual(runtime.data["runtime_identity_mode"], "legacy-compatibility") XCTAssertEqual(runtime.data["virtual_hardware_abi"], "1") + XCTAssertEqual( + runtime.data["diagnostic_overrides"], + "gpu-resource-tracing,virgl-renderer-path" + ) XCTAssertNil(runtime.data["environment"]) let doctor = reporter.doctorReport() XCTAssertTrue(doctor.results.contains { $0.id == "machine.local" }) XCTAssertTrue(doctor.results.contains { $0.id == "machine.local.dev" }) XCTAssertNil(try doctor.jsonString().range(of: "sk-opaque-value")) + XCTAssertNil(try doctor.jsonString().range(of: "/private/opaque-renderer.dylib")) _ = try manager.pause(id: "dev") let pausedHealth = reporter.report() From 63229c7b10ce40d372ae9db80c09597ac22d7ab2 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:42:10 +0000 Subject: [PATCH 106/338] feat(release): verify signed catalog metadata --- .github/scripts/test-release-metadata.py | 285 +++++++++++ .../scripts/verify-ed25519-signature.swift | 46 ++ .github/workflows/release.yml | 2 + .github/workflows/tests.yml | 2 + scripts/validate-release-metadata.py | 474 ++++++++++++++++++ 5 files changed, 809 insertions(+) create mode 100644 .github/scripts/test-release-metadata.py create mode 100644 .github/scripts/verify-ed25519-signature.swift create mode 100755 scripts/validate-release-metadata.py diff --git a/.github/scripts/test-release-metadata.py b/.github/scripts/test-release-metadata.py new file mode 100644 index 00000000..886243ca --- /dev/null +++ b/.github/scripts/test-release-metadata.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Focused tamper tests for the signed schema-2 release catalog boundary.""" + +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +VALIDATOR_PATH = ROOT / "scripts" / "validate-release-metadata.py" +SPEC = importlib.util.spec_from_file_location("validate_release_metadata", VALIDATOR_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("release metadata validator could not be loaded") +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class ReleaseCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tool_directory = tempfile.TemporaryDirectory(prefix="dory-release-signing-test.") + tool_root = pathlib.Path(cls.tool_directory.name) + signer_source = tool_root / "signer.swift" + signer_source.write_text( + textwrap.dedent( + """ + import CryptoKit + import Foundation + + func fail() -> Never { exit(2) } + let arguments = Array(CommandLine.arguments.dropFirst()) + guard let command = arguments.first else { fail() } + if command == "keygen", arguments.count == 3 { + let key = Curve25519.Signing.PrivateKey() + try key.rawRepresentation.write(to: URL(fileURLWithPath: arguments[1])) + try Data(key.publicKey.rawRepresentation.base64EncodedString().utf8) + .write(to: URL(fileURLWithPath: arguments[2])) + } else if command == "sign", arguments.count == 4 { + let privateData = try Data(contentsOf: URL(fileURLWithPath: arguments[1])) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: privateData) + let message = try Data(contentsOf: URL(fileURLWithPath: arguments[2])) + let signature = try key.signature(for: message).base64EncodedString() + "\\n" + try Data(signature.utf8).write(to: URL(fileURLWithPath: arguments[3])) + } else { + fail() + } + """ + ), + encoding="utf-8", + ) + cls.signer = tool_root / "signer" + subprocess.run( + ["xcrun", "swiftc", str(signer_source), "-o", str(cls.signer)], + check=True, + ) + cls.private_key = tool_root / "private-key" + cls.public_key_file = tool_root / "public-key" + subprocess.run( + [str(cls.signer), "keygen", str(cls.private_key), str(cls.public_key_file)], + check=True, + ) + cls.public_key = cls.public_key_file.read_text(encoding="ascii") + + @classmethod + def tearDownClass(cls) -> None: + cls.tool_directory.cleanup() + + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory(prefix="dory-release-catalog-test.") + self.build = pathlib.Path(self.directory.name) + self.components = self.build / "components" / "arm64" + self.components.mkdir(parents=True) + self.catalog_path = self.components / "catalog.json" + self.digest_path = self.components / "catalog.json.sha256" + self.signature_path = self.components / "catalog.json.sig" + key_id = hashlib.sha256(base64.b64decode(self.public_key, validate=True)).hexdigest() + self.catalog = { + "kind": "dev.dory.component-catalog", + "schemaVersion": 2, + "releaseVersion": "9.8.7", + "generatedAt": "2026-08-21T01:02:03Z", + "minimumAppVersion": "9.8.7", + "architecture": "arm64", + "components": [ + self.component("docker-core", [], []), + self.component( + "linux-desktop", + ["docker-core"], + [{ + "path": "virtual-machine-qualification.json", + "role": "qualification-evidence", + "url": ( + "https://github.com/Augani/dory/releases/download/v9.8.7/" + "Dory-9.8.7-component-linux-desktop-arm64-" + "virtual-machine-qualification.json" + ), + "compression": "none", + "downloadBytes": 1, + "installedBytes": 1, + "sha256": "c" * 64, + "installedSHA256": "d" * 64, + "executable": False, + }], + qualification=["qualification-1"], + attestation_digest="d" * 64, + ), + ], + "virtualMachineQualification": { + "component": "linux-desktop", + "path": "virtual-machine-qualification.json", + "manifestIdentity": "qualification-1", + "manifestFormatVersion": 1, + "signingKeyID": key_id, + }, + } + + def tearDown(self) -> None: + self.directory.cleanup() + + @staticmethod + def component( + identifier: str, + dependencies: list[str], + assets: list[dict], + *, + qualification: list[str] | None = None, + attestation_digest: str = "d" * 64, + ) -> dict: + return { + "id": identifier, + "version": "9.8.7", + "displayName": identifier, + "summary": f"{identifier} fixture", + "dependencies": dependencies, + "downloadBytes": 1, + "installedBytes": 1, + "assets": assets, + "architectures": ["arm64"], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": [], + "requires": [], + "provenance": { + "sourceCommit": "a" * 40, + "builder": "dory.test", + "recipeDigest": "b" * 64, + "sbomDigest": "c" * 64, + "attestationDigest": attestation_digest, + }, + "qualification": qualification or [], + } + + def publish(self, *, resign: bool = True) -> None: + self.catalog_path.write_text( + json.dumps(self.catalog, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + self.digest_path.write_text( + hashlib.sha256(self.catalog_path.read_bytes()).hexdigest() + "\n", + encoding="ascii", + ) + if resign: + subprocess.run( + [ + str(self.signer), + "sign", + str(self.private_key), + str(self.catalog_path), + str(self.signature_path), + ], + check=True, + ) + + def validate(self) -> set[str]: + return VALIDATOR.validate_catalog( + self.build, + "9.8.7", + "a" * 40, + public_key=self.public_key, + ) + + def test_signed_schema_two_catalog_is_accepted(self) -> None: + self.publish() + self.assertEqual( + self.validate(), + {"Dory-9.8.7-component-linux-desktop-arm64-virtual-machine-qualification.json"}, + ) + + def test_schema_one_is_rejected_even_when_correctly_signed(self) -> None: + self.catalog["schemaVersion"] = 1 + self.publish() + with self.assertRaisesRegex(ValueError, "schema 2"): + self.validate() + + def test_catalog_mutation_with_rewritten_digest_fails_signature(self) -> None: + self.publish() + self.catalog["generatedAt"] = "2026-08-21T01:02:04Z" + self.publish(resign=False) + with self.assertRaisesRegex(ValueError, "signature is invalid"): + self.validate() + + def test_unknown_catalog_field_is_rejected(self) -> None: + self.catalog["unexpected"] = True + self.publish() + with self.assertRaisesRegex(ValueError, "shape is invalid"): + self.validate() + + def test_qualification_must_use_the_pinned_signing_key(self) -> None: + self.catalog["virtualMachineQualification"]["signingKeyID"] = "e" * 64 + self.publish() + with self.assertRaisesRegex(ValueError, "trust root"): + self.validate() + + def test_current_appcast_must_declare_catalog_schema_two(self) -> None: + update = self.build / "Dory-9.8.7-app-update.zip" + update.write_bytes(b"fixture") + + def write_appcast(component_schema: int) -> None: + (self.build / "appcast.xml").write_text( + textwrap.dedent( + f"""\ + + + + Dory + https://augani.github.io/dory/appcast.xml + Updates for Dory - native Docker and Linux containers for macOS. + en + + 9.8.7 + Fri, 21 Aug 2026 01:02:03 +0000 + 42 + 9.8.7 + 14.0 + 1 + 1 + 1 + {component_schema} + + + + + """ + ), + encoding="utf-8", + ) + + write_appcast(1) + with self.assertRaisesRegex(ValueError, "component schema"): + VALIDATOR.validate_appcast( + self.build, + "9.8.7", + "42", + "appcast.xml", + "Dory", + "https://augani.github.io/dory/appcast.xml", + update.name, + ) + write_appcast(2) + VALIDATOR.validate_appcast( + self.build, + "9.8.7", + "42", + "appcast.xml", + "Dory", + "https://augani.github.io/dory/appcast.xml", + update.name, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/verify-ed25519-signature.swift b/.github/scripts/verify-ed25519-signature.swift new file mode 100644 index 00000000..ece2a4d7 --- /dev/null +++ b/.github/scripts/verify-ed25519-signature.swift @@ -0,0 +1,46 @@ +#!/usr/bin/env swift + +import CryptoKit +import Foundation + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("Ed25519 verification error: \(message)\n".utf8)) + exit(2) +} + +let arguments = Array(CommandLine.arguments.dropFirst()) +guard arguments.count == 3 else { + fail("usage: verify-ed25519-signature.swift PUBLIC_KEY_BASE64 SIGNATURE_FILE MESSAGE_FILE") +} + +guard let publicKeyData = Data(base64Encoded: arguments[0]), publicKeyData.count == 32 else { + fail("public key must be one canonical 32-byte base64 value") +} + +let signatureURL = URL(fileURLWithPath: arguments[1]) +let messageURL = URL(fileURLWithPath: arguments[2]) +let signatureText: String +let message: Data +do { + signatureText = try String(contentsOf: signatureURL, encoding: .utf8) + message = try Data(contentsOf: messageURL, options: [.mappedIfSafe]) +} catch { + fail("input could not be read") +} + +let trimmedSignature = signatureText.trimmingCharacters(in: .whitespacesAndNewlines) +guard !trimmedSignature.isEmpty, + signatureText == trimmedSignature + "\n", + let signature = Data(base64Encoded: trimmedSignature), + signature.count == 64 else { + fail("signature must be one canonical 64-byte base64 line") +} + +do { + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) + guard publicKey.isValidSignature(signature, for: message) else { + fail("signature does not authenticate the message") + } +} catch { + fail("public key is invalid") +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 944c8bbd..41ba0c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -332,6 +332,8 @@ jobs: run: xcodebuild -downloadComponent MetalToolchain || true - name: Build shared Rust guest-control client run: scripts/build-dory-ffi-xcframework.sh --if-needed + - name: Signed release metadata contract + run: python3 .github/scripts/test-release-metadata.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89ff1670..153ae749 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,6 +33,8 @@ jobs: echo "DEVELOPER_DIR=$newest/Contents/Developer" >> "$GITHUB_ENV" - name: Dory CLI machine create run: .github/scripts/test-dory-machine-create.sh + - name: Signed release metadata contract + run: python3 .github/scripts/test-release-metadata.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/validate-release-metadata.py b/scripts/validate-release-metadata.py new file mode 100755 index 00000000..5e33bd24 --- /dev/null +++ b/scripts/validate-release-metadata.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""Validate the public release manifest and complete Sparkle appcast contract.""" + +import base64 +import email.utils +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys +import urllib.parse +import xml.etree.ElementTree as ET + +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DORY = "https://augani.github.io/dory/appcast" +MANIFEST_KEYS = { + "schemaVersion", + "version", + "build", + "sourceCommit", + "publicRelease", + "bundleEngine", + "notarized", + "variants", + "artifacts", +} +RECORD_KEYS = {"name", "path", "kind", "bytes", "sha256"} +VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?") +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +CATALOG_PUBLIC_KEY = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" +CATALOG_KEYS = { + "kind", + "schemaVersion", + "releaseVersion", + "generatedAt", + "minimumAppVersion", + "architecture", + "components", + "virtualMachineQualification", +} +COMPONENT_KEYS = { + "id", + "version", + "displayName", + "summary", + "dependencies", + "downloadBytes", + "installedBytes", + "assets", + "architectures", + "hostRequirements", + "provides", + "requires", + "provenance", + "qualification", +} +ASSET_REQUIRED_KEYS = { + "path", + "role", + "url", + "compression", + "downloadBytes", + "installedBytes", + "sha256", + "installedSHA256", + "executable", +} +QUALIFICATION_KEYS = { + "component", + "path", + "manifestIdentity", + "manifestFormatVersion", + "signingKeyID", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, f"JSON contains duplicate key: {key}") + result[key] = value + return result + + +def exact_object(value: object, keys: set[str], label: str) -> dict: + require(isinstance(value, dict) and set(value) == keys, f"{label} shape is invalid") + return value + + +def positive_integer(value: object, label: str) -> int: + require( + not isinstance(value, bool) and isinstance(value, int) and value > 0, + f"{label} must be a positive integer", + ) + return value + + +def nonempty_string(value: object, label: str) -> str: + require(isinstance(value, str) and value and len(value.encode("utf-8")) <= 4_096, f"{label} is invalid") + return value + + +def digest_value(value: object, label: str) -> str: + require(isinstance(value, str) and SHA256_PATTERN.fullmatch(value) is not None, f"{label} is invalid") + return value + + +def verify_catalog_signature( + catalog_path: pathlib.Path, + signature_path: pathlib.Path, + public_key: str, +) -> None: + verifier = pathlib.Path(__file__).resolve().parent.parent / ".github/scripts/verify-ed25519-signature.swift" + require(verifier.is_file(), "Ed25519 verifier is missing") + completed = subprocess.run( + ["xcrun", "swift", str(verifier), public_key, str(signature_path), str(catalog_path)], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + require(completed.returncode == 0, "component catalog signature is invalid") + + +def validate_catalog( + build_dir: pathlib.Path, + version: str, + source_commit: str, + *, + public_key: str = CATALOG_PUBLIC_KEY, +) -> set[str]: + component_dir = build_dir / "components" / "arm64" + catalog_path = component_dir / "catalog.json" + digest_path = component_dir / "catalog.json.sha256" + signature_path = component_dir / "catalog.json.sig" + catalog_bytes = catalog_path.read_bytes() + require(0 < len(catalog_bytes) <= 2 * 1_024 * 1_024, "component catalog size is invalid") + expected_digest = hashlib.sha256(catalog_bytes).hexdigest() + require( + digest_path.read_text(encoding="ascii") == expected_digest + "\n", + "component catalog digest does not authenticate catalog.json", + ) + verify_catalog_signature(catalog_path, signature_path, public_key) + catalog = json.loads(catalog_bytes, object_pairs_hook=unique_json_object) + exact_object(catalog, CATALOG_KEYS, "component catalog") + require(catalog["kind"] == "dev.dory.component-catalog", "component catalog kind mismatch") + require(catalog["schemaVersion"] == 2, "component catalog must use schema 2") + require(catalog["releaseVersion"] == version, "component catalog release version mismatch") + require(catalog["minimumAppVersion"] == version, "component catalog app version mismatch") + require(catalog["architecture"] == "arm64", "component catalog architecture mismatch") + generated_at = nonempty_string(catalog["generatedAt"], "component catalog generatedAt") + require( + re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", generated_at) is not None, + "component catalog generatedAt is not canonical UTC", + ) + + qualification = exact_object( + catalog["virtualMachineQualification"], + QUALIFICATION_KEYS, + "component catalog VM qualification declaration", + ) + require(qualification["component"] == "linux-desktop", "VM qualification component mismatch") + require( + qualification["path"] == "virtual-machine-qualification.json", + "VM qualification path mismatch", + ) + nonempty_string(qualification["manifestIdentity"], "VM qualification identity") + require(qualification["manifestFormatVersion"] == 1, "VM qualification schema mismatch") + decoded_key = base64.b64decode(public_key, validate=True) + require(len(decoded_key) == 32, "component catalog public key is invalid") + require( + qualification["signingKeyID"] == hashlib.sha256(decoded_key).hexdigest(), + "VM qualification signing key does not match the catalog trust root", + ) + + components = catalog["components"] + require(isinstance(components, list) and components, "component catalog has no components") + component_ids: list[str] = [] + component_assets: dict[tuple[str, str], dict] = {} + artifact_names: set[str] = set() + for index, value in enumerate(components): + component = exact_object(value, COMPONENT_KEYS, f"component {index}") + component_id = nonempty_string(component["id"], f"component {index} id") + component_ids.append(component_id) + require(component["version"] == version, f"component {component_id} version mismatch") + nonempty_string(component["displayName"], f"component {component_id} display name") + nonempty_string(component["summary"], f"component {component_id} summary") + require(component["architectures"] == ["arm64"], f"component {component_id} architecture mismatch") + host = exact_object( + component["hostRequirements"], + {"platform", "minimumVersion"}, + f"component {component_id} host requirements", + ) + require(host == {"platform": "macos", "minimumVersion": "14.0"}, f"component {component_id} host contract mismatch") + for field in ("dependencies", "provides", "requires", "qualification"): + require( + isinstance(component[field], list) + and all(isinstance(item, str) and item for item in component[field]), + f"component {component_id} {field} is invalid", + ) + positive_integer(component["downloadBytes"], f"component {component_id} downloadBytes") + positive_integer(component["installedBytes"], f"component {component_id} installedBytes") + provenance = exact_object( + component["provenance"], + {"sourceCommit", "builder", "recipeDigest", "sbomDigest", "attestationDigest"}, + f"component {component_id} provenance", + ) + require( + provenance["sourceCommit"] == source_commit, + f"component {component_id} source commit does not match the release", + ) + nonempty_string(provenance["builder"], f"component {component_id} builder") + for field in ("recipeDigest", "sbomDigest", "attestationDigest"): + digest_value(provenance[field], f"component {component_id} {field}") + assets = component["assets"] + require(isinstance(assets, list), f"component {component_id} assets are invalid") + for asset_index, raw_asset in enumerate(assets): + allowed_keys = ASSET_REQUIRED_KEYS | {"codeRequirement"} + require( + isinstance(raw_asset, dict) + and ASSET_REQUIRED_KEYS <= set(raw_asset) <= allowed_keys, + f"component {component_id} asset {asset_index} shape is invalid", + ) + asset = raw_asset + nonempty_string(asset["role"], "component asset role") + installed_path = pathlib.PurePosixPath(nonempty_string(asset["path"], "component asset path")) + require( + not installed_path.is_absolute() and ".." not in installed_path.parts, + f"component {component_id} asset path is unsafe", + ) + require(asset["compression"] in {"none", "lzfse"}, "component asset compression is invalid") + require(isinstance(asset["executable"], bool), "component asset executable flag is invalid") + require( + (asset["executable"] and nonempty_string(asset.get("codeRequirement"), "component asset code requirement")) + or (not asset["executable"] and "codeRequirement" not in asset), + "component asset code requirement does not match executable state", + ) + positive_integer(asset["downloadBytes"], "component asset downloadBytes") + positive_integer(asset["installedBytes"], "component asset installedBytes") + digest_value(asset["sha256"], "component asset digest") + digest_value(asset["installedSHA256"], "component installed digest") + parsed = urllib.parse.urlparse(nonempty_string(asset["url"], "component asset URL")) + require( + parsed.scheme == "https" + and parsed.netloc == "github.com" + and parsed.path.startswith(f"/Augani/dory/releases/download/v{version}/") + and not parsed.params + and not parsed.query + and not parsed.fragment, + "component asset URL is not canonical", + ) + artifact_name = os.path.basename(parsed.path) + require( + artifact_name.startswith(f"Dory-{version}-component-{component_id}-arm64-"), + "component asset name is invalid", + ) + require(artifact_name not in artifact_names, "component asset name is duplicated") + artifact_names.add(artifact_name) + component_assets[(component_id, str(installed_path))] = asset + require(len(set(component_ids)) == len(component_ids), "component IDs are duplicated") + require(component_ids[0] == "docker-core", "Docker Core must be the first component") + require(components[0]["assets"] == [], "Docker Core must not declare downloadable assets") + require("linux-desktop" in component_ids, "Linux Desktop qualification component is missing") + require( + all(dependency in set(component_ids) for component in components for dependency in component["dependencies"]), + "component dependency is unavailable", + ) + qualification_asset = component_assets.get((qualification["component"], qualification["path"])) + require( + qualification_asset is not None and qualification_asset["role"] == "qualification-evidence", + "signed catalog does not bind its VM qualification asset", + ) + linux_desktop = components[component_ids.index("linux-desktop")] + require( + linux_desktop["provenance"]["attestationDigest"] == qualification_asset["installedSHA256"], + "VM qualification provenance does not bind the declared asset", + ) + return artifact_names + + +def validate_manifest(build_dir: pathlib.Path, version: str, build: str) -> tuple[dict, str]: + path = build_dir / "release-manifest.json" + manifest = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=unique_json_object, + ) + require(isinstance(manifest, dict) and set(manifest) == MANIFEST_KEYS, "release manifest shape is invalid") + require(manifest["schemaVersion"] == 2, "unexpected release manifest schema") + require(manifest["version"] == version, "manifest version mismatch") + require(str(manifest["build"]) == build, "manifest build mismatch") + source_commit = manifest["sourceCommit"] + require( + isinstance(source_commit, str) + and re.fullmatch(r"[0-9a-f]{40}", source_commit) is not None, + "manifest sourceCommit is not a full lowercase Git SHA", + ) + require(manifest["publicRelease"] is True, "manifest is not marked as a public release") + require(manifest["bundleEngine"] is True, "manifest describes an app-only release") + require(manifest["notarized"] is True, "manifest describes an unnotarized release") + require(manifest["variants"] == "arm64", "manifest is not Apple-Silicon-only") + + required = { + f"Dory-{version}-arm64.zip", + f"Dory-{version}.zip", + f"Dory-{version}-arm64.dmg", + f"Dory-{version}.dmg", + f"Dory-{version}-app-update.zip", + f"dory-engine-{version}-arm64.tar.gz", + f"Dory-{version}.cdx.json", + "appcast.xml", + "catalog.json", + "catalog.json.sha256", + "catalog.json.sig", + } + required.update(validate_catalog(build_dir, version, source_commit)) + records = manifest["artifacts"] + require(isinstance(records, list) and records, "manifest has no artifacts") + require( + all(isinstance(record, dict) and set(record) == RECORD_KEYS for record in records), + "manifest artifact record shape is invalid", + ) + by_name = {record["name"]: record for record in records} + require(len(by_name) == len(records), "manifest contains duplicate artifact names") + require(set(by_name) == required, f"manifest artifact set mismatch: {sorted(set(by_name) ^ required)}") + for name, record in by_name.items(): + expected_path = f"components/arm64/{name}" if name.startswith("catalog.json") or \ + name.startswith(f"Dory-{version}-component-") else name + require(record["path"] == expected_path, f"manifest path is not portable: {name}") + require(isinstance(record["kind"], str) and record["kind"], f"manifest kind is invalid: {name}") + artifact = build_dir / record["path"] + require(artifact.is_file(), f"manifest artifact is missing: {name}") + require(record["bytes"] == artifact.stat().st_size, f"manifest byte count mismatch: {name}") + require(record["sha256"] == sha256_file(artifact), f"manifest SHA-256 mismatch: {name}") + require(by_name[f"Dory-{version}.cdx.json"]["kind"] == "cyclonedx-json", "SBOM artifact kind mismatch") + return by_name, source_commit + + +def validate_appcast( + build_dir: pathlib.Path, + version: str, + build: str, + filename: str, + title: str, + link: str, + expected_name: str, +) -> None: + root = ET.parse(build_dir / filename).getroot() + require(root.tag == "rss" and root.attrib == {"version": "2.0"}, "appcast root is invalid") + channel = root.find("channel") + require(channel is not None, "appcast has no channel") + require(channel.findtext("title") == title, f"{filename} channel identity mismatch") + require(channel.findtext("link") == link, f"{filename} link mismatch") + require( + channel.findtext("description") == "Updates for Dory - native Docker and Linux containers for macOS.", + "appcast description mismatch", + ) + require(channel.findtext("language") == "en", "appcast language mismatch") + items = channel.findall("item") + require(bool(items), "appcast has no current item") + require(items[0].findtext(f"{{{SPARKLE}}}version") == build, "appcast build mismatch") + require(items[0].findtext(f"{{{SPARKLE}}}shortVersionString") == version, "appcast version mismatch") + + expected_size = (build_dir / expected_name).stat().st_size + seen_builds: set[str] = set() + seen_versions: set[str] = set() + for index, item in enumerate(items): + release_build = item.findtext(f"{{{SPARKLE}}}version", "") + release_version = item.findtext(f"{{{SPARKLE}}}shortVersionString", "") + require(release_build.isdigit() and int(release_build) > 0, "appcast item build is invalid") + require(VERSION_PATTERN.fullmatch(release_version) is not None, "appcast item version is invalid") + require(release_build not in seen_builds, f"duplicate appcast build: {release_build}") + require(release_version not in seen_versions, f"duplicate appcast version: {release_version}") + seen_builds.add(release_build) + seen_versions.add(release_version) + require(item.findtext("title") == release_version, f"appcast {release_version} title mismatch") + require( + item.findtext(f"{{{SPARKLE}}}minimumSystemVersion") == "14.0", + f"appcast {release_version} macOS floor mismatch", + ) + target_schema = item.findtext(f"{{{DORY}}}dataSchemaVersion", "") + minimum_schema = item.findtext(f"{{{DORY}}}minimumReadableDataSchema", "") + maximum_schema = item.findtext(f"{{{DORY}}}maximumReadableDataSchema", "") + component_schema = item.findtext(f"{{{DORY}}}componentCatalogSchema", "") + require( + all(value.isdigit() and int(value) > 0 for value in ( + target_schema, minimum_schema, maximum_schema, component_schema + )), + f"appcast {release_version} Dory schema contract is missing or invalid", + ) + require( + int(minimum_schema) <= int(target_schema) <= int(maximum_schema), + f"appcast {release_version} readable data-schema range excludes its target", + ) + require( + int(component_schema) == (2 if index == 0 else 1), + f"appcast {release_version} component schema is unsupported", + ) + publication_date = email.utils.parsedate_to_datetime(item.findtext("pubDate", "")) + require(publication_date.tzinfo is not None, f"appcast {release_version} publication date is invalid") + enclosure = item.find("enclosure") + require(enclosure is not None, f"appcast {release_version} item has no enclosure") + expected_attributes = {"url", f"{{{SPARKLE}}}edSignature", "length", "type"} + require(set(enclosure.attrib) == expected_attributes, f"appcast {release_version} enclosure shape is invalid") + parsed_url = urllib.parse.urlparse(enclosure.attrib["url"]) + filename = os.path.basename(parsed_url.path) + require( + parsed_url.scheme == "https" + and parsed_url.netloc == "github.com" + and not parsed_url.params + and not parsed_url.query + and not parsed_url.fragment, + f"appcast {release_version} enclosure is not a canonical GitHub URL", + ) + require( + parsed_url.path.startswith(f"/Augani/dory/releases/download/v{release_version}/"), + f"appcast {release_version} enclosure is outside its versioned Dory release", + ) + require( + filename.startswith(f"Dory-{release_version}") and filename.endswith(".zip"), + f"appcast {release_version} enclosure filename is invalid", + ) + require(enclosure.attrib["type"] == "application/octet-stream", "appcast enclosure type is invalid") + length = enclosure.attrib["length"] + require(length.isdigit() and int(length) > 0, f"appcast {release_version} length is invalid") + signature = base64.b64decode(enclosure.attrib[f"{{{SPARKLE}}}edSignature"], validate=True) + require(len(signature) == 64, f"appcast {release_version} EdDSA signature length is invalid") + if index == 0: + require(filename == expected_name, f"appcast points at {filename!r}, expected {expected_name!r}") + require(int(length) == expected_size, "appcast length mismatch") + else: + require( + int(release_build) < int(build), + f"historical appcast build {release_build} is not older than {build}", + ) + + +def main() -> None: + if len(sys.argv) != 4: + raise SystemExit("usage: validate-release-metadata.py ") + build_dir = pathlib.Path(sys.argv[1]) + version, build = sys.argv[2:] + _, source_commit = validate_manifest(build_dir, version, build) + validate_appcast( + build_dir, + version, + build, + "appcast.xml", + "Dory", + "https://augani.github.io/dory/appcast.xml", + f"Dory-{version}-app-update.zip", + ) + print(source_commit) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, ET.ParseError) as error: + raise SystemExit(f"release metadata error: {error}") from error From 9fcf693c31b419d1fccd791de9237b1bd0864e35 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:44:47 +0000 Subject: [PATCH 107/338] feat(release): bind exact app SBOM --- .github/scripts/test-release-sbom.py | 136 ++++++++++++++++++ .github/workflows/release.yml | 4 +- .github/workflows/tests.yml | 4 +- scripts/generate-release-sbom.py | 197 +++++++++++++++++++++++++++ scripts/verify-release-sbom.py | 116 ++++++++++++++++ 5 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/test-release-sbom.py create mode 100755 scripts/generate-release-sbom.py create mode 100755 scripts/verify-release-sbom.py diff --git a/.github/scripts/test-release-sbom.py b/.github/scripts/test-release-sbom.py new file mode 100644 index 00000000..bbb969d4 --- /dev/null +++ b/.github/scripts/test-release-sbom.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Offline security regressions for exact Dory.app CycloneDX evidence.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GENERATOR = ROOT / "scripts" / "generate-release-sbom.py" +VERIFIER = ROOT / "scripts" / "verify-release-sbom.py" +VERSION = "9.8.7" +COMMIT = "a" * 40 + + +class ReleaseSBOMTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory(prefix="dory-release-sbom-test.") + self.root = pathlib.Path(self.directory.name) + self.app = self.root / "Dory.app" + executable = self.app / "Contents" / "MacOS" / "Dory" + resource = self.app / "Contents" / "Resources" / "payload.txt" + executable.parent.mkdir(parents=True) + resource.parent.mkdir(parents=True) + executable.write_bytes(b"candidate executable\n") + executable.chmod(0o755) + resource.write_bytes(b"candidate payload\n") + os.symlink("payload.txt", resource.parent / "payload-current.txt") + self.sbom = self.root / "Dory.cdx.json" + + def tearDown(self) -> None: + self.directory.cleanup() + + def run_generator(self, output: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(GENERATOR), + "--app", + str(self.app), + "--version", + VERSION, + "--source-commit", + COMMIT, + "--output", + str(output or self.sbom), + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def run_verifier(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(VERIFIER), + "--sbom", + str(self.sbom), + "--app", + str(self.app), + "--version", + VERSION, + "--source-commit", + COMMIT, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def test_exact_tree_round_trip_is_deterministic_and_portable(self) -> None: + generated = self.run_generator() + self.assertEqual(generated.returncode, 0, generated.stderr) + first = self.sbom.read_bytes() + second_path = self.root / "second.cdx.json" + regenerated = self.run_generator(second_path) + self.assertEqual(regenerated.returncode, 0, regenerated.stderr) + self.assertEqual(first, second_path.read_bytes()) + self.assertNotIn(str(self.root).encode(), first) + verified = self.run_verifier() + self.assertEqual(verified.returncode, 0, verified.stderr) + self.assertIn("PASS", verified.stdout) + + def test_file_mutation_invalidates_the_inventory(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + with (self.app / "Contents" / "MacOS" / "Dory").open("ab") as handle: + handle.write(b"tampered\n") + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("does not exactly inventory", verified.stderr) + + def test_symlink_mutation_invalidates_the_inventory(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + link = self.app / "Contents" / "Resources" / "payload-current.txt" + link.unlink() + os.symlink("../MacOS/Dory", link) + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("does not exactly inventory", verified.stderr) + + def test_symlink_that_escapes_the_app_is_rejected(self) -> None: + link = self.app / "Contents" / "Resources" / "payload-current.txt" + link.unlink() + os.symlink(str(self.root / "outside"), link) + (self.root / "outside").write_bytes(b"outside\n") + generated = self.run_generator() + self.assertNotEqual(generated.returncode, 0) + self.assertIn("absolute symlink", generated.stderr) + + def test_unknown_sbom_field_is_rejected(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + document = json.loads(self.sbom.read_text(encoding="utf-8")) + document["unexpected"] = True + self.sbom.write_text(json.dumps(document) + "\n", encoding="utf-8") + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("shape is invalid", verified.stderr) + + def test_output_inside_the_app_is_rejected(self) -> None: + output = self.app / "Contents" / "Resources" / "Dory.cdx.json" + generated = self.run_generator(output) + self.assertNotEqual(generated.returncode, 0) + self.assertIn("outside Dory.app", generated.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41ba0c88..8b5cefc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -333,7 +333,9 @@ jobs: - name: Build shared Rust guest-control client run: scripts/build-dory-ffi-xcframework.sh --if-needed - name: Signed release metadata contract - run: python3 .github/scripts/test-release-metadata.py + run: | + python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-sbom.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 153ae749..783e44f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,7 +34,9 @@ jobs: - name: Dory CLI machine create run: .github/scripts/test-dory-machine-create.sh - name: Signed release metadata contract - run: python3 .github/scripts/test-release-metadata.py + run: | + python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-sbom.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/generate-release-sbom.py b/scripts/generate-release-sbom.py new file mode 100755 index 00000000..cdc6f79f --- /dev/null +++ b/scripts/generate-release-sbom.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Generate a deterministic CycloneDX inventory for the exact shipped Dory.app tree.""" + +import argparse +import hashlib +import json +import os +import pathlib +import stat +import tempfile +import urllib.parse +import uuid + + +def digest_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def snapshot(metadata: os.stat_result) -> tuple[int, int, int, int, int, int]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def digest_file(path: pathlib.Path) -> tuple[str, os.stat_result]: + flags = os.O_RDONLY | os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"release app entry is not a regular file: {path}") + value = hashlib.sha256() + while chunk := os.read(descriptor, 1024 * 1024): + value.update(chunk) + after = os.fstat(descriptor) + finally: + os.close(descriptor) + current = path.lstat() + if snapshot(before) != snapshot(after) or snapshot(after) != snapshot(current): + raise ValueError(f"release app file changed while it was inventoried: {path}") + return value.hexdigest(), current + + +def symlink_snapshot(path: pathlib.Path, app_root: pathlib.Path) -> tuple[str, int, os.stat_result]: + before = path.lstat() + target = os.readlink(path) + after = path.lstat() + if snapshot(before) != snapshot(after): + raise ValueError(f"release app symlink changed while it was inventoried: {path}") + if pathlib.PurePosixPath(target).is_absolute(): + raise ValueError(f"release app contains an absolute symlink: {path}") + try: + resolved = (path.parent / target).resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError(f"release app contains an unresolved symlink: {path}") from error + if resolved != app_root and app_root not in resolved.parents: + raise ValueError(f"release app symlink escapes Dory.app: {path}") + try: + encoded = target.encode("utf-8") + except UnicodeError as error: + raise ValueError(f"release app symlink is not portable UTF-8: {path}") from error + return digest_bytes(encoded), len(encoded), after + + +def app_entries(app: pathlib.Path) -> list[pathlib.Path]: + result: list[pathlib.Path] = [] + + def visit(directory: pathlib.Path) -> None: + with os.scandir(directory) as entries: + ordered = sorted(entries, key=lambda entry: entry.name.encode("utf-8")) + for entry in ordered: + path = pathlib.Path(entry.path) + metadata = entry.stat(follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + visit(path) + elif stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + result.append(path) + else: + raise ValueError(f"release app contains unsupported filesystem entry: {path}") + + visit(app) + return result + + +def inventory(app: pathlib.Path) -> tuple[list[dict[str, object]], str]: + root_metadata = app.lstat() + if app.name != "Dory.app" or not stat.S_ISDIR(root_metadata.st_mode): + raise ValueError(f"exact non-symlink Dory.app is missing: {app}") + app = app.resolve(strict=True) + components: list[dict[str, object]] = [] + tree = hashlib.sha256() + for path in app_entries(app): + relative = path.relative_to(app.parent).as_posix() + metadata = path.lstat() + if stat.S_ISREG(metadata.st_mode): + kind = "regular" + sha256, metadata = digest_file(path) + size = metadata.st_size + elif stat.S_ISLNK(metadata.st_mode): + kind = "symlink" + sha256, size, metadata = symlink_snapshot(path, app) + else: + raise ValueError(f"release app entry changed type while it was inventoried: {path}") + mode = f"{stat.S_IMODE(metadata.st_mode):04o}" + bom_ref = "dory-file:" + urllib.parse.quote(relative, safe="/._-") + components.append( + { + "type": "file", + "bom-ref": bom_ref, + "name": relative, + "hashes": [{"alg": "SHA-256", "content": sha256}], + "properties": [ + {"name": "dev.dory.file.type", "value": kind}, + {"name": "dev.dory.file.mode", "value": mode}, + {"name": "dev.dory.file.size", "value": str(size)}, + ], + } + ) + tree.update(f"{relative}\0{kind}\0{mode}\0{size}\0{sha256}\n".encode()) + return components, tree.hexdigest() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=pathlib.Path) + parser.add_argument("--version", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--output", required=True, type=pathlib.Path) + args = parser.parse_args() + if len(args.source_commit) != 40 or any(char not in "0123456789abcdef" for char in args.source_commit): + raise SystemExit("source commit must be a full lowercase Git SHA") + + components, tree_sha256 = inventory(args.app) + if not components: + raise SystemExit("Dory.app inventory is empty") + root_ref = f"pkg:github/Augani/dory@{args.version}?commit={args.source_commit}" + serial = uuid.uuid5(uuid.NAMESPACE_URL, f"{root_ref}#{tree_sha256}") + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{serial}", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": root_ref, + "group": "Augani", + "name": "Dory", + "version": args.version, + "licenses": [{"license": {"id": "GPL-3.0-only"}}], + "properties": [ + {"name": "dev.dory.source.commit", "value": args.source_commit}, + {"name": "dev.dory.app.tree.sha256", "value": tree_sha256}, + {"name": "dev.dory.inventory.scope", "value": "exact-shipped-app-files"}, + ], + } + }, + "components": components, + "dependencies": [{"ref": root_ref, "dependsOn": [item["bom-ref"] for item in components]}], + } + output = args.output.resolve() + app_root = args.app.resolve(strict=True) + if output == app_root or app_root in output.parents: + raise SystemExit("SBOM output must remain outside Dory.app") + output.parent.mkdir(parents=True, exist_ok=True) + payload = (json.dumps(document, indent=2, sort_keys=True) + "\n").encode("utf-8") + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{output.name}.tmp-", dir=output.parent) + temporary = pathlib.Path(temporary_name) + try: + os.fchmod(descriptor, 0o644) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, output) + directory_descriptor = os.open(output.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + if temporary.exists(): + temporary.unlink() + + +if __name__ == "__main__": + try: + main() + except (OSError, UnicodeError, ValueError) as error: + raise SystemExit(f"release SBOM generation error: {error}") from error diff --git a/scripts/verify-release-sbom.py b/scripts/verify-release-sbom.py new file mode 100755 index 00000000..2dc8a9c9 --- /dev/null +++ b/scripts/verify-release-sbom.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Verify that a Dory CycloneDX SBOM is an exact, portable app-tree inventory.""" + +import argparse +import importlib.util +import json +import pathlib +import re +import uuid + +generator_path = pathlib.Path(__file__).with_name("generate-release-sbom.py") +generator_spec = importlib.util.spec_from_file_location("generate_release_sbom", generator_path) +if generator_spec is None or generator_spec.loader is None: + raise RuntimeError("cannot load release SBOM generator") +generator = importlib.util.module_from_spec(generator_spec) +generator_spec.loader.exec_module(generator) + +DOCUMENT_KEYS = { + "bomFormat", + "specVersion", + "serialNumber", + "version", + "metadata", + "components", + "dependencies", +} + + +def unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"SBOM contains duplicate JSON key: {key}") + result[key] = value + return result + + +def exact_object(value: object, keys: set[str], label: str) -> dict: + if not isinstance(value, dict) or set(value) != keys: + raise ValueError(f"{label} shape is invalid") + return value + + +def properties(rows: object) -> dict[str, str]: + if not isinstance(rows, list): + raise ValueError("SBOM properties are malformed") + result: dict[str, str] = {} + for row in rows: + if not isinstance(row, dict) or set(row) != {"name", "value"} or row["name"] in result: + raise ValueError("SBOM property is malformed or duplicated") + result[str(row["name"])] = str(row["value"]) + return result + + +def leaks_runner_local_path(serialized: str, app: pathlib.Path) -> bool: + app_parent_root = app.resolve().parent.parent + filesystem_root = pathlib.Path(app_parent_root.anchor) + leaks_app_parent = app_parent_root != filesystem_root and str(app_parent_root) in serialized + return leaks_app_parent or "/Users/" in serialized + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--sbom", required=True, type=pathlib.Path) + parser.add_argument("--app", required=True, type=pathlib.Path) + parser.add_argument("--version", required=True) + parser.add_argument("--source-commit", required=True) + args = parser.parse_args() + if re.fullmatch(r"[0-9a-f]{40}", args.source_commit) is None: + raise ValueError("source commit must be a full lowercase Git SHA") + serialized = args.sbom.read_text(encoding="utf-8") + document = json.loads(serialized, object_pairs_hook=unique_json_object) + exact_object(document, DOCUMENT_KEYS, "release SBOM") + if document.get("bomFormat") != "CycloneDX" or document.get("specVersion") != "1.6": + raise ValueError("release SBOM is not CycloneDX 1.6") + if document.get("version") != 1: + raise ValueError("release SBOM identity is malformed") + expected_components, tree_sha256 = generator.inventory(args.app) + if document.get("components") != expected_components: + raise ValueError("release SBOM does not exactly inventory the shipped app tree") + metadata = exact_object(document["metadata"], {"component"}, "release SBOM metadata") + root = exact_object( + metadata["component"], + {"type", "bom-ref", "group", "name", "version", "licenses", "properties"}, + "release SBOM root component", + ) + root_ref = f"pkg:github/Augani/dory@{args.version}?commit={args.source_commit}" + expected_serial = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, f'{root_ref}#{tree_sha256}')}" + if document["serialNumber"] != expected_serial: + raise ValueError("release SBOM deterministic identity mismatch") + if root.get("type") != "application" or root.get("bom-ref") != root_ref: + raise ValueError("release SBOM root component mismatch") + if root.get("group") != "Augani" or root.get("name") != "Dory" or root.get("version") != args.version: + raise ValueError("release SBOM product identity mismatch") + if root.get("licenses") != [{"license": {"id": "GPL-3.0-only"}}]: + raise ValueError("release SBOM license mismatch") + expected_properties = { + "dev.dory.source.commit": args.source_commit, + "dev.dory.app.tree.sha256": tree_sha256, + "dev.dory.inventory.scope": "exact-shipped-app-files", + } + if properties(root.get("properties")) != expected_properties: + raise ValueError("release SBOM source/tree binding mismatch") + expected_dependencies = [{"ref": root_ref, "dependsOn": [item["bom-ref"] for item in expected_components]}] + if document.get("dependencies") != expected_dependencies: + raise ValueError("release SBOM dependency inventory mismatch") + if leaks_runner_local_path(serialized, args.app): + raise ValueError("release SBOM leaks a runner-local path") + print("release CycloneDX SBOM: PASS") + + +if __name__ == "__main__": + try: + main() + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: + raise SystemExit(f"release SBOM error: {error}") from error From a1cf9d90a7df3f80c1e0e7b05ea500ec763f5a7f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:46:29 +0000 Subject: [PATCH 108/338] feat(release): add clean DMG install gate --- .../scripts/test-direct-dmg-install-gate.py | 85 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/direct-dmg-install-gate.sh | 284 ++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 .github/scripts/test-direct-dmg-install-gate.py create mode 100755 scripts/direct-dmg-install-gate.sh diff --git a/.github/scripts/test-direct-dmg-install-gate.py b/.github/scripts/test-direct-dmg-install-gate.py new file mode 100644 index 00000000..23946c97 --- /dev/null +++ b/.github/scripts/test-direct-dmg-install-gate.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Offline contract checks for the destructive physical DMG install boundary.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "direct-dmg-install-gate.sh" + + +class DirectDMGInstallGateTests(unittest.TestCase): + def invoke(self, *arguments: str, runner_temp: str | None = None) -> subprocess.CompletedProcess[str]: + environment = dict(os.environ) + environment["DORY_RELEASE_CLEAN_USER"] = "1" + if runner_temp is not None: + environment["RUNNER_TEMP"] = runner_temp + return subprocess.run( + ["bash", str(GATE), *arguments], + cwd=ROOT, + env=environment, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def test_script_is_syntax_valid_and_has_no_python_assertions(self) -> None: + syntax = subprocess.run(["bash", "-n", str(GATE)], check=False) + self.assertEqual(syntax.returncode, 0) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + self.assertIn("validate-release-metadata.py", source) + self.assertIn("verify-release-sbom.py", source) + self.assertIn("hdiutil attach -readonly -nobrowse -plist", source) + self.assertIn("release-candidate-live-smoke.sh", source) + + def test_help_documents_the_destructive_confirmation(self) -> None: + result = self.invoke("--help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("CLEAN-RELEASE-USER-DMG-INSTALL", result.stdout) + self.assertIn("--install-only", result.stdout) + + def test_missing_confirmation_fails_before_mutation(self) -> None: + result = self.invoke( + "--dmg", "missing.dmg", + "--sbom", "missing.json", + "--release-manifest", "missing-manifest.json", + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--confirm", result.stderr) + + def test_workroot_must_be_beneath_runner_temp(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-dmg-gate-test.") as directory: + root = pathlib.Path(directory) + inputs = [] + for name in ("candidate.dmg", "candidate.cdx.json", "release-manifest.json"): + path = root / name + path.write_bytes(b"fixture\n") + inputs.append(path) + result = self.invoke( + "--dmg", str(inputs[0]), + "--sbom", str(inputs[1]), + "--release-manifest", str(inputs[2]), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--workroot", "/Applications", + "--confirm", "CLEAN-RELEASE-USER-DMG-INSTALL", + runner_temp=str(root), + ) + self.assertEqual(result.returncode, 2) + self.assertIn("unsafe --workroot", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b5cefc7..533fee5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,6 +336,7 @@ jobs: run: | python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-sbom.py + python3 .github/scripts/test-direct-dmg-install-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 783e44f1..dac45214 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,7 @@ jobs: run: | python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-sbom.py + python3 .github/scripts/test-direct-dmg-install-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/direct-dmg-install-gate.sh b/scripts/direct-dmg-install-gate.sh new file mode 100755 index 00000000..0472bb3b --- /dev/null +++ b/scripts/direct-dmg-install-gate.sh @@ -0,0 +1,284 @@ +#!/bin/bash +# Copy the exact DMG app into /Applications, apply the normal quarantine launch boundary, and run +# the existing physical candidate smoke against that installed tree. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DMG="" +SBOM="" +RELEASE_MANIFEST="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-release-direct-dmg" +CONFIRM="" +INSTALL_ONLY=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --dmg) need_value "$1" "$#"; DMG="$2"; shift 2 ;; + --sbom) need_value "$1" "$#"; SBOM="$2"; shift 2 ;; + --release-manifest) need_value "$1" "$#"; RELEASE_MANIFEST="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --install-only) INSTALL_ONLY=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +for pair in dmg:"$DMG" sbom:"$SBOM" release-manifest:"$RELEASE_MANIFEST" \ + version:"$VERSION" build:"$BUILD" source-commit:"$SOURCE_COMMIT"; do + [ -n "${pair#*:}" ] || die "--${pair%%:*} is required" +done +case "$BUILD" in ''|*[!0-9]*) die "--build must be a positive integer" ;; esac +[ "$BUILD" -gt 0 ] || die "--build must be a positive integer" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "--source-commit must be a full lowercase Git SHA" +[ "$CONFIRM" = CLEAN-RELEASE-USER-DMG-INSTALL ] \ + || die "--confirm CLEAN-RELEASE-USER-DMG-INSTALL is required" +[ "${DORY_RELEASE_CLEAN_USER:-0}" = 1 ] || die "DORY_RELEASE_CLEAN_USER=1 is required" + +absolute_path() { + case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s\n' "$ROOT/$1" ;; esac +} +DMG="$(absolute_path "$DMG")" +SBOM="$(absolute_path "$SBOM")" +RELEASE_MANIFEST="$(absolute_path "$RELEASE_MANIFEST")" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") die "unsafe --workroot: $WORKROOT" ;; esac +for input in "$DMG" "$SBOM" "$RELEASE_MANIFEST"; do + [ -f "$input" ] && [ ! -L "$input" ] && [ -s "$input" ] \ + || die "release input is not a non-empty regular file: $input" +done +[ -n "${RUNNER_TEMP:-}" ] || die "RUNNER_TEMP must identify the dedicated release workspace" +case "$RUNNER_TEMP" in /*) ;; *) die "RUNNER_TEMP must be absolute" ;; esac +[ ! -L "$WORKROOT" ] || die "--workroot must not be a symlink" +workspace="$(python3 - "$RUNNER_TEMP" "$WORKROOT" <<'PY' +import os +import sys + +runner, requested = map(os.path.realpath, sys.argv[1:]) +if requested == runner or not requested.startswith(runner.rstrip(os.sep) + os.sep): + raise SystemExit("workroot must be a strict child of RUNNER_TEMP") +print(requested) +PY +)" || die "unsafe --workroot: $WORKROOT" +WORKROOT="$workspace" + +BUILD_DIR="$(cd "$(dirname "$RELEASE_MANIFEST")" && pwd)" +manifest_commit="$(python3 "$ROOT/scripts/validate-release-metadata.py" "$BUILD_DIR" "$VERSION" "$BUILD")" \ + || die "candidate metadata is invalid" +[ "$manifest_commit" = "$SOURCE_COMMIT" ] || die "candidate source commit mismatch" +DMG_NAME="$(basename "$DMG")" +DMG_SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" +python3 - "$RELEASE_MANIFEST" "$DMG_NAME" "$DMG_SHA" <<'PY' +import json, pathlib, sys +manifest, name, digest = sys.argv[1:] +records = {row["name"]: row for row in json.loads(pathlib.Path(manifest).read_text(encoding="utf-8"))["artifacts"]} +record = records.get(name) +if record is None or record.get("sha256") != digest: + raise SystemExit("DMG differs from the immutable release manifest") +PY + +APP="/Applications/Dory.app" +PREF_DOMAIN="com.pythonxi.Dory" +PREF_PLIST="$HOME/Library/Preferences/$PREF_DOMAIN.plist" +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +DRIVE="$APP_SUPPORT/Dory.dorydrive" +SELECTION="$APP_SUPPORT/data-drive-selection.json" +SERVICE="gui/$(id -u)/dev.dory.doryd" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +[ ! -e "$APP" ] || die "$APP already exists" +[ ! -e "$STATE" ] || die "existing Dory state would be touched: $STATE" +[ ! -e "$APP_SUPPORT" ] || die "existing Dory application state would be touched: $APP_SUPPORT" +[ ! -e "$PLIST" ] || die "existing Dory LaunchAgent would be touched: $PLIST" +defaults read "$PREF_DOMAIN" >/dev/null 2>&1 \ + && die "existing Dory preferences would be touched; use a clean dedicated release user" +launchctl print "$SERVICE" >/dev/null 2>&1 \ + && die "Dory service is already loaded; use a clean dedicated release user" +for process in Dory doryd dory-hv dory-vmm; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "$process is already running; use a clean dedicated release user" +done +rm -rf "$WORKROOT" +mkdir -p "$WORKROOT/evidence" +EVIDENCE="$WORKROOT/evidence" +MOUNT="" + +cleanup() { + set +e + [ -z "$MOUNT" ] || hdiutil detach "$MOUNT" -quiet >/dev/null 2>&1 || true + /usr/bin/pkill -u "$(/usr/bin/id -u)" -x Dory >/dev/null 2>&1 || true + if [ -S "$STATE/dory.sock" ] && [ -x "$APP/Contents/Helpers/dory" ]; then + "$APP/Contents/Helpers/dory" engine sleep >/dev/null 2>&1 || true + "$APP/Contents/Helpers/dory" uninstall >/dev/null 2>&1 || true + fi + launchctl bootout "$SERVICE" >/dev/null 2>&1 || true + rm -f "$PLIST" + rm -rf "$STATE" "$APP_SUPPORT" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + if [ -d "$APP" ] && [ "$(defaults read "$APP/Contents/Info" CFBundleIdentifier 2>/dev/null)" = "$PREF_DOMAIN" ]; then + rm -rf "$APP" + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +quarantine="0081;$(printf '%x' "$(date +%s)");dev.dory.release-gate;" +xattr -w com.apple.quarantine "$quarantine" "$DMG" +xattr -p com.apple.quarantine "$DMG" > "$EVIDENCE/dmg-quarantine.txt" +/usr/bin/hdiutil attach -readonly -nobrowse -plist "$DMG" > "$EVIDENCE/image-attach.plist" +MOUNT="$(python3 - "$EVIDENCE/image-attach.plist" <<'PY' +import plistlib, sys +with open(sys.argv[1], "rb") as handle: + data = plistlib.load(handle) +mounts = [entry["mount-point"] for entry in data["system-entities"] if entry.get("mount-point")] +if len(mounts) != 1: + raise SystemExit(f"expected one DMG mount, found {mounts}") +print(mounts[0]) +PY +)" +[ -d "$MOUNT/Dory.app" ] && [ ! -L "$MOUNT/Dory.app" ] \ + || die "DMG does not contain a regular Dory.app bundle" +[ -L "$MOUNT/Applications" ] || die "DMG does not contain its Applications link" +[ "$(readlink "$MOUNT/Applications")" = /Applications ] || die "DMG Applications link is invalid" +[ "$(find "$MOUNT" -maxdepth 1 -type d -name '*.app' -print | wc -l | tr -d ' ')" = 1 ] \ + || die "DMG contains an unexpected app layout" +/usr/bin/ditto "$MOUNT/Dory.app" "$APP" +hdiutil detach "$MOUNT" -quiet +MOUNT="" + +xattr -w com.apple.quarantine "$quarantine" "$APP" +xattr -p com.apple.quarantine "$APP" > "$EVIDENCE/quarantine.txt" +codesign --verify --deep --strict "$APP" > "$EVIDENCE/codesign.log" 2>&1 +xcrun stapler validate "$APP" > "$EVIDENCE/stapler.log" 2>&1 +spctl --assess --type execute --verbose=4 "$APP" > "$EVIDENCE/gatekeeper.log" 2>&1 +"$ROOT/scripts/verify-release-sbom.py" --sbom "$SBOM" --app "$APP" \ + --version "$VERSION" --source-commit "$SOURCE_COMMIT" > "$EVIDENCE/sbom.log" + +if [ "$INSTALL_ONLY" -eq 1 ]; then + defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true + defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool false + open "$APP" + ready=0 + for _ in $(seq 1 360); do + if [ -S "$STATE/dory.sock" ] \ + && curl -fsS --max-time 2 --unix-socket "$STATE/dory.sock" http://d/_ping >/dev/null 2>&1; then + ready=1 + break + fi + pgrep -u "$(id -u)" -x Dory >/dev/null 2>&1 \ + || die "DMG-installed Dory exited during first launch" + sleep 0.5 + done + [ "$ready" -eq 1 ] || die "DMG-installed Dory did not become ready" + "$APP/Contents/Helpers/docker" -H "unix://$STATE/dory.sock" version \ + > "$EVIDENCE/docker-version.txt" + [ -s "$DRIVE/drive.json" ] || die "DMG first launch did not create the durable Dory drive" + [ -s "$SELECTION" ] || die "DMG first launch did not record the selected Dory drive" + sentinel="$DRIVE/direct-dmg-uninstall-preservation.txt" + printf 'source_commit=%s\ndmg_sha256=%s\n' "$SOURCE_COMMIT" "$DMG_SHA" > "$sentinel" + + osascript -e 'tell application id "com.pythonxi.Dory" to quit' >/dev/null 2>&1 || true + for _ in $(seq 1 120); do + pgrep -u "$(id -u)" -x Dory >/dev/null 2>&1 || break + sleep 0.25 + done + "$APP/Contents/Helpers/dory" uninstall > "$EVIDENCE/uninstall.log" 2>&1 + for _ in $(seq 1 120); do + remaining=0 + for process in Dory doryd dory-hv dory-vmm; do + pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 && remaining=1 + done + [ "$remaining" -eq 1 ] || break + sleep 0.25 + done + for process in Dory doryd dory-hv dory-vmm; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "DMG uninstall left $process running" + done + launchctl print "$SERVICE" >/dev/null 2>&1 && die "DMG uninstall left doryd loaded" + [ ! -e "$PLIST" ] || die "DMG uninstall left the doryd LaunchAgent" + [ -f "$sentinel" ] || die "DMG uninstall removed the durable Dory drive" + grep -qx "source_commit=$SOURCE_COMMIT" "$sentinel" + grep -qx "dmg_sha256=$DMG_SHA" "$sentinel" + + cleanup + set -e + [ ! -e "$APP" ] && [ ! -e "$STATE" ] && [ ! -e "$APP_SUPPORT" ] && [ ! -e "$PLIST" ] \ + || die "DMG install-only gate did not restore the clean user state" + defaults read "$PREF_DOMAIN" >/dev/null 2>&1 \ + && die "DMG install-only gate left Dory preferences" + + { + printf 'source_commit=%s\n' "$SOURCE_COMMIT" + printf 'run_id=%s\n' "${GITHUB_RUN_ID:-local}" + printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT:-local}" + printf 'version=%s\n' "$VERSION" + printf 'build=%s\n' "$BUILD" + printf 'dmg_sha256=%s\n' "$DMG_SHA" + printf 'normal_quarantine=PASS\n' + printf 'gatekeeper=PASS\n' + printf 'sbom=PASS\n' + printf 'first_launch=PASS\n' + printf 'docker_ready=PASS\n' + printf 'uninstall_preserved_data=PASS\n' + printf 'initial_clean_user_state_restored=PASS\n' + printf 'release_qualifying=false\n' + printf 'status=PASS\n' + } > "$EVIDENCE/install-manifest.txt" + + echo "Direct DMG install-only gate: PASS ($EVIDENCE/install-manifest.txt)" + exit 0 +fi + +"$ROOT/scripts/release-candidate-live-smoke.sh" "$APP" +[ ! -e "$HOME/.dory" ] || die "physical smoke did not restore the clean runtime state" +[ ! -e "$HOME/Library/Application Support/Dory" ] \ + || die "physical smoke did not restore the clean durable-data state" +launchctl print "$SERVICE" >/dev/null 2>&1 \ + && die "physical smoke left doryd loaded" + +{ + printf 'source_commit=%s\n' "$SOURCE_COMMIT" + printf 'run_id=%s\n' "${GITHUB_RUN_ID:-local}" + printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT:-local}" + printf 'version=%s\n' "$VERSION" + printf 'build=%s\n' "$BUILD" + printf 'dmg_sha256=%s\n' "$DMG_SHA" + printf 'normal_quarantine=PASS\n' + printf 'gatekeeper=PASS\n' + printf 'sbom=PASS\n' + printf 'live_smoke=PASS\n' + printf 'initial_clean_user_state_restored=PASS\n' + printf 'status=PASS\n' +} > "$EVIDENCE/manifest.txt" + +echo "Direct DMG install gate: PASS ($EVIDENCE/manifest.txt)" From 4f1d3ff6a564185d766430de9acdc94e787914b5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 03:53:43 +0000 Subject: [PATCH 109/338] feat(components): import signed local candidates --- ...DorySignedComponentCandidateImporter.swift | 255 ++++++++++++++++ dory-core-swift/Sources/dorydctl/main.swift | 36 ++- ...ignedComponentCandidateImporterTests.swift | 272 ++++++++++++++++++ 3 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift diff --git a/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift b/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift new file mode 100644 index 00000000..75c0f7da --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift @@ -0,0 +1,255 @@ +import CryptoKit +import Darwin +import Foundation + +public struct DorySignedComponentCandidateImportResult: Sendable, Equatable { + public let catalogDigest: String + public let installed: [DoryInstalledComponent] + + public init(catalogDigest: String, installed: [DoryInstalledComponent]) { + self.catalogDigest = catalogDigest + self.installed = installed + } +} + +/// Installs an immutable pre-publication component set from a local release-candidate directory. +/// +/// The directory is only transport. The compiled production key authenticates the exact catalog, +/// and every local filename, byte count, digest, dependency, and installed result is derived from +/// that signed catalog before it can become active. +public struct DorySignedComponentCandidateImporter: Sendable { + public let store: DoryComponentStore + private let publicKey: String + private let expectedArchitecture: String + private let appVersion: String + + public init( + store: DoryComponentStore, + expectedArchitecture: String = DoryComponentDefaults.architecture, + appVersion: String + ) { + self.init( + store: store, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + } + + init( + store: DoryComponentStore, + publicKey: String, + expectedArchitecture: String, + appVersion: String + ) { + self.store = store + self.publicKey = publicKey + self.expectedArchitecture = expectedArchitecture + self.appVersion = appVersion + } + + public func install( + _ id: DoryComponentID, + from candidateDirectory: String + ) throws -> DorySignedComponentCandidateImportResult { + guard id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + let root = try Self.candidateDirectory(candidateDirectory) + let catalogPath = try Self.candidateFile("catalog.json", in: root) + let digestPath = try Self.candidateFile("catalog.json.sha256", in: root) + let signaturePath = try Self.candidateFile("catalog.json.sig", in: root) + let catalogData = try Self.stableData(catalogPath, maximumBytes: 2 * 1_024 * 1_024) + let digestData = try Self.stableData(digestPath, maximumBytes: 256) + let signatureData = try Self.stableData(signaturePath, maximumBytes: 256) + guard let digestLine = String(data: digestData, encoding: .ascii), + let signatureLine = String(data: signatureData, encoding: .ascii), + digestLine == DoryComponentCatalogVerifier.digest(catalogData) + "\n", + signatureLine == signatureLine.trimmingCharacters(in: .whitespacesAndNewlines) + "\n" else { + throw DoryComponentError.invalidCatalog("candidate metadata binding is invalid") + } + let catalog = try DoryComponentCatalogVerifier.verify( + catalogData: catalogData, + signatureBase64: signatureLine, + publicKeyBase64: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + guard catalog.schemaVersion == DoryComponentCatalog.schemaVersion, + let qualification = catalog.virtualMachineQualification, + let publicKeyData = Data(base64Encoded: publicKey), + publicKeyData.count == 32, + qualification.signingKeyID == Self.digest(publicKeyData) else { + throw DoryComponentError.invalidCatalog( + "candidate does not contain production VM qualification authority" + ) + } + let ordered = try Self.installationOrder(id, catalog: catalog) + let availableIDs = Set(catalog.components.map(\.id)) + guard ordered.allSatisfy({ release in + release.dependencies.allSatisfy { $0 == .dockerCore || availableIDs.contains($0) } + }) else { + throw DoryComponentError.invalidCatalog("candidate dependency is unavailable") + } + + var sourcesByComponent: [DoryComponentID: [String: String]] = [:] + var claimedNames: Set = [] + for release in ordered { + var sources: [String: String] = [:] + for asset in release.assets { + guard let url = URL(string: asset.url), + url.scheme == "https", + url.host == "github.com", + url.user == nil, + url.password == nil, + url.port == nil, + url.query == nil, + url.fragment == nil, + url.path.hasPrefix( + "/Augani/dory/releases/download/v\(catalog.releaseVersion)/" + ) else { + throw DoryComponentError.invalidCatalog( + "candidate asset URL is outside the signed Dory release" + ) + } + let name = url.lastPathComponent + guard DoryComponentCatalogVerifier.safeRelativePath(name), + name.hasPrefix( + "Dory-\(catalog.releaseVersion)-component-\(release.id.rawValue)-" + ), + claimedNames.insert(name).inserted else { + throw DoryComponentError.invalidCatalog("candidate asset name is invalid") + } + sources[asset.path] = try Self.candidateFile(name, in: root) + } + sourcesByComponent[release.id] = sources + } + + _ = try store.cacheCatalog( + data: catalogData, + signature: signatureLine, + publicKey: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + let catalogDigest = DoryComponentCatalogVerifier.digest(catalogData) + var installed: [DoryInstalledComponent] = [] + for release in ordered { + let component = try store.install( + release, + catalogDigest: catalogDigest, + downloadedAssets: sourcesByComponent[release.id] ?? [:] + ) + try store.verify(component) + installed.append(component) + } + return DorySignedComponentCandidateImportResult( + catalogDigest: catalogDigest, + installed: installed + ) + } + + private static func installationOrder( + _ id: DoryComponentID, + catalog: DoryComponentCatalog + ) throws -> [DoryComponentRelease] { + var visited: Set = [] + var visiting: Set = [] + var ordered: [DoryComponentRelease] = [] + func append(_ current: DoryComponentID) throws { + guard current != .dockerCore, !visited.contains(current) else { return } + guard visiting.insert(current).inserted, + let release = catalog.component(current) else { + throw DoryComponentError.invalidCatalog("candidate dependency graph is invalid") + } + for dependency in release.dependencies { try append(dependency) } + visiting.remove(current) + visited.insert(current) + ordered.append(release) + } + try append(id) + return ordered + } + + private static func candidateDirectory(_ path: String) throws -> String { + var info = stat() + guard !path.isEmpty, + lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == getuid(), + info.st_mode & 0o022 == 0 else { + throw DoryComponentError.invalidAsset(path) + } + return URL(fileURLWithPath: path).standardizedFileURL.path + } + + private static func candidateFile(_ name: String, in root: String) throws -> String { + guard DoryComponentCatalogVerifier.safeRelativePath(name) else { + throw DoryComponentError.invalidAsset(name) + } + let path = root + "/" + name + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + info.st_size > 0, + info.st_mode & 0o022 == 0 else { + throw DoryComponentError.invalidAsset(path) + } + return path + } + + private static func stableData(_ path: String, maximumBytes: Int) throws -> Data { + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { throw DoryComponentError.invalidAsset(path) } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_size > 0, + before.st_size <= maximumBytes else { + throw DoryComponentError.invalidAsset(path) + } + var data = Data() + data.reserveCapacity(Int(before.st_size)) + var buffer = [UInt8](repeating: 0, count: min(maximumBytes, 1 << 20)) + while true { + let count = buffer.withUnsafeMutableBytes { + read(descriptor, $0.baseAddress, $0.count) + } + guard count >= 0 else { + throw DoryComponentError.filesystem( + "read signed component candidate: errno \(errno)" + ) + } + if count == 0 { break } + guard data.count + count <= maximumBytes else { + throw DoryComponentError.invalidAsset(path) + } + data.append(buffer, count: count) + } + var after = stat() + var current = stat() + guard fstat(descriptor, &after) == 0, + lstat(path, ¤t) == 0, + Self.sameSnapshot(before, after), + Self.sameSnapshot(after, current) else { + throw DoryComponentError.invalidAsset(path) + } + return data + } + + private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_mode == rhs.st_mode + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec + && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 600eac0a..7b02cf54 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -170,7 +170,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE - dorydctl [global] machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH + dorydctl [global] machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --distribution-installation ID --runtime-installation ID dorydctl [global] machine snapshots [NAME] dorydctl [global] machine snapshot NAME [--note NOTE] [--id ID] dorydctl [global] machine clone-snapshot NAME SNAPSHOT_ID NEW_NAME @@ -184,6 +184,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine backup remove NAME dorydctl [global] component list [--json] [--offline] dorydctl [global] component install|update ID [--json] + dorydctl [global] component install-candidate ID --candidate-dir PATH [--json] dorydctl [global] component verify [ID|all] [--json] [--offline] dorydctl [global] component remove ID [--json] [--offline] dorydctl [global] remote connect NAME --host HOST --user USER --private-key-id ID --remote-root PATH (--host-key KEY | --known-hosts PATH) [--port N] [--endpoint-unix PATH | --endpoint-tcp HOST:PORT] @@ -391,6 +392,39 @@ private func runComponent(cursor: inout ArgumentCursor) throws { print(path) return } + if subcommand == "install-candidate" { + let rawID = try cursor.take( + "usage: dorydctl component install-candidate ID --candidate-dir PATH [--json]" + ) + let candidateDirectory = try requiredOption( + "--candidate-dir", + cursor: &cursor, + usage: "usage: dorydctl component install-candidate ID --candidate-dir PATH [--json]" + ) + guard cursor.values.isEmpty, let id = DoryComponentID(rawValue: rawID), id.isRemovable else { + throw DoryComponentError.unknownComponent(rawID) + } + let importer = DorySignedComponentCandidateImporter( + store: store, + appVersion: componentAppVersion() + ) + let result = try importer.install(id, from: candidateDirectory) + if json { + try emitJSON([ + "schema": "dev.dory.component-candidate-import", + "schemaVersion": 1, + "catalogDigest": result.catalogDigest, + "installations": Dictionary(uniqueKeysWithValues: result.installed.map { + ($0.id.rawValue, $0.installationName) + }), + ] as NSDictionary) + } else { + for component in result.installed { + print("\(component.id.rawValue)\t\(component.installationName)") + } + } + return + } let catalog = try loadComponentCatalog(store: store, offline: offline) let digest = DoryComponentCatalogVerifier.digest(catalog.data) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift new file mode 100644 index 00000000..ab63849b --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift @@ -0,0 +1,272 @@ +@testable import DoryOperations +import CryptoKit +import Foundation +import Testing + +@Suite("Signed component candidate importer") +struct DorySignedComponentCandidateImporterTests { + @Test("signed local candidate installs exact dependencies and caches its authority") + func installsSignedCandidate() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let candidate = try fixture.candidate() + let result = try candidate.importer.install(.desktopUbuntu, from: candidate.directory.path) + + #expect(result.installed.map(\.id) == [.linuxDesktop, .desktopUbuntu]) + #expect(try fixture.store.verify(.linuxDesktop).installationName + == result.installed[0].installationName) + #expect(try fixture.store.verify(.desktopUbuntu).installationName + == result.installed[1].installationName) + #expect(try fixture.store.cachedCatalog( + publicKey: candidate.publicKey, + expectedArchitecture: "arm64", + appVersion: Fixture.version + )?.catalog == candidate.catalog) + } + + @Test("asset mutation is rejected without activating the component") + func rejectsMutatedAsset() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let candidate = try fixture.candidate() + try Data("tampered".utf8).write(to: candidate.updateAsset) + + #expect(throws: DoryComponentError.self) { + try candidate.importer.install(.desktopUbuntu, from: candidate.directory.path) + } + #expect(try fixture.store.installedComponent(.desktopUbuntu) == nil) + } + + @Test("catalog mutation is rejected even when its checksum sidecar is rewritten") + func rejectsCatalogMutation() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let candidate = try fixture.candidate() + var bytes = try Data(contentsOf: candidate.directory.appendingPathComponent("catalog.json")) + bytes.append(Data(" ".utf8)) + try bytes.write(to: candidate.directory.appendingPathComponent("catalog.json")) + try Data((Fixture.digest(bytes) + "\n").utf8).write( + to: candidate.directory.appendingPathComponent("catalog.json.sha256") + ) + + #expect(throws: DoryComponentError.self) { + try candidate.importer.install(.desktopUbuntu, from: candidate.directory.path) + } + #expect(try fixture.store.installedComponent(.linuxDesktop) == nil) + } + + @Test("candidate assets cannot be symlinks") + func rejectsSymlinkAsset() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let candidate = try fixture.candidate() + let replacement = fixture.root.appendingPathComponent("replacement") + try Data("desktop update".utf8).write(to: replacement) + try FileManager.default.removeItem(at: candidate.updateAsset) + try FileManager.default.createSymbolicLink( + at: candidate.updateAsset, + withDestinationURL: replacement + ) + + #expect(throws: DoryComponentError.self) { + try candidate.importer.install(.desktopUbuntu, from: candidate.directory.path) + } + } + + private final class Fixture { + static let version = "9.8.7" + + let root: URL + let store: DoryComponentStore + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-signed-candidate-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let drive = try DoryDataDrive(home: root.appendingPathComponent("home").path) + try drive.prepare() + store = DoryComponentStore(drive: drive) + try store.prepare() + } + + func cleanup() { + try? FileManager.default.removeItem(at: root) + } + + struct Candidate { + let directory: URL + let updateAsset: URL + let publicKey: String + let catalog: DoryComponentCatalog + let importer: DorySignedComponentCandidateImporter + } + + func candidate() throws -> Candidate { + let directory = root.appendingPathComponent("candidate", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory.path + ) + let key = Curve25519.Signing.PrivateKey() + let publicKey = key.publicKey.rawRepresentation.base64EncodedString() + let qualificationData = Data("qualification".utf8) + let kernelData = Data("desktop kernel".utf8) + let updateData = Data("desktop update".utf8) + let qualificationName = assetName(.linuxDesktop, "virtual-machine-qualification.json") + let kernelName = assetName(.linuxDesktop, "Image-desktop") + let updateName = assetName(.desktopUbuntu, "dory-desktop-ubuntu-update-arm64.tar") + let qualificationURL = try write(qualificationData, name: qualificationName, in: directory) + _ = try write(kernelData, name: kernelName, in: directory) + let updateURL = try write(updateData, name: updateName, in: directory) + let provenance = DoryComponentProvenance( + sourceCommit: String(repeating: "a", count: 40), + builder: "dory.release.test", + recipeDigest: String(repeating: "b", count: 64), + sbomDigest: String(repeating: "c", count: 64), + attestationDigest: Self.digest(qualificationData) + ) + let host = DoryComponentHostRequirements(platform: "macos", minimumVersion: "14.0") + let core = DoryComponentRelease( + id: .dockerCore, + version: Self.version, + displayName: "Docker Core", + summary: "Signed app", + dependencies: [], + downloadBytes: 1, + installedBytes: 1, + assets: [], + architectures: ["arm64"], + hostRequirements: host, + provides: ["app.dory-core@9.8.7"], + requires: [], + provenance: provenance, + qualification: [] + ) + let runtime = DoryComponentRelease( + id: .linuxDesktop, + version: Self.version, + displayName: "Linux Desktop", + summary: "Signed desktop runtime", + dependencies: [.dockerCore], + downloadBytes: UInt64(qualificationData.count + kernelData.count), + installedBytes: UInt64(qualificationData.count + kernelData.count), + assets: [ + asset( + path: "virtual-machine-qualification.json", + name: qualificationName, + data: qualificationData, + role: .qualificationEvidence + ), + asset( + path: "Image-desktop", + name: kernelName, + data: kernelData, + role: .guestKernel + ), + ], + architectures: ["arm64"], + hostRequirements: host, + provides: ["device.virtio-gpu.venus@1"], + requires: ["app.dory-core>=9.8.7"], + provenance: provenance, + qualification: ["qualification-1"] + ) + let distribution = DoryComponentRelease( + id: .desktopUbuntu, + version: Self.version, + displayName: "Ubuntu Desktop", + summary: "Signed Ubuntu update", + dependencies: [.dockerCore, .linuxDesktop], + downloadBytes: UInt64(updateData.count), + installedBytes: UInt64(updateData.count), + assets: [asset( + path: "dory-desktop-ubuntu-update-arm64.tar", + name: updateName, + data: updateData, + role: .guestUpdate + )], + architectures: ["arm64"], + hostRequirements: host, + provides: ["guest.desktop.ubuntu@9.8.7"], + requires: ["device.virtio-gpu.venus@1"], + provenance: provenance, + qualification: [] + ) + let catalog = DoryComponentCatalog( + releaseVersion: Self.version, + generatedAt: "2026-08-21T01:02:03Z", + minimumAppVersion: Self.version, + architecture: "arm64", + components: [core, runtime, distribution], + virtualMachineQualification: DoryComponentVirtualMachineQualificationAsset( + component: .linuxDesktop, + path: qualificationURL.lastPathComponent.replacingOccurrences( + of: "Dory-\(Self.version)-component-linux-desktop-arm64-", + with: "" + ), + manifestIdentity: "qualification-manifest-1", + manifestFormatVersion: 1, + signingKeyID: Self.digest(key.publicKey.rawRepresentation) + ) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let catalogData = try encoder.encode(catalog) + Data("\n".utf8) + let signature = try key.signature(for: catalogData).base64EncodedString() + "\n" + _ = try write(catalogData, name: "catalog.json", in: directory) + _ = try write(Data((Self.digest(catalogData) + "\n").utf8), name: "catalog.json.sha256", in: directory) + _ = try write(Data(signature.utf8), name: "catalog.json.sig", in: directory) + return Candidate( + directory: directory, + updateAsset: updateURL, + publicKey: publicKey, + catalog: catalog, + importer: DorySignedComponentCandidateImporter( + store: store, + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: Self.version + ) + ) + } + + private func asset( + path: String, + name: String, + data: Data, + role: DoryComponentArtifactRole + ) -> DoryComponentAsset { + DoryComponentAsset( + path: path, + url: "https://github.com/Augani/dory/releases/download/v\(Self.version)/\(name)", + downloadBytes: UInt64(data.count), + installedBytes: UInt64(data.count), + sha256: Self.digest(data), + installedSHA256: Self.digest(data), + role: role + ) + } + + private func assetName(_ id: DoryComponentID, _ path: String) -> String { + "Dory-\(Self.version)-component-\(id.rawValue)-arm64-\(path)" + } + + @discardableResult + private func write(_ data: Data, name: String, in directory: URL) throws -> URL { + let path = directory.appendingPathComponent(name) + try data.write(to: path) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path.path + ) + return path + } + + static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + } +} From 834fd2f48043a268886b2bf02b7341d0840023e3 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:02:50 +0000 Subject: [PATCH 110/338] fix(release): qualify signed Venus desktops --- .../scripts/test-desktop-linux-live-gate.py | 110 +++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/desktop-linux-live-gate.sh | 223 ++++++++++++++---- 4 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 .github/scripts/test-desktop-linux-live-gate.py diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py new file mode 100644 index 00000000..a9634827 --- /dev/null +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical managed-desktop release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "desktop-linux-live-gate.sh" + + +class DesktopLinuxLiveGateTests(unittest.TestCase): + def test_shell_contract_uses_signed_typed_venus_path(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + + required = ( + "--component-dir", + "component install-candidate", + "component verify all --offline --json", + "component path linux-desktop dory-desktop-kernel-arm64.lzfse", + 'cmp -s "$KERNEL" "$installed_kernel"', + "--guest-user dorygate --guest-uid 1550", + '--desktop-distro "$distro" --runtime accelerated --graphics virgl-venus', + "--clipboard bidirectional", + "--resolved-graphics hardware-accelerated-3d", + "virgl2+venus", + "venus-ready:", + '--distribution-installation "$distribution_installation"', + '--runtime-installation "$runtime_installation"', + '"provenance": "verified-update-bundle"', + "stale-update-rejected", + "workroot must be a strict child of RUNNER_TEMP", + ) + for proof in required: + self.assertIn(proof, text, proof) + + forbidden = ( + "--env DORY_", + '--env "DORY_', + '--bundle "$update_bundle"', + '--kernel "$KERNEL"', + "assert ", + "= virgl2\n", + "rollback-pass", + ) + for stale in forbidden: + self.assertNotIn(stale, text, stale) + + def test_workroot_must_be_inside_runner_temp(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + runner = root / "runner" + helpers = root / "helpers" + components = root / "components" + outside = root / "outside" + for directory in (runner, helpers, components): + directory.mkdir() + for helper in ("dorydctl", "dory-hv", "dory-vmm"): + path = helpers / helper + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o700) + assets = [] + for name in ("Image-desktop", "ubuntu.ext4", "ubuntu-update.tar"): + path = root / name + path.write_bytes(name.encode("ascii")) + assets.append(path) + + result = subprocess.run( + [ + str(GATE), + "--ctl", + str(helpers / "dorydctl"), + "--component-dir", + str(components), + "--kernel", + str(assets[0]), + "--ubuntu-rootfs", + str(assets[1]), + "--ubuntu-update", + str(assets[2]), + "--distro", + "ubuntu", + "--version", + "9.8.7", + "--workroot", + str(outside), + "--confirm", + "EXACT-CANDIDATE-DESKTOPS", + ], + cwd=ROOT, + env={**os.environ, "RUNNER_TEMP": str(runner)}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 64, result.stderr) + self.assertIn("workroot must be a strict child of RUNNER_TEMP", result.stderr) + self.assertFalse(outside.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 533fee5b..d484efd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -337,6 +337,7 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py + python3 .github/scripts/test-desktop-linux-live-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dac45214..0ea9a225 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,6 +38,7 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py + python3 .github/scripts/test-desktop-linux-live-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index 1add0c89..a88ab266 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" CTL="" +COMPONENT_DIR="" KERNEL="" DEBIAN_ROOTFS="" UBUNTU_ROOTFS="" @@ -20,13 +21,14 @@ WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-desktop-linux-live" CONFIRM="" usage() { - echo "usage: desktop-linux-live-gate.sh --ctl PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2 + echo "usage: desktop-linux-live-gate.sh --ctl PATH --component-dir PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2 exit 64 } while [ "$#" -gt 0 ]; do case "$1" in --ctl) CTL="${2:?missing path}"; shift 2 ;; + --component-dir) COMPONENT_DIR="${2:?missing path}"; shift 2 ;; --kernel) KERNEL="${2:?missing path}"; shift 2 ;; --debian-rootfs) DEBIAN_ROOTFS="${2:?missing path}"; shift 2 ;; --ubuntu-rootfs) UBUNTU_ROOTFS="${2:?missing path}"; shift 2 ;; @@ -48,6 +50,8 @@ case "$SELECTED_DISTRO" in *) echo "desktop live gate: unsupported distro selection: $SELECTED_DISTRO" >&2; exit 64 ;; esac [ -x "$CTL" ] || { echo "desktop live gate: missing dorydctl: $CTL" >&2; exit 66; } +[ -d "$COMPONENT_DIR" ] && [ ! -L "$COMPONENT_DIR" ] \ + || { echo "desktop live gate: missing component candidate directory: $COMPONENT_DIR" >&2; exit 66; } HELPERS="$(cd "$(dirname "$CTL")" && pwd)" VMM="$HELPERS/dory-hv" VZ_VMM="$HELPERS/dory-vmm" @@ -61,7 +65,8 @@ case "$SELECTED_DISTRO" in kali) assets+=("$KALI_ROOTFS" "$KALI_UPDATE") ;; esac for asset in "${assets[@]}"; do - [ -s "$asset" ] || { echo "desktop live gate: missing asset: $asset" >&2; exit 66; } + [ -f "$asset" ] && [ ! -L "$asset" ] && [ -s "$asset" ] \ + || { echo "desktop live gate: missing regular asset: $asset" >&2; exit 66; } done absolute_asset() { local asset_input="$1" @@ -70,6 +75,7 @@ absolute_asset() { printf '%s/%s\n' "$asset_directory" "$(basename "$asset_input")" } KERNEL="$(absolute_asset "$KERNEL")" +COMPONENT_DIR="$(cd "$COMPONENT_DIR" && pwd -P)" case "$SELECTED_DISTRO" in all) DEBIAN_ROOTFS="$(absolute_asset "$DEBIAN_ROOTFS")" @@ -94,16 +100,22 @@ case "$SELECTED_DISTRO" in esac printf '%s\n' "$DESKTOP_VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$' \ || { echo "desktop live gate: invalid desktop version: $DESKTOP_VERSION" >&2; exit 64; } -case "$WORKROOT" in - ""|/|"$HOME"|"$ROOT"|"${RUNNER_TEMP:-/definitely-not-this-path}") - echo "desktop live gate: unsafe workroot: $WORKROOT" >&2 - exit 64 - ;; -esac -case "$WORKROOT" in - *dory*desktop*) ;; - *) echo "desktop live gate: workroot must identify the Dory desktop gate: $WORKROOT" >&2; exit 64 ;; -esac +[ -n "${RUNNER_TEMP:-}" ] \ + || { echo "desktop live gate: RUNNER_TEMP must identify the dedicated release workspace" >&2; exit 64; } +case "$RUNNER_TEMP" in /*) ;; *) echo "desktop live gate: RUNNER_TEMP must be absolute" >&2; exit 64 ;; esac +case "$WORKROOT" in /*) ;; *) echo "desktop live gate: workroot must be absolute" >&2; exit 64 ;; esac +[ ! -L "$WORKROOT" ] \ + || { echo "desktop live gate: workroot must not be a symlink" >&2; exit 64; } +WORKROOT="$(python3 - "$RUNNER_TEMP" "$WORKROOT" <<'PY' +import os +import sys + +runner, requested = map(os.path.realpath, sys.argv[1:]) +if requested == runner or not requested.startswith(runner.rstrip(os.sep) + os.sep): + raise SystemExit("workroot must be a strict child of RUNNER_TEMP") +print(requested) +PY +)" || { echo "desktop live gate: unsafe workroot: $WORKROOT" >&2; exit 64; } rm -rf "$WORKROOT" mkdir -p "$WORKROOT/share" "$WORKROOT/evidence" @@ -148,8 +160,13 @@ assert_exec_token() { import json, sys token = sys.argv[1] body = json.load(sys.stdin) -assert body["exitCode"] == 0 and not body["timedOut"], body -assert token in body["stdout"], body +if not isinstance(body, dict): + raise SystemExit("exec response is not an object") +if body.get("exitCode") != 0 or body.get("timedOut") is not False: + raise SystemExit(f"exec failed: {body!r}") +stdout = body.get("stdout") +if not isinstance(stdout, str) or token not in stdout: + raise SystemExit(f"exec response omitted {token!r}: {body!r}") ' "$token"; then printf '%s\n' "$output" >&2 return 1 @@ -220,6 +237,90 @@ run_desktop() { machine="dory-release-desktop-${distro}-$$" ACTIVE_MACHINE="$machine" + component_id="desktop-$distro" + candidate_result="$WORKROOT/evidence/$distro-component-import.json" + "$CTL" component install-candidate "$component_id" \ + --candidate-dir "$COMPONENT_DIR" --json > "$candidate_result" + selection="$WORKROOT/evidence/$distro-component-selection.txt" + python3 - "$candidate_result" "$COMPONENT_DIR/catalog.json" \ + "$component_id" "$DESKTOP_VERSION" > "$selection" <<'PY' +import json +import pathlib +import re +import sys + +result_path, catalog_path, component_id, expected_version = sys.argv[1:] +result = json.loads(pathlib.Path(result_path).read_text(encoding="utf-8")) +if not isinstance(result, dict) or set(result) != { + "catalogDigest", "installations", "schema", "schemaVersion" +}: + raise SystemExit("component import response has an unexpected shape") +if result["schema"] != "dev.dory.component-candidate-import" or result["schemaVersion"] != 1: + raise SystemExit("component import response has an unexpected contract") +digest = result["catalogDigest"] +if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + raise SystemExit("component import response has an invalid catalog digest") +installations = result["installations"] +if not isinstance(installations, dict) or set(installations) != {"linux-desktop", component_id}: + raise SystemExit("component import response does not contain the exact desktop dependency set") +safe_id = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,254}") +for value in installations.values(): + if not isinstance(value, str) or safe_id.fullmatch(value) is None: + raise SystemExit("component import response has an invalid installation identity") + +catalog = json.loads(pathlib.Path(catalog_path).read_text(encoding="utf-8")) +if not isinstance(catalog, dict) or catalog.get("schemaVersion") != 2: + raise SystemExit("signed component catalog is not schema 2") +if catalog.get("releaseVersion") != expected_version: + raise SystemExit("signed component catalog release differs from the desktop candidate") +components = catalog.get("components") +if not isinstance(components, list): + raise SystemExit("signed component catalog omits components") +matches = { + row.get("id"): row for row in components + if isinstance(row, dict) and row.get("id") in {"linux-desktop", component_id} +} +if set(matches) != {"linux-desktop", component_id}: + raise SystemExit("signed component catalog omits the selected desktop components") +runtime_version = matches["linux-desktop"].get("version") +distribution_version = matches[component_id].get("version") +for value in (runtime_version, distribution_version): + if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", value) is None: + raise SystemExit("signed component version is invalid") + +print(digest) +print(installations["linux-desktop"]) +print(installations[component_id]) +print(runtime_version) +print(distribution_version) +PY + [ "$(wc -l < "$selection" | tr -d ' ')" = 5 ] \ + || { echo "desktop live gate: invalid component selection evidence" >&2; exit 1; } + catalog_digest="$(sed -n '1p' "$selection")" + runtime_installation="$(sed -n '2p' "$selection")" + distribution_installation="$(sed -n '3p' "$selection")" + runtime_version="$(sed -n '4p' "$selection")" + distribution_version="$(sed -n '5p' "$selection")" + update_version="$distribution_version+runtime.$runtime_version" + + "$CTL" component verify all --offline --json \ + > "$WORKROOT/evidence/$distro-component-verify.json" + installed_kernel="$("$CTL" component path linux-desktop dory-desktop-kernel-arm64.lzfse)" + installed_rootfs="$("$CTL" component path "$component_id" \ + "dory-desktop-$distro-rootfs-arm64.ext4.lzfse")" + installed_update="$("$CTL" component path "$component_id" \ + "dory-desktop-$distro-update-arm64.tar")" + for installed_asset in "$installed_kernel" "$installed_rootfs" "$installed_update"; do + [ -f "$installed_asset" ] && [ ! -L "$installed_asset" ] && [ -s "$installed_asset" ] \ + || { echo "desktop live gate: installed component asset is invalid: $installed_asset" >&2; exit 1; } + done + cmp -s "$KERNEL" "$installed_kernel" \ + || { echo "desktop live gate: installed desktop kernel differs from the candidate" >&2; exit 1; } + cmp -s "$rootfs" "$installed_rootfs" \ + || { echo "desktop live gate: installed $distro rootfs differs from the candidate" >&2; exit 1; } + cmp -s "$update_bundle" "$installed_update" \ + || { echo "desktop live gate: installed $distro update differs from the candidate" >&2; exit 1; } + if "$CTL" machine status "$machine" >/dev/null 2>&1; then echo "desktop live gate: refusing to overwrite existing machine $machine" >&2 exit 1 @@ -227,19 +328,20 @@ run_desktop() { created="$WORKROOT/evidence/$distro-create.json" "$CTL" machine create "$machine" \ - --kernel "$KERNEL" --rootfs "$rootfs" --memory-mb 4096 --cpus 4 \ + --kernel "$installed_kernel" --rootfs "$installed_rootfs" --memory-mb 4096 --cpus 4 \ --display-mode desktop \ --share "releasegate=$WORKROOT/share:/home/dorygate/Mac:ro" \ - --env "DORY_DESKTOP_DISTRO=$distro" \ - --env DORY_DESKTOP_VMM=accelerated \ - --env DORY_DESKTOP_GRAPHICS=virgl \ - --env DORY_GUEST_USER=dorygate --env DORY_GUEST_UID=1550 > "$created" + --guest-user dorygate --guest-uid 1550 \ + --desktop-distro "$distro" --runtime accelerated --graphics virgl-venus \ + --clipboard bidirectional > "$created" python3 - "$created" <<'PY' import json, sys with open(sys.argv[1], encoding="utf-8") as handle: body = json.load(handle) -assert body["state"] == "created", body -assert body["displayMode"] == "desktop", body +if not isinstance(body, dict) or body.get("state") != "created": + raise SystemExit(f"machine create did not return created: {body!r}") +if body.get("displayMode") != "desktop": + raise SystemExit(f"machine create did not retain desktop mode: {body!r}") PY "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-start.json" @@ -250,12 +352,19 @@ PY import json, sys with open(sys.argv[1], encoding="utf-8") as handle: body = json.load(handle) -assert body["state"] == "running" and body["displayMode"] == "desktop", body -print(body["pid"]) +if not isinstance(body, dict) or body.get("state") != "running" \ + or body.get("displayMode") != "desktop": + raise SystemExit(f"machine did not reach running desktop state: {body!r}") +pid = body.get("pid") +if not isinstance(pid, int) or pid <= 0: + raise SystemExit(f"machine status has an invalid pid: {body!r}") +print(pid) PY )" ps -ww -p "$machine_pid" -o command= | grep -F "$VMM" \ > "$WORKROOT/evidence/$distro-vmm-command.txt" + grep -F -- '--resolved-graphics hardware-accelerated-3d' \ + "$WORKROOT/evidence/$distro-vmm-command.txt" app_checks="" for app in $expected_apps; do @@ -265,7 +374,8 @@ PY set -eu systemctl is-active '$manager' >/dev/null systemctl is-active dory-zram.service >/dev/null - test \"\$(cat /run/dory/graphics-backend)\" = virgl2 + test \"\$(cat /run/dory/graphics-backend)\" = virgl2+venus + grep -q '^venus-ready:' /run/dory/graphics-status grep -q '^/dev/zram0 ' /proc/swaps pgrep -u dorygate -x '$session' >/dev/null desktop_uid=\$(id -u dorygate) @@ -406,42 +516,65 @@ PY > "$WORKROOT/evidence/$distro-persistence.json" "$CTL" machine desktop-update "$machine" \ - --distro "$distro" --version "$DESKTOP_VERSION" \ - --bundle "$update_bundle" --kernel "$KERNEL" \ + --distro "$distro" --version "$update_version" \ + --distribution-installation "$distribution_installation" \ + --runtime-installation "$runtime_installation" \ > "$WORKROOT/evidence/$distro-desktop-update.json" - python3 - "$WORKROOT/evidence/$distro-desktop-update.json" "$DESKTOP_VERSION" <<'PY' + python3 - "$WORKROOT/evidence/$distro-desktop-update.json" "$update_version" \ + "$catalog_digest" "$distribution_installation" "$runtime_installation" <<'PY' import json, sys with open(sys.argv[1], encoding="utf-8") as handle: body = json.load(handle) -assert body["version"] == sys.argv[2], body -assert body["status"]["state"] == "running", body -assert len(body["inputSHA256"]) == 64, body -assert len(body["bundleSHA256"]) == 64, body -assert body["snapshotID"], body +if not isinstance(body, dict) or body.get("version") != sys.argv[2]: + raise SystemExit(f"desktop update returned the wrong version: {body!r}") +status = body.get("status") +if not isinstance(status, dict) or status.get("state") != "running": + raise SystemExit(f"desktop update did not restore running state: {body!r}") +for key in ("inputSHA256", "bundleSHA256"): + value = body.get(key) + if not isinstance(value, str) or len(value) != 64: + raise SystemExit(f"desktop update omitted {key}: {body!r}") +if not isinstance(body.get("snapshotID"), str) or not body["snapshotID"]: + raise SystemExit(f"desktop update omitted rollback snapshot authority: {body!r}") +receipt = status.get("installedDesktopPayloadReceipt") +if not isinstance(receipt, dict): + raise SystemExit(f"desktop status omitted installed payload receipt: {body!r}") +expected = { + "releaseVersion": sys.argv[2], + "distributionCatalogSHA256": sys.argv[3], + "runtimeCatalogSHA256": sys.argv[3], + "distributionInstallationName": sys.argv[4], + "runtimeInstallationName": sys.argv[5], + "provenance": "verified-update-bundle", +} +for key, value in expected.items(): + if receipt.get(key) != value: + raise SystemExit(f"desktop receipt mismatch for {key}: {body!r}") PY wait_for_desktop "$machine" "$manager" "$session" wait_for_running "$machine" assert_exec_token "$machine" update-pass sh -lc \ - "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo update-pass" \ + "grep -Fqx 'version=$update_version' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo update-pass" \ > "$WORKROOT/evidence/$distro-update-qualified.json" - bad_update="$WORKROOT/$distro-corrupt-update.tar" - printf 'not a tar archive\n' > "$bad_update" + # Public updates accept only signed component installation identities. A stale identity must be + # rejected before guest mutation while the already-qualified machine keeps running. if "$CTL" machine desktop-update "$machine" \ - --distro "$distro" --version "$DESKTOP_VERSION-fault" \ - --bundle "$bad_update" --kernel "$KERNEL" \ - > "$WORKROOT/evidence/$distro-rollback-stdout.json" \ - 2> "$WORKROOT/evidence/$distro-rollback-stderr.txt"; then - echo "desktop live gate: corrupt $distro update unexpectedly succeeded" >&2 + --distro "$distro" --version "$update_version" \ + --distribution-installation "$distribution_installation-stale" \ + --runtime-installation "$runtime_installation" \ + > "$WORKROOT/evidence/$distro-stale-update-stdout.json" \ + 2> "$WORKROOT/evidence/$distro-stale-update-stderr.txt"; then + echo "desktop live gate: stale $distro component identity unexpectedly succeeded" >&2 exit 1 fi - grep -q 'last-good snapshot' "$WORKROOT/evidence/$distro-rollback-stderr.txt" - grep -q 'was restored' "$WORKROOT/evidence/$distro-rollback-stderr.txt" + grep -q 'desktop update component selection is stale' \ + "$WORKROOT/evidence/$distro-stale-update-stderr.txt" wait_for_desktop "$machine" "$manager" "$session" wait_for_running "$machine" - assert_exec_token "$machine" rollback-pass sh -lc \ - "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo rollback-pass" \ - > "$WORKROOT/evidence/$distro-rollback-qualified.json" + assert_exec_token "$machine" stale-update-rejected sh -lc \ + "grep -Fqx 'version=$update_version' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo stale-update-rejected" \ + > "$WORKROOT/evidence/$distro-stale-update-qualified.json" "$CTL" machine stop "$machine" >/dev/null "$CTL" machine delete "$machine" >/dev/null From 2abf87c513b90129547e80d9fce1c9aa9a249e73 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:04:42 +0000 Subject: [PATCH 111/338] feat(release): qualify VM resource reconfiguration --- ...t-machine-resource-reconfiguration-gate.py | 78 +++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + .../machine-resource-reconfiguration-gate.sh | 191 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 .github/scripts/test-machine-resource-reconfiguration-gate.py create mode 100755 scripts/machine-resource-reconfiguration-gate.sh diff --git a/.github/scripts/test-machine-resource-reconfiguration-gate.py b/.github/scripts/test-machine-resource-reconfiguration-gate.py new file mode 100644 index 00000000..85314629 --- /dev/null +++ b/.github/scripts/test-machine-resource-reconfiguration-gate.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Offline safety and CLI contract for the physical VM resource gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "machine-resource-reconfiguration-gate.sh" + + +class MachineResourceReconfigurationGateTests(unittest.TestCase): + def test_current_machine_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + 'machine create "$MACHINE" --kernel "$KERNEL" --rootfs "$ROOTFS"', + 'machine update "$MACHINE" --cpus 8 --memory-mb 16384', + 'machine update "$MACHINE" --cpus 2 --memory-mb 4096', + 'machine exec "$MACHINE" --json -- sh -ec', + 'machine provision "$MACHINE" --recipe k8s-lab', + 'machine stats "$MACHINE"', + 'machine delete "$MACHINE"', + 'out-of-contract $invalid update unexpectedly succeeded', + 'test -f /root/dory-resource-marker', + '[ ! -L "$KERNEL" ]', + '[ ! -L "$ROOTFS" ]', + ): + self.assertIn(proof, text, proof) + for stale in ("--env", "assert ", "rm -rf"): + self.assertNotIn(stale, text, stale) + + def test_symlinked_candidate_input_fails_before_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + ctl = root / "dorydctl" + ctl.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + ctl.chmod(0o700) + kernel = root / "kernel" + kernel.write_bytes(b"kernel") + kernel_link = root / "kernel-link" + kernel_link.symlink_to(kernel) + rootfs = root / "rootfs" + rootfs.write_bytes(b"rootfs") + work = root / "evidence" + + result = subprocess.run( + [ + str(GATE), + "--ctl", + str(ctl), + "--kernel", + str(kernel_link), + "--rootfs", + str(rootfs), + "--workroot", + str(work), + "--confirm", + "ISOLATED-DORY-MACHINE-RESOURCES", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("kernel is not an exact regular file", result.stderr) + self.assertFalse(work.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d484efd5..3d5ecd7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -338,6 +338,7 @@ jobs: python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-machine-resource-reconfiguration-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0ea9a225..a738af80 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,7 @@ jobs: python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-machine-resource-reconfiguration-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/machine-resource-reconfiguration-gate.sh b/scripts/machine-resource-reconfiguration-gate.sh new file mode 100755 index 00000000..d6ba3359 --- /dev/null +++ b/scripts/machine-resource-reconfiguration-gate.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Exact-artifact regression for VM CPU/memory edits. Owns one uniquely named machine and never +# mutates an existing machine definition. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CTL="" +KERNEL="" +ROOTFS="" +WORKROOT="${DORY_MACHINE_RESOURCE_WORKROOT:-$HOME/.dory-machine-resource-gate}" +CONFIRM="" + +usage() { + cat < 8/16GiB -> 2/4GiB while it is running, verifies guest-visible resources, truthful +machine-stats schema/ranges, and persistent disk state after every automatic restart, proves +out-of-contract updates fail without mutation, then deletes only its owned machine. +EOF +} + +die() { echo "machine-resource-gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --ctl) need_value "$1" "$#"; CTL="$2"; shift 2 ;; + --kernel) need_value "$1" "$#"; KERNEL="$2"; shift 2 ;; + --rootfs) need_value "$1" "$#"; ROOTFS="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-MACHINE-RESOURCES ] \ + || die "requires --confirm ISOLATED-DORY-MACHINE-RESOURCES" +[ -f "$CTL" ] && [ ! -L "$CTL" ] && [ -x "$CTL" ] \ + || die "dorydctl is not an exact regular executable: $CTL" +[ -f "$KERNEL" ] && [ ! -L "$KERNEL" ] && [ -s "$KERNEL" ] \ + || die "machine kernel is not an exact regular file: $KERNEL" +[ -f "$ROOTFS" ] && [ ! -L "$ROOTFS" ] && [ -s "$ROOTFS" ] \ + || die "machine rootfs is not an exact regular file: $ROOTFS" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -L "$WORKROOT" ] || die "--workroot must not be a symlink" +for command in jq shasum; do command -v "$command" >/dev/null || die "missing required command: $command"; done + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +MACHINE="dory-resource-$RUN_ID" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/results.tsv" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$WORKDIR" +[ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] \ + || die "workroot changed while preparing evidence" +printf 'test\tstatus\tdetail\n' > "$RESULTS" +OWNED=0 + +ctl() { "$CTL" --timeout 180 "$@"; } +cleanup() { + set +e + if [ "$OWNED" -eq 1 ]; then + ctl machine stop "$MACHINE" > "$WORKDIR/cleanup-stop.json" 2> "$WORKDIR/cleanup-stop.err" || true + ctl machine delete "$MACHINE" > "$WORKDIR/cleanup-delete.json" 2> "$WORKDIR/cleanup-delete.err" || true + fi +} +trap cleanup EXIT INT TERM +pass() { printf '%s\tPASS\t%s\n' "$1" "$2" >> "$RESULTS"; } + +ctl machine list > "$WORKDIR/list-before.json" +jq -e --arg id "$MACHINE" 'all(.[]; .id != $id)' "$WORKDIR/list-before.json" >/dev/null \ + || die "owned machine name already exists: $MACHINE" + +ctl machine create "$MACHINE" --kernel "$KERNEL" --rootfs "$ROOTFS" \ + --memory-mb 1024 --cpus 1 > "$WORKDIR/create.json" +OWNED=1 +ctl machine start "$MACHINE" > "$WORKDIR/start.json" + +verify_status() { + local label="$1" cpus="$2" memory="$3" + local status="$WORKDIR/status-$label.json" stats="$WORKDIR/stats-$label.json" state="" + for _ in $(seq 1 360); do + ctl machine status "$MACHINE" > "$status" + state="$(jq -r '.state' "$status")" + case "$state" in + running) break ;; + failed|stopped) die "$label machine entered terminal state: $state" ;; + esac + sleep 0.5 + done + [ "$state" = running ] || die "$label machine did not become ready within 180 seconds" + jq -e --argjson cpus "$cpus" --argjson memory "$memory" \ + '.state == "running" and .cpuCount == $cpus and .memoryMB == $memory' "$status" >/dev/null \ + || die "$label status does not report running cpus=$cpus memoryMB=$memory" + ctl machine exec "$MACHINE" --json -- sh -ec \ + 'test "$(getconf _NPROCESSORS_ONLN)" -eq "$1"; mem=$(awk "/MemTotal:/ {print int(\$2 / 1024)}" /proc/meminfo); test "$mem" -ge "$2"; test -f /root/dory-resource-marker || printf marker > /root/dory-resource-marker' \ + sh "$cpus" "$((memory * 85 / 100))" > "$WORKDIR/guest-$label.json" + jq -e '.exitCode == 0 and .timedOut == false and .stdoutTruncated == false and .stderrTruncated == false' \ + "$WORKDIR/guest-$label.json" >/dev/null || die "$label guest resource/persistence probe failed" + ctl machine stats "$MACHINE" > "$stats" + jq -e --argjson minimumTotal "$((memory * 1024 * 1024 * 85 / 100))" ' + (keys | sort) == ([ + "blockReadBytes", "blockWriteBytes", "cpuPercent", "memoryTotalBytes", + "memoryUsedBytes", "networkReceiveBytes", "networkTransmitBytes", "processCount", + "schema", "uptimeSeconds", "version" + ] | sort) + and .schema == "dev.dory.machine.stats" + and .version == 1 + and (.cpuPercent | type == "number" and . >= 0 and . <= 100) + and (.memoryUsedBytes | type == "number" and . >= 0) + and (.memoryTotalBytes | type == "number" and . >= $minimumTotal) + and .memoryUsedBytes <= .memoryTotalBytes + and (.networkReceiveBytes | type == "number" and . >= 0) + and (.networkTransmitBytes | type == "number" and . >= 0) + and (.blockReadBytes | type == "number" and . >= 0) + and (.blockWriteBytes | type == "number" and . >= 0) + and (.processCount | type == "number" and . > 0) + and (.uptimeSeconds | type == "number" and . >= 0) + ' "$stats" >/dev/null || die "$label machine stats schema/range probe failed" + pass "resource-$label" "running cpus=$cpus memoryMB=$memory; guest, live stats, and disk marker verified" +} + +verify_status initial 1 1024 +ctl machine provision "$MACHINE" --recipe k8s-lab > "$WORKDIR/provision-k8s-lab.json" +jq -e ' + .recipe == "k8s-lab" + and .install.exitCode == 0 and .install.timedOut == false + and .install.stdoutTruncated == false and .install.stderrTruncated == false + and .verify.exitCode == 0 and .verify.timedOut == false + and .verify.stdoutTruncated == false and .verify.stderrTruncated == false +' "$WORKDIR/provision-k8s-lab.json" >/dev/null \ + || die "required k8s-lab provisioning did not report complete install/verify success" +ctl machine exec "$MACHINE" --json -- sh -ec \ + 'kubectl version --client=true >/dev/null' > "$WORKDIR/provision-k8s-lab-independent-verify.json" +jq -e '.exitCode == 0 and .timedOut == false and .stdoutTruncated == false and .stderrTruncated == false' \ + "$WORKDIR/provision-k8s-lab-independent-verify.json" >/dev/null \ + || die "required k8s-lab provisioning did not survive independent verification" +pass required-provisioning "k8s-lab install and verify completed; independent kubectl check passed" +ctl machine update "$MACHINE" --cpus 8 --memory-mb 16384 > "$WORKDIR/update-maximum.json" +verify_status maximum 8 16384 +ctl machine update "$MACHINE" --cpus 2 --memory-mb 4096 > "$WORKDIR/update-normal.json" +verify_status normal 2 4096 + +for invalid in cpu memory; do + set +e + if [ "$invalid" = cpu ]; then + ctl machine update "$MACHINE" --cpus 9 > "$WORKDIR/invalid-cpu.out" 2> "$WORKDIR/invalid-cpu.err" + else + ctl machine update "$MACHINE" --memory-mb 512 > "$WORKDIR/invalid-memory.out" 2> "$WORKDIR/invalid-memory.err" + fi + rc=$? + set -e + [ "$rc" -ne 0 ] || die "out-of-contract $invalid update unexpectedly succeeded" + pass "invalid-$invalid" "rejected without daemon exit (exit=$rc)" +done +verify_status after-invalid 2 4096 + +ctl machine stop "$MACHINE" > "$WORKDIR/stop.json" +ctl machine delete "$MACHINE" > "$WORKDIR/delete.json" +OWNED=0 +ctl machine list > "$WORKDIR/list-after.json" +jq -e --arg id "$MACHINE" 'all(.[]; .id != $id)' "$WORKDIR/list-after.json" >/dev/null \ + || die "owned machine survived deletion" +pass cleanup "owned machine stopped and deleted; no pre-existing machine touched" + +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "machine=$MACHINE" + echo "ctl=$CTL" + echo "ctl_sha256=$(shasum -a 256 "$CTL" | awk '{print $1}')" + echo "kernel_sha256=$(shasum -a 256 "$KERNEL" | awk '{print $1}')" + echo "rootfs_sha256=$(shasum -a 256 "$ROOTFS" | awk '{print $1}')" + echo "required_provisioning=PASS" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" +echo "machine resource reconfiguration gate PASS; evidence: $WORKDIR" From 4a32bd58097f71932c0673161855523c30071a0d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:06:15 +0000 Subject: [PATCH 112/338] feat(release): qualify sandbox isolation --- .github/scripts/test-sandbox-security-gate.py | 66 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/sandbox-security-gate.sh | 209 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 .github/scripts/test-sandbox-security-gate.py create mode 100755 scripts/sandbox-security-gate.sh diff --git a/.github/scripts/test-sandbox-security-gate.py b/.github/scripts/test-sandbox-security-gate.py new file mode 100644 index 00000000..23eb1fed --- /dev/null +++ b/.github/scripts/test-sandbox-security-gate.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical sandbox security gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "sandbox-security-gate.sh" + + +class SandboxSecurityGateTests(unittest.TestCase): + def test_security_contract_is_complete_and_assert_free(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "test \"$(id -u)\" -ne 0", + "! touch /input/write-must-fail", + 'test -z "${SSH_AUTH_SOCK:-}"', + "secretEnvironmentNames", + "secret in manifest_text", + "169.254.169.254", + "network none", + "network outbound", + "--rollback", + "--ttl-seconds 2", + "daemon_ttl=PASS", + "egressFilterEnforced", + ): + self.assertIn(proof, text, proof) + self.assertNotIn("assert ", text) + + def test_invalid_network_authority_fails_before_sandbox_creation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + dory = root / "dory" + dory.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + dory.chmod(0o700) + result = subprocess.run( + [ + str(GATE), + "--dory", + str(dory), + "--workroot", + str(root / "evidence"), + "--allowed-network", + "not a host:70000", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("allowed network port is outside", result.stderr) + self.assertFalse((root / "evidence").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d5ecd7e..d072e3e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -339,6 +339,7 @@ jobs: python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py + python3 .github/scripts/test-sandbox-security-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a738af80..2aec8d1f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,7 @@ jobs: python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py + python3 .github/scripts/test-sandbox-security-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/sandbox-security-gate.sh b/scripts/sandbox-security-gate.sh new file mode 100755 index 00000000..2a26416c --- /dev/null +++ b/scripts/sandbox-security-gate.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# Physical exact-candidate negative/positive qualification for the supported Dory sandbox boundary. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DORY_BIN="${DORY_SANDBOX_GATE_DORY:-$(command -v dory || true)}" +WORKROOT="${DORY_SANDBOX_GATE_WORKROOT:-$HOME/.dory-sandbox-gate}" +ALLOWED_DESTINATION="${DORY_SANDBOX_GATE_ALLOWED_NETWORK:-1.1.1.1:443}" + +usage() { + cat <&2; exit 2 ;; + esac +done + +[ -f "$DORY_BIN" ] && [ ! -L "$DORY_BIN" ] && [ -x "$DORY_BIN" ] \ + || { echo "sandbox-security-gate: Dory CLI is not an exact regular executable: $DORY_BIN" >&2; exit 2; } +case "$WORKROOT" in /*) ;; *) echo "sandbox-security-gate: --workroot must be absolute" >&2; exit 2 ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") echo "sandbox-security-gate: unsafe --workroot: $WORKROOT" >&2; exit 2 ;; esac +[ ! -L "$WORKROOT" ] \ + || { echo "sandbox-security-gate: --workroot must not be a symlink" >&2; exit 2; } +python3 - "$ALLOWED_DESTINATION" <<'PY' +import ipaddress +import re +import sys + +value = sys.argv[1] +if value.count(":") != 1: + raise SystemExit("sandbox-security-gate: --allowed-network must be HOST:PORT") +host, raw_port = value.rsplit(":", 1) +try: + port = int(raw_port) +except ValueError: + raise SystemExit("sandbox-security-gate: allowed network port is invalid") +if not 1 <= port <= 65535: + raise SystemExit("sandbox-security-gate: allowed network port is outside 1...65535") +try: + ipaddress.ip_address(host) +except ValueError: + if re.fullmatch(r"(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host) is None: + raise SystemExit("sandbox-security-gate: allowed network host is invalid") +PY + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" +WORKDIR="$WORKROOT/$RUN_ID" +INPUT="$WORKDIR/read-only-input" +EVIDENCE="$WORKDIR/evidence" +BASE="dory-sandbox-gate-base-$RUN_ID" +ROLLBACK="dory-sandbox-gate-rollback-$RUN_ID" +TTL="dory-sandbox-gate-ttl-$RUN_ID" +mkdir -p "$INPUT" "$EVIDENCE" +[ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] \ + || { echo "sandbox-security-gate: workroot changed while preparing evidence" >&2; exit 2; } +printf 'host-sentinel\n' > "$INPUT/sentinel.txt" +export DORY_SANDBOX_GATE_SECRET="dory-secret-$RUN_ID" + +cleanup() { + "$DORY_BIN" sandbox kill "$BASE" >/dev/null 2>&1 || true + "$DORY_BIN" sandbox kill "$ROLLBACK" >/dev/null 2>&1 || true + "$DORY_BIN" sandbox kill "$TTL" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +"$DORY_BIN" sandbox run --json --keep --name "$BASE" \ + --mount "$INPUT:/input" --network none --secret-env DORY_SANDBOX_GATE_SECRET \ + --memory-mb 1024 --cpus 1 --disk-mb 64 --processes 32 --open-files 64 \ + --wall-time-seconds 45 -- /bin/sh -eu -c ' +test "$(id -u)" -ne 0 +test "$(cat /input/sentinel.txt)" = host-sentinel +! touch /input/write-must-fail +test "$DORY_SANDBOX_GATE_SECRET" != "" +test -z "${SSH_AUTH_SOCK:-}" +test "$(ulimit -n)" -eq 64 +test "$(ulimit -u)" -le 32 +! nslookup example.com >/dev/null 2>&1 +! nc -z -w 1 127.0.0.1 80 >/dev/null 2>&1 +! nc -z -w 1 192.168.127.1 80 >/dev/null 2>&1 +! nc -z -w 1 169.254.169.254 80 >/dev/null 2>&1 +! (dd if=/dev/zero of="$DORY_SCRATCH/over-cap" bs=1M count=80 >/dev/null 2>&1) +printf SANDBOX-NEGATIVE-PASS +' > "$EVIDENCE/negative.json" + +python3 - "$EVIDENCE/negative.json" "$HOME/.dory/sandboxes/$BASE.json" "$DORY_SANDBOX_GATE_SECRET" <<'PY' +import json, pathlib, sys +result = json.load(open(sys.argv[1], encoding="utf-8")) +manifest_path = pathlib.Path(sys.argv[2]) +secret = sys.argv[3] +manifest_text = manifest_path.read_text(encoding="utf-8") +manifest = json.loads(manifest_text) +if result.get("exitCode") != 0 or result.get("exec", {}).get("stdout") != "SANDBOX-NEGATIVE-PASS": + raise SystemExit(f"negative sandbox execution failed: {result!r}") +identity = manifest.get("identity", {}) +if identity.get("elevated") is not False or identity.get("uid") == 0: + raise SystemExit(f"sandbox identity is not unprivileged: {manifest!r}") +mounts = manifest.get("mounts") +if not isinstance(mounts, list) or len(mounts) != 1 or mounts[0].get("readOnly") is not True: + raise SystemExit(f"sandbox mount is not read-only: {manifest!r}") +network = manifest.get("network", {}) +if network.get("mode") != "none" or network.get("egressFilterEnforced") is not True: + raise SystemExit(f"sandbox network-none authority is invalid: {manifest!r}") +if manifest.get("credentials", {}).get("secretEnvironmentNames") != ["DORY_SANDBOX_GATE_SECRET"]: + raise SystemExit(f"sandbox secret reference is absent: {manifest!r}") +if secret in manifest_text: + raise SystemExit("sandbox manifest leaked the opaque secret value") +if manifest.get("command", {}).get("argumentsPersisted") is not False: + raise SystemExit(f"sandbox persisted command arguments: {manifest!r}") +expected_limits = {"cpus":1,"memoryMB":1024,"diskMB":64,"processes":32,"openFiles":64,"wallTimeSeconds":45} +if manifest.get("limits") != expected_limits: + raise SystemExit(f"sandbox limits differ from the requested contract: {manifest!r}") +PY +[ ! -e "$INPUT/write-must-fail" ] || { echo "sandbox-security-gate: read-only mount was modified" >&2; exit 1; } + +allowed_host="${ALLOWED_DESTINATION%:*}" +allowed_port="${ALLOWED_DESTINATION##*:}" +"$DORY_BIN" sandbox run --json --network outbound --allow-network "$ALLOWED_DESTINATION" \ + --memory-mb 1024 --cpus 1 --disk-mb 32 --wall-time-seconds 30 -- \ + /bin/sh -eu -c ' +host="$1"; port="$2" +nslookup example.com >/dev/null +nc -z -w 10 "$host" "$port" +! nc -z -w 1 127.0.0.1 80 >/dev/null 2>&1 +! nc -z -w 1 169.254.169.254 80 >/dev/null 2>&1 +printf SANDBOX-OUTBOUND-PASS +' sh "$allowed_host" "$allowed_port" > "$EVIDENCE/outbound.json" + +set +e +"$DORY_BIN" sandbox run --json --network none --memory-mb 1024 --cpus 1 --disk-mb 32 \ + --wall-time-seconds 1 -- /bin/sleep 30 > "$EVIDENCE/wall-time.json" +wall_rc=$? +set -e +[ "$wall_rc" -eq 124 ] || { echo "sandbox-security-gate: wall timeout returned $wall_rc, expected 124" >&2; exit 1; } +python3 - "$EVIDENCE/wall-time.json" <<'PY' +import json, sys +row = json.load(open(sys.argv[1], encoding="utf-8")) +if row.get("exitCode") != 124 or row.get("exec", {}).get("timedOut") is not True: + raise SystemExit(f"sandbox timeout evidence is invalid: {row!r}") +if row.get("cleanup", {}).get("deleted") is not True: + raise SystemExit(f"timed-out sandbox was not deleted: {row!r}") +PY + +"$DORY_BIN" sandbox run --json --keep --name "$ROLLBACK" --network none --rollback \ + --memory-mb 1024 --cpus 1 --disk-mb 32 --wall-time-seconds 30 -- \ + /bin/sh -eu -c 'printf changed > "$DORY_SCRATCH/rollback-must-remove"' \ + > "$EVIDENCE/rollback-create.json" +"$DORY_BIN" sandbox run --json --reuse "$ROLLBACK" --network none --wall-time-seconds 30 -- \ + /bin/sh -eu -c 'test ! -e "$DORY_SCRATCH/rollback-must-remove"; printf SANDBOX-ROLLBACK-PASS' \ + > "$EVIDENCE/rollback-reuse.json" +"$DORY_BIN" sandbox kill "$ROLLBACK" > "$EVIDENCE/rollback-kill.txt" + +"$DORY_BIN" sandbox run --json --keep --name "$TTL" --ttl-seconds 2 --network none \ + --memory-mb 1024 --cpus 1 --disk-mb 32 --wall-time-seconds 30 -- /bin/true \ + > "$EVIDENCE/ttl-create.json" +ttl_deleted=0 +for _ in $(seq 1 35); do + if ! "$DORY_BIN" machine status "$TTL" >/dev/null 2>&1; then ttl_deleted=1; break; fi + sleep 1 +done +[ "$ttl_deleted" -eq 1 ] || { echo "sandbox-security-gate: daemon did not delete expired TTL sandbox" >&2; exit 1; } + +python3 - "$EVIDENCE/outbound.json" "$EVIDENCE/rollback-reuse.json" <<'PY' +import json, sys +outbound = json.load(open(sys.argv[1], encoding="utf-8")) +rollback = json.load(open(sys.argv[2], encoding="utf-8")) +if outbound.get("exec", {}).get("stdout") != "SANDBOX-OUTBOUND-PASS": + raise SystemExit(f"sandbox outbound execution failed: {outbound!r}") +network = outbound.get("manifest", {}).get("network", {}) +if network.get("mode") != "outbound" or not network.get("grants"): + raise SystemExit(f"sandbox outbound authority is invalid: {outbound!r}") +if rollback.get("exec", {}).get("stdout") != "SANDBOX-ROLLBACK-PASS": + raise SystemExit(f"sandbox rollback execution failed: {rollback!r}") +if rollback.get("rollback", {}).get("requested") is not False: + raise SystemExit(f"sandbox reuse unexpectedly requested another rollback: {rollback!r}") +PY + +cat > "$WORKDIR/manifest.txt" < Date: Fri, 21 Aug 2026 04:07:41 +0000 Subject: [PATCH 113/338] feat(release): qualify SSH agent forwarding --- .../scripts/test-ssh-agent-forwarding-gate.py | 65 +++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/ssh-agent-forwarding-gate.sh | 178 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 .github/scripts/test-ssh-agent-forwarding-gate.py create mode 100755 scripts/ssh-agent-forwarding-gate.sh diff --git a/.github/scripts/test-ssh-agent-forwarding-gate.py b/.github/scripts/test-ssh-agent-forwarding-gate.py new file mode 100644 index 00000000..910dfeb7 --- /dev/null +++ b/.github/scripts/test-ssh-agent-forwarding-gate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical SSH-agent forwarding gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "ssh-agent-forwarding-gate.sh" + + +class SSHAgentForwardingGateTests(unittest.TestCase): + def test_forwarding_contract_retains_only_public_listing_digests(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "SSH_AUTH_SOCK is not owned by the release user", + "Dory socket is not owned by the release user", + "/run/host-services/ssh-auth.sock:/agent.sock", + "ssh-add -L", + "--mount=type=ssh,required=true", + "--network=none", + 'single_hash" = "$host_hash', + 'buildkit_hash" = "$host_hash', + "rm -f \"$WORKDIR\"/client-*.out", + "public_key_listing_sha256", + "bundled_buildx=PASS", + ): + self.assertIn(proof, text, proof) + for leak in ("ssh-add -l", "ssh-add -D", "cat ~/.ssh", "assert "): + self.assertNotIn(leak, text, leak) + + def test_excessive_concurrency_fails_before_socket_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "example.invalid/ssh@sha256:" + "a" * 64, + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + "--concurrency", + "65", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("concurrency must be between 1 and 64", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d072e3e0..35732b89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,6 +340,7 @@ jobs: python3 .github/scripts/test-desktop-linux-live-gate.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py + python3 .github/scripts/test-ssh-agent-forwarding-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2aec8d1f..20929fa2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,7 @@ jobs: python3 .github/scripts/test-desktop-linux-live-gate.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py + python3 .github/scripts/test-ssh-agent-forwarding-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/ssh-agent-forwarding-gate.sh b/scripts/ssh-agent-forwarding-gate.sh new file mode 100755 index 00000000..e2ff07cf --- /dev/null +++ b/scripts/ssh-agent-forwarding-gate.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Prove Docker Desktop-compatible SSH-agent forwarding through Dory's guest-local well-known socket. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCKET="${DORY_SSH_AGENT_GATE_SOCKET:-$HOME/.dory/dory.sock}" +DOCKER="${DORY_SSH_AGENT_GATE_DOCKER:-docker}" +IMAGE="${DORY_SSH_AGENT_GATE_IMAGE:-}" +WORKROOT="${DORY_SSH_AGENT_GATE_WORKROOT:-$HOME/.dory-ssh-agent-gate}" +CONCURRENCY="${DORY_SSH_AGENT_GATE_CONCURRENCY:-8}" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --concurrency) need_value "$1" "$#"; CONCURRENCY="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +case "$CONCURRENCY" in ''|*[!0-9]*) die "concurrency must be a positive integer" ;; esac +[ "$CONCURRENCY" -gt 0 ] && [ "$CONCURRENCY" -le 64 ] \ + || die "concurrency must be between 1 and 64" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -L "$WORKROOT" ] || die "--workroot must not be a symlink" +[ -S "$SOCKET" ] || die "Dory socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "Dory socket is not owned by the release user" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--image must be a digest-pinned image containing sh and ssh-add" +[ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ] \ + || die "SSH_AUTH_SOCK must name a live same-user SSH agent socket" +[ "$(stat -f %u "$SSH_AUTH_SOCK")" = "$(id -u)" ] \ + || die "SSH_AUTH_SOCK is not owned by the release user" +if [[ "$DOCKER" == */* ]]; then + [ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is not an exact regular executable" + DOCKER="$(cd "$(dirname "$DOCKER")" && pwd)/$(basename "$DOCKER")" +else + DOCKER="$(command -v "$DOCKER")" + [ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is not an exact regular executable" +fi +BUILDX="$(dirname "$DOCKER")/docker-buildx" +[ -f "$BUILDX" ] && [ ! -L "$BUILDX" ] && [ -x "$BUILDX" ] \ + || die "the exact candidate Docker CLI has no regular sibling docker-buildx plugin: $BUILDX" +for command in ssh-add shasum sort; do command -v "$command" >/dev/null || die "missing $command"; done + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-ssh-agent-$RUN_ID" +BUILD_IMAGE="dory-ssh-agent-buildkit:gate-$(date +%s)-$$" +WORKDIR="$WORKROOT/$RUN_ID" +DOCKER_CONFIG="$WORKDIR/docker-config" +mkdir -p "$DOCKER_CONFIG/cli-plugins" +[ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] \ + || die "workroot changed while preparing evidence" +ln -s "$BUILDX" "$DOCKER_CONFIG/cli-plugins/docker-buildx" + +docker_e() { DOCKER_HOST="unix://$SOCKET" DOCKER_CONFIG="$DOCKER_CONFIG" "$DOCKER" "$@"; } +docker_e version >/dev/null || die "Dory Docker API is not ready" +docker_e buildx version >/dev/null \ + || die "the bundled Buildx plugin is unavailable inside the isolated gate config" +docker_e image inspect "$IMAGE" >/dev/null 2>&1 || die "required image is not local: $IMAGE" +host_keys="$(ssh-add -L 2>/dev/null | LC_ALL=C sort)" +[ -n "$host_keys" ] || die "the release SSH agent has no public keys loaded" +host_hash="$(printf '%s\n' "$host_keys" | shasum -a 256 | awk '{print $1}')" + +cleanup() { + local id + docker_e ps -aq --filter "label=dev.dory.ssh-agent=$OWNER" 2>/dev/null \ + | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f "$id" >/dev/null 2>&1 || true + done + docker_e image rm -f "$BUILD_IMAGE" >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" +} +trap cleanup EXIT INT TERM + +container_keys() { + docker_e run --rm --label "dev.dory.ssh-agent=$OWNER" \ + --entrypoint sh \ + -v /run/host-services/ssh-auth.sock:/agent.sock \ + -e SSH_AUTH_SOCK=/agent.sock "$IMAGE" -ec ' + command -v ssh-add >/dev/null + test -S "$SSH_AUTH_SOCK" + ssh-add -L + ' | LC_ALL=C sort +} + +single_keys="$(container_keys)" +[ -n "$single_keys" ] || die "container SSH agent returned no identities" +single_hash="$(printf '%s\n' "$single_keys" | shasum -a 256 | awk '{print $1}')" +[ "$single_hash" = "$host_hash" ] || die "container SSH agent identities differ from the host" + +pids="" +for index in $(seq 1 "$CONCURRENCY"); do + ( + keys="$(container_keys)" + printf '%s\n' "$keys" | shasum -a 256 | awk '{print $1}' > "$WORKDIR/client-$index.sha256" + ) > "$WORKDIR/client-$index.out" 2> "$WORKDIR/client-$index.err" & + pids="$pids $!" +done +for pid in $pids; do wait "$pid" || die "a concurrent SSH-agent client failed"; done +for hash_file in "$WORKDIR"/client-*.sha256; do + [ "$(cat "$hash_file")" = "$host_hash" ] \ + || die "a concurrent SSH-agent client observed different identities" +done +# Successful evidence retains only digests. Per-client stdout/stderr are useful on failure but are +# empty on success and are removed before the immutable qualification tree is hashed. +rm -f "$WORKDIR"/client-*.out "$WORKDIR"/client-*.err + +# BuildKit's SSH session is a separate data path from an ordinary bind-mounted container socket. +# Prove the exact public identity listing reaches a required, network-disabled build mount. Only +# its digest is committed to the layer/evidence; neither public-key text nor private material is +# retained in the image or logs. +BUILD_CONTEXT="$WORKDIR/buildkit-context" +mkdir -p "$BUILD_CONTEXT" +cat > "$BUILD_CONTEXT/Dockerfile" < /dory-agent-listing.sha256 +EOF +DOCKER_BUILDKIT=1 docker_e build --progress=plain \ + --ssh "default=$SSH_AUTH_SOCK" \ + --label "dev.dory.ssh-agent=$OWNER" \ + -t "$BUILD_IMAGE" "$BUILD_CONTEXT" \ + > "$WORKDIR/buildkit.out" 2> "$WORKDIR/buildkit.err" \ + || die "BuildKit required SSH mount failed" +buildkit_hash="$(docker_e run --rm --network none --label "dev.dory.ssh-agent=$OWNER" \ + --entrypoint cat "$BUILD_IMAGE" /dory-agent-listing.sha256 | tr -d '[:space:]')" +printf '%s\n' "$buildkit_hash" | grep -Eq '^[0-9a-f]{64}$' \ + || die "BuildKit SSH mount did not produce one identity-listing digest" +[ "$buildkit_hash" = "$host_hash" ] \ + || die "BuildKit SSH mount identities differ from the host" +rm -rf "$BUILD_CONTEXT" + +{ + echo status=PASS + echo "run_id=$RUN_ID" + echo "guest_socket=/run/host-services/ssh-auth.sock" + echo "concurrency=$CONCURRENCY" + echo "public_key_listing_sha256=$host_hash" + echo "buildkit_required_ssh_mount=PASS" + echo "buildkit_public_key_listing_sha256=$buildkit_hash" + echo "image=$IMAGE" + echo "docker_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "buildx_sha256=$(shasum -a 256 "$BUILDX" | awk '{print $1}')" + echo "bundled_buildx=PASS" + echo "completed_epoch=$(date +%s)" +} > "$WORKDIR/manifest.txt" +cleanup +trap - EXIT INT TERM +echo "ssh-agent forwarding gate PASS; evidence: $WORKDIR" From 38aaffd17cad28b7a6c6c419d328a119a6b93eba Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:09:06 +0000 Subject: [PATCH 114/338] feat(release): qualify bind advisory locks --- .../scripts/test-bind-advisory-lock-gate.py | 71 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/bind-advisory-lock-gate.sh | 221 ++++++++++++++++++ scripts/bind-advisory-lock-probe.py | 123 ++++++++++ 5 files changed, 417 insertions(+) create mode 100644 .github/scripts/test-bind-advisory-lock-gate.py create mode 100755 scripts/bind-advisory-lock-gate.sh create mode 100755 scripts/bind-advisory-lock-probe.py diff --git a/.github/scripts/test-bind-advisory-lock-gate.py b/.github/scripts/test-bind-advisory-lock-gate.py new file mode 100644 index 00000000..0bec6005 --- /dev/null +++ b/.github/scripts/test-bind-advisory-lock-gate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the cross-container advisory-lock gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "bind-advisory-lock-gate.sh" +PROBE = ROOT / "scripts" / "bind-advisory-lock-probe.py" + + +class BindAdvisoryLockGateTests(unittest.TestCase): + def test_gate_and_probe_cover_native_lock_semantics(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + compile(PROBE.read_text(encoding="utf-8"), str(PROBE), "exec") + gate = GATE.read_text(encoding="utf-8") + probe = PROBE.read_text(encoding="utf-8") + for proof in ( + "create-mode-zero", + "O_CREAT|O_EXCL mode-0000", + "flock-exclusive-contender", + "flock-shared-peer", + "flock-upgrade.blocked", + "flock-after-crash", + "record-nonoverlap", + "record-waiter.acquired", + "record-after-crash", + "cross_container_bind_mount=PASS", + 'mktemp -d "$HOME/.dory-bind-lock-gate.XXXXXXXX"', + "Dory socket is not owned by the release user", + ): + self.assertIn(proof, gate, proof) + for proof in ("fcntl.flock", "fcntl.lockf", "LOCK_NB", "LOCK_UN", "os.O_EXCL"): + self.assertIn(proof, probe, proof) + self.assertNotIn("assert ", gate) + self.assertNotIn("assert ", probe) + + def test_relative_workroot_fails_before_socket_or_docker_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "example.invalid/python@sha256:" + "a" * 64, + "--workroot", + "relative-evidence", + "--confirm", + "ISOLATED-DORY-BIND-LOCKS", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("--workroot must be absolute", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35732b89..0a5a4740 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,6 +341,7 @@ jobs: python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py + python3 .github/scripts/test-bind-advisory-lock-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 20929fa2..4d107f86 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,6 +42,7 @@ jobs: python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py + python3 .github/scripts/test-bind-advisory-lock-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/bind-advisory-lock-gate.sh b/scripts/bind-advisory-lock-gate.sh new file mode 100755 index 00000000..da843cae --- /dev/null +++ b/scripts/bind-advisory-lock-gate.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Prove Linux POSIX and BSD advisory locks coordinate across containers on one Dory bind mount. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCKET="" +DOCKER="docker" +IMAGE="" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-bind-lock-gate" +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || fail "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-BIND-LOCKS ] \ + || fail "requires --confirm ISOLATED-DORY-BIND-LOCKS" +case "$WORKROOT" in /*) ;; *) fail "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") fail "unsafe --workroot: $WORKROOT" ;; esac +[ ! -L "$WORKROOT" ] || fail "--workroot must not be a symlink" +[ -S "$SOCKET" ] || fail "Dory socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || fail "Dory socket is not owned by the release user" +[ -x "$DOCKER" ] || command -v "$DOCKER" >/dev/null || fail "Docker CLI is unavailable: $DOCKER" +case "$DOCKER" in + */*) ;; + *) DOCKER="$(command -v "$DOCKER")" ;; +esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || fail "resolved Docker CLI is not an exact regular executable: $DOCKER" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || fail "--image must be digest-pinned" +[ -f "$ROOT/scripts/bind-advisory-lock-probe.py" ] \ + && [ ! -L "$ROOT/scripts/bind-advisory-lock-probe.py" ] \ + || fail "bind advisory lock probe is unavailable" +for command in cp curl date mkdir mktemp rm shasum sleep; do + command -v "$command" >/dev/null || fail "missing required command: $command" +done +curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null \ + || fail "Dory Docker API is not ready" + +RUN="$(date -u +%Y%m%dT%H%M%SZ)-$$" +LABEL="dev.dory.bind-advisory-lock-gate=$RUN" +BIND_ROOT="$(mktemp -d "$HOME/.dory-bind-lock-gate.XXXXXXXX")" +EVIDENCE="$WORKROOT/$RUN" +mkdir -p "$BIND_ROOT" "$EVIDENCE" +[ -d "$BIND_ROOT" ] && [ ! -L "$BIND_ROOT" ] \ + && [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + && [ -d "$EVIDENCE" ] && [ ! -L "$EVIDENCE" ] \ + || fail "gate workspace changed while preparing fixtures" +cp "$ROOT/scripts/bind-advisory-lock-probe.py" "$BIND_ROOT/probe.py" + +docker_e() { "$DOCKER" -H "unix://$SOCKET" "$@"; } +container_name() { printf 'dory-bind-lock-%s-%s' "$RUN" "$1"; } +cleanup() { + set +e + docker_e ps -aq --filter "label=$LABEL" | while IFS= read -r container; do + [ -n "$container" ] && docker_e rm -f "$container" >/dev/null 2>&1 || true + done + rm -rf "$BIND_ROOT" +} +trap cleanup EXIT INT TERM + +run_probe() { + docker_e run --rm --label "$LABEL" -v "$BIND_ROOT:/shared" "$IMAGE" \ + python3 /shared/probe.py "$@" +} + +start_probe() { + local name="$1" + shift + docker_e run -d --name "$(container_name "$name")" --label "$LABEL" \ + -v "$BIND_ROOT:/shared" "$IMAGE" python3 /shared/probe.py "$@" >/dev/null +} + +wait_marker() { + local marker="$1" i + # The first Python container can still be unpacking immediately after a clean engine boot. + # Give fixture startup 30 seconds while retaining the 20 ms polling needed by lock handoffs. + for i in $(seq 1 1500); do + [ -s "$BIND_ROOT/$marker" ] && return 0 + sleep 0.02 + done + return 1 +} + +finish_probe() { + local name="$1" token="$2" id status + : > "$BIND_ROOT/$token.release" + id="$(container_name "$name")" + status="$(docker_e wait "$id")" + [ "$status" = 0 ] || fail "$name exited with status $status" + docker_e rm "$id" >/dev/null +} + +expect_blocked() { + local rc + set +e + run_probe "$@" >/dev/null 2>"$EVIDENCE/expected-blocked.stderr" + rc=$? + set -e + [ "$rc" -eq 73 ] || fail "expected lock contention exit 73, got $rc for: $*" +} + +# Reproduce Lima's VirtioFS orphan: the creating O_RDONLY descriptor must remain valid even though +# the requested mode is 0000, and unlink must depend on the writable parent rather than file mode. +mkdir -p "$BIND_ROOT/mode-zero" +chmod 0777 "$BIND_ROOT/mode-zero" +docker_e run --rm --user 1000:1000 --label "$LABEL" -v "$BIND_ROOT:/shared" "$IMAGE" \ + python3 /shared/probe.py create-mode-zero +[ ! -e "$BIND_ROOT/mode-zero/create-excl.lock" ] \ + || fail "O_CREAT|O_EXCL mode-0000 probe left an inaccessible orphan" + +# BSD flock: exclusive exclusion, shared compatibility, explicit unlock, crash release, and a +# failed shared→exclusive upgrade followed by a successful retry. Linux explicitly does not +# guarantee atomic conversion: the original lock may be removed before the contended replacement +# fails, so the gate must not require a stronger post-failure lock state than native flock(2). +start_probe flock-exclusive holder flock flock.bin exclusive 0 0 flock-exclusive +wait_marker flock-exclusive.ready || fail "exclusive flock holder did not become ready" +expect_blocked try flock flock.bin exclusive 0 0 flock-exclusive-contender +finish_probe flock-exclusive flock-exclusive +run_probe try flock flock.bin exclusive 0 0 flock-after-release + +start_probe flock-shared holder flock flock.bin shared 0 0 flock-shared +wait_marker flock-shared.ready || fail "shared flock holder did not become ready" +run_probe try flock flock.bin shared 0 0 flock-shared-peer +expect_blocked try flock flock.bin exclusive 0 0 flock-shared-exclusive +finish_probe flock-shared flock-shared + +start_probe flock-unlocked unlocked-holder flock flock.bin exclusive 0 0 flock-unlocked +wait_marker flock-unlocked.ready || fail "explicit-unlock holder did not become ready" +run_probe try flock flock.bin exclusive 0 0 flock-unlocked-peer +finish_probe flock-unlocked flock-unlocked + +start_probe flock-crash holder flock flock.bin exclusive 0 0 flock-crash +wait_marker flock-crash.ready || fail "crash-release flock holder did not become ready" +docker_e rm -f "$(container_name flock-crash)" >/dev/null +run_probe try flock flock.bin exclusive 0 0 flock-after-crash + +start_probe flock-upgrade upgrade-holder flock flock.bin shared 0 0 flock-upgrade +wait_marker flock-upgrade.ready || fail "upgrade holder did not become ready" +start_probe flock-upgrade-peer holder flock flock.bin shared 0 0 flock-upgrade-peer +wait_marker flock-upgrade-peer.ready || fail "upgrade peer did not become ready" +: > "$BIND_ROOT/flock-upgrade.upgrade" +wait_marker flock-upgrade.blocked || fail "contended flock upgrade did not report blocking" +finish_probe flock-upgrade-peer flock-upgrade-peer +: > "$BIND_ROOT/flock-upgrade.retry" +wait_marker flock-upgrade.upgraded || fail "flock upgrade did not succeed after peer release" +finish_probe flock-upgrade flock-upgrade + +# POSIX record locks: same-range exclusion, non-overlapping range independence, blocking SETLKW, +# explicit unlock while the process remains alive, and forced-container crash cleanup. +start_probe record-holder holder record record.bin exclusive 0 8 record-holder +wait_marker record-holder.ready || fail "record-lock holder did not become ready" +expect_blocked try record record.bin exclusive 0 8 record-contender +run_probe try record record.bin exclusive 8 8 record-nonoverlap +start_probe record-waiter waiter record record.bin exclusive 0 8 record-waiter +sleep 0.25 +[ ! -e "$BIND_ROOT/record-waiter.acquired" ] \ + || fail "blocking record-lock waiter acquired before owner release" +finish_probe record-holder record-holder +wait_marker record-waiter.acquired || fail "blocking record-lock waiter did not acquire after release" +status="$(docker_e wait "$(container_name record-waiter)")" +[ "$status" = 0 ] || fail "record-lock waiter exited with status $status" +docker_e rm "$(container_name record-waiter)" >/dev/null + +start_probe record-unlocked unlocked-holder record record.bin exclusive 0 8 record-unlocked +wait_marker record-unlocked.ready || fail "record explicit-unlock holder did not become ready" +run_probe try record record.bin exclusive 0 8 record-unlocked-peer +finish_probe record-unlocked record-unlocked + +start_probe record-crash holder record record.bin exclusive 0 8 record-crash +wait_marker record-crash.ready || fail "record crash-release holder did not become ready" +docker_e rm -f "$(container_name record-crash)" >/dev/null +run_probe try record record.bin exclusive 0 8 record-after-crash + +docker_e image inspect "$IMAGE" > "$EVIDENCE/image-inspect.json" +docker_cli_sha256="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +printf '%s\n' "$docker_cli_sha256" | grep -Eq '^[0-9a-f]{64}$' \ + || fail "could not hash the exact Docker CLI: $DOCKER" +{ + printf 'status=PASS\n' + printf 'timestamp_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'image=%s\n' "$IMAGE" + printf 'docker_cli_sha256=%s\n' "$docker_cli_sha256" + printf 'create_excl_readonly_mode0000_unlink=PASS\n' + printf 'bsd_flock_exclusive_shared_unlock_upgrade_crash=PASS\n' + printf 'posix_range_nonoverlap_blocking_unlock_crash=PASS\n' + printf 'cross_container_bind_mount=PASS\n' +} > "$EVIDENCE/manifest.txt" + +echo "bind advisory lock gate: PASS ($EVIDENCE)" diff --git a/scripts/bind-advisory-lock-probe.py b/scripts/bind-advisory-lock-probe.py new file mode 100755 index 00000000..67089a78 --- /dev/null +++ b/scripts/bind-advisory-lock-probe.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Container-side worker for the exact bind-mount advisory-lock gate.""" + +import errno +import fcntl +import os +from pathlib import Path +import sys +import time + + +def marker(name: str) -> Path: + return Path("/shared") / name + + +def wait_for(name: str, timeout: float = 120.0) -> None: + deadline = time.monotonic() + timeout + path = marker(name) + while time.monotonic() < deadline: + if path.exists(): + return + time.sleep(0.02) + raise TimeoutError(f"timed out waiting for {path}") + + +def acquire(file_object, family: str, mode: str, blocking: bool, start: int, length: int) -> None: + operation = fcntl.LOCK_SH if mode == "shared" else fcntl.LOCK_EX + if not blocking: + operation |= fcntl.LOCK_NB + if family == "flock": + fcntl.flock(file_object.fileno(), operation) + elif family == "record": + fcntl.lockf(file_object.fileno(), operation, length, start, os.SEEK_SET) + else: + raise ValueError(f"unsupported lock family: {family}") + + +def unlock(file_object, family: str, start: int, length: int) -> None: + if family == "flock": + fcntl.flock(file_object.fileno(), fcntl.LOCK_UN) + else: + fcntl.lockf(file_object.fileno(), fcntl.LOCK_UN, length, start, os.SEEK_SET) + + +def opened(path: str): + return open(Path("/shared") / path, "a+b", buffering=0) + + +def main() -> int: + if sys.argv[1:] == ["create-mode-zero"]: + path = marker("mode-zero/create-excl.lock") + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDONLY, 0) + os.close(descriptor) + if path.stat().st_mode & 0o7777 != 0: + raise RuntimeError("O_CREAT mode 0000 was not preserved") + path.unlink() + if path.exists(): + raise RuntimeError("mode-0000 file survived explicit unlink") + return 0 + + command, family, path, mode, start_text, length_text, token = sys.argv[1:8] + start, length = int(start_text), int(length_text) + with opened(path) as file_object: + if command == "try": + try: + acquire(file_object, family, mode, False, start, length) + except OSError as error: + if error.errno in (errno.EACCES, errno.EAGAIN): + return 73 + raise + unlock(file_object, family, start, length) + return 0 + + if command == "holder": + acquire(file_object, family, mode, True, start, length) + marker(f"{token}.ready").write_text("ready\n", encoding="utf-8") + wait_for(f"{token}.release") + unlock(file_object, family, start, length) + return 0 + + if command == "waiter": + acquire(file_object, family, mode, True, start, length) + marker(f"{token}.acquired").write_text("acquired\n", encoding="utf-8") + unlock(file_object, family, start, length) + return 0 + + if command == "unlocked-holder": + acquire(file_object, family, mode, True, start, length) + unlock(file_object, family, start, length) + marker(f"{token}.ready").write_text("ready\n", encoding="utf-8") + wait_for(f"{token}.release") + return 0 + + if command == "upgrade-holder": + if family != "flock" or mode != "shared": + raise ValueError("upgrade-holder requires a shared flock") + acquire(file_object, family, mode, True, start, length) + marker(f"{token}.ready").write_text("ready\n", encoding="utf-8") + wait_for(f"{token}.upgrade") + try: + acquire(file_object, family, "exclusive", False, start, length) + except OSError as error: + if error.errno not in (errno.EACCES, errno.EAGAIN): + raise + marker(f"{token}.blocked").write_text("blocked\n", encoding="utf-8") + else: + raise RuntimeError("exclusive upgrade unexpectedly succeeded with another shared owner") + wait_for(f"{token}.retry") + acquire(file_object, family, "exclusive", False, start, length) + marker(f"{token}.upgraded").write_text("upgraded\n", encoding="utf-8") + wait_for(f"{token}.release") + unlock(file_object, family, start, length) + return 0 + + raise ValueError(f"unsupported command: {command}") + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"bind advisory lock probe: {error}", file=sys.stderr) + raise From 4ee9a82af6d83dd8261486e5068a5152d502e4ce Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:12:36 +0000 Subject: [PATCH 115/338] feat(release): qualify non-native builds --- .github/scripts/test-nonnative-build-smoke.py | 74 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/nonnative-build-smoke.sh | 318 ++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 .github/scripts/test-nonnative-build-smoke.py create mode 100755 scripts/nonnative-build-smoke.sh diff --git a/.github/scripts/test-nonnative-build-smoke.py b/.github/scripts/test-nonnative-build-smoke.py new file mode 100644 index 00000000..85a12ba0 --- /dev/null +++ b/.github/scripts/test-nonnative-build-smoke.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the non-native BuildKit release smoke.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-build-smoke.sh" + + +class NonNativeBuildSmokeTests(unittest.TestCase): + def test_build_is_digest_pinned_and_network_free(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "images must be digest-pinned", + 'docker_e image inspect "$ALPINE_IMAGE"', + 'docker_e image inspect "$BUILD_IMAGE"', + "FEX-x86_64", + "flags: POCF", + "--platform \"linux/$TARGET_ARCH\"", + "RUN --network=none npm ci --ignore-scripts --no-audit --no-fund", + "RUN --network=none npm run build", + "--pull=false", + "nested-gnu-tar-ok", + "hardlink.txt", + "dory-nonnative-build-ok", + "explicit workdir must not already exist", + ): + self.assertIn(proof, text, proof) + for mutable in ("alpine:latest", "node:20-alpine\n", "apk add"): + self.assertNotIn(mutable, text, mutable) + + def test_fixture_is_self_contained(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = pathlib.Path(temporary) / "fixture" + subprocess.run( + [ + "bash", + "-c", + 'gate="$1"; fixture="$2"; set --; ' + 'DORY_NONNATIVE_SMOKE_SOURCE_ONLY=1 source "$gate"; ' + 'write_node_build_fixture "$fixture"', + "bash", + str(GATE), + str(fixture), + ], + cwd=ROOT, + check=True, + ) + expected = { + "package.json", + "package-lock.json", + "src/app.mjs", + "scripts/build.mjs", + "test/app.test.mjs", + "vendor/dory-math/package.json", + "vendor/dory-math/index.mjs", + } + actual = { + str(path.relative_to(fixture)) + for path in fixture.rglob("*") + if path.is_file() + } + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a5a4740..787296c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,6 +342,7 @@ jobs: python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py python3 .github/scripts/test-bind-advisory-lock-gate.py + python3 .github/scripts/test-nonnative-build-smoke.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d107f86..664904cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,7 @@ jobs: python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py python3 .github/scripts/test-bind-advisory-lock-gate.py + python3 .github/scripts/test-nonnative-build-smoke.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/nonnative-build-smoke.sh b/scripts/nonnative-build-smoke.sh new file mode 100755 index 00000000..71bffa9a --- /dev/null +++ b/scripts/nonnative-build-smoke.sh @@ -0,0 +1,318 @@ +#!/bin/bash +# Focused non-native architecture build smoke for reports like: +# docker build --platform linux/amd64 ... -> qemu signal 11 / build failure +# +# Requires a running Dory socket and non-native image support enabled in Dory when the target is +# not the host guest architecture. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +DORY_SOCK="${DORY_SOCK:-$HOME/.dory/dory.sock}" +ALPINE_IMAGE="${READINESS_ALPINE_IMAGE:-}" +BUILD_IMAGE="${READINESS_NONNATIVE_BUILD_IMAGE:-}" +TARGET_ARCH="${DORY_NONNATIVE_TARGET_ARCH:-}" +KEEP="${DORY_NONNATIVE_SMOKE_KEEP:-0}" +WORKDIR="${DORY_NONNATIVE_SMOKE_WORKDIR:-}" +DOCKER_BIN="${DORY_DOCKER_BIN:-}" + +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +host_guest_arch() { + [ "$(uname -m)" = "x86_64" ] && printf '%s\n' "amd64" || printf '%s\n' "arm64" +} + +default_target_arch() { + [ "$(host_guest_arch)" = "amd64" ] && printf '%s\n' "arm64" || printf '%s\n' "amd64" +} + +find_docker_bin() { + local cand + for cand in "$DOCKER_BIN" "$HOME/.dory/bin/docker" "$(command -v docker 2>/dev/null || true)"; do + [ -n "$cand" ] && [ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; } + done + echo "nonnative-build-smoke: docker CLI not found. Start doryd, then open a new terminal so ~/.dory/bin is on PATH." >&2 + return 1 +} + +handler_for_arch() { + case "$1" in + amd64) printf '%s\n' "FEX-x86_64" ;; + arm64) printf '%s\n' "qemu-aarch64" ;; + *) echo "unsupported target architecture: $1" >&2; return 2 ;; + esac +} + +uname_pattern_for_arch() { + case "$1" in + amd64) printf '%s\n' 'x86_64|amd64' ;; + arm64) printf '%s\n' 'aarch64|arm64' ;; + *) echo "unsupported target architecture: $1" >&2; return 2 ;; + esac +} + +node_arch_for_arch() { + case "$1" in + amd64) printf '%s\n' "x64" ;; + arm64) printf '%s\n' "arm64" ;; + *) echo "unsupported target architecture: $1" >&2; return 2 ;; + esac +} + +write_node_build_fixture() { + local dir="$1" + mkdir -p "$dir/src" "$dir/scripts" "$dir/test" "$dir/vendor/dory-math" + cat > "$dir/package.json" <<'EOF' +{ + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@dory/math-fixture": "file:vendor/dory-math" + }, + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --test test/*.test.mjs" + } +} +EOF + cat > "$dir/package-lock.json" <<'EOF' +{ + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "dependencies": { + "@dory/math-fixture": "file:vendor/dory-math" + } + }, + "node_modules/@dory/math-fixture": { + "resolved": "vendor/dory-math", + "link": true + }, + "vendor/dory-math": { + "name": "@dory/math-fixture", + "version": "1.0.0" + } + } +} +EOF + cat > "$dir/vendor/dory-math/package.json" <<'EOF' +{ + "name": "@dory/math-fixture", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} +EOF + cat > "$dir/vendor/dory-math/index.mjs" <<'EOF' +export function weightedChecksum(values) { + return values.reduce((total, value, index) => (total + value * (index + 1)) % 2147483647, 0); +} +EOF + cat > "$dir/src/app.mjs" <<'EOF' +import { weightedChecksum } from '@dory/math-fixture'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +export function fingerprint(value) { + return createHash('sha256').update(value).digest('hex'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const checksum = weightedChecksum(Array.from({ length: 4096 }, (_, index) => index % 251)); + console.log(`dory-nonnative-build-ok arch=${process.arch} checksum=${checksum} sha=${fingerprint('dory-readiness')}`); +} +EOF + cat > "$dir/scripts/build.mjs" <<'EOF' +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +const source = await readFile(new URL('../src/app.mjs', import.meta.url), 'utf8'); +await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); +await writeFile(new URL('../dist/app.mjs', import.meta.url), `// generated by npm run build\n${source}`); +EOF + cat > "$dir/test/app.test.mjs" <<'EOF' +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { weightedChecksum } from '@dory/math-fixture'; +import { fingerprint } from '../dist/app.mjs'; + +test('built artifact executes on the requested architecture', () => { + assert.equal(process.arch, process.env.EXPECTED_NODE_ARCH); + assert.match(fingerprint('dory-readiness'), /^[0-9a-f]{64}$/); + assert.notEqual(fingerprint('dory-readiness'), fingerprint('wrong-input')); + assert.equal(weightedChecksum([3, 5, 7]), 34); +}); +EOF +} + +if [ "${DORY_NONNATIVE_SMOKE_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + +TARGET_ARCH="${TARGET_ARCH:-$(default_target_arch)}" +HANDLER="$(handler_for_arch "$TARGET_ARCH")" +UNAME_PATTERN="$(uname_pattern_for_arch "$TARGET_ARCH")" +NODE_ARCH="$(node_arch_for_arch "$TARGET_ARCH")" +DOCKER_BIN="$(find_docker_bin)" + +[ "$KEEP" = 0 ] || [ "$KEEP" = 1 ] \ + || { echo "nonnative-build-smoke: keep setting must be 0 or 1" >&2; exit 2; } +[ -f "$DOCKER_BIN" ] && [ ! -L "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] \ + || { echo "nonnative-build-smoke: Docker CLI is not an exact regular executable" >&2; exit 2; } +[ -S "$DORY_SOCK" ] || { + echo "nonnative-build-smoke: missing Dory socket at $DORY_SOCK" >&2 + exit 1 +} +[ "$(stat -f %u "$DORY_SOCK")" = "$(id -u)" ] || { + echo "nonnative-build-smoke: Dory socket is not owned by the release user" >&2 + exit 1 +} +for image in "$ALPINE_IMAGE" "$BUILD_IMAGE"; do + printf '%s\n' "$image" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' || { + echo "nonnative-build-smoke: images must be digest-pinned: $image" >&2 + exit 2 + } +done + +if [ -z "$WORKDIR" ]; then + WORKDIR="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-nonnative-build.XXXXXXXX")" +else + case "$WORKDIR" in /*) ;; *) echo "nonnative-build-smoke: workdir must be absolute" >&2; exit 2 ;; esac + [ ! -e "$WORKDIR" ] && [ ! -L "$WORKDIR" ] || { + echo "nonnative-build-smoke: explicit workdir must not already exist" >&2 + exit 2 + } + mkdir -m 700 "$WORKDIR" +fi +[ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] || { + echo "nonnative-build-smoke: workdir changed while preparing the build" >&2 + exit 2 +} +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +TAG="dory-nonnative-smoke:${RUN_ID}-linux-${TARGET_ARCH}" +write_node_build_fixture "$WORKDIR" + +cleanup() { + if [ "$KEEP" != "1" ]; then + "$DOCKER_BIN" -H "unix://$DORY_SOCK" rmi -f "$TAG" >/dev/null 2>&1 || true + rm -rf "$WORKDIR" + else + echo "nonnative-build-smoke: kept context at $WORKDIR" + echo "nonnative-build-smoke: kept image tag $TAG" + fi +} +trap cleanup EXIT + +docker_e() { + "$DOCKER_BIN" -H "unix://$DORY_SOCK" "$@" +} + +docker_e image inspect "$ALPINE_IMAGE" >/dev/null 2>&1 \ + || { echo "nonnative-build-smoke: local fixture image is unavailable: $ALPINE_IMAGE" >&2; exit 1; } +docker_e image inspect "$BUILD_IMAGE" >/dev/null 2>&1 \ + || { echo "nonnative-build-smoke: local build image is unavailable: $BUILD_IMAGE" >&2; exit 1; } + +echo "nonnative-build-smoke: target linux/$TARGET_ARCH using $BUILD_IMAGE" + +if [ "$(host_guest_arch)" != "$TARGET_ARCH" ]; then + if ! binfmt_output="$(docker_e run --rm --privileged --label "dev.dory.nonnative-smoke=$RUN_ID" "$ALPINE_IMAGE" sh -c ' +handler="$1" +mkdir -p /proc/sys/fs/binfmt_misc +grep -qs " /proc/sys/fs/binfmt_misc " /proc/mounts \ + || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc +test -e /proc/sys/fs/binfmt_misc/register || { echo "binfmt_misc not mounted" >&2; exit 1; } +test -e "/proc/sys/fs/binfmt_misc/$handler" || { echo "$handler handler not registered" >&2; exit 1; } +grep -qx enabled "/proc/sys/fs/binfmt_misc/$handler" || { echo "$handler handler not enabled" >&2; exit 1; } +if [ "$handler" = FEX-x86_64 ]; then + grep -qx 'interpreter /usr/lib/dory/fex/FEX' "/proc/sys/fs/binfmt_misc/$handler" \ + || { echo "$handler interpreter is invalid" >&2; exit 1; } + grep -qx 'flags: POCF' "/proc/sys/fs/binfmt_misc/$handler" \ + || { echo "$handler flags are invalid" >&2; exit 1; } +else + grep -qx 'interpreter /usr/bin/qemu-aarch64-static' "/proc/sys/fs/binfmt_misc/$handler" \ + || { echo "$handler interpreter is invalid" >&2; exit 1; } + grep -qx 'flags: F' "/proc/sys/fs/binfmt_misc/$handler" \ + || { echo "$handler flags are invalid" >&2; exit 1; } +fi +' sh "$HANDLER" 2>&1)"; then + printf '%s\n' "$binfmt_output" >&2 + echo "nonnative-build-smoke: non-native support is not ready. Enable non-native image support in Dory, restart doryd, then retry." >&2 + exit 1 + fi +fi + +cat > "$WORKDIR/Dockerfile" < /uname.txt \\ + && printf '%s\n' "\$actual" | grep -Eq "\$EXPECTED_UNAME" +RUN node -e "if (process.arch !== process.env.EXPECTED_NODE_ARCH) { throw new Error(process.arch + ' != ' + process.env.EXPECTED_NODE_ARCH) } console.log(process.arch)" > /node-arch.txt +RUN --network=none mkdir -p /tmp/tar-source/a/b/c /tmp/tar-output \ + && printf nested-gnu-tar-ok > /tmp/tar-source/a/b/c/payload.txt \ + && ln /tmp/tar-source/a/b/c/payload.txt /tmp/tar-source/a/b/c/hardlink.txt \ + && tar -cf /tmp/nested.tar -C /tmp/tar-source . \ + && tar -xf /tmp/nested.tar -C /tmp/tar-output \ + && grep -qx nested-gnu-tar-ok /tmp/tar-output/a/b/c/payload.txt \ + && test "\$(stat -c %i /tmp/tar-output/a/b/c/payload.txt)" = "\$(stat -c %i /tmp/tar-output/a/b/c/hardlink.txt)" +COPY package.json package-lock.json ./ +COPY vendor ./vendor +RUN --network=none npm ci --ignore-scripts --no-audit --no-fund +COPY src ./src +COPY scripts ./scripts +COPY test ./test +RUN --network=none npm run build \\ + && npm test \\ + && node dist/app.mjs | tee /build-result.txt \\ + && grep -q "dory-nonnative-build-ok arch=\$EXPECTED_NODE_ARCH" /build-result.txt +CMD ["node", "dist/app.mjs"] +EOF + +DOCKER_BUILDKIT=1 docker_e build --pull=false --progress=plain --platform "linux/$TARGET_ARCH" \ + --build-arg "EXPECTED_UNAME=$UNAME_PATTERN" \ + --build-arg "EXPECTED_NODE_ARCH=$NODE_ARCH" \ + -t "$TAG" "$WORKDIR" + +docker_e run --rm --platform "linux/$TARGET_ARCH" \ + --label "dev.dory.nonnative-smoke=$RUN_ID" \ + "$TAG" | tee "$WORKDIR/result.txt" + +grep -q "dory-nonnative-build-ok arch=$NODE_ARCH" "$WORKDIR/result.txt" +echo "nonnative-build-smoke: PASS linux/$TARGET_ARCH BuildKit npm ci + tar nested/hardlink + build + test + runtime" From 4895a341790745b9255933162c60ffb7d2740769 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:14:31 +0000 Subject: [PATCH 116/338] feat(release): qualify external APFS binds --- .../scripts/test-external-volume-bind-gate.py | 73 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/external-volume-bind-gate.sh | 314 ++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 .github/scripts/test-external-volume-bind-gate.py create mode 100755 scripts/external-volume-bind-gate.sh diff --git a/.github/scripts/test-external-volume-bind-gate.py b/.github/scripts/test-external-volume-bind-gate.py new file mode 100644 index 00000000..9356d720 --- /dev/null +++ b/.github/scripts/test-external-volume-bind-gate.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical external-APFS bind gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "external-volume-bind-gate.sh" + + +class ExternalVolumeBindGateTests(unittest.TestCase): + def test_external_volume_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISCONNECT-RECONNECT-DEDICATED-APFS", + "DORY-DEDICATED-RELEASE-APFS-V1", + "test root must be below /Volumes/", + "test path is not on an external physical volume", + "external test volume is not APFS", + "--image must be a digest-pinned fixture", + "candidate socket is not owned by the release user", + "external FIFO rejected promptly", + "operations=10000", + "64 MiB external bind checksum mismatch", + '"$DORY" engine sleep', + 'diskutil unmount "$device_identifier"', + "missing external volume unexpectedly accepted a bind write", + "external APFS bytes were lost across unmount/remount", + "disconnect_reconnect=PASS", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:latest", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_is_required_before_host_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--dory", + "/missing/dory", + "--state-dir", + str(pathlib.Path(temporary) / "state"), + "--path", + str(pathlib.Path(temporary) / "volume"), + "--image", + "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 787296c9..53b4dd2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -343,6 +343,7 @@ jobs: python3 .github/scripts/test-ssh-agent-forwarding-gate.py python3 .github/scripts/test-bind-advisory-lock-gate.py python3 .github/scripts/test-nonnative-build-smoke.py + python3 .github/scripts/test-external-volume-bind-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 664904cd..136d7833 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,6 +44,7 @@ jobs: python3 .github/scripts/test-ssh-agent-forwarding-gate.py python3 .github/scripts/test-bind-advisory-lock-gate.py python3 .github/scripts/test-nonnative-build-smoke.py + python3 .github/scripts/test-external-volume-bind-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/external-volume-bind-gate.sh b/scripts/external-volume-bind-gate.sh new file mode 100755 index 00000000..1a70ef2e --- /dev/null +++ b/scripts/external-volume-bind-gate.sh @@ -0,0 +1,314 @@ +#!/bin/bash +# Physical external-APFS bind-mount qualification. Requires a dedicated writable directory on a +# real external volume and refuses internal/System volumes or a non-empty Dory engine. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCKET="" +DOCKER="" +DORY="" +EXTERNAL_ROOT="" +STATE_DIR="" +WORKROOT="${DORY_EXTERNAL_VOLUME_EVIDENCE_ROOT:-$HOME/.dory-external-volume-gate}" +IMAGE="${DORY_EXTERNAL_VOLUME_IMAGE:-}" +FD_BUDGET=8 +CONFIRM="" +DISCONNECT_CONFIRM="" +DEDICATED_MARKER_NAME=".dory-release-external-volume" +DEDICATED_MARKER_VALUE="DORY-DEDICATED-RELEASE-APFS-V1" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --dory) need_value "$1" "$#"; DORY="$2"; shift 2 ;; + --state-dir) need_value "$1" "$#"; STATE_DIR="$2"; shift 2 ;; + --path) need_value "$1" "$#"; EXTERNAL_ROOT="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --fd-growth) need_value "$1" "$#"; FD_BUDGET="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --disconnect-confirm) need_value "$1" "$#"; DISCONNECT_CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-EXTERNAL-APFS-BIND ] \ + || die "requires --confirm ISOLATED-EXTERNAL-APFS-BIND" +[ "$DISCONNECT_CONFIRM" = DISCONNECT-RECONNECT-DEDICATED-APFS ] \ + || die "requires --disconnect-confirm DISCONNECT-RECONNECT-DEDICATED-APFS" +[ "$(uname -s)" = Darwin ] || die "physical external-volume gate requires macOS" +[ -n "$SOCKET" ] && [ -S "$SOCKET" ] || die "candidate socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "candidate socket is not owned by the release user" +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is not an exact regular executable: $DOCKER" +[ -f "$DORY" ] && [ ! -L "$DORY" ] && [ -x "$DORY" ] \ + || die "Dory CLI is not an exact regular executable: $DORY" +[ -d "$STATE_DIR" ] && [ ! -L "$STATE_DIR" ] \ + || die "candidate state directory is unavailable or indirect: $STATE_DIR" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -L "$WORKROOT" ] || die "--workroot must not be a symlink" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--image must be a digest-pinned fixture" +case "$FD_BUDGET" in ''|*[!0-9]*) die "--fd-growth must be a non-negative integer" ;; esac +[ "$FD_BUDGET" -le 64 ] || die "--fd-growth must not exceed 64" +for command in diskutil jq lsof mkfifo plutil ps python3 seq shasum stat; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +[ -d "$EXTERNAL_ROOT" ] || die "external test root is unavailable: $EXTERNAL_ROOT" +[ ! -L "$EXTERNAL_ROOT" ] || die "external test root must not be a symlink" +EXTERNAL_ROOT="$(cd "$EXTERNAL_ROOT" && pwd -P)" +case "$EXTERNAL_ROOT/" in /Volumes/*/*/) ;; *) die "test root must be below /Volumes/: $EXTERNAL_ROOT" ;; esac +volume_device="$(df -P "$EXTERNAL_ROOT" | awk 'NR == 2 { print $1 }')" +[ -n "$volume_device" ] \ + || die "could not resolve the backing device for external test root: $EXTERNAL_ROOT" +disk_plist="$(mktemp "${TMPDIR:-/tmp}/dory-external-disk.XXXXXXXX")" +if ! diskutil info -plist "$volume_device" > "$disk_plist"; then + rm -f "$disk_plist" + die "could not inspect external APFS device" +fi +if ! disk_json="$(plutil -convert json -o - "$disk_plist")"; then + rm -f "$disk_plist" + die "external APFS device metadata is invalid" +fi +rm -f "$disk_plist" +mount_point="$(printf '%s' "$disk_json" | jq -r '.MountPoint // empty')" +device_identifier="$(printf '%s' "$disk_json" | jq -r '.DeviceIdentifier // empty')" +internal="$(printf '%s' "$disk_json" | jq -r 'if has("Internal") then .Internal else true end')" +filesystem="$(printf '%s' "$disk_json" | jq -r '.FilesystemType // .FilesystemName // empty' | tr '[:upper:]' '[:lower:]')" +[ -n "$mount_point" ] && [ -n "$device_identifier" ] \ + || die "external APFS device identity is incomplete" +[ "$internal" = false ] || die "test path is not on an external physical volume" +case "$filesystem" in *apfs*) ;; *) die "external test volume is not APFS (reported: $filesystem)" ;; esac +case "$EXTERNAL_ROOT/" in "$mount_point"/*/) ;; *) die "test root is outside reported mount point $mount_point" ;; esac +[ -w "$EXTERNAL_ROOT" ] || die "external test root is not writable" +dedicated_marker="$mount_point/$DEDICATED_MARKER_NAME" +[ -f "$dedicated_marker" ] && [ ! -L "$dedicated_marker" ] \ + || die "external volume lacks the operator-created dedicated-release marker: $dedicated_marker" +[ "$(cat "$dedicated_marker")" = "$DEDICATED_MARKER_VALUE" ] \ + || die "external volume marker does not authorize disconnect/reconnect qualification" + +docker_e() { DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@"; } +docker_e version >/dev/null || die "candidate Docker API is unavailable" +docker_e image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "required fixture image is unavailable: $IMAGE" +[ -z "$(docker_e ps -aq)" ] || die "gate requires zero pre-existing containers" +[ -z "$(docker_e volume ls -q)" ] || die "gate requires zero pre-existing named volumes" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-external-$RUN_ID" +HOST_DIR="$EXTERNAL_ROOT/$OWNER" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/results.tsv" +VOLUME_UNMOUNTED=0 +mkdir -p "$HOST_DIR" "$WORKDIR" +[ -d "$HOST_DIR" ] && [ ! -L "$HOST_DIR" ] \ + && [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] \ + || die "gate workspace changed while preparing fixtures" +printf 'test\tstatus\tdetail\n' > "$RESULTS" +remove_owned_shadow_path() { + local path + [ ! -e "$mount_point/$DEDICATED_MARKER_NAME" ] || return 0 + rm -rf "$HOST_DIR" + path="$EXTERNAL_ROOT" + while [ "$path" != "$mount_point" ]; do + rmdir "$path" 2>/dev/null || break + path="$(dirname "$path")" + done + rmdir "$mount_point" 2>/dev/null || true +} +cleanup() { + set +e + docker_e ps -aq --filter "label=dev.dory.external=$OWNER" | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f -v "$id" >/dev/null 2>&1 || true + done + if [ "$VOLUME_UNMOUNTED" = 1 ] || [ ! -d "$mount_point" ]; then + remove_owned_shadow_path + diskutil mount "$device_identifier" >/dev/null 2>&1 || true + for _ in $(seq 1 100); do [ -d "$mount_point" ] && break; sleep 0.1; done + VOLUME_UNMOUNTED=0 + fi + if [ -d "$mount_point" ] \ + && [ -f "$mount_point/$DEDICATED_MARKER_NAME" ] \ + && [ "$(cat "$mount_point/$DEDICATED_MARKER_NAME" 2>/dev/null)" = "$DEDICATED_MARKER_VALUE" ]; then + rm -rf "$HOST_DIR" + fi +} +trap cleanup EXIT INT TERM +pass() { printf '%s\tPASS\t%s\n' "$1" "$2" >> "$RESULTS"; } + +sample_fds() { + local output="$1" total=0 pid command count + : > "$output" + while read -r pid command; do + [ -n "$pid" ] || continue + case "$command" in + *dory-hv*"$STATE_DIR"*|*gvproxy*"$STATE_DIR"*|*dory-dataplane-proxy*"$STATE_DIR"*) ;; + *) continue ;; + esac + count="$(lsof -a -p "$pid" 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" + printf '%s\t%s\t%s\n' "$pid" "$count" "$command" >> "$output" + total=$((total + count)) + done < <(ps axww -o pid=,command=) + [ "$total" -gt 0 ] || die "could not attribute any candidate engine file descriptors" + printf '%s' "$total" +} + +printf host-marker > "$HOST_DIR/host-marker" +docker_e run --rm --label "dev.dory.external=$OWNER" -v "$HOST_DIR:/external" "$IMAGE" \ + sh -ec 'test "$(cat /external/host-marker)" = host-marker; printf guest-marker > /external/guest-marker; ln /external/guest-marker /external/guest-hardlink; chmod 0400 /external/guest-marker; test "$(cat /external/guest-hardlink)" = guest-marker' +[ "$(cat "$HOST_DIR/guest-marker")" = guest-marker ] || die "guest write did not reach external APFS" +[ "$(stat -f '%l' "$HOST_DIR/guest-marker")" = 2 ] || die "external APFS hard link was not preserved" +pass ownership-hardlink "bidirectional bytes and restrictive hard-link alias passed on $mount_point" + +mkfifo "$HOST_DIR/host-fifo" +set +e +python3 - "$SOCKET" "$DOCKER" "$HOST_DIR" "$OWNER" "$IMAGE" <<'PY' +import os, subprocess, sys +socket, docker, host, owner, image = sys.argv[1:] +try: + result = subprocess.run( + [docker, "-H", "unix://" + socket, "run", "--rm", "--label", "dev.dory.external=" + owner, "-v", host + ":/external", image, "dd", "if=/external/host-fifo", "of=/dev/null", "bs=1", "count=1"], + timeout=5, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) +except subprocess.TimeoutExpired: + raise SystemExit(124) +raise SystemExit(result.returncode) +PY +fifo_rc=$? +set -e +[ "$fifo_rc" -ne 0 ] || die "external FIFO unexpectedly opened as a regular file" +[ "$fifo_rc" -ne 124 ] || die "external FIFO open exceeded five seconds" +docker_e version >/dev/null || die "Docker API wedged after external FIFO open" +rm -f "$HOST_DIR/host-fifo" +pass fifo-fail-fast "external FIFO rejected promptly; API remained live" + +before="$(sample_fds "$WORKDIR/fds-before.tsv")" +docker_e run --rm --label "dev.dory.external=$OWNER" -v "$HOST_DIR:/external" "$IMAGE" sh -ec ' + i=0 + while [ "$i" -lt 10000 ]; do + printf "%s" "$i" > /external/fd-churn + dd if=/external/fd-churn of=/dev/null bs=32 count=1 2>/dev/null + i=$((i + 1)) + done + rm -f /external/fd-churn +' +sleep 2 +after="$(sample_fds "$WORKDIR/fds-after.tsv")" +growth=$((after - before)) +[ "$growth" -le "$FD_BUDGET" ] || die "external bind churn grew Dory FDs by $growth (budget $FD_BUDGET)" +pass fd-stability "operations=10000 before=$before after=$after growth=$growth" + +python3 - "$HOST_DIR/large.bin" <<'PY' +import pathlib, sys +path = pathlib.Path(sys.argv[1]) +chunk = bytes(range(256)) * 4096 +with path.open("wb") as handle: + for _ in range(64): + handle.write(chunk) +PY +host_sha="$(shasum -a 256 "$HOST_DIR/large.bin" | awk '{print $1}')" +guest_sha="$(docker_e run --rm --label "dev.dory.external=$OWNER" -v "$HOST_DIR:/external:ro" "$IMAGE" sha256sum /external/large.bin | awk '{print $1}')" +[ "$host_sha" = "$guest_sha" ] || die "64 MiB external bind checksum mismatch" +pass large-file "64 MiB sha256=$host_sha" + +"$DORY" engine sleep >/dev/null +"$DORY" engine wake >/dev/null +for _ in $(seq 1 120); do docker_e version >/dev/null 2>&1 && break; sleep 1; done +docker_e run --rm --label "dev.dory.external=$OWNER" -v "$HOST_DIR:/external:ro" "$IMAGE" \ + sh -ec 'test "$(cat /external/guest-marker)" = guest-marker' +pass engine-restart "external bind and bytes survived candidate sleep/wake" + +diskutil unmount "$device_identifier" > "$WORKDIR/diskutil-unmount.txt" +VOLUME_UNMOUNTED=1 +for _ in $(seq 1 100); do [ ! -e "$mount_point" ] && break; sleep 0.1; done +[ ! -e "$mount_point" ] || die "external APFS mount point remained present after unmount" +set +e +python3 - "$SOCKET" "$DOCKER" "$HOST_DIR" "$OWNER" "$IMAGE" <<'PY' +import subprocess +import sys + +socket, docker, host, owner, image = sys.argv[1:] +try: + result = subprocess.run( + [ + docker, "-H", "unix://" + socket, + "run", "--rm", "--label", "dev.dory.external=" + owner, + "-v", host + ":/external", image, + "sh", "-ec", "printf forbidden-shadow-write > /external/should-not-exist", + ], + timeout=10, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) +except subprocess.TimeoutExpired: + raise SystemExit(124) +raise SystemExit(result.returncode) +PY +missing_rc=$? +set -e +[ "$missing_rc" -ne 0 ] || die "missing external volume unexpectedly accepted a bind write" +[ "$missing_rc" -ne 124 ] || die "missing external volume bind did not fail within ten seconds" +docker_e version >/dev/null || die "Docker API wedged after missing external-volume bind" +[ ! -e "$HOST_DIR" ] || die "missing external volume created an internal shadow path" +diskutil mount "$device_identifier" > "$WORKDIR/diskutil-mount.txt" +VOLUME_UNMOUNTED=0 +for _ in $(seq 1 100); do + [ -f "$dedicated_marker" ] && [ -d "$HOST_DIR" ] && break + sleep 0.1 +done +[ "$(cat "$dedicated_marker" 2>/dev/null || true)" = "$DEDICATED_MARKER_VALUE" ] \ + || die "dedicated external APFS volume did not remount with its identity marker" +[ "$(cat "$HOST_DIR/guest-marker" 2>/dev/null || true)" = guest-marker ] \ + || die "external APFS bytes were lost across unmount/remount" +[ "$(stat -f '%l' "$HOST_DIR/guest-marker")" = 2 ] \ + || die "external APFS hard link was lost across unmount/remount" +docker_e run --rm --label "dev.dory.external=$OWNER" -v "$HOST_DIR:/external:ro" "$IMAGE" \ + sh -ec 'test "$(cat /external/guest-marker)" = guest-marker; test "$(cat /external/guest-hardlink)" = guest-marker' +pass disconnect-reconnect "device=$device_identifier missing bind failed rc=$missing_rc; bytes and hard links survived remount" + +{ + echo status=PASS + echo "run_id=$RUN_ID" + echo "mount_point=$mount_point" + echo "filesystem=$filesystem" + echo "internal=$internal" + echo "device_identifier=$device_identifier" + echo "image=$IMAGE" + echo 'disconnect_reconnect=PASS' + echo 'missing_drive_rejected=PASS' + echo "docker_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "dory_sha256=$(shasum -a 256 "$DORY" | awk '{print $1}')" + echo "completed_epoch=$(date +%s)" +} > "$WORKDIR/manifest.txt" +echo "external APFS bind gate PASS; evidence: $WORKDIR" From 6dccf7df61ba1f4659041b8b1c8d96c83ec28b43 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:18:53 +0000 Subject: [PATCH 117/338] feat(release): qualify host network recovery --- .../test-host-network-integrity-gate.py | 87 +++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/host-network-integrity-gate.sh | 614 ++++++++++++++++++ 4 files changed, 703 insertions(+) create mode 100644 .github/scripts/test-host-network-integrity-gate.py create mode 100755 scripts/host-network-integrity-gate.sh diff --git a/.github/scripts/test-host-network-integrity-gate.py b/.github/scripts/test-host-network-integrity-gate.py new file mode 100644 index 00000000..b865d3a1 --- /dev/null +++ b/.github/scripts/test-host-network-integrity-gate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical host-network recovery gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "host-network-integrity-gate.sh" + + +class HostNetworkIntegrityGateTests(unittest.TestCase): + def test_physical_recovery_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + + for proof in ( + "SLEEP-AND-WAKE-THIS-MAC", + "VPN-ROUTE-CHURN", + "network probe image must be digest-pinned", + "Dory socket is not owned by the release user", + "candidate app has no notarization ticket", + "Notarized Developer ID", + "sudo -n pmset relative wake", + "sudo -n pmset sleepnow", + "default-route.contract dns.contract proxy.contract service-dns.contract resolvers.contract", + "required custom DNS server is absent", + "no active VPN-like interface is present", + "already has an active Tailscale exit node", + "ROUTE_CHURN_ROUNDS=3", + "Arm cleanup before requesting the mutation", + "host network contract did not self-heal after exit-node round", + "interactive machine shell", + "fresh machine exec failed", + "machine disk persistence failed", + "release_qualifying=", + "tailscale_cli_sha256=", + ): + self.assertIn(proof, text, proof) + + self.assertRegex(text, r"\^\.\+@sha256:\[0-9a-f\]\{64\}\$") + self.assertIn("(.ExitNodeStatus // null) == null", text) + self.assertIn("(.ExitNode // false) == true", text) + self.assertLess( + text.index("TAILSCALE_EXIT_NODE_ACTIVE=1", text.index("run_route_churn()")), + text.index('"$TAILSCALE_BIN" set --exit-node="$TAILSCALE_EXIT_NODE"'), + ) + self.assertNotIn("tailscale-baseline-disable", text) + for stale in ("alpine:latest", "assert "): + self.assertNotIn(stale, text, stale) + + def test_physical_confirmation_fails_before_host_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--app", + "/missing/Dory.app", + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={ + "DORY_NETWORK_INTEGRITY_IMAGE": "example.invalid/alpine@sha256:" + "a" * 64, + "HOME": temporary, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("physical sleep requires --confirm-physical-sleep", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53b4dd2a..43215f24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -344,6 +344,7 @@ jobs: python3 .github/scripts/test-bind-advisory-lock-gate.py python3 .github/scripts/test-nonnative-build-smoke.py python3 .github/scripts/test-external-volume-bind-gate.py + python3 .github/scripts/test-host-network-integrity-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 136d7833..416bdbb8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,7 @@ jobs: python3 .github/scripts/test-bind-advisory-lock-gate.py python3 .github/scripts/test-nonnative-build-smoke.py python3 .github/scripts/test-external-volume-bind-gate.py + python3 .github/scripts/test-host-network-integrity-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/host-network-integrity-gate.sh b/scripts/host-network-integrity-gate.sh new file mode 100755 index 00000000..14cb0a4e --- /dev/null +++ b/scripts/host-network-integrity-gate.sh @@ -0,0 +1,614 @@ +#!/bin/bash +# Physical sleep/wake gate for the class of failures where a container runtime leaves macOS Wi-Fi, +# DNS, routes, or proxies broken. Release qualification requires real sleep and an active Wi-Fi +# service; a no-sleep preflight is available but is explicitly marked non-qualifying. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCKET="${DORY_NETWORK_INTEGRITY_SOCKET:-$HOME/.dory/dory.sock}" +DOCKER="${DORY_NETWORK_INTEGRITY_DOCKER:-}" +APP="${DORY_NETWORK_INTEGRITY_APP:-}" +IMAGE="${DORY_NETWORK_INTEGRITY_IMAGE:-}" +WORKROOT="${DORY_NETWORK_INTEGRITY_WORKROOT:-$HOME/.dory-network-integrity}" +CYCLES="${DORY_NETWORK_INTEGRITY_CYCLES:-5}" +WAKE_TIMEOUT="${DORY_NETWORK_INTEGRITY_WAKE_TIMEOUT:-120}" +AUTO_WAKE_SECONDS="${DORY_NETWORK_INTEGRITY_AUTO_WAKE_SECONDS:-30}" +SOURCE_COMMIT="${DORY_NETWORK_INTEGRITY_SOURCE_COMMIT:-${GITHUB_SHA:-}}" +CUSTOM_DNS="${DORY_NETWORK_INTEGRITY_CUSTOM_DNS:-}" +TAILSCALE_EXIT_NODE="${DORY_NETWORK_INTEGRITY_TAILSCALE_EXIT_NODE:-}" +TAILSCALE_BIN="${DORY_NETWORK_INTEGRITY_TAILSCALE_BIN:-}" +SLEEP_TOKEN="" +ROUTE_CHURN_TOKEN="" +ROUTE_CHURN_ROUNDS=3 +TAILSCALE_EXIT_NODE_ACTIVE=0 +NO_SLEEP=0 +REQUIRE_WIFI=1 +REQUIRE_VPN=0 +PROBE_HOST="${DORY_NETWORK_INTEGRITY_PROBE_HOST:-registry-1.docker.io}" +PROBE_URL="${DORY_NETWORK_INTEGRITY_PROBE_URL:-https://registry-1.docker.io/v2/}" +MACHINE_CTL="" +MACHINE_KERNEL="" +MACHINE_ROOTFS="" +MACHINE="" +MACHINE_OWNED=0 +SESSION_PID="" +SESSION_WRITER_PID="" +SESSION_FIFO="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --app) need_value "$1" "$#"; APP="$2"; shift 2 ;; + --cycles) need_value "$1" "$#"; CYCLES="$2"; shift 2 ;; + --wake-timeout) need_value "$1" "$#"; WAKE_TIMEOUT="$2"; shift 2 ;; + --auto-wake-seconds) need_value "$1" "$#"; AUTO_WAKE_SECONDS="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --probe-host) need_value "$1" "$#"; PROBE_HOST="$2"; shift 2 ;; + --probe-url) need_value "$1" "$#"; PROBE_URL="$2"; shift 2 ;; + --custom-dns) need_value "$1" "$#"; CUSTOM_DNS="$2"; shift 2 ;; + --require-vpn) REQUIRE_VPN=1; shift ;; + --tailscale-exit-node) need_value "$1" "$#"; TAILSCALE_EXIT_NODE="$2"; shift 2 ;; + --confirm-route-churn) need_value "$1" "$#"; ROUTE_CHURN_TOKEN="$2"; shift 2 ;; + --confirm-physical-sleep) need_value "$1" "$#"; SLEEP_TOKEN="$2"; shift 2 ;; + --no-sleep) NO_SLEEP=1; shift ;; + --allow-non-wifi) REQUIRE_WIFI=0; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done +for pair in "cycles:$CYCLES" "wake-timeout:$WAKE_TIMEOUT" "auto-wake-seconds:$AUTO_WAKE_SECONDS"; do + case "${pair#*:}" in ''|*[!0-9]*) die "${pair%%:*} must be a positive integer" ;; esac + [ "${pair#*:}" -gt 0 ] || die "${pair%%:*} must be positive" +done +[ "$CYCLES" -le 10 ] || die "cycles must not exceed 10" +[ "$WAKE_TIMEOUT" -le 600 ] || die "wake-timeout must not exceed 600 seconds" +[ "$AUTO_WAKE_SECONDS" -le 600 ] || die "auto-wake-seconds must not exceed 600 seconds" +case "$NO_SLEEP" in 0|1) ;; *) die "invalid no-sleep mode" ;; esac +case "$REQUIRE_WIFI" in 0|1) ;; *) die "invalid Wi-Fi requirement" ;; esac +case "$REQUIRE_VPN" in 0|1) ;; *) die "invalid VPN requirement" ;; esac +if [ "$NO_SLEEP" = "0" ] && [ "$SLEEP_TOKEN" != "SLEEP-AND-WAKE-THIS-MAC" ]; then + die "physical sleep requires --confirm-physical-sleep SLEEP-AND-WAKE-THIS-MAC" +fi +if [ -n "$TAILSCALE_EXIT_NODE" ] && [ "$ROUTE_CHURN_TOKEN" != "VPN-ROUTE-CHURN" ]; then + die "exit-node testing requires --confirm-route-churn VPN-ROUTE-CHURN" +fi +case "$TAILSCALE_EXIT_NODE" in + -*|*[!A-Za-z0-9_.:%-]*) die "--tailscale-exit-node contains unsafe characters" ;; +esac +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -L "$WORKROOT" ] || die "--workroot must not be a symlink" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "network probe image must be digest-pinned" +python3 - "$PROBE_HOST" "$PROBE_URL" "$CUSTOM_DNS" <<'PY' +import ipaddress +import re +import sys +from urllib.parse import urlsplit + +host, raw_url, custom_dns = sys.argv[1:] +if re.fullmatch(r"(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host) is None: + raise SystemExit("network-integrity: probe host is invalid") +url = urlsplit(raw_url) +if url.scheme != "https" or url.hostname != host or url.username is not None \ + or url.password is not None or url.port not in (None, 443) or url.fragment: + raise SystemExit("network-integrity: probe URL must use the exact probe host over HTTPS") +if custom_dns: + try: + ipaddress.ip_address(custom_dns) + except ValueError: + raise SystemExit("network-integrity: custom DNS must be one IP address") +PY + +if [ "${DORY_NETWORK_INTEGRITY_SOURCE_ONLY:-0}" = "1" ]; then + if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0; else exit 0; fi +fi + +[ "$(uname -s)" = Darwin ] || die "physical host-network gate requires macOS" +for command in codesign curl dscacheutil ifconfig jq networksetup route scutil pmset shasum spctl sudo xcrun; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +[ -n "$DOCKER" ] || DOCKER="$(command -v docker 2>/dev/null || true)" +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "exact Docker CLI is unavailable or indirect: $DOCKER" +[ -S "$SOCKET" ] || die "Dory socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "Dory socket is not owned by the release user" +docker_e() { DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@"; } +docker_e version >/dev/null || die "Docker API is not ready" +docker_e image inspect "$IMAGE" >/dev/null 2>&1 || die "required offline image is missing: $IMAGE" +if [ "$NO_SLEEP" = "0" ]; then + [ "$(uname -m)" = arm64 ] || die "release sleep/wake qualification requires Apple silicon" + [ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Hypervisor.framework is unavailable" + [ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested virtualization cannot qualify physical sleep/wake" + case "$(sysctl -n hw.model 2>/dev/null || printf unknown)" in + VirtualMac*) die "VirtualMac cannot qualify physical sleep/wake" ;; + esac + [ -d "$APP" ] && [ ! -L "$APP" ] \ + && [ -f "$APP/Contents/MacOS/Dory" ] && [ ! -L "$APP/Contents/MacOS/Dory" ] \ + && [ -x "$APP/Contents/MacOS/Dory" ] \ + || die "exact candidate app is required for physical sleep/wake" + MACHINE_CTL="$APP/Contents/Helpers/dorydctl" + MACHINE_KERNEL="$APP/Contents/Resources/dory-hv-kernel-arm64" + MACHINE_ROOTFS="$APP/Contents/Resources/dory-machine-rootfs-arm64.ext4" + [ -f "$MACHINE_CTL" ] && [ ! -L "$MACHINE_CTL" ] && [ -x "$MACHINE_CTL" ] \ + || die "exact candidate dorydctl is unavailable: $MACHINE_CTL" + [ -f "$MACHINE_KERNEL" ] && [ ! -L "$MACHINE_KERNEL" ] && [ -s "$MACHINE_KERNEL" ] \ + || die "exact candidate machine kernel is unavailable: $MACHINE_KERNEL" + [ -f "$MACHINE_ROOTFS" ] && [ ! -L "$MACHINE_ROOTFS" ] && [ -s "$MACHINE_ROOTFS" ] \ + || die "exact candidate machine rootfs is unavailable: $MACHINE_ROOTFS" + [ "$(shasum -a 256 "$DOCKER" | awk '{print $1}')" = \ + "$(shasum -a 256 "$APP/Contents/Helpers/docker" | awk '{print $1}')" ] \ + || die "sleep/wake Docker CLI differs from the exact candidate app" + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "physical sleep/wake requires an exact source commit" + codesign --verify --strict --deep "$APP" || die "candidate app signature is invalid" + xcrun stapler validate "$APP" || die "candidate app has no notarization ticket" + assessment="$(spctl --assess --type execute --verbose=4 "$APP" 2>&1)" \ + || die "Gatekeeper rejected the candidate app: $assessment" + grep -q '^source=Notarized Developer ID$' <<< "$assessment" \ + || die "sleep/wake candidate is not accepted as Notarized Developer ID" + sudo -n -l /usr/bin/pmset >/dev/null 2>&1 \ + || die "passwordless /usr/bin/pmset access is required only on the dedicated sleep/wake runner" + [ "$REQUIRE_VPN" = 1 ] \ + || die "physical release qualification requires --require-vpn" + [ -n "$TAILSCALE_EXIT_NODE" ] \ + || die "physical release qualification requires --tailscale-exit-node" + [ "$ROUTE_CHURN_TOKEN" = VPN-ROUTE-CHURN ] \ + || die "physical release qualification requires explicit route-churn confirmation" + [ -n "$TAILSCALE_BIN" ] || TAILSCALE_BIN="$(command -v tailscale 2>/dev/null || true)" + TAILSCALE_BIN="$(python3 - "$TAILSCALE_BIN" <<'PY' +import os +import sys + +print(os.path.realpath(sys.argv[1])) +PY +)" + [ -f "$TAILSCALE_BIN" ] && [ ! -L "$TAILSCALE_BIN" ] && [ -x "$TAILSCALE_BIN" ] \ + || die "physical route-churn qualification requires the Tailscale CLI" + [ -n "$CUSTOM_DNS" ] \ + || die "physical release qualification requires --custom-dns" + case "$PROBE_URL" in + https://"$PROBE_HOST"|https://"$PROBE_HOST"/*) ;; + *) die "physical release probe URL must use the exact probe host over HTTPS" ;; + esac +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-network-integrity-$RUN_ID" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/results.tsv" +mkdir -p "$WORKDIR" +[ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] \ + || die "workroot changed while preparing evidence" +printf 'cycle\tphase\tstatus\tdetail\n' > "$RESULTS" + +wifi_interface() { + networksetup -listallhardwareports | awk ' + /^Hardware Port: (Wi-Fi|AirPort)$/ {wifi=1; next} + wifi && /^Device: / {print $2; exit} + /^$/ {wifi=0} + ' +} + +capture_contract() { + local dir="$1" wifi service + mkdir -p "$dir" + route -n get default > "$dir/default-route.raw" 2>&1 + awk '/gateway:|interface:|if scope:|flags:/{gsub(/^[[:space:]]+/, ""); print}' \ + "$dir/default-route.raw" > "$dir/default-route.contract" + scutil --dns > "$dir/dns.raw" 2>&1 + scutil --nwi > "$dir/network-information.raw" 2>&1 || true + ifconfig > "$dir/interfaces.raw" 2>&1 + awk '/nameserver\[[0-9]+\]|search domain\[[0-9]+\]|domain[[:space:]]*:|if_index[[:space:]]*:|flags[[:space:]]*:.*(Scoped|Supplemental)/ { + gsub(/^[[:space:]]+/, ""); print + }' "$dir/dns.raw" > "$dir/dns.contract" + scutil --proxy > "$dir/proxy.contract" 2>&1 + networksetup -listallnetworkservices > "$dir/network-services.raw" 2>&1 + : > "$dir/service-dns.contract" + tail -n +2 "$dir/network-services.raw" | sed 's/^\*//' | while IFS= read -r service; do + [ -n "$service" ] || continue + printf 'SERVICE %s\n' "$service" + networksetup -getdnsservers "$service" 2>&1 || true + networksetup -getsearchdomains "$service" 2>&1 || true + done > "$dir/service-dns.contract" + if [ -d /etc/resolver ]; then + for file in /etc/resolver/*; do + [ -f "$file" ] || continue + printf '%s %s\n' "$(shasum -a 256 "$file" | awk '{print $1}')" "$(basename "$file")" + done | LC_ALL=C sort > "$dir/resolvers.contract" + else + : > "$dir/resolvers.contract" + fi + wifi="$(wifi_interface)" + printf '%s\n' "$wifi" > "$dir/wifi-interface" + if [ -n "$wifi" ]; then + networksetup -getairportpower "$wifi" > "$dir/wifi-power" 2>&1 || true + networksetup -getairportnetwork "$wifi" > "$dir/wifi-network" 2>&1 || true + fi + for file in default-route.contract dns.contract proxy.contract service-dns.contract resolvers.contract; do + shasum -a 256 "$dir/$file" + done > "$dir/contract.sha256" +} + +vpn_detected() { + grep -Eiq '(^utun[0-9]*:|^ppp[0-9]*:|^tun[0-9]*:|^tap[0-9]*:|wireguard|tailscale|zerotier|vpn)' \ + "$1/interfaces.raw" "$1/network-information.raw" "$1/default-route.raw" "$1/dns.raw" \ + 2>/dev/null +} + +custom_dns_active() { + awk -v expected="$CUSTOM_DNS" \ + '$1 ~ /^nameserver\[[0-9]+\]$/ && $2 == ":" && $3 == expected { found=1 } END { exit !found }' \ + "$1/dns.raw" +} + +compare_contract() { + local baseline="$1" current="$2" failed=0 file + for file in default-route.contract dns.contract proxy.contract service-dns.contract resolvers.contract; do + if ! diff -u "$baseline/$file" "$current/$file" > "$current/$file.diff"; then + echo "host network contract changed: $file" >&2 + failed=1 + fi + done + [ "$failed" -eq 0 ] +} + +host_probe() { + dscacheutil -q host -a name "$PROBE_HOST" | grep -Eq '^ip_address:' + route -n get default | grep -q 'interface:' + code="$(curl -sS -o /dev/null --connect-timeout 5 --max-time 20 -w '%{http_code}' "$PROBE_URL")" + case "$code" in 2??|3??|401|403) ;; *) echo "unexpected host HTTPS status: $code" >&2; return 1 ;; esac +} + +container_probe() { + docker_e run --rm --label "dev.dory.network-integrity=$OWNER" "$IMAGE" sh -c \ + 'getent hosts "$1" >/dev/null && nc -z -w 10 "$1" 443' sh "$PROBE_HOST" + if [ -n "$CUSTOM_DNS" ]; then + docker_e run --rm --dns "$CUSTOM_DNS" --label "dev.dory.network-integrity=$OWNER" \ + "$IMAGE" sh -c \ + 'getent hosts "$1" >/dev/null && nc -z -w 10 "$1" 443' sh "$PROBE_HOST" + fi +} + +engine_activity() { + local name="$OWNER-activity" + docker_e rm -f "$name" >/dev/null 2>&1 || true + docker_e run -d --name "$name" --label "dev.dory.network-integrity=$OWNER" "$IMAGE" sleep 120 >/dev/null + docker_e exec "$name" sh -c 'printf activity-ok' | grep -q activity-ok + docker_e rm -f "$name" >/dev/null +} + +machine_ctl() { "$MACHINE_CTL" --timeout 180 "$@"; } + +terminate_machine_session() { + if [ -n "$SESSION_PID" ]; then + kill "$SESSION_PID" >/dev/null 2>&1 || true + wait "$SESSION_PID" >/dev/null 2>&1 || true + SESSION_PID="" + fi + if [ -n "$SESSION_WRITER_PID" ]; then + kill "$SESSION_WRITER_PID" >/dev/null 2>&1 || true + wait "$SESSION_WRITER_PID" >/dev/null 2>&1 || true + SESSION_WRITER_PID="" + fi + if [ -n "$SESSION_FIFO" ]; then + rm -f "$SESSION_FIFO" + SESSION_FIFO="" + fi +} + +start_machine_session() { + local cycle="$1" token="DORY_SESSION_READY_${cycle}_${RUN_ID}" deadline + SESSION_FIFO="$WORKDIR/cycle-$cycle-machine-shell.fifo" + rm -f "$SESSION_FIFO" + mkfifo "$SESSION_FIFO" + ( + printf 'echo %s\n' "$token" + sleep $((AUTO_WAKE_SECONDS + WAKE_TIMEOUT + 300)) + ) > "$SESSION_FIFO" & + SESSION_WRITER_PID=$! + machine_ctl machine shell "$MACHINE" < "$SESSION_FIFO" \ + > "$WORKDIR/cycle-$cycle-machine-shell.out" \ + 2> "$WORKDIR/cycle-$cycle-machine-shell.err" & + SESSION_PID=$! + deadline=$(( $(date +%s) + 60 )) + until grep -aFq "$token" "$WORKDIR/cycle-$cycle-machine-shell.out" 2>/dev/null; do + if ! kill -0 "$SESSION_PID" >/dev/null 2>&1; then + terminate_machine_session + die "interactive machine shell disconnected before sleep cycle $cycle" + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + terminate_machine_session + die "interactive machine shell was not ready before sleep cycle $cycle" + fi + sleep 1 + done +} + +verify_machine_reconnect() { + local cycle="$1" reconnect_token="dory-machine-reconnect-$1" restart_token="dory-machine-restart-$1" + terminate_machine_session + machine_ctl machine status "$MACHINE" > "$WORKDIR/cycle-$cycle-machine-status-after-wake.json" + jq -e '.state == "running"' "$WORKDIR/cycle-$cycle-machine-status-after-wake.json" >/dev/null \ + || die "machine is not running after sleep cycle $cycle" + machine_ctl machine exec "$MACHINE" --json -- sh -ec \ + 'test -f /root/dory-sleep-session-marker; printf "%s" "$1"' sh "$reconnect_token" \ + > "$WORKDIR/cycle-$cycle-machine-reconnect.json" + jq -e --arg machine "$MACHINE" --arg token "$reconnect_token" \ + '.schema == "dev.dory.machine.exec" and .version == 1 and .machine == $machine and + .exitCode == 0 and .timedOut == false and .stdout == $token and + .stdoutTruncated == false and .stderrTruncated == false' \ + "$WORKDIR/cycle-$cycle-machine-reconnect.json" >/dev/null \ + || die "fresh machine exec failed after sleep cycle $cycle" + machine_ctl machine stop "$MACHINE" > "$WORKDIR/cycle-$cycle-machine-stop.json" + machine_ctl machine status "$MACHINE" > "$WORKDIR/cycle-$cycle-machine-status-stopped.json" + jq -e '.state == "stopped"' "$WORKDIR/cycle-$cycle-machine-status-stopped.json" >/dev/null \ + || die "machine stop wedged after sleep cycle $cycle" + machine_ctl machine start "$MACHINE" > "$WORKDIR/cycle-$cycle-machine-start.json" + machine_ctl machine status "$MACHINE" > "$WORKDIR/cycle-$cycle-machine-status-restarted.json" + jq -e '.state == "running"' "$WORKDIR/cycle-$cycle-machine-status-restarted.json" >/dev/null \ + || die "machine restart failed after sleep cycle $cycle" + machine_ctl machine exec "$MACHINE" --json -- sh -ec \ + 'test -f /root/dory-sleep-session-marker; printf "%s" "$1"' sh "$restart_token" \ + > "$WORKDIR/cycle-$cycle-machine-restart-persistence.json" + jq -e --arg machine "$MACHINE" --arg token "$restart_token" \ + '.schema == "dev.dory.machine.exec" and .version == 1 and .machine == $machine and + .exitCode == 0 and .timedOut == false and .stdout == $token and + .stdoutTruncated == false and .stderrTruncated == false' \ + "$WORKDIR/cycle-$cycle-machine-restart-persistence.json" >/dev/null \ + || die "machine disk persistence failed after stop/start in sleep cycle $cycle" +} + +cleanup() { + if [ "$TAILSCALE_EXIT_NODE_ACTIVE" -eq 1 ] && [ -x "${TAILSCALE_BIN:-}" ]; then + "$TAILSCALE_BIN" set --exit-node= \ + > "$WORKDIR/cleanup-tailscale-exit-node.out" \ + 2> "$WORKDIR/cleanup-tailscale-exit-node.err" || true + TAILSCALE_EXIT_NODE_ACTIVE=0 + fi + terminate_machine_session + if [ "$MACHINE_OWNED" -eq 1 ]; then + machine_ctl machine stop "$MACHINE" > "$WORKDIR/cleanup-machine-stop.json" \ + 2> "$WORKDIR/cleanup-machine-stop.err" || true + machine_ctl machine delete "$MACHINE" > "$WORKDIR/cleanup-machine-delete.json" \ + 2> "$WORKDIR/cleanup-machine-delete.err" || true + MACHINE_OWNED=0 + fi + docker_e ps -aq --filter "label=dev.dory.network-integrity=$OWNER" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f -v "$id" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [ "$NO_SLEEP" = "0" ]; then + "$TAILSCALE_BIN" status --json > "$WORKDIR/tailscale-preflight-status.json" + jq -e ' + (.ExitNodeStatus // null) == null and + ([.Peer[]? | select((.ExitNode // false) == true)] | length) == 0 + ' "$WORKDIR/tailscale-preflight-status.json" >/dev/null \ + || die "dedicated runner already has an active Tailscale exit node; refusing to change user network state" + + MACHINE="dory-sleep-session-$RUN_ID" + machine_ctl machine list > "$WORKDIR/machine-list-before.json" + jq -e --arg id "$MACHINE" 'all(.[]; .id != $id)' "$WORKDIR/machine-list-before.json" >/dev/null \ + || die "owned sleep-session machine name already exists: $MACHINE" + machine_ctl machine create "$MACHINE" --kernel "$MACHINE_KERNEL" --rootfs "$MACHINE_ROOTFS" \ + --memory-mb 2048 --cpus 2 > "$WORKDIR/machine-create.json" + MACHINE_OWNED=1 + machine_ctl machine start "$MACHINE" > "$WORKDIR/machine-start.json" + machine_ctl machine exec "$MACHINE" --json -- sh -ec \ + 'printf marker > /root/dory-sleep-session-marker' > "$WORKDIR/machine-marker.json" + jq -e --arg machine "$MACHINE" \ + '.schema == "dev.dory.machine.exec" and .version == 1 and .machine == $machine and + .exitCode == 0 and .timedOut == false and .stdoutTruncated == false and .stderrTruncated == false' \ + "$WORKDIR/machine-marker.json" >/dev/null || die "could not initialize machine persistence marker" +fi + +run_route_churn() { + local round deadline enabled restored + printf 'round\tphase\tstatus\tdetail\n' > "$WORKDIR/route-churn-results.tsv" + round=1 + while [ "$round" -le "$ROUTE_CHURN_ROUNDS" ]; do + enabled="$WORKDIR/route-churn-$round-enabled" + restored="$WORKDIR/route-churn-$round-restored" + mkdir -p "$enabled" "$restored" + # Arm cleanup before requesting the mutation so a signal between a successful set and the + # next shell statement cannot leave the dedicated runner using the test exit node. + TAILSCALE_EXIT_NODE_ACTIVE=1 + "$TAILSCALE_BIN" set --exit-node="$TAILSCALE_EXIT_NODE" \ + --exit-node-allow-lan-access=true \ + > "$enabled/tailscale-set.out" 2> "$enabled/tailscale-set.err" + "$TAILSCALE_BIN" status --json > "$enabled/tailscale-status.json" + deadline=$(( $(date +%s) + WAKE_TIMEOUT )) + until host_probe >/dev/null 2>&1 && container_probe >/dev/null 2>&1 \ + && docker_e version >/dev/null 2>&1; do + [ "$(date +%s)" -lt "$deadline" ] \ + || die "host/container/Docker did not survive exit-node activation in round $round" + sleep 2 + done + capture_contract "$enabled" + printf '%s\texit-node-active\tPASS\thost/container/API remained reachable\n' "$round" \ + >> "$WORKDIR/route-churn-results.tsv" + + "$TAILSCALE_BIN" set --exit-node= \ + > "$restored/tailscale-set.out" 2> "$restored/tailscale-set.err" + TAILSCALE_EXIT_NODE_ACTIVE=0 + deadline=$(( $(date +%s) + WAKE_TIMEOUT )) + until capture_contract "$restored" && compare_contract "$BASELINE" "$restored" \ + && host_probe >/dev/null 2>&1 && container_probe >/dev/null 2>&1 \ + && docker_e version >/dev/null 2>&1; do + [ "$(date +%s)" -lt "$deadline" ] \ + || die "host network contract did not self-heal after exit-node round $round" + sleep 2 + done + "$TAILSCALE_BIN" status --json > "$restored/tailscale-status.json" + printf '%s\tbaseline-restored\tPASS\troute/DNS/proxy contract and Docker recovered\n' \ + "$round" >> "$WORKDIR/route-churn-results.tsv" + round=$((round + 1)) + done +} + +if [ "$NO_SLEEP" = "0" ]; then + baseline_deadline=$(( $(date +%s) + WAKE_TIMEOUT )) + until host_probe >/dev/null 2>&1 && container_probe >/dev/null 2>&1 \ + && docker_e version >/dev/null 2>&1; do + [ "$(date +%s)" -lt "$baseline_deadline" ] \ + || die "host/container/Docker is not healthy before route-churn qualification" + sleep 2 + done +fi + +BASELINE="$WORKDIR/baseline" +capture_contract "$BASELINE" +wifi="$(cat "$BASELINE/wifi-interface")" +if [ "$REQUIRE_WIFI" = "1" ]; then + [ -n "$wifi" ] || die "no Wi-Fi hardware service found" + grep -Eqi ': On$' "$BASELINE/wifi-power" || die "Wi-Fi is not powered on" + ! grep -Eqi 'not associated' "$BASELINE/wifi-network" || die "Wi-Fi is not associated with a network" +fi +if [ "$REQUIRE_VPN" = 1 ]; then + vpn_detected "$BASELINE" || die "no active VPN-like interface is present" + custom_dns_active "$BASELINE" \ + || die "required custom DNS server is absent from the active macOS resolver contract" +fi +host_probe +container_probe +if [ "$NO_SLEEP" = "0" ]; then + run_route_churn +fi + +cycle=1 +while [ "$cycle" -le "$CYCLES" ]; do + engine_activity + before="$WORKDIR/cycle-$cycle-before" + after="$WORKDIR/cycle-$cycle-after" + capture_contract "$before" + compare_contract "$BASELINE" "$before" + if [ "$NO_SLEEP" = "0" ]; then + start_machine_session "$cycle" + printf '%s\tmachine-session-pre-sleep\tPASS\tinteractive shell ready token observed\n' \ + "$cycle" >> "$RESULTS" + printf '%s\tpre-sleep\tPASS\tcontract and probes healthy\n' "$cycle" >> "$RESULTS" + before_sleep_epoch="$(date +%s)" + sudo -n pmset relative wake "$AUTO_WAKE_SECONDS" + pmset -g sched > "$WORKDIR/cycle-$cycle-scheduled-wake.txt" + grep -Eqi 'wake' "$WORKDIR/cycle-$cycle-scheduled-wake.txt" \ + || die "relative hardware wake was not scheduled for cycle $cycle" + sudo -n pmset sleepnow + after_wake_epoch="$(date +%s)" + slept_seconds=$((after_wake_epoch - before_sleep_epoch)) + minimum_sleep=$((AUTO_WAKE_SECONDS / 2)) + [ "$minimum_sleep" -ge 5 ] || minimum_sleep=5 + [ "$slept_seconds" -ge "$minimum_sleep" ] \ + || die "physical sleep cycle $cycle resumed after only ${slept_seconds}s" + pmset -g log | tail -300 > "$WORKDIR/cycle-$cycle-pmset-log.txt" + printf '%s\tsleep-resume\tPASS\telapsed_seconds=%s scheduled_wake_seconds=%s\n' \ + "$cycle" "$slept_seconds" "$AUTO_WAKE_SECONDS" >> "$RESULTS" + verify_machine_reconnect "$cycle" + printf '%s\tmachine-session-reconnect\tPASS\tfresh exec, stop/start, and disk marker verified\n' \ + "$cycle" >> "$RESULTS" + fi + deadline=$(( $(date +%s) + WAKE_TIMEOUT )) + until host_probe >/dev/null 2>&1 && docker_e version >/dev/null 2>&1; do + [ "$(date +%s)" -lt "$deadline" ] || { + printf '%s\tpost-wake\tFAIL\tnetwork/API did not recover before deadline\n' "$cycle" >> "$RESULTS" + die "network/API did not recover within ${WAKE_TIMEOUT}s after cycle $cycle" + } + sleep 2 + done + host_probe + container_probe + capture_contract "$after" + compare_contract "$BASELINE" "$after" + printf '%s\t%s\tPASS\thost/container probes and network contract preserved\n' \ + "$cycle" "$([ "$NO_SLEEP" = "1" ] && echo preflight || echo post-wake)" >> "$RESULTS" + cycle=$((cycle + 1)) +done + +if [ "$NO_SLEEP" = "0" ]; then + terminate_machine_session + machine_ctl machine stop "$MACHINE" > "$WORKDIR/machine-final-stop.json" + machine_ctl machine delete "$MACHINE" > "$WORKDIR/machine-final-delete.json" + MACHINE_OWNED=0 + machine_ctl machine list > "$WORKDIR/machine-list-after.json" + jq -e --arg id "$MACHINE" 'all(.[]; .id != $id)' "$WORKDIR/machine-list-after.json" >/dev/null \ + || die "owned sleep-session machine survived deletion" +fi +cleanup +{ + echo "run_id=$RUN_ID" + echo "cycles=$CYCLES" + echo "auto_wake_seconds=$AUTO_WAKE_SECONDS" + echo "physical_sleep=$([ "$NO_SLEEP" = "0" ] && echo true || echo false)" + echo "wifi_required=$([ "$REQUIRE_WIFI" = "1" ] && echo true || echo false)" + echo "vpn_required=$([ "$REQUIRE_VPN" = "1" ] && echo true || echo false)" + echo "custom_dns_required=$([ -n "$CUSTOM_DNS" ] && echo true || echo false)" + echo "route_churn=$([ "$NO_SLEEP" = "0" ] && echo PASS || echo SKIP)" + echo "route_churn_rounds=$([ "$NO_SLEEP" = "0" ] && echo "$ROUTE_CHURN_ROUNDS" || echo 0)" + echo "release_qualifying=$([ "$NO_SLEEP" = "0" ] && [ "$REQUIRE_WIFI" = "1" ] && [ "$REQUIRE_VPN" = "1" ] && [ -n "$CUSTOM_DNS" ] && [ -n "$TAILSCALE_EXIT_NODE" ] && echo true || echo false)" + if [ "$NO_SLEEP" = "0" ]; then + echo "source_commit=$SOURCE_COMMIT" + echo "github_run_id=${GITHUB_RUN_ID:-manual}" + echo "github_run_attempt=${GITHUB_RUN_ATTEMPT:-1}" + echo "app_executable_sha256=$(shasum -a 256 "$APP/Contents/MacOS/Dory" | awk '{print $1}')" + echo "docker_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "doryd_sha256=$(shasum -a 256 "$APP/Contents/Helpers/doryd" | awk '{print $1}')" + echo "dory_hv_sha256=$(shasum -a 256 "$APP/Contents/Helpers/dory-hv" | awk '{print $1}')" + echo "dorydctl_sha256=$(shasum -a 256 "$MACHINE_CTL" | awk '{print $1}')" + echo "tailscale_cli_sha256=$(shasum -a 256 "$TAILSCALE_BIN" | awk '{print $1}')" + echo "machine_kernel_sha256=$(shasum -a 256 "$MACHINE_KERNEL" | awk '{print $1}')" + echo "machine_rootfs_sha256=$(shasum -a 256 "$MACHINE_ROOTFS" | awk '{print $1}')" + echo "machine_id=$MACHINE" + echo "machine_session_reconnect=PASS" + echo "custom_dns_sha256=$(printf '%s' "$CUSTOM_DNS" | shasum -a 256 | awk '{print $1}')" + echo "probe_host_sha256=$(printf '%s' "$PROBE_HOST" | shasum -a 256 | awk '{print $1}')" + echo "probe_url_sha256=$(printf '%s' "$PROBE_URL" | shasum -a 256 | awk '{print $1}')" + echo "tailscale_exit_node_sha256=$(printf '%s' "$TAILSCALE_EXIT_NODE" | shasum -a 256 | awk '{print $1}')" + fi +} > "$WORKDIR/manifest.txt" +echo "host network integrity gate PASS; evidence: $WORKDIR" From f4bbd04d73edbf7fb905191889c8f3d53c1f22fa Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:21:36 +0000 Subject: [PATCH 118/338] feat(release): qualify P0 candidate smoke --- scripts/p0-smoke.sh | 379 +++++++++++++++++++++++++++++++++++++++ scripts/test-p0-smoke.sh | 229 +++++++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100755 scripts/p0-smoke.sh create mode 100755 scripts/test-p0-smoke.sh diff --git a/scripts/p0-smoke.sh b/scripts/p0-smoke.sh new file mode 100755 index 00000000..6bf57d82 --- /dev/null +++ b/scripts/p0-smoke.sh @@ -0,0 +1,379 @@ +#!/bin/bash +# P0 release smoke for the Dory Docker-compatible surface. +# +# Requires a running Dory socket. This intentionally fails on any doctor/network/mount/Compose +# regression so it can gate a release candidate after the app bundle has been rebuilt/relaunched. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${DORY_P0_IMAGE:-}" + +resolve_docker_bin() { + local candidate resolved + if [ -n "${DORY_DOCKER_BIN:-}" ]; then + candidate="$DORY_DOCKER_BIN" + elif [ -n "${DORY_APP:-}" ]; then + candidate="${DORY_APP%/}/Contents/Helpers/docker" + if [ ! -x "$candidate" ]; then + echo "p0-smoke: DORY_APP has no executable bundled Docker CLI at $candidate" >&2 + return 1 + fi + printf '%s\n' "$candidate" + return 0 + else + candidate="docker" + fi + + if [[ "$candidate" == */* ]]; then + resolved="$candidate" + else + resolved="$(command -v "$candidate" 2>/dev/null || true)" + fi + if [ -z "$resolved" ] || [ ! -x "$resolved" ]; then + echo "p0-smoke: Docker CLI is not executable: $candidate" >&2 + return 1 + fi + printf '%s\n' "$resolved" +} + +resolve_dory_cli() { + local candidate="${DORY_CLI_BIN:-}" + if [ -z "$candidate" ] && [ -n "${DORY_APP:-}" ]; then + candidate="${DORY_APP%/}/Contents/Helpers/dory" + fi + [ -n "$candidate" ] || candidate="scripts/dory" + if [ ! -x "$candidate" ]; then + echo "p0-smoke: Dory CLI is not executable: $candidate" >&2 + return 1 + fi + printf '%s\n' "$candidate" +} + +resolve_dorydctl() { + local candidate="${DORYDCTL_BIN:-}" + if [ -z "$candidate" ] && [ -n "${DORY_APP:-}" ]; then + candidate="${DORY_APP%/}/Contents/Helpers/dorydctl" + fi + [ -n "$candidate" ] || candidate="$HOME/.dory/bin/dorydctl" + if [ ! -x "$candidate" ]; then + echo "p0-smoke: dorydctl is not executable: $candidate" >&2 + return 1 + fi + printf '%s\n' "$candidate" +} + +wake_engine_and_capture_status() { + local status_file="$1" dory_cli="${DORY_CLI_BIN:?DORY_CLI_BIN is required}" + "$dory_cli" engine wake >/dev/null + "$dory_cli" engine status --json > "$status_file" + if ! python3 - "$status_file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + status = json.load(handle) +if status.get("state") not in {"running", "awake"} and status.get("awake") is not True: + raise SystemExit(1) +PY + then + echo "p0-smoke: dory engine status did not report a running engine after wake" >&2 + return 1 + fi +} + +cleanup() { + if [ "${STOP_WAKE_STARTED:-0}" = "1" ] && [ -n "${DORY_CLI_BIN:-}" ]; then + "$DORY_CLI_BIN" engine wake >/dev/null 2>&1 || true + fi + if [ -n "${PERSISTENT_CONTAINER:-}" ]; then + docker_e rm -f "$PERSISTENT_CONTAINER" >/dev/null 2>&1 || true + fi + if [ -n "$PORT" ]; then + "$DOCKER_BIN" -H "unix://$DORY_SOCK" compose -p "$PROJECT" -f "$WORKDIR/compose.yaml" down -v --remove-orphans >/dev/null 2>&1 || true + fi + rm -rf "$WORKDIR" +} + +stop_wake_storage_smoke() { + local running created before after before_label after_label http_status response="$WORKDIR/system-df.json" + running="$(docker_e ps -q)" + if [ -n "$running" ]; then + echo "p0-smoke: refusing stop/wake gate while unrelated containers are running" >&2 + return 1 + fi + + PERSISTENT_CONTAINER="dory-p0-persist-$$" + created="$(docker_e create --name "$PERSISTENT_CONTAINER" \ + --label "dev.dory.p0-smoke=$PROJECT" \ + "$IMAGE" sh -c 'printf dory-persist >/marker')" + [ -n "$created" ] || { echo "p0-smoke: persistent container create returned no ID" >&2; return 1; } + before="$(docker_e inspect --format '{{.Id}}' "$PERSISTENT_CONTAINER")" + [ -n "$before" ] || { echo "p0-smoke: persistent container inspect returned no ID" >&2; return 1; } + before_label="$(docker_e inspect --format '{{ index .Config.Labels "dev.dory.p0-smoke" }}' "$PERSISTENT_CONTAINER")" + [ "$before_label" = "$PROJECT" ] \ + || { echo "p0-smoke: persistent container lost its ownership label before sleep" >&2; return 1; } + + STOP_WAKE_STARTED=1 + "$DORY_CLI_BIN" engine sleep --json > "$WORKDIR/engine-sleep.json" + wake_engine_and_capture_status "$WORKDIR/engine-wake.json" + STOP_WAKE_STARTED=0 + + after="$(docker_e inspect --format '{{.Id}}' "$PERSISTENT_CONTAINER")" + [ "$after" = "$before" ] || { + echo "p0-smoke: stopped container identity changed across engine sleep/wake" >&2 + return 1 + } + after_label="$(docker_e inspect --format '{{ index .Config.Labels "dev.dory.p0-smoke" }}' "$PERSISTENT_CONTAINER")" + [ "$after_label" = "$before_label" ] || { + echo "p0-smoke: stopped container ownership label changed across engine sleep/wake" >&2 + return 1 + } + + http_status="$(curl -sS --max-time 15 --unix-socket "$DORY_SOCK" \ + -o "$response" -w '%{http_code}' http://d/system/df)" + [ "$http_status" = "200" ] || { + echo "p0-smoke: Docker /system/df returned HTTP $http_status after stop/wake" >&2 + cat "$response" >&2 || true + return 1 + } + python3 - "$response" "$before" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) +if not isinstance(payload, dict): + raise SystemExit("/system/df did not return a JSON object") +containers = payload.get("Containers") +if not isinstance(containers, list): + raise SystemExit("/system/df did not return its Containers inventory") +expected = sys.argv[2] +if expected not in {item.get("Id") for item in containers if isinstance(item, dict)}: + raise SystemExit("/system/df omitted the stopped container preserved across sleep/wake") +PY + docker_e rm "$PERSISTENT_CONTAINER" >/dev/null + PERSISTENT_CONTAINER="" +} + +docker_e() { + "$DOCKER_BIN" -H "unix://$DORY_SOCK" "$@" +} + +compose_down() { + local output status=0 remaining + output="$(docker_e compose -p "$PROJECT" -f "$WORKDIR/compose.yaml" down -v --remove-orphans 2>&1)" || status=$? + if [ "$status" -eq 0 ]; then + [ -z "$output" ] || printf '%s\n' "$output" + return 0 + fi + remaining="$(docker_e ps -a --filter "label=com.docker.compose.project=$PROJECT" -q 2>/dev/null || true)" + if printf '%s\n' "$output" | grep -q 'parsing time ""' && [ -z "$remaining" ]; then + printf '%s\n' "$output" >&2 + echo "p0-smoke: tolerated Docker Compose empty-time parse bug after cleanup" >&2 + return 0 + fi + printf '%s\n' "$output" >&2 + return "$status" +} + +free_port() { + python3 - <<'PY' +import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +s.close() +PY +} + +wait_http() { + local url="$1" expected="$2" + for _ in $(seq 1 40); do + body="$(curl -fsS --max-time 2 "$url" 2>/dev/null || true)" + [ "$body" = "$expected" ] && return 0 + sleep 0.25 + done + echo "p0-smoke: timed out waiting for $url" >&2 + return 1 +} + +repair_subsystem() { + local target="$1" output + output="$WORKDIR/repair-$target.json" + "$DORYDCTL_BIN" network repair "$target" > "$output" + python3 - "$output" "$target" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) +if payload.get("ok") is not True or not payload.get("message"): + raise SystemExit(f"{sys.argv[2]} repair did not return a successful attributed result") +PY +} + +subsystem_recovery_smoke() { + local hv_log="$HOME/.dory/hv/dory-hv.log" before after=0 + for target in dns domains routes guest-agent docker-api; do + repair_subsystem "$target" + done + + before="$(grep -Fc 'manual port reconcile requested' "$hv_log" 2>/dev/null || true)" + repair_subsystem ports + for _ in $(seq 1 40); do + after="$(grep -Fc 'manual port reconcile requested' "$hv_log" 2>/dev/null || true)" + [ "$after" -gt "$before" ] && break + sleep 0.1 + done + [ "$after" -gt "$before" ] || { + echo "p0-smoke: dory-hv did not acknowledge the manual published-port reconciliation" >&2 + return 1 + } +} + +compatibility_smoke() { + local recipes="$WORKDIR/compat-recipes.json" results="$WORKDIR/compat-results.json" + "$DORY_DOCTOR_BIN" compat --recipe --json > "$recipes" + "$DORY_DOCTOR_BIN" compat --json > "$results" || true + python3 - "$recipes" "$results" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + recipe_payload = json.load(handle) +with open(sys.argv[2], encoding="utf-8") as handle: + result_payload = json.load(handle) + +required = { + "docker", "compose", "testcontainers", "act", "kubernetes", "vscode", "cursor", + "supabase", "localstack", "amd64", +} +tools = recipe_payload.get("tools") +if not isinstance(tools, dict) or set(tools) != required: + raise SystemExit("candidate compatibility recipes do not expose the exact supported tool set") +for name, recipe in tools.items(): + if not isinstance(recipe, dict): + raise SystemExit(f"{name}: compatibility recipe is not an object") + if not isinstance(recipe.get("title"), str) or not recipe["title"]: + raise SystemExit(f"{name}: compatibility recipe has no title") + if not isinstance(recipe.get("steps"), list) or not recipe["steps"]: + raise SystemExit(f"{name}: compatibility recipe has no steps") + if not isinstance(recipe.get("verify"), str) or not recipe["verify"]: + raise SystemExit(f"{name}: compatibility recipe has no verification command") + +results = result_payload.get("results") +if not isinstance(results, list) or not results: + raise SystemExit("candidate compatibility diagnostics returned no results") +checked = set() +for result in results: + if not isinstance(result, dict): + raise SystemExit("candidate compatibility result is not an object") + identifier = result.get("id") + if not isinstance(identifier, str) or not identifier.startswith("compat."): + raise SystemExit("candidate compatibility result has an invalid identifier") + parts = identifier.split(".") + if len(parts) < 2 or parts[1] not in required: + raise SystemExit(f"candidate compatibility result has an unknown identifier: {identifier}") + checked.add(parts[1]) + status = result.get("status") + if status not in {"pass", "warn", "fail", "skip"}: + raise SystemExit(f"{identifier}: invalid compatibility status") + if status in {"warn", "fail"} and not (result.get("action") or result.get("detail")): + raise SystemExit(f"{identifier}: non-pass compatibility result is not actionable") +if checked != required: + raise SystemExit(f"candidate compatibility diagnostics omitted: {sorted(required - checked)}") +PY +} + +main() { + cd "$ROOT" + case "${DORY_APP:-}" in + /*) ;; + *) echo "p0-smoke: DORY_APP must be the absolute exact candidate app" >&2; exit 1 ;; + esac + [ -d "$DORY_APP" ] && [ ! -L "$DORY_APP" ] \ + || { echo "p0-smoke: exact candidate app is unavailable or indirect: $DORY_APP" >&2; exit 1; } + for relative in Contents/Helpers/docker Contents/Helpers/dory Contents/Helpers/dorydctl \ + Contents/Helpers/dory-doctor; do + [ -f "$DORY_APP/$relative" ] && [ ! -L "$DORY_APP/$relative" ] \ + && [ -x "$DORY_APP/$relative" ] \ + || { echo "p0-smoke: candidate helper is unavailable or indirect: $relative" >&2; exit 1; } + done + printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || { echo "p0-smoke: DORY_P0_IMAGE must be digest-pinned" >&2; exit 1; } + DORY_SOCK="${DORY_SOCK:-$HOME/.dory/dory.sock}" + DOCKER_BIN="$(resolve_docker_bin)" + DORY_CLI_BIN="$(resolve_dory_cli)" + DORYDCTL_BIN="$(resolve_dorydctl)" + DORY_DOCTOR_BIN="${DORY_DOCTOR_BIN:-$DORY_APP/Contents/Helpers/dory-doctor}" + [ "$DOCKER_BIN" = "$DORY_APP/Contents/Helpers/docker" ] \ + || { echo "p0-smoke: Docker CLI is not the exact candidate helper" >&2; exit 1; } + [ "$DORY_CLI_BIN" = "$DORY_APP/Contents/Helpers/dory" ] \ + || { echo "p0-smoke: Dory CLI is not the exact candidate helper" >&2; exit 1; } + [ "$DORYDCTL_BIN" = "$DORY_APP/Contents/Helpers/dorydctl" ] \ + || { echo "p0-smoke: dorydctl is not the exact candidate helper" >&2; exit 1; } + [ "$DORY_DOCTOR_BIN" = "$DORY_APP/Contents/Helpers/dory-doctor" ] \ + || { echo "p0-smoke: dory-doctor is not the exact candidate helper" >&2; exit 1; } + # Keep every nested doctor/compatibility probe on the same release-candidate CLI. + export DORY_DOCKER_BIN="$DOCKER_BIN" DORY_CLI_BIN + export DORY_DOCTOR_BIN + PROJECT="dory-p0-smoke-$$" + WORKDIR="$(mktemp -d)" + PORT="" + PERSISTENT_CONTAINER="" + STOP_WAKE_STARTED=0 + trap cleanup EXIT + + [ -S "$DORY_SOCK" ] || { echo "p0-smoke: missing Dory socket at $DORY_SOCK" >&2; exit 1; } + [ "$(stat -f %u "$DORY_SOCK")" = "$(id -u)" ] \ + || { echo "p0-smoke: Dory socket is not owned by the release user" >&2; exit 1; } + docker_e image inspect "$IMAGE" >/dev/null 2>&1 \ + || { echo "p0-smoke: required offline fixture image is missing: $IMAGE" >&2; exit 1; } + + "$DORY_CLI_BIN" doctor --json --only socket,api,docker,context,disk,memory,helpers > "$WORKDIR/doctor.json" + "$DORY_CLI_BIN" network --active --json > "$WORKDIR/network.json" + "$DORY_CLI_BIN" mount --json > "$WORKDIR/mount.json" + + ENGINE_SOCK="${DORY_ENGINE_SOCK:-$HOME/.dory/engine.sock}" + if [ -S "$ENGINE_SOCK" ]; then + wake_engine_and_capture_status "$WORKDIR/engine-status.json" + fi + "$DORY_CLI_BIN" idle history --json > "$WORKDIR/idle-history.json" + python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$WORKDIR/idle-history.json" + "$DORY_CLI_BIN" idle status --json > "$WORKDIR/idle-status.json" 2>/dev/null || true + + compatibility_smoke + + docker_e buildx version >/dev/null + docker_e run --rm "$IMAGE" true + + PORT="$(free_port)" + cat > "$WORKDIR/compose.yaml" < "$WORKDIR/compose-ps.json" + compose_down + + if [ "${DORY_P0_STOP_WAKE:-0}" = "1" ]; then + stop_wake_storage_smoke + fi + + echo "p0-smoke: PASS" +} + +if [ "${DORY_P0_SMOKE_SOURCE_ONLY:-0}" != "1" ]; then + main "$@" +fi diff --git a/scripts/test-p0-smoke.sh b/scripts/test-p0-smoke.sh new file mode 100755 index 00000000..b586c61a --- /dev/null +++ b/scripts/test-p0-smoke.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# Offline regression tests for p0-smoke's release-candidate CLI selection and wake ordering. +set -euo pipefail +TEST_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$TEST_ROOT" + +DORY_P0_SMOKE_SOURCE_ONLY=1 +# shellcheck source=p0-smoke.sh +source scripts/p0-smoke.sh + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +PATH_BIN="$TMP_ROOT/path-bin" +APP="$TMP_ROOT/Dory Candidate.app" +EXPLICIT_DOCKER="$TMP_ROOT/explicit-docker" +mkdir -p "$PATH_BIN" "$APP/Contents/Helpers" + +for cli in "$PATH_BIN/docker" "$APP/Contents/Helpers/docker" "$APP/Contents/Helpers/dory" \ + "$APP/Contents/Helpers/dorydctl" "$EXPLICIT_DOCKER"; do + cat > "$cli" <<'SH' +#!/bin/sh +exit 0 +SH + chmod +x "$cli" +done + +assert_eq() { + local expected="$1" actual="$2" label="$3" + if [ "$actual" != "$expected" ]; then + echo "test-p0-smoke: $label: expected '$expected', got '$actual'" >&2 + exit 1 + fi +} + +resolved="$(DORY_DOCKER_BIN= DORY_APP= PATH="$PATH_BIN:/usr/bin:/bin" resolve_docker_bin)" +assert_eq "$PATH_BIN/docker" "$resolved" "PATH fallback" + +resolved="$(DORY_DOCKER_BIN= DORY_APP="$APP" PATH="$PATH_BIN:/usr/bin:/bin" resolve_docker_bin)" +assert_eq "$APP/Contents/Helpers/docker" "$resolved" "DORY_APP bundled CLI" + +resolved="$(DORY_DOCKER_BIN="$EXPLICIT_DOCKER" DORY_APP="$APP" PATH="$PATH_BIN:/usr/bin:/bin" resolve_docker_bin)" +assert_eq "$EXPLICIT_DOCKER" "$resolved" "explicit DORY_DOCKER_BIN precedence" + +MISSING_APP="$TMP_ROOT/Missing.app" +mkdir -p "$MISSING_APP/Contents/Helpers" +if DORY_DOCKER_BIN= DORY_APP="$MISSING_APP" PATH="$PATH_BIN:/usr/bin:/bin" resolve_docker_bin > /dev/null 2> "$TMP_ROOT/missing.err"; then + echo "test-p0-smoke: DORY_APP without a bundled Docker CLI unexpectedly fell back to PATH" >&2 + exit 1 +fi +grep -q 'has no executable bundled Docker CLI' "$TMP_ROOT/missing.err" + +resolved="$(DORY_CLI_BIN= DORY_APP="$APP" resolve_dory_cli)" +assert_eq "$APP/Contents/Helpers/dory" "$resolved" "DORY_APP bundled Dory CLI" +if DORY_CLI_BIN= DORY_APP="$MISSING_APP" resolve_dory_cli > /dev/null 2> "$TMP_ROOT/missing-dory.err"; then + echo "test-p0-smoke: DORY_APP without a bundled Dory CLI unexpectedly used the source CLI" >&2 + exit 1 +fi +grep -q 'Dory CLI is not executable' "$TMP_ROOT/missing-dory.err" + +resolved="$(DORYDCTL_BIN= DORY_APP="$APP" resolve_dorydctl)" +assert_eq "$APP/Contents/Helpers/dorydctl" "$resolved" "DORY_APP bundled dorydctl" +if DORYDCTL_BIN= DORY_APP="$MISSING_APP" resolve_dorydctl > /dev/null 2> "$TMP_ROOT/missing-dorydctl.err"; then + echo "test-p0-smoke: DORY_APP without bundled dorydctl unexpectedly used another binary" >&2 + exit 1 +fi +grep -q 'dorydctl is not executable' "$TMP_ROOT/missing-dorydctl.err" + +WAKE_STATE="$TMP_ROOT/wake-state" +WAKE_LOG="$TMP_ROOT/wake.log" +FAKE_DORY="$TMP_ROOT/fake-dory" +cat > "$FAKE_DORY" <<'SH' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$*" >> "$DORY_FAKE_WAKE_LOG" +case "$*" in + 'engine wake') + : > "$DORY_FAKE_WAKE_STATE" + printf '{"ok":true}\n' + ;; + 'engine sleep --json') + printf '{"ok":true,"message":"sleep requested"}\n' + ;; + 'engine status --json') + if [ -f "$DORY_FAKE_WAKE_STATE" ]; then + printf '{"state":"running"}\n' + else + printf '{"state":"sleeping"}\n' + fi + ;; + *) exit 64 ;; +esac +SH +chmod +x "$FAKE_DORY" + +DORY_CLI_BIN="$FAKE_DORY" \ + DORY_FAKE_WAKE_STATE="$WAKE_STATE" \ + DORY_FAKE_WAKE_LOG="$WAKE_LOG" \ + wake_engine_and_capture_status "$TMP_ROOT/engine-status.json" + +expected_calls="$(printf 'engine wake\nengine status --json')" +assert_eq "$expected_calls" "$(cat "$WAKE_LOG")" "wake/status order" +python3 - "$TMP_ROOT/engine-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + state = json.load(handle).get("state") +if state != "running": + raise SystemExit(f"unexpected engine state: {state!r}") +PY + +# Exercise the destructive release-only tier entirely against fakes: it must preserve the stopped +# container identity, perform sleep before wake, and demand a healthy /system/df response. +WORKDIR="$TMP_ROOT/stop-wake" +mkdir -p "$WORKDIR" +DORY_CLI_BIN="$FAKE_DORY" +DORY_FAKE_WAKE_STATE="$WAKE_STATE" +DORY_FAKE_WAKE_LOG="$WAKE_LOG" +DORY_SOCK="$TMP_ROOT/dory.sock" +PROJECT="dory-p0-smoke-fixture" +PERSISTENT_CONTAINER="" +STOP_WAKE_STARTED=0 +export DORY_FAKE_WAKE_STATE DORY_FAKE_WAKE_LOG +: > "$WAKE_LOG" + +docker_e() { + case "$*" in + 'ps -q') return 0 ;; + create\ --name\ dory-p0-persist-* ) printf '%s\n' fixture-container-id ;; + "inspect --format {{.Id}} dory-p0-persist-"*) printf '%s\n' fixture-container-id ;; + "inspect --format {{ index .Config.Labels \"dev.dory.p0-smoke\" }} dory-p0-persist-"*) printf '%s\n' "$PROJECT" ;; + "rm dory-p0-persist-"*) return 0 ;; + *) echo "unexpected fake docker call: $*" >&2; return 1 ;; + esac +} +curl() { + local output="" previous="" + for argument in "$@"; do + if [ "$previous" = "-o" ]; then output="$argument"; fi + previous="$argument" + done + printf '{"Containers":[{"Id":"fixture-container-id"}]}\n' > "$output" + printf '200' +} + +stop_wake_storage_smoke +expected_calls="$(printf 'engine sleep --json\nengine wake\nengine status --json')" +assert_eq "$expected_calls" "$(cat "$WAKE_LOG")" "release stop/wake order" +[ -z "$PERSISTENT_CONTAINER" ] || { echo "test-p0-smoke: persistent fixture was not cleaned" >&2; exit 1; } + +# Every recovery command must return an attributed success, and the port request must be observed +# by dory-hv rather than merely accepted by doryd. +RECOVERY_HOME="$TMP_ROOT/recovery-home" +WORKDIR="$TMP_ROOT/recovery-work" +DORYDCTL_BIN="$TMP_ROOT/fake-dorydctl" +mkdir -p "$RECOVERY_HOME/.dory/hv" "$WORKDIR" +: > "$RECOVERY_HOME/.dory/hv/dory-hv.log" +cat > "$DORYDCTL_BIN" <<'SH' +#!/bin/bash +set -euo pipefail +target="${3:?missing repair target}" +if [ "$target" = ports ]; then + printf 'manual port reconcile requested\n' >> "$HOME/.dory/hv/dory-hv.log" +fi +printf '{"ok":true,"message":"repaired %s"}\n' "$target" +SH +chmod +x "$DORYDCTL_BIN" +HOME="$RECOVERY_HOME" subsystem_recovery_smoke +for target in dns domains routes guest-agent docker-api ports; do + [ -s "$WORKDIR/repair-$target.json" ] \ + || { echo "test-p0-smoke: missing $target recovery evidence" >&2; exit 1; } +done + +# Candidate compatibility output is validated as a closed contract rather than merely parsed. +COMPAT_WORKDIR="$TMP_ROOT/compat-work" +FAKE_DOCTOR="$TMP_ROOT/fake-dory-doctor" +mkdir -p "$COMPAT_WORKDIR" +cat > "$FAKE_DOCTOR" <<'PY' +#!/usr/bin/env python3 +import json +import os +import sys + +required = [ + "docker", "compose", "testcontainers", "act", "kubernetes", "vscode", "cursor", + "supabase", "localstack", "amd64", +] +if sys.argv[1:] == ["compat", "--recipe", "--json"]: + tools = { + name: {"title": name, "steps": ["configure"], "verify": f"verify {name}"} + for name in required + } + if os.environ.get("DORY_FAKE_COMPAT_INVALID") == "1": + tools.pop("docker") + print(json.dumps({"tools": tools})) +elif sys.argv[1:] == ["compat", "--json"]: + print(json.dumps({"results": [ + {"id": f"compat.{name}", "status": "pass", "detail": "ok"} + for name in required + ]})) +else: + raise SystemExit(64) +PY +chmod +x "$FAKE_DOCTOR" +WORKDIR="$COMPAT_WORKDIR" DORY_DOCTOR_BIN="$FAKE_DOCTOR" compatibility_smoke +if WORKDIR="$COMPAT_WORKDIR" DORY_DOCTOR_BIN="$FAKE_DOCTOR" \ + DORY_FAKE_COMPAT_INVALID=1 compatibility_smoke \ + > "$TMP_ROOT/invalid-compat.out" 2> "$TMP_ROOT/invalid-compat.err"; then + echo "test-p0-smoke: malformed compatibility recipes unexpectedly passed" >&2 + exit 1 +fi +grep -q 'exact supported tool set' "$TMP_ROOT/invalid-compat.err" + +gate_text="$(cat scripts/p0-smoke.sh)" +for required in \ + 'DORY_APP must be the absolute exact candidate app' \ + 'DORY_P0_IMAGE must be digest-pinned' \ + 'Dory socket is not owned by the release user' \ + 'compatibility_smoke' \ + 'candidate compatibility diagnostics omitted'; do + grep -Fq "$required" <<< "$gate_text" \ + || { echo "test-p0-smoke: missing contract proof: $required" >&2; exit 1; } +done +if grep -Eq 'alpine:latest|scripts/(test-dory-doctor|compat-smoke)\.sh|assert ' <<< "$gate_text"; then + echo "test-p0-smoke: gate retains a mutable fixture, ignored dependency, or Python assert" >&2 + exit 1 +fi + +echo "test-p0-smoke: PASS" From 41980f13ef459b08bb0a519050f5e23ae914aa76 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:27:45 +0000 Subject: [PATCH 119/338] feat(release): qualify engine readiness --- .github/scripts/test-readiness-gate.py | 120 ++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/readiness.sh | 1646 ++++++++++++++++++++++++ 4 files changed, 1768 insertions(+) create mode 100644 .github/scripts/test-readiness-gate.py create mode 100755 scripts/readiness.sh diff --git a/.github/scripts/test-readiness-gate.py b/.github/scripts/test-readiness-gate.py new file mode 100644 index 00000000..a4d09588 --- /dev/null +++ b/.github/scripts/test-readiness-gate.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the cross-engine readiness gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "readiness.sh" +DIGEST_IMAGE = "example.invalid/fixture@sha256:" + "a" * 64 + + +class ReadinessGateTests(unittest.TestCase): + def test_release_contract_is_fail_closed_and_reproducible(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "strict readiness requires READINESS_DOCKER_BIN for the exact candidate CLI", + "strict readiness Docker CLI is unavailable or indirect", + "readiness fixture images must be exact digest references", + "READINESS_NONNATIVE_BUILD_IMAGE must be an exact digest reference", + "READINESS_WORKDIR must not be a symlink", + "physical Intel qualification must target exactly the Dory engine", + "independently confirmed native Intel host facts", + "READINESS_STOP_ORBSTACK_CONFIRMED=STOP-ORBSTACK-FOR-READINESS", + 'stat -f %u "$ENGINE_SOCK"', + 'docker_e image inspect "$ALPINE_IMAGE"', + "container lifecycle + logs + exec + stats", + "BuildKit npm ci + build + test", + "memory/cpu resource limits + update", + "same-host competitor correctness gate", + "json.dumps(payload, indent=2, sort_keys=True)", + ): + self.assertIn(proof, text, proof) + for stale in ( + "alpine:latest", + "nginx:alpine", + "node:20-alpine", + "FROM ubuntu:24.04", + "docker_e pull", + ): + self.assertNotIn(stale, text, stale) + + def test_source_mode_defines_helpers_without_creating_evidence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + evidence = pathlib.Path(temporary) / "must-not-exist" + summary = pathlib.Path(temporary) / "summary.json" + command = f''' +set -euo pipefail +READINESS_SOURCE_ONLY=1 READINESS_WORKDIR={str(evidence)!r} source {str(GATE)!r} +test "$(binfmt_handler_for_arch amd64)" = FEX-x86_64 +test "$(binfmt_handler_for_arch arm64)" = qemu-aarch64 +test ! -e {str(evidence)!r} +SUMMARY_JSON={str(summary)!r} +RESULTS={str(pathlib.Path(temporary) / 'results.tsv')!r} +MEMORY_RESULTS={str(pathlib.Path(temporary) / 'memory.tsv')!r} +RUN_ID='quoted"run' +ENGINES='dory,"other' +write_summary +''' + subprocess.run(["bash", "-c", command], cwd=ROOT, check=True) + payload = json.loads(summary.read_text(encoding="utf-8")) + self.assertEqual(payload["runId"], 'quoted"run') + self.assertEqual(payload["engines"], 'dory,"other') + + def test_mutable_fixture_fails_before_docker_or_socket_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + env = { + **os.environ, + "HOME": temporary, + "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), + "READINESS_DOCKER_BIN": "/usr/bin/true", + "READINESS_ALPINE_IMAGE": "alpine:latest", + "READINESS_NGINX_IMAGE": DIGEST_IMAGE, + "RUN_NONNATIVE_ARCH": "0", + } + result = subprocess.run( + [str(GATE), "--engines", "dory"], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("fixture images must be exact digest references", result.stderr) + + def test_strict_mode_requires_an_explicit_candidate_cli(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + env = { + **os.environ, + "HOME": temporary, + "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), + "READINESS_DOCKER_BIN": "", + "READINESS_ALPINE_IMAGE": DIGEST_IMAGE, + "READINESS_NGINX_IMAGE": DIGEST_IMAGE, + "RUN_NONNATIVE_ARCH": "0", + } + result = subprocess.run( + [str(GATE), "--engines", "dory", "--strict"], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("strict readiness requires READINESS_DOCKER_BIN", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43215f24..01cb4087 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -345,6 +345,7 @@ jobs: python3 .github/scripts/test-nonnative-build-smoke.py python3 .github/scripts/test-external-volume-bind-gate.py python3 .github/scripts/test-host-network-integrity-gate.py + python3 .github/scripts/test-readiness-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 416bdbb8..0b1ef1d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,7 @@ jobs: python3 .github/scripts/test-nonnative-build-smoke.py python3 .github/scripts/test-external-volume-bind-gate.py python3 .github/scripts/test-host-network-integrity-gate.py + python3 .github/scripts/test-readiness-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/readiness.sh b/scripts/readiness.sh new file mode 100755 index 00000000..fc45c009 --- /dev/null +++ b/scripts/readiness.sh @@ -0,0 +1,1646 @@ +#!/bin/bash +# End-to-end replacement readiness checks for Dory, OrbStack, and Docker Desktop. +# +# The suite drives the public Docker CLI against each engine socket. It labels and names every +# resource it creates, then removes only those resources during cleanup. +# +# Examples: +# scripts/readiness.sh --engines dory +# scripts/readiness.sh --engines doryd +# scripts/readiness.sh --engines orbstack,dory --memory-count 5 +# RUN_DOMAINS=1 RUN_NONNATIVE_ARCH=1 scripts/readiness.sh --engines dory,orbstack +# +# Environment knobs: +# DORY_SOCK, DORYD_SOCK, ORBSTACK_SOCK, DOCKER_DESKTOP_SOCK +# READINESS_WORKDIR, READINESS_SETTLE, READINESS_DOCKER_BIN +# READINESS_ALPINE_IMAGE, READINESS_NGINX_IMAGE (exact digest references) +# READINESS_BUILD_CONTEXT_MB for the BuildKit streaming build payload (default: 32) +# READINESS_NONNATIVE_BUILD_IMAGE for the non-native BuildKit qemu/npm probe (exact digest) +# RUN_MEMORY=0|1, RUN_NONNATIVE_ARCH=0|1, RUN_AMD64=0|1 (legacy alias), RUN_ONLINE=0|1, RUN_DOMAINS=0|1, RUN_DIRECT_IP=0|1, RUN_FILE_WATCH=0|1, RUN_K8S=0|1, RUN_MACHINES=0|1, RUN_MACHINE_RECIPE=0|1, RUN_USB=0|1, RUN_VPN=0|1 +# DORY_DIRECT_IP_INTERFACE_FILE points at the helper-written utun interface file +# READINESS_FILE_WATCH_IMAGE for --file-watch (default: alpine image) +# READINESS_MACHINE_RECIPE, READINESS_MACHINE_RECIPE_COMMAND for --machine-recipe +# RUN_DEBUG_SHELL=0|1 (unsupported; an enabled probe fails in strict mode) +# RUN_CLOCK_SYNC=0|1, DORY_CLOCK_SYNC_TOLERANCE_MS +# RUN_GUEST_AGENT=0|1, DORY_HV_BIN, DORY_GUEST_KERNEL, DORY_GUEST_INITFS +# DORYD_CTL, DORYD_MACHINE_KERNEL, DORYD_MACHINE_ROOTFS for doryd per-VM machine checks +# RUN_USB=1 records unsupported until the agent has a vhci attach RPC (fails in strict mode) +# DORY_REQUIRE_VPN=1 makes --vpn fail when no active VPN-like interface or route is detected +# READINESS_STRICT=1 turns unavailable requested engines/probes into failures +# READINESS_REQUIRE_PHYSICAL_INTEL=1 requires this run to execute on a physical Intel Mac +# READINESS_REQUIRE_COMPETITOR=1 requires OrbStack or Docker Desktop in --engines +# STOP_ORBSTACK=1 to quit OrbStack before running Dory-only checks +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENGINES="${ENGINES:-dory}" +ALPINE_IMAGE="${READINESS_ALPINE_IMAGE:-}" +NGINX_IMAGE="${READINESS_NGINX_IMAGE:-}" +NONNATIVE_BUILD_IMAGE="${READINESS_NONNATIVE_BUILD_IMAGE:-}" +DOCKER_BIN="${READINESS_DOCKER_BIN:-}" +MEMORY_COUNT="${READINESS_MEMORY_COUNT:-3}" +BUILD_CONTEXT_MB="${READINESS_BUILD_CONTEXT_MB:-32}" +SETTLE="${READINESS_SETTLE:-8}" +RUN_MEMORY="${RUN_MEMORY:-1}" +RUN_NONNATIVE_ARCH="${RUN_NONNATIVE_ARCH:-${RUN_AMD64:-1}}" +RUN_ONLINE="${RUN_ONLINE:-0}" +RUN_DOMAINS="${RUN_DOMAINS:-0}" +RUN_DIRECT_IP="${RUN_DIRECT_IP:-0}" +RUN_FILE_WATCH="${RUN_FILE_WATCH:-0}" +RUN_K8S="${RUN_K8S:-0}" +RUN_MACHINES="${RUN_MACHINES:-0}" +RUN_MACHINE_RECIPE="${RUN_MACHINE_RECIPE:-0}" +RUN_BRIDGE="${RUN_BRIDGE:-0}" +RUN_GUEST_AGENT="${RUN_GUEST_AGENT:-0}" +RUN_DAX="${RUN_DAX:-0}" +RUN_ROSETTA="${RUN_ROSETTA:-0}" +RUN_USB="${RUN_USB:-0}" +RUN_VPN="${RUN_VPN:-0}" +RUN_DEBUG_SHELL="${RUN_DEBUG_SHELL:-0}" +RUN_CLOCK_SYNC="${RUN_CLOCK_SYNC:-0}" +CLOCK_SYNC_TOLERANCE_MS="${DORY_CLOCK_SYNC_TOLERANCE_MS:-100}" +STOP_ORBSTACK="${STOP_ORBSTACK:-0}" +STRICT="${READINESS_STRICT:-0}" +REQUIRE_PHYSICAL_INTEL="${READINESS_REQUIRE_PHYSICAL_INTEL:-0}" +REQUIRE_COMPETITOR="${READINESS_REQUIRE_COMPETITOR:-0}" +PHYSICAL_INTEL_CONFIRMED="${READINESS_PHYSICAL_INTEL_CONFIRMED:-0}" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_SLUG="$(printf '%s' "$RUN_ID" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]_.-')" +WORKROOT="${READINESS_WORKDIR:-$HOME/.dory-readiness}" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/results.tsv" +MEMORY_RESULTS="$WORKDIR/memory.tsv" +SUMMARY_JSON="$WORKDIR/summary.json" +LABEL_KEY="dev.dory.readiness" +CASE_ID=0 +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 +REQUIRED_UNAVAILABLE_COUNT=0 +CURRENT_ENGINE="" +ENGINE_SOCK="" +ENGINE_ID="" +PREFIX="" + +usage() { + cat <&2; exit 2; } +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --engines) need_value "$1" "$#"; ENGINES="$2"; shift 2 ;; + --memory-count) need_value "$1" "$#"; MEMORY_COUNT="$2"; shift 2 ;; + --settle) need_value "$1" "$#"; SETTLE="$2"; shift 2 ;; + --skip-memory) RUN_MEMORY=0; shift ;; + --skip-nonnative-arch|--skip-amd64) RUN_NONNATIVE_ARCH=0; shift ;; + --online) RUN_ONLINE=1; shift ;; + --domains) RUN_DOMAINS=1; shift ;; + --direct-ip) RUN_DIRECT_IP=1; shift ;; + --file-watch) RUN_FILE_WATCH=1; shift ;; + --k8s) RUN_K8S=1; shift ;; + --machines) RUN_MACHINES=1; shift ;; + --machine-recipe) RUN_MACHINE_RECIPE=1; shift ;; + --bridge) RUN_BRIDGE=1; shift ;; + --guest-agent) RUN_GUEST_AGENT=1; shift ;; + --dax) RUN_DAX=1; shift ;; + --rosetta) RUN_ROSETTA=1; shift ;; + --usb) RUN_USB=1; shift ;; + --vpn) RUN_VPN=1; shift ;; + --debug-shell) RUN_DEBUG_SHELL=1; shift ;; + --clock-sync) RUN_CLOCK_SYNC=1; shift ;; + --stop-orbstack) STOP_ORBSTACK=1; shift ;; + --strict) STRICT=1; shift ;; + --require-physical-intel) REQUIRE_PHYSICAL_INTEL=1; STRICT=1; shift ;; + --require-competitor) REQUIRE_COMPETITOR=1; STRICT=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown option: $1" >&2; usage; exit 2 ;; + esac +done + +for value_name in STRICT REQUIRE_PHYSICAL_INTEL REQUIRE_COMPETITOR PHYSICAL_INTEL_CONFIRMED; do + value="${!value_name}" + case "$value" in + 0|1) ;; + *) echo "$value_name must be 0 or 1" >&2; exit 2 ;; + esac +done +if [ "$REQUIRE_PHYSICAL_INTEL" = "1" ] || [ "$REQUIRE_COMPETITOR" = "1" ]; then + STRICT=1 +fi + +note() { + printf '==> %s\n' "$*" +} + +sanitize() { + printf '%s' "$*" | tr '\n\t' ' ' | sed 's/ */ /g' | cut -c 1-500 +} + +record() { + local status="$1" engine="$2" test_name="$3" detail="$4" + printf '%s\t%s\t%s\t%s\n' "$status" "$engine" "$test_name" "$(sanitize "$detail")" >> "$RESULTS" + case "$status" in + PASS) PASS_COUNT=$((PASS_COUNT + 1)); printf ' [PASS] %s\n' "$test_name" ;; + FAIL) FAIL_COUNT=$((FAIL_COUNT + 1)); printf ' [FAIL] %s -- %s\n' "$test_name" "$(sanitize "$detail")" ;; + SKIP) SKIP_COUNT=$((SKIP_COUNT + 1)); printf ' [SKIP] %s -- %s\n' "$test_name" "$(sanitize "$detail")" ;; + esac +} + +run_case() { + local engine="$1" test_name="$2" + shift 2 + CASE_ID=$((CASE_ID + 1)) + local out="$WORKDIR/${ENGINE_ID:-$(engine_id "$engine")}-${CASE_ID}.log" + if ( set -e; "$@" ) > "$out" 2>&1; then + record PASS "$engine" "$test_name" "ok" + else + local rc=$? + record FAIL "$engine" "$test_name" "exit=$rc $(tail -20 "$out" 2>/dev/null)" + fi +} + +skip_case() { + record SKIP "$1" "$2" "$3" +} + +# An engine or probe selected by the caller is a requirement, not an optional omission. Keep the +# legacy exploratory mode useful by recording a skip there, but make release/CI runs fail closed. +required_unavailable_case() { + local engine="$1" test_name="$2" detail="$3" + REQUIRED_UNAVAILABLE_COUNT=$((REQUIRED_UNAVAILABLE_COUNT + 1)) + if [ "$STRICT" = "1" ]; then + record FAIL "$engine" "$test_name" "STRICT REQUIRED — $detail" + else + record SKIP "$engine" "$test_name" "REQUIRED BUT UNAVAILABLE — $detail (rerun with --strict for a failing gate)" + fi +} + +engine_list_has_competitor() { + local engine normalized old_ifs="$IFS" + IFS=',' + for engine in $ENGINES; do + IFS="$old_ifs" + normalized="$(printf '%s' "$engine" | sed 's/^ *//;s/ *$//')" + case "$normalized" in + orbstack|docker-desktop|desktop) IFS="$old_ifs"; return 0 ;; + esac + IFS=',' + done + IFS="$old_ifs" + return 1 +} + +physical_intel_host_confirmed() { + local translated nested arm64_capable + [ "$(host_guest_arch)" = "amd64" ] || return 1 + [ "$PHYSICAL_INTEL_CONFIRMED" = "1" ] || return 1 + translated="$(sysctl -in sysctl.proc_translated 2>/dev/null || printf '0')" + nested="$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf '0')" + arm64_capable="$(sysctl -in hw.optional.arm64 2>/dev/null || printf '0')" + [ "$translated" != "1" ] && [ "$nested" != "1" ] && [ "$arm64_capable" != "1" ] +} + +record_external_coverage() { + local competitor_state + if physical_intel_host_confirmed; then + record PASS "coverage" "physical Intel hardware gate" "confirmed physical Intel host" + elif [ "$REQUIRE_PHYSICAL_INTEL" = "1" ]; then + required_unavailable_case "coverage" "physical Intel hardware gate" \ + "EXTERNAL GATE NOT COVERED — requires a confirmed physical Intel Mac; emulation/Rosetta is not evidence" + else + skip_case "coverage" "physical Intel hardware gate" \ + "EXTERNAL GATE NOT COVERED — requires READINESS_PHYSICAL_INTEL_CONFIRMED=1 on a physical Intel Mac" + fi + + if engine_list_has_competitor; then + competitor_state="$(awk -F '\t' ' + $2 ~ /^(orbstack|docker-desktop|desktop)$/ { + seen = 1 + if ($1 == "PASS") passed = 1 + if ($1 == "FAIL") failed = 1 + if ($1 == "SKIP" && $3 ~ /^all /) unavailable = 1 + } + END { + if (seen && passed && !failed && !unavailable) print "pass" + else print "incomplete" + } + ' "$RESULTS")" + if [ "$competitor_state" = "pass" ]; then + record PASS "coverage" "same-host competitor correctness gate" \ + "competitor engine completed its enabled correctness checks (not a performance claim)" + elif [ "$STRICT" = "1" ]; then + record FAIL "coverage" "same-host competitor correctness gate" \ + "EXTERNAL GATE INCOMPLETE — inspect the competitor engine FAIL/required-SKIP rows" + else + skip_case "coverage" "same-host competitor correctness gate" \ + "EXTERNAL GATE INCOMPLETE — competitor was requested but did not complete its enabled checks" + fi + elif [ "$REQUIRE_COMPETITOR" = "1" ]; then + required_unavailable_case "coverage" "same-host competitor correctness gate" \ + "EXTERNAL GATE NOT COVERED — add orbstack or docker-desktop to --engines" + else + skip_case "coverage" "same-host competitor correctness gate" \ + "EXTERNAL GATE NOT COVERED — this run includes no OrbStack or Docker Desktop engine" + fi +} + +mb() { + awk -v b="${1:-0}" 'BEGIN { printf "%.0f", b / 1048576 }' +} + +host_guest_arch() { + [ "$(uname -m)" = "x86_64" ] && printf '%s\n' "amd64" || printf '%s\n' "arm64" +} + +nonnative_guest_arch() { + [ "$(host_guest_arch)" = "amd64" ] && printf '%s\n' "arm64" || printf '%s\n' "amd64" +} + +binfmt_handler_for_arch() { + case "$1" in + amd64) printf '%s\n' "FEX-x86_64" ;; + arm64) printf '%s\n' "qemu-aarch64" ;; + *) echo "unsupported arch: $1" >&2; return 2 ;; + esac +} + +uname_pattern_for_arch() { + case "$1" in + amd64) printf '%s\n' 'x86_64|amd64' ;; + arm64) printf '%s\n' 'aarch64|arm64' ;; + *) echo "unsupported arch: $1" >&2; return 2 ;; + esac +} + +used_mem() { + vm_stat | awk ' + /page size of/ { for (i=1;i<=NF;i++) if ($i+0>0) ps=$i } + /Pages active/ { gsub(/\./,"",$3); a=$3 } + /Pages wired down/ { gsub(/\./,"",$4); w=$4 } + /Pages occupied by compressor/ { gsub(/\./,"",$5); c=$5 } + END { printf "%.0f", (a+w+c)*ps }' +} + +process_rss_bytes() { + local engine="$1" pattern + case "$engine" in + dory|doryd) pattern="${DORY_PROCESS_PATTERN:-Dory|doryd|dory-hv|dory-vmm|gvproxy}" ;; + orbstack) pattern="${ORBSTACK_PROCESS_PATTERN:-OrbStack}" ;; + docker-desktop|desktop) pattern="${DOCKER_DESKTOP_PROCESS_PATTERN:-Docker|com.docker}" ;; + *) pattern="${GENERIC_ENGINE_PROCESS_PATTERN:-$engine}" ;; + esac + ps -axo rss,args | awk -v pat="$pattern" '$0 ~ pat && $0 !~ /awk/ { sum += $1 } END { printf "%.0f", sum * 1024 }' +} + +engine_id() { + local engine="$1" base + base="$(basename "$engine" 2>/dev/null | sed 's/\.sock$//')" + [ -n "$base" ] || base="$engine" + printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' +} + +engine_socket() { + local engine="$1" + case "$engine" in + dory) echo "${DORY_SOCK:-$HOME/.dory/dory.sock}" ;; + doryd) echo "${DORYD_SOCK:-${DORY_SOCK:-$HOME/.dory/dory.sock}}" ;; + orbstack) echo "${ORBSTACK_SOCK:-$HOME/.orbstack/run/docker.sock}" ;; + docker-desktop|desktop) echo "${DOCKER_DESKTOP_SOCK:-$HOME/.docker/run/docker.sock}" ;; + current) + "$DOCKER_BIN" context inspect "$("$DOCKER_BIN" context show)" \ + --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null | sed 's#^unix://##' + ;; + *) + echo "$engine" + ;; + esac +} + +docker_e() { + "$DOCKER_BIN" -H "unix://$ENGINE_SOCK" "$@" +} + +dorydctl() { + local arch cand + if [ -n "${DORYD_CTL:-}" ]; then + "$DORYD_CTL" "$@" + return + fi + arch="$(uname -m)" + for cand in \ + "$ROOT/dory-core-swift/.build/debug/dorydctl" \ + "$ROOT/dory-core-swift/.build/release/dorydctl" \ + "$ROOT/dory-core-swift/.build/$arch-apple-macosx/debug/dorydctl" \ + "$ROOT/dory-core-swift/.build/$arch-apple-macosx/release/dorydctl"; do + [ -x "$cand" ] && { "$cand" "$@"; return; } + done + [ -f "$ROOT/dory-core-swift/Package.swift" ] || { + echo "dorydctl not found; set DORYD_CTL or build dory-core-swift" >&2 + return 1 + } + swift run --package-path "$ROOT/dory-core-swift" dorydctl "$@" +} + +json_get() { + local path="$1" + python3 -c ' +import json +import sys + +path = sys.argv[1].split(".") +value = json.load(sys.stdin) +for part in path: + if isinstance(value, dict): + value = value.get(part) + else: + value = None + if value is None: + break +if value is None: + print("") +elif isinstance(value, bool): + print("true" if value else "false") +else: + print(value) +' "$path" +} + +compose_e() { + DOCKER_HOST="unix://$ENGINE_SOCK" "$DOCKER_BIN" compose "$@" +} + +first_host_port() { + awk -F: ' + NF { + port = $NF + gsub(/[^0-9].*/, "", port) + if (port ~ /^[0-9]+$/) { + print port + exit + } + } + ' +} + +wait_for_engine_ready() { + local timeout="${READINESS_READY_TIMEOUT:-90}" + local i + for i in $(seq 1 "$timeout"); do + docker_e version >/dev/null 2>&1 && return 0 + sleep 1 + done + docker_e version >/dev/null +} + +is_dory_engine() { + [ "$CURRENT_ENGINE" = "dory" ] || [ "$CURRENT_ENGINE" = "doryd" ] +} + +cleanup_engine() { + [ -n "${ENGINE_SOCK:-}" ] || return 0 + docker_e ps -aq --filter "label=$LABEL_KEY=$RUN_ID" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f "$id" >/dev/null 2>&1 + done + docker_e network ls -q --filter "label=$LABEL_KEY=$RUN_ID" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e network rm "$id" >/dev/null 2>&1 + done + docker_e volume ls -q --filter "label=$LABEL_KEY=$RUN_ID" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e volume rm -f "$id" >/dev/null 2>&1 + done + docker_e image ls -q --filter "label=$LABEL_KEY=$RUN_ID" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e rmi -f "$id" >/dev/null 2>&1 + done +} + +stop_orbstack() { + osascript -e 'tell application "OrbStack" to quit' >/dev/null 2>&1 || true + sleep 3 + pgrep -f '/Applications/OrbStack.app' >/dev/null 2>&1 && pkill -f '/Applications/OrbStack.app' >/dev/null 2>&1 || true +} + +require_socket() { + [ -S "$ENGINE_SOCK" ] || return 1 + if [ "$STRICT" = "1" ]; then + [ "$(stat -f %u "$ENGINE_SOCK" 2>/dev/null || printf invalid)" = "$(id -u)" ] || return 1 + fi +} + +test_engine_info() { + require_socket + docker_e version >/dev/null + docker_e info >/dev/null + docker_e system df >/dev/null +} + +test_pull_images() { + docker_e image inspect "$ALPINE_IMAGE" >/dev/null + docker_e image inspect "$NGINX_IMAGE" >/dev/null +} + +test_lifecycle_logs_exec_stats() { + local name="$PREFIX-basic" + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" sh -c 'echo ready; sleep 300' >/dev/null + sleep 1 + docker_e logs "$name" | grep -q 'ready' + docker_e exec "$name" sh -c 'echo exec-ok' | grep -q 'exec-ok' + docker_e stats --no-stream --format '{{.Name}} {{.MemUsage}}' "$name" | grep -q "$name" + docker_e restart "$name" >/dev/null + docker_e stop "$name" >/dev/null + docker_e start "$name" >/dev/null + docker_e inspect "$name" >/dev/null + docker_e rm -f "$name" >/dev/null +} + +test_cp_archive() { + local name="$PREFIX-cp" + local host_file="$WORKDIR/${ENGINE_ID}-host.txt" + local copied="$WORKDIR/${ENGINE_ID}-copied.txt" + printf 'host-ok\n' > "$host_file" + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" sh -c 'echo container-ok > /tmp/container.txt; sleep 300' >/dev/null + docker_e cp "$host_file" "$name:/tmp/host.txt" + docker_e exec "$name" cat /tmp/host.txt | grep -q 'host-ok' + docker_e cp "$name:/tmp/container.txt" "$copied" + grep -q 'container-ok' "$copied" + docker_e export "$name" >/dev/null + docker_e rm -f "$name" >/dev/null +} + +test_bind_mount() { + local dir="$WORKDIR/${ENGINE_ID}-bind" + mkdir -p "$dir" + printf 'from-host\n' > "$dir/input.txt" + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" -v "$dir:/mnt" "$ALPINE_IMAGE" sh -c 'grep -q from-host /mnt/input.txt && echo from-container > /mnt/output.txt' + grep -q 'from-container' "$dir/output.txt" +} + +test_file_watch() { + local dir="$WORKDIR/${ENGINE_ID}-file-watch" + local name="$PREFIX-file-watch" + local image="${READINESS_FILE_WATCH_IMAGE:-$ALPINE_IMAGE}" + mkdir -p "$dir" + printf 'before\n' > "$dir/input.txt" + docker_e rm -f "$name" >/dev/null 2>&1 || true + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -v "$dir:/work" "$image" sh -lc ' + apk add --no-cache inotify-tools >/dev/null + touch /work/ready + timeout 20 inotifywait -q -e modify,attrib,create,delete /work/input.txt > /work/event.txt + ' >/dev/null + trap 'docker_e rm -f "$name" >/dev/null 2>&1 || true' RETURN + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + [ -f "$dir/ready" ] && break + docker_e inspect "$name" --format '{{.State.Running}}' 2>/dev/null | grep -q true || { docker_e logs "$name" 2>&1; return 1; } + sleep 1 + done + [ -f "$dir/ready" ] || { docker_e logs "$name" 2>&1; echo "inotify watcher did not become ready"; return 1; } + sleep 1 + printf 'after\n' >> "$dir/input.txt" + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$dir/event.txt" ] && break + sleep 1 + done + [ -s "$dir/event.txt" ] || { docker_e logs "$name" 2>&1; echo "host edit did not produce an inotify event"; return 1; } + grep -Eq 'MODIFY|ATTRIB|CREATE|DELETE' "$dir/event.txt" + docker_e rm -f "$name" >/dev/null 2>&1 || true + trap - RETURN +} + +test_volume_roundtrip() { + local vol="$PREFIX-vol" + docker_e volume create --label "$LABEL_KEY=$RUN_ID" "$vol" >/dev/null + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" -v "$vol:/data" "$ALPINE_IMAGE" sh -c 'echo volume-ok > /data/msg' + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" -v "$vol:/data" "$ALPINE_IMAGE" cat /data/msg | grep -q 'volume-ok' + docker_e volume inspect "$vol" >/dev/null + docker_e volume rm "$vol" >/dev/null +} + +test_network_dns() { + local net="$PREFIX-net" + local web="$PREFIX-web" + docker_e network create --label "$LABEL_KEY=$RUN_ID" "$net" >/dev/null + docker_e run -d --name "$web" --label "$LABEL_KEY=$RUN_ID" --network "$net" --network-alias web "$NGINX_IMAGE" >/dev/null + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" --network "$net" "$ALPINE_IMAGE" wget -qO- http://web | grep -qi 'welcome' + docker_e rm -f "$web" >/dev/null + docker_e network rm "$net" >/dev/null +} + +test_published_port() { + local name="$PREFIX-port" + local port + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -p 127.0.0.1::80 "$NGINX_IMAGE" >/dev/null + port="$(docker_e port "$name" 80/tcp | first_host_port)" + [ -n "$port" ] + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + curl -fsS "http://127.0.0.1:$port" | grep -qi 'welcome' && { docker_e rm -f "$name" >/dev/null; return 0; } + sleep 1 + done + docker_e rm -f "$name" >/dev/null + return 1 +} + +test_buildkit_build() { + local dir="$WORKDIR/${ENGINE_ID}-build" + local tag="dory-readiness-${ENGINE_ID}-${RUN_SLUG}:build" + local payload_mb payload_sha + mkdir -p "$dir" + case "$BUILD_CONTEXT_MB" in + ''|*[!0-9]*) echo "READINESS_BUILD_CONTEXT_MB must be a non-negative integer"; return 1 ;; + esac + if [ "$BUILD_CONTEXT_MB" -gt 0 ]; then + dd if=/dev/zero of="$dir/payload.bin" bs=1048576 count="$BUILD_CONTEXT_MB" status=none + else + : > "$dir/payload.bin" + fi + payload_mb="$BUILD_CONTEXT_MB" + payload_sha="$(shasum -a 256 "$dir/payload.bin" | awk '{print $1}')" + cat > "$dir/Dockerfile" < /built.txt +CMD ["cat", "/built.txt"] +EOF + DOCKER_BUILDKIT=1 docker_e build --progress=plain \ + --build-arg "PAYLOAD_SHA=$payload_sha" \ + --build-arg "PAYLOAD_MB=$payload_mb" \ + -t "$tag" "$dir" + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" "$tag" | grep -q "built-ok ${payload_mb}MB $payload_sha" + docker_e rmi -f "$tag" >/dev/null +} + +test_compose() { + local dir="$WORKDIR/${ENGINE_ID}-compose" + local project="doryreadiness${ENGINE_ID}${RUN_SLUG}" port rc + project="$(printf '%s' "$project" | tr -cd '[:alnum:]' | cut -c 1-40)" + mkdir -p "$dir" + cat > "$dir/compose.yaml" </dev/null + compose_e -f "$dir/compose.yaml" -p "$project" ps >/dev/null + compose_e -f "$dir/compose.yaml" -p "$project" logs worker | grep -q 'compose-ok' + port="$(compose_e -f "$dir/compose.yaml" -p "$project" port web 80 | first_host_port)" + [ -n "$port" ] + rc=1 + for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS "http://127.0.0.1:$port" | grep -qi 'welcome'; then + rc=0 + break + fi + sleep 1 + done + compose_e -f "$dir/compose.yaml" -p "$project" down -v --remove-orphans >/dev/null + return "$rc" +} + +test_image_archive_commit() { + local name="$PREFIX-commit" + local tag="dory-readiness-${ENGINE_ID}-${RUN_SLUG}:commit" + local tar="$WORKDIR/${ENGINE_ID}-image.tar" + docker_e create --name "$name" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" sh -c 'echo commit-ok > /result.txt' >/dev/null + docker_e start -a "$name" >/dev/null + docker_e commit --change "LABEL $LABEL_KEY=$RUN_ID" "$name" "$tag" >/dev/null + docker_e image inspect "$tag" >/dev/null + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" "$tag" cat /result.txt | grep -q 'commit-ok' + docker_e save -o "$tar" "$tag" + docker_e rmi -f "$tag" >/dev/null + docker_e load -i "$tar" >/dev/null + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" "$tag" cat /result.txt | grep -q 'commit-ok' + docker_e rm -f "$name" >/dev/null + docker_e rmi -f "$tag" >/dev/null +} + +test_resource_limits_update() { + local name="$PREFIX-limits" limits + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" --memory 128m --cpus 0.5 "$ALPINE_IMAGE" sleep 300 >/dev/null + limits="$(docker_e inspect "$name" --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}')" + printf '%s\n' "$limits" | grep -q '134217728' + printf '%s\n' "$limits" | grep -q '500000000' + docker_e update --memory 256m "$name" >/dev/null + docker_e inspect "$name" --format '{{.HostConfig.Memory}}' | grep -q '268435456' + docker_e rm -f "$name" >/dev/null +} + +test_nonnative_arch() { + local arch handler pattern name + arch="$(nonnative_guest_arch)" + handler="$(binfmt_handler_for_arch "$arch")" + pattern="$(uname_pattern_for_arch "$arch")" + name="$PREFIX-binfmt" + docker_e run --rm --privileged --name "$name" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" \ + sh -c 'handler="$1" + mkdir -p /proc/sys/fs/binfmt_misc + grep -qs " /proc/sys/fs/binfmt_misc " /proc/mounts \ + || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc + test -e /proc/sys/fs/binfmt_misc/register || { echo "binfmt_misc not mounted" >&2; exit 1; } + test -e "/proc/sys/fs/binfmt_misc/$handler" || { echo "$handler handler not registered" >&2; exit 1; } + grep -qx enabled "/proc/sys/fs/binfmt_misc/$handler" || { echo "$handler handler not enabled" >&2; exit 1; }' sh "$handler" + docker_e run --rm --platform "linux/$arch" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" uname -m | grep -Eq "$pattern" +} + +write_nonnative_node_fixture() { + local dir="$1" + mkdir -p "$dir/src" "$dir/scripts" "$dir/test" "$dir/vendor/dory-math" + cat > "$dir/package.json" <<'EOF' +{ + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@dory/math-fixture": "file:vendor/dory-math" + }, + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --test test/*.test.mjs" + } +} +EOF + cat > "$dir/package-lock.json" <<'EOF' +{ + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dory-nonnative-build-smoke", + "version": "1.0.0", + "dependencies": { + "@dory/math-fixture": "file:vendor/dory-math" + } + }, + "node_modules/@dory/math-fixture": { + "resolved": "vendor/dory-math", + "link": true + }, + "vendor/dory-math": { + "name": "@dory/math-fixture", + "version": "1.0.0" + } + } +} +EOF + cat > "$dir/vendor/dory-math/package.json" <<'EOF' +{ + "name": "@dory/math-fixture", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} +EOF + cat > "$dir/vendor/dory-math/index.mjs" <<'EOF' +export function weightedChecksum(values) { + return values.reduce((total, value, index) => (total + value * (index + 1)) % 2147483647, 0); +} +EOF + cat > "$dir/src/app.mjs" <<'EOF' +import { weightedChecksum } from '@dory/math-fixture'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +export function fingerprint(value) { + return createHash('sha256').update(value).digest('hex'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const checksum = weightedChecksum(Array.from({ length: 4096 }, (_, index) => index % 251)); + console.log(`dory-nonnative-build-ok arch=${process.arch} checksum=${checksum} sha=${fingerprint('dory-readiness')}`); +} +EOF + cat > "$dir/scripts/build.mjs" <<'EOF' +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +const source = await readFile(new URL('../src/app.mjs', import.meta.url), 'utf8'); +await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); +await writeFile(new URL('../dist/app.mjs', import.meta.url), `// generated by npm run build\n${source}`); +EOF + cat > "$dir/test/app.test.mjs" <<'EOF' +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { weightedChecksum } from '@dory/math-fixture'; +import { fingerprint } from '../dist/app.mjs'; + +test('built artifact executes on the requested architecture', () => { + assert.equal(process.arch, process.env.EXPECTED_NODE_ARCH); + assert.match(fingerprint('dory-readiness'), /^[0-9a-f]{64}$/); + assert.notEqual(fingerprint('dory-readiness'), fingerprint('wrong-input')); + assert.equal(weightedChecksum([3, 5, 7]), 34); +}); +EOF +} + +test_nonnative_build() { + local arch pattern node_arch dir tag + arch="$(nonnative_guest_arch)" + pattern="$(uname_pattern_for_arch "$arch")" + case "$arch" in + amd64) node_arch="x64" ;; + arm64) node_arch="arm64" ;; + *) echo "unsupported arch: $arch" >&2; return 2 ;; + esac + dir="$WORKDIR/${ENGINE_ID}-nonnative-build" + tag="dory-readiness-${ENGINE_ID}-${RUN_SLUG}:nonnative-$arch" + write_nonnative_node_fixture "$dir" + cat > "$dir/Dockerfile" < /uname.txt \\ + && printf '%s\n' "\$actual" | grep -Eq "\$EXPECTED_UNAME" +RUN node -e "if (process.arch !== process.env.EXPECTED_NODE_ARCH) { throw new Error(process.arch + ' != ' + process.env.EXPECTED_NODE_ARCH) } console.log(process.arch)" > /node-arch.txt +COPY package.json package-lock.json ./ +COPY vendor ./vendor +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY src ./src +COPY scripts ./scripts +COPY test ./test +RUN npm run build \\ + && npm test \\ + && node dist/app.mjs | tee /build-result.txt \\ + && grep -q "dory-nonnative-build-ok arch=\$EXPECTED_NODE_ARCH" /build-result.txt +CMD ["node", "dist/app.mjs"] +EOF + DOCKER_BUILDKIT=1 docker_e build --progress=plain --platform "linux/$arch" \ + --build-arg "EXPECTED_UNAME=$pattern" \ + --build-arg "EXPECTED_NODE_ARCH=$node_arch" \ + -t "$tag" "$dir" + docker_e run --rm --platform "linux/$arch" --label "$LABEL_KEY=$RUN_ID" "$tag" \ + | grep -q "dory-nonnative-build-ok arch=$node_arch" + docker_e rmi -f "$tag" >/dev/null +} + +test_online_search() { + docker_e search --limit 1 alpine | grep -qi 'alpine' +} + +test_domains() { + local name="$PREFIX-domain" + local host + case "$CURRENT_ENGINE" in + dory|doryd) host="$name.dory.local" ;; + orbstack) host="$name.orb.local" ;; + *) return 2 ;; + esac + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -p 80 "$NGINX_IMAGE" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + curl -fsS "http://$host" | grep -qi 'welcome' && { docker_e rm -f "$name" >/dev/null; return 0; } + sleep 1 + done + docker_e rm -f "$name" >/dev/null + return 1 +} + +test_direct_ip() { + is_dory_engine || { echo "direct container IP routing is a Dory shared-VM claim"; return 1; } + local name="$PREFIX-direct-ip" ip iface_file iface route_info + iface_file="${DORY_DIRECT_IP_INTERFACE_FILE:-$HOME/.dory/hv/direct-ip.interface}" + [ -f "$iface_file" ] || { + echo "direct-IP interface file missing: $iface_file; start Dory and run scripts/enable-networking.sh --direct-ip" + return 1 + } + iface="$(tr -d '[:space:]' < "$iface_file")" + [ -n "$iface" ] || { echo "direct-IP interface file is empty: $iface_file"; return 1; } + if ! ifconfig "$iface" >/dev/null 2>&1; then + echo "direct-IP interface is not active: $iface" + return 1 + fi + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" "$NGINX_IMAGE" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + ip="$(docker_e inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name" 2>/dev/null)" + [ -n "$ip" ] && break + sleep 1 + done + [ -n "$ip" ] || { docker_e rm -f "$name" >/dev/null; echo "container has no IPv4 address"; return 1; } + route_info="$(route -n get "$ip" 2>/dev/null || true)" + if ! printf '%s\n' "$route_info" | grep -Eq "interface: +$iface"; then + docker_e rm -f "$name" >/dev/null + echo "host route for $ip does not use $iface; run scripts/enable-networking.sh --direct-ip" + return 1 + fi + ping -c 1 -W 1000 "$ip" >/dev/null + curl -fsS --connect-timeout 3 "http://$ip" | grep -qi 'welcome' + docker_e rm -f "$name" >/dev/null +} + +vpn_snapshot() { + local dir="$1" + mkdir -p "$dir" + if command -v ifconfig >/dev/null 2>&1; then + ifconfig > "$dir/interfaces.txt" 2>&1 || true + elif command -v ip >/dev/null 2>&1; then + ip addr show > "$dir/interfaces.txt" 2>&1 || true + fi + if command -v netstat >/dev/null 2>&1; then + netstat -rn > "$dir/routes.txt" 2>&1 || true + elif command -v ip >/dev/null 2>&1; then + ip route show table all > "$dir/routes.txt" 2>&1 || true + fi + if command -v route >/dev/null 2>&1; then + route -n get default > "$dir/default-route.txt" 2>&1 || true + fi + if command -v scutil >/dev/null 2>&1; then + scutil --dns > "$dir/dns.txt" 2>&1 || true + elif [ -f /etc/resolv.conf ]; then + cp /etc/resolv.conf "$dir/dns.txt" 2>/dev/null || true + fi +} + +vpn_detected() { + local dir="$1" + grep -Eiq '(^utun[0-9]*:|^ppp[0-9]*:|^tun[0-9]*:|^tap[0-9]*:|wireguard|wg[0-9]|tailscale|zerotier|vpn)' \ + "$dir/interfaces.txt" "$dir/routes.txt" "$dir/dns.txt" 2>/dev/null +} + +test_vpn_coexistence() { + is_dory_engine || { echo "VPN coexistence is a Dory gvproxy claim"; return 1; } + local dir="$WORKDIR/${ENGINE_ID}-vpn" + vpn_snapshot "$dir" + if vpn_detected "$dir"; then + echo "VPN-like interface or route detected; artifacts: $dir" + elif [ "${DORY_REQUIRE_VPN:-0}" = "1" ] || [ "$STRICT" = "1" ]; then + echo "no VPN-like interface or route detected; strict VPN readiness requires a real active VPN; artifacts: $dir" + return 1 + else + echo "no VPN-like interface or route detected; recorded baseline artifacts: $dir" + fi + test_engine_info + test_network_dns + test_published_port +} + +test_k8s() { + command -v kubectl >/dev/null + case "$CURRENT_ENGINE" in + dory|doryd) KUBECONFIG="$HOME/.kube/dory-config" kubectl get nodes >/dev/null ;; + orbstack) kubectl --context orbstack get nodes >/dev/null ;; + docker-desktop|desktop) kubectl --context docker-desktop get nodes >/dev/null ;; + *) return 2 ;; + esac +} + +test_machines() { + case "$CURRENT_ENGINE" in + dory|doryd) test_machines_doryd ;; + orbstack) orb list >/dev/null ;; + docker-desktop|desktop) return 2 ;; + *) return 2 ;; + esac +} + +# Legacy container-backed machine smoke retained for explicit migration troubleshooting only. +# Release readiness uses doryd VM machines through test_machines_doryd. +test_machines_legacy_container() { + local dir="$WORKDIR/${ENGINE_ID}-machine" + local tag="dory-readiness-machine-${ENGINE_ID}-${RUN_SLUG}:latest" + local name="$PREFIX-machine" + local machine_image="${READINESS_MACHINE_CONTAINER_IMAGE:-}" + printf '%s\n' "$machine_image" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { + echo "READINESS_MACHINE_CONTAINER_IMAGE must be digest-pinned for the legacy container probe" + return 1 + } + mkdir -p "$dir" + cat > "$dir/Dockerfile" </dev/null + docker_e create --name "$name" --hostname machinetest \ + --label "$LABEL_KEY=$RUN_ID" --label "dory.machine=ubuntu" \ + --privileged --cgroupns host \ + --tmpfs /run --tmpfs /run/lock --tmpfs /tmp \ + --restart unless-stopped --stop-signal SIGRTMIN+3 \ + -e container=docker \ + "$tag" /sbin/init >/dev/null + docker_e start "$name" >/dev/null + local ok=0 + for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do + if docker_e exec "$name" systemctl is-system-running 2>/dev/null | grep -Eq 'running|degraded'; then ok=1; break; fi + sleep 2 + done + [ "$ok" = "1" ] + docker_e exec "$name" cat /proc/1/comm | grep -q systemd + docker_e exec "$name" sh -c 'echo machine-exec-ok' | grep -q machine-exec-ok + docker_e exec "$name" sh -c 'getent hosts host.docker.internal >/dev/null || ping -c1 -W1 host.docker.internal >/dev/null' + docker_e exec "$name" sh -c 'getent hosts host.dory.internal >/dev/null || ping -c1 -W1 host.dory.internal >/dev/null' + docker_e rm -f "$name" >/dev/null + docker_e rmi -f "$tag" >/dev/null +} + +test_machines_doryd() { + local host_arch rootfs_default kernel rootfs + case "$(uname -m)" in + arm64|aarch64) host_arch="arm64" ;; + *) host_arch="amd64" ;; + esac + rootfs_default="$ROOT/guest/out/initfs-$host_arch.ext4" + kernel="${DORYD_MACHINE_KERNEL:-${DORYD_GUEST_KERNEL:-$ROOT/guest/out/Image}}" + rootfs="${DORYD_MACHINE_ROOTFS:-${DORYD_GUEST_ROOTFS:-$rootfs_default}}" + local memory="${DORYD_MACHINE_MEMORY_MB:-2048}" + local cpus="${DORYD_MACHINE_CPUS:-2}" + local dir="$WORKDIR/${ENGINE_ID}-machine-vm" + local name="ready-${RUN_SLUG}" + local state agent agent_socket detail + + [ -n "$kernel" ] && [ -f "$kernel" ] || { + echo "set DORYD_MACHINE_KERNEL to a bootable Linux kernel for doryd VM machine readiness" + return 1 + } + [ -n "$rootfs" ] && [ -f "$rootfs" ] || { + echo "set DORYD_MACHINE_ROOTFS to a rootfs with dory-agent as PID 1 for doryd VM machine readiness" + return 1 + } + + mkdir -p "$dir" + dorydctl machine delete "$name" >/dev/null 2>&1 || true + trap 'dorydctl machine stop "$name" >/dev/null 2>&1 || true; dorydctl machine delete "$name" >/dev/null 2>&1 || true' RETURN + + dorydctl machine create "$name" --kernel "$kernel" --rootfs "$rootfs" --memory-mb "$memory" --cpus "$cpus" > "$dir/create.json" + dorydctl machine start "$name" > "$dir/start.json" + + state="" + for _ in $(seq 1 "${DORYD_MACHINE_READY_POLLS:-60}"); do + dorydctl machine status "$name" > "$dir/status.json" + state="$(json_get state < "$dir/status.json")" + case "$state" in + running) break ;; + failed) + detail="$(json_get lastError < "$dir/status.json")" + echo "doryd machine failed: ${detail:-unknown failure}" + return 1 + ;; + esac + sleep "${DORYD_MACHINE_READY_INTERVAL:-2}" + done + + [ "$state" = "running" ] || { + cat "$dir/status.json" >&2 + echo "doryd machine did not reach running" + return 1 + } + agent="$(json_get agentBuild < "$dir/status.json")" + [ -n "$agent" ] || { + cat "$dir/status.json" >&2 + echo "doryd machine reached running without an agent build" + return 1 + } + agent_socket="$(json_get agentSocketPath < "$dir/status.json")" + [ -n "$agent_socket" ] && [ -S "$agent_socket" ] || { + cat "$dir/status.json" >&2 + echo "doryd machine reached running without a live agent socket" + return 1 + } + dorydctl machine exec "$name" -- /bin/sh -lc 'echo machine-exec-ok' > "$dir/exec.out" + grep -qx 'machine-exec-ok' "$dir/exec.out" + dorydctl machine stop "$name" > "$dir/stop.json" + dorydctl machine delete "$name" > "$dir/delete.json" + trap - RETURN +} + +machine_recipe_command() { + local recipe="$1" + if [ -n "${READINESS_MACHINE_RECIPE_COMMAND:-}" ]; then + printf '%s' "$READINESS_MACHINE_RECIPE_COMMAND" + return + fi + if [ "${CURRENT_ENGINE:-}" = "dory" ] || [ "${CURRENT_ENGINE:-}" = "doryd" ]; then + case "$recipe" in + rust|rust-dev) printf '%s' 'cargo --version' ;; + node) printf '%s' 'node --version && npm --version' ;; + go) printf '%s' 'go version' ;; + python|python-ml) printf '%s' 'python3 --version && python3 -m pip --version' ;; + docker-host) printf '%s' 'docker --version' ;; + k8s-lab|k8s|kubectl) printf '%s' 'kubectl version --client=true' ;; + *) printf '%s' 'cat /proc/1/comm | grep -Eq "dory-agent|init|systemd|tail"' ;; + esac + return + fi + case "$recipe" in + rust|rust-dev) printf '%s' 'test -x "$HOME/.cargo/bin/cargo" && "$HOME/.cargo/bin/cargo" --version' ;; + node) printf '%s' 'node --version && corepack --version' ;; + go) printf '%s' '. /etc/profile.d/go.sh 2>/dev/null || true; go version' ;; + python-ml) printf '%s' 'python3 --version && python3 -m pip --version' ;; + docker-host) printf '%s' 'docker --version' ;; + k8s-lab) printf '%s' 'kubectl version --client=true' ;; + *) printf '%s' 'cat /proc/1/comm | grep -Eq "systemd|tail"' ;; + esac +} + +test_machine_recipe() { + is_dory_engine || return 2 + if is_dory_engine; then + local host_arch rootfs_default kernel rootfs memory cpus dir name state detail command recipe + case "$(uname -m)" in + arm64|aarch64) host_arch="arm64" ;; + *) host_arch="amd64" ;; + esac + rootfs_default="$ROOT/guest/out/initfs-$host_arch.ext4" + kernel="${DORYD_MACHINE_KERNEL:-${DORYD_GUEST_KERNEL:-$ROOT/guest/out/Image}}" + rootfs="${DORYD_MACHINE_ROOTFS:-${DORYD_GUEST_ROOTFS:-$rootfs_default}}" + memory="${DORYD_MACHINE_MEMORY_MB:-2048}" + cpus="${DORYD_MACHINE_CPUS:-2}" + recipe="${READINESS_MACHINE_RECIPE:-rust}" + dir="$WORKDIR/${ENGINE_ID}-machine-recipe" + name="recipe-${RUN_SLUG}" + command="$(machine_recipe_command "$recipe")" + + [ -n "$kernel" ] && [ -f "$kernel" ] || { + echo "set DORYD_MACHINE_KERNEL to a bootable Linux kernel for doryd VM recipe readiness" + return 1 + } + [ -n "$rootfs" ] && [ -f "$rootfs" ] || { + echo "set DORYD_MACHINE_ROOTFS to a rootfs with dory-agent as PID 1 for doryd VM recipe readiness" + return 1 + } + + mkdir -p "$dir" + dorydctl machine delete "$name" >/dev/null 2>&1 || true + trap 'dorydctl machine stop "$name" >/dev/null 2>&1 || true; dorydctl machine delete "$name" >/dev/null 2>&1 || true' RETURN + + dorydctl machine create "$name" --kernel "$kernel" --rootfs "$rootfs" --memory-mb "$memory" --cpus "$cpus" > "$dir/create.json" + dorydctl machine start "$name" > "$dir/start.json" + state="" + for _ in $(seq 1 "${DORYD_MACHINE_READY_POLLS:-60}"); do + dorydctl machine status "$name" > "$dir/status.json" + state="$(json_get state < "$dir/status.json")" + case "$state" in + running) break ;; + failed) + detail="$(json_get lastError < "$dir/status.json")" + echo "doryd recipe machine failed: ${detail:-unknown failure}" + return 1 + ;; + esac + sleep "${DORYD_MACHINE_READY_INTERVAL:-2}" + done + [ "$state" = "running" ] || { + cat "$dir/status.json" >&2 + echo "doryd recipe machine did not reach running" + return 1 + } + dorydctl machine provision "$name" --recipe "$recipe" > "$dir/provision.json" + dorydctl machine exec "$name" -- /bin/sh -lc "$command" > "$dir/verify.out" + [ -s "$dir/verify.out" ] || { + cat "$dir/provision.json" >&2 + echo "doryd recipe verification produced no output" + return 1 + } + dorydctl machine stop "$name" > "$dir/stop.json" + dorydctl machine delete "$name" > "$dir/delete.json" + trap - RETURN + return 0 + fi + return 2 +} + +test_bridge() { + local mname="dory-brdg-$RUN_SLUG" + local cname="dory-machine-$mname" + local hbridge="$HOME/.dory/bridge/$mname" + rm -rf "$hbridge"; mkdir -p "$hbridge/open" "$hbridge/forward" + docker_e rm -f "$cname" >/dev/null 2>&1 || true + docker_e run -d --name "$cname" --label "$LABEL_KEY=$RUN_ID" \ + -v "$hbridge:/opt/dory/bridge" -e BROWSER=dory-open \ + "$ALPINE_IMAGE" tail -f /dev/null >/dev/null + awk '/static let script = ##"""/{f=1;next} f && $0 ~ /^[[:space:]]*"""##[[:space:]]*$/{f=0;next} f' \ + "$ROOT/Dory/Runtime/Machines/DoryOpenShim.swift" \ + | docker_e exec -i "$cname" sh -c 'cat > /usr/local/bin/dory-open && chmod +x /usr/local/bin/dory-open' + docker_e exec "$cname" sh -c 'command -v nc >/dev/null 2>&1 || (apk add --no-cache netcat-openbsd >/dev/null 2>&1 || true)' + docker_e exec -d "$cname" sh -c '(printf "HTTP/1.0 200 OK\r\n\r\nok" | nc -l -p 53219) >/dev/null 2>&1' + docker_e exec "$cname" sh -c '/usr/local/bin/dory-open "http://127.0.0.1:53219/cb?code=xyz"' >/dev/null + local seen_open=0 seen_forward=0 + for _ in $(seq 1 20); do + ls "$hbridge/open/"*.json >/dev/null 2>&1 && seen_open=1 + [ -f "$hbridge/forward/53219.json" ] && seen_forward=1 + [ "$seen_open" = 1 ] && [ "$seen_forward" = 1 ] && break + sleep 0.25 + done + grep -q 'http://127.0.0.1:53219/cb?code=xyz' "$hbridge/open/"*.json 2>/dev/null || { echo "open request missing"; return 1; } + [ "$seen_forward" = 1 ] || { echo "forward request missing"; return 1; } + ( echo -e "GET /cb HTTP/1.0\r\n\r" | docker_e exec -i "$cname" nc 127.0.0.1 53219 ) | grep -q 'ok' \ + || { echo "guest loopback server unreachable via exec-nc"; return 1; } + docker_e rm -f "$cname" >/dev/null 2>&1 || true + rm -rf "$hbridge" + return 0 +} + +test_dax() { + is_dory_engine || return 2 + local hv="${DORY_HV_BIN:-$ROOT/Packages/ContainerizationEngine/.build/debug/dory-hv}" + [ -x "$hv" ] || { echo "dory-hv not found or executable: $hv"; return 1; } + "$hv" daxprobe | grep -q "dax coherence passed" +} + +test_rosetta() { + echo "Rosetta machine execution is pending the dory-vmm machine backend; the removed legacy VZ helper path is no longer supported" + return 2 +} + +test_guest_agent() { + is_dory_engine || return 2 + local hv="${DORY_HV_BIN:-$ROOT/Packages/ContainerizationEngine/.build/debug/dory-hv}" + local kernel="${DORY_GUEST_KERNEL:-$ROOT/guest/out/Image}" + local initfs="${DORY_GUEST_INITFS:-}" + [ -x "$hv" ] || { echo "dory-hv not found or executable: $hv"; return 1; } + [ -f "$kernel" ] || { echo "guest kernel not found: $kernel"; return 1; } + [ -n "$initfs" ] && [ -f "$initfs" ] || { echo "set DORY_GUEST_INITFS to a built initfs.ext4"; return 1; } + "$hv" agent-ping --kernel "$kernel" --initfs "$initfs" --mem-mb "${DORY_GUEST_AGENT_MEM_MB:-512}" --cpus 2 --timeout-sec "${DORY_GUEST_AGENT_TIMEOUT:-30}" \ + | tee "$WORKDIR/${ENGINE_ID}-guest-agent.json" \ + | grep -q '"kernel":"6.12.30-dory"' +} + +test_clock_sync() { + [ "$CURRENT_ENGINE" = "doryd" ] || return 2 + local result_file="$WORKDIR/${ENGINE_ID}-clock-sync.json" + local guest_raw delta_ms + # This calls DockerTier.syncAgentClock, the exact typed path HostWakeCoordinator invokes after + # host wake. The CLI fails unless the running agent attempted and accepted clock_settime. + dorydctl docker clock-sync > "$result_file" + python3 - "$result_file" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + result = json.load(handle) +if result.get("attempted") is not True or result.get("synced") is not True or result.get("error"): + raise SystemExit(f"clock synchronization was not accepted: {result}") +PY + guest_raw="$(docker_e info --format '{{.SystemTime}}' | tr -d '\r\n')" + delta_ms="$(python3 - "$guest_raw" <<'PY' +from datetime import datetime +import sys, time + +raw = sys.argv[1].strip().strip('"') +if raw.endswith('Z'): + raw = raw[:-1] + '+00:00' +guest_ns = int(datetime.fromisoformat(raw).timestamp() * 1_000_000_000) +print(abs(time.time_ns() - guest_ns) // 1_000_000) +PY +)" + [ "$delta_ms" -le "$CLOCK_SYNC_TOLERANCE_MS" ] || { + echo "clock delta ${delta_ms}ms exceeds tolerance ${CLOCK_SYNC_TOLERANCE_MS}ms" + return 1 + } + echo "clock delta ${delta_ms}ms" +} + +test_usb() { + is_dory_engine || return 2 + if ! "$ROOT/scripts/dory" usb ls 2>/dev/null | grep -q "$DORY_USB_TEST_BUSID"; then + echo "device $DORY_USB_TEST_BUSID not present in 'dory usb ls'" >&2 + return 1 + fi + local mode="${DORY_USB_MODE:-userAuthorized}" + "$ROOT/scripts/dory" usb attach "$DORY_USB_TEST_BUSID" --mode "$mode" || return 1 + "$ROOT/scripts/dory" usb detach "$DORY_USB_TEST_BUSID" || return 1 +} + +test_doryd_launchd() { + [ "$CURRENT_ENGINE" = "doryd" ] || return 2 + local domain="gui/$(id -u)/dev.dory.doryd" + launchctl print "$domain" | grep -q 'state = running' + launchctl print "$domain" | grep -q '"dev.dory.doryd"' +} + +measure_memory() { + local engine="$1" + local image="$ALPINE_IMAGE" + local base peak rss_base rss_peak sys_delta rss_delta i name + cleanup_engine + docker_e image inspect "$image" >/dev/null 2>&1 || return 1 + sleep "$SETTLE" + base="$(used_mem)" + rss_base="$(process_rss_bytes "$engine")" + for i in $(seq 1 "$MEMORY_COUNT"); do + name="$PREFIX-mem-$i" + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" "$image" sleep 600 >/dev/null || return 1 + done + sleep "$SETTLE" + peak="$(used_mem)" + rss_peak="$(process_rss_bytes "$engine")" + sys_delta=$((peak - base)) + rss_delta=$((rss_peak - rss_base)) + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$engine" "$MEMORY_COUNT" "$image" "$sys_delta" "$(mb "$sys_delta")" "$rss_delta" "$(mb "$rss_delta")" >> "$MEMORY_RESULTS" + cleanup_engine +} + +run_engine() { + CURRENT_ENGINE="$1" + ENGINE_ID="$(engine_id "$CURRENT_ENGINE")" + ENGINE_SOCK="$(engine_socket "$CURRENT_ENGINE")" + PREFIX="dory-ready-$ENGINE_ID-$$" + + note "$CURRENT_ENGINE ($ENGINE_SOCK)" + if ! require_socket; then + required_unavailable_case "$CURRENT_ENGINE" "all checks" "requested engine socket not found: $ENGINE_SOCK" + return + fi + + if [ "$CURRENT_ENGINE" = "doryd" ]; then + run_case "$CURRENT_ENGINE" "doryd launchd MachService loaded" test_doryd_launchd + test_doryd_launchd >/dev/null 2>&1 || return + fi + + run_case "$CURRENT_ENGINE" "Docker API ready" wait_for_engine_ready + wait_for_engine_ready >/dev/null 2>&1 || return + + cleanup_engine + run_case "$CURRENT_ENGINE" "engine info / version / system df" test_engine_info + run_case "$CURRENT_ENGINE" "offline Alpine + Nginx fixtures available" test_pull_images + run_case "$CURRENT_ENGINE" "container lifecycle + logs + exec + stats" test_lifecycle_logs_exec_stats + run_case "$CURRENT_ENGINE" "docker cp + export" test_cp_archive + run_case "$CURRENT_ENGINE" "bind mount host read/write" test_bind_mount + if [ "$RUN_FILE_WATCH" = "1" ]; then + run_case "$CURRENT_ENGINE" "host file edit propagates to inotify" test_file_watch + else + skip_case "$CURRENT_ENGINE" "host file edit propagates to inotify" "enable with --file-watch" + fi + run_case "$CURRENT_ENGINE" "volume create/use/inspect/remove" test_volume_roundtrip + run_case "$CURRENT_ENGINE" "network create + service DNS" test_network_dns + run_case "$CURRENT_ENGINE" "localhost published port" test_published_port + run_case "$CURRENT_ENGINE" "BuildKit docker build" test_buildkit_build + run_case "$CURRENT_ENGINE" "docker compose up/logs/port/down" test_compose + run_case "$CURRENT_ENGINE" "commit + save/load image archive" test_image_archive_commit + run_case "$CURRENT_ENGINE" "memory/cpu resource limits + update" test_resource_limits_update + + if [ "$RUN_NONNATIVE_ARCH" = "1" ]; then + run_case "$CURRENT_ENGINE" "linux/$(nonnative_guest_arch) emulation" test_nonnative_arch + run_case "$CURRENT_ENGINE" "linux/$(nonnative_guest_arch) BuildKit npm ci + build + test" test_nonnative_build + else + skip_case "$CURRENT_ENGINE" "linux/$(nonnative_guest_arch) emulation" "disabled" + skip_case "$CURRENT_ENGINE" "linux/$(nonnative_guest_arch) BuildKit npm ci + build + test" "disabled" + fi + + if [ "$RUN_ONLINE" = "1" ]; then + run_case "$CURRENT_ENGINE" "online docker search" test_online_search + else + skip_case "$CURRENT_ENGINE" "online docker search" "disabled by default" + fi + + if [ "$RUN_DOMAINS" = "1" ]; then + run_case "$CURRENT_ENGINE" "automatic local domains" test_domains + else + skip_case "$CURRENT_ENGINE" "automatic local domains" "enable with --domains after system integration is installed" + fi + + if [ "$RUN_DIRECT_IP" = "1" ]; then + run_case "$CURRENT_ENGINE" "direct container IP ping + HTTP" test_direct_ip + else + skip_case "$CURRENT_ENGINE" "direct container IP ping + HTTP" "enable with --direct-ip after scripts/enable-networking.sh --direct-ip" + fi + + if [ "$RUN_VPN" = "1" ]; then + if is_dory_engine; then + run_case "$CURRENT_ENGINE" "VPN coexistence userspace networking" test_vpn_coexistence + else + skip_case "$CURRENT_ENGINE" "VPN coexistence userspace networking" "not applicable: this probe validates Dory's gvproxy coexistence contract" + fi + else + skip_case "$CURRENT_ENGINE" "VPN coexistence userspace networking" "enable with --vpn; set DORY_REQUIRE_VPN=1 to require an active VPN" + fi + + if [ "$RUN_K8S" = "1" ]; then + run_case "$CURRENT_ENGINE" "Kubernetes context reachable" test_k8s + else + skip_case "$CURRENT_ENGINE" "Kubernetes context reachable" "enable with --k8s" + fi + + if [ "$RUN_MACHINES" = "1" ]; then + run_case "$CURRENT_ENGINE" "Linux VM machine boot + exec" test_machines + else + skip_case "$CURRENT_ENGINE" "Linux VM machine boot + exec" "enable with --machines" + fi + + if [ "$RUN_MACHINE_RECIPE" = "1" ]; then + run_case "$CURRENT_ENGINE" "Linux machine recipe create + provisioned command" test_machine_recipe + else + skip_case "$CURRENT_ENGINE" "Linux machine recipe create + provisioned command" "enable with --machine-recipe" + fi + + if [ "$RUN_BRIDGE" = "1" ]; then + run_case "$CURRENT_ENGINE" "guest→host bridge (dory-open open + forward)" test_bridge + else + skip_case "$CURRENT_ENGINE" "guest→host bridge (dory-open open + forward)" "enable with --bridge" + fi + + if [ "$RUN_GUEST_AGENT" = "1" ]; then + run_case "$CURRENT_ENGINE" "dory-hv guest-agent vsock ping" test_guest_agent + else + skip_case "$CURRENT_ENGINE" "dory-hv guest-agent vsock ping" "enable with --guest-agent and DORY_GUEST_INITFS" + fi + + if [ "$RUN_DAX" = "1" ]; then + run_case "$CURRENT_ENGINE" "dory-hv low-level DAX mapping probe" test_dax + else + skip_case "$CURRENT_ENGINE" "dory-hv low-level DAX mapping probe" "enable with --dax (production host-share DAX remains disabled)" + fi + + if [ "$RUN_ROSETTA" = "1" ] && [ "$(host_guest_arch)" = "amd64" ]; then + required_unavailable_case "$CURRENT_ENGINE" "Rosetta x86-64 machine execution" "probe is not applicable on Intel hosts; amd64 is native" + elif [ "$RUN_ROSETTA" = "1" ]; then + run_case "$CURRENT_ENGINE" "Rosetta x86-64 machine execution" test_rosetta + else + skip_case "$CURRENT_ENGINE" "Rosetta x86-64 machine execution" "pending dory-vmm Rosetta machine backend" + fi + + if [ "$RUN_CLOCK_SYNC" = "1" ] && [ "$CURRENT_ENGINE" != "doryd" ]; then + required_unavailable_case "$CURRENT_ENGINE" "host wake clock sync" "typed clock-sync readiness is supported by the doryd engine only" + elif [ "$RUN_CLOCK_SYNC" = "1" ]; then + run_case "$CURRENT_ENGINE" "host wake clock sync" test_clock_sync + else + skip_case "$CURRENT_ENGINE" "host wake clock sync" "enable with --clock-sync on the doryd engine" + fi + + if [ "$RUN_USB" = "1" ]; then + required_unavailable_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "unsupported: dory-agent protocol v1 has no guest vhci attach/detach RPC" + else + skip_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "enable with --usb and DORY_USB_TEST_BUSID" + fi + + if [ "$RUN_DEBUG_SHELL" = "1" ]; then + required_unavailable_case "$CURRENT_ENGINE" "debug shell via guest agent" "unsupported: dory-agent protocol v1 has no namespace-debug RPC" + else + skip_case "$CURRENT_ENGINE" "debug shell via guest agent" "enable with --debug-shell" + fi + + if [ "$RUN_MEMORY" = "1" ]; then + run_case "$CURRENT_ENGINE" "memory delta for $MEMORY_COUNT idle containers" measure_memory "$CURRENT_ENGINE" + else + skip_case "$CURRENT_ENGINE" "memory delta" "disabled" + fi + cleanup_engine +} + +write_summary() { + python3 - "$SUMMARY_JSON" "$RUN_ID" "$ENGINES" "$STRICT" \ + "$REQUIRED_UNAVAILABLE_COUNT" "$REQUIRE_PHYSICAL_INTEL" "$REQUIRE_COMPETITOR" \ + "$PASS_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" "$RESULTS" "$MEMORY_RESULTS" <<'PY' +import json +import pathlib +import sys + +( + output, run_id, engines, strict, required_unavailable, physical, competitor, + passed, failed, skipped, results, memory, +) = sys.argv[1:] +payload = { + "runId": run_id, + "engines": engines, + "strict": strict == "1", + "requiredUnavailable": int(required_unavailable), + "physicalIntelRequired": physical == "1", + "competitorRequired": competitor == "1", + "pass": int(passed), + "fail": int(failed), + "skip": int(skipped), + "results": results, + "memory": memory, +} +pathlib.Path(output).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY +} + +validate_configuration() { + local value_name value engine old_ifs resolved item name rest minimum maximum + for value_name in RUN_MEMORY RUN_NONNATIVE_ARCH RUN_ONLINE RUN_DOMAINS RUN_DIRECT_IP \ + RUN_FILE_WATCH RUN_K8S RUN_MACHINES RUN_MACHINE_RECIPE RUN_BRIDGE RUN_GUEST_AGENT \ + RUN_DAX RUN_ROSETTA RUN_USB RUN_VPN RUN_DEBUG_SHELL RUN_CLOCK_SYNC STOP_ORBSTACK \ + STRICT REQUIRE_PHYSICAL_INTEL REQUIRE_COMPETITOR PHYSICAL_INTEL_CONFIRMED; do + value="${!value_name}" + case "$value" in 0|1) ;; *) echo "$value_name must be 0 or 1" >&2; return 2 ;; esac + done + case "${DORY_REQUIRE_VPN:-0}" in 0|1) ;; *) echo "DORY_REQUIRE_VPN must be 0 or 1" >&2; return 2 ;; esac + + for item in "memory-count:$MEMORY_COUNT:1:32" "settle:$SETTLE:0:300" \ + "build-context-mb:$BUILD_CONTEXT_MB:1:1024" \ + "clock-sync-tolerance-ms:$CLOCK_SYNC_TOLERANCE_MS:1:60000" \ + "ready-timeout:${READINESS_READY_TIMEOUT:-90}:1:600"; do + name="${item%%:*}" + rest="${item#*:}" + value="${rest%%:*}" + rest="${rest#*:}" + minimum="${rest%%:*}" + maximum="${rest##*:}" + case "$value" in ''|*[!0-9]*) echo "$name must be an integer" >&2; return 2 ;; esac + [ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] || { + echo "$name must be between $minimum and $maximum" >&2 + return 2 + } + done + + case "$WORKROOT" in /*) ;; *) echo "READINESS_WORKDIR must be absolute" >&2; return 2 ;; esac + case "$WORKROOT" in /|"$HOME"|"$ROOT") echo "unsafe READINESS_WORKDIR: $WORKROOT" >&2; return 2 ;; esac + [ ! -L "$WORKROOT" ] || { echo "READINESS_WORKDIR must not be a symlink" >&2; return 2; } + + for image in "$ALPINE_IMAGE" "$NGINX_IMAGE"; do + printf '%s\n' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { + echo "readiness fixture images must be exact digest references" >&2 + return 2 + } + done + if [ "$RUN_NONNATIVE_ARCH" = "1" ]; then + printf '%s\n' "$NONNATIVE_BUILD_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { + echo "READINESS_NONNATIVE_BUILD_IMAGE must be an exact digest reference" >&2 + return 2 + } + fi + if [ "$RUN_FILE_WATCH" = "1" ]; then + printf '%s\n' "${READINESS_FILE_WATCH_IMAGE:-$ALPINE_IMAGE}" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { + echo "READINESS_FILE_WATCH_IMAGE must be an exact digest reference" >&2 + return 2 + } + fi + + if [ "$STRICT" = "1" ] && [ -z "${READINESS_DOCKER_BIN:-}" ]; then + echo "strict readiness requires READINESS_DOCKER_BIN for the exact candidate CLI" >&2 + return 2 + fi + if [ -z "$DOCKER_BIN" ]; then + DOCKER_BIN="$(command -v docker 2>/dev/null || true)" + fi + case "$DOCKER_BIN" in /*) ;; *) echo "readiness Docker CLI must be an absolute path" >&2; return 2 ;; esac + if [ "$STRICT" = "1" ]; then + [ -f "$DOCKER_BIN" ] && [ ! -L "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { + echo "strict readiness Docker CLI is unavailable or indirect: $DOCKER_BIN" >&2 + return 2 + } + else + resolved="$(python3 - "$DOCKER_BIN" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1])) +PY +)" + [ -f "$resolved" ] && [ -x "$resolved" ] || { + echo "readiness Docker CLI is unavailable: $DOCKER_BIN" >&2 + return 2 + } + DOCKER_BIN="$resolved" + fi + + if [ "$STOP_ORBSTACK" = "1" ] \ + && [ "${READINESS_STOP_ORBSTACK_CONFIRMED:-}" != STOP-ORBSTACK-FOR-READINESS ]; then + echo "STOP_ORBSTACK requires READINESS_STOP_ORBSTACK_CONFIRMED=STOP-ORBSTACK-FOR-READINESS" >&2 + return 2 + fi + + old_ifs="$IFS" + IFS=',' + for engine in $ENGINES; do + IFS="$old_ifs" + engine="$(printf '%s' "$engine" | sed 's/^ *//;s/ *$//')" + [ -n "$engine" ] || { echo "engine list contains an empty entry" >&2; return 2; } + if [ "$STRICT" = "1" ]; then + case "$engine" in dory|doryd|orbstack|docker-desktop|desktop|current) ;; + *) echo "strict readiness rejects unknown engine: $engine" >&2; return 2 ;; + esac + fi + IFS=',' + done + IFS="$old_ifs" + + if [ "$REQUIRE_PHYSICAL_INTEL" = "1" ]; then + [ "$(printf '%s' "$ENGINES" | tr -d '[:space:]')" = dory ] || { + echo "physical Intel qualification must target exactly the Dory engine" >&2 + return 2 + } + physical_intel_host_confirmed || { + echo "physical Intel qualification requires independently confirmed native Intel host facts" >&2 + return 2 + } + fi + if [ "$REQUIRE_COMPETITOR" = "1" ]; then + engine_list_has_competitor || { + echo "competitor qualification requires OrbStack or Docker Desktop in --engines" >&2 + return 2 + } + fi +} + +if [ "${READINESS_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + +if ! validate_configuration; then + exit 2 +fi +mkdir -p "$WORKDIR" +[ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + && [ -d "$WORKDIR" ] && [ ! -L "$WORKDIR" ] || { + echo "readiness evidence root changed while it was being prepared" >&2 + exit 2 + } +printf 'status\tengine\ttest\tdetail\n' > "$RESULTS" +printf 'engine\tcontainers\timage\tsystem_delta_bytes\tsystem_delta_mb\tprocess_delta_bytes\tprocess_delta_mb\n' > "$MEMORY_RESULTS" + +trap '{ cleanup_engine; write_summary; } >/dev/null 2>&1 || true' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +note "readiness run $RUN_ID" +note "results: $RESULTS" +note "mode: $([ "$STRICT" = "1" ] && printf strict || printf exploratory)" + +if [ "$STOP_ORBSTACK" = "1" ]; then + note "stopping OrbStack before checks" + stop_orbstack +fi + +OLD_IFS="$IFS" +IFS=',' +for engine in $ENGINES; do + IFS="$OLD_IFS" + engine="$(printf '%s' "$engine" | sed 's/^ *//;s/ *$//')" + [ -n "$engine" ] && run_engine "$engine" + IFS=',' +done +IFS="$OLD_IFS" + +record_external_coverage + +write_summary + +note "summary: pass=$PASS_COUNT fail=$FAIL_COUNT skip=$SKIP_COUNT required-unavailable=$REQUIRED_UNAVAILABLE_COUNT" +note "memory: $MEMORY_RESULTS" +note "summary json: $SUMMARY_JSON" + +[ "$FAIL_COUNT" -eq 0 ] From 6537c3265cb5a481ef8c53eb570f1c216b1f15a4 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:29:49 +0000 Subject: [PATCH 120/338] fix(release): use exact offline readiness fixtures --- .github/scripts/test-readiness-gate.py | 3 +-- scripts/p0-smoke.sh | 2 +- scripts/readiness.sh | 36 ++++++++++++++++---------- scripts/test-p0-smoke.sh | 1 + 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.github/scripts/test-readiness-gate.py b/.github/scripts/test-readiness-gate.py index a4d09588..00d98aa2 100644 --- a/.github/scripts/test-readiness-gate.py +++ b/.github/scripts/test-readiness-gate.py @@ -36,6 +36,7 @@ def test_release_contract_is_fail_closed_and_reproducible(self) -> None: "memory/cpu resource limits + update", "same-host competitor correctness gate", "json.dumps(payload, indent=2, sort_keys=True)", + "Content-Length: 14", ): self.assertIn(proof, text, proof) for stale in ( @@ -77,7 +78,6 @@ def test_mutable_fixture_fails_before_docker_or_socket_access(self) -> None: "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), "READINESS_DOCKER_BIN": "/usr/bin/true", "READINESS_ALPINE_IMAGE": "alpine:latest", - "READINESS_NGINX_IMAGE": DIGEST_IMAGE, "RUN_NONNATIVE_ARCH": "0", } result = subprocess.run( @@ -100,7 +100,6 @@ def test_strict_mode_requires_an_explicit_candidate_cli(self) -> None: "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), "READINESS_DOCKER_BIN": "", "READINESS_ALPINE_IMAGE": DIGEST_IMAGE, - "READINESS_NGINX_IMAGE": DIGEST_IMAGE, "RUN_NONNATIVE_ARCH": "0", } result = subprocess.run( diff --git a/scripts/p0-smoke.sh b/scripts/p0-smoke.sh index 6bf57d82..1c9851fc 100755 --- a/scripts/p0-smoke.sh +++ b/scripts/p0-smoke.sh @@ -355,7 +355,7 @@ services: - sh - -c - | - while true; do printf 'HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\ndory-p0-smoke' | nc -l -p 8080; done + while true; do printf 'HTTP/1.1 200 OK\r\nContent-Length: 14\r\n\r\ndory-p0-smoke' | nc -l -p 8080; done ports: - "127.0.0.1:${PORT}:8080" YAML diff --git a/scripts/readiness.sh b/scripts/readiness.sh index fc45c009..2d7e99e1 100755 --- a/scripts/readiness.sh +++ b/scripts/readiness.sh @@ -13,7 +13,7 @@ # Environment knobs: # DORY_SOCK, DORYD_SOCK, ORBSTACK_SOCK, DOCKER_DESKTOP_SOCK # READINESS_WORKDIR, READINESS_SETTLE, READINESS_DOCKER_BIN -# READINESS_ALPINE_IMAGE, READINESS_NGINX_IMAGE (exact digest references) +# READINESS_ALPINE_IMAGE (exact digest reference) # READINESS_BUILD_CONTEXT_MB for the BuildKit streaming build payload (default: 32) # READINESS_NONNATIVE_BUILD_IMAGE for the non-native BuildKit qemu/npm probe (exact digest) # RUN_MEMORY=0|1, RUN_NONNATIVE_ARCH=0|1, RUN_AMD64=0|1 (legacy alias), RUN_ONLINE=0|1, RUN_DOMAINS=0|1, RUN_DIRECT_IP=0|1, RUN_FILE_WATCH=0|1, RUN_K8S=0|1, RUN_MACHINES=0|1, RUN_MACHINE_RECIPE=0|1, RUN_USB=0|1, RUN_VPN=0|1 @@ -35,7 +35,6 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENGINES="${ENGINES:-dory}" ALPINE_IMAGE="${READINESS_ALPINE_IMAGE:-}" -NGINX_IMAGE="${READINESS_NGINX_IMAGE:-}" NONNATIVE_BUILD_IMAGE="${READINESS_NONNATIVE_BUILD_IMAGE:-}" DOCKER_BIN="${READINESS_DOCKER_BIN:-}" MEMORY_COUNT="${READINESS_MEMORY_COUNT:-3}" @@ -483,7 +482,6 @@ test_engine_info() { test_pull_images() { docker_e image inspect "$ALPINE_IMAGE" >/dev/null - docker_e image inspect "$NGINX_IMAGE" >/dev/null } test_lifecycle_logs_exec_stats() { @@ -566,8 +564,11 @@ test_network_dns() { local net="$PREFIX-net" local web="$PREFIX-web" docker_e network create --label "$LABEL_KEY=$RUN_ID" "$net" >/dev/null - docker_e run -d --name "$web" --label "$LABEL_KEY=$RUN_ID" --network "$net" --network-alias web "$NGINX_IMAGE" >/dev/null - docker_e run --rm --label "$LABEL_KEY=$RUN_ID" --network "$net" "$ALPINE_IMAGE" wget -qO- http://web | grep -qi 'welcome' + docker_e run -d --name "$web" --label "$LABEL_KEY=$RUN_ID" --network "$net" \ + --network-alias web "$ALPINE_IMAGE" sh -c \ + 'while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 14\r\n\r\ndory-readiness" | nc -l -p 8080; done' >/dev/null + docker_e run --rm --label "$LABEL_KEY=$RUN_ID" --network "$net" "$ALPINE_IMAGE" \ + wget -qO- http://web:8080 | grep -q 'dory-readiness' docker_e rm -f "$web" >/dev/null docker_e network rm "$net" >/dev/null } @@ -575,11 +576,14 @@ test_network_dns() { test_published_port() { local name="$PREFIX-port" local port - docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -p 127.0.0.1::80 "$NGINX_IMAGE" >/dev/null - port="$(docker_e port "$name" 80/tcp | first_host_port)" + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -p 127.0.0.1::8080 \ + "$ALPINE_IMAGE" sh -c \ + 'while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 14\r\n\r\ndory-readiness" | nc -l -p 8080; done' >/dev/null + port="$(docker_e port "$name" 8080/tcp | first_host_port)" [ -n "$port" ] for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do - curl -fsS "http://127.0.0.1:$port" | grep -qi 'welcome' && { docker_e rm -f "$name" >/dev/null; return 0; } + curl -fsS "http://127.0.0.1:$port" | grep -q 'dory-readiness' \ + && { docker_e rm -f "$name" >/dev/null; return 0; } sleep 1 done docker_e rm -f "$name" >/dev/null @@ -628,11 +632,15 @@ test_compose() { cat > "$dir/compose.yaml" </dev/null compose_e -f "$dir/compose.yaml" -p "$project" ps >/dev/null compose_e -f "$dir/compose.yaml" -p "$project" logs worker | grep -q 'compose-ok' - port="$(compose_e -f "$dir/compose.yaml" -p "$project" port web 80 | first_host_port)" + port="$(compose_e -f "$dir/compose.yaml" -p "$project" port web 8080 | first_host_port)" [ -n "$port" ] rc=1 for _ in 1 2 3 4 5 6 7 8 9 10; do - if curl -fsS "http://127.0.0.1:$port" | grep -qi 'welcome'; then + if curl -fsS "http://127.0.0.1:$port" | grep -q 'dory-readiness'; then rc=0 break fi @@ -1323,7 +1331,7 @@ run_engine() { cleanup_engine run_case "$CURRENT_ENGINE" "engine info / version / system df" test_engine_info - run_case "$CURRENT_ENGINE" "offline Alpine + Nginx fixtures available" test_pull_images + run_case "$CURRENT_ENGINE" "offline Alpine fixture available" test_pull_images run_case "$CURRENT_ENGINE" "container lifecycle + logs + exec + stats" test_lifecycle_logs_exec_stats run_case "$CURRENT_ENGINE" "docker cp + export" test_cp_archive run_case "$CURRENT_ENGINE" "bind mount host read/write" test_bind_mount @@ -1509,7 +1517,7 @@ validate_configuration() { case "$WORKROOT" in /|"$HOME"|"$ROOT") echo "unsafe READINESS_WORKDIR: $WORKROOT" >&2; return 2 ;; esac [ ! -L "$WORKROOT" ] || { echo "READINESS_WORKDIR must not be a symlink" >&2; return 2; } - for image in "$ALPINE_IMAGE" "$NGINX_IMAGE"; do + for image in "$ALPINE_IMAGE"; do printf '%s\n' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { echo "readiness fixture images must be exact digest references" >&2 return 2 diff --git a/scripts/test-p0-smoke.sh b/scripts/test-p0-smoke.sh index b586c61a..f0e5050f 100755 --- a/scripts/test-p0-smoke.sh +++ b/scripts/test-p0-smoke.sh @@ -217,6 +217,7 @@ for required in \ 'DORY_P0_IMAGE must be digest-pinned' \ 'Dory socket is not owned by the release user' \ 'compatibility_smoke' \ + 'Content-Length: 14' \ 'candidate compatibility diagnostics omitted'; do grep -Fq "$required" <<< "$gate_text" \ || { echo "test-p0-smoke: missing contract proof: $required" >&2; exit 1; } From 1813d32c448a1f25a247bbb08897549934fb0d64 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:33:11 +0000 Subject: [PATCH 121/338] feat(release): qualify exact live candidate --- .../scripts/test-direct-dmg-install-gate.py | 2 + .../test-release-candidate-live-smoke.py | 84 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/direct-dmg-install-gate.sh | 4 +- scripts/release-candidate-live-smoke.sh | 444 ++++++++++++++++++ 6 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/test-release-candidate-live-smoke.py create mode 100755 scripts/release-candidate-live-smoke.sh diff --git a/.github/scripts/test-direct-dmg-install-gate.py b/.github/scripts/test-direct-dmg-install-gate.py index 23946c97..ea32d56f 100644 --- a/.github/scripts/test-direct-dmg-install-gate.py +++ b/.github/scripts/test-direct-dmg-install-gate.py @@ -39,6 +39,8 @@ def test_script_is_syntax_valid_and_has_no_python_assertions(self) -> None: self.assertIn("verify-release-sbom.py", source) self.assertIn("hdiutil attach -readonly -nobrowse -plist", source) self.assertIn("release-candidate-live-smoke.sh", source) + self.assertIn("DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER", source) + self.assertIn('DORY_RELEASE_SOURCE_COMMIT="$SOURCE_COMMIT"', source) def test_help_documents_the_destructive_confirmation(self) -> None: result = self.invoke("--help") diff --git a/.github/scripts/test-release-candidate-live-smoke.py b/.github/scripts/test-release-candidate-live-smoke.py new file mode 100644 index 00000000..81c10f58 --- /dev/null +++ b/.github/scripts/test-release-candidate-live-smoke.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact physical release-candidate wrapper.""" + +from __future__ import annotations + +import os +import pathlib +import re +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "release-candidate-live-smoke.sh" + + +class ReleaseCandidateLiveSmokeTests(unittest.TestCase): + def test_live_contract_binds_candidate_and_all_physical_gates(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER", + "live qualification requires an exact source commit", + "candidate app is unavailable or indirect", + "candidate executable is unavailable or indirect", + "candidate Docker socket is not owned by the release user", + "candidate app has no valid notarization ticket", + "candidate is not accepted as Notarized Developer ID", + "required offline release fixture is missing", + "ISOLATED-DORY-MACHINE-RESOURCES", + "EXACT-CANDIDATE-DESKTOPS", + "ISOLATED-EXTERNAL-APFS-BIND", + "ISOLATED-DORY-BIND-LOCKS", + "SLEEP-AND-WAKE-THIS-MAC", + 'DORY_APP="$APP"', + 'READINESS_DOCKER_BIN="$DOCKER_CLI"', + 'READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE"', + 'READINESS_NONNATIVE_BUILD_IMAGE="$NONNATIVE_BUILD_IMAGE"', + "live-manifest.txt", + "live_candidate=PASS", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:latest", "nginx:alpine", "node:20-alpine", "assert "): + self.assertNotIn(stale, text, stale) + + def test_every_invoked_script_is_tracked(self) -> None: + text = GATE.read_text(encoding="utf-8") + dependencies = sorted(set(re.findall(r"scripts/[A-Za-z0-9._/-]+\.sh", text))) + self.assertGreaterEqual(len(dependencies), 10) + for dependency in dependencies: + result = subprocess.run( + ["git", "ls-files", "--error-unmatch", dependency], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 0, f"untracked live dependency: {dependency}") + + def test_dedicated_user_confirmation_fails_before_host_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + app = pathlib.Path(temporary) / "Dory.app" + app.mkdir() + result = subprocess.run( + [str(GATE), str(app)], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_RELEASE_SOURCE_COMMIT": "a" * 40, + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("DORY_RELEASE_LIVE_CONFIRMED", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01cb4087..418886c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -346,6 +346,7 @@ jobs: python3 .github/scripts/test-external-volume-bind-gate.py python3 .github/scripts/test-host-network-integrity-gate.py python3 .github/scripts/test-readiness-gate.py + python3 .github/scripts/test-release-candidate-live-smoke.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b1ef1d5..e02dbdc5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,6 +47,7 @@ jobs: python3 .github/scripts/test-external-volume-bind-gate.py python3 .github/scripts/test-host-network-integrity-gate.py python3 .github/scripts/test-readiness-gate.py + python3 .github/scripts/test-release-candidate-live-smoke.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/direct-dmg-install-gate.sh b/scripts/direct-dmg-install-gate.sh index 0472bb3b..e5be62e7 100755 --- a/scripts/direct-dmg-install-gate.sh +++ b/scripts/direct-dmg-install-gate.sh @@ -259,7 +259,9 @@ if [ "$INSTALL_ONLY" -eq 1 ]; then exit 0 fi -"$ROOT/scripts/release-candidate-live-smoke.sh" "$APP" +DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER \ +DORY_RELEASE_SOURCE_COMMIT="$SOURCE_COMMIT" \ + "$ROOT/scripts/release-candidate-live-smoke.sh" "$APP" [ ! -e "$HOME/.dory" ] || die "physical smoke did not restore the clean runtime state" [ ! -e "$HOME/Library/Application Support/Dory" ] \ || die "physical smoke did not restore the clean durable-data state" diff --git a/scripts/release-candidate-live-smoke.sh b/scripts/release-candidate-live-smoke.sh new file mode 100755 index 00000000..da25a697 --- /dev/null +++ b/scripts/release-candidate-live-smoke.sh @@ -0,0 +1,444 @@ +#!/bin/bash +# Exercise the exact Dory.app candidate on clean physical hardware. The public Apple-silicon gate +# additionally proves that the persisted GPU + amd64 settings reach launchd, the GPU-specific guest +# kernel, dory-hv, the Venus renderer, /dev/dri, and a real non-native BuildKit workload. The Intel +# release gate uses the same harness with notarization disabled only for its same-commit ad-hoc +# candidate, then runs the strict physical-Intel readiness suite. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +APP="${1:?usage: release-candidate-live-smoke.sh }" +case "$APP" in /*) ;; *) APP="$ROOT/$APP" ;; esac +APP="$(cd "$(dirname "$APP")" && pwd)/$(basename "$APP")" +EXECUTABLE="$APP/Contents/MacOS/Dory" +DORY_CLI="$APP/Contents/Helpers/dory" +DOCKER_CLI="$APP/Contents/Helpers/docker" +SERVICE="gui/$(id -u)/dev.dory.doryd" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +PREF_DOMAIN="com.pythonxi.Dory" +PREF_PLIST="$HOME/Library/Preferences/$PREF_DOMAIN.plist" +LOG_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-release-live-${GITHUB_RUN_ID:-$$}-${GITHUB_RUN_ATTEMPT:-1}" +REQUIRED_ARCH="${DORY_RELEASE_LIVE_REQUIRED_ARCH:-arm64}" +REQUIRE_NOTARIZED="${DORY_RELEASE_LIVE_REQUIRE_NOTARIZED:-1}" +REQUIRE_PHYSICAL_INTEL="${DORY_RELEASE_LIVE_REQUIRE_PHYSICAL_INTEL:-0}" +EXTERNAL_VOLUME_ROOT="${DORY_RELEASE_EXTERNAL_VOLUME_ROOT:-}" +LOCK_IMAGE="${DORY_RELEASE_LOCK_IMAGE:-}" +FIXTURE_IMAGE="${DORY_RELEASE_FIXTURE_IMAGE:-}" +NONNATIVE_BUILD_IMAGE="${DORY_RELEASE_NONNATIVE_BUILD_IMAGE:-}" +SSH_CLIENT_IMAGE="${DORY_RELEASE_SSH_CLIENT_IMAGE:-}" +RUN_PHYSICAL_SLEEP="${DORY_RELEASE_RUN_PHYSICAL_SLEEP:-0}" +CORPORATE_DNS="${DORY_RELEASE_CORPORATE_DNS_SERVER:-}" +CORPORATE_PROBE_HOST="${DORY_RELEASE_CORPORATE_VPN_PROBE_HOST:-}" +CORPORATE_PROBE_URL="${DORY_RELEASE_CORPORATE_VPN_PROBE_URL:-}" +TAILSCALE_EXIT_NODE="${DORY_RELEASE_TAILSCALE_EXIT_NODE:-}" +DESKTOP_KERNEL="${DORY_RELEASE_DESKTOP_KERNEL:-}" +DESKTOP_DEBIAN_ROOTFS="${DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS:-}" +DESKTOP_UBUNTU_ROOTFS="${DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS:-}" +DESKTOP_KALI_ROOTFS="${DORY_RELEASE_DESKTOP_KALI_ROOTFS:-}" +DESKTOP_DEBIAN_UPDATE="${DORY_RELEASE_DESKTOP_DEBIAN_UPDATE:-}" +DESKTOP_UBUNTU_UPDATE="${DORY_RELEASE_DESKTOP_UBUNTU_UPDATE:-}" +DESKTOP_KALI_UPDATE="${DORY_RELEASE_DESKTOP_KALI_UPDATE:-}" +DESKTOP_VERSION="${DORY_RELEASE_DESKTOP_VERSION:-}" +SOURCE_COMMIT="${DORY_RELEASE_SOURCE_COMMIT:-${GITHUB_SHA:-}}" +APP_PID="" +PREVIOUS_CONTEXT="default" +PROFILE_PATHS=("$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.profile") +PROFILE_EXISTED=() + +fail() { + echo "release live smoke error: $*" >&2 + exit 1 +} + +is_digest_reference() { + printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' +} + +cleanup() { + local profile index + set +e + mkdir -p "$LOG_ROOT" + [ -f "$STATE/doryd.log" ] && cp "$STATE/doryd.log" "$LOG_ROOT/doryd.log" + [ -f "$STATE/hv/dory-hv.log" ] && cp "$STATE/hv/dory-hv.log" "$LOG_ROOT/dory-hv.log" + [ -f "$PLIST" ] && cp "$PLIST" "$LOG_ROOT/dev.dory.doryd.plist" + if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then + "$DORY_CLI" engine sleep >/dev/null 2>&1 || true + kill -TERM "$APP_PID" 2>/dev/null || true + for _ in $(seq 1 60); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 0.5 + done + kill -KILL "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + launchctl bootout "$SERVICE" >/dev/null 2>&1 || true + "$DORY_CLI" uninstall >/dev/null 2>&1 || true + "$DOCKER_CLI" context use "$PREVIOUS_CONTEXT" >/dev/null 2>&1 || true + "$DOCKER_CLI" context rm -f dory >/dev/null 2>&1 || true + rm -f "$PLIST" + rm -rf "$STATE" "$APP_SUPPORT" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + for index in "${!PROFILE_PATHS[@]}"; do + profile="${PROFILE_PATHS[$index]}" + if [ "${PROFILE_EXISTED[$index]}" = "0" ] && [ -f "$profile" ] \ + && ! grep -q '[^[:space:]]' "$profile"; then + rm -f "$profile" + fi + done +} + +[ "${DORY_RELEASE_LIVE_CONFIRMED:-}" = ISOLATED-DORY-RELEASE-USER ] \ + || fail "live qualification requires DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER" +[ "$(uname -s)" = Darwin ] || fail "live release candidate requires macOS" +case "$REQUIRED_ARCH" in + arm64|x86_64) ;; + *) fail "DORY_RELEASE_LIVE_REQUIRED_ARCH must be arm64 or x86_64" ;; +esac +case "$REQUIRE_NOTARIZED:$REQUIRE_PHYSICAL_INTEL" in + 0:0|0:1|1:0|1:1) ;; + *) fail "live-gate boolean settings must be 0 or 1" ;; +esac +case "$RUN_PHYSICAL_SLEEP" in 0|1) ;; + *) fail "DORY_RELEASE_RUN_PHYSICAL_SLEEP must be 0 or 1" ;; +esac +[ "$RUN_PHYSICAL_SLEEP" = 0 ] || [ "$REQUIRED_ARCH:$REQUIRE_NOTARIZED" = arm64:1 ] \ + || fail "physical sleep/wake is valid only for the notarized Apple-silicon candidate" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || fail "live qualification requires an exact source commit" +case "$LOG_ROOT" in /*) ;; *) fail "release evidence root must be absolute" ;; esac +case "$LOG_ROOT" in /|"$HOME"|"$ROOT") fail "unsafe release evidence root: $LOG_ROOT" ;; esac +[ ! -e "$LOG_ROOT" ] && [ ! -L "$LOG_ROOT" ] \ + || fail "release evidence root already exists or is indirect: $LOG_ROOT" +is_digest_reference "$FIXTURE_IMAGE" \ + || fail "DORY_RELEASE_FIXTURE_IMAGE must be an exact digest reference" +is_digest_reference "$NONNATIVE_BUILD_IMAGE" \ + || fail "DORY_RELEASE_NONNATIVE_BUILD_IMAGE must be an exact digest reference" +if [ "$RUN_PHYSICAL_SLEEP" = 1 ]; then + [ -n "$CORPORATE_DNS" ] \ + || fail "DORY_RELEASE_CORPORATE_DNS_SERVER is required for physical release qualification" + [ -n "$CORPORATE_PROBE_HOST" ] \ + || fail "DORY_RELEASE_CORPORATE_VPN_PROBE_HOST is required for physical release qualification" + [ -n "$CORPORATE_PROBE_URL" ] \ + || fail "DORY_RELEASE_CORPORATE_VPN_PROBE_URL is required for physical release qualification" + [ -n "$TAILSCALE_EXIT_NODE" ] \ + || fail "DORY_RELEASE_TAILSCALE_EXIT_NODE is required for physical release qualification" +fi +[ "$(uname -m)" = "$REQUIRED_ARCH" ] \ + || fail "candidate requires physical $REQUIRED_ARCH hardware; this host is $(uname -m)" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || fail "Hypervisor.framework is unavailable (hosted/nested macOS runners cannot satisfy the live gate)" +if [ "$REQUIRED_ARCH" = arm64 ]; then + [ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || fail "nested Virtualization.framework host detected; physical hardware is required" + case "$(sysctl -n hw.model 2>/dev/null || printf unknown)" in + VirtualMac*) fail "VirtualMac hosts do not qualify as physical release hardware" ;; + esac + [ "${DORY_RELEASE_PHYSICAL_ARM64_CONFIRMED:-0}" = 1 ] \ + || fail "physical Apple-silicon host facts were not independently recorded" + [ -n "$EXTERNAL_VOLUME_ROOT" ] \ + || fail "DORY_RELEASE_EXTERNAL_VOLUME_ROOT must name dedicated writable external APFS test media" + [ -d "$EXTERNAL_VOLUME_ROOT" ] \ + || fail "external APFS release test root is unavailable: $EXTERNAL_VOLUME_ROOT" + is_digest_reference "$LOCK_IMAGE" \ + || fail "DORY_RELEASE_LOCK_IMAGE must be a digest-pinned image containing python3" + is_digest_reference "$SSH_CLIENT_IMAGE" \ + || fail "DORY_RELEASE_SSH_CLIENT_IMAGE must be a digest-pinned image containing ssh-add" + for asset in "$DESKTOP_KERNEL" "$DESKTOP_DEBIAN_ROOTFS" \ + "$DESKTOP_UBUNTU_ROOTFS" "$DESKTOP_KALI_ROOTFS" "$DESKTOP_DEBIAN_UPDATE" \ + "$DESKTOP_UBUNTU_UPDATE" "$DESKTOP_KALI_UPDATE"; do + [ -f "$asset" ] && [ ! -L "$asset" ] && [ -s "$asset" ] \ + || fail "same-commit desktop release asset is unavailable or indirect: $asset" + done + [ -n "$DESKTOP_VERSION" ] || fail "DORY_RELEASE_DESKTOP_VERSION is required" + [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ] \ + || fail "physical release qualification requires a live SSH_AUTH_SOCK" + ssh-add -L >/dev/null 2>&1 \ + || fail "physical release qualification requires at least one loaded SSH-agent identity" +fi +[ -d "$APP" ] && [ ! -L "$APP" ] \ + || fail "candidate app is unavailable or indirect: $APP" +for helper in \ + "$EXECUTABLE" "$DORY_CLI" "$DOCKER_CLI" \ + "$APP/Contents/Helpers/dory-doctor" "$APP/Contents/Helpers/dorydctl" \ + "$APP/Contents/Helpers/doryd" "$APP/Contents/Helpers/dory-hv" \ + "$APP/Contents/Helpers/dory-vmm"; do + [ -f "$helper" ] && [ ! -L "$helper" ] && [ -x "$helper" ] \ + || fail "candidate executable is unavailable or indirect: $helper" +done +codesign --verify --strict --deep "$APP" || fail "candidate app signature is invalid" +if [ "$REQUIRE_NOTARIZED" = 1 ]; then + xcrun stapler validate "$APP" || fail "candidate app has no valid notarization ticket" + assessment="$(spctl --assess --type execute --verbose=4 "$APP" 2>&1)" \ + || fail "Gatekeeper rejected the candidate app: $assessment" + grep -q '^source=Notarized Developer ID$' <<< "$assessment" \ + || fail "candidate is not accepted as Notarized Developer ID" +fi +if [ "$REQUIRE_PHYSICAL_INTEL" = 1 ]; then + [ "$REQUIRED_ARCH" = x86_64 ] || fail "the physical Intel readiness gate requires x86_64" + [ "${READINESS_PHYSICAL_INTEL_CONFIRMED:-0}" = 1 ] \ + || fail "Intel host facts were not independently recorded before the live gate" +fi + +if launchctl print "$SERVICE" >/dev/null 2>&1; then + fail "dev.dory.doryd is already loaded; use a clean dedicated release user" +fi +[ ! -e "$PLIST" ] && [ ! -L "$PLIST" ] \ + || fail "existing Dory LaunchAgent would be overwritten: $PLIST" +[ ! -e "$STATE" ] && [ ! -L "$STATE" ] \ + || fail "existing Dory state would be touched: $STATE" +[ ! -e "$APP_SUPPORT" ] && [ ! -L "$APP_SUPPORT" ] \ + || fail "existing Dory application state would be touched: $APP_SUPPORT" +[ ! -e "$PREF_PLIST" ] && [ ! -L "$PREF_PLIST" ] \ + || fail "existing Dory preference file would be touched: $PREF_PLIST" +if defaults read "$PREF_DOMAIN" >/dev/null 2>&1; then + fail "existing Dory preferences would be touched; use a clean dedicated release user" +fi +for process in Dory doryd dory-hv dory-vmm; do + if pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1; then + fail "$process is already running; use a clean dedicated release user" + fi +done +for profile in "${PROFILE_PATHS[@]}"; do + [ ! -L "$profile" ] || fail "shell profile must not be a symlink on the release user: $profile" + if [ -f "$profile" ]; then PROFILE_EXISTED+=(1); else PROFILE_EXISTED+=(0); fi + if [ -f "$profile" ] && grep -Fq '# >>> dory cli >>>' "$profile"; then + fail "existing Dory shell integration would be touched: $profile" + fi +done +if "$DOCKER_CLI" context inspect dory >/dev/null 2>&1; then + fail "existing Docker context 'dory' would be touched" +fi +PREVIOUS_CONTEXT="$($DOCKER_CLI context show 2>/dev/null || printf default)" +[ -n "$PREVIOUS_CONTEXT" ] || PREVIOUS_CONTEXT=default + +mkdir -p "$LOG_ROOT" +[ -d "$LOG_ROOT" ] && [ ! -L "$LOG_ROOT" ] \ + || fail "release evidence root changed while it was being prepared" +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true +defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool false +if [ "$REQUIRED_ARCH" = arm64 ]; then + defaults write "$PREF_DOMAIN" dory.experimentalGPU -bool true + defaults write "$PREF_DOMAIN" dory.rosettaX86Enabled -bool true + EXPECTED_GPU=venus + EXPECTED_AMD64=1 +else + defaults write "$PREF_DOMAIN" dory.experimentalGPU -bool false + defaults write "$PREF_DOMAIN" dory.rosettaX86Enabled -bool false + EXPECTED_GPU=off + EXPECTED_AMD64=0 +fi + +"$EXECUTABLE" >"$LOG_ROOT/app.stdout.log" 2>"$LOG_ROOT/app.stderr.log" & +APP_PID=$! + +ready=0 +for _ in $(seq 1 360); do + kill -0 "$APP_PID" 2>/dev/null || { + tail -100 "$LOG_ROOT/app.stderr.log" >&2 || true + fail "candidate app exited before its Docker socket became ready" + } + if [ -S "$STATE/dory.sock" ] \ + && curl -fsS --max-time 2 --unix-socket "$STATE/dory.sock" http://d/_ping >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.5 +done +[ "$ready" = 1 ] || fail "candidate app did not boot its bundled engine within 180 seconds" +[ "$(stat -f %u "$STATE/dory.sock")" = "$(id -u)" ] \ + || fail "candidate Docker socket is not owned by the release user" +for image in "$FIXTURE_IMAGE" "$NONNATIVE_BUILD_IMAGE"; do + "$DOCKER_CLI" -H "unix://$STATE/dory.sock" image inspect "$image" >/dev/null 2>&1 \ + || fail "required offline release fixture is missing: $image" +done +"$DOCKER_CLI" buildx version >/dev/null \ + || fail "candidate did not install its bundled Docker Buildx plugin on the clean release account" + +launch_state="$(launchctl print "$SERVICE")" || fail "candidate app did not load dev.dory.doryd" +printf '%s\n' "$launch_state" > "$LOG_ROOT/launchctl-print.txt" +grep -Fq "program = $APP/Contents/Helpers/doryd" <<< "$launch_state" \ + || fail "loaded daemon does not come from the release candidate app" +grep -Eq 'exit timeout = 45([[:space:]]|$)' <<< "$launch_state" \ + || fail "loaded daemon does not honor the 45-second graceful shutdown contract" +[ "$(/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:DORYD_GPU' "$PLIST")" = "$EXPECTED_GPU" ] \ + || fail "candidate LaunchAgent did not persist DORYD_GPU=$EXPECTED_GPU" +[ "$(/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:DORYD_AMD64' "$PLIST")" = "$EXPECTED_AMD64" ] \ + || fail "candidate LaunchAgent did not persist DORYD_AMD64=$EXPECTED_AMD64" +grep -Eq "DORYD_GPU[^[:alnum:]_]+$EXPECTED_GPU([^[:alnum:]_]|$)" <<< "$launch_state" \ + || fail "loaded daemon environment does not contain DORYD_GPU=$EXPECTED_GPU" +grep -Eq "DORYD_AMD64[^[:alnum:]_]+$EXPECTED_AMD64([^[:alnum:]_]|$)" <<< "$launch_state" \ + || fail "loaded daemon environment does not contain DORYD_AMD64=$EXPECTED_AMD64" + +hv_pid_list="$(pgrep -u "$(id -u)" -x dory-hv || true)" +hv_pid_count="$(printf '%s\n' "$hv_pid_list" | awk 'NF { count++ } END { print count + 0 }')" +[ "$hv_pid_count" -eq 1 ] \ + || fail "expected exactly one candidate dory-hv helper, found $hv_pid_count" +hv_pid="$(printf '%s\n' "$hv_pid_list" | awk 'NF { print; exit }')" +hv_command="$(ps -ww -p "$hv_pid" -o command=)" +printf '%s\n' "$hv_command" > "$LOG_ROOT/dory-hv-command.txt" +grep -Fq "$APP/Contents/Helpers/dory-hv" <<< "$hv_command" \ + || fail "running dory-hv helper does not come from the candidate app: $hv_command" + +if [ "$REQUIRED_ARCH" = arm64 ]; then + gpu_kernel="$STATE/hv/assets/dory-hv-kernel-gpu-arm64" + hv_log="$STATE/hv/dory-hv.log" + [ -s "$gpu_kernel" ] || fail "GPU-specific candidate kernel was not prepared at $gpu_kernel" + grep -Fq -- "--kernel $gpu_kernel" <<< "$hv_command" \ + || fail "dory-hv did not select the prepared GPU kernel: $hv_command" + grep -Eq -- '(^|[[:space:]])--gpu[[:space:]]+venus([[:space:]]|$)' <<< "$hv_command" \ + || fail "dory-hv was not launched with --gpu venus: $hv_command" + grep -Eq -- '(^|[[:space:]])--amd64([[:space:]]|$)' <<< "$hv_command" \ + || fail "dory-hv was not launched with non-native amd64 support: $hv_command" + + renderer_ready=0 + for _ in $(seq 1 120); do + if [ -f "$hv_log" ] \ + && grep -F 'experimental gpu=venus: attached virtio-gpu with virglrenderer ' "$hv_log" \ + | grep -Fq ' and MoltenVK ICD '; then + renderer_ready=1 + break + fi + sleep 0.5 + done + [ "$renderer_ready" = 1 ] \ + || fail "dory-hv log does not prove that virglrenderer and MoltenVK attached in Venus mode" + + "$DOCKER_CLI" -H "unix://$STATE/dory.sock" run --rm \ + --device /dev/dri/renderD128 "$FIXTURE_IMAGE" \ + sh -c 'test -c /dev/dri/renderD128' + + binfmt_ready=0 + for _ in $(seq 1 90); do + if "$DOCKER_CLI" -H "unix://$STATE/dory.sock" run --rm --privileged "$FIXTURE_IMAGE" \ + sh -c 'test -e /proc/sys/fs/binfmt_misc/FEX-x86_64 && grep -qx enabled /proc/sys/fs/binfmt_misc/FEX-x86_64 && grep -qx "flags: POCF" /proc/sys/fs/binfmt_misc/FEX-x86_64' \ + >/dev/null 2>&1; then + binfmt_ready=1 + break + fi + sleep 1 + done + [ "$binfmt_ready" = 1 ] \ + || fail "candidate did not register seccomp-correct FEX-x86_64 after the persisted amd64 opt-in" + + DORY_SOCK="$STATE/dory.sock" DORY_DOCKER_BIN="$DOCKER_CLI" \ + READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE" \ + scripts/nonnative-build-smoke.sh \ + --target amd64 --socket "$STATE/dory.sock" --docker "$DOCKER_CLI" \ + --image "$NONNATIVE_BUILD_IMAGE" + + scripts/machine-resource-reconfiguration-gate.sh \ + --ctl "$APP/Contents/Helpers/dorydctl" \ + --kernel "$APP/Contents/Resources/dory-hv-kernel-arm64" \ + --rootfs "$APP/Contents/Resources/dory-machine-rootfs-arm64.ext4" \ + --workroot "$LOG_ROOT/machine-resource" \ + --confirm ISOLATED-DORY-MACHINE-RESOURCES + + scripts/desktop-linux-live-gate.sh \ + --ctl "$APP/Contents/Helpers/dorydctl" \ + --kernel "$DESKTOP_KERNEL" \ + --debian-rootfs "$DESKTOP_DEBIAN_ROOTFS" \ + --ubuntu-rootfs "$DESKTOP_UBUNTU_ROOTFS" \ + --kali-rootfs "$DESKTOP_KALI_ROOTFS" \ + --debian-update "$DESKTOP_DEBIAN_UPDATE" \ + --ubuntu-update "$DESKTOP_UBUNTU_UPDATE" \ + --kali-update "$DESKTOP_KALI_UPDATE" \ + --version "$DESKTOP_VERSION" \ + --workroot "$LOG_ROOT/desktop-linux" \ + --confirm EXACT-CANDIDATE-DESKTOPS + + scripts/sandbox-security-gate.sh \ + --dory "$DORY_CLI" \ + --workroot "$LOG_ROOT/sandbox-security" + + scripts/external-volume-bind-gate.sh \ + --socket "$STATE/dory.sock" \ + --docker "$DOCKER_CLI" \ + --dory "$DORY_CLI" \ + --state-dir "$STATE/hv" \ + --path "$EXTERNAL_VOLUME_ROOT" \ + --image "$FIXTURE_IMAGE" \ + --workroot "$LOG_ROOT/external-volume" \ + --confirm ISOLATED-EXTERNAL-APFS-BIND \ + --disconnect-confirm DISCONNECT-RECONNECT-DEDICATED-APFS + + scripts/bind-advisory-lock-gate.sh \ + --socket "$STATE/dory.sock" \ + --docker "$DOCKER_CLI" \ + --image "$LOCK_IMAGE" \ + --workroot "$LOG_ROOT/bind-advisory-lock" \ + --confirm ISOLATED-DORY-BIND-LOCKS + + scripts/ssh-agent-forwarding-gate.sh \ + --socket "$STATE/dory.sock" \ + --docker "$DOCKER_CLI" \ + --image "$SSH_CLIENT_IMAGE" \ + --workroot "$LOG_ROOT/ssh-agent" + + if [ "$RUN_PHYSICAL_SLEEP" = 1 ]; then + DORY_NETWORK_INTEGRITY_SOURCE_COMMIT="$SOURCE_COMMIT" \ + DORY_NETWORK_INTEGRITY_IMAGE="$FIXTURE_IMAGE" \ + scripts/host-network-integrity-gate.sh \ + --socket "$STATE/dory.sock" \ + --docker "$DOCKER_CLI" \ + --app "$APP" \ + --cycles 5 \ + --auto-wake-seconds 30 \ + --workroot "$LOG_ROOT/sleep-wake" \ + --require-vpn \ + --custom-dns "$CORPORATE_DNS" \ + --probe-host "$CORPORATE_PROBE_HOST" \ + --probe-url "$CORPORATE_PROBE_URL" \ + --tailscale-exit-node "$TAILSCALE_EXIT_NODE" \ + --confirm-route-churn VPN-ROUTE-CHURN \ + --confirm-physical-sleep SLEEP-AND-WAKE-THIS-MAC + fi +fi + +DORY_APP="$APP" \ +DORY_CLI_BIN="$DORY_CLI" \ +DORY_DOCTOR_BIN="$APP/Contents/Helpers/dory-doctor" \ +DORY_DOCKER_BIN="$DOCKER_CLI" \ +DORY_SOCK="$STATE/dory.sock" \ +DORY_ENGINE_SOCK="$STATE/hv/engine.sock" \ +DORY_P0_STOP_WAKE=1 \ +DORY_P0_IMAGE="$FIXTURE_IMAGE" \ +DORY_COMPAT_IMAGE="$FIXTURE_IMAGE" \ + scripts/p0-smoke.sh + +if [ "$REQUIRE_PHYSICAL_INTEL" = 1 ]; then + READINESS_DOCKER_BIN="$DOCKER_CLI" \ + READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE" \ + READINESS_NONNATIVE_BUILD_IMAGE="$NONNATIVE_BUILD_IMAGE" \ + READINESS_WORKDIR="$LOG_ROOT/readiness" \ + DORY_SOCK="$STATE/dory.sock" \ + READINESS_STRICT=1 \ + READINESS_REQUIRE_PHYSICAL_INTEL=1 \ + scripts/readiness.sh --engines dory --strict --require-physical-intel +fi + +{ + echo "source_commit=$SOURCE_COMMIT" + echo "required_arch=$REQUIRED_ARCH" + echo "notarized_required=$REQUIRE_NOTARIZED" + echo "physical_sleep_required=$RUN_PHYSICAL_SLEEP" + echo "app_executable_sha256=$(shasum -a 256 "$EXECUTABLE" | awk '{print $1}')" + echo "docker_sha256=$(shasum -a 256 "$DOCKER_CLI" | awk '{print $1}')" + echo "doryd_sha256=$(shasum -a 256 "$APP/Contents/Helpers/doryd" | awk '{print $1}')" + echo "dory_hv_sha256=$(shasum -a 256 "$APP/Contents/Helpers/dory-hv" | awk '{print $1}')" + echo "dory_vmm_sha256=$(shasum -a 256 "$APP/Contents/Helpers/dory-vmm" | awk '{print $1}')" + echo "fixture_image=$FIXTURE_IMAGE" + echo "nonnative_build_image=$NONNATIVE_BUILD_IMAGE" + echo "p0_smoke=PASS" + echo "live_candidate=PASS" +} > "$LOG_ROOT/live-manifest.txt" +echo "release candidate live smoke: PASS" From 8413a0174fcbceba19dcb1757a59fc0cbadb224a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:35:49 +0000 Subject: [PATCH 122/338] feat(release): verify exact Sparkle update --- scripts/test-verify-sparkle-update.sh | 98 +++++++++++++++ scripts/verify-sparkle-update.sh | 174 ++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100755 scripts/test-verify-sparkle-update.sh create mode 100755 scripts/verify-sparkle-update.sh diff --git a/scripts/test-verify-sparkle-update.sh b/scripts/test-verify-sparkle-update.sh new file mode 100755 index 00000000..fbf7c4fa --- /dev/null +++ b/scripts/test-verify-sparkle-update.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Offline orchestration/key-compatibility regressions for verify-sparkle-update.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-sparkle-verify.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +PRIVATE_KEY='nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=' +PUBLIC_KEY='11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=' +APP="$TMP/Dory.app" +ZIP="$TMP/Dory-0.3.0-app-update.zip" +APPCAST="$TMP/appcast.xml" +mkdir -p "$APP/Contents" +printf 'fixture update\n' > "$ZIP" + +write_plist() { + local key="$1" + cat > "$APP/Contents/Info.plist" < + +SUPublicEDKey$key +PLIST +} + +cat > "$APPCAST" <<'XML' + + + +XML + +sed -i '' \ + 's#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==#tH/4Ma4fh3Sbj27irBE0mHHJ6HbQzFfEZAjFP3GvxgEY7cv6mP1I5AI9d01WdVE9Pfmt9pPbGUaAxFfbOPo/Cg==#' \ + "$APPCAST" + +cat > "$TMP/sign_update" <<'SH' +#!/bin/bash +set -euo pipefail +[ "$1" = --verify ] +[ "$2" = --ed-key-file ] +[ "$3" = - ] +[ "$4" = "$EXPECTED_ZIP" ] +[ -n "$5" ] +[ "$(cat)" = "$EXPECTED_PRIVATE_KEY" ] +SH +chmod +x "$TMP/sign_update" + +write_plist "$PUBLIC_KEY" +scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$APPCAST" >/dev/null + +EXPECTED_ZIP="$ZIP" EXPECTED_PRIVATE_KEY="$PRIVATE_KEY" \ +DORY_SPARKLE_SIGN_UPDATE="$TMP/sign_update" DORY_SPARKLE_PRIVATE_KEY="$PRIVATE_KEY" \ + scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$APPCAST" >/dev/null + +write_plist 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +if EXPECTED_ZIP="$ZIP" EXPECTED_PRIVATE_KEY="$PRIVATE_KEY" \ + DORY_SPARKLE_SIGN_UPDATE="$TMP/sign_update" DORY_SPARKLE_PRIVATE_KEY="$PRIVATE_KEY" \ + scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$APPCAST" >/dev/null 2>&1; then + echo "test-verify-sparkle-update: accepted a private/public key mismatch" >&2 + exit 1 +fi + +printf 'tampered update\n' >> "$ZIP" +if scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$APPCAST" >/dev/null 2>&1; then + echo "test-verify-sparkle-update: accepted a tampered update ZIP" >&2 + exit 1 +fi +printf 'fixture update\n' > "$ZIP" + +sed 's/Dory-0.3.0-app-update.zip/wrong.zip/' "$APPCAST" > "$TMP/wrong-appcast.xml" +write_plist "$PUBLIC_KEY" +if EXPECTED_ZIP="$ZIP" EXPECTED_PRIVATE_KEY="$PRIVATE_KEY" \ + DORY_SPARKLE_SIGN_UPDATE="$TMP/sign_update" DORY_SPARKLE_PRIVATE_KEY="$PRIVATE_KEY" \ + scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$TMP/wrong-appcast.xml" >/dev/null 2>&1; then + echo "test-verify-sparkle-update: accepted an appcast pointing at another artifact" >&2 + exit 1 +fi + +sed 's#https://github.com/Augani/dory#https://example.invalid/Augani/dory#' \ + "$APPCAST" > "$TMP/wrong-host-appcast.xml" +if scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$TMP/wrong-host-appcast.xml" \ + >/dev/null 2>&1; then + echo "test-verify-sparkle-update: accepted an appcast on an untrusted host" >&2 + exit 1 +fi + +sed 's/length="15"/length="14"/' "$APPCAST" > "$TMP/wrong-length-appcast.xml" +if scripts/verify-sparkle-update.sh "$APP" "$ZIP" "$TMP/wrong-length-appcast.xml" \ + >/dev/null 2>&1; then + echo "test-verify-sparkle-update: accepted a mismatched enclosure length" >&2 + exit 1 +fi + +bash -n scripts/verify-sparkle-update.sh +echo "test-verify-sparkle-update: PASS" diff --git a/scripts/verify-sparkle-update.sh b/scripts/verify-sparkle-update.sh new file mode 100755 index 00000000..e2efb71b --- /dev/null +++ b/scripts/verify-sparkle-update.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# Prove that the final Sparkle ZIP is signed by the private key corresponding to the public key +# embedded in the exact candidate app. This closes the gap between "64 bytes of base64" and an +# update that Sparkle will actually accept. +set -euo pipefail + +APP="${1:?usage: verify-sparkle-update.sh }" +UPDATE_ZIP="${2:?usage: verify-sparkle-update.sh }" +APPCAST="${3:?usage: verify-sparkle-update.sh }" + +fail() { + echo "Sparkle verification error: $*" >&2 + exit 1 +} + +find_sign_update() { + local candidate found="" + if [ -n "${DORY_SPARKLE_SIGN_UPDATE:-}" ]; then + [ -f "$DORY_SPARKLE_SIGN_UPDATE" ] && [ ! -L "$DORY_SPARKLE_SIGN_UPDATE" ] \ + && [ -x "$DORY_SPARKLE_SIGN_UPDATE" ] \ + || fail "DORY_SPARKLE_SIGN_UPDATE is unavailable or indirect" + printf '%s\n' "$DORY_SPARKLE_SIGN_UPDATE" + return 0 + fi + for candidate in \ + .build/artifacts/sparkle/Sparkle/bin/sign_update \ + SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update; do + if [ -f "$candidate" ] && [ ! -L "$candidate" ] && [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then + found="$(find "$HOME/Library/Developer/Xcode/DerivedData" \ + -path '*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update' \ + -type f -perm -111 2>/dev/null | sort | tail -n 1 || true)" + fi + [ -n "$found" ] || fail "Sparkle sign_update was not resolved by the release build" + printf '%s\n' "$found" +} + +[ -d "$APP" ] && [ ! -L "$APP" ] || fail "candidate app is missing or indirect: $APP" +[ -f "$APP/Contents/Info.plist" ] && [ ! -L "$APP/Contents/Info.plist" ] \ + || fail "candidate Info.plist is missing or indirect" +[ -f "$UPDATE_ZIP" ] && [ ! -L "$UPDATE_ZIP" ] && [ -s "$UPDATE_ZIP" ] \ + || fail "update ZIP is missing, indirect, or empty: $UPDATE_ZIP" +[ -f "$APPCAST" ] && [ ! -L "$APPCAST" ] && [ -s "$APPCAST" ] \ + || fail "appcast is missing, indirect, or empty: $APPCAST" + +SIGNATURE="$(python3 - "$APPCAST" "$(basename "$UPDATE_ZIP")" \ + "$(stat -f %z "$UPDATE_ZIP")" <<'PY' +import base64 +import os +import re +import sys +import urllib.parse +import xml.etree.ElementTree as ET + +path, expected_name, expected_length = sys.argv[1:4] +sparkle = "http://www.andymatuschak.org/xml-namespaces/sparkle" +items = ET.parse(path).getroot().findall("./channel/item") +if len(items) != 1: + raise SystemExit("appcast must contain exactly one current item") +item = items[0] +enclosure = item.find("enclosure") +if enclosure is None: + raise SystemExit("current appcast item has no enclosure") +parsed = urllib.parse.urlparse(enclosure.attrib.get("url", "")) +name = os.path.basename(parsed.path) +if name != expected_name: + raise SystemExit(f"appcast encloses {name!r}, expected {expected_name!r}") +match = re.fullmatch(r"Dory-(.+)-app-update\.zip", expected_name) +if match is None: + raise SystemExit("update ZIP name does not identify a Dory release") +expected_path = f"/Augani/dory/releases/download/v{match.group(1)}/{expected_name}" +if ( + parsed.scheme != "https" + or parsed.netloc != "github.com" + or parsed.path != expected_path + or parsed.params + or parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None +): + raise SystemExit("appcast enclosure URL is not the canonical Dory release asset") +if enclosure.attrib.get("length") != expected_length: + raise SystemExit("appcast enclosure length does not match the exact update ZIP") +signature = enclosure.attrib.get(f"{{{sparkle}}}edSignature", "") +if not signature: + raise SystemExit("current appcast enclosure has no EdDSA signature") +try: + decoded = base64.b64decode(signature, validate=True) +except ValueError as error: + raise SystemExit(f"appcast EdDSA signature is not valid base64: {error}") +if len(decoded) != 64: + raise SystemExit("appcast EdDSA signature must decode to 64 bytes") +print(signature) +PY +)" + +EMBEDDED_PUBLIC_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP/Contents/Info.plist" 2>/dev/null || true)" +[ -n "$EMBEDDED_PUBLIC_KEY" ] || fail "candidate app has no SUPublicEDKey" + +# The embedded public key is the runtime trust anchor. Verify the exact final ZIP directly so local +# keychain-backed releases do not have to export their private key merely to prove the appcast. +if ! xcrun swift - "$UPDATE_ZIP" "$SIGNATURE" "$EMBEDDED_PUBLIC_KEY" <<'SWIFT' +import CryptoKit +import Foundation + +guard CommandLine.arguments.count == 4, + let signature = Data(base64Encoded: CommandLine.arguments[2]), + let publicKeyData = Data(base64Encoded: CommandLine.arguments[3]) else { + fatalError("Sparkle signature or public key is not valid base64") +} +let payload = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]), options: .mappedIfSafe) +let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) +guard publicKey.isValidSignature(signature, for: payload) else { + fatalError("Sparkle signature does not match the exact update ZIP and embedded public key") +} +SWIFT +then + fail "embedded Sparkle public key rejected the final update ZIP signature" +fi + +if [ -z "${DORY_SPARKLE_PRIVATE_KEY:-}" ]; then + echo "Sparkle update verification: PASS (signature valid against embedded public key)" + exit 0 +fi + +SIGN_UPDATE="$(find_sign_update)" +printf '%s' "$DORY_SPARKLE_PRIVATE_KEY" \ + | "$SIGN_UPDATE" --verify --ed-key-file - "$UPDATE_ZIP" "$SIGNATURE" >/dev/null \ + || fail "Sparkle private key rejected the final update ZIP signature" + +DERIVED_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/dory-sparkle-public.XXXXXX")" +trap 'rm -f "$DERIVED_OUTPUT"' EXIT +if ! xcrun swift - > "$DERIVED_OUTPUT" <<'SWIFT' +import CryptoKit +import Foundation + +guard let encoded = ProcessInfo.processInfo.environment["DORY_SPARKLE_PRIVATE_KEY"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let secret = Data(base64Encoded: encoded) else { + fatalError("Sparkle private key is not valid base64") +} + +let publicKey: Data +if secret.count == 32 { + do { + publicKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secret) + .publicKey.rawRepresentation + } catch { + fatalError("Sparkle private seed could not derive an Ed25519 public key") + } +} else if secret.count == 96 { + // Sparkle's legacy exported format is a 64-byte private key followed by its 32-byte public key. + publicKey = secret.suffix(32) +} else { + fatalError("Sparkle private key must decode to 32 or 96 bytes") +} +print(publicKey.base64EncodedString()) +SWIFT +then + fail "could not derive the Sparkle public key" +fi +DERIVED_PUBLIC_KEY="$(cat "$DERIVED_OUTPUT")" +rm -f "$DERIVED_OUTPUT" +trap - EXIT + +[ "$DERIVED_PUBLIC_KEY" = "$EMBEDDED_PUBLIC_KEY" ] \ + || fail "configured Sparkle private key does not match the candidate app's SUPublicEDKey" + +echo "Sparkle update verification: PASS (signature valid; embedded public key matches)" From 9ab62d2b4d37df0b68c42b3884b779334cc6ab60 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:37:46 +0000 Subject: [PATCH 123/338] feat(release): qualify live engine migration --- .github/scripts/test-live-migration-gate.py | 61 +++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/live-orbstack-migration-smoke.sh | 129 ++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 .github/scripts/test-live-migration-gate.py create mode 100755 scripts/live-orbstack-migration-smoke.sh diff --git a/.github/scripts/test-live-migration-gate.py b/.github/scripts/test-live-migration-gate.py new file mode 100644 index 00000000..bb771826 --- /dev/null +++ b/.github/scripts/test-live-migration-gate.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the isolated live migration gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "live-orbstack-migration-smoke.sh" + + +class LiveMigrationGateTests(unittest.TestCase): + def test_contract_is_exact_and_owned(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-LIVE-MIGRATION", + "live migration base image must be an exact digest reference", + "DORY_LIVE_DOCKER_BIN is required", + "live migration Docker CLI is unavailable or indirect", + "source and target sockets must differ", + "socket is not owned by the release user", + "DORY_LIVE_ORBSTACK_MIGRATION_MARKER", + "live migration marker must remain below its private root", + "source_baseline_restored=PASS", + "target_baseline_restored=PASS", + "volume_64mib_checksum=PASS", + "docker_cli_sha256=", + "helper_archive_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:3.20", "DOCKER_HOST=\"unix://$socket\" docker", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_or_docker_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [str(GATE)], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_LIVE_MIGRATION_BASE_IMAGE": "example.invalid/alpine@sha256:" + "a" * 64, + "DORY_LIVE_DOCKER_BIN": "/missing/docker", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("DORY_LIVE_MIGRATION_CONFIRMED", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 418886c2..1d282234 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -347,6 +347,7 @@ jobs: python3 .github/scripts/test-host-network-integrity-gate.py python3 .github/scripts/test-readiness-gate.py python3 .github/scripts/test-release-candidate-live-smoke.py + python3 .github/scripts/test-live-migration-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e02dbdc5..1ffbcb0d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,6 +48,7 @@ jobs: python3 .github/scripts/test-host-network-integrity-gate.py python3 .github/scripts/test-readiness-gate.py python3 .github/scripts/test-release-candidate-live-smoke.py + python3 .github/scripts/test-live-migration-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/live-orbstack-migration-smoke.sh b/scripts/live-orbstack-migration-smoke.sh new file mode 100755 index 00000000..f7c06144 --- /dev/null +++ b/scripts/live-orbstack-migration-smoke.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Real, non-destructive migration smoke. Creates uniquely named fixtures in OrbStack, exercises +# Dory's production migration code through the unit-test host, and removes only those fixtures. +set -euo pipefail +cd "$(dirname "$0")/.." + +SOURCE_SOCKET="${DORY_LIVE_SOURCE_SOCKET:-$HOME/.orbstack/run/docker.sock}" +TARGET_SOCKET="${DORY_LIVE_TARGET_SOCKET:-$HOME/.dory/dory.sock}" +# Keep the fixture image free of Config.Volumes. The gate creates and verifies its own two named +# volumes; an image-declared anonymous volume would add unrelated daemon-owned state and make exact +# source-baseline cleanup depend on Docker's anonymous-volume retention policy. +BASE_IMAGE="${DORY_LIVE_MIGRATION_BASE_IMAGE:-}" +DOCKER_BIN="${DORY_LIVE_DOCKER_BIN:-}" +EVIDENCE_DIR="${DORY_LIVE_MIGRATION_EVIDENCE_DIR:-}" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +MARKER_ROOT="${EVIDENCE_DIR:-${TMPDIR:-/tmp}}" +MARKER="${DORY_LIVE_ORBSTACK_MIGRATION_MARKER:-$MARKER_ROOT/dory-live-migration-$RUN_ID.marker}" +ACK="$MARKER.passed" +HELPER_ARCHIVE="${DORY_LIVE_MIGRATION_HELPER_ARCHIVE:-}" +HELPER_METADATA="${DORY_LIVE_MIGRATION_HELPER_METADATA:-}" +HELPER_DIR="" + +[ "${DORY_LIVE_MIGRATION_CONFIRMED:-}" = ISOLATED-DORY-LIVE-MIGRATION ] || { + echo "live migration requires DORY_LIVE_MIGRATION_CONFIRMED=ISOLATED-DORY-LIVE-MIGRATION" >&2 + exit 2 +} +printf '%s\n' "$BASE_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' || { + echo "live migration base image must be an exact digest reference" >&2 + exit 2 + } +[ -n "$DOCKER_BIN" ] || { echo "DORY_LIVE_DOCKER_BIN is required" >&2; exit 2; } +case "$DOCKER_BIN" in /*) ;; *) echo "live migration Docker CLI must be absolute" >&2; exit 2 ;; esac +[ -f "$DOCKER_BIN" ] && [ ! -L "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { + echo "live migration Docker CLI is unavailable or indirect" >&2 + exit 2 +} +[ -S "$SOURCE_SOCKET" ] || { echo "source socket is not ready: $SOURCE_SOCKET" >&2; exit 1; } +[ -S "$TARGET_SOCKET" ] || { echo "Dory socket is not ready: $TARGET_SOCKET" >&2; exit 1; } +[ "$SOURCE_SOCKET" != "$TARGET_SOCKET" ] \ + || { echo "live migration source and target sockets must differ" >&2; exit 2; } +for socket in "$SOURCE_SOCKET" "$TARGET_SOCKET"; do + [ "$(stat -f %u "$socket")" = "$(id -u)" ] \ + || { echo "live migration socket is not owned by the release user: $socket" >&2; exit 2; } +done +DOCKER_HOST="unix://$SOURCE_SOCKET" "$DOCKER_BIN" image inspect "$BASE_IMAGE" >/dev/null 2>&1 \ + || { echo "source fixture image is missing: $BASE_IMAGE" >&2; exit 1; } + +case "$BASE_IMAGE$SOURCE_SOCKET$TARGET_SOCKET" in + *$'\n'*) echo "live migration inputs must not contain newlines" >&2; exit 1 ;; +esac +case "$MARKER_ROOT" in /*) ;; *) echo "live migration marker root must be absolute" >&2; exit 2 ;; esac +case "$MARKER_ROOT" in /|"$HOME"|"$(pwd)") echo "unsafe live migration marker root" >&2; exit 2 ;; esac +[ ! -L "$MARKER_ROOT" ] || { echo "live migration marker root must not be a symlink" >&2; exit 2; } +case "$MARKER" in "$MARKER_ROOT"/*) ;; + *) echo "live migration marker must remain below its private root" >&2; exit 2 ;; +esac +if [ -n "$EVIDENCE_DIR" ]; then + case "$EVIDENCE_DIR" in + /*) ;; + *) echo "live migration evidence directory must be absolute" >&2; exit 1 ;; + esac + case "$EVIDENCE_DIR" in /|"$HOME"|"$(pwd)") echo "unsafe live migration evidence directory" >&2; exit 2 ;; esac + [ ! -e "$EVIDENCE_DIR" ] && [ ! -L "$EVIDENCE_DIR" ] \ + || { echo "live migration evidence directory already exists: $EVIDENCE_DIR" >&2; exit 1; } + mkdir -p "$EVIDENCE_DIR" + [ -d "$EVIDENCE_DIR" ] && [ ! -L "$EVIDENCE_DIR" ] \ + || { echo "live migration evidence directory changed while preparing it" >&2; exit 1; } +fi +[ ! -e "$MARKER" ] && [ ! -L "$MARKER" ] && [ ! -e "$ACK" ] && [ ! -L "$ACK" ] \ + || { echo "stale live migration marker exists: $MARKER" >&2; exit 1; } +cleanup() { + rm -f "$MARKER" "$ACK" + [ -z "$HELPER_DIR" ] || rm -rf "$HELPER_DIR" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +if [ -z "$HELPER_ARCHIVE" ] || [ -z "$HELPER_METADATA" ]; then + HELPER_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dory-live-transfer-helper.XXXXXX")" + HELPER_ARCHIVE="$HELPER_DIR/dory-transfer-helper-image-arm64.tar" + HELPER_METADATA="$HELPER_DIR/dory-transfer-helper-image-arm64.json" + scripts/build-transfer-helper.sh \ + --image-output "$HELPER_ARCHIVE" \ + --image-metadata-output "$HELPER_METADATA" >/dev/null +fi +[ -f "$HELPER_ARCHIVE" ] && [ ! -L "$HELPER_ARCHIVE" ] && [ -s "$HELPER_ARCHIVE" ] \ + || { echo "live migration helper archive is missing or indirect" >&2; exit 1; } +[ -f "$HELPER_METADATA" ] && [ ! -L "$HELPER_METADATA" ] && [ -s "$HELPER_METADATA" ] \ + || { echo "live migration helper metadata is missing or indirect" >&2; exit 1; } +export DORY_LIVE_MIGRATION_HELPER_ARCHIVE="$HELPER_ARCHIVE" +export DORY_LIVE_MIGRATION_HELPER_METADATA="$HELPER_METADATA" +export DORY_LIVE_ORBSTACK_MIGRATION_MARKER="$MARKER" +printf '%s\n%s\n%s\n%s\n%s\n' \ + "$BASE_IMAGE" "$SOURCE_SOCKET" "$TARGET_SOCKET" "$HELPER_ARCHIVE" "$HELPER_METADATA" > "$MARKER" + +scripts/test.sh app -- -only-testing:DoryTests/MigrationTests +[ "$(cat "$ACK" 2>/dev/null || true)" = "passed" ] \ + || { echo "live migration XCTest did not execute the Docker fixture" >&2; exit 1; } + +for socket in "$SOURCE_SOCKET" "$TARGET_SOCKET"; do + leftovers="$(DOCKER_HOST="unix://$socket" "$DOCKER_BIN" ps -aq \ + --filter 'name=dory-migration-live' 2>/dev/null || true)" + [ -z "$leftovers" ] || { echo "owned migration fixture cleanup failed on $socket: $leftovers" >&2; exit 1; } +done +if [ -n "$EVIDENCE_DIR" ]; then + cat > "$EVIDENCE_DIR/manifest.txt.partial" <<'EOF' +status=PASS +production_migration_path=PASS +source_baseline_restored=PASS +target_baseline_restored=PASS +image_transfer=PASS +two_named_volumes=PASS +volume_64mib_checksum=PASS +volume_metadata_symlink_hardlink=PASS +custom_network_ipam=PASS +running_paused_state=PASS +stopped_writable_layer=PASS +fixed_port_handoff=PASS +EOF + { + printf 'base_image=%s\n' "$BASE_IMAGE" + printf 'docker_cli_sha256=%s\n' "$(shasum -a 256 "$DOCKER_BIN" | awk '{print $1}')" + printf 'helper_archive_sha256=%s\n' "$(shasum -a 256 "$HELPER_ARCHIVE" | awk '{print $1}')" + printf 'helper_metadata_sha256=%s\n' "$(shasum -a 256 "$HELPER_METADATA" | awk '{print $1}')" + } >> "$EVIDENCE_DIR/manifest.txt.partial" + mv "$EVIDENCE_DIR/manifest.txt.partial" "$EVIDENCE_DIR/manifest.txt" +fi +echo "live production migration smoke passed" From 7ecca903001b76e6ce156aa31599acc5d2e07d5f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 04:39:49 +0000 Subject: [PATCH 124/338] feat(release): qualify Dev Containers compatibility --- .../test-devcontainers-compatibility-gate.py | 64 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/devcontainers-compatibility-gate.sh | 197 ++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 .github/scripts/test-devcontainers-compatibility-gate.py create mode 100755 scripts/devcontainers-compatibility-gate.sh diff --git a/.github/scripts/test-devcontainers-compatibility-gate.py b/.github/scripts/test-devcontainers-compatibility-gate.py new file mode 100644 index 00000000..8ae695d9 --- /dev/null +++ b/.github/scripts/test-devcontainers-compatibility-gate.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the Dev Containers compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "devcontainers-compatibility-gate.sh" + + +class DevContainersCompatibilityGateTests(unittest.TestCase): + def test_contract_uses_exact_candidate_and_fixture(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-DEVCONTAINERS", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "--image must be an exact digest reference", + "required offline fixture image is missing", + "dist.integrity", + "registry.npmjs.org", + "executed Dev Containers CLI version does not match", + "host_to_container_workspace=PASS", + "container_to_host_workspace=PASS", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + "node_sha256=", + "npm_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ('"image": "alpine:3.22"', "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_or_npm_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--version", "0.87.0", + "--image", "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d282234..6fe41df0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,6 +348,7 @@ jobs: python3 .github/scripts/test-readiness-gate.py python3 .github/scripts/test-release-candidate-live-smoke.py python3 .github/scripts/test-live-migration-gate.py + python3 .github/scripts/test-devcontainers-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1ffbcb0d..e10b07ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,6 +49,7 @@ jobs: python3 .github/scripts/test-readiness-gate.py python3 .github/scripts/test-release-candidate-live-smoke.py python3 .github/scripts/test-live-migration-gate.py + python3 .github/scripts/test-devcontainers-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/devcontainers-compatibility-gate.sh b/scripts/devcontainers-compatibility-gate.sh new file mode 100755 index 00000000..d075fbb9 --- /dev/null +++ b/scripts/devcontainers-compatibility-gate.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# Runs the official Dev Containers CLI against an explicitly empty, disposable Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +VERSION="${DORY_RELEASE_DEVCONTAINERS_VERSION:-0.87.0}" +WORKROOT="" +CONFIRM="" +IMAGE="" + +usage() { + cat <<'EOF' +Usage: scripts/devcontainers-compatibility-gate.sh [required options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate runtime + --version VERSION Exact @devcontainers/cli npm version + --image IMAGE Exact digest-pinned Alpine fixture already present in Dory + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-DEVCONTAINERS + +The gate refuses an engine with any existing container, named volume, or custom network. It creates +one Dev Container, proves host-to-container and container-to-host workspace coherence plus exec, +then returns the engine to the exact empty object baseline. It never uses a user's default socket. +EOF +} + +die() { echo "devcontainers gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-DEVCONTAINERS ] \ + || die "requires --confirm ISOLATED-ENGINE-DEVCONTAINERS" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' \ + || die "--version must be an exact npm semver" +printf '%s\n' "$IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--image must be an exact digest reference" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl node npm python3; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/workspace/.devcontainer" +WORKROOT="$(cd "$WORKROOT" && pwd)" +WORKSPACE="$WORKROOT/workspace" +EVIDENCE="$WORKROOT/evidence" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +docker_e image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "required offline fixture image is missing: $IMAGE" + +npm view --json "@devcontainers/cli@$VERSION" dist.integrity dist.tarball \ + > "$EVIDENCE/npm-package.json" +python3 - "$EVIDENCE/npm-package.json" "$VERSION" <<'PY' +import base64 +import json +import re +import sys +import urllib.parse + +with open(sys.argv[1], encoding="utf-8") as handle: + package = json.load(handle) +if not isinstance(package, dict) or set(package) != {"dist.integrity", "dist.tarball"}: + raise SystemExit("Dev Containers npm package metadata has an unexpected shape") +integrity = package["dist.integrity"] +if not isinstance(integrity, str) or not integrity.startswith("sha512-"): + raise SystemExit("Dev Containers npm package has no SHA-512 integrity") +try: + digest = base64.b64decode(integrity.removeprefix("sha512-"), validate=True) +except ValueError as error: + raise SystemExit(f"Dev Containers npm integrity is invalid: {error}") +if len(digest) != 64: + raise SystemExit("Dev Containers npm integrity is not SHA-512") +url = urllib.parse.urlparse(package["dist.tarball"]) +expected = f"/@devcontainers/cli/-/cli-{sys.argv[2]}.tgz" +if url.scheme != "https" or url.netloc != "registry.npmjs.org" or url.path != expected \ + or url.params or url.query or url.fragment: + raise SystemExit("Dev Containers npm tarball URL is not canonical") +PY + +custom_network_ids() { + docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d' +} +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} + +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing custom networks" + +cat > "$WORKSPACE/.devcontainer/devcontainer.json" < "$WORKSPACE/host-sentinel.txt" + +container_id="" +cleanup() { + set +e + if [ -n "$container_id" ]; then + docker_e rm -f "$container_id" > "$EVIDENCE/container-remove.log" 2>&1 || true + fi + # A failed CLI invocation may have created a labeled container before its ID reached stdout. + docker_e ps -aq --filter "label=devcontainer.local_folder=$WORKSPACE" \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >> "$EVIDENCE/container-remove.log" 2>&1 || true + done + rm -rf "$WORKROOT/.npm-cache" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +export NPM_CONFIG_CACHE="$WORKROOT/.npm-cache" +npm exec --yes --package "@devcontainers/cli@$VERSION" -- devcontainer --version \ + > "$EVIDENCE/cli-version.txt" +grep -Fxq "$VERSION" "$EVIDENCE/cli-version.txt" \ + || die "executed Dev Containers CLI version does not match $VERSION" +npm exec --yes --package "@devcontainers/cli@$VERSION" -- \ + devcontainer up \ + --workspace-folder "$WORKSPACE" \ + --remove-existing-container \ + --log-format json \ + > "$EVIDENCE/up.jsonl" 2> "$EVIDENCE/up.stderr" + +container_id="$(docker_e ps -q | sed '/^$/d')" +[ -n "$container_id" ] || die "Dev Containers CLI created no running container" +[ "$(printf '%s\n' "$container_id" | wc -l | tr -d ' ')" = 1 ] \ + || die "Dev Containers CLI created more than one running container" +docker_e inspect "$container_id" > "$EVIDENCE/container-inspect.json" + +npm exec --yes --package "@devcontainers/cli@$VERSION" -- \ + devcontainer exec --workspace-folder "$WORKSPACE" \ + sh -lc \ + "grep -qx 'host-to-container:$VERSION' host-sentinel.txt && printf 'container-to-host:%s\\n' '$VERSION' > container-sentinel.txt && printf 'exec=PASS\\n'" \ + > "$EVIDENCE/exec.txt" 2> "$EVIDENCE/exec.stderr" +grep -qx 'exec=PASS' "$EVIDENCE/exec.txt" || die "Dev Containers exec proof is missing" +grep -qx "container-to-host:$VERSION" "$WORKSPACE/container-sentinel.txt" \ + || die "container-to-host workspace write was not visible on macOS" + +docker_e rm -f "$container_id" > "$EVIDENCE/container-remove.log" +container_id="" +rm -rf "$WORKROOT/.npm-cache" +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "Dev Containers gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 04:42:28 +0000 Subject: [PATCH 125/338] feat(release): qualify Act compatibility --- .../scripts/test-act-compatibility-gate.py | 63 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/act-compatibility-gate.sh | 180 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 .github/scripts/test-act-compatibility-gate.py create mode 100755 scripts/act-compatibility-gate.sh diff --git a/.github/scripts/test-act-compatibility-gate.py b/.github/scripts/test-act-compatibility-gate.py new file mode 100644 index 00000000..0f52cb96 --- /dev/null +++ b/.github/scripts/test-act-compatibility-gate.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the checksum-pinned act compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "act-compatibility-gate.sh" + + +class ActCompatibilityGateTests(unittest.TestCase): + def test_contract_is_exact_and_offline_at_runtime(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-ACT", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "runner image must be an exact digest reference", + "required offline runner image is missing", + "act_Darwin_$ARCHIVE_ARCH.tar.gz", + "shasum -a 256 -c -", + "verified act archive did not contain a direct executable", + "--container-daemon-socket unix:///var/run/docker.sock", + "--pull=false", + "host_to_runner_workspace=PASS", + "runner_to_host_workspace=PASS", + "act_binary_sha256=", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertNotIn("--pull \\", text) + self.assertNotIn("assert ", text) + + def test_confirmation_fails_before_socket_or_download_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--runner-image", "example.invalid/runner@sha256:" + "a" * 64, + "--workroot", str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fe41df0..a85c9ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,6 +349,7 @@ jobs: python3 .github/scripts/test-release-candidate-live-smoke.py python3 .github/scripts/test-live-migration-gate.py python3 .github/scripts/test-devcontainers-compatibility-gate.py + python3 .github/scripts/test-act-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e10b07ba..8bbe8a7b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,7 @@ jobs: python3 .github/scripts/test-release-candidate-live-smoke.py python3 .github/scripts/test-live-migration-gate.py python3 .github/scripts/test-devcontainers-compatibility-gate.py + python3 .github/scripts/test-act-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/act-compatibility-gate.sh b/scripts/act-compatibility-gate.sh new file mode 100755 index 00000000..774512d6 --- /dev/null +++ b/scripts/act-compatibility-gate.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# Runs a real GitHub Actions workflow through checksum-pinned act on an empty disposable Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +VERSION="${DORY_RELEASE_ACT_VERSION:-0.2.89}" +SHA256="" +RUNNER_IMAGE="${DORY_RELEASE_ACT_RUNNER_IMAGE:-node:20.19.5-bookworm-slim@sha256:9e70124bd00f47dd023e349cd587132ae61892acc0e47ed641416c3e18f401c3}" +WORKROOT="" +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/act-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate runtime + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-ACT + +Options: + --version VERSION Exact nektos/act version (default: 0.2.89) + --sha256 HASH Archive SHA-256 (defaults to the published 0.2.89 Darwin host checksum) + --runner-image REF Digest-pinned runner image + +The host act process talks to the supplied Dory socket. Runner containers receive the daemon's +guest-local unix:///var/run/docker.sock, never the macOS socket path. The gate refuses any existing +container, named volume, or custom network and returns those object counts to zero. +EOF +} + +die() { echo "act compatibility gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --sha256) need_value "$1" "$#"; SHA256="$2"; shift 2 ;; + --runner-image) need_value "$1" "$#"; RUNNER_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-ACT ] || die "requires --confirm ISOLATED-ENGINE-ACT" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--version must be an exact semantic version" +case "$(uname -m)" in + arm64) ARCHIVE_ARCH=arm64; DEFAULT_SHA=48ae218af96725f7635a66de2b87e1e346893b02add0f16b92f560296b2151fc ;; + x86_64) ARCHIVE_ARCH=x86_64; DEFAULT_SHA=41b31488e7c254baec31cce12c7dade3e35973b8a31b9486206ad43f233d814e ;; + *) die "unsupported macOS architecture: $(uname -m)" ;; +esac +if [ -z "$SHA256" ]; then + [ "$VERSION" = 0.2.89 ] \ + || die "--sha256 is required when --version differs from the pinned default" + SHA256="$DEFAULT_SHA" +fi +printf '%s\n' "$SHA256" | grep -Eq '^[0-9a-f]{64}$' || die "--sha256 is invalid" +printf '%s\n' "$RUNNER_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "runner image must be an exact digest reference" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl python3 shasum tar; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/workspace/.github/workflows" "$WORKROOT/download" +WORKROOT="$(cd "$WORKROOT" && pwd)" +WORKSPACE="$WORKROOT/workspace" +EVIDENCE="$WORKROOT/evidence" +DOWNLOAD="$WORKROOT/download" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +docker_e image inspect "$RUNNER_IMAGE" >/dev/null 2>&1 \ + || die "required offline runner image is missing: $RUNNER_IMAGE" +custom_network_ids() { docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d'; } +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +cleanup_objects() { + local ids + ids="$(docker_e ps -aq)"; [ -z "$ids" ] || docker_e rm -f $ids >/dev/null 2>&1 || true + ids="$(docker_e volume ls -q)"; [ -z "$ids" ] || docker_e volume rm -f $ids >/dev/null 2>&1 || true + ids="$(custom_network_ids)"; [ -z "$ids" ] || docker_e network rm $ids >/dev/null 2>&1 || true +} +cleanup() { + set +e + cleanup_objects + rm -rf "$DOWNLOAD" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing custom networks" + +archive="$DOWNLOAD/act.tgz" +url="https://github.com/nektos/act/releases/download/v$VERSION/act_Darwin_$ARCHIVE_ARCH.tar.gz" +curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 "$url" -o "$archive" +printf '%s %s\n' "$SHA256" "$archive" | shasum -a 256 -c - > "$EVIDENCE/archive-checksum.txt" +tar -xzf "$archive" -C "$DOWNLOAD" act +[ -f "$DOWNLOAD/act" ] && [ ! -L "$DOWNLOAD/act" ] && [ -x "$DOWNLOAD/act" ] \ + || die "verified act archive did not contain a direct executable" +"$DOWNLOAD/act" --version > "$EVIDENCE/act-version.txt" +grep -Eq "(^|[[:space:]])v?$VERSION([[:space:]]|$)" "$EVIDENCE/act-version.txt" \ + || die "act binary version differs from the requested release" + +cat > "$WORKSPACE/.github/workflows/dory-act-smoke.yml" <<'YAML' +name: Dory act compatibility +on: workflow_dispatch +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Prove workspace and runner execution + run: | + test "$(cat host-sentinel.txt)" = "host-to-act" + printf 'act-to-host\n' > act-sentinel.txt + printf 'runner-exec=PASS\n' +YAML +printf 'host-to-act\n' > "$WORKSPACE/host-sentinel.txt" + +(cd "$WORKSPACE" && \ + "$DOWNLOAD/act" workflow_dispatch \ + --workflows .github/workflows/dory-act-smoke.yml \ + --job smoke \ + --bind \ + --container-daemon-socket unix:///var/run/docker.sock \ + --platform "ubuntu-latest=$RUNNER_IMAGE" \ + --pull=false \ + --verbose) > "$EVIDENCE/act-run.log" 2> "$EVIDENCE/act-run.stderr" +grep -q 'runner-exec=PASS' "$EVIDENCE/act-run.log" \ + || die "act workflow did not execute its runner step" +grep -qx 'act-to-host' "$WORKSPACE/act-sentinel.txt" \ + || die "act runner write was not visible in the macOS workspace" + +cleanup_objects +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "act gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 04:54:53 +0000 Subject: [PATCH 126/338] feat(release): qualify Testcontainers and Ryuk --- .../test-testcontainers-compatibility-gate.py | 79 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/testcontainers-compatibility-gate.sh | 401 ++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100755 .github/scripts/test-testcontainers-compatibility-gate.py create mode 100755 scripts/testcontainers-compatibility-gate.sh diff --git a/.github/scripts/test-testcontainers-compatibility-gate.py b/.github/scripts/test-testcontainers-compatibility-gate.py new file mode 100755 index 00000000..b1ef426d --- /dev/null +++ b/.github/scripts/test-testcontainers-compatibility-gate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact Testcontainers and Ryuk release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "testcontainers-compatibility-gate.sh" + + +class TestcontainersCompatibilityGateTests(unittest.TestCase): + def test_contract_binds_package_images_ryuk_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-TESTCONTAINERS", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "workload and Ryuk images must be exact digest references", + "required offline workload image is missing", + "required offline Ryuk image is missing", + "dist.integrity", + "registry.npmjs.org", + "Testcontainers npm integrity differs from the pinned release value", + "--npm-integrity is required when --version differs", + "downloaded Testcontainers tarball failed its SHA-512 integrity check", + "installed Testcontainers version", + 'RYUK_CONTAINER_IMAGE="$RYUK_IMAGE"', + "TESTCONTAINERS_RYUK_DISABLED=false", + "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock", + 'socketMount.Source !== "/var/run/docker.sock"', + 'withPullPolicy({ shouldPull: () => false })', + 'Wait.forHttp("/", 8080)', + "workload is not bound to the exact Ryuk session", + "dory.release.testcontainers.run", + "engine has pre-existing named volumes", + "engine has pre-existing custom networks", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + "node_sha256=", + "npm_sha256=", + "package_lock_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:3.20", "npm install testcontainers@", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_registry_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--image", "example.invalid/workload@sha256:" + "a" * 64, + "--ryuk-image", "example.invalid/ryuk@sha256:" + "b" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a85c9ee4..e06e3843 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -350,6 +350,7 @@ jobs: python3 .github/scripts/test-live-migration-gate.py python3 .github/scripts/test-devcontainers-compatibility-gate.py python3 .github/scripts/test-act-compatibility-gate.py + python3 .github/scripts/test-testcontainers-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8bbe8a7b..7800c9b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,6 +51,7 @@ jobs: python3 .github/scripts/test-live-migration-gate.py python3 .github/scripts/test-devcontainers-compatibility-gate.py python3 .github/scripts/test-act-compatibility-gate.py + python3 .github/scripts/test-testcontainers-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/testcontainers-compatibility-gate.sh b/scripts/testcontainers-compatibility-gate.sh new file mode 100755 index 00000000..111c38d0 --- /dev/null +++ b/scripts/testcontainers-compatibility-gate.sh @@ -0,0 +1,401 @@ +#!/bin/bash +# Runs Node Testcontainers and its real Ryuk reaper against an explicitly empty Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +DEFAULT_VERSION="12.0.4" +DEFAULT_PACKAGE_INTEGRITY="sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==" +VERSION="${DORY_RELEASE_TESTCONTAINERS_VERSION:-$DEFAULT_VERSION}" +PACKAGE_INTEGRITY="${DORY_RELEASE_TESTCONTAINERS_INTEGRITY:-}" +IMAGE="" +RYUK_IMAGE="${DORY_RELEASE_TESTCONTAINERS_RYUK_IMAGE:-testcontainers/ryuk:0.14.0@sha256:7c1a8a9a47c780ed0f983770a662f80deb115d95cce3e2daa3d12115b8cd28f0}" +WORKROOT="" +NODE="${DORY_RELEASE_NODE_BIN:-$(command -v node 2>/dev/null || true)}" +NPM="${DORY_RELEASE_NPM_BIN:-$(command -v npm 2>/dev/null || true)}" +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/testcontainers-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate runtime + --image REF Digest-pinned workload fixture already present in Dory + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-TESTCONTAINERS + +Options: + --version VERSION Exact npm testcontainers version (default: 12.0.4) + --npm-integrity SRI Expected sha512- npm integrity (required for a non-default version) + --ryuk-image REF Digest-pinned Ryuk fixture already present in Dory + --node PATH Exact Node executable used by the harness + --npm PATH Exact npm executable used by the harness + +The gate verifies the canonical npm tarball and SHA-512 integrity, runs an HTTP workload through +Testcontainers, proves that the real Ryuk container uses the guest-local Docker socket, and returns +the disposable engine to its exact empty container/volume/custom-network baseline. It never uses a +user's default Docker socket and never pulls either container image. +EOF +} + +die() { echo "testcontainers gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --npm-integrity) need_value "$1" "$#"; PACKAGE_INTEGRITY="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --ryuk-image) need_value "$1" "$#"; RYUK_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --node) need_value "$1" "$#"; NODE="$2"; shift 2 ;; + --npm) need_value "$1" "$#"; NPM="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +# This acknowledgement must fail before the gate looks at a socket, tool, registry, or workroot. +[ "$CONFIRM" = ISOLATED-ENGINE-TESTCONTAINERS ] \ + || die "requires --confirm ISOLATED-ENGINE-TESTCONTAINERS" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' \ + || die "--version must be an exact npm semver" +if [ -z "$PACKAGE_INTEGRITY" ]; then + [ "$VERSION" = "$DEFAULT_VERSION" ] \ + || die "--npm-integrity is required when --version differs from $DEFAULT_VERSION" + PACKAGE_INTEGRITY="$DEFAULT_PACKAGE_INTEGRITY" +fi +for exact_image in "$IMAGE" "$RYUK_IMAGE"; do + printf '%s\n' "$exact_image" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "workload and Ryuk images must be exact digest references" +done +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl python3 shasum; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +resolve_executable() { + python3 - "$1" <<'PY' +import os +import sys + +path = os.path.realpath(sys.argv[1]) +if not os.path.isabs(path) or not os.path.isfile(path) or not os.access(path, os.X_OK): + raise SystemExit(1) +print(path) +PY +} +NODE="$(resolve_executable "$NODE")" || die "Node is unavailable or indirect" +NPM="$(resolve_executable "$NPM")" || die "npm is unavailable or indirect" + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/project" "$WORKROOT/download" +WORKROOT="$(cd "$WORKROOT" && pwd)" +PROJECT="$WORKROOT/project" +EVIDENCE="$WORKROOT/evidence" +DOWNLOAD="$WORKROOT/download" +STATE="$EVIDENCE/runtime-state.json" +RESULT="$EVIDENCE/result.json" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +docker_e version > "$EVIDENCE/docker-version.txt" || die "Docker API is not ready" +docker_e image inspect "$IMAGE" > "$EVIDENCE/workload-image.json" 2>&1 \ + || die "required offline workload image is missing: $IMAGE" +docker_e image inspect "$RYUK_IMAGE" > "$EVIDENCE/ryuk-image.json" 2>&1 \ + || die "required offline Ryuk image is missing: $RYUK_IMAGE" + +custom_network_ids() { + docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d' +} +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" \ + || die "engine has pre-existing custom networks" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +owned_container_ids() { + if [ -s "$STATE" ]; then + python3 - "$STATE" <<'PY' 2>/dev/null || true +import json +import re +import sys + +try: + state = json.load(open(sys.argv[1], encoding="utf-8")) +except (OSError, ValueError): + raise SystemExit(0) +for key in ("workloadContainerId", "ryukContainerId"): + value = state.get(key) + if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{12,64}", value): + print(value) +PY + fi + docker_e ps -aq --filter "label=dory.release.testcontainers.run=$RUN_ID" 2>/dev/null || true + # This label is enabled only for this explicitly empty test engine and handles a signal in the + # tiny interval between Ryuk creation and publication of runtime-state.json. + docker_e ps -aq --filter 'label=TESTCONTAINERS_RYUK_TEST_LABEL=true' 2>/dev/null || true +} +cleanup_owned() { + local ids + ids="$(owned_container_ids | sed '/^$/d' | sort -u)" + [ -z "$ids" ] || docker_e rm -f -v $ids > "$EVIDENCE/container-cleanup.log" 2>&1 || true +} +cleanup() { + set +e + cleanup_owned + rm -rf "$WORKROOT/.npm-cache" "$DOWNLOAD" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +"$NPM" view --json "testcontainers@$VERSION" version dist.integrity dist.tarball \ + > "$EVIDENCE/npm-package.json" +python3 - "$EVIDENCE/npm-package.json" "$VERSION" "$PACKAGE_INTEGRITY" <<'PY' +import base64 +import json +import sys +import urllib.parse + +with open(sys.argv[1], encoding="utf-8") as handle: + package = json.load(handle) +expected_keys = {"version", "dist.integrity", "dist.tarball"} +if not isinstance(package, dict) or set(package) != expected_keys: + raise SystemExit("Testcontainers npm package metadata has an unexpected shape") +if package["version"] != sys.argv[2]: + raise SystemExit("Testcontainers npm registry returned a different version") +integrity = package["dist.integrity"] +if not isinstance(integrity, str) or not integrity.startswith("sha512-"): + raise SystemExit("Testcontainers npm package has no SHA-512 integrity") +try: + digest = base64.b64decode(integrity.removeprefix("sha512-"), validate=True) +except ValueError as error: + raise SystemExit(f"Testcontainers npm integrity is invalid: {error}") +if len(digest) != 64: + raise SystemExit("Testcontainers npm integrity is not SHA-512") +if integrity != sys.argv[3]: + raise SystemExit("Testcontainers npm integrity differs from the pinned release value") +url = urllib.parse.urlparse(package["dist.tarball"]) +expected_path = f"/testcontainers/-/testcontainers-{sys.argv[2]}.tgz" +if url.scheme != "https" or url.netloc != "registry.npmjs.org" or url.path != expected_path \ + or url.params or url.query or url.fragment: + raise SystemExit("Testcontainers npm tarball URL is not canonical") +PY +PACKAGE_URL="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["dist.tarball"])' "$EVIDENCE/npm-package.json")" +curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 \ + "$PACKAGE_URL" -o "$DOWNLOAD/testcontainers.tgz" +python3 - "$DOWNLOAD/testcontainers.tgz" "$PACKAGE_INTEGRITY" <<'PY' +import base64 +import hashlib +import sys + +expected = base64.b64decode(sys.argv[2].removeprefix("sha512-"), validate=True) +digest = hashlib.sha512() +with open(sys.argv[1], "rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) +if digest.digest() != expected: + raise SystemExit("downloaded Testcontainers tarball failed its SHA-512 integrity check") +PY + +cat > "$PROJECT/package.json" <<'JSON' +{"private":true} +JSON +(cd "$PROJECT" && \ + NPM_CONFIG_CACHE="$WORKROOT/.npm-cache" "$NPM" install \ + --ignore-scripts --no-audit --no-fund "$DOWNLOAD/testcontainers.tgz") \ + > "$EVIDENCE/npm-install.out" 2> "$EVIDENCE/npm-install.err" +"$NODE" - "$PROJECT/node_modules/testcontainers/package.json" "$VERSION" <<'JS' +const fs = require("node:fs"); +const actual = JSON.parse(fs.readFileSync(process.argv[2], "utf8")).version; +if (actual !== process.argv[3]) { + throw new Error(`installed Testcontainers version ${actual} differs from ${process.argv[3]}`); +} +JS +cp "$PROJECT/package-lock.json" "$EVIDENCE/package-lock.json" + +cat > "$PROJECT/gate.cjs" <<'JS' +const fs = require("node:fs"); +const http = require("node:http"); +const { + GenericContainer, + Wait, + getContainerRuntimeClient, + getReaper, +} = require("testcontainers"); + +function fail(message) { + throw new Error(message); +} + +async function get(host, port) { + return await new Promise((resolve, reject) => { + const request = http.get({ host, port, path: "/", timeout: 5000 }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { body += chunk; }); + response.on("end", () => resolve({ status: response.statusCode, body })); + }); + request.on("timeout", () => request.destroy(new Error("HTTP timeout"))); + request.on("error", reject); + }); +} + +(async () => { + const image = process.env.DORY_TESTCONTAINERS_IMAGE; + const ryukImage = process.env.RYUK_CONTAINER_IMAGE; + const marker = process.env.DORY_TESTCONTAINERS_MARKER; + const runId = process.env.DORY_TESTCONTAINERS_RUN_ID; + const statePath = process.env.DORY_TESTCONTAINERS_STATE; + const resultPath = process.env.DORY_TESTCONTAINERS_RESULT; + const startupTimeout = Number(process.env.DORY_TESTCONTAINERS_TIMEOUT_MS || "60000"); + const runtime = await getContainerRuntimeClient(); + const reaper = await getReaper(runtime); + const reaperHandle = runtime.container.getById(reaper.containerId); + const reaperInspect = await runtime.container.inspect(reaperHandle); + if (reaperInspect.Config.Image !== ryukImage) fail("Ryuk did not use the exact requested image"); + if (reaperInspect.Config.Labels["org.testcontainers.ryuk"] !== "true") { + fail("Ryuk identity label is missing"); + } + if (reaperInspect.Config.Labels["org.testcontainers.session-id"] !== reaper.sessionId) { + fail("Ryuk session label is not exact"); + } + const socketMount = reaperInspect.Mounts.find((mount) => mount.Destination === "/var/run/docker.sock"); + if (!socketMount || socketMount.Type !== "bind" || socketMount.Source !== "/var/run/docker.sock") { + fail("Ryuk did not receive the guest-local Docker socket bind"); + } + const state = { + ryukContainerId: reaper.containerId, + ryukSessionId: reaper.sessionId, + workloadContainerId: null, + }; + fs.writeFileSync(statePath, JSON.stringify(state) + "\n", { mode: 0o600 }); + + const responseText = `HTTP/1.1 200 OK\r\nContent-Length: ${marker.length}\r\nConnection: close\r\n\r\n${marker}`; + const command = `while true; do printf '%s' '${responseText}' | nc -l -p 8080; done`; + const container = await new GenericContainer(image) + .withCommand(["sh", "-c", command]) + .withLabels({ "dory.release.testcontainers.run": runId }) + .withPullPolicy({ shouldPull: () => false }) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forHttp("/", 8080).forStatusCode(200)) + .withStartupTimeout(startupTimeout) + .start(); + state.workloadContainerId = container.getId(); + fs.writeFileSync(statePath, JSON.stringify(state) + "\n", { mode: 0o600 }); + try { + const workloadInspect = await runtime.container.inspect(runtime.container.getById(container.getId())); + if (workloadInspect.Config.Image !== image) fail("workload did not use the exact requested image"); + if (workloadInspect.Config.Labels["dory.release.testcontainers.run"] !== runId) { + fail("workload ownership label is missing"); + } + if (workloadInspect.Config.Labels["org.testcontainers.session-id"] !== reaper.sessionId) { + fail("workload is not bound to the exact Ryuk session"); + } + const response = await get(container.getHost(), container.getMappedPort(8080)); + if (response.status !== 200 || response.body !== marker) { + fail(`wrong HTTP response: ${response.status} ${JSON.stringify(response.body)}`); + } + fs.writeFileSync(resultPath, JSON.stringify({ + status: "PASS", + marker, + workloadImage: workloadInspect.Config.Image, + ryukImage: reaperInspect.Config.Image, + ryukSessionId: reaper.sessionId, + host: container.getHost(), + mappedPort: container.getMappedPort(8080), + }) + "\n", { mode: 0o600 }); + } finally { + await container.stop(); + } +})().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +JS + +MARKER="dory-testcontainers-$RUN_ID" +(cd "$PROJECT" && \ + DOCKER_HOST="unix://$SOCKET" \ + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock \ + TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 \ + TESTCONTAINERS_RYUK_DISABLED=false \ + TESTCONTAINERS_RYUK_TEST_LABEL=true \ + RYUK_CONTAINER_IMAGE="$RYUK_IMAGE" \ + DORY_TESTCONTAINERS_IMAGE="$IMAGE" \ + DORY_TESTCONTAINERS_MARKER="$MARKER" \ + DORY_TESTCONTAINERS_RUN_ID="$RUN_ID" \ + DORY_TESTCONTAINERS_STATE="$STATE" \ + DORY_TESTCONTAINERS_RESULT="$RESULT" \ + "$NODE" gate.cjs) > "$EVIDENCE/gate.out" 2> "$EVIDENCE/gate.err" + +python3 - "$RESULT" "$MARKER" "$IMAGE" "$RYUK_IMAGE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + result = json.load(handle) +expected_keys = {"status", "marker", "workloadImage", "ryukImage", "ryukSessionId", "host", "mappedPort"} +if not isinstance(result, dict) or set(result) != expected_keys: + raise SystemExit("Testcontainers result has an unexpected shape") +if result["status"] != "PASS" or result["marker"] != sys.argv[2]: + raise SystemExit("Testcontainers HTTP marker proof is missing") +if result["workloadImage"] != sys.argv[3] or result["ryukImage"] != sys.argv[4]: + raise SystemExit("Testcontainers result did not bind exact images") +if result["host"] != "127.0.0.1" or not isinstance(result["mappedPort"], int) \ + or not 1 <= result["mappedPort"] <= 65535: + raise SystemExit("Testcontainers host port proof is invalid") +if not isinstance(result["ryukSessionId"], str) or not result["ryukSessionId"]: + raise SystemExit("Testcontainers Ryuk session proof is missing") +PY + +cleanup_owned +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "Testcontainers gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 04:57:35 +0000 Subject: [PATCH 127/338] feat(release): qualify LocalStack compatibility --- .../test-localstack-compatibility-gate.py | 71 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/localstack-compatibility-gate.sh | 247 ++++++++++++++++++ 4 files changed, 320 insertions(+) create mode 100755 .github/scripts/test-localstack-compatibility-gate.py create mode 100755 scripts/localstack-compatibility-gate.sh diff --git a/.github/scripts/test-localstack-compatibility-gate.py b/.github/scripts/test-localstack-compatibility-gate.py new file mode 100755 index 00000000..4ccb84cb --- /dev/null +++ b/.github/scripts/test-localstack-compatibility-gate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact LocalStack S3/SQS compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "localstack-compatibility-gate.sh" + + +class LocalStackCompatibilityGateTests(unittest.TestCase): + def test_contract_is_exact_offline_and_scoped(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-LOCALSTACK", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "LocalStack image must be an exact digest reference", + "required offline LocalStack image is missing", + "--pull=never", + "dory.release.localstack.run", + "LocalStack container did not use the exact requested image", + "LocalStack unexpectedly received a Docker socket", + 'binding[0].get("HostIp") != "127.0.0.1"', + "requested loopback port was widened to all host interfaces", + "awslocal s3api put-object", + "awslocal s3api get-object", + "awslocal sqs send-message", + "awslocal sqs receive-message", + "s3_object_roundtrip=PASS", + "sqs_message_roundtrip=PASS", + "owned_container_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("docker_e pull", "docker_e volume rm", "docker_e network rm", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--image", "example.invalid/localstack@sha256:" + "a" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e06e3843..c535a0e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -351,6 +351,7 @@ jobs: python3 .github/scripts/test-devcontainers-compatibility-gate.py python3 .github/scripts/test-act-compatibility-gate.py python3 .github/scripts/test-testcontainers-compatibility-gate.py + python3 .github/scripts/test-localstack-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7800c9b2..8115399d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,7 @@ jobs: python3 .github/scripts/test-devcontainers-compatibility-gate.py python3 .github/scripts/test-act-compatibility-gate.py python3 .github/scripts/test-testcontainers-compatibility-gate.py + python3 .github/scripts/test-localstack-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/localstack-compatibility-gate.sh b/scripts/localstack-compatibility-gate.sh new file mode 100755 index 00000000..48f5f305 --- /dev/null +++ b/scripts/localstack-compatibility-gate.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# Exercises LocalStack's real S3 and SQS APIs on an explicitly empty disposable Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +IMAGE="${DORY_RELEASE_LOCALSTACK_IMAGE:-localstack/localstack:4.14.0@sha256:3ebc37595918b8accb852f8048fef2aff047d465167edd655528065b07bc364a}" +WORKROOT="" +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/localstack-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate runtime + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-LOCALSTACK + +Options: + --image REF Digest-pinned LocalStack image already present in Dory + +The gate refuses any existing container, named volume, or custom network. It proves dynamic +loopback-only publishing, LocalStack health, an S3 object round-trip, an SQS message round-trip, +and exact engine cleanup. It never pulls the image or binds any Docker socket into LocalStack. +EOF +} + +die() { echo "LocalStack compatibility gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +# Refuse before inspecting a socket or creating evidence unless isolation was explicitly approved. +[ "$CONFIRM" = ISOLATED-ENGINE-LOCALSTACK ] \ + || die "requires --confirm ISOLATED-ENGINE-LOCALSTACK" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "LocalStack image must be an exact digest reference" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl lsof python3 shasum; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +mkdir -p "$WORKROOT/evidence" +WORKROOT="$(cd "$WORKROOT" && pwd)" +EVIDENCE="$WORKROOT/evidence" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +CONTAINER_NAME="dory-localstack-$RUN_ID" +container_id="" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +docker_e version > "$EVIDENCE/docker-version.txt" || die "Docker API is not ready" +docker_e image inspect "$IMAGE" > "$EVIDENCE/image-inspect.json" 2>&1 \ + || die "required offline LocalStack image is missing: $IMAGE" + +custom_network_ids() { + docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d' +} +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" \ + || die "engine has pre-existing custom networks" + +cleanup() { + set +e + if [ -n "$container_id" ]; then + docker_e rm -f -v "$container_id" > "$EVIDENCE/container-cleanup.log" 2>&1 || true + fi + docker_e ps -aq --filter "label=dory.release.localstack.run=$RUN_ID" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f -v "$id" >> "$EVIDENCE/container-cleanup.log" 2>&1 || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +container_id="$(docker_e run -d --pull=never \ + --name "$CONTAINER_NAME" \ + --label "dory.release.localstack.run=$RUN_ID" \ + -e SERVICES=s3,sqs \ + -e EAGER_SERVICE_LOADING=1 \ + -p 127.0.0.1::4566 \ + "$IMAGE")" +printf '%s\n' "$container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "LocalStack returned an invalid container ID" +printf '%s\n' "$container_id" > "$EVIDENCE/container-id.txt" +docker_e inspect "$container_id" > "$EVIDENCE/container-inspect.json" +python3 - "$EVIDENCE/container-inspect.json" "$IMAGE" "$RUN_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + documents = json.load(handle) +if not isinstance(documents, list) or len(documents) != 1 or not isinstance(documents[0], dict): + raise SystemExit("LocalStack inspect result has an unexpected shape") +document = documents[0] +config = document.get("Config") +host = document.get("HostConfig") +mounts = document.get("Mounts") +if not isinstance(config, dict) or config.get("Image") != sys.argv[2]: + raise SystemExit("LocalStack container did not use the exact requested image") +labels = config.get("Labels") +if not isinstance(labels, dict) or labels.get("dory.release.localstack.run") != sys.argv[3]: + raise SystemExit("LocalStack ownership label is missing") +if not isinstance(host, dict) or host.get("Binds") not in (None, []): + raise SystemExit("LocalStack unexpectedly received a host bind") +if not isinstance(mounts, list) or any( + isinstance(mount, dict) and mount.get("Destination") == "/var/run/docker.sock" + for mount in mounts +): + raise SystemExit("LocalStack unexpectedly received a Docker socket") +bindings = host.get("PortBindings") +binding = bindings.get("4566/tcp") if isinstance(bindings, dict) else None +if not isinstance(binding, list) or len(binding) != 1 or binding[0].get("HostIp") != "127.0.0.1": + raise SystemExit("LocalStack port was not bound only to IPv4 loopback") +PY + +published="" +for _ in $(seq 1 300); do + published="$(docker_e port "$container_id" 4566/tcp 2>/dev/null | sed -n '1p')" + [ -n "$published" ] && break + sleep 0.2 +done +[ -n "$published" ] || die "LocalStack dynamic host port was not published" +printf '%s\n' "$published" > "$EVIDENCE/published-port.txt" +case "$published" in 127.0.0.1:*) ;; *) die "LocalStack port is not loopback-only: $published" ;; esac +host_port="${published##*:}" +python3 - "$host_port" <<'PY' +import sys + +if not sys.argv[1].isdigit() or not 1 <= int(sys.argv[1]) <= 65535: + raise SystemExit("LocalStack published port is invalid") +PY + +healthy=0 +for _ in $(seq 1 600); do + if curl -fsS --max-time 3 "http://127.0.0.1:$host_port/_localstack/health" \ + > "$EVIDENCE/health.json.partial" 2>/dev/null; then + if python3 - "$EVIDENCE/health.json.partial" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + document = json.load(handle) +services = document.get("services") if isinstance(document, dict) else None +if not isinstance(services, dict): + raise SystemExit(1) +valid = {"available", "running"} +raise SystemExit(0 if services.get("s3") in valid and services.get("sqs") in valid else 1) +PY + then + healthy=1 + break + fi + fi + sleep 0.5 +done +if [ "$healthy" -ne 1 ]; then + docker_e logs "$container_id" > "$EVIDENCE/container.log" 2>&1 || true + die "LocalStack S3/SQS did not become healthy" +fi +mv "$EVIDENCE/health.json.partial" "$EVIDENCE/health.json" + +lsof -nP -iTCP:"$host_port" -sTCP:LISTEN > "$EVIDENCE/host-listener.txt" +grep -Eq "TCP 127\\.0\\.0\\.1:$host_port \\(LISTEN\\)" "$EVIDENCE/host-listener.txt" \ + || die "requested loopback port has no 127.0.0.1 host listener" +if grep -Eq "TCP (\\*|0\\.0\\.0\\.0):$host_port \\(LISTEN\\)" "$EVIDENCE/host-listener.txt"; then + die "requested loopback port was widened to all host interfaces" +fi + +docker_e exec "$container_id" sh -lc ' + set -eu + printf "dory-localstack-object\n" > /tmp/dory-object + awslocal s3api create-bucket --bucket dory-compat-bucket >/tmp/create-bucket.json + awslocal s3api put-object --bucket dory-compat-bucket --key proof --body /tmp/dory-object >/tmp/put-object.json + awslocal s3api get-object --bucket dory-compat-bucket --key proof /tmp/dory-object-out >/tmp/get-object.json + cmp /tmp/dory-object /tmp/dory-object-out + queue_url="$(awslocal sqs create-queue --queue-name dory-compat-queue --query QueueUrl --output text)" + awslocal sqs send-message --queue-url "$queue_url" --message-body dory-localstack-message >/tmp/send-message.json + body="" + attempt=0 + while [ "$attempt" -lt 10 ] && [ "$body" != dory-localstack-message ]; do + body="$(awslocal sqs receive-message --queue-url "$queue_url" --wait-time-seconds 2 --query "Messages[0].Body" --output text)" + attempt=$((attempt + 1)) + done + test "$body" = dory-localstack-message + printf "s3=PASS\nsqs=PASS\n" +' > "$EVIDENCE/service-roundtrip.txt" 2> "$EVIDENCE/service-roundtrip.stderr" +grep -qx 's3=PASS' "$EVIDENCE/service-roundtrip.txt" \ + || die "LocalStack S3 round-trip failed" +grep -qx 'sqs=PASS' "$EVIDENCE/service-roundtrip.txt" \ + || die "LocalStack SQS round-trip failed" + +docker_e logs "$container_id" > "$EVIDENCE/container.log" 2>&1 || true +cleanup +container_id="" +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "LocalStack gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:01:28 +0000 Subject: [PATCH 128/338] feat(release): qualify Tilt Compose compatibility --- .../test-tilt-compose-compatibility-gate.py | 74 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/tilt-compose-compatibility-gate.sh | 285 ++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100755 .github/scripts/test-tilt-compose-compatibility-gate.py create mode 100755 scripts/tilt-compose-compatibility-gate.sh diff --git a/.github/scripts/test-tilt-compose-compatibility-gate.py b/.github/scripts/test-tilt-compose-compatibility-gate.py new file mode 100755 index 00000000..9c2131e7 --- /dev/null +++ b/.github/scripts/test-tilt-compose-compatibility-gate.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact Tilt and Docker Compose compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "tilt-compose-compatibility-gate.sh" + + +class TiltComposeCompatibilityGateTests(unittest.TestCase): + def test_contract_binds_tilt_candidate_compose_image_and_project(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-TILT", + "socket is not owned by the release user", + "$helper_name helper is unavailable or indirect", + "--image must be an exact digest reference", + "shasum -a 256 -c -", + "verified Tilt archive did not contain a direct executable", + "private Compose plugin differs from the candidate helper", + "Tilt would not discover the exact candidate Docker CLI", + "Tilt would not discover the exact candidate Compose helper", + "exact candidate Compose plugin is not loadable", + "required offline Tilt workload image is missing", + "pull_policy: never", + "com.docker.compose.project=$PROJECT_NAME", + "Tilt Compose service did not use the exact workload image", + "Tilt Compose workspace bind is not exact and writable", + "host_to_service_workspace=PASS", + "service_to_host_workspace=PASS", + "owned_project_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "tilt_binary_sha256=", + "docker_cli_sha256=", + "compose_plugin_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("image: alpine:", "docker_e pull", "ids=\"$(docker_e ps -aq)\"", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_download_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--compose", "/missing/docker-compose", + "--image", "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c535a0e0..3160d821 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -352,6 +352,7 @@ jobs: python3 .github/scripts/test-act-compatibility-gate.py python3 .github/scripts/test-testcontainers-compatibility-gate.py python3 .github/scripts/test-localstack-compatibility-gate.py + python3 .github/scripts/test-tilt-compose-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8115399d..75fd90ea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,7 @@ jobs: python3 .github/scripts/test-act-compatibility-gate.py python3 .github/scripts/test-testcontainers-compatibility-gate.py python3 .github/scripts/test-localstack-compatibility-gate.py + python3 .github/scripts/test-tilt-compose-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/tilt-compose-compatibility-gate.sh b/scripts/tilt-compose-compatibility-gate.sh new file mode 100755 index 00000000..5591569e --- /dev/null +++ b/scripts/tilt-compose-compatibility-gate.sh @@ -0,0 +1,285 @@ +#!/bin/bash +# Runs checksum-pinned Tilt CI with the exact candidate Docker/Compose pair on a disposable engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +COMPOSE="" +IMAGE="" +VERSION="${DORY_RELEASE_TILT_VERSION:-0.37.5}" +SHA256="" +WORKROOT="" +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/tilt-compose-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate app + --compose PATH Exact Docker Compose plugin from the candidate app + --image REF Digest-pinned workload fixture already present in Dory + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-TILT + +Options: + --version VERSION Exact Tilt version (default: 0.37.5) + --sha256 HASH Archive SHA-256 (defaults to the published 0.37.5 checksum for this Mac) + +The gate verifies Tilt's release archive, installs only the supplied Compose plugin into a private +Docker config, runs Tilt CI against a preloaded image, proves service health and two-way workspace +coherence, then returns the engine to its exact empty container/volume/custom-network baseline. +EOF +} + +die() { echo "Tilt compatibility gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --compose) need_value "$1" "$#"; COMPOSE="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --sha256) need_value "$1" "$#"; SHA256="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-TILT ] || die "requires --confirm ISOLATED-ENGINE-TILT" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +for helper_name in DOCKER COMPOSE; do + helper_path="${!helper_name}" + case "$helper_path" in /*) ;; *) die "$helper_name helper must be an absolute path" ;; esac + [ -f "$helper_path" ] && [ ! -L "$helper_path" ] && [ -x "$helper_path" ] \ + || die "$helper_name helper is unavailable or indirect: $helper_path" +done +[ "$(basename "$DOCKER")" = docker ] \ + || die "candidate Docker CLI must be named docker for Tilt discovery" +[ "$(basename "$COMPOSE")" = docker-compose ] \ + || die "candidate Compose helper must be named docker-compose for Tilt discovery" +printf '%s\n' "$IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--image must be an exact digest reference" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--version must be an exact semantic version" +case "$(uname -m)" in + arm64) + ARCHIVE_ARCH=arm64 + DEFAULT_SHA=d8c701ada9d3ee29c983651a8f344d8a4c13363e6c25a843b478aa4444ee6f30 + ;; + x86_64) + ARCHIVE_ARCH=x86_64 + DEFAULT_SHA=5db0bd3a690db4d12ddf22afbe14df5a56f0d6351731694c2e1e59158b3eb00c + ;; + *) die "unsupported macOS architecture: $(uname -m)" ;; +esac +if [ -z "$SHA256" ]; then + [ "$VERSION" = 0.37.5 ] || die "--sha256 is required for a non-default Tilt version" + SHA256="$DEFAULT_SHA" +fi +printf '%s\n' "$SHA256" | grep -Eq '^[0-9a-f]{64}$' || die "--sha256 is invalid" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl python3 shasum tar; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/workspace" "$WORKROOT/download" \ + "$WORKROOT/docker-config/cli-plugins" +WORKROOT="$(cd "$WORKROOT" && pwd)" +WORKSPACE="$WORKROOT/workspace" +EVIDENCE="$WORKROOT/evidence" +DOWNLOAD="$WORKROOT/download" +PRIVATE_COMPOSE="$WORKROOT/docker-config/cli-plugins/docker-compose" +cp "$COMPOSE" "$PRIVATE_COMPOSE" +chmod 755 "$PRIVATE_COMPOSE" +[ "$(shasum -a 256 "$PRIVATE_COMPOSE" | awk '{print $1}')" = \ + "$(shasum -a 256 "$COMPOSE" | awk '{print $1}')" ] \ + || die "private Compose plugin differs from the candidate helper" +export DOCKER_CONFIG="$WORKROOT/docker-config" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +export PATH="$(dirname "$DOCKER"):/usr/bin:/bin:/usr/sbin:/sbin" +[ "$(command -v docker)" = "$DOCKER" ] || die "Tilt would not discover the exact candidate Docker CLI" +[ "$(command -v docker-compose)" = "$COMPOSE" ] \ + || die "Tilt would not discover the exact candidate Compose helper" +docker_e() { "$DOCKER" "$@"; } +docker_e version > "$EVIDENCE/docker-version.txt" || die "Docker API is not ready" +docker_e compose version > "$EVIDENCE/compose-version.txt" \ + || die "exact candidate Compose plugin is not loadable" +docker_e image inspect "$IMAGE" > "$EVIDENCE/image-inspect.json" 2>&1 \ + || die "required offline Tilt workload image is missing: $IMAGE" + +custom_network_ids() { + docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d' +} +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" \ + || die "engine has pre-existing custom networks" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +PROJECT_NAME="dory_tilt_$$" +export COMPOSE_PROJECT_NAME="$PROJECT_NAME" +cleanup_project() { + local ids + ids="$(docker_e ps -aq --filter "label=com.docker.compose.project=$PROJECT_NAME")" + [ -z "$ids" ] || docker_e rm -f -v $ids > "$EVIDENCE/container-cleanup.log" 2>&1 || true + ids="$(docker_e volume ls -q --filter "label=com.docker.compose.project=$PROJECT_NAME")" + [ -z "$ids" ] || docker_e volume rm -f $ids > "$EVIDENCE/volume-cleanup.log" 2>&1 || true + ids="$(docker_e network ls -q --filter "label=com.docker.compose.project=$PROJECT_NAME")" + [ -z "$ids" ] || docker_e network rm $ids > "$EVIDENCE/network-cleanup.log" 2>&1 || true +} +cleanup() { + set +e + if [ -x "$DOWNLOAD/tilt" ]; then + (cd "$WORKSPACE" && "$DOWNLOAD/tilt" down --file Tiltfile) \ + > "$EVIDENCE/tilt-down-cleanup.log" 2>&1 || true + fi + cleanup_project + rm -rf "$DOWNLOAD" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +archive="$DOWNLOAD/tilt.tgz" +url="https://github.com/tilt-dev/tilt/releases/download/v$VERSION/tilt.$VERSION.mac.$ARCHIVE_ARCH.tar.gz" +curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 "$url" -o "$archive" +printf '%s %s\n' "$SHA256" "$archive" \ + | shasum -a 256 -c - > "$EVIDENCE/archive-checksum.txt" +tar -xzf "$archive" -C "$DOWNLOAD" tilt +[ -f "$DOWNLOAD/tilt" ] && [ ! -L "$DOWNLOAD/tilt" ] && [ -x "$DOWNLOAD/tilt" ] \ + || die "verified Tilt archive did not contain a direct executable" +"$DOWNLOAD/tilt" version > "$EVIDENCE/tilt-version.txt" +python3 - "$EVIDENCE/tilt-version.txt" "$VERSION" <<'PY' +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + output = handle.read() +pattern = rf"(? "$WORKSPACE/Tiltfile" <<'TILT' +docker_compose('docker-compose.yml') +dc_resource('smoke') +TILT +cat > "$WORKSPACE/docker-compose.yml" < /workspace/tilt-sentinel.txt + while :; do sleep 60; done + volumes: + - ./:/workspace + healthcheck: + test: ["CMD-SHELL", "grep -qx tilt-to-host /workspace/tilt-sentinel.txt"] + interval: 1s + timeout: 2s + retries: 30 +YAML +printf 'host-to-tilt\n' > "$WORKSPACE/host-sentinel.txt" + +(cd "$WORKSPACE" && "$DOWNLOAD/tilt" ci \ + --file Tiltfile --host localhost --port 0 --timeout 5m \ + --output-snapshot-on-exit "$EVIDENCE/tilt-snapshot.json") \ + > "$EVIDENCE/tilt-ci.log" 2> "$EVIDENCE/tilt-ci.stderr" +grep -qx 'tilt-to-host' "$WORKSPACE/tilt-sentinel.txt" \ + || die "Tilt service write was not visible in the macOS workspace" +container_ids="$(docker_e ps -q --filter "label=com.docker.compose.project=$PROJECT_NAME")" +[ "$(printf '%s\n' "$container_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] \ + || die "Tilt CI did not leave exactly one owned Compose service" +container_id="$container_ids" +compose_health="" +for _ in $(seq 1 120); do + compose_health="$(docker_e inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id")" + [ "$compose_health" = healthy ] && break + [ "$(docker_e inspect --format '{{.State.Running}}' "$container_id")" = true ] \ + || die "Tilt Compose service exited before health convergence" + sleep 0.5 +done +[ "$compose_health" = healthy ] || die "Tilt Compose service did not become healthy" +docker_e inspect "$container_id" > "$EVIDENCE/container-inspect.json" +python3 - "$EVIDENCE/container-inspect.json" "$IMAGE" "$PROJECT_NAME" "$WORKSPACE" <<'PY' +import json +import os +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + documents = json.load(handle) +if not isinstance(documents, list) or len(documents) != 1 or not isinstance(documents[0], dict): + raise SystemExit("Tilt Compose inspect result has an unexpected shape") +document = documents[0] +config = document.get("Config") +if not isinstance(config, dict) or config.get("Image") != sys.argv[2]: + raise SystemExit("Tilt Compose service did not use the exact workload image") +labels = config.get("Labels") +if not isinstance(labels, dict) or labels.get("com.docker.compose.project") != sys.argv[3] \ + or labels.get("com.docker.compose.service") != "smoke": + raise SystemExit("Tilt Compose service authority labels are not exact") +mounts = document.get("Mounts") +workspace = os.path.realpath(sys.argv[4]) +matches = [mount for mount in mounts if isinstance(mount, dict) and mount.get("Destination") == "/workspace"] \ + if isinstance(mounts, list) else [] +if len(matches) != 1 or matches[0].get("Type") != "bind" \ + or os.path.realpath(matches[0].get("Source", "")) != workspace \ + or matches[0].get("RW") is not True: + raise SystemExit("Tilt Compose workspace bind is not exact and writable") +PY + +(cd "$WORKSPACE" && "$DOWNLOAD/tilt" down --file Tiltfile) \ + > "$EVIDENCE/tilt-down.log" 2> "$EVIDENCE/tilt-down.stderr" +cleanup_project +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "Tilt gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:13:50 +0000 Subject: [PATCH 129/338] feat(release): qualify exact Supabase stack --- .../fixtures/supabase-cli-2.109.1-images.json | 105 ++++ .../test-supabase-compatibility-gate.py | 110 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/supabase-compatibility-gate.sh | 489 ++++++++++++++++++ 5 files changed, 706 insertions(+) create mode 100644 .github/fixtures/supabase-cli-2.109.1-images.json create mode 100755 .github/scripts/test-supabase-compatibility-gate.py create mode 100755 scripts/supabase-compatibility-gate.sh diff --git a/.github/fixtures/supabase-cli-2.109.1-images.json b/.github/fixtures/supabase-cli-2.109.1-images.json new file mode 100644 index 00000000..e737b6d5 --- /dev/null +++ b/.github/fixtures/supabase-cli-2.109.1-images.json @@ -0,0 +1,105 @@ +{ + "schemaVersion": 1, + "cliVersion": "2.109.1", + "registry": "public.ecr.aws", + "services": [ + { + "service": "db", + "source": "supabase/postgres:17.6.1.143", + "runtime": "public.ecr.aws/supabase/postgres:17.6.1.143", + "digest": "sha256:80d7b27c3e8d77cfa7226eee9508671796da214781ff15a35b3670d7ad5ee453", + "enabledByDefault": true + }, + { + "service": "gateway", + "source": "library/kong:2.8.1", + "runtime": "public.ecr.aws/supabase/kong:2.8.1", + "digest": "sha256:1b53405d8680a09d6f44494b7990bf7da2ea43f84a258c59717d4539abf09f6d", + "enabledByDefault": true + }, + { + "service": "mailpit", + "source": "axllent/mailpit:v1.30.2", + "runtime": "public.ecr.aws/supabase/mailpit:v1.30.2", + "digest": "sha256:37a38e48e9338cd7e89dfeb487f37b02ebfcd9cb23111bed2d345e79d37d6dd6", + "enabledByDefault": true + }, + { + "service": "api", + "source": "postgrest/postgrest:v14.14", + "runtime": "public.ecr.aws/supabase/postgrest:v14.14", + "digest": "sha256:d2009b5c9deffc210c8a5592698472fede14fd9f6ca89823c8474ca54d58c012", + "enabledByDefault": true + }, + { + "service": "pgmeta", + "source": "supabase/postgres-meta:v0.96.6", + "runtime": "public.ecr.aws/supabase/postgres-meta:v0.96.6", + "digest": "sha256:a84cc713585eea7b401e4a2561ec4a1e48c87083d1c7ecb4502f204bb4391300", + "enabledByDefault": true + }, + { + "service": "studio", + "source": "supabase/studio:2026.07.06-sha-66cf431", + "runtime": "public.ecr.aws/supabase/studio:2026.07.06-sha-66cf431", + "digest": "sha256:4a5163e33578b346e6eab1352034d29140cf84b6d9989aab6fcf4b39edb3b13c", + "enabledByDefault": true + }, + { + "service": "imgproxy", + "source": "darthsim/imgproxy:v3.8.0", + "runtime": "public.ecr.aws/supabase/imgproxy:v3.8.0", + "digest": "sha256:0facd355d50f3be665ebe674486f2b2e9cdaebd3f74404acd9b7fece2f661435", + "enabledByDefault": true + }, + { + "service": "edgeRuntime", + "source": "supabase/edge-runtime:v1.74.2", + "runtime": "public.ecr.aws/supabase/edge-runtime:v1.74.2", + "digest": "sha256:a82676277615aee03c4f288cbbbf68dedb5ba8693073e567ab8dbfdd11ba5d45", + "enabledByDefault": true + }, + { + "service": "vector", + "source": "timberio/vector:0.53.0-alpine", + "runtime": "public.ecr.aws/supabase/vector:0.53.0-alpine", + "digest": "sha256:ca92d617e905953c3f852e7e88061f7039460e733522e3f0c21bc6ae946b2558", + "enabledByDefault": true + }, + { + "service": "pooler", + "source": "supabase/supavisor:2.9.7", + "runtime": "public.ecr.aws/supabase/supavisor:2.9.7", + "digest": "sha256:be033cf4746a438fa7bfb6a7e589c9a4c950cc7fafea1ea8ec1a15952aa851e6", + "enabledByDefault": false + }, + { + "service": "auth", + "source": "supabase/gotrue:v2.192.0", + "runtime": "public.ecr.aws/supabase/gotrue:v2.192.0", + "digest": "sha256:b252efb680be37d4a8bf77c210cf0439c19b63a4b51929233a65dd101d25bdab", + "enabledByDefault": true + }, + { + "service": "realtime", + "source": "supabase/realtime:v2.112.6", + "runtime": "public.ecr.aws/supabase/realtime:v2.112.6", + "digest": "sha256:7b56da34216fd568042be043900d15cdd33c2c48c2116c9a333f9465255da80d", + "enabledByDefault": true + }, + { + "service": "storage", + "source": "supabase/storage-api:v1.62.5", + "runtime": "public.ecr.aws/supabase/storage-api:v1.62.5", + "digest": "sha256:1dbe962d9862ef12e20357f9d7ba5431989c1daf4a556d6cb20ee4efd1c57320", + "enabledByDefault": true + }, + { + "service": "analytics", + "source": "supabase/logflare:1.46.0", + "runtime": "public.ecr.aws/supabase/logflare:1.46.0", + "digest": "sha256:f3c7a387ab7bb94af001b907c08df14258bd255f29d4cdb8bf6b393707558bf2", + "enabledByDefault": true + } + ] +} diff --git a/.github/scripts/test-supabase-compatibility-gate.py b/.github/scripts/test-supabase-compatibility-gate.py new file mode 100755 index 00000000..ab386677 --- /dev/null +++ b/.github/scripts/test-supabase-compatibility-gate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Offline contracts for the exact Supabase CLI and service-image qualification gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "supabase-compatibility-gate.sh" +INVENTORY = ROOT / ".github" / "fixtures" / "supabase-cli-2.109.1-images.json" + + +class SupabaseCompatibilityGateTests(unittest.TestCase): + def test_inventory_is_closed_and_digest_pinned(self) -> None: + document = json.loads(INVENTORY.read_text(encoding="utf-8")) + self.assertEqual( + set(document), {"schemaVersion", "cliVersion", "registry", "services"} + ) + self.assertEqual(document["schemaVersion"], 1) + self.assertEqual(document["cliVersion"], "2.109.1") + self.assertEqual(document["registry"], "public.ecr.aws") + services = document["services"] + self.assertEqual(len(services), 14) + self.assertEqual(sum(item["enabledByDefault"] for item in services), 13) + self.assertEqual(len({item["service"] for item in services}), 14) + self.assertEqual(len({item["runtime"] for item in services}), 14) + for service in services: + self.assertEqual( + set(service), + {"service", "source", "runtime", "digest", "enabledByDefault"}, + ) + self.assertRegex( + service["runtime"], + r"^public\.ecr\.aws/supabase/[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$", + ) + self.assertRegex(service["digest"], r"^sha256:[0-9a-f]{64}$") + self.assertEqual( + {item["service"] for item in services if not item["enabledByDefault"]}, + {"pooler"}, + ) + + def test_gate_binds_cli_inventory_runtime_behavior_and_secret_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-SUPABASE", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "--inventory is required for a non-default Supabase version", + "Supabase image inventory has an unexpected top-level shape", + "Supabase default stack must contain exactly 13 enabled service images", + "Docker-Content-Digest", + "Supabase registry digest changed", + 'docker_e pull "$runtime@$digest"', + 'docker_e tag "$runtime@$digest" "$runtime"', + "verified Supabase archive did not contain direct CLI executables", + "full Supabase default stack did not create exactly 13 owned containers", + "Supabase service image is not bound to its approved digest", + "com.supabase.cli.project", + "Supabase REST round-trip returned unexpected rows", + "required Supabase default host port is already in use", + "Supabase port $port was widened to all host interfaces", + '> /dev/null 2> "$EVIDENCE/start.stderr"', + "secret_free_evidence=PASS", + "exact_service_image_inventory=PASS", + "owned_project_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "supabase_binary_sha256=", + "image_inventory_sha256=", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ( + "ids=\"$(docker_e ps -aq)\"", + "docker_e volume ls -q);", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_registry_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--inventory", str(INVENTORY), + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3160d821..85fa7271 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -353,6 +353,7 @@ jobs: python3 .github/scripts/test-testcontainers-compatibility-gate.py python3 .github/scripts/test-localstack-compatibility-gate.py python3 .github/scripts/test-tilt-compose-compatibility-gate.py + python3 .github/scripts/test-supabase-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75fd90ea..cf02d27e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,7 @@ jobs: python3 .github/scripts/test-testcontainers-compatibility-gate.py python3 .github/scripts/test-localstack-compatibility-gate.py python3 .github/scripts/test-tilt-compose-compatibility-gate.py + python3 .github/scripts/test-supabase-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/supabase-compatibility-gate.sh b/scripts/supabase-compatibility-gate.sh new file mode 100755 index 00000000..18e6aead --- /dev/null +++ b/scripts/supabase-compatibility-gate.sh @@ -0,0 +1,489 @@ +#!/bin/bash +# Runs the complete checksum- and image-inventory-pinned Supabase stack on a disposable Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +VERSION="${DORY_RELEASE_SUPABASE_VERSION:-2.109.1}" +SHA256="" +INVENTORY="" +WORKROOT="" +CONFIRM="" +MIN_FREE_GB=15 + +usage() { + cat <<'EOF' +Usage: scripts/supabase-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate app + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-SUPABASE + +Options: + --version VERSION Exact Supabase CLI version (default: 2.109.1) + --sha256 HASH Archive SHA-256 (defaults to published 2.109.1 checksum for this Mac) + --inventory PATH Exact service-image inventory (defaults to the tracked 2.109.1 inventory) + --min-free-gb N Initial host free-space floor (default: 15) + +No Supabase service is excluded. Before startup, the gate validates every enabled service tag +against its immutable public-ECR digest, preloads that digest, and assigns the exact tag locally. +It then requires the running 13-container default stack to equal that inventory, proves Postgres, +REST, auth, and storage behavior, removes only Supabase-labeled objects, and emits no API keys. +EOF +} + +die() { echo "Supabase compatibility gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --sha256) need_value "$1" "$#"; SHA256="$2"; shift 2 ;; + --inventory) need_value "$1" "$#"; INVENTORY="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --min-free-gb) need_value "$1" "$#"; MIN_FREE_GB="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-SUPABASE ] \ + || die "requires --confirm ISOLATED-ENGINE-SUPABASE" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--version must be an exact semantic version" +case "$MIN_FREE_GB" in ''|*[!0-9]*) die "--min-free-gb must be a positive integer" ;; esac +[ "$MIN_FREE_GB" -gt 0 ] || die "--min-free-gb must be a positive integer" +case "$(uname -m)" in + arm64) + ARCHIVE_ARCH=arm64 + DEFAULT_SHA=e36776717a56d704769229649349b3a382f413cb31f1fb2ba4647ef8bcf7339b + ;; + x86_64) + ARCHIVE_ARCH=amd64 + DEFAULT_SHA=fee962ecf455c69497f93c19b369443b114a934161b8cecbd8a5b812c3c8c013 + ;; + *) die "unsupported macOS architecture: $(uname -m)" ;; +esac +if [ -z "$SHA256" ]; then + [ "$VERSION" = 2.109.1 ] || die "--sha256 is required for a non-default Supabase version" + SHA256="$DEFAULT_SHA" +fi +printf '%s\n' "$SHA256" | grep -Eq '^[0-9a-f]{64}$' || die "--sha256 is invalid" +if [ -z "$INVENTORY" ]; then + [ "$VERSION" = 2.109.1 ] \ + || die "--inventory is required for a non-default Supabase version" + INVENTORY="$(cd "$(dirname "$0")/.." && pwd)/.github/fixtures/supabase-cli-2.109.1-images.json" +fi +case "$INVENTORY" in /*) ;; *) die "--inventory must be absolute" ;; esac +[ -f "$INVENTORY" ] && [ ! -L "$INVENTORY" ] \ + || die "Supabase image inventory is unavailable or indirect: $INVENTORY" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +[ -d "$(dirname "$WORKROOT")" ] || die "workroot parent does not exist" +for command in curl lsof python3 shasum tar; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +free_kb="$(df -Pk "$(dirname "$WORKROOT")" | awk 'NR == 2 { print $4 }')" +printf '%s\n' "$free_kb" | grep -Eq '^[0-9]+$' || die "could not determine host free space" +[ "$free_kb" -ge $((MIN_FREE_GB * 1024 * 1024)) ] \ + || die "requires at least $MIN_FREE_GB GiB free before the full Supabase stack" + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/project" "$WORKROOT/download" +WORKROOT="$(cd "$WORKROOT" && pwd)" +PROJECT="$WORKROOT/project" +EVIDENCE="$WORKROOT/evidence" +DOWNLOAD="$WORKROOT/download" +export DOCKER_HOST="unix://$SOCKET" +export INTERNAL_IMAGE_REGISTRY=public.ecr.aws +unset DOCKER_SOCKET_LOCATION DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +docker_e version > "$EVIDENCE/docker-version.txt" || die "Docker API is not ready" + +custom_network_ids() { + docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d' +} +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" \ + || die "engine has pre-existing custom networks" + +cleanup_owned() { + local ids + ids="$(docker_e ps -aq --filter 'label=com.supabase.cli.project')" + [ -z "$ids" ] || docker_e rm -f -v $ids > "$EVIDENCE/container-cleanup.log" 2>&1 || true + ids="$(docker_e volume ls -q --filter 'label=com.supabase.cli.project')" + [ -z "$ids" ] || docker_e volume rm -f $ids > "$EVIDENCE/volume-cleanup.log" 2>&1 || true + ids="$(docker_e network ls -q --filter 'label=com.supabase.cli.project')" + [ -z "$ids" ] || docker_e network rm $ids > "$EVIDENCE/network-cleanup.log" 2>&1 || true +} +remove_secret_evidence() { + rm -f "$EVIDENCE/start.stderr" "$EVIDENCE/status-env.stderr" +} +cleanup() { + set +e + if [ -x "$DOWNLOAD/supabase" ] && [ -f "$PROJECT/supabase/config.toml" ]; then + "$DOWNLOAD/supabase" stop --workdir "$PROJECT" --no-backup --yes \ + > "$EVIDENCE/cleanup-stop.log" 2>&1 || true + fi + cleanup_owned + remove_secret_evidence + rm -rf "$DOWNLOAD" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Validate the closed inventory and prove that every enabled tag still resolves to its pinned OCI +# digest immediately before preloading. The proof contains public image identities only. +python3 - "$INVENTORY" "$VERSION" "$EVIDENCE/approved-images.tsv" \ + "$EVIDENCE/registry-proof.json" <<'PY' +import json +import re +import sys +import urllib.parse +import urllib.request + +with open(sys.argv[1], encoding="utf-8") as handle: + inventory = json.load(handle) +if not isinstance(inventory, dict) or set(inventory) != { + "schemaVersion", "cliVersion", "registry", "services" +}: + raise SystemExit("Supabase image inventory has an unexpected top-level shape") +if inventory["schemaVersion"] != 1 or inventory["cliVersion"] != sys.argv[2] \ + or inventory["registry"] != "public.ecr.aws": + raise SystemExit("Supabase image inventory authority does not match the requested CLI") +services = inventory["services"] +if not isinstance(services, list) or not services: + raise SystemExit("Supabase image inventory has no services") +expected_keys = {"service", "source", "runtime", "digest", "enabledByDefault"} +names = set() +runtimes = set() +enabled = [] +for service in services: + if not isinstance(service, dict) or set(service) != expected_keys: + raise SystemExit("Supabase service image entry has an unexpected shape") + name = service["service"] + source = service["source"] + runtime = service["runtime"] + digest = service["digest"] + state = service["enabledByDefault"] + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): + raise SystemExit("Supabase service name is invalid") + if not isinstance(source, str) or not re.fullmatch(r"[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+", source): + raise SystemExit("Supabase source image is invalid") + if not isinstance(runtime, str) or not re.fullmatch( + r"public\.ecr\.aws/supabase/[A-Za-z0-9._-]+:[A-Za-z0-9._-]+", runtime + ): + raise SystemExit("Supabase runtime image is not in the exact public ECR namespace") + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise SystemExit("Supabase service digest is invalid") + if not isinstance(state, bool) or name in names or runtime in runtimes: + raise SystemExit("Supabase service inventory is duplicate or has an invalid state") + names.add(name) + runtimes.add(runtime) + if state: + enabled.append(service) +if len(enabled) != 13: + raise SystemExit("Supabase default stack must contain exactly 13 enabled service images") + +proof = [] +with open(sys.argv[3], "w", encoding="utf-8") as approved: + for service in enabled: + runtime = service["runtime"] + digest = service["digest"] + leaf = runtime.removeprefix("public.ecr.aws/supabase/") + repository, tag = leaf.rsplit(":", 1) + scope = f"repository:supabase/{repository}:pull" + token_url = "https://public.ecr.aws/token/?" + urllib.parse.urlencode({ + "service": "public.ecr.aws", "scope": scope + }) + with urllib.request.urlopen(token_url, timeout=30) as response: + token_document = json.load(response) + token = token_document.get("token") if isinstance(token_document, dict) else None + if not isinstance(token, str) or not token: + raise SystemExit(f"Supabase registry returned no token for {runtime}") + manifest_url = f"https://public.ecr.aws/v2/supabase/{repository}/manifests/{tag}" + request = urllib.request.Request(manifest_url, method="HEAD", headers={ + "Authorization": f"Bearer {token}", + "Accept": ",".join([ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ]), + }) + with urllib.request.urlopen(request, timeout=30) as response: + current = response.headers.get("Docker-Content-Digest") + if current != digest: + raise SystemExit(f"Supabase registry digest changed for {runtime}") + approved.write(f"{service['service']}\t{runtime}\t{digest}\n") + proof.append({"service": service["service"], "runtime": runtime, "digest": digest}) +with open(sys.argv[4], "w", encoding="utf-8") as handle: + json.dump({"status": "PASS", "images": proof}, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") +PY + +while IFS=$'\t' read -r service runtime digest; do + [ -n "$service" ] || continue + docker_e pull "$runtime@$digest" > "$EVIDENCE/image-$service-pull.log" + docker_e tag "$runtime@$digest" "$runtime" + [ "$(docker_e image inspect --format '{{.Id}}' "$runtime@$digest")" = \ + "$(docker_e image inspect --format '{{.Id}}' "$runtime")" ] \ + || die "Supabase local tag is not bound to approved digest: $runtime" +done < "$EVIDENCE/approved-images.tsv" + +archive="$DOWNLOAD/supabase.tgz" +url="https://github.com/supabase/cli/releases/download/v$VERSION/supabase_${VERSION}_darwin_${ARCHIVE_ARCH}.tar.gz" +curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "$url" -o "$archive" +printf '%s %s\n' "$SHA256" "$archive" \ + | shasum -a 256 -c - > "$EVIDENCE/archive-checksum.txt" +tar -xzf "$archive" -C "$DOWNLOAD" supabase supabase-go +for executable in supabase supabase-go; do + [ -f "$DOWNLOAD/$executable" ] && [ ! -L "$DOWNLOAD/$executable" ] \ + && [ -x "$DOWNLOAD/$executable" ] \ + || die "verified Supabase archive did not contain direct CLI executables" +done +"$DOWNLOAD/supabase" --version > "$EVIDENCE/supabase-version.txt" +grep -Fxq "$VERSION" "$EVIDENCE/supabase-version.txt" \ + || die "Supabase binary version differs from the requested release" + +# Default ports are part of this compatibility contract; remapping a subset would not exercise the +# standard local stack that editors and SDKs discover. +for port in 54320 54321 54322 54323 54324 54325 54326 54327 54329; do + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 \ + || die "required Supabase default host port is already in use: $port" +done + +"$DOWNLOAD/supabase" init --workdir "$PROJECT" --yes \ + > "$EVIDENCE/init.log" 2> "$EVIDENCE/init.stderr" +mkdir -p "$PROJECT/supabase/migrations" +cat > "$PROJECT/supabase/migrations/20260712000000_dory_compat.sql" <<'SQL' +create table if not exists public.dory_compat (id bigint primary key, value text not null); +insert into public.dory_compat (id, value) values (1, 'supabase-on-dory') +on conflict (id) do update set value = excluded.value; +grant usage on schema public to anon, authenticated; +grant select on public.dory_compat to anon, authenticated; +SQL +cat > "$PROJECT/supabase/seed.sql" <<'SQL' +insert into public.dory_compat (id, value) values (2, 'seed-on-dory') +on conflict (id) do update set value = excluded.value; +SQL + +"$DOWNLOAD/supabase" start --workdir "$PROJECT" --yes \ + > /dev/null 2> "$EVIDENCE/start.stderr" +printf 'supabase_start=PASS\n' > "$EVIDENCE/start-proof.txt" +remove_secret_evidence + +owned_ids="$(docker_e ps -aq --filter 'label=com.supabase.cli.project')" +[ "$(printf '%s\n' "$owned_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = 13 ] \ + || die "full Supabase default stack did not create exactly 13 owned containers" +: > "$EVIDENCE/runtime-containers.jsonl" +for id in $owned_ids; do + docker_e inspect --format \ + '{"id":{{json .Id}},"runtime":{{json .Config.Image}},"project":{{json (index .Config.Labels "com.supabase.cli.project")}},"running":{{json .State.Running}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}"none"{{end}},"imageId":{{json .Image}}}' \ + "$id" >> "$EVIDENCE/runtime-containers.jsonl" +done +image_ids="$(docker_e inspect --format '{{.Image}}' $owned_ids | sort -u)" +: > "$EVIDENCE/runtime-images.jsonl" +for id in $image_ids; do + docker_e image inspect --format \ + '{"id":{{json .Id}},"repoDigests":{{json .RepoDigests}}}' \ + "$id" >> "$EVIDENCE/runtime-images.jsonl" +done +python3 - "$INVENTORY" "$EVIDENCE/runtime-containers.jsonl" \ + "$EVIDENCE/runtime-images.jsonl" \ + "$EVIDENCE/runtime-image-proof.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + inventory = json.load(handle) +def load_json_lines(path): + with open(path, encoding="utf-8") as handle: + return [json.loads(line) for line in handle if line.strip()] + +containers = load_json_lines(sys.argv[2]) +images = load_json_lines(sys.argv[3]) +enabled = {entry["runtime"]: entry for entry in inventory["services"] if entry["enabledByDefault"]} +if not isinstance(containers, list) or len(containers) != len(enabled): + raise SystemExit("Supabase running container inventory is incomplete") +if not isinstance(images, list): + raise SystemExit("Supabase local image inventory is invalid") +image_by_id = {image.get("id"): image for image in images if isinstance(image, dict)} +actual = set() +project_labels = set() +proof = [] +for container in containers: + if not isinstance(container, dict): + raise SystemExit("Supabase container inspect shape is invalid") + runtime = container.get("runtime") + if runtime not in enabled or runtime in actual: + raise SystemExit("Supabase runtime image set is unexpected or duplicated") + actual.add(runtime) + project = container.get("project") + if not isinstance(project, str) or not project: + raise SystemExit("Supabase project ownership label is missing") + project_labels.add(project) + if container.get("running") is not True: + raise SystemExit("Supabase service is not running") + health = container.get("health") + if health not in {"none", "healthy"}: + raise SystemExit("Supabase defined healthcheck is not healthy") + image = image_by_id.get(container.get("imageId")) + repo_digests = image.get("repoDigests") if isinstance(image, dict) else None + entry = enabled[runtime] + repository = runtime.rsplit(":", 1)[0] + exact = f"{repository}@{entry['digest']}" + if not isinstance(repo_digests, list) or exact not in repo_digests: + raise SystemExit("Supabase service image is not bound to its approved digest") + proof.append({"service": entry["service"], "runtime": runtime, "digest": entry["digest"]}) +if actual != set(enabled) or len(project_labels) != 1: + raise SystemExit("Supabase exact default image/project authority does not match") +with open(sys.argv[4], "w", encoding="utf-8") as handle: + json.dump({"status": "PASS", "services": sorted(proof, key=lambda item: item["service"])}, + handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") +PY + +docker_e ps --filter 'label=com.supabase.cli.project' \ + --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' > "$EVIDENCE/containers.tsv" +healthcheck_count="$(docker_e ps -q --filter 'label=com.supabase.cli.project' | while IFS= read -r id; do + [ -z "$id" ] && continue + docker_e inspect --format '{{if .State.Health}}1{{else}}0{{end}}' "$id" +done | awk '{ total += $1 } END { print total + 0 }')" +[ "$healthcheck_count" -ge 8 ] \ + || die "full Supabase stack exposed only $healthcheck_count Docker healthchecks" + +db_container="$(docker_e ps --filter 'name=supabase_db_' \ + --filter 'label=com.supabase.cli.project' --format '{{.ID}}' | sed -n '1p')" +[ -n "$db_container" ] || die "Supabase Postgres container is missing" +docker_e exec "$db_container" psql -U postgres -d postgres -Atc \ + "select string_agg(value, ',' order by id) from public.dory_compat" \ + > "$EVIDENCE/postgres-roundtrip.txt" +grep -qx 'supabase-on-dory,seed-on-dory' "$EVIDENCE/postgres-roundtrip.txt" \ + || die "Supabase migration/seed data is missing from Postgres" + +cat > "$DOWNLOAD/verify-status.py" <<'PY' +import json +import shlex +import sys +import urllib.parse +import urllib.request + +values = {} +for raw in sys.stdin: + raw = raw.strip() + if not raw or "=" not in raw: + continue + key, value = raw.split("=", 1) + try: + parsed = shlex.split(value) + values[key] = parsed[0] if parsed else "" + except ValueError: + values[key] = "" +required = ["API_URL", "ANON_KEY"] +if any(not isinstance(values.get(key), str) or not values[key] for key in required): + raise SystemExit("Supabase status omitted required local connection evidence") +api_url = values["API_URL"] +anon_key = values["ANON_KEY"] +url = urllib.parse.urlparse(api_url) +if url.scheme != "http" or url.hostname not in {"127.0.0.1", "localhost"} or url.port != 54321 \ + or url.path not in {"", "/"} or url.params or url.query or url.fragment: + raise SystemExit("Supabase API URL is not the standard loopback endpoint") +rest_url = api_url.rstrip("/") + "/rest/v1/dory_compat?select=id,value&order=id" +request = urllib.request.Request(rest_url, headers={ + "apikey": anon_key, + "Authorization": f"Bearer {anon_key}", +}) +with urllib.request.urlopen(request, timeout=10) as response: + rows = json.load(response) +expected = [ + {"id": 1, "value": "supabase-on-dory"}, + {"id": 2, "value": "seed-on-dory"}, +] +if rows != expected: + raise SystemExit("Supabase REST round-trip returned unexpected rows") +for endpoint in ("/auth/v1/health", "/storage/v1/status"): + with urllib.request.urlopen(api_url.rstrip("/") + endpoint, timeout=10) as response: + if response.status != 200: + raise SystemExit(f"Supabase health endpoint failed: {endpoint}") +with open(sys.argv[1] + "/rest-roundtrip.json", "w", encoding="utf-8") as handle: + json.dump(rows, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") +with open(sys.argv[1] + "/status-proof.txt", "w", encoding="utf-8") as handle: + handle.write("api_url_loopback=PASS\nanon_key_present=PASS\n") +with open(sys.argv[1] + "/auth-health.json", "w", encoding="utf-8") as handle: + handle.write('{"status":"PASS"}\n') +with open(sys.argv[1] + "/storage-health.json", "w", encoding="utf-8") as handle: + handle.write('{"status":"PASS"}\n') +PY +"$DOWNLOAD/supabase" status --workdir "$PROJECT" --output env \ + 2> "$EVIDENCE/status-env.stderr" \ + | python3 "$DOWNLOAD/verify-status.py" "$EVIDENCE" +remove_secret_evidence + +for port in 54321 54322 54323 54324 54327; do + lsof -nP -iTCP:"$port" -sTCP:LISTEN > "$EVIDENCE/listener-$port.txt" + if grep -Eq "TCP (\\*|0\\.0\\.0\\.0):$port \\(LISTEN\\)" "$EVIDENCE/listener-$port.txt"; then + die "Supabase port $port was widened to all host interfaces" + fi + grep -Eq "TCP (127\\.0\\.0\\.1|\\[::1\\]):$port \\(LISTEN\\)" "$EVIDENCE/listener-$port.txt" \ + || die "Supabase port $port has no loopback-only host listener" +done + +"$DOWNLOAD/supabase" stop --workdir "$PROJECT" --no-backup --yes \ + > "$EVIDENCE/stop.log" 2> "$EVIDENCE/stop.stderr" +cleanup_owned +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "Supabase gate did not restore the exact empty object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:19:18 +0000 Subject: [PATCH 130/338] feat(release): qualify Kubernetes tooling --- ...t-kubernetes-tooling-compatibility-gate.py | 104 +++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + .../kubernetes-tooling-compatibility-gate.sh | 604 ++++++++++++++++++ 4 files changed, 710 insertions(+) create mode 100755 .github/scripts/test-kubernetes-tooling-compatibility-gate.py create mode 100755 scripts/kubernetes-tooling-compatibility-gate.sh diff --git a/.github/scripts/test-kubernetes-tooling-compatibility-gate.py b/.github/scripts/test-kubernetes-tooling-compatibility-gate.py new file mode 100755 index 00000000..dc9844ca --- /dev/null +++ b/.github/scripts/test-kubernetes-tooling-compatibility-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for the exact Kubernetes tooling compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "kubernetes-tooling-compatibility-gate.sh" + + +class KubernetesToolingCompatibilityGateTests(unittest.TestCase): + def test_gate_binds_candidate_tools_control_plane_workloads_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-KUBERNETES-TOOLING", + "socket is not owned by the release user", + "$helper_name helper must be an absolute path", + "$helper_name helper is unavailable or indirect", + "tool cache tilt.tgz is missing or indirect", + "tool cache skaffold is missing or indirect", + "verified Tilt archive did not contain a direct regular binary", + "re.escape(version)", + '"$KUBECTL" version --client -o json', + 'shasum -a 256 "$DOCKER"', + 'shasum -a 256 "$KUBECTL"', + 'docker_e run -d --pull=never --privileged --name "$K3S_CONTAINER"', + 'dory.release.kubernetes-tooling.run=$RUN_ID', + "nested k3s container does not use the exact qualified image", + "nested k3s control plane unexpectedly binds host paths", + 'binding.get("HostIp") != "127.0.0.1"', + 're.fullmatch(r"127\\.0\\.0\\.1:([0-9]{1,5})\\n?", text)', + 'servers != ["https://127.0.0.1:6443"]', + "host Kubernetes API connection failed at stability sample", + "expected exactly one qualified workload pod", + "workload pod does not declare the exact qualified image", + "runtime workload image has no immutable image ID", + "ingress_only_network_policy_egress=PASS", + 'docker_e rm -f -v "$k3s_container_id"', + 'label=dory.release.kubernetes-tooling.run=$RUN_ID', + "k3s_container_exact_image=PASS", + "privileged_nested_control_plane=PASS", + "exact_workload_image=PASS", + "docker_cli_sha256=", + "kubectl_sha256=", + "tilt_binary_sha256=", + "skaffold_binary_sha256=", + "owned_container_cleanup=PASS", + "exact_baseline_cleanup=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "dory-k8s-tooling-gate", + "cleanup_objects", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helpers_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--kubectl", + "/missing/kubectl", + "--workroot", + str(workroot), + "--k3s-image", + "invalid", + "--workload-image", + "invalid", + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85fa7271..557c6b37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -354,6 +354,7 @@ jobs: python3 .github/scripts/test-localstack-compatibility-gate.py python3 .github/scripts/test-tilt-compose-compatibility-gate.py python3 .github/scripts/test-supabase-compatibility-gate.py + python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cf02d27e..ab301d7d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,6 +55,7 @@ jobs: python3 .github/scripts/test-localstack-compatibility-gate.py python3 .github/scripts/test-tilt-compose-compatibility-gate.py python3 .github/scripts/test-supabase-compatibility-gate.py + python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/kubernetes-tooling-compatibility-gate.sh b/scripts/kubernetes-tooling-compatibility-gate.sh new file mode 100755 index 00000000..59dc1ed7 --- /dev/null +++ b/scripts/kubernetes-tooling-compatibility-gate.sh @@ -0,0 +1,604 @@ +#!/bin/bash +# Runs checksum/digest-pinned k3s, Skaffold, and Tilt Kubernetes workflows on an empty Dory engine. +set -euo pipefail + +SOCKET="" +DOCKER="" +KUBECTL="" +WORKROOT="" +CONFIRM="" +K3S_IMAGE="${DORY_RELEASE_K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1@sha256:6a47cea22c4b834d4ba72c89d291696b79ebe406251f90b446e4dff03513dd87}" +WORKLOAD_IMAGE="${DORY_RELEASE_K8S_WORKLOAD_IMAGE:-nginx:alpine@sha256:54f2a904c251d5a34adf545a72d32515a15e08418dae0266e23be2e18c66fefa}" +TILT_VERSION="${DORY_RELEASE_TILT_VERSION:-0.37.5}" +TILT_SHA256="" +SKAFFOLD_VERSION="${DORY_RELEASE_SKAFFOLD_VERSION:-2.23.0}" +SKAFFOLD_SHA256="" +TOOL_CACHE="" +KUBERNETES_STABILITY_SAMPLES=60 + +usage() { + cat <<'EOF' +Usage: scripts/kubernetes-tooling-compatibility-gate.sh [required options] [options] + +Required: + --socket PATH Unix socket for an already-running disposable Dory engine + --docker PATH Exact Docker CLI from the candidate app + --kubectl PATH Exact kubectl CLI from the candidate app + --workroot DIR New evidence directory owned by this gate + --confirm TOKEN Must be ISOLATED-ENGINE-KUBERNETES-TOOLING + +Options: + --k3s-image REF Digest-pinned k3s image + --workload-image REF Digest-pinned Kubernetes HTTP fixture image + --tilt-version V Exact Tilt version (default: 0.37.5) + --tilt-sha256 HASH Tilt archive SHA-256 + --skaffold-version V Exact Skaffold version (default: 2.23.0) + --skaffold-sha256 HASH Skaffold binary SHA-256 + --tool-cache DIR Optional directory containing tilt.tgz and skaffold + +The gate starts a disposable nested k3s control plane, proves its API and node readiness, deploys +and deletes a NodePort workload through Skaffold, repeats through Tilt's Kubernetes engine, verifies +both host-facing listeners are loopback-only, and restores the exact empty Docker-object baseline. +EOF +} + +die() { echo "Kubernetes tooling compatibility gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --kubectl) need_value "$1" "$#"; KUBECTL="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --k3s-image) need_value "$1" "$#"; K3S_IMAGE="$2"; shift 2 ;; + --workload-image) need_value "$1" "$#"; WORKLOAD_IMAGE="$2"; shift 2 ;; + --tilt-version) need_value "$1" "$#"; TILT_VERSION="$2"; shift 2 ;; + --tilt-sha256) need_value "$1" "$#"; TILT_SHA256="$2"; shift 2 ;; + --skaffold-version) need_value "$1" "$#"; SKAFFOLD_VERSION="$2"; shift 2 ;; + --skaffold-sha256) need_value "$1" "$#"; SKAFFOLD_SHA256="$2"; shift 2 ;; + --tool-cache) need_value "$1" "$#"; TOOL_CACHE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-KUBERNETES-TOOLING ] \ + || die "requires --confirm ISOLATED-ENGINE-KUBERNETES-TOOLING" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +for helper_name in DOCKER KUBECTL; do + helper_path="${!helper_name}" + case "$helper_path" in /*) ;; *) die "$helper_name helper must be an absolute path" ;; esac + [ -f "$helper_path" ] && [ ! -L "$helper_path" ] && [ -x "$helper_path" ] \ + || die "$helper_name helper is unavailable or indirect: $helper_path" +done +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for exact_image in "$K3S_IMAGE" "$WORKLOAD_IMAGE"; do + printf '%s\n' "$exact_image" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "k3s and workload images must be exact digest references" +done +printf '%s\n' "$TILT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--tilt-version must be an exact semantic version" +printf '%s\n' "$SKAFFOLD_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--skaffold-version must be an exact semantic version" +case "$(uname -m)" in + arm64) + TILT_ARCH=arm64 + SKAFFOLD_ARCH=arm64 + DEFAULT_TILT_SHA=d8c701ada9d3ee29c983651a8f344d8a4c13363e6c25a843b478aa4444ee6f30 + DEFAULT_SKAFFOLD_SHA=91723c608562b11cbbdd1df8596e8bb54ab4d7069184ba1e29497bba8d69047c + ;; + x86_64) + TILT_ARCH=x86_64 + SKAFFOLD_ARCH=amd64 + DEFAULT_TILT_SHA=5db0bd3a690db4d12ddf22afbe14df5a56f0d6351731694c2e1e59158b3eb00c + DEFAULT_SKAFFOLD_SHA=2a10d49399eaa87794af73a1f0687d6501d72a15ece60de2c3b712248fe583e4 + ;; + *) die "unsupported macOS architecture: $(uname -m)" ;; +esac +if [ -z "$TILT_SHA256" ]; then + [ "$TILT_VERSION" = 0.37.5 ] || die "--tilt-sha256 is required for a non-default Tilt version" + TILT_SHA256="$DEFAULT_TILT_SHA" +fi +if [ -z "$SKAFFOLD_SHA256" ]; then + [ "$SKAFFOLD_VERSION" = 2.23.0 ] \ + || die "--skaffold-sha256 is required for a non-default Skaffold version" + SKAFFOLD_SHA256="$DEFAULT_SKAFFOLD_SHA" +fi +printf '%s\n' "$TILT_SHA256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "Tilt SHA-256 is invalid" +printf '%s\n' "$SKAFFOLD_SHA256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "Skaffold SHA-256 is invalid" +for command in curl lsof python3 shasum tar; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +if [ -n "$TOOL_CACHE" ]; then + case "$TOOL_CACHE" in /*) ;; *) die "--tool-cache must be absolute" ;; esac + [ -d "$TOOL_CACHE" ] && [ ! -L "$TOOL_CACHE" ] \ + || die "tool cache is unavailable or indirect: $TOOL_CACHE" +fi + +mkdir -p "$WORKROOT/evidence" "$WORKROOT/workspace" "$WORKROOT/download" +WORKROOT="$(cd "$WORKROOT" && pwd)" +EVIDENCE="$WORKROOT/evidence" +WORKSPACE="$WORKROOT/workspace" +DOWNLOAD="$WORKROOT/download" +TOOL_HOME="$WORKROOT/tool-home" +KUBECONFIG="$WORKROOT/kubeconfig" +mkdir -p "$TOOL_HOME" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +K3S_CONTAINER="dory-k8s-tooling-$RUN_ID" +k3s_container_id="" +export DOCKER_HOST="unix://$SOCKET" +unset DOCKER_CONTEXT +docker_e() { "$DOCKER" "$@"; } +engine_health() { + docker_e version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Server.APIVersion}}' +} +custom_network_ids() { docker_e network ls --filter type=custom --format '{{.ID}}' | sed '/^$/d'; } +object_counts() { + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(custom_network_ids | wc -l | tr -d ' ')" +} +cleanup_outer_container() { + if [ -n "$k3s_container_id" ]; then + docker_e rm -f -v "$k3s_container_id" \ + > "$EVIDENCE/k3s-cleanup.log" 2>&1 || true + fi + docker_e ps -aq --filter "label=dory.release.kubernetes-tooling.run=$RUN_ID" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f -v "$id" \ + >> "$EVIDENCE/k3s-cleanup.log" 2>&1 || true + done +} +cleanup() { + set +e + if [ -x "$DOWNLOAD/tilt" ] && [ -f "$WORKSPACE/Tiltfile" ] && [ -f "$KUBECONFIG" ]; then + (cd "$WORKSPACE" && HOME="$TOOL_HOME" KUBECONFIG="$KUBECONFIG" \ + "$DOWNLOAD/tilt" down --file Tiltfile --delete-namespaces) \ + >/dev/null 2>&1 || true + fi + if [ -x "$DOWNLOAD/skaffold" ] && [ -f "$WORKSPACE/skaffold.yaml" ] && [ -f "$KUBECONFIG" ]; then + (cd "$WORKSPACE" && HOME="$TOOL_HOME" KUBECONFIG="$KUBECONFIG" \ + "$DOWNLOAD/skaffold" delete --filename skaffold.yaml) >/dev/null 2>&1 || true + fi + cleanup_outer_container + rm -rf "$DOWNLOAD" "$KUBECONFIG" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +object_counts > "$EVIDENCE/baseline.txt" +grep -qx 'containers=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing containers" +grep -qx 'volumes=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing named volumes" +grep -qx 'custom_networks=0' "$EVIDENCE/baseline.txt" || die "engine has pre-existing custom networks" + +engine_health > "$EVIDENCE/engine-health-before-tools.txt" +tilt_archive="$DOWNLOAD/tilt.tgz" +if [ -n "$TOOL_CACHE" ]; then + [ -f "$TOOL_CACHE/tilt.tgz" ] && [ ! -L "$TOOL_CACHE/tilt.tgz" ] \ + || die "tool cache tilt.tgz is missing or indirect" + [ -f "$TOOL_CACHE/skaffold" ] && [ ! -L "$TOOL_CACHE/skaffold" ] \ + || die "tool cache skaffold is missing or indirect" + cp "$TOOL_CACHE/tilt.tgz" "$tilt_archive" + cp "$TOOL_CACHE/skaffold" "$DOWNLOAD/skaffold" + printf 'source=checksum-verified-cache\n' > "$EVIDENCE/tool-source.txt" +else + curl -fsSL --retry 5 --retry-all-errors --continue-at - --connect-timeout 15 --max-time 900 \ + "https://github.com/tilt-dev/tilt/releases/download/v$TILT_VERSION/tilt.$TILT_VERSION.mac.$TILT_ARCH.tar.gz" \ + -o "$tilt_archive" + curl -fsSL --retry 5 --retry-all-errors --continue-at - --connect-timeout 15 --max-time 900 \ + "https://github.com/GoogleContainerTools/skaffold/releases/download/v$SKAFFOLD_VERSION/skaffold-darwin-$SKAFFOLD_ARCH" \ + -o "$DOWNLOAD/skaffold" + printf 'source=official-release-download\n' > "$EVIDENCE/tool-source.txt" +fi +printf '%s %s\n' "$TILT_SHA256" "$tilt_archive" | shasum -a 256 -c - \ + > "$EVIDENCE/tilt-checksum.txt" +tar -xzf "$tilt_archive" -C "$DOWNLOAD" tilt +[ -f "$DOWNLOAD/tilt" ] && [ ! -L "$DOWNLOAD/tilt" ] \ + || die "verified Tilt archive did not contain a direct regular binary" + +printf '%s %s\n' "$SKAFFOLD_SHA256" "$DOWNLOAD/skaffold" | shasum -a 256 -c - \ + > "$EVIDENCE/skaffold-checksum.txt" +chmod 0755 "$DOWNLOAD/skaffold" +[ -f "$DOWNLOAD/skaffold" ] && [ ! -L "$DOWNLOAD/skaffold" ] \ + && [ -x "$DOWNLOAD/tilt" ] && [ -x "$DOWNLOAD/skaffold" ] \ + || die "verified tooling downloads are not executable" +"$DOWNLOAD/tilt" version > "$EVIDENCE/tilt-version.txt" +python3 - "$EVIDENCE/tilt-version.txt" "$TILT_VERSION" <<'PY' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +version = sys.argv[2] +if re.search(rf"(? "$EVIDENCE/skaffold-telemetry-disabled.txt" +HOME="$TOOL_HOME" "$DOWNLOAD/skaffold" version > "$EVIDENCE/skaffold-version.txt" +grep -Fx "v$SKAFFOLD_VERSION" "$EVIDENCE/skaffold-version.txt" >/dev/null \ + || die "Skaffold binary version differs from the requested release" +"$KUBECTL" version --client -o json > "$EVIDENCE/kubectl-client-version.json" \ + || die "candidate kubectl client version inspection failed" +shasum -a 256 "$DOCKER" > "$EVIDENCE/docker-cli-sha256.txt" +shasum -a 256 "$KUBECTL" > "$EVIDENCE/kubectl-sha256.txt" +shasum -a 256 "$DOWNLOAD/tilt" > "$EVIDENCE/tilt-binary-sha256.txt" +shasum -a 256 "$DOWNLOAD/skaffold" > "$EVIDENCE/skaffold-binary-sha256.txt" +engine_health > "$EVIDENCE/engine-health-after-tools.txt" \ + || die "Dory engine became unavailable while preparing Kubernetes tools" + +pull_ok=0 +for pull_attempt in 1 2 3; do + if docker_e pull "$K3S_IMAGE" >> "$EVIDENCE/k3s-pull.txt" 2>> "$EVIDENCE/k3s-pull.stderr"; then + pull_ok=1 + break + fi + if ! engine_health >> "$EVIDENCE/k3s-pull-engine-health.txt" 2>&1; then + die "Dory engine became unavailable during the k3s image pull" + fi + printf 'attempt=%s result=retryable-stream-failure\n' "$pull_attempt" \ + >> "$EVIDENCE/k3s-pull-retries.txt" + sleep 2 +done +[ "$pull_ok" -eq 1 ] || die "k3s image pull failed after three healthy-engine attempts" +engine_health > "$EVIDENCE/engine-health-after-k3s-pull.txt" +k3s_container_id="$(docker_e run -d --pull=never --privileged --name "$K3S_CONTAINER" \ + --label "dory.release.kubernetes-tooling.run=$RUN_ID" \ + -p 127.0.0.1::6443 -p 127.0.0.1::30080 \ + "$K3S_IMAGE" server --disable=traefik --tls-san=127.0.0.1 --write-kubeconfig-mode=644 \ + )" +printf '%s\n' "$k3s_container_id" > "$EVIDENCE/k3s-container-id.txt" +printf '%s\n' "$k3s_container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "nested k3s launch did not return an exact container ID" +docker_e inspect "$k3s_container_id" > "$EVIDENCE/k3s-container-inspect.json" +python3 - "$EVIDENCE/k3s-container-inspect.json" "$K3S_IMAGE" "$RUN_ID" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(document, list) or len(document) != 1 or not isinstance(document[0], dict): + raise SystemExit("unexpected nested k3s inspect shape") +container = document[0] +config = container.get("Config") +host = container.get("HostConfig") +if not isinstance(config, dict) or not isinstance(host, dict): + raise SystemExit("nested k3s inspect omitted configuration") +if config.get("Image") != sys.argv[2]: + raise SystemExit("nested k3s container does not use the exact qualified image") +labels = config.get("Labels") +if not isinstance(labels, dict) or labels.get("dory.release.kubernetes-tooling.run") != sys.argv[3]: + raise SystemExit("nested k3s container is missing its run authority label") +if host.get("Privileged") is not True: + raise SystemExit("nested k3s control plane is not privileged") +if host.get("Binds") not in (None, []): + raise SystemExit("nested k3s control plane unexpectedly binds host paths") +for key in ("6443/tcp", "30080/tcp"): + bindings = host.get("PortBindings", {}).get(key) + if not isinstance(bindings, list) or len(bindings) != 1: + raise SystemExit(f"nested k3s has unexpected {key} bindings") + binding = bindings[0] + if not isinstance(binding, dict) or binding.get("HostIp") != "127.0.0.1": + raise SystemExit(f"nested k3s {key} is not loopback-only") +PY +k3s_ready=0 +for _ in $(seq 1 180); do + state="$(docker_e inspect "$k3s_container_id" --format '{{.State.Status}}' 2>/dev/null || true)" + [ "$state" = running ] || die "k3s container exited during startup" + if docker_e exec "$k3s_container_id" kubectl get nodes --no-headers 2>/dev/null \ + | grep -q ' Ready'; then + k3s_ready=1 + break + fi + sleep 2 +done +[ "$k3s_ready" -eq 1 ] || die "k3s node did not become Ready" +docker_e port "$k3s_container_id" 6443/tcp > "$EVIDENCE/k3s-api-port.txt" +docker_e port "$k3s_container_id" 30080/tcp > "$EVIDENCE/k3s-node-port.txt" +read_port() { + python3 - "$1" <<'PY' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +match = re.fullmatch(r"127\.0\.0\.1:([0-9]{1,5})\n?", text) +if match is None or not 1 <= int(match.group(1)) <= 65535: + raise SystemExit("Dory did not allocate one exact loopback host port") +print(match.group(1)) +PY +} +api_port="$(read_port "$EVIDENCE/k3s-api-port.txt")" +node_port="$(read_port "$EVIDENCE/k3s-node-port.txt")" +docker_e exec "$k3s_container_id" cat /etc/rancher/k3s/k3s.yaml \ + > "$EVIDENCE/kubeconfig-container.yaml" +python3 - "$EVIDENCE/kubeconfig-container.yaml" "$KUBECONFIG" "$api_port" <<'PY' +import pathlib +import re +import sys + +source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +servers = re.findall(r"(?m)^\s*server:\s*(\S+)\s*$", source) +if servers != ["https://127.0.0.1:6443"]: + raise SystemExit("nested kubeconfig has unexpected API authorities") +rendered = source.replace("https://127.0.0.1:6443", f"https://127.0.0.1:{sys.argv[3]}") +pathlib.Path(sys.argv[2]).write_text(rendered, encoding="utf-8") +PY +chmod 0600 "$KUBECONFIG" +KUBECONFIG="$KUBECONFIG" "$KUBECTL" get --raw /version > "$EVIDENCE/kubernetes-version.json" +KUBECONFIG="$KUBECONFIG" "$KUBECTL" get nodes -o wide > "$EVIDENCE/kubernetes-nodes.txt" +grep -q ' Ready ' "$EVIDENCE/kubernetes-nodes.txt" || die "host kubectl did not observe a Ready node" +verify_workload_pod() { + selector="$1" + evidence_path="$2" + KUBECONFIG="$KUBECONFIG" "$KUBECTL" -n dory-tooling-gate get pods \ + -l "$selector" -o json > "$evidence_path" + python3 - "$evidence_path" "$WORKLOAD_IMAGE" <<'PY' +import json +import pathlib +import re +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +items = document.get("items") if isinstance(document, dict) else None +if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): + raise SystemExit("expected exactly one qualified workload pod") +pod = items[0] +containers = pod.get("spec", {}).get("containers") +statuses = pod.get("status", {}).get("containerStatuses") +if not isinstance(containers, list) or len(containers) != 1 or containers[0].get("image") != sys.argv[2]: + raise SystemExit("workload pod does not declare the exact qualified image") +if pod.get("status", {}).get("phase") != "Running": + raise SystemExit("qualified workload pod is not running") +if not isinstance(statuses, list) or len(statuses) != 1 or statuses[0].get("ready") is not True: + raise SystemExit("qualified workload pod is not ready") +image_id = statuses[0].get("imageID") +if not isinstance(image_id, str) or re.search(r"@sha256:[0-9a-f]{64}$", image_id) is None: + raise SystemExit("runtime workload image has no immutable image ID") +PY +} +for mapping in "api:$api_port" "nodeport:$node_port"; do + name="${mapping%%:*}" + port="${mapping##*:}" + lsof -nP -iTCP:"$port" -sTCP:LISTEN > "$EVIDENCE/listener-$name.txt" + grep -Eq "TCP (127\\.0\\.0\\.1|\\[::1\\]):$port \\(LISTEN\\)" "$EVIDENCE/listener-$name.txt" \ + || die "$name port has no loopback-only host listener" + ! grep -Eq "TCP (\\*|0\\.0\\.0\\.0):$port \\(LISTEN\\)" "$EVIDENCE/listener-$name.txt" \ + || die "$name port widened to all host interfaces" +done + +stability_started=$SECONDS +: > "$EVIDENCE/kubernetes-api-stability.tsv" +printf 'sample\tepoch\treadyz\n' >> "$EVIDENCE/kubernetes-api-stability.tsv" +for sample in $(seq 1 "$KUBERNETES_STABILITY_SAMPLES"); do + readyz="$(KUBECONFIG="$KUBECONFIG" "$KUBECTL" get --raw /readyz 2>/dev/null)" \ + || die "host Kubernetes API connection failed at stability sample $sample" + [ "$readyz" = ok ] \ + || die "host Kubernetes API was not ready at stability sample $sample: $readyz" + printf '%s\t%s\t%s\n' "$sample" "$(date +%s)" "$readyz" \ + >> "$EVIDENCE/kubernetes-api-stability.tsv" + [ "$sample" -eq "$KUBERNETES_STABILITY_SAMPLES" ] || sleep 1 +done +stability_duration=$((SECONDS - stability_started)) + +cat > "$WORKSPACE/skaffold.yaml" <<'YAML' +apiVersion: skaffold/v4beta13 +kind: Config +metadata: + name: dory-kubernetes-tooling +manifests: + rawYaml: + - skaffold-k8s.yaml +deploy: + kubectl: {} +YAML +cat > "$WORKSPACE/skaffold-k8s.yaml" < "$EVIDENCE/skaffold-run.log" 2> "$EVIDENCE/skaffold-run.stderr" +KUBECONFIG="$KUBECONFIG" "$KUBECTL" -n dory-tooling-gate rollout status \ + deployment/skaffold-web --timeout=5m > "$EVIDENCE/skaffold-rollout.txt" +verify_workload_pod 'app=skaffold-web' "$EVIDENCE/skaffold-workload-pod.json" +cat > "$WORKSPACE/ingress-only-network-policy.yaml" <<'YAML' +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ingress-only-web + namespace: dory-tooling-gate +spec: + podSelector: + matchLabels: {app: skaffold-web} + policyTypes: [Ingress] + ingress: + - {} +YAML +KUBECONFIG="$KUBECONFIG" "$KUBECTL" apply -f "$WORKSPACE/ingress-only-network-policy.yaml" \ + > "$EVIDENCE/ingress-only-network-policy-apply.txt" +skaffold_pod="$(KUBECONFIG="$KUBECONFIG" "$KUBECTL" -n dory-tooling-gate get pod \ + -l app=skaffold-web -o jsonpath='{.items[0].metadata.name}')" +[ -n "$skaffold_pod" ] || die "Skaffold workload pod is missing for NetworkPolicy probe" +KUBECONFIG="$KUBECONFIG" "$KUBECTL" -n dory-tooling-gate exec "$skaffold_pod" -- sh -ec ' + getent hosts skaffold-web.dory-tooling-gate.svc.cluster.local + wget -qO- --timeout=5 http://skaffold-web.dory-tooling-gate.svc.cluster.local/ \ + | grep -qi "Welcome to nginx!" +' > "$EVIDENCE/ingress-only-network-policy-egress.txt" +curl -fsS --retry 30 --retry-delay 1 --retry-all-errors --max-time 5 \ + "http://127.0.0.1:$node_port/" > "$EVIDENCE/skaffold-http.html" +grep -qi 'Welcome to nginx!' "$EVIDENCE/skaffold-http.html" \ + || die "Skaffold NodePort workload returned unexpected content" +(cd "$WORKSPACE" && HOME="$TOOL_HOME" KUBECONFIG="$KUBECONFIG" "$DOWNLOAD/skaffold" delete \ + --filename skaffold.yaml) > "$EVIDENCE/skaffold-delete.log" 2>&1 +for _ in $(seq 1 120); do + ! KUBECONFIG="$KUBECONFIG" "$KUBECTL" get namespace dory-tooling-gate >/dev/null 2>&1 \ + && break + sleep 0.5 +done +! KUBECONFIG="$KUBECONFIG" "$KUBECTL" get namespace dory-tooling-gate >/dev/null 2>&1 \ + || die "Skaffold delete did not remove its namespace" + +cat > "$WORKSPACE/Tiltfile" <<'TILT' +allow_k8s_contexts('default') +k8s_yaml('tilt-k8s.yaml') +k8s_resource('tilt-web') +TILT +cat > "$WORKSPACE/tilt-k8s.yaml" < "$EVIDENCE/tilt-kubernetes.log" 2> "$EVIDENCE/tilt-kubernetes.stderr" +KUBECONFIG="$KUBECONFIG" "$KUBECTL" -n dory-tooling-gate rollout status \ + deployment/tilt-web --timeout=5m > "$EVIDENCE/tilt-rollout.txt" +verify_workload_pod 'app=tilt-web' "$EVIDENCE/tilt-workload-pod.json" +curl -fsS --retry 30 --retry-delay 1 --retry-all-errors --max-time 5 \ + "http://127.0.0.1:$node_port/" > "$EVIDENCE/tilt-http.html" +grep -qi 'Welcome to nginx!' "$EVIDENCE/tilt-http.html" \ + || die "Tilt NodePort workload returned unexpected content" +(cd "$WORKSPACE" && HOME="$TOOL_HOME" KUBECONFIG="$KUBECONFIG" "$DOWNLOAD/tilt" down \ + --file Tiltfile --delete-namespaces) \ + > "$EVIDENCE/tilt-down.log" 2> "$EVIDENCE/tilt-down.stderr" +for _ in $(seq 1 120); do + ! KUBECONFIG="$KUBECONFIG" "$KUBECTL" get namespace dory-tooling-gate >/dev/null 2>&1 \ + && break + sleep 0.5 +done +! KUBECONFIG="$KUBECONFIG" "$KUBECTL" get namespace dory-tooling-gate >/dev/null 2>&1 \ + || die "Tilt down did not remove its namespace" + +docker_e rm -f -v "$k3s_container_id" > "$EVIDENCE/k3s-remove.txt" +k3s_container_id="" +rm -f "$KUBECONFIG" +rm -rf "$DOWNLOAD" +object_counts > "$EVIDENCE/final.txt" +cmp -s "$EVIDENCE/baseline.txt" "$EVIDENCE/final.txt" \ + || die "Kubernetes tooling gate did not restore the exact empty Docker-object baseline" + +cat > "$WORKROOT/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:22:16 +0000 Subject: [PATCH 131/338] feat(release): qualify default platform selection --- .../test-default-platform-image-gate.py | 92 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/default-platform-image-gate.sh | 287 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100755 .github/scripts/test-default-platform-image-gate.py create mode 100755 scripts/default-platform-image-gate.sh diff --git a/.github/scripts/test-default-platform-image-gate.py b/.github/scripts/test-default-platform-image-gate.py new file mode 100755 index 00000000..45af8561 --- /dev/null +++ b/.github/scripts/test-default-platform-image-gate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Offline contract for default multi-platform image selection qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "default-platform-image-gate.sh" + + +class DefaultPlatformImageGateTests(unittest.TestCase): + def test_gate_binds_candidate_default_selection_and_reporting_surfaces(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-DEFAULT-PLATFORM", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "workroot already exists or is indirect", + "qualification engine is not empty", + "# Intentionally no --platform here.", + 'docker_e pull "$IMAGE"', + 'docker_e create --pull=never --name "$NAME"', + "container did not use the exact requested image", + "container does not carry the exact run authority label", + "default-platform container binds host paths", + "fresh qualification store contains", + "image-list and system-df storage bytes disagree", + "local image does not retain the exact requested manifest-list authority", + 'docker_e ps -aq --filter "label=dev.dory.default-platform=$OWNER"', + "fresh_empty_store=PASS", + "default_pull_without_platform=PASS", + "single_platform_local_image=PASS", + "default_run_architecture=PASS", + "exact_container_image=PASS", + "host_path_free_container=PASS", + "image_list_system_df_reconciled=PASS", + "owned_container_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + self.assertNotIn('docker_e pull --platform', text) + for stale in ( + "assert ", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + 'echo "socket=$SOCKET"', + 'echo "docker=$DOCKER"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 557c6b37..fadc5fdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,6 +355,7 @@ jobs: python3 .github/scripts/test-tilt-compose-compatibility-gate.py python3 .github/scripts/test-supabase-compatibility-gate.py python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py + python3 .github/scripts/test-default-platform-image-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ab301d7d..4c47e974 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,7 @@ jobs: python3 .github/scripts/test-tilt-compose-compatibility-gate.py python3 .github/scripts/test-supabase-compatibility-gate.py python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py + python3 .github/scripts/test-default-platform-image-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/default-platform-image-gate.sh b/scripts/default-platform-image-gate.sh new file mode 100755 index 00000000..59dd59d0 --- /dev/null +++ b/scripts/default-platform-image-gate.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# Proves an unqualified multi-platform pull selects only the Apple-Silicon platform and that Docker's +# three local image/storage reporting surfaces reconcile to the same bytes. +set -euo pipefail + +SOCKET="" +DOCKER="" +IMAGE="" +EXPECTED_PLATFORM="linux/arm64" +WORKROOT="${TMPDIR:-/tmp}/dory-default-platform-image" +REQUIRE_DOCKER_HUB=0 +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --expected-platform) need_value "$1" "$#"; EXPECTED_PLATFORM="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --require-docker-hub) REQUIRE_DOCKER_HUB=1; shift ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-DEFAULT-PLATFORM ] \ + || die "requires --confirm ISOLATED-ENGINE-DEFAULT-PLATFORM" +[ -S "$SOCKET" ] || die "Dory socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$IMAGE" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--image must be digest-pinned" +printf '%s\n' "$EXPECTED_PLATFORM" | grep -Eq '^linux/(arm64|amd64)$' \ + || die "--expected-platform must be linux/arm64 or linux/amd64" +image_name="${IMAGE%@*}" +first_component="${image_name%%/*}" +registry=docker.io +if [ "$image_name" != "$first_component" ]; then + case "$first_component" in + *.*|*:*|localhost) registry="$first_component" ;; + esac +fi +if [ "$REQUIRE_DOCKER_HUB" -eq 1 ] && [ "$registry" != docker.io ]; then + die "--require-docker-hub received a non-Docker-Hub image: $IMAGE" +fi +for command in curl python3 shasum; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" + +docker_e() { DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@"; } +docker_e version >/dev/null || die "Docker API is not ready at $SOCKET" +baseline_counts="$( + printf 'containers=%s\n' "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'images=%s\n' "$(docker_e image ls -q | sed '/^$/d' | sort -u | wc -l | tr -d ' ')" + printf 'volumes=%s\n' "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'custom_networks=%s\n' "$(docker_e network ls --filter type=custom -q | sed '/^$/d' | wc -l | tr -d ' ')" +)" +for empty_object in containers images volumes custom_networks; do + printf '%s\n' "$baseline_counts" | grep -qx "$empty_object=0" \ + || die "qualification engine is not empty: $empty_object" +done +if docker_e image inspect "$IMAGE" >/dev/null 2>&1; then + die "target image already exists; default pull selection would not be proven: $IMAGE" +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-default-platform-$RUN_ID" +NAME="dory-default-platform-${RUN_ID//[^a-zA-Z0-9]/}" +WORKDIR="$WORKROOT/$RUN_ID" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$WORKDIR" +printf '%s\n' "$baseline_counts" > "$WORKDIR/baseline.txt" +container_id="" + +cleanup() { + if [ -n "$container_id" ]; then + docker_e rm -f "$container_id" >/dev/null 2>&1 || true + fi + docker_e ps -aq --filter "label=dev.dory.default-platform=$OWNER" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Intentionally no --platform here. This exact command is the behavior under qualification. +docker_e pull "$IMAGE" > "$WORKDIR/default-pull.txt" \ + || die "default image pull failed" +docker_e image inspect "$IMAGE" > "$WORKDIR/image-inspect.json" +curl -fsS --max-time 10 --unix-socket "$SOCKET" 'http://d/images/json?digests=1' \ + > "$WORKDIR/images-json.json" +curl -fsS --max-time 10 --unix-socket "$SOCKET" http://d/system/df \ + > "$WORKDIR/system-df.json" + +expected_arch="${EXPECTED_PLATFORM#linux/}" +container_id="$(docker_e create --pull=never --name "$NAME" \ + --label "dev.dory.default-platform=$OWNER" "$IMAGE" uname -m)" +printf '%s\n' "$container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "default-platform container creation returned an invalid ID" +docker_e inspect "$container_id" > "$WORKDIR/container-inspect.json" +docker_e start -a "$container_id" > "$WORKDIR/default-run-uname.txt" +docker_e rm "$container_id" >/dev/null +container_id="" +[ -z "$(docker_e ps -aq --filter "label=dev.dory.default-platform=$OWNER")" ] \ + || die "owned default-platform container survived cleanup" + +stats="$(python3 - "$WORKDIR/image-inspect.json" "$WORKDIR/images-json.json" \ + "$WORKDIR/system-df.json" "$WORKDIR/container-inspect.json" \ + "$WORKDIR/default-run-uname.txt" "$IMAGE" "$expected_arch" "$OWNER" <<'PY' +import json +import pathlib +import sys + +( + inspect_path, + images_path, + df_path, + container_path, + uname_path, + reference, + expected_arch, + owner, +) = sys.argv[1:] +inspect = json.loads(pathlib.Path(inspect_path).read_text(encoding="utf-8")) + +def require(condition, message): + if not condition: + raise SystemExit(message) + +require(isinstance(inspect, list) and len(inspect) == 1, "image inspect is not one exact image") +image = inspect[0] +require(image.get("Os") == "linux", f"default pull selected non-Linux OS: {image.get('Os')}") +require( + image.get("Architecture") == expected_arch, + f"default pull selected {image.get('Architecture')} instead of {expected_arch}", +) +image_id = image.get("Id") +require( + isinstance(image_id, str) and len(image_id) == 71 + and image_id.startswith("sha256:") + and all(character in "0123456789abcdef" for character in image_id[7:]), + "local image ID is invalid", +) +inspect_size = int(image.get("Size", 0)) +require(inspect_size > 0, "image inspect reports zero local bytes") + +container_document = json.loads(pathlib.Path(container_path).read_text(encoding="utf-8")) +require( + isinstance(container_document, list) and len(container_document) == 1, + "container inspect is not one exact container", +) +container = container_document[0] +container_config = container.get("Config") +host_config = container.get("HostConfig") +require(isinstance(container_config, dict), "container inspect omits Config") +require(isinstance(host_config, dict), "container inspect omits HostConfig") +require(container_config.get("Image") == reference, "container did not use the exact requested image") +labels = container_config.get("Labels") +require( + isinstance(labels, dict) and labels.get("dev.dory.default-platform") == owner, + "container does not carry the exact run authority label", +) +require(host_config.get("Binds") in (None, []), "default-platform container binds host paths") + +images = json.loads(pathlib.Path(images_path).read_text(encoding="utf-8")) +require(isinstance(images, list), "/images/json is not a list") +require(len(images) == 1, f"fresh qualification store contains {len(images)} image records") +matches = [entry for entry in images if entry.get("Id") == image_id] +require(len(matches) == 1, f"/images/json has {len(matches)} entries for the selected image") +list_size = int(matches[0].get("Size", 0)) + +system_df = json.loads(pathlib.Path(df_path).read_text(encoding="utf-8")) +require(isinstance(system_df, dict), "/system/df is not an object") +df_images = system_df.get("Images") or [] +require(isinstance(df_images, list), "/system/df Images is not a list") +require(len(df_images) == 1, f"fresh /system/df contains {len(df_images)} image records") +df_matches = [entry for entry in df_images if entry.get("Id") == image_id] +require(len(df_matches) == 1, f"/system/df has {len(df_matches)} entries for the selected image") +df_size = int(df_matches[0].get("Size", 0)) +require( + list_size == df_size, + f"image-list and system-df storage bytes disagree: list={list_size} system_df={df_size}", +) +size_ratio_milli = max(inspect_size, df_size) * 1000 // min(inspect_size, df_size) +require( + size_ratio_milli <= 16000, + f"inspect/storage size definitions diverge by more than 16x: {size_ratio_milli / 1000:.3f}x", +) +layers_size = int(system_df.get("LayersSize", 0)) +require( + layers_size >= df_size, + f"system-df layer bytes are below its image bytes: layers={layers_size} image={df_size}", +) +require( + layers_size <= df_size * 2, + f"system-df layer bytes are not attributable to the one local image: {layers_size} vs {df_size}", +) + +uname = pathlib.Path(uname_path).read_text(encoding="utf-8").strip() +expected_uname = {"arm64": {"aarch64", "arm64"}, "amd64": {"x86_64", "amd64"}}[expected_arch] +require(uname in expected_uname, f"default run architecture is {uname}, expected {expected_arch}") +repo_digests = image.get("RepoDigests") or [] +requested_digest = reference.rsplit("@", 1)[-1] +requested_name = reference.rsplit("@", 1)[0] +accepted_names = {requested_name} +if "/" not in requested_name: + accepted_names.add("docker.io/library/" + requested_name) +elif ( + requested_name.split("/", 1)[0] != "localhost" + and "." not in requested_name.split("/", 1)[0] + and ":" not in requested_name.split("/", 1)[0] +): + accepted_names.add("docker.io/" + requested_name) +require( + isinstance(repo_digests, list) + and any(value == name + "@" + requested_digest for name in accepted_names for value in repo_digests), + "local image does not retain the exact requested manifest-list authority", +) + +print(f"image_id={image_id}") +print(f"local_architecture={expected_arch}") +print(f"default_run_uname={uname}") +print(f"inspect_size_bytes={inspect_size}") +print(f"image_list_size_bytes={list_size}") +print(f"system_df_size_bytes={df_size}") +print(f"system_df_layers_size_bytes={layers_size}") +print(f"inspect_to_storage_ratio_milli={size_ratio_milli}") +print(f"requested_digest={requested_digest}") +PY +)" || die "default platform/storage evidence failed semantic verification" + +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "owner=$OWNER" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "image=$IMAGE" + echo "registry=$registry" + echo "expected_platform=$EXPECTED_PLATFORM" + echo "fresh_empty_store=PASS" + echo "default_pull_without_platform=PASS" + echo "single_platform_local_image=PASS" + echo "default_run_architecture=PASS" + echo "exact_container_image=PASS" + echo "host_path_free_container=PASS" + echo "image_list_system_df_reconciled=PASS" + echo "owned_container_cleanup=PASS" + printf '%s\n' "$stats" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +trap - EXIT INT TERM +echo "default platform image gate: PASS ($MANIFEST)" From 9c3e627f80d559b285bb817855b63ab620a78be5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:24:31 +0000 Subject: [PATCH 132/338] feat(release): qualify bind file coherence --- .../scripts/test-bind-file-coherence-gate.py | 104 ++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/bind-file-coherence-gate.sh | 243 ++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100755 .github/scripts/test-bind-file-coherence-gate.py create mode 100755 scripts/bind-file-coherence-gate.sh diff --git a/.github/scripts/test-bind-file-coherence-gate.py b/.github/scripts/test-bind-file-coherence-gate.py new file mode 100755 index 00000000..4270f03d --- /dev/null +++ b/.github/scripts/test-bind-file-coherence-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for direct-file bind coherence qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "bind-file-coherence-gate.sh" + + +class BindFileCoherenceGateTests(unittest.TestCase): + def test_gate_binds_exact_runtime_mounts_and_all_coherence_phases(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-BIND-FILE-COHERENCE", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "probe image must be an exact digest reference", + 'docker_e run -d --pull=never --network none --name "$NAME"', + '--mount "type=bind,src=$SHARE,dst=/work"', + '--mount "type=bind,src=$FILE,dst=/single/value.bin"', + "bind probe did not launch the exact qualified image", + "bind probe is missing its exact ownership label", + "bind probe unexpectedly has network access", + "bind probe mount graph differs from the exact host sources", + "guest retained stale metadata/content", + "same-inode-shrink", + "same-inode-grow", + "same-inode-content", + "atomic-replacement-pinned-direct", + "direct-rebind-after-replacement", + "guest-truncate", + 'docker_e ps -aq --filter "label=$LABEL"', + "exact_container_authority=PASS", + "network_isolation=PASS", + "direct_single_file_recreate_cycles=%s", + "same_inode_shrink=PASS", + "same_inode_grow=PASS", + "same_inode_content_refresh=PASS", + "direct_atomic_replacement_pins_inode=PASS", + "direct_rebind_follows_replacement=PASS", + "guest_to_host_truncation=PASS", + "owned_container_cleanup=PASS", + "docker_cli_sha256=", + "results_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "alpine:latest", + "--pull never", + "docker_e rm -f $owned", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_BIND_COHERENCE_CONFIRM": "", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fadc5fdc..d421e16d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,6 +356,7 @@ jobs: python3 .github/scripts/test-supabase-compatibility-gate.py python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py python3 .github/scripts/test-default-platform-image-gate.py + python3 .github/scripts/test-bind-file-coherence-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4c47e974..6ca6fa77 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,7 @@ jobs: python3 .github/scripts/test-supabase-compatibility-gate.py python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py python3 .github/scripts/test-default-platform-image-gate.py + python3 .github/scripts/test-bind-file-coherence-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/bind-file-coherence-gate.sh b/scripts/bind-file-coherence-gate.sh new file mode 100755 index 00000000..caf61c08 --- /dev/null +++ b/scripts/bind-file-coherence-gate.sh @@ -0,0 +1,243 @@ +#!/bin/bash +# Focused reproduction for stale bind-file size/content metadata (Docker Desktop #7501). +set -euo pipefail +umask 077 + +SOCKET="${DORY_SOCK:-$HOME/.dory/dory.sock}" +DOCKER="${DORY_DOCKER_BIN:-$(command -v docker 2>/dev/null || true)}" +IMAGE="${DORY_BIND_COHERENCE_IMAGE:-}" +WORKROOT="${DORY_BIND_COHERENCE_WORKROOT:-$HOME/.dory-bind-coherence}" +CONFIRM="${DORY_BIND_COHERENCE_CONFIRM:-}" + +usage() { + cat <<'EOF' +Usage: scripts/bind-file-coherence-gate.sh [options] + +Options: + --socket PATH Docker-compatible Dory socket (default: ~/.dory/dory.sock) + --docker PATH Docker CLI + --image REF Already-local digest-pinned probe image + --workroot PATH Evidence root inside Dory's shared home (default: ~/.dory-bind-coherence) + --confirm TOKEN Must be ISOLATED-ENGINE-BIND-FILE-COHERENCE + -h, --help + +The gate uses uniquely named/labeled containers with --pull=never and a fresh host directory whose +path contains spaces. It proves direct single-file bind reliability, same-inode shrink/grow/content +refresh, atomic replacement, and guest-to-host truncation. +EOF +} + +die() { echo "bind-file-coherence: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-BIND-FILE-COHERENCE ] \ + || die "requires --confirm ISOLATED-ENGINE-BIND-FILE-COHERENCE" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect" +case "$SOCKET" in /*) ;; *) die "socket must be absolute" ;; esac +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +printf '%s\n' "$IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "probe image must be an exact digest reference" +for command in dd python3 seq shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +"$DOCKER" -H "unix://$SOCKET" version >/dev/null 2>&1 || die "Docker API is unreachable" +"$DOCKER" -H "unix://$SOCKET" image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "probe image must already be local: $IMAGE" + +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_DIR="$WORKROOT/$RUN_ID" +SHARE="$RUN_DIR/path with spaces" +RESULTS="$RUN_DIR/results.tsv" +NAME="dory-bind-coherence-$(printf '%s' "$RUN_ID" | tr -cd '[:alnum:]')" +LABEL="dev.dory.bind-coherence=$RUN_ID" +mkdir -p "$SHARE" +printf 'phase\thost_inode\thost_size\tguest_directory_size\tguest_direct_size\thost_sha256\tguest_directory_sha256\tguest_direct_sha256\n' > "$RESULTS" +container_id="" + +docker_e() { "$DOCKER" -H "unix://$SOCKET" "$@"; } +cleanup() { + if [ -n "$container_id" ]; then + docker_e rm -f "$container_id" >/dev/null 2>&1 || true + fi + docker_e ps -aq --filter "label=$LABEL" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +FILE="$SHARE/value.bin" +dd if=/dev/zero of="$FILE" bs=4096 count=1 2>/dev/null +printf 'initial-marker\n' | dd of="$FILE" conv=notrunc 2>/dev/null +start_primary_container() { + container_id="$(docker_e run -d --pull=never --network none --name "$NAME" --label "$LABEL" \ + --mount "type=bind,src=$SHARE,dst=/work" \ + --mount "type=bind,src=$FILE,dst=/single/value.bin" \ + "$IMAGE" sh -c 'exec tail -f /dev/null')" + printf '%s\n' "$container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "bind probe launch returned an invalid container ID" +} +start_primary_container +docker_e inspect "$container_id" > "$RUN_DIR/primary-container-inspect.json" +python3 - "$RUN_DIR/primary-container-inspect.json" "$IMAGE" "$LABEL" "$SHARE" "$FILE" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(document, list) or len(document) != 1 or not isinstance(document[0], dict): + raise SystemExit("bind probe inspect is not one exact container") +container = document[0] +config = container.get("Config") +host = container.get("HostConfig") +mounts = container.get("Mounts") +if not isinstance(config, dict) or not isinstance(host, dict) or not isinstance(mounts, list): + raise SystemExit("bind probe inspect omits runtime authority") +if config.get("Image") != sys.argv[2]: + raise SystemExit("bind probe did not launch the exact qualified image") +label_key, label_value = sys.argv[3].split("=", 1) +labels = config.get("Labels") +if not isinstance(labels, dict) or labels.get(label_key) != label_value: + raise SystemExit("bind probe is missing its exact ownership label") +if host.get("NetworkMode") != "none": + raise SystemExit("bind probe unexpectedly has network access") +expected = {(sys.argv[4], "/work"), (sys.argv[5], "/single/value.bin")} +actual = { + (item.get("Source"), item.get("Destination")) + for item in mounts + if isinstance(item, dict) and item.get("Type") == "bind" and item.get("RW") is True +} +if actual != expected or len(mounts) != 2: + raise SystemExit("bind probe mount graph differs from the exact host sources") +PY + +host_inode() { stat -f%i "$FILE"; } +host_size() { stat -f%z "$FILE"; } +host_sha() { shasum -a 256 "$FILE" | awk '{print $1}'; } +guest_size() { docker_e exec "$container_id" stat -c %s /work/value.bin 2>/dev/null | tr -d '\r'; } +guest_sha() { docker_e exec "$container_id" sha256sum /work/value.bin 2>/dev/null | awk '{print $1}'; } +guest_direct_size() { docker_e exec "$container_id" stat -c %s /single/value.bin 2>/dev/null | tr -d '\r'; } +guest_direct_sha() { docker_e exec "$container_id" sha256sum /single/value.bin 2>/dev/null | awk '{print $1}'; } + +wait_guest_state() { + local expected_size="$1" expected_sha="$2" expected_direct_size="$3" expected_direct_sha="$4" + local deadline=$(( $(date +%s) + 15 )) size sha direct_size direct_sha + while [ "$(date +%s)" -lt "$deadline" ]; do + size="$(guest_size || true)"; sha="$(guest_sha || true)" + direct_size="$(guest_direct_size || true)"; direct_sha="$(guest_direct_sha || true)" + [ "$size" = "$expected_size" ] && [ "$sha" = "$expected_sha" ] \ + && [ "$direct_size" = "$expected_direct_size" ] && [ "$direct_sha" = "$expected_direct_sha" ] \ + && return 0 + sleep 1 + done + die "guest retained stale metadata/content: expected size=$expected_size sha=$expected_sha, directory=${size:-unavailable}/${sha:-unavailable}, direct=${direct_size:-unavailable}/${direct_sha:-unavailable}" +} + +record_phase() { + local phase="$1" hs hsha gs gsha direct_size direct_sha expected_direct_size expected_direct_sha + hs="$(host_size)"; hsha="$(host_sha)" + expected_direct_size="${2:-$hs}"; expected_direct_sha="${3:-$hsha}" + wait_guest_state "$hs" "$hsha" "$expected_direct_size" "$expected_direct_sha" + gs="$(guest_size)"; gsha="$(guest_sha)" + direct_size="$(guest_direct_size)"; direct_sha="$(guest_direct_sha)" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$phase" "$(host_inode)" "$hs" "$gs" "$direct_size" "$hsha" "$gsha" "$direct_sha" \ + >> "$RESULTS" +} + +initial_inode="$(host_inode)" +record_phase initial + +printf 'shrunk\n' > "$FILE" +[ "$(host_inode)" = "$initial_inode" ] || die "shrink unexpectedly replaced the host inode" +record_phase same-inode-shrink + +dd if=/dev/zero of="$FILE" bs=131073 count=1 2>/dev/null +printf 'grown-marker\n' | dd of="$FILE" conv=notrunc 2>/dev/null +[ "$(host_inode)" = "$initial_inode" ] || die "grow unexpectedly replaced the host inode" +record_phase same-inode-grow + +printf 'same-size-new-content' | dd of="$FILE" bs=21 count=1 conv=notrunc 2>/dev/null +[ "$(host_inode)" = "$initial_inode" ] || die "content write unexpectedly replaced the host inode" +record_phase same-inode-content + +direct_size_before_replace="$(guest_direct_size)" +direct_sha_before_replace="$(guest_direct_sha)" +printf 'atomic-replacement-with-a-distinct-size\n' > "$FILE.replacement" +mv "$FILE.replacement" "$FILE" +[ "$(host_inode)" != "$initial_inode" ] || die "atomic replacement reused the old host inode" +# Native Linux direct file binds pin the source inode: the directory view follows an atomic path +# replacement, while the direct view intentionally remains on the old inode until reattached. +record_phase atomic-replacement-pinned-direct \ + "$direct_size_before_replace" "$direct_sha_before_replace" +docker_e rm -f "$container_id" >/dev/null +container_id="" +start_primary_container +record_phase direct-rebind-after-replacement + +docker_e exec "$container_id" sh -c "printf xyz > /single/value.bin" +[ "$(host_size)" = 3 ] || die "guest truncation was not visible on the host" +[ "$(host_sha)" = "$(printf xyz | shasum -a 256 | awk '{print $1}')" ] \ + || die "guest write content was not visible on the host" +record_phase guest-truncate + +docker_e rm -f "$container_id" >/dev/null +container_id="" +DIRECT_CYCLES=20 +for cycle in $(seq 1 "$DIRECT_CYCLES"); do + docker_e run --rm --pull=never --network none \ + --name "$NAME-direct-$cycle" --label "$LABEL" \ + --mount "type=bind,src=$FILE,dst=/single/value.bin" "$IMAGE" \ + sh -c '[ "$(cat /single/value.bin)" = xyz ]' >/dev/null +done +[ "$(host_sha)" = "$(printf xyz | shasum -a 256 | awk '{print $1}')" ] \ + || die "repeated direct file mounts changed the host file" +[ -z "$(docker_e ps -aq --filter "label=$LABEL")" ] \ + || die "owned bind probe container survived cleanup" + +{ + printf 'status=PASS\n' + printf 'path_with_spaces=PASS\n' + printf 'directory_bind=PASS\n' + printf 'direct_single_file_bind=PASS\n' + printf 'exact_container_authority=PASS\n' + printf 'network_isolation=PASS\n' + printf 'direct_single_file_recreate_cycles=%s\n' "$DIRECT_CYCLES" + printf 'same_inode_shrink=PASS\n' + printf 'same_inode_grow=PASS\n' + printf 'same_inode_content_refresh=PASS\n' + printf 'atomic_replacement=PASS\n' + printf 'direct_atomic_replacement_pins_inode=PASS\n' + printf 'direct_rebind_follows_replacement=PASS\n' + printf 'guest_to_host_truncation=PASS\n' + printf 'owned_container_cleanup=PASS\n' + printf 'image=%s\n' "$IMAGE" + printf 'docker_cli_sha256=%s\n' "$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + printf 'results_sha256=%s\n' "$(shasum -a 256 "$RESULTS" | awk '{print $1}')" +} > "$RUN_DIR/manifest.txt" +trap - EXIT INT TERM +printf 'bind-file coherence gate PASS; evidence: %s\n' "$RUN_DIR" From 4f0fed4a65ad88c8d75804c2980e285c7d39230b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:28:31 +0000 Subject: [PATCH 133/338] feat(release): qualify prune safety --- .github/scripts/test-prune-safety-gate.py | 98 ++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/prune-safety-gate.sh | 277 ++++++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100755 .github/scripts/test-prune-safety-gate.py create mode 100755 scripts/prune-safety-gate.sh diff --git a/.github/scripts/test-prune-safety-gate.py b/.github/scripts/test-prune-safety-gate.py new file mode 100755 index 00000000..9eefd445 --- /dev/null +++ b/.github/scripts/test-prune-safety-gate.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Offline contract for destructive prune qualification on an exact isolated engine.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "prune-safety-gate.sh" + + +class PruneSafetyGateTests(unittest.TestCase): + def test_gate_proves_exact_preconditions_survivors_victims_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-PRUNE", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "dedicated engine must contain exactly the qualified base image", + "initial system-df does not contain exactly the qualified base image", + "initial system-df contains pre-existing", + "--network none --pull=false", + "prune fixture build returned an invalid image ID", + "prune fixture containers do not bind the exact built images", + "fixture engine contains containers outside the exact prune scenario", + "fixture engine contains volumes outside the exact prune scenario", + "fixture engine contains custom networks outside the exact prune scenario", + "fixture engine contains images outside the exact prune scenario", + "docker_e system prune -af --volumes", + "docker_e container prune -f", + "docker_e image prune -af", + "docker_e network prune -f", + "docker_e volume prune -af", + "docker_e builder prune -af", + "protected volume data changed during prune", + "stopped victim container survived prune", + "unused victim image survived prune", + "unused victim volume survived prune", + "unused victim network survived prune", + "post-prune images differ from the protected fixture image", + "exact_base_image_precondition=PASS", + "exact_owned_fixture=PASS", + "active_volume_bytes_preserved=PASS", + "build_cache_removed=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "assert ", + "Docker CLI is unavailable\"", + "^.+@sha256", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_source_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--source-commit", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("destructive prune requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d421e16d..e1ca9340 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -357,6 +357,7 @@ jobs: python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py python3 .github/scripts/test-default-platform-image-gate.py python3 .github/scripts/test-bind-file-coherence-gate.py + python3 .github/scripts/test-prune-safety-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6ca6fa77..a2439b06 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,7 @@ jobs: python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py python3 .github/scripts/test-default-platform-image-gate.py python3 .github/scripts/test-bind-file-coherence-gate.py + python3 .github/scripts/test-prune-safety-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/prune-safety-gate.sh b/scripts/prune-safety-gate.sh new file mode 100755 index 00000000..1f5ec880 --- /dev/null +++ b/scripts/prune-safety-gate.sh @@ -0,0 +1,277 @@ +#!/bin/bash +# Destructive prune contract for an explicitly empty, dedicated Dory engine. +set -euo pipefail +umask 077 + +SOCKET="${DORY_SOCK:-$HOME/.dory/dory.sock}" +DOCKER="${DORY_DOCKER_BIN:-$(command -v docker 2>/dev/null || true)}" +BASE_IMAGE="${DORY_PRUNE_BASE_IMAGE:-}" +SOURCE_COMMIT="${DORY_PRUNE_SOURCE_COMMIT:-}" +WORKROOT="${DORY_PRUNE_WORKROOT:-$HOME/.dory-prune-safety}" +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/prune-safety-gate.sh --confirm ISOLATED-ENGINE-PRUNE [options] + +Options: + --socket PATH Dedicated Dory Docker socket + --docker PATH Docker CLI + --base-image REF Digest-pinned, already-local base image + --source-commit SHA Exact 40-character source commit + --workroot PATH Evidence root (default: ~/.dory-prune-safety) + -h, --help + +This runs unfiltered container/image/network/volume/system/builder prune commands. It refuses to +start unless the engine has zero containers, volumes, and custom networks. Never point it at a +user engine. The exact confirmation token is mandatory. +EOF +} +die() { echo "prune-safety: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option $1" ;; + esac +done + +[ "$CONFIRM" = "ISOLATED-ENGINE-PRUNE" ] \ + || die "destructive prune requires --confirm ISOLATED-ENGINE-PRUNE" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect" +printf '%s\n' "$BASE_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--base-image must be digest-pinned" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "--source-commit must be a full lowercase Git SHA" +case "$SOCKET" in /*) ;; *) die "socket must be absolute" ;; esac +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +for command in curl python3 shasum; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done + +docker_e() { "$DOCKER" -H "unix://$SOCKET" "$@"; } +docker_e version >/dev/null 2>&1 || die "Docker API is unreachable" +[ "$(docker_e ps -aq | wc -l | tr -d ' ')" = 0 ] || die "dedicated engine must have zero containers" +[ "$(docker_e volume ls -q | wc -l | tr -d ' ')" = 0 ] || die "dedicated engine must have zero volumes" +[ "$(docker_e network ls --filter type=custom -q | wc -l | tr -d ' ')" = 0 ] \ + || die "dedicated engine must have zero custom networks" +docker_e image inspect "$BASE_IMAGE" >/dev/null 2>&1 || die "base image must already be local: $BASE_IMAGE" +base_image_id="$(docker_e image inspect "$BASE_IMAGE" --format '{{.Id}}')" +printf '%s\n' "$base_image_id" | grep -Eq '^sha256:[0-9a-f]{64}$' \ + || die "base image has an invalid local content ID" +local_image_ids="$(docker_e image ls -aq --no-trunc | sort -u)" +[ "$local_image_ids" = "$base_image_id" ] \ + || die "dedicated engine must contain exactly the qualified base image" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +SLUG="$(printf '%s' "$RUN_ID" | tr -cd '[:alnum:]')" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +RUN_DIR="$WORKROOT/$RUN_ID" +mkdir "$RUN_DIR" +PROTECTED_IMAGE="dory-prune-protected:$SLUG" +VICTIM_IMAGE="dory-prune-victim:$SLUG" +PROTECTED_CONTAINER="dory-prune-protected-$SLUG" +VICTIM_CONTAINER="dory-prune-victim-$SLUG" +PROTECTED_VOLUME="dory-prune-protected-$SLUG" +VICTIM_VOLUME="dory-prune-victim-$SLUG" +PROTECTED_NETWORK="dory-prune-protected-$SLUG" +VICTIM_NETWORK="dory-prune-victim-$SLUG" +LABEL="dev.dory.prune-safety=$RUN_ID" +curl -fsS --max-time 5 --unix-socket "$SOCKET" http://d/v1.41/system/df \ + > "$RUN_DIR/system-df-initial.json" +python3 - "$RUN_DIR/system-df-initial.json" "$base_image_id" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(document, dict): + raise SystemExit("initial system-df response is not an object") +images = document.get("Images") or [] +if not isinstance(images, list) or len(images) != 1 or images[0].get("Id") != sys.argv[2]: + raise SystemExit("initial system-df does not contain exactly the qualified base image") +for key in ("Containers", "Volumes", "BuildCache"): + records = document.get(key) or [] + if not isinstance(records, list) or records: + raise SystemExit(f"initial system-df contains pre-existing {key}") +PY + +cleanup() { + set +e + docker_e rm -f "$PROTECTED_CONTAINER" "$VICTIM_CONTAINER" >/dev/null 2>&1 || true + docker_e network rm "$PROTECTED_NETWORK" "$VICTIM_NETWORK" >/dev/null 2>&1 || true + docker_e volume rm -f "$PROTECTED_VOLUME" "$VICTIM_VOLUME" >/dev/null 2>&1 || true + docker_e image rm -f "$PROTECTED_IMAGE" "$VICTIM_IMAGE" >/dev/null 2>&1 || true + set -e +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +protected_image_id="$(printf 'FROM %s\nLABEL %s\nRUN printf protected-image >/image-marker\n' \ + "$BASE_IMAGE" "$LABEL" | docker_e build --network none --pull=false -q -t "$PROTECTED_IMAGE" -)" +victim_image_id="$(printf 'FROM %s\nLABEL %s\nRUN printf victim-image >/image-marker\n' \ + "$BASE_IMAGE" "$LABEL" | docker_e build --network none --pull=false -q -t "$VICTIM_IMAGE" -)" +printf '%s\n' "$protected_image_id" > "$RUN_DIR/protected-build.id" +printf '%s\n' "$victim_image_id" > "$RUN_DIR/victim-build.id" +for fixture_image_id in "$protected_image_id" "$victim_image_id"; do + printf '%s\n' "$fixture_image_id" | grep -Eq '^sha256:[0-9a-f]{64}$' \ + || die "prune fixture build returned an invalid image ID" +done +[ "$protected_image_id" != "$victim_image_id" ] \ + || die "protected and victim fixture images unexpectedly share an ID" +[ "$(docker_e volume create --label "$LABEL" "$PROTECTED_VOLUME")" = "$PROTECTED_VOLUME" ] \ + || die "protected volume creation returned the wrong name" +[ "$(docker_e volume create --label "$LABEL" "$VICTIM_VOLUME")" = "$VICTIM_VOLUME" ] \ + || die "victim volume creation returned the wrong name" +protected_network_id="$(docker_e network create --label "$LABEL" "$PROTECTED_NETWORK")" +victim_network_id="$(docker_e network create --label "$LABEL" "$VICTIM_NETWORK")" +for fixture_network_id in "$protected_network_id" "$victim_network_id"; do + printf '%s\n' "$fixture_network_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "prune fixture network creation returned an invalid ID" +done +protected_container_id="$(docker_e run -d --pull=never --name "$PROTECTED_CONTAINER" \ + --label "$LABEL" --network "$PROTECTED_NETWORK" \ + --mount "type=volume,src=$PROTECTED_VOLUME,dst=/state" "$PROTECTED_IMAGE" sh -c \ + 'printf protected-volume >/state/marker; exec tail -f /dev/null')" +victim_container_id="$(docker_e create --pull=never --name "$VICTIM_CONTAINER" \ + --label "$LABEL" "$VICTIM_IMAGE" true)" +for fixture_container_id in "$protected_container_id" "$victim_container_id"; do + printf '%s\n' "$fixture_container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "prune fixture launch returned an invalid container ID" +done +docker_e inspect "$protected_container_id" "$victim_container_id" \ + > "$RUN_DIR/fixture-containers.json" +docker_e image inspect "$protected_image_id" "$victim_image_id" \ + > "$RUN_DIR/fixture-images.json" +python3 - "$RUN_DIR/fixture-containers.json" "$RUN_DIR/fixture-images.json" \ + "$LABEL" "$protected_image_id" "$victim_image_id" <<'PY' +import json +import pathlib +import sys + +containers = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +images = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +label_key, label_value = sys.argv[3].split("=", 1) +if not isinstance(containers, list) or len(containers) != 2: + raise SystemExit("prune fixture does not contain exactly two containers") +if not isinstance(images, list) or len(images) != 2: + raise SystemExit("prune fixture does not contain exactly two images") +for item in containers + images: + config = item.get("Config") + labels = config.get("Labels") if isinstance(config, dict) else None + if not isinstance(labels, dict) or labels.get(label_key) != label_value: + raise SystemExit("prune fixture is missing its exact ownership label") +actual_image_ids = {item.get("Image") for item in containers} +if actual_image_ids != {sys.argv[4], sys.argv[5]}: + raise SystemExit("prune fixture containers do not bind the exact built images") +PY +[ "$(docker_e ps -aq | sed '/^$/d' | wc -l | tr -d ' ')" = 2 ] \ + || die "fixture engine contains containers outside the exact prune scenario" +[ "$(docker_e volume ls -q | sed '/^$/d' | wc -l | tr -d ' ')" = 2 ] \ + || die "fixture engine contains volumes outside the exact prune scenario" +[ "$(docker_e network ls --filter type=custom -q | sed '/^$/d' | wc -l | tr -d ' ')" = 2 ] \ + || die "fixture engine contains custom networks outside the exact prune scenario" +fixture_image_ids="$(docker_e image ls -aq --no-trunc | sort -u)" +expected_fixture_image_ids="$(printf '%s\n' \ + "$base_image_id" "$protected_image_id" "$victim_image_id" | sort -u)" +[ "$fixture_image_ids" = "$expected_fixture_image_ids" ] \ + || die "fixture engine contains images outside the exact prune scenario" + +curl -fsS --max-time 5 --unix-socket "$SOCKET" http://d/v1.41/system/df > "$RUN_DIR/system-df-before.json" +docker_e system prune -af --volumes > "$RUN_DIR/system-prune.txt" +docker_e container prune -f > "$RUN_DIR/container-prune.txt" +docker_e image prune -af > "$RUN_DIR/image-prune.txt" +docker_e network prune -f > "$RUN_DIR/network-prune.txt" +docker_e volume prune -af > "$RUN_DIR/volume-prune.txt" +docker_e builder prune -af > "$RUN_DIR/builder-prune.txt" +curl -fsS --max-time 5 --unix-socket "$SOCKET" http://d/v1.41/system/df > "$RUN_DIR/system-df-after.json" + +docker_e inspect "$PROTECTED_CONTAINER" >/dev/null +docker_e image inspect "$PROTECTED_IMAGE" >/dev/null +docker_e volume inspect "$PROTECTED_VOLUME" >/dev/null +docker_e network inspect "$PROTECTED_NETWORK" >/dev/null +[ "$(docker_e exec "$PROTECTED_CONTAINER" cat /state/marker)" = protected-volume ] \ + || die "protected volume data changed during prune" + +! docker_e inspect "$VICTIM_CONTAINER" >/dev/null 2>&1 || die "stopped victim container survived prune" +! docker_e image inspect "$VICTIM_IMAGE" >/dev/null 2>&1 || die "unused victim image survived prune" +! docker_e volume inspect "$VICTIM_VOLUME" >/dev/null 2>&1 || die "unused victim volume survived prune" +! docker_e network inspect "$VICTIM_NETWORK" >/dev/null 2>&1 || die "unused victim network survived prune" + +python3 - "$RUN_DIR/system-df-after.json" "$protected_image_id" <<'PY' +import json +import pathlib +import sys + +report = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(report, dict): + raise SystemExit("post-prune system-df response is not an object") +cache = report.get("BuildCache") or [] +if not isinstance(cache, list) or cache: + raise SystemExit(f"builder cache survived prune: {len(cache)} records") +images = report.get("Images") or [] +if not isinstance(images, list) or {item.get("Id") for item in images} != {sys.argv[2]}: + raise SystemExit("post-prune images differ from the protected fixture image") +PY + +cat > "$RUN_DIR/manifest.txt.partial" </dev/null 2>&1 \ + || docker_e image inspect "$VICTIM_IMAGE" >/dev/null 2>&1; then + die "prune gate cleanup left run-owned image tags" +fi +cat >> "$RUN_DIR/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:31:30 +0000 Subject: [PATCH 134/338] feat(release): qualify data drive volume identity --- .../test-data-drive-volume-identity-gate.py | 85 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/data-drive-volume-identity-gate.sh | 253 ++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100755 .github/scripts/test-data-drive-volume-identity-gate.py create mode 100755 scripts/data-drive-volume-identity-gate.sh diff --git a/.github/scripts/test-data-drive-volume-identity-gate.py b/.github/scripts/test-data-drive-volume-identity-gate.py new file mode 100755 index 00000000..f3b8c88b --- /dev/null +++ b/.github/scripts/test-data-drive-volume-identity-gate.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Offline contract for physical APFS data-drive volume identity qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "data-drive-volume-identity-gate.sh" + + +class DataDriveVolumeIdentityGateTests(unittest.TestCase): + def test_gate_binds_candidate_helper_images_devices_and_drive_authority(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "PHYSICAL-APFS-VOLUME-IDENTITY", + "physical Apple silicon is required", + "dory-hv must be absolute", + "dory-hv is missing or indirect", + "workroot already exists or is indirect", + "generated APFS mount name is already in use", + "hdiutil attach -plist -nobrowse", + "hdiutil attached the image at an unexpected mount point", + "hdiutil did not report an exact mounted APFS device", + 'hdiutil detach "$first_device"', + 'hdiutil detach "$second_device"', + "data-drive manifest has an unexpected shape", + "data-drive manifest ID differs from dory-hv output", + "selection authority has an unexpected shape", + "selection authority differs from the APFS volume", + "clearing runtime state forgot the selected drive", + "bookmark did not recover the renamed volume", + "detached selected volume was accepted", + "same-name replacement volume was accepted", + "original drive identity changed", + "external_volume_identity=PASS", + "durable_selection_outside_runtime_state=PASS", + "bookmark_volume_rename_recovery=PASS", + "missing_volume_shadow_prevention=PASS", + "same_name_wrong_volume_rejected=PASS", + "original_volume_reaccepted=PASS", + "exact_candidate_helper=PASS", + "exact_device_detach=PASS", + "dory_hv_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ( + "assert ", + 'hdiutil detach "$MOUNT"', + 'hdiutil detach "$RENAMED_MOUNT"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_architecture_helper_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--dory-hv", + "/missing/dory-hv", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1ca9340..9a859502 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -358,6 +358,7 @@ jobs: python3 .github/scripts/test-default-platform-image-gate.py python3 .github/scripts/test-bind-file-coherence-gate.py python3 .github/scripts/test-prune-safety-gate.py + python3 .github/scripts/test-data-drive-volume-identity-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a2439b06..98e284cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,6 +59,7 @@ jobs: python3 .github/scripts/test-default-platform-image-gate.py python3 .github/scripts/test-bind-file-coherence-gate.py python3 .github/scripts/test-prune-safety-gate.py + python3 .github/scripts/test-data-drive-volume-identity-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/data-drive-volume-identity-gate.sh b/scripts/data-drive-volume-identity-gate.sh new file mode 100755 index 00000000..ae601033 --- /dev/null +++ b/scripts/data-drive-volume-identity-gate.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# Proves that the exact dory-hv binds an external .dorydrive to its APFS volume UUID, not merely +# the reusable /Volumes/ path. Uses two disposable sparse APFS images with the same name. +set -euo pipefail +umask 077 + +usage() { + echo "Usage: $0 --dory-hv PATH --confirm PHYSICAL-APFS-VOLUME-IDENTITY [--workroot DIR]" >&2 +} + +DORY_HV="" +WORKROOT="${TMPDIR:-/tmp}/dory-volume-identity-evidence" +CONFIRM="" +while [ "$#" -gt 0 ]; do + case "$1" in + --dory-hv) DORY_HV="${2:?--dory-hv requires a path}"; shift 2 ;; + --workroot) WORKROOT="${2:?--workroot requires a directory}"; shift 2 ;; + --confirm) CONFIRM="${2:?--confirm requires a token}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "data-drive volume identity gate: unknown argument: $1" >&2; usage; exit 64 ;; + esac +done + +[ "$CONFIRM" = PHYSICAL-APFS-VOLUME-IDENTITY ] \ + || { echo "data-drive volume identity gate: requires --confirm PHYSICAL-APFS-VOLUME-IDENTITY" >&2; exit 2; } +[ "$(uname -m)" = arm64 ] \ + || { echo "data-drive volume identity gate: physical Apple silicon is required" >&2; exit 69; } +case "$DORY_HV" in /*) ;; *) echo "data-drive volume identity gate: dory-hv must be absolute" >&2; exit 66 ;; esac +[ -f "$DORY_HV" ] && [ ! -L "$DORY_HV" ] && [ -x "$DORY_HV" ] \ + || { echo "data-drive volume identity gate: dory-hv is missing or indirect" >&2; exit 66; } +case "$WORKROOT" in /*) ;; *) echo "data-drive volume identity gate: workroot must be absolute" >&2; exit 73 ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") echo "data-drive volume identity gate: unsafe workroot" >&2; exit 73 ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || { echo "data-drive volume identity gate: workroot already exists or is indirect" >&2; exit 73; } +for command in diskutil hdiutil python3 shasum; do + command -v "$command" >/dev/null \ + || { echo "data-drive volume identity gate: $command is missing" >&2; exit 69; } +done + +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +TEST_HOME="$RUN_ROOT/home" +FIRST_IMAGE="$RUN_ROOT/first.dmg" +SECOND_IMAGE="$RUN_ROOT/second.dmg" +COPIED_DRIVE="$RUN_ROOT/copied.dorydrive" +VOLUME_NAME="DoryIdentity-$PPID-$$" +RENAMED_VOLUME_NAME="DoryRenamed-$PPID-$$" +MOUNT="/Volumes/$VOLUME_NAME" +RENAMED_MOUNT="/Volumes/$RENAMED_VOLUME_NAME" +DRIVE="$MOUNT/Dory.dorydrive" +RENAMED_DRIVE="$RENAMED_MOUNT/Dory.dorydrive" +SELECTION_RECORD="$TEST_HOME/Library/Application Support/Dory/data-drive-selection.json" + +[ ! -e "$RUN_ROOT" ] \ + || { echo "data-drive volume identity gate: run directory already exists" >&2; exit 73; } +mkdir -p "$EVIDENCE" "$TEST_HOME" +[ ! -e "$MOUNT" ] && [ ! -e "$RENAMED_MOUNT" ] \ + || { echo "data-drive volume identity gate: generated APFS mount name is already in use" >&2; exit 73; } +first_device="" +second_device="" + +run_dory_hv() { + HOME="$TEST_HOME" "$DORY_HV" "$@" +} + +cleanup() { + status=$? + set +e + [ -z "$second_device" ] || hdiutil detach "$second_device" -quiet >/dev/null 2>&1 || true + [ -z "$first_device" ] || hdiutil detach "$first_device" -quiet >/dev/null 2>&1 || true + rm -f "$FIRST_IMAGE" "$SECOND_IMAGE" + rm -rf "$COPIED_DRIVE" + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +attach_image() { + local image="$1" expected_mount="$2" evidence="$3" + hdiutil attach -plist -nobrowse "$image" > "$evidence" + python3 - "$evidence" "$expected_mount" <<'PY' +import pathlib +import plistlib +import re +import sys + +document = plistlib.loads(pathlib.Path(sys.argv[1]).read_bytes()) +if not isinstance(document, dict) or "system-entities" not in document: + raise SystemExit("hdiutil attach returned an unexpected authority shape") +entities = document["system-entities"] +if not isinstance(entities, list) or not all(isinstance(item, dict) for item in entities): + raise SystemExit("hdiutil attach returned invalid system entities") +mounted = [item for item in entities if "mount-point" in item] +if len(mounted) != 1 or mounted[0].get("mount-point") != sys.argv[2]: + raise SystemExit("hdiutil attached the image at an unexpected mount point") +device = mounted[0].get("dev-entry") +if not isinstance(device, str) or re.fullmatch(r"/dev/disk[0-9]+s[0-9]+", device) is None: + raise SystemExit("hdiutil did not report an exact mounted APFS device") +print(device) +PY +} + +hdiutil create -quiet -size 128m -fs APFS -volname "$VOLUME_NAME" "$FIRST_IMAGE" +[ -f "$FIRST_IMAGE" ] && [ ! -L "$FIRST_IMAGE" ] \ + || { echo "data-drive volume identity gate: first APFS image is unavailable or indirect" >&2; exit 1; } +first_device="$(attach_image "$FIRST_IMAGE" "$MOUNT" "$EVIDENCE/first-attach.plist")" +first_drive_id="$(run_dory_hv data-drive select "$DRIVE")" +printf '%s\n' "$first_drive_id" | grep -Eq '^[0-9a-fA-F-]{36}$' \ + || { echo "data-drive volume identity gate: dory-hv returned an invalid drive ID" >&2; exit 1; } +cp "$DRIVE/drive.json" "$EVIDENCE/first-drive.json" +cp "$SELECTION_RECORD" "$EVIDENCE/initial-selection.json" +cp -R "$DRIVE" "$COPIED_DRIVE" + +first_volume_uuid="$(python3 - "$EVIDENCE/first-drive.json" "$first_drive_id" <<'PY' +import json +import pathlib +import sys +import uuid +from datetime import datetime + +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +expected = {"kind", "schemaVersion", "id", "product", "createdAt", "volume"} +if not isinstance(manifest, dict) or set(manifest) != expected: + raise SystemExit("data-drive manifest has an unexpected shape") +if ( + manifest["kind"] != "dev.dory.data-drive" + or manifest["schemaVersion"] != 1 + or manifest["product"] != "Dory" +): + raise SystemExit("data-drive manifest has an unsupported contract") +if str(uuid.UUID(manifest["id"])) != str(uuid.UUID(sys.argv[2])): + raise SystemExit("data-drive manifest ID differs from dory-hv output") +try: + datetime.fromisoformat(manifest["createdAt"].replace("Z", "+00:00")) +except (AttributeError, ValueError) as error: + raise SystemExit("data-drive manifest has an invalid creation timestamp") from error +volume = manifest["volume"] +if not isinstance(volume, dict) or set(volume) != {"filesystem", "nameAtCreation", "uuid"}: + raise SystemExit("data-drive volume authority has an unexpected shape") +if volume["filesystem"] != "apfs" or not volume["nameAtCreation"].startswith("DoryIdentity-"): + raise SystemExit("data-drive volume authority is not the expected APFS identity") +print(str(uuid.UUID(volume["uuid"]))) +PY +)" + +[ "$(run_dory_hv data-drive selected-path)" = "$DRIVE" ] \ + || { echo "data-drive volume identity gate: initial selection path was not remembered" >&2; exit 1; } +mkdir -p "$TEST_HOME/.dory" +printf 'replaceable runtime state\n' > "$TEST_HOME/.dory/cache" +rm -rf "$TEST_HOME/.dory" +[ "$(run_dory_hv data-drive selected-path)" = "$DRIVE" ] \ + || { echo "data-drive volume identity gate: clearing runtime state forgot the selected drive" >&2; exit 1; } + +diskutil rename "$MOUNT" "$RENAMED_VOLUME_NAME" > "$EVIDENCE/rename-volume.out" +[ -d "$RENAMED_MOUNT" ] \ + || { echo "data-drive volume identity gate: renamed APFS mount is unavailable" >&2; exit 1; } +remembered_after_rename="$(run_dory_hv data-drive selected-path)" +[ "$remembered_after_rename" = "$RENAMED_DRIVE" ] \ + || { echo "data-drive volume identity gate: bookmark did not recover the renamed volume" >&2; exit 1; } +renamed_drive_id="$(run_dory_hv data-drive select "$remembered_after_rename")" +[ "$renamed_drive_id" = "$first_drive_id" ] \ + || { echo "data-drive volume identity gate: renamed drive identity changed" >&2; exit 1; } +cp "$SELECTION_RECORD" "$EVIDENCE/renamed-selection.json" +python3 - "$EVIDENCE/renamed-selection.json" "$RENAMED_DRIVE" "$first_drive_id" "$first_volume_uuid" <<'PY' +import json +import pathlib +import sys +import uuid +from datetime import datetime + +selection = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +expected = { + "schemaVersion", "phase", "canonicalPath", "driveID", + "volumeUUID", "bookmark", "selectedAt", +} +if not isinstance(selection, dict) or set(selection) != expected: + raise SystemExit("selection authority has an unexpected shape") +if selection["schemaVersion"] != 2 or selection["phase"] != "ready": + raise SystemExit("selection authority is not ready schema v2") +if selection["canonicalPath"] != sys.argv[2] or selection["driveID"].lower() != sys.argv[3].lower(): + raise SystemExit("selection authority differs from the selected drive") +if str(uuid.UUID(selection["volumeUUID"])) != str(uuid.UUID(sys.argv[4])): + raise SystemExit("selection authority differs from the APFS volume") +if not isinstance(selection["bookmark"], str) or not selection["bookmark"]: + raise SystemExit("selection authority omits its security-scoped bookmark") +try: + datetime.fromisoformat(selection["selectedAt"].replace("Z", "+00:00")) +except (AttributeError, ValueError) as error: + raise SystemExit("selection authority has an invalid timestamp") from error +PY + +diskutil rename "$RENAMED_MOUNT" "$VOLUME_NAME" > "$EVIDENCE/restore-volume-name.out" +[ -d "$MOUNT" ] \ + || { echo "data-drive volume identity gate: restored APFS mount is unavailable" >&2; exit 1; } +[ "$(run_dory_hv data-drive selected-path)" = "$DRIVE" ] \ + || { echo "data-drive volume identity gate: bookmark did not follow the restored name" >&2; exit 1; } +[ "$(run_dory_hv data-drive select "$DRIVE")" = "$first_drive_id" ] \ + || { echo "data-drive volume identity gate: restored-name drive identity changed" >&2; exit 1; } + +hdiutil detach "$first_device" -quiet +first_device="" +if run_dory_hv data-drive id "$DRIVE" \ + >"$EVIDENCE/missing-volume.out" 2>"$EVIDENCE/missing-volume.err"; then + echo "data-drive volume identity gate: detached selected volume was accepted" >&2 + exit 1 +fi +grep -F 'volume is not mounted' "$EVIDENCE/missing-volume.err" >/dev/null +[ ! -e "$MOUNT" ] \ + || { echo "data-drive volume identity gate: detached volume left a shadow mount path" >&2; exit 1; } + +hdiutil create -quiet -size 128m -fs APFS -volname "$VOLUME_NAME" "$SECOND_IMAGE" +[ -f "$SECOND_IMAGE" ] && [ ! -L "$SECOND_IMAGE" ] \ + || { echo "data-drive volume identity gate: second APFS image is unavailable or indirect" >&2; exit 1; } +second_device="$(attach_image "$SECOND_IMAGE" "$MOUNT" "$EVIDENCE/second-attach.plist")" +cp -R "$COPIED_DRIVE" "$DRIVE" +if run_dory_hv data-drive select "$DRIVE" \ + >"$EVIDENCE/wrong-volume.out" 2>"$EVIDENCE/wrong-volume.err"; then + echo "data-drive volume identity gate: same-name replacement volume was accepted" >&2 + exit 1 +fi +grep -F 'invalid or incompatible manifest' "$EVIDENCE/wrong-volume.err" >/dev/null +hdiutil detach "$second_device" -quiet +second_device="" + +first_device="$(attach_image "$FIRST_IMAGE" "$MOUNT" "$EVIDENCE/first-reattach.plist")" +restored_drive_id="$(run_dory_hv data-drive select "$DRIVE")" +[ "$restored_drive_id" = "$first_drive_id" ] \ + || { echo "data-drive volume identity gate: original drive identity changed" >&2; exit 1; } +hdiutil detach "$first_device" -quiet +first_device="" + +{ + printf 'status=PASS\n' + printf 'architecture=arm64\n' + printf 'external_volume_identity=PASS\n' + printf 'durable_selection_outside_runtime_state=PASS\n' + printf 'bookmark_volume_rename_recovery=PASS\n' + printf 'missing_volume_shadow_prevention=PASS\n' + printf 'same_name_wrong_volume_rejected=PASS\n' + printf 'original_volume_reaccepted=PASS\n' + printf 'exact_candidate_helper=PASS\n' + printf 'exact_device_detach=PASS\n' + printf 'drive_id=%s\n' "$first_drive_id" + printf 'volume_uuid=%s\n' "$first_volume_uuid" + shasum -a 256 "$DORY_HV" | awk '{print "dory_hv_sha256=" $1}' +} > "$EVIDENCE/summary.txt" + +echo "data-drive volume identity gate: PASS ($EVIDENCE/summary.txt)" From 46470fd3ca47d6b8e1d22a8affadff2bf1ae4207 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:36:32 +0000 Subject: [PATCH 135/338] feat(release): qualify data disk growth --- .github/scripts/test-data-disk-growth-gate.py | 111 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/data-disk-growth-gate.sh | 420 ++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100755 .github/scripts/test-data-disk-growth-gate.py create mode 100755 scripts/data-disk-growth-gate.sh diff --git a/.github/scripts/test-data-disk-growth-gate.py b/.github/scripts/test-data-disk-growth-gate.py new file mode 100755 index 00000000..08567daa --- /dev/null +++ b/.github/scripts/test-data-disk-growth-gate.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Offline contract for sparse Docker data-disk growth qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "data-disk-growth-gate.sh" + + +class DataDiskGrowthGateTests(unittest.TestCase): + def test_gate_binds_helpers_storage_authority_and_unprivileged_probes(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-RUNTIME-DATA-DISK-GROWTH", + "runtime must be absolute", + "Docker CLI must be absolute", + "candidate helper is unavailable or indirect", + "--image must be an exact digest reference", + "workroot already exists or is indirect", + "mke2fs is required as a direct executable", + "data-disk growth Unix socket path is", + "runtime socket is unavailable or owned by another user", + 'docker_e pull "$IMAGE"', + "local probe image does not retain its exact registry authority", + 'docker_e volume create --label "$LABEL" "$NAME"', + "growth volume differs from its exact run authority", + "--rm --pull=never --network none --label", + '--mount "type=volume,src=$NAME,dst=/data"', + "df -Pk /data", + "resize/fstrim startup took", + "boot-time fstrim evidence", + "named-volume marker changed after restart", + "helper grew a disk still attached to the running VM", + "pre-growth capacity response is not initialized at 128 GiB", + "explicit growth response is not exactly 256 GiB", + "e2fsck_mode=forced-preen", + "explicit growth did not use a forced offline preen", + "owned probe container survived completion", + "owned volume cleanup failed", + "dory_engine_sha256=", + "dory_hv_sha256=", + "docker_cli_sha256=", + "mke2fs_sha256=", + "exact_candidate_helpers=PASS", + "exact_probe_image=PASS", + "network_isolated_probes=PASS", + "privileged_host_bind_absent=PASS", + "discard_reclaim=PASS", + "explicit_capacity_growth=PASS", + "owned_container_cleanup=PASS", + "owned_volume_cleanup=PASS", + "runtime_shutdown=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "alpine:3.20", + "--privileged", + "-v /:/host", + "/host/var/lib/docker", + "assert ", + "runtime=$RUNTIME", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_helpers_runtime_home_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + runtime_home = pathlib.Path(temporary) / "runtime-must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + "/missing/runtime", + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_DATA_DISK_RUNTIME_HOME": str(runtime_home), + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + self.assertFalse(runtime_home.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a859502..da7e7333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,6 +359,7 @@ jobs: python3 .github/scripts/test-bind-file-coherence-gate.py python3 .github/scripts/test-prune-safety-gate.py python3 .github/scripts/test-data-drive-volume-identity-gate.py + python3 .github/scripts/test-data-disk-growth-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 98e284cc..a0848b72 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,6 +60,7 @@ jobs: python3 .github/scripts/test-bind-file-coherence-gate.py python3 .github/scripts/test-prune-safety-gate.py python3 .github/scripts/test-data-drive-volume-identity-gate.py + python3 .github/scripts/test-data-disk-growth-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/data-disk-growth-gate.sh b/scripts/data-disk-growth-gate.sh new file mode 100755 index 00000000..a128ad96 --- /dev/null +++ b/scripts/data-disk-growth-gate.sh @@ -0,0 +1,420 @@ +#!/bin/bash +# Proves a smaller public-v1 ext4 Docker disk grows safely under the exact bundled runtime. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/data-disk-growth-gate.sh --runtime DIR --docker PATH --confirm TOKEN [options] + +Required: + --runtime DIR Exact runtime bundle containing dory-engine + --docker PATH Exact Docker CLI to use + --confirm TOKEN Must be ISOLATED-RUNTIME-DATA-DISK-GROWTH + +Options: + --image REF Digest-pinned Linux image used for guest df + persistence + --workroot DIR Evidence root (default /tmp/dory-data-disk-growth) + --keep-runtime-home Preserve the isolated runtime HOME after completion + --help Show this help +EOF +} + +RUNTIME="" +DOCKER="" +IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-data-disk-growth" +KEEP_HOME=0 +CONFIRM="" +while [ "$#" -gt 0 ]; do + case "$1" in + --runtime) RUNTIME="${2:?--runtime requires a directory}"; shift 2 ;; + --docker) DOCKER="${2:?--docker requires a path}"; shift 2 ;; + --image) IMAGE="${2:?--image requires a reference}"; shift 2 ;; + --workroot) WORKROOT="${2:?--workroot requires a directory}"; shift 2 ;; + --confirm) CONFIRM="${2:?--confirm requires a token}"; shift 2 ;; + --keep-runtime-home) KEEP_HOME=1; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "data-disk growth gate: unknown argument: $1" >&2; usage >&2; exit 64 ;; + esac +done + +[ "$CONFIRM" = ISOLATED-RUNTIME-DATA-DISK-GROWTH ] \ + || { echo "data-disk growth gate: requires --confirm ISOLATED-RUNTIME-DATA-DISK-GROWTH" >&2; exit 2; } +[ -n "$RUNTIME" ] || { echo "data-disk growth gate: --runtime is required" >&2; exit 64; } +[ -n "$DOCKER" ] || { echo "data-disk growth gate: --docker is required" >&2; exit 64; } +case "$RUNTIME" in /*) ;; *) echo "data-disk growth gate: runtime must be absolute" >&2; exit 66 ;; esac +case "$DOCKER" in /*) ;; *) echo "data-disk growth gate: Docker CLI must be absolute" >&2; exit 66 ;; esac +[ -d "$RUNTIME" ] && [ ! -L "$RUNTIME" ] \ + || { echo "data-disk growth gate: runtime is unavailable or indirect" >&2; exit 66; } +for helper in "$RUNTIME/dory-engine" "$RUNTIME/bin/dory-hv" "$DOCKER"; do + [ -f "$helper" ] && [ ! -L "$helper" ] && [ -x "$helper" ] \ + || { echo "data-disk growth gate: candidate helper is unavailable or indirect: $helper" >&2; exit 66; } +done +RUNTIME="$(cd "$RUNTIME" && pwd -P)" +DOCKER="$(cd "$(dirname "$DOCKER")" && pwd -P)/$(basename "$DOCKER")" +printf '%s\n' "$IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || { echo "data-disk growth gate: --image must be an exact digest reference" >&2; exit 64; } +case "$WORKROOT" in /*) ;; *) echo "data-disk growth gate: workroot must be absolute" >&2; exit 73 ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") echo "data-disk growth gate: unsafe workroot" >&2; exit 73 ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || { echo "data-disk growth gate: workroot already exists or is indirect" >&2; exit 73; } +for command in dd python3 shasum stat truncate; do + command -v "$command" >/dev/null \ + || { echo "data-disk growth gate: $command is required" >&2; exit 69; } +done + +MKE2FS="${DORY_MKE2FS:-}" +if [ -z "$MKE2FS" ]; then + for candidate in \ + /opt/homebrew/opt/e2fsprogs/sbin/mke2fs \ + /usr/local/opt/e2fsprogs/sbin/mke2fs \ + "$(command -v mke2fs 2>/dev/null || true)" \ + "$(command -v mkfs.ext4 2>/dev/null || true)"; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then MKE2FS="$candidate"; break; fi + done +fi +[ -f "$MKE2FS" ] && [ ! -L "$MKE2FS" ] && [ -x "$MKE2FS" ] \ + || { echo "data-disk growth gate: mke2fs is required as a direct executable" >&2; exit 69; } + +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +RUNTIME_HOME="${DORY_DATA_DISK_RUNTIME_HOME:-${TMPDIR:-/tmp}/dory-ddg-$UID-$$}" +RUNTIME_HOME="$(python3 - "$RUNTIME_HOME" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1])) +PY +)" +NAME="dory-data-growth-${RUN_ID//[^a-zA-Z0-9]/}" +MARKER="persistent-$RUN_ID" +LABEL="dev.dory.data-disk-growth=$RUN_ID" +[ "${RUNTIME_HOME#/}" != "$RUNTIME_HOME" ] \ + || { echo "data-disk growth gate: runtime HOME must be absolute" >&2; exit 73; } +case "$RUNTIME_HOME" in /|"$HOME"|"$(pwd)"|"$WORKROOT"|"$WORKROOT"/*) \ + echo "data-disk growth gate: unsafe runtime HOME" >&2; exit 73 ;; esac +[ ! -e "$RUNTIME_HOME" ] || { + echo "data-disk growth gate: isolated runtime HOME already exists: $RUNTIME_HOME" >&2 + exit 73 +} +mkdir "$RUNTIME_HOME" +RUNTIME_HOME="$(cd "$RUNTIME_HOME" && pwd -P)" +EVIDENCE="$RUN_ROOT/evidence" +DORY_ROOT="$RUNTIME_HOME/.dory" +STATE="$DORY_ROOT/standalone" +DATA_DRIVE="$RUNTIME_HOME/Library/Application Support/Dory/Dory.dorydrive" +DISK="$DATA_DRIVE/engine/docker-data.ext4" +SOCKET="$DORY_ROOT/engine.sock" +python3 - "$SOCKET" "$STATE/hv/docker-backend.sock" "$STATE/hv/gvproxy-api.sock" <<'PY' +import os +import sys + +for path in sys.argv[1:]: + length = len(os.fsencode(path)) + if length > 103: + raise SystemExit(f"data-disk growth Unix socket path is {length} bytes (limit 103): {path}") +PY +mkdir -p "$STATE/hv" "$EVIDENCE" +engine_started=0 +volume_created=0 +docker_e() { DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@"; } + +capture_failure_evidence() { + [ ! -f "$STATE/engine.log" ] || cp "$STATE/engine.log" "$EVIDENCE/engine.log" + [ ! -d "$STATE/hv/guest-logs" ] || { + mkdir -p "$EVIDENCE/guest-logs" + cp -R "$STATE/hv/guest-logs/." "$EVIDENCE/guest-logs/" + } + if [ -f "$DISK" ]; then + { + stat -f 'logical_bytes=%z' "$DISK" + stat -f 'allocated_blocks=%b' "$DISK" + printf 'allocated_bytes=%s\n' "$(( $(stat -f '%b' "$DISK") * 512 ))" + } >"$EVIDENCE/failure-disk-stat.txt" + fi + ps ax -o pid=,ppid=,state=,command= 2>/dev/null \ + | awk -v home="$RUNTIME_HOME" 'index($0, home) { print }' \ + >"$EVIDENCE/failure-runtime-processes.txt" || true +} + +cleanup() { + status=$? + set +e + [ "$status" -eq 0 ] || capture_failure_evidence + if [ -S "$SOCKET" ]; then + docker_e ps -aq --filter "label=$LABEL" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >/dev/null 2>&1 || true + done + [ "$volume_created" -eq 0 ] || docker_e volume rm "$NAME" >/dev/null 2>&1 || true + fi + HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >/dev/null 2>&1 || true + if [ "$KEEP_HOME" -ne 1 ]; then rm -rf "$RUNTIME_HOME"; fi + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Create the canonical public-v1 drive before seeding its smaller ext4 image. Preallocate the first +# GiB without changing the eventual 16 GiB geometry. `nodiscard` +# deliberately leaves unused ext4 blocks physically allocated so the exact guest boot must issue +# virtio DISCARD/fstrim and return them to APFS. This qualifies forward growth for public-v1 data +# without importing any prelaunch Dory layout. +HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" data-drive select "$DATA_DRIVE" \ + >"$EVIDENCE/data-drive-select.txt" +dd if=/dev/zero of="$DISK" bs=1m count=1024 >/dev/null 2>&1 +truncate -s 16g "$DISK" +chmod 600 "$DISK" +"$MKE2FS" -q -F -t ext4 -E nodiscard "$DISK" +BEFORE_LOGICAL="$(stat -f '%z' "$DISK")" +BEFORE_ALLOCATED="$(( $(stat -f '%b' "$DISK") * 512 ))" +MIN_SEED_ALLOCATED=$((768 * 1024 * 1024)) +[ "$BEFORE_ALLOCATED" -ge "$MIN_SEED_ALLOCATED" ] || { + echo "data-disk growth gate: discard-reclaim seed allocated only $BEFORE_ALLOCATED bytes" >&2 + exit 1 +} + +START_BEGIN="$(date +%s)" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DATA_DRIVE" >"$EVIDENCE/start.log" 2>&1 +engine_started=1 +[ -S "$SOCKET" ] && [ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || { echo "data-disk growth gate: runtime socket is unavailable or owned by another user" >&2; exit 1; } +START_SECONDS=$(( $(date +%s) - START_BEGIN )) +[ "$START_SECONDS" -le 60 ] || { + echo "data-disk growth gate: resize/fstrim startup took $START_SECONDS seconds" >&2 + exit 1 +} +TRIM_LOG="$STATE/hv/guest-logs/data-trim.log" +[ -s "$TRIM_LOG" ] || { + echo "data-disk growth gate: guest did not publish boot-time fstrim evidence" >&2 + exit 1 +} +cp "$TRIM_LOG" "$EVIDENCE/data-trim.log" +docker_e info >"$EVIDENCE/docker-info.txt" +docker_e image inspect "$IMAGE" >/dev/null 2>&1 \ + || docker_e pull "$IMAGE" >"$EVIDENCE/pull.log" 2>&1 +docker_e image inspect "$IMAGE" > "$EVIDENCE/image-inspect.json" \ + || { echo "data-disk growth gate: exact probe image is unavailable after pull" >&2; exit 1; } +[ "$(docker_e volume create --label "$LABEL" "$NAME")" = "$NAME" ] \ + || { echo "data-disk growth gate: named-volume creation returned the wrong authority" >&2; exit 1; } +volume_created=1 +docker_e volume inspect "$NAME" > "$EVIDENCE/volume-inspect.json" +python3 - "$EVIDENCE/image-inspect.json" "$EVIDENCE/volume-inspect.json" \ + "$IMAGE" "$NAME" "$LABEL" <<'PY' +import json +import pathlib +import sys + +image_document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +volume_document = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +if not isinstance(image_document, list) or len(image_document) != 1: + raise SystemExit("probe image inspect is not one exact image") +image = image_document[0] +requested_name, requested_digest = sys.argv[3].rsplit("@", 1) +accepted_names = {requested_name} +first = requested_name.split("/", 1)[0] +if "/" not in requested_name: + accepted_names.add("docker.io/library/" + requested_name) +elif first != "localhost" and "." not in first and ":" not in first: + accepted_names.add("docker.io/" + requested_name) +repo_digests = image.get("RepoDigests") +if not isinstance(repo_digests, list) or not any( + value == name + "@" + requested_digest + for name in accepted_names + for value in repo_digests +): + raise SystemExit("local probe image does not retain its exact registry authority") +if not isinstance(volume_document, list) or len(volume_document) != 1: + raise SystemExit("growth volume inspect is not one exact volume") +volume = volume_document[0] +label_key, label_value = sys.argv[5].split("=", 1) +labels = volume.get("Labels") +if volume.get("Name") != sys.argv[4] or not isinstance(labels, dict) or labels.get(label_key) != label_value: + raise SystemExit("growth volume differs from its exact run authority") +PY + +GUEST_KIB="$(docker_e run --rm --pull=never --network none --label "$LABEL" \ + --mount "type=volume,src=$NAME,dst=/data" "$IMAGE" \ + sh -c "df -Pk /data | awk 'NR==2 {print \$2}'")" +case "$GUEST_KIB" in *[!0-9]*|'') echo "data-disk growth gate: unreadable guest capacity: $GUEST_KIB" >&2; exit 1 ;; esac +GUEST_BYTES="$((GUEST_KIB * 1024))" +case "$GUEST_BYTES" in *[!0-9]*|'') echo "data-disk growth gate: unreadable guest capacity: $GUEST_BYTES" >&2; exit 1 ;; esac +MIN_BYTES=$((120 * 1024 * 1024 * 1024)) +[ "$GUEST_BYTES" -ge "$MIN_BYTES" ] || { + echo "data-disk growth gate: guest ext4 capacity is $GUEST_BYTES, expected at least $MIN_BYTES" >&2 + exit 1 +} + +printf '%s\n' "$NAME" > "$EVIDENCE/volume-create.txt" +docker_e run --name "$NAME" --rm --pull=never --network none --label "$LABEL" \ + --mount "type=volume,src=$NAME,dst=/data" "$IMAGE" \ + sh -c "printf '%s' '$MARKER' > /data/marker" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >"$EVIDENCE/stop.log" 2>&1 +engine_started=0 +RESTART_BEGIN="$(date +%s)" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DATA_DRIVE" >"$EVIDENCE/restart.log" 2>&1 +engine_started=1 +RESTART_SECONDS=$(( $(date +%s) - RESTART_BEGIN )) +[ "$RESTART_SECONDS" -le 60 ] || { + echo "data-disk growth gate: post-growth restart took $RESTART_SECONDS seconds" >&2 + exit 1 +} +PERSISTED="$(docker_e run --name "$NAME" --rm --pull=never --network none --label "$LABEL" \ + --mount "type=volume,src=$NAME,dst=/data" "$IMAGE" cat /data/marker)" +[ "$PERSISTED" = "$MARKER" ] || { echo "data-disk growth gate: named-volume marker changed after restart" >&2; exit 1; } + +AFTER_LOGICAL="$(stat -f '%z' "$DISK")" +AFTER_ALLOCATED="$(( $(stat -f '%b' "$DISK") * 512 ))" +MIN_LOGICAL=$((128 * 1024 * 1024 * 1024)) +[ "$AFTER_LOGICAL" -ge "$MIN_LOGICAL" ] || { + echo "data-disk growth gate: host disk stayed at $AFTER_LOGICAL bytes, expected at least $MIN_LOGICAL" >&2 + exit 1 +} +[ "$AFTER_ALLOCATED" -lt "$AFTER_LOGICAL" ] || { + echo "data-disk growth gate: sparse growth eagerly allocated $AFTER_ALLOCATED of $AFTER_LOGICAL bytes" >&2 + exit 1 +} +[ "$AFTER_ALLOCATED" -lt "$BEFORE_ALLOCATED" ] || { + echo "data-disk growth gate: boot-time fstrim did not reclaim the preallocated seed ($BEFORE_ALLOCATED -> $AFTER_ALLOCATED)" >&2 + exit 1 +} + +# Exercise the public capacity control after proving default growth and trim. Inspection is safe +# while the VM owns the drive, but mutation must fail until that attachment releases its lease. +HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" data-drive capacity \ + >"$EVIDENCE/capacity-running.json" +if HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" data-drive grow 256 \ + >"$EVIDENCE/running-growth.out" 2>"$EVIDENCE/running-growth.err"; then + echo "data-disk growth gate: helper grew a disk still attached to the running VM" >&2 + exit 1 +fi +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >"$EVIDENCE/user-growth-stop.log" 2>&1 +engine_started=0 +HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" data-drive capacity \ + >"$EVIDENCE/capacity-before.json" +HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" data-drive grow 256 \ + >"$EVIDENCE/capacity-grown.json" +python3 - "$EVIDENCE/capacity-before.json" "$EVIDENCE/capacity-grown.json" <<'PY' +import json +import pathlib +import sys + +before = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +grown = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +required = { + "initialized", "logicalBytes", "allocatedBytes", "capacityGiB", + "minimumCapacityGiB", "maximumCapacityGiB", +} +if not isinstance(before, dict) or not required.issubset(before): + raise SystemExit("pre-growth capacity response has an unexpected shape") +if not isinstance(grown, dict) or not required.issubset(grown): + raise SystemExit("grown capacity response has an unexpected shape") +if before["capacityGiB"] != 128 or before["initialized"] is not True: + raise SystemExit("pre-growth capacity response is not initialized at 128 GiB") +if grown["capacityGiB"] != 256 or grown["logicalBytes"] != 256 * 1024**3: + raise SystemExit("explicit growth response is not exactly 256 GiB") +if not isinstance(grown["allocatedBytes"], int) or grown["allocatedBytes"] >= grown["logicalBytes"]: + raise SystemExit("explicit growth response is not sparse") +if grown["minimumCapacityGiB"] != 128 or grown["maximumCapacityGiB"] != 2048: + raise SystemExit("explicit growth response has unexpected capacity bounds") +PY +USER_START_BEGIN="$(date +%s)" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DATA_DRIVE" \ + >"$EVIDENCE/user-growth-start.log" 2>&1 +engine_started=1 +USER_START_SECONDS=$(( $(date +%s) - USER_START_BEGIN )) +[ "$USER_START_SECONDS" -le 60 ] || { + echo "data-disk growth gate: explicit 256 GiB growth boot took $USER_START_SECONDS seconds" >&2 + exit 1 +} +RESIZE_LOG="$STATE/hv/guest-logs/data-resize.log" +[ -s "$RESIZE_LOG" ] || { + echo "data-disk growth gate: guest did not publish explicit growth evidence" >&2 + exit 1 +} +cp "$RESIZE_LOG" "$EVIDENCE/data-resize.log" +grep -qx 'e2fsck_mode=forced-preen' "$RESIZE_LOG" || { + echo "data-disk growth gate: explicit growth did not use a forced offline preen" >&2 + exit 1 +} +grep -Fq 'resize2fs' "$RESIZE_LOG" || { + echo "data-disk growth gate: explicit growth log does not contain resize2fs evidence" >&2 + exit 1 +} +USER_GUEST_KIB="$(docker_e run --rm --pull=never --network none --label "$LABEL" \ + --mount "type=volume,src=$NAME,dst=/data" "$IMAGE" \ + sh -c "df -Pk /data | awk 'NR==2 {print \$2}'")" +case "$USER_GUEST_KIB" in *[!0-9]*|'') echo "data-disk growth gate: unreadable 256 GiB guest capacity: $USER_GUEST_KIB" >&2; exit 1 ;; esac +USER_GUEST_BYTES="$((USER_GUEST_KIB * 1024))" +USER_MIN_BYTES=$((240 * 1024 * 1024 * 1024)) +[ "$USER_GUEST_BYTES" -ge "$USER_MIN_BYTES" ] || { + echo "data-disk growth gate: grown guest ext4 capacity is $USER_GUEST_BYTES, expected at least $USER_MIN_BYTES" >&2 + exit 1 +} +USER_PERSISTED="$(docker_e run --name "$NAME" --rm --pull=never --network none --label "$LABEL" \ + --mount "type=volume,src=$NAME,dst=/data" "$IMAGE" cat /data/marker)" +[ "$USER_PERSISTED" = "$MARKER" ] || { + echo "data-disk growth gate: named-volume marker changed after explicit capacity growth" >&2 + exit 1 +} +USER_LOGICAL="$(stat -f '%z' "$DISK")" +USER_ALLOCATED="$(( $(stat -f '%b' "$DISK") * 512 ))" +[ "$USER_LOGICAL" -eq $((256 * 1024 * 1024 * 1024)) ] || { + echo "data-disk growth gate: host disk is $USER_LOGICAL bytes after explicit growth" >&2 + exit 1 +} +[ "$USER_ALLOCATED" -lt "$USER_LOGICAL" ] || { + echo "data-disk growth gate: explicit growth eagerly allocated $USER_ALLOCATED of $USER_LOGICAL bytes" >&2 + exit 1 +} +[ -z "$(docker_e ps -aq --filter "label=$LABEL")" ] \ + || { echo "data-disk growth gate: owned probe container survived completion" >&2; exit 1; } +docker_e volume rm "$NAME" > "$EVIDENCE/volume-remove.txt" \ + || { echo "data-disk growth gate: owned volume cleanup failed" >&2; exit 1; } +volume_created=0 +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop > "$EVIDENCE/final-stop.log" 2>&1 +engine_started=0 +cat >"$EVIDENCE/summary.txt" < Date: Fri, 21 Aug 2026 05:40:40 +0000 Subject: [PATCH 136/338] feat(release): qualify ECR push retry --- .../scripts/test-ecr-registry-retry-gate.py | 109 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/ecr-registry-retry-gate.sh | 403 ++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100755 .github/scripts/test-ecr-registry-retry-gate.py create mode 100755 scripts/ecr-registry-retry-gate.sh diff --git a/.github/scripts/test-ecr-registry-retry-gate.py b/.github/scripts/test-ecr-registry-retry-gate.py new file mode 100755 index 00000000..8896dc77 --- /dev/null +++ b/.github/scripts/test-ecr-registry-retry-gate.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Offline contract for interrupted ECR push/retry qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "ecr-registry-retry-gate.sh" + + +class ECRRegistryRetryGateTests(unittest.TestCase): + def test_gate_binds_candidate_account_repository_digests_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISPOSABLE-ECR-INTERRUPT-RETRY", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "no sibling docker-buildx plugin", + "registry hostname region differs from --region", + "workroot already exists or is indirect", + "isolated Buildx copy differs from the candidate plugin", + "local base image does not retain its exact registry authority", + "AWS caller account differs from the ECR registry account", + "ECR repository authority differs from the requested registry path", + 'rm -f "$WORKDIR/aws-caller.json" "$WORKDIR/ecr-repository.json"', + "DOCKER_CONFIG=\"$DOCKER_CONFIG\"", + "--network none --pull=false --progress=plain", + "first ECR push completed or stalled before an upload could be interrupted", + "interrupted ECR push returned success", + "ECR push output does not contain one exact manifest digest", + "repeated ECR manifest PUT changed the manifest digest", + "ECR registry digest differs from the repeated push digest", + 'REMOTE_DIGEST_REF="$REGISTRY/$REPOSITORY@$remote_digest"', + 'docker_e pull "$REMOTE_DIGEST_REF"', + "--rm --pull=never --network none --label", + "ECR repull/run returned the wrong layer checksum", + "ECR cleanup did not report the one unique image tag", + "remote ECR tag survived confirmed deletion", + "remote ECR deletion could not be verified fail-closed", + "owned ECR retry container survived cleanup", + "isolated Docker credential directory survived cleanup", + "caller_account_sha256=", + "repository_authority_sha256=", + "docker_cli_sha256=", + "buildx_cli_sha256=", + "aws_cli_sha256=", + "registry_digest_agreement=PASS", + "digest_based_repull=PASS", + "remote_deletion_verified=PASS", + "owned_container_cleanup=PASS", + "isolated_credential_cleanup=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "assert ", + 'ln -s "$BUILDX"', + 'docker_e pull "$REMOTE_REF"', + 'docker_e run --rm "$REMOTE_REF"', + "aws ecr ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_aws_helpers_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--registry", + "invalid", + "--repository", + "invalid", + "--region", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da7e7333..5fe936e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -360,6 +360,7 @@ jobs: python3 .github/scripts/test-prune-safety-gate.py python3 .github/scripts/test-data-drive-volume-identity-gate.py python3 .github/scripts/test-data-disk-growth-gate.py + python3 .github/scripts/test-ecr-registry-retry-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0848b72..94b3e148 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,6 +61,7 @@ jobs: python3 .github/scripts/test-prune-safety-gate.py python3 .github/scripts/test-data-drive-volume-identity-gate.py python3 .github/scripts/test-data-disk-growth-gate.py + python3 .github/scripts/test-ecr-registry-retry-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/ecr-registry-retry-gate.sh b/scripts/ecr-registry-retry-gate.sh new file mode 100755 index 00000000..e0c19969 --- /dev/null +++ b/scripts/ecr-registry-retry-gate.sh @@ -0,0 +1,403 @@ +#!/bin/bash +# Exact ECR regression: interrupt a large layer upload, resume it, repeat the manifest PUT, +# repull/run exact bytes, and delete both remote state and isolated credentials. +set -euo pipefail +umask 077 + +SOCKET="" +DOCKER="" +BASE_IMAGE="" +REGISTRY="" +REPOSITORY="" +REGION="" +WORKROOT="${TMPDIR:-/tmp}/dory-ecr-retry" +CONFIRM="" +LAYER_MIB=96 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --registry) need_value "$1" "$#"; REGISTRY="$2"; shift 2 ;; + --repository) need_value "$1" "$#"; REPOSITORY="$2"; shift 2 ;; + --region) need_value "$1" "$#"; REGION="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --layer-mib) need_value "$1" "$#"; LAYER_MIB="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = DISPOSABLE-ECR-INTERRUPT-RETRY ] \ + || die "requires --confirm DISPOSABLE-ECR-INTERRUPT-RETRY" +[ -S "$SOCKET" ] || die "Dory socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +DOCKER="$(cd "$(dirname "$DOCKER")" && pwd -P)/$(basename "$DOCKER")" +BUILDX="$(dirname "$DOCKER")/docker-buildx" +[ -f "$BUILDX" ] && [ ! -L "$BUILDX" ] && [ -x "$BUILDX" ] \ + || die "the exact candidate Docker CLI has no sibling docker-buildx plugin: $BUILDX" +printf '%s\n' "$BASE_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--base-image must be digest-pinned" +printf '%s\n' "$REGISTRY" | grep -Eq '^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$' \ + || die "--registry must be an ECR registry host" +printf '%s\n' "$REPOSITORY" | grep -Eq '^[a-z0-9]+([._/-][a-z0-9]+)*$' \ + || die "--repository contains unsupported characters" +printf '%s\n' "$REGION" | grep -Eq '^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$' \ + || die "--region is invalid" +registry_account="${REGISTRY%%.*}" +registry_region="${REGISTRY#*.dkr.ecr.}" +registry_region="${registry_region%.amazonaws.com}" +[ "$registry_region" = "$REGION" ] \ + || die "registry hostname region differs from --region" +case "$LAYER_MIB" in ''|*[!0-9]*) die "--layer-mib must be an integer" ;; esac +[ "$LAYER_MIB" -ge 64 ] || die "--layer-mib must be at least 64" +for command in python3 shasum; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +AWS_CLI="$(command -v aws 2>/dev/null || true)" +[ -n "$AWS_CLI" ] || die "required command is missing: aws" +AWS_CLI="$(python3 - "$AWS_CLI" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1])) +PY +)" +[ -f "$AWS_CLI" ] && [ ! -L "$AWS_CLI" ] && [ -x "$AWS_CLI" ] \ + || die "resolved AWS CLI is unavailable or indirect" +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +TAG="dory-retry-${RUN_ID//[^a-zA-Z0-9]/}" +REMOTE_REF="$REGISTRY/$REPOSITORY:$TAG" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +WORKDIR="$WORKROOT/$RUN_ID" +DOCKER_CONFIG="$WORKDIR/docker-config" +CONTEXT="$WORKDIR/context" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$DOCKER_CONFIG/cli-plugins" "$CONTEXT" +cp "$BUILDX" "$DOCKER_CONFIG/cli-plugins/docker-buildx" +chmod 0755 "$DOCKER_CONFIG/cli-plugins/docker-buildx" +[ -f "$DOCKER_CONFIG/cli-plugins/docker-buildx" ] \ + && [ ! -L "$DOCKER_CONFIG/cli-plugins/docker-buildx" ] \ + || die "isolated Buildx plugin is unavailable or indirect" +[ "$(shasum -a 256 "$DOCKER_CONFIG/cli-plugins/docker-buildx" | awk '{print $1}')" \ + = "$(shasum -a 256 "$BUILDX" | awk '{print $1}')" ] \ + || die "isolated Buildx copy differs from the candidate plugin" +REMOTE_DELETED=0 +RUN_LABEL="dev.dory.ecr-retry=$RUN_ID" + +docker_e() { DOCKER_HOST="unix://$SOCKET" DOCKER_CONFIG="$DOCKER_CONFIG" "$DOCKER" "$@"; } +docker_e version > "$WORKDIR/docker-version.txt" || die "Docker API is not ready" +docker_e buildx version > "$WORKDIR/buildx-version.txt" \ + || die "the bundled Buildx plugin is unavailable inside the isolated credential store" +docker_e image inspect "$BASE_IMAGE" > "$WORKDIR/base-image-inspect.json" 2>/dev/null \ + || die "base image is not present in the isolated store" +python3 - "$WORKDIR/base-image-inspect.json" "$BASE_IMAGE" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(document, list) or len(document) != 1 or not isinstance(document[0], dict): + raise SystemExit("base image inspect is not one exact image") +requested_name, requested_digest = sys.argv[2].rsplit("@", 1) +accepted_names = {requested_name} +first = requested_name.split("/", 1)[0] +if "/" not in requested_name: + accepted_names.add("docker.io/library/" + requested_name) +elif first != "localhost" and "." not in first and ":" not in first: + accepted_names.add("docker.io/" + requested_name) +repo_digests = document[0].get("RepoDigests") +if not isinstance(repo_digests, list) or not any( + value == name + "@" + requested_digest + for name in accepted_names + for value in repo_digests +): + raise SystemExit("local base image does not retain its exact registry authority") +PY +"$AWS_CLI" --version > "$WORKDIR/aws-version.txt" 2>&1 \ + || die "AWS CLI version inspection failed" +"$AWS_CLI" sts get-caller-identity > "$WORKDIR/aws-caller.json" \ + || die "AWS caller identity is unavailable" +"$AWS_CLI" ecr describe-repositories --region "$REGION" --repository-names "$REPOSITORY" \ + > "$WORKDIR/ecr-repository.json" || die "disposable ECR repository is unavailable" +python3 - "$WORKDIR/aws-caller.json" "$WORKDIR/ecr-repository.json" \ + "$registry_account" "$REGISTRY/$REPOSITORY" <<'PY' +import json +import pathlib +import sys + +caller = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +repositories = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +if not isinstance(caller, dict) or caller.get("Account") != sys.argv[3]: + raise SystemExit("AWS caller account differs from the ECR registry account") +items = repositories.get("repositories") if isinstance(repositories, dict) else None +if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): + raise SystemExit("ECR repository lookup did not return one exact repository") +repository = items[0] +if repository.get("registryId") != sys.argv[3] or repository.get("repositoryUri") != sys.argv[4]: + raise SystemExit("ECR repository authority differs from the requested registry path") +PY +caller_account_sha256="$(printf '%s' "$registry_account" | shasum -a 256 | awk '{print $1}')" +repository_authority_sha256="$(printf '%s' "$REGISTRY/$REPOSITORY" | shasum -a 256 | awk '{print $1}')" +rm -f "$WORKDIR/aws-caller.json" "$WORKDIR/ecr-repository.json" +REMOTE_DIGEST_REF="" + +cleanup() { + set +e + docker_e ps -aq --filter "label=$RUN_LABEL" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >/dev/null 2>&1 || true + done + docker_e image rm -f "$REMOTE_REF" >/dev/null 2>&1 || true + [ -z "$REMOTE_DIGEST_REF" ] || docker_e image rm -f "$REMOTE_DIGEST_REF" >/dev/null 2>&1 || true + if [ "$REMOTE_DELETED" -eq 0 ]; then + "$AWS_CLI" ecr batch-delete-image --region "$REGION" --repository-name "$REPOSITORY" \ + --image-ids "imageTag=$TAG" >/dev/null 2>&1 || true + fi + docker_e logout "$REGISTRY" >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" "$CONTEXT" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if "$AWS_CLI" ecr describe-images --region "$REGION" --repository-name "$REPOSITORY" \ + --image-ids "imageTag=$TAG" >/dev/null 2>&1; then + die "unique retry tag unexpectedly exists before the gate" +fi + +"$AWS_CLI" ecr get-login-password --region "$REGION" \ + | docker_e login --username AWS --password-stdin "$REGISTRY" \ + > "$WORKDIR/login.out" 2> "$WORKDIR/login.err" \ + || die "isolated ECR login failed" + +layer_path="$CONTEXT/retry-layer.bin" +layer_sha="$(python3 - "$layer_path" "$LAYER_MIB" <<'PY' +import hashlib +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +remaining = int(sys.argv[2]) * 1024 * 1024 +digest = hashlib.sha256() +counter = 0 +with path.open("wb") as handle: + while remaining: + block = bytearray() + while len(block) < min(1024 * 1024, remaining): + block.extend(hashlib.sha256(f"dory-ecr-retry-{counter}".encode()).digest()) + counter += 1 + chunk = bytes(block[:min(len(block), remaining)]) + handle.write(chunk) + digest.update(chunk) + remaining -= len(chunk) +print(digest.hexdigest()) +PY +)" +cat > "$CONTEXT/Dockerfile" < "$WORKDIR/build.out" 2> "$WORKDIR/build.err" \ + || die "ECR retry fixture build failed" + +set +e +docker_e push "$REMOTE_REF" > "$WORKDIR/interrupted-push.out" \ + 2> "$WORKDIR/interrupted-push.err" & +push_pid=$! +interrupted=0 +for _ in $(seq 1 600); do + if ! kill -0 "$push_pid" 2>/dev/null; then break; fi + # Docker 28's non-TTY progress renderer reports active uploads as `Waiting` and then jumps + # directly to `Pushed`; older clients report `Pushing`. Accept either active-progress spelling, + # but require the push process to remain alive through a one-second upload window before killing + # it. A completed fast push cannot masquerade as the interrupted-upload regression. + if grep -Eq '([[:space:]]|:)(Pushing|Waiting)([[:space:]]|$)' \ + "$WORKDIR/interrupted-push.out" "$WORKDIR/interrupted-push.err" 2>/dev/null; then + sleep 1 + kill -0 "$push_pid" 2>/dev/null || break + kill -TERM "$push_pid" + interrupted=1 + break + fi + sleep 0.1 +done +wait "$push_pid" +interrupted_rc=$? +set -e +[ "$interrupted" -eq 1 ] || die "first ECR push completed or stalled before an upload could be interrupted" +[ "$interrupted_rc" -ne 0 ] || die "interrupted ECR push returned success" + +docker_e push "$REMOTE_REF" > "$WORKDIR/resumed-push.out" 2> "$WORKDIR/resumed-push.err" \ + || die "resumed ECR push failed" +docker_e push "$REMOTE_REF" > "$WORKDIR/repeated-manifest-put.out" \ + 2> "$WORKDIR/repeated-manifest-put.err" \ + || die "repeated ECR manifest PUT failed" +push_digests="$(python3 - \ + "$WORKDIR/resumed-push.out" "$WORKDIR/resumed-push.err" \ + "$WORKDIR/repeated-manifest-put.out" "$WORKDIR/repeated-manifest-put.err" <<'PY' +import pathlib +import re +import sys + +def digest_for(paths): + text = "\n".join(pathlib.Path(path).read_text(encoding="utf-8") for path in paths) + matches = re.findall(r"(?m)^digest:\s*(sha256:[0-9a-f]{64})\s+size:\s*[1-9][0-9]*\s*$", text) + if len(matches) != 1: + raise SystemExit("ECR push output does not contain one exact manifest digest") + return matches[0] + +resumed = digest_for(sys.argv[1:3]) +repeated = digest_for(sys.argv[3:5]) +if resumed != repeated: + raise SystemExit("repeated ECR manifest PUT changed the manifest digest") +print(resumed) +PY +)" || die "ECR push digest evidence failed semantic validation" +"$AWS_CLI" ecr describe-images --region "$REGION" --repository-name "$REPOSITORY" \ + --image-ids "imageTag=$TAG" > "$WORKDIR/ecr-image-after-push.json" \ + || die "ECR image is unavailable after repeated push" +remote_digest="$(python3 - "$WORKDIR/ecr-image-after-push.json" "$TAG" "$push_digests" <<'PY' +import json +import pathlib +import re +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +details = document.get("imageDetails") if isinstance(document, dict) else None +if not isinstance(details, list) or len(details) != 1 or not isinstance(details[0], dict): + raise SystemExit("ECR did not return one exact pushed image") +detail = details[0] +digest = detail.get("imageDigest") +if digest != sys.argv[3] or re.fullmatch(r"sha256:[0-9a-f]{64}", digest or "") is None: + raise SystemExit("ECR registry digest differs from the repeated push digest") +tags = detail.get("imageTags") +if not isinstance(tags, list) or tags != [sys.argv[2]]: + raise SystemExit("ECR pushed image has an unexpected tag authority") +print(digest) +PY +)" || die "ECR remote image evidence failed semantic validation" +REMOTE_DIGEST_REF="$REGISTRY/$REPOSITORY@$remote_digest" +docker_e image rm -f "$REMOTE_REF" >/dev/null +docker_e pull "$REMOTE_DIGEST_REF" > "$WORKDIR/repull.out" 2> "$WORKDIR/repull.err" \ + || die "ECR repull failed after interrupted/resumed push" +run_sha="$(docker_e run --rm --pull=never --network none --label "$RUN_LABEL" \ + "$REMOTE_DIGEST_REF" | awk '{print $1}')" +[ "$run_sha" = "$layer_sha" ] || die "ECR repull/run returned the wrong layer checksum" +docker_e image rm -f "$REMOTE_DIGEST_REF" > "$WORKDIR/local-image-delete.out" \ + 2> "$WORKDIR/local-image-delete.err" \ + || die "local ECR retry image cleanup failed" +if docker_e image inspect "$REMOTE_REF" >/dev/null 2>&1 \ + || docker_e image inspect "$REMOTE_DIGEST_REF" >/dev/null 2>&1; then + die "local ECR retry image survived cleanup" +fi + +"$AWS_CLI" ecr batch-delete-image --region "$REGION" --repository-name "$REPOSITORY" \ + --image-ids "imageTag=$TAG" > "$WORKDIR/remote-delete.json" \ + || die "remote ECR cleanup failed" +python3 - "$WORKDIR/remote-delete.json" "$TAG" <<'PY' +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +tag = sys.argv[2] +image_ids = payload.get("imageIds") if isinstance(payload, dict) else None +if not isinstance(image_ids, list) or len(image_ids) != 1 or image_ids[0].get("imageTag") != tag: + raise SystemExit("ECR cleanup did not report the one unique image tag") +if payload.get("failures") not in (None, []): + raise SystemExit(f"ECR cleanup reported failures: {payload.get('failures')}") +PY +set +e +"$AWS_CLI" ecr describe-images --region "$REGION" --repository-name "$REPOSITORY" \ + --image-ids "imageTag=$TAG" > "$WORKDIR/remote-after-delete.out" \ + 2> "$WORKDIR/remote-after-delete.err" +remote_after_delete_rc=$? +set -e +[ "$remote_after_delete_rc" -ne 0 ] \ + || die "remote ECR tag survived confirmed deletion" +grep -F 'ImageNotFoundException' "$WORKDIR/remote-after-delete.err" >/dev/null \ + || die "remote ECR deletion could not be verified fail-closed" +REMOTE_DELETED=1 +[ -z "$(docker_e ps -aq --filter "label=$RUN_LABEL")" ] \ + || die "owned ECR retry container survived cleanup" +docker_e logout "$REGISTRY" > "$WORKDIR/logout.out" 2> "$WORKDIR/logout.err" \ + || die "isolated ECR logout failed" +rm -rf "$DOCKER_CONFIG" "$CONTEXT" +[ ! -e "$DOCKER_CONFIG" ] || die "isolated Docker credential directory survived cleanup" + +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "registry_sha256=$(printf '%s' "$REGISTRY" | shasum -a 256 | awk '{print $1}')" + echo "repository_sha256=$(printf '%s' "$REPOSITORY" | shasum -a 256 | awk '{print $1}')" + echo "caller_account_sha256=$caller_account_sha256" + echo "repository_authority_sha256=$repository_authority_sha256" + echo "region=$REGION" + echo "base_image=$BASE_IMAGE" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "buildx_cli_sha256=$(shasum -a 256 "$BUILDX" | awk '{print $1}')" + echo "aws_cli_sha256=$(shasum -a 256 "$AWS_CLI" | awk '{print $1}')" + echo "layer_mib=$LAYER_MIB" + echo "layer_sha256=$layer_sha" + echo "authenticated_login=PASS" + echo "bundled_buildx=PASS" + echo "interrupted_push_progress=PASS" + echo "interrupted_push_nonzero=PASS" + echo "resumed_blob_upload=PASS" + echo "repeated_manifest_put=PASS" + echo "repeated_manifest_digest=$remote_digest" + echo "registry_digest_agreement=PASS" + echo "digest_based_repull=PASS" + echo "repull_run_checksum=PASS" + echo "local_image_cleanup=PASS" + echo "remote_tag_cleanup=PASS" + echo "remote_deletion_verified=PASS" + echo "owned_container_cleanup=PASS" + echo "isolated_credential_cleanup=PASS" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +trap - EXIT INT TERM +echo "ECR retry gate: PASS ($MANIFEST)" From 075f8c6688676218cdc0f41bf58d5effc185f54e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:44:59 +0000 Subject: [PATCH 137/338] feat(release): qualify private registry auth --- .../test-private-registry-auth-gate.py | 105 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/private-registry-auth-gate.sh | 433 ++++++++++++++++++ 4 files changed, 540 insertions(+) create mode 100755 .github/scripts/test-private-registry-auth-gate.py create mode 100755 scripts/private-registry-auth-gate.sh diff --git a/.github/scripts/test-private-registry-auth-gate.py b/.github/scripts/test-private-registry-auth-gate.py new file mode 100755 index 00000000..88d0f806 --- /dev/null +++ b/.github/scripts/test-private-registry-auth-gate.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Offline contract for isolated authenticated-registry and image-lifecycle qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "private-registry-auth-gate.sh" + + +class PrivateRegistryAuthGateTests(unittest.TestCase): + def test_gate_binds_candidate_registry_auth_context_and_secret_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-PRIVATE-REGISTRY", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "Docker Buildx plugin must be an absolute path", + "Docker Buildx plugin is unavailable or indirect", + "isolated Buildx copy differs from the candidate plugin", + "qualified image does not retain its exact registry authority", + "registry volume is missing its exact run authority", + "--pull=never --name", + '--mount "type=volume,src=$VOLUME,dst=/var/lib/registry"', + '--mount "type=bind,src=$AUTH,dst=/auth"', + "authenticated registry runtime binding differs from the qualified image/network", + "authenticated registry mount graph differs from its exact authorities", + "authenticated registry environment omits its loopback/auth contract", + "unauthenticated pull unexpectedly succeeded", + "unauthenticated pull failed without an authentication rejection", + 'CONTEXT="$WORKDIR/context"', + '"$CONTEXT/Dockerfile"', + "BuildKit context is not the exact Dockerfile-only authority", + "--progress plain --pull --network none", + "BuildKit secret leaked into image history", + "private registry credential or BuildKit secret leaked into the image archive", + "save/load changed the image identity", + "filtered image prune retained the run-owned derived image", + 'docker_e ps -aq --filter "label=dev.dory.private-registry=$RUN_ID"', + "private registry evidence retained secret bytes", + "dockerfile_only_build_context=PASS", + "archive_secret_nonleak=PASS", + "secret_free_evidence=PASS", + "owned_cleanup=PASS", + "isolated_credential_cleanup=PASS", + "docker_cli_sha256=", + "buildx_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + 'ln -s "$BUILDX"', + '"$HOME/.docker/cli-plugins/docker-buildx"', + '-v "$VOLUME:/var/lib/registry"', + '-v "$AUTH:/auth"', + '-t "$BUILT_REF" -- "$WORKDIR"', + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helpers_images_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--buildx", + "/missing/buildx", + "--base-image", + "invalid", + "--source-commit", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fe936e5..4c05ae43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -361,6 +361,7 @@ jobs: python3 .github/scripts/test-data-drive-volume-identity-gate.py python3 .github/scripts/test-data-disk-growth-gate.py python3 .github/scripts/test-ecr-registry-retry-gate.py + python3 .github/scripts/test-private-registry-auth-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94b3e148..02b56f8d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,6 +62,7 @@ jobs: python3 .github/scripts/test-data-drive-volume-identity-gate.py python3 .github/scripts/test-data-disk-growth-gate.py python3 .github/scripts/test-ecr-registry-retry-gate.py + python3 .github/scripts/test-private-registry-auth-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/private-registry-auth-gate.sh b/scripts/private-registry-auth-gate.sh new file mode 100755 index 00000000..f4c1a75a --- /dev/null +++ b/scripts/private-registry-auth-gate.sh @@ -0,0 +1,433 @@ +#!/bin/bash +# Qualify authenticated registry and image lifecycle behavior on an isolated Dory engine. +set -euo pipefail +umask 077 + +SOCKET="${DORY_REGISTRY_AUTH_SOCKET:-$HOME/.dory/dory.sock}" +DOCKER="${DORY_DOCKER_BIN:-$(command -v docker 2>/dev/null || true)}" +BUILDX="${DORY_BUILDX_BIN:-}" +BASE_IMAGE="${DORY_REGISTRY_AUTH_BASE_IMAGE:-}" +REGISTRY_IMAGE="${DORY_REGISTRY_AUTH_IMAGE:-registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373}" +SOURCE_COMMIT="${DORY_REGISTRY_AUTH_SOURCE_COMMIT:-}" +PORT="${DORY_REGISTRY_AUTH_PORT:-$((55000 + $$ % 400))}" +WORKROOT="${DORY_REGISTRY_AUTH_WORKROOT:-$HOME/.dory-private-registry-auth}" +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --buildx) need_value "$1" "$#"; BUILDX="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --registry-image) need_value "$1" "$#"; REGISTRY_IMAGE="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --port) need_value "$1" "$#"; PORT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-PRIVATE-REGISTRY ] \ + || die "requires --confirm ISOLATED-ENGINE-PRIVATE-REGISTRY" +case "$SOCKET:$WORKROOT" in /*:/*) ;; *) die "socket and workroot must be absolute" ;; esac +case "$PORT" in ''|*[!0-9]*) die "port must be an integer" ;; esac +[ "$PORT" -ge 1024 ] && [ "$PORT" -le 65535 ] || die "port must be between 1024 and 65535" +printf '%s\n' "$BASE_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--base-image must be digest-pinned" +printf '%s\n' "$REGISTRY_IMAGE" \ + | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$' \ + || die "--registry-image must be digest-pinned" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "--source-commit must be a full lowercase Git SHA" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect" +DOCKER="$(cd "$(dirname "$DOCKER")" && pwd -P)/$(basename "$DOCKER")" +command -v htpasswd >/dev/null || die "htpasswd is required for the disposable bcrypt credential" +command -v openssl >/dev/null || die "openssl is required" +command -v shasum >/dev/null || die "shasum is required" +command -v tar >/dev/null || die "tar is required" +[ -S "$SOCKET" ] || die "socket is unavailable: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "socket is not owned by the release user" +case "$WORKROOT" in /|"$HOME"|"$(pwd)") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" + +if [ -z "$BUILDX" ]; then + docker_dir="$(cd "$(dirname "$DOCKER")" && pwd -P)" + for candidate in "$docker_dir/docker-buildx"; do + if [ -x "$candidate" ]; then BUILDX="$candidate"; break; fi + done +fi +case "$BUILDX" in /*) ;; *) die "Docker Buildx plugin must be an absolute path" ;; esac +[ -f "$BUILDX" ] && [ ! -L "$BUILDX" ] && [ -x "$BUILDX" ] \ + || die "Docker Buildx plugin is unavailable or indirect" +BUILDX="$(cd "$(dirname "$BUILDX")" && pwd -P)/$(basename "$BUILDX")" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +WORKDIR="$WORKROOT/$RUN_ID" +CONFIG="$WORKDIR/docker-config" +UNAUTH_CONFIG="$WORKDIR/unauth-config" +AUTH="$WORKDIR/auth" +CONTEXT="$WORKDIR/context" +NAME="dory-private-registry-$RUN_ID" +VOLUME="dory-private-registry-data-$RUN_ID" +SOURCE_REF="localhost:$PORT/dory-auth-probe:source" +BUILT_REF="localhost:$PORT/dory-auth-probe:built" +CACHE_REF="localhost:$PORT/dory-auth-probe:cache-$RUN_ID" +LOADED_REF="dory-auth-probe:$RUN_ID" +USER_NAME=doryprobe +PASSWORD="$(openssl rand -hex 16)" +OWNED_CLEANUP=FAIL +ISOLATED_CREDENTIAL_CLEANUP=FAIL +mkdir -p "$CONFIG/cli-plugins" "$UNAUTH_CONFIG" "$AUTH" "$CONTEXT" +cp "$BUILDX" "$CONFIG/cli-plugins/docker-buildx" +chmod 0755 "$CONFIG/cli-plugins/docker-buildx" +[ -f "$CONFIG/cli-plugins/docker-buildx" ] \ + && [ ! -L "$CONFIG/cli-plugins/docker-buildx" ] \ + && [ "$(shasum -a 256 "$CONFIG/cli-plugins/docker-buildx" | awk '{print $1}')" \ + = "$(shasum -a 256 "$BUILDX" | awk '{print $1}')" ] \ + || die "isolated Buildx copy differs from the candidate plugin" +container_id="" + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_CONFIG="$CONFIG" \ + DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@" +} +buildx_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u BUILDKIT_HOST -u BUILDX_BUILDER -u BUILDX_CONFIG \ + DOCKER_CONFIG="$CONFIG" DOCKER_HOST="unix://$SOCKET" BUILDKIT_PROGRESS=plain \ + NO_COLOR=1 "$BUILDX" --builder default "$@" +} +wait_registry_ready() { + local phase="$1" running + for _ in $(seq 1 100); do + running="$(docker_e inspect --format '{{.State.Running}}' "$container_id" 2>/dev/null || true)" + [ "$running" = true ] || die "$phase registry exited before binding guest port $PORT" + if docker_e logs "$container_id" 2>&1 | grep -Fq "listening on 127.0.0.1:$PORT"; then + return + fi + sleep 0.1 + done + die "$phase registry did not bind guest port $PORT within 10 seconds" +} +cleanup() { + local images_absent=1 + set +e + [ -z "$container_id" ] || docker_e logs "$container_id" >> "$WORKDIR/registry.log" 2>&1 + docker_e logout "localhost:$PORT" >/dev/null 2>&1 + [ -z "$container_id" ] || docker_e rm -f "$container_id" >/dev/null 2>&1 + docker_e ps -aq --filter "label=dev.dory.private-registry=$RUN_ID" 2>/dev/null \ + | while IFS= read -r id; do + [ -z "$id" ] || docker_e rm -f "$id" >/dev/null 2>&1 || true + done + docker_e volume rm -f "$VOLUME" >/dev/null 2>&1 + for reference in "$SOURCE_REF" "$BUILT_REF" "$LOADED_REF"; do + docker_e image rm -f "$reference" >/dev/null 2>&1 + done + for reference in "$SOURCE_REF" "$BUILT_REF" "$LOADED_REF"; do + if docker_e image inspect "$reference" >/dev/null 2>&1; then images_absent=0; fi + done + if [ -z "$(docker_e ps -aq --filter "label=dev.dory.private-registry=$RUN_ID")" ] \ + && ! docker_e volume inspect "$VOLUME" >/dev/null 2>&1 \ + && [ "$images_absent" -eq 1 ]; then + OWNED_CLEANUP=PASS + fi + rm -rf "$CONFIG" "$UNAUTH_CONFIG" "$AUTH" "$WORKDIR/secret.txt" + if [ ! -e "$CONFIG" ] && [ ! -e "$UNAUTH_CONFIG" ] && [ ! -e "$AUTH" ] \ + && [ ! -e "$WORKDIR/secret.txt" ]; then + ISOLATED_CREDENTIAL_CLEANUP=PASS + fi + set -e +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker_e version > "$WORKDIR/docker-version.txt" || die "Docker API is unreachable" +DOCKER_SHA256="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +BUILDX_SHA256="$(shasum -a 256 "$BUILDX" | awk '{print $1}')" +docker_e buildx version > "$WORKDIR/buildx-version.txt" +docker_e image inspect "$BASE_IMAGE" > "$WORKDIR/base-image-inspect.json" 2>&1 \ + || die "missing local image: $BASE_IMAGE" +docker_e pull --platform linux/arm64 "$REGISTRY_IMAGE" > "$WORKDIR/registry-image-pull.out" +docker_e image inspect "$REGISTRY_IMAGE" > "$WORKDIR/registry-image-inspect.json" +[ "$(docker_e image inspect --format '{{.Os}}/{{.Architecture}}' "$REGISTRY_IMAGE")" = \ + linux/arm64 ] || die "registry fixture did not resolve to linux/arm64" +python3 - "$WORKDIR/base-image-inspect.json" "$BASE_IMAGE" \ + "$WORKDIR/registry-image-inspect.json" "$REGISTRY_IMAGE" <<'PY' +import json +import pathlib +import sys + +def validate(path, reference): + document = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + if not isinstance(document, list) or len(document) != 1 or not isinstance(document[0], dict): + raise SystemExit("qualified image inspect is not one exact image") + requested_name, requested_digest = reference.rsplit("@", 1) + accepted_names = {requested_name} + first = requested_name.split("/", 1)[0] + if "/" not in requested_name: + accepted_names.add("docker.io/library/" + requested_name) + elif first != "localhost" and "." not in first and ":" not in first: + accepted_names.add("docker.io/" + requested_name) + repo_digests = document[0].get("RepoDigests") + if not isinstance(repo_digests, list) or not any( + value == name + "@" + requested_digest + for name in accepted_names + for value in repo_digests + ): + raise SystemExit("qualified image does not retain its exact registry authority") + +validate(sys.argv[1], sys.argv[2]) +validate(sys.argv[3], sys.argv[4]) +PY +[ "$(docker_e volume create --label "dev.dory.private-registry=$RUN_ID" "$VOLUME")" = "$VOLUME" ] \ + || die "registry volume creation returned the wrong authority" +[ "$(docker_e volume inspect --format \ + '{{index .Labels "dev.dory.private-registry"}}' "$VOLUME")" = "$RUN_ID" ] \ + || die "registry volume is missing its exact run authority" + +# Seed one private tag before restarting the same run-owned registry with authentication. +container_id="$(docker_e run -d --pull=never --name "$NAME" \ + --label "dev.dory.private-registry=$RUN_ID" --network host \ + --mount "type=volume,src=$VOLUME,dst=/var/lib/registry" \ + -e "REGISTRY_HTTP_ADDR=127.0.0.1:$PORT" "$REGISTRY_IMAGE")" +printf '%s\n' "$container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "seed registry launch returned an invalid container ID" +wait_registry_ready seed +docker_e tag "$BASE_IMAGE" "$SOURCE_REF" +docker_e push "$SOURCE_REF" > "$WORKDIR/seed-push.out" +docker_e image rm "$SOURCE_REF" >/dev/null +docker_e rm -f "$container_id" >/dev/null +container_id="" + +htpasswd -Bbn "$USER_NAME" "$PASSWORD" > "$AUTH/htpasswd" +# Distribution 2.8 opens the existing htpasswd file with write-capable flags. This directory is +# run-scoped, mode 0700 via umask, and deleted after the gate, so keep this bind writable. +container_id="$(docker_e run -d --pull=never --name "$NAME" \ + --label "dev.dory.private-registry=$RUN_ID" --network host \ + --mount "type=volume,src=$VOLUME,dst=/var/lib/registry" \ + --mount "type=bind,src=$AUTH,dst=/auth" \ + -e "REGISTRY_HTTP_ADDR=127.0.0.1:$PORT" \ + -e REGISTRY_AUTH=htpasswd -e 'REGISTRY_AUTH_HTPASSWD_REALM=Dory candidate gate' \ + -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd "$REGISTRY_IMAGE")" +printf '%s\n' "$container_id" | grep -Eq '^[0-9a-f]{64}$' \ + || die "authenticated registry launch returned an invalid container ID" +wait_registry_ready authenticated +docker_e inspect "$container_id" > "$WORKDIR/authenticated-registry-inspect.json" +python3 - "$WORKDIR/authenticated-registry-inspect.json" "$REGISTRY_IMAGE" \ + "$RUN_ID" "$VOLUME" "$AUTH" "$PORT" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(document, list) or len(document) != 1 or not isinstance(document[0], dict): + raise SystemExit("authenticated registry inspect is not one exact container") +container = document[0] +config = container.get("Config") +host = container.get("HostConfig") +mounts = container.get("Mounts") +if not isinstance(config, dict) or not isinstance(host, dict) or not isinstance(mounts, list): + raise SystemExit("authenticated registry inspect omits runtime authority") +if config.get("Image") != sys.argv[2] or host.get("NetworkMode") != "host": + raise SystemExit("authenticated registry runtime binding differs from the qualified image/network") +labels = config.get("Labels") +if not isinstance(labels, dict) or labels.get("dev.dory.private-registry") != sys.argv[3]: + raise SystemExit("authenticated registry omits its exact run label") +expected_mounts = { + ("volume", sys.argv[4], "/var/lib/registry", True), + ("bind", sys.argv[5], "/auth", True), +} +actual_mounts = { + (item.get("Type"), item.get("Name") if item.get("Type") == "volume" else item.get("Source"), + item.get("Destination"), item.get("RW")) + for item in mounts if isinstance(item, dict) +} +if actual_mounts != expected_mounts: + raise SystemExit("authenticated registry mount graph differs from its exact authorities") +environment = config.get("Env") +required_environment = { + f"REGISTRY_HTTP_ADDR=127.0.0.1:{sys.argv[6]}", + "REGISTRY_AUTH=htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM=Dory candidate gate", + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd", +} +if not isinstance(environment, list) or not required_environment.issubset(environment): + raise SystemExit("authenticated registry environment omits its loopback/auth contract") +PY + +if env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_CONFIG="$UNAUTH_CONFIG" \ + DOCKER_HOST="unix://$SOCKET" "$DOCKER" pull "$SOURCE_REF" \ + > "$WORKDIR/unauth.out" 2>&1; then + die "unauthenticated pull unexpectedly succeeded" +fi +grep -Eiq 'unauthorized|authentication required|no basic auth credentials' "$WORKDIR/unauth.out" \ + || die "unauthenticated pull failed without an authentication rejection" +printf '%s' "$PASSWORD" | docker_e login "localhost:$PORT" \ + --username "$USER_NAME" --password-stdin > "$WORKDIR/login.out" +docker_e pull "$SOURCE_REF" > "$WORKDIR/pull-source.out" +docker_e run --rm --pull=never --network none \ + --label "dev.dory.private-registry=$RUN_ID" "$SOURCE_REF" true + +SECRET_VALUE="$(openssl rand -hex 24)" +SECRET_SHA="$(printf '%s' "$SECRET_VALUE" | shasum -a 256 | awk '{print $1}')" +printf '%s' "$SECRET_VALUE" > "$WORKDIR/secret.txt" +{ + printf 'FROM %s\n' "$SOURCE_REF" + printf 'LABEL dev.dory.private-registry=%s\n' "$RUN_ID" + printf 'RUN --mount=type=secret,id=probe test "$(sha256sum /run/secrets/probe | awk '\''{print $1}'\'')" = %s\n' "$SECRET_SHA" + printf 'RUN test ! -e /run/secrets/probe\n' +} > "$CONTEXT/Dockerfile" +[ "$(find "$CONTEXT" -mindepth 1 -maxdepth 1 -print | wc -l | tr -d ' ')" = 1 ] \ + && [ -f "$CONTEXT/Dockerfile" ] && [ ! -L "$CONTEXT/Dockerfile" ] \ + || die "BuildKit context is not the exact Dockerfile-only authority" +buildx_e build --progress plain --pull --network none \ + --secret "id=probe,src=$WORKDIR/secret.txt" \ + --cache-to "type=registry,ref=$CACHE_REF,mode=max" --load \ + -t "$BUILT_REF" -- "$CONTEXT" > "$WORKDIR/build.out" 2> "$WORKDIR/build.err" +docker_e image rm "$BUILT_REF" >/dev/null +buildx_e build --progress plain --pull --network none \ + --secret "id=probe,src=$WORKDIR/secret.txt" \ + --cache-from "type=registry,ref=$CACHE_REF" --load \ + -t "$BUILT_REF" -- "$CONTEXT" \ + > "$WORKDIR/cache-import-build.out" 2> "$WORKDIR/cache-import-build.err" +grep -q 'CACHED' "$WORKDIR/cache-import-build.out" "$WORKDIR/cache-import-build.err" \ + || die "authenticated registry cache import did not reuse the exported result" +docker_e push "$BUILT_REF" > "$WORKDIR/push-built.out" +docker_e image inspect "$BUILT_REF" > "$WORKDIR/built-image-inspect.json" +docker_e history --no-trunc "$BUILT_REF" > "$WORKDIR/built-image-history.txt" +if grep -Fq "$SECRET_VALUE" "$WORKDIR/built-image-history.txt"; then + die "BuildKit secret leaked into image history" +fi + +IMAGE_ID_BEFORE="$(docker_e image inspect --format '{{.Id}}' "$BUILT_REF")" +docker_e save -o "$WORKDIR/built-image.tar" "$BUILT_REF" +ARCHIVE_SHA256="$(shasum -a 256 "$WORKDIR/built-image.tar" | awk '{print $1}')" +python3 - "$WORKDIR/built-image.tar" "$SECRET_VALUE" "$PASSWORD" <<'PY' +import pathlib +import sys + +archive = pathlib.Path(sys.argv[1]).read_bytes() +for value in sys.argv[2:]: + if value.encode("utf-8") in archive: + raise SystemExit("private registry credential or BuildKit secret leaked into the image archive") +PY +tar -tf "$WORKDIR/built-image.tar" > "$WORKDIR/built-image-tar-list.txt" +grep -qx 'manifest.json' "$WORKDIR/built-image-tar-list.txt" \ + || die "saved image archive has no manifest.json" +docker_e image rm "$BUILT_REF" > "$WORKDIR/remove-before-load.out" +if docker_e image inspect "$BUILT_REF" >/dev/null 2>&1; then + die "removed image tag remained inspectable" +fi +docker_e load -i "$WORKDIR/built-image.tar" > "$WORKDIR/load.out" +IMAGE_ID_AFTER="$(docker_e image inspect --format '{{.Id}}' "$BUILT_REF")" +[ "$IMAGE_ID_AFTER" = "$IMAGE_ID_BEFORE" ] || die "save/load changed the image identity" +docker_e tag "$BUILT_REF" "$LOADED_REF" +docker_e image inspect "$LOADED_REF" > "$WORKDIR/loaded-image-inspect.json" +docker_e history --no-trunc "$LOADED_REF" > "$WORKDIR/loaded-image-history.txt" +if grep -Fq "$SECRET_VALUE" "$WORKDIR/loaded-image-history.txt"; then + die "BuildKit secret appeared after image load" +fi +docker_e run --rm --pull=never --network none \ + --label "dev.dory.private-registry=$RUN_ID" "$LOADED_REF" true + +# Make only the uniquely labeled derived image dangling, then prove filtered prune removes it. +docker_e image rm "$LOADED_REF" >/dev/null +docker_e image rm "$BUILT_REF" >/dev/null +docker_e image prune --force --filter "label=dev.dory.private-registry=$RUN_ID" \ + > "$WORKDIR/image-prune.out" +if docker_e image inspect "$IMAGE_ID_BEFORE" >/dev/null 2>&1; then + die "filtered image prune retained the run-owned derived image" +fi + +cat > "$WORKDIR/manifest.txt.partial" <> "$WORKDIR/manifest.txt.partial" < Date: Fri, 21 Aug 2026 05:51:07 +0000 Subject: [PATCH 138/338] feat(release): qualify offline bundled boot --- .../scripts/test-offline-bundled-boot-gate.py | 102 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/offline-bundled-boot-gate.sh | 319 ++++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100755 .github/scripts/test-offline-bundled-boot-gate.py create mode 100755 scripts/offline-bundled-boot-gate.sh diff --git a/.github/scripts/test-offline-bundled-boot-gate.py b/.github/scripts/test-offline-bundled-boot-gate.py new file mode 100755 index 00000000..ef144a7e --- /dev/null +++ b/.github/scripts/test-offline-bundled-boot-gate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Offline contract for exact bundled-image boot without an observed host TCP dependency.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "offline-bundled-boot-gate.sh" + + +class OfflineBundledBootGateTests(unittest.TestCase): + def test_gate_binds_exact_candidate_bytes_and_continuously_samples_tcp(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISPOSABLE-RUNTIME-OFFLINE-CACHE", + "--runtime must be absolute", + "runtime directory is unavailable or indirect", + "required runtime helper is missing or indirect", + "required runtime asset is missing or indirect", + "workroot already exists or is indirect", + "offline HOME base overlaps a protected runtime/evidence root", + "offline Docker backend socket path is", + 'cp -cR "$RUNTIME/." "$RUNTIME_COPY/"', + 'cmp "$RUNTIME/$relative" "$RUNTIME_COPY/$relative"', + "dory_engine_sha256=", + "dory_hv_sha256=", + "gvproxy_sha256=", + "dataplane_proxy_sha256=", + "kernel_asset_sha256=", + "rootfs_asset_sha256=", + "agent_asset_sha256=", + "HTTP_PROXY=http://127.0.0.1:9", + "HTTPS_PROXY=http://127.0.0.1:9", + "ALL_PROXY=socks5://127.0.0.1:9", + "NO_PROXY=", + "http_proxy=http://127.0.0.1:9", + "https_proxy=http://127.0.0.1:9", + "all_proxy=socks5://127.0.0.1:9", + "no_proxy=", + 'TCP_MONITOR_OUTPUT="$WORKDIR/fresh-host-tcp-continuous.txt"', + 'TCP_MONITOR_OUTPUT="$WORKDIR/cached-host-tcp-continuous.txt"', + "sleep 0.05", + "offline boot retained a host TCP dependency", + "published a Docker socket owned by another user", + 'mv "$kernel_asset" "$HIDDEN_ASSETS/"', + 'mv "$rootfs_asset" "$HIDDEN_ASSETS/"', + "fresh boot did not prepare a direct bundled kernel", + "fresh boot did not prepare a direct bundled rootfs", + "cached offline boot changed the prepared kernel", + "cached offline boot changed the prepared rootfs", + "cached offline boot tried to prepare a missing kernel source", + "cached offline boot tried to prepare a missing rootfs source", + "stop returned but the isolated Docker socket remained published", + "fresh_bundled_boot=PASS", + "cached_boot_without_bundle_sources=PASS", + "dead_proxy_environment=PASS", + "host_tcp_dependency_absence=PASS", + "continuous_host_tcp_sampling=PASS", + "same_user_docker_socket=PASS", + "observable_stop_teardown=PASS", + "prepared_assets_unchanged=PASS", + ): + self.assertIn(proof, text, proof) + for stale in ( + "assert ", + 'echo "runtime=$RUNTIME"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_runtime_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + str(pathlib.Path(temporary) / "missing-runtime"), + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c05ae43..c6ef4c53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -362,6 +362,7 @@ jobs: python3 .github/scripts/test-data-disk-growth-gate.py python3 .github/scripts/test-ecr-registry-retry-gate.py python3 .github/scripts/test-private-registry-auth-gate.py + python3 .github/scripts/test-offline-bundled-boot-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 02b56f8d..cfde1431 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,6 +63,7 @@ jobs: python3 .github/scripts/test-data-disk-growth-gate.py python3 .github/scripts/test-ecr-registry-retry-gate.py python3 .github/scripts/test-private-registry-auth-gate.py + python3 .github/scripts/test-offline-bundled-boot-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/offline-bundled-boot-gate.sh b/scripts/offline-bundled-boot-gate.sh new file mode 100755 index 00000000..4a6ba2a2 --- /dev/null +++ b/scripts/offline-bundled-boot-gate.sh @@ -0,0 +1,319 @@ +#!/bin/bash +# Proves Dory's exact bundled VM image has no remote freshness/HEAD dependency. A disposable +# clone boots once from the compressed release assets, then boots again after those source assets +# are hidden, using only the prepared local kernel/rootfs cache under deliberately dead proxies. +set -euo pipefail + +RUNTIME="" +WORKROOT="${TMPDIR:-/tmp}/dory-offline-bundled-boot" +START_TIMEOUT=180 +STOP_TIMEOUT=40 +CONFIRM="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --runtime) need_value "$1" "$#"; RUNTIME="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --start-timeout) need_value "$1" "$#"; START_TIMEOUT="$2"; shift 2 ;; + --stop-timeout) need_value "$1" "$#"; STOP_TIMEOUT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +positive_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a positive integer" ;; esac + [ "$2" -gt 0 ] || die "$1 must be a positive integer" +} + +[ "$CONFIRM" = DISPOSABLE-RUNTIME-OFFLINE-CACHE ] \ + || die "requires --confirm DISPOSABLE-RUNTIME-OFFLINE-CACHE" +[ -n "$RUNTIME" ] || die "--runtime is required" +case "$RUNTIME" in /*) ;; *) die "--runtime must be absolute" ;; esac +[ -d "$RUNTIME" ] && [ ! -L "$RUNTIME" ] || die "runtime directory is unavailable or indirect" +RUNTIME="$(cd "$RUNTIME" 2>/dev/null && pwd -P)" || die "runtime directory not found: $RUNTIME" +positive_integer start-timeout "$START_TIMEOUT" +positive_integer stop-timeout "$STOP_TIMEOUT" +for relative in dory-engine bin/dory-hv bin/gvproxy bin/dory-dataplane-proxy; do + path="$RUNTIME/$relative" + [ -f "$path" ] && [ ! -L "$path" ] && [ -s "$path" ] && [ -x "$path" ] \ + || die "required runtime helper is missing or indirect: $path" +done +for relative in share/dory/dory-hv-kernel-arm64.lzfse \ + share/dory/dory-engine-rootfs.ext4.lzfse share/dory/dory-agent-linux-arm64; do + path="$RUNTIME/$relative" + [ -f "$path" ] && [ ! -L "$path" ] && [ -s "$path" ] \ + || die "required runtime asset is missing or indirect: $path" +done +for command in cmp cp curl lsof ps python3 shasum; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$WORKROOT" in /*) ;; *) die "--workroot must be absolute" ;; esac +case "$WORKROOT" in /|"$HOME"|"$(pwd)"|"$RUNTIME"|"$RUNTIME"/*) \ + die "unsafe --workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" + +run_bounded() { + local limit="$1" output="$2" pid started rc monitor_pid="" + shift 2 + "$@" > "$output" 2>&1 & + pid=$! + if [ -n "${TCP_MONITOR_OUTPUT:-}" ]; then + ( + while kill -0 "$pid" 2>/dev/null; do + capture_tcp_snapshot "$TCP_MONITOR_OUTPUT" + sleep 0.05 + done + capture_tcp_snapshot "$TCP_MONITOR_OUTPUT" + ) & + monitor_pid=$! + fi + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + [ -z "$monitor_pid" ] || kill -TERM "$monitor_pid" 2>/dev/null || true + [ -z "$monitor_pid" ] || wait "$monitor_pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + [ -z "$monitor_pid" ] || wait "$monitor_pid" 2>/dev/null || true + return "$rc" +} + +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +WORKDIR="$WORKROOT/$RUN_ID" +RUNTIME_COPY="$WORKDIR/runtime-under-test" +# Keep the disposable HOME short. The engine owns several AF_UNIX sockets below HOME and macOS +# rejects paths longer than 103 UTF-8 bytes. Evidence roots are intentionally descriptive and can +# be much deeper than a real user home, so nesting the runtime HOME below WORKDIR makes the gate +# fail before it tests offline boot. +OFFLINE_HOME_BASE="${DORY_OFFLINE_BOOT_HOME_BASE:-$HOME}" +OFFLINE_HOME_BASE="$(cd "$OFFLINE_HOME_BASE" 2>/dev/null && pwd -P)" \ + || die "offline HOME base is unavailable: $OFFLINE_HOME_BASE" +case "$OFFLINE_HOME_BASE" in /|"$RUNTIME"|"$RUNTIME"/*|"$WORKROOT"|"$WORKROOT"/*) \ + die "offline HOME base overlaps a protected runtime/evidence root" ;; esac +OFFLINE_HOME="$OFFLINE_HOME_BASE/.dob-$$" +HIDDEN_ASSETS="$WORKDIR/hidden-assets" +MANIFEST="$WORKDIR/manifest.txt" +[ ! -e "$OFFLINE_HOME" ] || die "disposable offline HOME already exists: $OFFLINE_HOME" +python3 - "$OFFLINE_HOME/.dory/standalone/hv/docker-backend.sock" <<'PY' +import os +import sys + +path = sys.argv[1] +length = len(os.fsencode(path)) +if length > 103: + raise SystemExit(f"offline Docker backend socket path is {length} bytes (limit 103): {path}") +PY +mkdir -p "$WORKDIR" "$RUNTIME_COPY" "$OFFLINE_HOME" "$HIDDEN_ASSETS" + +cleanup() { + HOME="$OFFLINE_HOME" "$RUNTIME_COPY/dory-engine" stop >/dev/null 2>&1 || true + if [ -s "$OFFLINE_HOME/.dory/standalone/engine.log" ]; then + cp "$OFFLINE_HOME/.dory/standalone/engine.log" "$WORKDIR/last-engine.log" || true + fi + rm -rf "$RUNTIME_COPY" "$OFFLINE_HOME" "$HIDDEN_ASSETS" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# APFS clone-copy keeps the exact input bytes without doubling the large release payload. The +# supplied runtime remains immutable while the clone's source assets are hidden for phase two. +cp -cR "$RUNTIME/." "$RUNTIME_COPY/" +for relative in dory-engine bin/dory-hv bin/gvproxy bin/dory-dataplane-proxy \ + share/dory/dory-hv-kernel-arm64.lzfse \ + share/dory/dory-engine-rootfs.ext4.lzfse \ + share/dory/dory-agent-linux-arm64; do + cmp "$RUNTIME/$relative" "$RUNTIME_COPY/$relative" \ + || die "runtime clone differs before testing: $relative" +done + +kernel_asset="$RUNTIME_COPY/share/dory/dory-hv-kernel-arm64.lzfse" +rootfs_asset="$RUNTIME_COPY/share/dory/dory-engine-rootfs.ext4.lzfse" +kernel_asset_sha="$(shasum -a 256 "$kernel_asset" | awk '{print $1}')" +rootfs_asset_sha="$(shasum -a 256 "$rootfs_asset" | awk '{print $1}')" +agent_asset_sha="$(shasum -a 256 "$RUNTIME_COPY/share/dory/dory-agent-linux-arm64" | awk '{print $1}')" +dory_engine_sha="$(shasum -a 256 "$RUNTIME_COPY/dory-engine" | awk '{print $1}')" +dory_hv_sha="$(shasum -a 256 "$RUNTIME_COPY/bin/dory-hv" | awk '{print $1}')" +gvproxy_sha="$(shasum -a 256 "$RUNTIME_COPY/bin/gvproxy" | awk '{print $1}')" +dataplane_sha="$(shasum -a 256 "$RUNTIME_COPY/bin/dory-dataplane-proxy" | awk '{print $1}')" + +offline_env=( + env HOME="$OFFLINE_HOME" + HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 + ALL_PROXY=socks5://127.0.0.1:9 NO_PROXY= + http_proxy=http://127.0.0.1:9 https_proxy=http://127.0.0.1:9 + all_proxy=socks5://127.0.0.1:9 no_proxy= +) + +probe() { + local phase="$1" socket="$OFFLINE_HOME/.dory/engine.sock" + [ -S "$socket" ] || die "$phase boot did not publish the isolated Docker socket" + [ "$(stat -f %u "$socket")" = "$(id -u)" ] \ + || die "$phase boot published a Docker socket owned by another user" + [ "$(curl -fsS --max-time 5 --unix-socket "$socket" http://d/_ping)" = OK ] \ + || die "$phase boot Docker API is not ready" + curl -fsS --max-time 5 --unix-socket "$socket" http://d/version \ + > "$WORKDIR/$phase-version.json" +} + +wait_for_stopped() { + local phase="$1" socket="$OFFLINE_HOME/.dory/engine.sock" started=$SECONDS + while [ -S "$socket" ]; do + [ $((SECONDS - started)) -lt "$STOP_TIMEOUT" ] \ + || die "$phase stop returned but the isolated Docker socket remained published" + sleep 0.1 + done +} + +capture_tcp_snapshot() { + local output="$1" pid + touch "$output" + for pidfile in \ + "$OFFLINE_HOME/.dory/standalone/engine-cli.pid" \ + "$OFFLINE_HOME/.dory/standalone/dataplane-cli.pid"; do + [ -s "$pidfile" ] || continue + pid="$(cat "$pidfile")" + lsof -nP -a -p "$pid" -iTCP >> "$output" 2>/dev/null || true + done + ps -axo pid=,command= | awk -v needle="$OFFLINE_HOME/.dory" \ + 'index($0, needle) {print $1}' | while IFS= read -r pid; do + [ -n "$pid" ] || continue + lsof -nP -a -p "$pid" -iTCP >> "$output" 2>/dev/null || true + done +} + +validate_tcp_absence() { + local output="$1" + python3 - "$output" <<'PY' +import pathlib +import sys + +lines = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() +dependencies = [line for line in lines if "->" in line and ("(ESTABLISHED)" in line or "(SYN_SENT)" in line)] +if dependencies: + raise SystemExit("offline boot retained a host TCP dependency: " + " | ".join(dependencies)) +PY +} + +capture_host_tcp() { + local phase="$1" output + output="$WORKDIR/$phase-host-tcp.txt" + : > "$output" + capture_tcp_snapshot "$output" + validate_tcp_absence "$output" +} + +TCP_MONITOR_OUTPUT="$WORKDIR/fresh-host-tcp-continuous.txt" \ +run_bounded "$START_TIMEOUT" "$WORKDIR/fresh-start.log" \ + "${offline_env[@]}" "$RUNTIME_COPY/dory-engine" start --mem-mb 2048 --cpus 2 \ + || die "fresh exact-bundle boot failed or exceeded $START_TIMEOUT seconds" +validate_tcp_absence "$WORKDIR/fresh-host-tcp-continuous.txt" +probe fresh +capture_host_tcp fresh +prepared_kernel="$OFFLINE_HOME/.dory/standalone/vm/dory-hv-kernel-arm64" +prepared_rootfs="$OFFLINE_HOME/.dory/standalone/vm/dory-engine-rootfs.ext4" +[ -f "$prepared_kernel" ] && [ ! -L "$prepared_kernel" ] && [ -s "$prepared_kernel" ] \ + || die "fresh boot did not prepare a direct bundled kernel" +[ -f "$prepared_rootfs" ] && [ ! -L "$prepared_rootfs" ] && [ -s "$prepared_rootfs" ] \ + || die "fresh boot did not prepare a direct bundled rootfs" +prepared_kernel_sha="$(shasum -a 256 "$prepared_kernel" | awk '{print $1}')" +prepared_rootfs_sha="$(shasum -a 256 "$prepared_rootfs" | awk '{print $1}')" +run_bounded "$STOP_TIMEOUT" "$WORKDIR/fresh-stop.log" \ + "${offline_env[@]}" "$RUNTIME_COPY/dory-engine" stop \ + || die "fresh exact-bundle stop failed or exceeded $STOP_TIMEOUT seconds" +wait_for_stopped fresh +cp "$OFFLINE_HOME/.dory/standalone/engine.log" "$WORKDIR/fresh-engine.log" + +mv "$kernel_asset" "$HIDDEN_ASSETS/" +mv "$rootfs_asset" "$HIDDEN_ASSETS/" +[ ! -e "$kernel_asset" ] && [ ! -e "$rootfs_asset" ] \ + || die "disposable compressed image assets were not hidden" + +TCP_MONITOR_OUTPUT="$WORKDIR/cached-host-tcp-continuous.txt" \ +run_bounded "$START_TIMEOUT" "$WORKDIR/cached-start.log" \ + "${offline_env[@]}" "$RUNTIME_COPY/dory-engine" start --mem-mb 2048 --cpus 2 \ + || die "cached offline boot failed or exceeded $START_TIMEOUT seconds" +validate_tcp_absence "$WORKDIR/cached-host-tcp-continuous.txt" +probe cached +capture_host_tcp cached +[ "$prepared_kernel_sha" = "$(shasum -a 256 "$prepared_kernel" | awk '{print $1}')" ] \ + || die "cached offline boot changed the prepared kernel" +[ "$prepared_rootfs_sha" = "$(shasum -a 256 "$prepared_rootfs" | awk '{print $1}')" ] \ + || die "cached offline boot changed the prepared rootfs" +run_bounded "$STOP_TIMEOUT" "$WORKDIR/cached-stop.log" \ + "${offline_env[@]}" "$RUNTIME_COPY/dory-engine" stop \ + || die "cached offline stop failed or exceeded $STOP_TIMEOUT seconds" +wait_for_stopped cached +cp "$OFFLINE_HOME/.dory/standalone/engine.log" "$WORKDIR/cached-engine.log" +grep -Fq 'ready in' "$WORKDIR/fresh-start.log" || die "fresh boot never reported readiness" +grep -Fq 'ready in' "$WORKDIR/cached-start.log" || die "cached offline boot never reported readiness" +! grep -Fq 'preparing kernel' "$WORKDIR/cached-start.log" \ + || die "cached offline boot tried to prepare a missing kernel source" +! grep -Fq 'preparing engine rootfs' "$WORKDIR/cached-start.log" \ + || die "cached offline boot tried to prepare a missing rootfs source" + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "dory_engine_sha256=$dory_engine_sha" + echo "dory_hv_sha256=$dory_hv_sha" + echo "gvproxy_sha256=$gvproxy_sha" + echo "dataplane_proxy_sha256=$dataplane_sha" + echo "kernel_asset_sha256=$kernel_asset_sha" + echo "rootfs_asset_sha256=$rootfs_asset_sha" + echo "agent_asset_sha256=$agent_asset_sha" + echo "prepared_kernel_sha256=$prepared_kernel_sha" + echo "prepared_rootfs_sha256=$prepared_rootfs_sha" + echo "fresh_bundled_boot=PASS" + echo "cached_boot_without_bundle_sources=PASS" + echo "dead_proxy_environment=PASS" + echo "host_tcp_dependency_absence=PASS" + echo "continuous_host_tcp_sampling=PASS" + echo "same_user_docker_socket=PASS" + echo "observable_stop_teardown=PASS" + echo "prepared_assets_unchanged=PASS" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +cleanup +trap - EXIT INT TERM +echo "offline bundled boot gate: PASS ($MANIFEST)" From cb49de11b1548ef186ecaff2e1f3ae3332e4f9bb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:55:23 +0000 Subject: [PATCH 139/338] feat(release): qualify competitor runtime regressions --- ...test-competitor-runtime-regression-gate.py | 104 + .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/competitor-runtime-regression-gate.sh | 2079 +++++++++++++++++ 4 files changed, 2185 insertions(+) create mode 100755 .github/scripts/test-competitor-runtime-regression-gate.py create mode 100755 scripts/competitor-runtime-regression-gate.sh diff --git a/.github/scripts/test-competitor-runtime-regression-gate.py b/.github/scripts/test-competitor-runtime-regression-gate.py new file mode 100755 index 00000000..0e496c9d --- /dev/null +++ b/.github/scripts/test-competitor-runtime-regression-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for the run-owned competitor-runtime regression qualification gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "competitor-runtime-regression-gate.sh" + + +class CompetitorRuntimeRegressionGateTests(unittest.TestCase): + def test_gate_is_exact_run_owned_bounded_and_release_bindable(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-COMPETITOR-REGRESSION", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Dory state directory is unavailable or indirect", + "workroot already exists or is indirect", + "canonical workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate image must be digest-pinned", + "release candidate evidence requires --runtime", + "release candidate evidence requires --compose", + "release candidate evidence requires --buildx", + "standalone runtime is unavailable or indirect", + "standalone runtime member is missing or indirect", + "release candidate CLI is unavailable or indirect", + "engine restart test requires an isolated engine with zero pre-existing containers", + 'label=dev.dory.compatibility=$OWNER', + 'docker_e ps -aq --filter "label=dev.dory.compatibility=$OWNER"', + 'docker_e volume ls -q --filter "label=dev.dory.compatibility=$OWNER"', + 'docker_e network ls -q --filter "label=dev.dory.compatibility=$OWNER"', + "forwarded-connection-fds", + "concurrent-proxy-backpressure", + "compose-v2-lifecycle", + "network-api-lifecycle", + "standalone-engine-restart", + "volume-api-lifecycle", + "bind-open-fd-stability", + "image-hardlink-missing-parent", + "buildkit-concurrent-sessions", + "buildkit-cache-cancellation", + "cleanup-restart-persistence", + "docker_bin_sha256=", + "compose_bin_sha256=", + "buildx_bin_sha256=", + "results_sha256=", + "status=PASS", + "release_qualifying=", + "raise SystemExit", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'echo "socket=$SOCKET"', + 'echo "state_dir=$STATE_DIR"', + 'echo "runtime=$RUNTIME"', + 'echo "runtime_home=$RUNTIME_HOME"', + 'echo "docker_bin_resolved=$docker_bin_resolved"', + 'echo "compose_bin_resolved=$compose_bin_resolved"', + 'echo "buildx_bin_resolved=$buildx_bin_resolved"', + "docker_e system prune", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--state-dir", + str(pathlib.Path(temporary) / "missing-state"), + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ef4c53..0a1b42b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -363,6 +363,7 @@ jobs: python3 .github/scripts/test-ecr-registry-retry-gate.py python3 .github/scripts/test-private-registry-auth-gate.py python3 .github/scripts/test-offline-bundled-boot-gate.py + python3 .github/scripts/test-competitor-runtime-regression-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfde1431..938125a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,6 +64,7 @@ jobs: python3 .github/scripts/test-ecr-registry-retry-gate.py python3 .github/scripts/test-private-registry-auth-gate.py python3 .github/scripts/test-offline-bundled-boot-gate.py + python3 .github/scripts/test-competitor-runtime-regression-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/competitor-runtime-regression-gate.sh b/scripts/competitor-runtime-regression-gate.sh new file mode 100755 index 00000000..39a6bbbe --- /dev/null +++ b/scripts/competitor-runtime-regression-gate.sh @@ -0,0 +1,2079 @@ +#!/bin/bash +# Live, run-scoped regressions for competitor failures involving published ports, forwarded- +# connection descriptor leaks, concurrent-proxy head-of-line blocking, wedged container operations, +# missing-source docker cp, complete Compose v2 lifecycle semantics, restrictive bind creates, +# healthchecks, named BuildKit contexts, BuildKit cache round-trips and cancellation recovery, +# resolver search leakage, network-scoped aliases/restart IP continuity, named-volume copy, clean +# image archive streams, missing-parent hard links, default BuildKit ARG expansion, exact Docker +# ignore precedence, and named volumes. +set -euo pipefail + +SOCKET="${DORY_COMPAT_SOCKET:-$HOME/.dory/dory.sock}" +STATE_DIR="${DORY_COMPAT_STATE_DIR:-$(dirname "$SOCKET")}" +ALPINE_IMAGE="${DORY_COMPAT_ALPINE_IMAGE:-alpine:latest}" +WORKROOT="${DORY_COMPAT_WORKROOT:-$HOME/.dory-compatibility}" +CONNECTIONS="${DORY_COMPAT_CONNECTIONS:-2000}" +RESTARTS="${DORY_COMPAT_RESTARTS:-20}" +FD_GROWTH_BUDGET="${DORY_COMPAT_FD_GROWTH_BUDGET:-8}" +DOCKER_BIN="${DORY_COMPAT_DOCKER_BIN:-docker}" +COMPOSE_BIN="${DORY_COMPAT_COMPOSE_BIN:-}" +BUILDX_BIN="${DORY_COMPAT_BUILDX_BIN:-}" +RUNTIME="${DORY_COMPAT_RUNTIME:-}" +RUNTIME_HOME="${DORY_COMPAT_RUNTIME_HOME:-$(dirname "$STATE_DIR")}" +SOURCE_COMMIT="${DORY_COMPAT_SOURCE_COMMIT:-}" +CONFIRM="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --state-dir) need_value "$1" "$#"; STATE_DIR="$2"; shift 2 ;; + --image) need_value "$1" "$#"; ALPINE_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --connections) need_value "$1" "$#"; CONNECTIONS="$2"; shift 2 ;; + --restarts) need_value "$1" "$#"; RESTARTS="$2"; shift 2 ;; + --fd-growth) need_value "$1" "$#"; FD_GROWTH_BUDGET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER_BIN="$2"; shift 2 ;; + --compose) need_value "$1" "$#"; COMPOSE_BIN="$2"; shift 2 ;; + --buildx) need_value "$1" "$#"; BUILDX_BIN="$2"; shift 2 ;; + --runtime) need_value "$1" "$#"; RUNTIME="$2"; shift 2 ;; + --runtime-home) need_value "$1" "$#"; RUNTIME_HOME="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +positive_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a positive integer" ;; esac + [ "$2" -gt 0 ] || die "$1 must be a positive integer" +} +nonnegative_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a non-negative integer" ;; esac +} +[ "$CONFIRM" = ISOLATED-ENGINE-COMPETITOR-REGRESSION ] \ + || die "requires --confirm ISOLATED-ENGINE-COMPETITOR-REGRESSION" +positive_integer connections "$CONNECTIONS" +positive_integer restarts "$RESTARTS" +nonnegative_integer fd-growth "$FD_GROWTH_BUDGET" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi + +for command in cmp curl id lsof mkfifo ps python3 shasum stat tar; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +if [[ "$DOCKER_BIN" == */* ]]; then + [ -x "$DOCKER_BIN" ] || die "Docker CLI is not executable: $DOCKER_BIN" +else + command -v "$DOCKER_BIN" >/dev/null || die "Docker CLI is unavailable: $DOCKER_BIN" +fi +if [ -n "$COMPOSE_BIN" ]; then + if [[ "$COMPOSE_BIN" == */* ]]; then + [ -x "$COMPOSE_BIN" ] || die "Compose v2 helper is not executable: $COMPOSE_BIN" + else + command -v "$COMPOSE_BIN" >/dev/null || die "Compose v2 helper is unavailable: $COMPOSE_BIN" + fi +fi +if [ -n "$BUILDX_BIN" ]; then + if [[ "$BUILDX_BIN" == */* ]]; then + [ -x "$BUILDX_BIN" ] || die "Buildx helper is not executable: $BUILDX_BIN" + else + command -v "$BUILDX_BIN" >/dev/null || die "Buildx helper is unavailable: $BUILDX_BIN" + fi +fi +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$STATE_DIR" in /*) ;; *) die "Dory state directory must be absolute" ;; esac +[ -d "$STATE_DIR" ] && [ ! -L "$STATE_DIR" ] \ + || die "Dory state directory is unavailable or indirect: $STATE_DIR" +STATE_DIR="$(cd "$STATE_DIR" && pwd -P)" +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME"|"$STATE_DIR"|"$RUNTIME_HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +workroot_parent="$(dirname "$WORKROOT")" +[ -d "$workroot_parent" ] && [ ! -L "$workroot_parent" ] \ + || die "workroot parent is unavailable or indirect: $workroot_parent" +workroot_name="$(basename "$WORKROOT")" +case "$workroot_name" in ''|.|..) die "unsafe workroot leaf: $workroot_name" ;; esac +WORKROOT="$(cd "$workroot_parent" && pwd -P)/$workroot_name" +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "canonical workroot already exists or is indirect: $WORKROOT" + +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" + printf '%s\n' "$ALPINE_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "release candidate image must be digest-pinned" + [ -n "$RUNTIME" ] || die "release candidate evidence requires --runtime" + [ -n "$COMPOSE_BIN" ] || die "release candidate evidence requires --compose" + [ -n "$BUILDX_BIN" ] || die "release candidate evidence requires --buildx" +fi + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" "$@" +} +compose_e() { + if [ -n "$COMPOSE_BIN" ]; then + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u COMPOSE_FILE -u COMPOSE_PATH_SEPARATOR \ + DOCKER_HOST="unix://$SOCKET" COMPOSE_MENU=0 "$COMPOSE_BIN" "$@" + else + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u COMPOSE_FILE -u COMPOSE_PATH_SEPARATOR \ + DOCKER_HOST="unix://$SOCKET" COMPOSE_MENU=0 "$DOCKER_BIN" compose "$@" + fi +} +buildx_e() { + local -a command + if [ -n "$BUILDX_BIN" ]; then + command=("$BUILDX_BIN") + else + command=("$DOCKER_BIN" buildx) + fi + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u BUILDKIT_HOST -u BUILDKIT_COLORS -u BUILDX_BUILDER \ + -u BUILDX_CONFIG -u BUILDX_EXPERIMENTAL -u BUILDX_NO_DEFAULT_LOAD \ + DOCKER_HOST="unix://$SOCKET" BUILDKIT_PROGRESS=plain NO_COLOR=1 \ + "${command[@]}" "$@" +} +docker_e version >/dev/null || die "Docker API is not ready at $SOCKET" +docker_e image inspect "$ALPINE_IMAGE" >/dev/null 2>&1 \ + || die "required offline image is missing: $ALPINE_IMAGE" +compose_e version >/dev/null 2>&1 || die "Docker Compose v2 is unavailable" +buildx_e version >/dev/null 2>&1 || die "Docker Buildx is unavailable" +if [ -n "$RUNTIME" ]; then + case "$RUNTIME" in /*) ;; *) die "standalone runtime must be an absolute path" ;; esac + [ -f "$RUNTIME" ] && [ ! -L "$RUNTIME" ] && [ -s "$RUNTIME" ] && [ -x "$RUNTIME" ] \ + || die "standalone runtime is unavailable or indirect: $RUNTIME" + case "$RUNTIME_HOME" in /*) ;; *) die "standalone runtime HOME must be absolute" ;; esac + [ -d "$RUNTIME_HOME" ] && [ ! -L "$RUNTIME_HOME" ] \ + || die "standalone runtime HOME is unavailable or indirect: $RUNTIME_HOME" + runtime_home_real="$(cd "$RUNTIME_HOME" && pwd -P)" + RUNTIME_HOME="$runtime_home_real" + workroot_real="$WORKROOT" + # The standalone launcher exposes only its HOME to the Linux guest. A bind fixture outside that + # root is silently just a guest-rootfs path from dockerd's point of view and cannot prove host + # coherence, so reject the invalid qualification topology before creating Docker objects. + case "$workroot_real/" in + "$runtime_home_real/"*) ;; + *) die "standalone bind fixture workroot must be inside runtime HOME: $RUNTIME_HOME" ;; + esac + preexisting_containers="$(docker_e ps -aq)" + [ -z "$preexisting_containers" ] \ + || die "engine restart test requires an isolated engine with zero pre-existing containers" + runtime_dir="$(cd "$(dirname "$RUNTIME")" && pwd -P)" + for runtime_file in dory-engine bin/dory-hv bin/gvproxy bin/dory-dataplane-proxy \ + share/dory/dory-hv-kernel-arm64.lzfse \ + share/dory/dory-engine-rootfs.ext4.lzfse \ + share/dory/dory-agent-linux-arm64; do + [ -f "$runtime_dir/$runtime_file" ] && [ ! -L "$runtime_dir/$runtime_file" ] \ + && [ -s "$runtime_dir/$runtime_file" ] \ + || die "standalone runtime member is missing or indirect: $runtime_file" + done +fi + +mkdir "$WORKROOT" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +SAFE_RUN_ID="$(printf '%s' "$RUN_ID" | tr '[:upper:]' '[:lower:]')" +OWNER="dory-compat-$RUN_ID" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/results.tsv" +MANIFEST="$WORKDIR/manifest.txt" +SERVER_A="$OWNER-server-a" +SERVER_B="$OWNER-server-b" +BACKPRESSURE_CONTAINER="$OWNER-backpressure" +HEALTH_CONTAINER="$OWNER-health" +UNHEALTHY_CONTAINER="$OWNER-unhealthy" +NO_HEALTH_CONTAINER="$OWNER-no-health" +VOLUME="$OWNER-volume" +VOLUME_CP_CONTAINER="$OWNER-volume-cp" +VOLUME_METADATA="$OWNER-volume-metadata" +BUILD_TAG="dory-compatibility:$RUN_ID" +RELATIVE_BUILD_TAG="dory-relative-build:$RUN_ID" +DOCKERIGNORE_BUILD_TAG="dory-dockerignore-build:$RUN_ID" +LARGE_DOCKERFILE_BUILD_TAG="dory-large-dockerfile:$RUN_ID" +DEFAULT_ARG_BASE_REPOSITORY="dory-default-arg-base-$SAFE_RUN_ID" +DEFAULT_ARG_BUILD_TAG="dory-default-arg-build:$RUN_ID" +HARDLINK_IMPORT_TAG="dory-hardlink-import:$RUN_ID" +HARDLINK_IMPORT_CONTAINER="$OWNER-hardlink-import" +PARALLEL_BUILD_REPOSITORY="dory-parallel-build" +BUILDKIT_CACHE_TAG="dory-buildkit-cache:$RUN_ID" +BUILDKIT_RECOVERY_TAG="dory-buildkit-recovery:$RUN_ID" +BUILDKIT_CANCEL_TAG="dory-buildkit-cancel:$RUN_ID" +ROUTE_NETWORK="$OWNER-route" +CONFLICT_NETWORK="$OWNER-route-conflict" +ALIAS_NETWORK="$OWNER-alias" +NETWORK_METADATA="$OWNER-network-metadata" +NETWORK_METADATA_OCTET=$((($$ % 200) + 20)) +NETWORK_METADATA_SUBNET="198.18.${NETWORK_METADATA_OCTET}.0/24" +NETWORK_METADATA_RANGE="198.18.${NETWORK_METADATA_OCTET}.128/25" +NETWORK_METADATA_GATEWAY="198.18.${NETWORK_METADATA_OCTET}.1" +NETWORK_METADATA_RESERVED="198.18.${NETWORK_METADATA_OCTET}.2" +NETWORK_METADATA_STATIC_IP="198.18.${NETWORK_METADATA_OCTET}.129" +ALIAS_CONTAINER="$OWNER-alias-server" +PORT_COLLISION_CONTAINER="$OWNER-port-collision" +SIGNAL_CONTAINER="$OWNER-signal" +LIFECYCLE_CONTAINER="$OWNER-lifecycle" +ATTACH_CONTAINER="$OWNER-attach-wait" +EXIT_CODE_CONTAINER="$OWNER-exit-code" +MOUNT_OPTION_CONTAINER="$OWNER-mount-option" +READ_ONLY_MOUNT_CONTAINER="$OWNER-mount-read-only" +HOST_COLLISION_PID="" +COMPOSE_PROJECT="$(printf 'dorycompat%s' "$RUN_ID" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c 1-48)" +COMPOSE_EXTERNAL_NETWORK="$OWNER-compose-external" +mkdir -p "$WORKDIR" +GATE_COMPLETED=0 +printf 'test\tstatus\tdetail\n' > "$RESULTS" +{ + echo "run_id=$RUN_ID" + echo "owner=$OWNER" + echo "image=$ALPINE_IMAGE" + echo "connections=$CONNECTIONS" + echo "restarts=$RESTARTS" + echo "fd_growth_budget=$FD_GROWTH_BUDGET" + [ -z "$SOURCE_COMMIT" ] || echo "source_commit=$SOURCE_COMMIT" + echo "started_epoch=$(date +%s)" +} > "$MANIFEST" +docker_bin_resolved="$DOCKER_BIN" +case "$docker_bin_resolved" in + */*) ;; + *) docker_bin_resolved="$(command -v "$docker_bin_resolved")" ;; +esac +compose_bin_resolved="$COMPOSE_BIN" +if [ -n "$compose_bin_resolved" ]; then + case "$compose_bin_resolved" in + */*) ;; + *) compose_bin_resolved="$(command -v "$compose_bin_resolved")" ;; + esac +fi +buildx_bin_resolved="$BUILDX_BIN" +if [ -n "$buildx_bin_resolved" ]; then + case "$buildx_bin_resolved" in + */*) ;; + *) buildx_bin_resolved="$(command -v "$buildx_bin_resolved")" ;; + esac +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + for qualified_cli in "$docker_bin_resolved" "$compose_bin_resolved" "$buildx_bin_resolved"; do + case "$qualified_cli" in /*) ;; *) die "release candidate CLI must resolve to an absolute path" ;; esac + [ -f "$qualified_cli" ] && [ ! -L "$qualified_cli" ] && [ -s "$qualified_cli" ] \ + && [ -x "$qualified_cli" ] \ + || die "release candidate CLI is unavailable or indirect: $qualified_cli" + done +fi +{ + echo "docker_bin_sha256=$(shasum -a 256 "$docker_bin_resolved" | awk '{print $1}')" + if [ -n "$compose_bin_resolved" ]; then + echo "compose_bin_sha256=$(shasum -a 256 "$compose_bin_resolved" | awk '{print $1}')" + else + echo "compose_bin_sha256=embedded-docker-cli-plugin" + fi + if [ -n "$buildx_bin_resolved" ]; then + echo "buildx_bin_sha256=$(shasum -a 256 "$buildx_bin_resolved" | awk '{print $1}')" + else + echo "buildx_bin_sha256=embedded-docker-cli-plugin" + fi + if [ -n "$RUNTIME" ]; then + for runtime_file in \ + dory-engine \ + bin/dory-hv \ + bin/gvproxy \ + bin/dory-dataplane-proxy \ + share/dory/dory-hv-kernel-arm64.lzfse \ + share/dory/dory-engine-rootfs.ext4.lzfse \ + share/dory/dory-agent-linux-arm64; do + runtime_key="$(printf '%s' "$runtime_file" | tr '/.-' '___')_sha256" + echo "$runtime_key=$(shasum -a 256 "$runtime_dir/$runtime_file" | awk '{print $1}')" + done + fi +} >> "$MANIFEST" + +cleanup() { + if [ -n "${CANCEL_BUILDX_PID:-}" ]; then + kill -TERM "$CANCEL_BUILDX_PID" >/dev/null 2>&1 || true + sleep 0.2 + kill -KILL "$CANCEL_BUILDX_PID" >/dev/null 2>&1 || true + wait "$CANCEL_BUILDX_PID" 2>/dev/null || true + CANCEL_BUILDX_PID="" + fi + if [ -n "${buildkit_cache_dir:-}" ]; then + rm -rf "$buildkit_cache_dir" + fi + if [ -n "$HOST_COLLISION_PID" ]; then + kill "$HOST_COLLISION_PID" >/dev/null 2>&1 || true + wait "$HOST_COLLISION_PID" 2>/dev/null || true + HOST_COLLISION_PID="" + fi + if [ "$GATE_COMPLETED" -eq 0 ] && [ -n "$RUNTIME" ]; then + # A failed bounded API probe can leave /_ping alive while object endpoints are wedged. The + # zero-preexisting-container guard makes a bounded isolated restart the safest cleanup path. + bounded_capture 30 "$WORKDIR/failure-recovery-stop.out" "$WORKDIR/failure-recovery-stop.err" \ + env HOME="$RUNTIME_HOME" "$RUNTIME" stop || true + bounded_capture 60 "$WORKDIR/failure-recovery-start.out" "$WORKDIR/failure-recovery-start.err" \ + env HOME="$RUNTIME_HOME" "$RUNTIME" start || true + fi + # If the gate is interrupted between its explicit stop/start operations, recover the isolated + # engine before attempting object cleanup. The zero-preexisting-container guard above makes this + # recovery path safe for the only mode in which the gate is allowed to control the runtime. + if [ -n "$RUNTIME" ] && { [ ! -S "$SOCKET" ] \ + || ! curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null 2>&1; }; then + HOME="$RUNTIME_HOME" "$RUNTIME" start >/dev/null 2>&1 || true + fi + [ -S "$SOCKET" ] \ + && curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null 2>&1 \ + || return 0 + compose_e --project-directory "$WORKDIR" --env-file "$WORKDIR/.env" \ + -p "$COMPOSE_PROJECT" -f "$WORKDIR/compose.yaml" -f "$WORKDIR/compose.override.yaml" \ + --profile '*' down -v --remove-orphans \ + >/dev/null 2>&1 || true + local id + docker_e ps -aq --filter "label=dev.dory.compatibility=$OWNER" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f -v "$id" >/dev/null 2>&1 || true + done + docker_e volume rm -f "$VOLUME" "$VOLUME_METADATA" >/dev/null 2>&1 || true + docker_e volume ls -q --filter "label=dev.dory.compatibility=$OWNER" 2>/dev/null \ + | while IFS= read -r id; do + [ -n "$id" ] && docker_e volume rm -f "$id" >/dev/null 2>&1 || true + done + docker_e network rm "$CONFLICT_NETWORK" >/dev/null 2>&1 || true + docker_e network rm "$ROUTE_NETWORK" >/dev/null 2>&1 || true + docker_e network rm "$ALIAS_NETWORK" >/dev/null 2>&1 || true + docker_e network rm "$NETWORK_METADATA" >/dev/null 2>&1 || true + docker_e network ls -q --filter "label=dev.dory.compatibility=$OWNER" 2>/dev/null \ + | while IFS= read -r id; do + [ -n "$id" ] && docker_e network rm "$id" >/dev/null 2>&1 || true + done + docker_e image rm -f "$BUILD_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$RELATIVE_BUILD_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$DOCKERIGNORE_BUILD_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$LARGE_DOCKERFILE_BUILD_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$DEFAULT_ARG_BUILD_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$DEFAULT_ARG_BASE_REPOSITORY:latest" >/dev/null 2>&1 || true + docker_e image rm -f "$HARDLINK_IMPORT_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$BUILDKIT_CACHE_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$BUILDKIT_RECOVERY_TAG" >/dev/null 2>&1 || true + docker_e image rm -f "$BUILDKIT_CANCEL_TAG" >/dev/null 2>&1 || true + local parallel_index + for parallel_index in 1 2 3 4; do + docker_e image rm -f "$PARALLEL_BUILD_REPOSITORY:$RUN_ID-$parallel_index" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +pass() { printf '%s\tPASS\t%s\n' "$1" "$2" >> "$RESULTS"; } + +free_port() { + python3 - <<'PY' +import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +s.close() +PY +} + +wait_http() { + local port="$1" expected="$2" attempts=100 body + while [ "$attempts" -gt 0 ]; do + body="$(curl -fsS --max-time 1 "http://127.0.0.1:$port/" 2>/dev/null || true)" + [ "$body" = "$expected" ] && return 0 + attempts=$((attempts - 1)) + sleep 0.1 + done + return 1 +} + +bounded_capture() { + local limit="$1" stdout="$2" stderr="$3" pid started rc + shift 3 + "$@" > "$stdout" 2> "$stderr" & + pid=$! + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + return "$rc" +} + +candidate_pids() { + ps axww -o pid=,command= | awk -v state="$STATE_DIR" -v socket="$SOCKET" ' + (index($0, state) || index($0, socket)) && + ($0 ~ /\/dory-hv / || $0 ~ /\/gvproxy / || $0 ~ /\/dory-dataplane-proxy /) { print $1 } + ' +} + +sample_fds() { + local output="$1" pid count=0 total=0 + : > "$output" + for pid in $(candidate_pids); do + kill -0 "$pid" 2>/dev/null || continue + count="$(lsof -n -P -p "$pid" 2>/dev/null | awk 'NR > 1 {n++} END {print n+0}')" + printf '%s\t%s\n' "$pid" "$count" >> "$output" + total=$((total + count)) + done + [ -s "$output" ] || die "no exact Dory engine processes matched $STATE_DIR" + echo "$total" +} + +# Alpine's BusyBox build does not guarantee the optional httpd applet. Its nc applet is part of the +# pinned runtime surface. Its `-lk -e` mode keeps the listening socket open while it starts one +# response helper per connection, avoiding a fixture-side relisten gap during the 2,000-connection +# burst that is meant to measure Dory rather than BusyBox scheduling. +server_command='export DORY_HTTP_BODY="$1"; printf "%s\n" "#!/bin/sh" "awk '\''length() <= 1 { exit }'\'' >/dev/null" "length=\$((\${#DORY_HTTP_BODY} + 1))" "printf \"HTTP/1.1 200 OK\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n%s\\n\" \"\$length\" \"\$DORY_HTTP_BODY\"" > /tmp/dory-http; chmod 755 /tmp/dory-http; exec nc -lk -p 8080 -e /tmp/dory-http' +port="$(free_port)" +docker_e run -d --name "$SERVER_A" --label "dev.dory.compatibility=$OWNER" \ + -p "127.0.0.1:$port:8080" "$ALPINE_IMAGE" sh -c "$server_command" sh server-a >/dev/null +wait_http "$port" server-a || die "initial published port did not become reachable" +docker_e create --name "$SERVER_B" --label "dev.dory.compatibility=$OWNER" \ + -p "127.0.0.1:$port:8080" "$ALPINE_IMAGE" sh -c "$server_command" sh server-b >/dev/null +docker_e stop -t 2 "$SERVER_A" >/dev/null +docker_e start "$SERVER_B" >/dev/null +wait_http "$port" server-b || die "same-port handoff A -> B lost publishing" +docker_e stop -t 2 "$SERVER_B" >/dev/null +docker_e start "$SERVER_A" >/dev/null +wait_http "$port" server-a || die "same-port handoff B -> A lost publishing" +docker_e restart -t 2 "$SERVER_A" >/dev/null +wait_http "$port" server-a || die "published port disappeared after container restart" +pass published-port-handoff "port=$port A-B-A and restart remained reachable" + +# OrbStack #2509 loops forever when a macOS service (notably AirPlay on port 5000) already owns a +# requested host port. Guest dockerd cannot see macOS listeners, so Dory's dataplane must reject the +# container start promptly, leave the container stopped, keep unrelated API calls live, and permit +# the same start after the real host owner releases the port. +collision_port_file="$WORKDIR/host-port-collision.port" +python3 - "$collision_port_file" <<'PY' & +import pathlib +import socket +import sys + +listener = socket.socket() +listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +listener.bind(("127.0.0.1", 0)) +listener.listen(8) +pathlib.Path(sys.argv[1]).write_text(str(listener.getsockname()[1]), encoding="utf-8") +while True: + connection, _ = listener.accept() + connection.close() +PY +HOST_COLLISION_PID=$! +for _ in $(seq 1 100); do [ -s "$collision_port_file" ] && break; sleep 0.05; done +[ -s "$collision_port_file" ] || die "host port-collision fixture did not start" +collision_port="$(cat "$collision_port_file")" +docker_e create --name "$PORT_COLLISION_CONTAINER" \ + --label "dev.dory.compatibility=$OWNER" -p "127.0.0.1:$collision_port:8080" \ + "$ALPINE_IMAGE" sh -c "$server_command" sh collision-container >/dev/null +set +e +bounded_capture 8 "$WORKDIR/host-port-collision-start.out" \ + "$WORKDIR/host-port-collision-start.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" start "$PORT_COLLISION_CONTAINER" +collision_rc=$? +set -e +[ "$collision_rc" -ne 0 ] || die "container start silently accepted an occupied macOS host port" +[ "$collision_rc" -ne 124 ] || die "container start wedged on an occupied macOS host port" +[ "$(docker_e inspect -f '{{.State.Running}}' "$PORT_COLLISION_CONTAINER")" = false ] \ + || die "container remained running after host-port collision rejection" +bounded_capture 5 "$WORKDIR/post-host-port-collision-version.out" \ + "$WORKDIR/post-host-port-collision-version.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" version \ + || die "Docker API wedged after host-port collision rejection" +kill "$HOST_COLLISION_PID" +wait "$HOST_COLLISION_PID" 2>/dev/null || true +HOST_COLLISION_PID="" +docker_e start "$PORT_COLLISION_CONTAINER" >/dev/null +wait_http "$collision_port" collision-container \ + || die "host port did not recover after its external owner released it" +docker_e rm -f "$PORT_COLLISION_CONTAINER" >/dev/null +pass host-port-collision \ + "occupied port=$collision_port failed promptly rc=$collision_rc; API live; same start recovered" + +# Apple container #1941 serializes a Darwin signal integer while its server expects a name. Keep +# Dory's Docker contract name-based across the host/Linux boundary and prove a detached exec +# process can independently receive the same Linux signal without stopping or wedging the init. +docker_e run -d --name "$SIGNAL_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + "$ALPINE_IMAGE" sh -c \ + 'trap "printf container-usr1 > /container-signal" USR1; printf ready > /signal-ready; while :; do sleep 1; done' \ + >/dev/null +for _ in $(seq 1 50); do + docker_e exec "$SIGNAL_CONTAINER" test -f /signal-ready >/dev/null 2>&1 && break + sleep 0.1 +done +docker_e exec "$SIGNAL_CONTAINER" test -f /signal-ready >/dev/null 2>&1 \ + || die "named-signal container did not become ready" +docker_e kill --signal USR1 "$SIGNAL_CONTAINER" > "$WORKDIR/container-signal.out" +for _ in $(seq 1 50); do + [ "$(docker_e exec "$SIGNAL_CONTAINER" cat /container-signal 2>/dev/null || true)" = container-usr1 ] \ + && break + sleep 0.1 +done +[ "$(docker_e exec "$SIGNAL_CONTAINER" cat /container-signal 2>/dev/null || true)" = container-usr1 ] \ + || die "named USR1 did not reach container init" +[ "$(docker_e inspect -f '{{.State.Running}}' "$SIGNAL_CONTAINER")" = true ] \ + || die "nonterminating USR1 unexpectedly stopped container init" +docker_e exec -d "$SIGNAL_CONTAINER" sh -c \ + 'trap "printf exec-usr1 > /exec-signal" USR1; echo $$ > /exec-pid; while :; do sleep 1; done' +for _ in $(seq 1 50); do + docker_e exec "$SIGNAL_CONTAINER" test -s /exec-pid >/dev/null 2>&1 && break + sleep 0.1 +done +docker_e exec "$SIGNAL_CONTAINER" sh -ec 'test -s /exec-pid; kill -USR1 "$(cat /exec-pid)"' +for _ in $(seq 1 50); do + [ "$(docker_e exec "$SIGNAL_CONTAINER" cat /exec-signal 2>/dev/null || true)" = exec-usr1 ] \ + && break + sleep 0.1 +done +[ "$(docker_e exec "$SIGNAL_CONTAINER" cat /exec-signal 2>/dev/null || true)" = exec-usr1 ] \ + || die "named USR1 did not reach detached exec process" +bounded_capture 5 "$WORKDIR/post-signal-version.out" "$WORKDIR/post-signal-version.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" version \ + || die "Docker API wedged after named signal delivery" +docker_e rm -f "$SIGNAL_CONTAINER" >/dev/null +pass named-signal-delivery \ + "named USR1 reached container init and detached exec process; init and Docker API remained live" + +bounded_capture 10 "$WORKDIR/lifecycle-create.out" "$WORKDIR/lifecycle-create.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" create \ + --name "$LIFECYCLE_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + "$ALPINE_IMAGE" sh -c 'printf "lifecycle-started\n"; while :; do sleep 1; done' \ + || die "container lifecycle create failed or exceeded 10 seconds" +bounded_capture 10 "$WORKDIR/lifecycle-start.out" "$WORKDIR/lifecycle-start.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" start "$LIFECYCLE_CONTAINER" \ + || die "container lifecycle start failed or exceeded 10 seconds" +bounded_capture 10 "$WORKDIR/lifecycle-pause.out" "$WORKDIR/lifecycle-pause.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" pause "$LIFECYCLE_CONTAINER" \ + || die "container pause failed or exceeded 10 seconds" +[ "$(docker_e inspect -f '{{.State.Status}}' "$LIFECYCLE_CONTAINER")" = paused ] \ + || die "container did not enter paused state" +bounded_capture 10 "$WORKDIR/lifecycle-unpause.out" "$WORKDIR/lifecycle-unpause.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" unpause "$LIFECYCLE_CONTAINER" \ + || die "container unpause failed or exceeded 10 seconds" +[ "$(docker_e inspect -f '{{.State.Status}}' "$LIFECYCLE_CONTAINER")" = running ] \ + || die "container did not return to running state" +python3 - "$SOCKET" "$DOCKER_BIN" "$LIFECYCLE_CONTAINER" \ + "$WORKDIR/lifecycle-exec.out" "$WORKDIR/lifecycle-exec.err" <<'PY' +import os +import pathlib +import subprocess +import sys + +socket_path, docker, container, stdout_path, stderr_path = sys.argv[1:] +environment = os.environ.copy() +environment["DOCKER_HOST"] = "unix://" + socket_path +completed = subprocess.run( + [docker, "exec", "-i", container, "sh", "-c", 'read line; printf "exec:%s\\n" "$line"'], + input=b"stdin-marker\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + env=environment, + check=False, +) +pathlib.Path(stdout_path).write_bytes(completed.stdout) +pathlib.Path(stderr_path).write_bytes(completed.stderr) +if completed.returncode != 0 or completed.stdout != b"exec:stdin-marker\n": + raise SystemExit("interactive exec did not preserve stdin EOF and exact output") +PY +bounded_capture 10 "$WORKDIR/lifecycle-logs.out" "$WORKDIR/lifecycle-logs.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" logs "$LIFECYCLE_CONTAINER" \ + || die "container logs failed or exceeded 10 seconds" +grep -qx 'lifecycle-started' "$WORKDIR/lifecycle-logs.out" \ + || die "container logs lost exact stdout" +bounded_capture 10 "$WORKDIR/lifecycle-stats.out" "$WORKDIR/lifecycle-stats.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" stats --no-stream \ + --format '{{.Name}}' "$LIFECYCLE_CONTAINER" \ + || die "container stats failed or exceeded 10 seconds" +grep -qx "$LIFECYCLE_CONTAINER" "$WORKDIR/lifecycle-stats.out" \ + || die "container stats returned the wrong object" +bounded_capture 10 "$WORKDIR/lifecycle-restart.out" "$WORKDIR/lifecycle-restart.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" restart -t 2 "$LIFECYCLE_CONTAINER" \ + || die "container restart failed or exceeded 10 seconds" +bounded_capture 10 "$WORKDIR/lifecycle-stop.out" "$WORKDIR/lifecycle-stop.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" stop -t 2 "$LIFECYCLE_CONTAINER" \ + || die "container stop failed or exceeded 10 seconds" +[ "$(docker_e inspect -f '{{.State.Running}}' "$LIFECYCLE_CONTAINER")" = false ] \ + || die "container remained running after stop" +docker_e start "$LIFECYCLE_CONTAINER" >/dev/null +bounded_capture 10 "$WORKDIR/lifecycle-kill.out" "$WORKDIR/lifecycle-kill.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" kill --signal KILL "$LIFECYCLE_CONTAINER" \ + || die "container kill failed or exceeded 10 seconds" +[ "$(docker_e inspect -f '{{.State.Running}}' "$LIFECYCLE_CONTAINER")" = false ] \ + || die "container remained running after SIGKILL" + +docker_e create --name "$ATTACH_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + "$ALPINE_IMAGE" sh -c 'printf "attach-output\n"' >/dev/null +bounded_capture 15 "$WORKDIR/lifecycle-wait.out" "$WORKDIR/lifecycle-wait.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" wait "$ATTACH_CONTAINER" & +wait_pid=$! +sleep 0.2 +bounded_capture 10 "$WORKDIR/lifecycle-attach.out" "$WORKDIR/lifecycle-attach.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" start --attach "$ATTACH_CONTAINER" \ + || die "container attach failed or exceeded 10 seconds" +wait "$wait_pid" || die "container wait failed or exceeded 15 seconds" +grep -qx 'attach-output' "$WORKDIR/lifecycle-attach.out" \ + || die "container attach lost exact stdout" +grep -qx '0' "$WORKDIR/lifecycle-wait.out" \ + || die "container wait returned the wrong exit status" +docker_e create --name "$EXIT_CODE_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + "$ALPINE_IMAGE" sh -c 'exit 37' >/dev/null +docker_e start "$EXIT_CODE_CONTAINER" >/dev/null +bounded_capture 10 "$WORKDIR/lifecycle-exit-code-wait.out" \ + "$WORKDIR/lifecycle-exit-code-wait.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" wait "$EXIT_CODE_CONTAINER" \ + || die "nonzero container wait failed or exceeded 10 seconds" +grep -qx '37' "$WORKDIR/lifecycle-exit-code-wait.out" \ + || die "container wait did not surface the nonzero exit code" +[ "$(docker_e inspect -f '{{.State.Status}}:{{.State.ExitCode}}' "$EXIT_CODE_CONTAINER")" = \ + 'exited:37' ] \ + || die "container inspect did not preserve the nonzero exit code" +docker_e ps -aq --no-trunc --filter 'exited=37' --filter "label=dev.dory.compatibility=$OWNER" \ + | grep -Fxq "$(docker_e inspect -f '{{.Id}}' "$EXIT_CODE_CONTAINER")" \ + || die "container list could not select the recorded nonzero exit code" +bounded_capture 10 "$WORKDIR/lifecycle-remove.out" "$WORKDIR/lifecycle-remove.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" rm \ + "$LIFECYCLE_CONTAINER" "$ATTACH_CONTAINER" "$EXIT_CODE_CONTAINER" \ + || die "container remove failed or exceeded 10 seconds" +docker_e inspect "$LIFECYCLE_CONTAINER" >/dev/null 2>&1 \ + && die "removed lifecycle container is still inspectable" +docker_e inspect "$ATTACH_CONTAINER" >/dev/null 2>&1 \ + && die "removed attach/wait container is still inspectable" +docker_e inspect "$EXIT_CODE_CONTAINER" >/dev/null 2>&1 \ + && die "removed exit-code container is still inspectable" +pass container-api-lifecycle \ + "create/start/pause/unpause/exec/logs/stats/restart/stop/kill/attach/wait/nonzero-exit/remove were exact and deadline bounded" + +before_fd="$(sample_fds "$WORKDIR/fds-before.tsv")" +python3 - "$port" "$CONNECTIONS" <<'PY' +import socket, sys +port, count = map(int, sys.argv[1:]) +for index in range(count): + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"GET / HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n") + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + if b"server-a" not in b"".join(chunks): + raise SystemExit(f"connection {index + 1} returned the wrong response") +PY +sleep 2 +after_fd="$(sample_fds "$WORKDIR/fds-after.tsv")" +fd_growth=$((after_fd - before_fd)) +[ "$fd_growth" -le "$FD_GROWTH_BUDGET" ] \ + || die "forwarded connections grew aggregate Dory FDs by $fd_growth (budget $FD_GROWTH_BUDGET)" +pass forwarded-connection-fds "connections=$CONNECTIONS before=$before_fd after=$after_fd growth=$fd_growth" + +# Apple containerization #712 reproduced a permanent, process-wide proxy wedge when one of five +# concurrent relays filled its destination buffer. A sequential connection loop cannot expose that +# class. Retain enough stopped-container logs to fill several front-side Unix socket buffers, leave +# six log responses unread, then require unrelated requests to complete concurrently and promptly. +bounded_capture 60 "$WORKDIR/backpressure-fixture.out" "$WORKDIR/backpressure-fixture.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" run \ + -d --name "$BACKPRESSURE_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + "$ALPINE_IMAGE" sh -c 'head -c 8388608 /dev/zero | tr "\000" x' \ + || die "backpressure log fixture failed or exceeded 60 seconds" +bounded_capture 30 "$WORKDIR/backpressure-wait.out" "$WORKDIR/backpressure-wait.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" wait "$BACKPRESSURE_CONTAINER" \ + || die "backpressure log fixture did not finish within 30 seconds" +python3 - "$SOCKET" "$BACKPRESSURE_CONTAINER" <<'PY' +import concurrent.futures +import socket +import sys +import time + +socket_path, container = sys.argv[1:] + +def connect(): + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(2) + client.connect(socket_path) + return client + +stalled = [] +try: + request = ( + f"GET /containers/{container}/logs?stdout=1&stderr=1&tail=all HTTP/1.1\r\n" + "Host: docker\r\nConnection: close\r\n\r\n" + ).encode() + for _ in range(6): + client = connect() + client.sendall(request) + stalled.append(client) + + # Let every response exceed the host receive buffer while the clients deliberately do not + # consume it. Isolation is proven only if new control requests still finish during that stall. + time.sleep(1) + + def ping(index): + client = connect() + try: + client.sendall(b"GET /_ping HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n") + response = bytearray() + while b"\r\n\r\n" not in response or not response.endswith(b"OK"): + chunk = client.recv(4096) + if not chunk: + break + response.extend(chunk) + if len(response) > 65536: + raise RuntimeError(f"ping {index} returned an oversized response") + if b" 200 " not in response.split(b"\r\n", 1)[0] or not response.endswith(b"OK"): + raise RuntimeError(f"ping {index} returned an invalid response") + return index + finally: + client.close() + + started = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool: + completed = list(pool.map(ping, range(12))) + elapsed = time.monotonic() - started + if completed != list(range(12)): + raise SystemExit("concurrent proxy probes did not all complete") + if elapsed >= 5: + raise SystemExit(f"concurrent proxy probes took {elapsed:.3f}s") + print(f"stalled=6 probes=12 elapsed={elapsed:.3f}s") +finally: + for client in stalled: + client.close() +PY +docker_e inspect "$SERVER_A" >/dev/null +bounded_capture 10 "$WORKDIR/backpressure-remove.out" "$WORKDIR/backpressure-remove.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" rm "$BACKPRESSURE_CONTAINER" \ + || die "backpressure fixture cleanup failed or exceeded 10 seconds" +sleep 2 +after_backpressure_fd="$(sample_fds "$WORKDIR/fds-after-backpressure.tsv")" +backpressure_fd_growth=$((after_backpressure_fd - before_fd)) +[ "$backpressure_fd_growth" -le "$FD_GROWTH_BUDGET" ] \ + || die "concurrent stalled streams grew aggregate Dory FDs by $backpressure_fd_growth (budget $FD_GROWTH_BUDGET)" +pass concurrent-proxy-backpressure \ + "six unread 8MiB log streams did not block twelve concurrent control requests; fd-growth=$backpressure_fd_growth" + +set +e +bounded_capture 5 "$WORKDIR/missing-cp.out" "$WORKDIR/missing-cp.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" cp \ + "$SERVER_A:/definitely-missing-$RUN_ID" "$WORKDIR/missing-copy" +cp_rc=$? +set -e +[ "$cp_rc" -ne 0 ] || die "docker cp unexpectedly succeeded for a missing source" +[ "$cp_rc" -ne 124 ] || die "docker cp hung for a missing source" +bounded_capture 5 "$WORKDIR/post-missing-cp-exec.out" "$WORKDIR/post-missing-cp-exec.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" exec "$SERVER_A" true \ + || die "exec failed or wedged after missing-source docker cp" +bounded_capture 5 "$WORKDIR/post-missing-cp-inspect.out" "$WORKDIR/post-missing-cp-inspect.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" inspect "$SERVER_A" \ + || die "inspect failed or wedged after missing-source docker cp" +bounded_capture 5 "$WORKDIR/post-missing-cp-stats.out" "$WORKDIR/post-missing-cp-stats.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" stats --no-stream "$SERVER_A" \ + || die "stats failed or wedged after missing-source docker cp" +pass missing-source-cp "failed promptly with exit=$cp_rc; exec/inspect/stats remained responsive" + +i=1 +while [ "$i" -le "$RESTARTS" ]; do + bounded_capture 10 "$WORKDIR/restart-$i.out" "$WORKDIR/restart-$i.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" restart -t 1 "$SERVER_A" \ + || die "restart cycle $i failed or exceeded 10 seconds" + wait_http "$port" server-a || die "published port failed after restart cycle $i" + bounded_capture 5 "$WORKDIR/inspect-$i.out" "$WORKDIR/inspect-$i.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" inspect "$SERVER_A" \ + || die "inspect wedged after restart cycle $i" + bounded_capture 5 "$WORKDIR/logs-$i.out" "$WORKDIR/logs-$i.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" logs "$SERVER_A" \ + || die "logs wedged after restart cycle $i" + bounded_capture 5 "$WORKDIR/stats-$i.out" "$WORKDIR/stats-$i.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" stats --no-stream "$SERVER_A" \ + || die "stats wedged after restart cycle $i" + i=$((i + 1)) +done +docker_e rm -f "$SERVER_A" >/dev/null +docker_e run -d --name "$SERVER_A" --label "dev.dory.compatibility=$OWNER" \ + -p "127.0.0.1:$port:8080" "$ALPINE_IMAGE" sh -c "$server_command" sh server-a >/dev/null +wait_http "$port" server-a || die "container name/port could not be reused after churn cleanup" +pass restart-churn "restarts=$RESTARTS; restart/inspect/logs/stats bounded; name and port reusable" + +compose_port="$(free_port)" +mkdir -p "$WORKDIR/compose-bind" +printf 'from-bind-%s\n' "$RUN_ID" > "$WORKDIR/compose-bind/message" +docker_e network create --label "dev.dory.compatibility=$OWNER" \ + "$COMPOSE_EXTERNAL_NETWORK" >/dev/null +cat > "$WORKDIR/.env" < "$WORKDIR/compose.yaml" < /state/token"] + volumes: + - state:/state + networks: [app] + + database: + image: $ALPINE_IMAGE + labels: + dev.dory.compatibility: "$OWNER" + command: ["sh", "-ec", "trap 'exit 0' TERM INT; while :; do sleep 1; done"] + healthcheck: + test: ["CMD-SHELL", "test -s /state/token"] + interval: 250ms + timeout: 1s + retries: 40 + volumes: + - state:/state + networks: + app: + aliases: [database-alias] + + api: + image: $ALPINE_IMAGE + labels: + dev.dory.compatibility: "$OWNER" + dev.dory.compose-contract: base + depends_on: + initializer: + condition: service_completed_successfully + database: + condition: service_healthy + environment: + DORY_COMPOSE_TOKEN: \${DORY_COMPOSE_TOKEN:?DORY_COMPOSE_TOKEN is required} + MERGED_VALUE: base + command: + - sh + - -ec + - | + test "\$\${MERGED_VALUE}" = override + test "\$\$(cat /state/token)" = "\$\${DORY_COMPOSE_TOKEN}" + test "\$\$(cat /input/message)" = "from-bind-$RUN_ID" + ping -c 1 -W 2 database-alias >/dev/null + printf '%s\\n' '#!/bin/sh' "awk 'length() <= 1 { exit }' >/dev/null" 'printf "HTTP/1.1 200 OK\\r\\nContent-Length: 8\\r\\nConnection: close\\r\\n\\r\\ncompose\\n"' > /tmp/dory-http + chmod 755 /tmp/dory-http + echo compose-ready + exec nc -lk -p 8080 -e /tmp/dory-http + ports: + - "127.0.0.1:\${COMPOSE_PORT:?COMPOSE_PORT is required}:8080" + volumes: + - type: bind + source: ./compose-bind + target: /input + read_only: true + - state:/state + networks: [app, external] + cap_add: [CHOWN] + restart: unless-stopped + logging: + driver: json-file + options: + max-size: 1m + max-file: "2" + + debug: + image: $ALPINE_IMAGE + profiles: [debug] + labels: + dev.dory.compatibility: "$OWNER" + command: ["sh", "-ec", "trap 'exit 0' TERM INT; while :; do sleep 1; done"] + networks: [app] + +volumes: + state: + labels: + dev.dory.compatibility: "$OWNER" + +networks: + app: + labels: + dev.dory.compatibility: "$OWNER" + external: + name: $COMPOSE_EXTERNAL_NETWORK + external: true +EOF +cat > "$WORKDIR/compose.override.yaml" < "$WORKDIR/compose-config.json" 2> "$WORKDIR/compose-config.err" +unset DOCKER_CONTEXT COMPOSE_FILE COMPOSE_PATH_SEPARATOR +python3 - "$WORKDIR/compose-config.json" "$COMPOSE_PROJECT" "$RUN_ID" <<'PY' +import json +import sys + +path, project, run_id = sys.argv[1:] +with open(path, encoding="utf-8") as handle: + model = json.load(handle) +def require(condition, detail): + if not condition: + raise SystemExit(detail) + +require(model["name"] == project, f"unexpected Compose project: {model['name']!r}") +require(set(model["services"]) == {"initializer", "database", "api", "debug"}, + f"unexpected Compose services: {sorted(model['services'])!r}") +api = model["services"]["api"] +require(api["environment"]["MERGED_VALUE"] == "override", "Compose override merge failed") +require(api["environment"]["DORY_COMPOSE_TOKEN"] == f"compose-token-{run_id}", + "Compose env-file interpolation failed") +require(api["labels"]["dev.dory.compose-contract"] == "merged", "Compose labels did not merge") +require(api.get("cap_add", []) == [], "Compose !reset did not clear cap_add") +require(set(model["volumes"]) == {"state"}, f"unexpected Compose volumes: {model['volumes']!r}") +require(set(model["networks"]) == {"app", "external"}, + f"unexpected Compose networks: {model['networks']!r}") +PY + +compose_e "${compose_args[@]}" up --detach --remove-orphans --yes \ + > "$WORKDIR/compose-up.out" 2> "$WORKDIR/compose-up.err" +wait_http "$compose_port" compose || die "Compose published port did not become reachable" +debug_id="$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=debug')" +[ -z "$debug_id" ] || die "default Compose up activated a profile-only service" +initializer_id="$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=initializer')" +database_id="$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=database')" +api_id="$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=api')" +[ -n "$initializer_id" ] && [ -n "$database_id" ] && [ -n "$api_id" ] \ + || die "Compose did not create every default-profile service" +[ "$(docker_e inspect -f '{{.State.Status}}:{{.State.ExitCode}}' "$initializer_id")" = 'exited:0' ] \ + || die "Compose completion dependency did not exit successfully" +[ "$(docker_e inspect -f '{{.State.Health.Status}}' "$database_id")" = healthy ] \ + || die "Compose health dependency was not healthy when the API became reachable" +[ "$(docker_e inspect -f '{{ index .Config.Labels "dev.dory.compose-contract" }}' "$api_id")" = merged ] \ + || die "Compose override labels were not merged into the service" + +expected_config_files="$WORKDIR/compose.yaml,$WORKDIR/compose.override.yaml" +[ "$(docker_e inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$api_id")" = "$WORKDIR" ] \ + || die "Compose working-directory metadata is missing or not absolute" +[ "$(docker_e inspect -f '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' "$api_id")" = "$expected_config_files" ] \ + || die "Compose config-file metadata is missing, unordered, or not absolute" +[ "$(docker_e inspect -f '{{ index .Config.Labels "com.docker.compose.project.environment_file" }}' "$api_id")" = "$WORKDIR/.env" ] \ + || die "Compose environment-file metadata is missing or not absolute" + +compose_e "${compose_args[@]}" --profile debug up --detach --remove-orphans --yes \ + > "$WORKDIR/compose-profile-up.out" 2> "$WORKDIR/compose-profile-up.err" +debug_id="$(docker_e ps -q --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=debug')" +[ -n "$debug_id" ] || die "explicit Compose profile did not start its service" + +cat > "$WORKDIR/compose.retired.yaml" < "$WORKDIR/compose-retired-up.out" 2> "$WORKDIR/compose-retired-up.err" +retired_id="$(docker_e ps -q --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=retired')" +[ -n "$retired_id" ] || die "Compose did not create the retired-service orphan fixture" +compose_e "${compose_args[@]}" --profile debug up --detach --remove-orphans --yes \ + > "$WORKDIR/compose-orphan-up.out" 2> "$WORKDIR/compose-orphan-up.err" +! docker_e inspect "$retired_id" >/dev/null 2>&1 \ + || die "Compose --remove-orphans retained an exact-project retired service" +api_id="$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.service=api')" +[ -n "$api_id" ] || die "Compose orphan reconciliation lost the API service" + +compose_e "${compose_args[@]}" --profile '*' stop -- api \ + > "$WORKDIR/compose-stop.out" 2> "$WORKDIR/compose-stop.err" +[ "$(docker_e inspect -f '{{.State.Running}}' "$api_id")" = false ] \ + || die "Compose stop did not stop the selected service" +compose_e "${compose_args[@]}" --profile '*' start -- api \ + > "$WORKDIR/compose-start.out" 2> "$WORKDIR/compose-start.err" +wait_http "$compose_port" compose || die "Compose published port disappeared after stop/start" +compose_e "${compose_args[@]}" --profile '*' restart -- api \ + > "$WORKDIR/compose-restart.out" 2> "$WORKDIR/compose-restart.err" +wait_http "$compose_port" compose || die "Compose published port disappeared after restart" +compose_e "${compose_args[@]}" --profile '*' logs --no-color api \ + > "$WORKDIR/compose-logs.out" 2> "$WORKDIR/compose-logs.err" +grep -q 'compose-ready' "$WORKDIR/compose-logs.out" \ + || die "Compose logs did not return the selected service output" + +compose_volume="$(docker_e volume ls -q \ + --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.volume=state')" +compose_network="$(docker_e network ls -q \ + --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter 'label=com.docker.compose.network=app')" +[ -n "$compose_volume" ] && [ -n "$compose_network" ] \ + || die "Compose did not create its named volume and custom network" +compose_e "${compose_args[@]}" --profile '*' down --remove-orphans \ + > "$WORKDIR/compose-down.out" 2> "$WORKDIR/compose-down.err" +[ -z "$(docker_e ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT")" ] \ + || die "Compose down left project containers" +! docker_e network inspect "$compose_network" >/dev/null 2>&1 \ + || die "Compose down left its non-external project network" +docker_e network inspect "$COMPOSE_EXTERNAL_NETWORK" >/dev/null \ + || die "Compose down deleted an external network" +docker_e volume inspect "$compose_volume" >/dev/null \ + || die "Compose down deleted named user data without an explicit volume request" +[ "$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$compose_volume:/state:ro" "$ALPINE_IMAGE" cat /state/token)" = \ + "compose-token-$RUN_ID" ] \ + || die "Compose named data did not survive down" +docker_e volume rm "$compose_volume" >/dev/null +docker_e network rm "$COMPOSE_EXTERNAL_NETWORK" >/dev/null +pass compose-port-restart "port=$compose_port reachable after restart and stop/start" +pass compose-v2-lifecycle \ + "multi-file/.env/!reset merge, dependencies, profile, bind/volume/network, external preservation, labels, orphan cleanup, logs, lifecycle, and data-safe down passed" + +docker_e network create --label "dev.dory.compatibility=$OWNER" "$ROUTE_NETWORK" >/dev/null +docker_e network connect "$ROUTE_NETWORK" "$SERVER_A" +route_subnet="$(docker_e network inspect -f '{{(index .IPAM.Config 0).Subnet}}' "$ROUTE_NETWORK")" +[ -n "$route_subnet" ] || die "owned route-conflict fixture has no subnet" +set +e +bounded_capture 5 "$WORKDIR/network-conflict.out" "$WORKDIR/network-conflict.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" network create \ + --label "dev.dory.compatibility=$OWNER" --subnet "$route_subnet" "$CONFLICT_NETWORK" +network_rc=$? +set -e +[ "$network_rc" -ne 0 ] || die "Docker accepted an overlapping network subnet" +[ "$network_rc" -ne 124 ] || die "overlapping network creation wedged" +wait_http "$port" server-a || die "a failed network reconcile broke an unrelated published port" +bounded_capture 5 "$WORKDIR/network-post-conflict-inspect.out" "$WORKDIR/network-post-conflict-inspect.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" inspect "$SERVER_A" \ + || die "container inspect wedged after network conflict" +docker_e network disconnect "$ROUTE_NETWORK" "$SERVER_A" +docker_e network rm "$ROUTE_NETWORK" >/dev/null +pass network-route-conflict "overlap failed promptly; unrelated container API and port stayed healthy" + +bounded_capture 10 "$WORKDIR/network-metadata-create.out" \ + "$WORKDIR/network-metadata-create.err" env DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" network create --driver bridge --internal --attachable \ + --subnet "$NETWORK_METADATA_SUBNET" --ip-range "$NETWORK_METADATA_RANGE" \ + --gateway "$NETWORK_METADATA_GATEWAY" \ + --aux-address "reserved=$NETWORK_METADATA_RESERVED" \ + --opt com.docker.network.bridge.enable_icc=true \ + --label "dev.dory.compatibility=$OWNER" \ + --label dev.dory.network-contract=original "$NETWORK_METADATA" \ + || die "custom network creation failed or exceeded ten seconds" +docker_e network inspect "$NETWORK_METADATA" > "$WORKDIR/network-metadata-before.json" +python3 - "$WORKDIR/network-metadata-before.json" "$NETWORK_METADATA" "$OWNER" \ + "$NETWORK_METADATA_SUBNET" "$NETWORK_METADATA_RANGE" "$NETWORK_METADATA_GATEWAY" \ + "$NETWORK_METADATA_RESERVED" <<'PY' +import json +import sys + +path, expected_name, expected_owner, subnet, ip_range, gateway, reserved = sys.argv[1:] +with open(path, encoding="utf-8") as handle: + networks = json.load(handle) +def require(condition, detail): + if not condition: + raise SystemExit(detail) + +require(len(networks) == 1, f"expected one inspected network, got {len(networks)}") +network = networks[0] +require(network["Name"] == expected_name, f"network name mismatch: {network!r}") +require(network["Driver"] == "bridge" and network["Scope"] == "local", + f"network driver/scope mismatch: {network!r}") +require(network["Internal"] is True and network["Attachable"] is True, + f"network flags mismatch: {network!r}") +require(network["Ingress"] is False, f"network ingress mismatch: {network!r}") +require(network["Labels"]["dev.dory.compatibility"] == expected_owner, + f"network owner mismatch: {network!r}") +require(network["Labels"]["dev.dory.network-contract"] == "original", + f"network contract label mismatch: {network!r}") +require(network["Options"]["com.docker.network.bridge.enable_icc"] == "true", + f"network options mismatch: {network!r}") +require(network["IPAM"]["Driver"] == "default", f"network IPAM driver mismatch: {network!r}") +config = network["IPAM"]["Config"] +require(len(config) == 1, f"network IPAM config mismatch: {config!r}") +require(config[0]["Subnet"] == subnet, f"network subnet mismatch: {config!r}") +require(config[0]["IPRange"] == ip_range, f"network IP range mismatch: {config!r}") +require(config[0]["Gateway"] == gateway, f"network gateway mismatch: {config!r}") +require(config[0]["AuxiliaryAddresses"] == {"reserved": reserved}, + f"network auxiliary address mismatch: {config!r}") +PY +filtered_networks="$(docker_e network ls --format '{{.Name}}' \ + --filter "label=dev.dory.compatibility=$OWNER" \ + --filter label=dev.dory.network-contract=original)" +[ "$filtered_networks" = "$NETWORK_METADATA" ] \ + || die "filtered network listing returned unexpected objects: $filtered_networks" +set +e +bounded_capture 10 "$WORKDIR/network-duplicate-create.out" \ + "$WORKDIR/network-duplicate-create.err" env DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" network create --label dev.dory.network-contract=mutated "$NETWORK_METADATA" +network_duplicate_rc=$? +set -e +[ "$network_duplicate_rc" -ne 0 ] || die "duplicate network name was accepted" +[ "$network_duplicate_rc" -ne 124 ] || die "duplicate network creation wedged for ten seconds" +grep -Eiq 'already exists|conflict' \ + "$WORKDIR/network-duplicate-create.out" "$WORKDIR/network-duplicate-create.err" \ + || die "duplicate network rejection did not report the conflict" +docker_e network inspect "$NETWORK_METADATA" > "$WORKDIR/network-metadata-after.json" +python3 - "$WORKDIR/network-metadata-before.json" "$WORKDIR/network-metadata-after.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as before_handle: + before = json.load(before_handle) +with open(sys.argv[2], encoding="utf-8") as after_handle: + after = json.load(after_handle) +if after != before: + raise SystemExit("duplicate create mutated existing network metadata") +PY +bounded_capture 10 "$WORKDIR/network-connect.out" "$WORKDIR/network-connect.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" network connect \ + --alias metadata-api --ip "$NETWORK_METADATA_STATIC_IP" "$NETWORK_METADATA" "$SERVER_A" \ + || die "network connect failed or exceeded ten seconds" +docker_e container inspect "$SERVER_A" > "$WORKDIR/network-connected-container.json" +python3 - "$WORKDIR/network-connected-container.json" "$NETWORK_METADATA" \ + "$NETWORK_METADATA_STATIC_IP" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + containers = json.load(handle) +endpoint = containers[0]["NetworkSettings"]["Networks"][sys.argv[2]] +if endpoint["IPAddress"] != sys.argv[3]: + raise SystemExit(f"static network address mismatch: {endpoint!r}") +if "metadata-api" not in endpoint["Aliases"]: + raise SystemExit(f"network alias missing: {endpoint!r}") +PY +set +e +bounded_capture 10 "$WORKDIR/network-in-use-remove.out" "$WORKDIR/network-in-use-remove.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" network rm "$NETWORK_METADATA" +network_in_use_rc=$? +set -e +[ "$network_in_use_rc" -ne 0 ] || die "in-use network was removed" +[ "$network_in_use_rc" -ne 124 ] || die "in-use network removal wedged for ten seconds" +grep -Eiq 'active endpoints|in use' \ + "$WORKDIR/network-in-use-remove.out" "$WORKDIR/network-in-use-remove.err" \ + || die "in-use network rejection did not report active endpoints" +wait_http "$port" server-a || die "network lifecycle operations broke the published port" +bounded_capture 10 "$WORKDIR/network-disconnect.out" "$WORKDIR/network-disconnect.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" network disconnect \ + "$NETWORK_METADATA" "$SERVER_A" \ + || die "network disconnect failed or exceeded ten seconds" +[ -z "$(docker_e inspect -f "{{with index .NetworkSettings.Networks \"$NETWORK_METADATA\"}}{{.NetworkID}}{{end}}" "$SERVER_A")" ] \ + || die "network disconnect left the container attached" +bounded_capture 10 "$WORKDIR/network-metadata-remove.out" \ + "$WORKDIR/network-metadata-remove.err" env DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" network rm "$NETWORK_METADATA" \ + || die "explicit network removal failed or exceeded ten seconds" +docker_e network inspect "$NETWORK_METADATA" >/dev/null 2>&1 \ + && die "explicitly removed network is still inspectable" +pass network-api-lifecycle \ + "IPAM/options/labels, filters, conflict safety, static-IP alias connect, in-use reject, disconnect, and remove matched Docker" + +# Higher-level Compose/dev-environment tools depend on attachment-scoped aliases. Prove both aliases +# and the primary container name resolve only on the owned custom network, then preserve the exact +# endpoint IP and aliases across stop/start. +docker_e network create --label "dev.dory.compatibility=$OWNER" "$ALIAS_NETWORK" >/dev/null +docker_e run -d --name "$ALIAS_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + --network "$ALIAS_NETWORK" --network-alias db --network-alias database \ + "$ALPINE_IMAGE" sh -c "$server_command" sh alias-server >/dev/null +alias_ip_before="$(docker_e inspect -f "{{with index .NetworkSettings.Networks \"$ALIAS_NETWORK\"}}{{.IPAddress}}{{end}}" "$ALIAS_CONTAINER")" +[ -n "$alias_ip_before" ] || die "network alias fixture has no custom-network IP" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" --network "$ALIAS_NETWORK" \ + "$ALPINE_IMAGE" sh -ec ' + for name in "$@"; do + [ "$(wget -qO- -T 3 "http://$name:8080/")" = alias-server ] + done + ' sh "$ALIAS_CONTAINER" db database +docker_e run --rm --label "dev.dory.compatibility=$OWNER" "$ALPINE_IMAGE" sh -ec \ + 'command -v nslookup >/dev/null; ! nslookup db >/dev/null 2>&1; ! nslookup database >/dev/null 2>&1' +docker_e stop -t 2 "$ALIAS_CONTAINER" >/dev/null +docker_e start "$ALIAS_CONTAINER" >/dev/null +alias_ip_after="$(docker_e inspect -f "{{with index .NetworkSettings.Networks \"$ALIAS_NETWORK\"}}{{.IPAddress}}{{end}}" "$ALIAS_CONTAINER")" +[ "$alias_ip_after" = "$alias_ip_before" ] \ + || die "custom-network IP changed across stop/start ($alias_ip_before -> $alias_ip_after)" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" --network "$ALIAS_NETWORK" \ + "$ALPINE_IMAGE" sh -ec ' + for name in "$@"; do + [ "$(wget -qO- -T 3 "http://$name:8080/")" = alias-server ] + done + ' sh "$ALIAS_CONTAINER" db database +pass network-alias-restart-ip \ + "primary plus db/database aliases stayed network-scoped; IP=$alias_ip_before and names survived stop/start" + +if [ -n "$RUNTIME" ]; then + docker_e update --restart unless-stopped "$SERVER_A" >/dev/null + server_id="$(docker_e inspect -f '{{.Id}}' "$SERVER_A")" + bounded_capture 30 "$WORKDIR/engine-stop.out" "$WORKDIR/engine-stop.err" \ + env HOME="$RUNTIME_HOME" "$RUNTIME" stop \ + || die "standalone engine stop failed or exceeded 30 seconds" + [ ! -S "$SOCKET" ] || die "engine socket survived a reported standalone stop" + bounded_capture 60 "$WORKDIR/engine-start.out" "$WORKDIR/engine-start.err" \ + env HOME="$RUNTIME_HOME" "$RUNTIME" start \ + || die "standalone engine start failed or exceeded 60 seconds" + attempts=100 + while [ "$attempts" -gt 0 ]; do + docker_e version >/dev/null 2>&1 && break + attempts=$((attempts - 1)) + sleep 0.2 + done + [ "$attempts" -gt 0 ] || die "Docker API did not recover after standalone restart" + # Dockerd publishes its API before the restart manager has necessarily recreated every + # restart-policy task. Require bounded convergence instead of racing the first successful + # /version response, while still pinning the exact container identity throughout. + resume_attempts=100 + resumed_id="" + resumed_running="false" + while [ "$resume_attempts" -gt 0 ]; do + resumed_id="$(docker_e inspect -f '{{.Id}}' "$SERVER_A" 2>/dev/null || true)" + resumed_running="$(docker_e inspect -f '{{.State.Running}}' "$SERVER_A" 2>/dev/null || true)" + if [ "$resumed_id" = "$server_id" ] && [ "$resumed_running" = true ]; then + break + fi + resume_attempts=$((resume_attempts - 1)) + sleep 0.2 + done + [ "$(docker_e inspect -f '{{.Id}}' "$SERVER_A")" = "$server_id" ] \ + || die "container identity changed across standalone restart" + [ "$(docker_e inspect -f '{{.State.Running}}' "$SERVER_A")" = true ] \ + || die "restart-policy container did not resume within 20 seconds after standalone restart" + wait_http "$port" server-a || die "published port did not recover after standalone restart" + pass standalone-engine-restart "container identity/state and port=$port recovered after stop/start" +fi + +docker_e volume create --label "dev.dory.compatibility=$OWNER" "$VOLUME" >/dev/null +docker_e volume create --driver local \ + --label "dev.dory.compatibility=$OWNER" \ + --label dev.dory.volume-contract=original \ + --opt type=tmpfs --opt device=tmpfs --opt o=size=4m \ + "$VOLUME_METADATA" >/dev/null +docker_e volume inspect "$VOLUME_METADATA" > "$WORKDIR/volume-metadata-before.json" +python3 - "$WORKDIR/volume-metadata-before.json" "$VOLUME_METADATA" "$OWNER" <<'PY' +import json +import sys + +path, expected_name, expected_owner = sys.argv[1:] +with open(path, encoding="utf-8") as handle: + volumes = json.load(handle) +def require(condition, detail): + if not condition: + raise SystemExit(detail) + +require(len(volumes) == 1, f"expected one inspected volume, got {len(volumes)}") +volume = volumes[0] +require(volume["Name"] == expected_name, f"volume name mismatch: {volume!r}") +require(volume["Driver"] == "local", f"volume driver mismatch: {volume!r}") +require(volume["Scope"] == "local", f"volume scope mismatch: {volume!r}") +require(volume["Labels"]["dev.dory.compatibility"] == expected_owner, + f"volume owner mismatch: {volume!r}") +require(volume["Labels"]["dev.dory.volume-contract"] == "original", + f"volume contract label mismatch: {volume!r}") +require(volume["Options"] == {"type": "tmpfs", "device": "tmpfs", "o": "size=4m"}, + f"volume options mismatch: {volume!r}") +PY +filtered_volumes="$(docker_e volume ls -q \ + --filter "label=dev.dory.compatibility=$OWNER" \ + --filter label=dev.dory.volume-contract=original)" +[ "$filtered_volumes" = "$VOLUME_METADATA" ] \ + || die "filtered volume listing returned unexpected objects: $filtered_volumes" +same_volume="$(docker_e volume create --driver local \ + --label dev.dory.volume-contract=mutated \ + --opt type=none --opt device=/tmp --opt o=bind "$VOLUME_METADATA")" +[ "$same_volume" = "$VOLUME_METADATA" ] \ + || die "same-name volume creation returned the wrong identity: $same_volume" +docker_e volume inspect "$VOLUME_METADATA" > "$WORKDIR/volume-metadata-after.json" +python3 - "$WORKDIR/volume-metadata-before.json" "$WORKDIR/volume-metadata-after.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as before_handle: + before = json.load(before_handle) +with open(sys.argv[2], encoding="utf-8") as after_handle: + after = json.load(after_handle) +if after != before: + raise SystemExit("same-name create mutated existing volume metadata") +PY +bounded_capture 10 "$WORKDIR/volume-metadata-remove.out" \ + "$WORKDIR/volume-metadata-remove.err" env DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" volume rm "$VOLUME_METADATA" \ + || die "explicit volume removal failed or exceeded ten seconds" +docker_e volume inspect "$VOLUME_METADATA" >/dev/null 2>&1 \ + && die "explicitly removed volume is still inspectable" +volume_initial_entries="$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$VOLUME:/data" "$ALPINE_IMAGE" \ + find /data -mindepth 1 -maxdepth 1 -print)" +[ -z "$volume_initial_entries" ] \ + || die "fresh named volume is not empty: $volume_initial_entries" +pass named-volume-empty "fresh volume root had no lost+found or other image-breaking entries" + +marker="volume-$RUN_ID" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" -v "$VOLUME:/data" "$ALPINE_IMAGE" \ + sh -c 'printf "%s\n" "$1" > /data/marker' sh "$marker" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" -v "$VOLUME:/data" "$ALPINE_IMAGE" \ + grep -qx "$marker" /data/marker +pass named-volume "data survived removal and recreation of the consuming container" + +# Copying through a mounted named volume is a distinct API/tar-stream path from ordinary volume +# reads. Require both directions to complete promptly, preserve exact bytes, and leave unrelated +# Docker control requests responsive. +volume_cp_in="$WORKDIR/volume-cp-in.bin" +volume_cp_out="$WORKDIR/volume-cp-out.bin" +python3 - "$volume_cp_in" <<'PY' +from pathlib import Path +import sys +Path(sys.argv[1]).write_bytes(bytes(range(256)) * 4096) +PY +docker_e run -d --name "$VOLUME_CP_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + -v "$VOLUME:/data" "$ALPINE_IMAGE" sleep 300 >/dev/null +set +e +bounded_capture 10 "$WORKDIR/volume-in-use-remove.out" "$WORKDIR/volume-in-use-remove.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" volume rm "$VOLUME" +volume_in_use_rc=$? +set -e +[ "$volume_in_use_rc" -ne 0 ] || die "in-use named volume was removed" +[ "$volume_in_use_rc" -ne 124 ] || die "in-use named-volume removal wedged for ten seconds" +grep -Eiq 'in use|is being used' \ + "$WORKDIR/volume-in-use-remove.out" "$WORKDIR/volume-in-use-remove.err" \ + || die "in-use volume rejection did not report the conflict" +docker_e volume inspect "$VOLUME" >/dev/null \ + || die "in-use volume rejection removed the volume metadata" +docker_e exec "$VOLUME_CP_CONTAINER" grep -qx "$marker" /data/marker \ + || die "in-use volume rejection damaged persisted bytes" +bounded_capture 10 "$WORKDIR/volume-cp-in.out" "$WORKDIR/volume-cp-in.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" cp \ + "$volume_cp_in" "$VOLUME_CP_CONTAINER:/data/from-host.bin" \ + || die "docker cp into a mounted named volume failed or wedged" +docker_e exec "$VOLUME_CP_CONTAINER" sh -ec \ + 'test "$(wc -c < /data/from-host.bin | tr -d " ")" = 1048576' +docker_e exec "$VOLUME_CP_CONTAINER" cp /data/from-host.bin /data/from-volume.bin +bounded_capture 10 "$WORKDIR/volume-cp-out.out" "$WORKDIR/volume-cp-out.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" cp \ + "$VOLUME_CP_CONTAINER:/data/from-volume.bin" "$volume_cp_out" \ + || die "docker cp from a mounted named volume failed or wedged" +cmp "$volume_cp_in" "$volume_cp_out" || die "named-volume docker cp changed payload bytes" +bounded_capture 5 "$WORKDIR/volume-cp-version.out" "$WORKDIR/volume-cp-version.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" version \ + || die "Docker API wedged after named-volume copy" +docker_e rm -f "$VOLUME_CP_CONTAINER" >/dev/null +pass named-volume-cp "1MiB exact bytes copied host->mounted volume->host; Docker API remained responsive" +pass volume-api-lifecycle \ + "driver/labels/options, filtered list, same-name identity, in-use rejection, and explicit remove matched Docker" + +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + --security-opt label=disable "$ALPINE_IMAGE" true +pass security-opt-label "Docker-compatible label=disable security option accepted" + +seccomp_profile="$WORKDIR/seccomp-profile.json" +python3 - "$seccomp_profile" <<'PY' +import json +import pathlib +import sys + +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "defaultAction": "SCMP_ACT_ALLOW", + "architectures": ["SCMP_ARCH_AARCH64"], + "syscalls": [{ + "names": ["mkdir", "mkdirat"], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 13, + }], +}), encoding="utf-8") +PY +docker_e info --format '{{json .SecurityOptions}}' > "$WORKDIR/security-options.json" +grep -q 'name=seccomp' "$WORKDIR/security-options.json" \ + || die "Docker engine does not advertise seccomp support" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + --security-opt "seccomp=$seccomp_profile" "$ALPINE_IMAGE" sh -ec ' + test -r /etc/os-release + if mkdir /seccomp-should-block 2>/dev/null; then exit 1; fi + test ! -e /seccomp-should-block + ' || die "custom seccomp profile did not execute or enforce the blocked syscall" +bounded_capture 5 "$WORKDIR/post-seccomp-version.out" "$WORKDIR/post-seccomp-version.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" version \ + || die "Docker API wedged after custom seccomp enforcement" +pass seccomp-profile "engine advertised seccomp; custom profile blocked mkdir/mkdirat and API stayed live" + +bind_dir="$WORKDIR/bind" +mkdir -p "$bind_dir" +# Create the fixture directory through the bind itself. This proves the mount is writable and +# avoids relying on empty host directories being materialized before the first guest-side lookup. +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$bind_dir:/share" "$ALPINE_IMAGE" sh -ec 'mkdir -p /share/test; chmod 0777 /share/test' +docker_e run --rm --user 1000:1000 --label "dev.dory.compatibility=$OWNER" \ + -v "$bind_dir:/share" "$ALPINE_IMAGE" sh -ec \ + 'umask 0577; : > /share/test/write-only; printf x >> /share/test/write-only; test "$(stat -c %a /share/test/write-only)" = 200; : > /tmp/native-write-only; test "$(stat -c %a /tmp/native-write-only)" = 200' +[ "$(stat -f '%Lp' "$bind_dir/test/write-only")" = 200 ] || die "host did not retain mode 0200" +[ "$(stat -f '%z' "$bind_dir/test/write-only")" = 1 ] || die "write-only bind file has the wrong size" +pass bind-open-create-0200 "non-root uid=1000 O_CREAT/write matched native fs; host mode=0200 size=1" + +# Dory exposes Docker's mount contract, not Apple's undocumented host:guest:kernel-flags grammar. +# Unsupported flags must fail loudly instead of being silently discarded, while Docker's supported +# read-only mode must be visible in inspect and enforced by the kernel mount. +printf 'mount-option-%s\n' "$RUN_ID" > "$bind_dir/mount-option-marker" +set +e +bounded_capture 10 "$WORKDIR/mount-nosuid.out" "$WORKDIR/mount-nosuid.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" run --name "$MOUNT_OPTION_CONTAINER" \ + --label "dev.dory.compatibility=$OWNER" -v "$bind_dir:/share:nosuid" \ + "$ALPINE_IMAGE" true +nosuid_rc=$? +set -e +[ "$nosuid_rc" -ne 0 ] || die "unsupported nosuid bind option was silently accepted" +[ "$nosuid_rc" -ne 124 ] || die "unsupported nosuid bind option hung for ten seconds" +grep -Eiq 'invalid (mode|mount)|invalid.*nosuid|unknown.*nosuid' \ + "$WORKDIR/mount-nosuid.err" "$WORKDIR/mount-nosuid.out" \ + || die "unsupported nosuid bind option failed without an explicit validation error" +docker_e inspect "$MOUNT_OPTION_CONTAINER" >/dev/null 2>&1 \ + && die "unsupported nosuid bind option left a partial container" +docker_e run -d --name "$READ_ONLY_MOUNT_CONTAINER" \ + --label "dev.dory.compatibility=$OWNER" -v "$bind_dir:/share:ro" \ + "$ALPINE_IMAGE" sleep 300 >/dev/null +[ "$(docker_e inspect -f '{{range .Mounts}}{{if eq .Destination "/share"}}{{.RW}}{{end}}{{end}}' \ + "$READ_ONLY_MOUNT_CONTAINER")" = false ] \ + || die "Docker inspect did not retain the read-only bind contract" +docker_e exec "$READ_ONLY_MOUNT_CONTAINER" sh -ec ' + grep -q "^mount-option-" /share/mount-option-marker + if touch /share/should-not-write 2>/dev/null; then exit 1; fi +' || die "read-only bind was unreadable or silently writable" +[ ! -e "$bind_dir/should-not-write" ] || die "read-only bind modified the host" +docker_e rm -f "$READ_ONLY_MOUNT_CONTAINER" >/dev/null +bounded_capture 5 "$WORKDIR/post-mount-option-version.out" \ + "$WORKDIR/post-mount-option-version.err" env DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER_BIN" version || die "Docker API wedged after mount-option validation" +pass bind-mount-option-contract \ + "unsupported nosuid rejected explicitly without a partial container; ro retained and enforced" + +# Nested bind precedence and anonymous sub-volumes have regressed independently in desktop +# VirtioFS implementations. The child bind must win at its mountpoint while its parent remains +# writable, and an anonymous child volume must not leak into the parent host directory. +nested_parent="$WORKDIR/nested-bind-parent" +nested_child="$WORKDIR/nested-bind-child" +mkdir -p "$nested_parent/nested" "$nested_parent/cache" "$nested_child" +printf parent > "$nested_parent/parent.txt" +printf child > "$nested_child/child.txt" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$nested_parent:/workspace" -v "$nested_child:/workspace/nested" \ + "$ALPINE_IMAGE" sh -ec ' + test "$(cat /workspace/parent.txt)" = parent + test "$(cat /workspace/nested/child.txt)" = child + printf parent-write > /workspace/from-container.txt + printf child-write > /workspace/nested/from-container.txt + ' +[ "$(cat "$nested_parent/from-container.txt")" = parent-write ] \ + || die "nested bind corrupted the parent host mount" +[ "$(cat "$nested_child/from-container.txt")" = child-write ] \ + || die "nested child bind did not win at its mountpoint" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$nested_parent:/workspace" -v /workspace/cache \ + "$ALPINE_IMAGE" sh -ec ' + test "$(cat /workspace/parent.txt)" = parent + printf anonymous-volume > /workspace/cache/volume.txt + ' +[ ! -e "$nested_parent/cache/volume.txt" ] \ + || die "anonymous child volume leaked through the parent host bind" +pass nested-bind-subvolume \ + "nested child precedence, parent/child writes, and anonymous child isolation passed" + +# A host FIFO is the pathological case behind a competitor's whole-VM wedge: a synchronous host +# open without O_NONBLOCK can pin a vCPU forever. Dory's HostFS rejects unsupported special files +# during lookup (before Linux applies guest FIFO-open semantics), so the container operation must +# fail promptly and unrelated Docker control requests must remain live. +mkfifo "$bind_dir/test/host-fifo" +set +e +bounded_capture 5 "$WORKDIR/bind-fifo.out" "$WORKDIR/bind-fifo.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" run --rm \ + --label "dev.dory.compatibility=$OWNER" -v "$bind_dir:/share" "$ALPINE_IMAGE" \ + sh -ec 'exec dd if=/share/test/host-fifo of=/dev/null bs=1 count=1' +fifo_rc=$? +set -e +[ "$fifo_rc" -ne 0 ] || die "host FIFO unexpectedly opened as a regular bind file" +[ "$fifo_rc" -ne 124 ] || die "host FIFO open blocked the bind request for five seconds" +bounded_capture 5 "$WORKDIR/post-fifo-version.out" "$WORKDIR/post-fifo-version.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" version \ + || die "Docker API wedged after the host FIFO open attempt" +rm -f "$bind_dir/test/host-fifo" +pass bind-special-file-fail-fast "host FIFO failed promptly with exit=$fifo_rc; Docker API remained responsive" + +# Exercise more opens than the observed ~8k external-volume leak threshold while touching one +# pathname. This catches a descriptor leaked per operation without turning expected identity pins +# for many distinct live dentries into noise. +bind_fd_before="$(sample_fds "$WORKDIR/fds-before-bind-churn.tsv")" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$bind_dir:/share" "$ALPINE_IMAGE" sh -ec ' + i=0 + while [ "$i" -lt 10000 ]; do + printf "%s" "$i" > /share/test/fd-churn + dd if=/share/test/fd-churn of=/dev/null bs=32 count=1 2>/dev/null + i=$((i + 1)) + done + rm -f /share/test/fd-churn + ' +sleep 2 +bind_fd_after="$(sample_fds "$WORKDIR/fds-after-bind-churn.tsv")" +bind_fd_growth=$((bind_fd_after - bind_fd_before)) +[ "$bind_fd_growth" -le "$FD_GROWTH_BUDGET" ] \ + || die "10,000 bind-file operations grew aggregate Dory FDs by $bind_fd_growth (budget $FD_GROWTH_BUDGET)" +pass bind-open-fd-stability \ + "operations=10000 before=$bind_fd_before after=$bind_fd_after growth=$bind_fd_growth" + +# Read a restrictive hard-linked inode repeatedly through both aliases. This is the live Docker +# counterpart to HostFS's identity/virtual-ownership unit suite and catches intermittent alias +# permission failures under repeated lookup/open/forget churn. +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + -v "$bind_dir:/share" "$ALPINE_IMAGE" sh -ec ' + printf hard-link-payload > /share/test/hard-a + ln /share/test/hard-a /share/test/hard-b + chmod 0400 /share/test/hard-a + i=0 + while [ "$i" -lt 1000 ]; do + test "$(cat /share/test/hard-a)" = hard-link-payload + test "$(cat /share/test/hard-b)" = hard-link-payload + i=$((i + 1)) + done + rm -f /share/test/hard-a /share/test/hard-b + ' +pass bind-hardlink-permissions "1000 restrictive-mode reads succeeded through both hard-link aliases" + +docker_e run -d --name "$HEALTH_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + --health-cmd 'test -f /tmp/ready' --health-interval 1s --health-timeout 1s \ + --health-start-period 1s --health-start-interval 250ms --health-retries 5 \ + "$ALPINE_IMAGE" sh -c 'sleep 2; touch /tmp/ready; sleep 300' >/dev/null +initial_health="$(docker_e inspect -f '{{.State.Health.Status}}' "$HEALTH_CONTAINER")" +[ "$initial_health" = starting ] || die "healthcheck did not begin in starting state (got $initial_health)" +attempts=20 +health="" +while [ "$attempts" -gt 0 ]; do + health="$(docker_e inspect -f '{{.State.Health.Status}}' "$HEALTH_CONTAINER")" + [ "$health" = healthy ] && break + attempts=$((attempts - 1)) + sleep 0.5 +done +[ "$health" = healthy ] || die "healthcheck did not reach healthy (last=$health)" +docker_e inspect -f '{{json .Config.Healthcheck}}' "$HEALTH_CONTAINER" | grep -q 'test -f /tmp/ready' \ + || die "healthcheck configuration was not preserved" +[ "$(docker_e inspect -f '{{.State.Health.FailingStreak}}' "$HEALTH_CONTAINER")" = 0 ] \ + || die "successful healthcheck did not reset its failure streak" +[ "$(docker_e inspect -f '{{len .State.Health.Log}}' "$HEALTH_CONTAINER")" -gt 0 ] \ + || die "successful healthcheck retained no probe history" + +docker_e run -d --name "$UNHEALTHY_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + --health-cmd 'false' --health-interval 500ms --health-timeout 1s --health-retries 2 \ + "$ALPINE_IMAGE" sleep 300 >/dev/null +attempts=20 +unhealthy="" +while [ "$attempts" -gt 0 ]; do + unhealthy="$(docker_e inspect -f '{{.State.Health.Status}}' "$UNHEALTHY_CONTAINER")" + [ "$unhealthy" = unhealthy ] && break + attempts=$((attempts - 1)) + sleep 0.5 +done +[ "$unhealthy" = unhealthy ] || die "failing healthcheck did not reach unhealthy" +[ "$(docker_e inspect -f '{{.State.Health.FailingStreak}}' "$UNHEALTHY_CONTAINER")" -ge 2 ] \ + || die "unhealthy container did not retain its failure streak" +[ "$(docker_e inspect -f '{{len .State.Health.Log}}' "$UNHEALTHY_CONTAINER")" -ge 2 ] \ + || die "unhealthy container retained insufficient probe history" + +docker_e run -d --name "$NO_HEALTH_CONTAINER" --label "dev.dory.compatibility=$OWNER" \ + --no-healthcheck "$ALPINE_IMAGE" sleep 300 >/dev/null +[ "$(docker_e inspect -f '{{json .Config.Healthcheck.Test}}' "$NO_HEALTH_CONTAINER")" = '["NONE"]' ] \ + || die "--no-healthcheck did not retain HEALTHCHECK NONE" +[ "$(docker_e inspect -f '{{if .State.Health}}present{{else}}none{{end}}' "$NO_HEALTH_CONTAINER")" = none ] \ + || die "HEALTHCHECK NONE unexpectedly created runtime health state" +pass healthcheck "starting->healthy, unhealthy streak/history, start interval, and NONE passed" + +mkdir -p "$WORKDIR/build" "$WORKDIR/named-context" +printf '%s\n' "named-context-$RUN_ID" > "$WORKDIR/named-context/payload.txt" +cat > "$WORKDIR/build/Dockerfile" < "$WORKDIR/buildx.out" 2> "$WORKDIR/buildx.err" +[ "$(docker_e run --rm "$BUILD_TAG")" = "named-context-$RUN_ID" ] \ + || die "named BuildKit context built the wrong payload" +pass buildx-named-context "local plus docker-image named contexts built offline and ran" + +# Dockerfile ARG defaults are frontend syntax, not shell syntax. Reproduce the exact nested default +# form that Apple container failed to resolve, while keeping the base image offline and digest-bound +# by assigning the already-qualified fixture a run-unique local repository name. +docker_e tag "$ALPINE_IMAGE" "$DEFAULT_ARG_BASE_REPOSITORY:latest" +default_arg_context="$WORKDIR/default-arg-build" +mkdir -p "$default_arg_context" +cat > "$default_arg_context/Dockerfile" < /marker +LABEL dev.dory.compatibility="$OWNER" +CMD ["cat", "/marker"] +EOF +DOCKER_BUILDKIT=1 docker_e build --progress plain --pull=false \ + -t "$DEFAULT_ARG_BUILD_TAG" "$default_arg_context" \ + > "$WORKDIR/default-arg-build.out" 2> "$WORKDIR/default-arg-build.err" \ + || die "BuildKit failed to expand ARG TAG=\"\${TAG:-latest}\"" +[ "$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + "$DEFAULT_ARG_BUILD_TAG")" = "default-arg-$RUN_ID" ] \ + || die "default ARG build produced the wrong image" +pass buildkit-default-arg \ + 'ARG TAG="${TAG:-latest}" selected the run-local :latest base and produced exact output' + +# When stdout is the archive, no status/reference text may follow the tar EOF records. Parse the +# stream ourselves because permissive tar readers can silently ignore precisely the trailing bytes +# that broke other OCI consumers. +image_save_tar="$WORKDIR/image-save-stdout.tar" +bounded_capture 30 "$image_save_tar" "$WORKDIR/image-save-stderr.txt" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" image save "$ALPINE_IMAGE" \ + || die "docker image save to stdout failed or exceeded 30 seconds" +tar -tf "$image_save_tar" > "$WORKDIR/image-save-members.txt" \ + || die "docker image save stdout is not a readable tar archive" +grep -qx 'manifest.json' "$WORKDIR/image-save-members.txt" \ + || die "docker image save stdout omitted manifest.json" +archive_end="$(python3 - "$image_save_tar" <<'PY' +from pathlib import Path +import sys + +data = Path(sys.argv[1]).read_bytes() +block = 512 +offset = 0 +zero = bytes(block) + +def tar_number(field: bytes) -> int: + if field and field[0] & 0x80: + return int.from_bytes(field, "big") & ((1 << (len(field) * 8 - 1)) - 1) + value = field.rstrip(b"\0 ").lstrip(b" ") + return int(value or b"0", 8) + +while offset + block <= len(data): + header = data[offset:offset + block] + if header == zero: + if data[offset + block:offset + 2 * block] != zero: + raise SystemExit("single zero block before nonzero archive data") + if any(data[offset:]): + raise SystemExit("nonzero bytes follow the tar EOF records") + print(f"payload_end={offset} archive_bytes={len(data)} zero_tail={len(data)-offset}") + break + size = tar_number(header[124:136]) + offset += block + ((size + block - 1) // block) * block +else: + raise SystemExit("archive has no complete two-block tar EOF marker") +PY +)" || die "docker image save stdout contains trailing non-archive bytes" +pass image-save-stdout "readable archive with manifest.json and zero-only EOF tail; $archive_end" + +# Construct a layer containing a deep hard link but no explicit parent-directory records. Importing +# and exporting it must synthesize the parents and preserve the hard-link identity and exact bytes. +hardlink_root="$WORKDIR/hardlink-layer-root" +hardlink_layer="$WORKDIR/hardlink-missing-parent.tar" +hardlink_export="$WORKDIR/hardlink-import-export.tar" +hardlink_extract="$WORKDIR/hardlink-import-export" +mkdir -p "$hardlink_root/bin/app.runfiles/_main/app_" "$hardlink_extract" +printf 'hardlink-missing-parent-%s\n' "$RUN_ID" > "$hardlink_root/bin/app" +ln "$hardlink_root/bin/app" "$hardlink_root/bin/app.runfiles/_main/app_/app" +tar -cf "$hardlink_layer" -C "$hardlink_root" \ + bin/app bin/app.runfiles/_main/app_/app +tar -tf "$hardlink_layer" > "$WORKDIR/hardlink-layer-members.txt" +[ "$(wc -l < "$WORKDIR/hardlink-layer-members.txt" | tr -d '[:space:]')" = 2 ] \ + || die "hard-link fixture unexpectedly contains parent directory entries" +grep -qx 'bin/app' "$WORKDIR/hardlink-layer-members.txt" \ + || die "hard-link fixture omitted its source" +grep -qx 'bin/app.runfiles/_main/app_/app' "$WORKDIR/hardlink-layer-members.txt" \ + || die "hard-link fixture omitted its deep link" +tar -tvf "$hardlink_layer" > "$WORKDIR/hardlink-layer-verbose.txt" +grep -F 'link to bin/app' "$WORKDIR/hardlink-layer-verbose.txt" >/dev/null \ + || die "hard-link fixture did not encode its deep path as a hard link" +bounded_capture 30 "$WORKDIR/hardlink-import.out" "$WORKDIR/hardlink-import.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" import \ + "$hardlink_layer" "$HARDLINK_IMPORT_TAG" \ + || die "image import rejected a hard link whose parent has no explicit tar entry" +docker_e create --name "$HARDLINK_IMPORT_CONTAINER" \ + --label "dev.dory.compatibility=$OWNER" "$HARDLINK_IMPORT_TAG" /bin/app >/dev/null +bounded_capture 30 "$WORKDIR/hardlink-export.out" "$WORKDIR/hardlink-export.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER_BIN" export \ + --output "$hardlink_export" "$HARDLINK_IMPORT_CONTAINER" \ + || die "export of the missing-parent hard-link image failed" +tar -xf "$hardlink_export" -C "$hardlink_extract" \ + bin/app bin/app.runfiles/_main/app_/app +cmp "$hardlink_extract/bin/app" "$hardlink_extract/bin/app.runfiles/_main/app_/app" \ + || die "imported hard-link bytes differ" +[ "$(stat -f '%i' "$hardlink_extract/bin/app")" = \ + "$(stat -f '%i' "$hardlink_extract/bin/app.runfiles/_main/app_/app")" ] \ + || die "import/export did not preserve the deep hard-link identity" +docker_e rm "$HARDLINK_IMPORT_CONTAINER" >/dev/null +pass image-hardlink-missing-parent \ + "import synthesized omitted parents and export preserved exact bytes plus hard-link identity" + +large_context="$WORKDIR/large-dockerfile" +mkdir -p "$large_context" +python3 - "$large_context/Dockerfile" "$ALPINE_IMAGE" "$RUN_ID" <<'PY' +import pathlib +import sys + +path, image, run_id = sys.argv[1:] +prefix = f"FROM {image}\n" +suffix = f"RUN printf '%s\\n' 'large-dockerfile-{run_id}' > /marker\nCMD [\"cat\", \"/marker\"]\n" +padding = "# dory-buildkit-large-header-regression-padding\n" +body = prefix +while len((body + suffix).encode()) < 65536: + body += padding +body += suffix +pathlib.Path(path).write_text(body, encoding="utf-8") +if pathlib.Path(path).stat().st_size < 65536: + raise SystemExit("large Dockerfile fixture is below 64 KiB") +PY +large_dockerfile_bytes="$(stat -f '%z' "$large_context/Dockerfile")" +[ "$large_dockerfile_bytes" -ge 65536 ] || die "large Dockerfile fixture is below 64 KiB" +DOCKER_BUILDKIT=1 docker_e build --progress=plain \ + --label "dev.dory.compatibility=$OWNER" \ + -t "$LARGE_DOCKERFILE_BUILD_TAG" "$large_context" \ + > "$WORKDIR/large-dockerfile-build.out" 2> "$WORKDIR/large-dockerfile-build.err" \ + || die "64 KiB Dockerfile build failed" +[ "$(docker_e run --rm "$LARGE_DOCKERFILE_BUILD_TAG")" = "large-dockerfile-$RUN_ID" ] \ + || die "64 KiB Dockerfile build produced the wrong image" +pass buildkit-large-dockerfile \ + "Dockerfile bytes=$large_dockerfile_bytes crossed the 16KiB transport edge and built exact output" + +mkdir -p "$WORKDIR/relative-build/nested" +printf 'relative-context-%s\n' "$RUN_ID" > "$WORKDIR/relative-build/nested/payload.txt" +printf 'build-secret-%s\n' "$RUN_ID" > "$WORKDIR/relative-build/secret.txt" +chmod 0400 "$WORKDIR/relative-build/secret.txt" +printf 'secret.txt\n' > "$WORKDIR/relative-build/.dockerignore" +cat > "$WORKDIR/relative-build/Dockerfile" < "$WORKDIR/relative-build.out" 2> "$WORKDIR/relative-build.err" +[ "$(docker_e run --rm "$RELATIVE_BUILD_TAG")" = "relative-context-$RUN_ID" ] \ + || die "relative BuildKit context from a temporary directory produced the wrong payload" +pass buildkit-relative-temp-context \ + "relative dot context and required non-leaking mode-0400 secret under the temporary tree passed" + +# Match Docker's layered ignore/unignore semantics used by Rails and other generated projects. +# Both nested .gitkeep files must re-enter the context without the excluded sibling payloads or an +# out-of-order context-stream failure. +dockerignore_context="$WORKDIR/dockerignore-context" +mkdir -p "$dockerignore_context/foo/bar" +printf keep-root > "$dockerignore_context/foo/.gitkeep" +printf drop-root > "$dockerignore_context/foo/drop.txt" +printf keep-nested > "$dockerignore_context/foo/bar/.gitkeep" +printf drop-nested > "$dockerignore_context/foo/bar/drop.txt" +cat > "$dockerignore_context/.dockerignore" <<'EOF' +/foo/* +!/foo/.gitkeep + +/foo/bar/* +!/foo/bar/.gitkeep +EOF +cat > "$dockerignore_context/Dockerfile" < "$WORKDIR/dockerignore-build.out" 2> "$WORKDIR/dockerignore-build.err" \ + || die "layered .dockerignore unignore build failed" +docker_e run --rm --label "dev.dory.compatibility=$OWNER" "$DOCKERIGNORE_BUILD_TAG" +pass dockerignore-layered-unignore \ + "root/nested .gitkeep files included, excluded siblings absent, and context stream remained ordered" + +# A successful single build does not prove that independent BuildKit session streams remain +# isolated. Run four required-secret builds at the same time, keep them overlapped in RUN, and +# require each resulting image to contain only its own context bytes. +parallel_pids=() +parallel_index=1 +while [ "$parallel_index" -le 4 ]; do + parallel_context="$WORKDIR/parallel-build-$parallel_index" + mkdir -p "$parallel_context" + printf 'parallel-%s\n' "$parallel_index" > "$parallel_context/payload.txt" + printf 'secret-%s\n' "$parallel_index" > "$parallel_context/secret.txt" + chmod 0400 "$parallel_context/secret.txt" + printf 'secret.txt\n' > "$parallel_context/.dockerignore" + cat > "$parallel_context/Dockerfile" < "$WORKDIR/parallel-build-$parallel_index.out" \ + 2> "$WORKDIR/parallel-build-$parallel_index.err" + ) & + parallel_pids+=("$!") + parallel_index=$((parallel_index + 1)) +done +parallel_failed=0 +for parallel_pid in "${parallel_pids[@]}"; do + if ! wait "$parallel_pid"; then + parallel_failed=1 + fi +done +[ "$parallel_failed" -eq 0 ] || die "one or more concurrent BuildKit sessions failed" +parallel_index=1 +while [ "$parallel_index" -le 4 ]; do + [ "$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + "$PARALLEL_BUILD_REPOSITORY:$RUN_ID-$parallel_index")" = "parallel-$parallel_index" ] \ + || die "concurrent BuildKit session $parallel_index produced the wrong context bytes" + parallel_index=$((parallel_index + 1)) +done +pass buildkit-concurrent-sessions \ + "four overlapping required-secret builds produced four isolated exact context payloads" + +# BuildKit regressions have left cache exporters or cancelled solves alive while later clients hang. +# Prove the exact bundled Buildx can round-trip a local cache, cancel an active solve promptly, and +# complete a fresh solve plus Docker API probe without restarting the engine. +buildkit_cache_context="$WORKDIR/buildkit-cache-context" +buildkit_cache_dir="$WORKDIR/buildkit-local-cache" +mkdir -p "$buildkit_cache_context" +printf 'cache-roundtrip-%s\n' "$RUN_ID" > "$buildkit_cache_context/payload.txt" +cat > "$buildkit_cache_context/Dockerfile" </dev/null +bounded_capture 120 "$WORKDIR/buildkit-cache-import.out" \ + "$WORKDIR/buildkit-cache-import.err" buildx_e --builder default build \ + --progress plain --cache-from "type=local,src=$buildkit_cache_dir" \ + --load --tag "$BUILDKIT_CACHE_TAG" -- "$buildkit_cache_context" \ + || die "BuildKit local-cache import failed or exceeded 120 seconds" +grep -q 'CACHED' "$WORKDIR/buildkit-cache-import.out" "$WORKDIR/buildkit-cache-import.err" \ + || die "BuildKit local-cache import did not reuse an exported result" +[ "$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" "$BUILDKIT_CACHE_TAG")" = \ + "cache-roundtrip-$RUN_ID" ] || die "BuildKit cache-import image produced the wrong payload" +shasum -a 256 "$buildkit_cache_dir/index.json" > "$WORKDIR/buildkit-local-cache-index.sha256" +rm -rf "$buildkit_cache_dir" + +buildkit_cancel_context="$WORKDIR/buildkit-cancel-context" +mkdir -p "$buildkit_cancel_context" +cat > "$buildkit_cancel_context/Dockerfile" < "$WORKDIR/buildkit-cancel.out" 2> "$WORKDIR/buildkit-cancel.err" & +CANCEL_BUILDX_PID=$! +cancel_started=$SECONDS +while kill -0 "$CANCEL_BUILDX_PID" 2>/dev/null \ + && ! grep -q "buildkit-cancellation-started-$RUN_ID" \ + "$WORKDIR/buildkit-cancel.out" "$WORKDIR/buildkit-cancel.err"; do + [ $((SECONDS - cancel_started)) -lt 60 ] || break + sleep 0.1 +done +grep -q "buildkit-cancellation-started-$RUN_ID" \ + "$WORKDIR/buildkit-cancel.out" "$WORKDIR/buildkit-cancel.err" \ + || die "BuildKit cancellation fixture did not enter its active solve" +kill -TERM "$CANCEL_BUILDX_PID" +cancel_deadline=$((SECONDS + 10)) +while kill -0 "$CANCEL_BUILDX_PID" 2>/dev/null && [ "$SECONDS" -lt "$cancel_deadline" ]; do + sleep 0.1 +done +if kill -0 "$CANCEL_BUILDX_PID" 2>/dev/null; then + kill -KILL "$CANCEL_BUILDX_PID" 2>/dev/null || true + wait "$CANCEL_BUILDX_PID" 2>/dev/null || true + CANCEL_BUILDX_PID="" + die "Buildx client did not terminate within ten seconds of cancellation" +fi +set +e +wait "$CANCEL_BUILDX_PID" +cancel_rc=$? +set -e +CANCEL_BUILDX_PID="" +[ "$cancel_rc" -ne 0 ] || die "cancelled BuildKit solve unexpectedly succeeded" +! docker_e image inspect "$BUILDKIT_CANCEL_TAG" >/dev/null 2>&1 \ + || die "cancelled BuildKit solve published an image" + +cat > "$buildkit_cancel_context/Dockerfile" < /marker +LABEL dev.dory.compatibility="$OWNER" +CMD ["cat", "/marker"] +EOF +bounded_capture 60 "$WORKDIR/buildkit-post-cancel.out" \ + "$WORKDIR/buildkit-post-cancel.err" buildx_e --builder default build \ + --progress plain --load --tag "$BUILDKIT_RECOVERY_TAG" -- "$buildkit_cancel_context" \ + || die "fresh BuildKit solve failed or exceeded 60 seconds after cancellation" +[ "$(docker_e run --rm --label "dev.dory.compatibility=$OWNER" "$BUILDKIT_RECOVERY_TAG")" = \ + "post-cancel-$RUN_ID" ] || die "post-cancellation BuildKit solve produced the wrong payload" +bounded_capture 10 "$WORKDIR/buildkit-post-cancel-version.out" \ + "$WORKDIR/buildkit-post-cancel-version.err" docker_e version \ + || die "Docker API failed or exceeded ten seconds after BuildKit cancellation" +pass buildkit-cache-cancellation \ + "local cache export/import reused exact output; active solve cancelled within 10s; fresh solve and API recovered without restart" + +docker_e run --rm --label "dev.dory.compatibility=$OWNER" "$ALPINE_IMAGE" sh -ec \ + 'grep -q "^nameserver[[:space:]]" /etc/resolv.conf; ! grep -q "^search[[:space:]]" /etc/resolv.conf' +pass container-resolver-contract "nameserver present; no stale search directive" + +docker_e run --rm --label "dev.dory.compatibility=$OWNER" \ + --dns-search dev.dory.test "$ALPINE_IMAGE" sh -ec \ + 'grep -q "^nameserver[[:space:]]" /etc/resolv.conf; awk '\''$1 == "search" { for (i=2; i<=NF; i++) if ($i == "dev.dory.test") found=1 } END { exit !found }'\'' /etc/resolv.conf' \ + > "$WORKDIR/custom-dns-search.out" 2> "$WORKDIR/custom-dns-search.err" \ + || die "explicit Docker DNS search domain was not preserved" +pass container-dns-search "explicit --dns-search dev.dory.test present with a nameserver" + +GATE_COMPLETED=1 +cleanup +trap - EXIT INT TERM +leftovers="$(docker_e ps -aq --filter "label=dev.dory.compatibility=$OWNER" 2>/dev/null || true)" +[ -z "$leftovers" ] || die "owned container cleanup failed: $leftovers" +leftover_networks="$(docker_e network ls -q --filter "label=dev.dory.compatibility=$OWNER" 2>/dev/null || true)" +[ -z "$leftover_networks" ] || die "owned network cleanup failed: $leftover_networks" +docker_e volume inspect "$VOLUME" >/dev/null 2>&1 && die "owned volume cleanup failed" +docker_e volume inspect "$VOLUME_METADATA" >/dev/null 2>&1 \ + && die "owned metadata volume cleanup failed" +docker_e network inspect "$NETWORK_METADATA" >/dev/null 2>&1 \ + && die "owned metadata network cleanup failed" +docker_e image inspect "$BUILD_TAG" >/dev/null 2>&1 && die "owned build image cleanup failed" +docker_e image inspect "$DEFAULT_ARG_BUILD_TAG" >/dev/null 2>&1 \ + && die "owned default-ARG build image cleanup failed" +docker_e image inspect "$HARDLINK_IMPORT_TAG" >/dev/null 2>&1 \ + && die "owned hard-link import image cleanup failed" +docker_e image inspect "$BUILDKIT_CACHE_TAG" >/dev/null 2>&1 \ + && die "owned BuildKit cache-roundtrip image cleanup failed" +docker_e image inspect "$BUILDKIT_RECOVERY_TAG" >/dev/null 2>&1 \ + && die "owned BuildKit recovery image cleanup failed" +docker_e image inspect "$BUILDKIT_CANCEL_TAG" >/dev/null 2>&1 \ + && die "cancelled BuildKit image appeared during cleanup" +if [ -n "$RUNTIME" ]; then + bounded_capture 30 "$WORKDIR/cleanup-persistence-stop.out" \ + "$WORKDIR/cleanup-persistence-stop.err" env HOME="$RUNTIME_HOME" "$RUNTIME" stop \ + || die "cleanup-persistence engine stop failed or exceeded 30 seconds" + bounded_capture 60 "$WORKDIR/cleanup-persistence-start.out" \ + "$WORKDIR/cleanup-persistence-start.err" env HOME="$RUNTIME_HOME" "$RUNTIME" start \ + || die "cleanup-persistence engine start failed or exceeded 60 seconds" + curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null \ + || die "engine did not recover for cleanup-persistence verification" + [ -z "$(docker_e ps -aq --filter "label=dev.dory.compatibility=$OWNER")" ] \ + || die "owned containers reappeared after engine restart" + [ -z "$(docker_e network ls -q --filter "label=dev.dory.compatibility=$OWNER")" ] \ + || die "owned networks reappeared after engine restart" + docker_e volume inspect "$VOLUME" >/dev/null 2>&1 \ + && die "owned volume reappeared after engine restart" + docker_e volume inspect "$VOLUME_METADATA" >/dev/null 2>&1 \ + && die "owned metadata volume reappeared after engine restart" + docker_e network inspect "$NETWORK_METADATA" >/dev/null 2>&1 \ + && die "owned metadata network reappeared after engine restart" + docker_e image inspect "$BUILD_TAG" >/dev/null 2>&1 \ + && die "owned build image reappeared after engine restart" + docker_e image inspect "$BUILDKIT_CACHE_TAG" >/dev/null 2>&1 \ + && die "owned BuildKit cache-roundtrip image reappeared after engine restart" + docker_e image inspect "$BUILDKIT_RECOVERY_TAG" >/dev/null 2>&1 \ + && die "owned BuildKit recovery image reappeared after engine restart" + pass cleanup-restart-persistence \ + "owned containers, networks, volume, and build image stayed deleted after engine restart" +fi +if [ -s "$STATE_DIR/engine-settings" ]; then + cp "$STATE_DIR/engine-settings" "$WORKDIR/engine-settings.txt" + echo "engine_settings_sha256=$(shasum -a 256 "$WORKDIR/engine-settings.txt" | awk '{print $1}')" \ + >> "$MANIFEST" +fi +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +echo "results_sha256=$(shasum -a 256 "$RESULTS" | awk '{print $1}')" >> "$MANIFEST" +echo "status=PASS" >> "$MANIFEST" +echo "release_qualifying=$release_qualifying" >> "$MANIFEST" +echo "completed_epoch=$(date +%s)" >> "$MANIFEST" +echo "competitor runtime regression gate PASS; evidence: $WORKDIR" From c2c7f80ba63ef81bad3cf43d1dfbf5614e09b507 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 05:58:49 +0000 Subject: [PATCH 140/338] feat(release): qualify managed data drive --- .../scripts/test-managed-data-drive-gate.py | 102 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/managed-data-drive-gate.sh | 484 ++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100755 .github/scripts/test-managed-data-drive-gate.py create mode 100755 scripts/managed-data-drive-gate.sh diff --git a/.github/scripts/test-managed-data-drive-gate.py b/.github/scripts/test-managed-data-drive-gate.py new file mode 100755 index 00000000..7189cdbe --- /dev/null +++ b/.github/scripts/test-managed-data-drive-gate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Offline contract for managed durable-data-drive release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "managed-data-drive-gate.sh" + + +class ManagedDataDriveGateTests(unittest.TestCase): + def test_gate_binds_candidate_and_exact_durable_object_continuity(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-MANAGED-DATA-DRIVE", + "physical Apple silicon is required", + "runtime must be an absolute path", + "runtime is unavailable or indirect", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "runtime member is missing or indirect", + "workroot already exists or is indirect", + "isolated HOME exists or is indirect", + "temporary alias path already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate image must be digest-pinned", + "release candidate image is not preloaded in the isolated engine", + "engine socket is missing or indirect after first start", + "engine socket is not owned by the release user", + "first-launch retry left its partial bundle", + "interrupted first launch adopted a different drive identity", + "missing metadata silently adopted a different drive", + "running engine accepted a different drive", + "missing external volume was accepted", + "a second engine attached the live drive", + "stopped runtime silently created a replacement selected drive", + "transient runtime replacement changed the image identity", + "transient runtime replacement changed the container identity", + "transient runtime replacement changed the network identity", + "drive manifest has an unexpected shape", + "selection authority differs from the drive manifest", + "exact_image_identity=PASS", + "exact_container_identity=PASS", + "exact_network_identity=PASS", + "same_user_engine_socket=PASS", + "release_qualifying=", + "source_commit=", + "docker_cli_sha256=", + "dory_engine_sha256=", + "dory_hv_sha256=", + "gvproxy_sha256=", + "dataplane_proxy_sha256=", + "kernel_asset_sha256=", + "rootfs_asset_sha256=", + "agent_asset_sha256=", + "drive_manifest_sha256=", + "selection_authority_sha256=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'rm -rf "$WORKROOT"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_architecture_runtime_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + "/missing/runtime", + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a1b42b2..0505364e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -364,6 +364,7 @@ jobs: python3 .github/scripts/test-private-registry-auth-gate.py python3 .github/scripts/test-offline-bundled-boot-gate.py python3 .github/scripts/test-competitor-runtime-regression-gate.py + python3 .github/scripts/test-managed-data-drive-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 938125a8..21560fab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,7 @@ jobs: python3 .github/scripts/test-private-registry-auth-gate.py python3 .github/scripts/test-offline-bundled-boot-gate.py python3 .github/scripts/test-competitor-runtime-regression-gate.py + python3 .github/scripts/test-managed-data-drive-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/managed-data-drive-gate.sh b/scripts/managed-data-drive-gate.sh new file mode 100755 index 00000000..d7f9e99b --- /dev/null +++ b/scripts/managed-data-drive-gate.sh @@ -0,0 +1,484 @@ +#!/bin/bash +# Proves that a fresh managed .dorydrive preserves every durable Docker object even when all +# transient runtime state is replaced. Runs only against isolated disposable paths. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/managed-data-drive-gate.sh --runtime DIR --docker PATH [options] + +Required: + --runtime DIR Exact standalone runtime bundle containing dory-engine + --docker PATH Exact Docker CLI to use + +Options: + --image REF Small image used for persistence checks (default alpine:3.20) + --workroot DIR Evidence root (default /tmp/dory-managed-drive-evidence) + --source-commit SHA Exact 40-character source commit for release evidence + --confirm TOKEN Must be ISOLATED-ENGINE-MANAGED-DATA-DRIVE + --release-candidate Require digest-pinned, preloaded release inputs + --keep-workload Preserve the disposable runtime HOME and data drive + --help Show this help +EOF +} + +RUNTIME="" +DOCKER="" +IMAGE="alpine:3.20" +WORKROOT="${TMPDIR:-/tmp}/dory-managed-drive-evidence" +KEEP=0 +SOURCE_COMMIT="" +CONFIRM="" +RELEASE_CANDIDATE=0 +die() { echo "managed data-drive gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --runtime) need_value "$1" "$#"; RUNTIME="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + --keep-workload) KEEP=1; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "managed data-drive gate: unknown argument: $1" >&2; usage >&2; exit 64 ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-MANAGED-DATA-DRIVE ] \ + || die "requires --confirm ISOLATED-ENGINE-MANAGED-DATA-DRIVE" +for command in curl grep id python3 shasum stat uname uuidgen; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +[ "$(uname -m)" = arm64 ] || { echo "managed data-drive gate: physical Apple silicon is required" >&2; exit 69; } +case "$RUNTIME" in /*) ;; *) die "runtime must be an absolute path" ;; esac +[ -d "$RUNTIME" ] && [ ! -L "$RUNTIME" ] || die "runtime is unavailable or indirect" +RUNTIME="$(cd "$RUNTIME" && pwd -P)" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect" +for runtime_file in dory-engine bin/dory-hv bin/gvproxy bin/dory-dataplane-proxy \ + share/dory/dory-hv-kernel-arm64.lzfse share/dory/dory-engine-rootfs.ext4.lzfse \ + share/dory/dory-agent-linux-arm64; do + [ -f "$RUNTIME/$runtime_file" ] && [ ! -L "$RUNTIME/$runtime_file" ] \ + && [ -s "$RUNTIME/$runtime_file" ] \ + || die "runtime member is missing or indirect: $runtime_file" +done +[ -x "$RUNTIME/dory-engine" ] && [ -x "$RUNTIME/bin/dory-hv" ] \ + || die "runtime launch helpers are not executable" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" + printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "release candidate image must be digest-pinned" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME"|"$RUNTIME"|"$RUNTIME"/*) die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +if [ -n "${DORY_MANAGED_DRIVE_RUNTIME_HOME:-}" ]; then + RUNTIME_HOME="$DORY_MANAGED_DRIVE_RUNTIME_HOME" +else + RUNTIME_HOME_BASE="${DORY_MANAGED_DRIVE_RUNTIME_HOME_BASE:-$HOME}" + RUNTIME_HOME_BASE="$(cd "$RUNTIME_HOME_BASE" 2>/dev/null && pwd -P)" || { + echo "managed data-drive gate: runtime HOME base is unavailable: $RUNTIME_HOME_BASE" >&2 + exit 66 + } + RUNTIME_HOME="$RUNTIME_HOME_BASE/.dmd-$PPID-$$" +fi +case "$RUNTIME_HOME" in /*) ;; *) die "isolated runtime HOME must be an absolute path" ;; esac +DORY_ROOT="$RUNTIME_HOME/.dory" +STATE="$DORY_ROOT/standalone" +FIRST_STATE="$RUNTIME_HOME/.dory-first-standalone-runtime" +DRIVE="$RUNTIME_HOME/Library/Application Support/Dory/Dory.dorydrive" +SELECTION_RECORD="$RUNTIME_HOME/Library/Application Support/Dory/data-drive-selection.json" +SOCKET="$DORY_ROOT/engine.sock" +ENGINE_PIDFILE="$STATE/engine-cli.pid" +DATAPLANE_PIDFILE="$STATE/dataplane-cli.pid" +NAME="dory-drive-${RUN_ID//[^a-zA-Z0-9]/}" +VOLUME="$NAME-volume" +NETWORK="$NAME-network" +MARKER="marker-$RUN_ID" +OTHER_DRIVE="$RUNTIME_HOME/Library/Application Support/Dory/Other.dorydrive" +ALIAS_HOME="${TMPDIR:-/tmp}/dory-md-alias-$PPID-$$" + +[ ! -e "$RUNTIME_HOME" ] && [ ! -L "$RUNTIME_HOME" ] \ + || { echo "managed data-drive gate: isolated HOME exists or is indirect: $RUNTIME_HOME" >&2; exit 73; } +[ ! -e "$ALIAS_HOME" ] && [ ! -L "$ALIAS_HOME" ] \ + || die "temporary alias path already exists or is indirect: $ALIAS_HOME" +mkdir -p "$RUNTIME_HOME" "$EVIDENCE" +python3 - "$SOCKET" "$STATE/hv/docker-backend.sock" <<'PY' +import os, sys +for path in sys.argv[1:]: + if len(os.fsencode(path)) > 103: + raise SystemExit(f"managed data-drive socket path exceeds 103 bytes: {path}") +PY + +cleanup() { + status=$? + set +e + if [ -S "$SOCKET" ]; then + DOCKER_HOST="unix://$SOCKET" "$DOCKER" rm -f "$NAME" >/dev/null 2>&1 || true + DOCKER_HOST="unix://$SOCKET" "$DOCKER" volume rm "$VOLUME" >/dev/null 2>&1 || true + DOCKER_HOST="unix://$SOCKET" "$DOCKER" network rm "$NETWORK" >/dev/null 2>&1 || true + fi + HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >/dev/null 2>&1 || true + rm -f "$ALIAS_HOME" + if [ "$status" -ne 0 ]; then + [ ! -f "$STATE/engine.log" ] || cp "$STATE/engine.log" "$EVIDENCE/failure-engine.log" + [ ! -f "$FIRST_STATE/engine.log" ] || cp "$FIRST_STATE/engine.log" "$EVIDENCE/failure-first-engine.log" + fi + if [ "$KEEP" -ne 1 ]; then rm -rf "$RUNTIME_HOME"; fi + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +seed_provisioning_selection() { + local home="$1" drive="$2" drive_id="$3" + local selection="$home/Library/Application Support/Dory/data-drive-selection.json" + mkdir -p "$(dirname "$selection")" + python3 - "$selection" "$drive" "$drive_id" <<'PY' +import json, pathlib, sys +path, drive, drive_id = sys.argv[1:] +record = { + "canonicalPath": drive, + "driveID": drive_id, + "phase": "provisioning", + "schemaVersion": 2, + "selectedAt": "2026-07-14T00:00:00.000Z", +} +pathlib.Path(path).write_text(json.dumps(record, sort_keys=True) + "\n") +PY + chmod 0600 "$selection" +} + +assert_ready_selection() { + local home="$1" drive="$2" expected_id="$3" + python3 - "$drive/drive.json" \ + "$home/Library/Application Support/Dory/data-drive-selection.json" "$expected_id" <<'PY' +import json, pathlib, sys, uuid +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +selection = json.loads(pathlib.Path(sys.argv[2]).read_text()) +expected = uuid.UUID(sys.argv[3]) +if uuid.UUID(manifest["id"]) != expected: + raise SystemExit("drive manifest identity differs from the expected first-launch authority") +if selection["schemaVersion"] != 2 or selection["phase"] != "ready": + raise SystemExit("selection authority is not a schema-v2 ready record") +if uuid.UUID(selection["driveID"]) != expected: + raise SystemExit("selection authority differs from the drive manifest") +PY +} + +BOOTSTRAP_BEFORE_HOME="$RUN_ROOT/first-launch-before-drive" +BOOTSTRAP_BEFORE_DRIVE="$BOOTSTRAP_BEFORE_HOME/Library/Application Support/Dory/Dory.dorydrive" +mkdir -p "$BOOTSTRAP_BEFORE_HOME" +bootstrap_before_id="$(uuidgen)" +seed_provisioning_selection "$BOOTSTRAP_BEFORE_HOME" "$BOOTSTRAP_BEFORE_DRIVE" "$bootstrap_before_id" +bootstrap_before_partial="$(dirname "$BOOTSTRAP_BEFORE_DRIVE")/.Dory.dorydrive.$( + printf '%s' "$bootstrap_before_id" | tr '[:upper:]' '[:lower:]' +).partial" +mkdir -p "$bootstrap_before_partial" +printf 'abandoned before publication\n' > "$bootstrap_before_partial/abandoned" +HOME="$BOOTSTRAP_BEFORE_HOME" "$RUNTIME/bin/dory-hv" data-drive select "$BOOTSTRAP_BEFORE_DRIVE" \ + >"$EVIDENCE/first-launch-resume-before-drive.txt" +assert_ready_selection "$BOOTSTRAP_BEFORE_HOME" "$BOOTSTRAP_BEFORE_DRIVE" "$bootstrap_before_id" +[ ! -e "$bootstrap_before_partial" ] \ + || { echo "managed data-drive gate: first-launch retry left its partial bundle" >&2; exit 1; } + +BOOTSTRAP_AFTER_HOME="$RUN_ROOT/first-launch-after-drive" +BOOTSTRAP_AFTER_DRIVE="$BOOTSTRAP_AFTER_HOME/Library/Application Support/Dory/Dory.dorydrive" +mkdir -p "$BOOTSTRAP_AFTER_HOME" +bootstrap_after_id="$(HOME="$BOOTSTRAP_AFTER_HOME" "$RUNTIME/bin/dory-hv" data-drive prepare "$BOOTSTRAP_AFTER_DRIVE")" +seed_provisioning_selection "$BOOTSTRAP_AFTER_HOME" "$BOOTSTRAP_AFTER_DRIVE" "$bootstrap_after_id" +HOME="$BOOTSTRAP_AFTER_HOME" "$RUNTIME/bin/dory-hv" data-drive select "$BOOTSTRAP_AFTER_DRIVE" \ + >"$EVIDENCE/first-launch-resume-after-drive.txt" +assert_ready_selection "$BOOTSTRAP_AFTER_HOME" "$BOOTSTRAP_AFTER_DRIVE" "$bootstrap_after_id" + +BOOTSTRAP_MISMATCH_HOME="$RUN_ROOT/first-launch-mismatch" +BOOTSTRAP_MISMATCH_DRIVE="$BOOTSTRAP_MISMATCH_HOME/Library/Application Support/Dory/Dory.dorydrive" +mkdir -p "$BOOTSTRAP_MISMATCH_HOME" +bootstrap_actual_id="$(HOME="$BOOTSTRAP_MISMATCH_HOME" "$RUNTIME/bin/dory-hv" data-drive prepare "$BOOTSTRAP_MISMATCH_DRIVE")" +bootstrap_expected_id="$(uuidgen)" +seed_provisioning_selection "$BOOTSTRAP_MISMATCH_HOME" "$BOOTSTRAP_MISMATCH_DRIVE" "$bootstrap_expected_id" +if HOME="$BOOTSTRAP_MISMATCH_HOME" "$RUNTIME/bin/dory-hv" data-drive select "$BOOTSTRAP_MISMATCH_DRIVE" \ + >"$EVIDENCE/first-launch-mismatch.out" 2>"$EVIDENCE/first-launch-mismatch.err"; then + echo "managed data-drive gate: interrupted first launch adopted a different drive identity" >&2 + exit 1 +fi +grep -F 'identity changed during first-launch provisioning' "$EVIDENCE/first-launch-mismatch.err" >/dev/null +python3 - "$BOOTSTRAP_MISMATCH_DRIVE/drive.json" \ + "$BOOTSTRAP_MISMATCH_HOME/Library/Application Support/Dory/data-drive-selection.json" \ + "$bootstrap_actual_id" "$bootstrap_expected_id" <<'PY' +import json, pathlib, sys, uuid +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +selection = json.loads(pathlib.Path(sys.argv[2]).read_text()) +if uuid.UUID(manifest["id"]) != uuid.UUID(sys.argv[3]): + raise SystemExit("mismatch fixture changed the actual drive identity") +if selection["phase"] != "provisioning": + raise SystemExit("mismatch fixture unexpectedly advanced selection authority") +if uuid.UUID(selection["driveID"]) != uuid.UUID(sys.argv[4]): + raise SystemExit("mismatch fixture changed the expected drive identity") +PY + +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DRIVE" \ + >"$EVIDENCE/first-start.log" 2>&1 +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] \ + || die "engine socket is missing or indirect after first start" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "engine socket is not owned by the release user" +[ -f "$DRIVE/drive.json" ] || { echo "managed data-drive gate: manifest missing" >&2; exit 1; } +for directory in engine kubernetes machines snapshots exports operations; do + [ -d "$DRIVE/$directory" ] \ + || { echo "managed data-drive gate: durable directory missing: $directory" >&2; exit 1; } +done +[ -s "$STATE/data-drive.id" ] \ + || { echo "managed data-drive gate: drive UUID ownership record missing" >&2; exit 1; } +[ -s "$SELECTION_RECORD" ] \ + || { echo "managed data-drive gate: durable selected-drive record missing" >&2; exit 1; } +selection_hash_before="$(shasum -a 256 "$SELECTION_RECORD" | awk '{print $1}')" +cp "$SELECTION_RECORD" "$EVIDENCE/selection-before-runtime-reset.json" +[ ! -e "$DRIVE/engine/docker-data.ext4.partial" ] \ + || { echo "managed data-drive gate: first-launch disk transaction was not finalized" >&2; exit 1; } +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" status >"$EVIDENCE/first-status.log" +grep -F "data drive: $DRIVE" "$EVIDENCE/first-status.log" >/dev/null \ + || { echo "managed data-drive gate: status lost the selected drive" >&2; exit 1; } + +# Rebuild lost launcher metadata only when the actual live dory-hv command is bound to the exact +# requested drive. This reproduces an interrupted metadata flush without interrupting the VM. +original_engine_pid="$(cat "$ENGINE_PIDFILE")" +original_dataplane_pid="$(cat "$DATAPLANE_PIDFILE")" +rm -f "$STATE/data-drive.path" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DRIVE" \ + >"$EVIDENCE/lost-identity-same-drive.log" 2>&1 +grep -F "already running (pid $original_engine_pid)" "$EVIDENCE/lost-identity-same-drive.log" >/dev/null +[ "$(cat "$STATE/data-drive.path")" = "$DRIVE" ] \ + || { echo "managed data-drive gate: exact live drive identity was not restored" >&2; exit 1; } +[ "$(cat "$ENGINE_PIDFILE")" = "$original_engine_pid" ] +[ "$(cat "$DATAPLANE_PIDFILE")" = "$original_dataplane_pid" ] + +rm -f "$STATE/data-drive.path" +if HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$OTHER_DRIVE" \ + >"$EVIDENCE/lost-identity-mismatch.out" 2>"$EVIDENCE/lost-identity-mismatch.err"; then + echo "managed data-drive gate: missing metadata silently adopted a different drive" >&2 + exit 1 +fi +grep -F 'running engine data drive does not match requested' \ + "$EVIDENCE/lost-identity-mismatch.err" >/dev/null +[ ! -e "$OTHER_DRIVE" ] \ + || { echo "managed data-drive gate: rejected alternate drive was modified" >&2; exit 1; } +[ ! -e "$STATE/data-drive.path" ] \ + || { echo "managed data-drive gate: mismatched live identity was recorded" >&2; exit 1; } +[ "$(cat "$ENGINE_PIDFILE")" = "$original_engine_pid" ] +[ "$(cat "$DATAPLANE_PIDFILE")" = "$original_dataplane_pid" ] +curl -sf --max-time 2 --unix-socket "$SOCKET" http://d/_ping \ + >"$EVIDENCE/lost-identity-mismatch-api.txt" +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DRIVE" \ + >"$EVIDENCE/lost-identity-repair.log" 2>&1 +[ "$(cat "$STATE/data-drive.path")" = "$DRIVE" ] + +if HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start \ + --data-drive "$OTHER_DRIVE" \ + >"$EVIDENCE/drive-mismatch.out" 2>"$EVIDENCE/drive-mismatch.err"; then + echo "managed data-drive gate: running engine accepted a different drive" >&2 + exit 1 +fi +grep -F 'already running with data drive' "$EVIDENCE/drive-mismatch.err" >/dev/null +[ ! -e "$OTHER_DRIVE" ] \ + || { echo "managed data-drive gate: rejected alternate drive was modified" >&2; exit 1; } + +READ_ONLY_PARENT="$RUNTIME_HOME/Library/Application Support/Dory/ReadOnly" +UNWRITABLE_DRIVE="$READ_ONLY_PARENT/Blocked.dorydrive" +mkdir -p "$READ_ONLY_PARENT" +chmod 0500 "$READ_ONLY_PARENT" +set +e +HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" engine --data-drive "$UNWRITABLE_DRIVE" \ + >"$EVIDENCE/unwritable-drive.out" 2>"$EVIDENCE/unwritable-drive.err" +unwritable_rc=$? +set -e +chmod 0700 "$READ_ONLY_PARENT" +[ "$unwritable_rc" -eq 1 ] \ + || { echo "managed data-drive gate: unwritable drive did not fail cleanly (rc=$unwritable_rc)" >&2; exit 1; } +grep -F 'invalid Dory data drive: prepare Dory data drive' \ + "$EVIDENCE/unwritable-drive.err" >/dev/null +[ ! -e "$UNWRITABLE_DRIVE" ] \ + || { echo "managed data-drive gate: unwritable drive left a partial bundle" >&2; exit 1; } +curl -sf --max-time 2 --unix-socket "$SOCKET" http://d/_ping \ + >"$EVIDENCE/unwritable-drive-api.txt" + +MISSING="/Volumes/DoryMissing-$PPID-$$/Dory.dorydrive" +if HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" engine --data-drive "$MISSING" \ + >"$EVIDENCE/missing-volume.out" 2>"$EVIDENCE/missing-volume.err"; then + echo "managed data-drive gate: missing external volume was accepted" >&2 + exit 1 +fi +grep -F 'data drive volume is not mounted' "$EVIDENCE/missing-volume.err" >/dev/null + +ln -s "$RUNTIME_HOME" "$ALIAS_HOME" +ALIAS_DRIVE="$ALIAS_HOME/Library/Application Support/Dory/Dory.dorydrive" +if HOME="$RUNTIME_HOME" "$RUNTIME/bin/dory-hv" engine \ + --state-dir "$RUNTIME_HOME/second-state" --data-drive "$ALIAS_DRIVE" \ + --kernel "$STATE/vm/dory-hv-kernel-arm64" --gvproxy "$RUNTIME/bin/gvproxy" \ + --rootfs "$STATE/vm/dory-engine-rootfs.ext4" --engine-sock "$RUNTIME_HOME/second.sock" \ + >"$EVIDENCE/second-attach.out" 2>"$EVIDENCE/second-attach.err"; then + echo "managed data-drive gate: a second engine attached the live drive" >&2 + exit 1 +fi +grep -E 'lock|locked|in use' "$EVIDENCE/second-attach.err" >/dev/null + +export DOCKER_HOST="unix://$SOCKET" +if ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + [ "$RELEASE_CANDIDATE" -eq 0 ] \ + || die "release candidate image is not preloaded in the isolated engine" + "$DOCKER" pull "$IMAGE" >"$EVIDENCE/pull.log" 2>&1 +fi +"$DOCKER" volume create "$VOLUME" >"$EVIDENCE/volume-create.txt" +"$DOCKER" network create "$NETWORK" >"$EVIDENCE/network-create.txt" +"$DOCKER" run -d --name "$NAME" --network "$NETWORK" -v "$VOLUME:/proof" "$IMAGE" \ + sh -c "printf '%s' '$MARKER-volume' > /proof/marker; printf '%s' '$MARKER-layer' > /layer-marker; sleep 86400" \ + >"$EVIDENCE/container-create.txt" +[ "$("$DOCKER" exec "$NAME" cat /proof/marker)" = "$MARKER-volume" ] +[ "$("$DOCKER" exec "$NAME" cat /layer-marker)" = "$MARKER-layer" ] +"$DOCKER" stop "$NAME" >"$EVIDENCE/container-stop.txt" +"$DOCKER" image inspect "$IMAGE" >"$EVIDENCE/image-before.json" +"$DOCKER" container inspect "$NAME" >"$EVIDENCE/container-before.json" +"$DOCKER" volume inspect "$VOLUME" >"$EVIDENCE/volume-before.json" +"$DOCKER" network inspect "$NETWORK" >"$EVIDENCE/network-before.json" +image_id_before="$("$DOCKER" image inspect -f '{{.Id}}' "$IMAGE")" +container_id_before="$("$DOCKER" container inspect -f '{{.Id}}' "$NAME")" +network_id_before="$("$DOCKER" network inspect -f '{{.Id}}' "$NETWORK")" + +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >"$EVIDENCE/first-stop.log" 2>&1 +cp "$STATE/engine.log" "$EVIDENCE/first-engine.log" +mv "$STATE" "$FIRST_STATE" +[ -s "$SELECTION_RECORD" ] \ + || { echo "managed data-drive gate: replacing runtime state removed the drive selection" >&2; exit 1; } + +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start \ + >"$EVIDENCE/restart-with-fresh-runtime.log" 2>&1 +[ "$(shasum -a 256 "$SELECTION_RECORD" | awk '{print $1}')" = "$selection_hash_before" ] \ + || { echo "managed data-drive gate: runtime reset changed stable selected-drive authority" >&2; exit 1; } +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" status >"$EVIDENCE/status-after-runtime-reset.log" +grep -F "data drive: $DRIVE" "$EVIDENCE/status-after-runtime-reset.log" >/dev/null \ + || { echo "managed data-drive gate: fresh runtime did not recover its durable drive" >&2; exit 1; } +export DOCKER_HOST="unix://$SOCKET" +"$DOCKER" image inspect "$IMAGE" >"$EVIDENCE/image-after.json" +"$DOCKER" container inspect "$NAME" >"$EVIDENCE/container-after.json" +"$DOCKER" volume inspect "$VOLUME" >"$EVIDENCE/volume-after.json" +"$DOCKER" network inspect "$NETWORK" >"$EVIDENCE/network-after.json" +[ "$("$DOCKER" image inspect -f '{{.Id}}' "$IMAGE")" = "$image_id_before" ] \ + || die "transient runtime replacement changed the image identity" +[ "$("$DOCKER" container inspect -f '{{.Id}}' "$NAME")" = "$container_id_before" ] \ + || die "transient runtime replacement changed the container identity" +[ "$("$DOCKER" network inspect -f '{{.Id}}' "$NETWORK")" = "$network_id_before" ] \ + || die "transient runtime replacement changed the network identity" +"$DOCKER" start "$NAME" >"$EVIDENCE/container-restart.txt" +[ "$("$DOCKER" exec "$NAME" cat /proof/marker)" = "$MARKER-volume" ] +[ "$("$DOCKER" exec "$NAME" cat /layer-marker)" = "$MARKER-layer" ] +[ "$("$DOCKER" inspect -f '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' "$NAME")" = \ + "$("$DOCKER" network inspect -f '{{.Id}}' "$NETWORK")" ] + +"$DOCKER" rm -f "$NAME" >/dev/null +"$DOCKER" volume rm "$VOLUME" >/dev/null +"$DOCKER" network rm "$NETWORK" >/dev/null +HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" stop >"$EVIDENCE/final-stop.log" 2>&1 +cp "$STATE/engine.log" "$EVIDENCE/final-engine.log" +cp "$DRIVE/drive.json" "$EVIDENCE/drive.json" + +# A detached, renamed, or replaced selected drive must never turn into a fresh empty store at the +# remembered path. Keep the original as rollback while proving the stopped launcher fails closed. +PARKED_DRIVE="$DRIVE.parked" +mv "$DRIVE" "$PARKED_DRIVE" +if HOME="$RUNTIME_HOME" "$RUNTIME/dory-engine" start --data-drive "$DRIVE" \ + >"$EVIDENCE/stopped-missing-drive.out" 2>"$EVIDENCE/stopped-missing-drive.err"; then + echo "managed data-drive gate: stopped runtime silently created a replacement selected drive" >&2 + exit 1 +fi +grep -F 'refusing to create a replacement' "$EVIDENCE/stopped-missing-drive.err" >/dev/null +[ ! -e "$DRIVE" ] \ + || { echo "managed data-drive gate: missing selected drive left a shadow replacement" >&2; exit 1; } +mv "$PARKED_DRIVE" "$DRIVE" + +python3 - "$EVIDENCE/drive.json" "$SELECTION_RECORD" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +selection = json.loads(pathlib.Path(sys.argv[2]).read_text()) +import datetime, uuid +if set(data) != {"createdAt", "id", "kind", "product", "schemaVersion"}: + raise SystemExit("drive manifest has an unexpected shape") +if data["kind"] != "dev.dory.data-drive" or data["schemaVersion"] != 1 or data["product"] != "Dory": + raise SystemExit("drive manifest has an unexpected contract") +drive_id = uuid.UUID(data["id"]) +if datetime.datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")).tzinfo is None: + raise SystemExit("drive manifest timestamp is not timezone-aware") +if selection["schemaVersion"] != 2 or selection["phase"] != "ready": + raise SystemExit("selection authority has an unexpected shape") +if uuid.UUID(selection["driveID"]) != drive_id: + raise SystemExit("selection authority differs from the drive manifest") +PY + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + printf 'status=PASS\n' + printf 'architecture=arm64\n' + printf 'fresh_drive_default=PASS\n' + printf 'first_launch_resume_before_drive=PASS\n' + printf 'first_launch_resume_after_drive=PASS\n' + printf 'first_launch_identity_mismatch_rejected=PASS\n' + printf 'explicit_drive_status=PASS\n' + printf 'running_drive_mismatch_rejected=PASS\n' + printf 'lost_drive_identity_recovered=PASS\n' + printf 'lost_drive_identity_mismatch_rejected=PASS\n' + printf 'alternate_drive_untouched=PASS\n' + printf 'unwritable_drive_rejected_cleanly=PASS\n' + printf 'missing_external_drive_rejected=PASS\n' + printf 'concurrent_attach_rejected=PASS\n' + printf 'alias_concurrent_attach_rejected=PASS\n' + printf 'manifest_uuid_identity=PASS\n' + printf 'stopped_missing_selected_drive_rejected=PASS\n' + printf 'image_persistence=PASS\n' + printf 'container_writable_layer_persistence=PASS\n' + printf 'named_volume_persistence=PASS\n' + printf 'custom_network_persistence=PASS\n' + printf 'transient_runtime_replacement=PASS\n' + printf 'durable_selection_survives_runtime_reset=PASS\n' + printf 'exact_image_identity=PASS\n' + printf 'exact_container_identity=PASS\n' + printf 'exact_network_identity=PASS\n' + printf 'same_user_engine_socket=PASS\n' + printf 'release_qualifying=%s\n' "$release_qualifying" + [ -z "$SOURCE_COMMIT" ] || printf 'source_commit=%s\n' "$SOURCE_COMMIT" + printf 'docker_cli_sha256=%s\n' "$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + printf 'dory_engine_sha256=%s\n' "$(shasum -a 256 "$RUNTIME/dory-engine" | awk '{print $1}')" + printf 'dory_hv_sha256=%s\n' "$(shasum -a 256 "$RUNTIME/bin/dory-hv" | awk '{print $1}')" + printf 'gvproxy_sha256=%s\n' "$(shasum -a 256 "$RUNTIME/bin/gvproxy" | awk '{print $1}')" + printf 'dataplane_proxy_sha256=%s\n' \ + "$(shasum -a 256 "$RUNTIME/bin/dory-dataplane-proxy" | awk '{print $1}')" + printf 'kernel_asset_sha256=%s\n' \ + "$(shasum -a 256 "$RUNTIME/share/dory/dory-hv-kernel-arm64.lzfse" | awk '{print $1}')" + printf 'rootfs_asset_sha256=%s\n' \ + "$(shasum -a 256 "$RUNTIME/share/dory/dory-engine-rootfs.ext4.lzfse" | awk '{print $1}')" + printf 'agent_asset_sha256=%s\n' \ + "$(shasum -a 256 "$RUNTIME/share/dory/dory-agent-linux-arm64" | awk '{print $1}')" + printf 'image_id=%s\n' "$image_id_before" + printf 'drive_manifest_sha256=%s\n' "$(shasum -a 256 "$EVIDENCE/drive.json" | awk '{print $1}')" + printf 'selection_authority_sha256=%s\n' "$(shasum -a 256 "$SELECTION_RECORD" | awk '{print $1}')" + stat -f 'drive_logical_bytes=%z' "$DRIVE/engine/docker-data.ext4" + printf 'drive_allocated_bytes=%s\n' "$(( $(stat -f '%b' "$DRIVE/engine/docker-data.ext4") * 512 ))" +} >"$EVIDENCE/summary.txt" + +echo "managed data-drive gate: PASS ($EVIDENCE/summary.txt)" From f42ae85b07f09c60b446b81d5aade6996b74c16c Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:01:53 +0000 Subject: [PATCH 141/338] feat(release): qualify native IPv6 --- .github/scripts/test-native-ipv6-gate.py | 103 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/native-ipv6-gate.sh | 389 +++++++++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100755 .github/scripts/test-native-ipv6-gate.py create mode 100755 scripts/native-ipv6-gate.sh diff --git a/.github/scripts/test-native-ipv6-gate.py b/.github/scripts/test-native-ipv6-gate.py new file mode 100755 index 00000000..59b19671 --- /dev/null +++ b/.github/scripts/test-native-ipv6-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for exact native-IPv6 release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "native-ipv6-gate.sh" + + +class NativeIPv6GateTests(unittest.TestCase): + def test_gate_binds_candidate_network_contract_and_external_route(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-NATIVE-IPV6", + "Apple silicon is required", + "candidate inputs must use absolute paths", + "missing or indirect input", + "workroot already exists or is indirect", + "external IPv6 endpoint is invalid", + "release candidate evidence requires --source-commit", + "release candidate evidence requires --require-external", + "release candidate image must be digest-pinned", + "release candidate evidence requires signed gvproxy provenance and payload inventory", + "signed gvproxy authority is missing or indirect", + "dory_verify_signed_gvproxy_payload", + "DOCKER_CONTEXT", + 'DOCKER_HOST="unix://$SOCKET"', + "--direct-ipv6", + "default bridge does not enable IPv6", + "fd7d:6f72:7901::/64", + "Cloudflare AAAA resolution is missing", + "registry AAAA resolution is missing", + "fd7d:6f72:7900::1", + "--noproxy '*'", + "dual-stack localhost publishing failed", + "engine socket is not owned by the release user", + "engine restart changed the exact fixture image identity", + "release candidate image is not preloaded", + "Mac has an IPv6 route but container TCP failed", + "external_ipv6_tcp=", + "exact_image_identity=PASS", + "same_user_engine_socket=PASS", + "dory_hv_sha256=", + "gvproxy_input_sha256=", + "kernel_input_sha256=", + "rootfs_input_sha256=", + "docker_cli_sha256=", + "network_contract_sha256=", + "guest_network_log_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + 'run --rm alpine:3.20', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_architecture_inputs_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--dory-hv", + "/missing/dory-hv", + "--gvproxy", + "/missing/gvproxy", + "--kernel", + "/missing/kernel", + "--rootfs", + "/missing/rootfs", + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0505364e..43ed058a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -365,6 +365,7 @@ jobs: python3 .github/scripts/test-offline-bundled-boot-gate.py python3 .github/scripts/test-competitor-runtime-regression-gate.py python3 .github/scripts/test-managed-data-drive-gate.py + python3 .github/scripts/test-native-ipv6-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21560fab..cac7fa3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -66,6 +66,7 @@ jobs: python3 .github/scripts/test-offline-bundled-boot-gate.py python3 .github/scripts/test-competitor-runtime-regression-gate.py python3 .github/scripts/test-managed-data-drive-gate.py + python3 .github/scripts/test-native-ipv6-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/native-ipv6-gate.sh b/scripts/native-ipv6-gate.sh new file mode 100755 index 00000000..0c9e721d --- /dev/null +++ b/scripts/native-ipv6-gate.sh @@ -0,0 +1,389 @@ +#!/bin/bash +# Proves Dory's native container IPv6 contract against one isolated Apple Silicon engine. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HV="" +GVPROXY="" +GVPROXY_PROVENANCE="" +PAYLOAD_INVENTORY="" +KERNEL="" +ROOTFS="" +DOCKER="" +IMAGE="alpine:3.20" +WORKROOT="${TMPDIR:-/tmp}/dory-native-ipv6-evidence" +EXTERNAL_IPV6="2606:4700:4700::1111" +REQUIRE_EXTERNAL=0 +KEEP=0 +SOURCE_COMMIT="" +CONFIRM="" +RELEASE_CANDIDATE=0 + +usage() { + cat <<'EOF' +Usage: scripts/native-ipv6-gate.sh --dory-hv PATH --gvproxy PATH --kernel PATH --rootfs PATH --docker PATH [options] + +Options: + --workroot DIR Evidence root + --image REF Preloaded Alpine-compatible fixture image + --source-commit SHA Exact 40-character source commit for release evidence + --confirm TOKEN Must be ISOLATED-ENGINE-NATIVE-IPV6 + --release-candidate Require signed, digest-pinned release inputs + --gvproxy-provenance PATH Signed-app gvproxy build provenance + --payload-inventory PATH Signed-app payload digest inventory + --external-ipv6 IP Real IPv6 TCP endpoint used on a host with IPv6 routing + --require-external Fail unless the Mac has IPv6 routing and the container reaches the endpoint + --keep-workload Preserve disposable engine files +EOF +} + +die() { echo "native IPv6 gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --dory-hv) need_value "$1" "$#"; HV="$2"; shift 2 ;; + --gvproxy) need_value "$1" "$#"; GVPROXY="$2"; shift 2 ;; + --gvproxy-provenance) need_value "$1" "$#"; GVPROXY_PROVENANCE="$2"; shift 2 ;; + --payload-inventory) need_value "$1" "$#"; PAYLOAD_INVENTORY="$2"; shift 2 ;; + --kernel) need_value "$1" "$#"; KERNEL="$2"; shift 2 ;; + --rootfs) need_value "$1" "$#"; ROOTFS="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --external-ipv6) need_value "$1" "$#"; EXTERNAL_IPV6="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + --require-external) REQUIRE_EXTERNAL=1; shift ;; + --keep-workload) KEEP=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "native IPv6 gate: unknown argument: $1" >&2; usage >&2; exit 64 ;; + esac +done + +[ "$CONFIRM" = ISOLATED-ENGINE-NATIVE-IPV6 ] \ + || die "requires --confirm ISOLATED-ENGINE-NATIVE-IPV6" +for command in curl id nc ps python3 seq shasum stat uname; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +[ "$(uname -m)" = arm64 ] || { echo "native IPv6 gate: Apple silicon is required" >&2; exit 69; } +for path in "$HV" "$GVPROXY" "$KERNEL" "$ROOTFS" "$DOCKER"; do + case "$path" in /*) ;; *) die "candidate inputs must use absolute paths" ;; esac + [ -f "$path" ] && [ ! -L "$path" ] && [ -s "$path" ] \ + || { echo "native IPv6 gate: missing or indirect input: $path" >&2; exit 66; } +done +[ -x "$HV" ] && [ -x "$GVPROXY" ] && [ -x "$DOCKER" ] \ + || { echo "native IPv6 gate: helper inputs must be executable" >&2; exit 66; } +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +python3 - "$EXTERNAL_IPV6" <<'PY' +import ipaddress +import sys + +try: + ipaddress.IPv6Address(sys.argv[1]) +except ValueError as error: + raise SystemExit(f"external IPv6 endpoint is invalid: {error}") +PY +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" + [ "$REQUIRE_EXTERNAL" -eq 1 ] || die "release candidate evidence requires --require-external" + printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "release candidate image must be digest-pinned" + [ -n "$GVPROXY_PROVENANCE" ] && [ -n "$PAYLOAD_INVENTORY" ] \ + || die "release candidate evidence requires signed gvproxy provenance and payload inventory" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" + +# shellcheck source=gvproxy-payload.sh +source "$ROOT/scripts/gvproxy-payload.sh" +dory_gvproxy_validate_overrides +if [ -n "$GVPROXY_PROVENANCE$PAYLOAD_INVENTORY" ]; then + [ -n "$GVPROXY_PROVENANCE" ] && [ -n "$PAYLOAD_INVENTORY" ] \ + || { echo "native IPv6 gate: signed gvproxy provenance and payload inventory must be supplied together" >&2; exit 64; } + for authority_path in "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY"; do + case "$authority_path" in /*) ;; *) die "signed gvproxy authority must use absolute paths" ;; esac + [ -f "$authority_path" ] && [ ! -L "$authority_path" ] && [ -s "$authority_path" ] \ + || die "signed gvproxy authority is missing or indirect: $authority_path" + done + dory_verify_signed_gvproxy_payload "$GVPROXY" "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY" +else + dory_verify_gvproxy_payload \ + "$GVPROXY" "$(dory_gvproxy_version)" "$(dory_gvproxy_expected_sha256)" +fi +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" +input_hv_sha="$(shasum -a 256 "$HV" | awk '{print $1}')" +input_gvproxy_sha="$(shasum -a 256 "$GVPROXY" | awk '{print $1}')" +input_kernel_sha="$(shasum -a 256 "$KERNEL" | awk '{print $1}')" +input_rootfs_sha="$(shasum -a 256 "$ROOTFS" | awk '{print $1}')" +input_docker_sha="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +HOME_BASE="${DORY_NATIVE_IPV6_HOME_BASE:-$HOME}" +HOME_BASE="$(cd "$HOME_BASE" 2>/dev/null && pwd -P)" || { + echo "native IPv6 gate: runtime HOME base is unavailable: $HOME_BASE" >&2 + exit 66 +} +HOME_ROOT="$HOME_BASE/.dni6-$$" +STATE="$HOME_ROOT/s" +DRIVE="$HOME_ROOT/Library/Application Support/Dory/Dory.dorydrive" +SOCKET="$HOME_ROOT/e.sock" +PORT_FILE="$HOME_ROOT/host-port" +ENGINE_PID="" +HOST_PID="" +[ ! -e "$HOME_ROOT" ] || { + echo "native IPv6 gate: isolated runtime HOME already exists: $HOME_ROOT" >&2 + exit 73 +} +[ ! -L "$HOME_ROOT" ] || die "isolated runtime HOME is indirect: $HOME_ROOT" +python3 - "$SOCKET" "$STATE/docker-backend.sock" "$STATE/gvproxy-api.sock" <<'PY' +import os +import sys + +for path in sys.argv[1:]: + length = len(os.fsencode(path)) + if length > 103: + raise SystemExit(f"native IPv6 Unix socket path is {length} bytes (limit 103): {path}") +PY +mkdir -p "$EVIDENCE" "$HOME_ROOT" + +prepare_asset() { + source_path="$1" + output_name="$2" + case "$source_path" in + *.lzfse) + prepared="$HOME_ROOT/$output_name" + "$HV" lzfse decompress "$source_path" "$prepared" > "$EVIDENCE/decompress-$output_name.log" + printf '%s\n' "$prepared" + ;; + *) printf '%s\n' "$source_path" ;; + esac +} +KERNEL="$(prepare_asset "$KERNEL" kernel)" +ROOTFS="$(prepare_asset "$ROOTFS" rootfs.ext4)" + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} + +stop_engine() { + [ -n "$ENGINE_PID" ] || return 0 + cycle_pid="$ENGINE_PID" + ps -axo pid=,ppid=,command= | awk -v parent="$cycle_pid" '$2 == parent { print }' \ + > "$EVIDENCE/engine-children-before-stop-$cycle_pid.txt" + gvproxy_pids="$(awk '/gvproxy/ { print $1 }' "$EVIDENCE/engine-children-before-stop-$cycle_pid.txt")" + if kill -0 "$ENGINE_PID" 2>/dev/null; then kill -TERM "$ENGINE_PID" 2>/dev/null || true; fi + for _ in $(seq 1 300); do + kill -0 "$ENGINE_PID" 2>/dev/null || { + wait "$ENGINE_PID" 2>/dev/null || true + ENGINE_PID="" + for child_pid in $gvproxy_pids; do + if kill -0 "$child_pid" 2>/dev/null; then + echo "native IPv6 gate: gvproxy child $child_pid survived engine shutdown" >&2 + return 1 + fi + done + return 0 + } + sleep 0.1 + done + kill -KILL "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true + ENGINE_PID="" + return 1 +} + +cleanup() { + status=$? + set +e + stop_engine + [ -z "$HOST_PID" ] || kill "$HOST_PID" 2>/dev/null || true + [ -z "$HOST_PID" ] || wait "$HOST_PID" 2>/dev/null || true + if [ "$KEEP" -ne 1 ]; then rm -rf "$HOME_ROOT"; fi + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +python3 - "$PORT_FILE" <<'PY' & +import pathlib, socket, sys +s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) +s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("::1", 0)) +s.listen(32) +pathlib.Path(sys.argv[1]).write_text(str(s.getsockname()[1])) +payload = b"dory-ipv6-loop\n" +response = b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\n" + payload +while True: + conn, _ = s.accept() + with conn: + conn.recv(65536) + conn.sendall(response) +PY +HOST_PID=$! +for _ in $(seq 1 100); do [ -s "$PORT_FILE" ] && break; sleep 0.05; done +[ -s "$PORT_FILE" ] || { echo "native IPv6 gate: host listener did not start" >&2; exit 1; } +HOST_PORT="$(cat "$PORT_FILE")" + +start_engine() { + cycle="$1" + HOME="$HOME_ROOT" "$HV" engine \ + --state-dir "$STATE" --data-drive "$DRIVE" \ + --kernel "$KERNEL" --gvproxy "$GVPROXY" --rootfs "$ROOTFS" \ + --engine-sock "$SOCKET" --direct-ipv6 \ + >"$EVIDENCE/engine-$cycle.log" 2>&1 & + ENGINE_PID=$! + for _ in $(seq 1 180); do + kill -0 "$ENGINE_PID" 2>/dev/null || { + echo "native IPv6 gate: engine exited during $cycle" >&2 + tail -n 100 "$EVIDENCE/engine-$cycle.log" >&2 + exit 1 + } + docker_e info >/dev/null 2>&1 && return 0 + sleep 1 + done + echo "native IPv6 gate: engine readiness timed out during $cycle" >&2 + exit 1 +} + +verify_cycle() { + cycle="$1" + kill -0 "$HOST_PID" 2>/dev/null || { + echo "native IPv6 gate: host IPv6 listener exited before $cycle verification" >&2 + exit 1 + } + host_response="$(curl --noproxy '*' -gfsS --connect-timeout 2 "http://[::1]:$HOST_PORT/")" + printf '%s\n' "$host_response" > "$EVIDENCE/host-listener-$cycle.txt" + [ "$host_response" = dory-ipv6-loop ] || { + echo "native IPv6 gate: host IPv6 listener failed before $cycle verification" >&2 + exit 1 + } + docker_e network inspect bridge > "$EVIDENCE/bridge-$cycle.json" + docker_e run --rm "$IMAGE" sh -ec " + ip -6 address show dev eth0 + ip -6 route show + nslookup -type=AAAA one.one.one.one + nslookup -type=AAAA registry-1.docker.io + test \"\$(wget -T 10 -qO- 'http://[fd7d:6f72:7900::1]:$HOST_PORT/')\" = dory-ipv6-loop + " > "$EVIDENCE/container-$cycle.txt" + python3 - "$EVIDENCE/bridge-$cycle.json" "$EVIDENCE/container-$cycle.txt" <<'PY' +import json, pathlib, sys +bridge = json.loads(pathlib.Path(sys.argv[1]).read_text())[0] +text = pathlib.Path(sys.argv[2]).read_text() +if bridge["EnableIPv6"] is not True: + raise SystemExit("default bridge does not enable IPv6") +if not any(x.get("Subnet") == "fd7d:6f72:7901::/64" for x in bridge["IPAM"]["Config"]): + raise SystemExit("default bridge omits the exact qualified IPv6 subnet") +if "inet6 fd7d:6f72:7901::" not in text: + raise SystemExit("container does not have an address on the qualified IPv6 subnet") +if "2606:4700:4700::" not in text: + raise SystemExit("Cloudflare AAAA resolution is missing") +if "2600:1f18:" not in text: + raise SystemExit("registry AAAA resolution is missing") +PY + + name="dory-ni6-publish-$cycle-$$" + host_port="$(python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close() +PY +)" + docker_e run -d --name "$name" -p "$host_port:8080" "$IMAGE" sh -c \ + "while true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\ndory-port' | nc -l -p 8080; done" \ + > "$EVIDENCE/published-$cycle.id" + published_ok=0 + for _ in $(seq 1 60); do + if [ "$(curl --noproxy '*' -fsS --connect-timeout 2 "http://127.0.0.1:$host_port/" 2>/dev/null)" = dory-port ] \ + && [ "$(curl --noproxy '*' -gfsS --connect-timeout 2 "http://[::1]:$host_port/" 2>/dev/null)" = dory-port ]; then + published_ok=1 + break + fi + sleep 1 + done + docker_e rm -f "$name" >/dev/null + [ "$published_ok" = 1 ] || { echo "native IPv6 gate: dual-stack localhost publishing failed" >&2; exit 1; } +} + +start_engine first +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "engine socket is missing or indirect" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "engine socket is not owned by the release user" +if ! docker_e image inspect "$IMAGE" >/dev/null 2>&1; then + [ "$RELEASE_CANDIDATE" -eq 0 ] || die "release candidate image is not preloaded" + docker_e pull "$IMAGE" > "$EVIDENCE/pull.log" +fi +image_id="$(docker_e image inspect -f '{{.Id}}' "$IMAGE")" +verify_cycle first +stop_engine +start_engine restart +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "restart socket is missing or indirect" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] \ + || die "restart socket is not owned by the release user" +[ "$(docker_e image inspect -f '{{.Id}}' "$IMAGE")" = "$image_id" ] \ + || die "engine restart changed the exact fixture image identity" +verify_cycle restart + +EXTERNAL_RESULT=SKIP +if nc -6 -z -w 10 "$EXTERNAL_IPV6" 443 >/dev/null 2>&1; then + if docker_e run --rm "$IMAGE" \ + nc -z -w 15 "$EXTERNAL_IPV6" 443 > "$EVIDENCE/external-ipv6.out" 2> "$EVIDENCE/external-ipv6.err"; then + EXTERNAL_RESULT=PASS + else + echo "native IPv6 gate: Mac has an IPv6 route but container TCP failed" >&2 + exit 1 + fi +elif [ "$REQUIRE_EXTERNAL" = 1 ]; then + echo "native IPv6 gate: --require-external needs a real host IPv6 route" >&2 + exit 1 +fi + +stop_engine +cp "$STATE/gvproxy-dual-stack.yaml" "$EVIDENCE/gvproxy-dual-stack.yaml" +cp "$STATE/guest-logs/network.log" "$EVIDENCE/guest-network.log" +release_qualifying=false +if [ "$RELEASE_CANDIDATE" -eq 1 ] && [ "$EXTERNAL_RESULT" = PASS ]; then + release_qualifying=true +fi +{ + echo status=PASS + echo architecture=arm64 + echo gvproxy_version="$(dory_gvproxy_version)" + echo gvproxy_sha256="$(dory_gvproxy_file_sha256 "$GVPROXY")" + echo gvproxy_build_sha256="$(dory_gvproxy_expected_sha256)" + echo fresh_boot=PASS + echo restart=PASS + echo docker_bridge_ipv6=PASS + echo container_global_ipv6=PASS + echo dns_aaaa=PASS + echo registry_aaaa=PASS + echo ipv6_tcp_loopback=PASS + echo ipv6_localhost_publish=PASS + echo external_ipv6_tcp="$EXTERNAL_RESULT" + echo exact_image_identity=PASS + echo same_user_engine_socket=PASS + echo source_commit="$SOURCE_COMMIT" + echo dory_hv_sha256="$input_hv_sha" + echo gvproxy_input_sha256="$input_gvproxy_sha" + echo kernel_input_sha256="$input_kernel_sha" + echo rootfs_input_sha256="$input_rootfs_sha" + echo docker_cli_sha256="$input_docker_sha" + echo image_id="$image_id" + echo network_contract_sha256="$(shasum -a 256 "$EVIDENCE/gvproxy-dual-stack.yaml" | awk '{print $1}')" + echo guest_network_log_sha256="$(shasum -a 256 "$EVIDENCE/guest-network.log" | awk '{print $1}')" + echo release_qualifying="$release_qualifying" +} > "$EVIDENCE/manifest.txt" +echo "native IPv6 gate: PASS ($EVIDENCE/manifest.txt)" From c16466325a7351abc4c55c39f6ffae18b75be5ab Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:04:40 +0000 Subject: [PATCH 142/338] feat(release): qualify non-native exec conformance --- .../test-nonnative-exec-conformance-gate.py | 103 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/nonnative-exec-conformance-gate.sh | 360 ++++++++++++++++++ 4 files changed, 465 insertions(+) create mode 100755 .github/scripts/test-nonnative-exec-conformance-gate.py create mode 100755 scripts/nonnative-exec-conformance-gate.sh diff --git a/.github/scripts/test-nonnative-exec-conformance-gate.py b/.github/scripts/test-nonnative-exec-conformance-gate.py new file mode 100755 index 00000000..17f7cb70 --- /dev/null +++ b/.github/scripts/test-nonnative-exec-conformance-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for the Apple-Silicon FEX exec-conformance release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-exec-conformance-gate.sh" + + +class NonnativeExecConformanceGateTests(unittest.TestCase): + def test_gate_binds_exact_socket_cli_images_and_exec_matrix(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-EXEC", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "fixture images must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + 'DOCKER_HOST="unix://$SOCKET"', + "fixture already exists; the gate requires fresh isolated pulls", + "image platform does not match linux/", + "private FEX handoff marker leaked", + "PR_SET_NO_NEW_PRIVS failed", + "PR_SET_SECCOMP failed", + "mkdir bypassed inherited guest seccomp", + "fd-exec-arguments-buildkit=PASS", + "fd-exec-null-argv-buildkit=PASS", + "seccomp-shebang-chain-buildkit=PASS", + "flags: POCF", + "docker exec descriptor chain failed", + "Docker API wedged after exec conformance", + "builder prune --all --force", + "base_image_id=", + "native_image_id=", + "source_commit=", + "fex_sha256=", + "fex_server_sha256=", + "guest_seccomp_inheritance=PASS", + "fd_exec_arguments=PASS", + "fd_exec_null_argv=PASS", + "buildkit_exec_matrix=PASS", + "runtime_exec_matrix=PASS", + "docker_exec_matrix=PASS", + "isolated_builder_cache_prune=PASS", + "docker_cli_sha256=", + "dockerfile_sha256=", + "build_log_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--native-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43ed058a..4d3c06bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -366,6 +366,7 @@ jobs: python3 .github/scripts/test-competitor-runtime-regression-gate.py python3 .github/scripts/test-managed-data-drive-gate.py python3 .github/scripts/test-native-ipv6-gate.py + python3 .github/scripts/test-nonnative-exec-conformance-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cac7fa3e..1c10d344 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,6 +67,7 @@ jobs: python3 .github/scripts/test-competitor-runtime-regression-gate.py python3 .github/scripts/test-managed-data-drive-gate.py python3 .github/scripts/test-native-ipv6-gate.py + python3 .github/scripts/test-nonnative-exec-conformance-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/nonnative-exec-conformance-gate.sh b/scripts/nonnative-exec-conformance-gate.sh new file mode 100755 index 00000000..b71b2630 --- /dev/null +++ b/scripts/nonnative-exec-conformance-gate.sh @@ -0,0 +1,360 @@ +#!/bin/bash +# Qualify FEX's generic Linux exec contract on Apple Silicon. This deliberately covers process +# transitions rather than individual package-manager symptoms. +set -euo pipefail + +SOCKET="" +DOCKER="" +BASE_IMAGE="" +NATIVE_IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-nonnative-exec-conformance" +CONFIRM="" +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --native-image) need_value "$1" "$#"; NATIVE_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-NONNATIVE-EXEC ] \ + || die "requires --confirm ISOLATED-DORY-NONNATIVE-EXEC" +for command in id python3 shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +for ref in "$BASE_IMAGE" "$NATIVE_IMAGE"; do + printf '%s\n' "$ref" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "fixture images must be digest-pinned" +done +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +TAG="dory-exec-conformance:${RUN_ID//[^a-zA-Z0-9]/}" +CONTAINER="dory-exec-conformance-${RUN_ID//[^a-zA-Z0-9]/}" +HANDLER_CONTAINER="$CONTAINER-handler" +WORKDIR="$WORKROOT/$RUN_ID" +CONTEXT="$WORKDIR/context" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$CONTEXT" +BASE_OWNED=0 +NATIVE_OWNED=0 +BUILD_ATTEMPTED=0 + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} +cleanup() { + set +e + docker_e rm -f "$CONTAINER" "$HANDLER_CONTAINER" >/dev/null 2>&1 || true + docker_e image rm -f "$TAG" >/dev/null 2>&1 || true + [ "$BASE_OWNED" -eq 0 ] || docker_e image rm -f "$BASE_IMAGE" >/dev/null 2>&1 || true + [ "$NATIVE_OWNED" -eq 0 ] || docker_e image rm -f "$NATIVE_IMAGE" >/dev/null 2>&1 || true + [ "$BUILD_ATTEMPTED" -eq 0 ] || docker_e builder prune --all --force >/dev/null 2>&1 || true + rm -rf "$CONTEXT" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker_e version > "$WORKDIR/docker-version-before.txt" || die "Docker API is not ready" +docker_e info --format '{{.DefaultRuntime}}' > "$WORKDIR/default-runtime.txt" \ + || die "Docker runtime inventory is unavailable" +grep -qx dory-runc "$WORKDIR/default-runtime.txt" \ + || die "Dory's FEX-aware OCI runtime is not Docker's default runtime" +for ref in "$BASE_IMAGE" "$NATIVE_IMAGE"; do + if docker_e image inspect "$ref" >/dev/null 2>&1; then + die "fixture already exists; the gate requires fresh isolated pulls: $ref" + fi +done + +docker_e pull --platform linux/amd64 "$BASE_IMAGE" \ + > "$WORKDIR/base-pull.out" 2> "$WORKDIR/base-pull.err" \ + || die "fresh linux/amd64 Debian pull failed" +BASE_OWNED=1 +docker_e pull --platform linux/arm64 "$NATIVE_IMAGE" \ + > "$WORKDIR/native-pull.out" 2> "$WORKDIR/native-pull.err" \ + || die "fresh linux/arm64 handler fixture pull failed" +NATIVE_OWNED=1 +docker_e image inspect "$BASE_IMAGE" > "$WORKDIR/base-image-inspect.json" +docker_e image inspect "$NATIVE_IMAGE" > "$WORKDIR/native-image-inspect.json" +base_image_id="$(docker_e image inspect -f '{{.Id}}' "$BASE_IMAGE")" +native_image_id="$(docker_e image inspect -f '{{.Id}}' "$NATIVE_IMAGE")" +python3 - "$WORKDIR/base-image-inspect.json" "$WORKDIR/native-image-inspect.json" <<'PY' +import json +import pathlib +import sys + +for path, architecture in zip(sys.argv[1:], ("amd64", "arm64")): + payload = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + if not isinstance(payload, list) or len(payload) != 1: + raise SystemExit(f"image inspect has an unexpected shape: {payload!r}") + if payload[0].get("Os") != "linux" or payload[0].get("Architecture") != architecture: + raise SystemExit(f"image platform does not match linux/{architecture}: {payload[0]!r}") +PY + +cat > "$CONTEXT/fd_exec_probe.py" <<'PY' +#!/usr/bin/python3 +import os + +for key in ("FEX_INTERPRETER_INSTALLED", "FEX_EXECVEFD", "FEX_SECCOMPFD"): + if key in os.environ: + raise SystemExit(f"private FEX handoff marker leaked: {key}") +fd = os.open("/usr/bin/dpkg", os.O_RDONLY | os.O_CLOEXEC) +os.execve(fd, ["dory-dpkg", "--version"], os.environ.copy()) +PY + +cat > "$CONTEXT/fd_null_argv_probe.py" <<'PY' +#!/usr/bin/python3 +import ctypes +import os + +fd = os.open("/usr/bin/true", os.O_RDONLY | os.O_CLOEXEC) +libc = ctypes.CDLL(None, use_errno=True) +result = libc.syscall(322, fd, b"", None, None, 0x1000) +raise OSError(ctypes.get_errno(), f"execveat unexpectedly returned {result}") +PY + +cat > "$CONTEXT/seccomp_launcher.py" <<'PY' +#!/usr/bin/python3 +import ctypes +import os + + +class SockFilter(ctypes.Structure): + _fields_ = [("code", ctypes.c_ushort), ("jt", ctypes.c_ubyte), + ("jf", ctypes.c_ubyte), ("k", ctypes.c_uint)] + + +class SockFprog(ctypes.Structure): + _fields_ = [("length", ctypes.c_ushort), ("filter", ctypes.POINTER(SockFilter))] + + +for key in ("FEX_INTERPRETER_INSTALLED", "FEX_EXECVEFD", "FEX_SECCOMPFD"): + if key in os.environ: + raise SystemExit(f"private FEX handoff marker leaked: {key}") +filters = (SockFilter * 5)( + SockFilter(0x20, 0, 0, 0), + SockFilter(0x15, 2, 0, 83), + SockFilter(0x15, 1, 0, 258), + SockFilter(0x06, 0, 0, 0x7FFF0000), + SockFilter(0x06, 0, 0, 0x00050000 | 13), +) +program = SockFprog(len(filters), filters) +libc = ctypes.CDLL(None, use_errno=True) +if libc.prctl(38, 1, 0, 0, 0) != 0: + raise SystemExit(f"PR_SET_NO_NEW_PRIVS failed: errno={ctypes.get_errno()}") +if libc.prctl(22, 2, ctypes.byref(program), 0, 0) != 0: + raise SystemExit(f"PR_SET_SECCOMP failed: errno={ctypes.get_errno()}") +os.execve("/opt/dory-exec/chain-a.sh", ["dory-chain-a", "preserved"], os.environ.copy()) +PY + +cat > "$CONTEXT/chain-a.sh" <<'SH' +#!/bin/sh +set -eu +test "$0" = /opt/dory-exec/chain-a.sh +test "$1" = preserved +test -z "${FEX_INTERPRETER_INSTALLED-}" +test -z "${FEX_EXECVEFD-}" +test -z "${FEX_SECCOMPFD-}" +exec /opt/dory-exec/chain-b.py from-shell +SH + +cat > "$CONTEXT/chain-b.py" <<'PY' +#!/usr/bin/env python3 +import errno +import os +import subprocess +import sys + +if sys.argv != ["/opt/dory-exec/chain-b.py", "from-shell"]: + raise SystemExit(f"shebang argument vector changed: {sys.argv!r}") +for key in ("FEX_INTERPRETER_INSTALLED", "FEX_EXECVEFD", "FEX_SECCOMPFD"): + if key in os.environ: + raise SystemExit(f"private FEX handoff marker leaked: {key}") +try: + os.mkdir("/tmp/dory-seccomp-python") +except OSError as error: + if error.errno != errno.EACCES: + raise SystemExit(f"unexpected mkdir failure under guest seccomp: {error}") +else: + raise SystemExit("mkdir bypassed inherited guest seccomp") +mkdir = subprocess.run(["/bin/mkdir", "/tmp/dory-seccomp-child"], check=False, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +if mkdir.returncode == 0 or "Permission denied" not in mkdir.stderr: + raise SystemExit(f"child mkdir bypassed inherited guest seccomp: {mkdir!r}") +version = subprocess.check_output(["/bin/cat", "/etc/debian_version"], text=True).strip() +if not version: + raise SystemExit("Debian version probe returned no output") +print(f"seccomp-shebang-chain-ok debian={version}", flush=True) +PY + +cat > "$CONTEXT/Dockerfile" < "$WORKDIR/build.out" 2> "$WORKDIR/build.err" \ + || die "BuildKit exec conformance matrix failed" +for proof in fd-exec-arguments-buildkit fd-exec-null-argv-buildkit \ + seccomp-shebang-chain-buildkit; do + grep -q "$proof=PASS" "$WORKDIR/build.err" \ + || die "BuildKit log does not retain $proof" +done + +docker_e run --rm --platform linux/amd64 --entrypoint /usr/bin/python3 "$TAG" \ + /opt/dory-exec/fd_exec_probe.py > "$WORKDIR/fd-exec.out" 2> "$WORKDIR/fd-exec.err" \ + || die "runtime descriptor exec lost its argument vector" +grep -q '^Debian .dpkg. package management program version' "$WORKDIR/fd-exec.out" \ + || die "runtime descriptor exec output is incomplete" +docker_e run --rm --platform linux/amd64 --entrypoint /usr/bin/python3 "$TAG" \ + /opt/dory-exec/fd_null_argv_probe.py \ + > "$WORKDIR/fd-null-argv.out" 2> "$WORKDIR/fd-null-argv.err" \ + || die "runtime descriptor exec rejected a null argv" +docker_e run --rm --platform linux/amd64 --entrypoint /usr/bin/python3 "$TAG" \ + /opt/dory-exec/seccomp_launcher.py \ + > "$WORKDIR/seccomp-chain.out" 2> "$WORKDIR/seccomp-chain.err" \ + || die "runtime seccomp/shebang chain failed" +grep -q '^seccomp-shebang-chain-ok ' "$WORKDIR/seccomp-chain.out" \ + || die "runtime seccomp/shebang proof is missing" + +docker_e run -d --name "$CONTAINER" --platform linux/amd64 "$TAG" >/dev/null \ + || die "long-running amd64 exec fixture failed to start" +docker_e exec "$CONTAINER" /usr/bin/python3 /opt/dory-exec/fd_exec_probe.py \ + > "$WORKDIR/docker-exec.out" 2> "$WORKDIR/docker-exec.err" \ + || die "docker exec descriptor chain failed" +grep -q '^Debian .dpkg. package management program version' "$WORKDIR/docker-exec.out" \ + || die "docker exec output is incomplete" +docker_e exec "$CONTAINER" sh -ec \ + 'test -z "${FEX_INTERPRETER_INSTALLED-}"; test -z "${FEX_EXECVEFD-}"; test -z "${FEX_SECCOMPFD-}"' \ + || die "a private FEX handoff marker leaked through docker exec" +docker_e rm -f "$CONTAINER" > "$WORKDIR/container-delete.out" + +docker_e run --name "$HANDLER_CONTAINER" --rm --privileged --platform linux/arm64 \ + "$NATIVE_IMAGE" sh -ec ' + mkdir -p /proc/sys/fs/binfmt_misc + grep -qs " /proc/sys/fs/binfmt_misc " /proc/mounts \ + || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc + grep -qx enabled /proc/sys/fs/binfmt_misc/FEX-x86_64 + grep -qx "interpreter /usr/lib/dory/fex/FEX" /proc/sys/fs/binfmt_misc/FEX-x86_64 + grep -qx "flags: POCF" /proc/sys/fs/binfmt_misc/FEX-x86_64 + test ! -e /proc/sys/fs/binfmt_misc/FEX-x86 + ' > "$WORKDIR/handler.out" 2> "$WORKDIR/handler.err" \ + || die "the production amd64-only FEX binfmt contract is not exact" + +docker_e version > "$WORKDIR/docker-version-after.txt" \ + || die "Docker API wedged after exec conformance" +docker_e image rm -f "$TAG" > "$WORKDIR/built-image-delete.out" \ + || die "built exec fixture cleanup failed" +docker_e image rm -f "$BASE_IMAGE" > "$WORKDIR/base-image-delete.out" \ + || die "Debian fixture cleanup failed" +BASE_OWNED=0 +docker_e image rm -f "$NATIVE_IMAGE" > "$WORKDIR/native-image-delete.out" \ + || die "native fixture cleanup failed" +NATIVE_OWNED=0 +docker_e builder prune --all --force > "$WORKDIR/builder-prune.out" \ + || die "exec conformance BuildKit cache cleanup failed" +BUILD_ATTEMPTED=0 + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "base_image=$BASE_IMAGE" + echo "native_image=$NATIVE_IMAGE" + echo "base_image_id=$base_image_id" + echo "native_image_id=$native_image_id" + echo "source_commit=$SOURCE_COMMIT" + echo "platform=linux/amd64" + echo "architecture=x86_64" + echo "fresh_pulls=PASS" + echo "oci_default_runtime=dory-runc" + echo "fex_sha256=b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b" + echo "fex_server_sha256=bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597" + echo "amd64_only_binfmt=PASS" + echo "fex_binfmt_flags=POCF" + echo "canonical_shebang_paths=PASS" + echo "env_shebang_chain=PASS" + echo "private_marker_isolation=PASS" + echo "guest_seccomp_inheritance=PASS" + echo "fd_exec_arguments=PASS" + echo "fd_exec_null_argv=PASS" + echo "buildkit_exec_matrix=PASS" + echo "runtime_exec_matrix=PASS" + echo "docker_exec_matrix=PASS" + echo "docker_api_after_exec=PASS" + echo "build_cache_cleanup=PASS" + echo "isolated_builder_cache_prune=PASS" + echo "owned_cleanup=PASS" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "dockerfile_sha256=$(shasum -a 256 "$CONTEXT/Dockerfile" | awk '{print $1}')" + echo "build_log_sha256=$(shasum -a 256 "$WORKDIR/build.err" | awk '{print $1}')" + echo "seccomp_output_sha256=$(shasum -a 256 "$WORKDIR/seccomp-chain.out" | awk '{print $1}')" + echo "docker_exec_output_sha256=$(shasum -a 256 "$WORKDIR/docker-exec.out" | awk '{print $1}')" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +rm -rf "$CONTEXT" +trap - EXIT INT TERM +echo "non-native exec conformance gate: PASS ($MANIFEST)" From 1aa9daee3bf26596e28bd42044672607e8b61aea Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:06:17 +0000 Subject: [PATCH 143/338] feat(release): qualify non-native Nix GC --- .github/scripts/test-nonnative-nix-gc-gate.py | 90 +++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/nonnative-nix-gc-gate.sh | 187 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100755 .github/scripts/test-nonnative-nix-gc-gate.py create mode 100755 scripts/nonnative-nix-gc-gate.sh diff --git a/.github/scripts/test-nonnative-nix-gc-gate.py b/.github/scripts/test-nonnative-nix-gc-gate.py new file mode 100755 index 00000000..def4a78e --- /dev/null +++ b/.github/scripts/test-nonnative-nix-gc-gate.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 Nix garbage-collection qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-nix-gc-gate.sh" + + +class NonnativeNixGCGateTests(unittest.TestCase): + def test_gate_binds_fresh_amd64_image_and_exact_gc_outcome(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-NIX-GC", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Nix fixture already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Nix fixture is not linux/amd64", + 'version="$(nix --version)"', + 'nix-collect-garbage --delete-old', + "gc_deleted_unreachable_path=PASS", + "Docker API wedged after non-native Nix GC", + "Nix fixture survived local cleanup", + "image_id=", + "source_commit=", + "nix_version=2.34.7", + "fresh_pull=PASS", + "unreachable_store_path_created=PASS", + "nix_collect_garbage_delete_old=PASS", + "unreachable_store_path_deleted=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d3c06bd..5e507fdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -367,6 +367,7 @@ jobs: python3 .github/scripts/test-managed-data-drive-gate.py python3 .github/scripts/test-native-ipv6-gate.py python3 .github/scripts/test-nonnative-exec-conformance-gate.py + python3 .github/scripts/test-nonnative-nix-gc-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c10d344..3575561c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,6 +68,7 @@ jobs: python3 .github/scripts/test-managed-data-drive-gate.py python3 .github/scripts/test-native-ipv6-gate.py python3 .github/scripts/test-nonnative-exec-conformance-gate.py + python3 .github/scripts/test-nonnative-nix-gc-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/nonnative-nix-gc-gate.sh b/scripts/nonnative-nix-gc-gate.sh new file mode 100755 index 00000000..0e8097ac --- /dev/null +++ b/scripts/nonnative-nix-gc-gate.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Reproduce OrbStack #2538 on Apple Silicon: Nix garbage collection in linux/amd64 must not fail +# with EPERM while reading process state. This gate owns a fresh digest-pinned image and container. +set -euo pipefail + +SOCKET="" +DOCKER="" +IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-nonnative-nix-gc" +CONFIRM="" +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-NONNATIVE-NIX-GC ] \ + || die "requires --confirm ISOLATED-DORY-NONNATIVE-NIX-GC" +for command in id python3 shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--image must be digest-pinned" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +CONTAINER="dory-nonnative-nix-gc-${RUN_ID//[^a-zA-Z0-9]/}" +WORKDIR="$WORKROOT/$RUN_ID" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$WORKDIR" +IMAGE_OWNED=0 + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} +cleanup() { + set +e + docker_e rm -f "$CONTAINER" >/dev/null 2>&1 || true + if [ "$IMAGE_OWNED" -eq 1 ]; then + docker_e image rm -f "$IMAGE" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker_e version > "$WORKDIR/docker-version-before.txt" \ + || die "Docker API is not ready" +if docker_e image inspect "$IMAGE" >/dev/null 2>&1; then + die "Nix fixture already exists; the gate requires a fresh isolated pull" +fi +docker_e pull --platform linux/amd64 "$IMAGE" \ + > "$WORKDIR/pull.out" 2> "$WORKDIR/pull.err" \ + || die "fresh linux/amd64 Nix pull failed" +IMAGE_OWNED=1 +docker_e image inspect "$IMAGE" > "$WORKDIR/image-inspect.json" +python3 - "$WORKDIR/image-inspect.json" <<'PY' +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(payload, list) or len(payload) != 1: + raise SystemExit("Nix image inspect is not singular") +image = payload[0] +if image.get("Os") != "linux": + raise SystemExit("Nix fixture is not a Linux image") +if image.get("Architecture") != "amd64": + raise SystemExit("Nix fixture is not linux/amd64") +PY +image_id="$(docker_e image inspect -f '{{.Id}}' "$IMAGE")" + +set +e +docker_e run --name "$CONTAINER" --platform linux/amd64 --entrypoint sh "$IMAGE" -lc ' +set -eu +[ "$(uname -m)" = x86_64 ] +version="$(nix --version)" +[ "$version" = "nix (Nix) 2.34.7" ] +printf "architecture=x86_64\nversion=%s\n" "$version" +printf dory-nix-gc-unreachable > /tmp/dory-nix-garbage +garbage="$(nix-store --add /tmp/dory-nix-garbage)" +[ -n "$garbage" ] && [ -e "$garbage" ] +printf "garbage_path=%s\n" "$garbage" +nix-collect-garbage --delete-old +[ ! -e "$garbage" ] +printf "gc_deleted_unreachable_path=PASS\n" +' > "$WORKDIR/run.out" 2> "$WORKDIR/run.err" +run_rc=$? +set -e +[ "$run_rc" -eq 0 ] || die "linux/amd64 Nix garbage collection failed (exit=$run_rc)" +grep -qx 'architecture=x86_64' "$WORKDIR/run.out" \ + || die "Nix fixture did not execute as x86_64" +grep -qx 'version=nix (Nix) 2.34.7' "$WORKDIR/run.out" \ + || die "Nix fixture version changed" +grep -Eq '^garbage_path=/nix/store/[a-z0-9]{32}-dory-nix-garbage$' "$WORKDIR/run.out" \ + || die "Nix gate did not retain the exact unreachable store path" +grep -qx 'gc_deleted_unreachable_path=PASS' "$WORKDIR/run.out" \ + || die "Nix GC did not delete the unreachable store path" + +docker_e rm "$CONTAINER" > "$WORKDIR/container-delete.out" +docker_e version > "$WORKDIR/docker-version-after.txt" \ + || die "Docker API wedged after non-native Nix GC" +docker_e image rm -f "$IMAGE" > "$WORKDIR/image-delete.out" \ + || die "Nix fixture image cleanup failed" +IMAGE_OWNED=0 +if docker_e image inspect "$IMAGE" >/dev/null 2>&1; then + die "Nix fixture survived local cleanup" +fi + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "image=$IMAGE" + echo "image_id=$image_id" + echo "source_commit=$SOURCE_COMMIT" + echo "platform=linux/amd64" + echo "architecture=x86_64" + echo "nix_version=2.34.7" + echo "fresh_pull=PASS" + echo "unreachable_store_path_created=PASS" + echo "nix_collect_garbage_delete_old=PASS" + echo "unreachable_store_path_deleted=PASS" + echo "docker_api_after_gc=PASS" + echo "owned_cleanup=PASS" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "run_output_sha256=$(shasum -a 256 "$WORKDIR/run.out" | awk '{print $1}')" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +trap - EXIT INT TERM +echo "nonnative Nix GC gate: PASS ($MANIFEST)" From fa3ed8c30c51c2e8064b61c7befe335c9c156472 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:07:42 +0000 Subject: [PATCH 144/338] feat(release): qualify non-native Arch pacman --- .../test-nonnative-arch-pacman-gate.py | 98 ++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/nonnative-arch-pacman-gate.sh | 229 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100755 .github/scripts/test-nonnative-arch-pacman-gate.py create mode 100755 scripts/nonnative-arch-pacman-gate.sh diff --git a/.github/scripts/test-nonnative-arch-pacman-gate.py b/.github/scripts/test-nonnative-arch-pacman-gate.py new file mode 100755 index 00000000..2ab8a328 --- /dev/null +++ b/.github/scripts/test-nonnative-arch-pacman-gate.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 Arch pacman sandbox qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-arch-pacman-gate.sh" + + +class NonnativeArchPacmanGateTests(unittest.TestCase): + def test_gate_binds_fresh_arch_image_fex_and_default_pacman_sandbox(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-ARCH-PACMAN", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--base-image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Arch base image already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Arch fixture is not linux/amd64", + "RUN pacman -Sy --noconfirm fzf", + "--disable-sandbox", + "competitor's seccomp/sandbox failure", + "flags: POCF", + "FEX_ROOTFS", + "FEX_NEEDSSECCOMP", + "fex_bundle_read_only=PASS", + "fex_config_read_only=PASS", + "fex_private_runtime=PASS", + "fex_shared_server_socket=PASS", + "Arch pacman gate image survived cleanup", + "base_image_id=", + "source_commit=", + "pacman_default_sandbox=PASS", + "alpm_user_switch=PASS", + "fzf_inventory=PASS", + "fzf_runtime=PASS", + "docker_api_after_build=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "build_output_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + "pacman -Sy --noconfirm --disable-sandbox", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e507fdb..8579019f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -368,6 +368,7 @@ jobs: python3 .github/scripts/test-native-ipv6-gate.py python3 .github/scripts/test-nonnative-exec-conformance-gate.py python3 .github/scripts/test-nonnative-nix-gc-gate.py + python3 .github/scripts/test-nonnative-arch-pacman-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3575561c..7ca20230 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,6 +69,7 @@ jobs: python3 .github/scripts/test-native-ipv6-gate.py python3 .github/scripts/test-nonnative-exec-conformance-gate.py python3 .github/scripts/test-nonnative-nix-gc-gate.py + python3 .github/scripts/test-nonnative-arch-pacman-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/nonnative-arch-pacman-gate.sh b/scripts/nonnative-arch-pacman-gate.sh new file mode 100755 index 00000000..cba44d21 --- /dev/null +++ b/scripts/nonnative-arch-pacman-gate.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# Exact Apple container #1628 reproduction: Arch pacman must switch to its alpm sandbox user and +# install a package during a linux/amd64 BuildKit RUN without disabling the sandbox. +set -euo pipefail + +SOCKET="" +DOCKER="" +BASE_IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-nonnative-arch-pacman" +CONFIRM="" +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-NONNATIVE-ARCH-PACMAN ] \ + || die "requires --confirm ISOLATED-DORY-NONNATIVE-ARCH-PACMAN" +for command in id python3 shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$BASE_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--base-image must be digest-pinned" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +[ "$RELEASE_CANDIDATE" -eq 0 ] || [ -n "$SOURCE_COMMIT" ] \ + || die "release candidate evidence requires --source-commit" +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +TAG="dory-arch-pacman:${RUN_ID//[^a-zA-Z0-9]/}" +CONTAINER="dory-arch-pacman-${RUN_ID//[^a-zA-Z0-9]/}" +HANDLER_CONTAINER="$CONTAINER-handler" +WORKDIR="$WORKROOT/$RUN_ID" +CONTEXT="$WORKDIR/context" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$CONTEXT" +BASE_OWNED=0 + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} +cleanup() { + set +e + docker_e rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker_e rm -f "$HANDLER_CONTAINER" >/dev/null 2>&1 || true + docker_e image rm -f "$TAG" >/dev/null 2>&1 || true + if [ "$BASE_OWNED" -eq 1 ]; then + docker_e image rm -f "$BASE_IMAGE" >/dev/null 2>&1 || true + fi + rm -rf "$CONTEXT" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker_e version > "$WORKDIR/docker-version-before.txt" || die "Docker API is not ready" +docker_e info --format '{{.DefaultRuntime}}' > "$WORKDIR/default-runtime.txt" \ + || die "Docker runtime inventory is unavailable" +grep -qx dory-runc "$WORKDIR/default-runtime.txt" \ + || die "Dory's FEX-aware OCI runtime is not Docker's default runtime" +if docker_e image inspect "$BASE_IMAGE" >/dev/null 2>&1; then + die "Arch base image already exists; the gate requires a fresh isolated pull" +fi +docker_e pull --platform linux/amd64 "$BASE_IMAGE" \ + > "$WORKDIR/pull.out" 2> "$WORKDIR/pull.err" \ + || die "fresh linux/amd64 Arch pull failed" +BASE_OWNED=1 +docker_e image inspect "$BASE_IMAGE" > "$WORKDIR/base-image-inspect.json" +python3 - "$WORKDIR/base-image-inspect.json" <<'PY' +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(payload, list) or len(payload) != 1: + raise SystemExit("Arch image inspect is not singular") +if payload[0].get("Os") != "linux" or payload[0].get("Architecture") != "amd64": + raise SystemExit("Arch fixture is not linux/amd64") +PY +base_image_id="$(docker_e image inspect -f '{{.Id}}' "$BASE_IMAGE")" + +cat > "$CONTEXT/Dockerfile" < "$WORKDIR/build.out" 2> "$WORKDIR/build.err" \ + || die "linux/amd64 Arch pacman sandbox build failed" +if grep -Eqi 'error restricting syscalls via seccomp|switching to sandbox user .* failed' \ + "$WORKDIR/build.out" "$WORKDIR/build.err"; then + die "Arch pacman build logged the competitor's seccomp/sandbox failure" +fi +docker_e run --rm --name "$HANDLER_CONTAINER" --privileged --platform linux/amd64 "$TAG" \ + sh -ec 'mkdir -p /proc/sys/fs/binfmt_misc; grep -qs " /proc/sys/fs/binfmt_misc " /proc/mounts || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc; grep -qx enabled /proc/sys/fs/binfmt_misc/FEX-x86_64; grep -qx "interpreter /usr/lib/dory/fex/FEX" /proc/sys/fs/binfmt_misc/FEX-x86_64; grep -qx "flags: POCF" /proc/sys/fs/binfmt_misc/FEX-x86_64' \ + > "$WORKDIR/fex-handler.out" 2> "$WORKDIR/fex-handler.err" \ + || die "FEX binfmt handler is unavailable or has unsafe flags" +docker_e run --name "$CONTAINER" --platform linux/amd64 "$TAG" \ + sh -ec ' + test "$(uname -m)" = x86_64 + test "$FEX_ROOTFS" = / + test "$FEX_NEEDSSECCOMP" = 1 + test "$FEX_APP_CONFIG_LOCATION" = /usr/lib/dory/fex + test "$FEX_SERVERSOCKETPATH" = /run/dory-fex/FEXServer.Socket + case "$PATH" in /usr/lib/dory/fex:*) ;; *) exit 1;; esac + test -x /usr/lib/dory/fex/FEX + test -x /usr/lib/dory/fex/FEXServer + if touch /usr/lib/dory/fex/.dory-write-probe 2>/dev/null; then exit 1; fi + runtime_mount="$(awk '\''$2 == "/run/dory-fex" && $3 == "tmpfs" { print $4; exit }'\'' /proc/mounts)" + test -n "$runtime_mount" + case ",$runtime_mount," in *,nosuid,*) ;; *) exit 1;; esac + case ",$runtime_mount," in *,nodev,*) ;; *) exit 1;; esac + case ",$runtime_mount," in *,noexec,*) ;; *) exit 1;; esac + test "$(stat -c %a /run/dory-fex)" = 1777 + test -S "$FEX_SERVERSOCKETPATH" + fex_hash="$(sha256sum /usr/lib/dory/fex/FEX | awk "{print \$1}")" + server_hash="$(sha256sum /usr/lib/dory/fex/FEXServer | awk "{print \$1}")" + case "$fex_hash:$server_hash" in + b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b:bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597) ;; + *) exit 1;; + esac + echo "fex_sha256=$fex_hash" + echo "fex_server_sha256=$server_hash" + pacman -Q fzf + fzf --version + ' \ + > "$WORKDIR/run.out" 2> "$WORKDIR/run.err" \ + || die "installed linux/amd64 fzf did not execute" +grep -Eq '^fzf [0-9]' "$WORKDIR/run.out" \ + || die "pacman package inventory did not contain fzf" +grep -Eq '^[0-9]+[.][0-9]+' "$WORKDIR/run.out" \ + || die "fzf runtime version output is missing" +docker_e rm "$CONTAINER" > "$WORKDIR/container-delete.out" +docker_e version > "$WORKDIR/docker-version-after.txt" \ + || die "Docker API wedged after the Arch pacman build" +docker_e image rm -f "$TAG" > "$WORKDIR/built-image-delete.out" \ + || die "built Arch fixture cleanup failed" +docker_e image rm -f "$BASE_IMAGE" > "$WORKDIR/base-image-delete.out" \ + || die "Arch base image cleanup failed" +BASE_OWNED=0 +if docker_e image inspect "$TAG" >/dev/null 2>&1 \ + || docker_e image inspect "$BASE_IMAGE" >/dev/null 2>&1; then + die "Arch pacman gate image survived cleanup" +fi +rm -rf "$CONTEXT" + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "base_image=$BASE_IMAGE" + echo "base_image_id=$base_image_id" + echo "source_commit=$SOURCE_COMMIT" + echo "platform=linux/amd64" + echo "architecture=x86_64" + echo "fresh_pull=PASS" + echo "pacman_default_sandbox=PASS" + echo "alpm_user_switch=PASS" + echo "oci_default_runtime=dory-runc" + echo "fex_handler=PASS" + echo "fex_binfmt_flags=POCF" + echo "fex_bundle_read_only=PASS" + echo "fex_config_read_only=PASS" + echo "fex_private_runtime=PASS" + echo "fex_shared_server_socket=PASS" + grep -E '^fex(_server)?_sha256=' "$WORKDIR/run.out" + echo "fzf_inventory=PASS" + echo "fzf_runtime=PASS" + echo "docker_api_after_build=PASS" + echo "owned_cleanup=PASS" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "build_output_sha256=$(shasum -a 256 "$WORKDIR/build.out" | awk '{print $1}')" + echo "run_output_sha256=$(shasum -a 256 "$WORKDIR/run.out" | awk '{print $1}')" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +trap - EXIT INT TERM +echo "non-native Arch pacman gate: PASS ($MANIFEST)" From a63ad4a03b50a3c77bb4468143c3df808f0f60f2 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:09:27 +0000 Subject: [PATCH 145/338] feat(release): qualify non-native mmdebstrap --- .../scripts/test-nonnative-mmdebstrap-gate.py | 103 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/nonnative-mmdebstrap-gate.sh | 327 ++++++++++++++++++ 4 files changed, 432 insertions(+) create mode 100755 .github/scripts/test-nonnative-mmdebstrap-gate.py create mode 100755 scripts/nonnative-mmdebstrap-gate.sh diff --git a/.github/scripts/test-nonnative-mmdebstrap-gate.py b/.github/scripts/test-nonnative-mmdebstrap-gate.py new file mode 100755 index 00000000..e39a4b41 --- /dev/null +++ b/.github/scripts/test-nonnative-mmdebstrap-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 mmdebstrap release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-mmdebstrap-gate.sh" + + +class NonnativeMmdebstrapGateTests(unittest.TestCase): + def test_gate_binds_fresh_debian_image_and_proc_less_nested_rootfs(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-MMDEBSTRAP", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--base-image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Debian base image already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Debian fixture is not linux/amd64", + "RUN mmdebstrap --variant=minbase trixie /tmp/rootfs.tar", + "bad fd number|cat >&10 returned|hooklistener errored", + "flags: POCF", + "nested-chroot-shebang-ok", + "test ! -e /tmp/dory-nested-root/proc/self", + "private_marker_isolation=PASS", + "generated linux/amd64 Debian rootfs archive verification failed", + "Docker API wedged after the mmdebstrap build", + "builder prune --all --force", + "mmdebstrap gate image survived cleanup", + "base_image_id=", + "built_image_id=", + "source_commit=", + "reported_dockerfile_commands=PASS", + "mmdebstrap_minbase_trixie=PASS", + "bad_fd_number_absent=PASS", + "rootfs_archive_readable=PASS", + "nested_chroot_no_proc=PASS", + "nested_chroot_shebang=PASS", + "build_cache_cleanup=PASS", + "isolated_builder_cache_prune=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "dockerfile_sha256=", + "base_inspect_sha256=", + "build_log_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + "--security-opt seccomp=unconfined", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8579019f..a3ab82b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -369,6 +369,7 @@ jobs: python3 .github/scripts/test-nonnative-exec-conformance-gate.py python3 .github/scripts/test-nonnative-nix-gc-gate.py python3 .github/scripts/test-nonnative-arch-pacman-gate.py + python3 .github/scripts/test-nonnative-mmdebstrap-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7ca20230..f0e9513e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,6 +70,7 @@ jobs: python3 .github/scripts/test-nonnative-exec-conformance-gate.py python3 .github/scripts/test-nonnative-nix-gc-gate.py python3 .github/scripts/test-nonnative-arch-pacman-gate.py + python3 .github/scripts/test-nonnative-mmdebstrap-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/nonnative-mmdebstrap-gate.sh b/scripts/nonnative-mmdebstrap-gate.sh new file mode 100755 index 00000000..8228b33d --- /dev/null +++ b/scripts/nonnative-mmdebstrap-gate.sh @@ -0,0 +1,327 @@ +#!/bin/bash +# Exact OrbStack #2543 reproduction: mmdebstrap must bootstrap Debian during a linux/amd64 +# BuildKit RUN on Apple Silicon without Rosetta's /bin/sh "Bad fd number" failure. +set -euo pipefail + +SOCKET="" +DOCKER="" +BASE_IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-nonnative-mmdebstrap" +CONFIRM="" +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --base-image) need_value "$1" "$#"; BASE_IMAGE="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = ISOLATED-DORY-NONNATIVE-MMDEBSTRAP ] \ + || die "requires --confirm ISOLATED-DORY-NONNATIVE-MMDEBSTRAP" +for command in id python3 shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +printf '%s\n' "$BASE_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--base-image must be digest-pinned" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +[ "$RELEASE_CANDIDATE" -eq 0 ] || [ -n "$SOURCE_COMMIT" ] \ + || die "release candidate evidence requires --source-commit" +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +TAG="dory-mmdebstrap:${RUN_ID//[^a-zA-Z0-9]/}" +CONTAINER="dory-mmdebstrap-${RUN_ID//[^a-zA-Z0-9]/}" +HANDLER_CONTAINER="$CONTAINER-handler" +WORKDIR="$WORKROOT/$RUN_ID" +CONTEXT="$WORKDIR/context" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$CONTEXT" +BASE_OWNED=0 +BUILD_ATTEMPTED=0 + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} +cleanup() { + set +e + docker_e rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker_e rm -f "$HANDLER_CONTAINER" >/dev/null 2>&1 || true + docker_e image rm -f "$TAG" >/dev/null 2>&1 || true + if [ "$BASE_OWNED" -eq 1 ]; then + docker_e image rm -f "$BASE_IMAGE" >/dev/null 2>&1 || true + fi + if [ "$BUILD_ATTEMPTED" -eq 1 ]; then + docker_e builder prune --all --force >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker_e version > "$WORKDIR/docker-version-before.txt" \ + || die "Docker API is not ready" +docker_e info --format '{{.DefaultRuntime}}' > "$WORKDIR/default-runtime.txt" \ + || die "Docker runtime inventory is unavailable" +grep -qx dory-runc "$WORKDIR/default-runtime.txt" \ + || die "Dory's FEX-aware OCI runtime is not Docker's default runtime" +if docker_e image inspect "$BASE_IMAGE" >/dev/null 2>&1; then + die "Debian base image already exists; the gate requires a fresh isolated pull" +fi + +docker_e pull --platform linux/amd64 "$BASE_IMAGE" \ + > "$WORKDIR/pull.out" 2> "$WORKDIR/pull.err" \ + || die "fresh linux/amd64 Debian trixie pull failed" +BASE_OWNED=1 +docker_e image inspect "$BASE_IMAGE" > "$WORKDIR/base-image-inspect.json" +python3 - "$WORKDIR/base-image-inspect.json" <<'PY' +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(payload, list) or len(payload) != 1: + raise SystemExit("Debian image inspect is not singular") +image = payload[0] +if image.get("Os") != "linux": + raise SystemExit("Debian fixture is not a Linux image") +if image.get("Architecture") != "amd64": + raise SystemExit("Debian fixture is not linux/amd64") +PY +base_image_id="$(docker_e image inspect -f '{{.Id}}' "$BASE_IMAGE")" + +cat > "$CONTEXT/Dockerfile" < "$WORKDIR/build.out" 2> "$WORKDIR/build.err" \ + || die "linux/amd64 mmdebstrap build failed" +if grep -Eqi 'bad fd number|cat >&10 returned|hooklistener errored' \ + "$WORKDIR/build.out" "$WORKDIR/build.err"; then + die "mmdebstrap build logged OrbStack #2543's shell descriptor failure" +fi +grep -Fq 'mmdebstrap --variant=minbase trixie /tmp/rootfs.tar' "$WORKDIR/build.err" \ + || die "mmdebstrap build log lost the exact reported command" + +docker_e image inspect "$TAG" > "$WORKDIR/built-image-inspect.json" +python3 - "$WORKDIR/built-image-inspect.json" <<'PY' +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if not isinstance(payload, list) or len(payload) != 1: + raise SystemExit("built image inspect is not singular") +image = payload[0] +if image.get("Os") != "linux": + raise SystemExit("built fixture is not a Linux image") +if image.get("Architecture") != "amd64": + raise SystemExit("built fixture is not linux/amd64") +PY +built_image_id="$(docker_e image inspect -f '{{.Id}}' "$TAG")" + +docker_e run --rm --name "$HANDLER_CONTAINER" --privileged --platform linux/amd64 "$TAG" \ + sh -ec 'mkdir -p /proc/sys/fs/binfmt_misc; grep -qs " /proc/sys/fs/binfmt_misc " /proc/mounts || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc; grep -qx enabled /proc/sys/fs/binfmt_misc/FEX-x86_64; grep -qx "interpreter /usr/lib/dory/fex/FEX" /proc/sys/fs/binfmt_misc/FEX-x86_64; grep -qx "flags: POCF" /proc/sys/fs/binfmt_misc/FEX-x86_64' \ + > "$WORKDIR/fex-handler.out" 2> "$WORKDIR/fex-handler.err" \ + || die "FEX binfmt handler is unavailable or has unsafe flags" + +docker_e run --name "$CONTAINER" --platform linux/amd64 --entrypoint sh "$TAG" -ec ' + test "$(uname -m)" = x86_64 + test "$FEX_ROOTFS" = / + test "$FEX_NEEDSSECCOMP" = 1 + test "$FEX_APP_CONFIG_LOCATION" = /usr/lib/dory/fex + test "$FEX_SERVERSOCKETPATH" = /run/dory-fex/FEXServer.Socket + case "$PATH" in /usr/lib/dory/fex:*) ;; *) exit 1;; esac + test -x /usr/lib/dory/fex/FEX + test -x /usr/lib/dory/fex/FEXServer + if touch /usr/lib/dory/fex/.dory-write-probe 2>/dev/null; then exit 1; fi + runtime_mount="$(awk "\$2 == \"/run/dory-fex\" && \$3 == \"tmpfs\" { print \$4; exit }" /proc/mounts)" + test -n "$runtime_mount" + case ",$runtime_mount," in *,nosuid,*) ;; *) exit 1;; esac + case ",$runtime_mount," in *,nodev,*) ;; *) exit 1;; esac + case ",$runtime_mount," in *,noexec,*) ;; *) exit 1;; esac + test "$(stat -c %a /run/dory-fex)" = 1777 + test -S "$FEX_SERVERSOCKETPATH" + fex_hash="$(sha256sum /usr/lib/dory/fex/FEX | awk "{print \$1}")" + server_hash="$(sha256sum /usr/lib/dory/fex/FEXServer | awk "{print \$1}")" + case "$fex_hash:$server_hash" in + b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b:bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597) ;; + *) exit 1;; + esac + test -s /tmp/rootfs.tar + tar -tf /tmp/rootfs.tar | sed "s#^\\./##" > /tmp/rootfs.entries + grep -qx etc/debian_version /tmp/rootfs.entries + grep -Eq "^(bin/sh|usr/bin/dash|usr/bin/sh)$" /tmp/rootfs.entries + debian_version="$( + tar -xOf /tmp/rootfs.tar ./etc/debian_version 2>/dev/null \ + || tar -xOf /tmp/rootfs.tar etc/debian_version + )" + debian_version="$(printf "%s" "$debian_version" | tr -d "\r\n")" + test -n "$debian_version" + mmdebstrap_version="$(mmdebstrap --version)" + echo "$mmdebstrap_version" | grep -Eq "^mmdebstrap [0-9]" + mkdir /tmp/dory-nested-root + tar -xf /tmp/rootfs.tar -C /tmp/dory-nested-root + test ! -e /tmp/dory-nested-root/proc/self + printf "%s\n" \ + "#!/bin/sh" \ + "set -eu" \ + "test \"\$0\" = /dory-nested-probe" \ + "test ! -e /proc/self" \ + "test -z \"\${FEX_INTERPRETER_INSTALLED-}\"" \ + "test -z \"\${FEX_EXECVEFD-}\"" \ + "test -z \"\${FEX_SECCOMPFD-}\"" \ + "test \"\$(/bin/uname -m)\" = x86_64" \ + "/bin/echo nested-chroot-shebang-ok" \ + > /tmp/dory-nested-root/dory-nested-probe + chmod 0755 /tmp/dory-nested-root/dory-nested-probe + chroot /tmp/dory-nested-root /dory-nested-probe > /tmp/nested-chroot.out + grep -qx nested-chroot-shebang-ok /tmp/nested-chroot.out + echo "architecture=x86_64" + echo "fex_sha256=$fex_hash" + echo "fex_server_sha256=$server_hash" + echo "mmdebstrap_version=$mmdebstrap_version" + echo "debian_version=$debian_version" + echo "rootfs_archive_bytes=$(wc -c < /tmp/rootfs.tar | tr -d " ")" + echo "rootfs_archive_entries=$(wc -l < /tmp/rootfs.entries | tr -d " ")" + echo "rootfs_archive_readable=PASS" + echo "nested_chroot_no_proc=PASS" + echo "nested_chroot_shebang=PASS" + echo "private_marker_isolation=PASS" +' > "$WORKDIR/run.out" 2> "$WORKDIR/run.err" \ + || die "generated linux/amd64 Debian rootfs archive verification failed" + +grep -qx 'architecture=x86_64' "$WORKDIR/run.out" \ + || die "mmdebstrap fixture did not execute as x86_64" +grep -Eq '^mmdebstrap_version=mmdebstrap [0-9]' "$WORKDIR/run.out" \ + || die "mmdebstrap version output is missing" +grep -Eq '^debian_version=[^[:space:]]+' "$WORKDIR/run.out" \ + || die "bootstrapped Debian version is missing" +grep -Eq '^rootfs_archive_bytes=[1-9][0-9]+$' "$WORKDIR/run.out" \ + || die "bootstrapped rootfs archive size is invalid" +grep -Eq '^rootfs_archive_entries=[1-9][0-9]+$' "$WORKDIR/run.out" \ + || die "bootstrapped rootfs archive inventory is invalid" +grep -qx 'rootfs_archive_readable=PASS' "$WORKDIR/run.out" \ + || die "bootstrapped rootfs archive was not verified" +grep -qx 'nested_chroot_no_proc=PASS' "$WORKDIR/run.out" \ + && grep -qx 'nested_chroot_shebang=PASS' "$WORKDIR/run.out" \ + && grep -qx 'private_marker_isolation=PASS' "$WORKDIR/run.out" \ + || die "proc-less nested chroot execution contract failed" + +docker_e rm "$CONTAINER" > "$WORKDIR/container-delete.out" +docker_e version > "$WORKDIR/docker-version-after.txt" \ + || die "Docker API wedged after the mmdebstrap build" +docker_e image rm -f "$TAG" > "$WORKDIR/built-image-delete.out" \ + || die "built mmdebstrap fixture cleanup failed" +docker_e image rm -f "$BASE_IMAGE" > "$WORKDIR/base-image-delete.out" \ + || die "Debian base image cleanup failed" +BASE_OWNED=0 +docker_e builder prune --all --force > "$WORKDIR/builder-prune.out" \ + || die "mmdebstrap BuildKit cache cleanup failed" +BUILD_ATTEMPTED=0 +if docker_e image inspect "$TAG" >/dev/null 2>&1 \ + || docker_e image inspect "$BASE_IMAGE" >/dev/null 2>&1; then + die "mmdebstrap gate image survived cleanup" +fi + +release_qualifying=false +[ "$RELEASE_CANDIDATE" -eq 0 ] || release_qualifying=true +{ + echo "status=PASS" + echo "run_id=$RUN_ID" + echo "orbstack_issue=2543" + echo "orbstack_issue_url=https://github.com/orbstack/orbstack/issues/2543" + echo "base_image=$BASE_IMAGE" + echo "base_image_id=$base_image_id" + echo "built_image_id=$built_image_id" + echo "source_commit=$SOURCE_COMMIT" + echo "platform=linux/amd64" + echo "architecture=x86_64" + echo "fresh_pull=PASS" + echo "reported_dockerfile_commands=PASS" + echo "mmdebstrap_minbase_trixie=PASS" + echo "bad_fd_number_absent=PASS" + echo "oci_default_runtime=dory-runc" + echo "fex_handler=PASS" + echo "fex_binfmt_flags=POCF" + echo "fex_bundle_read_only=PASS" + echo "fex_config_read_only=PASS" + echo "fex_private_runtime=PASS" + echo "fex_shared_server_socket=PASS" + grep -E '^fex(_server)?_sha256=' "$WORKDIR/run.out" + grep -E '^(mmdebstrap_version|debian_version|rootfs_archive_bytes|rootfs_archive_entries)=' \ + "$WORKDIR/run.out" + echo "rootfs_archive_readable=PASS" + echo "nested_chroot_no_proc=PASS" + echo "nested_chroot_shebang=PASS" + echo "private_marker_isolation=PASS" + echo "docker_api_after_build=PASS" + echo "build_cache_cleanup=PASS" + echo "isolated_builder_cache_prune=PASS" + echo "owned_cleanup=PASS" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "dockerfile_sha256=$(shasum -a 256 "$CONTEXT/Dockerfile" | awk '{print $1}')" + echo "base_inspect_sha256=$(shasum -a 256 "$WORKDIR/base-image-inspect.json" | awk '{print $1}')" + echo "build_log_sha256=$(shasum -a 256 "$WORKDIR/build.err" | awk '{print $1}')" + echo "run_output_sha256=$(shasum -a 256 "$WORKDIR/run.out" | awk '{print $1}')" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} > "$MANIFEST" + +trap - EXIT INT TERM +echo "non-native mmdebstrap gate: PASS ($MANIFEST)" From abed93de4c54ed1d8f871156a6b49b7dee0eaa6a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:14:53 +0000 Subject: [PATCH 146/338] feat(release): qualify gvproxy QEMU switch --- .../scripts/test-gvproxy-qemu-switch-gate.py | 88 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/gvproxy-qemu-switch-gate.py | 270 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100755 .github/scripts/test-gvproxy-qemu-switch-gate.py create mode 100755 scripts/gvproxy-qemu-switch-gate.py diff --git a/.github/scripts/test-gvproxy-qemu-switch-gate.py b/.github/scripts/test-gvproxy-qemu-switch-gate.py new file mode 100755 index 00000000..f9cac0c2 --- /dev/null +++ b/.github/scripts/test-gvproxy-qemu-switch-gate.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Offline contract for exact gvproxy QEMU-switch qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "gvproxy-qemu-switch-gate.py" + + +class GVProxyQEMUSwitchGateTests(unittest.TestCase): + def test_gate_binds_compiled_identity_private_sockets_frames_and_shutdown(self) -> None: + subprocess.run(["python3", "-m", "py_compile", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "EXACT-GVPROXY-QEMU-SWITCH", + "gvproxy must be an absolute path", + "gvproxy is unavailable or indirect", + "gvproxy is not owned by the release user", + "release candidate digest is compiled and cannot be overridden", + "release candidate evidence requires --source-commit", + "release candidate evidence requires --provenance", + "provenance is missing or indirect", + "provenance must contain exactly one verified_sha256", + "reproducible-build SHA-256 mismatch", + "unexpected version identity", + "workroot already exists or is indirect", + "canonical workroot already exists or is indirect", + "evidence must be the exact WORKROOT/manifest.txt authority", + "gvproxy published a missing, indirect, or foreign-owned switch socket", + "LAN-to-guest Ethernet frame changed in transit", + "guest-to-LAN Ethernet frame", + "gvproxy exited before teardown", + "gvproxy did not terminate within two seconds of SIGTERM", + "schema=3", + "status=PASS", + "source_commit=", + "gvproxy_sha256=", + "gvproxy_build_sha256=", + "provenance_sha256=", + "same_user_switch_sockets=PASS", + "graceful_helper_shutdown=PASS", + "lan_to_guest=PASS", + "guest_to_lan=PASS", + "frame_contract_sha256=", + "release_qualifying=", + "evidence.chmod(0o600)", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "gvproxy_path=", + 'os.environ.get("DORY_NETWORK_MTU"', + 'mkdir(parents=True, exist_ok=True)', + '"release_qualifying=true"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_binary_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "python3", + str(GATE), + "/missing/gvproxy", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3ab82b8..c31a4a21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,7 @@ jobs: python3 .github/scripts/test-nonnative-nix-gc-gate.py python3 .github/scripts/test-nonnative-arch-pacman-gate.py python3 .github/scripts/test-nonnative-mmdebstrap-gate.py + python3 .github/scripts/test-gvproxy-qemu-switch-gate.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0e9513e..2e780db7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,7 @@ jobs: python3 .github/scripts/test-nonnative-nix-gc-gate.py python3 .github/scripts/test-nonnative-arch-pacman-gate.py python3 .github/scripts/test-nonnative-mmdebstrap-gate.py + python3 .github/scripts/test-gvproxy-qemu-switch-gate.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/gvproxy-qemu-switch-gate.py b/scripts/gvproxy-qemu-switch-gate.py new file mode 100755 index 00000000..3e3cbde5 --- /dev/null +++ b/scripts/gvproxy-qemu-switch-gate.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Prove the exact gvproxy gives Dory's LAN bridge an independent bidirectional L2 port.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path +import re +import shutil +import signal +import socket +import stat +import struct +import subprocess +import tempfile +import time + + +PINNED_SHA256 = "47c278f1636736ba552de3d2f0e68409cdc968d63bc02149637e449f40274459" +PINNED_VERSION = "gvproxy version v0.8.9-dory2" +CONFIRMATION = "EXACT-GVPROXY-QEMU-SWITCH" +GUEST_MAC = bytes.fromhex("5a94efe40cee") +BRIDGE_MAC = bytes.fromhex("5a94efd01201") + + +def fail(message: str) -> None: + raise SystemExit(f"gvproxy QEMU switch gate: FAIL: {message}") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def frame(destination: bytes, source: bytes, marker: bytes) -> bytes: + return destination + source + b"\x88\xb5" + marker + + +def wait_for_paths(process: subprocess.Popen[bytes], paths: list[Path], deadline: float) -> None: + while time.monotonic() < deadline: + if process.poll() is not None: + stderr = process.stderr.read().decode(errors="replace") if process.stderr else "" + fail(f"gvproxy exited early with status {process.returncode}: {stderr.strip()}") + if all(path.exists() for path in paths): + return + time.sleep(0.02) + fail("gvproxy did not create its vfkit and QEMU sockets") + + +def receive_bytes(sock: socket.socket, count: int) -> bytes: + result = bytearray() + while len(result) < count: + chunk = sock.recv(count - len(result)) + if not chunk: + fail("gvproxy closed the QEMU switch connection") + result.extend(chunk) + return bytes(result) + + +def send_qemu_frame(sock: socket.socket, payload: bytes) -> None: + sock.sendall(struct.pack(">I", len(payload)) + payload) + + +def receive_qemu_frame(sock: socket.socket, expected: bytes, label: str) -> None: + sock.settimeout(3) + try: + length = struct.unpack(">I", receive_bytes(sock, 4))[0] + actual = receive_bytes(sock, length) + except TimeoutError: + fail(f"timed out waiting for {label}") + if actual != expected: + fail(f"{label} changed in transit (expected {expected.hex()}, got {actual.hex()})") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("gvproxy", type=Path, help="exact gvproxy executable to validate") + parser.add_argument("--expected-sha256", default=PINNED_SHA256) + parser.add_argument("--provenance", type=Path) + parser.add_argument("--workroot", type=Path, required=True, help="new private evidence root") + parser.add_argument("--evidence", type=Path, help="must be WORKROOT/manifest.txt") + parser.add_argument("--source-commit", default="") + parser.add_argument("--mtu", type=int, default=1280) + parser.add_argument("--confirm", default="") + parser.add_argument("--release-candidate", action="store_true") + args = parser.parse_args() + + if args.confirm != CONFIRMATION: + fail(f"requires --confirm {CONFIRMATION}") + if not 576 <= args.mtu <= 9000: + fail("MTU must be between 576 and 9000") + if args.source_commit and re.fullmatch(r"[0-9a-f]{40}", args.source_commit) is None: + fail("source commit must be a full lowercase Git SHA") + if re.fullmatch(r"[0-9a-f]{64}", args.expected_sha256.lower()) is None: + fail("expected SHA-256 is invalid") + if not args.gvproxy.is_absolute(): + fail("gvproxy must be an absolute path") + if args.gvproxy.is_symlink() or not args.gvproxy.is_file() or not os.access(args.gvproxy, os.X_OK): + fail("gvproxy is unavailable or indirect") + binary = args.gvproxy.resolve(strict=True) + if binary.stat().st_uid != os.getuid(): + fail("gvproxy is not owned by the release user") + if args.release_candidate: + if args.expected_sha256.lower() != PINNED_SHA256: + fail("release candidate digest is compiled and cannot be overridden") + if not args.source_commit: + fail("release candidate evidence requires --source-commit") + if args.provenance is None: + fail("release candidate evidence requires --provenance") + + actual_sha = sha256(binary) + if actual_sha != args.expected_sha256.lower(): + fail(f"SHA-256 mismatch (expected {args.expected_sha256.lower()}, got {actual_sha})") + build_sha = actual_sha + provenance_sha = "none" + if args.provenance: + if not args.provenance.is_absolute(): + fail("provenance must be an absolute path") + if args.provenance.is_symlink() or not args.provenance.is_file(): + fail("provenance is missing or indirect") + if args.provenance.stat().st_size > 1024 * 1024: + fail("provenance exceeds one MiB") + provenance_sha = sha256(args.provenance) + values = [ + line.partition("=")[2].strip().lower() + for line in args.provenance.read_text(encoding="utf-8").splitlines() + if line.partition("=")[0] == "verified_sha256" + ] + if len(values) != 1: + fail("provenance must contain exactly one verified_sha256") + build_sha = values[0] + if build_sha != PINNED_SHA256: + fail(f"reproducible-build SHA-256 mismatch (expected {PINNED_SHA256}, got {build_sha})") + + identity = subprocess.run( + [str(binary), "-version"], check=False, capture_output=True, text=True, timeout=5 + ) + version = (identity.stdout + identity.stderr).strip().splitlines() + if identity.returncode != 0 or not version or version[0] != PINNED_VERSION: + fail(f"unexpected version identity: {version[0] if version else 'no output'}") + + if not args.workroot.is_absolute(): + fail("workroot must be an absolute path") + if args.workroot.name in ("", ".", ".."): + fail("workroot has an unsafe leaf") + if args.workroot.exists() or args.workroot.is_symlink(): + fail("workroot already exists or is indirect") + workroot_parent = args.workroot.parent + if not workroot_parent.is_dir(): + fail("workroot parent is unavailable") + workroot = workroot_parent.resolve(strict=True) / args.workroot.name + if workroot.exists() or workroot.is_symlink(): + fail("canonical workroot already exists or is indirect") + workroot.mkdir(mode=0o700) + evidence_input = args.evidence or (workroot / "manifest.txt") + if not evidence_input.is_absolute(): + fail("evidence must be the exact WORKROOT/manifest.txt authority") + evidence = evidence_input.parent.resolve(strict=True) / evidence_input.name + if evidence != workroot / "manifest.txt": + fail("evidence must be the exact WORKROOT/manifest.txt authority") + + temporary = Path(tempfile.mkdtemp(prefix="runtime-", dir=workroot)) + process: subprocess.Popen[bytes] | None = None + vfkit_client: socket.socket | None = None + qemu_client: socket.socket | None = None + forced_termination = False + unexpected_status: int | None = None + ingress = b"" + reply = b"" + try: + vfkit_path = temporary / "vfkit.sock" + vfkit_client_path = temporary / "vfkit-client.sock" + qemu_path = temporary / "qemu.sock" + api_path = temporary / "api.sock" + process = subprocess.Popen( + [ + str(binary), + "-mtu", str(args.mtu), + "-listen-vfkit", f"unixgram://{vfkit_path}", + "-listen-qemu", f"unix://{qemu_path}", + "-listen", f"unix://{api_path}", + "-ssh-port", "-1", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + wait_for_paths(process, [vfkit_path, qemu_path], time.monotonic() + 5) + for socket_path in (vfkit_path, qemu_path): + socket_status = socket_path.stat() + if not stat.S_ISSOCK(socket_status.st_mode) or socket_status.st_uid != os.getuid(): + fail("gvproxy published a missing, indirect, or foreign-owned switch socket") + + vfkit_client = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + vfkit_client.bind(str(vfkit_client_path)) + vfkit_client.connect(str(vfkit_path)) + vfkit_client.settimeout(3) + vfkit_client.send(frame(BRIDGE_MAC, GUEST_MAC, b"learn-guest")) + + qemu_client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + qemu_client.connect(str(qemu_path)) + time.sleep(0.05) + + ingress = frame(GUEST_MAC, BRIDGE_MAC, b"lan-to-guest") + send_qemu_frame(qemu_client, ingress) + try: + actual_ingress = vfkit_client.recv(65536) + except TimeoutError: + fail("timed out waiting for LAN-to-guest Ethernet frame") + if actual_ingress != ingress: + fail("LAN-to-guest Ethernet frame changed in transit") + + reply = frame(BRIDGE_MAC, GUEST_MAC, b"guest-to-lan") + vfkit_client.send(reply) + receive_qemu_frame(qemu_client, reply, "guest-to-LAN Ethernet frame") + finally: + if vfkit_client is not None: + vfkit_client.close() + if qemu_client is not None: + qemu_client.close() + if process is not None: + unexpected_status = process.poll() + if unexpected_status is None: + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + forced_termination = True + process.kill() + process.wait(timeout=2) + shutil.rmtree(temporary, ignore_errors=True) + + if unexpected_status is not None: + fail(f"gvproxy exited before teardown with status {unexpected_status}") + if forced_termination: + fail("gvproxy did not terminate within two seconds of SIGTERM") + release_qualifying = args.release_candidate and build_sha == PINNED_SHA256 + evidence.write_text( + "\n".join( + [ + "schema=3", + "status=PASS", + f"source_commit={args.source_commit}", + f"gvproxy_sha256={actual_sha}", + f"gvproxy_build_sha256={build_sha}", + f"provenance_sha256={provenance_sha}", + f"gvproxy_version={PINNED_VERSION}", + f"mtu={args.mtu}", + "transport=qemu-unix-stream", + "same_user_switch_sockets=PASS", + "graceful_helper_shutdown=PASS", + "lan_to_guest=PASS", + "guest_to_lan=PASS", + f"frame_contract_sha256={hashlib.sha256(ingress + reply).hexdigest()}", + f"release_qualifying={str(release_qualifying).lower()}", + "", + ] + ), + encoding="utf-8", + ) + evidence.chmod(0o600) + print(f"gvproxy QEMU switch gate: PASS ({evidence})") + + +if __name__ == "__main__": + main() From 3d77caa54d13dc0a53f1b99d5689efe4b0e66e66 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:17:45 +0000 Subject: [PATCH 147/338] feat(release): qualify long-lived networking --- .../scripts/test-long-lived-network-soak.py | 101 ++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/long-lived-network-soak.sh | 524 ++++++++++++++++++ 4 files changed, 627 insertions(+) create mode 100755 .github/scripts/test-long-lived-network-soak.py create mode 100755 scripts/long-lived-network-soak.sh diff --git a/.github/scripts/test-long-lived-network-soak.py b/.github/scripts/test-long-lived-network-soak.py new file mode 100755 index 00000000..042b317b --- /dev/null +++ b/.github/scripts/test-long-lived-network-soak.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Offline contract for the >24-hour same-connection network release soak.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "long-lived-network-soak.sh" + + +class LongLivedNetworkSoakTests(unittest.TestCase): + def test_gate_binds_same_connection_latency_external_tcp_and_candidate(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-LONG-LIVED-TCP", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate duration must exceed 24 hours", + "release candidate image must be digest-pinned", + "release candidate fixture must resolve to linux/arm64", + "DOCKER_CONTEXT", + "required offline image is missing", + "`connection` is never replaced", + "measured TCP connection closed", + "connection tuple changed during the measured soak", + "duration_beyond_24_hours=", + "host.docker.internal", + "managed-machine p99 protocol RTT exceeded 100ms", + "sustained >=150ms plateau", + "managed-machine outbound TCP failed too often", + "managed-machine outbound TCP had consecutive failures", + "long-lived soak changed the exact fixture image identity", + "service fixture survived owned cleanup", + "managed-machine fixture survived owned cleanup", + "same_tcp_connection=PASS", + "machine_to_docker_service=PASS", + "machine_service_regular_200_400ms_plateau=ABSENT", + "machine_outbound_tcp=PASS", + "exact_image_identity=PASS", + "owned_cleanup=PASS", + "heartbeats_sha256=", + "machine_service_sha256=", + "machine_outbound_sha256=", + "summary_sha256=", + "docker_cli_sha256=", + "same_user_socket=PASS", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + 'echo "socket=$SOCKET"', + 'echo "docker=$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c31a4a21..1afaaf30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,6 +371,7 @@ jobs: python3 .github/scripts/test-nonnative-arch-pacman-gate.py python3 .github/scripts/test-nonnative-mmdebstrap-gate.py python3 .github/scripts/test-gvproxy-qemu-switch-gate.py + python3 .github/scripts/test-long-lived-network-soak.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e780db7..0cc3b300 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,7 @@ jobs: python3 .github/scripts/test-nonnative-arch-pacman-gate.py python3 .github/scripts/test-nonnative-mmdebstrap-gate.py python3 .github/scripts/test-gvproxy-qemu-switch-gate.py + python3 .github/scripts/test-long-lived-network-soak.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/long-lived-network-soak.sh b/scripts/long-lived-network-soak.sh new file mode 100755 index 00000000..9cbe90dc --- /dev/null +++ b/scripts/long-lived-network-soak.sh @@ -0,0 +1,524 @@ +#!/bin/bash +# Proves one active published TCP connection survives beyond the competitor's 24-hour failure edge +# while a Dory-style managed machine repeatedly reaches a Docker service through +# host.docker.internal without developing the reported 200/400 ms protocol-latency plateau, and +# independently samples external TCP so VM-to-host packet loss cannot hide behind local success. +set -euo pipefail + +SOCKET="" +DOCKER="" +IMAGE="" +DURATION=90000 +INTERVAL=30 +WORKROOT="${TMPDIR:-/tmp}/dory-long-lived-network" +CONFIRM="" +OUTBOUND_HOST="registry-1.docker.io" +OUTBOUND_PORT=443 +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <24-hour release contract + --help Show this help + +The gate creates uniquely named and labeled service/machine containers, keeps one host TCP socket +open for the full duration, and never reconnects that measured connection after it is established. +In parallel, the machine performs repeated HTTP round trips to a second published service port via +host.docker.internal and repeated external TCP connections after fresh IPv4 DNS resolution. An +outer deadline bounds every connect; DNS/connect failures are retained and budgeted. The gate +refuses to run without the exact confirmation token so a 25-hour fixture is not planted on a user +engine by accident. +EOF +} + +die() { echo "long-lived network soak: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --duration) need_value "$1" "$#"; DURATION="$2"; shift 2 ;; + --interval) need_value "$1" "$#"; INTERVAL="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --outbound-host) need_value "$1" "$#"; OUTBOUND_HOST="$2"; shift 2 ;; + --outbound-port) need_value "$1" "$#"; OUTBOUND_PORT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +positive_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a positive integer" ;; esac + [ "$2" -gt 0 ] || die "$1 must be a positive integer" +} + +[ "$CONFIRM" = "ISOLATED-ENGINE-LONG-LIVED-TCP" ] \ + || die "requires --confirm ISOLATED-ENGINE-LONG-LIVED-TCP" +for command in id python3 shasum stat; do + command -v "$command" >/dev/null || die "required command is missing: $command" +done +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +[ -n "$IMAGE" ] || die "--image is required" +positive_integer duration "$DURATION" +positive_integer interval "$INTERVAL" +printf '%s\n' "$OUTBOUND_HOST" | grep -Eq '^[A-Za-z0-9.-]+$' \ + || die "--outbound-host is invalid" +positive_integer outbound-port "$OUTBOUND_PORT" +[ "$OUTBOUND_PORT" -le 65535 ] || die "--outbound-port must be at most 65535" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" + [ "$DURATION" -gt 86400 ] || die "release candidate duration must exceed 24 hours" + printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "release candidate image must be digest-pinned" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +mkdir "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +docker_e() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_HOST="unix://$SOCKET" \ + "$DOCKER" "$@" +} +bounded() { + local limit="$1" pid started rc + shift + "$@" & + pid=$! + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + return "$rc" +} +bounded 10 docker_e version >/dev/null || die "Docker API is not ready at $SOCKET" +bounded 10 docker_e image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "required offline image is missing: $IMAGE" +image_id="$(docker_e image inspect -f '{{.Id}}' "$IMAGE")" +image_platform="$(docker_e image inspect -f '{{.Os}}/{{.Architecture}}' "$IMAGE")" +if [ "$RELEASE_CANDIDATE" -eq 1 ] && [ "$image_platform" != linux/arm64 ]; then + die "release candidate fixture must resolve to linux/arm64" +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-long-lived-$RUN_ID" +NAME="dory-long-lived-${RUN_ID//[^a-zA-Z0-9]/}" +MACHINE_NAME="$NAME-machine" +WORKDIR="$WORKROOT/$RUN_ID" +RESULTS="$WORKDIR/heartbeats.tsv" +MACHINE_RESULTS="$WORKDIR/machine-to-docker-rtt.tsv" +OUTBOUND_RESULTS="$WORKDIR/machine-outbound-tcp.tsv" +MACHINE_FAILURE="$WORKDIR/machine-runner-failure.txt" +MACHINE_HEARTBEAT="$WORKDIR/machine-runner-heartbeat.txt" +MACHINE_COMPLETE="$WORKDIR/machine-runner-complete.txt" +MANIFEST="$WORKDIR/manifest.txt" +mkdir -p "$WORKDIR" + +cleanup() { + bounded 15 docker_e rm -f "$MACHINE_NAME" >/dev/null 2>&1 || true + bounded 15 docker_e rm -f "$NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +read -r free_port service_port < "$MANIFEST" + +# BusyBox nc execs cat for one client. The container loops only after a client closes so it remains +# inspectable at the end; the host measurement itself never reconnects, so any premature close is +# still a hard failure rather than a successful retry. A second published port serves a tiny exact +# HTTP response to the machine-side latency runner without disturbing that persistent connection. +bounded 30 docker_e run -d --name "$NAME" --label "dev.dory.long-lived=$OWNER" \ + -p "127.0.0.1:$free_port:8080" -p "$service_port:8081" "$IMAGE" \ + sh -ec ' + while :; do + printf "HTTP/1.1 200 OK\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s\n" "$2" "$1" \ + | nc -l -p 8081 + done & + while :; do nc -l -p 8080 -e /bin/cat; done + ' sh "$service_marker" "$service_marker_bytes" > "$WORKDIR/container-id.txt" \ + || die "echo fixture creation failed or exceeded 30 seconds" + +# Match the public MachineService runtime shape closely enough to exercise its real network path: +# privileged container, host cgroup namespace, machine label, persistent init process, and a +# detached command doing the protocol work. Dory's create-request rewrite supplies +# host.docker.internal:host-gateway exactly as it does for user machines. +bounded 30 docker_e run -d --name "$MACHINE_NAME" \ + --hostname dory-latency-machine \ + --label "dev.dory.long-lived=$OWNER" \ + --label "dory.machine=alpine" \ + --privileged --cgroupns=host \ + --tmpfs /run --tmpfs /run/lock --tmpfs /tmp \ + -v "$WORKDIR:/evidence" \ + "$IMAGE" tail -f /dev/null > "$WORKDIR/machine-container-id.txt" \ + || die "managed-machine fixture creation failed or exceeded 30 seconds" +bounded 10 docker_e exec "$MACHINE_NAME" sh -ec \ + 'getent hosts host.docker.internal >/evidence/machine-service-hosts.txt 2>&1 || nslookup host.docker.internal >/evidence/machine-service-hosts.txt 2>&1' \ + || die "host.docker.internal did not resolve from the managed-machine fixture" +bounded 10 docker_e exec -d "$MACHINE_NAME" sh -ec ' + duration="$1" + interval="$2" + port="$3" + expected="$4" + outbound_host="$5" + outbound_port="$6" + monotonic_ms() { awk '\''{printf "%.0f\n", $1 * 1000}'\'' /proc/uptime; } + printf "sequence\tepoch\telapsed_seconds\trtt_ms\tstatus\n" > /evidence/machine-to-docker-rtt.tsv + printf "sequence\tepoch\telapsed_seconds\trtt_ms\tremote_ipv4\tstatus\n" > /evidence/machine-outbound-tcp.tsv + started_ms="$(monotonic_ms)" + deadline_ms=$((started_ms + duration * 1000)) + sequence=0 + while [ "$(monotonic_ms)" -lt "$deadline_ms" ]; do + sequence=$((sequence + 1)) + request_started_ms="$(monotonic_ms)" + if ! body="$(wget -qO- -T 2 "http://host.docker.internal:$port/health")"; then + printf "request %s failed at epoch %s\n" "$sequence" "$(date +%s)" > /evidence/machine-runner-failure.txt + exit 1 + fi + request_finished_ms="$(monotonic_ms)" + if [ "$body" != "$expected" ]; then + printf "request %s returned unexpected bytes\n" "$sequence" > /evidence/machine-runner-failure.txt + exit 1 + fi + rtt_ms=$((request_finished_ms - request_started_ms)) + elapsed_ms=$((request_finished_ms - started_ms)) + printf "%s\t%s\t%s.%03d\t%s\tPASS\n" \ + "$sequence" "$(date +%s)" "$((elapsed_ms / 1000))" "$((elapsed_ms % 1000))" "$rtt_ms" \ + >> /evidence/machine-to-docker-rtt.tsv + printf "%s\n" "$(date +%s)" > /evidence/machine-runner-heartbeat.txt.partial + mv /evidence/machine-runner-heartbeat.txt.partial /evidence/machine-runner-heartbeat.txt + outbound_started_ms="$(monotonic_ms)" + outbound_ip="$(nslookup "$outbound_host" 2>/dev/null \ + | awk "/^Address: / && \$2 ~ /^[0-9]+([.][0-9]+){3}\$/{address=\$2} END{print address}")" + if [ -n "$outbound_ip" ] \ + && timeout -s KILL 8 nc -z -w 5 "$outbound_ip" "$outbound_port" >/dev/null 2>&1; then + outbound_status=PASS + else + outbound_status=FAIL + fi + outbound_finished_ms="$(monotonic_ms)" + outbound_rtt_ms=$((outbound_finished_ms - outbound_started_ms)) + outbound_elapsed_ms=$((outbound_finished_ms - started_ms)) + printf "%s\t%s\t%s.%03d\t%s\t%s\t%s\n" \ + "$sequence" "$(date +%s)" "$((outbound_elapsed_ms / 1000))" \ + "$((outbound_elapsed_ms % 1000))" "$outbound_rtt_ms" "${outbound_ip:-NONE}" \ + "$outbound_status" >> /evidence/machine-outbound-tcp.tsv + next_sample_ms=$((started_ms + sequence * interval * 1000)) + remaining_ms=$((next_sample_ms - $(monotonic_ms))) + if [ "$remaining_ms" -gt 0 ]; then + sleep "$((remaining_ms / 1000)).$((remaining_ms % 1000))" + fi + done + finished_ms="$(monotonic_ms)" + printf "%s.%03d\n" "$(((finished_ms - started_ms) / 1000))" "$(((finished_ms - started_ms) % 1000))" \ + > /evidence/machine-duration-seconds.txt + printf "PASS\n" > /evidence/machine-runner-complete.txt +' sh "$DURATION" "$INTERVAL" "$service_port" "$service_marker" \ + "$OUTBOUND_HOST" "$OUTBOUND_PORT" \ + || die "managed-machine latency runner did not start" + +python3 - "$free_port" "$DURATION" "$INTERVAL" "$RESULTS" \ + "$MACHINE_FAILURE" "$MACHINE_HEARTBEAT" <<'PY' +import pathlib +import socket +import sys +import time + +port, duration, interval = map(int, sys.argv[1:4]) +results_path = sys.argv[4] +machine_failure = pathlib.Path(sys.argv[5]) +machine_heartbeat = pathlib.Path(sys.argv[6]) + +# Startup retries happen before measurement. Once this returns, `connection` is never replaced. +startup_deadline = time.monotonic() + 30 +while True: + try: + connection = socket.create_connection(("127.0.0.1", port), timeout=2) + break + except OSError: + if time.monotonic() >= startup_deadline: + raise SystemExit("published echo server did not become reachable within 30 seconds") + time.sleep(0.2) + +connection.settimeout(max(2, min(10, interval))) +started_mono = time.monotonic() +started_epoch = int(time.time()) +deadline = started_mono + duration +sequence = 0 + +with connection, open(results_path, "w", encoding="utf-8", buffering=1) as results: + results.write("sequence\tepoch\telapsed_seconds\tlocal\tremote\tstatus\n") + local = f"{connection.getsockname()[0]}:{connection.getsockname()[1]}" + remote = f"{connection.getpeername()[0]}:{connection.getpeername()[1]}" + while True: + now = time.monotonic() + if now >= deadline: + break + sequence += 1 + payload = f"dory-long-lived-{started_epoch}-{sequence}\n".encode() + connection.sendall(payload) + received = bytearray() + while len(received) < len(payload): + chunk = connection.recv(len(payload) - len(received)) + if not chunk: + raise SystemExit( + f"measured TCP connection closed at sequence {sequence} after " + f"{time.monotonic() - started_mono:.3f}s" + ) + received.extend(chunk) + if received != payload: + raise SystemExit(f"echo mismatch at sequence {sequence}") + elapsed = time.monotonic() - started_mono + results.write(f"{sequence}\t{int(time.time())}\t{elapsed:.3f}\t{local}\t{remote}\tPASS\n") + if machine_failure.exists(): + raise SystemExit( + "managed-machine protocol runner failed: " + + machine_failure.read_text(encoding="utf-8", errors="replace").strip() + ) + if elapsed > max(5, interval * 2): + if not machine_heartbeat.exists(): + raise SystemExit("managed-machine protocol runner produced no heartbeat") + heartbeat_age = time.time() - machine_heartbeat.stat().st_mtime + if heartbeat_age > interval * 2 + 10: + raise SystemExit( + f"managed-machine protocol runner heartbeat is stale by {heartbeat_age:.1f}s" + ) + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(interval, remaining)) + +elapsed = time.monotonic() - started_mono +if elapsed < duration: + raise SystemExit(f"connection measurement ended early after {elapsed:.3f}s") +with open(results_path + ".duration-seconds", "w", encoding="utf-8") as duration_file: + duration_file.write(f"{elapsed:.3f}\n") +print(f"same-connection active TCP PASS: duration={elapsed:.3f}s heartbeats={sequence}") +PY + +wait_for_machine_completion() { + while [ ! -s "$MACHINE_COMPLETE" ]; do + [ ! -s "$MACHINE_FAILURE" ] || return 1 + sleep 0.1 + done +} +bounded $((INTERVAL + 30)) wait_for_machine_completion \ + || die "managed-machine protocol runner failed or did not complete after the duration" +grep -qx PASS "$MACHINE_COMPLETE" || die "managed-machine protocol runner did not record PASS" +bounded 10 docker_e inspect -f '{{.State.Running}}' "$MACHINE_NAME" | grep -qx true \ + || die "managed-machine fixture stopped during the latency soak" +bounded 10 docker_e exec "$MACHINE_NAME" true \ + || die "managed-machine exec failed after the latency soak" + +bounded 10 docker_e inspect "$NAME" > "$WORKDIR/container-inspect.json" \ + || die "container inspect failed or exceeded 10 seconds after the connection soak" +bounded 10 docker_e exec "$NAME" true \ + || die "container exec failed or exceeded 10 seconds after the connection soak" +[ "$(docker_e image inspect -f '{{.Id}}' "$IMAGE")" = "$image_id" ] \ + || die "long-lived soak changed the exact fixture image identity" +heartbeats="$(($(wc -l < "$RESULTS") - 1))" +actual_elapsed="$(cat "$RESULTS.duration-seconds")" +tuple_count="$(awk -F '\t' 'NR > 1 {print $4 "\t" $5}' "$RESULTS" | LC_ALL=C sort -u | wc -l | tr -d ' ')" +[ "$heartbeats" -gt 0 ] || die "connection soak recorded no heartbeats" +[ "$tuple_count" -eq 1 ] || die "connection tuple changed during the measured soak" +beyond_24_hours="$(awk -v elapsed="$actual_elapsed" \ + 'BEGIN {if (elapsed > 86400) print "PASS"; else print "NOT_PROVEN"}')" +machine_stats="$(python3 - "$MACHINE_RESULTS" "$OUTBOUND_RESULTS" \ + "$WORKDIR/machine-duration-seconds.txt" "$DURATION" "$INTERVAL" <<'PY' +import csv +import math +import pathlib +import sys + +results_path = pathlib.Path(sys.argv[1]) +outbound_path = pathlib.Path(sys.argv[2]) +duration_path = pathlib.Path(sys.argv[3]) +duration = int(sys.argv[4]) +interval = int(sys.argv[5]) +with results_path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle, delimiter="\t")) +def require(condition, detail): + if not condition: + raise SystemExit(detail) + +require(bool(rows), "managed-machine latency evidence has no samples") +require(all(row.get("status") == "PASS" for row in rows), + "managed-machine latency evidence contains a failed sample") +values = [int(row["rtt_ms"]) for row in rows] +require(all(value >= 0 for value in values), "managed-machine latency evidence has a negative RTT") +expected_samples = max(1, duration // interval) +require(len(values) >= max(1, math.floor(expected_samples * 0.90)), + f"managed-machine latency evidence is too sparse: {len(values)} < 90% of {expected_samples}") +elapsed = float(duration_path.read_text(encoding="utf-8").strip()) +require(elapsed >= duration, f"managed-machine latency measurement ended early after {elapsed:.3f}s") +ordered = sorted(values) +p99 = ordered[max(0, math.ceil(len(ordered) * 0.99) - 1)] +over_100 = sum(value > 100 for value in values) +at_least_200 = sum(value >= 200 for value in values) +allowed_over_100 = max(1, math.floor(len(values) * 0.01)) +require(p99 <= 100, f"managed-machine p99 protocol RTT exceeded 100ms: {p99}ms") +require(over_100 <= allowed_over_100, + f"managed-machine protocol RTT exceeded 100ms too often: {over_100}/{len(values)}") +run = 0 +max_run = 0 +for value in values: + run = run + 1 if value >= 150 else 0 + max_run = max(max_run, run) +require(max_run < 3, + f"managed-machine protocol RTT formed a sustained >=150ms plateau ({max_run} samples)") +with outbound_path.open(newline="", encoding="utf-8") as handle: + outbound_rows = list(csv.DictReader(handle, delimiter="\t")) +require(len(outbound_rows) == len(rows), + "managed-machine outbound evidence does not match the local-route sample count") +outbound_successes = sum(row.get("status") == "PASS" for row in outbound_rows) +outbound_failures = len(outbound_rows) - outbound_successes +allowed_outbound_failures = max(1, math.floor(len(outbound_rows) * 0.005)) +require(outbound_failures <= allowed_outbound_failures, + f"managed-machine outbound TCP failed too often: {outbound_failures}/{len(outbound_rows)}") +failure_run = 0 +max_failure_run = 0 +for row in outbound_rows: + failure_run = failure_run + 1 if row.get("status") != "PASS" else 0 + max_failure_run = max(max_failure_run, failure_run) +require(max_failure_run < 2, + f"managed-machine outbound TCP had consecutive failures ({max_failure_run})") +require(all(int(row["rtt_ms"]) >= 0 for row in outbound_rows), + "managed-machine outbound TCP has a negative RTT") +require(all( + len(row["remote_ipv4"].split(".")) == 4 + and all(part.isdigit() and 0 <= int(part) <= 255 for part in row["remote_ipv4"].split(".")) + for row in outbound_rows if row.get("status") == "PASS" +), "managed-machine outbound PASS row lacks a valid IPv4 target") +print(f"machine_service_samples={len(values)}") +print(f"machine_service_actual_elapsed_seconds={elapsed:.3f}") +print(f"machine_service_p99_ms={p99}") +print(f"machine_service_max_ms={max(values)}") +print(f"machine_service_over_100ms_samples={over_100}") +print(f"machine_service_200ms_samples={at_least_200}") +print(f"machine_service_max_sustained_150ms_samples={max_run}") +print(f"machine_outbound_samples={len(outbound_rows)}") +print(f"machine_outbound_tcp_successes={outbound_successes}") +print(f"machine_outbound_timeout_samples={outbound_failures}") +print(f"machine_outbound_max_consecutive_failures={max_failure_run}") +PY +)" || die "managed-machine-to-Docker latency evidence failed semantic verification" +cleanup +set -e +trap - EXIT INT TERM +docker_e inspect "$NAME" >/dev/null 2>&1 && die "service fixture survived owned cleanup" +docker_e inspect "$MACHINE_NAME" >/dev/null 2>&1 && die "managed-machine fixture survived owned cleanup" +release_qualifying=false +if [ "$RELEASE_CANDIDATE" -eq 1 ] && [ "$beyond_24_hours" = PASS ]; then + release_qualifying=true +fi +{ + echo "status=PASS" + echo "completed_epoch=$(date +%s)" + echo "heartbeats=$heartbeats" + echo "actual_elapsed_seconds=$actual_elapsed" + echo "unique_connection_tuples=$tuple_count" + echo "same_tcp_connection=PASS" + echo "duration_beyond_24_hours=$beyond_24_hours" + echo "machine_to_docker_service=PASS" + echo "machine_service_route=host.docker.internal" + echo "machine_service_regular_200_400ms_plateau=ABSENT" + echo "machine_outbound_tcp=PASS" + echo "machine_outbound_failure_budget_per_mille=5" + echo "machine_outbound_consecutive_failure_limit=2" + echo "exact_image_identity=PASS" + echo "owned_cleanup=PASS" + echo "release_qualifying=$release_qualifying" + printf '%s\n' "$machine_stats" +} > "$WORKDIR/summary.txt" +{ + echo "heartbeats_sha256=$(shasum -a 256 "$RESULTS" | awk '{print $1}')" + echo "machine_service_sha256=$(shasum -a 256 "$MACHINE_RESULTS" | awk '{print $1}')" + echo "machine_outbound_sha256=$(shasum -a 256 "$OUTBOUND_RESULTS" | awk '{print $1}')" + echo "summary_sha256=$(shasum -a 256 "$WORKDIR/summary.txt" | awk '{print $1}')" + echo "docker_cli_sha256=$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + echo "same_user_socket=PASS" + echo "status=PASS" + echo "release_qualifying=$release_qualifying" + echo "completed_epoch=$(date +%s)" +} >> "$MANIFEST" +echo "long-lived network soak: PASS ($WORKDIR/summary.txt)" From 66e16c2943116905e26e49c627990c52e267727b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:27:03 +0000 Subject: [PATCH 148/338] feat(release): qualify endurance reliability --- .../test-endurance-reliability-soak.py | 157 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/analyze-endurance-resources.py | 181 ++++++ scripts/endurance-reliability-soak.sh | 567 ++++++++++++++++++ 5 files changed, 907 insertions(+) create mode 100755 .github/scripts/test-endurance-reliability-soak.py create mode 100755 scripts/analyze-endurance-resources.py create mode 100755 scripts/endurance-reliability-soak.sh diff --git a/.github/scripts/test-endurance-reliability-soak.py b/.github/scripts/test-endurance-reliability-soak.py new file mode 100755 index 00000000..162e5650 --- /dev/null +++ b/.github/scripts/test-endurance-reliability-soak.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Offline contract for the eight-hour endurance reliability release soak.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "endurance-reliability-soak.sh" +ANALYZER = ROOT / "scripts" / "analyze-endurance-resources.py" +HEADER = ( + "phase\tcycle\tepoch\tpid_count\tfd_total\trss_kb\tcpu_percent\tstate_kb\t" + "fseventsd_pid_count\tfseventsd_rss_kb\tfseventsd_cpu_percent\n" +) + + +class EnduranceReliabilitySoakTests(unittest.TestCase): + def test_gate_binds_isolated_candidate_resources_and_terminal_evidence(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + subprocess.run(["python3", "-m", "py_compile", str(ANALYZER)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-ENDURANCE-RELIABILITY", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "state directory must be a direct canonical path", + "process root must exactly equal the state directory", + "dory-dataplane-proxy|gvproxy", + "Docker CLI must be a direct canonical path", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate evidence cannot use --cycles", + "release candidate duration must be at least eight hours", + "release candidate image must be digest-pinned", + "release candidate fixture must resolve to linux/arm64", + "release candidate FD growth budget is too permissive", + "DOCKER_CONTEXT", + "COMPOSE_PROJECT_NAME", + 'bounded 120 docker_raw "$@"', + "resource analyzer is unavailable or indirect", + "resource analyzer changed during the soak", + "isolated Docker socket identity changed during the soak", + "isolated state authority changed during the soak", + "Docker CLI changed during the soak", + "endurance gate changed during the soak", + "endurance soak changed the exact fixture image identity", + "guest ownership request changed host bind ownership", + "duration-based soak ended before its requested wall time", + "cycles_sha256=", + "resources_sha256=", + "resource_analysis_sha256=", + "analyzer_sha256=", + "same_user_socket=PASS", + "exact_process_authority=PASS", + "exact_image_identity=PASS", + "resource_plateau=PASS", + "owned_cleanup=PASS", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" docker', + 'echo "socket=$SOCKET"', + 'echo "state_dir=$STATE_DIR"', + 'echo "process_root=$PROCESS_ROOT"', + "DORY_ENDURANCE_SOURCE_ONLY", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_state_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + workroot = root / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(root / "missing.sock"), + "--state-dir", + str(root / "missing-state"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--process-root", + str(root / "missing-state"), + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + def test_analyzer_accepts_exact_plateau_and_rejects_bad_schema_and_nan(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + valid = root / "valid.tsv" + valid.write_text( + HEADER + + "baseline\t0\t100\t2\t10\t1000\t1.0\t2000\t1\t3000\t1.0\n" + + "cleaned\t1\t110\t2\t11\t1010\t1.0\t2010\t1\t3010\t1.0\n" + + "final\t1\t120\t2\t11\t1010\t1.0\t2010\t1\t3010\t1.0\n", + encoding="utf-8", + ) + args = [ + "python3", + str(ANALYZER), + str(valid), + "--fd-growth", + "16", + "--rss-growth-mb", + "384", + "--disk-growth-mb", + "256", + "--idle-cpu", + "25", + "--fseventsd-rss-growth-mb", + "128", + "--fseventsd-cpu", + "25", + ] + result = subprocess.run(args, text=True, capture_output=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("resource plateau PASS", result.stdout) + + bad_schema = root / "bad-schema.tsv" + bad_schema.write_text(HEADER.replace("phase", "unexpected") + "x\n", encoding="utf-8") + result = subprocess.run(args[:2] + [str(bad_schema)] + args[3:], text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unexpected schema", result.stderr) + + invalid = root / "invalid.tsv" + invalid.write_text(valid.read_text(encoding="utf-8").replace("1.0", "nan", 1), encoding="utf-8") + result = subprocess.run(args[:2] + [str(invalid)] + args[3:], text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("finite and non-negative", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1afaaf30..e1f85627 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -372,6 +372,7 @@ jobs: python3 .github/scripts/test-nonnative-mmdebstrap-gate.py python3 .github/scripts/test-gvproxy-qemu-switch-gate.py python3 .github/scripts/test-long-lived-network-soak.py + python3 .github/scripts/test-endurance-reliability-soak.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0cc3b300..4f5363be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,6 +73,7 @@ jobs: python3 .github/scripts/test-nonnative-mmdebstrap-gate.py python3 .github/scripts/test-gvproxy-qemu-switch-gate.py python3 .github/scripts/test-long-lived-network-soak.py + python3 .github/scripts/test-endurance-reliability-soak.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/analyze-endurance-resources.py b/scripts/analyze-endurance-resources.py new file mode 100755 index 00000000..bda2f0c1 --- /dev/null +++ b/scripts/analyze-endurance-resources.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Fail closed when cleaned Dory resource samples do not form a bounded plateau.""" + +from __future__ import annotations + +import argparse +import csv +import math +import statistics +from pathlib import Path + + +EXPECTED_FIELDS = [ + "phase", + "cycle", + "epoch", + "pid_count", + "fd_total", + "rss_kb", + "cpu_percent", + "state_kb", + "fseventsd_pid_count", + "fseventsd_rss_kb", + "fseventsd_cpu_percent", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("resources", type=Path) + parser.add_argument("--fd-growth", type=int, required=True) + parser.add_argument("--rss-growth-mb", type=int, required=True) + parser.add_argument("--disk-growth-mb", type=int, required=True) + parser.add_argument("--idle-cpu", type=float, required=True) + parser.add_argument("--fseventsd-rss-growth-mb", type=int, required=True) + parser.add_argument("--fseventsd-cpu", type=float, required=True) + return parser.parse_args() + + +def numeric(row: dict[str, str], key: str) -> float: + try: + value = float(row[key]) + except (KeyError, TypeError, ValueError) as error: + raise SystemExit(f"invalid resource sample field {key!r}: {row.get(key)!r}") from error + if not math.isfinite(value) or value < 0: + raise SystemExit(f"resource sample field {key!r} must be finite and non-negative") + return value + + +def exact_integer(row: dict[str, str], key: str) -> int: + text = row.get(key, "") + if not text.isdigit(): + raise SystemExit(f"resource sample field {key!r} must be an unsigned integer") + return int(text) + + +def validate_rows(rows: list[dict[str, str]]) -> None: + if len(rows) < 3: + raise SystemExit("baseline, cleaned, and final resource samples are required") + phases = [row.get("phase") for row in rows] + if phases.count("baseline") != 1 or phases[0] != "baseline": + raise SystemExit("resource evidence must begin with exactly one baseline sample") + if phases.count("final") != 1 or phases[-1] != "final": + raise SystemExit("resource evidence must end with exactly one final sample") + if any(phase not in {"baseline", "cleaned", "final"} for phase in phases): + raise SystemExit("resource evidence contains an unknown phase") + + previous_epoch = -1 + previous_cleaned_cycle = 0 + for index, row in enumerate(rows): + if set(row) != set(EXPECTED_FIELDS): + raise SystemExit(f"resource sample {index + 1} has an unexpected shape") + if any(not isinstance(row.get(field), str) or row[field] == "" for field in EXPECTED_FIELDS): + raise SystemExit(f"resource sample {index + 1} contains an empty field") + cycle = exact_integer(row, "cycle") + epoch = exact_integer(row, "epoch") + if epoch < previous_epoch: + raise SystemExit("resource sample epochs are not monotonic") + previous_epoch = epoch + for key in EXPECTED_FIELDS[3:]: + numeric(row, key) + if exact_integer(row, "pid_count") == 0: + raise SystemExit("resource sample has no attributed Dory process") + if exact_integer(row, "fseventsd_pid_count") == 0: + raise SystemExit("resource sample has no attributed fseventsd process") + if row["phase"] == "baseline" and cycle != 0: + raise SystemExit("baseline cycle must be zero") + if row["phase"] == "cleaned": + if cycle != previous_cleaned_cycle + 1: + raise SystemExit("cleaned resource cycles are not contiguous") + previous_cleaned_cycle = cycle + if row["phase"] == "final" and cycle != previous_cleaned_cycle: + raise SystemExit("final resource cycle does not match the last cleaned cycle") + + +def median(rows: list[dict[str, str]], key: str) -> float: + return statistics.median(numeric(row, key) for row in rows) + + +def format_growth(value: float) -> str: + return str(int(value)) if value.is_integer() else f"{value:.1f}" + + +def main() -> None: + args = parse_args() + if min(args.fd_growth, args.rss_growth_mb, args.disk_growth_mb, args.fseventsd_rss_growth_mb) < 0: + raise SystemExit("resource growth budgets must be non-negative") + if min(args.idle_cpu, args.fseventsd_cpu) < 0: + raise SystemExit("CPU ceilings must be non-negative") + + try: + handle = args.resources.open(encoding="utf-8", newline="") + except OSError as error: + raise SystemExit(f"cannot read resource evidence: {error}") from error + with handle: + reader = csv.DictReader(handle, delimiter="\t") + if reader.fieldnames != EXPECTED_FIELDS: + raise SystemExit("resource evidence has an unexpected schema") + rows = list(reader) + validate_rows(rows) + cleaned = [row for row in rows if row.get("phase") in {"cleaned", "final"}] + if len(cleaned) < 2: + raise SystemExit("at least two cleaned/final resource samples are required") + + # At eight-hour scale, 200 samples remove cold-start and one-off allocator noise while still + # comparing disjoint early/late periods. Short preflights use one quarter of their samples. + window_size = min(200, max(1, len(cleaned) // 4)) + first = cleaned[:window_size] + last = cleaned[-window_size:] + failures: list[str] = [] + + process_growth = median(last, "pid_count") - median(first, "pid_count") + fd_growth = median(last, "fd_total") - median(first, "fd_total") + rss_growth = median(last, "rss_kb") - median(first, "rss_kb") + disk_growth = median(last, "state_kb") - median(first, "state_kb") + fseventsd_rss_growth = median(last, "fseventsd_rss_kb") - median(first, "fseventsd_rss_kb") + if process_growth > 0: + failures.append(f"median Dory process count grew by {format_growth(process_growth)}") + if fd_growth > args.fd_growth: + failures.append(f"median FD growth {format_growth(fd_growth)} > {args.fd_growth}") + if rss_growth > args.rss_growth_mb * 1024: + failures.append( + f"median RSS growth {format_growth(rss_growth)} KiB > {args.rss_growth_mb} MiB" + ) + if disk_growth > args.disk_growth_mb * 1024: + failures.append( + f"median state growth {format_growth(disk_growth)} KiB > {args.disk_growth_mb} MiB" + ) + if fseventsd_rss_growth > args.fseventsd_rss_growth_mb * 1024: + failures.append( + f"median fseventsd RSS growth {format_growth(fseventsd_rss_growth)} KiB > " + f"{args.fseventsd_rss_growth_mb} MiB" + ) + + idle_rows = cleaned[-min(5, len(cleaned)) :] + idle_cpu = median(idle_rows, "cpu_percent") + fseventsd_cpu = median(idle_rows, "fseventsd_cpu_percent") + if idle_cpu > args.idle_cpu: + failures.append(f"median cleaned CPU {idle_cpu:.2f}% > {args.idle_cpu:g}%") + if fseventsd_cpu > args.fseventsd_cpu: + failures.append( + f"median cleaned fseventsd CPU {fseventsd_cpu:.2f}% > {args.fseventsd_cpu:g}%" + ) + if failures: + raise SystemExit("; ".join(failures)) + + print( + "resource plateau PASS: " + f"window={window_size} " + f"process_growth={format_growth(process_growth)} " + f"fd_growth={format_growth(fd_growth)} " + f"rss_growth_kib={format_growth(rss_growth)} " + f"disk_growth_kib={format_growth(disk_growth)} " + f"cleaned_cpu_median={idle_cpu:.2f}% " + f"fseventsd_rss_growth_kib={format_growth(fseventsd_rss_growth)} " + f"fseventsd_cpu_median={fseventsd_cpu:.2f}%" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/endurance-reliability-soak.sh b/scripts/endurance-reliability-soak.sh new file mode 100755 index 00000000..970dbb85 --- /dev/null +++ b/scripts/endurance-reliability-soak.sh @@ -0,0 +1,567 @@ +#!/bin/bash +# Eight-hour release gate for the user-visible failure modes behind intermittent shares, leaked +# descriptors, high idle CPU/memory, and unbounded engine-state growth. Every Docker object is +# run-scoped and labeled; the gate never pulls workload images implicitly. +set -euo pipefail +export LC_ALL=C LANG=C +umask 077 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +SOCKET="" +STATE_DIR="" +DOCKER="" +ALPINE_IMAGE="" +WORKROOT="${TMPDIR:-/tmp}/dory-endurance-reliability" +DURATION_SECONDS=28800 +CYCLES=0 +FILES_PER_CYCLE=100 +COMPOSE_EVERY=5 +SETTLE_SECONDS=2 +FD_GROWTH_BUDGET=16 +RSS_GROWTH_MB=384 +DISK_GROWTH_MB=256 +IDLE_CPU_PERCENT=25 +FSEVENTSD_RSS_GROWTH_MB=128 +FSEVENTSD_CPU_PERCENT=25 +MIN_FREE_GB=2 +PROCESS_ROOT="" +CONFIRM="" +SOURCE_COMMIT="" +RELEASE_CANDIDATE=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --socket) need_value "$1" "$#"; SOCKET="$2"; shift 2 ;; + --state-dir) need_value "$1" "$#"; STATE_DIR="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --image) need_value "$1" "$#"; ALPINE_IMAGE="$2"; shift 2 ;; + --duration) need_value "$1" "$#"; DURATION_SECONDS="$2"; shift 2 ;; + --cycles) need_value "$1" "$#"; CYCLES="$2"; shift 2 ;; + --files) need_value "$1" "$#"; FILES_PER_CYCLE="$2"; shift 2 ;; + --compose-every) need_value "$1" "$#"; COMPOSE_EVERY="$2"; shift 2 ;; + --settle) need_value "$1" "$#"; SETTLE_SECONDS="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --fd-growth) need_value "$1" "$#"; FD_GROWTH_BUDGET="$2"; shift 2 ;; + --rss-growth-mb) need_value "$1" "$#"; RSS_GROWTH_MB="$2"; shift 2 ;; + --disk-growth-mb) need_value "$1" "$#"; DISK_GROWTH_MB="$2"; shift 2 ;; + --idle-cpu) need_value "$1" "$#"; IDLE_CPU_PERCENT="$2"; shift 2 ;; + --fseventsd-rss-growth-mb) need_value "$1" "$#"; FSEVENTSD_RSS_GROWTH_MB="$2"; shift 2 ;; + --fseventsd-cpu) need_value "$1" "$#"; FSEVENTSD_CPU_PERCENT="$2"; shift 2 ;; + --min-free-gb) need_value "$1" "$#"; MIN_FREE_GB="$2"; shift 2 ;; + --process-root) need_value "$1" "$#"; PROCESS_ROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --release-candidate) RELEASE_CANDIDATE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +nonnegative_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a non-negative integer" ;; esac +} +[ "$CONFIRM" = "ISOLATED-ENGINE-ENDURANCE-RELIABILITY" ] \ + || die "requires --confirm ISOLATED-ENGINE-ENDURANCE-RELIABILITY" +for pair in \ + "duration:$DURATION_SECONDS" "cycles:$CYCLES" "files:$FILES_PER_CYCLE" \ + "compose-every:$COMPOSE_EVERY" "settle:$SETTLE_SECONDS" \ + "fd-growth:$FD_GROWTH_BUDGET" "rss-growth-mb:$RSS_GROWTH_MB" \ + "disk-growth-mb:$DISK_GROWTH_MB" "idle-cpu:$IDLE_CPU_PERCENT" \ + "fseventsd-rss-growth-mb:$FSEVENTSD_RSS_GROWTH_MB" \ + "fseventsd-cpu:$FSEVENTSD_CPU_PERCENT" \ + "min-free-gb:$MIN_FREE_GB"; do + nonnegative_integer "${pair%%:*}" "${pair#*:}" +done +[ "$CYCLES" -gt 0 ] || [ "$DURATION_SECONDS" -gt 0 ] || die "duration or cycles must be positive" +[ "$FILES_PER_CYCLE" -gt 0 ] || die "files must be positive" +[ "$COMPOSE_EVERY" -gt 0 ] || die "compose-every must be positive" +[ "$MIN_FREE_GB" -gt 0 ] || die "min-free-gb must be positive" + +for command in id python3 lsof ps du shasum stat df awk grep find xargs; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +ANALYZER="$SCRIPT_DIR/analyze-endurance-resources.py" +[ -f "$ANALYZER" ] && [ ! -L "$ANALYZER" ] \ + || die "resource analyzer is unavailable or indirect" +ANALYZER_SHA256="$(shasum -a 256 "$ANALYZER" | awk '{print $1}')" +GATE_SHA256="$(shasum -a 256 "${BASH_SOURCE[0]}" | awk '{print $1}')" +case "$SOCKET" in /*) ;; *) die "Dory socket must be an absolute path" ;; esac +[ -S "$SOCKET" ] && [ ! -L "$SOCKET" ] || die "Dory socket is unavailable or indirect: $SOCKET" +[ "$(stat -f %u "$SOCKET")" = "$(id -u)" ] || die "Dory socket is not owned by the release user" +case "$STATE_DIR" in /*) ;; *) die "state directory must be an absolute path" ;; esac +[ -d "$STATE_DIR" ] && [ ! -L "$STATE_DIR" ] || die "state directory is unavailable or indirect" +[ "$(stat -f %u "$STATE_DIR")" = "$(id -u)" ] || die "state directory is not owned by the release user" +state_canonical="$(cd "$STATE_DIR" && pwd -P)" +[ "$state_canonical" = "$STATE_DIR" ] || die "state directory must be a direct canonical path" +case "$PROCESS_ROOT" in /*) ;; *) die "process root must be an absolute path" ;; esac +[ "$PROCESS_ROOT" = "$STATE_DIR" ] || die "process root must exactly equal the state directory" +case "$DOCKER" in /*) ;; *) die "Docker CLI must be an absolute path" ;; esac +[ -f "$DOCKER" ] && [ ! -L "$DOCKER" ] && [ -s "$DOCKER" ] && [ -x "$DOCKER" ] \ + || die "Docker CLI is unavailable or indirect: $DOCKER" +docker_canonical="$(cd "$(dirname "$DOCKER")" && pwd -P)/$(basename "$DOCKER")" +[ "$docker_canonical" = "$DOCKER" ] || die "Docker CLI must be a direct canonical path" +[ -n "$ALPINE_IMAGE" ] || die "--image is required" +if [ -n "$SOURCE_COMMIT" ]; then + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +fi +if [ "$RELEASE_CANDIDATE" -eq 1 ]; then + [ -n "$SOURCE_COMMIT" ] || die "release candidate evidence requires --source-commit" + [ "$CYCLES" -eq 0 ] || die "release candidate evidence cannot use --cycles" + [ "$DURATION_SECONDS" -ge 28800 ] || die "release candidate duration must be at least eight hours" + [ "$FILES_PER_CYCLE" -ge 100 ] || die "release candidate requires at least 100 files per cycle" + [ "$COMPOSE_EVERY" -le 5 ] || die "release candidate must exercise Compose at least every five cycles" + [ "$SETTLE_SECONDS" -ge 2 ] || die "release candidate settle interval must be at least two seconds" + [ "$FD_GROWTH_BUDGET" -le 16 ] || die "release candidate FD growth budget is too permissive" + [ "$RSS_GROWTH_MB" -le 384 ] || die "release candidate RSS growth budget is too permissive" + [ "$DISK_GROWTH_MB" -le 256 ] || die "release candidate disk growth budget is too permissive" + [ "$IDLE_CPU_PERCENT" -le 25 ] || die "release candidate idle CPU ceiling is too permissive" + [ "$FSEVENTSD_RSS_GROWTH_MB" -le 128 ] \ + || die "release candidate fseventsd RSS growth budget is too permissive" + [ "$FSEVENTSD_CPU_PERCENT" -le 25 ] \ + || die "release candidate fseventsd CPU ceiling is too permissive" + [ "$MIN_FREE_GB" -ge 2 ] || die "release candidate disk reserve must be at least two GiB" + printf '%s\n' "$ALPINE_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "release candidate image must be digest-pinned" +fi +case "$WORKROOT" in /*) ;; *) die "workroot must be an absolute path" ;; esac +case "$WORKROOT" in /|"$HOME") die "unsafe workroot: $WORKROOT" ;; esac +[ ! -e "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot already exists or is indirect: $WORKROOT" +workroot_parent="$(dirname "$WORKROOT")" +[ -d "$workroot_parent" ] && [ ! -L "$workroot_parent" ] \ + || die "workroot parent is unavailable or indirect" +[ "$(cd "$workroot_parent" && pwd -P)" = "$workroot_parent" ] \ + || die "workroot parent must be a direct canonical path" +mkdir "$WORKROOT" +chmod 0700 "$WORKROOT" +WORKROOT="$(cd "$WORKROOT" && pwd -P)" + +docker_raw() { + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u COMPOSE_FILE -u COMPOSE_PROJECT_NAME \ + -u COMPOSE_PROFILES DOCKER_HOST="unix://$SOCKET" "$DOCKER" "$@" +} +bounded() { + local limit="$1" pid started rc + shift + "$@" & + pid=$! + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + return "$rc" +} +docker_e() { + bounded 120 docker_raw "$@" +} +bounded 15 docker_raw version >/dev/null || die "Docker API is not ready at the isolated socket" +bounded 15 docker_raw image inspect "$ALPINE_IMAGE" >/dev/null 2>&1 \ + || die "required offline image is missing: $ALPINE_IMAGE" +bounded 15 docker_raw compose version >/dev/null 2>&1 || die "docker compose plugin is unavailable" +image_id="$(docker_e image inspect -f '{{.Id}}' "$ALPINE_IMAGE")" +image_platform="$(docker_e image inspect -f '{{.Os}}/{{.Architecture}}' "$ALPINE_IMAGE")" +if [ "$RELEASE_CANDIDATE" -eq 1 ] && [ "$image_platform" != linux/arm64 ]; then + die "release candidate fixture must resolve to linux/arm64" +fi +docker_cli_sha256="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" + +disk_probe_path="$WORKROOT" +available_disk_kb() { + df -Pk "$disk_probe_path" | awk 'NR == 2 {print $4}' +} +MIN_FREE_KB=$((MIN_FREE_GB * 1024 * 1024)) +initial_free_kb="$(available_disk_kb)" +[ "$initial_free_kb" -ge "$MIN_FREE_KB" ] \ + || die "host disk headroom is ${initial_free_kb} KiB; at least ${MIN_FREE_KB} KiB is required" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OWNER="dory-endurance-$RUN_ID" +WORKDIR="$WORKROOT/$RUN_ID" +BIND_DIR="$WORKDIR/share" +RESULTS="$WORKDIR/cycles.tsv" +RESOURCES="$WORKDIR/resources.tsv" +MANIFEST="$WORKDIR/manifest.txt" +ANALYSIS="$WORKDIR/resource-analysis.txt" +COMPOSE_PROJECTS="$WORKDIR/compose-projects.tsv" +START_EPOCH="$(date +%s)" +SOCKET_IDENTITY="$(stat -f '%d:%i:%u' "$SOCKET")" +STATE_IDENTITY="$(stat -f '%d:%i:%u' "$STATE_DIR")" +SOCKET_IDENTITY_SHA256="$(printf '%s\n' "$SOCKET_IDENTITY" | shasum -a 256 | awk '{print $1}')" +STATE_AUTHORITY_SHA256="$(printf '%s\0%s\n' "$STATE_DIR" "$PROCESS_ROOT" | shasum -a 256 | awk '{print $1}')" +mkdir -p "$BIND_DIR" +printf 'cycle\tstarted_epoch\telapsed_seconds\tstatus\tdetail\n' > "$RESULTS" +printf 'phase\tcycle\tepoch\tpid_count\tfd_total\trss_kb\tcpu_percent\tstate_kb\tfseventsd_pid_count\tfseventsd_rss_kb\tfseventsd_cpu_percent\n' > "$RESOURCES" +printf 'project\tcompose_file\n' > "$COMPOSE_PROJECTS" +{ + echo "run_id=$RUN_ID" + echo "owner=$OWNER" + echo "source_commit=$SOURCE_COMMIT" + echo "image=$ALPINE_IMAGE" + echo "image_id=$image_id" + echo "image_platform=$image_platform" + echo "duration_seconds=$DURATION_SECONDS" + echo "cycles=$CYCLES" + echo "files_per_cycle=$FILES_PER_CYCLE" + echo "compose_every=$COMPOSE_EVERY" + echo "settle_seconds=$SETTLE_SECONDS" + echo "min_free_gb=$MIN_FREE_GB" + echo "initial_free_kb=$initial_free_kb" + echo "fd_growth_budget=$FD_GROWTH_BUDGET" + echo "rss_growth_mb=$RSS_GROWTH_MB" + echo "disk_growth_mb=$DISK_GROWTH_MB" + echo "idle_cpu_percent=$IDLE_CPU_PERCENT" + echo "fseventsd_rss_growth_mb=$FSEVENTSD_RSS_GROWTH_MB" + echo "fseventsd_cpu_percent=$FSEVENTSD_CPU_PERCENT" + echo "socket_identity_sha256=$SOCKET_IDENTITY_SHA256" + echo "state_authority_sha256=$STATE_AUTHORITY_SHA256" + echo "docker_cli_sha256=$docker_cli_sha256" + echo "gate_sha256=$GATE_SHA256" + echo "analyzer_sha256=$ANALYZER_SHA256" + echo "started_epoch=$START_EPOCH" + echo "requested_release_candidate=$([ "$RELEASE_CANDIDATE" -eq 1 ] && echo true || echo false)" +} > "$MANIFEST" + +cleanup_owned() { + local id project compose_file + if [ -f "$COMPOSE_PROJECTS" ]; then + while IFS=$'\t' read -r project compose_file; do + [ "$project" = project ] && continue + [ -n "$project" ] && [ -n "$compose_file" ] || continue + docker_e compose -p "$project" -f "$compose_file" down -v --remove-orphans \ + >/dev/null 2>&1 || true + done < "$COMPOSE_PROJECTS" + fi + docker_e ps -aq --filter "label=dev.dory.endurance=$OWNER" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e rm -f -v "$id" >/dev/null 2>&1 || true + done + docker_e network ls -q --filter "label=dev.dory.endurance=$OWNER" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e network rm "$id" >/dev/null 2>&1 || true + done + docker_e volume ls -q --filter "label=dev.dory.endurance=$OWNER" 2>/dev/null | while IFS= read -r id; do + [ -n "$id" ] && docker_e volume rm -f "$id" >/dev/null 2>&1 || true + done +} +cleanup() { + cleanup_owned +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +dory_pids() { + ps -axo pid=,uid=,command= | awk -v uid="$(id -u)" -v root="$PROCESS_ROOT" \ + '$2 == uid && index($0, root) > 0 && \ + $0 ~ /(^|[\/[[:space:]]])(Dory|doryd|dory-hv|dory-vmm|dory-dataplane-proxy|gvproxy)([[:space:]]|$)/ \ + {print $1}' +} + +verify_owned_cleanup() { + local project compose_file + [ -z "$(docker_e ps -aq --filter "label=dev.dory.endurance=$OWNER")" ] \ + || die "owned containers survived cleanup" + [ -z "$(docker_e network ls -q --filter "label=dev.dory.endurance=$OWNER")" ] \ + || die "owned networks survived cleanup" + [ -z "$(docker_e volume ls -q --filter "label=dev.dory.endurance=$OWNER")" ] \ + || die "owned volumes survived cleanup" + while IFS=$'\t' read -r project compose_file; do + [ "$project" = project ] && continue + [ -n "$project" ] || continue + [ -z "$(docker_e ps -aq --filter "label=com.docker.compose.project=$project")" ] \ + || die "Compose project containers survived cleanup" + [ -z "$(docker_e network ls -q --filter "label=com.docker.compose.project=$project")" ] \ + || die "Compose project networks survived cleanup" + [ -z "$(docker_e volume ls -q --filter "label=com.docker.compose.project=$project")" ] \ + || die "Compose project volumes survived cleanup" + done < "$COMPOSE_PROJECTS" +} + +sample_resources() { + local phase="$1" cycle="$2" pids pid pid_count=0 fd_total=0 rss_kb=0 cpu=0 state_kb + local fd_sample rss_sample cpu_sample fseventsd_pids fseventsd_pid_count=0 + local fseventsd_rss_kb=0 fseventsd_cpu=0 + pids="$(dory_pids)" + [ -n "$pids" ] || { echo "no Dory processes found" >&2; return 1; } + for pid in $pids; do + kill -0 "$pid" 2>/dev/null || continue + pid_count=$((pid_count + 1)) + fd_sample="$(lsof -n -P -p "$pid" 2>/dev/null | awk 'NR > 1 {n++} END {print n+0}')" + rss_sample="$(ps -p "$pid" -o rss= 2>/dev/null | awk 'NF {sum += $1} END {print sum+0}')" + cpu_sample="$(ps -p "$pid" -o %cpu= 2>/dev/null | awk 'NF {sum += $1} END {printf "%.2f", sum+0}')" + fd_total=$((fd_total + fd_sample)) + rss_kb=$((rss_kb + rss_sample)) + cpu="$(awk -v total="$cpu" -v sample="$cpu_sample" 'BEGIN {printf "%.2f", total+sample}')" + done + state_kb="$(du -sk "$STATE_DIR" 2>/dev/null | awk 'NF {sum += $1} END {print sum+0}')" + fseventsd_pids="$(pgrep -x fseventsd 2>/dev/null || true)" + [ -n "$fseventsd_pids" ] || { echo "no host fseventsd process found" >&2; return 1; } + for pid in $fseventsd_pids; do + kill -0 "$pid" 2>/dev/null || continue + fseventsd_pid_count=$((fseventsd_pid_count + 1)) + rss_sample="$(ps -p "$pid" -o rss= 2>/dev/null | awk 'NF {sum += $1} END {print sum+0}')" + cpu_sample="$(ps -p "$pid" -o %cpu= 2>/dev/null | awk 'NF {sum += $1} END {printf "%.2f", sum+0}')" + fseventsd_rss_kb=$((fseventsd_rss_kb + rss_sample)) + fseventsd_cpu="$(awk -v total="$fseventsd_cpu" -v sample="$cpu_sample" 'BEGIN {printf "%.2f", total+sample}')" + done + [ "$fseventsd_pid_count" -gt 0 ] || { echo "host fseventsd exited during sampling" >&2; return 1; } + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$phase" "$cycle" "$(date +%s)" "$pid_count" "$fd_total" "$rss_kb" "$cpu" "$state_kb" \ + "$fseventsd_pid_count" "$fseventsd_rss_kb" "$fseventsd_cpu" >> "$RESOURCES" +} + +wait_file() { + local path="$1" attempts="${2:-100}" + while [ "$attempts" -gt 0 ]; do + [ -s "$path" ] && return 0 + attempts=$((attempts - 1)) + sleep 0.1 + done + return 1 +} + +run_compose_cycle() { + local cycle="$1" dir="$WORKDIR/compose-$cycle" project + project="$(printf 'doryendurance%s%s' "$RUN_ID" "$cycle" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c 1-48)" + mkdir -p "$dir" + cat > "$dir/compose.yaml" <> "$COMPOSE_PROJECTS" + docker_e compose -p "$project" -f "$dir/compose.yaml" up -d >/dev/null + docker_e compose -p "$project" -f "$dir/compose.yaml" exec -T worker sh -c 'echo exec-ok > /cache/exec' + docker_e compose -p "$project" -f "$dir/compose.yaml" logs worker | grep -q "compose-$cycle" + docker_e compose -p "$project" -f "$dir/compose.yaml" down -v --remove-orphans >/dev/null +} + +run_cycle() { + local cycle="$1" dir="$BIND_DIR/cycle-$cycle" name="$OWNER-$cycle" vol="$OWNER-vol-$cycle" + local i marker expected copied watcher="$OWNER-watch-$cycle" + rm -rf "$dir" + mkdir -p "$dir/host" "$dir/guest" "$dir/watch" + marker="cycle-$cycle-$(date +%s)-$$" + printf '%s\n' "$marker" > "$dir/host/input.txt" + + docker_e volume create --label "dev.dory.endurance=$OWNER" "$vol" >/dev/null + docker_e run -d --name "$name" --label "dev.dory.endurance=$OWNER" \ + -v "$dir:/share" -v "$vol:/volume" "$ALPINE_IMAGE" sleep 300 >/dev/null + docker_e exec "$name" grep -qx "$marker" /share/host/input.txt + docker_e exec "$name" sh -c 'printf "%s\n" "$1" > /share/guest/output.txt; printf "%s\n" "$1" > /volume/state.txt' sh "$marker" + grep -qx "$marker" "$dir/guest/output.txt" + docker_e exec "$name" grep -qx "$marker" /volume/state.txt + # Database images commonly chown bind-mounted data before dropping from root to a service UID. + # The host tree must stay owned by the macOS user, but the Linux ownership request must succeed. + docker_e exec "$name" sh -ec 'touch /share/guest/chown-probe; chown 999:999 /share/guest/chown-probe; chmod 600 /share/guest/chown-probe' + [ "$(stat -f %u "$dir/guest/chown-probe")" = "$(id -u)" ] \ + || { echo "guest ownership request changed host bind ownership" >&2; return 1; } + + printf 'copy-%s\n' "$marker" > "$dir/copy-in.txt" + docker_e cp "$dir/copy-in.txt" "$name:/tmp/copy-in.txt" + docker_e exec "$name" grep -qx "copy-$marker" /tmp/copy-in.txt + docker_e exec "$name" sh -c 'printf "copy-out-%s\n" "$1" > /tmp/copy-out.txt' sh "$marker" + docker_e cp "$name:/tmp/copy-out.txt" "$dir/copy-out.txt" + grep -qx "copy-out-$marker" "$dir/copy-out.txt" + + printf 'watch-before-%s\n' "$marker" > "$dir/watch/input.txt" + cat > "$dir/watch/handler.sh" <<'EOF' +#!/bin/sh +printf '%s %s %s\n' "$1" "$2" "${3:-}" > /watch/event.txt +EOF + chmod 0755 "$dir/watch/handler.sh" + docker_e run -d --name "$watcher" --label "dev.dory.endurance=$OWNER" \ + -v "$dir/watch:/watch" "$ALPINE_IMAGE" sh -c \ + 'test -f /watch/input.txt; printf ready > /watch/ready; exec inotifyd /watch/handler.sh /watch/input.txt:cewDx' + wait_file "$dir/watch/ready" + # `ready` proves the container started, but the following exec still needs a scheduling turn to + # install its inotify watch. Without this barrier a very fast host create can win that race. + sleep 1 + printf 'watch-after-%s\n' "$marker" >> "$dir/watch/input.txt" + wait_file "$dir/watch/event.txt" + grep -Eq '^[cewDx]+' "$dir/watch/event.txt" + docker_e rm -f "$watcher" >/dev/null + + i=1 + while [ "$i" -le "$FILES_PER_CYCLE" ]; do + printf '%s-%s\n' "$marker" "$i" > "$dir/host/file-$i" + i=$((i + 1)) + done + expected="$(find "$dir/host" -type f | LC_ALL=C sort | xargs cat | shasum -a 256 | awk '{print $1}')" + copied="$(docker_e exec "$name" sh -c \ + 'find /share/host -type f | LC_ALL=C sort | xargs cat | sha256sum' | awk '{print $1}')" + [ "$expected" = "$copied" ] || { echo "host/guest exact tree digest mismatch" >&2; return 1; } + docker_e exec "$name" sh -c 'rm -f /share/host/file-*' + [ -z "$(find "$dir/host" -name 'file-*' -print -quit)" ] + + docker_e logs "$name" >/dev/null + docker_e stats --no-stream "$name" >/dev/null + docker_e inspect "$name" >/dev/null + docker_e rm -f "$name" >/dev/null + docker_e run --rm --label "dev.dory.endurance=$OWNER" -v "$vol:/volume" "$ALPINE_IMAGE" \ + grep -qx "$marker" /volume/state.txt + docker_e volume rm "$vol" >/dev/null + + if [ $((cycle % COMPOSE_EVERY)) -eq 0 ]; then + run_compose_cycle "$cycle" + fi + rm -rf "$dir" +} + +sample_resources baseline 0 +cycle=0 +while :; do + now="$(date +%s)" + elapsed=$((now - START_EPOCH)) + if [ "$CYCLES" -gt 0 ]; then + [ "$cycle" -lt "$CYCLES" ] || break + elif [ "$cycle" -gt 0 ] && [ "$elapsed" -ge "$DURATION_SECONDS" ]; then + break + fi + cycle=$((cycle + 1)) + cycle_started="$(date +%s)" + free_kb="$(available_disk_kb)" + if [ "$free_kb" -lt "$MIN_FREE_KB" ]; then + printf '%s\t%s\t%s\tFAIL\thost_free_kb=%s_below_reserve=%s\n' \ + "$cycle" "$cycle_started" "$(( $(date +%s) - START_EPOCH ))" "$free_kb" "$MIN_FREE_KB" >> "$RESULTS" + die "host disk headroom fell to ${free_kb} KiB below the ${MIN_FREE_KB} KiB reserve" + fi + set +e + ( set -e; run_cycle "$cycle" ) >> "$WORKDIR/cycle-$cycle.log" 2>&1 + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + printf '%s\t%s\t%s\tPASS\tok\n' "$cycle" "$cycle_started" "$(( $(date +%s) - START_EPOCH ))" >> "$RESULTS" + else + printf '%s\t%s\t%s\tFAIL\texit=%s\n' "$cycle" "$cycle_started" "$(( $(date +%s) - START_EPOCH ))" "$rc" >> "$RESULTS" + tail -60 "$WORKDIR/cycle-$cycle.log" >&2 || true + exit "$rc" + fi + cleanup_owned + sleep "$SETTLE_SECONDS" + sample_resources cleaned "$cycle" +done + +cleanup_owned +sleep "$SETTLE_SECONDS" +sample_resources final "$cycle" + +[ "$(shasum -a 256 "$ANALYZER" | awk '{print $1}')" = "$ANALYZER_SHA256" ] \ + || die "resource analyzer changed during the soak" +python3 "$ANALYZER" "$RESOURCES" \ + --fd-growth "$FD_GROWTH_BUDGET" \ + --rss-growth-mb "$RSS_GROWTH_MB" \ + --disk-growth-mb "$DISK_GROWTH_MB" \ + --idle-cpu "$IDLE_CPU_PERCENT" \ + --fseventsd-rss-growth-mb "$FSEVENTSD_RSS_GROWTH_MB" \ + --fseventsd-cpu "$FSEVENTSD_CPU_PERCENT" > "$ANALYSIS" + +grep -q $'\tFAIL\t' "$RESULTS" && die "one or more cycles failed" +pass_cycles="$(awk -F '\t' 'NR > 1 && $4 == "PASS" {count++} END {print count+0}' "$RESULTS")" +[ "$pass_cycles" -eq "$cycle" ] && [ "$cycle" -ge 1 ] \ + || die "cycle result count does not match completed cycles" +grep -q '^resource plateau PASS: ' "$ANALYSIS" || die "resource plateau proof is missing" +cleanup_owned +verify_owned_cleanup +[ "$(stat -f '%d:%i:%u' "$SOCKET")" = "$SOCKET_IDENTITY" ] \ + || die "isolated Docker socket identity changed during the soak" +[ "$(stat -f '%d:%i:%u' "$STATE_DIR")" = "$STATE_IDENTITY" ] \ + || die "isolated state authority changed during the soak" +[ "$(shasum -a 256 "$DOCKER" | awk '{print $1}')" = "$docker_cli_sha256" ] \ + || die "Docker CLI changed during the soak" +[ "$(shasum -a 256 "${BASH_SOURCE[0]}" | awk '{print $1}')" = "$GATE_SHA256" ] \ + || die "endurance gate changed during the soak" +final_image_id="$(docker_e image inspect -f '{{.Id}}' "$ALPINE_IMAGE")" +final_image_platform="$(docker_e image inspect -f '{{.Os}}/{{.Architecture}}' "$ALPINE_IMAGE")" +[ "$final_image_id" = "$image_id" ] && [ "$final_image_platform" = "$image_platform" ] \ + || die "endurance soak changed the exact fixture image identity" +END_EPOCH="$(date +%s)" +ELAPSED_SECONDS=$((END_EPOCH - START_EPOCH)) +if [ "$CYCLES" -eq 0 ]; then + [ "$ELAPSED_SECONDS" -ge "$DURATION_SECONDS" ] \ + || die "duration-based soak ended before its requested wall time" +fi +release_qualifying=false +if [ "$RELEASE_CANDIDATE" -eq 1 ] \ + && [ "$CYCLES" -eq 0 ] \ + && [ "$DURATION_SECONDS" -ge 28800 ] \ + && [ "$ELAPSED_SECONDS" -ge "$DURATION_SECONDS" ]; then + release_qualifying=true +fi +final_free_kb="$(available_disk_kb)" +{ + echo "completed_cycles=$cycle" + echo "elapsed_seconds=$ELAPSED_SECONDS" + echo "final_free_kb=$final_free_kb" + echo "cycles_sha256=$(shasum -a 256 "$RESULTS" | awk '{print $1}')" + echo "resources_sha256=$(shasum -a 256 "$RESOURCES" | awk '{print $1}')" + echo "resource_analysis_sha256=$(shasum -a 256 "$ANALYSIS" | awk '{print $1}')" + echo "same_user_socket=PASS" + echo "exact_process_authority=PASS" + echo "exact_image_identity=PASS" + echo "resource_plateau=PASS" + echo "owned_cleanup=PASS" + echo "ended_epoch=$END_EPOCH" + echo "release_qualifying=$release_qualifying" + echo "status=PASS" +} >> "$MANIFEST" +echo "endurance reliability soak PASS: $cycle cycles; evidence: $WORKDIR" From 31035046367860561690c15c9c6c135289f79a37 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:41:24 +0000 Subject: [PATCH 149/338] feat(release): qualify exact signed candidate --- .../test-release-candidate-qualifier.py | 151 ++ .github/scripts/test-release-metadata.py | 8 + .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/qualify-release-candidate.sh | 1722 +++++++++++++++++ scripts/validate-release-metadata.py | 7 +- 6 files changed, 1889 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/test-release-candidate-qualifier.py create mode 100755 scripts/qualify-release-candidate.sh diff --git a/.github/scripts/test-release-candidate-qualifier.py b/.github/scripts/test-release-candidate-qualifier.py new file mode 100755 index 00000000..f8c582a8 --- /dev/null +++ b/.github/scripts/test-release-candidate-qualifier.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Offline contract for the exact signed-candidate qualification orchestrator.""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "qualify-release-candidate.sh" + + +class ReleaseCandidateQualifierTests(unittest.TestCase): + def test_orchestrator_uses_signed_schema_two_authority_and_current_gate_apis(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "QUALIFY-EXACT-DORY-RELEASE", + "validate-release-metadata.py", + "signed schema-2 release metadata validation failed", + "checked-out source does not match --source-commit", + "tracked qualification harness differs from the checked-out source commit", + "qualification harness authority changed during qualification", + "qualification harness bytes changed during qualification", + "engine PID is outside the exact isolated runtime authority", + "PHYSICAL-APFS-VOLUME-IDENTITY", + "ISOLATED-RUNTIME-DATA-DISK-GROWTH", + "ISOLATED-ENGINE-DEFAULT-PLATFORM", + "ISOLATED-ENGINE-PRIVATE-REGISTRY", + "ISOLATED-ENGINE-BIND-FILE-COHERENCE", + "ISOLATED-ENGINE-TESTCONTAINERS", + '--ryuk-image "$TESTCONTAINERS_RYUK_IMAGE"', + '--image "$IMAGE"', + '--compose "$COMPOSE"', + '--runner-image "$ACT_RUNNER_IMAGE"', + "ISOLATED-ENGINE-LONG-LIVED-TCP", + "ISOLATED-ENGINE-ENDURANCE-RELIABILITY", + "candidate-binding.txt", + "component_catalog_schema=2", + "component_catalog_signature_sha256=", + "app_executable_sha256=", + "dory_vmm_sha256=", + "kernel_sha256=", + "rootfs_sha256=", + "guest_agent_sha256=", + '"schemaVersion": 2', + '"kind": "dev.dory.release-qualification"', + '"candidateBindingSha256"', + '"componentCatalogSchemaVersion": 2', + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + '"schemaVersion": 1', + 'catalog.get("schemaVersion") == 1', + "DORY_ALLOW_UNNOTARIZED_QUALIFICATION", + "DORY_ALLOW_SHORT_QUALIFICATION", + "trap cleanup EXIT INT TERM", + 'rm -rf "$private_registry_workroot"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + references = set( + re.findall(r"(? None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + qualification = root / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--build-dir", + str(root / "missing-build"), + "--version", + "1.2.3", + "--build", + "42", + "--source-commit", + "a" * 40, + "--qualification-root", + str(qualification), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(qualification.exists()) + + def test_completion_writer_emits_typed_schema_two_binding(self) -> None: + text = GATE.read_text(encoding="utf-8") + marker = '<<\'PY\'\nimport json\nimport sys\n\n(\n output, release_qualifying' + start = text.find(marker) + self.assertNotEqual(start, -1) + script_start = start + len("<<'PY'\n") + script_end = text.find("\nPY\nmv \"$WORKDIR/qualification.complete.json.partial\"", script_start) + self.assertNotEqual(script_end, -1) + writer = text[script_start:script_end] + + digest = "b" * 64 + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) / "complete.json" + arguments = [ + str(output), "true", "false", "1.2.3", "42", "a" * 40, "7", "3", + digest, digest, digest, digest, digest, "28800", "90000", "12.0.4", + "0.87.0", "0.2.89", "localstack@sha256:" + digest, "0.37.5", "2.109.1", + "k3s@sha256:" + digest, "nginx@sha256:" + digest, "2.23.0", + "python@sha256:" + digest, digest, digest, "alpine@sha256:" + digest, + "registry@sha256:" + digest, "ssh@sha256:" + digest, "ryuk@sha256:" + digest, + "node@sha256:" + digest, digest, digest, digest, digest, "123456", + ] + result = subprocess.run( + ["python3", "-", *arguments], + input=writer, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(payload["schemaVersion"], 2) + self.assertEqual(payload["kind"], "dev.dory.release-qualification") + self.assertIs(payload["releaseQualifying"], True) + self.assertEqual(payload["sourceCommit"], "a" * 40) + self.assertEqual(payload["candidateBindingSha256"], digest) + self.assertEqual(payload["componentCatalogSchemaVersion"], 2) + self.assertEqual(payload["componentCatalogSignatureSha256"], digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-metadata.py b/.github/scripts/test-release-metadata.py index 886243ca..a04decb8 100644 --- a/.github/scripts/test-release-metadata.py +++ b/.github/scripts/test-release-metadata.py @@ -212,6 +212,14 @@ def test_unknown_catalog_field_is_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "shape is invalid"): self.validate() + def test_catalog_symlink_is_rejected_before_signature_verification(self) -> None: + self.publish() + direct = self.components / "catalog-direct.json" + self.catalog_path.rename(direct) + self.catalog_path.symlink_to(direct.name) + with self.assertRaisesRegex(ValueError, "missing or indirect"): + self.validate() + def test_qualification_must_use_the_pinned_signing_key(self) -> None: self.catalog["virtualMachineQualification"]["signingKeyID"] = "e" * 64 self.publish() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1f85627..931a522c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -373,6 +373,7 @@ jobs: python3 .github/scripts/test-gvproxy-qemu-switch-gate.py python3 .github/scripts/test-long-lived-network-soak.py python3 .github/scripts/test-endurance-reliability-soak.py + python3 .github/scripts/test-release-candidate-qualifier.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f5363be..2dbf2ffc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,7 @@ jobs: python3 .github/scripts/test-gvproxy-qemu-switch-gate.py python3 .github/scripts/test-long-lived-network-soak.py python3 .github/scripts/test-endurance-reliability-soak.py + python3 .github/scripts/test-release-candidate-qualifier.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/qualify-release-candidate.sh b/scripts/qualify-release-candidate.sh new file mode 100755 index 00000000..53b7db9e --- /dev/null +++ b/scripts/qualify-release-candidate.sh @@ -0,0 +1,1722 @@ +#!/bin/bash +# Qualify immutable, notarized public artifacts before any GitHub/Homebrew publication. The +# 8-hour endurance gate and 25-hour same-connection TCP gate share one isolated engine and run +# concurrently, reducing qualification wall time to 25 hours without weakening either duration. +set -euo pipefail +export LC_ALL=C LANG=C +umask 077 + +BUILD_DIR="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +QUALIFICATION_ROOT="${DORY_RELEASE_QUALIFICATION_ROOT:-$HOME/.dory-release-qualification}" +LONG_DURATION=90000 +ENDURANCE_DURATION=28800 +IMAGE="${DORY_RELEASE_QUALIFICATION_IMAGE:-}" +REGISTRY_IMAGE="${DORY_RELEASE_REGISTRY_IMAGE:-registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373}" +LOCK_IMAGE="${DORY_SOURCE_GATE_IMAGE:-}" +SSH_CLIENT_IMAGE="${DORY_RELEASE_SSH_CLIENT_IMAGE:-}" +NONNATIVE_NIX_IMAGE="${DORY_RELEASE_NONNATIVE_NIX_IMAGE:-nixos/nix@sha256:898e3874bc80a8fbd7df6001b6c83d6e0c904a942e3a4cdf8a89881458333cac}" +NONNATIVE_ARCH_IMAGE="${DORY_RELEASE_NONNATIVE_ARCH_IMAGE:-archlinux@sha256:2b4d67033863d9f495dfd0f52ad8b451fae84adb71b4bdf63f69d10643df2403}" +NONNATIVE_DEBIAN_IMAGE="${DORY_RELEASE_NONNATIVE_DEBIAN_IMAGE:-debian@sha256:3a953985c225a97dfb5a8f1ddc6a3ecefefc35ef51f537075e08941305045a1e}" +TESTCONTAINERS_VERSION="${DORY_RELEASE_TESTCONTAINERS_VERSION:-12.0.4}" +TESTCONTAINERS_RYUK_IMAGE="${DORY_RELEASE_TESTCONTAINERS_RYUK_IMAGE:-testcontainers/ryuk:0.14.0@sha256:7c1a8a9a47c780ed0f983770a662f80deb115d95cce3e2daa3d12115b8cd28f0}" +DEVCONTAINERS_VERSION="${DORY_RELEASE_DEVCONTAINERS_VERSION:-0.87.0}" +ACT_VERSION="${DORY_RELEASE_ACT_VERSION:-0.2.89}" +ACT_RUNNER_IMAGE="${DORY_RELEASE_ACT_RUNNER_IMAGE:-node:20.19.5-bookworm-slim@sha256:9e70124bd00f47dd023e349cd587132ae61892acc0e47ed641416c3e18f401c3}" +LOCALSTACK_IMAGE="${DORY_RELEASE_LOCALSTACK_IMAGE:-localstack/localstack:4.14.0@sha256:3ebc37595918b8accb852f8048fef2aff047d465167edd655528065b07bc364a}" +TILT_VERSION="${DORY_RELEASE_TILT_VERSION:-0.37.5}" +SUPABASE_VERSION="${DORY_RELEASE_SUPABASE_VERSION:-2.109.1}" +K3S_IMAGE="${DORY_RELEASE_K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1@sha256:6a47cea22c4b834d4ba72c89d291696b79ebe406251f90b446e4dff03513dd87}" +K8S_WORKLOAD_IMAGE="${DORY_RELEASE_K8S_WORKLOAD_IMAGE:-nginx:alpine@sha256:54f2a904c251d5a34adf545a72d32515a15e08418dae0266e23be2e18c66fefa}" +SKAFFOLD_VERSION="${DORY_RELEASE_SKAFFOLD_VERSION:-2.23.0}" +ECR_REGISTRY="${DORY_RELEASE_ECR_REGISTRY:-}" +ECR_REPOSITORY="${DORY_RELEASE_ECR_REPOSITORY:-}" +ECR_REGION="${DORY_RELEASE_ECR_REGION:-}" +MIN_FREE_GB=20 +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --build-dir) need_value "$1" "$#"; BUILD_DIR="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --qualification-root) need_value "$1" "$#"; QUALIFICATION_ROOT="$2"; shift 2 ;; + --long-duration) need_value "$1" "$#"; LONG_DURATION="$2"; shift 2 ;; + --endurance-duration) need_value "$1" "$#"; ENDURANCE_DURATION="$2"; shift 2 ;; + --image) need_value "$1" "$#"; IMAGE="$2"; shift 2 ;; + --registry-image) need_value "$1" "$#"; REGISTRY_IMAGE="$2"; shift 2 ;; + --lock-image) need_value "$1" "$#"; LOCK_IMAGE="$2"; shift 2 ;; + --ssh-client-image) need_value "$1" "$#"; SSH_CLIENT_IMAGE="$2"; shift 2 ;; + --nonnative-nix-image) need_value "$1" "$#"; NONNATIVE_NIX_IMAGE="$2"; shift 2 ;; + --nonnative-arch-image) need_value "$1" "$#"; NONNATIVE_ARCH_IMAGE="$2"; shift 2 ;; + --nonnative-debian-image) need_value "$1" "$#"; NONNATIVE_DEBIAN_IMAGE="$2"; shift 2 ;; + --testcontainers-version) need_value "$1" "$#"; TESTCONTAINERS_VERSION="$2"; shift 2 ;; + --testcontainers-ryuk-image) need_value "$1" "$#"; TESTCONTAINERS_RYUK_IMAGE="$2"; shift 2 ;; + --devcontainers-version) need_value "$1" "$#"; DEVCONTAINERS_VERSION="$2"; shift 2 ;; + --act-version) need_value "$1" "$#"; ACT_VERSION="$2"; shift 2 ;; + --act-runner-image) need_value "$1" "$#"; ACT_RUNNER_IMAGE="$2"; shift 2 ;; + --localstack-image) need_value "$1" "$#"; LOCALSTACK_IMAGE="$2"; shift 2 ;; + --tilt-version) need_value "$1" "$#"; TILT_VERSION="$2"; shift 2 ;; + --supabase-version) need_value "$1" "$#"; SUPABASE_VERSION="$2"; shift 2 ;; + --k3s-image) need_value "$1" "$#"; K3S_IMAGE="$2"; shift 2 ;; + --k8s-workload-image) need_value "$1" "$#"; K8S_WORKLOAD_IMAGE="$2"; shift 2 ;; + --skaffold-version) need_value "$1" "$#"; SKAFFOLD_VERSION="$2"; shift 2 ;; + --ecr-registry) need_value "$1" "$#"; ECR_REGISTRY="$2"; shift 2 ;; + --ecr-repository) need_value "$1" "$#"; ECR_REPOSITORY="$2"; shift 2 ;; + --ecr-region) need_value "$1" "$#"; ECR_REGION="$2"; shift 2 ;; + --min-free-gb) need_value "$1" "$#"; MIN_FREE_GB="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +positive_integer() { + case "$2" in ''|*[!0-9]*) die "$1 must be a positive integer" ;; esac + [ "$2" -gt 0 ] || die "$1 must be a positive integer" +} + +bounded() { + local limit="$1" pid started rc + shift + "$@" & + pid=$! + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + return "$rc" +} + +[ "$CONFIRM" = "QUALIFY-EXACT-DORY-RELEASE" ] \ + || die "requires --confirm QUALIFY-EXACT-DORY-RELEASE" +[ -n "$BUILD_DIR" ] || die "--build-dir is required" +[ -d "$BUILD_DIR" ] && [ ! -L "$BUILD_DIR" ] \ + || die "build directory is unavailable or indirect: $BUILD_DIR" +BUILD_DIR="$(cd "$BUILD_DIR" && pwd -P)" +[ -n "$VERSION" ] || die "--version is required" +[ -n "$BUILD" ] || die "--build is required" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "--source-commit must be a full lowercase Git SHA" +positive_integer long-duration "$LONG_DURATION" +positive_integer endurance-duration "$ENDURANCE_DURATION" +positive_integer min-free-gb "$MIN_FREE_GB" +printf '%s\n' "$IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--image (or DORY_RELEASE_QUALIFICATION_IMAGE) must be digest-pinned" +printf '%s\n' "$REGISTRY_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--registry-image must be digest-pinned" +printf '%s\n' "$LOCK_IMAGE" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "--lock-image (or DORY_SOURCE_GATE_IMAGE) must be digest-pinned" +printf '%s\n' "$SSH_CLIENT_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--ssh-client-image (or DORY_RELEASE_SSH_CLIENT_IMAGE) must be digest-pinned" +printf '%s\n' "$NONNATIVE_NIX_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--nonnative-nix-image must be digest-pinned" +printf '%s\n' "$NONNATIVE_ARCH_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--nonnative-arch-image must be digest-pinned" +printf '%s\n' "$NONNATIVE_DEBIAN_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--nonnative-debian-image must be digest-pinned" +printf '%s\n' "$TESTCONTAINERS_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' \ + || die "--testcontainers-version must be an exact npm semver" +printf '%s\n' "$TESTCONTAINERS_RYUK_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--testcontainers-ryuk-image must be digest-pinned" +printf '%s\n' "$DEVCONTAINERS_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' \ + || die "--devcontainers-version must be an exact npm semver" +printf '%s\n' "$ACT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--act-version must be an exact semantic version" +printf '%s\n' "$ACT_RUNNER_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--act-runner-image must be digest-pinned" +case "$LOCALSTACK_IMAGE" in *@sha256:[0-9a-f][0-9a-f]*) ;; \ + *) die "--localstack-image must be digest-pinned" ;; esac +printf '%s\n' "$TILT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--tilt-version must be an exact semantic version" +printf '%s\n' "$SUPABASE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--supabase-version must be an exact semantic version" +printf '%s\n' "$K3S_IMAGE" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "--k3s-image must be digest-pinned" +printf '%s\n' "$K8S_WORKLOAD_IMAGE" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "--k8s-workload-image must be digest-pinned" +printf '%s\n' "$SKAFFOLD_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "--skaffold-version must be an exact semantic version" +printf '%s\n' "$ECR_REGISTRY" | grep -Eq '^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$' \ + || die "--ecr-registry (or DORY_RELEASE_ECR_REGISTRY) must be an ECR registry host" +printf '%s\n' "$ECR_REPOSITORY" | grep -Eq '^[a-z0-9]+([._/-][a-z0-9]+)*$' \ + || die "--ecr-repository (or DORY_RELEASE_ECR_REPOSITORY) is required" +printf '%s\n' "$ECR_REGION" | grep -Eq '^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$' \ + || die "--ecr-region (or DORY_RELEASE_ECR_REGION) is invalid" +[ "$LONG_DURATION" -gt 86400 ] \ + || die "release qualification must keep one TCP connection beyond 24 hours" +[ "$ENDURANCE_DURATION" -ge 28800 ] \ + || die "release qualification requires at least eight endurance hours" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +for command in caffeinate codesign curl ditto git htpasswd node npm openssl pmset python3 shasum \ + spctl tar xcodebuild xcrun; do + command -v "$command" >/dev/null || die "missing required command: $command" +done +[ "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" ] \ + || die "checked-out source does not match --source-commit" +git diff --quiet -- scripts .github/scripts \ + || die "tracked qualification harness differs from the checked-out source commit" +git diff --cached --quiet -- scripts .github/scripts \ + || die "staged qualification harness differs from the checked-out source commit" +QUALIFIER_SHA256="$(shasum -a 256 scripts/qualify-release-candidate.sh | awk '{print $1}')" +METADATA_VALIDATOR_SHA256="$(shasum -a 256 scripts/validate-release-metadata.py | awk '{print $1}')" + +MANIFEST="$BUILD_DIR/release-manifest.json" +[ -s "$MANIFEST" ] || die "release manifest is missing: $MANIFEST" +validated_source_commit="$(python3 scripts/validate-release-metadata.py \ + "$BUILD_DIR" "$VERSION" "$BUILD")" \ + || die "signed schema-2 release metadata validation failed" +[ "$validated_source_commit" = "$SOURCE_COMMIT" ] \ + || die "signed release metadata belongs to another source commit" + +# Bind immutable candidate identity before host/toolchain prerequisites. This preserves the +# no-mutation contract and makes a wrong source/artifact fail for that reason on every runner. +xcodebuild -version >/dev/null 2>&1 \ + || die "full Xcode is required; Command Line Tools alone cannot run migration tests" + +RUN_ID="${GITHUB_RUN_ID:-manual}" +RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" +RUN_KEY="$RUN_ID-$RUN_ATTEMPT-$SOURCE_COMMIT" +case "$QUALIFICATION_ROOT" in /*) ;; *) die "qualification root must be an absolute path" ;; esac +case "$QUALIFICATION_ROOT" in /|"$HOME") die "unsafe qualification root: $QUALIFICATION_ROOT" ;; esac +if [ ! -e "$QUALIFICATION_ROOT" ]; then + qualification_parent="$(dirname "$QUALIFICATION_ROOT")" + [ -d "$qualification_parent" ] && [ ! -L "$qualification_parent" ] \ + || die "qualification root parent is unavailable or indirect" + [ "$(cd "$qualification_parent" && pwd -P)" = "$qualification_parent" ] \ + || die "qualification root parent must be a direct canonical path" + mkdir "$QUALIFICATION_ROOT" + chmod 0700 "$QUALIFICATION_ROOT" +fi +[ -d "$QUALIFICATION_ROOT" ] && [ ! -L "$QUALIFICATION_ROOT" ] \ + || die "qualification root is unavailable or indirect" +[ "$(stat -f %u "$QUALIFICATION_ROOT")" = "$(id -u)" ] \ + || die "qualification root is not owned by the release user" +QUALIFICATION_ROOT="$(cd "$QUALIFICATION_ROOT" && pwd -P)" +[ -d "$HOME" ] && [ ! -L "$HOME" ] && [ "$(cd "$HOME" && pwd -P)" = "$HOME" ] \ + || die "release HOME must be a direct canonical directory" +WORKDIR="$QUALIFICATION_ROOT/$RUN_KEY" +ENGINE_HOME="$HOME/.dqe-$RUN_ID-$RUN_ATTEMPT" +MIGRATION_SOURCE_HOME="$HOME/.dqm-$RUN_ID-$RUN_ATTEMPT" +SOCKET="$ENGINE_HOME/.dory/engine.sock" +MIGRATION_SOURCE_SOCKET="$MIGRATION_SOURCE_HOME/.dory/engine.sock" +[ ! -e "$WORKDIR" ] || die "qualification path already exists; refusing to overwrite evidence: $WORKDIR" +[ ! -e "$ENGINE_HOME" ] || die "short qualification engine HOME already exists: $ENGINE_HOME" +[ ! -e "$MIGRATION_SOURCE_HOME" ] \ + || die "short migration source HOME already exists: $MIGRATION_SOURCE_HOME" +python3 - \ + "$SOCKET" \ + "$MIGRATION_SOURCE_SOCKET" \ + "$ENGINE_HOME/.dory/standalone/hv/docker-backend.sock" \ + "$ENGINE_HOME/.dory/standalone/hv/gvproxy-api.sock" <<'PY' +import os +import sys + +for path in sys.argv[1:]: + length = len(os.fsencode(path)) + if length > 103: + raise SystemExit(f"qualification Unix socket path is {length} bytes (limit 103): {path}") +PY +[ "$(uname -s)" = Darwin ] || die "release qualification requires macOS" +[ "$(uname -m)" = arm64 ] || die "release qualification requires physical Apple silicon" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Hypervisor.framework is unavailable" +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested virtualization cannot qualify a release" +case "$(sysctl -n hw.model 2>/dev/null || printf unknown)" in + VirtualMac*) die "VirtualMac cannot qualify a release" ;; +esac +mkdir "$WORKDIR" +mkdir -p "$WORKDIR/evidence" "$WORKDIR/extracted-app" "$WORKDIR/extracted-runtime" \ + "$WORKDIR/docker-config/cli-plugins" +mkdir "$ENGINE_HOME" +mkdir "$ENGINE_HOME/gate-evidence" + +{ + sw_vers + uname -a + printf 'hw.model='; sysctl -n hw.model + printf 'kern.hv_support='; sysctl -n kern.hv_support + printf 'kern.hv_vmm_present='; sysctl -in kern.hv_vmm_present 2>/dev/null || printf '0\n' + printf 'hw.optional.arm64='; sysctl -in hw.optional.arm64 2>/dev/null || printf '0\n' +} > "$WORKDIR/evidence/host-facts.txt" + +available_kb="$(df -Pk "$WORKDIR" | awk 'NR == 2 {print $4}')" +required_kb=$(((MIN_FREE_GB + 8) * 1024 * 1024)) +[ "$available_kb" -ge "$required_kb" ] \ + || die "host has ${available_kb} KiB free; qualification requires ${required_kb} KiB so extraction and engine setup still leave the ${MIN_FREE_GB} GiB runtime reserve" + +UPDATE_ZIP="$BUILD_DIR/Dory-$VERSION-app-update.zip" +RUNTIME_TAR="$BUILD_DIR/dory-engine-$VERSION-arm64.tar.gz" +COMPONENT_CATALOG="$BUILD_DIR/components/arm64/catalog.json" +ditto -x -k "$UPDATE_ZIP" "$WORKDIR/extracted-app" +APP="$WORKDIR/extracted-app/Dory.app" +[ -d "$APP" ] || die "Sparkle candidate did not extract exactly at Dory.app" +[ "$(find "$WORKDIR/extracted-app" -type d -name Dory.app -print | wc -l | tr -d ' ')" = 1 ] \ + || die "Sparkle candidate contains multiple Dory.app bundles" +tar -xzf "$RUNTIME_TAR" -C "$WORKDIR/extracted-runtime" +RUNTIME_DIR="$WORKDIR/extracted-runtime/dory-engine-$VERSION-arm64" +RUNTIME="$RUNTIME_DIR/dory-engine" +[ -x "$RUNTIME_DIR/bin/dory-hv" ] || die "standalone runtime dory-hv is missing" +CANDIDATE_DORY_HV_SHA="$(shasum -a 256 "$RUNTIME_DIR/bin/dory-hv" | awk '{print $1}')" +DOCKER="$APP/Contents/Helpers/docker" +COMPOSE="$APP/Contents/Helpers/docker-compose" +BUILDX="$APP/Contents/Helpers/docker-buildx" +KUBECTL="$(python3 - "$BUILD_DIR" "$VERSION" <<'PY' +import hashlib +import json +import pathlib +import sys +import urllib.parse + +root = pathlib.Path(sys.argv[1]) +version = sys.argv[2] +catalog = json.loads((root / "components/arm64/catalog.json").read_text(encoding="utf-8")) +matches = [item for item in catalog["components"] if item.get("id") == "kubernetes"] +if len(matches) != 1: + raise SystemExit("component catalog must contain one Kubernetes component") +assets = matches[0].get("assets", []) +if len(assets) != 1 or assets[0].get("path") != "kubectl": + raise SystemExit("Kubernetes component must contain exactly kubectl") +asset = assets[0] +name = pathlib.PurePosixPath(urllib.parse.urlparse(asset["url"]).path).name +path = root / "components" / "arm64" / name +if not path.is_file() or path.is_symlink(): + raise SystemExit("Kubernetes component artifact is missing or indirect") +data = path.read_bytes() +if len(data) != asset["downloadBytes"] or len(data) != asset["installedBytes"]: + raise SystemExit("Kubernetes component size mismatch") +digest = hashlib.sha256(data).hexdigest() +if digest != asset["sha256"] or digest != asset["installedSHA256"]: + raise SystemExit("Kubernetes component digest mismatch") +if asset["compression"] != "none": + raise SystemExit("Kubernetes component must be uncompressed") +print(path) +PY +)" || die "focused Kubernetes component metadata is invalid" +[ -x "$RUNTIME" ] || die "standalone runtime launcher is missing" +[ -x "$DOCKER" ] || die "candidate Docker CLI is missing" +[ -x "$BUILDX" ] || die "candidate Buildx helper is missing" +[ -x "$KUBECTL" ] || die "candidate kubectl CLI is missing" +codesign --verify --strict "$KUBECTL" \ + || die "candidate Kubernetes component is not validly signed" +[ "$(lipo -archs "$KUBECTL")" = arm64 ] \ + || die "candidate Kubernetes component is not arm64-only" +[ -x "$COMPOSE" ] || die "candidate Compose plugin is missing" + +APP_EXECUTABLE="$APP/Contents/MacOS/Dory" +DORYD="$APP/Contents/Helpers/doryd" +DORY_VMM="$APP/Contents/Helpers/dory-vmm" +DATAPLANE="$APP/Contents/Helpers/dory-dataplane-proxy" +KERNEL="$RUNTIME_DIR/share/dory/dory-hv-kernel-arm64.lzfse" +ROOTFS="$RUNTIME_DIR/share/dory/dory-engine-rootfs.ext4.lzfse" +GUEST_AGENT="$RUNTIME_DIR/share/dory/dory-agent-linux-arm64" +for executable in "$APP_EXECUTABLE" "$DORYD" "$DORY_VMM" "$RUNTIME_DIR/bin/dory-hv" \ + "$RUNTIME_DIR/bin/gvproxy" "$DATAPLANE" "$DOCKER" "$COMPOSE" "$BUILDX" "$KUBECTL"; do + [ -f "$executable" ] && [ ! -L "$executable" ] && [ -x "$executable" ] \ + || die "candidate executable is missing or indirect: $(basename "$executable")" +done +for artifact in "$KERNEL" "$ROOTFS" "$GUEST_AGENT"; do + [ -f "$artifact" ] && [ ! -L "$artifact" ] && [ -s "$artifact" ] \ + || die "candidate launch artifact is missing or indirect: $(basename "$artifact")" +done +CANDIDATE_APP_EXECUTABLE_SHA="$(shasum -a 256 "$APP_EXECUTABLE" | awk '{print $1}')" +CANDIDATE_DORYD_SHA="$(shasum -a 256 "$DORYD" | awk '{print $1}')" +CANDIDATE_DORY_VMM_SHA="$(shasum -a 256 "$DORY_VMM" | awk '{print $1}')" +CANDIDATE_DATAPLANE_SHA="$(shasum -a 256 "$DATAPLANE" | awk '{print $1}')" +CANDIDATE_DOCKER_SHA="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +CANDIDATE_COMPOSE_SHA="$(shasum -a 256 "$COMPOSE" | awk '{print $1}')" +CANDIDATE_BUILDX_SHA="$(shasum -a 256 "$BUILDX" | awk '{print $1}')" +CANDIDATE_KUBECTL_SHA="$(shasum -a 256 "$KUBECTL" | awk '{print $1}')" +CANDIDATE_KERNEL_SHA="$(shasum -a 256 "$KERNEL" | awk '{print $1}')" +CANDIDATE_ROOTFS_SHA="$(shasum -a 256 "$ROOTFS" | awk '{print $1}')" +CANDIDATE_GUEST_AGENT_SHA="$(shasum -a 256 "$GUEST_AGENT" | awk '{print $1}')" + +codesign --verify --strict --deep "$APP" +codesign -dv --verbose=4 "$APP" 2> "$WORKDIR/evidence/codesign-details.txt" +grep -q 'Authority=Developer ID Application' "$WORKDIR/evidence/codesign-details.txt" \ + || die "candidate is not Developer ID signed" +# shellcheck source=gvproxy-payload.sh +source scripts/gvproxy-payload.sh +GVPROXY="$APP/Contents/Helpers/gvproxy" +GVPROXY_PROVENANCE="$APP/Contents/Resources/gvproxy-provenance.txt" +PAYLOAD_INVENTORY="$APP/Contents/Resources/dory-payload-sha256.txt" +dory_gvproxy_validate_overrides +dory_verify_signed_gvproxy_payload "$GVPROXY" "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY" \ + || die "candidate gvproxy is not bound to its reproducible build and signed payload inventory" +CANDIDATE_GVPROXY_SHA="$(dory_gvproxy_file_sha256 "$GVPROXY")" +CANDIDATE_GVPROXY_BUILD_SHA="$(dory_gvproxy_expected_sha256)" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=4 "$APP" \ + > "$WORKDIR/evidence/gatekeeper-assessment.txt" 2>&1 +! grep -qi 'assessment system is disabled' "$WORKDIR/evidence/gatekeeper-assessment.txt" \ + || die "Gatekeeper assessments are disabled on the qualification host" +grep -q 'source=Notarized Developer ID' "$WORKDIR/evidence/gatekeeper-assessment.txt" \ + || die "Gatekeeper did not accept the notarized Developer ID candidate" +scripts/verify-sparkle-update.sh "$APP" "$UPDATE_ZIP" "$BUILD_DIR/appcast.xml" \ + > "$WORKDIR/evidence/sparkle-verification.txt" +for helper in dory-hv gvproxy dory-dataplane-proxy; do + cmp "$RUNTIME_DIR/bin/$helper" "$APP/Contents/Helpers/$helper" \ + || die "standalone runtime $helper differs from the qualified app" +done +cmp "$RUNTIME_DIR/share/dory/dory-hv-kernel-arm64.lzfse" \ + "$APP/Contents/Resources/dory-hv-kernel-arm64.lzfse" \ + || die "standalone runtime kernel differs from the qualified app" +cmp "$RUNTIME_DIR/share/dory/dory-engine-rootfs.ext4.lzfse" \ + "$APP/Contents/Resources/dory-engine-rootfs-arm64.ext4.lzfse" \ + || die "standalone runtime rootfs differs from the qualified app" +cmp "$RUNTIME_DIR/share/dory/dory-agent-linux-arm64" \ + "$APP/Contents/Resources/dory-agent-linux-arm64" \ + || die "standalone runtime guest agent differs from the qualified app" + +bounded 600 scripts/offline-bundled-boot-gate.sh \ + --runtime "$RUNTIME_DIR" \ + --workroot "$WORKDIR/evidence/offline-bundled-boot" \ + --release-candidate \ + --confirm DISPOSABLE-RUNTIME-OFFLINE-CACHE \ + > "$WORKDIR/evidence/offline-bundled-boot.log" 2>&1 \ + || die "exact bundled/cached offline boot gate failed" +offline_boot_manifest="$(find "$WORKDIR/evidence/offline-bundled-boot" \ + -name manifest.txt -type f -print -quit)" +[ -s "$offline_boot_manifest" ] || die "offline bundled boot manifest is missing" +for proof in status fresh_bundled_boot cached_boot_without_bundle_sources \ + dead_proxy_environment host_tcp_dependency_absence prepared_assets_unchanged; do + grep -qx "$proof=PASS" "$offline_boot_manifest" \ + || die "offline bundled boot evidence does not prove $proof" +done +grep -qx 'release_qualifying=true' "$offline_boot_manifest" \ + || die "offline bundled boot evidence is not bound to the exact candidate" +ln -s "$DOCKER" "$WORKDIR/docker" +ln -s "$COMPOSE" "$WORKDIR/docker-config/cli-plugins/docker-compose" +ln -s "$APP/Contents/Helpers/docker-buildx" "$WORKDIR/docker-config/cli-plugins/docker-buildx" +export PATH="$WORKDIR:$PATH" +export DOCKER_CONFIG="$WORKDIR/docker-config" +docker_at() { + local socket="$1" + shift + env -u DOCKER_API_VERSION -u DOCKER_AUTH_CONFIG -u DOCKER_CERT_PATH \ + -u DOCKER_CONTEXT -u DOCKER_CUSTOM_HEADERS -u DOCKER_DEFAULT_PLATFORM \ + -u DOCKER_TLS -u DOCKER_TLS_VERIFY DOCKER_CONFIG="$DOCKER_CONFIG" \ + DOCKER_HOST="unix://$socket" "$DOCKER" "$@" +} +docker_e() { docker_at "$SOCKET" "$@"; } +migration_docker_e() { docker_at "$MIGRATION_SOURCE_SOCKET" "$@"; } + +bounded 30 scripts/gvproxy-qemu-switch-gate.py \ + "$GVPROXY" \ + --expected-sha256 "$CANDIDATE_GVPROXY_SHA" \ + --provenance "$GVPROXY_PROVENANCE" \ + --workroot "$WORKDIR/evidence/gvproxy-qemu-switch" \ + --evidence "$WORKDIR/evidence/gvproxy-qemu-switch/manifest.txt" \ + --source-commit "$SOURCE_COMMIT" \ + --confirm EXACT-GVPROXY-QEMU-SWITCH \ + --release-candidate \ + > "$WORKDIR/evidence/gvproxy-qemu-switch.log" 2>&1 \ + || die "exact gvproxy independent bidirectional LAN switch-port gate failed" +qemu_switch_manifest="$WORKDIR/evidence/gvproxy-qemu-switch/manifest.txt" +for proof in lan_to_guest guest_to_lan; do + grep -qx "$proof=PASS" "$qemu_switch_manifest" \ + || die "gvproxy QEMU switch evidence does not prove $proof" +done +grep -qx "gvproxy_sha256=$CANDIDATE_GVPROXY_SHA" \ + "$qemu_switch_manifest" \ + || die "gvproxy QEMU switch gate used the wrong binary" +grep -qx "gvproxy_build_sha256=$CANDIDATE_GVPROXY_BUILD_SHA" \ + "$qemu_switch_manifest" \ + || die "gvproxy QEMU switch gate used the wrong reproducible build" +grep -qx 'release_qualifying=true' "$qemu_switch_manifest" \ + || die "gvproxy QEMU switch evidence is not release qualifying" + +bounded 900 scripts/managed-data-drive-gate.sh \ + --runtime "$RUNTIME_DIR" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --workroot "$WORKDIR/evidence/managed-data-drive" \ + --source-commit "$SOURCE_COMMIT" \ + --confirm ISOLATED-ENGINE-MANAGED-DATA-DRIVE \ + --release-candidate \ + > "$WORKDIR/evidence/managed-data-drive.log" 2>&1 \ + || die "managed data-drive persistence/fail-closed gate failed" +drive_summary="$(find "$WORKDIR/evidence/managed-data-drive" -name summary.txt -type f -print -quit)" +[ -s "$drive_summary" ] || die "managed data-drive summary is missing" +for proof in status fresh_drive_default first_launch_resume_before_drive \ + first_launch_resume_after_drive first_launch_identity_mismatch_rejected \ + explicit_drive_status running_drive_mismatch_rejected \ + lost_drive_identity_recovered lost_drive_identity_mismatch_rejected alternate_drive_untouched \ + unwritable_drive_rejected_cleanly missing_external_drive_rejected concurrent_attach_rejected \ + alias_concurrent_attach_rejected manifest_uuid_identity image_persistence \ + stopped_missing_selected_drive_rejected \ + container_writable_layer_persistence named_volume_persistence custom_network_persistence \ + transient_runtime_replacement durable_selection_survives_runtime_reset; do + grep -qx "$proof=PASS" "$drive_summary" \ + || die "managed data-drive summary does not prove $proof" +done + +bounded 180 scripts/data-drive-volume-identity-gate.sh \ + --dory-hv "$RUNTIME_DIR/bin/dory-hv" \ + --workroot "$WORKDIR/evidence/data-drive-volume-identity" \ + --confirm PHYSICAL-APFS-VOLUME-IDENTITY \ + > "$WORKDIR/evidence/data-drive-volume-identity.log" 2>&1 \ + || die "data-drive APFS volume-identity gate failed" +volume_identity_summary="$(find "$WORKDIR/evidence/data-drive-volume-identity" \ + -name summary.txt -type f -print -quit)" +[ -s "$volume_identity_summary" ] || die "data-drive volume-identity summary is missing" +for proof in status external_volume_identity durable_selection_outside_runtime_state \ + bookmark_volume_rename_recovery missing_volume_shadow_prevention \ + same_name_wrong_volume_rejected original_volume_reaccepted; do + grep -qx "$proof=PASS" "$volume_identity_summary" \ + || die "data-drive volume-identity summary does not prove $proof" +done +grep -qx "dory_hv_sha256=$CANDIDATE_DORY_HV_SHA" "$volume_identity_summary" \ + || die "data-drive volume-identity gate used the wrong dory-hv" + +bounded 1200 scripts/native-ipv6-gate.sh \ + --dory-hv "$RUNTIME_DIR/bin/dory-hv" \ + --gvproxy "$RUNTIME_DIR/bin/gvproxy" \ + --gvproxy-provenance "$GVPROXY_PROVENANCE" \ + --payload-inventory "$PAYLOAD_INVENTORY" \ + --kernel "$RUNTIME_DIR/share/dory/dory-hv-kernel-arm64.lzfse" \ + --rootfs "$RUNTIME_DIR/share/dory/dory-engine-rootfs.ext4.lzfse" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --workroot "$WORKDIR/evidence/native-ipv6" \ + --source-commit "$SOURCE_COMMIT" \ + --require-external \ + --confirm ISOLATED-ENGINE-NATIVE-IPV6 \ + --release-candidate \ + > "$WORKDIR/evidence/native-ipv6.log" 2>&1 \ + || die "native IPv6 address/DNS/registry/TCP/restart gate failed" +ipv6_manifest="$(find "$WORKDIR/evidence/native-ipv6" -name manifest.txt -type f -print -quit)" +[ -s "$ipv6_manifest" ] || die "native IPv6 manifest is missing" +for proof in status fresh_boot restart docker_bridge_ipv6 container_global_ipv6 dns_aaaa \ + registry_aaaa ipv6_tcp_loopback ipv6_localhost_publish external_ipv6_tcp; do + grep -qx "$proof=PASS" "$ipv6_manifest" \ + || die "native IPv6 manifest does not prove $proof" +done +grep -qx 'release_qualifying=true' "$ipv6_manifest" \ + || die "native IPv6 gate did not use a real external IPv6 route" + +bounded 900 scripts/data-disk-growth-gate.sh \ + --runtime "$RUNTIME_DIR" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --workroot "$WORKDIR/evidence/data-disk-growth" \ + --confirm ISOLATED-RUNTIME-DATA-DISK-GROWTH \ + > "$WORKDIR/evidence/data-disk-growth.log" 2>&1 \ + || die "16→128→256 GiB growth/sparse-trim/persistence gate failed" +growth_summary="$(find "$WORKDIR/evidence/data-disk-growth" -name summary.txt -type f -print -quit)" +[ -s "$growth_summary" ] || die "data-disk growth summary is missing" +grep -qx 'status=PASS' "$growth_summary" || die "data-disk growth summary is not PASS" +grep -qx 'sparse_allocation=PASS' "$growth_summary" \ + || die "data-disk growth did not remain sparse" +grep -qx 'discard_reclaim=PASS' "$growth_summary" \ + || die "data-disk growth did not reclaim deleted blocks" +grep -qx 'named_volume_restart_persistence=PASS' "$growth_summary" \ + || die "data-disk growth lost named-volume data across restart" +grep -qx 'capacity_api=PASS' "$growth_summary" \ + || die "data-disk growth did not expose truthful capacity metadata" +grep -qx 'running_growth_rejected=PASS' "$growth_summary" \ + || die "data-disk growth bypassed the running-drive lease" +grep -qx 'forced_offline_check=PASS' "$growth_summary" \ + || die "data-disk growth skipped the required offline filesystem check" +grep -qx 'guest_resize_evidence=PASS' "$growth_summary" \ + || die "data-disk growth did not retain guest resize evidence" +grep -qx 'explicit_capacity_growth=PASS' "$growth_summary" \ + || die "explicit Docker capacity growth failed" +grep -qx 'explicit_growth_named_volume_persistence=PASS' "$growth_summary" \ + || die "explicit Docker capacity growth lost named-volume data" + +ENGINE_STARTED=0 +MIGRATION_SOURCE_STARTED=0 +LONG_PID="" +ENDURANCE_PID="" +CAFFEINATE_PID="" +NEXT_POWER_CHECK=0 +stop_power_assertion() { + local pid="${CAFFEINATE_PID:-}" + [ -n "$pid" ] || return 0 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + CAFFEINATE_PID="" +} +assert_stable_power() { + local power compact + power="$(pmset -g batt)" + compact="$(printf '%s' "$power" | tr '\n' ';')" + printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$compact" \ + >> "$WORKDIR/evidence/power-history.tsv" + grep -Fq "Now drawing from 'AC Power'" <<< "$power" \ + || die "qualification host lost AC power" + [ -n "$CAFFEINATE_PID" ] && kill -0 "$CAFFEINATE_PID" 2>/dev/null \ + || die "qualification sleep-prevention assertion exited" +} +start_power_assertion() { + local power assertions + power="$(pmset -g batt)" + printf '%s\n' "$power" > "$WORKDIR/evidence/power-source.txt" + grep -Fq "Now drawing from 'AC Power'" <<< "$power" \ + || die "25-hour qualification requires stable AC power" + /usr/bin/caffeinate -is -w $$ \ + > "$WORKDIR/evidence/caffeinate.log" 2>&1 & + CAFFEINATE_PID=$! + sleep 0.2 + kill -0 "$CAFFEINATE_PID" 2>/dev/null \ + || die "could not establish the 25-hour sleep-prevention assertion" + assertions="$(pmset -g assertions)" + printf '%s\n' "$assertions" > "$WORKDIR/evidence/power-assertions.txt" + grep -F "pid $CAFFEINATE_PID(caffeinate)" <<< "$assertions" \ + | grep -Fq 'PreventUserIdleSystemSleep' \ + || die "caffeinate did not hold PreventUserIdleSystemSleep" + grep -F "pid $CAFFEINATE_PID(caffeinate)" <<< "$assertions" \ + | grep -Fq 'PreventSystemSleep' \ + || die "caffeinate did not hold PreventSystemSleep" + printf 'status=PASS\nflags=-is\ndisplay_sleep_prevented=false\n' \ + > "$WORKDIR/evidence/power-assertion-manifest.txt" + printf 'checked_utc\tpmset_state\n' > "$WORKDIR/evidence/power-history.tsv" + assert_stable_power + NEXT_POWER_CHECK=$((SECONDS + 60)) +} +owned_engine_pids_for() { + local home="$1" + ps axww -o pid=,command= | awk -v home="$home" ' + index($0, home) && ($0 ~ /\/dory-hv / || $0 ~ /\/gvproxy / || + $0 ~ /\/dory-dataplane-proxy /) { print $1 } + ' +} +owned_engine_pids() { owned_engine_pids_for "$ENGINE_HOME"; } +require_owned_engine_pid() { + local pid="$1" + printf '%s\n' "$pid" | grep -Eq '^[0-9]+$' \ + || die "engine PID metadata is invalid" + owned_engine_pids | grep -qx "$pid" \ + || die "engine PID is outside the exact isolated runtime authority" +} +cleanup() { + set +e + [ -n "$ENDURANCE_PID" ] && kill -TERM "$ENDURANCE_PID" 2>/dev/null || true + [ -n "$LONG_PID" ] && kill -TERM "$LONG_PID" 2>/dev/null || true + [ -n "$ENDURANCE_PID" ] && wait "$ENDURANCE_PID" 2>/dev/null || true + [ -n "$LONG_PID" ] && wait "$LONG_PID" 2>/dev/null || true + if [ "$MIGRATION_SOURCE_STARTED" -eq 1 ]; then + HOME="$MIGRATION_SOURCE_HOME" "$RUNTIME" stop \ + > "$WORKDIR/evidence/migration-source-stop.log" 2>&1 || true + fi + if [ "$ENGINE_STARTED" -eq 1 ]; then + HOME="$ENGINE_HOME" "$RUNTIME" stop > "$WORKDIR/evidence/runtime-stop.log" 2>&1 || true + fi + for _ in $(seq 1 300); do + [ ! -S "$SOCKET" ] && [ -z "$(owned_engine_pids)" ] && break + sleep 0.1 + done + if [ ! -S "$SOCKET" ] && [ -z "$(owned_engine_pids)" ]; then + # Preserve partial long-gate diagnostics in the durable qualification root, but never leave a + # multi-gigabyte short-path engine home behind after a clean cancellation or gate failure. + for gate in long-lived endurance competitor-runtime bind-file-coherence bind-advisory-lock ssh-agent testcontainers devcontainers act localstack tilt supabase kubernetes-tooling migration; do + if [ -d "$ENGINE_HOME/gate-evidence/$gate" ] \ + && [ ! -e "$WORKDIR/evidence/$gate" ]; then + mv "$ENGINE_HOME/gate-evidence/$gate" "$WORKDIR/evidence/$gate" || true + fi + done + rm -rf "$ENGINE_HOME" + else + printf 'cleanup retained active engine home for manual recovery: %s\n' "$ENGINE_HOME" \ + > "$WORKDIR/evidence/cleanup-retained-engine-home.txt" + fi + for _ in $(seq 1 300); do + [ ! -S "$MIGRATION_SOURCE_SOCKET" ] \ + && [ -z "$(owned_engine_pids_for "$MIGRATION_SOURCE_HOME")" ] && break + sleep 0.1 + done + if [ ! -S "$MIGRATION_SOURCE_SOCKET" ] \ + && [ -z "$(owned_engine_pids_for "$MIGRATION_SOURCE_HOME")" ]; then + rm -rf "$MIGRATION_SOURCE_HOME" + else + printf 'cleanup retained active migration source home for manual recovery: %s\n' \ + "$MIGRATION_SOURCE_HOME" \ + > "$WORKDIR/evidence/cleanup-retained-migration-source-home.txt" + fi + stop_power_assertion +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +start_power_assertion + +# The full default Supabase stack is mandatory and cannot converge inside the standalone launcher's +# deliberately tiny 2 GiB default. Qualify the exact runtime with an explicit workstation-class +# ceiling; this changes capacity only, not the candidate binaries or guest image under test. +HOME="$ENGINE_HOME" "$RUNTIME" start --mem-mb 8192 --cpus 6 --amd64 \ + > "$WORKDIR/evidence/runtime-start.log" 2>&1 +ENGINE_STARTED=1 +ready=0 +for _ in $(seq 1 900); do + if [ -S "$SOCKET" ] \ + && curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.2 +done +[ "$ready" -eq 1 ] || die "isolated candidate engine did not become ready within 180 seconds" + +supervisor_evidence="$WORKDIR/evidence/standalone-supervisor-recovery" +mkdir -p "$supervisor_evidence" +initial_engine_pid="$(cat "$ENGINE_HOME/.dory/standalone/engine-cli.pid")" +initial_dataplane_pid="$(cat "$ENGINE_HOME/.dory/standalone/dataplane-cli.pid")" +rm -f "$ENGINE_HOME/.dory/standalone/engine-cli.pid" \ + "$ENGINE_HOME/.dory/standalone/dataplane-cli.pid" +bounded 30 env HOME="$ENGINE_HOME" "$RUNTIME" start --mem-mb 8192 --cpus 6 --amd64 \ + > "$supervisor_evidence/pidfile-repair.out" \ + 2> "$supervisor_evidence/pidfile-repair.err" \ + || die "exact standalone runtime did not repair lost healthy PID metadata" +grep -q 'already running' "$supervisor_evidence/pidfile-repair.out" \ + || die "lost PID metadata caused a healthy engine restart" +repaired_engine_pid="$(cat "$ENGINE_HOME/.dory/standalone/engine-cli.pid")" +repaired_dataplane_pid="$(cat "$ENGINE_HOME/.dory/standalone/dataplane-cli.pid")" +[ "$repaired_engine_pid" = "$initial_engine_pid" ] \ + && [ "$repaired_dataplane_pid" = "$initial_dataplane_pid" ] \ + || die "healthy PID metadata repair changed the running helper identities" + +require_owned_engine_pid "$repaired_dataplane_pid" +kill -KILL "$repaired_dataplane_pid" 2>/dev/null \ + || die "could not create the isolated dead-dataplane recovery fixture" +for _ in $(seq 1 100); do + if ! kill -0 "$repaired_dataplane_pid" 2>/dev/null; then break; fi + process_state="$(ps -p "$repaired_dataplane_pid" -o state= 2>/dev/null | tr -d '[:space:]')" + [ "$process_state" = Z ] && break + sleep 0.05 +done +rm -f "$ENGINE_HOME/.dory/standalone/dataplane-cli.pid" +bounded 90 env HOME="$ENGINE_HOME" "$RUNTIME" start --mem-mb 8192 --cpus 6 --amd64 \ + > "$supervisor_evidence/incomplete-runtime-recovery.out" \ + 2> "$supervisor_evidence/incomplete-runtime-recovery.err" \ + || die "exact standalone runtime did not recover its dead dataplane" +grep -q 'stopping incomplete previous runtime' \ + "$supervisor_evidence/incomplete-runtime-recovery.out" \ + || die "dead dataplane did not trigger incomplete-runtime recovery" +recovered_engine_pid="$(cat "$ENGINE_HOME/.dory/standalone/engine-cli.pid")" +recovered_dataplane_pid="$(cat "$ENGINE_HOME/.dory/standalone/dataplane-cli.pid")" +[ "$recovered_engine_pid" != "$initial_engine_pid" ] \ + && [ "$recovered_dataplane_pid" != "$initial_dataplane_pid" ] \ + || die "incomplete-runtime recovery did not create a new helper pair" +[ "$(curl -fsS --max-time 5 --unix-socket "$SOCKET" http://d/_ping)" = OK ] \ + || die "Docker API did not recover after exact standalone supervisor repair" +grep 'engine stopped:' "$ENGINE_HOME/.dory/standalone/engine.log" | tail -1 \ + > "$supervisor_evidence/recovery-shutdown.txt" +grep -qx 'dory-hv: engine stopped: powerOff' \ + "$supervisor_evidence/recovery-shutdown.txt" \ + || die "incomplete-runtime recovery did not gracefully power off the guest" +cat > "$supervisor_evidence/manifest.txt" < "$WORKDIR/evidence/docker-version.txt" +owned_engine_pids > "$WORKDIR/evidence/engine-pids-started.txt" +[ -s "$WORKDIR/evidence/engine-pids-started.txt" ] \ + || die "no isolated candidate engine processes were attributable after start" +bounded 1200 scripts/nonnative-exec-conformance-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --base-image "$NONNATIVE_DEBIAN_IMAGE" \ + --native-image "$IMAGE" \ + --workroot "$WORKDIR/evidence/nonnative-exec-conformance" \ + --confirm ISOLATED-DORY-NONNATIVE-EXEC \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/nonnative-exec-conformance.log" 2>&1 \ + || die "linux/amd64 generic exec conformance gate failed" +nonnative_exec_manifest="$(find "$WORKDIR/evidence/nonnative-exec-conformance" \ + -name manifest.txt -type f -print -quit)" +[ -s "$nonnative_exec_manifest" ] \ + || die "non-native exec conformance evidence manifest is missing" +for proof in status fresh_pulls amd64_only_binfmt canonical_shebang_paths env_shebang_chain \ + private_marker_isolation guest_seccomp_inheritance fd_exec_arguments fd_exec_null_argv \ + buildkit_exec_matrix runtime_exec_matrix docker_exec_matrix docker_api_after_exec \ + build_cache_cleanup owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_exec_manifest" \ + || die "non-native exec conformance evidence does not prove $proof" +done +grep -Fx "base_image=$NONNATIVE_DEBIAN_IMAGE" "$nonnative_exec_manifest" >/dev/null \ + && grep -Fx "native_image=$IMAGE" "$nonnative_exec_manifest" >/dev/null \ + || die "non-native exec conformance gate used the wrong fixtures" +grep -qx 'oci_default_runtime=dory-runc' "$nonnative_exec_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_exec_manifest" \ + && grep -qx 'platform=linux/amd64' "$nonnative_exec_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_exec_manifest" \ + || die "non-native exec evidence omits the exact FEX/platform contract" + +bounded 600 scripts/default-platform-image-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --expected-platform linux/arm64 \ + --require-docker-hub \ + --workroot "$WORKDIR/evidence/default-platform-image" \ + --confirm ISOLATED-ENGINE-DEFAULT-PLATFORM \ + > "$WORKDIR/evidence/default-platform-image.log" 2>&1 \ + || die "default arm64 image pull/storage-reporting gate failed" +default_platform_manifest="$(find "$WORKDIR/evidence/default-platform-image" \ + -name manifest.txt -type f -print -quit)" +[ -s "$default_platform_manifest" ] || die "default platform image manifest is missing" +for proof in status default_pull_without_platform single_platform_local_image \ + default_run_architecture image_list_system_df_reconciled; do + grep -qx "$proof=PASS" "$default_platform_manifest" \ + || die "default platform image evidence does not prove $proof" +done +grep -Fx "image=$IMAGE" "$default_platform_manifest" >/dev/null \ + || die "default platform image gate used the wrong fixture" +grep -qx 'expected_platform=linux/arm64' "$default_platform_manifest" \ + || die "default image pull did not qualify Apple Silicon" +grep -qx 'registry=docker.io' "$default_platform_manifest" \ + || die "default image pull did not qualify Docker Hub manifest access" + +bounded 1200 scripts/prune-safety-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --base-image "$IMAGE" \ + --source-commit "$SOURCE_COMMIT" \ + --workroot "$WORKDIR/evidence/prune-safety" \ + --confirm ISOLATED-ENGINE-PRUNE \ + > "$WORKDIR/evidence/prune-safety.log" 2>&1 \ + || die "isolated Docker prune-safety gate failed" +prune_manifest="$(find "$WORKDIR/evidence/prune-safety" -name manifest.txt \ + -type f -print -quit)" +[ -s "$prune_manifest" ] || die "prune-safety evidence manifest is missing" +for proof in status empty_engine_precondition unfiltered_system_prune \ + unfiltered_container_prune unfiltered_image_prune unfiltered_network_prune \ + unfiltered_volume_prune unfiltered_builder_prune active_container_survived \ + active_image_survived active_volume_survived active_network_survived \ + active_volume_bytes_preserved unused_container_removed unused_image_removed \ + unused_volume_removed unused_network_removed build_cache_removed owned_cleanup; do + grep -qx "$proof=PASS" "$prune_manifest" \ + || die "prune-safety evidence does not prove $proof" +done +prune_docker_sha="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +grep -Fx "source_commit=$SOURCE_COMMIT" "$prune_manifest" >/dev/null \ + && grep -Fx "base_image=$IMAGE" "$prune_manifest" >/dev/null \ + && grep -qx "docker_cli_sha256=$prune_docker_sha" "$prune_manifest" \ + || die "prune-safety evidence used the wrong source, image, or Docker CLI" +# An all-unused image prune may remove the fixture tag after the protected resources are cleaned. +# Restore that exact digest before the remaining image and registry qualification. +bounded 600 docker_e pull --platform linux/arm64 "$IMAGE" \ + > "$WORKDIR/evidence/post-prune-base-pull.log" 2>&1 \ + || die "could not restore the digest-pinned base image after prune qualification" + +private_registry_workroot="$ENGINE_HOME/gate-evidence/private-registry-auth" +[ ! -e "$private_registry_workroot" ] && [ ! -L "$private_registry_workroot" ] \ + || die "private-registry evidence root already exists or is indirect" +bounded 1200 scripts/private-registry-auth-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --buildx "$APP/Contents/Helpers/docker-buildx" \ + --base-image "$IMAGE" \ + --registry-image "$REGISTRY_IMAGE" \ + --source-commit "$SOURCE_COMMIT" \ + --workroot "$private_registry_workroot" \ + --confirm ISOLATED-ENGINE-PRIVATE-REGISTRY \ + > "$WORKDIR/evidence/private-registry-auth.log" 2>&1 \ + || die "private-registry authentication and image-lifecycle gate failed" +private_registry_manifest="$(find "$private_registry_workroot" \ + -name manifest.txt -type f -print -quit)" +[ -s "$private_registry_manifest" ] || die "private-registry evidence manifest is missing" +for proof in status registry_fixture_arm64 unauthenticated_pull_rejected authenticated_login \ + authenticated_pull_run buildkit_registry_auth buildkit_secret_nonleak \ + buildkit_registry_cache_export buildkit_registry_cache_import registry_push \ + image_inspect_history image_save_load_identity image_tag_remove filtered_image_prune \ + owned_cleanup isolated_credential_cleanup; do + grep -qx "$proof=PASS" "$private_registry_manifest" \ + || die "private-registry evidence does not prove $proof" +done +grep -Fx "source_commit=$SOURCE_COMMIT" "$private_registry_manifest" >/dev/null \ + && grep -Fx "base_image=$IMAGE" "$private_registry_manifest" >/dev/null \ + && grep -Fx "registry_image=$REGISTRY_IMAGE" "$private_registry_manifest" >/dev/null \ + || die "private-registry evidence used the wrong source or image fixtures" +private_registry_docker_sha="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +private_registry_buildx_sha="$(shasum -a 256 \ + "$APP/Contents/Helpers/docker-buildx" | awk '{print $1}')" +grep -qx "docker_cli_sha256=$private_registry_docker_sha" "$private_registry_manifest" \ + && grep -qx "buildx_cli_sha256=$private_registry_buildx_sha" \ + "$private_registry_manifest" \ + || die "private-registry evidence used the wrong candidate Docker or Buildx client" +[ -z "$(docker_e ps -aq)" ] \ + || die "private-registry gate left containers on the isolated engine" +[ ! -e "$WORKDIR/evidence/private-registry-auth" ] \ + || die "durable private-registry evidence destination already exists" +mkdir "$WORKDIR/evidence/private-registry-auth" +cp -R "$private_registry_workroot/." "$WORKDIR/evidence/private-registry-auth/" + +bounded 900 scripts/nonnative-nix-gc-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$NONNATIVE_NIX_IMAGE" \ + --workroot "$WORKDIR/evidence/nonnative-nix-gc" \ + --confirm ISOLATED-DORY-NONNATIVE-NIX-GC \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/nonnative-nix-gc.log" 2>&1 \ + || die "linux/amd64 Nix garbage-collection gate failed" +nonnative_nix_manifest="$(find "$WORKDIR/evidence/nonnative-nix-gc" \ + -name manifest.txt -type f -print -quit)" +[ -s "$nonnative_nix_manifest" ] || die "non-native Nix GC evidence manifest is missing" +for proof in status fresh_pull unreachable_store_path_created nix_collect_garbage_delete_old \ + unreachable_store_path_deleted docker_api_after_gc owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_nix_manifest" \ + || die "non-native Nix GC evidence does not prove $proof" +done +grep -Fx "image=$NONNATIVE_NIX_IMAGE" "$nonnative_nix_manifest" >/dev/null \ + || die "non-native Nix GC gate used the wrong image" +grep -qx 'platform=linux/amd64' "$nonnative_nix_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_nix_manifest" \ + && grep -qx 'nix_version=2.34.7' "$nonnative_nix_manifest" \ + || die "non-native Nix GC gate lost its exact architecture/version contract" + +bounded 900 scripts/nonnative-arch-pacman-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --base-image "$NONNATIVE_ARCH_IMAGE" \ + --workroot "$WORKDIR/evidence/nonnative-arch-pacman" \ + --confirm ISOLATED-DORY-NONNATIVE-ARCH-PACMAN \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/nonnative-arch-pacman.log" 2>&1 \ + || die "linux/amd64 Arch pacman sandbox gate failed" +nonnative_arch_manifest="$(find "$WORKDIR/evidence/nonnative-arch-pacman" \ + -name manifest.txt -type f -print -quit)" +[ -s "$nonnative_arch_manifest" ] || die "non-native Arch pacman evidence manifest is missing" +for proof in status fresh_pull pacman_default_sandbox alpm_user_switch fex_handler \ + fex_bundle_read_only fzf_inventory fzf_runtime docker_api_after_build owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_arch_manifest" \ + || die "non-native Arch pacman evidence does not prove $proof" +done +grep -qx 'oci_default_runtime=dory-runc' "$nonnative_arch_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_arch_manifest" \ + && grep -Eq '^fex_sha256=[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + && grep -Eq '^fex_server_sha256=[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + || die "non-native Arch pacman evidence omits the FEX runtime contract" +grep -Fx "base_image=$NONNATIVE_ARCH_IMAGE" "$nonnative_arch_manifest" >/dev/null \ + || die "non-native Arch pacman gate used the wrong image" + +bounded 1800 scripts/nonnative-mmdebstrap-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --base-image "$NONNATIVE_DEBIAN_IMAGE" \ + --workroot "$WORKDIR/evidence/nonnative-mmdebstrap" \ + --confirm ISOLATED-DORY-NONNATIVE-MMDEBSTRAP \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/nonnative-mmdebstrap.log" 2>&1 \ + || die "linux/amd64 mmdebstrap nested-chroot gate failed" +nonnative_mmdebstrap_manifest="$(find "$WORKDIR/evidence/nonnative-mmdebstrap" \ + -name manifest.txt -type f -print -quit)" +[ -s "$nonnative_mmdebstrap_manifest" ] \ + || die "non-native mmdebstrap evidence manifest is missing" +for proof in status fresh_pull reported_dockerfile_commands mmdebstrap_minbase_trixie \ + bad_fd_number_absent fex_handler fex_bundle_read_only rootfs_archive_readable \ + nested_chroot_no_proc nested_chroot_shebang private_marker_isolation \ + docker_api_after_build build_cache_cleanup owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_mmdebstrap_manifest" \ + || die "non-native mmdebstrap evidence does not prove $proof" +done +grep -Fx "base_image=$NONNATIVE_DEBIAN_IMAGE" "$nonnative_mmdebstrap_manifest" >/dev/null \ + || die "non-native mmdebstrap gate used the wrong image" +grep -qx 'oci_default_runtime=dory-runc' "$nonnative_mmdebstrap_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_mmdebstrap_manifest" \ + && grep -qx 'platform=linux/amd64' "$nonnative_mmdebstrap_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_mmdebstrap_manifest" \ + || die "non-native mmdebstrap evidence omits the exact FEX/platform contract" + +bounded 1800 scripts/ecr-registry-retry-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --base-image "$IMAGE" \ + --registry "$ECR_REGISTRY" \ + --repository "$ECR_REPOSITORY" \ + --region "$ECR_REGION" \ + --workroot "$WORKDIR/evidence/ecr-registry" \ + --confirm DISPOSABLE-ECR-INTERRUPT-RETRY \ + > "$WORKDIR/evidence/ecr-registry.log" 2>&1 \ + || die "managed ECR interrupted-upload/retry gate failed" +ecr_manifest="$(find "$WORKDIR/evidence/ecr-registry" -name manifest.txt -type f -print -quit)" +[ -s "$ecr_manifest" ] || die "managed ECR evidence manifest is missing" +for proof in status authenticated_login bundled_buildx interrupted_push_progress \ + interrupted_push_nonzero resumed_blob_upload \ + repeated_manifest_put repull_run_checksum local_image_cleanup remote_tag_cleanup \ + isolated_credential_cleanup; do + grep -qx "$proof=PASS" "$ecr_manifest" \ + || die "managed ECR evidence does not prove $proof" +done +unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE \ + AWS_WEB_IDENTITY_TOKEN_FILE DORY_RELEASE_ECR_REGISTRY DORY_RELEASE_ECR_REPOSITORY \ + DORY_RELEASE_ECR_REGION || true + +bounded 180 scripts/bind-file-coherence-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/bind-file-coherence" \ + --confirm ISOLATED-ENGINE-BIND-FILE-COHERENCE \ + > "$WORKDIR/evidence/bind-file-coherence.log" 2>&1 \ + || die "spaced-path/direct-file bind coherence gate failed" +bind_file_manifest="$(find "$ENGINE_HOME/gate-evidence/bind-file-coherence" \ + -name manifest.txt -type f -print -quit)" +[ -s "$bind_file_manifest" ] || die "bind-file coherence manifest is missing" +for proof in status path_with_spaces directory_bind direct_single_file_bind \ + same_inode_shrink same_inode_grow same_inode_content_refresh atomic_replacement \ + direct_atomic_replacement_pins_inode direct_rebind_follows_replacement \ + guest_to_host_truncation; do + grep -qx "$proof=PASS" "$bind_file_manifest" \ + || die "bind-file coherence manifest does not prove $proof" +done +grep -qx 'direct_single_file_recreate_cycles=20' "$bind_file_manifest" \ + || die "bind-file coherence gate did not repeat direct file attachment 20 times" +grep -Fx "image=$IMAGE" "$bind_file_manifest" >/dev/null \ + || die "bind-file coherence gate used the wrong image" +bind_file_docker_sha="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +grep -qx "docker_cli_sha256=$bind_file_docker_sha" "$bind_file_manifest" \ + || die "bind-file coherence gate used the wrong Docker CLI" + +guest_agent_evidence="$WORKDIR/evidence/guest-agent" +mkdir -p "$guest_agent_evidence" +guest_agent_expected_sha256="$(shasum -a 256 \ + "$RUNTIME_DIR/share/dory/dory-agent-linux-arm64" | awk '{print $1}')" +printf '%s\n' "$guest_agent_expected_sha256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "could not hash the bundled standalone guest agent" +printf 'expected_sha256=%s\n' "$guest_agent_expected_sha256" \ + > "$guest_agent_evidence/manifest.txt" +verify_running_guest_agent() { + local phase="$1" container="dory-qualified-agent-${RUN_ID}-${RUN_ATTEMPT}-$1" + local output="$guest_agent_evidence/$phase.txt" actual + if ! bounded 60 docker_e run --rm --privileged \ + --name "$container" --label dev.dory.qualification=guest-agent \ + -v /:/dory-guest-root:ro "$IMAGE" \ + sh -ec 'sha256sum /dory-guest-root/run/dory-agent' > "$output" 2>&1; then + docker_e rm -f "$container" >/dev/null 2>&1 || true + die "could not inspect the running guest agent after $phase boot" + fi + actual="$(awk 'NR == 1 {print $1}' "$output")" + [ "$actual" = "$guest_agent_expected_sha256" ] \ + || die "running guest agent after $phase boot differs from the exact bundled agent" + printf '%s_sha256=%s\n' "$phase" "$actual" >> "$guest_agent_evidence/manifest.txt" +} +verify_running_guest_agent fresh + +# Exercise the production migration implementation between two disposable candidate engines. +# This is intentionally before the long soaks: it proves the exact source commit can restore +# images, volumes, metadata, networks, containers, writable layers, and state without touching a +# user's Docker/OrbStack installation, then removes the second engine before duration sampling. +MIGRATION_SOURCE_STARTED=1 +HOME="$MIGRATION_SOURCE_HOME" "$RUNTIME" start \ + > "$WORKDIR/evidence/migration-source-start.log" 2>&1 +migration_source_ready=0 +for _ in $(seq 1 900); do + if [ -S "$MIGRATION_SOURCE_SOCKET" ] \ + && curl -fsS --max-time 2 --unix-socket "$MIGRATION_SOURCE_SOCKET" \ + http://d/_ping >/dev/null 2>&1; then + migration_source_ready=1 + break + fi + sleep 0.2 +done +[ "$migration_source_ready" -eq 1 ] \ + || die "isolated migration source engine did not become ready within 180 seconds" +migration_docker_e pull "$IMAGE" \ + > "$WORKDIR/evidence/migration-source-image-pull.txt" +bounded 1800 env \ + DORY_LIVE_SOURCE_SOCKET="$MIGRATION_SOURCE_SOCKET" \ + DORY_LIVE_TARGET_SOCKET="$SOCKET" \ + DORY_LIVE_MIGRATION_BASE_IMAGE="$IMAGE" \ + DORY_LIVE_MIGRATION_EVIDENCE_DIR="$ENGINE_HOME/gate-evidence/migration" \ + scripts/live-orbstack-migration-smoke.sh \ + > "$WORKDIR/evidence/migration.log" 2>&1 \ + || die "disposable two-engine production migration gate failed" +migration_manifest="$ENGINE_HOME/gate-evidence/migration/manifest.txt" +[ -s "$migration_manifest" ] || die "migration evidence manifest is missing" +grep -qx 'status=PASS' "$migration_manifest" \ + || die "migration evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "migration gate left containers on the isolated target engine" +HOME="$MIGRATION_SOURCE_HOME" "$RUNTIME" stop \ + > "$WORKDIR/evidence/migration-source-stop.log" 2>&1 +for _ in $(seq 1 300); do + [ ! -S "$MIGRATION_SOURCE_SOCKET" ] \ + && [ -z "$(owned_engine_pids_for "$MIGRATION_SOURCE_HOME")" ] && break + sleep 0.1 +done +[ ! -S "$MIGRATION_SOURCE_SOCKET" ] \ + || die "isolated migration source socket survived shutdown" +[ -z "$(owned_engine_pids_for "$MIGRATION_SOURCE_HOME")" ] \ + || die "isolated migration source helpers survived shutdown" +MIGRATION_SOURCE_STARTED=0 +rm -rf "$MIGRATION_SOURCE_HOME" + +# Supabase's released Go client pulls images and then reuses the same Docker HTTP connection for +# Vector's socket-mounted create. This gate therefore covers both the full product stack and the +# post-stream create classification that a one-shot `docker run` smoke cannot exercise. +bounded 1800 scripts/supabase-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --version "$SUPABASE_VERSION" \ + --workroot "$ENGINE_HOME/gate-evidence/supabase" \ + --confirm ISOLATED-ENGINE-SUPABASE \ + > "$WORKDIR/evidence/supabase.log" 2>&1 \ + || die "full default Supabase compatibility gate failed" +supabase_manifest="$ENGINE_HOME/gate-evidence/supabase/manifest.txt" +[ -s "$supabase_manifest" ] || die "Supabase evidence manifest is missing" +grep -qx 'status=PASS' "$supabase_manifest" || die "Supabase evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "Supabase gate left containers on the isolated engine" + +# Exercise the Kubernetes toolchain that commonly exposes nested-container, privileged-runtime, +# dynamic-port, and long-lived API-stream incompatibilities in desktop container engines. +bounded 1800 scripts/kubernetes-tooling-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --kubectl "$KUBECTL" \ + --k3s-image "$K3S_IMAGE" \ + --workload-image "$K8S_WORKLOAD_IMAGE" \ + --tilt-version "$TILT_VERSION" \ + --skaffold-version "$SKAFFOLD_VERSION" \ + --workroot "$ENGINE_HOME/gate-evidence/kubernetes-tooling" \ + --confirm ISOLATED-ENGINE-KUBERNETES-TOOLING \ + > "$WORKDIR/evidence/kubernetes-tooling.log" 2>&1 \ + || die "k3s/Skaffold/Tilt Kubernetes compatibility gate failed" +kubernetes_tooling_manifest="$ENGINE_HOME/gate-evidence/kubernetes-tooling/manifest.txt" +[ -s "$kubernetes_tooling_manifest" ] \ + || die "Kubernetes tooling evidence manifest is missing" +grep -qx 'status=PASS' "$kubernetes_tooling_manifest" \ + || die "Kubernetes tooling evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "Kubernetes tooling gate left containers on the isolated engine" + +bounded 1800 scripts/competitor-runtime-regression-gate.sh \ + --socket "$SOCKET" \ + --state-dir "$ENGINE_HOME/.dory/standalone" \ + --image "$IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/competitor-runtime" \ + --connections 2000 \ + --restarts 20 \ + --fd-growth 8 \ + --docker "$DOCKER" \ + --compose "$COMPOSE" \ + --buildx "$BUILDX" \ + --runtime "$RUNTIME" \ + --runtime-home "$ENGINE_HOME" \ + --source-commit "$SOURCE_COMMIT" \ + --confirm ISOLATED-ENGINE-COMPETITOR-REGRESSION \ + --release-candidate \ + > "$WORKDIR/evidence/competitor-runtime.log" 2>&1 \ + || die "bounded competitor runtime/backpressure/restart gate failed" +competitor_results="$(find "$ENGINE_HOME/gate-evidence/competitor-runtime" \ + -name results.tsv -type f -print -quit)" +[ -s "$competitor_results" ] || die "competitor runtime results are missing" +competitor_manifest="$(dirname "$competitor_results")/manifest.txt" +[ -s "$competitor_manifest" ] || die "competitor runtime manifest is missing" +competitor_docker_sha="$(shasum -a 256 "$DOCKER" | awk '{print $1}')" +competitor_compose_sha="$(shasum -a 256 "$COMPOSE" | awk '{print $1}')" +competitor_buildx_sha="$(shasum -a 256 "$BUILDX" | awk '{print $1}')" +grep -qx "docker_bin_sha256=$competitor_docker_sha" "$competitor_manifest" \ + || die "competitor runtime manifest is not bound to the qualified Docker CLI" +grep -qx "compose_bin_sha256=$competitor_compose_sha" "$competitor_manifest" \ + || die "competitor runtime manifest is not bound to the qualified Compose v2 helper" +grep -qx "buildx_bin_sha256=$competitor_buildx_sha" "$competitor_manifest" \ + || die "competitor runtime manifest is not bound to the qualified Buildx helper" +grep -qx "source_commit=$SOURCE_COMMIT" "$competitor_manifest" \ + || die "competitor runtime manifest is not bound to the qualified source commit" +grep -Eq '^dory_engine_sha256=[0-9a-f]{64}$' "$competitor_manifest" \ + || die "competitor runtime manifest omits the launcher digest" +grep -Eq '^bin_dory_hv_sha256=[0-9a-f]{64}$' "$competitor_manifest" \ + || die "competitor runtime manifest omits the dory-hv digest" +grep -Eq '^share_dory_dory_engine_rootfs_ext4_lzfse_sha256=[0-9a-f]{64}$' \ + "$competitor_manifest" \ + || die "competitor runtime manifest omits the rootfs digest" +grep -qx 'amd64_enabled=1' "$(dirname "$competitor_results")/engine-settings.txt" \ + || die "competitor runtime restart did not retain Apple Silicon amd64/FEX mode" +grep -q $'\tFAIL\t' "$competitor_results" \ + && die "competitor runtime results contain a failed row" +for proof in \ + published-port-handoff host-port-collision named-signal-delivery container-api-lifecycle \ + forwarded-connection-fds \ + concurrent-proxy-backpressure missing-source-cp restart-churn compose-port-restart \ + compose-v2-lifecycle \ + network-route-conflict network-api-lifecycle network-alias-restart-ip standalone-engine-restart \ + named-volume-empty named-volume named-volume-cp volume-api-lifecycle security-opt-label seccomp-profile \ + bind-open-create-0200 bind-mount-option-contract nested-bind-subvolume \ + bind-special-file-fail-fast bind-open-fd-stability bind-hardlink-permissions healthcheck \ + buildx-named-context buildkit-default-arg image-save-stdout image-hardlink-missing-parent \ + buildkit-large-dockerfile buildkit-relative-temp-context dockerignore-layered-unignore \ + buildkit-concurrent-sessions buildkit-cache-cancellation \ + container-resolver-contract container-dns-search \ + cleanup-restart-persistence; do + awk -F '\t' -v proof="$proof" \ + '$1 == proof && $2 == "PASS" { found=1 } END { exit !found }' "$competitor_results" \ + || die "competitor runtime results do not prove $proof" +done +[ -S "$SOCKET" ] \ + && curl -fsS --max-time 2 --unix-socket "$SOCKET" http://d/_ping >/dev/null \ + || die "candidate engine did not recover after the restart gate" +verify_running_guest_agent restart +printf 'status=PASS\n' >> "$guest_agent_evidence/manifest.txt" + +bounded 300 env HOME="$ENGINE_HOME" scripts/bind-advisory-lock-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$LOCK_IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/bind-advisory-lock" \ + --confirm ISOLATED-DORY-BIND-LOCKS \ + > "$WORKDIR/evidence/bind-advisory-lock.log" 2>&1 \ + || die "cross-container bind advisory-lock gate failed" +bind_lock_manifest="$(find "$ENGINE_HOME/gate-evidence/bind-advisory-lock" \ + -name manifest.txt -type f -print -quit)" +[ -s "$bind_lock_manifest" ] || die "bind advisory-lock evidence manifest is missing" +for proof in status create_excl_readonly_mode0000_unlink \ + bsd_flock_exclusive_shared_unlock_upgrade_crash \ + posix_range_nonoverlap_blocking_unlock_crash cross_container_bind_mount; do + grep -qx "$proof=PASS" "$bind_lock_manifest" \ + || die "bind advisory-lock manifest does not prove $proof" +done +bind_lock_docker_sha256="$(sed -n 's/^docker_cli_sha256=//p' "$bind_lock_manifest")" +printf '%s\n' "$bind_lock_docker_sha256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "bind advisory-lock manifest does not identify the exact Docker CLI" +[ -z "$(docker_e ps -aq)" ] \ + || die "bind advisory-lock gate left containers on the isolated engine" + +bounded 600 scripts/ssh-agent-forwarding-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$SSH_CLIENT_IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/ssh-agent" \ + --concurrency 8 \ + > "$WORKDIR/evidence/ssh-agent.log" 2>&1 \ + || die "SSH-agent forwarding compatibility gate failed" +ssh_agent_manifest="$(find "$ENGINE_HOME/gate-evidence/ssh-agent" \ + -name manifest.txt -type f -print -quit)" +[ -s "$ssh_agent_manifest" ] || die "SSH-agent forwarding evidence manifest is missing" +grep -qx 'status=PASS' "$ssh_agent_manifest" \ + || die "SSH-agent forwarding evidence manifest is not PASS" +grep -qx "docker_sha256=$competitor_docker_sha" "$ssh_agent_manifest" \ + || die "SSH-agent forwarding evidence used the wrong Docker CLI" +candidate_buildx_sha="$(shasum -a 256 "$APP/Contents/Helpers/docker-buildx" | awk '{print $1}')" +grep -qx "buildx_sha256=$candidate_buildx_sha" "$ssh_agent_manifest" \ + || die "SSH-agent forwarding evidence used the wrong Buildx plugin" +grep -qx 'bundled_buildx=PASS' "$ssh_agent_manifest" \ + || die "SSH-agent forwarding evidence did not prove the bundled Buildx plugin" +[ -z "$(docker_e ps -aq)" ] \ + || die "SSH-agent gate left containers on the isolated engine" + +bounded 600 docker_e pull --platform linux/arm64 "$TESTCONTAINERS_RYUK_IMAGE" \ + > "$WORKDIR/evidence/testcontainers-ryuk-pull.log" 2>&1 \ + || die "could not preload the digest-pinned Testcontainers Ryuk fixture" +bounded 900 scripts/testcontainers-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --version "$TESTCONTAINERS_VERSION" \ + --image "$IMAGE" \ + --ryuk-image "$TESTCONTAINERS_RYUK_IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/testcontainers" \ + --confirm ISOLATED-ENGINE-TESTCONTAINERS \ + > "$WORKDIR/evidence/testcontainers.log" 2>&1 \ + || die "Testcontainers/Ryuk compatibility gate failed" +testcontainers_manifest="$(find "$ENGINE_HOME/gate-evidence/testcontainers" \ + -name manifest.txt -type f -print -quit)" +[ -s "$testcontainers_manifest" ] || die "Testcontainers evidence manifest is missing" +grep -qx 'status=PASS' "$testcontainers_manifest" \ + || die "Testcontainers evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "Testcontainers gate left containers on the isolated engine" + +bounded 900 scripts/devcontainers-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --version "$DEVCONTAINERS_VERSION" \ + --image "$IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/devcontainers" \ + --confirm ISOLATED-ENGINE-DEVCONTAINERS \ + > "$WORKDIR/evidence/devcontainers.log" 2>&1 \ + || die "Dev Containers CLI compatibility gate failed" +devcontainers_manifest="$ENGINE_HOME/gate-evidence/devcontainers/manifest.txt" +[ -s "$devcontainers_manifest" ] || die "Dev Containers evidence manifest is missing" +grep -qx 'status=PASS' "$devcontainers_manifest" \ + || die "Dev Containers evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "Dev Containers gate left containers on the isolated engine" + +bounded 600 docker_e pull --platform linux/arm64 "$ACT_RUNNER_IMAGE" \ + > "$WORKDIR/evidence/act-runner-pull.log" 2>&1 \ + || die "could not preload the digest-pinned Act runner fixture" +bounded 900 scripts/act-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --version "$ACT_VERSION" \ + --runner-image "$ACT_RUNNER_IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/act" \ + --confirm ISOLATED-ENGINE-ACT \ + > "$WORKDIR/evidence/act.log" 2>&1 \ + || die "act workflow compatibility gate failed" +act_manifest="$ENGINE_HOME/gate-evidence/act/manifest.txt" +[ -s "$act_manifest" ] || die "act evidence manifest is missing" +grep -qx 'status=PASS' "$act_manifest" || die "act evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "act gate left containers on the isolated engine" + +bounded 600 docker_e pull --platform linux/arm64 "$LOCALSTACK_IMAGE" \ + > "$WORKDIR/evidence/localstack-image-pull.log" 2>&1 \ + || die "could not preload the digest-pinned LocalStack fixture" +bounded 1200 scripts/localstack-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$LOCALSTACK_IMAGE" \ + --workroot "$ENGINE_HOME/gate-evidence/localstack" \ + --confirm ISOLATED-ENGINE-LOCALSTACK \ + > "$WORKDIR/evidence/localstack.log" 2>&1 \ + || die "LocalStack S3/SQS compatibility gate failed" +localstack_manifest="$ENGINE_HOME/gate-evidence/localstack/manifest.txt" +[ -s "$localstack_manifest" ] || die "LocalStack evidence manifest is missing" +grep -qx 'status=PASS' "$localstack_manifest" \ + || die "LocalStack evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "LocalStack gate left containers on the isolated engine" + +bounded 900 scripts/tilt-compose-compatibility-gate.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --compose "$COMPOSE" \ + --image "$IMAGE" \ + --version "$TILT_VERSION" \ + --workroot "$ENGINE_HOME/gate-evidence/tilt" \ + --confirm ISOLATED-ENGINE-TILT \ + > "$WORKDIR/evidence/tilt.log" 2>&1 \ + || die "Tilt Compose compatibility gate failed" +tilt_manifest="$ENGINE_HOME/gate-evidence/tilt/manifest.txt" +[ -s "$tilt_manifest" ] || die "Tilt evidence manifest is missing" +grep -qx 'status=PASS' "$tilt_manifest" || die "Tilt evidence manifest is not PASS" +[ -z "$(docker_e ps -aq)" ] \ + || die "Tilt gate left containers on the isolated engine" + +# Do not retain an Actions/GitHub token in a process that intentionally runs beyond its 24-hour +# lifetime. Publication happens in a subsequent job with fresh credentials. +assert_stable_power +unset GITHUB_TOKEN GH_TOKEN ACTIONS_RUNTIME_TOKEN ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + ACTIONS_ID_TOKEN_REQUEST_URL || true + +scripts/long-lived-network-soak.sh \ + --socket "$SOCKET" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --duration "$LONG_DURATION" \ + --workroot "$ENGINE_HOME/gate-evidence/long-lived" \ + --confirm ISOLATED-ENGINE-LONG-LIVED-TCP \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/long-lived.log" 2>&1 & +LONG_PID=$! + +# Establish the measured TCP connection before taking the endurance baseline, so the long-lived +# fixture is present in both the first and final resource/state windows. +heartbeat_ready=0 +for _ in $(seq 1 600); do + kill -0 "$LONG_PID" 2>/dev/null || break + if find "$ENGINE_HOME/gate-evidence/long-lived" -name heartbeats.tsv -type f -exec sh -c \ + 'test "$(wc -l < "$1")" -ge 2' sh {} \; -print -quit 2>/dev/null | grep -q .; then + heartbeat_ready=1 + break + fi + sleep 0.1 +done +[ "$heartbeat_ready" -eq 1 ] || die "long-lived gate did not establish its measured connection" + +scripts/endurance-reliability-soak.sh \ + --socket "$SOCKET" \ + --state-dir "$ENGINE_HOME/.dory/standalone" \ + --docker "$DOCKER" \ + --image "$IMAGE" \ + --duration "$ENDURANCE_DURATION" \ + --compose-every 5 \ + --settle 2 \ + --workroot "$ENGINE_HOME/gate-evidence/endurance" \ + --min-free-gb "$MIN_FREE_GB" \ + --process-root "$ENGINE_HOME/.dory/standalone" \ + --confirm ISOLATED-ENGINE-ENDURANCE-RELIABILITY \ + --source-commit "$SOURCE_COMMIT" \ + --release-candidate \ + > "$WORKDIR/evidence/endurance.log" 2>&1 & +ENDURANCE_PID=$! + +long_done=0 +endurance_done=0 +while [ "$long_done" -eq 0 ] || [ "$endurance_done" -eq 0 ]; do + if [ "$SECONDS" -ge "$NEXT_POWER_CHECK" ]; then + assert_stable_power + NEXT_POWER_CHECK=$((SECONDS + 60)) + fi + if [ "$long_done" -eq 0 ] && ! kill -0 "$LONG_PID" 2>/dev/null; then + set +e; wait "$LONG_PID"; long_rc=$?; set -e + LONG_PID="" + long_done=1 + if [ "$long_rc" -ne 0 ]; then + die "25-hour same-connection gate failed (exit $long_rc)" + fi + fi + if [ "$endurance_done" -eq 0 ] && ! kill -0 "$ENDURANCE_PID" 2>/dev/null; then + set +e; wait "$ENDURANCE_PID"; endurance_rc=$?; set -e + ENDURANCE_PID="" + endurance_done=1 + if [ "$endurance_rc" -ne 0 ]; then + die "eight-hour endurance gate failed (exit $endurance_rc)" + fi + fi + [ "$long_done" -eq 1 ] && [ "$endurance_done" -eq 1 ] || sleep 5 +done +assert_stable_power + +long_summary="$(find "$ENGINE_HOME/gate-evidence/long-lived" -name summary.txt -type f -print -quit)" +long_manifest="$(find "$ENGINE_HOME/gate-evidence/long-lived" -name manifest.txt -type f -print -quit)" +endurance_manifest="$(find "$ENGINE_HOME/gate-evidence/endurance" -name manifest.txt -type f -print -quit)" +endurance_cycles="$(find "$ENGINE_HOME/gate-evidence/endurance" -name cycles.tsv -type f -print -quit)" +[ -s "$long_summary" ] || die "long-lived summary is missing" +[ -s "$long_manifest" ] || die "long-lived manifest is missing" +grep -qx 'status=PASS' "$long_summary" || die "long-lived summary is not PASS" +grep -qx 'machine_to_docker_service=PASS' "$long_summary" \ + && grep -qx 'machine_service_route=host.docker.internal' "$long_summary" \ + && grep -qx 'machine_service_regular_200_400ms_plateau=ABSENT' "$long_summary" \ + || die "long-lived summary does not prove stable managed-machine-to-Docker service latency" +grep -qx 'machine_outbound_tcp=PASS' "$long_summary" \ + && grep -qx 'machine_outbound_failure_budget_per_mille=5' "$long_summary" \ + && grep -qx 'machine_outbound_consecutive_failure_limit=2' "$long_summary" \ + || die "long-lived summary does not prove stable managed-machine outbound TCP" +if [ "$LONG_DURATION" -gt 86400 ]; then + grep -qx 'duration_beyond_24_hours=PASS' "$long_summary" \ + || die "long-lived summary does not prove the 24-hour edge" +fi +[ -s "$endurance_manifest" ] || die "endurance manifest is missing" +[ -s "$endurance_cycles" ] || die "endurance cycle results are missing" +if [ "$ENDURANCE_DURATION" -ge 28800 ]; then + grep -qx 'release_qualifying=true' "$endurance_manifest" \ + || die "endurance summary is not release qualifying" +fi +grep -q $'\tFAIL\t' "$endurance_cycles" && die "endurance results contain a failed cycle" + +long_owner="$(sed -n 's/^owner=//p' "$long_manifest")" +endurance_run_id="$(sed -n 's/^run_id=//p' "$endurance_manifest")" +[ -n "$long_owner" ] || die "long-lived ownership marker is missing" +[ -n "$endurance_run_id" ] || die "endurance ownership marker is missing" +endurance_owner="dory-endurance-$endurance_run_id" +long_leftovers="$(bounded 15 docker_e ps -aq \ + --filter "label=dev.dory.long-lived=$long_owner")" \ + || die "could not verify long-lived fixture cleanup" +[ -z "$long_leftovers" ] || die "long-lived fixture container survived: $long_leftovers" +endurance_containers="$(bounded 15 docker_e ps -aq \ + --filter "label=dev.dory.endurance=$endurance_owner")" \ + || die "could not verify endurance container cleanup" +endurance_volumes="$(bounded 15 docker_e volume ls -q \ + --filter "label=dev.dory.endurance=$endurance_owner")" \ + || die "could not verify endurance volume cleanup" +endurance_networks="$(bounded 15 docker_e network ls -q \ + --filter "label=dev.dory.endurance=$endurance_owner")" \ + || die "could not verify endurance network cleanup" +[ -z "$endurance_containers$endurance_volumes$endurance_networks" ] \ + || die "endurance-owned Docker objects survived cleanup" + +HOME="$ENGINE_HOME" "$RUNTIME" stop > "$WORKDIR/evidence/runtime-stop.log" 2>&1 +ENGINE_STARTED=0 +for _ in $(seq 1 300); do + [ ! -S "$SOCKET" ] && [ -z "$(owned_engine_pids)" ] && break + sleep 0.1 +done +[ ! -S "$SOCKET" ] || die "isolated candidate engine socket survived shutdown" +remaining_pids="$(owned_engine_pids)" +[ -z "$remaining_pids" ] \ + || die "isolated candidate engine helpers survived shutdown: $remaining_pids" +# Both roots normally live under the dedicated runner's persistent HOME. Move the immutable gate +# trees after engine shutdown so qualification does not briefly duplicate several GiB of evidence. +mv "$ENGINE_HOME/gate-evidence/long-lived" "$WORKDIR/evidence/long-lived" +mv "$ENGINE_HOME/gate-evidence/endurance" "$WORKDIR/evidence/endurance" +mv "$ENGINE_HOME/gate-evidence/competitor-runtime" "$WORKDIR/evidence/competitor-runtime" +mv "$ENGINE_HOME/gate-evidence/bind-file-coherence" "$WORKDIR/evidence/bind-file-coherence" +mv "$ENGINE_HOME/gate-evidence/bind-advisory-lock" "$WORKDIR/evidence/bind-advisory-lock" +mv "$ENGINE_HOME/gate-evidence/ssh-agent" "$WORKDIR/evidence/ssh-agent" +mv "$ENGINE_HOME/gate-evidence/testcontainers" "$WORKDIR/evidence/testcontainers" +mv "$ENGINE_HOME/gate-evidence/devcontainers" "$WORKDIR/evidence/devcontainers" +mv "$ENGINE_HOME/gate-evidence/act" "$WORKDIR/evidence/act" +mv "$ENGINE_HOME/gate-evidence/localstack" "$WORKDIR/evidence/localstack" +mv "$ENGINE_HOME/gate-evidence/tilt" "$WORKDIR/evidence/tilt" +mv "$ENGINE_HOME/gate-evidence/supabase" "$WORKDIR/evidence/supabase" +mv "$ENGINE_HOME/gate-evidence/kubernetes-tooling" "$WORKDIR/evidence/kubernetes-tooling" +mv "$ENGINE_HOME/gate-evidence/migration" "$WORKDIR/evidence/migration" + +final_validated_source_commit="$(python3 scripts/validate-release-metadata.py \ + "$BUILD_DIR" "$VERSION" "$BUILD")" \ + || die "signed release metadata changed during qualification" +[ "$final_validated_source_commit" = "$SOURCE_COMMIT" ] \ + || die "signed release metadata source authority changed during qualification" +[ "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" ] \ + && git diff --quiet -- scripts .github/scripts \ + && git diff --cached --quiet -- scripts .github/scripts \ + || die "qualification harness authority changed during qualification" +[ "$(shasum -a 256 scripts/qualify-release-candidate.sh | awk '{print $1}')" \ + = "$QUALIFIER_SHA256" ] \ + && [ "$(shasum -a 256 scripts/validate-release-metadata.py | awk '{print $1}')" \ + = "$METADATA_VALIDATOR_SHA256" ] \ + || die "qualification harness bytes changed during qualification" +verify_candidate_digest() { + local path="$1" expected="$2" label="$3" + [ "$(shasum -a 256 "$path" | awk '{print $1}')" = "$expected" ] \ + || die "$label changed during qualification" +} +verify_candidate_digest "$APP_EXECUTABLE" "$CANDIDATE_APP_EXECUTABLE_SHA" "app executable" +verify_candidate_digest "$DORYD" "$CANDIDATE_DORYD_SHA" "doryd helper" +verify_candidate_digest "$DORY_VMM" "$CANDIDATE_DORY_VMM_SHA" "dory-vmm helper" +verify_candidate_digest "$RUNTIME_DIR/bin/dory-hv" "$CANDIDATE_DORY_HV_SHA" "dory-hv helper" +verify_candidate_digest "$RUNTIME_DIR/bin/gvproxy" "$CANDIDATE_GVPROXY_SHA" "gvproxy helper" +verify_candidate_digest "$DATAPLANE" "$CANDIDATE_DATAPLANE_SHA" "dataplane helper" +verify_candidate_digest "$DOCKER" "$CANDIDATE_DOCKER_SHA" "Docker CLI" +verify_candidate_digest "$COMPOSE" "$CANDIDATE_COMPOSE_SHA" "Compose helper" +verify_candidate_digest "$BUILDX" "$CANDIDATE_BUILDX_SHA" "Buildx helper" +verify_candidate_digest "$KUBECTL" "$CANDIDATE_KUBECTL_SHA" "kubectl component" +verify_candidate_digest "$KERNEL" "$CANDIDATE_KERNEL_SHA" "engine kernel" +verify_candidate_digest "$ROOTFS" "$CANDIDATE_ROOTFS_SHA" "engine rootfs" +verify_candidate_digest "$GUEST_AGENT" "$CANDIDATE_GUEST_AGENT_SHA" "guest agent" + +manifest_sha="$(shasum -a 256 "$MANIFEST" | awk '{print $1}')" +update_sha="$(shasum -a 256 "$UPDATE_ZIP" | awk '{print $1}')" +component_catalog_sha="$(shasum -a 256 "$COMPONENT_CATALOG" | awk '{print $1}')" +component_catalog_digest_sha="$(shasum -a 256 \ + "$BUILD_DIR/components/arm64/catalog.json.sha256" | awk '{print $1}')" +component_catalog_signature_sha="$(shasum -a 256 \ + "$BUILD_DIR/components/arm64/catalog.json.sig" | awk '{print $1}')" +runtime_sha="$(shasum -a 256 "$RUNTIME_TAR" | awk '{print $1}')" +sbom_sha="$(shasum -a 256 "$BUILD_DIR/Dory-$VERSION.cdx.json" | awk '{print $1}')" +host_facts_sha="$(shasum -a 256 "$WORKDIR/evidence/host-facts.txt" | awk '{print $1}')" +CANDIDATE_BINDING="$WORKDIR/evidence/candidate-binding.txt" +cat > "$CANDIDATE_BINDING" < "$WORKDIR/evidence/evidence-sha256.txt" +evidence_sha="$(shasum -a 256 "$WORKDIR/evidence/evidence-sha256.txt" | awk '{print $1}')" +completed_epoch="$(date +%s)" +release_qualifying=true +development_unnotarized=false +python3 - \ + "$WORKDIR/qualification.complete.json.partial" \ + "$release_qualifying" "$development_unnotarized" \ + "$VERSION" "$BUILD" "$SOURCE_COMMIT" "$RUN_ID" "$RUN_ATTEMPT" \ + "$manifest_sha" "$update_sha" "$component_catalog_sha" "$runtime_sha" "$evidence_sha" \ + "$ENDURANCE_DURATION" "$LONG_DURATION" "$TESTCONTAINERS_VERSION" \ + "$DEVCONTAINERS_VERSION" "$ACT_VERSION" "$LOCALSTACK_IMAGE" "$TILT_VERSION" \ + "$SUPABASE_VERSION" "$K3S_IMAGE" "$K8S_WORKLOAD_IMAGE" "$SKAFFOLD_VERSION" \ + "$LOCK_IMAGE" "$bind_lock_docker_sha256" "$guest_agent_expected_sha256" \ + "$IMAGE" "$REGISTRY_IMAGE" "$SSH_CLIENT_IMAGE" "$TESTCONTAINERS_RYUK_IMAGE" \ + "$ACT_RUNNER_IMAGE" "$candidate_binding_sha" "$component_catalog_signature_sha" \ + "$QUALIFIER_SHA256" "$METADATA_VALIDATOR_SHA256" "$completed_epoch" <<'PY' +import json +import sys + +( + output, release_qualifying, development_unnotarized, version, build, source_commit, + run_id, run_attempt, manifest_sha, update_sha, component_catalog_sha, runtime_sha, evidence_sha, + endurance_duration, long_duration, testcontainers_version, devcontainers_version, + act_version, localstack_image, tilt_version, supabase_version, k3s_image, + k8s_workload_image, skaffold_version, bind_lock_image, bind_lock_docker_sha256, + guest_agent_sha256, fixture_image, registry_image, ssh_client_image, + testcontainers_ryuk_image, act_runner_image, candidate_binding_sha, + component_catalog_signature_sha, qualifier_sha, metadata_validator_sha, completed_epoch, +) = sys.argv[1:] +payload = { + "schemaVersion": 2, + "kind": "dev.dory.release-qualification", + "status": "PASS", + "releaseQualifying": release_qualifying == "true", + "developmentUnnotarized": development_unnotarized == "true", + "dataDiskGrowthGate": "PASS", + "managedDataDriveGate": "PASS", + "dataDriveVolumeIdentityGate": "PASS", + "offlineBundledBootGate": "PASS", + "defaultPlatformImageGate": "PASS", + "pruneSafetyGate": "PASS", + "privateRegistryAuthGate": "PASS", + "privateRegistryImage": registry_image, + "nonnativeNixGCGate": "PASS", + "nonnativeArchPacmanGate": "PASS", + "nonnativeMmdebstrapGate": "PASS", + "nonnativeExecConformanceGate": "PASS", + "ecrRegistryRetryGate": "PASS", + "bindFileCoherenceGate": "PASS", + "powerAssertion": "PASS", + "gvproxyQEMUSwitchGate": "PASS", + "nativeIPv6Gate": "PASS", + "migrationGate": "PASS", + "competitorRuntimeGate": "PASS", + "machineToDockerLongLivedGate": "PASS", + "machineOutboundLongLivedGate": "PASS", + "standaloneSupervisorRecoveryGate": "PASS", + "bindAdvisoryLockGate": "PASS", + "sshAgentForwardingGate": "PASS", + "sshAgentImage": ssh_client_image, + "bindAdvisoryLockImage": bind_lock_image, + "bindAdvisoryLockDockerCLI_SHA256": bind_lock_docker_sha256, + "guestAgentBootConfigGate": "PASS", + "guestAgentSha256": guest_agent_sha256, + "fixtureImage": fixture_image, + "testcontainersGate": "PASS", + "testcontainersVersion": testcontainers_version, + "testcontainersRyukImage": testcontainers_ryuk_image, + "devcontainersGate": "PASS", + "devcontainersVersion": devcontainers_version, + "actGate": "PASS", + "actVersion": act_version, + "actRunnerImage": act_runner_image, + "localstackGate": "PASS", + "localstackImage": localstack_image, + "tiltGate": "PASS", + "tiltVersion": tilt_version, + "supabaseGate": "PASS", + "supabaseVersion": supabase_version, + "kubernetesToolingGate": "PASS", + "k3sImage": k3s_image, + "kubernetesWorkloadImage": k8s_workload_image, + "skaffoldVersion": skaffold_version, + "version": version, + "build": build, + "sourceCommit": source_commit, + "githubRunId": run_id, + "githubRunAttempt": run_attempt, + "candidateManifestSha256": manifest_sha, + "appUpdateSha256": update_sha, + "componentCatalogSha256": component_catalog_sha, + "componentCatalogSchemaVersion": 2, + "componentCatalogSignatureSha256": component_catalog_signature_sha, + "runtimeSha256": runtime_sha, + "candidateBindingSha256": candidate_binding_sha, + "qualificationHarnessSha256": qualifier_sha, + "metadataValidatorSha256": metadata_validator_sha, + "evidenceManifestSha256": evidence_sha, + "enduranceDurationSeconds": int(endurance_duration), + "longLivedDurationSeconds": int(long_duration), + "completedEpoch": int(completed_epoch), +} +with open(output, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") +PY +mv "$WORKDIR/qualification.complete.json.partial" "$WORKDIR/qualification.complete.json" + +# Retain only immutable evidence and its small completion record between workflow jobs. Candidate +# artifacts are re-downloaded and rehashed by the publication job using fresh credentials. +rm -rf "$WORKDIR/extracted-app" "$WORKDIR/extracted-runtime" "$WORKDIR/docker-config" \ + "$WORKDIR/docker" "$ENGINE_HOME" +stop_power_assertion +trap - EXIT INT TERM +echo "release qualification PASS: $WORKDIR/qualification.complete.json" diff --git a/scripts/validate-release-metadata.py b/scripts/validate-release-metadata.py index 5e33bd24..81c110ae 100755 --- a/scripts/validate-release-metadata.py +++ b/scripts/validate-release-metadata.py @@ -148,6 +148,8 @@ def validate_catalog( catalog_path = component_dir / "catalog.json" digest_path = component_dir / "catalog.json.sha256" signature_path = component_dir / "catalog.json.sig" + for path in (catalog_path, digest_path, signature_path): + require(path.is_file() and not path.is_symlink(), f"component catalog input is missing or indirect: {path.name}") catalog_bytes = catalog_path.read_bytes() require(0 < len(catalog_bytes) <= 2 * 1_024 * 1_024, "component catalog size is invalid") expected_digest = hashlib.sha256(catalog_bytes).hexdigest() @@ -295,6 +297,7 @@ def validate_catalog( def validate_manifest(build_dir: pathlib.Path, version: str, build: str) -> tuple[dict, str]: path = build_dir / "release-manifest.json" + require(path.is_file() and not path.is_symlink(), "release manifest is missing or indirect") manifest = json.loads( path.read_text(encoding="utf-8"), object_pairs_hook=unique_json_object, @@ -343,7 +346,7 @@ def validate_manifest(build_dir: pathlib.Path, version: str, build: str) -> tupl require(record["path"] == expected_path, f"manifest path is not portable: {name}") require(isinstance(record["kind"], str) and record["kind"], f"manifest kind is invalid: {name}") artifact = build_dir / record["path"] - require(artifact.is_file(), f"manifest artifact is missing: {name}") + require(artifact.is_file() and not artifact.is_symlink(), f"manifest artifact is missing or indirect: {name}") require(record["bytes"] == artifact.stat().st_size, f"manifest byte count mismatch: {name}") require(record["sha256"] == sha256_file(artifact), f"manifest SHA-256 mismatch: {name}") require(by_name[f"Dory-{version}.cdx.json"]["kind"] == "cyclonedx-json", "SBOM artifact kind mismatch") @@ -453,6 +456,8 @@ def main() -> None: if len(sys.argv) != 4: raise SystemExit("usage: validate-release-metadata.py ") build_dir = pathlib.Path(sys.argv[1]) + require(build_dir.is_dir() and not build_dir.is_symlink(), "build directory is missing or indirect") + require(build_dir.resolve() == build_dir.absolute(), "build directory must be canonical") version, build = sys.argv[2:] _, source_commit = validate_manifest(build_dir, version, build) validate_appcast( From bcec41af2617b3929910dfb137775645ec1f0022 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:53:13 +0000 Subject: [PATCH 150/338] feat(release): verify durable qualification --- .../test-release-qualification-verifier.py | 119 ++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/validate-release-qualification.py | 381 ++++++ scripts/verify-release-qualification.sh | 1122 +++++++++++++++++ 5 files changed, 1624 insertions(+) create mode 100644 .github/scripts/test-release-qualification-verifier.py create mode 100644 scripts/validate-release-qualification.py create mode 100755 scripts/verify-release-qualification.sh diff --git a/.github/scripts/test-release-qualification-verifier.py b/.github/scripts/test-release-qualification-verifier.py new file mode 100644 index 00000000..76e427ae --- /dev/null +++ b/.github/scripts/test-release-qualification-verifier.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Offline contract tests for durable release-qualification verification.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import pathlib +import re +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SHELL_VERIFIER = ROOT / "scripts/verify-release-qualification.sh" +AUTHORITY_VALIDATOR = ROOT / "scripts/validate-release-qualification.py" + + +def load_validator(): + spec = importlib.util.spec_from_file_location("release_qualification_validator", AUTHORITY_VALIDATOR) + if spec is None or spec.loader is None: + raise RuntimeError("could not load release qualification validator") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class ReleaseQualificationVerifierTests(unittest.TestCase): + def test_verifier_is_explicit_schema_two_fail_closed_authority(self) -> None: + subprocess.run(["bash", "-n", str(SHELL_VERIFIER)], check=True) + subprocess.run([sys.executable, "-m", "py_compile", str(AUTHORITY_VALIDATOR)], check=True) + shell = SHELL_VERIFIER.read_text(encoding="utf-8") + validator = AUTHORITY_VALIDATOR.read_text(encoding="utf-8") + for proof in ( + "validate-release-qualification.py", + "durable schema-2 qualification authority is invalid", + "checked-out source does not match --source-commit", + "tracked qualification verifier differs from the checked-out source commit", + "qualification verifier bytes changed during verification", + "durable qualification authority changed during semantic verification", + "testcontainers_version=", + "ryuk_image=", + "runner_image=", + ): + self.assertIn(proof, shell, proof) + self.assertEqual(shell.count("python3 scripts/validate-release-qualification.py"), 2) + for proof in ( + '"schemaVersion"', + '"dev.dory.release-qualification"', + '"candidateBindingSha256"', + '"componentCatalogSchemaVersion"', + '"componentCatalogSignatureSha256"', + "validate_evidence_manifest", + "evidence manifest does not cover the exact retained evidence set", + "candidate binding shape is invalid", + "qualificationHarnessSha256", + "metadataValidatorSha256", + "testcontainersRyukImage", + "actRunnerImage", + ): + self.assertIn(proof, validator, proof) + self.assertNotRegex(shell, r"\bassert\s") + self.assertNotRegex(validator, r"\bassert\s") + self.assertNotIn('schemaVersion"] == 1', shell) + self.assertNotIn('schemaVersion"] == 1', validator) + + references = set( + re.findall(r"(? None: + validator = load_validator() + with tempfile.TemporaryDirectory() as temporary: + qualification = pathlib.Path(temporary) + evidence = qualification / "evidence" + evidence.mkdir() + retained = evidence / "retained.txt" + retained.write_text("safe evidence\n", encoding="utf-8") + digest = hashlib.sha256(retained.read_bytes()).hexdigest() + manifest = evidence / "evidence-sha256.txt" + manifest.write_text(f"{digest} evidence/retained.txt\n", encoding="ascii") + self.assertEqual(validator.validate_evidence_manifest(qualification), manifest) + + indirect = evidence / "indirect.txt" + indirect.symlink_to(retained) + with self.assertRaisesRegex(ValueError, "contains a symlink"): + validator.validate_evidence_manifest(qualification) + indirect.unlink() + + unlisted = evidence / "unlisted.txt" + unlisted.write_text("not authenticated\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "exact retained evidence set"): + validator.validate_evidence_manifest(qualification) + + def test_completion_and_candidate_binding_shapes_are_closed(self) -> None: + validator = load_validator() + self.assertEqual(len(validator.COMPLETION_KEYS), 71) + self.assertEqual(len(validator.BINDING_KEYS), 36) + self.assertIn("componentCatalogSignatureSha256", validator.COMPLETION_KEYS) + self.assertIn("candidateBindingSha256", validator.COMPLETION_KEYS) + self.assertIn("component_catalog_digest_file_sha256", validator.BINDING_KEYS) + self.assertIn("host_facts_sha256", validator.BINDING_KEYS) + self.assertIn("qualifier_sha256", validator.BINDING_KEYS) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 931a522c..555fa1e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -374,6 +374,7 @@ jobs: python3 .github/scripts/test-long-lived-network-soak.py python3 .github/scripts/test-endurance-reliability-soak.py python3 .github/scripts/test-release-candidate-qualifier.py + python3 .github/scripts/test-release-qualification-verifier.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2dbf2ffc..2ce2d386 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -75,6 +75,7 @@ jobs: python3 .github/scripts/test-long-lived-network-soak.py python3 .github/scripts/test-endurance-reliability-soak.py python3 .github/scripts/test-release-candidate-qualifier.py + python3 .github/scripts/test-release-qualification-verifier.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler diff --git a/scripts/validate-release-qualification.py b/scripts/validate-release-qualification.py new file mode 100644 index 00000000..350604a8 --- /dev/null +++ b/scripts/validate-release-qualification.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Validate durable qualification authority against freshly downloaded release bytes.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import re +import subprocess +import sys +import tarfile +import zipfile + + +SHA256 = re.compile(r"[0-9a-f]{64}") +DIGEST_IMAGE = re.compile(r".+@sha256:[0-9a-f]{64}") +COMPLETION_KEYS = { + "schemaVersion", "kind", "status", "releaseQualifying", "developmentUnnotarized", + "dataDiskGrowthGate", "managedDataDriveGate", "dataDriveVolumeIdentityGate", + "offlineBundledBootGate", "defaultPlatformImageGate", "pruneSafetyGate", + "privateRegistryAuthGate", "privateRegistryImage", "nonnativeNixGCGate", + "nonnativeArchPacmanGate", "nonnativeMmdebstrapGate", "nonnativeExecConformanceGate", + "ecrRegistryRetryGate", "bindFileCoherenceGate", "powerAssertion", + "gvproxyQEMUSwitchGate", "nativeIPv6Gate", "migrationGate", "competitorRuntimeGate", + "machineToDockerLongLivedGate", "machineOutboundLongLivedGate", + "standaloneSupervisorRecoveryGate", "bindAdvisoryLockGate", "sshAgentForwardingGate", + "sshAgentImage", "bindAdvisoryLockImage", "bindAdvisoryLockDockerCLI_SHA256", + "guestAgentBootConfigGate", "guestAgentSha256", "fixtureImage", "testcontainersGate", + "testcontainersVersion", "testcontainersRyukImage", "devcontainersGate", + "devcontainersVersion", "actGate", "actVersion", "actRunnerImage", "localstackGate", + "localstackImage", "tiltGate", "tiltVersion", "supabaseGate", "supabaseVersion", + "kubernetesToolingGate", "k3sImage", "kubernetesWorkloadImage", "skaffoldVersion", + "version", "build", "sourceCommit", "githubRunId", "githubRunAttempt", + "candidateManifestSha256", "appUpdateSha256", "componentCatalogSha256", + "componentCatalogSchemaVersion", "componentCatalogSignatureSha256", "runtimeSha256", + "candidateBindingSha256", "qualificationHarnessSha256", "metadataValidatorSha256", + "evidenceManifestSha256", "enduranceDurationSeconds", "longLivedDurationSeconds", + "completedEpoch", +} +BINDING_KEYS = { + "schema_version", "kind", "version", "build", "source_commit", + "release_manifest_sha256", "app_update_sha256", "runtime_archive_sha256", + "sbom_sha256", "component_catalog_schema", "component_catalog_sha256", + "component_catalog_digest_file_sha256", "component_catalog_signature_sha256", + "app_executable_sha256", "doryd_sha256", "dory_vmm_sha256", "dory_hv_sha256", + "gvproxy_sha256", "gvproxy_build_sha256", "dataplane_sha256", "docker_cli_sha256", + "compose_sha256", "buildx_sha256", "kubectl_sha256", "kernel_sha256", + "rootfs_sha256", "guest_agent_sha256", "host_facts_sha256", "qualifier_sha256", + "metadata_validator_sha256", "developer_id", "notarization", "stapling", + "gatekeeper", "sparkle", "status", +} +PASS_FIELDS = { + "status", "dataDiskGrowthGate", "managedDataDriveGate", "dataDriveVolumeIdentityGate", + "offlineBundledBootGate", "defaultPlatformImageGate", "pruneSafetyGate", + "privateRegistryAuthGate", "nonnativeNixGCGate", "nonnativeArchPacmanGate", + "nonnativeMmdebstrapGate", "nonnativeExecConformanceGate", "ecrRegistryRetryGate", + "bindFileCoherenceGate", "powerAssertion", "gvproxyQEMUSwitchGate", "nativeIPv6Gate", + "migrationGate", "competitorRuntimeGate", "machineToDockerLongLivedGate", + "machineOutboundLongLivedGate", "standaloneSupervisorRecoveryGate", + "bindAdvisoryLockGate", "guestAgentBootConfigGate", "sshAgentForwardingGate", + "testcontainersGate", "devcontainersGate", "actGate", "localstackGate", "tiltGate", + "supabaseGate", "kubernetesToolingGate", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def unique_json(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, f"JSON contains duplicate key: {key}") + result[key] = value + return result + + +def load_json(path: pathlib.Path) -> object: + require(path.is_file() and not path.is_symlink(), f"input is missing or indirect: {path}") + require(path.stat().st_size <= 2 * 1024 * 1024, f"JSON input is too large: {path.name}") + return json.loads(path.read_bytes(), object_pairs_hook=unique_json) + + +def direct_canonical_directory(value: str, label: str) -> pathlib.Path: + path = pathlib.Path(value) + require(path.is_absolute(), f"{label} must be absolute") + require(path.is_dir() and not path.is_symlink(), f"{label} is missing or indirect") + require(path.resolve() == path, f"{label} must be canonical") + return path + + +def parse_properties(path: pathlib.Path) -> dict[str, str]: + require(path.is_file() and not path.is_symlink(), f"evidence is missing or indirect: {path}") + result: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + require("=" in line, f"evidence property is malformed: {path.name}") + key, value = line.split("=", 1) + require(key and key not in result, f"evidence property is duplicate or empty: {key}") + result[key] = value + return result + + +def exactly_one(root: pathlib.Path, directory: str, name: str) -> pathlib.Path: + base = root / "evidence" / directory + require(base.is_dir() and not base.is_symlink(), f"evidence directory is missing: {directory}") + matches = [path for path in base.rglob(name) if path.is_file() and not path.is_symlink()] + require(len(matches) == 1, f"expected exactly one {directory}/{name} evidence file") + return matches[0] + + +def validate_evidence_manifest(qualification: pathlib.Path) -> pathlib.Path: + evidence = qualification / "evidence" + manifest = evidence / "evidence-sha256.txt" + require(evidence.is_dir() and not evidence.is_symlink(), "evidence directory is missing or indirect") + require(manifest.is_file() and not manifest.is_symlink(), "evidence digest manifest is missing or indirect") + for path in evidence.rglob("*"): + require(not path.is_symlink(), f"qualification evidence contains a symlink: {path.relative_to(evidence)}") + records: list[tuple[str, str]] = [] + seen: set[str] = set() + pattern = re.compile(r"([0-9a-f]{64}) (evidence/[A-Za-z0-9._/+@=-]+)") + for line in manifest.read_text(encoding="ascii").splitlines(): + match = pattern.fullmatch(line) + require(match is not None, "evidence digest manifest contains a malformed record") + digest, relative = match.groups() + require(relative not in seen, f"evidence digest manifest repeats {relative}") + require(relative != "evidence/evidence-sha256.txt", "evidence manifest cannot authenticate itself") + seen.add(relative) + records.append((relative, digest)) + require(records and [item[0] for item in records] == sorted(seen), "evidence manifest is empty or unsorted") + actual = { + path.relative_to(qualification).as_posix() + for path in evidence.rglob("*") + if path.is_file() and path != manifest + } + require(seen == actual, "evidence manifest does not cover the exact retained evidence set") + for relative, expected in records: + path = qualification / relative + require(path.is_file() and not path.is_symlink(), f"evidence member is missing or indirect: {relative}") + require(sha256_file(path) == expected, f"evidence digest mismatch: {relative}") + return manifest + + +def zip_member_digest(archive: pathlib.Path, name: str) -> str: + with zipfile.ZipFile(archive) as candidate: + matches = [item for item in candidate.infolist() if item.filename == name] + require(len(matches) == 1 and not matches[0].is_dir(), f"candidate ZIP member is missing or duplicate: {name}") + mode = (matches[0].external_attr >> 16) & 0o170000 + require(mode != 0o120000, f"candidate ZIP member is a symlink: {name}") + with candidate.open(matches[0]) as handle: + digest = hashlib.sha256() + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def zip_member_bytes(archive: pathlib.Path, name: str) -> bytes: + with zipfile.ZipFile(archive) as candidate: + matches = [item for item in candidate.infolist() if item.filename == name] + require(len(matches) == 1 and not matches[0].is_dir(), f"candidate ZIP member is missing or duplicate: {name}") + mode = (matches[0].external_attr >> 16) & 0o170000 + require(mode != 0o120000, f"candidate ZIP member is a symlink: {name}") + return candidate.read(matches[0]) + + +def tar_suffix_digest(archive: pathlib.Path, suffix: str) -> str: + with tarfile.open(archive, "r:gz") as candidate: + matches = [item for item in candidate.getmembers() if item.name.endswith(suffix)] + require(len(matches) == 1 and matches[0].isfile(), f"runtime member is missing, indirect, or duplicate: {suffix}") + handle = candidate.extractfile(matches[0]) + require(handle is not None, f"runtime member cannot be read: {suffix}") + with handle: + digest = hashlib.sha256() + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def catalog_kubectl_digest(build_dir: pathlib.Path) -> str: + catalog = load_json(build_dir / "components/arm64/catalog.json") + require(isinstance(catalog, dict), "component catalog is not an object") + components = catalog.get("components") + require(isinstance(components, list), "component catalog components are invalid") + matches = [item for item in components if isinstance(item, dict) and item.get("id") == "kubernetes"] + require(len(matches) == 1, "component catalog must contain one Kubernetes component") + assets = matches[0].get("assets") + require(isinstance(assets, list) and len(assets) == 1, "Kubernetes component assets are invalid") + asset = assets[0] + require(isinstance(asset, dict) and asset.get("path") == "kubectl", "Kubernetes component must contain kubectl") + url = asset.get("url") + require(isinstance(url, str), "kubectl component URL is invalid") + name = pathlib.PurePosixPath(url.split("?", 1)[0]).name + require(name and "/" not in name, "kubectl component filename is invalid") + path = build_dir / "components/arm64" / name + require(path.is_file() and not path.is_symlink(), "kubectl component is missing or indirect") + return sha256_file(path) + + +def verify_metadata(repo: pathlib.Path, build_dir: pathlib.Path, version: str, build: str, source: str) -> None: + validator = repo / "scripts/validate-release-metadata.py" + require(validator.is_file() and not validator.is_symlink(), "release metadata validator is missing or indirect") + completed = subprocess.run( + [sys.executable, str(validator), str(build_dir), version, build], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + require(completed.returncode == 0, f"signed schema-2 release metadata is invalid: {completed.stderr.strip()}") + require(completed.stdout.strip() == source, "signed release metadata belongs to another source commit") + + +def validate( + build_dir_value: str, + qualification_value: str, + version: str, + build: str, + source: str, + run_id: str, + run_attempt: str, + primary_sha: str, +) -> None: + repo = pathlib.Path(__file__).resolve().parent.parent + require(pathlib.Path(__file__).is_file() and not pathlib.Path(__file__).is_symlink(), "qualification validator is indirect") + build_dir = direct_canonical_directory(build_dir_value, "build directory") + qualification = direct_canonical_directory(qualification_value, "qualification directory") + require(re.fullmatch(r"[0-9a-f]{40}", source) is not None, "source commit is invalid") + require(SHA256.fullmatch(primary_sha) is not None, "primary SHA-256 is invalid") + verify_metadata(repo, build_dir, version, build, source) + evidence_manifest = validate_evidence_manifest(qualification) + + completion_path = qualification / "qualification.complete.json" + completion = load_json(completion_path) + require(isinstance(completion, dict) and set(completion) == COMPLETION_KEYS, "qualification completion shape is invalid") + require(completion["schemaVersion"] == 2, "qualification must use schema 2") + require(completion["kind"] == "dev.dory.release-qualification", "qualification kind is invalid") + require(completion["releaseQualifying"] is True, "qualification used development exceptions") + require(completion["developmentUnnotarized"] is False, "qualification skipped notarization") + for field in PASS_FIELDS: + require(completion[field] == "PASS", f"qualification did not pass {field}") + + manifests = { + name: parse_properties(exactly_one(qualification, directory, "manifest.txt")) + for name, directory in { + "private": "private-registry-auth", "lock": "bind-advisory-lock", + "agent": "guest-agent", "ssh": "ssh-agent", "testcontainers": "testcontainers", + "devcontainers": "devcontainers", "act": "act", "localstack": "localstack", + "tilt": "tilt", "supabase": "supabase", "kubernetes": "kubernetes-tooling", + }.items() + } + dynamic = { + "privateRegistryImage": manifests["private"].get("registry_image"), + "bindAdvisoryLockImage": manifests["lock"].get("image"), + "bindAdvisoryLockDockerCLI_SHA256": manifests["lock"].get("docker_cli_sha256"), + "guestAgentSha256": manifests["agent"].get("expected_sha256"), + "sshAgentImage": manifests["ssh"].get("image"), + "fixtureImage": manifests["private"].get("base_image"), + "testcontainersVersion": manifests["testcontainers"].get("testcontainers_version"), + "testcontainersRyukImage": manifests["testcontainers"].get("ryuk_image"), + "devcontainersVersion": manifests["devcontainers"].get("devcontainers_cli"), + "actVersion": manifests["act"].get("act_version"), + "actRunnerImage": manifests["act"].get("runner_image"), + "localstackImage": manifests["localstack"].get("localstack_image"), + "tiltVersion": manifests["tilt"].get("tilt_version"), + "supabaseVersion": manifests["supabase"].get("supabase_cli"), + "k3sImage": manifests["kubernetes"].get("k3s_image"), + "kubernetesWorkloadImage": manifests["kubernetes"].get("workload_image"), + "skaffoldVersion": manifests["kubernetes"].get("skaffold_version"), + } + for field, expected in dynamic.items(): + require(isinstance(expected, str) and expected, f"retained evidence omits {field}") + require(completion[field] == expected, f"completion record differs from retained evidence: {field}") + for field in ("privateRegistryImage", "bindAdvisoryLockImage", "sshAgentImage", "fixtureImage", + "testcontainersRyukImage", "actRunnerImage", "localstackImage", "k3sImage", + "kubernetesWorkloadImage"): + require(DIGEST_IMAGE.fullmatch(str(completion[field])) is not None, f"{field} is not digest-pinned") + + scalar_expected: dict[str, object] = { + "version": version, "build": build, "sourceCommit": source, + "githubRunId": run_id, "githubRunAttempt": run_attempt, + "candidateManifestSha256": sha256_file(build_dir / "release-manifest.json"), + "appUpdateSha256": sha256_file(build_dir / f"Dory-{version}-app-update.zip"), + "componentCatalogSha256": sha256_file(build_dir / "components/arm64/catalog.json"), + "componentCatalogSchemaVersion": 2, + "componentCatalogSignatureSha256": sha256_file(build_dir / "components/arm64/catalog.json.sig"), + "runtimeSha256": sha256_file(build_dir / f"dory-engine-{version}-arm64.tar.gz"), + "qualificationHarnessSha256": sha256_file(repo / "scripts/qualify-release-candidate.sh"), + "metadataValidatorSha256": sha256_file(repo / "scripts/validate-release-metadata.py"), + "evidenceManifestSha256": sha256_file(evidence_manifest), + } + for field, expected in scalar_expected.items(): + require(completion[field] == expected, f"qualification completion mismatch: {field}") + for field, minimum, strict in ( + ("enduranceDurationSeconds", 28800, False), + ("longLivedDurationSeconds", 86400, True), + ("completedEpoch", 0, True), + ): + value = completion[field] + require(type(value) is int and (value > minimum if strict else value >= minimum), f"{field} is invalid") + + update = build_dir / f"Dory-{version}-app-update.zip" + runtime = build_dir / f"dory-engine-{version}-arm64.tar.gz" + provenance = zip_member_bytes(update, "Dory.app/Contents/Resources/gvproxy-provenance.txt").decode("utf-8") + provenance_values = {} + for line in provenance.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + require(key not in provenance_values, f"gvproxy provenance repeats {key}") + provenance_values[key] = value + binding_path = exactly_one(qualification, "", "candidate-binding.txt") + require(binding_path == qualification / "evidence/candidate-binding.txt", "candidate binding is misplaced") + binding = parse_properties(binding_path) + require(set(binding) == BINDING_KEYS, "candidate binding shape is invalid") + require(completion["candidateBindingSha256"] == sha256_file(binding_path), "candidate binding digest changed") + expected_binding = { + "schema_version": "2", "kind": "dev.dory.release-qualification-candidate-binding", + "version": version, "build": build, "source_commit": source, + "release_manifest_sha256": sha256_file(build_dir / "release-manifest.json"), + "app_update_sha256": sha256_file(update), "runtime_archive_sha256": sha256_file(runtime), + "sbom_sha256": sha256_file(build_dir / f"Dory-{version}.cdx.json"), + "component_catalog_schema": "2", + "component_catalog_sha256": sha256_file(build_dir / "components/arm64/catalog.json"), + "component_catalog_digest_file_sha256": sha256_file(build_dir / "components/arm64/catalog.json.sha256"), + "component_catalog_signature_sha256": sha256_file(build_dir / "components/arm64/catalog.json.sig"), + "app_executable_sha256": zip_member_digest(update, "Dory.app/Contents/MacOS/Dory"), + "doryd_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/doryd"), + "dory_vmm_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/dory-vmm"), + "dory_hv_sha256": tar_suffix_digest(runtime, "/bin/dory-hv"), + "gvproxy_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/gvproxy"), + "gvproxy_build_sha256": provenance_values.get("verified_sha256"), + "dataplane_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/dory-dataplane-proxy"), + "docker_cli_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/docker"), + "compose_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/docker-compose"), + "buildx_sha256": zip_member_digest(update, "Dory.app/Contents/Helpers/docker-buildx"), + "kubectl_sha256": catalog_kubectl_digest(build_dir), + "kernel_sha256": tar_suffix_digest(runtime, "/share/dory/dory-hv-kernel-arm64.lzfse"), + "rootfs_sha256": tar_suffix_digest(runtime, "/share/dory/dory-engine-rootfs.ext4.lzfse"), + "guest_agent_sha256": tar_suffix_digest(runtime, "/share/dory/dory-agent-linux-arm64"), + "host_facts_sha256": sha256_file(qualification / "evidence/host-facts.txt"), + "qualifier_sha256": sha256_file(repo / "scripts/qualify-release-candidate.sh"), + "metadata_validator_sha256": sha256_file(repo / "scripts/validate-release-metadata.py"), + "developer_id": "PASS", "notarization": "PASS", "stapling": "PASS", + "gatekeeper": "PASS", "sparkle": "PASS", "status": "PASS", + } + for field, expected in expected_binding.items(): + require(isinstance(expected, str) and binding[field] == expected, f"candidate binding mismatch: {field}") + require(completion["guestAgentSha256"] == binding["guest_agent_sha256"], "guest-agent evidence is not candidate-bound") + require(binding["docker_cli_sha256"] == manifests["lock"].get("docker_cli_sha256"), "bind-lock evidence used another Docker CLI") + + manifest = load_json(build_dir / "release-manifest.json") + require(isinstance(manifest, dict), "release manifest is invalid") + artifacts = manifest.get("artifacts") + require(isinstance(artifacts, list), "release manifest artifact list is invalid") + primary = [item for item in artifacts if isinstance(item, dict) and item.get("name") == f"Dory-{version}.zip"] + require(len(primary) == 1 and primary[0].get("sha256") == primary_sha, "Homebrew SHA differs from signed release metadata") + + +def main() -> None: + if len(sys.argv) != 9: + raise SystemExit( + "usage: validate-release-qualification.py " + " " + ) + validate(*sys.argv[1:]) + print("release qualification authority: PASS") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError, zipfile.BadZipFile) as error: + raise SystemExit(f"release qualification error: {error}") from error diff --git a/scripts/verify-release-qualification.sh b/scripts/verify-release-qualification.sh new file mode 100755 index 00000000..950d6f6f --- /dev/null +++ b/scripts/verify-release-qualification.sh @@ -0,0 +1,1122 @@ +#!/bin/bash +# Re-verify durable long-gate evidence against freshly downloaded public candidate artifacts. +set -euo pipefail + +BUILD_DIR="" +QUALIFICATION="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +RUN_ID="" +RUN_ATTEMPT="" +PRIMARY_SHA256="" + +usage() { + cat <<'EOF' +Usage: scripts/verify-release-qualification.sh [required options] + + --build-dir DIR Freshly downloaded public candidate artifacts + --qualification DIR Durable qualification directory + --version VERSION Exact release version + --build BUILD Exact release build + --source-commit SHA Exact full Git commit + --run-id ID Exact workflow run ID + --run-attempt N Exact workflow rerun attempt + --primary-sha256 HASH Apple Silicon SHA advertised to Homebrew for Dory-VERSION.zip +EOF +} + +die() { echo "release qualification verification: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --build-dir) need_value "$1" "$#"; BUILD_DIR="$2"; shift 2 ;; + --qualification) need_value "$1" "$#"; QUALIFICATION="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --run-id) need_value "$1" "$#"; RUN_ID="$2"; shift 2 ;; + --run-attempt) need_value "$1" "$#"; RUN_ATTEMPT="$2"; shift 2 ;; + --primary-sha256|--universal-sha256) need_value "$1" "$#"; PRIMARY_SHA256="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +for pair in \ + "build-dir:$BUILD_DIR" "qualification:$QUALIFICATION" "version:$VERSION" "build:$BUILD" \ + "source-commit:$SOURCE_COMMIT" "run-id:$RUN_ID" "run-attempt:$RUN_ATTEMPT" \ + "primary-sha256:$PRIMARY_SHA256"; do + [ -n "${pair#*:}" ] || die "--${pair%%:*} is required" +done +[ -d "$BUILD_DIR" ] || die "build directory is unavailable: $BUILD_DIR" +[ -d "$QUALIFICATION" ] || die "qualification directory is unavailable: $QUALIFICATION" +[ ! -L "$BUILD_DIR" ] || die "build directory is indirect: $BUILD_DIR" +[ ! -L "$QUALIFICATION" ] || die "qualification directory is indirect: $QUALIFICATION" +BUILD_DIR_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$BUILD_DIR")" +QUALIFICATION_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$QUALIFICATION")" +BUILD_DIR="$(cd "$BUILD_DIR" && pwd -P)" +QUALIFICATION="$(cd "$QUALIFICATION" && pwd -P)" +[ "$BUILD_DIR" = "$BUILD_DIR_LOGICAL" ] || die "build directory has an indirect ancestor" +[ "$QUALIFICATION" = "$QUALIFICATION_LOGICAL" ] || die "qualification directory has an indirect ancestor" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +printf '%s\n' "$PRIMARY_SHA256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "primary Apple Silicon SHA-256 is invalid" +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +cd "$ROOT" +[ "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" ] \ + || die "checked-out source does not match --source-commit" +git diff --quiet -- scripts .github/scripts \ + || die "tracked qualification verifier differs from the checked-out source commit" +git diff --cached --quiet -- scripts .github/scripts \ + || die "staged qualification verifier differs from the checked-out source commit" +VERIFIER_SHA256="$(shasum -a 256 scripts/verify-release-qualification.sh | awk '{print $1}')" +AUTHORITY_VALIDATOR_SHA256="$(shasum -a 256 scripts/validate-release-qualification.py | awk '{print $1}')" + +UPDATE_ZIP="$BUILD_DIR/Dory-$VERSION-app-update.zip" +[ -s "$UPDATE_ZIP" ] || die "candidate app-update ZIP is missing" +RUNTIME_TAR="$BUILD_DIR/dory-engine-$VERSION-arm64.tar.gz" +[ -s "$RUNTIME_TAR" ] || die "candidate standalone runtime archive is missing" +DORY_HV_MEMBER="$(tar -tzf "$RUNTIME_TAR" | awk '/\/bin\/dory-hv$/ {count += 1; value=$0} END { + if (count == 1) print value; else exit 1 +}')" || die "candidate runtime archive does not contain one dory-hv" +DORY_HV_SHA256="$(tar -xOzf "$RUNTIME_TAR" "$DORY_HV_MEMBER" | shasum -a 256 | awk '{print $1}')" +CANDIDATE_GVPROXY_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Helpers/gvproxy | shasum -a 256 | awk '{print $1}')" +CANDIDATE_BUILDX_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Helpers/docker-buildx | shasum -a 256 | awk '{print $1}')" +CANDIDATE_DOCKER_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Helpers/docker | shasum -a 256 | awk '{print $1}')" +CANDIDATE_COMPOSE_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Helpers/docker-compose | shasum -a 256 | awk '{print $1}')" +CANDIDATE_GVPROXY_BUILD_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Resources/gvproxy-provenance.txt | awk -F= ' + $1 == "verified_sha256" { count += 1; value = $2 } + END { if (count == 1) print value; else exit 1 } + ')" || die "candidate gvproxy provenance is missing its singular build hash" +[ "$CANDIDATE_GVPROXY_BUILD_SHA" = \ + 47c278f1636736ba552de3d2f0e68409cdc968d63bc02149637e449f40274459 ] \ + || die "candidate gvproxy provenance has the wrong reproducible-build hash" +CANDIDATE_GVPROXY_INVENTORY_SHA="$(unzip -p "$UPDATE_ZIP" \ + Dory.app/Contents/Resources/dory-payload-sha256.txt | awk ' + $2 == "Contents/Helpers/gvproxy" { count += 1; value = $1 } + END { if (count == 1) print value; else exit 1 } + ')" || die "candidate payload inventory is missing its singular gvproxy hash" +[ "$CANDIDATE_GVPROXY_INVENTORY_SHA" = "$CANDIDATE_GVPROXY_SHA" ] \ + || die "candidate signed gvproxy is outside its payload inventory" + +COMPLETE="$QUALIFICATION/qualification.complete.json" +EVIDENCE_MANIFEST="$QUALIFICATION/evidence/evidence-sha256.txt" +[ -s "$COMPLETE" ] || die "completion record is missing" +[ -s "$EVIDENCE_MANIFEST" ] || die "evidence digest manifest is missing" +python3 scripts/validate-release-qualification.py \ + "$BUILD_DIR" "$QUALIFICATION" "$VERSION" "$BUILD" "$SOURCE_COMMIT" \ + "$RUN_ID" "$RUN_ATTEMPT" "$PRIMARY_SHA256" \ + || die "durable schema-2 qualification authority is invalid" + +single_evidence_file() { + local directory="$1" name="$2" matches count + [ -d "$QUALIFICATION/evidence/$directory" ] \ + || die "qualification evidence directory is missing: $directory" + matches="$(find "$QUALIFICATION/evidence/$directory" -type f -name "$name" -print)" + count="$(printf '%s\n' "$matches" | awk 'NF { count++ } END { print count + 0 }')" + [ "$count" -eq 1 ] \ + || die "expected exactly one $directory/$name evidence file, found $count" + printf '%s\n' "$matches" +} + +growth_summary="$(single_evidence_file data-disk-growth summary.txt)" +grep -qx 'status=PASS' "$growth_summary" \ + && grep -qx 'sparse_allocation=PASS' "$growth_summary" \ + && grep -qx 'discard_reclaim=PASS' "$growth_summary" \ + && grep -qx 'boot_trim_evidence=PASS' "$growth_summary" \ + && grep -qx 'named_volume_restart_persistence=PASS' "$growth_summary" \ + && grep -qx 'capacity_api=PASS' "$growth_summary" \ + && grep -qx 'running_growth_rejected=PASS' "$growth_summary" \ + && grep -qx 'forced_offline_check=PASS' "$growth_summary" \ + && grep -qx 'guest_resize_evidence=PASS' "$growth_summary" \ + && grep -qx 'explicit_capacity_growth=PASS' "$growth_summary" \ + && grep -qx 'explicit_growth_named_volume_persistence=PASS' "$growth_summary" \ + || die "retained data-disk growth evidence is not release qualifying" + +drive_summary="$(single_evidence_file managed-data-drive summary.txt)" +for proof in status fresh_drive_default first_launch_resume_before_drive \ + first_launch_resume_after_drive first_launch_identity_mismatch_rejected \ + explicit_drive_status running_drive_mismatch_rejected \ + lost_drive_identity_recovered lost_drive_identity_mismatch_rejected alternate_drive_untouched \ + unwritable_drive_rejected_cleanly missing_external_drive_rejected concurrent_attach_rejected \ + alias_concurrent_attach_rejected manifest_uuid_identity image_persistence \ + stopped_missing_selected_drive_rejected \ + container_writable_layer_persistence named_volume_persistence custom_network_persistence \ + transient_runtime_replacement durable_selection_survives_runtime_reset; do + grep -qx "$proof=PASS" "$drive_summary" \ + || die "retained managed data-drive evidence does not prove $proof" +done + +volume_identity_summary="$(single_evidence_file data-drive-volume-identity summary.txt)" +for proof in status external_volume_identity durable_selection_outside_runtime_state \ + bookmark_volume_rename_recovery missing_volume_shadow_prevention \ + same_name_wrong_volume_rejected original_volume_reaccepted; do + grep -qx "$proof=PASS" "$volume_identity_summary" \ + || die "retained data-drive volume-identity evidence does not prove $proof" +done +grep -qx "dory_hv_sha256=$DORY_HV_SHA256" "$volume_identity_summary" \ + || die "retained data-drive volume-identity evidence used the wrong dory-hv" + +offline_boot_manifest="$(single_evidence_file offline-bundled-boot manifest.txt)" +for proof in status fresh_bundled_boot cached_boot_without_bundle_sources \ + dead_proxy_environment host_tcp_dependency_absence prepared_assets_unchanged; do + grep -qx "$proof=PASS" "$offline_boot_manifest" \ + || die "retained offline bundled boot evidence does not prove $proof" +done + +default_platform_manifest="$(single_evidence_file default-platform-image manifest.txt)" +default_platform_inspect="$(single_evidence_file default-platform-image image-inspect.json)" +default_platform_images="$(single_evidence_file default-platform-image images-json.json)" +default_platform_df="$(single_evidence_file default-platform-image system-df.json)" +default_platform_uname="$(single_evidence_file default-platform-image default-run-uname.txt)" +for proof in status default_pull_without_platform single_platform_local_image \ + default_run_architecture image_list_system_df_reconciled; do + grep -qx "$proof=PASS" "$default_platform_manifest" \ + || die "retained default platform image evidence does not prove $proof" +done +grep -qx 'expected_platform=linux/arm64' "$default_platform_manifest" \ + || die "retained default image evidence does not qualify Apple Silicon" +grep -qx 'registry=docker.io' "$default_platform_manifest" \ + || die "retained default image evidence does not qualify Docker Hub manifest access" +grep -Eq '^image=.+@sha256:[0-9a-f]{64}$' "$default_platform_manifest" \ + || die "retained default image evidence is not digest-pinned" +grep -Eq '^docker_cli_sha256=[0-9a-f]{64}$' "$default_platform_manifest" \ + || die "retained default image evidence omits the Docker CLI digest" +default_inspect_size="$(sed -n 's/^inspect_size_bytes=//p' "$default_platform_manifest")" +default_list_size="$(sed -n 's/^image_list_size_bytes=//p' "$default_platform_manifest")" +default_df_size="$(sed -n 's/^system_df_size_bytes=//p' "$default_platform_manifest")" +default_size_ratio="$(sed -n 's/^inspect_to_storage_ratio_milli=//p' "$default_platform_manifest")" +[ -n "$default_inspect_size" ] && [ "$default_inspect_size" -gt 0 ] \ + && [ -n "$default_list_size" ] && [ "$default_list_size" = "$default_df_size" ] \ + && [ -n "$default_size_ratio" ] && [ "$default_size_ratio" -le 16000 ] \ + || die "retained default image storage sizes do not reconcile within Docker's definitions" +python3 - "$default_platform_manifest" "$default_platform_inspect" \ + "$default_platform_images" "$default_platform_df" "$default_platform_uname" <<'PY' +import json +import pathlib +import sys +(manifest_path, inspect_path, images_path, df_path, uname_path) = sys.argv[1:] +properties = {} +for raw in pathlib.Path(manifest_path).read_text(encoding='utf-8').splitlines(): + (key, separator, value) = raw.partition('=') + if separator: + properties[key] = value +inspect = json.loads(pathlib.Path(inspect_path).read_text(encoding='utf-8')) +if not (isinstance(inspect, list) and len(inspect) == 1): + raise SystemExit('retained image inspect is not singular') +image = inspect[0] +if not (image.get('Os') == 'linux' and image.get('Architecture') == 'arm64'): + raise SystemExit('retained default image is not linux/arm64') +image_id = image.get('Id') +inspect_size = int(image.get('Size', 0)) +images = json.loads(pathlib.Path(images_path).read_text(encoding='utf-8')) +if not (len(images) == 1 and images[0].get('Id') == image_id): + raise SystemExit('retained fresh image list is not one selected image') +list_size = int(images[0].get('Size', 0)) +system_df = json.loads(pathlib.Path(df_path).read_text(encoding='utf-8')) +df_images = system_df.get('Images') or [] +if not (len(df_images) == 1 and df_images[0].get('Id') == image_id): + raise SystemExit('retained fresh system-df is not one selected image') +df_size = int(df_images[0].get('Size', 0)) +layers_size = int(system_df.get('LayersSize', 0)) +if not (inspect_size > 0 and list_size == df_size): + raise SystemExit('retained image-list/system-df bytes disagree') +ratio = max(inspect_size, df_size) * 1000 // min(inspect_size, df_size) +if not ratio <= 16000: + raise SystemExit('retained inspect/storage definitions diverge by more than 16x') +if not df_size <= layers_size <= df_size * 2: + raise SystemExit('retained layer bytes are not attributable to the one local image') +if not pathlib.Path(uname_path).read_text(encoding='utf-8').strip() in {'aarch64', 'arm64'}: + raise SystemExit('retained default run is not arm64') +if not properties.get('image_id') == image_id: + raise SystemExit('retained image ID differs from the manifest') +if not int(properties.get('inspect_size_bytes', -1)) == inspect_size: + raise SystemExit('qualification evidence check failed') +if not int(properties.get('image_list_size_bytes', -1)) == list_size: + raise SystemExit('qualification evidence check failed') +if not int(properties.get('system_df_size_bytes', -1)) == df_size: + raise SystemExit('qualification evidence check failed') +if not int(properties.get('system_df_layers_size_bytes', -1)) == layers_size: + raise SystemExit('qualification evidence check failed') +if not int(properties.get('inspect_to_storage_ratio_milli', -1)) == ratio: + raise SystemExit('qualification evidence check failed') +PY + +prune_manifest="$(single_evidence_file prune-safety manifest.txt)" +prune_system_df="$(single_evidence_file prune-safety system-df-after.json)" +for proof in status empty_engine_precondition unfiltered_system_prune \ + unfiltered_container_prune unfiltered_image_prune unfiltered_network_prune \ + unfiltered_volume_prune unfiltered_builder_prune active_container_survived \ + active_image_survived active_volume_survived active_network_survived \ + active_volume_bytes_preserved unused_container_removed unused_image_removed \ + unused_volume_removed unused_network_removed build_cache_removed owned_cleanup; do + grep -qx "$proof=PASS" "$prune_manifest" \ + || die "retained prune-safety evidence does not prove $proof" +done +grep -Fx "source_commit=$SOURCE_COMMIT" "$prune_manifest" >/dev/null \ + || die "retained prune-safety evidence belongs to another source commit" +prune_base_image="$(sed -n 's/^base_image=//p' "$prune_manifest")" +printf '%s\n' "$prune_base_image" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "retained prune-safety fixture is not digest-pinned" +grep -qx "docker_cli_sha256=$CANDIDATE_DOCKER_SHA" "$prune_manifest" \ + || die "retained prune-safety evidence used a different Docker client" +for output in system-prune.txt container-prune.txt image-prune.txt network-prune.txt \ + volume-prune.txt builder-prune.txt; do + single_evidence_file prune-safety "$output" >/dev/null +done +python3 - "$prune_system_df" <<'PY' +import json +import pathlib +import sys +report = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')) +if not not (report.get('BuildCache') or []): + raise SystemExit('retained prune evidence contains build cache') +PY + +private_registry_manifest="$(single_evidence_file private-registry-auth manifest.txt)" +private_registry_archive="$(single_evidence_file private-registry-auth built-image.tar)" +private_registry_archive_list="$(single_evidence_file \ + private-registry-auth built-image-tar-list.txt)" +private_registry_inspect="$(single_evidence_file \ + private-registry-auth registry-image-inspect.json)" +for proof in status registry_fixture_arm64 unauthenticated_pull_rejected authenticated_login \ + authenticated_pull_run buildkit_registry_auth buildkit_secret_nonleak \ + buildkit_registry_cache_export buildkit_registry_cache_import registry_push \ + image_inspect_history image_save_load_identity image_tag_remove filtered_image_prune \ + owned_cleanup isolated_credential_cleanup; do + grep -qx "$proof=PASS" "$private_registry_manifest" \ + || die "retained private-registry evidence does not prove $proof" +done +grep -Fx "source_commit=$SOURCE_COMMIT" "$private_registry_manifest" >/dev/null \ + || die "retained private-registry evidence belongs to another source commit" +private_registry_base_image="$(sed -n 's/^base_image=//p' "$private_registry_manifest")" +private_registry_image="$(sed -n 's/^registry_image=//p' "$private_registry_manifest")" +printf '%s\n' "$private_registry_base_image" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + && printf '%s\n' "$private_registry_image" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "retained private-registry image fixtures are not digest-pinned" +[ "$prune_base_image" = "$private_registry_base_image" ] \ + || die "prune and private-registry gates used different base images" +grep -qx "docker_cli_sha256=$CANDIDATE_DOCKER_SHA" "$private_registry_manifest" \ + && grep -qx "buildx_cli_sha256=$CANDIDATE_BUILDX_SHA" "$private_registry_manifest" \ + || die "retained private-registry evidence used different candidate clients" +private_registry_archive_sha="$(shasum -a 256 "$private_registry_archive" | awk '{print $1}')" +grep -qx "archive_sha256=$private_registry_archive_sha" "$private_registry_manifest" \ + || die "retained private-registry image archive digest is wrong" +grep -qx 'manifest.json' "$private_registry_archive_list" \ + || die "retained private-registry image archive has no manifest.json" +tar -tf "$private_registry_archive" | cmp -s - "$private_registry_archive_list" \ + || die "retained private-registry archive listing differs from the archive" +private_registry_id_before="$(sed -n 's/^image_id_before=//p' "$private_registry_manifest")" +private_registry_id_after="$(sed -n 's/^image_id_after=//p' "$private_registry_manifest")" +printf '%s\n' "$private_registry_id_before" | grep -Eq '^sha256:[0-9a-f]{64}$' \ + && [ "$private_registry_id_before" = "$private_registry_id_after" ] \ + || die "retained private-registry save/load image identity differs" +if find "$(dirname "$(dirname "$private_registry_manifest")")" -type d \ + \( -name docker-config -o -name unauth-config -o -name auth \) -print -quit \ + | grep -q .; then + die "retained private-registry evidence still contains credential directories" +fi +python3 - "$private_registry_inspect" "$private_registry_image" <<'PY' +import json +import pathlib +import sys +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')) +if not (isinstance(payload, list) and len(payload) == 1): + raise SystemExit('registry image inspect is not singular') +image = payload[0] +if not (image.get('Os') == 'linux' and image.get('Architecture') == 'arm64'): + raise SystemExit('registry fixture is not linux/arm64') +digest = sys.argv[2].rsplit('@', 1)[1] +if not any((value.endswith('@' + digest) for value in image.get('RepoDigests') or [])): + raise SystemExit('registry fixture inspect does not contain the qualified digest') +PY + +nonnative_nix_manifest="$(single_evidence_file nonnative-nix-gc manifest.txt)" +nonnative_nix_output="$(single_evidence_file nonnative-nix-gc run.out)" +nonnative_nix_inspect="$(single_evidence_file nonnative-nix-gc image-inspect.json)" +for proof in status fresh_pull unreachable_store_path_created nix_collect_garbage_delete_old \ + unreachable_store_path_deleted docker_api_after_gc owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_nix_manifest" \ + || die "retained non-native Nix GC evidence does not prove $proof" +done +grep -Eq '^image=.+@sha256:[0-9a-f]{64}$' "$nonnative_nix_manifest" \ + && grep -qx 'platform=linux/amd64' "$nonnative_nix_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_nix_manifest" \ + && grep -qx 'nix_version=2.34.7' "$nonnative_nix_manifest" \ + && grep -Eq '^docker_cli_sha256=[0-9a-f]{64}$' "$nonnative_nix_manifest" \ + && grep -Eq '^run_output_sha256=[0-9a-f]{64}$' "$nonnative_nix_manifest" \ + || die "retained non-native Nix GC evidence omits exact provenance" +expected_nix_output_sha="$(sed -n 's/^run_output_sha256=//p' "$nonnative_nix_manifest")" +[ "$expected_nix_output_sha" = "$(shasum -a 256 "$nonnative_nix_output" | awk '{print $1}')" ] \ + || die "retained non-native Nix GC output digest does not match" +grep -qx 'architecture=x86_64' "$nonnative_nix_output" \ + && grep -qx 'version=nix (Nix) 2.34.7' "$nonnative_nix_output" \ + && grep -Eq '^garbage_path=/nix/store/[a-z0-9]{32}-dory-nix-garbage$' "$nonnative_nix_output" \ + && grep -qx 'gc_deleted_unreachable_path=PASS' "$nonnative_nix_output" \ + || die "retained non-native Nix GC raw output is incomplete" +python3 - "$nonnative_nix_manifest" "$nonnative_nix_inspect" <<'PY' +import json +import pathlib +import sys +(manifest, inspect_path) = map(pathlib.Path, sys.argv[1:]) +properties = dict((line.split('=', 1) for line in manifest.read_text(encoding='utf-8').splitlines() if '=' in line)) +digest = properties['image'].rsplit('@', 1)[1] +payload = json.loads(inspect_path.read_text(encoding='utf-8')) +if not (isinstance(payload, list) and len(payload) == 1): + raise SystemExit('retained Nix image inspect is not singular') +image = payload[0] +if not (image.get('Os') == 'linux' and image.get('Architecture') == 'amd64'): + raise SystemExit('retained Nix image is not linux/amd64') +if not any((value.endswith('@' + digest) for value in image.get('RepoDigests') or [])): + raise SystemExit('retained Nix image digest differs from the manifest') +PY + +nonnative_arch_manifest="$(single_evidence_file nonnative-arch-pacman manifest.txt)" +nonnative_arch_build="$(single_evidence_file nonnative-arch-pacman build.out)" +nonnative_arch_run="$(single_evidence_file nonnative-arch-pacman run.out)" +nonnative_arch_inspect="$(single_evidence_file nonnative-arch-pacman base-image-inspect.json)" +for proof in status fresh_pull pacman_default_sandbox alpm_user_switch fex_handler \ + fex_bundle_read_only fzf_inventory fzf_runtime docker_api_after_build owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_arch_manifest" \ + || die "retained non-native Arch pacman evidence does not prove $proof" +done +grep -qx 'oci_default_runtime=dory-runc' "$nonnative_arch_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_arch_manifest" \ + && grep -Eq '^fex_sha256=[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + && grep -Eq '^fex_server_sha256=[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + || die "retained non-native Arch pacman evidence omits the FEX runtime contract" +fex_pair="$(sed -n 's/^fex_sha256=//p' "$nonnative_arch_manifest"):$(sed -n 's/^fex_server_sha256=//p' "$nonnative_arch_manifest")" +case "$fex_pair" in + b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b:bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597) ;; + *) die "retained non-native Arch pacman evidence has an unverified FEX binary pair" ;; +esac +grep -Eq '^base_image=.+@sha256:[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + && grep -qx 'platform=linux/amd64' "$nonnative_arch_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_arch_manifest" \ + && grep -Eq '^docker_cli_sha256=[0-9a-f]{64}$' "$nonnative_arch_manifest" \ + || die "retained non-native Arch pacman evidence omits exact provenance" +expected_arch_build_sha="$(sed -n 's/^build_output_sha256=//p' "$nonnative_arch_manifest")" +expected_arch_run_sha="$(sed -n 's/^run_output_sha256=//p' "$nonnative_arch_manifest")" +[ "$expected_arch_build_sha" = "$(shasum -a 256 "$nonnative_arch_build" | awk '{print $1}')" ] \ + && [ "$expected_arch_run_sha" = "$(shasum -a 256 "$nonnative_arch_run" | awk '{print $1}')" ] \ + || die "retained non-native Arch pacman output digest does not match" +grep -Fq 'pacman -Sy --noconfirm fzf' "$nonnative_arch_build" \ + || die "retained Arch build did not run the competitor's exact pacman command" +if grep -Fq -- '--disable-sandbox' "$nonnative_arch_build"; then + die "retained Arch build disabled pacman's sandbox" +fi +grep -Eq '^fzf [0-9]' "$nonnative_arch_run" \ + && grep -Eq '^[0-9]+[.][0-9]+' "$nonnative_arch_run" \ + || die "retained Arch pacman package did not execute" +python3 - "$nonnative_arch_manifest" "$nonnative_arch_inspect" <<'PY' +import json +import pathlib +import sys +(manifest, inspect_path) = map(pathlib.Path, sys.argv[1:]) +properties = dict((line.split('=', 1) for line in manifest.read_text(encoding='utf-8').splitlines() if '=' in line)) +digest = properties['base_image'].rsplit('@', 1)[1] +payload = json.loads(inspect_path.read_text(encoding='utf-8')) +if not (isinstance(payload, list) and len(payload) == 1): + raise SystemExit('retained Arch inspect is not singular') +image = payload[0] +if not (image.get('Os') == 'linux' and image.get('Architecture') == 'amd64'): + raise SystemExit('retained Arch image is not linux/amd64') +if not any((value.endswith('@' + digest) for value in image.get('RepoDigests') or [])): + raise SystemExit('retained Arch digest differs from the manifest') +PY + +nonnative_mm_manifest="$(single_evidence_file nonnative-mmdebstrap manifest.txt)" +nonnative_mm_build="$(single_evidence_file nonnative-mmdebstrap build.err)" +nonnative_mm_run="$(single_evidence_file nonnative-mmdebstrap run.out)" +nonnative_mm_inspect="$(single_evidence_file nonnative-mmdebstrap base-image-inspect.json)" +for proof in status fresh_pull reported_dockerfile_commands mmdebstrap_minbase_trixie \ + bad_fd_number_absent fex_handler fex_bundle_read_only rootfs_archive_readable \ + nested_chroot_no_proc nested_chroot_shebang private_marker_isolation \ + docker_api_after_build build_cache_cleanup owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_mm_manifest" \ + || die "retained non-native mmdebstrap evidence does not prove $proof" +done +grep -qx 'orbstack_issue=2543' "$nonnative_mm_manifest" \ + && grep -qx 'platform=linux/amd64' "$nonnative_mm_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_mm_manifest" \ + && grep -qx 'oci_default_runtime=dory-runc' "$nonnative_mm_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_mm_manifest" \ + || die "retained non-native mmdebstrap evidence omits exact provenance" +mm_fex_pair="$(sed -n 's/^fex_sha256=//p' "$nonnative_mm_manifest"):$(sed -n 's/^fex_server_sha256=//p' "$nonnative_mm_manifest")" +case "$mm_fex_pair" in + b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b:bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597) ;; + *) die "retained non-native mmdebstrap evidence has an unverified FEX binary pair" ;; +esac +expected_mm_build_sha="$(sed -n 's/^build_log_sha256=//p' "$nonnative_mm_manifest")" +expected_mm_run_sha="$(sed -n 's/^run_output_sha256=//p' "$nonnative_mm_manifest")" +[ "$expected_mm_build_sha" = "$(shasum -a 256 "$nonnative_mm_build" | awk '{print $1}')" ] \ + && [ "$expected_mm_run_sha" = "$(shasum -a 256 "$nonnative_mm_run" | awk '{print $1}')" ] \ + || die "retained non-native mmdebstrap output digest does not match" +grep -Fq 'mmdebstrap --variant=minbase trixie /tmp/rootfs.tar' "$nonnative_mm_build" \ + && grep -qx 'nested_chroot_no_proc=PASS' "$nonnative_mm_run" \ + && grep -qx 'nested_chroot_shebang=PASS' "$nonnative_mm_run" \ + && grep -qx 'private_marker_isolation=PASS' "$nonnative_mm_run" \ + || die "retained non-native mmdebstrap raw evidence is incomplete" +python3 - "$nonnative_mm_manifest" "$nonnative_mm_inspect" <<'PY' +import json +import pathlib +import sys +(manifest, inspect_path) = map(pathlib.Path, sys.argv[1:]) +properties = dict((line.split('=', 1) for line in manifest.read_text().splitlines() if '=' in line)) +digest = properties['base_image'].rsplit('@', 1)[1] +payload = json.loads(inspect_path.read_text()) +if not (isinstance(payload, list) and len(payload) == 1): + raise SystemExit('qualification evidence check failed') +image = payload[0] +if not (image.get('Os') == 'linux' and image.get('Architecture') == 'amd64'): + raise SystemExit('qualification evidence check failed') +if not any((value.endswith('@' + digest) for value in image.get('RepoDigests') or [])): + raise SystemExit('qualification evidence check failed') +PY + +nonnative_exec_manifest="$(single_evidence_file nonnative-exec-conformance manifest.txt)" +nonnative_exec_build="$(single_evidence_file nonnative-exec-conformance build.err)" +nonnative_exec_seccomp="$(single_evidence_file nonnative-exec-conformance seccomp-chain.out)" +nonnative_exec_docker_exec="$(single_evidence_file nonnative-exec-conformance docker-exec.out)" +nonnative_exec_base_inspect="$(single_evidence_file nonnative-exec-conformance base-image-inspect.json)" +nonnative_exec_native_inspect="$(single_evidence_file nonnative-exec-conformance native-image-inspect.json)" +for proof in status fresh_pulls amd64_only_binfmt canonical_shebang_paths env_shebang_chain \ + private_marker_isolation guest_seccomp_inheritance fd_exec_arguments fd_exec_null_argv \ + buildkit_exec_matrix runtime_exec_matrix docker_exec_matrix docker_api_after_exec \ + build_cache_cleanup owned_cleanup; do + grep -qx "$proof=PASS" "$nonnative_exec_manifest" \ + || die "retained non-native exec evidence does not prove $proof" +done +grep -qx 'platform=linux/amd64' "$nonnative_exec_manifest" \ + && grep -qx 'architecture=x86_64' "$nonnative_exec_manifest" \ + && grep -qx 'oci_default_runtime=dory-runc' "$nonnative_exec_manifest" \ + && grep -qx 'fex_binfmt_flags=POCF' "$nonnative_exec_manifest" \ + || die "retained non-native exec evidence omits exact provenance" +exec_fex_pair="$(sed -n 's/^fex_sha256=//p' "$nonnative_exec_manifest"):$(sed -n 's/^fex_server_sha256=//p' "$nonnative_exec_manifest")" +case "$exec_fex_pair" in + b862d2a4358b102b125ae50da357b189a5d4710a3be830ef3280cba400c7099b:bbe8a34fc2ba4e606acd7e5b11d9b51da283835f40d2851e2ed39d35d28f2597) ;; + *) die "retained non-native exec evidence has an unverified FEX binary pair" ;; +esac +for evidence in \ + "build_log_sha256:$nonnative_exec_build" \ + "seccomp_output_sha256:$nonnative_exec_seccomp" \ + "docker_exec_output_sha256:$nonnative_exec_docker_exec"; do + key="${evidence%%:*}" + path="${evidence#*:}" + expected="$(sed -n "s/^${key}=//p" "$nonnative_exec_manifest")" + [ "$expected" = "$(shasum -a 256 "$path" | awk '{print $1}')" ] \ + || die "retained non-native exec digest does not match for $key" +done +for proof in fd-exec-arguments-buildkit fd-exec-null-argv-buildkit \ + seccomp-shebang-chain-buildkit; do + grep -q "$proof=PASS" "$nonnative_exec_build" \ + || die "retained BuildKit exec evidence omits $proof" +done +grep -q '^seccomp-shebang-chain-ok ' "$nonnative_exec_seccomp" \ + && grep -q '^Debian .dpkg. package management program version' "$nonnative_exec_docker_exec" \ + || die "retained runtime/docker exec output is incomplete" +python3 - "$nonnative_exec_manifest" "$nonnative_exec_base_inspect" \ + "$nonnative_exec_native_inspect" <<'PY' +import json +import pathlib +import sys +manifest = pathlib.Path(sys.argv[1]) +properties = dict((line.split('=', 1) for line in manifest.read_text().splitlines() if '=' in line)) +for (key, path, architecture) in (('base_image', sys.argv[2], 'amd64'), ('native_image', sys.argv[3], 'arm64')): + digest = properties[key].rsplit('@', 1)[1] + payload = json.loads(pathlib.Path(path).read_text()) + if not (isinstance(payload, list) and len(payload) == 1): + raise SystemExit('qualification evidence check failed') + image = payload[0] + if not (image.get('Os') == 'linux' and image.get('Architecture') == architecture): + raise SystemExit('qualification evidence check failed') + if not any((value.endswith('@' + digest) for value in image.get('RepoDigests') or [])): + raise SystemExit('qualification evidence check failed') +PY + +ecr_manifest="$(single_evidence_file ecr-registry manifest.txt)" +for proof in status authenticated_login bundled_buildx interrupted_push_progress \ + interrupted_push_nonzero resumed_blob_upload \ + repeated_manifest_put repull_run_checksum local_image_cleanup remote_tag_cleanup \ + isolated_credential_cleanup; do + grep -qx "$proof=PASS" "$ecr_manifest" \ + || die "retained managed ECR evidence does not prove $proof" +done +grep -Eq '^registry_sha256=[0-9a-f]{64}$' "$ecr_manifest" \ + && grep -Eq '^repository_sha256=[0-9a-f]{64}$' "$ecr_manifest" \ + && grep -Eq '^layer_sha256=[0-9a-f]{64}$' "$ecr_manifest" \ + && grep -Eq '^docker_cli_sha256=[0-9a-f]{64}$' "$ecr_manifest" \ + && grep -Eq '^buildx_cli_sha256=[0-9a-f]{64}$' "$ecr_manifest" \ + || die "retained managed ECR evidence omits provenance digests" +ecr_layer_mib="$(sed -n 's/^layer_mib=//p' "$ecr_manifest")" +[ -n "$ecr_layer_mib" ] && [ "$ecr_layer_mib" -ge 64 ] \ + || die "retained managed ECR retry layer is below 64 MiB" +grep -qx 'release_qualifying=true' "$offline_boot_manifest" \ + || die "retained offline bundled boot evidence is not release qualifying" +for asset in kernel_asset rootfs_asset agent_asset prepared_kernel prepared_rootfs; do + grep -Eq "^${asset}_sha256=[0-9a-f]{64}$" "$offline_boot_manifest" \ + || die "retained offline bundled boot evidence omits ${asset} digest" +done + +bind_file_manifest="$(single_evidence_file bind-file-coherence manifest.txt)" +bind_file_results="$(single_evidence_file bind-file-coherence results.tsv)" +for proof in status path_with_spaces directory_bind direct_single_file_bind \ + same_inode_shrink same_inode_grow same_inode_content_refresh atomic_replacement \ + direct_atomic_replacement_pins_inode direct_rebind_follows_replacement \ + guest_to_host_truncation; do + grep -qx "$proof=PASS" "$bind_file_manifest" \ + || die "retained bind-file coherence evidence does not prove $proof" +done +grep -qx 'direct_single_file_recreate_cycles=20' "$bind_file_manifest" \ + || die "retained bind-file coherence evidence omitted 20 direct attachment cycles" +grep -Eq '^image=.+@sha256:[0-9a-f]{64}$' "$bind_file_manifest" \ + || die "retained bind-file coherence image is not digest-pinned" +grep -Eq '^docker_cli_sha256=[0-9a-f]{64}$' "$bind_file_manifest" \ + || die "retained bind-file coherence Docker CLI digest is invalid" +expected_bind_results_sha="$(awk -F= '$1 == "results_sha256" {print $2; exit}' "$bind_file_manifest")" +[ "$expected_bind_results_sha" = "$(shasum -a 256 "$bind_file_results" | awk '{print $1}')" ] \ + || die "retained bind-file coherence result digest does not match" +python3 - "$bind_file_results" <<'PY' +import csv +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +with path.open(newline='', encoding='utf-8') as handle: + rows = list(csv.DictReader(handle, delimiter='\t')) +expected = ['initial', 'same-inode-shrink', 'same-inode-grow', 'same-inode-content', 'atomic-replacement-pinned-direct', 'direct-rebind-after-replacement', 'guest-truncate'] +if not [row['phase'] for row in rows] == expected: + raise SystemExit('unexpected bind coherence phases') +for (index, row) in enumerate(rows): + if not row['host_size'] == row['guest_directory_size']: + raise SystemExit(f"directory size divergence in {row['phase']}") + if not row['host_sha256'] == row['guest_directory_sha256']: + raise SystemExit(f"directory content divergence in {row['phase']}") + if row['phase'] == 'atomic-replacement-pinned-direct': + previous = rows[index - 1] + if not row['guest_direct_size'] == previous['guest_direct_size']: + raise SystemExit('direct bind did not retain the pre-replacement inode size') + if not row['guest_direct_sha256'] == previous['guest_direct_sha256']: + raise SystemExit('direct bind did not retain the pre-replacement inode content') + if not row['guest_direct_sha256'] != row['host_sha256']: + raise SystemExit('atomic replacement fixture did not distinguish direct and directory views') + else: + if not row['host_size'] == row['guest_direct_size']: + raise SystemExit(f"direct size divergence in {row['phase']}") + if not row['host_sha256'] == row['guest_direct_sha256']: + raise SystemExit(f"direct content divergence in {row['phase']}") +PY + +native_ipv6_manifest="$(single_evidence_file native-ipv6 manifest.txt)" +for proof in \ + status fresh_boot restart docker_bridge_ipv6 container_global_ipv6 dns_aaaa registry_aaaa \ + ipv6_tcp_loopback ipv6_localhost_publish external_ipv6_tcp; do + grep -qx "$proof=PASS" "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence does not prove $proof" +done +grep -qx 'architecture=arm64' "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence is not from Apple Silicon" +grep -qx 'gvproxy_version=v0.8.9-dory2' "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence used the wrong gvproxy derivative" +grep -qx "gvproxy_sha256=$CANDIDATE_GVPROXY_SHA" \ + "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence used the wrong gvproxy binary" +grep -qx "gvproxy_build_sha256=$CANDIDATE_GVPROXY_BUILD_SHA" \ + "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence used the wrong reproducible gvproxy build" +grep -qx 'release_qualifying=true' "$native_ipv6_manifest" \ + || die "retained native IPv6 evidence skipped real external IPv6 routing" + +qemu_switch_manifest="$(single_evidence_file gvproxy-qemu-switch manifest.txt)" +for proof in lan_to_guest guest_to_lan; do + grep -qx "$proof=PASS" "$qemu_switch_manifest" \ + || die "retained gvproxy QEMU switch evidence does not prove $proof" +done +grep -qx 'transport=qemu-unix-stream' "$qemu_switch_manifest" \ + || die "retained LAN switch evidence used the wrong transport" +grep -qx "gvproxy_sha256=$CANDIDATE_GVPROXY_SHA" \ + "$qemu_switch_manifest" \ + || die "retained LAN switch evidence used the wrong gvproxy binary" +grep -qx "gvproxy_build_sha256=$CANDIDATE_GVPROXY_BUILD_SHA" \ + "$qemu_switch_manifest" \ + || die "retained LAN switch evidence used the wrong reproducible gvproxy build" +grep -qx 'release_qualifying=true' "$qemu_switch_manifest" \ + || die "retained LAN switch evidence is not release qualifying" + +migration_manifest="$(single_evidence_file migration manifest.txt)" +for proof in \ + status production_migration_path source_baseline_restored target_baseline_restored \ + image_transfer two_named_volumes volume_64mib_checksum volume_metadata_symlink_hardlink \ + custom_network_ipam running_paused_state stopped_writable_layer fixed_port_handoff; do + grep -qx "$proof=PASS" "$migration_manifest" \ + || die "retained migration evidence does not prove $proof" +done + +competitor_results="$(single_evidence_file competitor-runtime results.tsv)" +competitor_manifest="$(single_evidence_file competitor-runtime manifest.txt)" +[ "$(wc -l < "$competitor_results" | tr -d ' ')" -gt 1 ] \ + || die "retained competitor runtime evidence has no result rows" +grep -qx "source_commit=$SOURCE_COMMIT" "$competitor_manifest" \ + || die "retained competitor runtime evidence used the wrong source commit" +for digest_key in docker_bin_sha256 compose_bin_sha256 buildx_bin_sha256 \ + dory_engine_sha256 bin_dory_hv_sha256 \ + bin_gvproxy_sha256 bin_dory_dataplane_proxy_sha256 \ + share_dory_dory_hv_kernel_arm64_lzfse_sha256 \ + share_dory_dory_engine_rootfs_ext4_lzfse_sha256 \ + share_dory_dory_agent_linux_arm64_sha256 engine_settings_sha256; do + grep -Eq "^${digest_key}=[0-9a-f]{64}$" "$competitor_manifest" \ + || die "retained competitor runtime evidence omits $digest_key" +done +grep -qx "bin_dory_hv_sha256=$DORY_HV_SHA256" "$competitor_manifest" \ + || die "retained competitor runtime evidence used the wrong dory-hv" +competitor_settings="$(single_evidence_file competitor-runtime engine-settings.txt)" +grep -qx 'amd64_enabled=1' "$competitor_settings" \ + || die "retained competitor runtime restart lost Apple Silicon amd64/FEX mode" +[ "$(shasum -a 256 "$competitor_settings" | awk '{print $1}')" = \ + "$(sed -n 's/^engine_settings_sha256=//p' "$competitor_manifest")" ] \ + || die "retained competitor engine settings do not match their digest" +competitor_docker_sha256="$(sed -n 's/^docker_bin_sha256=//p' "$competitor_manifest")" +competitor_compose_sha256="$(sed -n 's/^compose_bin_sha256=//p' "$competitor_manifest")" +competitor_buildx_sha256="$(sed -n 's/^buildx_bin_sha256=//p' "$competitor_manifest")" +[ "$competitor_compose_sha256" = "$CANDIDATE_COMPOSE_SHA" ] \ + || die "retained competitor evidence used the wrong Compose v2 helper" +[ "$competitor_buildx_sha256" = "$CANDIDATE_BUILDX_SHA" ] \ + || die "retained competitor evidence used the wrong Buildx helper" +grep -q $'\tFAIL\t' "$competitor_results" \ + && die "retained competitor runtime evidence contains a failed row" +for proof in \ + published-port-handoff host-port-collision named-signal-delivery forwarded-connection-fds concurrent-proxy-backpressure \ + missing-source-cp restart-churn compose-port-restart compose-v2-lifecycle \ + network-route-conflict network-api-lifecycle \ + network-alias-restart-ip standalone-engine-restart named-volume-empty named-volume named-volume-cp volume-api-lifecycle \ + security-opt-label seccomp-profile bind-open-create-0200 bind-mount-option-contract \ + nested-bind-subvolume bind-special-file-fail-fast bind-open-fd-stability \ + bind-hardlink-permissions healthcheck buildx-named-context buildkit-default-arg \ + image-save-stdout image-hardlink-missing-parent buildkit-large-dockerfile \ + buildkit-relative-temp-context dockerignore-layered-unignore \ + buildkit-concurrent-sessions buildkit-cache-cancellation \ + container-resolver-contract container-dns-search \ + cleanup-restart-persistence; do + awk -F '\t' -v proof="$proof" \ + '$1 == proof && $2 == "PASS" { found=1 } END { exit !found }' "$competitor_results" \ + || die "retained competitor runtime evidence does not prove $proof" +done + +supervisor_manifest="$(single_evidence_file standalone-supervisor-recovery manifest.txt)" +for proof in status healthy_pidfile_repair dead_dataplane_detected \ + incomplete_runtime_poweroff fresh_helper_pair docker_api_recovery; do + grep -qx "$proof=PASS" "$supervisor_manifest" \ + || die "retained standalone supervisor evidence does not prove $proof" +done +for digest_name in runtime_launcher_sha256 dory_hv_sha256 dataplane_sha256; do + grep -Eq "^${digest_name}=[0-9a-f]{64}$" "$supervisor_manifest" \ + || die "retained standalone supervisor evidence omits $digest_name" +done +grep -qx "dory_hv_sha256=$DORY_HV_SHA256" "$supervisor_manifest" \ + || die "retained supervisor recovery evidence used the wrong dory-hv" +grep -qx 'release_qualifying=true' "$supervisor_manifest" \ + || die "retained standalone supervisor evidence is not release qualifying" + +bind_lock_manifest="$(single_evidence_file bind-advisory-lock manifest.txt)" +for proof in status create_excl_readonly_mode0000_unlink \ + bsd_flock_exclusive_shared_unlock_upgrade_crash \ + posix_range_nonoverlap_blocking_unlock_crash cross_container_bind_mount; do + grep -qx "$proof=PASS" "$bind_lock_manifest" \ + || die "retained bind advisory-lock evidence does not prove $proof" +done +bind_lock_image="$(sed -n 's/^image=//p' "$bind_lock_manifest")" +printf '%s\n' "$bind_lock_image" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "retained bind advisory-lock evidence has no digest-pinned image" +bind_lock_docker_sha256="$(sed -n 's/^docker_cli_sha256=//p' "$bind_lock_manifest")" +printf '%s\n' "$bind_lock_docker_sha256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "retained bind advisory-lock evidence has no exact Docker CLI digest" +[ "$competitor_docker_sha256" = "$bind_lock_docker_sha256" ] \ + || die "competitor runtime and bind-lock evidence used different Docker CLIs" + +guest_agent_manifest="$(single_evidence_file guest-agent manifest.txt)" +for proof in status; do + grep -qx "$proof=PASS" "$guest_agent_manifest" \ + || die "retained guest-agent boot evidence does not prove $proof" +done +guest_agent_sha256="$(sed -n 's/^expected_sha256=//p' "$guest_agent_manifest")" +printf '%s\n' "$guest_agent_sha256" | grep -Eq '^[0-9a-f]{64}$' \ + || die "retained guest-agent boot evidence has no bundled-agent digest" +grep -qx "fresh_sha256=$guest_agent_sha256" "$guest_agent_manifest" \ + || die "fresh guest boot did not execute the exact bundled agent" +grep -qx "restart_sha256=$guest_agent_sha256" "$guest_agent_manifest" \ + || die "restarted guest did not execute the exact bundled agent" + +ssh_agent_manifest="$(single_evidence_file ssh-agent manifest.txt)" +for proof in status; do + grep -qx "$proof=PASS" "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence does not prove $proof" +done +grep -qx 'guest_socket=/run/host-services/ssh-auth.sock' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence used the wrong guest socket" +ssh_agent_concurrency="$(sed -n 's/^concurrency=//p' "$ssh_agent_manifest")" +case "$ssh_agent_concurrency" in ''|*[!0-9]*) die "retained SSH-agent concurrency is invalid" ;; esac +[ "$ssh_agent_concurrency" -ge 8 ] \ + || die "retained SSH-agent evidence covers fewer than eight concurrent clients" +grep -Eq '^public_key_listing_sha256=[0-9a-f]{64}$' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence has no identity-listing digest" +grep -qx 'buildkit_required_ssh_mount=PASS' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence does not prove a required BuildKit SSH mount" +grep -Eq '^buildkit_public_key_listing_sha256=[0-9a-f]{64}$' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence has no BuildKit identity-listing digest" +ssh_agent_hash="$(sed -n 's/^public_key_listing_sha256=//p' "$ssh_agent_manifest")" +buildkit_ssh_agent_hash="$(sed -n 's/^buildkit_public_key_listing_sha256=//p' "$ssh_agent_manifest")" +[ "$buildkit_ssh_agent_hash" = "$ssh_agent_hash" ] \ + || die "retained BuildKit SSH identities differ from the ordinary agent proof" +grep -Eq '^image=.+@sha256:[0-9a-f]{64}$' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence has no digest-pinned image" +ssh_agent_image="$(sed -n 's/^image=//p' "$ssh_agent_manifest")" +grep -qx "docker_sha256=$competitor_docker_sha256" "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence used the wrong Docker CLI" +grep -qx "buildx_sha256=$CANDIDATE_BUILDX_SHA" "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence used the wrong Buildx plugin" +grep -qx 'bundled_buildx=PASS' "$ssh_agent_manifest" \ + || die "retained SSH-agent evidence did not prove the bundled Buildx plugin" + +testcontainers_manifest="$(single_evidence_file testcontainers manifest.txt)" +grep -qx 'status=PASS' "$testcontainers_manifest" \ + || die "retained Testcontainers/Ryuk evidence is not PASS" +testcontainers_version="$(sed -n 's/^testcontainers_version=//p' "$testcontainers_manifest")" +printf '%s\n' "$testcontainers_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' \ + || die "retained Testcontainers evidence has no exact npm version" +testcontainers_ryuk_image="$(sed -n 's/^ryuk_image=//p' "$testcontainers_manifest")" +printf '%s\n' "$testcontainers_ryuk_image" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "retained Testcontainers evidence has no digest-pinned Ryuk image" + +devcontainers_manifest="$(single_evidence_file devcontainers manifest.txt)" +for proof in \ + status official_cli_invocation host_to_container_workspace container_to_host_workspace \ + container_exec exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$devcontainers_manifest" \ + || die "retained Dev Containers evidence does not prove $proof" +done +devcontainers_version="$(sed -n 's/^devcontainers_cli=//p' "$devcontainers_manifest")" +printf '%s\n' "$devcontainers_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' \ + || die "retained Dev Containers evidence has no exact npm version" + +act_manifest="$(single_evidence_file act manifest.txt)" +for proof in \ + status host_socket_routing guest_local_socket_mount workflow_execution \ + host_to_runner_workspace runner_to_host_workspace exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$act_manifest" \ + || die "retained act evidence does not prove $proof" +done +act_version="$(sed -n 's/^act_version=//p' "$act_manifest")" +printf '%s\n' "$act_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "retained act evidence has no exact version" +grep -Eq '^act_archive_sha256=[0-9a-f]{64}$' "$act_manifest" \ + || die "retained act evidence has no archive digest" +grep -Eq '^runner_image=.+@sha256:[0-9a-f]{64}$' "$act_manifest" \ + || die "retained act evidence has no digest-pinned runner image" +act_runner_image="$(sed -n 's/^runner_image=//p' "$act_manifest")" +[ -n "$act_runner_image" ] || die "retained act evidence omits its runner image" + +localstack_manifest="$(single_evidence_file localstack manifest.txt)" +for proof in \ + status dynamic_localhost_port loopback_only_listener health_endpoint \ + s3_object_roundtrip sqs_message_roundtrip exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$localstack_manifest" \ + || die "retained LocalStack evidence does not prove $proof" +done +localstack_image="$(sed -n 's/^localstack_image=//p' "$localstack_manifest")" +printf '%s\n' "$localstack_image" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "retained LocalStack evidence has no digest-pinned image" + +tilt_manifest="$(single_evidence_file tilt manifest.txt)" +for proof in \ + status tilt_ci docker_compose_resource compose_health host_to_service_workspace \ + service_to_host_workspace tilt_down exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$tilt_manifest" || die "retained Tilt evidence does not prove $proof" +done +tilt_version="$(sed -n 's/^tilt_version=//p' "$tilt_manifest")" +printf '%s\n' "$tilt_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "retained Tilt evidence has no exact version" +grep -Eq '^tilt_archive_sha256=[0-9a-f]{64}$' "$tilt_manifest" \ + || die "retained Tilt evidence has no archive digest" + +supabase_manifest="$(single_evidence_file supabase manifest.txt)" +for proof in \ + status full_default_stack guest_local_docker_socket all_services_running \ + defined_healthchecks_healthy postgres_migration_seed_roundtrip postgrest_roundtrip \ + auth_health storage_health loopback_only_listeners supabase_stop_no_backup \ + exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$supabase_manifest" \ + || die "retained Supabase evidence does not prove $proof" +done +supabase_version="$(sed -n 's/^supabase_cli=//p' "$supabase_manifest")" +printf '%s\n' "$supabase_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "retained Supabase evidence has no exact CLI version" +grep -Eq '^supabase_archive_sha256=[0-9a-f]{64}$' "$supabase_manifest" \ + || die "retained Supabase evidence has no archive digest" +supabase_healthchecks="$(sed -n 's/^docker_healthcheck_count=//p' "$supabase_manifest")" +case "$supabase_healthchecks" in ''|*[!0-9]*) die "retained Supabase healthcheck count is invalid" ;; esac +[ "$supabase_healthchecks" -ge 8 ] \ + || die "retained Supabase evidence covers too few Docker healthchecks" + +kubernetes_tooling_manifest="$(single_evidence_file kubernetes-tooling manifest.txt)" +for proof in \ + status k3s_node_ready host_kubectl_api host_kubectl_stability loopback_only_api_listener \ + loopback_only_nodeport_listener skaffold_run skaffold_rollout \ + skaffold_nodeport_http ingress_only_network_policy_egress skaffold_delete \ + tilt_kubernetes_ci tilt_rollout \ + tilt_nodeport_http tilt_down exact_baseline_cleanup; do + grep -qx "$proof=PASS" "$kubernetes_tooling_manifest" \ + || die "retained Kubernetes tooling evidence does not prove $proof" +done +kubernetes_stability_samples="$(sed -n 's/^host_kubectl_stability_samples=//p' \ + "$kubernetes_tooling_manifest")" +case "$kubernetes_stability_samples" in + ''|*[!0-9]*) die "retained Kubernetes API stability sample count is invalid" ;; +esac +[ "$kubernetes_stability_samples" -ge 60 ] \ + || die "retained Kubernetes API stability evidence has fewer than 60 consecutive samples" +kubernetes_stability_duration="$(sed -n \ + 's/^host_kubectl_stability_duration_seconds=//p' "$kubernetes_tooling_manifest")" +case "$kubernetes_stability_duration" in + ''|*[!0-9]*) die "retained Kubernetes API stability duration is invalid" ;; +esac +[ "$kubernetes_stability_duration" -ge 55 ] \ + || die "retained Kubernetes API stability evidence did not span at least 55 seconds" +k3s_image="$(sed -n 's/^k3s_image=//p' "$kubernetes_tooling_manifest")" +kubernetes_workload_image="$(sed -n 's/^workload_image=//p' "$kubernetes_tooling_manifest")" +skaffold_version="$(sed -n 's/^skaffold_version=//p' "$kubernetes_tooling_manifest")" +printf '%s\n' "$k3s_image" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "retained Kubernetes tooling evidence has no digest-pinned k3s image" +printf '%s\n' "$kubernetes_workload_image" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "retained Kubernetes tooling evidence has no digest-pinned workload image" +printf '%s\n' "$skaffold_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "retained Kubernetes tooling evidence has no exact Skaffold version" +grep -Eq '^skaffold_sha256=[0-9a-f]{64}$' "$kubernetes_tooling_manifest" \ + || die "retained Kubernetes tooling evidence has no Skaffold binary digest" +grep -Eq '^tilt_archive_sha256=[0-9a-f]{64}$' "$kubernetes_tooling_manifest" \ + || die "retained Kubernetes tooling evidence has no Tilt archive digest" + +long_summary="$(single_evidence_file long-lived summary.txt)" +long_manifest="$(single_evidence_file long-lived manifest.txt)" +long_machine_results="$(single_evidence_file long-lived machine-to-docker-rtt.tsv)" +long_outbound_results="$(single_evidence_file long-lived machine-outbound-tcp.tsv)" +long_machine_hosts="$(single_evidence_file long-lived machine-service-hosts.txt)" +grep -qx 'status=PASS' "$long_summary" \ + && grep -qx 'same_tcp_connection=PASS' "$long_summary" \ + && grep -qx 'duration_beyond_24_hours=PASS' "$long_summary" \ + && grep -qx 'unique_connection_tuples=1' "$long_summary" \ + || die "retained long-lived TCP evidence does not prove one connection beyond 24 hours" +grep -qx 'machine_to_docker_service=PASS' "$long_summary" \ + && grep -qx 'machine_service_route=host.docker.internal' "$long_summary" \ + && grep -qx 'machine_service_regular_200_400ms_plateau=ABSENT' "$long_summary" \ + || die "retained long-lived evidence does not prove stable machine-to-Docker service latency" +grep -qx 'machine_outbound_tcp=PASS' "$long_summary" \ + && grep -qx 'machine_outbound_failure_budget_per_mille=5' "$long_summary" \ + && grep -qx 'machine_outbound_consecutive_failure_limit=2' "$long_summary" \ + || die "retained long-lived evidence weakens managed-machine outbound TCP reliability" +grep -qx 'machine_service_host=host.docker.internal' "$long_manifest" \ + && grep -qx 'machine_service_route=machine-container-to-published-docker-service' "$long_manifest" \ + && grep -qx 'machine_service_p99_budget_ms=100' "$long_manifest" \ + && grep -qx 'machine_service_sustained_budget_ms=150' "$long_manifest" \ + && grep -qx 'machine_service_sustained_sample_limit=3' "$long_manifest" \ + || die "retained machine-to-Docker manifest weakens the latency route or budgets" +grep -qx 'machine_outbound_host=registry-1.docker.io' "$long_manifest" \ + && grep -qx 'machine_outbound_port=443' "$long_manifest" \ + && grep -qx 'machine_outbound_failure_budget_per_mille=5' "$long_manifest" \ + && grep -qx 'machine_outbound_consecutive_failure_limit=2' "$long_manifest" \ + || die "retained managed-machine outbound target or budgets changed" +grep -Fq 'host.docker.internal' "$long_machine_hosts" \ + || die "retained machine-to-Docker evidence does not prove route-name resolution" + +endurance_manifest="$(single_evidence_file endurance manifest.txt)" +endurance_cycles="$(single_evidence_file endurance cycles.tsv)" +endurance_resources="$(single_evidence_file endurance resources.tsv)" +power_manifest="$QUALIFICATION/evidence/power-assertion-manifest.txt" +power_history="$QUALIFICATION/evidence/power-history.tsv" +power_assertions="$QUALIFICATION/evidence/power-assertions.txt" +power_source="$QUALIFICATION/evidence/power-source.txt" +for power_evidence in "$power_manifest" "$power_history" "$power_assertions" "$power_source"; do + [ -s "$power_evidence" ] || die "retained qualification power evidence is missing: $power_evidence" +done +grep -qx 'release_qualifying=true' "$endurance_manifest" \ + || die "retained endurance manifest is not release qualifying" +grep -qx 'fseventsd_rss_growth_mb=128' "$endurance_manifest" \ + && grep -qx 'fseventsd_cpu_percent=25' "$endurance_manifest" \ + || die "retained endurance manifest weakens host fseventsd budgets" +[ "$(wc -l < "$endurance_cycles" | tr -d ' ')" -gt 1 ] \ + || die "retained endurance evidence has no cycle rows" +grep -q $'\tFAIL\t' "$endurance_cycles" \ + && die "retained endurance evidence contains a failed cycle" +python3 scripts/analyze-endurance-resources.py "$endurance_resources" \ + --fd-growth 16 --rss-growth-mb 384 --disk-growth-mb 256 --idle-cpu 25 \ + --fseventsd-rss-growth-mb 128 --fseventsd-cpu 25 \ + >/dev/null \ + || die "retained endurance resource evidence does not show a stable plateau" + +python3 - "$growth_summary" "$competitor_results" "$long_summary" "$long_machine_results" \ + "$long_outbound_results" \ + "$endurance_manifest" \ + "$power_manifest" "$power_history" "$power_assertions" "$power_source" <<'PY' +import csv +import datetime as dt +import math +import sys +(growth_path, competitor_path, long_path, long_machine_path, long_outbound_path, endurance_path, power_manifest_path, power_history_path, power_assertions_path, power_source_path) = sys.argv[1:] + +def properties(path): + values = {} + with open(path, encoding='utf-8') as handle: + for raw in handle: + (key, separator, value) = raw.strip().partition('=') + if separator: + values[key] = value + return values +growth = properties(growth_path) +for key in ('seed_allocated_bytes', 'grown_logical_bytes', 'grown_allocated_bytes', 'minimum_logical_bytes', 'guest_ext4_bytes', 'minimum_guest_bytes'): + if not key in growth: + raise SystemExit(f'growth evidence omits {key}') +if not int(growth['grown_logical_bytes']) >= int(growth['minimum_logical_bytes']): + raise SystemExit('sparse disk did not reach the required logical capacity') +if not int(growth['grown_allocated_bytes']) < int(growth['grown_logical_bytes']): + raise SystemExit('grown disk is not sparse') +if not int(growth['grown_allocated_bytes']) < int(growth['seed_allocated_bytes']): + raise SystemExit('discard did not reclaim the preallocated seed') +if not int(growth['guest_ext4_bytes']) >= int(growth['minimum_guest_bytes']): + raise SystemExit('guest ext4 capacity stayed below its required floor') +required_competitor_tests = {'published-port-handoff', 'host-port-collision', 'named-signal-delivery', 'container-api-lifecycle', 'forwarded-connection-fds', 'concurrent-proxy-backpressure', 'missing-source-cp', 'restart-churn', 'compose-port-restart', 'compose-v2-lifecycle', 'network-route-conflict', 'network-api-lifecycle', 'standalone-engine-restart', 'named-volume-empty', 'named-volume', 'named-volume-cp', 'volume-api-lifecycle', 'security-opt-label', 'seccomp-profile', 'bind-open-create-0200', 'bind-mount-option-contract', 'healthcheck', 'buildx-named-context', 'buildkit-default-arg', 'image-save-stdout', 'image-hardlink-missing-parent', 'buildkit-large-dockerfile', 'buildkit-concurrent-sessions', 'buildkit-cache-cancellation', 'container-resolver-contract', 'container-dns-search', 'cleanup-restart-persistence'} +with open(competitor_path, encoding='utf-8', newline='') as handle: + rows = list(csv.DictReader(handle, delimiter='\t')) +by_test = {row.get('test'): row.get('status') for row in rows} +missing = required_competitor_tests - set(by_test) +if not not missing: + raise SystemExit(f'competitor evidence omits required tests: {sorted(missing)}') +if not all((by_test[name] == 'PASS' for name in required_competitor_tests)): + raise SystemExit('one or more required competitor tests did not pass') +long = properties(long_path) +if not float(long.get('actual_elapsed_seconds', '0')) > 86400: + raise SystemExit('same-connection evidence did not actually cross 24 hours') +if not int(long.get('heartbeats', '0')) > 0: + raise SystemExit('same-connection evidence has no heartbeats') +if not int(long.get('unique_connection_tuples', '0')) == 1: + raise SystemExit('same-connection evidence changed its TCP tuple') +if not long.get('machine_to_docker_service') == 'PASS': + raise SystemExit('machine-to-Docker service route did not pass') +if not long.get('machine_service_route') == 'host.docker.internal': + raise SystemExit('machine-to-Docker service used the wrong route') +if not float(long.get('machine_service_actual_elapsed_seconds', '0')) > 86400: + raise SystemExit('machine-to-Docker latency evidence did not actually cross 24 hours') +with open(long_machine_path, encoding='utf-8', newline='') as handle: + machine_rows = list(csv.DictReader(handle, delimiter='\t')) +if not len(machine_rows) == int(long.get('machine_service_samples', '0')): + raise SystemExit('machine-to-Docker sample count differs from the summary') +if not len(machine_rows) >= 2592: + raise SystemExit('machine-to-Docker evidence retains fewer than 90% of 25-hour/30-second samples') +if not all((row.get('status') == 'PASS' for row in machine_rows)): + raise SystemExit('machine-to-Docker latency evidence contains a failed sample') +machine_values = [int(row['rtt_ms']) for row in machine_rows] +ordered = sorted(machine_values) +machine_p99 = ordered[max(0, math.ceil(len(ordered) * 0.99) - 1)] +machine_over_100 = sum((value > 100 for value in machine_values)) +machine_200 = sum((value >= 200 for value in machine_values)) +if not machine_p99 <= 100: + raise SystemExit(f'machine-to-Docker p99 exceeds 100ms: {machine_p99}ms') +if not machine_over_100 <= max(1, math.floor(len(machine_values) * 0.01)): + raise SystemExit('machine-to-Docker RTT exceeds 100ms too frequently') +run = 0 +max_run = 0 +for value in machine_values: + run = run + 1 if value >= 150 else 0 + max_run = max(max_run, run) +if not max_run < 3: + raise SystemExit('machine-to-Docker RTT contains a sustained >=150ms plateau') +if not int(long.get('machine_service_p99_ms', '-1')) == machine_p99: + raise SystemExit('machine-to-Docker p99 differs from the raw evidence') +if not int(long.get('machine_service_max_ms', '-1')) == max(machine_values): + raise SystemExit('machine-to-Docker maximum differs from the raw evidence') +if not int(long.get('machine_service_over_100ms_samples', '-1')) == machine_over_100: + raise SystemExit('machine-to-Docker >100ms count differs from the raw evidence') +if not int(long.get('machine_service_200ms_samples', '-1')) == machine_200: + raise SystemExit('machine-to-Docker >=200ms count differs from the raw evidence') +if not int(long.get('machine_service_max_sustained_150ms_samples', '-1')) == max_run: + raise SystemExit('machine-to-Docker sustained-latency run differs from the raw evidence') +if not long.get('machine_outbound_tcp') == 'PASS': + raise SystemExit('managed-machine outbound TCP qualification did not pass') +with open(long_outbound_path, encoding='utf-8', newline='') as handle: + outbound_rows = list(csv.DictReader(handle, delimiter='\t')) +if not len(outbound_rows) == int(long.get('machine_outbound_samples', '0')): + raise SystemExit('managed-machine outbound sample count differs from the summary') +if not len(outbound_rows) == len(machine_rows): + raise SystemExit('managed-machine outbound sample count differs from the local-route count') +outbound_successes = sum((row.get('status') == 'PASS' for row in outbound_rows)) +outbound_failures = len(outbound_rows) - outbound_successes +if not outbound_failures <= max(1, math.floor(len(outbound_rows) * 0.005)): + raise SystemExit('managed-machine outbound TCP failure budget exceeded') +failure_run = 0 +max_failure_run = 0 +for row in outbound_rows: + failure_run = failure_run + 1 if row.get('status') != 'PASS' else 0 + max_failure_run = max(max_failure_run, failure_run) +if not max_failure_run < 2: + raise SystemExit('managed-machine outbound TCP has consecutive failures') +if not all((len(row.get('remote_ipv4', '').split('.')) == 4 and all((part.isdigit() and 0 <= int(part) <= 255 for part in row['remote_ipv4'].split('.'))) for row in outbound_rows if row.get('status') == 'PASS')): + raise SystemExit('managed-machine outbound PASS row lacks a valid IPv4 target') +if not int(long.get('machine_outbound_tcp_successes', '-1')) == outbound_successes: + raise SystemExit('managed-machine outbound success count differs from raw evidence') +if not int(long.get('machine_outbound_timeout_samples', '-1')) == outbound_failures: + raise SystemExit('managed-machine outbound failure count differs from raw evidence') +if not int(long.get('machine_outbound_max_consecutive_failures', '-1')) == max_failure_run: + raise SystemExit('managed-machine outbound failure run differs from raw evidence') +endurance = properties(endurance_path) +if not int(endurance.get('duration_seconds', '0')) >= 28800: + raise SystemExit('endurance evidence was configured below eight hours') +power_manifest = properties(power_manifest_path) +if not power_manifest == {'status': 'PASS', 'flags': '-is', 'display_sleep_prevented': 'false'}: + raise SystemExit('power assertion manifest is not the display-neutral -is contract') +with open(power_source_path, encoding='utf-8') as handle: + if not "Now drawing from 'AC Power'" in handle.read(): + raise SystemExit('qualification did not start on AC power') +with open(power_assertions_path, encoding='utf-8') as handle: + assertions = handle.read() +if not ('pid ' in assertions and '(caffeinate)' in assertions): + raise SystemExit('power evidence is not owned by caffeinate') +if not 'PreventUserIdleSystemSleep' in assertions: + raise SystemExit('idle-sleep assertion is missing') +if not 'PreventSystemSleep' in assertions: + raise SystemExit('system-sleep assertion is missing') +with open(power_history_path, encoding='utf-8', newline='') as handle: + power_rows = list(csv.DictReader(handle, delimiter='\t')) +if not len(power_rows) >= 2: + raise SystemExit('continuous power evidence has fewer than two samples') +timestamps = [] +for row in power_rows: + if not "Now drawing from 'AC Power'" in row['pmset_state']: + raise SystemExit('qualification lost AC power') + timestamps.append(dt.datetime.fromisoformat(row['checked_utc'].replace('Z', '+00:00'))) +required_power_span = float(long['actual_elapsed_seconds']) - 120 +if not (timestamps[-1] - timestamps[0]).total_seconds() >= required_power_span: + raise SystemExit('power assertion history does not span the same-connection qualification') +PY + +[ "$(shasum -a 256 scripts/verify-release-qualification.sh | awk '{print $1}')" = \ + "$VERIFIER_SHA256" ] \ + || die "qualification verifier bytes changed during verification" +[ "$(shasum -a 256 scripts/validate-release-qualification.py | awk '{print $1}')" = \ + "$AUTHORITY_VALIDATOR_SHA256" ] \ + || die "qualification authority validator changed during verification" +[ "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" ] \ + || die "checked-out source changed during qualification verification" +git diff --quiet -- scripts .github/scripts \ + || die "tracked qualification verifier changed during verification" +git diff --cached --quiet -- scripts .github/scripts \ + || die "staged qualification verifier changed during verification" +python3 scripts/validate-release-qualification.py \ + "$BUILD_DIR" "$QUALIFICATION" "$VERSION" "$BUILD" "$SOURCE_COMMIT" \ + "$RUN_ID" "$RUN_ATTEMPT" "$PRIMARY_SHA256" \ + || die "durable qualification authority changed during semantic verification" + +echo "release qualification verification: PASS" From 8c61874069bd51a801cdb293b6fd7fd48969edec Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:54:57 +0000 Subject: [PATCH 151/338] test(ci): track hosted test log parser --- scripts/ci-test-support.sh | 29 ++++++++++++++++ scripts/test-ci-test-support.sh | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100755 scripts/ci-test-support.sh create mode 100755 scripts/test-ci-test-support.sh diff --git a/scripts/ci-test-support.sh b/scripts/ci-test-support.sh new file mode 100755 index 00000000..d2af1ebd --- /dev/null +++ b/scripts/ci-test-support.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Return the completed-test count that Xcode reported. A single invocation can contain distinct +# XCTest and Swift Testing runners, so take the largest aggregate from each runner and add them. +# Individual pass lines remain a last-resort signal for an interrupted runner with no summary. +dory_ci_count_completed_tests() { + local log="$1" swift_summary xctest_summary individual total + + swift_summary="$(sed -nE 's/.*Test run with ([0-9]+) tests.*/\1/p' "$log" \ + | awk '$1 > maximum { maximum=$1 } END { if (maximum != "") print maximum }')" + xctest_summary="$(sed -nE 's/.*Executed ([0-9]+) tests.*/\1/p' "$log" \ + | awk '$1 > maximum { maximum=$1 } END { if (maximum != "") print maximum }')" + total=0 + case "$swift_summary" in ''|*[!0-9]*) ;; *) total=$((total + swift_summary)) ;; esac + case "$xctest_summary" in ''|*[!0-9]*) ;; *) total=$((total + xctest_summary)) ;; esac + if [ "$total" -gt 0 ]; then + printf '%s\n' "$total" + return + fi + + individual="$(grep -cE "Test case '[^']+' passed|Test .+\(.*\) passed after" "$log" || true)" + printf '%s\n' "$individual" +} + +dory_ci_failure_lines() { + local log="$1" + grep -E "Test case '[^']+' failed|Test .+ failed after|(^|[^[:alpha:]])error:" "$log" \ + | tail -50 || true +} diff --git a/scripts/test-ci-test-support.sh b/scripts/test-ci-test-support.sh new file mode 100755 index 00000000..0f7510f8 --- /dev/null +++ b/scripts/test-ci-test-support.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +# shellcheck source=ci-test-support.sh +source scripts/ci-test-support.sh + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-ci-parser.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +assert_count() { + local expected="$1" fixture="$2" actual + actual="$(dory_ci_count_completed_tests "$fixture")" + if [ "$actual" != "$expected" ]; then + echo "ci-test parser: expected $expected completed tests, got $actual for $fixture" >&2 + exit 1 + fi +} + +printf '%s\n' \ + 'Test alpha() passed after 0.001 seconds.' \ + 'Test run with 616 tests in 95 suites passed after 9.750 seconds.' \ + > "$TMP/swift-testing.log" +assert_count 616 "$TMP/swift-testing.log" + +printf '%s\n' \ + "Test case '-[DoryTests.LegacyTests testOne]' passed (0.001 seconds)." \ + 'Executed 423 tests, with 0 failures (0 unexpected) in 12.000 seconds' \ + > "$TMP/xctest.log" +assert_count 423 "$TMP/xctest.log" + +printf '%s\n' \ + 'Executed 10 tests, with 0 failures (0 unexpected) in 1.000 seconds' \ + 'Executed 220 tests, with 0 failures (0 unexpected) in 2.000 seconds' \ + 'Test run with 125 tests in 20 suites passed after 3.000 seconds.' \ + > "$TMP/mixed-runners.log" +assert_count 345 "$TMP/mixed-runners.log" + +printf '%s\n' \ + 'Test first() passed after 0.001 seconds.' \ + 'Test second() passed after 0.001 seconds.' \ + > "$TMP/interrupted.log" +assert_count 2 "$TMP/interrupted.log" + +printf '%s\n' \ + 'Test brokenPath() failed after 0.001 seconds with 1 issue.' \ + 'DoryTests/File.swift:10: error: expectation failed' \ + > "$TMP/failure.log" +failure_lines="$(dory_ci_failure_lines "$TMP/failure.log")" +printf '%s' "$failure_lines" | grep -q 'brokenPath' || { + echo 'ci-test parser: Swift Testing failure line was not retained' >&2 + exit 1 +} +printf '%s' "$failure_lines" | grep -q 'expectation failed' || { + echo 'ci-test parser: compiler-style error line was not retained' >&2 + exit 1 +} + +echo 'ci-test log parser tests passed' From a82fd20f2ecb6c43541e657db32bf4915e9c0963 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:55:56 +0000 Subject: [PATCH 152/338] test(security): track product safety contracts --- scripts/test-destructive-action-contracts.sh | 97 ++++++++++++++++++++ scripts/test-security-contracts.sh | 87 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100755 scripts/test-destructive-action-contracts.sh create mode 100644 scripts/test-security-contracts.sh diff --git a/scripts/test-destructive-action-contracts.sh b/scripts/test-destructive-action-contracts.sh new file mode 100755 index 00000000..4c8345f7 --- /dev/null +++ b/scripts/test-destructive-action-contracts.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +fail() { + echo "test-destructive-action-contracts: $*" >&2 + exit 1 +} + +fixture="$(mktemp -d -t dory-destructive-contracts.XXXXXX)" +trap 'rm -rf "$fixture"' EXIT +fake_ctl="$fixture/dorydctl" +log="$fixture/calls.log" + +cat > "$fake_ctl" <<'SH' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$*" >> "${DORY_DESTRUCTIVE_TEST_LOG:?}" +if [ "${1:-}" = machine ] && [ "${2:-}" = snapshot ]; then + printf '%s\n' '{"id":"safety123","ok":true}' +else + printf '%s\n' '{"ok":true}' +fi +SH +chmod +x "$fake_ctl" + +run_cli() { + DORYDCTL_BIN="$fake_ctl" DORY_DESTRUCTIVE_TEST_LOG="$log" bash scripts/dory "$@" +} + +if run_cli machine delete dev >"$fixture/out" 2>"$fixture/err"; then + fail "machine delete ran without exact confirmation" +fi +grep -Fq -- "--confirm 'dev'" "$fixture/err" || fail "machine delete did not explain exact confirmation" +test ! -s "$log" || fail "unconfirmed machine delete reached dorydctl" + +if run_cli machine delete dev --confirm prod >"$fixture/out" 2>"$fixture/err"; then + fail "machine delete accepted a different confirmation target" +fi +test ! -s "$log" || fail "wrong-target machine delete reached dorydctl" + +run_cli machine delete dev --confirm dev >"$fixture/out" +grep -qx 'machine delete dev' "$log" || fail "confirmed machine delete did not preserve exact scope" +: > "$log" + +if run_cli machine restore dev s-old >"$fixture/out" 2>"$fixture/err"; then + fail "machine restore ran without exact confirmation" +fi +test ! -s "$log" || fail "unconfirmed machine restore created state" + +run_cli machine restore dev s-old --confirm dev >"$fixture/out" 2>"$fixture/err" +grep -qx 'machine snapshot dev --note Automatic pre-restore safety snapshot' "$log" \ + || fail "confirmed restore did not create a safety snapshot first" +grep -qx 'machine restore-snapshot dev s-old' "$log" \ + || fail "confirmed restore did not target the selected snapshot" +test "$(wc -l < "$log" | tr -d ' ')" = 2 || fail "restore invoked an unexpected control operation" +grep -Fq 'safety123' "$fixture/err" || fail "restore did not report the undo snapshot" +: > "$log" + +if run_cli machine delete-snapshot dev s-old --confirm dev >"$fixture/out" 2>"$fixture/err"; then + fail "snapshot deletion accepted the machine name instead of the exact snapshot ID" +fi +test ! -s "$log" || fail "wrong-target snapshot deletion reached dorydctl" +run_cli machine delete-snapshot dev s-old --confirm s-old >"$fixture/out" +grep -qx 'machine delete-snapshot dev s-old' "$log" || fail "confirmed snapshot deletion changed scope" +: > "$log" + +if run_cli component remove kubernetes >"$fixture/out" 2>"$fixture/err"; then + fail "component removal ran without exact confirmation" +fi +test ! -s "$log" || fail "unconfirmed component removal reached dorydctl" +run_cli component remove kubernetes --confirm kubernetes --json >"$fixture/out" +grep -qx 'component remove kubernetes --json' "$log" || fail "confirmed component removal changed options" + +if rg -n '\.keyboardShortcut\([^\n]*(delete|backspace)|\.onDeleteCommand' Dory --glob '*.swift' >"$fixture/keyboard"; then + fail "a destructive keyboard shortcut bypasses the confirmation contract: $(tr '\n' ' ' < "$fixture/keyboard")" +fi + +grep -Fq 'Automatic pre-restore safety snapshot' Dory/Models/AppStore.swift \ + || fail "the GUI restore path lost its automatic undo snapshot" +grep -Fq 'Create Safety Snapshot & Restore' Dory/Features/Machines/SnapshotsSheet.swift \ + || fail "snapshot restore no longer communicates confirmation and recoverability" +grep -Fq 'Stop & Remove Stack' Dory/Features/Containers/ContainersView.swift \ + || fail "container list Compose removal lacks confirmation" +grep -Fq 'Stop & Remove Stack' Dory/Features/Containers/ContainerDetailView.swift \ + || fail "container detail Compose removal lacks confirmation" +grep -Fq 'Stop & Remove Stack' Dory/Features/Compose/ComposeProjectsView.swift \ + || fail "Compose page removal lacks confirmation" +grep -Fq 'Disable & Remove Cluster' Dory/Features/Tables/KubernetesView.swift \ + || fail "Kubernetes removal lacks confirmation" + +if rg -n 'Button\("Down [-—].*\{ Task \{ await store\.composeDown|Button\("Disable Kubernetes".*await store\.disableKubernetes' \ + Dory/App/DoryCommands.swift Dory/Features/MenuBar/MenuBarContentView.swift; then + fail "a global or menu-bar action still performs destructive work without opening the confirming UI" +fi + +echo "destructive action contract tests passed" diff --git a/scripts/test-security-contracts.sh b/scripts/test-security-contracts.sh new file mode 100644 index 00000000..eb22dbe1 --- /dev/null +++ b/scripts/test-security-contracts.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { echo "security contract failed: $*" >&2; exit 1; } + +for forbidden_entitlement in \ + com.apple.security.cs.allow-jit \ + com.apple.security.cs.allow-unsigned-executable-memory \ + com.apple.security.cs.disable-library-validation \ + com.apple.security.virtualization \ + com.apple.security.hypervisor; do + if grep -F "$forbidden_entitlement" Dory/Dory.entitlements >/dev/null; then + fail "main app retains $forbidden_entitlement" + fi +done + +for required_entitlement in \ + com.apple.security.network.client \ + com.apple.security.network.server; do + grep -F "$required_entitlement" Dory/Dory.entitlements >/dev/null \ + || fail "main app lost $required_entitlement" +done + +for required_vmm_entitlement in \ + com.apple.security.virtualization \ + com.apple.security.device.audio-input; do + grep -F "$required_vmm_entitlement" \ + dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements >/dev/null \ + || fail "dory-vmm lost $required_vmm_entitlement" +done +grep -F 'NSMicrophoneUsageDescription' dory-core-swift/Sources/dory-vmm/Info.plist >/dev/null \ + || fail "dory-vmm lost its microphone privacy description" + +if grep -R -E --include='*.swift' --include='init' \ + 'tcp://0\.0\.0\.0:2375|guestPort: 2375|remote[^\n]*:2375' \ + Packages/ContainerizationEngine/Sources dory-core-swift/Sources guest/initfs/init >/dev/null; then + fail "a production guest path exposes unauthenticated Docker TCP 2375" +fi + +grep -F 'DorydXPCSecurity.configureIncomingConnection(connection)' \ + dory-core-swift/Sources/DorydKit/DorydService.swift >/dev/null \ + || fail "doryd listener does not authenticate incoming peers" +grep -F 'connection.setCodeSigningRequirement(productionClientRequirement)' \ + dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift >/dev/null \ + || fail "production doryd does not pin client signatures" +grep -F 'DorydDaemonSigningPolicy.daemonRequirement' \ + Dory/Runtime/Doryd/DorydClient.swift >/dev/null \ + || fail "production app does not pin doryd's signature" +grep -F 'DorydXPCSecurity.productionDaemonRequirement' \ + dory-core-swift/Sources/dorydctl/main.swift >/dev/null \ + || fail "production dorydctl does not pin doryd's signature" + +grep -F 'static let attachSupported = false' Dory/Net/UsbAttachmentStore.swift >/dev/null \ + || fail "USB passthrough can be advertised before the guest RPC exists" + +for kernel_contract in \ + 'CONFIG_NETFILTER_XT_MATCH_OWNER=y' \ + 'CONFIG_IP6_NF_FILTER=y' \ + 'CONFIG_BLK_DEV_LOOP=y'; do + grep -Fx "$kernel_contract" guest/kernel/dory.config >/dev/null \ + || fail "sandbox guest kernel lost $kernel_contract" +done +for agent_contract in DORY_AGENT_RUN_UID DORY_AGENT_MAX_PROCESSES DORY_AGENT_MAX_FILE_BYTES; do + grep -F "$agent_contract" dory-core/agent/src/exec.rs >/dev/null \ + || fail "guest agent lost restricted exec key $agent_contract" +done +grep -F 'mode = "ro"' scripts/dory >/dev/null \ + || fail "sandbox mounts no longer default read-only" +grep -F 'create_args+=(--sandbox-expires-at "$expires_epoch")' scripts/dory >/dev/null \ + || fail "sandbox expiry is not sent through typed machine authority" +grep -F 'let expiration = policy.expiresAtUnixSeconds' \ + dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift >/dev/null \ + || fail "daemon does not reconcile typed sandbox expiry" +grep -F 'legacyExpirationEnvironmentKey = "DORY_SANDBOX_EXPIRES_AT"' \ + dory-core-swift/Sources/DoryOperations/DoryVirtualMachineSandboxPolicy.swift >/dev/null \ + || fail "sandbox expiry lost legacy read compatibility" +grep -F 'sandboxSSHAgentDenied' dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift >/dev/null \ + || fail "sandbox VMM does not fail closed for ambient SSH-agent forwarding" +grep -F -- '--env-json-stdin' dory-core-swift/Sources/dorydctl/main.swift scripts/dory >/dev/null \ + || fail "ephemeral sandbox secrets can no longer avoid process argv" +[ -s SANDBOX_THREAT_MODEL.md ] || fail "sandbox threat model is missing" +[ -x scripts/sandbox-security-gate.sh ] || fail "inside-VM sandbox security gate is missing" + +echo "security contracts: PASS" From 87f89bbc5cb4c5cad310435d6884602a0db2df2e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:56:34 +0000 Subject: [PATCH 153/338] test(release): track payload policy regressions --- scripts/test-gvproxy-payload.sh | 139 ++++++++++++++++++++++++++ scripts/test-host-cli-payload.sh | 45 +++++++++ scripts/test-transfer-helper-image.py | 88 ++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100755 scripts/test-gvproxy-payload.sh create mode 100755 scripts/test-host-cli-payload.sh create mode 100755 scripts/test-transfer-helper-image.py diff --git a/scripts/test-gvproxy-payload.sh b/scripts/test-gvproxy-payload.sh new file mode 100755 index 00000000..95bd5f6a --- /dev/null +++ b/scripts/test-gvproxy-payload.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# Offline regression tests for the gvproxy release pin and payload verifier. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=gvproxy-payload.sh +source "$SCRIPT_DIR/gvproxy-payload.sh" + +fail() { + echo "test-gvproxy-payload: FAIL: $*" >&2 + exit 1 +} + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-gvproxy-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +PAYLOAD="$TMP/gvproxy" +FAKE_LIPO="$TMP/lipo" + +write_payload() { + local version="$1" + printf '%s\n' '#!/bin/sh' "printf '%s\\n' 'gvproxy version $version'" > "$PAYLOAD" + chmod +x "$PAYLOAD" +} + +write_lipo_arches() { + local arches="$1" + printf '%s\n' '#!/bin/sh' "printf '%s\\n' '$arches'" > "$FAKE_LIPO" + chmod +x "$FAKE_LIPO" +} + +write_payload "v0.8.9-dory2" +write_lipo_arches "x86_64 arm64" +PAYLOAD_SHA="$(shasum -a 256 "$PAYLOAD" | awk '{print $1}')" +DORY_LIPO_BIN="$FAKE_LIPO" dory_verify_gvproxy_payload "$PAYLOAD" "v0.8.9-dory2" "$PAYLOAD_SHA" \ + || fail "valid universal payload was rejected" + +FAKE_CODESIGN="$TMP/codesign" +cat > "$FAKE_CODESIGN" <<'SH' +#!/bin/sh +case "$1" in + --verify) exit 0 ;; + -dv) printf '%s\n' 'Authority=Developer ID Application: Fixture (TEAMID)' >&2; exit 0 ;; +esac +exit 64 +SH +chmod 0755 "$FAKE_CODESIGN" +PROVENANCE="$TMP/gvproxy-provenance.txt" +INVENTORY="$TMP/dory-payload-sha256.txt" +printf 'verified_sha256=%s\n' "$DORY_GVPROXY_DEFAULT_SHA256" > "$PROVENANCE" +printf '%s Contents/Helpers/gvproxy\n' "$PAYLOAD_SHA" > "$INVENTORY" +DORY_LIPO_BIN="$FAKE_LIPO" DORY_CODESIGN_BIN="$FAKE_CODESIGN" \ + dory_verify_signed_gvproxy_payload "$PAYLOAD" "$PROVENANCE" "$INVENTORY" \ + || fail "valid signed-candidate identity chain was rejected" +printf '%064d Contents/Helpers/gvproxy\n' 0 > "$INVENTORY" +if DORY_LIPO_BIN="$FAKE_LIPO" DORY_CODESIGN_BIN="$FAKE_CODESIGN" \ + dory_verify_signed_gvproxy_payload "$PAYLOAD" "$PROVENANCE" "$INVENTORY" \ + >/dev/null 2>&1; then + fail "signed candidate outside the payload inventory was accepted" +fi +printf '%s Contents/Helpers/gvproxy\n' "$PAYLOAD_SHA" > "$INVENTORY" + +if DORY_LIPO_BIN="$FAKE_LIPO" dory_verify_gvproxy_payload \ + "$PAYLOAD" "v0.8.9-dory2" "0000000000000000000000000000000000000000000000000000000000000000" \ + >/dev/null 2>&1; then + fail "checksum mismatch was accepted" +fi + +write_lipo_arches "arm64" +if DORY_LIPO_BIN="$FAKE_LIPO" dory_verify_gvproxy_payload \ + "$PAYLOAD" "v0.8.9-dory2" "$PAYLOAD_SHA" >/dev/null 2>&1; then + fail "single-slice payload was accepted" +fi + +write_lipo_arches "x86_64 arm64" +write_payload "v0.8.8" +PAYLOAD_SHA="$(shasum -a 256 "$PAYLOAD" | awk '{print $1}')" +if DORY_LIPO_BIN="$FAKE_LIPO" dory_verify_gvproxy_payload \ + "$PAYLOAD" "v0.8.9-dory2" "$PAYLOAD_SHA" >/dev/null 2>&1; then + fail "wrong gvproxy version was accepted" +fi + +if (DORY_GVPROXY_VERSION="v0.9.0"; unset DORY_GVPROXY_SHA256; dory_gvproxy_validate_overrides) \ + >/dev/null 2>&1; then + fail "unpaired version override was accepted" +fi +if (unset DORY_GVPROXY_VERSION; DORY_GVPROXY_SHA256="$PAYLOAD_SHA"; dory_gvproxy_validate_overrides) \ + >/dev/null 2>&1; then + fail "unpaired checksum override was accepted" +fi +dory_gvproxy_validate_overrides || fail "default pinned metadata was rejected" +(DORY_GVPROXY_VERSION="v0.9.0"; DORY_GVPROXY_SHA256="$PAYLOAD_SHA"; \ + dory_gvproxy_validate_overrides) || fail "paired custom metadata was rejected" + +[ "$DORY_GVPROXY_DEFAULT_VERSION" = "v0.8.9-dory2" ] || fail "default version pin regressed" +[ "$DORY_GVPROXY_DEFAULT_SHA256" = \ + "47c278f1636736ba552de3d2f0e68409cdc968d63bc02149637e449f40274459" ] \ + || fail "default checksum pin regressed" +for reproducible_input in \ + 'GO_TOOLCHAIN="go1.26.5"' \ + 'GO_MOD_SHA256="75848c190dca5cc7af27ebe017d5a4d59d4a117c97eaa6b8ac0359e58d868eec"' \ + 'GO_SUM_SHA256="25b1a52ad3181030b6ccf92af5d69a1a4282f8f2342dad5348b5c954c304c4b3"' \ + 'export GOTOOLCHAIN="$GO_TOOLCHAIN"' \ + 'go test -mod=readonly' \ + 'go build -mod=readonly' \ + '-segalign x86_64 0x1000 -segalign arm64 0x4000'; do + grep -Fq "$reproducible_input" "$SCRIPT_DIR/build-gvproxy.sh" \ + || fail "gvproxy rebuild omits reproducible input: $reproducible_input" +done +if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GOTOOLCHAIN=.*auto|[[:space:]]-mod=mod([[:space:]]|$)' \ + "$SCRIPT_DIR/build-gvproxy.sh"; then + fail "gvproxy rebuild still permits a floating toolchain or mutable module graph" +fi + +for script in "$REPO_ROOT/scripts/build.sh" "$REPO_ROOT/scripts/bundle-engine.sh"; do + grep -q 'source .*gvproxy-payload.sh' "$script" || fail "$(basename "$script") does not load verifier" + grep -q 'dory_verify_gvproxy_payload' "$script" || fail "$(basename "$script") does not verify payload" + if grep -Eq 'podman/libexec/podman/gvproxy|command -v gvproxy|/tmp/dory-gvproxy-darwin' "$script"; then + fail "$(basename "$script") still accepts ambient or stale gvproxy binaries" + fi +done + +ENGINE_MODE="$REPO_ROOT/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift" +grep -Fq 'gvproxy.arguments?.append(contentsOf: ["-ssh-port", "-1"])' "$ENGINE_MODE" \ + || fail "EngineMode does not disable gvproxy's unused SSH listener in flag-only mode" +grep -Fq '"-listen-qemu", "unix://\(lanDatapathSocket)"' "$ENGINE_MODE" \ + || fail "EngineMode does not give the source-preserving LAN bridge an independent QEMU port" +grep -Fq 'GVProxyQEMUFrameDecoder' "$REPO_ROOT/dory-core-swift/Sources/DoryCore/DirectIPBridge.swift" \ + || fail "the source-preserving LAN bridge does not frame gvproxy's QEMU stream" +if sed -n '/if let nativeIPv6 {/,/} else {/p' "$ENGINE_MODE" | grep -q -- '-ssh-port'; then + fail "EngineMode passes the incompatible -ssh-port flag with the native IPv6 config" +fi + +bash -n "$SCRIPT_DIR/gvproxy-payload.sh" +bash -n "$SCRIPT_DIR/build-gvproxy.sh" +bash -n "$SCRIPT_DIR/build.sh" +bash -n "$SCRIPT_DIR/bundle-engine.sh" +python3 -m py_compile "$SCRIPT_DIR/gvproxy-qemu-switch-gate.py" +echo "test-gvproxy-payload: PASS" diff --git a/scripts/test-host-cli-payload.sh b/scripts/test-host-cli-payload.sh new file mode 100755 index 00000000..e3a261cc --- /dev/null +++ b/scripts/test-host-cli-payload.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=host-cli-payload.sh +source "$ROOT/scripts/host-cli-payload.sh" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-host-cli.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "test-host-cli-payload: FAIL: $*" >&2; exit 1; } + +dory_host_cli_validate_metadata || fail "default pinned metadata was rejected" +[ "$(dory_host_cli_version kubectl)" = v1.36.1 ] || fail "kubectl version pin regressed" +[ "$(dory_host_cli_version docker)" = 29.0.1 ] || fail "Docker CLI version pin regressed" +[ "$(dory_host_cli_version docker-buildx)" = v0.34.1 ] || fail "Buildx version pin regressed" +[ "$(dory_host_cli_version docker-compose)" = v2.39.2 ] || fail "Compose version pin regressed" + +printf 'payload\n' > "$TMP/cli" +sha="$(shasum -a 256 "$TMP/cli" | awk '{print $1}')" +dory_verify_host_cli_payload "$TMP/cli" "$sha" || fail "valid payload was rejected" +if dory_verify_host_cli_payload "$TMP/cli" 0000000000000000000000000000000000000000000000000000000000000000 \ + >/dev/null 2>&1; then + fail "checksum mismatch was accepted" +fi + +if (DORY_KUBECTL_VERSION=v9.9.9; unset DORY_KUBECTL_SHA256_ARM64 DORY_KUBECTL_SHA256_X86_64; \ + dory_host_cli_validate_metadata) >/dev/null 2>&1; then + fail "unpaired kubectl version override was accepted" +fi +(DORY_KUBECTL_VERSION=v9.9.9 \ + DORY_KUBECTL_SHA256_ARM64="$sha" DORY_KUBECTL_SHA256_X86_64="$sha" \ + dory_host_cli_validate_metadata) || fail "paired kubectl override was rejected" + +grep -q 'source scripts/host-cli-payload.sh' "$ROOT/scripts/bundle-engine.sh" \ + || fail "bundle-engine does not load the host CLI verifier" +grep -q 'dory_verify_host_cli_payload' "$ROOT/scripts/bundle-engine.sh" \ + || fail "bundle-engine does not verify downloaded host CLIs" +grep -Fq 'chmod 0644 "$HOST_CLI_PROVENANCE"' "$ROOT/scripts/bundle-engine.sh" \ + || fail "bundle-engine does not publish portable host CLI provenance permissions" +[ "$(grep -c 'dory_verify_host_cli_payload .*|| return 1' "$ROOT/scripts/bundle-engine.sh")" -eq 4 ] \ + || fail "host CLI checksum failures are not propagated out of conditional download functions" +grep -A2 'docker-buildx)' "$ROOT/scripts/bundle-engine.sh" | grep -q 'darwin_download_arch' \ + || fail "Buildx download does not use its arm64/amd64 Darwin asset names" +bash -n "$ROOT/scripts/host-cli-payload.sh" "$ROOT/scripts/bundle-engine.sh" +echo "test-host-cli-payload: PASS" diff --git a/scripts/test-transfer-helper-image.py b/scripts/test-transfer-helper-image.py new file mode 100755 index 00000000..d5da7af9 --- /dev/null +++ b/scripts/test-transfer-helper-image.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Offline regression tests for the deterministic transfer-helper image archive.""" + +from __future__ import annotations + +import importlib.util +import io +import sys +import tarfile +import struct +import unittest +from pathlib import Path + + +sys.dont_write_bytecode = True +SCRIPT = Path(__file__).with_name("build-transfer-helper-image.py") +SPEC = importlib.util.spec_from_file_location("dory_transfer_helper_image", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"could not load {SCRIPT}") +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class TransferHelperImageTests(unittest.TestCase): + def setUp(self) -> None: + helper = bytearray(120) + helper[:7] = b"\x7fELF\x02\x01\x01" + struct.pack_into(" None: + first, first_metadata = MODULE.build_archive(self.helper) + second, second_metadata = MODULE.build_archive(self.helper) + self.assertEqual(first, second) + self.assertEqual(first_metadata, second_metadata) + self.assertEqual( + MODULE.verify_archive(first, self.helper_sha256), first_metadata + ) + + def test_wrong_expected_helper_digest_fails_closed(self) -> None: + archive, _ = MODULE.build_archive(self.helper) + with self.assertRaisesRegex(ValueError, "helper digest label"): + MODULE.verify_archive(archive, "0" * 64) + + def test_extra_outer_member_fails_closed(self) -> None: + archive, _ = MODULE.build_archive(self.helper) + output = io.BytesIO() + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as source: + with tarfile.open( + fileobj=output, mode="w", format=tarfile.USTAR_FORMAT + ) as target: + for member in source.getmembers(): + target.addfile(member, source.extractfile(member)) + target.addfile( + MODULE.tar_info("unexpected", 1, 0o644), io.BytesIO(b"x") + ) + with self.assertRaisesRegex(ValueError, "unexpected member set"): + MODULE.verify_archive(output.getvalue(), self.helper_sha256) + + def test_changed_layer_bytes_fail_the_rootfs_digest(self) -> None: + archive, _ = MODULE.build_archive(self.helper) + output = io.BytesIO() + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as source: + manifest = MODULE.json.loads(source.extractfile("manifest.json").read()) + layer_name = manifest[0]["Layers"][0] + with tarfile.open( + fileobj=output, mode="w", format=tarfile.USTAR_FORMAT + ) as target: + for member in source.getmembers(): + contents = source.extractfile(member).read() + if member.name == layer_name: + contents = contents[:-1] + bytes([contents[-1] ^ 1]) + target.addfile( + MODULE.tar_info(member.name, len(contents), member.mode), + io.BytesIO(contents), + ) + with self.assertRaisesRegex(ValueError, "layer digest"): + MODULE.verify_archive(output.getvalue(), self.helper_sha256) + + +if __name__ == "__main__": + unittest.main() From 7be8e40da3a938f01fd1c9be15e019effa9f5c87 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:57:18 +0000 Subject: [PATCH 154/338] test(release): track installer verification contracts --- scripts/test-make-dmg.sh | 75 +++++++++++ scripts/test-sparkle-install-evidence.sh | 154 +++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100755 scripts/test-make-dmg.sh create mode 100755 scripts/test-sparkle-install-evidence.sh diff --git a/scripts/test-make-dmg.sh b/scripts/test-make-dmg.sh new file mode 100755 index 00000000..a1db9bfc --- /dev/null +++ b/scripts/test-make-dmg.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Offline regression tests for the disk-image tool transition and failure-safe staging cleanup. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-make-dmg.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +BIN="$TMP/bin" +APP="$TMP/Dory.app" +mkdir -p "$BIN" "$APP/Contents/MacOS" +printf 'fixture\n' > "$APP/Contents/MacOS/Dory" + +cat > "$BIN/diskutil" <<'SH' +#!/bin/bash +set -eu +printf '%s\n' "$*" >> "$DORY_TEST_DISKUTIL_LOG" +if [ "${4:-}" = "--help" ]; then + [ "${DORY_TEST_DISKUTIL_SUPPORTED:-0}" = "1" ] + exit +fi +last="" +for argument in "$@"; do last="$argument"; done +printf 'diskutil image\n' > "$last" +SH +chmod 0755 "$BIN/diskutil" + +cat > "$BIN/hdiutil" <<'SH' +#!/bin/bash +set -eu +printf '%s\n' "$*" >> "$DORY_TEST_HDIUTIL_LOG" +[ "${DORY_TEST_HDIUTIL_FAIL:-0}" != "1" ] || exit 70 +last="" +for argument in "$@"; do last="$argument"; done +printf 'hdiutil image\n' > "$last" +SH +chmod 0755 "$BIN/hdiutil" + +DISKUTIL_LOG="$TMP/diskutil.log" +HDIUTIL_LOG="$TMP/hdiutil.log" +NEW_DMG="$TMP/new.dmg" +DORY_TEST_DISKUTIL_SUPPORTED=1 \ +DORY_TEST_DISKUTIL_LOG="$DISKUTIL_LOG" \ +DORY_TEST_HDIUTIL_LOG="$HDIUTIL_LOG" \ +PATH="$BIN:/usr/bin:/bin" \ + "$ROOT/scripts/make-dmg.sh" "$APP" 0.3.0 "$NEW_DMG" >/dev/null +grep -Fq 'image create from --format UDZO --volumeName Dory 0.3.0' "$DISKUTIL_LOG" +[ -f "$NEW_DMG" ] || { echo "test-make-dmg: diskutil path produced no image" >&2; exit 1; } +[ ! -e "$HDIUTIL_LOG" ] || { echo "test-make-dmg: diskutil path invoked hdiutil" >&2; exit 1; } + +: > "$DISKUTIL_LOG" +OLD_DMG="$TMP/old.dmg" +DORY_TEST_DISKUTIL_SUPPORTED=0 \ +DORY_TEST_DISKUTIL_LOG="$DISKUTIL_LOG" \ +DORY_TEST_HDIUTIL_LOG="$HDIUTIL_LOG" \ +PATH="$BIN:/usr/bin:/bin" \ + "$ROOT/scripts/make-dmg.sh" "$APP" 0.3.0 "$OLD_DMG" >/dev/null +grep -Fq 'create -volname Dory 0.3.0 -srcfolder' "$HDIUTIL_LOG" +[ -f "$OLD_DMG" ] || { echo "test-make-dmg: hdiutil fallback produced no image" >&2; exit 1; } + +FAIL_TMP="$TMP/failure-tmp" +mkdir -p "$FAIL_TMP" +if DORY_TEST_DISKUTIL_SUPPORTED=0 \ + DORY_TEST_HDIUTIL_FAIL=1 \ + DORY_TEST_DISKUTIL_LOG="$DISKUTIL_LOG" \ + DORY_TEST_HDIUTIL_LOG="$HDIUTIL_LOG" \ + TMPDIR="$FAIL_TMP" \ + PATH="$BIN:/usr/bin:/bin" \ + "$ROOT/scripts/make-dmg.sh" "$APP" 0.3.0 "$TMP/failure.dmg" >/dev/null 2>&1; then + echo "test-make-dmg: accepted a disk-image tool failure" >&2 + exit 1 +fi +[ -z "$(find "$FAIL_TMP" -mindepth 1 -print -quit)" ] \ + || { echo "test-make-dmg: retained staging data after failure" >&2; exit 1; } + +echo "test-make-dmg: PASS" diff --git a/scripts/test-sparkle-install-evidence.sh b/scripts/test-sparkle-install-evidence.sh new file mode 100755 index 00000000..bd039f60 --- /dev/null +++ b/scripts/test-sparkle-install-evidence.sh @@ -0,0 +1,154 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-sparkle-evidence-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +SOURCE_COMMIT="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SPARKLE_REVISION="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +TEAM="864H636QW4" +VERSION="1.2.3" +BUILD="42" +for artifact in update.zip appcast.xml release-manifest.json gate.sh; do + printf 'fixture:%s\n' "$artifact" > "$TMP/$artifact" +done +python3 - "$TMP/sbom.json" "$TMP/Package.resolved" "$SPARKLE_REVISION" <<'PY' +import json +import sys + +sbom, resolved, revision = sys.argv[1:] +with open(sbom, "w", encoding="utf-8") as handle: + json.dump({ + "metadata": { + "component": { + "properties": [{ + "name": "dev.dory.app.tree.sha256", + "value": "c" * 64, + }] + } + } + }, handle) +with open(resolved, "w", encoding="utf-8") as handle: + json.dump({ + "pins": [{ + "identity": "sparkle", + "state": {"version": "2.9.4", "revision": revision}, + }] + }, handle) +PY + +write_manifest() { + python3 - "$TMP/manifest.txt" "$TMP" "$SOURCE_COMMIT" "$SPARKLE_REVISION" \ + "$TEAM" "$VERSION" "$BUILD" <<'PY' +import hashlib +import pathlib +import sys + +output, root_value, source, sparkle_revision, team, version, build = sys.argv[1:] +root = pathlib.Path(root_value) + + +def digest(name): + return hashlib.sha256((root / name).read_bytes()).hexdigest() + + +rows = { + "status": "PASS", + "release_qualifying": "true", + "run_id": "20260714T120000Z-1234", + "workflow_run_id": "5678", + "workflow_run_attempt": "2", + "source_commit": source, + "candidate_version": version, + "candidate_build": build, + "previous_fixture_build": str(int(build) - 1), + "candidate_tree_sha256": "c" * 64, + "update_zip_sha256": digest("update.zip"), + "appcast_sha256": digest("appcast.xml"), + "release_manifest_sha256": digest("release-manifest.json"), + "sbom_sha256": digest("sbom.json"), + "gate_script_sha256": digest("gate.sh"), + "sparkle_version": "2.9.4", + "sparkle_revision": sparkle_revision, + "sparkle_cli_sha256": "d" * 64, + "sparkle_framework_sha256": "e" * 64, + "candidate_team": team, + "sparkle_cli_team": team, + "sparkle_autoupdate_team": team, + "old_pid": "100", + "relaunched_pid": "101", + "old_process_terminated": "PASS", + "different_relaunch_pid": "PASS", + "loopback_appcast_fetched": "PASS", + "exact_update_archive_fetched": "PASS", + "ed25519_archive_verified": "PASS", + "installed_tree_exact": "PASS", + "installed_notarization_ticket": "PASS", + "installed_gatekeeper": "PASS", + "application_support_preserved": "PASS", + "preferences_preserved": "PASS", + "atomic_install_swap": "PASS", + "sparkle_fallback_restoration_verified": "PASS", + "qualification_fixture_restored": "PASS", + "initial_clean_user_state_restored": "PASS", + "completed_epoch": "1784030400", +} +with open(output, "w", encoding="utf-8") as handle: + for key, value in rows.items(): + handle.write(f"{key}={value}\n") +PY +} + +verify() { + "$ROOT/scripts/verify-sparkle-install-evidence.py" \ + --manifest "$TMP/manifest.txt" \ + --app-update "$TMP/update.zip" \ + --appcast "$TMP/appcast.xml" \ + --release-manifest "$TMP/release-manifest.json" \ + --sbom "$TMP/sbom.json" \ + --gate-script "$TMP/gate.sh" \ + --package-resolved "$TMP/Package.resolved" \ + --candidate-team "$TEAM" \ + --source-commit "$SOURCE_COMMIT" \ + --run-id 5678 \ + --run-attempt 2 \ + --version "$VERSION" \ + --build "$BUILD" +} + +write_manifest +verify >/dev/null + +sed -i '' 's/atomic_install_swap=PASS/atomic_install_swap=FAIL/' "$TMP/manifest.txt" +if verify > "$TMP/invalid-pass.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted failed atomic replacement" >&2 + exit 1 +fi +grep -Fq 'Sparkle evidence mismatch for atomic_install_swap' "$TMP/invalid-pass.out" + +write_manifest +printf 'status=PASS\n' >> "$TMP/manifest.txt" +if verify > "$TMP/duplicate.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted duplicate evidence" >&2 + exit 1 +fi +grep -Fq 'malformed Sparkle evidence' "$TMP/duplicate.out" + +write_manifest +sed -i '' 's/relaunched_pid=101/relaunched_pid=100/' "$TMP/manifest.txt" +if verify > "$TMP/reused-pid.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted a reused app PID" >&2 + exit 1 +fi +grep -Fq 'reused the previous app PID' "$TMP/reused-pid.out" + +write_manifest +printf 'tampered\n' >> "$TMP/update.zip" +if verify > "$TMP/tampered.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted a different update archive" >&2 + exit 1 +fi +grep -Fq 'Sparkle evidence mismatch for update_zip_sha256' "$TMP/tampered.out" + +echo "test-sparkle-install-evidence: PASS" From 6a6b2209537bf0b7466947014d5f510445980325 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 06:59:33 +0000 Subject: [PATCH 155/338] test(readiness): track offline resource contracts --- scripts/corporate-connectivity-gate.sh | 154 ++++++++++++++++++++++ scripts/staged-readiness-resource-gate.sh | 98 ++++++++++++++ scripts/test-clean-xcode-products.sh | 55 ++++++++ scripts/test-readiness-offline.sh | 107 +++++++++++++++ 4 files changed, 414 insertions(+) create mode 100755 scripts/corporate-connectivity-gate.sh create mode 100755 scripts/staged-readiness-resource-gate.sh create mode 100755 scripts/test-clean-xcode-products.sh create mode 100755 scripts/test-readiness-offline.sh diff --git a/scripts/corporate-connectivity-gate.sh b/scripts/corporate-connectivity-gate.sh new file mode 100755 index 00000000..75e7281c --- /dev/null +++ b/scripts/corporate-connectivity-gate.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LIVE=0 +RELEASE=0 +BASELINE="" +OUTPUT="" +EXPECT_INTERFACE="" +EXPECT_DNS="" +REQUIRE_PROXY=0 +REQUIRE_CA=0 + +usage() { + cat <<'EOF' +Usage: scripts/corporate-connectivity-gate.sh [options] + +Without --live, performs the hermetic source/contract gate used by CI. + + --live Query the installed doryd profile and run its bounded probes + --release Require a changed baseline plus explicit VPN/DNS expectations + --baseline STATUS.json Earlier live status captured before VPN/exit-node/DHCP churn + --expect-interface NAME Require this route or tunnel interface in current evidence + --expect-dns ADDRESS Require a successful probe sent explicitly to this DNS server + --require-proxy Require every registry probe to name its selected proxy + --require-ca Require at least one registry probe to name an applied CA id + --output PATH Write the current schema-v1 status to PATH + +A release-qualifying run is intentionally two-phase: capture a live baseline, perform the +physical VPN/Tailscale/DHCP/sleep-wake transition, then run --live --release with that baseline. +The gate never toggles VPNs, sleeps the user's Mac, or changes a profile on its own. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --live) LIVE=1; shift ;; + --release) RELEASE=1; LIVE=1; shift ;; + --baseline) BASELINE="${2:?--baseline requires a path}"; shift 2 ;; + --output) OUTPUT="${2:?--output requires a path}"; shift 2 ;; + --expect-interface) EXPECT_INTERFACE="${2:?--expect-interface requires a name}"; shift 2 ;; + --expect-dns) EXPECT_DNS="${2:?--expect-dns requires an address}"; shift 2 ;; + --require-proxy) REQUIRE_PROXY=1; shift ;; + --require-ca) REQUIRE_CA=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "corporate connectivity gate: unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +require_source() { + local pattern="$1" file="$2" detail="$3" + rg -q -- "$pattern" "$ROOT/$file" || { + echo "corporate connectivity gate: missing $detail in $file" >&2 + exit 1 + } +} + +require_source 'dev\.dory\.corporate-connectivity' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'versioned profile schema' +require_source 'var host: CorporateProxyLayer' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'macOS/PAC proxy layer' +require_source 'var dockerd: CorporateProxyLayer' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'dockerd pull layer' +require_source 'var buildKit: CorporateProxyLayer' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'BuildKit layer' +require_source 'var containers: CorporateProxyLayer' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'container layer' +require_source 'proxies\.default contract cannot honor different values' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'truthful BuildKit/container conflict check' +require_source 'ownership conflict' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'ownership-safe Docker config restore' +require_source 'ProxyAutoConfigURLString' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'PAC discovery' +require_source 'requireSOA' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'SOA behavior probe' +require_source '"CNAME"' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'CNAME behavior probe' +require_source 'bridge subnet.*collides' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'VPN subnet collision refusal' +require_source 'temporaryCABundle' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'actual scoped CA probe bundle' +require_source 'reconcileAfterWake' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'wake reconciliation' +require_source 'lastFingerprint.*snapshot\.fingerprint' dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift 'DHCP/interface/VPN fingerprint reconciliation' +require_source 'DORY_CORPORATE_CHANGED=0' dory-core-swift/Sources/DorydKit/DockerTier.swift 'effective digest no-op' +require_source '--live-restore' Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift 'live-restore dockerd reconfiguration' +require_source 'network corporate status' dory-core-swift/Sources/dorydctl/main.swift 'CLI status/plan/apply surface' +require_source 'CORPORATE CONNECTIVITY' Dory/Features/Settings/SettingsView.swift 'guided Settings surface' + +if [ "$LIVE" -eq 0 ]; then + echo "corporate connectivity gate: PASS (static contract)" + exit 0 +fi + +command -v jq >/dev/null || { echo "corporate connectivity gate: jq is required for --live" >&2; exit 2; } + +find_ctl() { + local candidate + for candidate in \ + "${DORYDCTL_BIN:-}" \ + "$ROOT/dory-core-swift/.build/debug/dorydctl" \ + "$HOME/.dory/bin/dorydctl" \ + "$(command -v dorydctl 2>/dev/null || true)"; do + [ -n "$candidate" ] && [ -x "$candidate" ] && { printf '%s\n' "$candidate"; return 0; } + done + return 1 +} + +CTL="$(find_ctl)" || { echo "corporate connectivity gate: dorydctl not found" >&2; exit 2; } +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-corporate-gate.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +CURRENT="$TMP/status.json" +"$CTL" --timeout 45 network corporate status > "$CURRENT" + +jq -e ' + .schema == "dev.dory.corporate-connectivity.status" and + .version == 1 and .enabled == true and .valid == true and + (.profile.schema == "dev.dory.corporate-connectivity") and + (.profile.version == 1) and + ([.plan[].destructive] | all(. == false)) and + ([.probes[].succeeded] | all(. == true)) +' "$CURRENT" >/dev/null || { + jq '{valid, validationErrors, warnings, probes}' "$CURRENT" >&2 + echo "corporate connectivity gate: current profile or probe evidence is not release-ready" >&2 + exit 1 +} + +if [ -n "$EXPECT_DNS" ]; then + jq -e --arg dns "$EXPECT_DNS" '[.probes[] | select(.dnsServer == $dns and .succeeded == true)] | length > 0' "$CURRENT" >/dev/null \ + || { echo "corporate connectivity gate: no successful explicit DNS probe used $EXPECT_DNS" >&2; exit 1; } +fi +if [ -n "$EXPECT_INTERFACE" ]; then + jq -e --arg interface "$EXPECT_INTERFACE" ' + (.system.defaultInterface == $interface) or + (.system.tunnelInterfaces | index($interface) != null) or + ([.probes[].routeInterface] | index($interface) != null) + ' "$CURRENT" >/dev/null \ + || { echo "corporate connectivity gate: expected interface $EXPECT_INTERFACE is absent from routes/tunnels" >&2; exit 1; } +fi +if [ "$REQUIRE_PROXY" -eq 1 ]; then + jq -e '[.probes[] | select(.kind == "registry")] | length > 0 and all(.proxy != null and .proxy != "")' "$CURRENT" >/dev/null \ + || { echo "corporate connectivity gate: a registry probe did not name its selected proxy" >&2; exit 1; } +fi +if [ "$REQUIRE_CA" -eq 1 ]; then + jq -e '[.probes[] | select(.kind == "registry" and (.caIDs | length > 0))] | length > 0' "$CURRENT" >/dev/null \ + || { echo "corporate connectivity gate: no registry probe used a declared host-probe CA" >&2; exit 1; } +fi + +if [ "$RELEASE" -eq 1 ]; then + [ -s "$BASELINE" ] || { echo "corporate connectivity gate: --release requires --baseline STATUS.json" >&2; exit 2; } + [ -n "$EXPECT_INTERFACE" ] || { echo "corporate connectivity gate: --release requires --expect-interface" >&2; exit 2; } + [ -n "$EXPECT_DNS" ] || { echo "corporate connectivity gate: --release requires --expect-dns" >&2; exit 2; } + jq -e '.schema == "dev.dory.corporate-connectivity.status" and .version == 1' "$BASELINE" >/dev/null \ + || { echo "corporate connectivity gate: baseline has the wrong schema" >&2; exit 2; } + before="$(jq -r '.system.fingerprint' "$BASELINE")" + after="$(jq -r '.system.fingerprint' "$CURRENT")" + [ -n "$before" ] && [ "$before" != "$after" ] \ + || { echo "corporate connectivity gate: system fingerprint did not change across the physical transition" >&2; exit 1; } +fi + +if [ -n "$OUTPUT" ]; then + mkdir -p "$(dirname "$OUTPUT")" + cp "$CURRENT" "$OUTPUT" + chmod 0600 "$OUTPUT" +fi + +echo "corporate connectivity gate: PASS (live=$LIVE release_qualifying=$RELEASE)" diff --git a/scripts/staged-readiness-resource-gate.sh b/scripts/staged-readiness-resource-gate.sh new file mode 100755 index 00000000..b934ccd6 --- /dev/null +++ b/scripts/staged-readiness-resource-gate.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Static contract gate by default; --live verifies an installed doryd without mutating workloads. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE=static +case "${1:-}" in + "") ;; + --live) MODE=live ;; + -h|--help) + echo "usage: scripts/staged-readiness-resource-gate.sh [--live]" + exit 0 + ;; + *) echo "unknown option: $1" >&2; exit 2 ;; +esac +cd "$ROOT" + +fail() { echo "staged readiness/resource gate failed: $*" >&2; exit 1; } +require_source() { + local pattern="$1" file="$2" label="$3" + grep -F "$pattern" "$file" >/dev/null || fail "$label" +} + +require_source 'dev.dory.readiness' dory-core-swift/Sources/DorydKit/Readiness.swift \ + "versioned readiness schema is missing" +for stage in app doryd vmProcess guestAgent mountsDataDisk network dockerd hostSocketContext kubernetes; do + require_source "case $stage" dory-core-swift/Sources/DorydKit/Readiness.swift \ + "readiness stage $stage is missing" +done +require_source 'destructive: Bool = false' dory-core-swift/Sources/DorydKit/Readiness.swift \ + "bounded repairs no longer state their non-destructive contract" +require_source 'promotionWaiters' dory-core-swift/Sources/DorydKit/DockerTier.swift \ + "promotion is no longer event/condition driven" +require_source 'repairSocketForwarder' dory-core-swift/Sources/DorydKit/DockerTier.swift \ + "socket-only repair is missing" +require_source 'repairDockerDaemon' dory-core-swift/Sources/DorydKit/DockerTier.swift \ + "dockerd-only repair is missing" +require_source 'guestResourceSnapshot' dory-core-swift/Sources/DorydKit/DockerTier.swift \ + "guest memory/disk composition probe is missing" +require_source 'host-share-resources.json' Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift \ + "host-share watcher/backpressure snapshot is missing" +for check in resources.processes resources.guest resources.file_service resources.trend network.resources disk.reclaimable; do + require_source "id: \"$check\"" dory-core-swift/Sources/DorydKit/HealthReporter.swift \ + "health surface omits $check" +done +require_source 'dory readiness [--json]' scripts/dory \ + "CLI does not expose the staged contract" + +[ "$MODE" = live ] || { echo "staged readiness/resource gate: PASS (static)"; exit 0; } + +CTL="${DORYDCTL:-}" +if [ -z "$CTL" ]; then + for candidate in \ + "$ROOT/dory-core-swift/.build/debug/dorydctl" \ + "$HOME/.dory/bin/dorydctl" \ + "/Applications/Dory.app/Contents/Helpers/dorydctl"; do + if [ -x "$candidate" ]; then CTL="$candidate"; break; fi + done +fi +[ -x "$CTL" ] || fail "dorydctl not found; set DORYDCTL to the exact installed candidate helper" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-staged-readiness.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +"$CTL" health > "$TMP/health.json" +python3 - "$TMP/health.json" <<'PY' +import json +import sys + +report = json.load(open(sys.argv[1], encoding="utf-8")) +readiness = report.get("readiness") +assert isinstance(readiness, dict) +assert readiness.get("schema") == "dev.dory.readiness" +assert readiness.get("version") == 1 +expected = [ + "app", "doryd", "vmProcess", "guestAgent", "mountsDataDisk", "network", + "dockerd", "hostSocketContext", "kubernetes", +] +stages = readiness.get("stages") +assert isinstance(stages, list) and [stage.get("id") for stage in stages] == expected +for stage in stages: + assert stage.get("state") in {"waiting", "ready", "degraded", "blocked", "inactive"} + assert isinstance(stage.get("reasonCode"), str) and stage["reasonCode"] + assert isinstance(stage.get("repair"), dict) + assert stage["repair"].get("owner") + assert stage["repair"].get("mutation") + assert stage["repair"].get("destructive") is False + if stage.get("required"): + assert stage.get("deadlineAt") + +results = {item.get("id"): item for item in report.get("results", [])} +for check in ( + "memory.footprint", "resources.processes", "resources.guest", + "resources.file_service", "resources.trend", "network.resources", + "disk.dory_drive", "disk.reclaimable", +): + assert check in results, check +assert results["disk.reclaimable"].get("data", {}).get("mutation_performed") in {None, "false"} +print("staged readiness/resource gate: PASS (live)") +PY diff --git a/scripts/test-clean-xcode-products.sh b/scripts/test-clean-xcode-products.sh new file mode 100755 index 00000000..8f4a7799 --- /dev/null +++ b/scripts/test-clean-xcode-products.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Offline regression for macOS 27's stale LaunchServices/provenance test-host rejection. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-clean-xcode.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +PRODUCTS="$TMP/DerivedData/Dory-fixture/Build/Products/Debug" +APP="$PRODUCTS/Dory.app" +RUNNER="$PRODUCTS/DoryUITests-Runner.app" +mkdir -p "$APP/Contents/MacOS" "$RUNNER/Contents/MacOS" +printf 'app\n' > "$APP/Contents/MacOS/Dory" +printf 'runner\n' > "$RUNNER/Contents/MacOS/DoryUITests-Runner" +xattr -w com.apple.quarantine '0081;fixture;DoryTests;' "$APP/Contents/MacOS/Dory" +xattr -w com.apple.quarantine '0081;fixture;DoryTests;' "$RUNNER/Contents/MacOS/DoryUITests-Runner" +xattr -wx com.apple.provenance 01020a "$APP/Contents/MacOS/Dory" +xattr -wx com.apple.provenance 01020a "$RUNNER/Contents/MacOS/DoryUITests-Runner" + +LS_LOG="$TMP/lsregister.log" +cat > "$TMP/lsregister" <<'SH' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$*" >> "$DORY_TEST_LSREGISTER_LOG" +if [ "${1:-}" = -dump ]; then exit 0; fi +SH +chmod +x "$TMP/lsregister" + +DORY_LSREGISTER_BIN="$TMP/lsregister" DORY_TEST_LSREGISTER_LOG="$LS_LOG" \ + scripts/clean-xcode-products.sh --root "$TMP/DerivedData" + +if xattr -p com.apple.quarantine "$APP/Contents/MacOS/Dory" >/dev/null 2>&1; then + echo "test-clean-xcode-products: Dory test host quarantine survived cleanup" >&2 + exit 1 +fi +if xattr -p com.apple.quarantine "$RUNNER/Contents/MacOS/DoryUITests-Runner" >/dev/null 2>&1; then + echo "test-clean-xcode-products: UI test runner quarantine survived cleanup" >&2 + exit 1 +fi +grep -Fq -- "-u $APP" "$LS_LOG" \ + || { echo "test-clean-xcode-products: Dory test host was not unregistered" >&2; exit 1; } +grep -Fq -- "-u $RUNNER" "$LS_LOG" \ + || { echo "test-clean-xcode-products: UI test runner was not unregistered" >&2; exit 1; } +grep -Fq 'trap cleanup_test_products EXIT' scripts/test.sh \ + || { echo "test-clean-xcode-products: failure-path cleanup trap is missing" >&2; exit 1; } +grep -Fq 'duplicate com.pythonxi.Dory apps cause LaunchServices Code 20' scripts/test.sh \ + || { echo "test-clean-xcode-products: duplicate-app preflight is missing" >&2; exit 1; } +grep -Fq 'has_quarantine_xattrs' scripts/clean-xcode-products.sh \ + || { echo "test-clean-xcode-products: cleanup does not verify quarantine removal" >&2; exit 1; } +grep -Fq 'provenance is system-managed' scripts/clean-xcode-products.sh \ + || { echo "test-clean-xcode-products: protected provenance policy is missing" >&2; exit 1; } + +bash -n scripts/clean-xcode-products.sh scripts/test.sh +echo "test-clean-xcode-products: PASS" diff --git a/scripts/test-readiness-offline.sh b/scripts/test-readiness-offline.sh new file mode 100755 index 00000000..da5052a5 --- /dev/null +++ b/scripts/test-readiness-offline.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Offline regression tests for readiness fail-closed accounting and the non-native Node fixture. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +bash -n scripts/readiness.sh +bash -n scripts/nonnative-build-smoke.sh + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" +cat > "$TMP/bin/docker" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x "$TMP/bin/docker" +FIXTURE_IMAGE="alpine@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +# Exploratory mode retains a visible skip for an unavailable requested engine, while strict mode +# turns the exact same prerequisite into a failure. No live engine is contacted. +PATH="$TMP/bin:/usr/bin:/bin" \ + READINESS_WORKDIR="$TMP/exploratory" \ + READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE" \ + READINESS_DOCKER_BIN="$TMP/bin/docker" \ + DORY_SOCK="$TMP/missing-dory.sock" \ + RUN_MEMORY=0 RUN_NONNATIVE_ARCH=0 \ + scripts/readiness.sh --engines dory > "$TMP/exploratory.log" +exploratory_results="$(find "$TMP/exploratory" -name results.tsv -type f -print -quit)" +grep -F $'SKIP\tdory\tall checks\tREQUIRED BUT UNAVAILABLE' "$exploratory_results" >/dev/null + +if PATH="$TMP/bin:/usr/bin:/bin" \ + READINESS_WORKDIR="$TMP/strict" \ + READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE" \ + READINESS_DOCKER_BIN="$TMP/bin/docker" \ + DORY_SOCK="$TMP/missing-dory.sock" \ + RUN_MEMORY=0 RUN_NONNATIVE_ARCH=0 \ + scripts/readiness.sh --strict --engines dory > "$TMP/strict.log" 2>&1; then + echo "strict readiness unexpectedly passed with a missing requested engine socket" >&2 + exit 1 +fi +strict_results="$(find "$TMP/strict" -name results.tsv -type f -print -quit)" +strict_summary="$(find "$TMP/strict" -name summary.json -type f -print -quit)" +grep -F $'FAIL\tdory\tall checks\tSTRICT REQUIRED' "$strict_results" >/dev/null +python3 - "$strict_summary" <<'PY' +import json +import sys + +summary = json.load(open(sys.argv[1], encoding="utf-8")) +assert summary["strict"] is True +assert summary["requiredUnavailable"] >= 1 +assert summary["fail"] >= 1 +PY + +# Directly exercise the requested-probe path without a socket. In strict mode an unavailable +# enabled probe cannot be counted as a harmless skip. +READINESS_SOURCE_ONLY=1 READINESS_STRICT=1 READINESS_WORKDIR="$TMP/source" \ + bash -c ' + set -- + source scripts/readiness.sh + mkdir -p "$WORKDIR" + : > "$RESULTS" + required_unavailable_case dory "requested probe" "unsupported in fixture" + test "$FAIL_COUNT" -eq 1 + test "$SKIP_COUNT" -eq 0 + grep -F "STRICT REQUIRED" "$RESULTS" + ' >/dev/null + +READINESS_SOURCE_ONLY=1 READINESS_STRICT=1 READINESS_WORKDIR="$TMP/coverage" \ + READINESS_REQUIRE_COMPETITOR=1 \ + bash -c ' + set -- + source scripts/readiness.sh + mkdir -p "$WORKDIR" + : > "$RESULTS" + host_guest_arch() { printf "%s\n" arm64; } + record_external_coverage + grep -F "same-host competitor correctness gate" "$RESULTS" | grep -F "STRICT REQUIRED — EXTERNAL GATE NOT COVERED" + ' >/dev/null + +# Generate and execute the deterministic package fixture locally when Node/npm are available. The +# Docker smoke uses the same files under the requested non-native platform. +DORY_NONNATIVE_SMOKE_SOURCE_ONLY=1 FIXTURE_DIR="$TMP/node-fixture" \ + bash -c 'set --; source scripts/nonnative-build-smoke.sh; write_node_build_fixture "$FIXTURE_DIR"' +READINESS_SOURCE_ONLY=1 READINESS_WORKDIR="$TMP/readiness-source" \ + FIXTURE_DIR="$TMP/readiness-node-fixture" \ + bash -c 'set --; source scripts/readiness.sh; write_nonnative_node_fixture "$FIXTURE_DIR"' +diff -ru "$TMP/node-fixture" "$TMP/readiness-node-fixture" +grep -F '"build": "node scripts/build.mjs"' "$TMP/node-fixture/package.json" >/dev/null +grep -F '"test": "node --test test/*.test.mjs"' "$TMP/node-fixture/package.json" >/dev/null +grep -F 'npm ci --ignore-scripts --no-audit --no-fund' scripts/nonnative-build-smoke.sh >/dev/null +grep -F 'npm run build' scripts/nonnative-build-smoke.sh >/dev/null +grep -F 'npm test' scripts/nonnative-build-smoke.sh >/dev/null +grep -F 'npm ci --ignore-scripts --no-audit --no-fund' scripts/readiness.sh >/dev/null + +if command -v npm >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then + ( + cd "$TMP/node-fixture" + npm ci --ignore-scripts --no-audit --no-fund >/dev/null + npm run build >/dev/null + EXPECTED_NODE_ARCH="$(node -p 'process.arch')" npm test >/dev/null + node dist/app.mjs | grep -F "dory-nonnative-build-ok arch=$(node -p 'process.arch')" >/dev/null + ) +fi + +echo "readiness offline tests: PASS" From 1f690d66db045451afa7c5dd1611ec25752f5b4e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:01:57 +0000 Subject: [PATCH 156/338] feat(release): track transactional upgrade contract --- .github/scripts/test-generate-appcast.py | 103 +++++++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/generate-appcast.sh | 180 ++++++++++++++++++ scripts/transactional-upgrade-gate.sh | 222 +++++++++++++++++++++++ 5 files changed, 507 insertions(+) create mode 100644 .github/scripts/test-generate-appcast.py create mode 100755 scripts/generate-appcast.sh create mode 100755 scripts/transactional-upgrade-gate.sh diff --git a/.github/scripts/test-generate-appcast.py b/.github/scripts/test-generate-appcast.py new file mode 100644 index 00000000..fdb5d418 --- /dev/null +++ b/.github/scripts/test-generate-appcast.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the signed appcast generator.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GENERATOR = ROOT / "scripts/generate-appcast.sh" +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DORY = "https://augani.github.io/dory/appcast" + + +class GenerateAppcastTests(unittest.TestCase): + def test_schema_two_item_is_escaped_signed_and_preserves_only_older_items(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + artifact = root / "Dory-1.2.3-app-update.zip" + artifact.write_bytes(b"signed candidate bytes") + previous = root / "previous.xml" + previous.write_text( + """ + +42 +1.2.3 + + +41 +1.2.2 + +\n""", + encoding="utf-8", + ) + output = root / "appcast.xml" + environment = { + **os.environ, + "DORY_SPARKLE_ED_SIGNATURE": "c2lnbmF0dXJl", + "DORY_APPCAST_TITLE": "Dory & Desktop", + "DORY_APPCAST_PUBDATE": "Fri, 21 Aug 2026 06:00:00 +0000", + "DORY_DATA_SCHEMA_VERSION": "2", + "DORY_MINIMUM_READABLE_DATA_SCHEMA": "1", + "DORY_MAXIMUM_READABLE_DATA_SCHEMA": "2", + "DORY_COMPONENT_CATALOG_SCHEMA": "2", + } + completed = subprocess.run( + ["bash", str(GENERATOR), "1.2.3", "42", str(artifact), str(output), str(previous)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + tree = ET.parse(output) + channel = tree.getroot().find("channel") + self.assertIsNotNone(channel) + self.assertEqual(channel.findtext("title"), "Dory & Desktop") + items = channel.findall("item") + self.assertEqual(len(items), 2) + self.assertEqual(items[0].findtext(f"{{{SPARKLE}}}version"), "42") + self.assertEqual(items[0].findtext(f"{{{DORY}}}dataSchemaVersion"), "2") + self.assertEqual(items[0].findtext(f"{{{DORY}}}componentCatalogSchema"), "2") + enclosure = items[0].find("enclosure") + self.assertIsNotNone(enclosure) + self.assertEqual(enclosure.attrib[f"{{{SPARKLE}}}edSignature"], "c2lnbmF0dXJl") + self.assertEqual(int(enclosure.attrib["length"]), artifact.stat().st_size) + self.assertEqual(items[1].findtext(f"{{{SPARKLE}}}version"), "41") + + def test_invalid_schema_range_fails_without_publishing_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + artifact = root / "candidate.zip" + artifact.write_bytes(b"candidate") + output = root / "appcast.xml" + completed = subprocess.run( + ["bash", str(GENERATOR), "1.2.3", "42", str(artifact), str(output)], + cwd=ROOT, + env={ + **os.environ, + "DORY_SPARKLE_ED_SIGNATURE": "c2lnbmF0dXJl", + "DORY_DATA_SCHEMA_VERSION": "3", + "DORY_MINIMUM_READABLE_DATA_SCHEMA": "1", + "DORY_MAXIMUM_READABLE_DATA_SCHEMA": "2", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("range excludes", completed.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 555fa1e6..6c46d3c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -335,6 +335,7 @@ jobs: - name: Signed release metadata contract run: | python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ce2d386..ddb5cd49 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,7 @@ jobs: - name: Signed release metadata contract run: | python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh new file mode 100755 index 00000000..ebd71c94 --- /dev/null +++ b/scripts/generate-appcast.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# Generate a Sparkle appcast item for a signed Dory release artifact. +set -euo pipefail + +usage() { + echo "usage: scripts/generate-appcast.sh [previous-appcast.xml]" >&2 + exit 64 +} + +[ "$#" -ge 4 ] || usage + +VERSION="$1" +BUILD="$2" +ARTIFACT="$3" +OUTPUT="$4" +PREVIOUS="${5:-$OUTPUT}" + +[ -f "$ARTIFACT" ] || { echo "appcast error: artifact not found: $ARTIFACT" >&2; exit 1; } + +APPCAST_TITLE="${DORY_APPCAST_TITLE:-Dory}" +APPCAST_LINK="${DORY_APPCAST_LINK:-https://augani.github.io/dory/appcast.xml}" +APPCAST_DESCRIPTION="${DORY_APPCAST_DESCRIPTION:-Updates for Dory - native Docker and Linux containers for macOS.}" +MINIMUM_SYSTEM_VERSION="${DORY_APPCAST_MINIMUM_SYSTEM_VERSION:-14.0}" +DORY_DATA_SCHEMA_VERSION="${DORY_DATA_SCHEMA_VERSION:-1}" +DORY_MINIMUM_READABLE_DATA_SCHEMA="${DORY_MINIMUM_READABLE_DATA_SCHEMA:-1}" +DORY_MAXIMUM_READABLE_DATA_SCHEMA="${DORY_MAXIMUM_READABLE_DATA_SCHEMA:-1}" +DORY_COMPONENT_CATALOG_SCHEMA="${DORY_COMPONENT_CATALOG_SCHEMA:-1}" +ASSET_BASE_URL="${DORY_RELEASE_ASSET_BASE_URL:-https://github.com/Augani/dory/releases/download/v$VERSION}" +ARTIFACT_URL="${DORY_APPCAST_ARTIFACT_URL:-${ASSET_BASE_URL%/}/$(basename "$ARTIFACT")}" +PUBDATE="${DORY_APPCAST_PUBDATE:-$(LC_ALL=C TZ=UTC date -u '+%a, %d %b %Y %H:%M:%S +0000')}" + +for schema_value in \ + "$DORY_DATA_SCHEMA_VERSION" \ + "$DORY_MINIMUM_READABLE_DATA_SCHEMA" \ + "$DORY_MAXIMUM_READABLE_DATA_SCHEMA" \ + "$DORY_COMPONENT_CATALOG_SCHEMA"; do + case "$schema_value" in + ''|*[!0-9]*|0) echo "appcast error: Dory schema values must be positive integers" >&2; exit 1 ;; + esac +done +[ "$DORY_MINIMUM_READABLE_DATA_SCHEMA" -le "$DORY_DATA_SCHEMA_VERSION" ] \ + && [ "$DORY_DATA_SCHEMA_VERSION" -le "$DORY_MAXIMUM_READABLE_DATA_SCHEMA" ] \ + || { echo "appcast error: candidate readable data-schema range excludes its target schema" >&2; exit 1; } + +xml_escape() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' +} + +file_size_bytes() { + wc -c < "$1" | tr -d '[:space:]' +} + +find_sign_update() { + local found="" + if [ -n "${DORY_SPARKLE_SIGN_UPDATE:-}" ]; then + [ -x "$DORY_SPARKLE_SIGN_UPDATE" ] || { + echo "appcast error: DORY_SPARKLE_SIGN_UPDATE is not executable: $DORY_SPARKLE_SIGN_UPDATE" >&2 + exit 1 + } + printf '%s' "$DORY_SPARKLE_SIGN_UPDATE" + return 0 + fi + + for candidate in \ + ".build/artifacts/sparkle/Sparkle/bin/sign_update" \ + "SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + done + + if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then + found="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update' -type f -perm -111 2>/dev/null | sort | tail -n 1 || true)" + fi + if [ -n "$found" ]; then + printf '%s' "$found" + return 0 + fi + + echo "appcast error: Sparkle sign_update not found; set DORY_SPARKLE_SIGN_UPDATE or build once so SwiftPM resolves Sparkle" >&2 + exit 1 +} + +sparkle_signature() { + local tool signature + if [ -n "${DORY_SPARKLE_ED_SIGNATURE:-}" ]; then + printf '%s' "$DORY_SPARKLE_ED_SIGNATURE" + return 0 + fi + + if [ "${CI:-}" = "true" ] && [ -z "${DORY_SPARKLE_PRIVATE_KEY:-}" ] && [ -z "${DORY_SPARKLE_ACCOUNT:-}" ]; then + echo "appcast error: CI appcast signing requires DORY_SPARKLE_PRIVATE_KEY or DORY_SPARKLE_ACCOUNT" >&2 + exit 1 + fi + + tool="$(find_sign_update)" + if [ -n "${DORY_SPARKLE_PRIVATE_KEY:-}" ]; then + if [ -n "${DORY_SPARKLE_ACCOUNT:-}" ]; then + signature="$(printf '%s' "$DORY_SPARKLE_PRIVATE_KEY" | "$tool" --account "$DORY_SPARKLE_ACCOUNT" --ed-key-file - -p "$ARTIFACT")" + else + signature="$(printf '%s' "$DORY_SPARKLE_PRIVATE_KEY" | "$tool" --ed-key-file - -p "$ARTIFACT")" + fi + else + if [ -n "${DORY_SPARKLE_ACCOUNT:-}" ]; then + signature="$("$tool" --account "$DORY_SPARKLE_ACCOUNT" -p "$ARTIFACT")" + else + signature="$("$tool" -p "$ARTIFACT")" + fi + fi + + signature="$(printf '%s' "$signature" | tail -n 1 | tr -d '\r\n')" + [ -n "$signature" ] || { echo "appcast error: Sparkle signature was empty" >&2; exit 1; } + printf '%s' "$signature" +} + +append_previous_items() { + [ -f "$PREVIOUS" ] || return 0 + awk -v build="$BUILD" -v version="$VERSION" ' + // { + in_item = 1 + item = $0 ORS + next + } + in_item { + item = item $0 ORS + if ($0 ~ /<\/item>/) { + if (index(item, "" build "") == 0 && + index(item, "" version "") == 0) { + printf "%s", item + } + in_item = 0 + item = "" + } + } + ' "$PREVIOUS" +} + +SIGNATURE="$(sparkle_signature)" +LENGTH="$(file_size_bytes "$ARTIFACT")" +mkdir -p "$(dirname "$OUTPUT")" +TMP_OUTPUT="$(mktemp "${OUTPUT}.XXXXXX")" +trap 'rm -f "$TMP_OUTPUT"' EXIT + +{ + cat < + + + $(xml_escape "$APPCAST_TITLE") + $(xml_escape "$APPCAST_LINK") + $(xml_escape "$APPCAST_DESCRIPTION") + en + + $(xml_escape "$VERSION") + $(xml_escape "$PUBDATE") + $(xml_escape "$BUILD") + $(xml_escape "$VERSION") + $(xml_escape "$MINIMUM_SYSTEM_VERSION") + $(xml_escape "$DORY_DATA_SCHEMA_VERSION") + $(xml_escape "$DORY_MINIMUM_READABLE_DATA_SCHEMA") + $(xml_escape "$DORY_MAXIMUM_READABLE_DATA_SCHEMA") + $(xml_escape "$DORY_COMPONENT_CATALOG_SCHEMA") + + +EOF + append_previous_items + cat < + +EOF +} > "$TMP_OUTPUT" + +mv "$TMP_OUTPUT" "$OUTPUT" +trap - EXIT +printf '%s\n' "$OUTPUT" diff --git a/scripts/transactional-upgrade-gate.sh b/scripts/transactional-upgrade-gate.sh new file mode 100755 index 00000000..2a950cf3 --- /dev/null +++ b/scripts/transactional-upgrade-gate.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Static/offline contract plus exact interrupted-upgrade evidence verifier. With no arguments this +# runs hermetic source and CLI regressions. Evidence mode is intentionally fail-closed and is used +# by release qualification after a physical signed-candidate interruption campaign. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +usage() { + cat <<'EOF' +usage: + scripts/transactional-upgrade-gate.sh + scripts/transactional-upgrade-gate.sh --record PATH --interruption-evidence PATH --expect-state rolledBack|recoveryRequired + +No arguments runs the hermetic transaction, appcast, rollback, and CLI contract checks. +Evidence mode verifies a retained exact-candidate interrupted-update result. It never performs or +simulates a release-qualifying interruption by itself. +EOF +} + +fail() { echo "transactional upgrade gate failed: $*" >&2; exit 1; } + +static_gate() { + local tmp root transaction recovery + for needle in \ + 'dev.dory.upgrade.transaction' \ + 'validateReadyToInstall' \ + 'restoreConfigurationAndComponents' \ + 'durableDataWasRolledBack' \ + 'archive.signature' \ + 'DoryUpgradeRollbackHelper.launch' \ + 'runUpgradeSmokeTests' \ + 'volume.marker' \ + 'published.ports' \ + 'kubernetes.api'; do + grep -R -F "$needle" \ + Dory/App/Updater.swift Dory/Models/AppStore.swift \ + dory-core-swift/Sources/DoryOperations/DoryUpgradeTransaction.swift >/dev/null \ + || fail "source contract omits $needle" + done + grep -F 'try store.restoreConfigurationAndComponents(transactionID)' Dory/App/Updater.swift >/dev/null \ + || fail "configuration/component restore is not delegated until after the failed app exits" + if grep -F 'try transactionStore.restoreConfigurationAndComponents(record.id)' Dory/App/Updater.swift >/dev/null; then + fail "failed app can still restore preferences before terminating" + fi + grep -F 'to: .installing' Dory/App/Updater.swift >/dev/null \ + || fail "journal is not armed before Sparkle installation" + grep -F 'DoryComponentSelectionSnapshot' dory-core-swift/Sources/DoryOperations/DoryComponents.swift >/dev/null \ + || fail "component generation rollback snapshot is missing" + grep -F 'Set([installationName, priorInstallation].compactMap { $0 })' \ + dory-core-swift/Sources/DoryOperations/DoryComponents.swift >/dev/null \ + || fail "component store no longer retains a prior verified generation" + + python3 - <<'PY' +import xml.etree.ElementTree as ET + +dory = "https://augani.github.io/dory/appcast" +items = ET.parse("website/public/appcast.xml").getroot().findall("./channel/item") +if not items: + raise SystemExit("bootstrap appcast has no release items") +for item in items: + values = [item.findtext(f"{{{dory}}}{name}", "") for name in ( + "dataSchemaVersion", "minimumReadableDataSchema", + "maximumReadableDataSchema", "componentCatalogSchema", + )] + if values != ["1", "1", "1", "1"]: + raise SystemExit(f"bootstrap appcast schema contract is invalid: {values}") +PY + + bash -n scripts/dory scripts/generate-appcast.sh + python3 -m py_compile scripts/validate-release-metadata.py + + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dory-upgrade-gate.XXXXXX")" + trap 'rm -rf "$tmp"' RETURN + root="$tmp/upgrades" + transaction="$root/11111111-1111-1111-1111-111111111111" + recovery="$transaction/recovery" + mkdir -p "$recovery" + chmod 700 "$root" "$transaction" "$recovery" + python3 - "$transaction" <<'PY' +import json +import os +import pathlib +import sys + +directory = pathlib.Path(sys.argv[1]) +transaction_id = directory.name +record = { + "kind": "dev.dory.upgrade.transaction", + "schemaVersion": 1, + "id": transaction_id, + "state": "recoveryRequired", + "createdAt": "2026-07-18T00:00:00.000Z", + "updatedAt": "2026-07-18T00:01:00.000Z", + "priorVersion": "0.3.2", + "priorBuild": "43", + "candidate": {"version": "0.4.0", "build": "44"}, + "appSnapshot": {"build": "43"}, + "dataSnapshot": {"archivePath": "/tmp/last-good-data.dorybackup"}, + "smokeChecks": [{"id": "docker.api", "required": True, "passed": False}], + "error": "fixture smoke failure", + "recoveryDirectory": str(directory / "recovery"), +} +recovery = { + "schema": "dev.dory.upgrade.recovery", + "version": 1, + "transactionID": transaction_id, + "reason": "fixture smoke failure", + "durableDataWasRolledBack": False, + "exportCommand": "dory data verify /tmp/last-good-data.dorybackup", + "restoreCommand": "dory data restore /tmp/last-good-data.dorybackup /tmp/new.dorydrive", +} +for path, payload in ((directory / "transaction.json", record), (directory / "recovery/recovery.json", recovery)): + path.write_text(json.dumps(payload), encoding="utf-8") + os.chmod(path, 0o600) +PY + + DORY_UPGRADE_ROOT="$root" scripts/dory upgrade status --json \ + | python3 -c 'import json,sys; p=json.load(sys.stdin); (p.get("schema") == "dev.dory.upgrade.status" and p.get("transaction", {}).get("state") == "recoveryRequired") or sys.exit("upgrade status projection is invalid")' + DORY_UPGRADE_ROOT="$root" scripts/dory upgrade recovery --json \ + | python3 -c 'import json,sys; p=json.load(sys.stdin); p.get("recovery", {}).get("durableDataWasRolledBack") is False or sys.exit("upgrade recovery projection is invalid")' + + mv "$root" "$tmp/real-upgrades" + ln -s "$tmp/real-upgrades" "$root" + if DORY_UPGRADE_ROOT="$root" scripts/dory upgrade status --json >/dev/null 2>&1; then + fail "CLI followed a symlinked upgrade journal root" + fi + rm -f "$root" + mv "$tmp/real-upgrades" "$root" + rm -f "$transaction/transaction.json" + ln -s /etc/hosts "$transaction/transaction.json" + if DORY_UPGRADE_ROOT="$root" scripts/dory upgrade status --json >/dev/null 2>&1; then + fail "CLI followed a symlinked upgrade transaction" + fi + rm -rf "$tmp" + trap - RETURN + echo "transactional-upgrade-static: PASS" +} + +record="" +evidence="" +expected="" +if [ "$#" -eq 0 ]; then + static_gate + exit 0 +fi +while [ "$#" -gt 0 ]; do + case "$1" in + --record) [ "$#" -ge 2 ] || fail "$1 requires a path"; record="$2"; shift 2 ;; + --interruption-evidence) [ "$#" -ge 2 ] || fail "$1 requires a path"; evidence="$2"; shift 2 ;; + --expect-state) [ "$#" -ge 2 ] || fail "$1 requires a state"; expected="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done +[ -f "$record" ] && [ ! -L "$record" ] || fail "transaction record is missing or indirect" +[ -f "$evidence" ] && [ ! -L "$evidence" ] || fail "interruption evidence is missing or indirect" +record_logical="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$record")" +evidence_logical="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$evidence")" +record="$(cd "$(dirname "$record")" && printf '%s/%s\n' "$(pwd -P)" "$(basename "$record")")" +evidence="$(cd "$(dirname "$evidence")" && printf '%s/%s\n' "$(pwd -P)" "$(basename "$evidence")")" +[ "$record" = "$record_logical" ] || fail "transaction record has an indirect ancestor" +[ "$evidence" = "$evidence_logical" ] || fail "interruption evidence has an indirect ancestor" +case "$expected" in rolledBack|recoveryRequired) ;; *) fail "expected state must be rolledBack or recoveryRequired" ;; esac + +python3 - "$record" "$evidence" "$expected" <<'PY' +import hashlib +import json +import pathlib +import re +import sys + +record_path = pathlib.Path(sys.argv[1]) +evidence_path = pathlib.Path(sys.argv[2]) +expected = sys.argv[3] +def require(condition, message): + if not condition: + raise SystemExit(message) + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + +require(record_path.stat().st_size <= 2 * 1024 * 1024, "transaction record is too large") +require(evidence_path.stat().st_size <= 2 * 1024 * 1024, "interruption evidence is too large") +record_bytes = record_path.read_bytes() +record = json.loads(record_bytes, object_pairs_hook=unique_object) +evidence = json.loads(evidence_path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + +require(record.get("kind") == "dev.dory.upgrade.transaction" and record.get("schemaVersion") == 1, "invalid transaction schema") +require(record.get("state") == expected, f"transaction ended in {record.get('state')}, expected {expected}") +require(record.get("appSnapshot") and record.get("configurationSnapshots") is not None, "last-good app/config evidence is incomplete") +require(record.get("dataSnapshot") and record.get("markerVolume"), "verified data snapshot/runtime marker is incomplete") +selection = record.get("componentSelection") or {} +require(selection.get("kind") == "dev.dory.component-selection-snapshot" and selection.get("schemaVersion") == 1, "component selection snapshot is invalid") +candidate = record.get("candidate") or {} +require(candidate.get("archiveSignatureValidated") is True and candidate.get("enclosureSignatureDeclared") is True, "Sparkle enclosure signature declaration and archive validation were not both recorded") +failed = [check for check in record.get("smokeChecks", []) if check.get("required", True) and not check.get("passed")] +require(failed, "interrupted campaign did not retain a required smoke failure") +require(record.get("error"), "rollback has no failure reason") + +require(evidence.get("schema") == "dev.dory.upgrade.interruption-evidence" and evidence.get("version") == 1, "invalid interruption evidence schema") +require(evidence.get("transactionID") == str(record.get("id")).lower(), "evidence transaction mismatch") +require(evidence.get("transactionSHA256") == hashlib.sha256(record_bytes).hexdigest(), "evidence transaction digest mismatch") +require(re.fullmatch(r"[0-9a-f]{40}", evidence.get("sourceCommit", "")) is not None, "source commit is not exact") +require(re.fullmatch(r"[0-9a-f]{64}", evidence.get("candidateAppSHA256", "")) is not None, "candidate app digest is not exact") +require(evidence.get("priorBuild") == record.get("priorBuild") and evidence.get("candidateBuild") == candidate.get("build"), "build identity mismatch") +require(evidence.get("failureInjection") in {"post-install-smoke", "component-activation-interruption"}, "failure injection is not an allowed exact scenario") +require(evidence.get("automaticRollback") is (expected == "rolledBack"), "automatic rollback result mismatch") +require(evidence.get("durableDataWasRolledBack") is False, "campaign downgraded durable data") +require(evidence.get("durableSentinelBeforeSHA256") == evidence.get("durableSentinelAfterSHA256"), "durable data changed during app rollback") +if expected == "rolledBack": + require(record.get("candidate", {}).get("schema", {}).get("priorMaximumReadableSchema", 0) >= record.get("candidate", {}).get("schema", {}).get("targetDataSchema", 1), "rollback was not schema-safe") + require(evidence.get("finalAppBuild") == record.get("priorBuild"), "last-good app was not restored") +else: + require(record.get("recoveryDirectory") and evidence.get("recoveryExportVerified") is True, "unsafe schema path has no verified recovery export") +print("transactional-upgrade-evidence: PASS") +PY From f87cb12e98e269f57b296d92375e202c96f5ca6b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:02:36 +0000 Subject: [PATCH 157/338] test(storage): track data-drive lifecycle contracts --- scripts/test-data-drive-backup.sh | 127 +++++++++++++++++ scripts/test-data-drive-capacity.sh | 202 ++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100755 scripts/test-data-drive-backup.sh create mode 100755 scripts/test-data-drive-capacity.sh diff --git a/scripts/test-data-drive-backup.sh b/scripts/test-data-drive-backup.sh new file mode 100755 index 00000000..2f905d94 --- /dev/null +++ b/scripts/test-data-drive-backup.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode-26.6.0-Release.Candidate.app/Contents/Developer}" +export DEVELOPER_DIR +HELPER="${DORY_HV_BIN:-$REPO_ROOT/Packages/ContainerizationEngine/.build/debug/dory-hv}" + +if [ ! -x "$HELPER" ]; then + swift build --package-path "$REPO_ROOT/Packages/ContainerizationEngine" --product dory-hv +fi + +TMP_HOME="$(mktemp -d /tmp/dory-data-backup-gate.XXXXXX)" +trap 'rm -rf "$TMP_HOME"' EXIT +DRIVE="$TMP_HOME/Library/Application Support/Dory/Dory.dorydrive" +RESTORED="$TMP_HOME/Library/Application Support/Dory/Restored.dorydrive" +ARCHIVE="$TMP_HOME/Full.dorybackup" + +HOME="$TMP_HOME" "$HELPER" data-drive select "$DRIVE" >/dev/null +printf 'verified-cli-roundtrip\n' > "$DRIVE/engine/fixture.txt" +xattr -wx dev.dory.test 0001feff "$DRIVE/engine/fixture.txt" +truncate -s 33554432 "$DRIVE/engine/sparse.img" +printf head | dd of="$DRIVE/engine/sparse.img" bs=1 seek=0 conv=notrunc status=none +printf tail | dd of="$DRIVE/engine/sparse.img" bs=1 seek=33554428 conv=notrunc status=none + +HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" "$REPO_ROOT/scripts/dory" data backup "$ARCHIVE" \ + > "$TMP_HOME/backup.json" +HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" "$REPO_ROOT/scripts/dory" data verify "$ARCHIVE" \ + > "$TMP_HOME/verify.json" + +mv "$ARCHIVE/complete.json" "$ARCHIVE/complete.invalid" +if HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" "$REPO_ROOT/scripts/dory" data verify "$ARCHIVE" \ + > /dev/null 2>&1; then + echo "backup gate: incomplete archive unexpectedly verified" >&2 + exit 1 +fi +mv "$ARCHIVE/complete.invalid" "$ARCHIVE/complete.json" + +HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" "$REPO_ROOT/scripts/dory" data restore \ + "$ARCHIVE" "$RESTORED" > "$TMP_HOME/restore.json" +cmp "$DRIVE/engine/fixture.txt" "$RESTORED/engine/fixture.txt" +cmp "$DRIVE/engine/sparse.img" "$RESTORED/engine/sparse.img" +restored_xattr="$(xattr -px dev.dory.test "$RESTORED/engine/fixture.txt" \ + | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" +[ "$restored_xattr" = "0001feff" ] +logical="$(stat -f %z "$RESTORED/engine/sparse.img")" +allocated="$(( $(stat -f %b "$RESTORED/engine/sparse.img") * 512 ))" +[ "$logical" -eq 33554432 ] +[ "$allocated" -lt "$logical" ] + +mkdir -p "$TMP_HOME/Library/LaunchAgents" "$TMP_HOME/fake-bin" +cat > "$TMP_HOME/Library/LaunchAgents/dev.dory.doryd.plist" <<'PLIST' + + + +Labeldev.dory.doryd +ProgramArguments/Applications/Dory.app/Contents/Helpers/doryd + +PLIST +printf 'loaded\n' > "$TMP_HOME/.fake-launchctl-state" +cat > "$TMP_HOME/fake-bin/launchctl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >> "$HOME/.fake-launchctl-commands" +case "$1" in + print) [ "$(cat "$HOME/.fake-launchctl-state")" = loaded ] ;; + bootout) printf 'stopped\n' > "$HOME/.fake-launchctl-state" ;; + bootstrap) printf 'loaded\n' > "$HOME/.fake-launchctl-state" ;; + kickstart) exit 0 ;; + *) exit 64 ;; +esac +SH +cat > "$TMP_HOME/fake-bin/dorydctl" <<'SH' +#!/bin/sh +printf '1\n' +SH +chmod +x "$TMP_HOME/fake-bin/launchctl" "$TMP_HOME/fake-bin/dorydctl" + +if HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" \ + DORY_LAUNCHCTL_BIN="$TMP_HOME/fake-bin/launchctl" DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + "$REPO_ROOT/scripts/dory" data use "$TMP_HOME/Library/Application Support/Dory/Absent.dorydrive" \ + >/dev/null 2>&1; then + echo "backup gate: absent drive was unexpectedly selected" >&2 + exit 1 +fi +[ "$(cat "$TMP_HOME/.fake-launchctl-state")" = loaded ] + +HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" \ + DORY_LAUNCHCTL_BIN="$TMP_HOME/fake-bin/launchctl" DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + "$REPO_ROOT/scripts/dory" data use "$RESTORED" \ + > /dev/null +selected="$(HOME="$TMP_HOME" DORY_HV_BIN="$HELPER" "$REPO_ROOT/scripts/dory" data path)" +[ "$selected" = "$RESTORED" ] +grep -Fxq "bootout gui/$(id -u)/dev.dory.doryd" "$TMP_HOME/.fake-launchctl-commands" +grep -Fxq "bootstrap gui/$(id -u) $TMP_HOME/Library/LaunchAgents/dev.dory.doryd.plist" \ + "$TMP_HOME/.fake-launchctl-commands" +[ "$(cat "$TMP_HOME/.fake-launchctl-state")" = loaded ] + +if HOME="$TMP_HOME" PATH="/usr/bin:/bin" DORY_HV_BIN="$HELPER" \ + DORY_LAUNCHCTL_BIN="$TMP_HOME/fake-bin/launchctl" \ + DORYDCTL_BIN="$TMP_HOME/missing-dorydctl" \ + "$REPO_ROOT/scripts/dory" data use "$RESTORED" >/dev/null 2>&1; then + echo "backup gate: loaded daemon selection passed without a readiness client" >&2 + exit 1 +fi +[ "$(cat "$TMP_HOME/.fake-launchctl-state")" = loaded ] + +python3 - "$TMP_HOME/backup.json" "$TMP_HOME/verify.json" "$TMP_HOME/restore.json" <<'PY' +import json +import sys + +records = [] +for path in sys.argv[1:]: + with open(path, encoding="utf-8") as handle: + record = json.load(handle) + assert len(record["archiveManifestDigest"]) == 64 + assert record["entryCount"] >= 9 + assert record["logicalBytes"] > record["storedBytes"] + records.append(record) +assert records[0] == records[1] == records[2] +PY + +if find "$TMP_HOME" -name '*.partial' -print -quit | grep -q .; then + echo "backup gate: unpublished partial remained after success" >&2 + exit 1 +fi + +echo "Dory sparse data-drive backup/restore gate passed." diff --git a/scripts/test-data-drive-capacity.sh b/scripts/test-data-drive-capacity.sh new file mode 100755 index 00000000..28642533 --- /dev/null +++ b/scripts/test-data-drive-capacity.sh @@ -0,0 +1,202 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP_HOME="$(mktemp -d /tmp/dory-data-capacity-gate.XXXXXX)" +trap 'rm -rf "$TMP_HOME"' EXIT +BIN="$TMP_HOME/bin" +LOG="$TMP_HOME/calls.log" +mkdir -p "$BIN" + +cat > "$BIN/dory-hv" <<'SH' +#!/bin/bash +set -euo pipefail +printf 'helper %s\n' "$*" >> "$DORY_TEST_LOG" +[ "${1:-}" = data-drive ] || exit 64 +capacity_file="$HOME/.capacity" +capacity="$(cat "$capacity_file" 2>/dev/null || printf 128)" +case "${2:-}" in + capacity) + cat <&2 + exit 73 + } + printf '%s\n' "$3" > "$capacity_file" + capacity="$3" + cat < "$BIN/dorydctl" <<'SH' +#!/bin/bash +set -euo pipefail +printf 'ctl %s\n' "$*" >> "$DORY_TEST_LOG" +state_file="$HOME/.engine-state" +state="$(cat "$state_file" 2>/dev/null || printf stopped)" +command="${*: -2:1}" +argument="${*: -1}" +if [ "$command" != engine ]; then exit 64; fi +case "$argument" in + status) printf '{"state":"%s","detail":"test"}\n' "$state" ;; + stop) printf 'stopped\n' > "$state_file"; printf '{"ok":true}\n' ;; + start) + [ "${DORY_TEST_START_FAIL:-0}" != 1 ] || exit 74 + printf 'running\n' > "$state_file" + printf '{"ok":true}\n' + ;; + *) exit 64 ;; +esac +SH + +cat > "$BIN/docker" <<'SH' +#!/bin/bash +set -euo pipefail +printf 'docker %s\n' "$*" >> "$DORY_TEST_LOG" +shift 2 +case "${1:-}" in + ps) + [ "${DORY_TEST_DOCKER_PS_FAIL:-0}" != 1 ] || exit 75 + printf 'abc123\ndef456\n' + ;; + start) + case "${2:-}" in + abc123|def456) printf '%s\n' "$2" ;; + *) exit 76 ;; + esac + ;; + *) exit 64 ;; +esac +SH +chmod +x "$BIN/dory-hv" "$BIN/dorydctl" "$BIN/docker" + +run_dory() { + HOME="$TMP_HOME" \ + DORY_HV_BIN="$BIN/dory-hv" \ + DORYDCTL_BIN="$BIN/dorydctl" \ + DORY_DOCKER_BIN="$BIN/docker" \ + DORY_SOCK="$TMP_HOME/dory.sock" \ + DORY_TEST_LOG="$LOG" \ + "$REPO_ROOT/scripts/dory" "$@" +} + +reset_fixture() { + printf '128\n' > "$TMP_HOME/.capacity" + printf '%s\n' "${1:-running}" > "$TMP_HOME/.engine-state" + : > "$LOG" +} + +reset_fixture +run_dory data capacity --json | python3 -c ' +import json, sys +record = json.load(sys.stdin) +assert record["capacityGiB"] == 128 +assert record["allocatedBytes"] == 1073741824 +' +human="$(run_dory data capacity)" +printf '%s\n' "$human" | grep -Fq '128 GiB logical capacity' +printf '%s\n' "$human" | grep -Fq '1.00 GiB physically allocated' + +reset_fixture +run_dory data grow 128 --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["capacityGiB"] == 128' +grep -Fxq 'helper data-drive capacity' "$LOG" +[ "$(wc -l < "$LOG" | tr -d ' ')" -eq 1 ] +[ "$(cat "$TMP_HOME/.engine-state")" = running ] + +reset_fixture +if run_dory data grow 64 >/dev/null 2>"$TMP_HOME/range.err"; then + echo "capacity gate: below-minimum growth unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq 'between 128 and 2048 GiB' "$TMP_HOME/range.err" +! grep -q '^ctl ' "$LOG" + +reset_fixture +printf '256\n' > "$TMP_HOME/.capacity" +if run_dory data grow 128 >/dev/null 2>"$TMP_HOME/shrink.err"; then + echo "capacity gate: shrink unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq 'shrinking is not supported' "$TMP_HOME/shrink.err" +! grep -q '^ctl ' "$LOG" +[ "$(cat "$TMP_HOME/.capacity")" -eq 256 ] + +reset_fixture +run_dory data grow 256 --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["capacityGiB"] == 256' +[ "$(cat "$TMP_HOME/.capacity")" -eq 256 ] +[ "$(cat "$TMP_HOME/.engine-state")" = running ] +expected_success="$TMP_HOME/expected-success.log" +cat > "$expected_success" <<'LOG' +helper data-drive capacity +ctl --timeout 5 engine status +docker -H unix://DORY_SOCKET ps -q +ctl --timeout 250 engine stop +helper data-drive grow 256 +ctl --timeout 250 engine start +docker -H unix://DORY_SOCKET start abc123 +docker -H unix://DORY_SOCKET start def456 +LOG +sed "s|$TMP_HOME/dory.sock|DORY_SOCKET|g" "$LOG" > "$TMP_HOME/normalized.log" +cmp "$expected_success" "$TMP_HOME/normalized.log" + +reset_fixture +if DORY_TEST_HELPER_FAIL=1 run_dory data grow 256 >/dev/null 2>"$TMP_HOME/grow-failure.err"; then + echo "capacity gate: injected growth failure unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq 'injected capacity growth failure' "$TMP_HOME/grow-failure.err" +[ "$(cat "$TMP_HOME/.capacity")" -eq 128 ] +[ "$(cat "$TMP_HOME/.engine-state")" = running ] +grep -Fxq 'ctl --timeout 250 engine start' "$LOG" +grep -Fq 'docker -H ' "$LOG" +grep -Fq ' start abc123' "$LOG" +grep -Fq ' start def456' "$LOG" + +for preserved_state in stopped sleeping failed; do + reset_fixture "$preserved_state" + run_dory data grow 256 --json >/dev/null + [ "$(cat "$TMP_HOME/.capacity")" -eq 256 ] + [ "$(cat "$TMP_HOME/.engine-state")" = "$preserved_state" ] + grep -Fxq 'ctl --timeout 5 engine status' "$LOG" + ! grep -q 'engine stop\|engine start\|docker ' "$LOG" +done + +reset_fixture starting +if run_dory data grow 256 >/dev/null 2>"$TMP_HOME/starting.err"; then + echo "capacity gate: transient starting state unexpectedly allowed growth" >&2 + exit 1 +fi +grep -Fq 'engine is still starting' "$TMP_HOME/starting.err" +[ "$(cat "$TMP_HOME/.capacity")" -eq 128 ] +[ "$(cat "$TMP_HOME/.engine-state")" = starting ] +! grep -q 'engine stop\|data-drive grow' "$LOG" + +reset_fixture +if DORY_TEST_DOCKER_PS_FAIL=1 run_dory data grow 256 >/dev/null 2>"$TMP_HOME/ps-failure.err"; then + echo "capacity gate: unverifiable running set unexpectedly allowed growth" >&2 + exit 1 +fi +grep -Fq 'could not verify running containers' "$TMP_HOME/ps-failure.err" +[ "$(cat "$TMP_HOME/.capacity")" -eq 128 ] +[ "$(cat "$TMP_HOME/.engine-state")" = running ] +! grep -q 'engine stop' "$LOG" + +reset_fixture unknown +if run_dory data grow 256 >/dev/null 2>"$TMP_HOME/state-failure.err"; then + echo "capacity gate: unknown engine state unexpectedly allowed growth" >&2 + exit 1 +fi +grep -Fq 'unrecognized engine state' "$TMP_HOME/state-failure.err" +[ "$(cat "$TMP_HOME/.capacity")" -eq 128 ] +[ "$(cat "$TMP_HOME/.engine-state")" = unknown ] +! grep -q 'engine stop\|data-drive grow' "$LOG" + +echo "Dory data-drive capacity lifecycle gate passed." From 3b4a6679129cd07a789e59d4f0c6bd62d3ada54a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:04:23 +0000 Subject: [PATCH 158/338] feat(release): verify physical sleep evidence --- scripts/test-sleep-wake-evidence.sh | 184 +++++++++++++++ scripts/verify-sleep-wake-evidence.py | 309 ++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100755 scripts/test-sleep-wake-evidence.sh create mode 100755 scripts/verify-sleep-wake-evidence.py diff --git a/scripts/test-sleep-wake-evidence.sh b/scripts/test-sleep-wake-evidence.sh new file mode 100755 index 00000000..487b1a42 --- /dev/null +++ b/scripts/test-sleep-wake-evidence.sh @@ -0,0 +1,184 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-sleep-evidence.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +COMMIT=0123456789abcdef0123456789abcdef01234567 +RUN_ID=1234 +ATTEMPT=2 +CUSTOM_DNS=10.20.30.40 +PROBE_HOST=registry.corp.example +PROBE_URL=https://registry.corp.example/v2/ +TAILSCALE_EXIT_NODE=release-exit-node.example.ts.net +APP="$TMP/Dory.app" +EVIDENCE="$TMP/evidence" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Helpers" "$APP/Contents/Resources" "$EVIDENCE" +printf app > "$APP/Contents/MacOS/Dory" +printf docker > "$APP/Contents/Helpers/docker" +printf doryd > "$APP/Contents/Helpers/doryd" +printf hv > "$APP/Contents/Helpers/dory-hv" +printf ctl > "$APP/Contents/Helpers/dorydctl" +printf kernel > "$APP/Contents/Resources/dory-hv-kernel-arm64" +printf rootfs > "$APP/Contents/Resources/dory-machine-rootfs-arm64.ext4" + +sha() { shasum -a 256 "$1" | awk '{print $1}'; } +text_sha() { printf '%s' "$1" | shasum -a 256 | awk '{print $1}'; } +cat > "$EVIDENCE/manifest.txt" < "$EVIDENCE/route-churn-results.tsv" +for round in 1 2 3; do + printf '%s\texit-node-active\tPASS\thost/container/API remained reachable\n' "$round" \ + >> "$EVIDENCE/route-churn-results.tsv" + printf '%s\tbaseline-restored\tPASS\troute/DNS/proxy contract and Docker recovered\n' \ + "$round" >> "$EVIDENCE/route-churn-results.tsv" + for phase in enabled restored; do + route_dir="$EVIDENCE/route-churn-$round-$phase" + mkdir -p "$route_dir" + printf '{"BackendState":"Running"}\n' > "$route_dir/tailscale-status.json" + done + for contract in default-route dns proxy service-dns resolvers; do + : > "$EVIDENCE/route-churn-$round-restored/$contract.contract.diff" + done +done +printf 'cycle\tphase\tstatus\tdetail\n' > "$EVIDENCE/results.tsv" +for cycle in 1 2 3 4 5; do + printf '%s\tmachine-session-pre-sleep\tPASS\tinteractive shell ready token observed\n' \ + "$cycle" >> "$EVIDENCE/results.tsv" + printf '%s\tpre-sleep\tPASS\tcontract and probes healthy\n' "$cycle" >> "$EVIDENCE/results.tsv" + printf '%s\tsleep-resume\tPASS\telapsed_seconds=30 scheduled_wake_seconds=30\n' \ + "$cycle" >> "$EVIDENCE/results.tsv" + printf '%s\tpost-wake\tPASS\thost/container probes and network contract preserved\n' \ + "$cycle" >> "$EVIDENCE/results.tsv" + printf '%s\tmachine-session-reconnect\tPASS\tfresh exec, stop/start, and disk marker verified\n' \ + "$cycle" >> "$EVIDENCE/results.tsv" + printf 'DORY_SESSION_READY_%s_20260712T120000Z-123\n' "$cycle" \ + > "$EVIDENCE/cycle-$cycle-machine-shell.out" + for pair in machine-status-after-wake:running machine-status-stopped:stopped \ + machine-status-restarted:running; do + suffix="${pair%%:*}" + state="${pair#*:}" + printf '{"id":"dory-sleep-session-20260712T120000Z-123","state":"%s"}\n' "$state" \ + > "$EVIDENCE/cycle-$cycle-$suffix.json" + done + for pair in machine-reconnect:dory-machine-reconnect \ + machine-restart-persistence:dory-machine-restart; do + suffix="${pair%%:*}" + token="${pair#*:}-$cycle" + printf '{"schema":"dev.dory.machine.exec","version":1,"machine":"dory-sleep-session-20260712T120000Z-123","exitCode":0,"timedOut":false,"stdout":"%s","stdoutTruncated":false,"stderrTruncated":false}\n' \ + "$token" > "$EVIDENCE/cycle-$cycle-$suffix.json" + done + printf 'Scheduled wake event\n' > "$EVIDENCE/cycle-$cycle-scheduled-wake.txt" + printf 'Entering Sleep\nDarkWake\nWake from Normal Sleep\n' > "$EVIDENCE/cycle-$cycle-pmset-log.txt" + mkdir -p "$EVIDENCE/cycle-$cycle-after" + printf 'fixture contract\n' > "$EVIDENCE/cycle-$cycle-after/contract.sha256" + for contract in default-route dns proxy service-dns resolvers; do + : > "$EVIDENCE/cycle-$cycle-after/$contract.contract.diff" + done +done + +verify() { + scripts/verify-sleep-wake-evidence.py \ + --manifest "$1/manifest.txt" \ + --results "$1/results.tsv" \ + --evidence-root "$1" \ + --app "$APP" \ + --source-commit "$COMMIT" \ + --run-id "$RUN_ID" \ + --run-attempt "$ATTEMPT" \ + --cycles 5 \ + --auto-wake-seconds 30 \ + --custom-dns "$CUSTOM_DNS" \ + --probe-host "$PROBE_HOST" \ + --probe-url "$PROBE_URL" \ + --tailscale-exit-node "$TAILSCALE_EXIT_NODE" +} + +verify "$EVIDENCE" >/dev/null + +expect_failure() { + local fixture="$1" label="$2" + if verify "$fixture" >/dev/null 2>&1; then + echo "test-sleep-wake-evidence: accepted $label" >&2 + exit 1 + fi +} + +cp -R "$EVIDENCE" "$TMP/failed-row" +sed -i '' $'s/1\tpost-wake\tPASS/1\tpost-wake\tFAIL/' "$TMP/failed-row/results.tsv" +expect_failure "$TMP/failed-row" "a failed post-wake row" + +cp -R "$EVIDENCE" "$TMP/missing-machine-reconnect" +sed -i '' $'/1\tmachine-session-reconnect\t/d' "$TMP/missing-machine-reconnect/results.tsv" +expect_failure "$TMP/missing-machine-reconnect" "a missing post-sleep machine reconnect" + +cp -R "$EVIDENCE" "$TMP/wedged-machine-stop" +sed -i '' 's/"state":"stopped"/"state":"running"/' \ + "$TMP/wedged-machine-stop/cycle-2-machine-status-stopped.json" +expect_failure "$TMP/wedged-machine-stop" "a machine stop that remained wedged" + +cp -R "$EVIDENCE" "$TMP/short-sleep" +sed -i '' 's/elapsed_seconds=30/elapsed_seconds=1/' "$TMP/short-sleep/results.tsv" +expect_failure "$TMP/short-sleep" "a sleep command that returned immediately" + +cp -R "$EVIDENCE" "$TMP/no-wake-log" +printf 'Entering Sleep only\n' > "$TMP/no-wake-log/cycle-3-pmset-log.txt" +expect_failure "$TMP/no-wake-log" "a pmset log without a wake event" + +cp -R "$EVIDENCE" "$TMP/changed-contract" +printf 'route changed\n' > "$TMP/changed-contract/cycle-4-after/default-route.contract.diff" +expect_failure "$TMP/changed-contract" "a changed post-wake network contract" + +cp -R "$EVIDENCE" "$TMP/wrong-private-network" +sed -i '' 's/vpn_required=true/vpn_required=false/' "$TMP/wrong-private-network/manifest.txt" +expect_failure "$TMP/wrong-private-network" "a non-VPN sleep campaign" + +cp -R "$EVIDENCE" "$TMP/route-churn-not-restored" +printf 'default route remained on exit node\n' \ + > "$TMP/route-churn-not-restored/route-churn-2-restored/default-route.contract.diff" +expect_failure "$TMP/route-churn-not-restored" "an exit-node route that did not self-heal" + +cp -R "$EVIDENCE" "$TMP/indirect-evidence" +rm "$TMP/indirect-evidence/manifest.txt" +ln -s "$EVIDENCE/manifest.txt" "$TMP/indirect-evidence/manifest.txt" +expect_failure "$TMP/indirect-evidence" "a symlinked retained manifest" + +cp -R "$EVIDENCE" "$TMP/duplicate-json" +printf '{"id":"dory-sleep-session-20260712T120000Z-123","state":"stopped","state":"running"}\n' \ + > "$TMP/duplicate-json/cycle-2-machine-status-stopped.json" +expect_failure "$TMP/duplicate-json" "duplicate JSON authority keys" + +cp -R "$EVIDENCE" "$TMP/wrong-app" +printf tampered > "$APP/Contents/Helpers/doryd" +expect_failure "$TMP/wrong-app" "a sleep result bound to different candidate binaries" + +echo "test-sleep-wake-evidence: PASS" diff --git a/scripts/verify-sleep-wake-evidence.py b/scripts/verify-sleep-wake-evidence.py new file mode 100755 index 00000000..b00ff53a --- /dev/null +++ b/scripts/verify-sleep-wake-evidence.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Semantically verify exact-candidate physical sleep/wake release evidence.""" + +import argparse +import csv +import hashlib +import json +import os +import pathlib +import re +import sys + + +def digest(path: pathlib.Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def text_digest(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +def properties(path: pathlib.Path) -> dict[str, str]: + values: dict[str, str] = {} + with path.open(encoding="utf-8") as handle: + for raw in handle: + key, separator, value = raw.rstrip("\n").partition("=") + if not separator or key in values: + raise ValueError(f"malformed manifest row: {raw!r}") + values[key] = value + return values + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def direct_path(path: pathlib.Path, label: str, *, directory: bool) -> pathlib.Path: + logical = pathlib.Path(os.path.abspath(path)) + require(not logical.is_symlink(), f"{label} is indirect") + require(logical.is_dir() if directory else logical.is_file(), f"{label} is missing") + require(logical.resolve() == logical, f"{label} has an indirect ancestor") + return logical + + +def unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_evidence_json(path: pathlib.Path, evidence_root: pathlib.Path, label: str) -> object: + require(path.is_file() and not path.is_symlink(), f"{label} is missing or indirect") + require(path.resolve().is_relative_to(evidence_root), f"{label} escapes the evidence root") + require(path.stat().st_size <= 2 * 1024 * 1024, f"{label} is too large") + return json.loads(path.read_bytes(), object_pairs_hook=unique_json_object) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", required=True, type=pathlib.Path) + parser.add_argument("--results", required=True, type=pathlib.Path) + parser.add_argument("--evidence-root", required=True, type=pathlib.Path) + parser.add_argument("--app", required=True, type=pathlib.Path) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--run-attempt", required=True) + parser.add_argument("--cycles", type=int, default=5) + parser.add_argument("--auto-wake-seconds", type=int, default=30) + parser.add_argument("--custom-dns", required=True) + parser.add_argument("--probe-host", required=True) + parser.add_argument("--probe-url", required=True) + parser.add_argument("--tailscale-exit-node", required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if not re.fullmatch(r"[0-9a-f]{40}", args.source_commit): + raise ValueError("source commit must be a full lowercase Git SHA") + if args.cycles <= 0 or args.auto_wake_seconds <= 0: + raise ValueError("cycles and auto-wake seconds must be positive") + args.evidence_root = direct_path(args.evidence_root, "evidence root", directory=True) + args.manifest = direct_path(args.manifest, "sleep/wake manifest", directory=False) + args.results = direct_path(args.results, "sleep/wake results", directory=False) + args.app = direct_path(args.app, "candidate app", directory=True) + require(args.manifest.parent == args.evidence_root, "sleep/wake manifest is outside the evidence root") + require(args.results.parent == args.evidence_root, "sleep/wake results are outside the evidence root") + require(args.manifest.stat().st_size <= 64 * 1024, "sleep/wake manifest is too large") + require(args.results.stat().st_size <= 4 * 1024 * 1024, "sleep/wake results are too large") + for path in args.evidence_root.rglob("*"): + require(not path.is_symlink(), f"sleep/wake evidence contains a symlink: {path}") + + manifest = properties(args.manifest) + expected_keys = { + "run_id", + "cycles", + "auto_wake_seconds", + "physical_sleep", + "wifi_required", + "vpn_required", + "custom_dns_required", + "route_churn", + "route_churn_rounds", + "release_qualifying", + "source_commit", + "github_run_id", + "github_run_attempt", + "app_executable_sha256", + "docker_sha256", + "doryd_sha256", + "dory_hv_sha256", + "dorydctl_sha256", + "machine_kernel_sha256", + "machine_rootfs_sha256", + "machine_id", + "machine_session_reconnect", + "custom_dns_sha256", + "probe_host_sha256", + "probe_url_sha256", + "tailscale_exit_node_sha256", + } + if set(manifest) != expected_keys: + raise ValueError(f"sleep/wake manifest keys differ: {sorted(set(manifest) ^ expected_keys)}") + if not re.fullmatch(r"[0-9]{8}T[0-9]{6}Z-[0-9]+", manifest["run_id"]): + raise ValueError("sleep/wake run ID is malformed") + expected = { + "cycles": str(args.cycles), + "auto_wake_seconds": str(args.auto_wake_seconds), + "physical_sleep": "true", + "wifi_required": "true", + "vpn_required": "true", + "custom_dns_required": "true", + "route_churn": "PASS", + "route_churn_rounds": "3", + "release_qualifying": "true", + "source_commit": args.source_commit, + "github_run_id": args.run_id, + "github_run_attempt": args.run_attempt, + "machine_session_reconnect": "PASS", + } + for key, value in expected.items(): + if manifest.get(key) != value: + raise ValueError(f"sleep/wake {key} mismatch: {manifest.get(key)!r} != {value!r}") + + binaries = { + "app_executable_sha256": args.app / "Contents/MacOS/Dory", + "docker_sha256": args.app / "Contents/Helpers/docker", + "doryd_sha256": args.app / "Contents/Helpers/doryd", + "dory_hv_sha256": args.app / "Contents/Helpers/dory-hv", + "dorydctl_sha256": args.app / "Contents/Helpers/dorydctl", + "machine_kernel_sha256": args.app / "Contents/Resources/dory-hv-kernel-arm64", + "machine_rootfs_sha256": args.app / "Contents/Resources/dory-machine-rootfs-arm64.ext4", + } + for key, path in binaries.items(): + if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(args.app) \ + or manifest.get(key) != digest(path): + raise ValueError(f"sleep/wake binary mismatch: {key}") + if manifest["machine_id"] != f"dory-sleep-session-{manifest['run_id']}": + raise ValueError("sleep/wake machine ID is not bound to the evidence run") + + private_contract = { + "custom_dns_sha256": text_digest(args.custom_dns), + "probe_host_sha256": text_digest(args.probe_host), + "probe_url_sha256": text_digest(args.probe_url), + "tailscale_exit_node_sha256": text_digest(args.tailscale_exit_node), + } + for key, value in private_contract.items(): + if manifest.get(key) != value: + raise ValueError(f"sleep/wake private network contract mismatch: {key}") + + route_results_path = args.evidence_root / "route-churn-results.tsv" + with route_results_path.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, delimiter="\t") + require(reader.fieldnames == ["round", "phase", "status", "detail"], "route-churn results schema mismatch") + route_rows = list(reader) + expected_route_rows = { + (str(round_number), phase) + for round_number in range(1, 4) + for phase in ("exit-node-active", "baseline-restored") + } + route_by_key = {(row.get("round"), row.get("phase")): row for row in route_rows} + if len(route_by_key) != len(route_rows) or set(route_by_key) != expected_route_rows: + raise ValueError("exit-node route-churn evidence is incomplete or duplicated") + if any(row.get("status") != "PASS" for row in route_rows): + raise ValueError("exit-node route-churn evidence contains a failure") + enabled_status = sorted(args.evidence_root.glob("route-churn-*-enabled/tailscale-status.json")) + restored_status = sorted(args.evidence_root.glob("route-churn-*-restored/tailscale-status.json")) + restored_diffs = sorted(args.evidence_root.glob("route-churn-*-restored/*.contract.diff")) + if len(enabled_status) != 3 or len(restored_status) != 3 or len(restored_diffs) != 15: + raise ValueError("exit-node route-churn artifacts are incomplete") + if any(path.stat().st_size == 0 for path in enabled_status + restored_status): + raise ValueError("exit-node route-churn Tailscale status evidence is empty") + if any(path.stat().st_size != 0 for path in restored_diffs): + raise ValueError("host network contract did not restore after exit-node churn") + + with args.results.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, delimiter="\t") + rows = list(reader) + expected_fields = ["cycle", "phase", "status", "detail"] + if not rows or reader.fieldnames != expected_fields: + raise ValueError("sleep/wake results schema mismatch") + expected_rows = { + (str(cycle), phase) + for cycle in range(1, args.cycles + 1) + for phase in ( + "machine-session-pre-sleep", + "pre-sleep", + "sleep-resume", + "machine-session-reconnect", + "post-wake", + ) + } + by_key = {(row["cycle"], row["phase"]): row for row in rows} + if len(by_key) != len(rows) or set(by_key) != expected_rows: + raise ValueError("sleep/wake evidence rows are incomplete or duplicated") + if any(row["status"] != "PASS" for row in rows): + raise ValueError("sleep/wake evidence contains a failure") + minimum_sleep = max(5, args.auto_wake_seconds // 2) + detail_pattern = re.compile( + rf"elapsed_seconds=([0-9]+) scheduled_wake_seconds={args.auto_wake_seconds}" + ) + for cycle in range(1, args.cycles + 1): + detail = by_key[(str(cycle), "sleep-resume")]["detail"] + match = detail_pattern.fullmatch(detail) + if match is None or int(match.group(1)) < minimum_sleep: + raise ValueError(f"cycle {cycle} did not prove physical sleep") + if by_key[(str(cycle), "machine-session-pre-sleep")]["detail"] != \ + "interactive shell ready token observed": + raise ValueError(f"cycle {cycle} did not prove a live pre-sleep machine shell") + if by_key[(str(cycle), "machine-session-reconnect")]["detail"] != \ + "fresh exec, stop/start, and disk marker verified": + raise ValueError(f"cycle {cycle} did not prove machine reconnect and restart") + + shell_output = args.evidence_root / f"cycle-{cycle}-machine-shell.out" + token = f"DORY_SESSION_READY_{cycle}_{manifest['run_id']}" + if not shell_output.is_file() or token not in shell_output.read_text(errors="replace"): + raise ValueError(f"cycle {cycle} interactive machine shell evidence is missing") + + status_files = { + "machine-status-after-wake": "running", + "machine-status-stopped": "stopped", + "machine-status-restarted": "running", + } + for suffix, state in status_files.items(): + path = args.evidence_root / f"cycle-{cycle}-{suffix}.json" + try: + payload = load_evidence_json(path, args.evidence_root, f"cycle {cycle} {suffix}") + except (OSError, ValueError, json.JSONDecodeError) as error: + raise ValueError(f"cycle {cycle} machine status evidence is invalid: {suffix}") from error + if payload.get("id") != manifest["machine_id"] or payload.get("state") != state: + raise ValueError(f"cycle {cycle} machine status evidence mismatch: {suffix}") + + exec_files = { + "machine-reconnect": f"dory-machine-reconnect-{cycle}", + "machine-restart-persistence": f"dory-machine-restart-{cycle}", + } + for suffix, expected_stdout in exec_files.items(): + path = args.evidence_root / f"cycle-{cycle}-{suffix}.json" + try: + payload = load_evidence_json(path, args.evidence_root, f"cycle {cycle} {suffix}") + except (OSError, ValueError, json.JSONDecodeError) as error: + raise ValueError(f"cycle {cycle} machine exec evidence is invalid: {suffix}") from error + expected_exec = { + "schema": "dev.dory.machine.exec", + "version": 1, + "machine": manifest["machine_id"], + "exitCode": 0, + "timedOut": False, + "stdout": expected_stdout, + "stdoutTruncated": False, + "stderrTruncated": False, + } + if any(payload.get(key) != value for key, value in expected_exec.items()): + raise ValueError(f"cycle {cycle} machine exec evidence mismatch: {suffix}") + + scheduled = sorted(args.evidence_root.glob("cycle-*-scheduled-wake.txt")) + power_logs = sorted(args.evidence_root.glob("cycle-*-pmset-log.txt")) + if len(scheduled) != args.cycles or len(power_logs) != args.cycles: + raise ValueError("sleep/wake power evidence is incomplete") + for path in scheduled: + if "wake" not in path.read_text(errors="replace").lower(): + raise ValueError(f"scheduled hardware wake evidence is missing: {path}") + for path in power_logs: + content = path.read_text(errors="replace").lower() + if "sleep" not in content or "wake" not in content: + raise ValueError(f"pmset log does not contain sleep and wake events: {path}") + + after_contracts = sorted(args.evidence_root.glob("cycle-*-after/contract.sha256")) + contract_diffs = sorted(args.evidence_root.glob("cycle-*-after/*.contract.diff")) + if len(after_contracts) != args.cycles or len(contract_diffs) != args.cycles * 5: + raise ValueError("post-wake host network-contract evidence is incomplete") + if any(path.stat().st_size != 0 for path in contract_diffs): + raise ValueError("host network contract changed after wake") + + print("physical sleep/wake evidence: PASS") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, csv.Error, json.JSONDecodeError) as error: + raise SystemExit(f"sleep/wake evidence error: {error}") from error From 7e293b3e36150b5a5517fc57b841b013a1ca296e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:05:06 +0000 Subject: [PATCH 159/338] test(compat): track protocol consumer contracts --- scripts/compat-smoke.sh | 78 ++++++++++++++++++++++++ scripts/test-agent-protocol-consumers.sh | 35 +++++++++++ 2 files changed, 113 insertions(+) create mode 100755 scripts/compat-smoke.sh create mode 100755 scripts/test-agent-protocol-consumers.sh diff --git a/scripts/compat-smoke.sh b/scripts/compat-smoke.sh new file mode 100755 index 00000000..7ea6b3b0 --- /dev/null +++ b/scripts/compat-smoke.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Compatibility CI harness (Track 4). +# +# Two tiers: +# 1. Structural — every registered tool is checked and every recipe carries a verification +# command. Needs no engine, so it runs in plain CI to guard the compatibility surface. +# 2. Engine-backed — when a live Dory socket is present (release readiness), run the real +# docker/act smoke tests against it. Absent an engine or a tool, that tier is skipped, never +# failed, so this script is safe to run anywhere. +set -euo pipefail +cd "$(dirname "$0")/.." + +DOCTOR="${DORY_DOCTOR_BIN:-scripts/dory-doctor}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +"$DOCTOR" compat --recipe --json > "$WORKDIR/recipes.json" +python3 - "$WORKDIR/recipes.json" <<'PY' +import json +import sys + +tools = json.load(open(sys.argv[1], encoding="utf-8"))["tools"] +assert tools, "compat exposed no recipes" +for name, recipe in tools.items(): + assert recipe.get("title"), f"{name}: recipe has no title" + assert recipe.get("steps"), f"{name}: recipe has no steps" + assert recipe.get("verify"), f"{name}: recipe has no verification command" +print(f"compat-smoke: {len(tools)} recipes, all carry a verification command") +PY + +"$DOCTOR" compat --json > "$WORKDIR/compat.json" || true +python3 - "$WORKDIR/compat.json" <<'PY' +import json +import sys + +results = json.load(open(sys.argv[1], encoding="utf-8"))["results"] +checked = {r["id"].split(".")[1] for r in results} +required = {"docker", "compose", "testcontainers", "act", "kubernetes", "vscode", "cursor", "supabase", "localstack", "amd64"} +missing = required - checked +assert not missing, f"compat did not check: {sorted(missing)}" +for result in results: + assert result["status"] in {"pass", "warn", "fail", "skip"}, result + if result["status"] in {"fail", "warn"}: + assert result.get("action") or result.get("detail"), f"{result['id']} has no actionable message" +print(f"compat-smoke: checked {len(checked)} tools, non-pass results are all actionable") +PY + +ENGINE_SOCK="${DORY_SOCK:-$HOME/.dory/dory.sock}" +if [ -S "$ENGINE_SOCK" ] && curl -fsS --max-time 3 --unix-socket "$ENGINE_SOCK" http://d/_ping >/dev/null 2>&1; then + export DOCKER_HOST="unix://$ENGINE_SOCK" + DOCKER_BIN="${DORY_DOCKER_BIN:-$(command -v docker || echo docker)}" + + "$DOCKER_BIN" run --rm "${DORY_COMPAT_IMAGE:-alpine:latest}" true + echo "compat-smoke: docker run against the Dory socket OK (Testcontainers/LocalStack host detection path)" + + if command -v act >/dev/null 2>&1; then + ACT_DIR="$WORKDIR/act" + mkdir -p "$ACT_DIR/.github/workflows" + cat > "$ACT_DIR/.github/workflows/smoke.yml" <<'YAML' +name: smoke +on: [push] +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: "true" +YAML + ( cd "$ACT_DIR" && DOCKER_HOST="unix://$ENGINE_SOCK" \ + act --container-daemon-socket unix:///var/run/docker.sock -l >/dev/null ) + echo "compat-smoke: act enumerated workflows against the Dory socket" + else + echo "compat-smoke: act not installed; skipped its engine smoke" + fi +else + echo "compat-smoke: no live Dory socket; ran structural checks only (CI mode)" +fi + +echo "compat-smoke: PASS" diff --git a/scripts/test-agent-protocol-consumers.sh b/scripts/test-agent-protocol-consumers.sh new file mode 100755 index 00000000..4e308634 --- /dev/null +++ b/scripts/test-agent-protocol-consumers.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Offline regression gate for shell consumers of the guest-control surface. These scripts must use +# typed DoryCore/doryd paths (or fail closed), never revive the retired BE-length + JSON protocol. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +bash -n scripts/dory +bash -n scripts/readiness.sh + +if grep -Eq 'struct\.pack\(">I"|debug\.shell|^[[:space:]]*agent_rpc(_readiness)?\(\)' \ + scripts/dory scripts/readiness.sh; then + echo "legacy JSON agent wire consumer found" >&2 + exit 1 +fi + +set +e +debug_output="$(scripts/dory debug example 2>&1)" +debug_status=$? +set -e +[ "$debug_status" -eq 2 ] +printf '%s\n' "$debug_output" | grep -q 'protocol v1 has no namespace-debug RPC' + +clock_body="$(sed -n '/^test_clock_sync() {/,/^}/p' scripts/readiness.sh)" +printf '%s\n' "$clock_body" | grep -q 'dorydctl docker clock-sync' +if printf '%s\n' "$clock_body" | grep -Eq 'date[[:space:]].*-s|kill[[:space:]]+-USR1|--privileged'; then + echo "clock readiness must not mutate a live VM or signal an unchecked PID" >&2 + exit 1 +fi + +grep -q 'dory-agent protocol v1 has no guest vhci attach/detach RPC' scripts/readiness.sh +grep -q 'dory-agent protocol v1 has no namespace-debug RPC' scripts/readiness.sh + +echo "agent protocol shell consumers: ok" From 96385497960c473dff5789eb9986e1f9edaff781 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:05:36 +0000 Subject: [PATCH 160/338] test(benchmark): track offline campaign contracts --- scripts/test-benchmark-campaign.sh | 146 +++++++++++++++ scripts/test-benchmark-developer-workflows.sh | 71 +++++++ scripts/test-benchmark-external-network.sh | 177 ++++++++++++++++++ scripts/test-benchmark-user-workflows.sh | 121 ++++++++++++ 4 files changed, 515 insertions(+) create mode 100755 scripts/test-benchmark-campaign.sh create mode 100755 scripts/test-benchmark-developer-workflows.sh create mode 100755 scripts/test-benchmark-external-network.sh create mode 100755 scripts/test-benchmark-user-workflows.sh diff --git a/scripts/test-benchmark-campaign.sh b/scripts/test-benchmark-campaign.sh new file mode 100755 index 00000000..f25f165e --- /dev/null +++ b/scripts/test-benchmark-campaign.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Offline safety/argument tests for benchmark-campaign.sh. No engine, app, package manager, Docker +# socket, or removal command is accessed. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HARNESS="$ROOT/scripts/benchmark-campaign.sh" +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dory-campaign-test.XXXXXX")" +cleanup() { /bin/rm -rf "$TMP_ROOT"; } +trap cleanup EXIT +fail() { echo "benchmark-campaign test failed: $*" >&2; exit 1; } + +FAKE_BIN="$TMP_ROOT/fake-bin" +MUTATION_LOG="$TMP_ROOT/mutations.log" +/bin/mkdir -p "$FAKE_BIN" "$TMP_ROOT/home" +: > "$MUTATION_LOG" + +# Every command that could mutate an installed engine or the filesystem is a tripwire. A safe +# --help/error/--dry-run path must not execute any of them. The harness may still use read-only +# platform commands such as date, df, and awk. +for command in mkdir rm brew orb colima podman open osascript pkill docker codesign sleep tee env; do + printf '%s\n' \ + '#!/bin/sh' \ + 'printf "MUTATION %s %s\n" "$0" "$*" >> "$MUTATION_LOG"' \ + 'exit 99' > "$FAKE_BIN/$command" + chmod +x "$FAKE_BIN/$command" +done + +safe_env() { + env PATH="$FAKE_BIN:$PATH" HOME="$TMP_ROOT/home" MUTATION_LOG="$MUTATION_LOG" "$@" +} + +assert_no_mutation() { + local label="$1" work="$2" + [ ! -s "$MUTATION_LOG" ] || fail "$label invoked a mutation command: $(tr '\n' ';' < "$MUTATION_LOG")" + [ ! -e "$work" ] || fail "$label created its campaign directory: $work" +} + +expect_rejected() { + local label="$1" work="$TMP_ROOT/work-$1" rc + shift + : > "$MUTATION_LOG" + set +e + safe_env CAMPAIGN_WORKDIR="$work" "$HARNESS" "$@" \ + > "$TMP_ROOT/$label.out" 2> "$TMP_ROOT/$label.err" + rc=$? + set -e + [ "$rc" -eq 2 ] || fail "$label returned $rc, expected argument/preflight exit 2" + assert_no_mutation "$label" "$work" +} + +bash -n "$HARNESS" + +# Help must be a pure read: notably, it must exit before RUN_ID/result-directory initialization. +help_work="$TMP_ROOT/help-work" +: > "$MUTATION_LOG" +safe_env CAMPAIGN_WORKDIR="$help_work" "$HARNESS" --help > "$TMP_ROOT/help.txt" +assert_no_mutation help "$help_work" +grep -Fq -- '--engines CSV' "$TMP_ROOT/help.txt" +grep -Fq -- '--profiles CSV' "$TMP_ROOT/help.txt" +grep -Fq -- '--dory-app PATH' "$TMP_ROOT/help.txt" +grep -Fq -- '--dry-run' "$TMP_ROOT/help.txt" +grep -Fq -- '--confirm-destructive-purge DELETE-SELECTED-ENGINE-DATA' "$TMP_ROOT/help.txt" + +# No-argument live execution is disabled by default. Parsing and all validation failures are inert. +expect_rejected confirmation-required +expect_rejected unknown-option --definitely-not-an-option +expect_rejected missing-engines --engines +expect_rejected option-is-not-value --engines --dry-run +expect_rejected empty-engine --engines 'dory,' --dry-run +expect_rejected unsupported-engine --engines dory,docker-desktop --dry-run +expect_rejected duplicate-engine --engines dory,dory --dry-run +expect_rejected unsupported-profile --profiles turbo --dry-run +expect_rejected duplicate-profile --profiles default,default --dry-run +expect_rejected unsupported-metric --metrics memory,imaginary --dry-run +expect_rejected zero-runs --runs 0 --dry-run +expect_rejected nonnumeric-memory --memory-count many --dry-run +expect_rejected excessive-socket-wait --socket-wait 3601 --dry-run +expect_rejected overflowing-cpus --pinned-cpus 999999999999999999999 --dry-run +expect_rejected bad-app-suffix --dory-app "$TMP_ROOT/not-dory.app" --dry-run +expect_rejected relative-work --work relative/results --dry-run +expect_rejected wrong-confirmation --confirm-destructive-purge YES --dry-run + +# A pre-existing destination is invalid even in planning mode, so dry-run accurately predicts the +# live preflight instead of promising a campaign that would later clobber results. +existing_work="$TMP_ROOT/existing-work" +/bin/mkdir -p "$existing_work" +: > "$MUTATION_LOG" +set +e +safe_env "$HARNESS" --engines colima --work "$existing_work" --dry-run \ + > "$TMP_ROOT/existing-work.out" 2> "$TMP_ROOT/existing-work.err" +existing_rc=$? +set -e +[ "$existing_rc" -eq 2 ] || fail "existing work directory returned $existing_rc, expected 2" +[ ! -s "$MUTATION_LOG" ] || fail 'existing work-directory preflight invoked a mutation command' +[ -z "$(find "$existing_work" -mindepth 1 -print -quit)" ] || fail 'existing work directory was modified' + +# The exact confirmation token is parsed, but a live campaign still validates every prerequisite +# before creating output or running an engine. This deliberately missing app keeps the test offline. +expect_rejected live-preflight \ + --engines dory \ + --dory-app "$TMP_ROOT/missing/Dory.app" \ + --confirm-destructive-purge DELETE-SELECTED-ENGINE-DATA + +# Exercise every documented selector plus numeric/path options. The fake commands turn any accidental +# execution into a hard failure and an auditable marker. A nonexistent Dory.app is valid for planning. +dry_work="$TMP_ROOT/dry-work" +: > "$MUTATION_LOG" +safe_env CAMPAIGN_WORKDIR="$TMP_ROOT/ignored-env-work" "$HARNESS" \ + --engines orbstack,colima,podman,dory \ + --profiles default,pinned \ + --dory-app "$TMP_ROOT/Release Build/Dory.app" \ + --metrics memory,build \ + --pinned-cpus 4 \ + --pinned-memory-gb 5 \ + --runs 3 \ + --memory-count 7 \ + --socket-wait 45 \ + --work "$dry_work" \ + --dry-run > "$TMP_ROOT/dry.tsv" 2> "$TMP_ROOT/dry.err" + +assert_no_mutation dry-run "$dry_work" +[ ! -e "$TMP_ROOT/ignored-env-work" ] || fail 'CLI --work did not safely override CAMPAIGN_WORKDIR' +grep -Fq 'no installs, starts, measurements, files, or purge commands are executed' "$TMP_ROOT/dry.err" +grep -Fq 'engines=orbstack,colima,podman,dory profiles=default,pinned pinned=4cpu/5GB runs=3' "$TMP_ROOT/dry.err" +awk -F '\t' ' + NR == 1 { + if ($0 != "engine\tprofile\tresult\tresult_dir\tdetail") exit 1 + next + } + { + rows++ + if ($3 != "DRY" || $5 != "dry-run") exit 1 + key=$1 "/" $2 + count[key]++ + } + END { + if (rows != 8) exit 1 + if (count["orbstack/default"] != 1 || count["orbstack/pinned"] != 1 || + count["colima/default"] != 1 || count["colima/pinned"] != 1 || + count["podman/default"] != 1 || count["podman/pinned"] != 1 || + count["dory/default"] != 1 || count["dory/pinned"] != 1) exit 1 + } +' "$TMP_ROOT/dry.tsv" || fail 'dry-run did not honor the requested engine/profile matrix' + +echo 'benchmark-campaign offline safety tests passed' diff --git a/scripts/test-benchmark-developer-workflows.sh b/scripts/test-benchmark-developer-workflows.sh new file mode 100755 index 00000000..d7ca3a88 --- /dev/null +++ b/scripts/test-benchmark-developer-workflows.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Offline argument, schedule, and destructive-orchestrator safety tests. No Docker socket, package +# registry, engine manager, app, or filesystem outside a temporary directory is touched. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HARNESS="$ROOT/scripts/benchmark-developer-workflows.sh" +QUALIFIER="$ROOT/scripts/qualify-release-performance.sh" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-developer-benchmark-test.XXXXXX")" +trap '/bin/rm -rf "$TMP"' EXIT +fail() { echo "benchmark developer workflow test: $*" >&2; exit 1; } + +RUBY='example.invalid/ruby@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +NODE='example.invalid/node@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +COMPOSER='example.invalid/composer@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' + +bash -n "$HARNESS" "$QUALIFIER" +"$HARNESS" --help > "$TMP/help.txt" +grep -Fq -- '--ruby-image REF' "$TMP/help.txt" +grep -Fq -- '--dry-run' "$TMP/help.txt" +"$QUALIFIER" --help > "$TMP/qualifier-help.txt" +grep -Fq 'CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA' "$TMP/qualifier-help.txt" + +"$HARNESS" --ruby-image "$RUBY" --node-image "$NODE" --composer-image "$COMPOSER" \ + --engines dory,orbstack,colima --rounds 9 --work "$TMP/must-not-exist" --dry-run \ + > "$TMP/schedule.tsv" 2> "$TMP/dry.err" +[ ! -e "$TMP/must-not-exist" ] || fail "dry-run created its work directory" +grep -Fq 'no engine, image, registry, cache, or filesystem mutation was attempted' "$TMP/dry.err" +awk -F '\t' ' + NR == 1 { if ($0 != "workflow\tround\tposition\tengine") exit 1; next } + { rows++; count[$1]++; positions[$1 "/" $4 "/" $3]++ } + END { + if (rows != 81 || count["rails"] != 27 || count["pnpm"] != 27 || count["composer"] != 27) exit 1 + for (workflow in count) for (engine_index = 1; engine_index <= 3; engine_index++) { + engine = engine_index == 1 ? "dory" : (engine_index == 2 ? "orbstack" : "colima") + for (position = 1; position <= 3; position++) + if (positions[workflow "/" engine "/" position] != 3) exit 1 + } + } +' "$TMP/schedule.tsv" || fail "dry schedule is not fully position-balanced" + +expect_failure() { + local label="$1" expected="$2" + shift 2 + if "$@" > "$TMP/$label.out" 2>&1; then fail "$label unexpectedly passed"; fi + grep -Fq "$expected" "$TMP/$label.out" || fail "$label failed for the wrong reason" +} +expect_failure mutable-image 'ruby-image must include an immutable' \ + "$HARNESS" --ruby-image ruby:latest --node-image "$NODE" --composer-image "$COMPOSER" --dry-run +expect_failure unbalanced-rounds 'rounds must be a multiple' \ + "$HARNESS" --ruby-image "$RUBY" --node-image "$NODE" --composer-image "$COMPOSER" --rounds 8 --dry-run +expect_failure missing-confirm 'CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA is required' "$QUALIFIER" +expect_failure missing-clean-user 'DORY_RELEASE_CLEAN_USER=1 is required' \ + env -u DORY_RELEASE_CLEAN_USER -u DORY_RELEASE_BENCHMARK_USER "$QUALIFIER" \ + --confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA +expect_failure missing-benchmark-user 'DORY_RELEASE_BENCHMARK_USER=1 is required' \ + env -u DORY_RELEASE_BENCHMARK_USER DORY_RELEASE_CLEAN_USER=1 "$QUALIFIER" \ + --confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA + +grep -Fq 'DORY_RELEASE_CLEAN_USER' "$QUALIFIER" +grep -Fq 'DORY_RELEASE_BENCHMARK_USER' "$QUALIFIER" +grep -Fq 'validate-release-metadata.py' "$QUALIFIER" +grep -Fq 'benchmark-campaign.sh' "$QUALIFIER" +grep -Fq 'benchmark-user-workflows.sh' "$QUALIFIER" +grep -Fq 'benchmark-developer-workflows.sh' "$QUALIFIER" +grep -Fq 'benchmark-registry-npm.sh' "$QUALIFIER" +grep -Fq 'benchmark-external-network.sh' "$QUALIFIER" +grep -Fq 'shasum -a 256 -c sha256.txt' "$QUALIFIER" +grep -Fq 'cleanup left state behind' "$QUALIFIER" + +echo "benchmark developer workflow offline tests passed" diff --git a/scripts/test-benchmark-external-network.sh b/scripts/test-benchmark-external-network.sh new file mode 100755 index 00000000..466eb17b --- /dev/null +++ b/scripts/test-benchmark-external-network.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Offline tests for benchmark-external-network.sh. No Docker socket or endpoint is accessed. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HARNESS="$ROOT/scripts/benchmark-external-network.sh" +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dory-network-bench-test.XXXXXX")" +cleanup() { rm -rf "$TMP_ROOT"; } +trap cleanup EXIT +fail() { echo "external-network benchmark test failed: $*" >&2; exit 1; } + +IMAGE='example.invalid/curl@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +PROBE='https://network-bench.example/probe' +DOWNLOAD='https://network-bench.example/payload-1048576.bin' + +bash -n "$HARNESS" +"$HARNESS" --help > "$TMP_ROOT/help.txt" +grep -q -- '--pull never' "$TMP_ROOT/help.txt" +grep -q -- '--dry-run' "$TMP_ROOT/help.txt" +grep -q -- '--globoff' "$HARNESS" + +"$HARNESS" \ + --engines dory,orbstack,colima \ + --rounds 9 \ + --image "$IMAGE" \ + --probe-url "$PROBE" \ + --download-url "$DOWNLOAD" \ + --download-bytes 1048576 \ + --dry-run > "$TMP_ROOT/schedule.tsv" 2> "$TMP_ROOT/dry-run.err" + +grep -q 'no engine, Docker API, image, or network endpoint was accessed' "$TMP_ROOT/dry-run.err" +grep -q 'planned_fixed_download_bytes_per_engine=386924544' "$TMP_ROOT/dry-run.err" +awk -F '\t' ' + NR == 1 { + if ($0 != "round\tworkload\tposition\tengine\tconcurrency") exit 1 + next + } + { + rows++ + count[$2 SUBSEP $4 SUBSEP $3]++ + if ($5 != 1 && $5 != 8 && $5 != 32) exit 1 + } + END { + if (rows != 108) exit 1 + for (key in count) if (count[key] != 3) exit 1 + } +' "$TMP_ROOT/schedule.tsv" + +expect_rejected() { + local label="$1" + shift + if "$HARNESS" "$@" > "$TMP_ROOT/$label.out" 2> "$TMP_ROOT/$label.err"; then + fail "$label was accepted" + fi +} +expect_rejected mutable-image \ + --image curl:latest --probe-url "$PROBE" --download-url "$DOWNLOAD" \ + --download-bytes 1 --dry-run +expect_rejected unbalanced-rounds \ + --engines dory,orbstack,colima --rounds 8 --image "$IMAGE" --probe-url "$PROBE" \ + --download-url "$DOWNLOAD" --download-bytes 1 --dry-run +expect_rejected duplicate-engine \ + --engines dory,dory --rounds 2 --image "$IMAGE" --probe-url "$PROBE" \ + --download-url "$DOWNLOAD" --download-bytes 1 --dry-run +expect_rejected plaintext-url \ + --image "$IMAGE" --probe-url http://network-bench.example/probe \ + --download-url "$DOWNLOAD" --download-bytes 1 --dry-run +expect_rejected username-only-userinfo \ + --image "$IMAGE" --probe-url https://token@network-bench.example/probe \ + --download-url "$DOWNLOAD" --download-bytes 1 --dry-run + +# A live preflight failure must be terminal and auditable, not leave run-status falsely "running". +set +e +DORY_SOCK="$TMP_ROOT/missing.sock" "$HARNESS" \ + --engines dory --rounds 1 --image "$IMAGE" --probe-url "$PROBE" \ + --download-url "$DOWNLOAD" --download-bytes 1 \ + --work "$TMP_ROOT/preflight-failure" > "$TMP_ROOT/preflight.out" 2> "$TMP_ROOT/preflight.err" +preflight_rc=$? +set -e +[ "$preflight_rc" -eq 2 ] || fail "missing-socket preflight returned $preflight_rc, expected 2" +grep -q $'^status\tfail$' "$TMP_ROOT/preflight-failure/run-status.tsv" +grep -q $'^reason\tunexpected_exit_2$' "$TMP_ROOT/preflight-failure/run-status.tsv" +grep -q $'^exit_code\t2$' "$TMP_ROOT/preflight-failure/run-status.tsv" + +# Docker's Go-template formatter prints backslash-t literally. Image metadata must therefore use +# independent inspect calls, never a composite format that is split on an assumed tab character. +if grep -Fq '\t{{' "$HARNESS"; then + fail 'image metadata reintroduced literal-backslash-t parsing' +fi +for template in '{{.Id}}' '{{join .RepoDigests ","}}' '{{.Os}}' '{{.Architecture}}' \ + '{{.Variant}}' '{{.Created}}' '{{json .RootFS.Layers}}'; do + grep -Fq -- "--format '$template'" "$HARNESS" || fail "missing independent inspect for $template" +done +grep -Fq 'resolved_repo_digest\timage_id\trepo_digests\tos\tarch\tvariant' "$HARNESS" || \ + fail 'image provenance does not retain RepoDigest, diagnostic image ID, and full platform' +grep -Fq 'rootfs_layers\trootfs_fingerprint_sha256' "$HARNESS" || \ + fail 'image provenance does not retain ordered RootFS evidence and fingerprint' +if grep -Fq 'BASE_IMAGE_ID' "$HARNESS"; then + fail 'store-dependent Docker image ID was reintroduced as a fairness identity' +fi + +extract_function() { + local signature="$1" + awk -v signature="$signature" ' + $0 == signature { copying=1 } + copying { print } + copying && $0 == "}" { exit } + ' "$HARNESS" +} + +# Exercise the exact metadata helpers. Docker 29 can expose the same immutable image with a manifest +# digest as `.Id` in one store and a config digest in another, so only RepoDigest/platform/layers rank. +eval "$(extract_function 'normal_image_variant() {')" +eval "$(extract_function 'valid_rootfs_layers() {')" +eval "$(extract_function 'resolved_requested_repo_digest() {')" +DIGEST_A="sha256:$(printf 'a%.0s' {1..64})" +DIGEST_B="sha256:$(printf 'b%.0s' {1..64})" +LAYER_A="sha256:$(printf '1%.0s' {1..64})" +LAYER_B="sha256:$(printf '2%.0s' {1..64})" +[ "$(normal_image_variant arm64 v8)" = v8 ] || fail 'arm64/v8 variant normalization failed' +[ "$(normal_image_variant amd64 '')" = none ] || fail 'amd64 omitted-variant normalization failed' +if normal_image_variant arm64 '' >/dev/null 2>&1; then fail 'missing arm64 variant was accepted'; fi +valid_rootfs_layers "[\"$LAYER_A\",\"$LAYER_B\"]" || fail 'valid ordered RootFS layers were rejected' +if valid_rootfs_layers '[]'; then fail 'empty RootFS layers were accepted'; fi +if valid_rootfs_layers "[\"$LAYER_A\",\"not-a-digest\"]"; then + fail 'malformed RootFS layers were accepted' +fi +[ "$(resolved_requested_repo_digest "curlimages/curl@$DIGEST_A,alias/curl@$DIGEST_B" "$DIGEST_A")" = "$DIGEST_A" ] || \ + fail 'exact requested RepoDigest was not resolved' +if resolved_requested_repo_digest "curlimages/curl@${DIGEST_A}0" "$DIGEST_A" >/dev/null 2>&1; then + fail 'RepoDigest substring was accepted as an exact requested digest' +fi + +# Extract and exercise the exact guest script using a fake curl function. This tests all 32 workers, +# raw-field positions, exact-byte validation, and failure preservation without using the network. +guest_start="$(grep -n "^GUEST_SCRIPT='" "$HARNESS" | cut -d: -f1)" +guest_end="$(awk -v start="$guest_start" 'NR > start && $0 == "\047" { print NR; exit }' "$HARNESS")" +[ -n "$guest_start" ] && [ -n "$guest_end" ] || fail 'could not locate embedded guest script' +guest_script="$(sed -n "$((guest_start + 1)),$((guest_end - 1))p" "$HARNESS")" +printf '%s\n' "$guest_script" | /bin/sh -n +guest_script="$(printf '%s\n' "$guest_script" | sed "s|/tmp/dory-net|$TMP_ROOT/dory-net|g")" + +fake_success='curl() { + found_globoff=0 + url_count=0 + for argument in "$@"; do + [ "$argument" = --globoff ] && found_globoff=1 + case "$argument" in https://*) url_count=$((url_count + 1)) ;; esac + done + [ "$found_globoff" -eq 1 ] && [ "$url_count" -eq 1 ] || return 97 + printf "%s\n" "https://network-bench.example/file|203.0.113.9|200|2|1|1048576|8388608|0.001000|0.010000|0.020000|0.021000|0.030000|1.000000|0" + return 0 +}' +/bin/sh -c "$fake_success +$guest_script" -- download 'https://network-bench.example/{one,two}[1-2]' 1048576 200 32 10 60 > "$TMP_ROOT/guest-success.tsv" +awk -F '\t' ' + $1 == "CURL" { + rows++ + if ($3 != 0 || $4 != "pass" || $12 != 1048576 || + $14 != "0.001000" || $19 != "1.000000") exit 1 + } + END { if (rows != 32) exit 1 } +' "$TMP_ROOT/guest-success.tsv" + +fake_failure='curl() { printf "%s\n" "https://network-bench.example/file||200|2|1|7|56|0.001000|0.010000|0.020000|0.021000|0.030000|1.000000|0"; return 0; }' +set +e +/bin/sh -c "$fake_failure +$guest_script" -- download "$DOWNLOAD" 1048576 200 1 10 60 > "$TMP_ROOT/guest-failure.tsv" +failure_rc=$? +set -e +[ "$failure_rc" -eq 1 ] || fail "invalid byte count returned $failure_rc, expected 1" +awk -F '\t' ' + $1 == "CURL" { found=1; if ($3 != 0 || $4 != "fail" || $5 != "byte_count" || $8 != "" || $12 != 7) exit 1 } + END { if (!found) exit 1 } +' "$TMP_ROOT/guest-failure.tsv" + +echo 'external-network benchmark offline tests passed' diff --git a/scripts/test-benchmark-user-workflows.sh b/scripts/test-benchmark-user-workflows.sh new file mode 100755 index 00000000..42328788 --- /dev/null +++ b/scripts/test-benchmark-user-workflows.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Offline tests for benchmark-user-workflows.sh image-fairness metadata. No Docker socket is accessed. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HARNESS="$ROOT/scripts/benchmark-user-workflows.sh" +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dory-workflow-bench-test.XXXXXX")" +cleanup() { rm -rf "$TMP_ROOT"; } +trap cleanup EXIT +fail() { echo "user-workflow benchmark test failed: $*" >&2; exit 1; } + +bash -n "$HARNESS" +for template in '{{.Id}}' '{{join .RepoDigests ","}}' '{{.Os}}' '{{.Architecture}}' \ + '{{.Variant}}' '{{.Created}}' '{{.Size}}' '{{json .RootFS.Layers}}'; do + grep -Fq -- "--format '$template'" "$HARNESS" || fail "missing independent inspect for $template" +done +grep -Fq 'resolved_repo_digest\timage_id\trepo_digests\tos\tarch\tvariant' "$HARNESS" || \ + fail 'image provenance does not retain RepoDigest, diagnostic image ID, and full platform' +grep -Fq 'rootfs_layers\trootfs_fingerprint_sha256' "$HARNESS" || \ + fail 'image provenance does not retain ordered RootFS evidence and fingerprint' +if grep -Fq 'canonical[image]' "$HARNESS" || grep -Fq 'identical image IDs' "$HARNESS"; then + fail 'store-dependent Docker image ID was reintroduced as a fairness identity' +fi +grep -Fq 'orb config get machine.docker.cpu' "$HARNESS" || \ + fail 'OrbStack Docker-specific CPU override is absent from provenance' +grep -Fq 'orb config get machine.docker.memory_mib' "$HARNESS" || \ + fail 'OrbStack Docker-specific memory override is absent from provenance' +grep -Fq 'npm_clear_bind_tree() {' "$HARNESS" || \ + fail 'npm setup does not clear node_modules through the engine bind mount' +grep -Fq 'fs.rmSync("/app/node_modules"' "$HARNESS" || \ + fail 'npm bind-tree cleanup does not retry removal of the guest-visible node_modules path' +if grep -Fq 'print NR > 0 ?' "$HARNESS"; then + fail 'benchmark ledger count uses an awk print expression that macOS parses as redirection' +fi +grep -Fq 'print (NR > 0 ? NR - 1 : 0)' "$HARNESS" || \ + fail 'benchmark ledger count does not use a portable parenthesized awk expression' + +extract_function() { + local signature="$1" + awk -v signature="$signature" ' + $0 == signature { copying=1 } + copying { print } + copying && $0 == "}" { exit } + ' "$HARNESS" +} + +# Run the exact helpers from the harness rather than duplicating their logic in the test. +eval "$(extract_function 'normal_image_variant() {')" +eval "$(extract_function 'valid_rootfs_layers() {')" +eval "$(extract_function 'unique_repo_digest() {')" +eval "$(extract_function 'validate_image_fairness() {')" + +DIGEST_A="sha256:$(printf 'a%.0s' {1..64})" +DIGEST_B="sha256:$(printf 'b%.0s' {1..64})" +LAYER_A="sha256:$(printf '1%.0s' {1..64})" +LAYER_B="sha256:$(printf '2%.0s' {1..64})" +ROOTFS_A="$(printf '3%.0s' {1..64})" +ROOTFS_B="$(printf '4%.0s' {1..64})" + +[ "$(normal_image_variant arm64 v8)" = v8 ] || fail 'arm64/v8 variant normalization failed' +[ "$(normal_image_variant amd64 '')" = none ] || fail 'amd64 omitted-variant normalization failed' +if normal_image_variant arm64 '' >/dev/null 2>&1; then fail 'missing arm64 variant was accepted'; fi +valid_rootfs_layers "[\"$LAYER_A\",\"$LAYER_B\"]" || fail 'valid ordered RootFS layers were rejected' +if valid_rootfs_layers '[]'; then fail 'empty RootFS layers were accepted'; fi +[ "$(unique_repo_digest "node@$DIGEST_A,alias/node@$DIGEST_A")" = "$DIGEST_A" ] || \ + fail 'same-digest repository aliases were not normalized' +if unique_repo_digest "node@$DIGEST_A,node@$DIGEST_B" >/dev/null 2>&1; then + fail 'ambiguous RepoDigests were accepted' +fi +if unique_repo_digest '' >/dev/null 2>&1; then fail 'missing RepoDigest was accepted'; fi + +WORK="$TMP_ROOT/work" +engine_count=3 +mkdir -p "$WORK" +write_fixture() { + local mode="$1" image engine digest variant rootfs repo image_id + printf 'engine\trequested_image\tresolved_repo_digest\timage_id\trepo_digests\tos\tarch\tvariant\tcreated\tbytes\trootfs_layers\trootfs_fingerprint_sha256\n' \ + > "$WORK/image-provenance.tsv" + for image in node:22-alpine alpine:3.21 postgres:16-alpine redis:7-alpine; do + for engine in dory orbstack colima; do + digest="$DIGEST_A" + variant=v8 + rootfs="$ROOTFS_A" + if [ "$mode" = digest-mismatch ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + digest="$DIGEST_B" + fi + if [ "$mode" = platform-mismatch ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + variant=v9 + fi + if [ "$mode" = rootfs-mismatch ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + rootfs="$ROOTFS_B" + fi + if [ "$mode" = missing-digest ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + digest="" + fi + if [ "$mode" = missing-platform ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + variant="" + fi + if [ "$mode" = missing-rootfs ] && [ "$image" = node:22-alpine ] && [ "$engine" = colima ]; then + rootfs="" + fi + repo="${image%%:*}@${digest:-missing}" + # Intentionally different IDs prove that store-specific `.Id` values are diagnostic only. + image_id="sha256:${engine}-${image}" + printf '%s\t%s\t%s\t%s\t%s\tlinux\tarm64\t%s\t2026-01-01T00:00:00Z\t1\t%s\t%s\n' \ + "$engine" "$image" "$digest" "$image_id" "$repo" "$variant" \ + "[\"$LAYER_A\"]" "$rootfs" >> "$WORK/image-provenance.tsv" + done + done +} + +write_fixture pass +validate_image_fairness || fail 'different diagnostic image IDs rejected identical immutable content' +for mode in digest-mismatch platform-mismatch rootfs-mismatch missing-digest missing-platform missing-rootfs; do + write_fixture "$mode" + if validate_image_fairness >"$TMP_ROOT/$mode.out" 2>"$TMP_ROOT/$mode.err"; then + fail "$mode fixture was accepted" + fi +done + +echo 'user-workflow benchmark offline tests passed' From 40bf7b70e970345bbf0e54a0ee6b42cc79003be9 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:06:14 +0000 Subject: [PATCH 161/338] test(release): track clean source authority --- scripts/test-clean-release-source.sh | 61 ++++++++++++++++++++++++++ scripts/verify-clean-release-source.sh | 32 ++++++++++++++ 2 files changed, 93 insertions(+) create mode 100755 scripts/test-clean-release-source.sh create mode 100755 scripts/verify-clean-release-source.sh diff --git a/scripts/test-clean-release-source.sh b/scripts/test-clean-release-source.sh new file mode 100755 index 00000000..d30a021b --- /dev/null +++ b/scripts/test-clean-release-source.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-clean-release-source.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +REPO="$TMP/repo" +mkdir -p "$REPO" + +git -C "$REPO" init -q +git -C "$REPO" config user.name 'Dory Release Test' +git -C "$REPO" config user.email 'release-test@dory.invalid' +printf 'tracked\n' > "$REPO/tracked.txt" +printf 'guest/out/\n' > "$REPO/.gitignore" +git -C "$REPO" add tracked.txt .gitignore +git -C "$REPO" commit -qm initial + +scripts/verify-clean-release-source.sh "$REPO" >/dev/null + +ln -s "$REPO" "$TMP/indirect-repo" +if scripts/verify-clean-release-source.sh "$TMP/indirect-repo" >"$TMP/indirect.out" 2>"$TMP/indirect.err"; then + echo "clean release source test failed: accepted a symlinked worktree" >&2 + exit 1 +fi +grep -q 'missing or indirect' "$TMP/indirect.err" || { + echo "clean release source test failed: indirect root failed for the wrong reason" >&2 + exit 1 +} + +expect_rejected() { + local name="$1" + if scripts/verify-clean-release-source.sh "$REPO" >"$TMP/$name.out" 2>"$TMP/$name.err"; then + echo "clean release source test failed: accepted $name" >&2 + exit 1 + fi + grep -q 'public release worktree is not clean' "$TMP/$name.err" || { + echo "clean release source test failed: $name did not fail for the clean-tree contract" >&2 + exit 1 + } +} + +printf 'modified\n' >> "$REPO/tracked.txt" +expect_rejected modified-tracked +git -C "$REPO" restore tracked.txt + +printf 'staged\n' >> "$REPO/tracked.txt" +git -C "$REPO" add tracked.txt +expect_rejected staged-tracked +git -C "$REPO" reset -q HEAD -- tracked.txt +git -C "$REPO" restore tracked.txt + +printf 'untracked\n' > "$REPO/new-source.swift" +expect_rejected untracked-source +rm "$REPO/new-source.swift" + +mkdir -p "$REPO/guest/out" +printf 'generated\n' > "$REPO/guest/out/Image" +scripts/verify-clean-release-source.sh "$REPO" >/dev/null + +echo "clean release source tests passed" diff --git a/scripts/verify-clean-release-source.sh b/scripts/verify-clean-release-source.sh new file mode 100755 index 00000000..b11b5116 --- /dev/null +++ b/scripts/verify-clean-release-source.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# A public artifact's sourceCommit is meaningful only when every non-ignored source path is exactly +# the recorded Git commit. Generated guest/out assets are intentionally ignored and are validated +# independently by their deterministic provenance stamps. +set -euo pipefail + +ROOT="${1:-.}" +[ -d "$ROOT" ] && [ ! -L "$ROOT" ] || { + echo "release source verification failed: worktree root is missing or indirect: $ROOT" >&2 + exit 1 +} +LOGICAL_ROOT="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$ROOT")" +cd "$ROOT" +PHYSICAL_ROOT="$(pwd -P)" +[ "$LOGICAL_ROOT" = "$PHYSICAL_ROOT" ] || { + echo "release source verification failed: worktree root has an indirect ancestor" >&2 + exit 1 +} + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "release source verification failed: not inside a Git worktree: $ROOT" >&2 + exit 1 +} + +STATUS="$(git status --porcelain=v1 --untracked-files=all --ignore-submodules=none)" +if [ -n "$STATUS" ]; then + echo "release source verification failed: public release worktree is not clean" >&2 + printf '%s\n' "$STATUS" >&2 + exit 1 +fi + +echo "release source verification: PASS (clean commit $(git rev-parse HEAD))" From 840254b9de2313ede5837a41a0ad797ce52a0344 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:07:44 +0000 Subject: [PATCH 162/338] feat(release): harden nested Sparkle signing --- scripts/sign-sparkle-for-distribution.sh | 56 ++++++++++ scripts/test-sparkle-distribution-signing.sh | 106 +++++++++++++++++++ scripts/verify-distribution-signatures.sh | 69 ++++++++++++ 3 files changed, 231 insertions(+) create mode 100755 scripts/sign-sparkle-for-distribution.sh create mode 100755 scripts/test-sparkle-distribution-signing.sh create mode 100755 scripts/verify-distribution-signatures.sh diff --git a/scripts/sign-sparkle-for-distribution.sh b/scripts/sign-sparkle-for-distribution.sh new file mode 100755 index 00000000..28a8e1a6 --- /dev/null +++ b/scripts/sign-sparkle-for-distribution.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Sparkle's prebuilt helpers are intentionally ad-hoc signed. A custom Developer ID export must +# re-sign them inside-out before the framework and containing app are signed. +set -euo pipefail + +APP="${1:?usage: sign-sparkle-for-distribution.sh }" +SIGN_IDENTITY="${2:?usage: sign-sparkle-for-distribution.sh }" +FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework" +VERSION="$FRAMEWORK/Versions/Current" + +fail() { + echo "Sparkle signing error: $*" >&2 + exit 1 +} + +[ -d "$APP" ] && [ ! -L "$APP" ] || fail "application is missing or indirect: $APP" +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP_PHYSICAL="$(cd "$APP" && pwd -P)" +[ "$APP_LOGICAL" = "$APP_PHYSICAL" ] || fail "application has an indirect ancestor" +[ -d "$FRAMEWORK" ] && [ ! -L "$FRAMEWORK" ] || fail "framework is missing or indirect: $FRAMEWORK" +[ -d "$VERSION" ] || fail "current framework version is missing: $VERSION" +FRAMEWORK_PHYSICAL="$(cd "$FRAMEWORK" && pwd -P)" +VERSION_PHYSICAL="$(cd "$VERSION" && pwd -P)" +case "$VERSION_PHYSICAL" in + "$FRAMEWORK_PHYSICAL"/Versions/*) ;; + *) fail "current framework version escapes Sparkle.framework" ;; +esac + +INSTALLER="$VERSION/XPCServices/Installer.xpc" +DOWNLOADER="$VERSION/XPCServices/Downloader.xpc" +AUTOUPDATE="$VERSION/Autoupdate" +UPDATER="$VERSION/Updater.app" + +for path in "$INSTALLER" "$DOWNLOADER" "$AUTOUPDATE" "$UPDATER"; do + [ -e "$path" ] || fail "required nested code is missing: $path" + nested_physical="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$path")" + case "$nested_physical" in + "$VERSION_PHYSICAL"/*) ;; + *) fail "nested code escapes the current Sparkle version: $path" ;; + esac +done + +sign_args=(--force --options runtime) +if [ "$SIGN_IDENTITY" != "-" ]; then + sign_args+=(--timestamp) +fi + +# This order and Downloader entitlement preservation follow Sparkle's distribution guidance. +# Do not use --deep: different nested targets can require different entitlements. +codesign "${sign_args[@]}" --sign "$SIGN_IDENTITY" "$INSTALLER" +codesign "${sign_args[@]}" --preserve-metadata=entitlements --sign "$SIGN_IDENTITY" "$DOWNLOADER" +codesign "${sign_args[@]}" --sign "$SIGN_IDENTITY" "$AUTOUPDATE" +codesign "${sign_args[@]}" --sign "$SIGN_IDENTITY" "$UPDATER" +codesign "${sign_args[@]}" --sign "$SIGN_IDENTITY" "$FRAMEWORK" + +echo "Sparkle distribution signing: PASS" diff --git a/scripts/test-sparkle-distribution-signing.sh b/scripts/test-sparkle-distribution-signing.sh new file mode 100755 index 00000000..eda1235a --- /dev/null +++ b/scripts/test-sparkle-distribution-signing.sh @@ -0,0 +1,106 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-sparkle-signing.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +APP="$TMP/Dory.app" +VERSION="$APP/Contents/Frameworks/Sparkle.framework/Versions/B" +mkdir -p \ + "$APP/Contents/MacOS" \ + "$VERSION/XPCServices/Installer.xpc/Contents/MacOS" \ + "$VERSION/XPCServices/Downloader.xpc/Contents/MacOS" \ + "$VERSION/Updater.app/Contents/MacOS" +ln -s B "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current" +touch \ + "$APP/Contents/MacOS/Dory" \ + "$VERSION/XPCServices/Installer.xpc/Contents/MacOS/Installer" \ + "$VERSION/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \ + "$VERSION/Autoupdate" \ + "$VERSION/Updater.app/Contents/MacOS/Updater" \ + "$VERSION/Sparkle" + +BIN="$TMP/bin" +LOG="$TMP/codesign.log" +mkdir -p "$BIN" + +cat > "$BIN/codesign" <<'SH' +#!/bin/bash +set -euo pipefail +if [ "${1:-}" = "-d" ]; then + path="${!#}" + if [ "${DORY_TEST_ADHOC_BASENAME:-}" = "$(basename "$path")" ]; then + printf '%s\n' \ + 'CodeDirectory v=20500 size=100 flags=0x10002(adhoc,runtime) hashes=1+2 location=embedded' \ + 'TeamIdentifier=not set' >&2 + else + printf '%s\n' \ + 'CodeDirectory v=20500 size=100 flags=0x10000(runtime) hashes=1+2 location=embedded' \ + 'Authority=Developer ID Application: Test (TESTTEAM01)' \ + 'Timestamp=14 Jul 2026 at 1:00:00 AM' \ + 'TeamIdentifier=TESTTEAM01' >&2 + fi + exit 0 +fi +if [ -n "${DORY_TEST_CODESIGN_LOG:-}" ]; then + printf '%q ' "$@" >> "$DORY_TEST_CODESIGN_LOG" + printf '\n' >> "$DORY_TEST_CODESIGN_LOG" +fi +exit 0 +SH + +cat > "$BIN/file" <<'SH' +#!/bin/bash +printf '%s\n' 'Mach-O 64-bit executable arm64' +SH +chmod 0755 "$BIN/codesign" "$BIN/file" + +PATH="$BIN:$PATH" DORY_TEST_CODESIGN_LOG="$LOG" \ + scripts/sign-sparkle-for-distribution.sh "$APP" 'Developer ID Application' + +[ "$(wc -l < "$LOG" | tr -d ' ')" = 5 ] \ + || { echo "test-sparkle-distribution-signing: expected five inside-out signatures" >&2; exit 1; } +sed -n '1p' "$LOG" | grep -F 'Installer.xpc' >/dev/null +sed -n '2p' "$LOG" | grep -F -- '--preserve-metadata=entitlements' >/dev/null +sed -n '2p' "$LOG" | grep -F 'Downloader.xpc' >/dev/null +sed -n '3p' "$LOG" | grep -F 'Autoupdate' >/dev/null +sed -n '4p' "$LOG" | grep -F 'Updater.app' >/dev/null +sed -n '5p' "$LOG" | grep -F 'Sparkle.framework' >/dev/null +grep -F -- '--timestamp' "$LOG" >/dev/null +if grep -F -- '--deep' "$LOG" >/dev/null; then + echo "test-sparkle-distribution-signing: unsafe --deep signing returned" >&2 + exit 1 +fi + +PATH="$BIN:$PATH" scripts/verify-distribution-signatures.sh "$APP" TESTTEAM01 >/dev/null +if PATH="$BIN:$PATH" DORY_TEST_ADHOC_BASENAME=Autoupdate \ + scripts/verify-distribution-signatures.sh "$APP" TESTTEAM01 >"$TMP/adhoc.out" 2>&1; then + echo "test-sparkle-distribution-signing: ad-hoc nested code was accepted" >&2 + exit 1 +fi +grep -F 'Autoupdate' "$TMP/adhoc.out" >/dev/null \ + || { echo "test-sparkle-distribution-signing: rejection did not identify nested code" >&2; exit 1; } + +ln -s "$APP" "$TMP/Indirect.app" +if PATH="$BIN:$PATH" scripts/sign-sparkle-for-distribution.sh \ + "$TMP/Indirect.app" 'Developer ID Application' >"$TMP/indirect-sign.out" 2>&1; then + echo "test-sparkle-distribution-signing: signer accepted an indirect app" >&2 + exit 1 +fi +if PATH="$BIN:$PATH" scripts/verify-distribution-signatures.sh \ + "$TMP/Indirect.app" TESTTEAM01 >"$TMP/indirect-verify.out" 2>&1; then + echo "test-sparkle-distribution-signing: verifier accepted an indirect app" >&2 + exit 1 +fi + +rm -rf "$VERSION/XPCServices/Installer.xpc" +if PATH="$BIN:$PATH" scripts/sign-sparkle-for-distribution.sh "$APP" 'Developer ID Application' \ + >"$TMP/missing.out" 2>&1; then + echo "test-sparkle-distribution-signing: missing Sparkle helper was accepted" >&2 + exit 1 +fi + +bash -n scripts/sign-sparkle-for-distribution.sh scripts/verify-distribution-signatures.sh +echo "test-sparkle-distribution-signing: PASS" diff --git a/scripts/verify-distribution-signatures.sh b/scripts/verify-distribution-signatures.sh new file mode 100755 index 00000000..4bd62692 --- /dev/null +++ b/scripts/verify-distribution-signatures.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Verify the notarization-facing contract for every Mach-O nested anywhere in an application. +set -euo pipefail + +APP="${1:?usage: verify-distribution-signatures.sh }" +EXPECTED_TEAM="${2:?usage: verify-distribution-signatures.sh }" + +fail() { + echo "distribution signature error: $*" >&2 + exit 1 +} + +[ -d "$APP" ] && [ ! -L "$APP" ] || fail "application is missing or indirect: $APP" +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP_PHYSICAL="$(cd "$APP" && pwd -P)" +[ "$APP_LOGICAL" = "$APP_PHYSICAL" ] || fail "application has an indirect ancestor" +printf '%s\n' "$EXPECTED_TEAM" | grep -Eq '^[A-Z0-9]{10}$' \ + || fail "expected team ID must be ten uppercase alphanumeric characters" +for tool in codesign file find grep mktemp; do + command -v "$tool" >/dev/null 2>&1 || fail "required tool is unavailable: $tool" +done + +python3 - "$APP_PHYSICAL" <<'PY' || fail "application contains a symlink that escapes its bundle" +import os +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +for directory, names, files in os.walk(root, followlinks=False): + for name in names + files: + path = pathlib.Path(directory, name) + if path.is_symlink(): + target = path.resolve(strict=False) + if os.path.commonpath((root, target)) != str(root): + raise SystemExit(f"escaping symlink: {path} -> {os.readlink(path)}") +PY + +codesign --verify --strict --deep --verbose=2 "$APP" \ + || fail "application or nested code has an invalid signature: $APP" + +inventory="$(mktemp "${TMPDIR:-/tmp}/dory-signatures.XXXXXX")" +trap 'rm -f "$inventory"' EXIT +find "$APP" -type f -print0 > "$inventory" + +count=0 +while IFS= read -r -d '' path; do + description="$(file -b "$path")" || fail "could not identify file type: $path" + case "$description" in + *Mach-O*) ;; + *) continue ;; + esac + + count=$((count + 1)) + details="$(codesign -d --verbose=4 "$path" 2>&1)" \ + || fail "could not inspect code signature: $path" + printf '%s\n' "$details" | grep -F 'Authority=Developer ID Application:' >/dev/null \ + || fail "Mach-O is not signed by a Developer ID Application certificate: $path" + printf '%s\n' "$details" | grep -Fx "TeamIdentifier=$EXPECTED_TEAM" >/dev/null \ + || fail "Mach-O is not signed by expected team $EXPECTED_TEAM: $path" + printf '%s\n' "$details" | grep -E '^Timestamp=' >/dev/null \ + || fail "Mach-O signature has no secure timestamp: $path" + printf '%s\n' "$details" | grep -E '^CodeDirectory .+flags=.*\([^)]*runtime[^)]*\)' >/dev/null \ + || fail "Mach-O does not enable hardened runtime: $path" + codesign --verify --strict --verbose=2 "$path" \ + || fail "Mach-O signature is invalid: $path" +done < "$inventory" + +[ "$count" -gt 0 ] || fail "application contains no Mach-O code: $APP" +echo "Distribution signatures: PASS ($count Mach-O files; team $EXPECTED_TEAM)" From 44528f3a9934997e79c2aa95ceb13366a5efe14a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:08:04 +0000 Subject: [PATCH 163/338] test(hostshare): track offline integration harness --- scripts/test-live-hostshare-integration.sh | 640 +++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100755 scripts/test-live-hostshare-integration.sh diff --git a/scripts/test-live-hostshare-integration.sh b/scripts/test-live-hostshare-integration.sh new file mode 100755 index 00000000..66bf78b9 --- /dev/null +++ b/scripts/test-live-hostshare-integration.sh @@ -0,0 +1,640 @@ +#!/bin/bash +# Offline tests only: no Docker socket, installed Dory process, or engine is contacted. +set -euo pipefail + +case "${PYTHONOPTIMIZE:-0}" in + ''|0) ;; + *) echo "live host-share offline tests require unoptimized Python" >&2; exit 2 ;; +esac + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HARNESS="$ROOT/scripts/live-hostshare-integration.sh" +GUEST="$ROOT/scripts/fixtures/hostshare_guest_probe.py" +NONPING="$ROOT/scripts/fixtures/hostshare_nonping_probe.py" +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dory-hostshare-harness-test.XXXXXX")" +cleanup() { rm -rf "$TMP_ROOT"; } +trap cleanup EXIT +fail() { echo "live host-share harness offline test failed: $*" >&2; exit 1; } + +bash -n "$HARNESS" +python3 - "$GUEST" <<'PY' +import errno +import importlib.util +import sys +sys.dont_write_bytecode = True +compile(open(sys.argv[1], "rb").read(), sys.argv[1], "exec") +spec = importlib.util.spec_from_file_location("dory_hostshare_guest_probe", sys.argv[1]) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + +results = [] +probe.attempt(results, "unit", "denied", lambda: (_ for _ in ()).throw(OSError(errno.EACCES, "denied"))) +probe.attempt(results, "unit", "resource", lambda: (_ for _ in ()).throw(OSError(errno.EMFILE, "exhausted"))) +probe.attempt(results, "unit", "bug", lambda: (_ for _ in ()).throw(RuntimeError("bug"))) +probe.attempt(results, "unit", "success", lambda: "escaped") +assert results[0]["outcome"] == "os_error" and results[0]["expected_denial"] is True +assert results[1]["outcome"] == "os_error" and results[1]["expected_denial"] is False +assert results[2]["outcome"] == "unexpected_exception" and results[2]["expected_denial"] is False +assert results[3]["outcome"] == "succeeded" and results[3]["expected_denial"] is False +PY +python3 - "$NONPING" <<'PY' +import sys +compile(open(sys.argv[1], "rb").read(), sys.argv[1], "exec") +PY + +# The fixture builder owns creation of SHARE/containment. Pre-creating it in the case makes +# pathlib.Path.mkdir(parents=True) fail before any containment operation reaches Dory. +python3 - "$HARNESS" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("test_containment() {\n") +end = source.index("\n}\n", start) +case = source[start:end] +assert 'mkdir -p "$SHARE/containment"' not in case +PY + +# The live case runs in a `set -euo` case subshell. Its EXIT cleanup must retain access to the +# monitor PID after the test function unwinds; a function-local PID becomes unbound on Bash 3. +python3 - "$HARNESS" "$TMP_ROOT/nonping-cleanup-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("cleanup_nonping_probe() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/nonping-cleanup-early-exit.sh" <<'SH' +#!/bin/bash +set -euo pipefail +NONPING_PROBE_PID="" +cleanup_source="$1" +pid_file="$2" +. "$cleanup_source" +exercise_early_exit() { + sleep 30 & + NONPING_PROBE_PID=$! + printf '%s\n' "$NONPING_PROBE_PID" > "$pid_file" + trap cleanup_nonping_probe EXIT + return 1 +} +exercise_early_exit +SH +set +e +/bin/bash "$TMP_ROOT/nonping-cleanup-early-exit.sh" \ + "$TMP_ROOT/nonping-cleanup-function.sh" "$TMP_ROOT/nonping-cleanup.pid" +nonping_cleanup_code=$? +set -e +[ "$nonping_cleanup_code" -eq 1 ] || fail "early-exit cleanup returned $nonping_cleanup_code" +nonping_cleanup_pid="$(cat "$TMP_ROOT/nonping-cleanup.pid")" +if kill -0 "$nonping_cleanup_pid" 2>/dev/null; then + kill -TERM "$nonping_cleanup_pid" 2>/dev/null || true + fail "early-exit cleanup left monitor pid $nonping_cleanup_pid alive" +fi + +python3 - "$HARNESS" "$TMP_ROOT/recovery-cleanup-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("cleanup_recovery_probe() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/recovery-cleanup-test.sh" <<'SH' +#!/bin/bash +set -euo pipefail +RECOVERY_PROBE_PID="" +now_ms() { /usr/bin/perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'printf "%.0f\n", 1000 * clock_gettime(CLOCK_MONOTONIC)'; } +pause_ms() { /usr/bin/perl -MTime::HiRes=usleep -e 'usleep(1000 * shift)' "$1"; } +. "$1" +child_file="$2" +/bin/bash -c 'sleep 30 & child=$!; printf "%s\n" "$child" > "$1"; wait "$child"' _ "$child_file" \ + > "$child_file.shell.log" 2>&1 & +RECOVERY_PROBE_PID=$! +recovery_pid="$RECOVERY_PROBE_PID" +for _attempt in {1..200}; do [ -f "$child_file" ] && break; sleep 0.01; done +[ -f "$child_file" ] +child_pid="$(cat "$child_file")" +cleanup_recovery_probe +! kill -0 "$recovery_pid" 2>/dev/null +! kill -0 "$child_pid" 2>/dev/null +SH +/bin/bash "$TMP_ROOT/recovery-cleanup-test.sh" "$TMP_ROOT/recovery-cleanup-function.sh" \ + "$TMP_ROOT/recovery-cleanup-child.pid" + +# A case must run outside an `||`/`if` test context. Bash disables errexit throughout functions +# invoked from those contexts, even when run_case explicitly enables it in its case subshell. +python3 - "$HARNESS" "$TMP_ROOT/run-case-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("run_case() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/run-case-fail-fast.sh" <<'SH' +#!/bin/bash +set -euo pipefail +test_root="$1" +EVIDENCE="$test_root/evidence" +RESULTS="$EVIDENCE/results.tsv" +FAIL_REASON="" +mkdir -p "$EVIDENCE" +record_result() { :; } +. "$2" +failing_case() { + : > "$test_root/entered-case" + false + : > "$test_root/continued-after-failure" +} +run_case offline-fail-fast failing_case +SH +set +e +/bin/bash "$TMP_ROOT/run-case-fail-fast.sh" "$TMP_ROOT/run-case-test" \ + "$TMP_ROOT/run-case-function.sh" > "$TMP_ROOT/run-case.out" 2> "$TMP_ROOT/run-case.err" +run_case_code=$? +set -e +[ "$run_case_code" -ne 0 ] || fail "run_case swallowed a failing command" +[ -e "$TMP_ROOT/run-case-test/entered-case" ] || \ + fail "run_case aborted in Bash-local setup before entering the requested case" +[ ! -e "$TMP_ROOT/run-case-test/continued-after-failure" ] || \ + fail "run_case continued after a failing command" + +# Even if Bash 3.2 supplies a misleading zero status to EXIT after an expansion failure, an +# incomplete harness run must still return nonzero. Exercise the exact cleanup function with all +# external work stubbed so this remains offline. +python3 - "$HARNESS" "$TMP_ROOT/cleanup-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("cleanup() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/cleanup-fail-closed.sh" <<'SH' +#!/bin/bash +set -euo pipefail +EVIDENCE="$1" +RESOURCES_STARTED=0 +DOCKER_BIN="" +CREATED_CONTAINERS="" +WORK_ROOT="" +OUTSIDE_ROOT="" +LOCK_OWNED=0 +LOCK_DIR="" +FINAL_STATUS=fail +FAIL_REASON=unexpected_exit +snapshot_final_trees() { return 0; } +write_run_status() { printf '%s\n' "$1:$2:$3" > "$EVIDENCE/captured-status"; } +. "$2" +trap cleanup EXIT +true +SH +mkdir -p "$TMP_ROOT/cleanup-fail-closed-evidence" +set +e +/bin/bash "$TMP_ROOT/cleanup-fail-closed.sh" \ + "$TMP_ROOT/cleanup-fail-closed-evidence" "$TMP_ROOT/cleanup-function.sh" +cleanup_fail_closed_code=$? +set -e +[ "$cleanup_fail_closed_code" -eq 1 ] || \ + fail "incomplete zero-status cleanup returned $cleanup_fail_closed_code" +grep -Fxq 'fail:unexpected_exit:1' \ + "$TMP_ROOT/cleanup-fail-closed-evidence/captured-status" || \ + fail "cleanup did not record the forced nonzero incomplete-run status" + +FAKE_DOCKER="$TMP_ROOT/fake-docker" +cat > "$FAKE_DOCKER" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >> "$DORY_FAKE_DOCKER_CALLS" +if [ -n "${DORY_FAKE_DOCKER_SLEEP:-}" ]; then sleep "$DORY_FAKE_DOCKER_SLEEP"; fi +exit "${DORY_FAKE_DOCKER_EXIT:-99}" +SH +chmod +x "$FAKE_DOCKER" +CALLS="$TMP_ROOT/docker-calls" + +offline_harness() { + DORY_FAKE_DOCKER_CALLS="$CALLS" DORY_DOCKER_BIN="$FAKE_DOCKER" HOME="$TMP_ROOT/home" \ + "$HARNESS" "$@" +} + +offline_harness --help > "$TMP_ROOT/help.txt" +grep -q 'Required explicit opt-in' "$TMP_ROOT/help.txt" +grep -q 'intentionally disruptive' "$TMP_ROOT/help.txt" +grep -q -- '--pull never' "$TMP_ROOT/help.txt" +[ ! -e "$CALLS" ] || fail "--help contacted Docker" + +offline_harness --list-cases > "$TMP_ROOT/cases.txt" +[ "$(wc -l < "$TMP_ROOT/cases.txt" | tr -d '[:space:]')" -eq 9 ] || fail "case inventory changed unexpectedly" +for expected in \ + clean-same-inode-overwrite \ + dirty-old-mmap-atomic-replacement \ + repeated-atomic-replacement \ + hardlink-lifetime \ + symlink-and-moved-parent-containment \ + stdin-passthrough \ + watcher-matrix-round-1 \ + watcher-matrix-round-2 \ + dirty-mmap-failstop-and-restart; do + grep -q "$expected" "$TMP_ROOT/cases.txt" || fail "missing case $expected" +done +[ ! -e "$CALLS" ] || fail "--list-cases contacted Docker" + +set +e +offline_harness > "$TMP_ROOT/no-run.out" 2> "$TMP_ROOT/no-run.err" +no_run_code=$? +offline_harness --run --list-cases > "$TMP_ROOT/conflict.out" 2> "$TMP_ROOT/conflict.err" +conflict_code=$? +offline_harness --run --failstop-timeout-ms nope > "$TMP_ROOT/number.out" 2> "$TMP_ROOT/number.err" +number_code=$? +offline_harness --run --nonping-timeout-seconds 31 > "$TMP_ROOT/nonping-number.out" 2> "$TMP_ROOT/nonping-number.err" +nonping_number_code=$? +offline_harness --run --replace-count 10001 > "$TMP_ROOT/replace-max.out" 2> "$TMP_ROOT/replace-max.err" +replace_max_code=$? +offline_harness --run --replace-count 999999999999999999 > "$TMP_ROOT/huge.out" 2> "$TMP_ROOT/huge.err" +huge_code=$? +PYTHONOPTIMIZE=1 offline_harness --run > "$TMP_ROOT/optimized.out" 2> "$TMP_ROOT/optimized.err" +optimized_code=$? +PYTHONOPTIMIZE=1 python3 "$GUEST" > "$TMP_ROOT/guest-optimized.out" 2> "$TMP_ROOT/guest-optimized.err" +guest_optimized_code=$? +PYTHONOPTIMIZE=1 python3 "$NONPING" > "$TMP_ROOT/nonping-optimized.out" 2> "$TMP_ROOT/nonping-optimized.err" +nonping_optimized_code=$? +set -e +[ "$no_run_code" -eq 2 ] || fail "missing --run returned $no_run_code" +[ "$conflict_code" -eq 2 ] || fail "--run/--list-cases conflict returned $conflict_code" +[ "$number_code" -eq 2 ] || fail "invalid numeric argument returned $number_code" +[ "$nonping_number_code" -eq 2 ] || fail "oversized non-ping timeout returned $nonping_number_code" +[ "$replace_max_code" -eq 2 ] || fail "oversized replace count returned $replace_max_code" +[ "$huge_code" -eq 2 ] || fail "oversized numeric argument returned $huge_code" +[ "$optimized_code" -eq 2 ] || fail "optimized host Python gate returned $optimized_code" +[ "$guest_optimized_code" -eq 2 ] || fail "optimized guest probe returned $guest_optimized_code" +[ "$nonping_optimized_code" -eq 2 ] || fail "optimized non-ping probe returned $nonping_optimized_code" +grep -q 'without the explicit --run flag' "$TMP_ROOT/no-run.err" +grep -q 'PYTHONOPTIMIZE must be unset or 0' "$TMP_ROOT/optimized.err" +grep -q 'refuses optimized Python' "$TMP_ROOT/guest-optimized.err" +grep -q 'refuses optimized Python' "$TMP_ROOT/nonping-optimized.err" +[ ! -e "$CALLS" ] || fail "an offline rejection contacted Docker" + +grep -Fq 'DORY_SOCK:-$HOME/.dory/dory.sock' "$HARNESS" || fail "default Dory socket changed" +grep -Fq -- '--pull never' "$HARNESS" || fail "live runs no longer prohibit pulls" +grep -Fq 'assert_no_running_containers' "$HARNESS" || fail "running-container fail-closed gate missing" +grep -Fq 'host-share coherence requires VM restart' "$HARNESS" || fail "fail-stop attribution check missing" +grep -Fq 'health_daemon_pid' "$HARNESS" || fail "doryd PID continuity check missing" +grep -Fq 'false-running-without-helper.json' "$HARNESS" || fail "false-running health guard missing" +grep -Fq 'assert_engine_status_not_false_running post-failstop-engine-status' "$HARNESS" || \ + fail "immediate post-exit engine-state guard missing" +grep -Fq 'wait_for_old_endpoint_cleanup' "$HARNESS" || fail "stale endpoint cleanup guard missing" +grep -Fq 'hostshare_nonping_probe.py' "$HARNESS" || fail "bounded non-ping regression missing" +grep -Fq 'DORY_EXPECTED_FINAL_VERSION' "$HARNESS" || fail "exact repeated final-value guard missing" +grep -Fq 'endpoint-cleanup-proof.tsv' "$HARNESS" || fail "endpoint transition proof missing" +grep -Fq 'PYTHONOPTIMIZE must be unset or 0' "$HARNESS" || fail "optimized Python gate missing" +grep -Fq '20 + (REPLACE_COUNT + 249) / 250' "$HARNESS" || \ + fail "replace-count-derived guest timeout missing" +grep -Fq 'bounded_docker_version cleanup-initial' "$HARNESS" || \ + fail "cleanup can regress to an unbounded Docker request" +grep -Fq 'bounded_docker_version recovery' "$HARNESS" || \ + fail "recovery can regress to an unbounded Docker request" +if grep -Eq '^[[:space:]]*run_case[[:space:]].*\|\|' "$HARNESS"; then + fail "run_case must not execute in Bash's errexit-ignored OR-list context" +fi +if grep -Eq '^[[:space:]]*prepare_roots[[:space:]].*\|\|' "$HARNESS"; then + fail "prepare_roots must not execute in Bash's errexit-ignored OR-list context" +fi +if grep -Eq '^[[:space:]]*local[[:space:]].*NONPING_PROBE_PID' "$HARNESS"; then + fail "non-ping monitor PID must outlive the dirty-case function for EXIT cleanup" +fi +if grep -Eq '^[[:space:]]*local[[:space:]].*RECOVERY_PROBE_PID' "$HARNESS"; then + fail "recovery monitor PID must outlive the dirty-case function for EXIT cleanup" +fi +if grep -Eq 'host\["[^"]*inode[^"]*"\].*==.*guest\["[^"]*inode[^"]*"\]' "$HARNESS"; then + fail "host st_ino must not be compared numerically with synthetic FUSE inode values" +fi +if grep -Eq 'declare[[:space:]]+-A|(^|[^[:alnum:]_])mapfile([^[:alnum:]_]|$)|(^|[^[:alnum:]_])readarray([^[:alnum:]_]|$)|\$\{[^}]+,,\}' "$HARNESS"; then + fail "Bash 4-only syntax found" +fi +# Bash 3.2 expands a complete `local` command before publishing any variable declared by it. +# Reject declarations such as `local name="$1" log=".../$name"`, which become unbound under +# `set -u`; derived assignments must be placed on a following line. +python3 - "$HARNESS" <<'PY' +import re +import sys + +for number, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1): + match = re.match(r"\s*local\s+(.*)$", line) + if not match: + continue + declaration = match.group(1) + assignments = list(re.finditer(r"(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=", declaration)) + for assignment in assignments: + name = assignment.group(1) + if re.search(r"\$\{?" + re.escape(name) + r"(?:\}|\b)", declaration[assignment.end():]): + raise SystemExit( + f"Bash 3.2 local-order hazard at {sys.argv[1]}:{number}: {name}" + ) +PY +python3 - "$HARNESS" <<'PY' +import sys +source=open(sys.argv[1], encoding="utf-8").read().split("test_dirty_failstop()", 1)[1] +recovery_start=source.index('poll_recovered_helper "$old_pid" "$event_ms" "$daemon_pid" > "$recovery_result" &') +gate=source.index(': > "$nonping_gate"') +request_started=source.index('[ -f "$nonping_started" ]') +state=source.index("assert_engine_status_not_false_running post-failstop-engine-status") +cleanup=source.index("wait_for_old_endpoint_cleanup") +recovery_wait=source.index('wait "$RECOVERY_PROBE_PID"') +assert recovery_start < gate < request_started < state < cleanup < recovery_wait +assert source.count("poll_recovered_helper") == 1 +PY + +# Exercise the exact timing oracle: a request between helper exit and independent recovery proof +# passes, while a request observed after recovery or before old-helper exit fails closed. +python3 - "$HARNESS" "$TMP_ROOT/nonping-window-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("validate_nonping_window() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/nonping-window-result.json" <<'JSON' +{ + "command": "docker version --format {{.Server.Version}}", + "elapsed_ms": 10, + "gate_observed": true, + "monitor_started_monotonic_ms": 90, + "request_started_monotonic_ms": 120, + "returncode": 1, + "timed_out": false +} +JSON +. "$TMP_ROOT/nonping-window-function.sh" +validate_nonping_window "$TMP_ROOT/nonping-window-result.json" 100 110 130 120 5 +set +e +validate_nonping_window "$TMP_ROOT/nonping-window-result.json" 100 110 119 120 5 \ + > "$TMP_ROOT/nonping-after-recovery.out" 2> "$TMP_ROOT/nonping-after-recovery.err" +after_recovery_code=$? +validate_nonping_window "$TMP_ROOT/nonping-window-result.json" 100 121 130 120 5 \ + > "$TMP_ROOT/nonping-before-exit.out" 2> "$TMP_ROOT/nonping-before-exit.err" +before_exit_code=$? +set -e +[ "$after_recovery_code" -ne 0 ] || fail "non-ping timing oracle accepted request after recovery" +[ "$before_exit_code" -ne 0 ] || fail "non-ping timing oracle accepted request before old-helper exit" + +# A final socket may legitimately receive the same st_ino after the old socket was observed absent +# or replaced. Exercise the exact recovered-endpoint validator with reused final numbers, then prove +# it still rejects a proof that never left the old identity. +python3 - "$HARNESS" "$TMP_ROOT/recovered-endpoint-function.sh" <<'PY' +import sys +source = open(sys.argv[1], encoding="utf-8").read() +start = source.index("assert_recovered_endpoints_are_fresh() {\n") +end = source.index("\n}\n", start) + 3 +open(sys.argv[2], "w", encoding="utf-8").write(source[start:end]) +PY +cat > "$TMP_ROOT/recovered-endpoint-reuse.sh" <<'SH' +#!/bin/bash +set -euo pipefail +EVIDENCE="$1" +DORY_SOCK=dory +FORWARD_SOCK=forward +ACTIVITY_SOCK=activity +mkdir -p "$EVIDENCE" +socket_identity() { + case "$1" in + dory) echo '1:11' ;; + forward) echo '1:22' ;; + activity) echo '1:33' ;; + *) return 1 ;; + esac +} +. "$2" +cat > "$EVIDENCE/endpoint-cleanup-proof.tsv" <<'EOF' +endpoint old_identity transition_identity transition_monotonic_ms +dory 1:11 absent 100 +forward 1:22 1:222 101 +activity 1:33 absent 102 +EOF +assert_recovered_endpoints_are_fresh '1:11' '1:22' '1:33' +cat > "$EVIDENCE/endpoint-cleanup-proof.tsv" <<'EOF' +endpoint old_identity transition_identity transition_monotonic_ms +dory 1:11 1:11 100 +forward 1:22 1:222 101 +activity 1:33 absent 102 +EOF +set +e +assert_recovered_endpoints_are_fresh '1:11' '1:22' '1:33' >/dev/null 2>&1 +invalid_proof_code=$? +set -e +[ "$invalid_proof_code" -ne 0 ] +SH +/bin/bash "$TMP_ROOT/recovered-endpoint-reuse.sh" "$TMP_ROOT/endpoint-evidence" \ + "$TMP_ROOT/recovered-endpoint-function.sh" + +# Exercise the exact independently bounded non-ping probe without a Docker socket. A quick +# connection failure is acceptable; an old 210-second backend wait must trip the outer watchdog. +NONPING_GATE="$TMP_ROOT/nonping.gate" +NONPING_RESULT="$TMP_ROOT/nonping-result.json" +: > "$NONPING_GATE" +rm -f "$CALLS" "$NONPING_RESULT.started" +DORY_FAKE_DOCKER_CALLS="$CALLS" python3 "$NONPING" "$NONPING_GATE" "$FAKE_DOCKER" \ + "$TMP_ROOT/missing-dory.sock" 2 2 "$NONPING_RESULT" +python3 - "$NONPING_RESULT" "$NONPING_RESULT.started" "$CALLS" <<'PY' +import json, sys +result=json.load(open(sys.argv[1], encoding="utf-8")) +started=int(open(sys.argv[2], encoding="utf-8").read().strip()) +calls=open(sys.argv[3], encoding="utf-8").read() +assert result["gate_observed"] is True and result["timed_out"] is False, result +assert result["command"].startswith("docker version ") and "/_ping" not in result["command"], result +assert result["returncode"] == 99, result +assert result["request_started_monotonic_ms"] == started, result +assert "version --format {{.Server.Version}}" in calls, calls +PY + +# The request-start handshake must be causally downstream of the gate, not another copy of the +# monitor-ready marker. Hold the gate closed and observe the exact probe in the intermediate state. +DELAYED_GATE="$TMP_ROOT/nonping-delayed.gate" +DELAYED_RESULT="$TMP_ROOT/nonping-delayed-result.json" +rm -f "$DELAYED_GATE" "$DELAYED_RESULT" "$DELAYED_RESULT.ready" "$DELAYED_RESULT.started" "$CALLS" +DORY_FAKE_DOCKER_CALLS="$CALLS" python3 "$NONPING" "$DELAYED_GATE" "$FAKE_DOCKER" \ + "$TMP_ROOT/missing-dory.sock" 2 2 "$DELAYED_RESULT" & +delayed_probe_pid=$! +for _attempt in {1..200}; do + [ -f "$DELAYED_RESULT.ready" ] && break + kill -0 "$delayed_probe_pid" 2>/dev/null || break + sleep 0.01 +done +[ -f "$DELAYED_RESULT.ready" ] || { kill -TERM "$delayed_probe_pid" 2>/dev/null || true; wait "$delayed_probe_pid" 2>/dev/null || true; fail "non-ping probe never armed"; } +[ ! -e "$DELAYED_RESULT.started" ] || { kill -TERM "$delayed_probe_pid" 2>/dev/null || true; wait "$delayed_probe_pid" 2>/dev/null || true; fail "non-ping request started before gate"; } +: > "$DELAYED_GATE" +set +e +wait "$delayed_probe_pid" +delayed_probe_code=$? +set -e +[ "$delayed_probe_code" -eq 0 ] || fail "gated non-ping probe returned $delayed_probe_code" +[ -f "$DELAYED_RESULT.started" ] || fail "non-ping request-start handshake missing after gate" + +rm -f "$CALLS" "$NONPING_RESULT" +set +e +DORY_FAKE_DOCKER_CALLS="$CALLS" DORY_FAKE_DOCKER_SLEEP=3 \ + python3 "$NONPING" "$NONPING_GATE" "$FAKE_DOCKER" "$TMP_ROOT/missing-dory.sock" \ + 1 2 "$NONPING_RESULT" +nonping_probe_code=$? +set -e +[ "$nonping_probe_code" -eq 124 ] || fail "bounded non-ping timeout returned $nonping_probe_code" +python3 - "$NONPING_RESULT" <<'PY' +import json, sys +result=json.load(open(sys.argv[1], encoding="utf-8")) +assert result["gate_observed"] is True and result["timed_out"] is True, result +assert result["elapsed_ms"] < 3000, result +PY + +# Exercise the exact guest fixture's four non-Linux-specific coordination modes on an isolated +# host directory. Containment and inotify remain live-only because macOS does not implement the +# Linux VFS flags/events those cases are designed to validate. +python3 - "$GUEST" "$TMP_ROOT/guest-offline" <<'PY' +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +guest = Path(sys.argv[1]) +root = Path(sys.argv[2]) +root.mkdir() + +def payload(label, size=4096): + seed = (label + "\n").encode() + return (seed * ((size + len(seed) - 1) // len(seed)))[:size] + +def start(mode, extra=None): + env = os.environ.copy() + env["DORY_PROBE_ROOT"] = str(root) + env["DORY_PROBE_TIMEOUT"] = "5" + if extra: + env.update(extra) + process = subprocess.Popen( + [sys.executable, str(guest), mode], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + line = process.stdout.readline().strip() + if "DORY_PROBE_READY" not in line: + stdout, stderr = process.communicate(timeout=5) + raise AssertionError((mode, line, stdout, stderr, process.returncode)) + return process + +def finish(process): + stdout, stderr = process.communicate(timeout=8) + assert process.returncode == 0, (stdout, stderr, process.returncode) + +clean = root / "clean" +clean.mkdir() +(clean / "value.bin").write_bytes(payload("CLEAN-OLD")) +process = start("clean", {"DORY_INITIAL_LABEL": "CLEAN-OLD", "DORY_EXPECTED_LABEL": "CLEAN-NEW"}) +descriptor = os.open(clean / "value.bin", os.O_WRONLY) +try: + assert os.pwrite(descriptor, payload("CLEAN-NEW"), 0) == 4096 +finally: + os.close(descriptor) +finish(process) +assert json.load(open(clean / "result.json"))["inode_before"] == os.stat(clean / "value.bin").st_ino + +atomic = root / "atomic" +atomic.mkdir() +(atomic / "value.bin").write_bytes(payload("ATOMIC-OLD")) +process = start( + "atomic", + { + "DORY_ORIGINAL_LABEL": "ATOMIC-OLD", + "DORY_DIRTY_PREFIX": "DORY-GUEST-DIRTY-OLD", + "DORY_REPLACEMENT_LABEL": "ATOMIC-NEW", + }, +) +temporary = atomic / "replacement" +temporary.write_bytes(payload("ATOMIC-NEW")) +os.replace(temporary, atomic / "value.bin") +(atomic / "go").touch() +finish(process) +atomic_result = json.load(open(atomic / "result.json")) +assert atomic_result["old_fd_sha256"] == atomic_result["expected_old_sha256"] +assert atomic_result["fresh_sha256"] == atomic_result["expected_fresh_sha256"] +assert atomic_result["old_inode"] != atomic_result["fresh_inode"] +assert atomic_result["old_nlink"] == 0 and atomic_result["samples"] > 0 + +repeated = root / "repeated" +repeated.mkdir() +(repeated / "value.txt").write_text("value-000000\n") +process = start("repeated", {"DORY_EXPECTED_FINAL_VERSION": "100"}) +for index in range(1, 101): + temporary = repeated / f"replacement-{index}" + temporary.write_text(f"value-{index:06d}\n") + os.replace(temporary, repeated / "value.txt") + time.sleep(0.001) +time.sleep(0.05) +(repeated / "stop").touch() +finish(process) +repeated_result = json.load(open(repeated / "result.json")) +assert repeated_result["samples"] > 0 and repeated_result["errors"] == [] +assert repeated_result["invalid_payloads"] == [] +assert repeated_result["violations"] == [] +assert repeated_result["final_payload"] == repeated_result["expected_final_payload"] == "value-000100" +assert repeated_result["final_version"] == 100 +assert repeated_result["final_convergence_samples"] > 0 +observations = repeated_result["observations"] +assert observations and observations[0]["version"] == 0 +versions = [item["version"] for item in observations] +assert versions == sorted(versions) +inode_versions = {} +for item in observations: + inode = item["inode"] + assert inode_versions.setdefault(inode, item["version"]) == item["version"] + +# Stop-barrier visibility can lead the final value's relay notification. The final oracle must poll +# through that bounded gap instead of taking one racy sample and rejecting a correct backend. +(repeated / "stop").unlink() +(repeated / "result.json").unlink() +(repeated / "value.txt").write_text("value-000000\n") +process = start( + "repeated", + {"DORY_EXPECTED_FINAL_VERSION": "1", "DORY_FINAL_CONVERGENCE_TIMEOUT": "1"}, +) +(repeated / "stop").touch() +time.sleep(0.05) +temporary = repeated / "delayed-final" +temporary.write_text("value-000001\n") +os.replace(temporary, repeated / "value.txt") +finish(process) +delayed_final = json.load(open(repeated / "result.json")) +assert delayed_final["final_version"] == 1 and delayed_final["violations"] == [] +assert delayed_final["final_convergence_samples"] > 1 + +# The guest must not bless the host's final value indirectly. Give it an unreachable expected final +# version and a short convergence window; the exact probe must fail and retain the mismatch evidence. +(repeated / "stop").unlink() +(repeated / "result.json").unlink() +(repeated / "value.txt").write_text("value-000000\n") +process = start( + "repeated", + {"DORY_EXPECTED_FINAL_VERSION": "101", "DORY_FINAL_CONVERGENCE_TIMEOUT": "0.05"}, +) +(repeated / "stop").touch() +stdout, stderr = process.communicate(timeout=3) +assert process.returncode == 1, (stdout, stderr, process.returncode) +mismatch = json.load(open(repeated / "result.json")) +assert any(item["kind"] == "wrong_final_guest_payload" for item in mismatch["violations"]) + +hardlink = root / "hardlink" +hardlink.mkdir() +expected = b"DORY-HARDLINK-SENTINEL\n" +(hardlink / "a.txt").write_bytes(expected) +process = start("hardlink") +assert os.stat(hardlink / "a.txt").st_nlink == os.stat(hardlink / "b.txt").st_nlink == 2 +os.unlink(hardlink / "a.txt") +(hardlink / "go1").touch() +line = process.stdout.readline().strip() +assert line == "DORY_PROBE_READY hardlink-phase2", line +os.unlink(hardlink / "b.txt") +(hardlink / "go2").touch() +finish(process) +hardlink_result = json.load(open(hardlink / "result.json")) +digest = hashlib.sha256(expected).hexdigest() +assert hardlink_result["old_fd_after_final_sha256"] == digest +assert hardlink_result["final_fd_nlink"] == 0 +PY + +echo "live host-share integration harness offline tests passed" From 0ce1a0982d021f046e27a1625e570c73ce595056 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:10:32 +0000 Subject: [PATCH 164/338] test(diagnostics): track doctor regression suite --- scripts/test-dory-doctor.sh | 1838 +++++++++++++++++++++++++++++++++++ 1 file changed, 1838 insertions(+) create mode 100755 scripts/test-dory-doctor.sh diff --git a/scripts/test-dory-doctor.sh b/scripts/test-dory-doctor.sh new file mode 100755 index 00000000..d474073d --- /dev/null +++ b/scripts/test-dory-doctor.sh @@ -0,0 +1,1838 @@ +#!/bin/bash +# Fast checks for the P0 diagnostics CLI. These intentionally do not require a +# running Dory engine; engine-backed probes are covered by readiness scripts. +set -euo pipefail +cd "$(dirname "$0")/.." + +TMP_HOME="$(mktemp -d)" +PIDS_TO_CLEAN=() + +stop_test_pid() { + local pid="${1:-}" state="" attempt + case "$pid" in + ''|*[!0-9]*) return 0 ;; + esac + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || true + return 0 + fi + kill "$pid" 2>/dev/null || true + # Never let an offline self-test wedge its caller during EXIT cleanup. A child that has exited + # but has not yet been waited is a zombie, so reap it instead of waiting for kill -0 to change. + for ((attempt = 0; attempt < 50; attempt++)); do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)" + case "$state" in + ''|*Z*) break ;; + esac + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null && [[ "$state" != *Z* ]]; then + kill -KILL "$pid" 2>/dev/null || true + fi + wait "$pid" 2>/dev/null || true +} + +cleanup() { + local pid engine_pid="" + # fake-engine backgrounds its server and then exits, so that server is not represented by the + # proxy PID. Track its private pidfile explicitly or successful test runs leak an orphan server. + if [ -n "${FAKE_ENGINE_PID:-}" ] && [ -s "$FAKE_ENGINE_PID" ]; then + read -r engine_pid < "$FAKE_ENGINE_PID" || true + PIDS_TO_CLEAN+=("$engine_pid") + fi + if [ "${#PIDS_TO_CLEAN[@]}" -gt 0 ]; then + for pid in "${PIDS_TO_CLEAN[@]}"; do + stop_test_pid "$pid" + done + fi + rm -rf "$TMP_HOME" +} +trap cleanup EXIT + +export HOME="$TMP_HOME" +export DORY_CONFIG="$TMP_HOME/config.json" +export DORY_SOCK="$TMP_HOME/missing-dory.sock" +# Fail closed against host-installed clients. Individual cases opt into their own fake binaries; +# every other case must remain offline even when the caller's PATH contains ~/.dory/bin. +export DORYDCTL_BIN=/usr/bin/false +export DORY_DOCKER_BIN=/usr/bin/false +export DORY_DOCKER_COMPOSE_BIN=/usr/bin/false +export DORY_KUBECTL_BIN=/usr/bin/false +export DORY_SUPPORT_UNIFIED_LOG=1 +export DORY_SUPPORT_UNIFIED_LOG_LAST=1m + +mkdir -p "$TMP_HOME/fake-system-bin" +cat > "$TMP_HOME/fake-system-bin/sw_vers" <<'SH' +#!/bin/sh +printf 'ProductName:\tmacOS\nProductVersion:\t14.7\nBuildVersion:\t23H124\n' +SH +cat > "$TMP_HOME/fake-system-bin/launchctl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >> "$HOME/.fake-launchctl-commands" +printf 'service = dev.dory.doryd\nstate = running\ntoken=supersecret\n' +SH +cat > "$TMP_HOME/fake-system-bin/log" <<'SH' +#!/bin/sh +printf '2026-07-08 00:00:00.000 Dory[123]: Authorization: Bearer secret-token\n' +SH +chmod +x "$TMP_HOME/fake-system-bin/sw_vers" "$TMP_HOME/fake-system-bin/launchctl" "$TMP_HOME/fake-system-bin/log" +export PATH="$TMP_HOME/fake-system-bin:$PATH" +export DORY_LAUNCHCTL_BIN="$TMP_HOME/fake-system-bin/launchctl" + +python3 -m py_compile scripts/dory-doctor +python3 -m py_compile scripts/dory-idle-proxy + +# The doctor helper deliberately follows PATH for python3. Ensure its registry probe supplements +# interpreter-specific OpenSSL roots with macOS's CA bundle so Python.org installs behave like the +# Apple and Homebrew interpreters. +python3 - <<'PY' +import importlib.machinery, importlib.util, pathlib, sys, tempfile +loader = importlib.machinery.SourceFileLoader("dd_tls", "scripts/dory-doctor") +dd = importlib.util.module_from_spec(importlib.util.spec_from_loader("dd_tls", loader)) +sys.modules["dd_tls"] = dd +loader.exec_module(dd) + +class Context: + def __init__(self): + self.ca_files = [] + + def load_verify_locations(self, *, cafile): + self.ca_files.append(cafile) + +original_platform = sys.platform +original_factory = dd.ssl.create_default_context +try: + system_ca = pathlib.Path(tempfile.mkdtemp()) / "cert.pem" + system_ca.write_text("test CA bundle", encoding="utf-8") + dd.DARWIN_SYSTEM_CA_FILE = system_ca + dd.ssl.create_default_context = Context + + sys.platform = "darwin" + assert dd.default_tls_context().ca_files == [str(system_ca)] + + sys.platform = "linux" + assert dd.default_tls_context().ca_files == [] +finally: + sys.platform = original_platform + dd.ssl.create_default_context = original_factory +PY + +grep -q 'DORY_DOCTOR_SHARED_PROBE_ROOT' scripts/dory-doctor +if grep -q 'probe_root = HOME / "\.dory" / "doctor"' scripts/dory-doctor; then + echo "active doctor probes must not use the guest-hidden ~/.dory state directory" >&2 + exit 1 +fi +for hidden in .dory .zsh_history .bash_history .codex .orbstack .colima; do + grep -q "\"$hidden\"" scripts/dory-doctor +done +bash -n scripts/dory +bash -n scripts/p0-smoke.sh +bash -n scripts/nonnative-build-smoke.sh + +scripts/dory agent guide --json | python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.agent.guide" +assert data["version"] == 1 +assert data["defaults"]["socket"] == os.environ["DORY_SOCK"] +assert "do not require dory install" in data["defaults"]["hostToolPolicy"] +assert data["schemas"]["wait"] == "dev.dory.wait v1" +assert data["schemas"]["events"] == "dev.dory.events v1" +commands = {item["id"]: item for item in data["commands"]} +assert commands["doctor"]["status"] == "available" +assert commands["doctor"]["json"] is True +assert commands["support"]["status"] == "available" +assert commands["support"]["redacted"] is True +assert commands["install"]["status"] == "available" +assert commands["install"]["dryRun"] is True +assert commands["install"]["manualRecoveryOnly"] is True +assert commands["install"]["invoke"] == "dory install --json --dry-run" +assert "Manual recovery only" in commands["install"]["notes"] +assert commands["component"]["json"] is True +assert "kubernetes" in commands["component"]["components"] +assert "selected Dory data drive" in commands["component"]["notes"] +assert commands["repair"]["dryRun"] is True +assert commands["wait"]["status"] == "available" +assert commands["wait"]["schema"] == "dev.dory.wait" +assert "wait.timeout" in commands["wait"]["resultCodes"] +assert "machine" in commands["wait"]["targets"] +assert commands["events"]["status"] == "available" +assert commands["events"]["schema"] == "dev.dory.events" +assert commands["events"]["eventSchema"] == "dev.dory.event" +assert "incident" in commands["events"]["sources"] +assert "authorization-plan" in commands["network"]["invoke"] +assert commands["engine"]["json"] is True +assert commands["machine"]["json"] is True +assert "machine exec NAME --json" in commands["machine"]["notes"] +assert commands["mcp"]["status"] == "available" +assert commands["mcp"]["transport"] == "stdio" +assert "dory.machine_exec" in commands["mcp"]["tools"] +assert "dory.sandbox_run" in commands["mcp"]["tools"] +assert commands["sandbox"]["status"] == "supported" +assert commands["sandbox"]["hostFileSharingDefault"] == "none" +assert commands["sandbox"]["scopedMounts"] is True +assert commands["sandbox"]["mountModeDefault"] == "ro" +assert commands["sandbox"]["networkPolicies"] == ["none", "outbound", "full"] +assert commands["sandbox"]["unenforcedNetworkPolicies"] == [] +assert commands["sandbox"]["networkPolicyDefault"] == "none" +assert "disk" in commands["sandbox"]["resourceCaps"] +assert commands["sandbox"]["rollback"] is True +assert commands["sandbox"]["ttlCleanup"] is True +assert data["recommendedRecoveryLoop"] +' + +scripts/dory agent guide --text | grep -q "Dory agent guide v1" +scripts/dory agent guide --text | grep -q "doryd reconciles host tools automatically" + +mkdir -p "$TMP_HOME/fake-bin" +for tool in docker docker-buildx docker-compose kubectl dorydctl; do + cat > "$TMP_HOME/fake-bin/$tool" <<'SH' +#!/bin/sh +echo "$0 $*" +SH + chmod +x "$TMP_HOME/fake-bin/$tool" +done +cat > "$TMP_HOME/fake-bin/docker" <<'SH' +#!/bin/sh +set -eu +case "${1:-} ${2:-}" in + "context inspect") + test -s "$HOME/.fake-dory-context" + cat "$HOME/.fake-dory-context" + ;; + "context create") + for argument in "$@"; do + case "$argument" in host=*) printf '%s\n' "${argument#host=}" > "$HOME/.fake-dory-context" ;; esac + done + ;; + "context use") + printf '%s\n' "${3:-default}" > "$HOME/.fake-current-context" + ;; + "context show") + cat "$HOME/.fake-current-context" 2>/dev/null || printf 'default\n' + ;; + "context rm") + rm -f "$HOME/.fake-dory-context" + ;; + *) ;; +esac +SH +chmod +x "$TMP_HOME/fake-bin/docker" +cat > "$TMP_HOME/fake-bin/Dory-maintenance" <<'SH' +#!/bin/sh +test "${1:-}" = --unregister-network-helper +printf '%s\n' "$*" >> "$HOME/.fake-app-maintenance-commands" +printf 'network-helper=disabled\n' +SH +chmod +x "$TMP_HOME/fake-bin/Dory-maintenance" + +install_json="$(DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" \ + DORY_DOCKER_BUILDX_BIN="$TMP_HOME/fake-bin/docker-buildx" \ + DORY_DOCKER_COMPOSE_BIN="$TMP_HOME/fake-bin/docker-compose" \ + DORY_KUBECTL_BIN="$TMP_HOME/fake-bin/kubectl" \ + DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + scripts/dory install --json)" +printf '%s' "$install_json" | python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.cli.install" +assert data["action"] == "install" +assert data["dryRun"] is False +assert data["composePluginInstalled"] is True +assert data["buildxPluginInstalled"] is True +assert data["dockerContextReconciled"] is True +linked = set(data["linked"]) +assert {"docker", "docker-buildx", "docker-compose", "kubectl", "dory", "dory-doctor", "dorydctl"} <= linked +assert os.path.islink(os.path.expanduser("~/.dory/bin/docker")) +assert os.path.islink(os.path.expanduser("~/.docker/cli-plugins/docker-compose")) +assert os.path.islink(os.path.expanduser("~/.docker/cli-plugins/docker-buildx")) +assert "dory cli" in open(os.path.expanduser("~/.zprofile"), encoding="utf-8").read() +' + +launch_agent="$TMP_HOME/Library/LaunchAgents/dev.dory.doryd.plist" +mkdir -p "$(dirname "$launch_agent")" +plutil -create xml1 "$launch_agent" +plutil -insert Label -string dev.dory.doryd "$launch_agent" +plutil -insert ProgramArguments \ + -json '["/Applications/Dory.app/Contents/Helpers/doryd"]' "$launch_agent" + +uninstall_json="$(DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" \ + DORY_DOCKER_BUILDX_BIN="$TMP_HOME/fake-bin/docker-buildx" \ + DORY_DOCKER_COMPOSE_BIN="$TMP_HOME/fake-bin/docker-compose" \ + DORY_KUBECTL_BIN="$TMP_HOME/fake-bin/kubectl" \ + DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + DORY_APP_BIN="$TMP_HOME/fake-bin/Dory-maintenance" \ + scripts/dory uninstall --json)" +printf '%s' "$uninstall_json" | python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.cli.install" +assert data["action"] == "uninstall" +assert data["dockerContextRemoved"] is True +assert not os.path.exists(os.path.expanduser("~/.dory/bin/docker")) +assert not os.path.exists(os.path.expanduser("~/.docker/cli-plugins/docker-compose")) +assert not os.path.exists(os.path.expanduser("~/.docker/cli-plugins/docker-buildx")) +assert not os.path.exists(os.path.expanduser("~/.zprofile")) +assert not os.path.exists(os.path.expanduser("~/Library/LaunchAgents/dev.dory.doryd.plist")) +' +test ! -e "$TMP_HOME/.fake-dory-context" +test "$(cat "$TMP_HOME/.fake-current-context")" = default +grep -Fxq "bootout gui/$(id -u)/dev.dory.doryd" "$TMP_HOME/.fake-launchctl-commands" +grep -Fxq -- '--unregister-network-helper' "$TMP_HOME/.fake-app-maintenance-commands" + +# Uninstall never follows or deletes a foreign LaunchAgent symlink. +printf '%s\n' 'user-owned' > "$TMP_HOME/user-owned-launch-agent" +ln -s "$TMP_HOME/user-owned-launch-agent" "$launch_agent" +if DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory uninstall --json \ + >"$TMP_HOME/symlinked-launch-agent.out" 2>"$TMP_HOME/symlinked-launch-agent.err"; then + echo "dory uninstall removed a symlinked LaunchAgent" >&2 + exit 1 +fi +grep -q 'left a symlinked LaunchAgent untouched' "$TMP_HOME/symlinked-launch-agent.err" +test "$(cat "$TMP_HOME/user-owned-launch-agent")" = user-owned +rm -f "$launch_agent" "$TMP_HOME/user-owned-launch-agent" + +# Existing profile bytes, including a missing final newline, survive an install/uninstall cycle. +printf '%s' 'export USER_SETTING=1' > "$TMP_HOME/.zprofile" +chmod 640 "$TMP_HOME/.zprofile" +profile_before="$(shasum -a 256 "$TMP_HOME/.zprofile" | awk '{print $1}')" +profile_mode_before="$(stat -f '%Lp' "$TMP_HOME/.zprofile")" +DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory install --json >/dev/null +DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory uninstall --json >/dev/null +test "$(shasum -a 256 "$TMP_HOME/.zprofile" | awk '{print $1}')" = "$profile_before" +test "$(stat -f '%Lp' "$TMP_HOME/.zprofile")" = "$profile_mode_before" +rm -f "$TMP_HOME/.zprofile" + +# A damaged marker never causes uninstall to discard the rest of a user's profile. +printf '%s\n' 'export KEEP_BEFORE=1' '# >>> dory cli >>>' 'export KEEP_AFTER=1' > "$TMP_HOME/.zprofile" +malformed_uninstall="$(DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory uninstall --json 2>"$TMP_HOME/malformed-uninstall.err")" +printf '%s' "$malformed_uninstall" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["pathProfileChanged"] is False +' +grep -q 'left malformed shell markers untouched' "$TMP_HOME/malformed-uninstall.err" +grep -q '^export KEEP_AFTER=1$' "$TMP_HOME/.zprofile" +rm -f "$TMP_HOME/.zprofile" + +# A context named dory that points elsewhere belongs to the user and is never overwritten. +printf '%s\n' 'ssh://foreign.example' > "$TMP_HOME/.fake-dory-context" +foreign_install="$(DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" \ + DORY_DOCKER_BUILDX_BIN="$TMP_HOME/fake-bin/docker-buildx" \ + DORY_DOCKER_COMPOSE_BIN="$TMP_HOME/fake-bin/docker-compose" \ + DORY_KUBECTL_BIN="$TMP_HOME/fake-bin/kubectl" \ + DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + scripts/dory install --json 2>"$TMP_HOME/foreign-context.err")" +printf '%s' "$foreign_install" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["dockerContextReconciled"] is False +' +grep -q "did not replace the existing 'dory' Docker context" "$TMP_HOME/foreign-context.err" +test "$(cat "$TMP_HOME/.fake-dory-context")" = 'ssh://foreign.example' +DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory uninstall --json \ + | python3 -c 'import json, sys; assert json.load(sys.stdin)["dockerContextRemoved"] is False' +test "$(cat "$TMP_HOME/.fake-dory-context")" = 'ssh://foreign.example' +rm -f "$TMP_HOME/.fake-dory-context" + +# A user's pre-existing Compose plugin is never replaced or removed by install/uninstall. +mkdir -p "$TMP_HOME/.docker/cli-plugins" +printf '%s\n' 'user-owned-compose' > "$TMP_HOME/.docker/cli-plugins/docker-compose" +printf '%s\n' 'user-owned-buildx' > "$TMP_HOME/.docker/cli-plugins/docker-buildx" +DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" \ + DORY_DOCKER_BUILDX_BIN="$TMP_HOME/fake-bin/docker-buildx" \ + DORY_DOCKER_COMPOSE_BIN="$TMP_HOME/fake-bin/docker-compose" \ + DORY_KUBECTL_BIN="$TMP_HOME/fake-bin/kubectl" \ + DORYDCTL_BIN="$TMP_HOME/fake-bin/dorydctl" \ + scripts/dory install --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["composePluginInstalled"] is False +assert data["buildxPluginInstalled"] is False +' +test "$(cat "$TMP_HOME/.docker/cli-plugins/docker-compose")" = "user-owned-compose" +test "$(cat "$TMP_HOME/.docker/cli-plugins/docker-buildx")" = "user-owned-buildx" +DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" \ + DORY_DOCKER_BUILDX_BIN="$TMP_HOME/fake-bin/docker-buildx" \ + scripts/dory uninstall --json >/dev/null +test "$(cat "$TMP_HOME/.docker/cli-plugins/docker-compose")" = "user-owned-compose" +test "$(cat "$TMP_HOME/.docker/cli-plugins/docker-buildx")" = "user-owned-buildx" + +DORYDCTL_BIN=/usr/bin/false DORY_DOCKER_BIN=/usr/bin/false scripts/dory wait engine --until not-running --timeout 0 --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["kind"] == "engine" +assert data["state"] == "not-running" +assert data["matched"] is True +' + +set +e +unavailable_engine="$(DORYDCTL_BIN=/usr/bin/false scripts/dory engine status --json)" +unavailable_engine_rc=$? +set -e +test "$unavailable_engine_rc" -eq 1 +printf '%s' "$unavailable_engine" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["state"] == "unavailable" +assert "doryd control is unavailable" in data["detail"] +' + +set +e +wait_json="$(DORYDCTL_BIN=/usr/bin/false DORY_DOCKER_BIN=/usr/bin/false scripts/dory wait engine --until running --timeout 0 --json)" +wait_rc=$? +set -e +test "$wait_rc" -eq 1 +printf '%s' "$wait_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.wait" +assert data["version"] == 1 +assert data["kind"] == "engine" +assert data["desiredState"] == "running" +assert data["ok"] is False +assert data["status"] == "timeout" +assert data["code"] == "wait.timeout" +assert data["matched"] is False +' + +DORYDCTL_BIN=/usr/bin/false DORY_DOCKER_BIN=/usr/bin/false scripts/dory wait machine ghost --until missing --timeout 0 --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.wait" +assert data["kind"] == "machine" +assert data["target"] == "ghost" +assert data["state"] == "missing" +assert data["ok"] is True +assert data["status"] == "matched" +assert data["code"] == "wait.matched" +assert data["matched"] is True +' + +mkdir -p "$TMP_HOME/no-dorydctl-home" +set +e +machine_err="$(HOME="$TMP_HOME/no-dorydctl-home" PATH="/usr/bin:/bin" DORYDCTL_BIN="$TMP_HOME/missing-dorydctl" DORY_DOCKER_BIN=/usr/bin/false scripts/dory machine ls 2>&1 >/dev/null)" +machine_rc=$? +ssh_err="$(HOME="$TMP_HOME/no-dorydctl-home" PATH="/usr/bin:/bin" DORYDCTL_BIN="$TMP_HOME/missing-dorydctl" DORY_DOCKER_BIN=/usr/bin/false scripts/dory ssh dev 2>&1 >/dev/null)" +ssh_rc=$? +set -e +test "$machine_rc" -eq 1 +test "$ssh_rc" -eq 1 +printf '%s' "$machine_err" | grep -q "requires dorydctl" +printf '%s' "$ssh_err" | grep -q "requires dorydctl" + +cat > "$TMP_HOME/fake-dorydctl" <<'SH' +#!/bin/sh +if [ "$1" = "--timeout" ]; then shift 2; fi +if [ "$1" = "doctor-json" ]; then + printf '{"schema":"dev.dory.doryd.doctor","token":"supersecret","checks":[]}\n' +elif [ "$1" = "health" ]; then + printf '{"state":"running","checks":[]}\n' +elif [ "$1" = "incidents" ]; then + printf '[{"at":"2026-07-08T00:00:04Z","type":"engine.start","detail":"Authorization: Bearer secret-token"}]\n' +elif [ "$1" = "idle" ] && [ "$2" = "status" ]; then + printf '{"mode":"auto-idle","engine_desired_state":"running","auto_idle_enabled":true,"can_sleep":true,"blockers":[],"engine_state":{"available":true,"owner":"doryd","state":"running"},"policy":{"sleepAfterMinutes":30,"keepPublishedPortsAwake":false,"keepKubernetesAwake":true,"keepPinnedProjectsAwake":true,"showWakeNotifications":true}}\n' +elif [ "$1" = "idle" ] && [ "$2" = "mode" ]; then + printf '{"mode":"%s","engine_desired_state":"running","auto_idle_enabled":true,"can_sleep":true,"blockers":[],"engine_state":{"available":true,"owner":"doryd","state":"running"},"policy":{"sleepAfterMinutes":30,"keepPublishedPortsAwake":false,"keepKubernetesAwake":true,"keepPinnedProjectsAwake":true,"showWakeNotifications":true}}\n' "$3" +elif [ "$1" = "idle" ] && [ "$2" = "set" ]; then + printf '{"mode":"auto-idle","engine_desired_state":"running","auto_idle_enabled":true,"can_sleep":true,"blockers":[],"engine_state":{"available":true,"owner":"doryd","state":"running"},"policy":{"sleepAfterMinutes":30,"keepPublishedPortsAwake":false,"keepKubernetesAwake":true,"keepPinnedProjectsAwake":true,"showWakeNotifications":true}}\n' +elif [ "$1" = "idle" ] && [ "$2" = "history" ]; then + printf '[{"at":"2026-07-08T00:00:05Z","state":"sleeping","detail":"idle policy"}]\n' +elif [ "$1" = "docker" ] && [ "$2" = "ports" ]; then + printf '[{"container":"web","hostPort":8080,"containerPort":80,"protocol":"tcp"}]\n' +elif [ "$1" = "engine" ] && [ "$2" = "status" ]; then + printf '{"state":"sleeping","detail":"idle policy"}\n' +elif [ "$1" = "engine" ] && [ "$2" = "sleep" ]; then + printf '{"ok":true,"message":"sleep requested"}\n' +elif [ "$1" = "engine" ] && [ "$2" = "wake" ]; then + printf '{"ok":true,"message":"wake requested"}\n' +elif [ "$1" = "machine" ] && [ "$2" = "list" ]; then + printf '[{"id":"dev","state":"running","address":"dev.dory.local"}]\n' +elif [ "$1" = "network" ] && [ "$2" = "authorization-plan" ]; then + cat <<'JSON' +{ + "degradedMode": "high-port-dns-only", + "authorizedMode": "system-resolver-proxy-tls", + "suffix": "dory.local", + "dnsBindAddress": "127.0.0.1", + "dnsPort": 15353, + "httpProxyPort": 8080, + "httpsProxyPort": 8443, + "privilegedTCPForwards": [ + {"listenPort": 25, "targetPort": 60025} + ], + "requests": [ + { + "id": "resolver.dory.local", + "kind": "resolverFile", + "title": "Install dory.local resolver", + "reason": "Route *.dory.local DNS queries to doryd.", + "requiresAdmin": true, + "filePath": "/etc/resolver/dory.local", + "fileContents": "# Managed by Dory. Do not edit.\nnameserver 127.0.0.1\nport 15353\n", + "command": ["/usr/bin/install", "-m", "0644", "", "/etc/resolver/dory.local"] + }, + { + "id": "pf.dev.dory.enable", + "kind": "pfEnable", + "title": "Enable Dory pf rules", + "reason": "Load Dory pf rules.", + "requiresAdmin": true, + "command": ["/sbin/pfctl", "-a", "com.apple/dev.dory", "-f", "/etc/pf.anchors/dev.dory"] + } + ] +} +JSON +elif [ "$1" = "machine" ] && [ "$2" = "create" ]; then + printf '{"id":"%s","state":"created"}\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "status" ]; then + printf '{"id":"%s","state":"running","agentSocketPath":"/tmp/dory-test-agent.sock"}\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "start" ]; then + printf '{"id":"%s","state":"running"}\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "stop" ]; then + printf '{"id":"%s","state":"stopped"}\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "delete" ]; then + printf '{"ok":true,"message":"deleted"}\n' +elif [ "$1" = "machine" ] && [ "$2" = "snapshot" ]; then + machine="$3" + shift 3 + snapshot_id="snap" + while [ "$#" -gt 0 ]; do + case "$1" in + --id) snapshot_id="$2"; shift 2 ;; + --note) shift 2 ;; + *) echo "unexpected snapshot args: $*" >&2; exit 64 ;; + esac + done + printf '{"id":"%s","machineID":"%s","createdISO":"2026-07-08T00:00:00Z","note":"pre-sandbox-run"}\n' "$snapshot_id" "$machine" +elif [ "$1" = "machine" ] && [ "$2" = "restore-snapshot" ]; then + printf '{"id":"%s","state":"running"}\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "delete-snapshot" ]; then + printf '{"ok":true,"message":"snapshot deleted"}\n' +elif [ "$1" = "machine" ] && [ "$2" = "shell" ]; then + printf 'shell:%s\n' "$3" +elif [ "$1" = "machine" ] && [ "$2" = "exec" ]; then + machine="$3" + shift 3 + cwd="" + if [ "${1:-}" = "--json" ]; then shift; fi + while [ "$#" -gt 0 ]; do + case "$1" in + --cwd) cwd="$2"; shift 2 ;; + --env-json-stdin) cat > "${DORY_FAKE_ENV_CAPTURE:-/dev/null}"; shift ;; + --timeout-ms) shift 2 ;; + --) break ;; + *) echo "unexpected exec option: $1" >&2; exit 64 ;; + esac + done + test "$1" = "--" + shift + if [ "$1" = "/bin/sh" ]; then + printf '{"schema":"dev.dory.machine.exec","version":1,"machine":"%s","argv":["/bin/sh"],"exitCode":0,"stdout":"","stderr":"","stdoutBase64":"","stderrBase64":"","timedOut":false,"stdoutTruncated":false,"stderrTruncated":false}\n' "$machine" + elif [ "$1" = "/bin/pwd" ]; then + printf '{"schema":"dev.dory.machine.exec","version":1,"machine":"%s","argv":["/bin/pwd"],"exitCode":0,"stdout":"%s\\n","stderr":"","stdoutBase64":"","stderrBase64":"","timedOut":false,"stdoutTruncated":false,"stderrTruncated":false}\n' "$machine" "$cwd" + elif [ "$1" = "/bin/echo" ] && [ "$machine" = "dev" ]; then + test "$2" = "ok" + printf '{"schema":"dev.dory.machine.exec","version":1,"machine":"dev","argv":["/bin/echo","ok"],"exitCode":0,"stdout":"ok\\n","stderr":"","stdoutBase64":"b2sK","stderrBase64":"","timedOut":false,"stdoutTruncated":false,"stderrTruncated":false}\n' + elif [ "$1" = "/bin/echo" ]; then + test "$2" = "isolated" + printf '{"schema":"dev.dory.machine.exec","version":1,"machine":"%s","argv":["/bin/echo","isolated"],"exitCode":0,"stdout":"isolated\\n","stderr":"","stdoutBase64":"aXNvbGF0ZWQK","stderrBase64":"","timedOut":false,"stdoutTruncated":false,"stderrTruncated":false}\n' "$machine" + else + echo "unexpected exec args: machine=$machine cwd=$cwd argv=$*" >&2 + exit 64 + fi +else + echo "unexpected args: $*" >&2 + exit 64 +fi +SH +chmod +x "$TMP_HOME/fake-dorydctl" + +cat > "$TMP_HOME/fake-dory-network-helper" <<'SH' +#!/bin/sh +dry=0 +remove=0 +plan="" +root="/" +while [ "$#" -gt 0 ]; do + case "$1" in + --plan-json) plan="$2"; shift 2 ;; + --dry-run) dry=1; shift ;; + --remove) remove=1; shift ;; + --owner-uid) test "$2" = "$(id -u)"; shift 2 ;; + --file-system-root) root="$2"; shift 2 ;; + *) echo "unexpected helper args: $*" >&2; exit 64 ;; + esac +done +test "$dry" = "1" || { echo "expected --dry-run" >&2; exit 64; } +test "$root" != "/" || { echo "expected test file-system root" >&2; exit 64; } +test -s "$plan" || { echo "missing plan" >&2; exit 64; } +if [ "$remove" = "1" ]; then + printf '[{"id":"pf.dev.dory.enable","kind":"pfEnable","action":"release-pf-reference","target":"com.apple/dev.dory","dryRun":true},{"id":"resolver.dory.local","kind":"resolverFile","action":"remove-file","target":"/etc/resolver/dory.local","dryRun":true}]\n' +else + printf '[{"id":"resolver.dory.local","kind":"resolverFile","action":"write-file","target":"/etc/resolver/dory.local","dryRun":true},{"id":"pf.dev.dory.enable","kind":"pfEnable","action":"run-command","target":"/sbin/pfctl -a com.apple/dev.dory -f /etc/pf.anchors/dev.dory","dryRun":true}]\n' +fi +SH +chmod +x "$TMP_HOME/fake-dory-network-helper" + +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory engine status --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["state"] == "sleeping" +assert data["detail"] == "idle policy" +' + +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory engine status | grep -q "Dory engine: sleeping" +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory engine sleep --json | grep -q '"sleep requested"' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory network authorization-plan --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["suffix"] == "dory.local" +assert data["privilegedTCPForwards"][0]["listenPort"] == 25 +' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_NETWORK_HELPER_BIN="$TMP_HOME/fake-dory-network-helper" \ + scripts/dory network authorize --json --dry-run --file-system-root "$TMP_HOME/network-root" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.network.authorization" +assert data["dryRun"] is True +assert data["applied"] is False +assert data["operation"] == "authorize" +assert data["suffix"] == "dory.local" +assert data["privilegedTCPForwards"][0]["targetPort"] == 60025 +assert {item["id"] for item in data["results"]} == {"resolver.dory.local", "pf.dev.dory.enable"} +' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_NETWORK_HELPER_BIN="$TMP_HOME/fake-dory-network-helper" \ + scripts/dory network deauthorize --json --dry-run --file-system-root "$TMP_HOME/network-root" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["operation"] == "deauthorize" +assert data["dryRun"] is True +assert data["applied"] is False +assert {item["action"] for item in data["results"]} == {"release-pf-reference", "remove-file"} +' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory machine exec dev --json -- /bin/echo ok | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.machine.exec" +assert data["machine"] == "dev" +assert data["argv"] == ["/bin/echo", "ok"] +assert data["stdout"] == "ok\n" +' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory machine exec dev -- /bin/echo ok | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.machine.exec" +assert data["machine"] == "dev" +assert data["argv"] == ["/bin/echo", "ok"] +assert data["stdout"] == "ok\n" +' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory ssh dev | grep -q '^shell:dev$' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory machine shell dev | grep -q '^shell:dev$' +touch "$TMP_HOME/kernel" "$TMP_HOME/rootfs.ext4" +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_SANDBOX_KERNEL="$TMP_HOME/kernel" DORY_SANDBOX_ROOTFS="$TMP_HOME/rootfs.ext4" \ + scripts/dory sandbox run --json --name agenttest -- /bin/echo isolated | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.sandbox.run" +assert data["sandbox"] == "agenttest" +assert data["isolation"] == "dedicated-vm" +assert data["version"] == 2 +assert data["networkPolicy"] == "none" +assert data["networkPolicyEnforced"] is True +assert data["hostFileSharing"] == "none" +assert data["mounts"] == [] +assert data["rollback"]["requested"] is False +assert data["ttl"]["seconds"] == 0 +assert data["cleanup"]["stopped"] is True +assert data["cleanup"]["deleted"] is True +assert data["exec"]["stdout"] == "isolated\n" +assert data["manifest"]["identity"]["elevated"] is False +assert data["manifest"]["limits"]["diskMB"] == 256 +assert data["manifest"]["network"]["dns"] == "blocked" +' +mkdir -p "$TMP_HOME/project" +sandbox_mount_json="$(DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_SANDBOX_KERNEL="$TMP_HOME/kernel" DORY_SANDBOX_ROOTFS="$TMP_HOME/rootfs.ext4" \ + scripts/dory sandbox run --json --name agentmount --mount "$TMP_HOME/project:/work/project:ro" -- /bin/pwd)" +printf '%s' "$sandbox_mount_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.sandbox.run" +assert data["sandbox"] == "agentmount" +assert data["networkPolicy"] == "none" +assert data["hostFileSharing"] == "scoped" +assert data["mounts"] == [{"guestPath": "/work/project", "hostPath": "'"$TMP_HOME"'/project", "mode": "ro", "readOnly": True, "tag": "dorysb0"}] +assert data["exec"]["stdout"] == "/work/project\n" +' + +sandbox_policy_json="$(DORY_SANDBOX_DISABLE_TTL_SCHEDULER=1 DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_SANDBOX_KERNEL="$TMP_HOME/kernel" DORY_SANDBOX_ROOTFS="$TMP_HOME/rootfs.ext4" \ + scripts/dory sandbox run --json --keep --name agentpolicy --network none --rollback --ttl-seconds 30 -- /bin/echo isolated)" +printf '%s' "$sandbox_policy_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.sandbox.run" +assert data["sandbox"] == "agentpolicy" +assert data["kept"] is True +assert data["networkPolicy"] == "none" +assert data["cleanup"]["stopped"] is False +assert data["cleanup"]["deleted"] is False +assert data["rollback"]["requested"] is True +assert data["rollback"]["created"] is True +assert data["rollback"]["restored"] is True +assert data["rollback"]["snapshotDeleted"] is True +assert data["rollback"]["snapshotID"].startswith("dory-sandbox-pre-") +assert data["ttl"] == {"seconds": 30, "scheduled": True} +assert data["exec"]["stdout"] == "isolated\n" +' + +# Mounts without an explicit suffix are read-only. Outbound policy records only explicit grants, +# and secret values travel over stdin but never enter the durable manifest or CLI argv. +export DORY_TEST_SANDBOX_SECRET='must-not-enter-sandbox-manifest-7f3b' +secret_capture="$TMP_HOME/sandbox-env-capture.json" +sandbox_grants_json="$(DORY_FAKE_ENV_CAPTURE="$secret_capture" DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_SANDBOX_KERNEL="$TMP_HOME/kernel" DORY_SANDBOX_ROOTFS="$TMP_HOME/rootfs.ext4" \ + scripts/dory sandbox run --json --keep --name agentgrants --mount "$TMP_HOME/project:/work/default-ro" \ + --network outbound --allow-network 127.0.0.1:8443 --secret-env DORY_TEST_SANDBOX_SECRET -- /bin/echo isolated)" +printf '%s' "$sandbox_grants_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["mounts"][0]["mode"] == "ro" +assert data["manifest"]["network"]["mode"] == "outbound" +assert data["manifest"]["network"]["egressFilterEnforced"] is True +assert data["manifest"]["network"]["grants"] == [{"addresses":["127.0.0.1"],"host":"127.0.0.1","port":8443}] +assert data["manifest"]["credentials"]["secretEnvironmentNames"] == ["DORY_TEST_SANDBOX_SECRET"] +assert "must-not-enter-sandbox-manifest" not in json.dumps(data["manifest"]) +' +python3 - "$secret_capture" <<'PY' +import json, sys +environment = json.load(open(sys.argv[1], encoding="utf-8")) +assert environment["DORY_TEST_SANDBOX_SECRET"] == "must-not-enter-sandbox-manifest-7f3b" +assert environment["DORY_AGENT_RUN_UID"] != "0" +assert environment["DORY_AGENT_MAX_PROCESSES"] == "128" +PY +! grep -R -F 'must-not-enter-sandbox-manifest-7f3b' "$TMP_HOME/.dory/sandboxes" +scripts/dory sandbox inspect agentgrants --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["sandbox"] == "agentgrants"' +scripts/dory sandbox list --json | python3 -c 'import json,sys; assert "agentgrants" in [r["sandbox"] for r in json.load(sys.stdin)["sandboxes"]]' +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory sandbox kill agentgrants | grep -q 'sandbox killed: agentgrants' +scripts/dory sandbox inspect agentgrants --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["status"] == "killed"' + +if DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_SANDBOX_KERNEL="$TMP_HOME/kernel" DORY_SANDBOX_ROOTFS="$TMP_HOME/rootfs.ext4" \ + scripts/dory sandbox run --elevated --network none -- /bin/echo isolated >/dev/null 2>&1; then + echo "sandbox accepted elevated execution under a filtered network policy" >&2 + exit 1 +fi + +mkdir -p "$TMP_HOME/.dory" +cat > "$TMP_HOME/.dory/idle-history.jsonl" <<'JSONL' +{"at":"2026-07-08T00:00:01Z","state":"waking","detail":"docker request"} +{"at":"2026-07-08T00:00:02Z","state":"awake","detail":"engine ready"} +JSONL +cat > "$TMP_HOME/.dory/incidents.jsonl" <<'JSONL' +{"at":"2026-07-08T00:00:03Z","type":"engine.sleep","detail":"idle policy"} +JSONL + +scripts/dory events --json --limit 10 | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.events" +events = data["events"] +assert [event["type"] for event in events] == ["idle.waking", "idle.awake", "engine.sleep"] +assert events[0]["source"] == "idle" +assert events[-1]["source"] == "incident" +' + +scripts/dory events --limit 1 | grep -q "engine.sleep" + +scripts/dory-doctor mode show --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["runtimeMode"] == "manual" +assert data["idle"]["sleepAfterMinutes"] == 15 +' + +scripts/dory-doctor mode auto-idle --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["runtimeMode"] == "auto-idle" +' + +scripts/dory-doctor idle status --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["mode"] == "auto-idle" +assert data["auto_idle_enabled"] is True +assert data["can_sleep"] is False +assert data["blockers"] +assert data["engine_state"]["available"] is False +assert data["engine_state"]["owner"] == "doryd" +assert data["engine_state"]["state"] == "unavailable" +' + +cat > "$TMP_HOME/idle-state.json" <<'JSON' +{ + "state": "idle-cooling-down", + "detail": "waiting 42s before sleep", + "updated_at": "2026-07-06T00:00:00Z", + "active_connections": 0, + "engine_ready": true +} +JSON + +DORY_IDLE_STATE="$TMP_HOME/idle-state.json" scripts/dory-doctor idle status --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["engine_state"]["available"] is False +assert data["engine_state"]["state"] == "unavailable" +assert data["engine_state"]["state"] != "idle-cooling-down" +' + +scripts/dory-doctor disk --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert "host" in data +assert data["data_drive"]["path"].endswith("/Library/Application Support/Dory/Dory.dorydrive") +assert data["data_drive"]["initialized"] is False +assert "docker" in data +assert data["docker"]["available"] is False +' + +# A clean launch creates both the public v1 data model and its durable selection authority. Use a +# non-default path here so the CLI proves that it inspects the selected drive rather than a shadow +# default. +FIRST_LAUNCH_DRIVE="$TMP_HOME/Library/Application Support/Dory/Selected.dorydrive" +FIRST_LAUNCH_SELECTION="$TMP_HOME/Library/Application Support/Dory/data-drive-selection.json" +mkdir -p "$FIRST_LAUNCH_DRIVE" +cat > "$FIRST_LAUNCH_DRIVE/drive.json" <<'JSON' +{ + "createdAt": "2026-07-14T00:00:00Z", + "id": "11111111-1111-4111-8111-111111111111", + "kind": "dev.dory.data-drive", + "product": "Dory", + "schemaVersion": 1 +} +JSON +chmod 600 "$FIRST_LAUNCH_DRIVE/drive.json" +DORY_DATA_DRIVE="$FIRST_LAUNCH_DRIVE" scripts/dory-doctor disk --json | python3 -c ' +import json, sys +drive = json.load(sys.stdin)["data_drive"] +assert drive["available"] is False +assert drive["unselected"] is True +' +cat > "$FIRST_LAUNCH_SELECTION" < "$TMP_HOME/snapshot-missing-server.py" <<'PY' +import json +import os +import socket +import sys + +path = sys.argv[1] +try: + os.unlink(path) +except FileNotFoundError: + pass +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(path) +server.listen(4) +message = ( + "failed to retrieve container list: rw layer snapshot not found for container " + "846e764c9e77f9b9f6983b2a7832ac3cf72eb95558b0c5414a4f55e1d4fa03ed" +) +body = json.dumps({"message": message}).encode() +response = ( + b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(body)).encode() + + b"\r\nConnection: close\r\n\r\n" + + body +) +for _ in range(3): + client, _ = server.accept() + client.recv(65536) + client.sendall(response) + client.close() +server.close() +PY +python3 "$TMP_HOME/snapshot-missing-server.py" "$SNAPSHOT_SOCK" & +SNAPSHOT_SERVER_PID=$! +PIDS_TO_CLEAN+=("$SNAPSHOT_SERVER_PID") +for _ in $(seq 1 50); do [ -S "$SNAPSHOT_SOCK" ] && break; sleep 0.02; done +[ -S "$SNAPSHOT_SOCK" ] + +DORY_SOCK="$SNAPSHOT_SOCK" scripts/dory-doctor disk --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +docker = data["docker"] +assert docker["available"] is False +assert docker["error_code"] == "docker.snapshot_missing" +assert docker["corrupt_container_ids"] == ["846e764c9e77f9b9f6983b2a7832ac3cf72eb95558b0c5414a4f55e1d4fa03ed"] +assert "rw layer snapshot not found" in docker["error"] +' + +snapshot_doctor="$(DORY_SOCK="$SNAPSHOT_SOCK" scripts/dory-doctor doctor --json --only disk 2>/dev/null || true)" +printf '%s' "$snapshot_doctor" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +check = next(item for item in data["results"] if item["id"] == "disk.docker") +assert check["status"] == "fail" +assert check["code"] == "disk.docker_snapshot_missing" +assert check["data"]["corrupt_container_ids"][0].startswith("846e764c9e77") +assert "dory cleanup --json" in check["action"] +' + +DORY_SOCK="$SNAPSHOT_SOCK" scripts/dory-doctor cleanup --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +action = next(item for item in data["actions"] if item["target"] == "inconsistent-containers") +assert action["status"] == "warn" +assert action["risk"] == "high" +assert action["applied"] is False +assert action["command"][:3] == ["docker", "rm", "-f"] +assert action["command"][3].startswith("846e764c9e77") +' +wait "$SNAPSHOT_SERVER_PID" + +mkdir -p "$TMP_HOME/.dory" +python3 - "$TMP_HOME/.dory/engine.log" <<'PY' +from pathlib import Path +import sys +Path(sys.argv[1]).write_bytes(b"0123456789" * 10) +PY + +scripts/dory-doctor cleanup --json --log-max-bytes 10 | python3 -c ' +import json, sys +data = json.load(sys.stdin) +logs = [item for item in data["actions"] if item["target"] == "logs"][0] +assert logs["bytes_reclaimable"] == 90 +assert logs["applied"] is False +assert any(item["target"] == "docker" and item["status"] == "skip" for item in data["actions"]) +' + +scripts/dory-doctor cleanup --json --apply --log-max-bytes 10 | python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +logs = [item for item in data["actions"] if item["target"] == "logs"][0] +assert logs["applied"] is True +assert os.path.getsize(os.path.expanduser("~/.dory/engine.log")) == 10 +' + +scripts/dory-doctor network --save-probe internal=registry.internal.example:5000 --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["saved"]["name"] == "internal" +assert data["saved"]["host"] == "registry.internal.example" +assert data["saved"]["port"] == 5000 +' + +scripts/dory-doctor network --list-probes --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +probes = {(item["host"], item["port"]) for item in data["probes"]} +assert ("registry-1.docker.io", 443) in probes +assert ("registry.internal.example", 5000) in probes +' + +set +e +bad_probe="$(scripts/dory-doctor network --save-probe 'https://user:secret@registry.internal.example' 2>&1)" +bad_rc=$? +set -e +test "$bad_rc" -eq 2 +printf '%s' "$bad_probe" | grep -q "must not contain credentials" + +scripts/dory-doctor network --remove-probe registry.internal.example:5000 --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["removed"] is True +' + +set +e +routes_json="$(scripts/dory-doctor routes --json)" +routes_rc=$? +set -e +test "$routes_rc" -eq 1 +printf '%s' "$routes_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["error"] +assert data["ports"] == [] +' + +set +e +doctor_json="$(scripts/dory-doctor doctor --json --only socket,api)" +doctor_rc=$? +set -e +test "$doctor_rc" -eq 1 +printf '%s' "$doctor_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +codes = {item["code"] for item in data["results"]} +assert "socket.missing" in codes +assert "socket.unreachable" in codes +' + +set +e +repair_json="$(scripts/dory-doctor repair all --json)" +repair_rc=$? +set -e +test "$repair_rc" -eq 1 +printf '%s' "$repair_json" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +targets = {item["target"] for item in data["actions"]} +assert {"socket", "context", "dns", "routes", "ports", "domains", "dockerd", "guest-agent"} <= targets +assert any(item["target"] == "guest-agent" and item["status"] == "skip" for item in data["actions"]) +' + +set +e +cat > "$TMP_HOME/.dory/engine.log" <<'LOG' +Authorization: Bearer secret-token +Proxy-Authorization: Basic abc123 +password=supersecret +LOG +bundle_json="$(DORY_TOKEN=supersecret scripts/dory-doctor doctor --json --only socket --bundle "$TMP_HOME/dory-diagnostics.zip")" +bundle_rc=$? +set -e +test "$bundle_rc" -eq 1 +printf '%s' "$bundle_json" | python3 -c ' +import json, os, sys, zipfile +data = json.load(sys.stdin) +assert os.path.exists(data["bundle"]) +assert data["bundle"].endswith("dory-diagnostics.zip") +with zipfile.ZipFile(data["bundle"]) as zf: + text = zf.read("doctor.json").decode() + log = zf.read("logs/engine.log").decode() +assert "supersecret" not in text +assert "supersecret" not in log +assert "secret-token" not in log +assert "[redacted]" in text +assert "[redacted]" in log +' + +set +e +dory_bundle_json="$(scripts/dory doctor --json --only socket --bundle)" +dory_bundle_rc=$? +set -e +test "$dory_bundle_rc" -eq 1 +printf '%s' "$dory_bundle_json" | python3 -c ' +import json, os, sys, zipfile +data = json.load(sys.stdin) +assert data["bundle"] +assert os.path.exists(data["bundle"]) +with zipfile.ZipFile(data["bundle"]) as zf: + assert "doctor.json" in zf.namelist() +' + +mkdir -p "$TMP_HOME/.dory/hv" "$TMP_HOME/.dory/machines/logs" "$TMP_HOME/Library/Logs" "$TMP_HOME/Library/Logs/DiagnosticReports" +cat > "$TMP_HOME/.dory/doryd.log" <<'LOG' +Authorization: Bearer secret-token +token=supersecret +LOG +cat > "$TMP_HOME/.dory/hv/dory-hv.log" <<'LOG' +engine helper ready +LOG +cat > "$TMP_HOME/.dory/machines/logs/ready-dev.log" <<'LOG' +machine ready +LOG +cat > "$TMP_HOME/Library/Logs/Dory.log" <<'LOG' +host app log password=supersecret +LOG +cat > "$TMP_HOME/Library/Logs/DiagnosticReports/Dory_2026-07-08.ips" <<'LOG' +{"incident":"Dory crash","authorization":"Bearer secret-token"} +LOG + +support_json="$(DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory support bundle --json "$TMP_HOME/dory-support.zip")" +printf '%s' "$support_json" | python3 -c ' +import json, os, sys, zipfile +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.support.bundle" +assert data["redacted"] is True +assert data["path"].endswith("dory-support.zip") +assert os.path.exists(data["path"]) +with zipfile.ZipFile(data["path"]) as zf: + names = set(zf.namelist()) + assert "doctor.json" in names + assert "manifest.json" in names + assert "system/launchctl-dev.dory.doryd.txt" in names + assert "system/unified-log-dory.txt" in names + assert "logs/doryd.log" in names + assert "logs/hv/dory-hv.log" in names + assert "logs/machines/ready-dev.log" in names + assert "logs/app/Dory.log" in names + assert "logs/crash/Dory_2026-07-08.ips" in names + doctor = json.loads(zf.read("doctor.json")) + manifest = json.loads(zf.read("manifest.json")) + assert manifest["schema"] == "dev.dory.support.bundle.manifest" + assert "logs/app/Dory.log" in manifest["entries"] + assert doctor["support"]["schema"] == "dev.dory.support.bundle" + assert doctor["support"]["redacted"] is True + assert doctor["host"]["sw_vers"]["ok"] is True + assert "process_memory" in doctor + assert doctor["launchd"]["ok"] is True + assert doctor["unified_log"]["ok"] is True + doryd = doctor["doryd"] + assert doryd["doctor"]["ok"] is True + assert doryd["health"]["body"]["state"] == "running" + assert doryd["incidents"]["body"][0]["type"] == "engine.start" + assert doryd["machines"]["body"][0]["address"] == "dev.dory.local" + log = zf.read("logs/doryd.log").decode() + app_log = zf.read("logs/app/Dory.log").decode() + crash_log = zf.read("logs/crash/Dory_2026-07-08.ips").decode() + launchd = zf.read("system/launchctl-dev.dory.doryd.txt").decode() + unified = zf.read("system/unified-log-dory.txt").decode() +text = json.dumps(doctor) +combined = "\n".join([text, log, app_log, crash_log, launchd, unified]) +assert "supersecret" not in combined +assert "secret-token" not in combined +assert "[redacted]" in combined +' + +logs_json="$(DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" scripts/dory logs collect --json "$TMP_HOME/dory-logs.zip")" +printf '%s' "$logs_json" | python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +assert data["schema"] == "dev.dory.support.bundle" +assert data["path"].endswith("dory-logs.zip") +assert os.path.exists(data["path"]) +' + +scripts/dory help | grep -q "dory doctor" +scripts/dory help | grep -q "dory install" +scripts/dory help | grep -q "dory uninstall" +scripts/dory help | grep -q "dory support bundle" +scripts/dory help | grep -q "dory logs collect" +scripts/dory help | grep -q "dory network authorize" +scripts/dory help | grep -q "dory idle history" +! scripts/dory help | grep -q "dory idle proxy" +scripts/dory help | grep -q "dory cleanup" +scripts/dory help | grep -q "dory compat" +scripts/dory help | grep -q "dory agent guide" +scripts/dory help | grep -q "dory mcp serve" +scripts/dory help | grep -q "dory sandbox run" +scripts/dory help | grep -q "dory wait engine" +scripts/dory help | grep -q "dory events" + +mcp_list="$(printf '%s\n%s\n%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + | scripts/dory mcp serve --read-only)" +printf '%s' "$mcp_list" | python3 -c ' +import json, sys +lines = [json.loads(line) for line in sys.stdin.read().splitlines() if line.strip()] +assert len(lines) == 2 +assert lines[0]["result"]["protocolVersion"] == "2025-11-25" +assert "tools" in lines[0]["result"]["capabilities"] +tools = {item["name"]: item for item in lines[1]["result"]["tools"]} +assert "dory.agent_guide" in tools +assert "dory.machine_exec" in tools +assert "dory.sandbox_run" in tools +' + +mcp_guide="$(printf '%s\n%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"dory.agent_guide","arguments":{}}}' \ + | scripts/dory mcp serve --read-only)" +printf '%s' "$mcp_guide" | python3 -c ' +import json, sys +lines = [json.loads(line) for line in sys.stdin.read().splitlines() if line.strip()] +result = lines[-1]["result"] +assert result["isError"] is False +assert result["structuredContent"]["schema"] == "dev.dory.agent.guide" +' + +mcp_readonly="$(printf '%s\n%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"dory.machine_exec","arguments":{"name":"dev","command":["/bin/true"]}}}' \ + | scripts/dory mcp serve --read-only)" +printf '%s' "$mcp_readonly" | python3 -c ' +import json, sys +lines = [json.loads(line) for line in sys.stdin.read().splitlines() if line.strip()] +result = lines[-1]["result"] +assert result["isError"] is True +assert "read-only" in result["structuredContent"]["error"] +' + +# Compatibility center: every registered tool is checked and every non-pass carries an action; +# runs without an engine (skips/warns/fails, never crashes). `compat` exits 1 when a tool fails, +# which is expected here, so capture first rather than piping under pipefail. +compat_json="$(scripts/dory compat --json || true)" +printf '%s' "$compat_json" | python3 -c ' +import json, sys +results = json.load(sys.stdin)["results"] +checked = {r["id"].split(".")[1] for r in results} +required = {"docker", "compose", "testcontainers", "act", "kubernetes", "vscode", "cursor", "supabase", "localstack", "amd64"} +missing = required - checked +assert not missing, f"compat did not check: {sorted(missing)}" +for r in results: + assert r["status"] in {"pass", "warn", "fail", "skip"}, r + if r["status"] in {"fail", "warn"}: + assert r.get("action") or r.get("detail"), r +' + +# Every recipe ships a verification command (Track 4 exit criterion). +scripts/dory compat --recipe --json | python3 -c ' +import json, sys +tools = json.load(sys.stdin)["tools"] +assert tools, "no recipes" +for name, recipe in tools.items(): + assert recipe.get("verify"), f"{name} has no verification command" +assert "export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock" in tools["testcontainers"]["steps"] +' + +scripts/dory compat --recipe testcontainers | grep -q "Verify:" +scripts/dory compat --recipe amd64 | grep -q "FEX" +set +e +scripts/dory compat --recipe no-such-tool >/dev/null 2>&1 +compat_rc=$? +set -e +[ "$compat_rc" = "2" ] || { echo "unknown compat recipe should exit 2, got $compat_rc"; exit 1; } + +# Log caps (Track 5 P0): the always-on proxy caps live O_APPEND logs in place (truncate), which is +# the only safe operation on a file another process is appending to; keep-tail rotation is the +# writers' job (they run when the log is closed), verified via the doctor disk check below. +python3 - <<'PY' +import importlib.machinery, importlib.util, tempfile +from pathlib import Path +loader = importlib.machinery.SourceFileLoader("dip", "scripts/dory-idle-proxy") +dip = importlib.util.module_from_spec(importlib.util.spec_from_loader("dip", loader)) +loader.exec_module(dip) +d = Path(tempfile.mkdtemp()) +live = d / "engine.log" +live.write_bytes(b"x" * 5000) +assert dip.cap_log_inplace(live, 1000) is True, "cap should truncate a live over-cap log" +assert live.stat().st_size == 0, "cap did not truncate in place" +assert dip.cap_log_inplace(live, 1000) is False, "cap must no-op under the hard cap" +# cap_dory_logs globs *.log / *.err.log in the state dir and caps each over the hard cap. +(d / "engine.log").write_bytes(b"a" * 5000) +(d / "idle-proxy.err.log").write_bytes(b"b" * 5000) +assert dip.cap_dory_logs(d, 1000) == 2, "cap_dory_logs should cap both oversized logs" +PY + +mkdir -p "$TMP_HOME/.dory" +head -c 200000 /dev/zero | tr '\0' 'x' > "$TMP_HOME/.dory/engine.log" +DORY_LOG_HARD_MAX_BYTES=1000 scripts/dory disk --json | python3 -c ' +import json, sys +report = json.load(sys.stdin) +assert report["state"]["largest_log"]["bytes"] >= 200000, report["state"] +' +# `doctor --only disk` exits non-zero when the host disk is critically low, so capture before piping. +disk_json="$(DORY_LOG_HARD_MAX_BYTES=1000 scripts/dory-doctor doctor --json --only disk 2>/dev/null || true)" +printf '%s' "$disk_json" | python3 -c ' +import json, sys +results = json.load(sys.stdin)["results"] +log_check = [r for r in results if r["id"] == "disk.dory_logs"] +assert log_check, "disk.dory_logs check missing" +assert log_check[0]["status"] == "warn", log_check[0] +assert log_check[0]["code"] == "disk.dory_log_uncapped", log_check[0] +' +rm -f "$TMP_HOME/.dory/engine.log" + +# Auto-Idle policy CLI: mutations go through dorydctl, status echoes the daemon-confirmed policy, +# invalid input exits as usage, and a rejected daemon request cannot write the requested config. +IDLE_CFG="$TMP_HOME/idle-policy-config.json" +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_CONFIG="$IDLE_CFG" scripts/dory mode auto-idle >/dev/null +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_CONFIG="$IDLE_CFG" scripts/dory idle set sleepAfterMinutes 30 >/dev/null +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_CONFIG="$IDLE_CFG" scripts/dory idle set keepPublishedPortsAwake off >/dev/null +DORYDCTL_BIN="$TMP_HOME/fake-dorydctl" DORY_CONFIG="$IDLE_CFG" scripts/dory idle status --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["mode"] == "auto-idle", data["mode"] +policy = data.get("policy") or {} +assert policy.get("sleepAfterMinutes") == 30, policy +assert policy.get("keepPublishedPortsAwake") is False, policy +assert policy.get("keepKubernetesAwake") is True, policy +' +set +e +DORY_CONFIG="$IDLE_CFG" scripts/dory idle set bogusKey 5 >/dev/null 2>&1 +idle_set_rc=$? +set -e +[ "$idle_set_rc" = "2" ] || { echo "unknown idle key should exit 2, got $idle_set_rc"; exit 1; } + +REJECTED_CFG="$TMP_HOME/rejected-idle-policy-config.json" +set +e +DORYDCTL_BIN=/usr/bin/false DORY_CONFIG="$REJECTED_CFG" scripts/dory mode always-on >/dev/null 2>&1 +mode_rejected_rc=$? +DORYDCTL_BIN=/usr/bin/false DORY_CONFIG="$REJECTED_CFG" scripts/dory idle set sleepAfterMinutes 60 >/dev/null 2>&1 +policy_rejected_rc=$? +set -e +[ "$mode_rejected_rc" = "1" ] || { echo "rejected mode should exit 1, got $mode_rejected_rc"; exit 1; } +[ "$policy_rejected_rc" = "1" ] || { echo "rejected policy should exit 1, got $policy_rejected_rc"; exit 1; } +[ ! -e "$REJECTED_CFG" ] || { echo "rejected doryd settings must not write config"; exit 1; } + +# Proxy inspector (Track 2 P1): a host proxy that Docker is not configured to use warns with an +# actionable fix, and proxy-URL credentials are redacted from the output. +HTTPS_PROXY="http://user:secretpw@proxy.corp.test:8080" \ + DORY_SOCK=/tmp/dory-no-such-proxy.sock scripts/dory-doctor doctor --json --only proxy | python3 -c ' +import json, sys +results = json.load(sys.stdin)["results"] +proxy = [r for r in results if r["id"] == "network.proxy"] +assert proxy, "network.proxy check missing" +assert proxy[0]["status"] == "warn", proxy[0] +assert proxy[0]["code"] == "network.proxy_not_propagated", proxy[0] +assert "secretpw" not in json.dumps(proxy[0]), "proxy credentials leaked in output" +' + +# LAN access controls (Track 2 P1): default is localhost-only (pass); `dory network --lan-visible on` +# flips the check to a clear warn (no silent exposure); a bad value exits 2. +LAN_CFG="$TMP_HOME/lan-config.json" +DORY_CONFIG="$LAN_CFG" DORY_SOCK=/tmp/dory-no-such-lan.sock scripts/dory-doctor doctor --json --only exposure | python3 -c ' +import json, sys +r = [x for x in json.load(sys.stdin)["results"] if x["id"] == "network.lan_exposure"][0] +assert r["status"] == "pass" and r["code"] == "network.lan_localhost_only", r +' +DORY_CONFIG="$LAN_CFG" scripts/dory network --lan-visible on >/dev/null +DORY_CONFIG="$LAN_CFG" DORY_SOCK=/tmp/dory-no-such-lan.sock scripts/dory-doctor doctor --json --only exposure | python3 -c ' +import json, sys +r = [x for x in json.load(sys.stdin)["results"] if x["id"] == "network.lan_exposure"][0] +assert r["status"] == "warn" and r["code"] == "network.lan_exposed", r +' +python3 -c 'import json; assert json.load(open("'"$LAN_CFG"'"))["network"]["lanVisible"] is True' +set +e +DORY_CONFIG="$LAN_CFG" scripts/dory network --lan-visible maybe >/dev/null 2>&1 +lan_rc=$? +set -e +[ "$lan_rc" = "2" ] || { echo "bad --lan-visible value should exit 2, got $lan_rc"; exit 1; } + +# File-sharing dashboard + file-lock probe (Track 3 P1): `dory mount --json` shows the active share + +# safe policy; the file-lock probe registers (skips without a live engine). +# `dory mount` exits non-zero when the socket is absent, so capture before piping under pipefail. +mount_json="$(DORY_SOCK=/tmp/dory-no-such-mount.sock scripts/dory mount --json 2>/dev/null || true)" +printf '%s' "$mount_json" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +assert d["shares"], "mount dashboard missing shares" +share = d["shares"][0] +assert share["mode"] == "rw" and share["policy"] == "safe", share +assert d.get("policy_note"), "missing policy note" +ids = {r["id"]: r for r in d["results"]} +assert "mount.lock" in ids and ids["mount.lock"]["status"] == "skip", ids.get("mount.lock") +' + +# Last-known-good doctor diff: a healthy run saves the baseline; a later regression is reported and +# a failing run never overwrites the good baseline. +python3 - <<'PY' +import importlib.machinery, importlib.util, os, sys, tempfile +loader = importlib.machinery.SourceFileLoader("dd", "scripts/dory-doctor") +dd = importlib.util.module_from_spec(importlib.util.spec_from_loader("dd", loader)) +sys.modules["dd"] = dd +loader.exec_module(dd) +os.environ["DORY_LAST_GOOD"] = os.path.join(tempfile.mkdtemp(), "lg.json") +CR = dd.CheckResult +dd.save_last_good([CR("socket.exists", "pass", "socket.ok", "Socket", "ok"), + CR("network.proxy", "pass", "network.proxy_ok", "Proxy", "ok")]) +diff = dd.diff_last_good([CR("socket.exists", "pass", "socket.ok", "Socket", "ok"), + CR("network.proxy", "fail", "network.proxy_x", "Proxy", "broke")]) +assert any(r["id"] == "network.proxy" and r["to"] == "fail" for r in diff["regressions"]), diff +dd.save_last_good([CR("network.proxy", "fail", "x", "Proxy", "broke")]) +assert dd.load_last_good()["checks"]["network.proxy"]["status"] == "pass", "fail run overwrote baseline" +PY + +# Incident timeline (Track 6): `dory repair --apply` records an incident; `dory incidents --json` +# reads it newest-first at 0600; a healthy `repair all --apply` records only what it actually applied. +INC_LOG="$TMP_HOME/incidents-test.jsonl" +DORY_INCIDENTS="$INC_LOG" scripts/dory incidents --json | python3 -c ' +import json, sys +assert json.load(sys.stdin)["incidents"] == [], "expected empty incident timeline" +' +DORY_INCIDENTS="$INC_LOG" DORY_DOCKER_BIN="$TMP_HOME/fake-bin/docker" scripts/dory repair context --apply >/dev/null +DORY_INCIDENTS="$INC_LOG" scripts/dory incidents --json | python3 -c ' +import json, sys +inc = json.load(sys.stdin)["incidents"] +assert inc, "repair --apply should record an incident" +assert inc[0]["type"] == "repair", inc[0] +assert "context" in (inc[0].get("detail") or ""), inc[0] +' +inc_perm="$(stat -f "%Lp" "$INC_LOG")" +[ "$inc_perm" = "600" ] || { echo "incidents perms=$inc_perm (want 600)"; exit 1; } + +# Concurrent record_incident calls must all land as whole, valid JSON lines (flock + O_APPEND), +# never interleaved or lost. +python3 - <<'PY' +import importlib.machinery, importlib.util, json, os, sys, tempfile, threading +loader = importlib.machinery.SourceFileLoader("dd", "scripts/dory-doctor") +dd = importlib.util.module_from_spec(importlib.util.spec_from_loader("dd", loader)) +sys.modules["dd"] = dd +loader.exec_module(dd) +path = os.path.join(tempfile.mkdtemp(), "incidents.jsonl") +os.environ["DORY_INCIDENTS"] = path +threads = [threading.Thread(target=dd.record_incident, args=("test", f"line-{i}")) for i in range(40)] +for t in threads: + t.start() +for t in threads: + t.join() +lines = [line for line in open(path, encoding="utf-8").read().splitlines() if line.strip()] +assert len(lines) == 40, f"expected 40 incident lines, got {len(lines)}" +for line in lines: + json.loads(line) + +linked = os.path.join(tempfile.mkdtemp(), "incidents.jsonl") +target = linked + ".target" +with open(target, "w", encoding="utf-8") as handle: + handle.write("untouched") +os.symlink(target, linked) +os.environ["DORY_INCIDENTS"] = linked +dd.record_incident("test", "must not follow") +assert open(target, encoding="utf-8").read() == "untouched" +assert dd.read_incidents(10) == [] +PY + +# Memory inspector + guest disk (Track 5 P1): footprint breaks host RSS into engine/app roles; +# the guest disk probe is active-only and must skip cleanly in the default passive run. +# `--only memory,disk` exits non-zero when the host disk is critically low; capture before piping. +python3 - <<'PY' +import importlib.machinery, importlib.util, sys +loader = importlib.machinery.SourceFileLoader("dd", "scripts/dory-doctor") +dd = importlib.util.module_from_spec(importlib.util.spec_from_loader("dd", loader)) +sys.modules["dd"] = dd +loader.exec_module(dd) + +class Completed: + returncode = 0 + stdout = """101 10 /Applications/Dory.app/Contents/Helpers/doryd doryd +202 20 /Applications/Dory.app/Contents/Helpers/dory-hv dory-hv engine +303 30 /Applications/Dory.app/Contents/Helpers/gvproxy gvproxy +404 40 /usr/bin/node node /Users/example/Projects/Dory/tool.js +""" + +dd.subprocess.run = lambda *args, **kwargs: Completed() +samples = { + 101: {"rss_bytes": 11_000, "phys_footprint_bytes": 21_000}, + 202: {"rss_bytes": 22_000, "phys_footprint_bytes": 52_000}, + 303: {"rss_bytes": 33_000, "phys_footprint_bytes": 63_000}, +} +dd.darwin_process_memory = lambda pid: samples.get(pid) +report = dd.memory_report() +assert report["rss_bytes"] == 66_000, report +assert report["phys_footprint_bytes"] == 136_000, report +assert report["engine_phys_footprint_bytes"] == 52_000, report +assert report["app_phys_footprint_bytes"] == 21_000, report +assert report["helper_phys_footprint_bytes"] == 63_000, report +assert report["phys_footprint_complete"] is True, report +assert report["phys_footprint_aggregation"] == "sum_of_per_process_charges_may_double_count_shared_pages", report +assert all("phys_footprint_bytes" in process for process in report["processes"]), report +assert {process["pid"] for process in report["processes"]} == {101, 202, 303}, report + +dd.darwin_process_memory = lambda pid: samples.get(pid) if pid != 303 else None +partial = dd.memory_report() +assert partial["physical_footprint_available"] is True, partial +assert partial["phys_footprint_complete"] is False, partial +assert partial["phys_footprint_sampled_processes"] == 2, partial +assert partial["rss_bytes"] == 11_000 + 22_000 + (30 * 1024), partial + +gib = 1024 * 1024 * 1024 +assert dd.host_disk_health(10 * gib, 1000 * gib)[:2] == ("fail", "disk.host_critical") +assert dd.host_disk_health(30 * gib, 1000 * gib)[:2] == ("warn", "disk.host_low") +assert dd.host_disk_health(200 * gib, 1000 * gib)[:2] == ("pass", "disk.host_ok") +PY + +memdisk_json="$(scripts/dory-doctor doctor --json --only memory,disk 2>/dev/null || true)" +printf '%s' "$memdisk_json" | python3 -c ' +import json, sys +results = json.load(sys.stdin)["results"] +mem = [r for r in results if r["id"] == "memory.footprint"] +assert mem, "memory.footprint check missing" +data = mem[0].get("data", {}) +assert "engine_rss_bytes" in data and "app_rss_bytes" in data, data +assert "physical_footprint_available" in data, data +assert data.get("phys_footprint_source") == "proc_pid_rusage.RUSAGE_INFO_V4", data +guest = [r for r in results if r["id"] == "disk.guest"] +assert guest and guest[0]["status"] == "skip", "guest disk should skip passively" +assert guest[0]["code"] == "disk.active_probe_skipped", guest[0] +' + +scripts/dory-idle-proxy launch-agent print | python3 -c ' +import plistlib, sys +data = plistlib.loads(sys.stdin.buffer.read()) +assert data["Label"] == "dev.dory.idle-proxy" +assert data["RunAtLoad"] is True +assert data["KeepAlive"] is True +assert data["ProgramArguments"][-2:] == ["proxy", "--foreground"] +' + +set +e +retired_idle="$(scripts/dory idle launch-agent print 2>&1 >/dev/null)" +retired_rc=$? +set -e +test "$retired_rc" -eq 64 +printf '%s' "$retired_idle" | grep -q "doryd owns Auto-Idle" + +DORY_IDLE_STATE="$TMP_HOME/idle-state.json" scripts/dory idle proxy-status --json | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["available"] is True +assert data["state"] == "idle-cooling-down" +' + +cat > "$TMP_HOME/fake_engine_server.py" <<'PY' +import os +import socket +import sys + +sock_path, ready_path = sys.argv[1], sys.argv[2] +try: + os.unlink(sock_path) +except FileNotFoundError: + pass + +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(sock_path) +server.listen(16) +with open(ready_path, "w", encoding="utf-8") as handle: + handle.write("ready") + +while True: + conn, _ = server.accept() + data = b"" + while b"\r\n\r\n" not in data: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + if b"GET /_ping " in data: + body = b"OK" + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 2\r\n" + b"Connection: close\r\n" + b"\r\n" + body + ) + elif b"GET /containers/json" in data: + body = b"[]" + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 2\r\n" + b"Connection: close\r\n" + b"\r\n" + body + ) + else: + body = b'{"ok":true}' + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: %d\r\n" + b"Connection: close\r\n" + b"\r\n" % len(body) + body + ) + conn.close() +PY + +cat > "$TMP_HOME/fake-engine" <<'SH' +#!/bin/sh +set -eu +case "${1:-}" in + start) + echo start >> "$FAKE_ENGINE_STARTS" + python3 "$FAKE_ENGINE_SERVER" "$FAKE_ENGINE_SOCK" "$FAKE_ENGINE_READY" > "$FAKE_ENGINE_LOG" 2>&1 & + echo "$!" > "$FAKE_ENGINE_PID" + ;; + stop) + if [ -s "$FAKE_ENGINE_PID" ]; then + kill "$(cat "$FAKE_ENGINE_PID")" 2>/dev/null || true + fi + ;; + *) + exit 64 + ;; +esac +SH +chmod +x "$TMP_HOME/fake-engine" + +export FAKE_ENGINE_SERVER="$TMP_HOME/fake_engine_server.py" +export FAKE_ENGINE_SOCK="$TMP_HOME/engine.sock" +export FAKE_ENGINE_READY="$TMP_HOME/fake-engine.ready" +export FAKE_ENGINE_STARTS="$TMP_HOME/fake-engine.starts" +export FAKE_ENGINE_PID="$TMP_HOME/fake-engine.pid" +export FAKE_ENGINE_LOG="$TMP_HOME/fake-engine.log" + +DORY_CONFIG="$TMP_HOME/config.json" scripts/dory-idle-proxy proxy \ + --foreground \ + --listener "$TMP_HOME/proxy.sock" \ + --engine-sock "$FAKE_ENGINE_SOCK" \ + --engine-command "$TMP_HOME/fake-engine" \ + --idle-seconds 3600 \ + --wake-timeout 5 \ + --state-file "$TMP_HOME/proxy-state.json" \ + > "$TMP_HOME/proxy.log" 2>&1 & +proxy_pid=$! +PIDS_TO_CLEAN+=("$proxy_pid") + +python3 - "$TMP_HOME/proxy.sock" "$TMP_HOME/proxy.log" <<'PY' +import os +import sys +import time + +sock_path, log_path = sys.argv[1], sys.argv[2] +deadline = time.time() + 5 +while time.time() < deadline: + if os.path.exists(sock_path): + raise SystemExit(0) + time.sleep(0.05) + +try: + log = open(log_path, encoding="utf-8").read() +except FileNotFoundError: + log = "" +raise SystemExit(f"proxy socket was not created; log:\n{log}") +PY + +proxy_response="$(python3 - "$TMP_HOME/proxy.sock" <<'PY' +import socket +import sys + +client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +client.settimeout(5) +client.connect(sys.argv[1]) +client.sendall(b"GET /_ping HTTP/1.1\r\nHost: dory\r\nConnection: close\r\n\r\n") +client.shutdown(socket.SHUT_WR) +chunks = [] +while True: + try: + chunk = client.recv(4096) + except TimeoutError: + break + if not chunk: + break + chunks.append(chunk) + if b"\r\n\r\nOK" in b"".join(chunks): + break +raw = b"".join(chunks) +if not raw: + raise SystemExit("proxy returned no response") +print(raw.decode("iso-8859-1")) +PY +)" +printf '%s' "$proxy_response" | grep -q "HTTP/1.1 200 OK" +printf '%s' "$proxy_response" | grep -q "OK" +grep -q "start" "$FAKE_ENGINE_STARTS" +test -S "$FAKE_ENGINE_SOCK" + +python3 - "$TMP_HOME/proxy-state.json" <<'PY' +import json +import sys +import time + +state_path = sys.argv[1] +deadline = time.time() + 5 +last = {} +while time.time() < deadline: + try: + last = json.load(open(state_path, encoding="utf-8")) + except FileNotFoundError: + last = {} + if last.get("state") in {"awake", "busy"} and last.get("engine_ready") is True: + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit(f"proxy did not publish an awake/busy state: {last!r}") +PY + +# --- Auto-Idle: incident history records genuine transitions (not every refresh) --- +python3 - "$TMP_HOME/idle-history.jsonl" <<'PY' +import json +import sys +import time + +hist = sys.argv[1] +deadline = time.time() + 5 +states: list[str] = [] +while time.time() < deadline: + try: + lines = [line for line in open(hist, encoding="utf-8").read().splitlines() if line.strip()] + except FileNotFoundError: + lines = [] + states = [json.loads(line).get("state") for line in lines] + if "waking" in states and "awake" in states: + break + time.sleep(0.05) +if "waking" not in states or "awake" not in states: + raise SystemExit(f"history missing wake transitions: {states!r}") +for previous, current in zip(states, states[1:]): + if previous == current: + raise SystemExit(f"history recorded a non-transition: {states!r}") +PY + +history_perm="$(stat -f '%Lp' "$TMP_HOME/idle-history.jsonl")" +[ "$history_perm" = "600" ] || { echo "idle-history.jsonl perms=$history_perm (want 600)"; exit 1; } + +scripts/dory-idle-proxy history --state-file "$TMP_HOME/proxy-state.json" --json | grep -q '"state": "awake"' + +# --- Auto-Idle: concurrent clients are all served (slot semaphore does not drop them) --- +python3 - "$TMP_HOME/proxy.sock" <<'PY' +import socket +import sys +import threading + +sock_path = sys.argv[1] +results: list[bool] = [] +lock = threading.Lock() + + +def one(_index: int) -> None: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(10) + try: + client.connect(sock_path) + client.sendall(b"GET /_ping HTTP/1.1\r\nHost: dory\r\nConnection: close\r\n\r\n") + client.shutdown(socket.SHUT_WR) + buf = b"" + while True: + try: + chunk = client.recv(4096) + except socket.timeout: + break + if not chunk: + break + buf += chunk + with lock: + results.append(b"200 OK" in buf) + finally: + client.close() + + +threads = [threading.Thread(target=one, args=(index,)) for index in range(8)] +for thread in threads: + thread.start() +for thread in threads: + thread.join() +if len(results) != 8 or not all(results): + raise SystemExit(f"concurrent clients failed: {results!r}") +PY + +# --- Auto-Idle: coexistence — proxy defers to the app that owns the socket, never steals it --- +APP_SOCK="$TMP_HOME/app-owned.sock" +APP_READY="$TMP_HOME/app.ready" +python3 "$TMP_HOME/fake_engine_server.py" "$APP_SOCK" "$APP_READY" > "$TMP_HOME/app.log" 2>&1 & +app_pid=$! +PIDS_TO_CLEAN+=("$app_pid") + +python3 - "$APP_READY" <<'PY' +import os +import sys +import time + +deadline = time.time() + 5 +while time.time() < deadline: + if os.path.exists(sys.argv[1]): + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("stand-in app socket never became ready") +PY + +app_inode="$(stat -f '%i' "$APP_SOCK")" + +scripts/dory-idle-proxy proxy \ + --foreground \ + --listener "$APP_SOCK" \ + --engine-sock "$FAKE_ENGINE_SOCK" \ + --engine-command "$TMP_HOME/fake-engine" \ + --idle-seconds 3600 \ + --wake-timeout 5 \ + --state-file "$TMP_HOME/coexist-state.json" \ + > "$TMP_HOME/coexist.log" 2>&1 & +coexist_pid=$! +PIDS_TO_CLEAN+=("$coexist_pid") + +python3 - "$TMP_HOME/coexist-state.json" <<'PY' +import json +import sys +import time + +state_path = sys.argv[1] +deadline = time.time() + 5 +last: dict = {} +while time.time() < deadline: + try: + last = json.load(open(state_path, encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + last = {} + if last.get("state") == "deferred": + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit(f"proxy did not defer to the app: {last!r}") +PY + +now_inode="$(stat -f '%i' "$APP_SOCK")" +[ "$app_inode" = "$now_inode" ] || { echo "proxy stole the app socket (inode $app_inode -> $now_inode)"; exit 1; } + +python3 - "$APP_SOCK" <<'PY' +import socket +import sys + +client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +client.settimeout(5) +client.connect(sys.argv[1]) +client.sendall(b"GET /_ping HTTP/1.1\r\nHost: dory\r\nConnection: close\r\n\r\n") +client.shutdown(socket.SHUT_WR) +buf = b"" +while True: + try: + chunk = client.recv(4096) + except TimeoutError: + break + if not chunk: + break + buf += chunk +if b"200 OK" not in buf: + raise SystemExit("stand-in app socket stopped answering after the proxy deferred") +PY + +stop_test_pid "$coexist_pid" From 1d595e88df9078de3b03cc51a92d9b038a3fa8c7 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:11:43 +0000 Subject: [PATCH 165/338] feat(release): verify Sparkle install evidence --- scripts/test-sparkle-install-evidence.sh | 22 +++ scripts/verify-sparkle-install-evidence.py | 214 +++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100755 scripts/verify-sparkle-install-evidence.py diff --git a/scripts/test-sparkle-install-evidence.sh b/scripts/test-sparkle-install-evidence.sh index bd039f60..3d3935dc 100755 --- a/scripts/test-sparkle-install-evidence.sh +++ b/scripts/test-sparkle-install-evidence.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-sparkle-evidence-test.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" trap 'rm -rf "$TMP"' EXIT SOURCE_COMMIT="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -143,6 +144,27 @@ if verify > "$TMP/reused-pid.out" 2>&1; then fi grep -Fq 'reused the previous app PID' "$TMP/reused-pid.out" +write_manifest +cp "$TMP/manifest.txt" "$TMP/direct-manifest.txt" +rm "$TMP/manifest.txt" +ln -s "$TMP/direct-manifest.txt" "$TMP/manifest.txt" +if verify > "$TMP/indirect.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted an indirect evidence manifest" >&2 + exit 1 +fi +grep -Fq 'missing or indirect' "$TMP/indirect.out" +rm "$TMP/manifest.txt" +mv "$TMP/direct-manifest.txt" "$TMP/manifest.txt" + +cp "$TMP/sbom.json" "$TMP/sbom-valid.json" +printf '{"metadata":{},"metadata":{}}\n' > "$TMP/sbom.json" +if verify > "$TMP/duplicate-json.out" 2>&1; then + echo "test-sparkle-install-evidence: accepted duplicate SBOM authority" >&2 + exit 1 +fi +grep -Fq 'duplicate JSON key' "$TMP/duplicate-json.out" +mv "$TMP/sbom-valid.json" "$TMP/sbom.json" + write_manifest printf 'tampered\n' >> "$TMP/update.zip" if verify > "$TMP/tampered.out" 2>&1; then diff --git a/scripts/verify-sparkle-install-evidence.py b/scripts/verify-sparkle-install-evidence.py new file mode 100755 index 00000000..72af4f23 --- /dev/null +++ b/scripts/verify-sparkle-install-evidence.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Bind retained Sparkle installation evidence to one immutable release candidate.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import re +import sys + + +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +RUN_ID_PATTERN = re.compile(r"[0-9]{8}T[0-9]{6}Z-[1-9][0-9]*") +TEAM_ID_PATTERN = re.compile(r"[A-Z0-9]{10}") + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True, type=pathlib.Path) + parser.add_argument("--app-update", required=True, type=pathlib.Path) + parser.add_argument("--appcast", required=True, type=pathlib.Path) + parser.add_argument("--release-manifest", required=True, type=pathlib.Path) + parser.add_argument("--sbom", required=True, type=pathlib.Path) + parser.add_argument("--gate-script", required=True, type=pathlib.Path) + parser.add_argument("--package-resolved", required=True, type=pathlib.Path) + parser.add_argument("--candidate-team", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--run-attempt", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--build", required=True, type=int) + return parser.parse_args() + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def direct_file(path: pathlib.Path, label: str, maximum_bytes: int | None = None) -> pathlib.Path: + logical = pathlib.Path(os.path.abspath(path)) + require(logical.is_file() and not logical.is_symlink(), f"{label} is missing or indirect") + require(logical.resolve() == logical, f"{label} has an indirect ancestor") + if maximum_bytes is not None: + require(logical.stat().st_size <= maximum_bytes, f"{label} is too large") + return logical + + +def unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json(path: pathlib.Path) -> object: + with path.open("rb") as handle: + return json.load(handle, object_pairs_hook=unique_json_object) + + +def read_evidence(path: pathlib.Path) -> dict[str, str]: + values: dict[str, str] = {} + with path.open(encoding="utf-8") as handle: + for line in handle: + key, separator, value = line.rstrip("\n").partition("=") + if not separator or not key or key in values: + raise ValueError(f"malformed Sparkle evidence: {line!r}") + values[key] = value + return values + + +def candidate_tree_digest(path: pathlib.Path) -> str: + payload = load_json(path) + values = [ + row["value"] + for row in payload["metadata"]["component"]["properties"] + if row["name"] == "dev.dory.app.tree.sha256" + ] + if len(values) != 1 or SHA256_PATTERN.fullmatch(values[0]) is None: + raise ValueError("SBOM must contain one valid Dory app tree digest") + return values[0] + + +def sparkle_revision(path: pathlib.Path) -> str: + payload = load_json(path) + pins = [pin for pin in payload["pins"] if pin["identity"] == "sparkle"] + if len(pins) != 1 or pins[0]["state"].get("version") != "2.9.4": + raise ValueError("Package.resolved must contain exactly one Sparkle 2.9.4 pin") + revision = pins[0]["state"].get("revision", "") + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("Sparkle pin has no full lowercase revision") + return revision + + +def require_positive_integer(values: dict[str, str], key: str) -> None: + value = values[key] + if not value.isdigit() or int(value) <= 0: + raise ValueError(f"invalid positive integer in Sparkle evidence: {key}") + + +def verify(arguments: argparse.Namespace) -> None: + arguments.manifest = direct_file(arguments.manifest, "Sparkle evidence manifest", 64 * 1024) + arguments.app_update = direct_file(arguments.app_update, "Sparkle update archive") + arguments.appcast = direct_file(arguments.appcast, "Sparkle appcast", 2 * 1024 * 1024) + arguments.release_manifest = direct_file(arguments.release_manifest, "release manifest", 2 * 1024 * 1024) + arguments.sbom = direct_file(arguments.sbom, "release SBOM", 64 * 1024 * 1024) + arguments.gate_script = direct_file(arguments.gate_script, "Sparkle gate script", 2 * 1024 * 1024) + arguments.package_resolved = direct_file(arguments.package_resolved, "Package.resolved", 2 * 1024 * 1024) + if arguments.build <= 1: + raise ValueError("Sparkle qualification requires a build greater than one") + if re.fullmatch(r"[0-9a-f]{40}", arguments.source_commit) is None: + raise ValueError("source commit must be a full lowercase Git SHA") + if TEAM_ID_PATTERN.fullmatch(arguments.candidate_team) is None: + raise ValueError("candidate team must be a ten-character Apple Team ID") + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", arguments.version) is None: + raise ValueError("candidate version is invalid") + if not arguments.run_id.isdigit() or int(arguments.run_id) <= 0 \ + or not arguments.run_attempt.isdigit() or int(arguments.run_attempt) <= 0: + raise ValueError("workflow run identity is invalid") + + values = read_evidence(arguments.manifest) + pass_keys = { + "old_process_terminated", + "different_relaunch_pid", + "loopback_appcast_fetched", + "exact_update_archive_fetched", + "ed25519_archive_verified", + "installed_tree_exact", + "installed_notarization_ticket", + "installed_gatekeeper", + "application_support_preserved", + "preferences_preserved", + "atomic_install_swap", + "sparkle_fallback_restoration_verified", + "qualification_fixture_restored", + "initial_clean_user_state_restored", + } + expected = { + "status": "PASS", + "release_qualifying": "true", + "workflow_run_id": arguments.run_id, + "workflow_run_attempt": arguments.run_attempt, + "source_commit": arguments.source_commit, + "candidate_version": arguments.version, + "candidate_build": str(arguments.build), + "previous_fixture_build": str(arguments.build - 1), + "candidate_tree_sha256": candidate_tree_digest(arguments.sbom), + "update_zip_sha256": sha256(arguments.app_update), + "appcast_sha256": sha256(arguments.appcast), + "release_manifest_sha256": sha256(arguments.release_manifest), + "sbom_sha256": sha256(arguments.sbom), + "gate_script_sha256": sha256(arguments.gate_script), + "sparkle_version": "2.9.4", + "sparkle_revision": sparkle_revision(arguments.package_resolved), + "candidate_team": arguments.candidate_team, + "sparkle_cli_team": arguments.candidate_team, + "sparkle_autoupdate_team": arguments.candidate_team, + **{key: "PASS" for key in pass_keys}, + } + for key, expected_value in expected.items(): + if values.get(key) != expected_value: + raise ValueError( + f"Sparkle evidence mismatch for {key}: " + f"{values.get(key)!r} != {expected_value!r}" + ) + + dynamic_keys = { + "run_id", + "sparkle_cli_sha256", + "sparkle_framework_sha256", + "old_pid", + "relaunched_pid", + "completed_epoch", + } + expected_keys = set(expected) | dynamic_keys + if set(values) != expected_keys: + difference = sorted(set(values) ^ expected_keys) + raise ValueError(f"unexpected Sparkle evidence keys: {difference}") + if RUN_ID_PATTERN.fullmatch(values["run_id"]) is None: + raise ValueError("Sparkle evidence has an invalid qualification run identifier") + for key in ("sparkle_cli_sha256", "sparkle_framework_sha256"): + if SHA256_PATTERN.fullmatch(values[key]) is None: + raise ValueError(f"invalid Sparkle evidence digest: {key}") + for key in ("old_pid", "relaunched_pid", "completed_epoch"): + require_positive_integer(values, key) + if values["old_pid"] == values["relaunched_pid"]: + raise ValueError("Sparkle evidence reused the previous app PID") + + +def main() -> int: + arguments = parse_arguments() + try: + verify(arguments) + except (KeyError, OSError, TypeError, ValueError) as error: + print(f"Sparkle install evidence error: {error}", file=sys.stderr) + return 1 + print("Sparkle install evidence: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9ed72cd3480f037b02375d8935b16f424b2b407b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:19:55 +0000 Subject: [PATCH 166/338] feat(release): validate published outputs --- .../scripts/test-release-output-validator.py | 78 +++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/validate-release-outputs.sh | 487 ++++++++++++++++++ 4 files changed, 567 insertions(+) create mode 100755 .github/scripts/test-release-output-validator.py create mode 100755 scripts/validate-release-outputs.sh diff --git a/.github/scripts/test-release-output-validator.py b/.github/scripts/test-release-output-validator.py new file mode 100755 index 00000000..2107ccf1 --- /dev/null +++ b/.github/scripts/test-release-output-validator.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Focused clean-checkout tests for the public release-output gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "scripts" / "validate-release-outputs.sh" + + +class ReleaseOutputValidatorTests(unittest.TestCase): + def run_validator(self, build_directory: pathlib.Path) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PYTHONOPTIMIZE"] = "2" + environment["DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION"] = "1" + return subprocess.run( + [str(VALIDATOR), str(build_directory), "9.8.7", "42"], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_validator_is_shell_valid_and_uses_the_strict_metadata_boundary(self) -> None: + subprocess.run(["bash", "-n", str(VALIDATOR)], cwd=ROOT, check=True) + source = VALIDATOR.read_text(encoding="utf-8") + + self.assertNotIn("assert ", source) + self.assertNotIn("DORY_RELEASE_OUTPUTS_SKIP_COMPONENT_SIGNATURE", source) + self.assertIn("scripts/validate-release-metadata.py", source) + self.assertIn("scripts/verify-release-sbom.py", source) + self.assertIn("scripts/verify-distribution-signatures.sh", source) + self.assertIn("duplicate ZIP member", source) + self.assertIn("traversing ZIP member", source) + self.assertIn("contains a symlink that escapes Dory.app", source) + self.assertIn("build directory has an indirect ancestor", source) + + metadata = source.index("scripts/validate-release-metadata.py") + platform_skip = source.index( + 'if [ "${DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION:-0}" != "1" ]' + ) + self.assertLess(metadata, platform_skip) + + def test_missing_build_directory_fails_closed_under_python_optimize(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + missing = pathlib.Path(root).resolve() / "missing" + result = self.run_validator(missing) + self.assertNotEqual(result.returncode, 0) + self.assertIn("build directory is missing or indirect", result.stdout) + + def test_symlinked_build_directory_is_rejected(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + directory = pathlib.Path(root).resolve() + target = directory / "target" + target.mkdir() + link = directory / "release-build-link" + link.symlink_to(target, target_is_directory=True) + result = self.run_validator(link) + self.assertNotEqual(result.returncode, 0) + self.assertIn("build directory is missing or indirect", result.stdout) + + def test_empty_direct_build_directory_rejects_missing_public_artifacts(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + result = self.run_validator(pathlib.Path(root).resolve()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("required public artifact is missing or empty", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c46d3c3..7697a1d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -335,6 +335,7 @@ jobs: - name: Signed release metadata contract run: | python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ddb5cd49..0a2e4f73 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,7 @@ jobs: - name: Signed release metadata contract run: | python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/validate-release-outputs.sh b/scripts/validate-release-outputs.sh new file mode 100755 index 00000000..81ff733a --- /dev/null +++ b/scripts/validate-release-outputs.sh @@ -0,0 +1,487 @@ +#!/bin/bash +# Fail closed on the exact artifact/update contract consumed by GitHub Releases, Sparkle, and the +# Homebrew cask. Run only after scripts/release.sh has finished every variant. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BUILD_DIR="${1:?usage: validate-release-outputs.sh }" +VERSION="${2:?usage: validate-release-outputs.sh }" +BUILD="${3:?usage: validate-release-outputs.sh }" +TEAM="${NOTARY_TEAM_ID:-864H636QW4}" + +fail() { + echo "release outputs error: $*" >&2 + exit 1 +} + +[ -d "$BUILD_DIR" ] && [ ! -L "$BUILD_DIR" ] \ + || fail "build directory is missing or indirect: $BUILD_DIR" +BUILD_DIR_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$BUILD_DIR")" +BUILD_DIR="$(cd "$BUILD_DIR" && pwd -P)" +[ "$BUILD_DIR" = "$BUILD_DIR_LOGICAL" ] || fail "build directory has an indirect ancestor" + +validate_app_zip_layout() { + local archive="$1" label="$2" + python3 - "$archive" <<'PY' || fail "$label has an unsafe or unexpected archive layout" +import pathlib +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as archive: + names = set() + for item in archive.infolist(): + name = item.filename + path = pathlib.PurePosixPath(name) + if not name or "\\" in name: + raise SystemExit(f"invalid ZIP member: {name!r}") + if name in names: + raise SystemExit(f"duplicate ZIP member: {name!r}") + names.add(name) + if path.is_absolute(): + raise SystemExit(f"absolute ZIP member: {name!r}") + if ".." in path.parts: + raise SystemExit(f"traversing ZIP member: {name!r}") + if path.parts[0] != "Dory.app": + raise SystemExit(f"unexpected top-level ZIP member: {name!r}") + if "Dory.app/Contents/MacOS/Dory" not in names: + raise SystemExit("Dory executable is absent") +PY +} + +validate_app_symlinks() { + local app="$1" label="$2" + python3 - "$app" <<'PY' || fail "$label contains a symlink that escapes Dory.app" +import os +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +for directory, names, files in os.walk(root, followlinks=False): + for name in names + files: + path = pathlib.Path(directory, name) + if path.is_symlink(): + target = path.resolve(strict=False) + if os.path.commonpath((root, target)) != str(root): + raise SystemExit(f"{path} -> {os.readlink(path)}") +PY +} + +validate_core_payload_aliases() { + local app="$1" label="$2" resources kernel_alias rootfs_alias + resources="$app/Contents/Resources" + kernel_alias="$resources/dory-vm-kernel-arm64.lzfse" + rootfs_alias="$resources/dory-vm-initfs-arm64.ext4.lzfse" + [ -L "$kernel_alias" ] \ + || fail "$label stores a duplicate VZ kernel instead of a Core payload alias" + [ "$(readlink "$kernel_alias")" = "dory-hv-kernel-arm64.lzfse" ] \ + || fail "$label VZ kernel alias has the wrong target" + [ -L "$rootfs_alias" ] \ + || fail "$label stores a duplicate VZ rootfs instead of a Core payload alias" + [ "$(readlink "$rootfs_alias")" = "dory-engine-rootfs-arm64.ext4.lzfse" ] \ + || fail "$label VZ rootfs alias has the wrong target" + [ -s "$kernel_alias" ] && [ -s "$rootfs_alias" ] \ + || fail "$label Core payload aliases do not resolve to non-empty files" +} + +validate_edition() { + local app="$1" edition="$2" label="$3" expected_flag expected_feed actual_flag actual_feed + case "$edition" in + lean) + expected_flag=false + expected_feed=https://augani.github.io/dory/appcast.xml + ;; + desktop) + expected_flag=true + expected_feed=https://augani.github.io/dory/appcast-desktop.xml + ;; + *) fail "unknown edition validator: $edition" ;; + esac + actual_flag="$(/usr/libexec/PlistBuddy -c 'Print :DoryIncludesDesktopLinux' \ + "$app/Contents/Info.plist" 2>/dev/null || true)" + actual_feed="$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' \ + "$app/Contents/Info.plist" 2>/dev/null || true)" + [ "$actual_flag" = "$expected_flag" ] \ + || fail "$label has the wrong DoryIncludesDesktopLinux value" + [ "$actual_feed" = "$expected_feed" ] || fail "$label uses the wrong Sparkle feed" +} + +validate_desktop_members() { + local members="$1" label="$2" member + for member in \ + Dory.app/Contents/Resources/dory-desktop-kernel-arm64.lzfse \ + Dory.app/Contents/Resources/kernel-build-arm64-desktop.stamp \ + Dory.app/Contents/Resources/dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + Dory.app/Contents/Resources/dory-desktop-debian-build-arm64.stamp \ + Dory.app/Contents/Resources/dory-desktop-debian-packages-arm64.txt \ + Dory.app/Contents/Resources/dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + Dory.app/Contents/Resources/dory-desktop-ubuntu-build-arm64.stamp \ + Dory.app/Contents/Resources/dory-desktop-ubuntu-packages-arm64.txt \ + Dory.app/Contents/Resources/dory-desktop-kali-rootfs-arm64.ext4.lzfse \ + Dory.app/Contents/Resources/dory-desktop-kali-build-arm64.stamp \ + Dory.app/Contents/Resources/dory-desktop-kali-packages-arm64.txt; do + grep -Fxq "$member" <<< "$members" || fail "$label omits Desktop payload: $member" + done +} + +validate_lean_members() { + local members="$1" label="$2" member + for member in \ + Dory.app/Contents/Resources/dory-desktop-kernel-arm64.lzfse \ + Dory.app/Contents/Resources/kernel-build-arm64-desktop.stamp \ + Dory.app/Contents/Resources/dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + Dory.app/Contents/Resources/dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + Dory.app/Contents/Resources/dory-desktop-kali-rootfs-arm64.ext4.lzfse; do + if grep -Fxq "$member" <<< "$members"; then + fail "$label unexpectedly contains Desktop payload: $member" + fi + done +} + +EXTRACT_ROOT="" +DMG_MOUNT="" +cleanup() { + if [ -n "$DMG_MOUNT" ]; then + hdiutil detach "$DMG_MOUNT" -quiet >/dev/null 2>&1 || true + fi + [ -z "$EXTRACT_ROOT" ] || rm -rf "$EXTRACT_ROOT" +} +trap cleanup EXIT + +required_artifacts=( + "Dory-$VERSION-arm64.zip" + "Dory-$VERSION.zip" + "Dory-$VERSION-arm64.dmg" + "Dory-$VERSION.dmg" + "Dory-$VERSION-app-update.zip" + "dory-engine-$VERSION-arm64.tar.gz" + "Dory-$VERSION.cdx.json" + "appcast.xml" +) + +for artifact in "${required_artifacts[@]}"; do + [ -s "$BUILD_DIR/$artifact" ] || fail "required public artifact is missing or empty: $BUILD_DIR/$artifact" +done +[ -s "$BUILD_DIR/release-manifest.json" ] || fail "release manifest is missing or empty" +[ -d "$BUILD_DIR/export-arm64/Dory.app" ] || fail "arm64 notarized candidate app is missing" +SBOM_SOURCE_COMMIT="$(python3 scripts/validate-release-metadata.py \ + "$BUILD_DIR" "$VERSION" "$BUILD")" \ + || fail "release manifest or appcast metadata is invalid" + +COMPONENT_DIR="$BUILD_DIR/components/arm64" +CATALOG_PUBLIC_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' \ + "$BUILD_DIR/export-arm64/Dory.app/Contents/Info.plist" 2>/dev/null)" \ + || fail "release app does not contain its component signature trust root" +[ "$CATALOG_PUBLIC_KEY" = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" ] \ + || fail "release app component signature trust root is unexpected" + +KUBECTL_COMPONENT="$(find "$COMPONENT_DIR" -maxdepth 1 -type f \ + -name "Dory-$VERSION-component-kubernetes-arm64-kubectl" -print)" +[ -n "$KUBECTL_COMPONENT" ] || fail "focused Kubernetes component is missing" +[ "$(lipo -archs "$KUBECTL_COMPONENT")" = arm64 ] \ + || fail "focused Kubernetes component is not arm64-only" +codesign --verify --strict --verbose=2 "$KUBECTL_COMPONENT" \ + || fail "focused Kubernetes component signature is invalid" +if [ "${DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION:-0}" != "1" ]; then + kubectl_signature="$(codesign -d --verbose=4 "$KUBECTL_COMPONENT" 2>&1)" \ + || fail "could not inspect the focused Kubernetes component signature" + grep -F 'Authority=Developer ID Application:' <<< "$kubectl_signature" >/dev/null \ + && grep -F "TeamIdentifier=$TEAM" <<< "$kubectl_signature" >/dev/null \ + || fail "focused Kubernetes component is not signed by the expected Developer ID team" +fi + +scripts/verify-release-sbom.py \ + --sbom "$BUILD_DIR/Dory-$VERSION.cdx.json" \ + --app "$BUILD_DIR/export-arm64/Dory.app" \ + --version "$VERSION" \ + --source-commit "$SBOM_SOURCE_COMMIT" >/dev/null \ + || fail "CycloneDX SBOM does not match the exact shipped app tree" +[ "$(shasum -a 256 "$BUILD_DIR/Dory-$VERSION.zip" | awk '{print $1}')" = \ + "$(shasum -a 256 "$BUILD_DIR/Dory-$VERSION-arm64.zip" | awk '{print $1}')" ] \ + || fail "compatibility ZIP is not byte-identical to the arm64 ZIP" +[ "$(shasum -a 256 "$BUILD_DIR/Dory-$VERSION.dmg" | awk '{print $1}')" = \ + "$(shasum -a 256 "$BUILD_DIR/Dory-$VERSION-arm64.dmg" | awk '{print $1}')" ] \ + || fail "compatibility DMG is not byte-identical to the arm64 DMG" + +EXTRACT_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dory-release-archives.XXXXXX")" +DIRECT_ZIP="$BUILD_DIR/Dory-$VERSION-arm64.zip" +DIRECT_MEMBERS="$(unzip -Z1 "$DIRECT_ZIP")" || fail "arm64 ZIP is unreadable" +validate_app_zip_layout "$DIRECT_ZIP" "arm64 ZIP" +if printf '%s\n' "$DIRECT_MEMBERS" \ + | grep -Eiq '(^|/)([^/]*(Tests?|UITests)-Runner\.app|[^/]+\.xctest)(/|$)'; then + fail "arm64 ZIP contains an XCTest runner or test bundle" +fi +DIRECT_EXTRACT="$EXTRACT_ROOT/direct" +mkdir -p "$DIRECT_EXTRACT" +ditto -x -k "$DIRECT_ZIP" "$DIRECT_EXTRACT" || fail "arm64 ZIP could not be extracted" +DIRECT_APP="$DIRECT_EXTRACT/Dory.app" +validate_app_symlinks "$DIRECT_APP" "arm64 ZIP" +validate_core_payload_aliases "$DIRECT_APP" "arm64 ZIP" +validate_edition "$DIRECT_APP" lean "arm64 ZIP" +validate_lean_members "$DIRECT_MEMBERS" "arm64 ZIP" +scripts/verify-release-sbom.py \ + --sbom "$BUILD_DIR/Dory-$VERSION.cdx.json" \ + --app "$DIRECT_APP" \ + --version "$VERSION" \ + --source-commit "$SBOM_SOURCE_COMMIT" >/dev/null \ + || fail "arm64 ZIP app differs from the SBOM-bound release app tree" + +UPDATE_ZIP="$BUILD_DIR/Dory-$VERSION-app-update.zip" +UPDATE_MEMBERS="$(unzip -Z1 "$UPDATE_ZIP")" || fail "Sparkle app-update ZIP is unreadable" +validate_app_zip_layout "$UPDATE_ZIP" "Sparkle app-update ZIP" +if printf '%s\n' "$UPDATE_MEMBERS" \ + | grep -Eiq '(^|/)([^/]*(Tests?|UITests)-Runner\.app|[^/]+\.xctest)(/|$)'; then + fail "Sparkle app-update ZIP contains an XCTest runner or test bundle" +fi +for member in \ + Dory.app/Contents/Resources/dory-hv-kernel-gpu-arm64.lzfse \ + Dory.app/Contents/Resources/dory-kernel-build-arm64-gpu.stamp \ + Dory.app/Contents/Resources/dory-transfer-helper-image-arm64.tar \ + Dory.app/Contents/Resources/dory-transfer-helper-image-arm64.json \ + Dory.app/Contents/Resources/dory-payload-sha256.txt \ + Dory.app/Contents/Helpers/dory-dataplane-proxy \ + Dory.app/Contents/Helpers/docker-buildx \ + Dory.app/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist; do + grep -Fxq "$member" <<< "$UPDATE_MEMBERS" \ + || fail "Sparkle app-update ZIP omits required self-contained payload: $member" +done +validate_lean_members "$UPDATE_MEMBERS" "Sparkle app-update ZIP" + +UPDATE_EXTRACT="$EXTRACT_ROOT/update" +mkdir -p "$UPDATE_EXTRACT" +ditto -x -k "$UPDATE_ZIP" "$UPDATE_EXTRACT" \ + || fail "Sparkle app-update ZIP could not be extracted" +UPDATE_APP="$UPDATE_EXTRACT/Dory.app" +validate_app_symlinks "$UPDATE_APP" "Sparkle app-update ZIP" +validate_core_payload_aliases "$UPDATE_APP" "Sparkle app-update ZIP" +validate_edition "$UPDATE_APP" lean "Sparkle app-update ZIP" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$UPDATE_APP/Contents/Info.plist")" = "$VERSION" ] \ + || fail "Sparkle app-update marketing version does not match $VERSION" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$UPDATE_APP/Contents/Info.plist")" = "$BUILD" ] \ + || fail "Sparkle app-update build does not match $BUILD" +scripts/verify-release-sbom.py \ + --sbom "$BUILD_DIR/Dory-$VERSION.cdx.json" \ + --app "$UPDATE_APP" \ + --version "$VERSION" \ + --source-commit "$SBOM_SOURCE_COMMIT" >/dev/null \ + || fail "Sparkle app-update differs from the SBOM-bound direct app tree" + +APP="$BUILD_DIR/export-arm64/Dory.app" +INFO="$APP/Contents/Info.plist" +validate_core_payload_aliases "$APP" "arm64 release app" +validate_edition "$APP" lean "arm64 release app" +NETWORK_HELPER="$APP/Contents/Helpers/dory-network-helper" +NETWORK_DAEMON_PLIST="$APP/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" +test_payload="$(find "$APP" \ + \( -type d -iname '*Tests-Runner.app' -o -type d -iname '*UITests-Runner.app' \ + -o -type d -iname '*.xctest' \) -print -quit)" +[ -z "$test_payload" ] \ + || fail "arm64 public app contains a test-only bundle: $test_payload" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO")" = "$VERSION" ] \ + || fail "arm64 app marketing version does not match $VERSION" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO")" = "$BUILD" ] \ + || fail "arm64 app build does not match $BUILD" +[ "$("$APP/Contents/Helpers/dory" version)" = "$VERSION" ] \ + || fail "bundled dory CLI version does not match $VERSION" + +[ -x "$NETWORK_HELPER" ] || fail "arm64 app has no privileged network helper" +[ -s "$NETWORK_DAEMON_PLIST" ] || fail "arm64 app has no privileged network daemon plist" +plutil -lint "$NETWORK_DAEMON_PLIST" >/dev/null \ + || fail "privileged network daemon plist is invalid" +[ "$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "$NETWORK_DAEMON_PLIST" 2>/dev/null)" = \ + "Contents/Helpers/dory-network-helper" ] \ + || fail "privileged network daemon BundleProgram is invalid" +[ "$(/usr/libexec/PlistBuddy -c 'Print :MachServices:dev.dory.network-helper' "$NETWORK_DAEMON_PLIST" 2>/dev/null)" = \ + "true" ] \ + || fail "privileged network daemon Mach service is missing" + +RESOURCES="$APP/Contents/Resources" +GVPROXY="$APP/Contents/Helpers/gvproxy" +GVPROXY_PROVENANCE="$RESOURCES/gvproxy-provenance.txt" +[ -x "$GVPROXY" ] || fail "arm64 app has no executable gvproxy helper" +[ -s "$GVPROXY_PROVENANCE" ] || fail "arm64 app has no gvproxy provenance" +[ -s "$RESOURCES/host-cli-provenance.txt" ] || fail "arm64 app has no host CLI provenance" +[ "$(stat -f '%Lp' "$RESOURCES/host-cli-provenance.txt")" = 644 ] \ + || fail "arm64 app host CLI provenance does not use portable mode 0644" +[ -s "$RESOURCES/dory-payload-sha256.txt" ] || fail "arm64 app has no payload digest inventory" +[ -s "$RESOURCES/dory-kernel-build-arm64.stamp" ] || fail "arm64 app has no kernel provenance" +[ -s "$RESOURCES/dory-initfs-build-arm64.stamp" ] || fail "arm64 app has no initfs provenance" +[ ! -e "$RESOURCES/dory-kernel-build-amd64.stamp" ] || fail "arm64 app unexpectedly bundles Intel guest assets" +[ ! -e "$RESOURCES/dory-initfs-build-amd64.stamp" ] || fail "arm64 app unexpectedly bundles Intel initfs assets" +[ -s "$RESOURCES/dory-hv-kernel-gpu-arm64.lzfse" ] || fail "arm64 app has no verified Apple-silicon GPU kernel" +[ -s "$RESOURCES/dory-kernel-build-arm64-gpu.stamp" ] || fail "arm64 app has no Apple-silicon GPU provenance" +for desktop_payload in \ + dory-desktop-kernel-arm64.lzfse \ + kernel-build-arm64-desktop.stamp \ + dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + dory-desktop-kali-rootfs-arm64.ext4.lzfse; do + [ ! -e "$RESOURCES/$desktop_payload" ] \ + || fail "lean arm64 app unexpectedly contains $desktop_payload" +done +[ -s "$RESOURCES/dory-transfer-helper-image-arm64.tar" ] \ + || fail "arm64 app has no named-volume transfer helper image" +[ -s "$RESOURCES/dory-transfer-helper-image-arm64.json" ] \ + || fail "arm64 app has no named-volume transfer helper metadata" +TRANSFER_HELPER_SHA256="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["helperSha256"])' \ + "$RESOURCES/dory-transfer-helper-image-arm64.json")" \ + || fail "transfer-helper metadata is invalid" +TRANSFER_VERIFIED_METADATA="$(python3 scripts/build-transfer-helper-image.py \ + --verify "$RESOURCES/dory-transfer-helper-image-arm64.tar" \ + --expected-helper-sha256 "$TRANSFER_HELPER_SHA256")" \ + || fail "named-volume transfer helper image is invalid" +[ "$TRANSFER_VERIFIED_METADATA" = "$(tr -d '\n' < "$RESOURCES/dory-transfer-helper-image-arm64.json")" ] \ + || fail "transfer-helper metadata does not describe the exact archive" +[ "$(shasum -a 256 "$RESOURCES/dory-transfer-helper-image-arm64.tar" | awk '{print $1}')" = \ + "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["archiveSha256"])' \ + "$RESOURCES/dory-transfer-helper-image-arm64.json")" ] \ + || fail "transfer-helper image archive digest does not match its metadata" +(cd "$APP" && shasum -a 256 -c Contents/Resources/dory-payload-sha256.txt >/dev/null) \ + || fail "arm64 app payload digest inventory does not match" + +# A release must contain the audited, reproducible Dory dual-stack derivative. Merely recording a +# provenance file is insufficient: every identity field is singular and exact, and the signed +# helper must still self-identify as that derivative. These pins intentionally require an explicit +# validator update whenever the upstream source or Dory patch changes. +gvproxy_provenance_value() { + local key="$1" + awk -v key="$key" ' + index($0, key "=") == 1 { count += 1; value = substr($0, length(key) + 2) } + END { if (count == 1) print value; else exit 1 } + ' "$GVPROXY_PROVENANCE" +} +require_gvproxy_provenance() { + local key="$1" expected="$2" actual + actual="$(gvproxy_provenance_value "$key")" \ + || fail "gvproxy provenance must contain exactly one $key entry" + [ "$actual" = "$expected" ] \ + || fail "gvproxy provenance $key mismatch (expected $expected, got ${actual:-empty})" +} +require_gvproxy_provenance version v0.8.9-dory2 +require_gvproxy_provenance upstream_version v0.8.9 +require_gvproxy_provenance source_url \ + https://github.com/containers/gvisor-tap-vsock/archive/refs/tags/v0.8.9.tar.gz +require_gvproxy_provenance source_sha256 \ + 6cbcb7959a5d90b59253ea6d8bdf0285e2cfbc3b301398704b41e3069293f4fb +require_gvproxy_provenance patch_sha256 \ + 3d6db9d9c2e6ff79b8abd334afe3664c84b84ca11a6308e8cc3d30f8fc05ab96 +require_gvproxy_provenance go_toolchain go1.26.5 +require_gvproxy_provenance go_mod_sha256 \ + 75848c190dca5cc7af27ebe017d5a4d59d4a117c97eaa6b8ac0359e58d868eec +require_gvproxy_provenance go_sum_sha256 \ + 25b1a52ad3181030b6ccf92af5d69a1a4282f8f2342dad5348b5c954c304c4b3 +require_gvproxy_provenance go_proxy https://proxy.golang.org +require_gvproxy_provenance go_sumdb sum.golang.org +require_gvproxy_provenance go_arm64 v8.0 +require_gvproxy_provenance go_amd64 v1 +require_gvproxy_provenance fat_x86_64_segalign 0x1000 +require_gvproxy_provenance fat_arm64_segalign 0x4000 +require_gvproxy_provenance arm64_sha256 \ + 4517af6ee49549b8b709ea3fa69e219364772f422ef97c940f7ba0a592b07a88 +require_gvproxy_provenance amd64_sha256 \ + 3907fc0c3431e561c27e10269a265fc7fa6430d6d915cd184dccdc8e29c55855 +require_gvproxy_provenance verified_sha256 \ + 47c278f1636736ba552de3d2f0e68409cdc968d63bc02149637e449f40274459 +require_gvproxy_provenance features native-ipv6-v2,host-route-aware-aaaa-v1,source-preserving-lan-qemu-v1 +require_gvproxy_provenance architectures "x86_64 arm64" +require_gvproxy_provenance source pinned-source-build +[ "$("$GVPROXY" -version 2>&1 | tr -d '\r' | sed -n '1p')" = \ + "gvproxy version v0.8.9-dory2" ] \ + || fail "bundled gvproxy does not identify as v0.8.9-dory2" + +if [ "${DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION:-0}" != "1" ]; then + DMG="$BUILD_DIR/Dory-$VERSION-arm64.dmg" + [ "$(lipo -archs "$GVPROXY")" = "x86_64 arm64" ] \ + || fail "bundled gvproxy architecture contract changed" + scripts/verify-distribution-signatures.sh "$APP" "$TEAM" + codesign --verify --strict --verbose=2 "$NETWORK_HELPER" + xcrun stapler validate "$APP" + app_assessment="$(spctl --assess --type execute --verbose=4 "$APP" 2>&1)" \ + || fail "Gatekeeper rejected arm64 app" + printf '%s\n' "$app_assessment" + grep -Fx 'source=Notarized Developer ID' <<< "$app_assessment" >/dev/null \ + || fail "arm64 app is not accepted as Notarized Developer ID" + + scripts/verify-distribution-signatures.sh "$DIRECT_APP" "$TEAM" + xcrun stapler validate "$DIRECT_APP" + direct_assessment="$(spctl --assess --type execute --verbose=4 "$DIRECT_APP" 2>&1)" \ + || fail "Gatekeeper rejected the extracted arm64 ZIP app" + printf '%s\n' "$direct_assessment" + grep -Fx 'source=Notarized Developer ID' <<< "$direct_assessment" >/dev/null \ + || fail "extracted arm64 ZIP app is not accepted as Notarized Developer ID" + + scripts/verify-distribution-signatures.sh "$UPDATE_APP" "$TEAM" + xcrun stapler validate "$UPDATE_APP" + update_assessment="$(spctl --assess --type execute --verbose=4 "$UPDATE_APP" 2>&1)" \ + || fail "Gatekeeper rejected Sparkle app-update" + printf '%s\n' "$update_assessment" + grep -Fx 'source=Notarized Developer ID' <<< "$update_assessment" >/dev/null \ + || fail "Sparkle app-update is not accepted as Notarized Developer ID" + + codesign --verify --strict --verbose=2 "$DMG" \ + || fail "arm64 DMG signature is invalid" + dmg_codesign="$(codesign -d --verbose=4 "$DMG" 2>&1)" \ + || fail "could not inspect arm64 DMG signature" + grep -F 'Authority=Developer ID Application:' <<< "$dmg_codesign" >/dev/null \ + || fail "arm64 DMG is not Developer ID signed" + grep -F "TeamIdentifier=$TEAM" <<< "$dmg_codesign" >/dev/null \ + || fail "arm64 DMG is signed by the wrong team" + grep -E '^Timestamp=' <<< "$dmg_codesign" >/dev/null \ + || fail "arm64 DMG signature has no secure timestamp" + xcrun stapler validate "$DMG" + dmg_assessment="$(spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG" 2>&1)" \ + || fail "Gatekeeper rejected arm64 DMG" + printf '%s\n' "$dmg_assessment" + grep -Fx 'source=Notarized Developer ID' <<< "$dmg_assessment" >/dev/null \ + || fail "arm64 DMG is not accepted as Notarized Developer ID" + + ATTACH_PLIST="$EXTRACT_ROOT/dmg-attach.plist" + if command -v diskutil >/dev/null 2>&1 \ + && diskutil image attach --help >/dev/null 2>&1; then + diskutil image attach --readOnly --nobrowse --plist "$DMG" > "$ATTACH_PLIST" + else + diskutil image attach --readOnly --nobrowse --plist "$DMG" > "$ATTACH_PLIST" + fi + DMG_MOUNT="$(python3 - "$ATTACH_PLIST" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], "rb") as handle: + payload = plistlib.load(handle) +mounts = [row["mount-point"] for row in payload.get("system-entities", []) if row.get("mount-point")] +if len(mounts) != 1: + raise SystemExit(f"expected one mounted volume, found {mounts}") +print(mounts[0]) +PY +)" || fail "could not identify the mounted DMG volume" + [ -d "$DMG_MOUNT/Dory.app" ] || fail "mounted DMG has no Dory.app" + [ "$(find "$DMG_MOUNT" -maxdepth 1 -type d -name Dory.app -print | wc -l | tr -d ' ')" = 1 ] \ + || fail "mounted DMG does not contain exactly one Dory.app" + [ -L "$DMG_MOUNT/Applications" ] \ + && [ "$(readlink "$DMG_MOUNT/Applications")" = /Applications ] \ + || fail "mounted DMG has no Applications shortcut" + mount | grep -F " on $DMG_MOUNT (" | grep -F 'read-only' >/dev/null \ + || fail "release DMG did not mount read-only" + MOUNTED_APP="$DMG_MOUNT/Dory.app" + validate_app_symlinks "$MOUNTED_APP" "mounted DMG app" + scripts/verify-release-sbom.py \ + --sbom "$BUILD_DIR/Dory-$VERSION.cdx.json" \ + --app "$MOUNTED_APP" \ + --version "$VERSION" \ + --source-commit "$SBOM_SOURCE_COMMIT" >/dev/null \ + || fail "mounted DMG app differs from the SBOM-bound direct app tree" + scripts/verify-distribution-signatures.sh "$MOUNTED_APP" "$TEAM" + xcrun stapler validate "$MOUNTED_APP" + mounted_assessment="$(spctl --assess --type execute --verbose=4 "$MOUNTED_APP" 2>&1)" \ + || fail "Gatekeeper rejected the app inside the mounted DMG" + printf '%s\n' "$mounted_assessment" + grep -Fx 'source=Notarized Developer ID' <<< "$mounted_assessment" >/dev/null \ + || fail "mounted DMG app is not accepted as Notarized Developer ID" + hdiutil detach "$DMG_MOUNT" -quiet + DMG_MOUNT="" +fi + +echo "release outputs: PASS ($VERSION build $BUILD)" From 62117a0fea7883a6848c796665ec4403643d26b4 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:23:19 +0000 Subject: [PATCH 167/338] test(release): track Homebrew install gate --- .github/scripts/test-homebrew-install-gate.py | 146 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/homebrew-install-gate.sh | 532 ++++++++++++++++++ 4 files changed, 680 insertions(+) create mode 100755 .github/scripts/test-homebrew-install-gate.py create mode 100755 scripts/homebrew-install-gate.sh diff --git a/.github/scripts/test-homebrew-install-gate.py b/.github/scripts/test-homebrew-install-gate.py new file mode 100755 index 00000000..271180cb --- /dev/null +++ b/.github/scripts/test-homebrew-install-gate.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Non-mutating contract tests for the clean-user Homebrew release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "homebrew-install-gate.sh" +SOURCE_COMMIT = "a" * 40 + + +class HomebrewInstallGateTests(unittest.TestCase): + def invoke( + self, + candidate: pathlib.Path, + workroot: pathlib.Path, + *, + clean_user: bool = True, + confirmation: str = "CLEAN-RELEASE-USER-HOMEBREW-INSTALL", + temporary_root: pathlib.Path, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + if clean_user: + environment["DORY_RELEASE_CLEAN_USER"] = "1" + else: + environment.pop("DORY_RELEASE_CLEAN_USER", None) + return subprocess.run( + [ + str(GATE), + "--candidate-dir", + str(candidate), + "--version", + "9.8.7", + "--build", + "42", + "--source-commit", + SOURCE_COMMIT, + "--workroot", + str(workroot), + "--confirm", + confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_has_no_optimizer_bypass(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + "scripts/verify-release-sbom.py", + "com.apple.quarantine", + "source=Notarized Developer ID", + "data_drive_preserved=PASS", + "zap_preserved_data=PASS", + "profile_restoration=PASS", + ): + self.assertIn(contract, source) + + def test_confirmation_and_clean_user_guards_precede_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + workroot = temporary / "dory-homebrew-install" + missing_confirmation = self.invoke( + candidate, + workroot, + confirmation="wrong", + temporary_root=temporary, + ) + missing_clean_user = self.invoke( + candidate, + workroot, + clean_user=False, + temporary_root=temporary, + ) + self.assertNotEqual(missing_confirmation.returncode, 0) + self.assertIn("--confirm CLEAN-RELEASE-USER-HOMEBREW-INSTALL", missing_confirmation.stdout) + self.assertNotEqual(missing_clean_user.returncode, 0) + self.assertIn("DORY_RELEASE_CLEAN_USER=1 is required", missing_clean_user.stdout) + + def test_candidate_symlink_is_rejected_before_host_checks(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + target = temporary / "candidate-target" + target.mkdir() + candidate = temporary / "candidate-link" + candidate.symlink_to(target, target_is_directory=True) + result = self.invoke( + candidate, + temporary / "dory-homebrew-install", + temporary_root=temporary, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--candidate-dir must be a direct directory", result.stdout) + + def test_workroot_is_restricted_to_owned_runner_temp_namespace(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + wrong_name = self.invoke( + candidate, + temporary / "unscoped-output", + temporary_root=temporary, + ) + outside = self.invoke( + candidate, + ROOT / "dory-homebrew-install", + temporary_root=temporary, + ) + target = temporary / "existing-output" + target.mkdir() + link = temporary / "dory-homebrew-install" + link.symlink_to(target, target_is_directory=True) + symlink = self.invoke(candidate, link, temporary_root=temporary) + nested_candidate = temporary / "dory-homebrew-install.candidate-parent" / "candidate" + nested_candidate.mkdir(parents=True) + overlap = self.invoke( + nested_candidate, + nested_candidate.parent, + temporary_root=temporary, + ) + self.assertIn("dedicated dory-homebrew-install name", wrong_name.stdout) + self.assertIn("inside the runner temporary directory", outside.stdout) + self.assertIn("must not be a symlink", symlink.stdout) + self.assertIn("cannot contain the candidate", overlap.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7697a1d1..73257a9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,6 +336,7 @@ jobs: run: | python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py + python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a2e4f73..a39a4164 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,7 @@ jobs: run: | python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py + python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/homebrew-install-gate.sh b/scripts/homebrew-install-gate.sh new file mode 100755 index 00000000..d8e162ce --- /dev/null +++ b/scripts/homebrew-install-gate.sh @@ -0,0 +1,532 @@ +#!/bin/bash +# Install the immutable candidate through Homebrew under normal quarantine, launch it once, and +# prove that uninstall removes integration without removing the durable data drive. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CANDIDATE_DIR="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-homebrew-install" +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --candidate-dir) need_value "$1" "$#"; CANDIDATE_DIR="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ -n "$CANDIDATE_DIR" ] || die "--candidate-dir is required" +[ -n "$VERSION" ] || die "--version is required" +case "$BUILD" in ''|*[!0-9]*) die "--build must be a positive integer" ;; esac +[ "$BUILD" -gt 0 ] || die "--build must be a positive integer" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "--source-commit must be a full lowercase Git SHA" +[ "$CONFIRM" = CLEAN-RELEASE-USER-HOMEBREW-INSTALL ] \ + || die "--confirm CLEAN-RELEASE-USER-HOMEBREW-INSTALL is required" +[ "${DORY_RELEASE_CLEAN_USER:-0}" = 1 ] || die "DORY_RELEASE_CLEAN_USER=1 is required" + +case "$CANDIDATE_DIR" in /*) ;; *) CANDIDATE_DIR="$ROOT/$CANDIDATE_DIR" ;; esac +[ -d "$CANDIDATE_DIR" ] && [ ! -L "$CANDIDATE_DIR" ] \ + || die "--candidate-dir must be a direct directory" +CANDIDATE_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' \ + "$CANDIDATE_DIR")" +CANDIDATE_DIR="$(cd "$CANDIDATE_DIR" && pwd -P)" +[ "$CANDIDATE_DIR" = "$CANDIDATE_LOGICAL" ] \ + || die "--candidate-dir cannot have an indirect ancestor" +case "$WORKROOT" in + /*) ;; + *) die "--workroot must be absolute" ;; +esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +case "$WORKROOT_NAME" in + dory-homebrew-install|dory-homebrew-install.*) ;; + *) die "--workroot must use the dedicated dory-homebrew-install name" ;; +esac +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || die "--workroot parent must be a direct directory" +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] || die "temporary authority root is unavailable" +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +case "$TEMP_AUTHORITY" in + /|"$HOME"|"$ROOT"|"$CANDIDATE_DIR") die "unsafe temporary authority root: $TEMP_AUTHORITY" ;; +esac +case "$WORKROOT" in + "$TEMP_AUTHORITY"/*) ;; + *) die "--workroot must be inside the runner temporary directory" ;; +esac +case "$WORKROOT" in /|"$HOME"|"$ROOT"|"$CANDIDATE_DIR") die "unsafe --workroot: $WORKROOT" ;; esac +case "$WORKROOT/" in "$CANDIDATE_DIR/"*) die "--workroot cannot be inside the candidate" ;; esac +case "$CANDIDATE_DIR/" in "$WORKROOT/"*) die "--workroot cannot contain the candidate" ;; esac +if [ -e "$WORKROOT" ] || [ -L "$WORKROOT" ]; then + [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "--workroot must not be a symlink or non-directory" + [ "$(stat -f '%u' "$WORKROOT")" = "$(id -u)" ] \ + || die "--workroot is not owned by the release user" +fi + +[ "$(uname -s)" = Darwin ] || die "physical macOS is required" +[ "$(uname -m)" = arm64 ] || die "physical Apple Silicon is required" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Hypervisor.framework is unavailable" +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested virtualization does not qualify" +case "$(sysctl -n hw.model 2>/dev/null || printf unknown)" in + VirtualMac*) die "a physical Mac is required" ;; +esac +command -v brew >/dev/null || die "Homebrew is required" +command -v python3 >/dev/null || die "python3 is required" + +ZIP="$CANDIDATE_DIR/Dory-$VERSION.zip" +SBOM="$CANDIDATE_DIR/Dory-$VERSION.cdx.json" +MANIFEST="$CANDIDATE_DIR/release-manifest.json" +[ -s "$ZIP" ] || die "candidate Homebrew ZIP is missing" +[ -s "$SBOM" ] || die "candidate SBOM is missing" +[ -s "$MANIFEST" ] || die "candidate release manifest is missing" +manifest_commit="$(python3 "$ROOT/scripts/validate-release-metadata.py" "$CANDIDATE_DIR" "$VERSION" "$BUILD")" \ + || die "candidate metadata is invalid" +[ "$manifest_commit" = "$SOURCE_COMMIT" ] || die "candidate source commit mismatch" +ZIP_SHA="$(shasum -a 256 "$ZIP" | awk '{print $1}')" + +APP="/Applications/Dory.app" +SERVICE="gui/$(id -u)/dev.dory.doryd" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +DRIVE="$APP_SUPPORT/Dory.dorydrive" +SELECTION="$APP_SUPPORT/data-drive-selection.json" +PREF_DOMAIN="com.pythonxi.Dory" +PREF_PLIST="$HOME/Library/Preferences/$PREF_DOMAIN.plist" +BREW_BIN="$(brew --prefix)/bin/dory" +TAP="doryci/release-install" +CASK="$TAP/dory" +PROFILE_NAMES=(.zprofile .zshrc .bash_profile .bashrc .profile) + +[ ! -e "$APP" ] || die "$APP already exists" +[ ! -e "$STATE" ] || die "$STATE already exists" +[ ! -e "$APP_SUPPORT" ] || die "$APP_SUPPORT already exists" +[ ! -e "$PLIST" ] || die "$PLIST already exists" +[ ! -e "$BREW_BIN" ] && [ ! -L "$BREW_BIN" ] || die "$BREW_BIN already exists" +launchctl print "$SERVICE" >/dev/null 2>&1 && die "dev.dory.doryd is already loaded" +defaults read "$PREF_DOMAIN" >/dev/null 2>&1 && die "Dory preferences already exist" +brew list --cask 2>/dev/null | grep -qx dory && die "a Dory cask is already installed" +python3 - "$HOME/.docker/contexts/meta" <<'PY' || die "Docker context 'dory' already exists" +import json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +for path in root.glob("*/meta.json"): + try: + if json.loads(path.read_text(encoding="utf-8")).get("Name") == "dory": + raise SystemExit(1) + except (OSError, ValueError): + continue +PY +for process in Dory doryd dory-hv dory-vmm OrbStack colima limactl; do + pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 && die "$process is already running" +done + +rm -rf "$WORKROOT" +mkdir -p "$WORKROOT/evidence" "$WORKROOT/private-profiles" +EVIDENCE="$WORKROOT/evidence" +python3 - "$HOME" "$WORKROOT/private-profiles" "$EVIDENCE/profile-baseline.json" \ + "${PROFILE_NAMES[@]}" <<'PY' +import hashlib, json, os, pathlib, shutil, stat, sys + +home, private, evidence, *names = sys.argv[1:] +private_root = pathlib.Path(private) +rows = [] +for index, name in enumerate(names): + path = pathlib.Path(home, name) + if path.is_symlink(): + kind = "symlink" + link = os.readlink(path) + target = path.resolve(strict=True) + elif path.exists(): + kind = "file" + link = None + target = path + else: + rows.append({"name": name, "kind": "missing"}) + continue + backup = private_root / str(index) + shutil.copy2(target, backup) + content = target.read_bytes() + rows.append({ + "name": name, + "kind": kind, + "link": link, + "target": str(target), + "backup": str(backup), + "sha256": hashlib.sha256(content).hexdigest(), + "mode": stat.S_IMODE(target.stat().st_mode), + }) +pathlib.Path(private, "profiles.json").write_text(json.dumps(rows), encoding="utf-8") +public = [{key: value for key, value in row.items() if key not in {"target", "backup", "link"}} for row in rows] +pathlib.Path(evidence).write_text(json.dumps(public, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +PLUGIN_BASELINE="$EVIDENCE/docker-plugin-baseline.json" +python3 - "$HOME" "$PLUGIN_BASELINE" docker-compose docker-buildx <<'PY' +import hashlib, json, os, pathlib, stat, sys + +home, output, *names = sys.argv[1:] +rows = [] +for name in names: + path = pathlib.Path(home, ".docker", "cli-plugins", name) + if path.is_symlink(): + rows.append({"name": name, "kind": "symlink", "target": os.readlink(path)}) + elif path.is_file(): + rows.append({ + "name": name, + "kind": "file", + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "mode": stat.S_IMODE(path.stat().st_mode), + }) + elif path.is_dir(): + rows.append({"name": name, "kind": "directory", "mode": stat.S_IMODE(path.stat().st_mode)}) + elif path.exists(): + raise SystemExit(f"unsupported Docker plugin path: {path}") + else: + rows.append({"name": name, "kind": "missing"}) +pathlib.Path(output).write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +verify_plugin_baseline() { + python3 - "$HOME" "$PLUGIN_BASELINE" <<'PY' +import hashlib, json, os, pathlib, stat, sys + +home = pathlib.Path(sys.argv[1]) +def require(condition, message): + if not condition: + raise SystemExit(message) + +for row in json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")): + path = home / ".docker" / "cli-plugins" / row["name"] + kind = row["kind"] + if kind == "missing": + require(not path.exists() and not path.is_symlink(), f"created Docker plugin remains: {path}") + elif kind == "symlink": + require(path.is_symlink() and os.readlink(path) == row["target"], f"Docker plugin symlink changed: {path}") + elif kind == "file": + require(path.is_file() and not path.is_symlink(), f"Docker plugin type changed: {path}") + require(hashlib.sha256(path.read_bytes()).hexdigest() == row["sha256"], f"Docker plugin bytes changed: {path}") + require(stat.S_IMODE(path.stat().st_mode) == row["mode"], f"Docker plugin mode changed: {path}") + elif kind == "directory": + require(path.is_dir() and not path.is_symlink(), f"Docker plugin directory changed: {path}") + require(stat.S_IMODE(path.stat().st_mode) == row["mode"], f"Docker plugin directory mode changed: {path}") +PY +} + +SERVER_PID="" +MUTATION_STARTED=0 +TRASH_MARKER="" +cleanup() { + set +e + if [ "$MUTATION_STARTED" -eq 1 ]; then + if [ -x "$APP/Contents/Helpers/dory" ]; then + "$APP/Contents/Helpers/dory" uninstall >/dev/null 2>&1 || true + fi + brew uninstall --cask --force "$CASK" >/dev/null 2>&1 || true + osascript -e 'tell application id "com.pythonxi.Dory" to quit' >/dev/null 2>&1 || true + launchctl bootout "$SERVICE" >/dev/null 2>&1 || true + if [ -d "$APP" ] && [ "$(defaults read "$APP/Contents/Info" CFBundleIdentifier 2>/dev/null)" = "$PREF_DOMAIN" ]; then + rm -rf "$APP" + fi + rm -f "$PLIST" + rm -rf "$STATE" "$APP_SUPPORT" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + fi + brew untap --force "$TAP" >/dev/null 2>&1 || true + [ -z "$SERVER_PID" ] || kill "$SERVER_PID" >/dev/null 2>&1 || true + [ -z "$SERVER_PID" ] || wait "$SERVER_PID" >/dev/null 2>&1 || true + if [ -n "$TRASH_MARKER" ] && [ -d "$HOME/.Trash" ]; then + while IFS= read -r trashed; do + case "$(basename "$trashed")" in + .dory|.dory\ *|com.pythonxi.Dory.plist|com.pythonxi.Dory.plist\ *|com.pythonxi.Dory|com.pythonxi.Dory\ *) + rm -rf "$trashed" + ;; + esac + done < <(find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -newer "$TRASH_MARKER" -print 2>/dev/null) + fi + python3 - "$HOME" "$WORKROOT/private-profiles/profiles.json" <<'PY' +import json, os, pathlib, shutil, sys + +home = pathlib.Path(sys.argv[1]) +manifest = pathlib.Path(sys.argv[2]) +if not manifest.is_file(): + raise SystemExit(0) +for row in json.loads(manifest.read_text(encoding="utf-8")): + path = home / row["name"] + if row["kind"] == "missing": + if path.is_symlink() or path.exists(): + path.unlink() + continue + if row["kind"] == "symlink" and (not path.is_symlink() or os.readlink(path) != row["link"]): + if path.is_symlink() or path.exists(): + path.unlink() + path.symlink_to(row["link"]) + elif row["kind"] == "file" and path.is_symlink(): + path.unlink() + target = pathlib.Path(row["target"]) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(row["backup"], target) +PY +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +MUTATION_STARTED=1 +brew untap --force "$TAP" >/dev/null 2>&1 || true +brew tap-new "$TAP" >/dev/null +TAP_ROOT="$(brew --repository "$TAP")" +mkdir -p "$TAP_ROOT/Casks" +PORT_FILE="$WORKROOT/http-port" +python3 - "$CANDIDATE_DIR" "$PORT_FILE" >"$EVIDENCE/http.log" 2>&1 <<'PY' & +import functools, http.server, pathlib, sys + +root, port_file = sys.argv[1:] +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root) +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) +pathlib.Path(port_file).write_text(str(server.server_port), encoding="utf-8") +server.serve_forever() +PY +SERVER_PID=$! +for _ in $(seq 1 100); do [ -s "$PORT_FILE" ] && break; sleep 0.1; done +[ -s "$PORT_FILE" ] || die "loopback artifact server did not start" +PORT="$(cat "$PORT_FILE")" +LOCAL_URL="http://127.0.0.1:$PORT/$(basename "$ZIP")" + +python3 - "$ROOT/Casks/dory.rb" "$TAP_ROOT/Casks/dory.rb" "$VERSION" "$ZIP_SHA" "$LOCAL_URL" <<'PY' +import pathlib, re, sys + +source, target, version, digest, url = sys.argv[1:] +text = pathlib.Path(source).read_text(encoding="utf-8") +replacements = [ + (r'(?m)^ version "[^"]+"$', f' version "{version}"'), + (r'(?m)^ sha256 "[0-9a-f]+"$', f' sha256 "{digest}"'), + (r'(?m)^ url ".*"$', f' url "{url}"'), +] +for pattern, replacement in replacements: + text, count = re.subn(pattern, replacement, text) + if count != 1: + raise SystemExit(f"cask replacement count was {count}: {pattern}") +pathlib.Path(target).write_text(text, encoding="utf-8") +PY +cp "$ROOT/Casks/dory.rb" "$EVIDENCE/production-dory.rb" +cp "$TAP_ROOT/Casks/dory.rb" "$EVIDENCE/local-install-dory.rb" +brew style "$ROOT/Casks/dory.rb" >"$EVIDENCE/style.log" 2>&1 + +export HOMEBREW_NO_AUTO_UPDATE=1 +export HOMEBREW_NO_ENV_HINTS=1 +brew install --cask --require-sha --appdir=/Applications "$CASK" \ + >"$EVIDENCE/brew-install.log" 2>&1 +[ -d "$APP" ] || die "Homebrew did not install Dory.app" +[ -L "$BREW_BIN" ] || die "Homebrew did not link the dory command" +[ "$(realpath "$BREW_BIN")" = "$APP/Contents/Helpers/dory" ] \ + || die "Homebrew linked dory to the wrong app" +for helper in docker docker-compose docker-buildx kubectl dory; do + [ ! -e "$HOME/.dory/bin/$helper" ] && [ ! -L "$HOME/.dory/bin/$helper" ] \ + || die "Homebrew install mutated terminal integration before first app launch: $helper" +done + +codesign --verify --deep --strict "$APP" >"$EVIDENCE/codesign.log" 2>&1 +xcrun stapler validate "$APP" >"$EVIDENCE/stapler.log" 2>&1 +spctl --assess --type execute --verbose=4 "$APP" >"$EVIDENCE/gatekeeper.log" 2>&1 +grep -F 'source=Notarized Developer ID' "$EVIDENCE/gatekeeper.log" >/dev/null \ + || die "Homebrew-installed app is not accepted as Notarized Developer ID" +xattr -p com.apple.quarantine "$APP" >"$EVIDENCE/quarantine.txt" \ + || die "Homebrew installation did not preserve normal quarantine" +"$ROOT/scripts/verify-release-sbom.py" --sbom "$SBOM" --app "$APP" \ + --version "$VERSION" --source-commit "$SOURCE_COMMIT" >"$EVIDENCE/sbom.log" + +defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true +defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool false +open -n "$APP" +READY=0 +for _ in $(seq 1 360); do + if [ -S "$STATE/dory.sock" ] \ + && curl -fsS --max-time 2 --unix-socket "$STATE/dory.sock" http://d/_ping >/dev/null 2>&1; then + READY=1 + break + fi + pgrep -u "$(id -u)" -x Dory >/dev/null 2>&1 || die "Dory exited during first launch" + sleep 0.5 +done +[ "$READY" -eq 1 ] || die "Homebrew-installed Dory did not become ready" +for helper in docker docker-compose docker-buildx kubectl dory; do + [ -L "$HOME/.dory/bin/$helper" ] \ + || die "first app launch did not install $helper terminal integration" +done +"$APP/Contents/Helpers/docker" -H "unix://$STATE/dory.sock" version \ + >"$EVIDENCE/docker-version.txt" +[ -s "$DRIVE/drive.json" ] || die "first launch did not create the durable Dory drive" +[ -s "$SELECTION" ] || die "first launch did not record the selected Dory drive" +python3 - "$DRIVE/drive.json" "$SELECTION" <<'PY' +import json, pathlib, sys, uuid +data = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +selection = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +def require(condition, message): + if not condition: + raise SystemExit(message) + +require(data.get("kind") == "dev.dory.data-drive", "data-drive kind mismatch") +require(data.get("schemaVersion") == 1, "data-drive schema mismatch") +require(data.get("product") == "Dory", "data-drive product mismatch") +drive_id = uuid.UUID(data["id"]) +require(selection.get("schemaVersion") == 2, "selection schema mismatch") +require(selection.get("phase") == "ready", "selection is not ready") +require(uuid.UUID(selection["driveID"]) == drive_id, "selection drive identity mismatch") +PY +PRESERVATION_SENTINEL="$DRIVE/homebrew-uninstall-preservation.txt" +SELECTION_SHA="$(shasum -a 256 "$SELECTION" | awk '{print $1}')" +printf 'source_commit=%s\nzip_sha256=%s\n' "$SOURCE_COMMIT" "$ZIP_SHA" \ + > "$PRESERVATION_SENTINEL" +cp -p "$APP/Contents/Helpers/docker" "$WORKROOT/candidate-docker" + +brew uninstall --cask "$CASK" >"$EVIDENCE/brew-uninstall.log" 2>&1 +[ ! -e "$APP" ] || die "Homebrew uninstall left Dory.app installed" +[ ! -e "$BREW_BIN" ] && [ ! -L "$BREW_BIN" ] || die "Homebrew uninstall left its dory link" +launchctl print "$SERVICE" >/dev/null 2>&1 && die "Homebrew uninstall left doryd loaded" +[ ! -e "$PLIST" ] || die "Homebrew uninstall left the doryd LaunchAgent" +for _ in $(seq 1 120); do + remaining=0 + for process in Dory doryd dory-hv dory-vmm; do + pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 && remaining=1 + done + [ "$remaining" -eq 1 ] || break + sleep 0.25 +done +for process in Dory doryd dory-hv dory-vmm; do + pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + && die "Homebrew uninstall left $process running" +done +if HOME="$HOME" "$WORKROOT/candidate-docker" context inspect dory >/dev/null 2>&1; then + die "Homebrew uninstall left the Dory Docker context" +fi +[ "$(HOME="$HOME" "$WORKROOT/candidate-docker" context show 2>/dev/null)" = default ] \ + || die "Homebrew uninstall did not restore the default Docker context" +for helper in docker docker-compose docker-buildx kubectl dory; do + [ ! -e "$HOME/.dory/bin/$helper" ] && [ ! -L "$HOME/.dory/bin/$helper" ] \ + || die "Homebrew uninstall left the $helper integration" +done +verify_plugin_baseline || die "Homebrew uninstall changed the user's Docker plugins" +[ -f "$PRESERVATION_SENTINEL" ] || die "Homebrew uninstall removed the durable Dory drive" +[ "$(shasum -a 256 "$SELECTION" | awk '{print $1}')" = "$SELECTION_SHA" ] \ + || die "Homebrew uninstall changed the selected-drive authority" +grep -qx "source_commit=$SOURCE_COMMIT" "$PRESERVATION_SENTINEL" +grep -qx "zip_sha256=$ZIP_SHA" "$PRESERVATION_SENTINEL" + +python3 - "$HOME" "$WORKROOT/private-profiles/profiles.json" <<'PY' +import hashlib, json, os, pathlib, stat, sys + +home = pathlib.Path(sys.argv[1]) +def require(condition, message): + if not condition: + raise SystemExit(message) + +for row in json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")): + path = home / row["name"] + if row["kind"] == "missing": + require(not path.exists() and not path.is_symlink(), f"created profile remains: {path}") + continue + require(path.exists() or path.is_symlink(), f"profile is missing: {path}") + if row["kind"] == "symlink": + require(path.is_symlink() and os.readlink(path) == row["link"], f"profile symlink changed: {path}") + else: + require(not path.is_symlink(), f"profile became a symlink: {path}") + target = path.resolve(strict=True) + require(hashlib.sha256(target.read_bytes()).hexdigest() == row["sha256"], f"profile bytes changed: {path}") + require(stat.S_IMODE(target.stat().st_mode) == row["mode"], f"profile mode changed: {path}") +PY + +# Zap is intentionally stronger than uninstall, but the user's selected drive remains out of scope. +TRASH_MARKER="$WORKROOT/pre-zap-trash-marker" +touch "$TRASH_MARKER" +brew install --cask --require-sha --appdir=/Applications "$CASK" \ + >"$EVIDENCE/brew-reinstall-for-zap.log" 2>&1 +[ -d "$APP" ] || die "Homebrew could not reinstall Dory for zap certification" +brew uninstall --cask --zap "$CASK" >"$EVIDENCE/brew-zap.log" 2>&1 +[ ! -e "$APP" ] || die "Homebrew zap left Dory.app installed" +[ ! -e "$STATE" ] || die "Homebrew zap left transient Dory state" +[ ! -e "$HOME/Library/Preferences/$PREF_DOMAIN.plist" ] \ + || die "Homebrew zap left Dory preferences" +[ -f "$PRESERVATION_SENTINEL" ] || die "Homebrew zap removed the durable Dory drive" +[ "$(shasum -a 256 "$SELECTION" | awk '{print $1}')" = "$SELECTION_SHA" ] \ + || die "Homebrew zap changed the selected-drive authority" +HOME="$HOME" "$WORKROOT/candidate-docker" context inspect dory >/dev/null 2>&1 \ + && die "Homebrew zap left the Dory Docker context" +verify_plugin_baseline || die "Homebrew zap changed the user's Docker plugins" +python3 - "$HOME" "$WORKROOT/private-profiles/profiles.json" <<'PY' +import hashlib, json, os, pathlib, stat, sys + +home = pathlib.Path(sys.argv[1]) +def require(condition, message): + if not condition: + raise SystemExit(message) + +for row in json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")): + path = home / row["name"] + if row["kind"] == "missing": + require(not path.exists() and not path.is_symlink(), f"created profile remains after zap: {path}") + continue + require(path.exists() or path.is_symlink(), f"profile is missing after zap: {path}") + if row["kind"] == "symlink": + require(path.is_symlink() and os.readlink(path) == row["link"], f"profile symlink changed after zap: {path}") + else: + require(not path.is_symlink(), f"profile became a symlink after zap: {path}") + target = path.resolve(strict=True) + require(hashlib.sha256(target.read_bytes()).hexdigest() == row["sha256"], f"profile bytes changed after zap: {path}") + require(stat.S_IMODE(target.stat().st_mode) == row["mode"], f"profile mode changed after zap: {path}") +PY + +brew --version > "$EVIDENCE/brew-version.txt" +sw_vers > "$EVIDENCE/macos-version.txt" +{ + printf 'source_commit=%s\n' "$SOURCE_COMMIT" + printf 'run_id=%s\n' "${GITHUB_RUN_ID:-local}" + printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT:-local}" + printf 'version=%s\n' "$VERSION" + printf 'build=%s\n' "$BUILD" + printf 'zip_sha256=%s\n' "$ZIP_SHA" + printf 'normal_quarantine=PASS\n' + printf 'gatekeeper=PASS\n' + printf 'sbom=PASS\n' + printf 'first_launch=PASS\n' + printf 'data_drive_preserved=PASS\n' + printf 'zap_preserved_data=PASS\n' + printf 'zap_removed_transient_state=PASS\n' + printf 'docker_plugin_restoration=PASS\n' + printf 'profile_restoration=PASS\n' + printf 'status=PASS\n' +} > "$EVIDENCE/manifest.txt" + +echo "Homebrew install gate: PASS ($EVIDENCE/manifest.txt)" From 94dc31235de57066fb88c0adc1f4a7c12fb958f8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:26:04 +0000 Subject: [PATCH 168/338] test(release): track performance qualification --- .../scripts/test-release-performance-gate.py | 134 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/qualify-release-performance.sh | 442 ++++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100755 .github/scripts/test-release-performance-gate.py create mode 100755 scripts/qualify-release-performance.sh diff --git a/.github/scripts/test-release-performance-gate.py b/.github/scripts/test-release-performance-gate.py new file mode 100755 index 00000000..611545e1 --- /dev/null +++ b/.github/scripts/test-release-performance-gate.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for exact-candidate performance qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "qualify-release-performance.sh" + + +class ReleasePerformanceGateTests(unittest.TestCase): + def invoke( + self, + candidate: pathlib.Path, + workroot: pathlib.Path, + temporary_root: pathlib.Path, + *, + confirmation: str = "CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA", + clean_user: bool = True, + benchmark_user: bool = True, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + for key, enabled in ( + ("DORY_RELEASE_CLEAN_USER", clean_user), + ("DORY_RELEASE_BENCHMARK_USER", benchmark_user), + ): + if enabled: + environment[key] = "1" + else: + environment.pop(key, None) + digest = "example.invalid/fixture@sha256:" + "a" * 64 + return subprocess.run( + [ + str(GATE), + "--candidate-dir", str(candidate), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "b" * 40, + "--workroot", str(workroot), + "--alpine-image", digest, + "--iperf-image", digest, + "--node-image", digest, + "--postgres-image", digest, + "--redis-image", digest, + "--ruby-image", digest, + "--composer-image", digest, + "--curl-image", digest, + "--probe-url", "https://example.invalid/probe", + "--download-url", "https://example.invalid/payload", + "--download-bytes", "4096", + "--confirm", confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_candidate_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + 'export PATH="$APP/Contents/Helpers:$PATH"', + '"$(command -v docker)" = "$CANDIDATE_DOCKER"', + "codesign --verify --strict --deep", + "xcrun stapler validate", + "candidate source commit mismatch", + "an existing OrbStack installation would be removed", + "an existing Colima installation would be removed", + 'LIMA_COLIMA_STATE="$HOME/.lima/colima"', + "host rebooted during the performance campaign", + "engine_state_removed=PASS", + ): + self.assertIn(contract, source) + + def test_explicit_clean_account_arming_precedes_host_or_filesystem_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-performance-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + workroot = temporary / "dory-release-performance" + no_confirmation = self.invoke( + candidate, workroot, temporary, confirmation="wrong" + ) + no_clean_user = self.invoke( + candidate, workroot, temporary, clean_user=False + ) + no_benchmark_user = self.invoke( + candidate, workroot, temporary, benchmark_user=False + ) + self.assertIn("--confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA", no_confirmation.stdout) + self.assertIn("DORY_RELEASE_CLEAN_USER=1 is required", no_clean_user.stdout) + self.assertIn("DORY_RELEASE_BENCHMARK_USER=1 is required", no_benchmark_user.stdout) + + def test_candidate_and_workroot_authorities_reject_indirection_or_overlap(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-performance-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + candidate_link = temporary / "candidate-link" + candidate_link.symlink_to(candidate, target_is_directory=True) + indirect = self.invoke( + candidate_link, + temporary / "dory-release-performance", + temporary, + ) + wrong_name = self.invoke( + candidate, + temporary / "performance-output", + temporary, + ) + parent = temporary / "dory-release-performance" + nested_candidate = parent / "candidate" + nested_candidate.mkdir(parents=True) + overlap = self.invoke(nested_candidate, parent, temporary) + self.assertIn("candidate directory must be direct", indirect.stdout) + self.assertIn("dedicated dory-release-performance name", wrong_name.stdout) + self.assertIn("workroot cannot contain the candidate", overlap.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73257a9f..e35a849a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -337,6 +337,7 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py + python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a39a4164..a9a6c76c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,6 +38,7 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py + python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/qualify-release-performance.sh b/scripts/qualify-release-performance.sh new file mode 100755 index 00000000..2b57c599 --- /dev/null +++ b/scripts/qualify-release-performance.sh @@ -0,0 +1,442 @@ +#!/bin/bash +# Destructive clean-account exact-candidate performance campaign. Runs isolated/default and +# matched/interleaved comparisons, verifies every raw result, cleans all selected engine state, and +# produces the stable release evidence ZIP described by PERFORMANCE_QUALIFICATION.md. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CANDIDATE_DIR="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-release-performance" +ALPINE_IMAGE="${DORY_BENCH_ALPINE_IMAGE:-}" +IPERF_IMAGE="${DORY_BENCH_IPERF_IMAGE:-}" +NODE_IMAGE="${DORY_BENCH_NODE_IMAGE:-}" +POSTGRES_IMAGE="${DORY_BENCH_POSTGRES_IMAGE:-}" +REDIS_IMAGE="${DORY_BENCH_REDIS_IMAGE:-}" +RUBY_IMAGE="${DORY_BENCH_RUBY_IMAGE:-}" +COMPOSER_IMAGE="${DORY_BENCH_COMPOSER_IMAGE:-}" +CURL_IMAGE="${DORY_BENCH_CURL_IMAGE:-}" +PROBE_URL="${DORY_BENCH_PROBE_URL:-}" +DOWNLOAD_URL="${DORY_BENCH_DOWNLOAD_URL:-}" +DOWNLOAD_BYTES="${DORY_BENCH_DOWNLOAD_BYTES:-}" +ROUNDS=9 +CPUS=6 +MEMORY_GB=6 +CONFIRM="" + +usage() { + cat <<'EOF' +Usage: scripts/qualify-release-performance.sh [required options] + +Candidate: + --candidate-dir DIR Downloaded immutable release artifacts + --version VERSION Exact marketing version + --build BUILD Exact CFBundleVersion + --source-commit SHA Exact full source commit + +Immutable fixtures (each must contain @sha256:<64 hex>): + --alpine-image REF Basic CPU/memory/filesystem fixture + --iperf-image REF Container network fixture + --node-image REF npm/pnpm fixture + --postgres-image REF Compose stack fixture + --redis-image REF Compose stack fixture + --ruby-image REF Rails/Bundler fixture + --composer-image REF Composer/PHP fixture + --curl-image REF Controlled external-network fixture + +Controlled network: + --probe-url URL Credential-free small HTTPS endpoint + --download-url URL Credential-free fixed-size HTTPS endpoint + --download-bytes N Exact response size + +Safety and output: + --workroot DIR New private work/evidence root + --rounds N Interleaved rounds, multiple of 3 and at least 9 + --confirm TOKEN CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA + --help Show this help + +Live execution also requires DORY_RELEASE_CLEAN_USER=1 and DORY_RELEASE_BENCHMARK_USER=1. It must +run in a dedicated physical Apple-silicon account with no Dory, OrbStack, or Colima state. It +installs and completely purges OrbStack and Colima, deletes all Dory benchmark state, and restores +the prior Docker context. The retained output is Dory-VERSION-performance-evidence.zip. +EOF +} + +die() { echo "release performance qualification: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --candidate-dir) need_value "$1" "$#"; CANDIDATE_DIR="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --alpine-image) need_value "$1" "$#"; ALPINE_IMAGE="$2"; shift 2 ;; + --iperf-image) need_value "$1" "$#"; IPERF_IMAGE="$2"; shift 2 ;; + --node-image) need_value "$1" "$#"; NODE_IMAGE="$2"; shift 2 ;; + --postgres-image) need_value "$1" "$#"; POSTGRES_IMAGE="$2"; shift 2 ;; + --redis-image) need_value "$1" "$#"; REDIS_IMAGE="$2"; shift 2 ;; + --ruby-image) need_value "$1" "$#"; RUBY_IMAGE="$2"; shift 2 ;; + --composer-image) need_value "$1" "$#"; COMPOSER_IMAGE="$2"; shift 2 ;; + --curl-image) need_value "$1" "$#"; CURL_IMAGE="$2"; shift 2 ;; + --probe-url) need_value "$1" "$#"; PROBE_URL="$2"; shift 2 ;; + --download-url) need_value "$1" "$#"; DOWNLOAD_URL="$2"; shift 2 ;; + --download-bytes) need_value "$1" "$#"; DOWNLOAD_BYTES="$2"; shift 2 ;; + --rounds) need_value "$1" "$#"; ROUNDS="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA ] \ + || die "--confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA is required" +[ "${DORY_RELEASE_CLEAN_USER:-0}" = 1 ] || die "DORY_RELEASE_CLEAN_USER=1 is required" +[ "${DORY_RELEASE_BENCHMARK_USER:-0}" = 1 ] || die "DORY_RELEASE_BENCHMARK_USER=1 is required" +[ -n "$CANDIDATE_DIR" ] && [ -n "$VERSION" ] && [ -n "$SOURCE_COMMIT" ] \ + || die "candidate directory, version, build, and source commit are required" +case "$BUILD" in ''|*[!0-9]*) die "build must be a positive integer" ;; esac +[ "$BUILD" -gt 0 ] || die "build must be a positive integer" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +case "$ROUNDS" in ''|*[!0-9]*) die "rounds must be a positive integer" ;; esac +[ "$ROUNDS" -ge 9 ] && [ $((ROUNDS % 3)) -eq 0 ] \ + || die "rounds must be at least 9 and a multiple of 3" +case "$DOWNLOAD_BYTES" in ''|*[!0-9]*) die "download bytes must be a positive integer" ;; esac +[ "$DOWNLOAD_BYTES" -gt 0 ] || die "download bytes must be a positive integer" +for pair in \ + "alpine:$ALPINE_IMAGE" "iperf:$IPERF_IMAGE" "node:$NODE_IMAGE" \ + "postgres:$POSTGRES_IMAGE" "redis:$REDIS_IMAGE" "ruby:$RUBY_IMAGE" \ + "composer:$COMPOSER_IMAGE" "curl:$CURL_IMAGE"; do + printf '%s\n' "${pair#*:}" | grep -Eq '^.+@sha256:[0-9a-fA-F]{64}$' \ + || die "${pair%%:*} image must be immutable by sha256 digest" +done +for pair in "probe:$PROBE_URL" "download:$DOWNLOAD_URL"; do + case "${pair#*:}" in https://*) ;; *) die "${pair%%:*} URL must use HTTPS" ;; esac + case "${pair#*:}" in *[[:space:]@]*) die "${pair%%:*} URL must not contain whitespace, credentials, or userinfo" ;; esac +done + +case "$CANDIDATE_DIR" in /*) ;; *) CANDIDATE_DIR="$ROOT/$CANDIDATE_DIR" ;; esac +[ -d "$CANDIDATE_DIR" ] && [ ! -L "$CANDIDATE_DIR" ] \ + || die "candidate directory must be direct and available" +CANDIDATE_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' \ + "$CANDIDATE_DIR")" +CANDIDATE_DIR="$(cd "$CANDIDATE_DIR" && pwd -P)" +[ "$CANDIDATE_DIR" = "$CANDIDATE_LOGICAL" ] \ + || die "candidate directory cannot have an indirect ancestor" +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +case "$WORKROOT" in *[[:space:]]*) die "workroot must not contain whitespace" ;; esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +[ "$WORKROOT_NAME" = dory-release-performance ] \ + || die "workroot must use the dedicated dory-release-performance name" +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || die "workroot parent must be a direct directory" +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] || die "temporary authority root is unavailable" +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +case "$TEMP_AUTHORITY" in + /|"$HOME"|"$ROOT"|"$CANDIDATE_DIR") die "unsafe temporary authority root: $TEMP_AUTHORITY" ;; +esac +case "$WORKROOT" in "$TEMP_AUTHORITY"/*) ;; *) die "workroot must be inside runner temporary storage" ;; esac +case "$WORKROOT" in /|"$HOME"|"$ROOT"|"$CANDIDATE_DIR") die "unsafe workroot: $WORKROOT" ;; esac +case "$WORKROOT/" in "$CANDIDATE_DIR/"*) die "workroot cannot be inside the candidate" ;; esac +case "$CANDIDATE_DIR/" in "$WORKROOT/"*) die "workroot cannot contain the candidate" ;; esac +[ ! -e "$WORKROOT" ] || die "workroot already exists: $WORKROOT" + +[ "$(uname -s)" = Darwin ] && [ "$(uname -m)" = arm64 ] \ + || die "physical Apple-silicon macOS is required" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Hypervisor.framework is unavailable" +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested virtualization does not qualify" +case "$(sysctl -n hw.model 2>/dev/null || true)" in VirtualMac*) die "a physical Mac is required" ;; esac +for command in brew codesign ditto perl plutil python3 shasum unzip zip; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +UPDATE_ZIP="$CANDIDATE_DIR/Dory-$VERSION-app-update.zip" +SBOM="$CANDIDATE_DIR/Dory-$VERSION.cdx.json" +RELEASE_MANIFEST="$CANDIDATE_DIR/release-manifest.json" +[ -s "$UPDATE_ZIP" ] && [ -s "$SBOM" ] && [ -s "$RELEASE_MANIFEST" ] \ + || die "candidate update, SBOM, or release manifest is missing" +manifest_commit="$(python3 "$ROOT/scripts/validate-release-metadata.py" \ + "$CANDIDATE_DIR" "$VERSION" "$BUILD")" || die "candidate metadata is invalid" +[ "$manifest_commit" = "$SOURCE_COMMIT" ] || die "candidate source commit mismatch" + +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +PREF_DOMAIN="com.pythonxi.Dory" +ORBSTACK_STATE="$HOME/.orbstack" +COLIMA_STATE="$HOME/.colima" +LIMA_COLIMA_STATE="$HOME/.lima/colima" +for path in /Applications/Dory.app "$STATE" "$APP_SUPPORT" "$PLIST" "$ORBSTACK_STATE" \ + "$COLIMA_STATE" "$LIMA_COLIMA_STATE"; do + [ ! -e "$path" ] || die "existing state would be destroyed: $path" +done +if brew list --cask orbstack >/dev/null 2>&1; then + die "an existing OrbStack installation would be removed" +fi +if brew list --formula colima >/dev/null 2>&1; then + die "an existing Colima installation would be removed" +fi +for process in Dory doryd dory-hv dory-vmm OrbStack colima limactl; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "$process is already running; use the dedicated clean benchmark account" +done +! launchctl print "gui/$(id -u)/dev.dory.doryd" >/dev/null 2>&1 \ + || die "Dory's LaunchAgent is already loaded" +! defaults read "$PREF_DOMAIN" >/dev/null 2>&1 || die "existing Dory preferences would be changed" +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + [ ! -f "$profile" ] || ! grep -Fq '# >>> dory cli >>>' "$profile" \ + || die "existing Dory shell integration would be changed: $profile" +done + +mkdir -p "$WORKROOT/install" "$WORKROOT/package/raw" "$WORKROOT/logs" +chmod 700 "$WORKROOT" "$WORKROOT/install" "$WORKROOT/package" "$WORKROOT/package/raw" "$WORKROOT/logs" +APP="$WORKROOT/install/Dory.app" +ditto -x -k "$UPDATE_ZIP" "$WORKROOT/install" +[ -d "$APP" ] || die "candidate did not extract as Dory.app" +codesign --verify --strict --deep "$APP" || die "candidate signature is invalid" +xcrun stapler validate "$APP" >/dev/null || die "candidate has no notarization ticket" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" ] \ + || die "candidate marketing version mismatch" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" = "$BUILD" ] \ + || die "candidate build mismatch" + +CANDIDATE_DOCKER="$APP/Contents/Helpers/docker" +[ -x "$CANDIDATE_DOCKER" ] || die "candidate Docker CLI is missing" +export PATH="$APP/Contents/Helpers:$PATH" +[ "$(command -v docker)" = "$CANDIDATE_DOCKER" ] \ + || die "benchmark Docker CLI is not bound to the exact candidate" +PREVIOUS_CONTEXT="$("$CANDIDATE_DOCKER" context show 2>/dev/null || printf default)" +[ -n "$PREVIOUS_CONTEXT" ] || PREVIOUS_CONTEXT=default +! "$CANDIDATE_DOCKER" context inspect dory >/dev/null 2>&1 \ + || die "existing Docker context dory would be changed" + +ENVIRONMENT_ARMED=1 +cleanup_environment() { + local cli="$APP/Contents/Helpers/dory" + set +e + /usr/bin/pkill -u "$(id -u)" -x Dory >/dev/null 2>&1 + [ -x "$cli" ] && "$cli" engine sleep >/dev/null 2>&1 + [ -x "$cli" ] && "$cli" uninstall >/dev/null 2>&1 + launchctl bootout "gui/$(id -u)/dev.dory.doryd" >/dev/null 2>&1 + orb stop >/dev/null 2>&1 + orb delete-data -y >/dev/null 2>&1 + colima stop >/dev/null 2>&1 + colima delete -f >/dev/null 2>&1 + brew uninstall --cask --zap orbstack >/dev/null 2>&1 || brew uninstall --cask orbstack >/dev/null 2>&1 + brew uninstall colima >/dev/null 2>&1 + "$CANDIDATE_DOCKER" context use "$PREVIOUS_CONTEXT" >/dev/null 2>&1 + "$CANDIDATE_DOCKER" context rm -f dory >/dev/null 2>&1 + rm -rf "$STATE" "$APP_SUPPORT" "$ORBSTACK_STATE" "$COLIMA_STATE" "$LIMA_COLIMA_STATE" + rm -f "$PLIST" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 + /usr/bin/killall -u "$(id -un)" cfprefsd >/dev/null 2>&1 + set -e +} +on_exit() { + local rc=$? + trap - EXIT INT TERM + if [ "${ENVIRONMENT_ARMED:-0}" = 1 ]; then cleanup_environment; fi + exit "$rc" +} +trap on_exit EXIT INT TERM + +RAW="$WORKROOT/package/raw" +STARTED_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +HOST_BOOT="$(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')" + +CAMPAIGN_PINNED_CPUS="$CPUS" CAMPAIGN_PINNED_MEM_GB="$MEMORY_GB" \ +BENCH_ALPINE_IMAGE="$ALPINE_IMAGE" BENCH_IPERF_IMAGE="$IPERF_IMAGE" \ + "$ROOT/scripts/benchmark-campaign.sh" \ + --engines orbstack,colima,dory \ + --profiles pinned,default \ + --dory-app "$APP" \ + --metrics memory,cpu,network,fs \ + --runs 9 \ + --memory-count 3 \ + --work "$RAW/isolated" \ + --confirm-destructive-purge DELETE-SELECTED-ENGINE-DATA \ + > "$WORKROOT/logs/isolated.log" 2>&1 +awk -F '\t' 'NR > 1 && $3 != "OK" { bad=1 } END { exit bad }' \ + "$RAW/isolated/campaign-results.tsv" || die "isolated campaign contains a failed row" +status_count="$(find "$RAW/isolated" -name status.tsv -type f -print | wc -l | tr -d ' ')" +[ "$status_count" -eq 6 ] || die "isolated campaign did not retain all six metric status files" +if find "$RAW/isolated" -name status.tsv -type f -exec awk -F '\t' ' + NR > 1 && ($1 == "FAIL" || $1 == "SKIP") { bad=1 } END { exit bad }' {} \;; then :; else + die "isolated campaign contains a failed or skipped metric" +fi + +brew install --cask orbstack > "$WORKROOT/logs/orbstack-install.log" 2>&1 +brew install colima > "$WORKROOT/logs/colima-install.log" 2>&1 +orb config set cpu "$CPUS" >/dev/null +orb config set memory_mib "$((MEMORY_GB * 1024))" >/dev/null +orb config set machine.docker.cpu "$CPUS" >/dev/null 2>&1 || true +orb config set machine.docker.memory_mib "$((MEMORY_GB * 1024))" >/dev/null 2>&1 || true +orb start > "$WORKROOT/logs/orbstack-start.log" 2>&1 +colima start --cpu "$CPUS" --memory "$MEMORY_GB" --disk 30 \ + > "$WORKROOT/logs/colima-start.log" 2>&1 +defaults write "$PREF_DOMAIN" dory.engineCPUCount -int "$CPUS" +defaults write "$PREF_DOMAIN" dory.engineMemoryMB -int "$((MEMORY_GB * 1024))" +defaults write "$PREF_DOMAIN" dory.enginePreference -string dory +open "$APP" + +wait_socket() { + local socket="$1" label="$2" waited=0 + while [ "$waited" -lt 180 ]; do + if [ -S "$socket" ] && docker -H "unix://$socket" version >/dev/null 2>&1; then return 0; fi + sleep 2 + waited=$((waited + 2)) + done + die "$label did not become ready" +} +DORY_SOCKET="$HOME/.dory/dory.sock" +ORB_SOCKET="$HOME/.orbstack/run/docker.sock" +COLIMA_SOCKET="$HOME/.colima/default/docker.sock" +wait_socket "$DORY_SOCKET" Dory +wait_socket "$ORB_SOCKET" OrbStack +wait_socket "$COLIMA_SOCKET" Colima + +for socket in "$DORY_SOCKET" "$ORB_SOCKET" "$COLIMA_SOCKET"; do + for image in "$ALPINE_IMAGE" "$IPERF_IMAGE" "$NODE_IMAGE" "$POSTGRES_IMAGE" \ + "$REDIS_IMAGE" "$RUBY_IMAGE" "$COMPOSER_IMAGE" "$CURL_IMAGE"; do + docker -H "unix://$socket" pull "$image" >/dev/null + docker -H "unix://$socket" image inspect "$image" --format '{{json .RepoDigests}}' \ + | grep -Fq '"'"${image##*@}"'"' || die "an engine pulled the wrong digest for $image" + done +done + +DORY_BENCH_APP="$APP" DORY_PROCESS_PATTERN='Dory[.]app/Contents/(MacOS/Dory|Helpers/(doryd|dory-hv|dory-vmm|gvproxy))' \ +BENCH_WORK="$RAW/user-workflows" BENCH_NODE_IMAGE="$NODE_IMAGE" \ +BENCH_ALPINE_IMAGE="$ALPINE_IMAGE" BENCH_PG_IMAGE="$POSTGRES_IMAGE" BENCH_REDIS_IMAGE="$REDIS_IMAGE" \ + "$ROOT/scripts/benchmark-user-workflows.sh" --engines dory,orbstack,colima --rounds "$ROUNDS" \ + > "$WORKROOT/logs/user-workflows.log" 2>&1 +grep -qx $'exit_code\t0' "$RAW/user-workflows/run-status.tsv" \ + || die "user workflow campaign is not PASS" + +BENCH_DEV_WORK="$RAW/developer-workflows" \ + "$ROOT/scripts/benchmark-developer-workflows.sh" \ + --engines dory,orbstack,colima --rounds "$ROUNDS" \ + --ruby-image "$RUBY_IMAGE" --node-image "$NODE_IMAGE" --composer-image "$COMPOSER_IMAGE" \ + > "$WORKROOT/logs/developer-workflows.log" 2>&1 +grep -qx $'status\tPASS' "$RAW/developer-workflows/run-status.tsv" \ + || die "developer workflow campaign is not PASS" + +"$ROOT/scripts/benchmark-registry-npm.sh" \ + --engines dory,orbstack,colima --rounds "$ROUNDS" --image "$NODE_IMAGE" \ + --fixture "$ROOT/website" --work "$RAW/registry-npm" \ + > "$WORKROOT/logs/registry-npm.log" 2>&1 +awk -F '\t' 'NR > 1 { rows++; if ($6 != "ok") bad=1 } END { exit bad || rows == 0 }' \ + "$RAW/registry-npm/samples.tsv" || die "registry npm campaign is not PASS" +[ "$(awk -F '\t' 'NR > 1 { rows++ } END { print rows + 0 }' \ + "$RAW/registry-npm/run-status.tsv")" -eq 3 ] \ + || die "registry npm campaign does not contain all three engine summaries" + +"$ROOT/scripts/benchmark-external-network.sh" \ + --engines dory,orbstack,colima --rounds "$ROUNDS" --image "$CURL_IMAGE" \ + --probe-url "$PROBE_URL" --download-url "$DOWNLOAD_URL" --download-bytes "$DOWNLOAD_BYTES" \ + --work "$RAW/external-network" \ + > "$WORKROOT/logs/external-network.log" 2>&1 +grep -qx $'status\tpass' "$RAW/external-network/run-status.tsv" \ + || die "external network campaign is not PASS" + +cleanup_environment +ENVIRONMENT_ARMED=0 +for path in "$STATE" "$APP_SUPPORT" "$PLIST" "$ORBSTACK_STATE" "$COLIMA_STATE" \ + "$LIMA_COLIMA_STATE"; do + [ ! -e "$path" ] || die "benchmark cleanup left state behind: $path" +done +for process in Dory doryd dory-hv dory-vmm OrbStack colima limactl; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 || die "benchmark cleanup left $process running" +done +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + [ ! -f "$profile" ] || ! grep -Fq '# >>> dory cli >>>' "$profile" \ + || die "benchmark cleanup left Dory shell integration in $profile" +done +printf 'status=PASS\nprior_docker_context=%s\nengine_state_removed=PASS\nprocesses_stopped=PASS\nprofiles_clean=PASS\n' \ + "$PREVIOUS_CONTEXT" > "$RAW/cleanup.txt" + +FINISHED_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +HOST_BOOT_AFTER="$(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')" +[ "$HOST_BOOT_AFTER" = "$HOST_BOOT" ] || die "host rebooted during the performance campaign" +RELEASE_MANIFEST_SHA="$(shasum -a 256 "$RELEASE_MANIFEST" | awk '{print $1}')" +UPDATE_SHA="$(shasum -a 256 "$UPDATE_ZIP" | awk '{print $1}')" +SBOM_SHA="$(shasum -a 256 "$SBOM" | awk '{print $1}')" +APP_TREE_SHA="$(python3 - "$SBOM" <<'PY' +import json, sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +values = [row["value"] for row in payload["metadata"]["component"]["properties"] + if row["name"] == "dev.dory.app.tree.sha256"] +if len(values) != 1: + raise SystemExit("SBOM must contain exactly one app-tree digest") +print(values[0]) +PY +)" + +python3 - "$WORKROOT/package/manifest.json" "$VERSION" "$BUILD" "$SOURCE_COMMIT" \ + "$RELEASE_MANIFEST_SHA" "$UPDATE_SHA" "$SBOM_SHA" "$APP_TREE_SHA" \ + "$STARTED_UTC" "$FINISHED_UTC" "$HOST_BOOT" "$ROUNDS" "$CPUS" "$MEMORY_GB" <<'PY' +import json, os, platform, subprocess, sys +(path, version, build, commit, release_manifest_sha, update_sha, sbom_sha, tree_sha, + started, finished, boot, rounds, cpus, memory_gb) = sys.argv[1:] +def output(*command): + return subprocess.check_output(command, text=True).strip() +manifest = { + "schemaVersion": 1, + "kind": "dev.dory.performance-qualification", + "status": "PASS", + "releaseQualifying": True, + "candidate": { + "version": version, "build": build, "sourceCommit": commit, + "releaseManifestSHA256": release_manifest_sha, + "appUpdateSHA256": update_sha, "sbomSHA256": sbom_sha, + "appTreeSHA256": tree_sha, + }, + "host": { + "model": output("sysctl", "-n", "hw.model"), + "architecture": output("uname", "-m"), + "memoryBytes": int(output("sysctl", "-n", "hw.memsize")), + "macOSVersion": output("sw_vers", "-productVersion"), + "macOSBuild": output("sw_vers", "-buildVersion"), + "bootEpoch": int(boot), + "power": output("pmset", "-g", "batt").splitlines()[0], + }, + "configuration": { + "engines": ["dory", "orbstack", "colima"], + "interleavedRounds": int(rounds), "matchedVCPUs": int(cpus), + "matchedMemoryGiB": int(memory_gb), + }, + "campaigns": [ + "isolated", "user-workflows", "developer-workflows", "registry-npm", "external-network" + ], + "startedUTC": started, + "finishedUTC": finished, + "cleanup": "PASS", + "methodology": "PERFORMANCE_QUALIFICATION.md", +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") +PY + +cp "$ROOT/PERFORMANCE_QUALIFICATION.md" "$WORKROOT/package/" +cp "$WORKROOT/logs/"*.log "$RAW/" +(cd "$WORKROOT/package" && find . -type f ! -name sha256.txt -print | LC_ALL=C sort \ + | while IFS= read -r path; do shasum -a 256 "${path#./}"; done > sha256.txt) +(cd "$WORKROOT/package" && shasum -a 256 -c sha256.txt) +PACKAGE_NAME="Dory-$VERSION-performance-evidence" +PACKAGE_ROOT="$WORKROOT/$PACKAGE_NAME" +mv "$WORKROOT/package" "$PACKAGE_ROOT" +OUTPUT="$WORKROOT/$PACKAGE_NAME.zip" +(cd "$WORKROOT" && zip -X -q -r "$OUTPUT" "$PACKAGE_NAME") +unzip -t "$OUTPUT" >/dev/null +OUTPUT_SHA="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')" +printf 'performance_evidence=%s\nperformance_evidence_sha256=%s\n' "$OUTPUT" "$OUTPUT_SHA" +echo "release performance qualification PASS: $OUTPUT" From b18b1c6fc2e0cf40079c6ce1ee595ae6b6406481 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:29:56 +0000 Subject: [PATCH 169/338] test(network): track source-preserving LAN gate --- .../test-source-preserving-lan-gate.py | 143 +++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/source-preserving-lan-gate.sh | 594 ++++++++++++++++++ 4 files changed, 739 insertions(+) create mode 100755 .github/scripts/test-source-preserving-lan-gate.py create mode 100755 scripts/source-preserving-lan-gate.sh diff --git a/.github/scripts/test-source-preserving-lan-gate.py b/.github/scripts/test-source-preserving-lan-gate.py new file mode 100755 index 00000000..b36462c1 --- /dev/null +++ b/.github/scripts/test-source-preserving-lan-gate.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Non-mutating authority tests for physical source-preserving networking.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "source-preserving-lan-gate.sh" + + +class SourcePreservingLANGateTests(unittest.TestCase): + def invoke( + self, + app: pathlib.Path, + runtime: pathlib.Path, + docker: pathlib.Path, + workroot: pathlib.Path, + temporary_root: pathlib.Path, + *, + address: str = "192.0.2.10", + confirmation: str = "PHYSICAL-SOURCE-PRESERVATION", + ssh_options: tuple[str, ...] = ("StrictHostKeyChecking=yes",), + ) -> subprocess.CompletedProcess[str]: + command = [ + str(GATE), + "--app", str(app), + "--runtime", str(runtime), + "--docker", str(docker), + "--host-address", address, + "--peer-ssh", "release-peer@example.invalid", + "--mode", "lan", + "--server-image", "example.invalid/server@sha256:" + "a" * 64, + "--workroot", str(workroot), + ] + for option in ssh_options: + command.extend(("--ssh-option", option)) + command.extend(("--confirm", confirmation)) + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + command, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + @staticmethod + def fixture(temporary: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]: + app = temporary / "Dory.app" + helpers = app / "Contents" / "Helpers" + helpers.mkdir(parents=True) + docker = helpers / "docker" + docker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + docker.chmod(0o755) + runtime = temporary / "dory-engine-runtime" + runtime.mkdir() + launcher = runtime / "dory-engine" + launcher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + launcher.chmod(0o755) + return app, runtime, docker + + def test_source_is_shell_valid_and_closes_privileged_helper_ownership(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "StrictHostKeyChecking=yes", + '"$DOCKER" = "$APP/Contents/Helpers/docker"', + "source=Notarized Developer ID", + "a pre-existing Dory network helper would be replaced", + "--unregister-network-helper", + "Dory network helper survived final cleanup", + "network_helper_unregistered=PASS", + "host_boot_session_unchanged=PASS", + ): + self.assertIn(contract, source) + + def test_confirmation_host_and_ssh_authorities_fail_before_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-source-lan-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, runtime, docker = self.fixture(temporary) + workroot = temporary / "dory-source-lan-lan" + confirmation = self.invoke( + app, runtime, docker, workroot, temporary, confirmation="wrong" + ) + loopback = self.invoke( + app, runtime, docker, workroot, temporary, address="127.0.0.1" + ) + missing_host_key = self.invoke( + app, runtime, docker, workroot, temporary, ssh_options=() + ) + disabled_host_key = self.invoke( + app, + runtime, + docker, + workroot, + temporary, + ssh_options=("StrictHostKeyChecking=no",), + ) + self.assertIn("confirmation token is required", confirmation.stdout) + self.assertIn("valid unicast IPv4", loopback.stdout) + self.assertIn("exactly one --ssh-option", missing_host_key.stdout) + self.assertIn("StrictHostKeyChecking must be yes", disabled_host_key.stdout) + + def test_indirect_app_and_non_candidate_docker_are_rejected(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-source-lan-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, runtime, docker = self.fixture(temporary) + app_link = temporary / "linked-Dory.app" + app_link.symlink_to(app, target_is_directory=True) + indirect = self.invoke( + app_link, + runtime, + docker, + temporary / "dory-source-lan-lan", + temporary, + ) + foreign = temporary / "foreign-docker" + foreign.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + foreign.chmod(0o755) + wrong_docker = self.invoke( + app, + runtime, + foreign, + temporary / "dory-source-lan-lan", + temporary, + ) + self.assertIn("candidate app must be a direct Dory.app directory", indirect.stdout) + self.assertIn("Docker CLI is not the exact candidate helper", wrong_docker.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e35a849a..4ec95732 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -338,6 +338,7 @@ jobs: python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-release-performance-gate.py + python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9a6c76c..fab96144 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,7 @@ jobs: python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-release-performance-gate.py + python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/source-preserving-lan-gate.sh b/scripts/source-preserving-lan-gate.sh new file mode 100755 index 00000000..bacd2b6d --- /dev/null +++ b/scripts/source-preserving-lan-gate.sh @@ -0,0 +1,594 @@ +#!/bin/bash +# Exact-artifact physical-peer certification for Dory's source-preserving LAN publication path. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +APP="" +RUNTIME="" +DOCKER="" +HOST_ADDRESS="" +PEER_SSH="" +MODE="" +SERVER_IMAGE="" +WORKROOT="" +CONFIRM="" +PRIVILEGED_PORT="${DORY_SOURCE_LAN_PRIVILEGED_PORT:-80}" +SSH_OPTIONS=() +STRICT_HOST_KEY_CHECKS=0 + +usage() { + cat <<'EOF' +Usage: scripts/source-preserving-lan-gate.sh [required options] + + --app Dory.app Exact signed/notarized candidate app + --runtime DIR Exact extracted dory-engine release directory + --docker PATH Exact Docker CLI + --host-address IPv4 This Mac's physical-LAN or Tailscale IPv4 address + --peer-ssh USER@HOST Real remote peer reachable with noninteractive SSH + --mode lan|tailscale Physical path being certified + --server-image REF@sha256:X Digest-pinned image containing python3 + --privileged-port PORT Free interface-specific TCP port below 1024 (default: 80) + --workroot DIR New evidence and isolated-engine root + --ssh-option VALUE Repeatable ssh option, such as StrictHostKeyChecking=yes + --confirm PHYSICAL-SOURCE-PRESERVATION + +The exact Dory app must have its embedded LaunchDaemon approved in System Settings. The runner +must allow `sudo -n` for launchctl/pfctl read/restart checks. The peer must have python3. +EOF +} + +die() { echo "source-preserving LAN gate: $*" >&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --app) need_value "$1" "$#"; APP="$2"; shift 2 ;; + --runtime) need_value "$1" "$#"; RUNTIME="$2"; shift 2 ;; + --docker) need_value "$1" "$#"; DOCKER="$2"; shift 2 ;; + --host-address) need_value "$1" "$#"; HOST_ADDRESS="$2"; shift 2 ;; + --peer-ssh) need_value "$1" "$#"; PEER_SSH="$2"; shift 2 ;; + --mode) need_value "$1" "$#"; MODE="$2"; shift 2 ;; + --server-image) need_value "$1" "$#"; SERVER_IMAGE="$2"; shift 2 ;; + --privileged-port) need_value "$1" "$#"; PRIVILEGED_PORT="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --ssh-option) + need_value "$1" "$#" + case "$2" in + StrictHostKeyChecking=yes) STRICT_HOST_KEY_CHECKS=$((STRICT_HOST_KEY_CHECKS + 1)) ;; + StrictHostKeyChecking=*) die "StrictHostKeyChecking must be yes" ;; + *) die "unsupported --ssh-option: $2" ;; + esac + SSH_OPTIONS+=("-o" "$2") + shift 2 + ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ "$CONFIRM" = PHYSICAL-SOURCE-PRESERVATION ] || die "confirmation token is required" +for pair in app:"$APP" runtime:"$RUNTIME" docker:"$DOCKER" host-address:"$HOST_ADDRESS" \ + peer-ssh:"$PEER_SSH" mode:"$MODE" server-image:"$SERVER_IMAGE" workroot:"$WORKROOT"; do + [ -n "${pair#*:}" ] || die "--${pair%%:*} is required" +done +case "$MODE" in lan|tailscale) ;; *) die "--mode must be lan or tailscale" ;; esac +case "$PRIVILEGED_PORT" in ''|*[!0-9]*) die "--privileged-port must be an integer" ;; esac +[ "$PRIVILEGED_PORT" -ge 1 ] && [ "$PRIVILEGED_PORT" -lt 1024 ] \ + || die "--privileged-port must be between 1 and 1023" +python3 - "$HOST_ADDRESS" <<'PY' || die "--host-address must be a valid unicast IPv4 address" +import ipaddress, sys +value = ipaddress.IPv4Address(sys.argv[1]) +if value.is_unspecified or value.is_multicast or value.is_loopback: + raise SystemExit("host address is not unicast") +PY +printf '%s\n' "$SERVER_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || die "--server-image must contain one exact lowercase sha256 digest" +case "$SERVER_IMAGE" in *[[:space:]]*) die "--server-image contains whitespace" ;; esac +printf '%s\n' "$PEER_SSH" | grep -Eq '^[A-Za-z0-9_.+-]+@[A-Za-z0-9_.:-]+$' \ + || die "--peer-ssh must be one safe USER@HOST authority" +[ "$STRICT_HOST_KEY_CHECKS" -eq 1 ] \ + || die "exactly one --ssh-option StrictHostKeyChecking=yes is required" +[ "$(uname -s)" = Darwin ] || die "physical certification requires macOS" +[ "$(uname -m)" = arm64 ] || die "physical certification requires Apple Silicon" +[ -d "$APP" ] && [ ! -L "$APP" ] && [ "$(basename "$APP")" = Dory.app ] \ + || die "candidate app must be a direct Dory.app directory: $APP" +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP="$(cd "$APP" && pwd -P)" +[ "$APP" = "$APP_LOGICAL" ] || die "candidate app cannot have an indirect ancestor" +[ -d "$RUNTIME" ] && [ ! -L "$RUNTIME" ] && [ -x "$RUNTIME/dory-engine" ] \ + || die "runtime must be a direct directory with a launcher" +RUNTIME_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$RUNTIME")" +RUNTIME="$(cd "$RUNTIME" && pwd -P)" +[ "$RUNTIME" = "$RUNTIME_LOGICAL" ] || die "runtime cannot have an indirect ancestor" +[ -x "$DOCKER" ] && [ ! -L "$DOCKER" ] || die "Docker CLI must be a direct executable" +DOCKER="$(cd "$(dirname "$DOCKER")" && pwd -P)/$(basename "$DOCKER")" +[ "$DOCKER" = "$APP/Contents/Helpers/docker" ] \ + || die "Docker CLI is not the exact candidate helper" +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +[ "$WORKROOT_NAME" = "dory-source-lan-$MODE" ] \ + || die "workroot must use the dedicated dory-source-lan-$MODE name" +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || die "workroot parent must be a direct directory" +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] || die "temporary authority root is unavailable" +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +case "$TEMP_AUTHORITY" in /|"$HOME"|"$ROOT"|"$APP"|"$RUNTIME") die "unsafe temporary authority root" ;; esac +case "$WORKROOT" in "$TEMP_AUTHORITY"/*) ;; *) die "workroot must be inside runner temporary storage" ;; esac +case "$WORKROOT/" in "$APP/"*|"$RUNTIME/"*) die "workroot cannot be inside candidate artifacts" ;; esac +case "$APP/" in "$WORKROOT/"*) die "workroot cannot contain the app" ;; esac +case "$RUNTIME/" in "$WORKROOT/"*) die "workroot cannot contain the runtime" ;; esac +[ ! -e "$WORKROOT" ] || die "workroot already exists: $WORKROOT" +for command in cmp codesign curl lsof netstat python3 shasum spctl ssh sudo xcrun; do + command -v "$command" >/dev/null || die "missing command: $command" +done +sudo -n true >/dev/null 2>&1 || die "runner does not provide required noninteractive sudo" +if sudo -n /bin/launchctl print system/dev.dory.network-helper >/dev/null 2>&1; then + die "a pre-existing Dory network helper would be replaced" +fi +sudo -n test ! -e /var/run/dev.dory/pf-enable-token \ + || die "a pre-existing Dory PF authority marker exists" +sudo -n test ! -e /var/run/dev.dory/ipv4-forwarding-owner \ + || die "a pre-existing Dory forwarding authority marker exists" + +umask 077 +mkdir -p "$WORKROOT/evidence" "$WORKROOT/runtime-home" +ENGINE_HOME="$WORKROOT/runtime-home" +SOCKET="$ENGINE_HOME/.dory/engine.sock" +DATA_DRIVE="$ENGINE_HOME/Library/Application Support/Dory/Dory.dorydrive" +OWNER="dory-source-lan-$MODE-$$" +SERVER_NAME="$OWNER-server" +LOOPBACK_NAME="$OWNER-loopback" +PRIVILEGED_NAME="$OWNER-privileged" +PRESSURE_NAME="$OWNER-pressure" +PRESSURE_NETWORK="$OWNER-pressure-net" +PRESSURE_MIB=960 +PRESSURE_ROUNDS=20 +ENGINE_STARTED=0 +HELPER_REGISTERED=0 +HOST_BOOT_EPOCH_BEFORE="$(/usr/sbin/sysctl -n kern.boottime \ + | sed -n 's/.*sec = \([0-9][0-9]*\).*/\1/p')" +printf '%s\n' "$HOST_BOOT_EPOCH_BEFORE" | grep -Eq '^[0-9]+$' \ + || die "could not capture the host boot epoch" +PANIC_MARKER="$WORKROOT/evidence/host-panic-window-start" +touch "$PANIC_MARKER" +if sudo -n lsof -nP -iTCP@"$HOST_ADDRESS:$PRIVILEGED_PORT" -sTCP:LISTEN \ + > "$WORKROOT/evidence/privileged-port-owner-before.txt" 2>&1; then + die "interface-specific privileged port is already owned: $HOST_ADDRESS:$PRIVILEGED_PORT" +fi + +cleanup() { + set +e + if [ -S "$SOCKET" ]; then + DOCKER_HOST="unix://$SOCKET" "$DOCKER" rm -f \ + "$SERVER_NAME" "$LOOPBACK_NAME" "$PRIVILEGED_NAME" "$PRESSURE_NAME" >/dev/null 2>&1 || true + DOCKER_HOST="unix://$SOCKET" "$DOCKER" network rm "$PRESSURE_NETWORK" \ + >/dev/null 2>&1 || true + fi + if [ "$ENGINE_STARTED" -eq 1 ]; then + HOME="$ENGINE_HOME" "$RUNTIME/dory-engine" stop >/dev/null 2>&1 || true + fi + if [ "$HELPER_REGISTERED" -eq 1 ]; then + "$APP/Contents/MacOS/Dory" --unregister-network-helper >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +codesign --verify --strict --deep "$APP" +xcrun stapler validate "$APP" >/dev/null +app_assessment="$(spctl --assess --type execute --verbose=4 "$APP" 2>&1)" \ + || die "Gatekeeper rejected the candidate app" +grep -F 'source=Notarized Developer ID' <<< "$app_assessment" >/dev/null \ + || die "candidate app is not accepted as Notarized Developer ID" +for helper in dory-hv dory-network-helper gvproxy; do + codesign --verify --strict "$APP/Contents/Helpers/$helper" +done +codesign -dv --verbose=4 "$APP" 2> "$WORKROOT/evidence/app-signature.txt" +grep -q 'TeamIdentifier=864H636QW4' "$WORKROOT/evidence/app-signature.txt" \ + || die "candidate app has the wrong signing team" +[ -s "$APP/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" ] \ + || die "candidate app omits the privileged network LaunchDaemon" +cmp "$RUNTIME/bin/dory-hv" "$APP/Contents/Helpers/dory-hv" \ + || die "runtime dory-hv differs from the candidate app" +cmp "$RUNTIME/bin/gvproxy" "$APP/Contents/Helpers/gvproxy" \ + || die "runtime gvproxy differs from the candidate app" + +# shellcheck source=gvproxy-payload.sh +source "$ROOT/scripts/gvproxy-payload.sh" +GVPROXY_PROVENANCE="$APP/Contents/Resources/gvproxy-provenance.txt" +PAYLOAD_INVENTORY="$APP/Contents/Resources/dory-payload-sha256.txt" +dory_gvproxy_validate_overrides +dory_verify_signed_gvproxy_payload \ + "$APP/Contents/Helpers/gvproxy" "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY" \ + || die "candidate gvproxy is not bound to its reproducible build and signed payload inventory" +GVPROXY_SHA="$(dory_gvproxy_file_sha256 "$APP/Contents/Helpers/gvproxy")" +GVPROXY_BUILD_SHA="$(dory_gvproxy_expected_sha256)" + +HELPER_REGISTERED=1 +"$APP/Contents/MacOS/Dory" --register-network-helper \ + > "$WORKROOT/evidence/network-helper-registration.txt" 2>&1 \ + || die "the exact app's privileged helper is not enabled/approved" +grep -qx 'network-helper=enabled' "$WORKROOT/evidence/network-helper-registration.txt" \ + || die "the exact app did not confirm its network helper" +sudo -n /sbin/pfctl -s References > "$WORKROOT/evidence/pf-references-before.txt" 2>&1 \ + || die "could not capture the baseline PF enable references" +/usr/sbin/sysctl -n net.inet.ip.forwarding \ + > "$WORKROOT/evidence/ipv4-forwarding-before.txt" + +scripts/gvproxy-qemu-switch-gate.py "$APP/Contents/Helpers/gvproxy" \ + --expected-sha256 "$GVPROXY_SHA" \ + --provenance "$GVPROXY_PROVENANCE" \ + --evidence "$WORKROOT/evidence/gvproxy-switch.txt" \ + > "$WORKROOT/evidence/gvproxy-switch.log" 2>&1 + +python3 - "$WORKROOT/server.py" <<'PY' +import pathlib, sys +pathlib.Path(sys.argv[1]).write_text(r''' +import socket, threading + +def tcp(): + server = socket.socket() + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("0.0.0.0", 8080)); server.listen(16) + while True: + connection, peer = server.accept() + token = connection.recv(1024).decode().strip() + print(f"tcp={peer[0]} token={token}", flush=True) + connection.sendall((token + "\n").encode()); connection.close() + +def udp(): + server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + server.bind(("0.0.0.0", 8080)) + while True: + payload, peer = server.recvfrom(2048) + token = payload.decode().strip() + print(f"udp={peer[0]} token={token}", flush=True) + server.sendto((token + "\n").encode(), peer) + +threading.Thread(target=tcp, daemon=True).start() +udp() +''', encoding="utf-8") +PY + +python3 - "$WORKROOT/peer.py" <<'PY' +import pathlib, sys +pathlib.Path(sys.argv[1]).write_text(r''' +import socket, sys +host, port, loop_port, privileged_port, token, operation = sys.argv[1:] +port = int(port); loop_port = int(loop_port); privileged_port = int(privileged_port) + +def tcp_open(target_port, payload=None): + connection = socket.create_connection((host, target_port), timeout=4) + source = connection.getsockname()[0] + if payload is not None: + connection.sendall((payload + "\n").encode()) + if connection.recv(1024).decode().strip() != payload: + raise SystemExit("TCP echo payload mismatch") + connection.close() + return source + +if operation == "run": + print("tcp_source=" + tcp_open(port, token)) + print("privileged_tcp_source=" + tcp_open(privileged_port, token + "-privileged")) + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); udp.settimeout(4) + udp.connect((host, port)); source = udp.getsockname()[0] + udp.send((token + "\n").encode()) + if udp.recv(1024).decode().strip() != token: + raise SystemExit("UDP echo payload mismatch") + udp.close(); print("udp_source=" + source) + try: + tcp_open(loop_port) + except OSError: + print("loopback_isolated=PASS") + else: + raise SystemExit("explicit loopback publication was reachable remotely") +elif operation == "closed": + try: + tcp_open(port) + except OSError: + print("tcp_unpublished=PASS") + else: + raise SystemExit("unpublished TCP port remains reachable") + try: + tcp_open(privileged_port) + except OSError: + print("privileged_tcp_unpublished=PASS") + else: + raise SystemExit("unpublished interface-specific privileged TCP port remains reachable") + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); udp.settimeout(2) + try: + udp.sendto(b"closed", (host, port)); udp.recvfrom(1024) + except OSError: + print("udp_unpublished=PASS") + else: + raise SystemExit("unpublished UDP port remains reachable") +''', encoding="utf-8") +PY + +pick_port() { + python3 - <<'PY' +import socket +sock = socket.socket(); sock.bind(("0.0.0.0", 0)); print(sock.getsockname()[1]); sock.close() +PY +} +PORT="$(pick_port)" +LOOPBACK_PORT="$(pick_port)" +[ "$PORT" != "$LOOPBACK_PORT" ] || LOOPBACK_PORT="$(pick_port)" + +start_engine() { + HOME="$ENGINE_HOME" "$RUNTIME/dory-engine" start \ + --lan-visible --data-drive "$DATA_DRIVE" \ + > "$WORKROOT/evidence/runtime-start-$1.log" 2>&1 + ENGINE_STARTED=1 + [ -S "$SOCKET" ] || die "engine socket was not created" + DOCKER_HOST="unix://$SOCKET" "$DOCKER" version >/dev/null +} + +create_fixtures() { + DOCKER_HOST="unix://$SOCKET" "$DOCKER" network create \ + --label "dev.dory.source-lan=$OWNER" "$PRESSURE_NETWORK" >/dev/null + DOCKER_HOST="unix://$SOCKET" "$DOCKER" pull "$SERVER_IMAGE" \ + > "$WORKROOT/evidence/image-pull-$1.txt" + DOCKER_HOST="unix://$SOCKET" "$DOCKER" run -d --name "$SERVER_NAME" \ + --label "dev.dory.source-lan=$OWNER" \ + --network "$PRESSURE_NETWORK" \ + -p "$PORT:8080/tcp" -p "$PORT:8080/udp" \ + -v "$WORKROOT/server.py:/server.py:ro" \ + "$SERVER_IMAGE" python3 /server.py >/dev/null + DOCKER_HOST="unix://$SOCKET" "$DOCKER" run -d --name "$LOOPBACK_NAME" \ + --label "dev.dory.source-lan=$OWNER" \ + --network "$PRESSURE_NETWORK" \ + -p "127.0.0.1:$LOOPBACK_PORT:8080/tcp" \ + -v "$WORKROOT/server.py:/server.py:ro" \ + "$SERVER_IMAGE" python3 /server.py >/dev/null + DOCKER_HOST="unix://$SOCKET" "$DOCKER" run -d --name "$PRIVILEGED_NAME" \ + --label "dev.dory.source-lan=$OWNER" \ + --network "$PRESSURE_NETWORK" \ + -p "$HOST_ADDRESS:$PRIVILEGED_PORT:8080/tcp" \ + -v "$WORKROOT/server.py:/server.py:ro" \ + "$SERVER_IMAGE" python3 /server.py >/dev/null + sleep 4 +} + +bounded_capture() { + local limit="$1" stdout="$2" stderr="$3" pid started rc + shift 3 + "$@" > "$stdout" 2> "$stderr" & + pid=$! + started=$SECONDS + while kill -0 "$pid" 2>/dev/null; do + if [ $((SECONDS - started)) -ge "$limit" ]; then + kill -TERM "$pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$pid"; then rc=0; else rc=$?; fi + return "$rc" +} + +run_memory_pressure_rounds() { + local expected_source="$1" round observed state + DOCKER_HOST="unix://$SOCKET" "$DOCKER" run -d --name "$PRESSURE_NAME" \ + --label "dev.dory.source-lan=$OWNER" --network "$PRESSURE_NETWORK" \ + --memory 1280m "$SERVER_IMAGE" python3 -c ' +import sys, time +blocks = [] +for _ in range(60): + block = bytearray(16 * 1024 * 1024) + for offset in range(0, len(block), 4096): + block[offset] = 1 + blocks.append(block) +print("pressure_ready_mib=960", flush=True) +time.sleep(300) +' >/dev/null + for _ in $(seq 1 100); do + DOCKER_HOST="unix://$SOCKET" "$DOCKER" logs "$PRESSURE_NAME" 2>/dev/null \ + | grep -qx "pressure_ready_mib=$PRESSURE_MIB" && break + sleep 0.2 + done + DOCKER_HOST="unix://$SOCKET" "$DOCKER" logs "$PRESSURE_NAME" \ + > "$WORKROOT/evidence/memory-pressure.log" 2>&1 + grep -qx "pressure_ready_mib=$PRESSURE_MIB" "$WORKROOT/evidence/memory-pressure.log" \ + || die "guest memory-pressure workload did not become ready" + + round=1 + while [ "$round" -le "$PRESSURE_ROUNDS" ]; do + observed="$(run_peer_round "memory-pressure-$round")" + [ "$observed" = "$expected_source" ] \ + || die "source identity changed under guest memory pressure in round $round" + bounded_capture 5 "$WORKROOT/evidence/docker-version-pressure-$round.out" \ + "$WORKROOT/evidence/docker-version-pressure-$round.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER" version \ + || die "Docker API stalled under guest memory pressure in round $round" + bounded_capture 5 "$WORKROOT/evidence/docker-dns-pressure-$round.out" \ + "$WORKROOT/evidence/docker-dns-pressure-$round.err" \ + env DOCKER_HOST="unix://$SOCKET" "$DOCKER" exec "$SERVER_NAME" \ + python3 -c 'import socket,sys; print(socket.getaddrinfo(sys.argv[1], 0, socket.AF_INET)[0][4][0])' \ + "$PRESSURE_NAME" \ + || die "Docker DNS stalled under guest memory pressure in round $round" + bounded_capture 5 "$WORKROOT/evidence/configd-pressure-$round.out" \ + "$WORKROOT/evidence/configd-pressure-$round.err" /usr/sbin/scutil --dns \ + || die "macOS configd query stalled under guest memory pressure in round $round" + sleep 3 + round=$((round + 1)) + done + state="$(DOCKER_HOST="unix://$SOCKET" "$DOCKER" inspect \ + -f '{{.State.Running}} {{.State.OOMKilled}}' "$PRESSURE_NAME")" + [ "$state" = "true false" ] || die "memory-pressure workload was killed or stopped: $state" + DOCKER_HOST="unix://$SOCKET" "$DOCKER" stats --no-stream \ + "$PRESSURE_NAME" "$SERVER_NAME" > "$WORKROOT/evidence/memory-pressure-stats.txt" + DOCKER_HOST="unix://$SOCKET" "$DOCKER" rm -f "$PRESSURE_NAME" >/dev/null +} + +run_peer_round() { + local round="$1" token="$OWNER-$1" output tcp_source udp_source privileged_source logs privileged_logs + output="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_OPTIONS[@]}" "$PEER_SSH" \ + python3 - "$HOST_ADDRESS" "$PORT" "$LOOPBACK_PORT" "$PRIVILEGED_PORT" "$token" run \ + < "$WORKROOT/peer.py")" \ + || die "physical peer round $round failed" + printf '%s\n' "$output" > "$WORKROOT/evidence/peer-$round.txt" + grep -qx 'loopback_isolated=PASS' "$WORKROOT/evidence/peer-$round.txt" \ + || die "round $round did not prove loopback isolation" + tcp_source="$(sed -n 's/^tcp_source=//p' "$WORKROOT/evidence/peer-$round.txt")" + udp_source="$(sed -n 's/^udp_source=//p' "$WORKROOT/evidence/peer-$round.txt")" + privileged_source="$(sed -n 's/^privileged_tcp_source=//p' "$WORKROOT/evidence/peer-$round.txt")" + [ -n "$tcp_source" ] && [ "$tcp_source" = "$udp_source" ] \ + && [ "$tcp_source" = "$privileged_source" ] \ + || die "peer used inconsistent ordinary/privileged TCP or UDP source addresses" + case "$tcp_source" in + 127.*|192.168.127.1|192.168.127.2|192.168.127.253|192.168.215.253|192.168.215.254|"") + die "peer source is a Dory/loopback hop: $tcp_source" ;; + esac + for _ in $(seq 1 50); do + logs="$(DOCKER_HOST="unix://$SOCKET" "$DOCKER" logs "$SERVER_NAME" 2>&1)" + printf '%s\n' "$logs" | grep -Fqx "tcp=$tcp_source token=$token" \ + && printf '%s\n' "$logs" | grep -Fqx "udp=$udp_source token=$token" && break + sleep 0.2 + done + printf '%s\n' "$logs" > "$WORKROOT/evidence/container-$round.log" + grep -Fqx "tcp=$tcp_source token=$token" "$WORKROOT/evidence/container-$round.log" \ + || die "container did not observe exact physical TCP source $tcp_source" + grep -Fqx "udp=$udp_source token=$token" "$WORKROOT/evidence/container-$round.log" \ + || die "container did not observe exact physical UDP source $udp_source" + for _ in $(seq 1 50); do + privileged_logs="$(DOCKER_HOST="unix://$SOCKET" "$DOCKER" logs "$PRIVILEGED_NAME" 2>&1)" + printf '%s\n' "$privileged_logs" \ + | grep -Fqx "tcp=$privileged_source token=$token-privileged" && break + sleep 0.2 + done + printf '%s\n' "$privileged_logs" > "$WORKROOT/evidence/privileged-container-$round.log" + grep -Fqx "tcp=$privileged_source token=$token-privileged" \ + "$WORKROOT/evidence/privileged-container-$round.log" \ + || die "interface-specific privileged port did not preserve physical source $privileged_source" + printf '%s\n' "$tcp_source" +} + +start_engine fresh +create_fixtures fresh +SOURCE_IP="$(run_peer_round fresh)" + +sudo -n /bin/launchctl kickstart -k system/dev.dory.network-helper \ + > "$WORKROOT/evidence/helper-restart.txt" 2>&1 \ + || die "could not restart the privileged network helper" +sleep 5 +RECOVERED_SOURCE_IP="$(run_peer_round helper-restart)" +[ "$RECOVERED_SOURCE_IP" = "$SOURCE_IP" ] || die "source identity changed after helper restart" +run_memory_pressure_rounds "$SOURCE_IP" + +DOCKER_HOST="unix://$SOCKET" "$DOCKER" rm -f \ + "$SERVER_NAME" "$LOOPBACK_NAME" "$PRIVILEGED_NAME" >/dev/null +DOCKER_HOST="unix://$SOCKET" "$DOCKER" network rm "$PRESSURE_NETWORK" >/dev/null +ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_OPTIONS[@]}" "$PEER_SSH" \ + python3 - "$HOST_ADDRESS" "$PORT" "$LOOPBACK_PORT" "$PRIVILEGED_PORT" "$OWNER" closed \ + < "$WORKROOT/peer.py" > "$WORKROOT/evidence/unpublish.txt" \ + || die "unpublish cleanup probe failed" +grep -qx 'tcp_unpublished=PASS' "$WORKROOT/evidence/unpublish.txt" +grep -qx 'udp_unpublished=PASS' "$WORKROOT/evidence/unpublish.txt" +grep -qx 'privileged_tcp_unpublished=PASS' "$WORKROOT/evidence/unpublish.txt" + +HOME="$ENGINE_HOME" "$RUNTIME/dory-engine" stop > "$WORKROOT/evidence/runtime-stop-first.log" 2>&1 +ENGINE_STARTED=0 +start_engine restart +create_fixtures restart +RESTART_SOURCE_IP="$(run_peer_round engine-restart)" +[ "$RESTART_SOURCE_IP" = "$SOURCE_IP" ] || die "source identity changed after engine restart" +DOCKER_HOST="unix://$SOCKET" "$DOCKER" rm -f "$SERVER_NAME" "$LOOPBACK_NAME" >/dev/null +DOCKER_HOST="unix://$SOCKET" "$DOCKER" network rm "$PRESSURE_NETWORK" >/dev/null +HOME="$ENGINE_HOME" "$RUNTIME/dory-engine" stop > "$WORKROOT/evidence/runtime-stop-final.log" 2>&1 +ENGINE_STARTED=0 +"$APP/Contents/MacOS/Dory" --unregister-network-helper \ + > "$WORKROOT/evidence/network-helper-unregistration.txt" 2>&1 \ + || die "the exact app could not unregister its privileged helper" +HELPER_REGISTERED=0 +if sudo -n /bin/launchctl print system/dev.dory.network-helper >/dev/null 2>&1; then + die "Dory network helper survived final cleanup" +fi + +sudo -n /sbin/pfctl -a com.apple/dev.dory.lan -sn \ + > "$WORKROOT/evidence/pf-after.txt" 2>&1 || true +if grep -Eq '(^|[[:space:]])rdr([[:space:]]|$)' "$WORKROOT/evidence/pf-after.txt"; then + die "Dory PF redirects survived final cleanup" +fi +if netstat -rn -f inet | awk '$1 == "192.168.215.254" { found=1 } END { exit !found }'; then + die "Dory source-preserving host route survived final cleanup" +fi +sudo -n test ! -e /var/run/dev.dory/pf-enable-token \ + || die "Dory PF enable token survived final cleanup" +sudo -n test ! -e /var/run/dev.dory/ipv4-forwarding-owner \ + || die "Dory IPv4-forwarding ownership marker survived final cleanup" +sudo -n /sbin/pfctl -s References > "$WORKROOT/evidence/pf-references-after.txt" 2>&1 \ + || die "could not capture final PF enable references" +cmp "$WORKROOT/evidence/pf-references-before.txt" "$WORKROOT/evidence/pf-references-after.txt" \ + || die "Dory changed the host PF enable-reference set after final cleanup" +/usr/sbin/sysctl -n net.inet.ip.forwarding \ + > "$WORKROOT/evidence/ipv4-forwarding-after.txt" +cmp "$WORKROOT/evidence/ipv4-forwarding-before.txt" "$WORKROOT/evidence/ipv4-forwarding-after.txt" \ + || die "Dory did not restore the host IPv4-forwarding state" + +HOST_BOOT_EPOCH_AFTER="$(/usr/sbin/sysctl -n kern.boottime \ + | sed -n 's/.*sec = \([0-9][0-9]*\).*/\1/p')" +[ "$HOST_BOOT_EPOCH_AFTER" = "$HOST_BOOT_EPOCH_BEFORE" ] \ + || die "the host boot session changed during physical network certification" +PANIC_REPORTS="$WORKROOT/evidence/new-host-panic-reports.txt" +: > "$PANIC_REPORTS" +for report_root in /Library/Logs/DiagnosticReports "$HOME/Library/Logs/DiagnosticReports"; do + [ ! -d "$report_root" ] || find "$report_root" -type f -newer "$PANIC_MARKER" \ + \( -iname '*.panic' -o -iname '*panic*.ips' \) -print 2>/dev/null >> "$PANIC_REPORTS" \ + || true +done +[ ! -s "$PANIC_REPORTS" ] \ + || die "a new host panic report appeared during physical network certification" + +APP_SHA="$(shasum -a 256 "$APP/Contents/MacOS/Dory" | awk '{print $1}')" +HV_SHA="$(shasum -a 256 "$RUNTIME/bin/dory-hv" | awk '{print $1}')" +cat > "$WORKROOT/evidence/manifest.txt" < Date: Fri, 21 Aug 2026 07:34:09 +0000 Subject: [PATCH 170/338] test(release): track Sparkle install gate --- .../test-sparkle-install-relaunch-gate.py | 119 +++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/sparkle-install-relaunch-gate.sh | 711 ++++++++++++++++++ 4 files changed, 832 insertions(+) create mode 100755 .github/scripts/test-sparkle-install-relaunch-gate.py create mode 100755 scripts/sparkle-install-relaunch-gate.sh diff --git a/.github/scripts/test-sparkle-install-relaunch-gate.py b/.github/scripts/test-sparkle-install-relaunch-gate.py new file mode 100755 index 00000000..64b79ff1 --- /dev/null +++ b/.github/scripts/test-sparkle-install-relaunch-gate.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the exact-candidate Sparkle install gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "sparkle-install-relaunch-gate.sh" + + +class SparkleInstallRelaunchGateTests(unittest.TestCase): + def invoke( + self, + paths: dict[str, pathlib.Path], + temporary_root: pathlib.Path, + *, + confirmation: str = "CLEAN-RELEASE-USER-SPARKLE-INSTALL", + clean_user: bool = True, + build_only: bool = False, + ) -> subprocess.CompletedProcess[str]: + command = [ + str(GATE), + "--candidate-app", str(paths["app"]), + "--update-zip", str(paths["update"]), + "--appcast", str(paths["appcast"]), + "--release-manifest", str(paths["manifest"]), + "--sbom", str(paths["sbom"]), + "--sparkle-source", str(paths["sparkle"]), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--workroot", str(temporary_root / "dory-release-live-sparkle"), + ] + if build_only: + command.append("--build-only") + else: + command.extend(("--confirm", confirmation)) + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + if clean_user: + environment["DORY_RELEASE_CLEAN_USER"] = "1" + else: + environment.pop("DORY_RELEASE_CLEAN_USER", None) + return subprocess.run( + command, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + @staticmethod + def fixture(temporary: pathlib.Path) -> dict[str, pathlib.Path]: + paths = { + "app": temporary / "Dory.app", + "update": temporary / "Dory-9.8.7-app-update.zip", + "appcast": temporary / "appcast.xml", + "manifest": temporary / "release-manifest.json", + "sbom": temporary / "Dory-9.8.7.cdx.json", + "sparkle": temporary / "Sparkle", + } + paths["app"].mkdir() + paths["sparkle"].mkdir() + for key in ("update", "appcast", "manifest", "sbom"): + paths[key].write_text("fixture\n", encoding="utf-8") + return paths + + def test_source_is_shell_valid_optimizer_safe_and_exactly_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + "scripts/verify-sparkle-update.sh", + "scripts/verify-release-sbom.py", + "--untracked-files=all", + "source=Notarized Developer ID", + "candidate metadata source commit mismatch", + "run evidence authority already exists", + "atomic_install_swap=PASS", + "different_relaunch_pid=PASS", + "docker_context_removed=PASS", + "daemon_processes_stopped=PASS", + "initial_clean_user_state_restored=PASS", + ): + self.assertIn(contract, source) + + def test_live_execution_requires_confirmation_and_clean_release_user(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-sparkle-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + confirmation = self.invoke(paths, temporary, confirmation="wrong") + clean_user = self.invoke(paths, temporary, clean_user=False) + self.assertIn("--confirm CLEAN-RELEASE-USER-SPARKLE-INSTALL", confirmation.stdout) + self.assertIn("DORY_RELEASE_CLEAN_USER=1", clean_user.stdout) + + def test_build_only_still_rejects_indirect_candidate_input(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-sparkle-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + linked = temporary / "linked-Dory.app" + linked.symlink_to(paths["app"], target_is_directory=True) + paths["app"] = linked + result = self.invoke(paths, temporary, build_only=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("required path is unavailable or indirect", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ec95732..013c1332 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -339,6 +339,7 @@ jobs: python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-source-preserving-lan-gate.py + python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fab96144..eaacdb2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,7 @@ jobs: python3 .github/scripts/test-homebrew-install-gate.py python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-source-preserving-lan-gate.py + python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/sparkle-install-relaunch-gate.sh b/scripts/sparkle-install-relaunch-gate.sh new file mode 100755 index 00000000..7bde5c68 --- /dev/null +++ b/scripts/sparkle-install-relaunch-gate.sh @@ -0,0 +1,711 @@ +#!/bin/bash +# Exercise Sparkle's real feed/download/Ed25519/install/terminate/relaunch path against the exact +# public Dory app-update archive. Full execution is destructive only inside a clean release user; +# --build-only validates and builds the exact pinned Sparkle CLI without launching or replacing an app. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CANDIDATE_APP="" +UPDATE_ZIP="" +APPCAST="" +RELEASE_MANIFEST="" +SBOM="" +SPARKLE_SOURCE="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +SIGNING_IDENTITY="${DORY_SIGN_ID:-Developer ID Application}" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-release-live-sparkle" +CONFIRM="" +BUILD_ONLY=0 + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --candidate-app) need_value "$1" "$#"; CANDIDATE_APP="$2"; shift 2 ;; + --update-zip) need_value "$1" "$#"; UPDATE_ZIP="$2"; shift 2 ;; + --appcast) need_value "$1" "$#"; APPCAST="$2"; shift 2 ;; + --release-manifest) need_value "$1" "$#"; RELEASE_MANIFEST="$2"; shift 2 ;; + --sbom) need_value "$1" "$#"; SBOM="$2"; shift 2 ;; + --sparkle-source) need_value "$1" "$#"; SPARKLE_SOURCE="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --signing-identity) need_value "$1" "$#"; SIGNING_IDENTITY="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + --build-only) BUILD_ONLY=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +for pair in \ + "candidate-app:$CANDIDATE_APP" "update-zip:$UPDATE_ZIP" "appcast:$APPCAST" \ + "release-manifest:$RELEASE_MANIFEST" "sbom:$SBOM" "sparkle-source:$SPARKLE_SOURCE" \ + "version:$VERSION" "build:$BUILD" "source-commit:$SOURCE_COMMIT"; do + [ -n "${pair#*:}" ] || die "--${pair%%:*} is required" +done +case "$BUILD" in ''|*[!0-9]*) die "build must be a positive integer" ;; esac +[ "$BUILD" -gt 0 ] || die "build must be a positive integer" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +if [ "$BUILD_ONLY" -eq 0 ]; then + [ "$CONFIRM" = CLEAN-RELEASE-USER-SPARKLE-INSTALL ] \ + || die "full execution requires --confirm CLEAN-RELEASE-USER-SPARKLE-INSTALL" + [ "${DORY_RELEASE_CLEAN_USER:-0}" = 1 ] \ + || die "full execution requires DORY_RELEASE_CLEAN_USER=1" +fi + +direct_existing() { + local path="$1" kind="$2" logical physical + [ -e "$path" ] && [ ! -L "$path" ] || die "required path is unavailable or indirect: $path" + logical="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$path")" + physical="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")" + [ "$logical" = "$physical" ] || die "required path has an indirect ancestor: $path" + case "$kind" in + directory) [ -d "$physical" ] || die "required directory is invalid: $path" ;; + file) [ -f "$physical" ] || die "required file is invalid: $path" ;; + *) die "internal path-kind error" ;; + esac + printf '%s\n' "$physical" +} +CANDIDATE_APP="$(direct_existing "$CANDIDATE_APP" directory)" +UPDATE_ZIP="$(direct_existing "$UPDATE_ZIP" file)" +APPCAST="$(direct_existing "$APPCAST" file)" +RELEASE_MANIFEST="$(direct_existing "$RELEASE_MANIFEST" file)" +SBOM="$(direct_existing "$SBOM" file)" +SPARKLE_SOURCE="$(direct_existing "$SPARKLE_SOURCE" directory)" +[ -d "$CANDIDATE_APP" ] && [ "$(basename "$CANDIDATE_APP")" = Dory.app ] \ + || die "candidate app must be an exact Dory.app directory" +git -C "$SPARKLE_SOURCE" rev-parse --is-inside-work-tree 2>/dev/null | grep -qx true \ + || die "Sparkle source is not a Git checkout" +for command in codesign curl ditto git plutil python3 security shasum spctl xcodebuild xcrun; do + command -v "$command" >/dev/null || die "missing required command: $command" +done + +PIN_FILE="$ROOT/Dory.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" +[ -s "$PIN_FILE" ] || die "Dory Package.resolved is missing" +pin_values="$(python3 - "$PIN_FILE" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) +rows = [row for row in payload.get("pins", []) if row.get("identity") == "sparkle"] +if len(rows) != 1: + raise SystemExit("Package.resolved must contain one Sparkle pin") +state = rows[0].get("state", {}) +print(state.get("version", "")) +print(state.get("revision", "")) +PY +)" || die "could not parse the Sparkle pin" +SPARKLE_VERSION="$(printf '%s\n' "$pin_values" | sed -n '1p')" +SPARKLE_REVISION="$(printf '%s\n' "$pin_values" | sed -n '2p')" +[ "$SPARKLE_VERSION" = 2.9.4 ] || die "release requires pinned Sparkle 2.9.4" +printf '%s\n' "$SPARKLE_REVISION" | grep -Eq '^[0-9a-f]{40}$' \ + || die "Sparkle revision pin is invalid" +[ "$(git -C "$SPARKLE_SOURCE" rev-parse HEAD)" = "$SPARKLE_REVISION" ] \ + || die "Sparkle source checkout differs from Package.resolved" +[ -z "$(git -C "$SPARKLE_SOURCE" status --porcelain --untracked-files=all)" ] \ + || die "Sparkle source checkout is not exact and clean" + +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +[ "$WORKROOT_NAME" = dory-release-live-sparkle ] \ + || die "workroot must use the dedicated dory-release-live-sparkle name" +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || die "workroot parent must be a direct directory" +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] || die "temporary authority root is unavailable" +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +case "$TEMP_AUTHORITY" in /|"$HOME"|"$ROOT"|"$CANDIDATE_APP"|"$SPARKLE_SOURCE") die "unsafe temporary authority root" ;; esac +case "$WORKROOT" in "$TEMP_AUTHORITY"/*) ;; *) die "workroot must be inside runner temporary storage" ;; esac +for input in "$CANDIDATE_APP" "$UPDATE_ZIP" "$APPCAST" "$RELEASE_MANIFEST" "$SBOM" "$SPARKLE_SOURCE"; do + case "$input/" in "$WORKROOT/"*) die "workroot cannot contain a candidate input" ;; esac + case "$WORKROOT/" in "$input/"*) die "workroot cannot be inside a candidate input" ;; esac +done +if [ -e "$WORKROOT" ] || [ -L "$WORKROOT" ]; then + [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot must not be a symlink or non-directory" + [ "$(stat -f '%u' "$WORKROOT")" = "$(id -u)" ] \ + || die "workroot is not owned by the release user" +else + mkdir "$WORKROOT" +fi +chmod 700 "$WORKROOT" + +EXPECTED_APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$CANDIDATE_APP/Contents/Info.plist")" +EXPECTED_APP_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$CANDIDATE_APP/Contents/Info.plist")" +[ "$EXPECTED_APP_VERSION" = "$VERSION" ] || die "candidate marketing version mismatch" +[ "$EXPECTED_APP_BUILD" = "$BUILD" ] || die "candidate build mismatch" +if /usr/libexec/PlistBuddy -c 'Print :NSUpdateSecurityPolicy' \ + "$CANDIDATE_APP/Contents/Info.plist" >/dev/null 2>&1; then + die "candidate custom update security policy disables Sparkle's safe atomic replacement" +fi +metadata_commit="$(python3 "$ROOT/scripts/validate-release-metadata.py" \ + "$(dirname "$RELEASE_MANIFEST")" "$VERSION" "$BUILD")" \ + || die "candidate release metadata is invalid" +[ "$metadata_commit" = "$SOURCE_COMMIT" ] || die "candidate metadata source commit mismatch" +python3 - "$RELEASE_MANIFEST" "$UPDATE_ZIP" "$APPCAST" "$SBOM" \ + "$VERSION" "$BUILD" "$SOURCE_COMMIT" <<'PY' +import hashlib, json, os, pathlib, sys +manifest_path, update, appcast, sbom, version, build, source = sys.argv[1:] +with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) +def require(condition, message): + if not condition: + raise SystemExit(message) + +require(manifest.get("schemaVersion") == 2, "release manifest schema mismatch") +require(manifest.get("version") == version, "release manifest version mismatch") +require(str(manifest.get("build")) == build, "release manifest build mismatch") +require(manifest.get("sourceCommit") == source, "release manifest source mismatch") +require(manifest.get("publicRelease") is True and manifest.get("notarized") is True, + "release manifest trust state mismatch") +require(manifest.get("variants") == "arm64", "release manifest architecture mismatch") +records = {row.get("name"): row for row in manifest.get("artifacts", [])} +for path in (update, appcast, sbom): + name = pathlib.Path(path).name + require(name in records, f"release manifest omits {name}") + digest = hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest() + require(records[name].get("sha256") == digest, f"release manifest digest mismatch: {name}") + require(records[name].get("bytes") == os.path.getsize(path), f"release manifest size mismatch: {name}") +PY +codesign --verify --strict --deep "$CANDIDATE_APP" \ + || die "candidate app signature is invalid" +xcrun stapler validate "$CANDIDATE_APP" >/dev/null \ + || die "candidate app has no valid notarization ticket" +candidate_assessment="$(spctl --assess --type execute --verbose=4 "$CANDIDATE_APP" 2>&1)" \ + || die "Gatekeeper rejected the exact candidate app" +grep -F 'source=Notarized Developer ID' <<< "$candidate_assessment" >/dev/null \ + || die "candidate app is not accepted as Notarized Developer ID" +"$ROOT/scripts/verify-sparkle-update.sh" "$CANDIDATE_APP" "$UPDATE_ZIP" "$APPCAST" \ + > /dev/null || die "candidate Sparkle signature/key verification failed" +"$ROOT/scripts/verify-release-sbom.py" --sbom "$SBOM" --app "$CANDIDATE_APP" \ + --version "$VERSION" --source-commit "$SOURCE_COMMIT" >/dev/null \ + || die "candidate SBOM does not match the exact app tree" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +DERIVED_DATA="$RUN_ROOT/DerivedData" +[ ! -e "$RUN_ROOT" ] && [ ! -L "$RUN_ROOT" ] || die "run evidence authority already exists" +mkdir "$RUN_ROOT" +chmod 700 "$RUN_ROOT" +mkdir "$EVIDENCE" +BUILD_LOG="$EVIDENCE/sparkle-cli-build.log" +DEVELOPER_DIR_VALUE="${DEVELOPER_DIR:-$(xcode-select -p)}" +DEVELOPER_DIR="$DEVELOPER_DIR_VALUE" xcodebuild \ + -project "$SPARKLE_SOURCE/Sparkle.xcodeproj" \ + -scheme sparkle-cli \ + -configuration Release \ + -derivedDataPath "$DERIVED_DATA" \ + -onlyUsePackageVersionsFromResolvedFile \ + -skipPackageUpdates \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \ + build > "$BUILD_LOG" 2>&1 \ + || die "pinned Sparkle CLI build failed; see $BUILD_LOG" +SPARKLE_CLI_APP="$DERIVED_DATA/Build/Products/Release/sparkle.app" +SPARKLE_CLI="$SPARKLE_CLI_APP/Contents/MacOS/sparkle" +[ -x "$SPARKLE_CLI" ] || die "pinned Sparkle CLI product is missing" +codesign --verify --strict --deep "$SPARKLE_CLI_APP" \ + || die "pinned Sparkle CLI product has an invalid local signature" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$SPARKLE_CLI_APP/Contents/Info.plist")" = "$SPARKLE_VERSION" ] \ + || die "built Sparkle CLI version differs from Package.resolved" +SPARKLE_CLI_SHA="$(shasum -a 256 "$SPARKLE_CLI" | awk '{print $1}')" +SPARKLE_FRAMEWORK_SHA="$(shasum -a 256 \ + "$SPARKLE_CLI_APP/Contents/Frameworks/Sparkle.framework/Versions/B/Sparkle" | awk '{print $1}')" +CANDIDATE_TREE_SHA="$(python3 - "$SBOM" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) +rows = payload["metadata"]["component"]["properties"] +values = [row["value"] for row in rows if row["name"] == "dev.dory.app.tree.sha256"] +if len(values) != 1: + raise SystemExit("SBOM must contain exactly one app-tree digest") +print(values[0]) +PY +)" +GATE_SCRIPT_SHA="$(shasum -a 256 "$ROOT/scripts/sparkle-install-relaunch-gate.sh" | awk '{print $1}')" +UPDATE_ZIP_SHA="$(shasum -a 256 "$UPDATE_ZIP" | awk '{print $1}')" +APPCAST_SHA="$(shasum -a 256 "$APPCAST" | awk '{print $1}')" +RELEASE_MANIFEST_SHA="$(shasum -a 256 "$RELEASE_MANIFEST" | awk '{print $1}')" +SBOM_SHA="$(shasum -a 256 "$SBOM" | awk '{print $1}')" + +if [ "$BUILD_ONLY" -eq 1 ]; then + { + echo status=PASS + echo release_qualifying=false + echo build_only=PASS + echo "source_commit=$SOURCE_COMMIT" + echo "candidate_version=$VERSION" + echo "candidate_build=$BUILD" + echo "candidate_tree_sha256=$CANDIDATE_TREE_SHA" + echo "update_zip_sha256=$UPDATE_ZIP_SHA" + echo "appcast_sha256=$APPCAST_SHA" + echo "release_manifest_sha256=$RELEASE_MANIFEST_SHA" + echo "sbom_sha256=$SBOM_SHA" + echo "gate_script_sha256=$GATE_SCRIPT_SHA" + echo "sparkle_version=$SPARKLE_VERSION" + echo "sparkle_revision=$SPARKLE_REVISION" + echo "sparkle_cli_sha256=$SPARKLE_CLI_SHA" + echo "sparkle_framework_sha256=$SPARKLE_FRAMEWORK_SHA" + echo "completed_epoch=$(date +%s)" + } > "$EVIDENCE/manifest.txt" + rm -rf "$DERIVED_DATA" + echo "Sparkle install gate build-only PASS: $EVIDENCE/manifest.txt" + exit 0 +fi + +[ "$(uname -s)" = Darwin ] && [ "$(uname -m)" = arm64 ] \ + || die "full execution requires physical Apple Silicon" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Hypervisor.framework is unavailable" +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested macOS cannot qualify the Sparkle install" +[ "$(spctl --status 2>&1)" = "assessments enabled" ] \ + || die "Gatekeeper assessments must be enabled" +[ "$BUILD" -gt 1 ] || die "a lower-build update fixture requires build greater than one" +security find-identity -v -p codesigning \ + | grep -F "$SIGNING_IDENTITY" >/dev/null \ + || die "Developer ID signing identity is unavailable: $SIGNING_IDENTITY" +CANDIDATE_TEAM="$(codesign -dv --verbose=4 "$CANDIDATE_APP" 2>&1 \ + | sed -n 's/^TeamIdentifier=//p')" +[ -n "$CANDIDATE_TEAM" ] || die "candidate app has no Developer ID team identifier" +"$ROOT/scripts/sign-sparkle-for-distribution.sh" \ + "$SPARKLE_CLI_APP" "$SIGNING_IDENTITY" \ + > "$EVIDENCE/sparkle-cli-signing.out" 2> "$EVIDENCE/sparkle-cli-signing.err" \ + || die "could not sign the Sparkle qualification CLI's nested code" +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" \ + "$SPARKLE_CLI_APP" \ + >> "$EVIDENCE/sparkle-cli-signing.out" 2>> "$EVIDENCE/sparkle-cli-signing.err" \ + || die "could not sign the Sparkle qualification CLI" +"$ROOT/scripts/verify-distribution-signatures.sh" \ + "$SPARKLE_CLI_APP" "$CANDIDATE_TEAM" \ + > "$EVIDENCE/sparkle-cli-signature-verification.txt" \ + || die "Sparkle qualification CLI is not signed entirely by the candidate team" +SPARKLE_AUTOUPDATE="$SPARKLE_CLI_APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Autoupdate" +[ -x "$SPARKLE_AUTOUPDATE" ] || die "signed Sparkle Autoupdate helper is missing" +SPARKLE_CLI_TEAM="$(codesign -dv --verbose=4 "$SPARKLE_CLI_APP" 2>&1 \ + | sed -n 's/^TeamIdentifier=//p')" +SPARKLE_AUTOUPDATE_TEAM="$(codesign -dv --verbose=4 "$SPARKLE_AUTOUPDATE" 2>&1 \ + | sed -n 's/^TeamIdentifier=//p')" +[ "$SPARKLE_CLI_TEAM" = "$CANDIDATE_TEAM" ] \ + || die "Sparkle qualification CLI team differs from the candidate team" +[ "$SPARKLE_AUTOUPDATE_TEAM" = "$CANDIDATE_TEAM" ] \ + || die "Sparkle Autoupdate team differs from the candidate team" +SPARKLE_CLI_SHA="$(shasum -a 256 "$SPARKLE_CLI" | awk '{print $1}')" +SPARKLE_FRAMEWORK_SHA="$(shasum -a 256 \ + "$SPARKLE_CLI_APP/Contents/Frameworks/Sparkle.framework/Versions/B/Sparkle" | awk '{print $1}')" +python3 - \ + "$SPARKLE_SOURCE/Autoupdate/SUPlainInstaller.m" \ + "$SPARKLE_SOURCE/Sparkle/SUFileManager.m" \ + "$SPARKLE_SOURCE/Configurations/ConfigCommon.xcconfig" \ + > "$EVIDENCE/sparkle-install-safety.txt" <<'PY' +import pathlib +import sys + +installer = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +file_manager = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8") +configuration = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8") +required_installer = ( + "_canPerformSafeAtomicSwap = (bundleTeamIdentifier == nil || (installerTeamIdentifier != nil && [installerTeamIdentifier isEqualToString:bundleTeamIdentifier]));", + "swappedApp = [fileManager swapItemAtURL:installationURL withItemAtURL:newFinalURL error:&swapError];", + "[fileManager moveItemAtURL:oldTempURL toURL:oldURL error:NULL];", +) +for fragment in required_installer: + if fragment not in installer: + raise SystemExit(f"pinned Sparkle installer safety contract is absent: {fragment}") +if "renamex_np(newPath, originalPath, RENAME_SWAP)" not in file_manager: + raise SystemExit("pinned Sparkle file manager no longer uses an atomic filesystem swap") +if "SPARKLE_NORMALIZE_INSTALLED_APPLICATION_NAME = 0" not in configuration: + raise SystemExit("pinned Sparkle build unexpectedly normalizes installed application names") +print("atomic_swap_implementation=PASS") +print("fallback_restoration_implementation=PASS") +PY + +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +PREF_DOMAIN="com.pythonxi.Dory" +PREF_PLIST="$HOME/Library/Preferences/$PREF_DOMAIN.plist" +SERVICE="gui/$(id -u)/dev.dory.doryd" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +for process in Dory doryd dory-hv dory-vmm; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "$process is already running; use a clean dedicated release user" +done +! launchctl print "$SERVICE" >/dev/null 2>&1 \ + || die "Dory service is already loaded; use a clean dedicated release user" +[ ! -e "$STATE" ] || die "existing Dory state would be touched: $STATE" +[ ! -e "$APP_SUPPORT" ] || die "existing Dory application state would be touched: $APP_SUPPORT" +[ ! -e "$PLIST" ] || die "existing Dory LaunchAgent would be touched: $PLIST" +if defaults read "$PREF_DOMAIN" >/dev/null 2>&1; then + die "existing Dory preferences would be touched; use a clean dedicated release user" +fi +CANDIDATE_DOCKER="$CANDIDATE_APP/Contents/Helpers/docker" +[ -x "$CANDIDATE_DOCKER" ] || die "candidate Docker CLI is missing" +PREVIOUS_CONTEXT="$($CANDIDATE_DOCKER context show 2>/dev/null || printf default)" +[ -n "$PREVIOUS_CONTEXT" ] || PREVIOUS_CONTEXT=default +! "$CANDIDATE_DOCKER" context inspect dory >/dev/null 2>&1 \ + || die "existing Docker context dory would be touched" +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ -f "$profile" ] && grep -Fq '# >>> dory cli >>>' "$profile"; then + die "existing Dory shell integration would be touched: $profile" + fi +done + +INSTALL_ROOT="$RUN_ROOT/install" +INSTALL_APP="$INSTALL_ROOT/Dory.app" +PREVIOUS_BACKUP="$RUN_ROOT/previous/Dory.app" +FEED_ROOT="$RUN_ROOT/feed" +UPDATED_BACKUP="$RUN_ROOT/updated/Dory.app" +OLD_PID="" +NEW_PID="" +SERVER_PID="" +SPARKLE_PID="" +CLEAN_USER_ARMED=1 + +processes_for_executable() { + ps -ww -axo pid=,command= | awk -v executable="$1" ' + { + pid=$1 + $1="" + sub(/^[[:space:]]+/, "") + if ($0 == executable || index($0, executable " ") == 1) print pid + } + ' +} + +stop_pid() { + local pid="$1" + [ -n "$pid" ] || return 0 + kill -TERM "$pid" >/dev/null 2>&1 || true + for _ in $(seq 1 60); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.25 + done + kill -KILL "$pid" >/dev/null 2>&1 || true +} + +clean_release_user_state() { + local cli="$INSTALL_APP/Contents/Helpers/dory" docker="$INSTALL_APP/Contents/Helpers/docker" + if [ -x "$cli" ]; then + "$cli" engine sleep >/dev/null 2>&1 || true + "$cli" uninstall >/dev/null 2>&1 || true + fi + launchctl bootout "$SERVICE" >/dev/null 2>&1 || true + if [ -x "$docker" ]; then + "$docker" context use "$PREVIOUS_CONTEXT" >/dev/null 2>&1 || true + "$docker" context rm -f dory >/dev/null 2>&1 || true + fi + rm -f "$PLIST" + rm -rf "$STATE" "$APP_SUPPORT" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" +} + +cleanup() { + status=$? + set +e + stop_pid "$SPARKLE_PID" + SPARKLE_PID="" + [ -z "$SERVER_PID" ] || { kill -TERM "$SERVER_PID" >/dev/null 2>&1; wait "$SERVER_PID" 2>/dev/null; } + stop_pid "$NEW_PID" + stop_pid "$OLD_PID" + [ "${CLEAN_USER_ARMED:-0}" != 1 ] || clean_release_user_state + if [ "$status" -eq 0 ] || [ "${DORY_SPARKLE_KEEP_FAILURE_PAYLOAD:-0}" != 1 ]; then + rm -rf "$DERIVED_DATA" "$INSTALL_ROOT" "$RUN_ROOT/previous" "$RUN_ROOT/updated" "$FEED_ROOT" + fi + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +mkdir -p "$INSTALL_ROOT" "$(dirname "$PREVIOUS_BACKUP")" "$FEED_ROOT" +ditto "$CANDIDATE_APP" "$INSTALL_APP" +PREVIOUS_BUILD=$((BUILD - 1)) +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $PREVIOUS_BUILD" \ + "$INSTALL_APP/Contents/Info.plist" +codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \ + --preserve-metadata=identifier,requirements,entitlements "$INSTALL_APP" \ + > "$EVIDENCE/previous-signing.out" 2> "$EVIDENCE/previous-signing.err" \ + || die "could not sign the lower-build update fixture" +codesign --verify --strict --deep "$INSTALL_APP" \ + || die "lower-build update fixture signature is invalid" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$INSTALL_APP/Contents/Info.plist")" = "$PREVIOUS_BUILD" ] \ + || die "lower-build fixture version did not change" +PREVIOUS_TEAM="$(codesign -dv --verbose=4 "$INSTALL_APP" 2>&1 \ + | sed -n 's/^TeamIdentifier=//p')" +[ -n "$CANDIDATE_TEAM" ] && [ "$PREVIOUS_TEAM" = "$CANDIDATE_TEAM" ] \ + || die "lower-build fixture is not signed by the candidate team" +ditto "$INSTALL_APP" "$PREVIOUS_BACKUP" + +mkdir -p "$APP_SUPPORT" +SENTINEL="$APP_SUPPORT/release-update-sentinel-$RUN_ID" +printf '%s\n' "$RUN_ID" > "$SENTINEL" +defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true +defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool false +defaults write "$PREF_DOMAIN" dory.releaseUpdateSentinel "$RUN_ID" + +UPDATE_NAME="$(basename "$UPDATE_ZIP")" +ditto "$UPDATE_ZIP" "$FEED_ROOT/$UPDATE_NAME" +PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +LOCAL_FEED_URL="http://127.0.0.1:$PORT/appcast.xml" +LOCAL_UPDATE_URL="http://127.0.0.1:$PORT/$UPDATE_NAME" +python3 - "$APPCAST" "$FEED_ROOT/appcast.xml" "$LOCAL_UPDATE_URL" \ + "$BUILD" "$VERSION" "$UPDATE_ZIP" <<'PY' +import base64, os, sys, xml.etree.ElementTree as ET +source, output, update_url, build, version, archive = sys.argv[1:] +uri = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", uri) +root = ET.parse(source).getroot() +item = root.find("./channel/item") +if item is None: + raise SystemExit("appcast item is missing") +if item.findtext(f"{{{uri}}}version") != build: + raise SystemExit("appcast build mismatch") +if item.findtext(f"{{{uri}}}shortVersionString") != version: + raise SystemExit("appcast version mismatch") +enclosure = item.find("enclosure") +if enclosure is None: + raise SystemExit("appcast enclosure is missing") +signature = enclosure.attrib[f"{{{uri}}}edSignature"] +if len(base64.b64decode(signature, validate=True)) != 64: + raise SystemExit("appcast signature shape mismatch") +if int(enclosure.attrib["length"]) != os.path.getsize(archive): + raise SystemExit("appcast archive size mismatch") +enclosure.set("url", update_url) +ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True) +PY +python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$FEED_ROOT" \ + > "$EVIDENCE/feed-server.out" 2> "$EVIDENCE/feed-server.err" & +SERVER_PID=$! +for _ in $(seq 1 50); do + curl -fsS --max-time 2 "$LOCAL_FEED_URL" >/dev/null 2>&1 && break + sleep 0.1 +done +curl -fsS --max-time 5 "$LOCAL_FEED_URL" >/dev/null \ + || die "loopback Sparkle feed did not become ready" + +INSTALLED_EXECUTABLE="$INSTALL_APP/Contents/MacOS/Dory" +DORY_UI_TEST=1 "$INSTALLED_EXECUTABLE" \ + > "$EVIDENCE/previous-app.out" 2> "$EVIDENCE/previous-app.err" & +OLD_PID=$! +sleep 2 +kill -0 "$OLD_PID" 2>/dev/null || die "lower-build fixture exited before the update" +[ "$(processes_for_executable "$INSTALLED_EXECUTABLE" | awk 'NF {count++} END {print count+0}')" -eq 1 ] \ + || die "lower-build fixture process identity is ambiguous" +printf '%s\n' "$OLD_PID" > "$EVIDENCE/previous.pid" +ps -ww -p "$OLD_PID" -o command= > "$EVIDENCE/previous-command.txt" + +SPARKLE_LOG_START="$(date -u '+%Y-%m-%d %H:%M:%S+0000')" +"$SPARKLE_CLI" \ + --application "$INSTALL_APP" \ + --feed-url "$LOCAL_FEED_URL" \ + --user-agent-name "Dory release qualification" \ + --check-immediately \ + --grant-automatic-checks \ + --verbose \ + "$INSTALL_APP" \ + > "$EVIDENCE/sparkle-install.out" 2> "$EVIDENCE/sparkle-install.err" & +SPARKLE_PID=$! +sparkle_started=$SECONDS +while kill -0 "$SPARKLE_PID" 2>/dev/null; do + if [ $((SECONDS - sparkle_started)) -ge 600 ]; then + kill -TERM "$SPARKLE_PID" 2>/dev/null || true + wait "$SPARKLE_PID" 2>/dev/null || true + die "Sparkle install exceeded 600 seconds" + fi + sleep 0.25 +done +if wait "$SPARKLE_PID"; then SPARKLE_RC=0; else SPARKLE_RC=$?; fi +[ "$SPARKLE_RC" -eq 0 ] || die "Sparkle CLI install failed with exit $SPARKLE_RC" + +for _ in $(seq 1 120); do + candidate_pids="$(processes_for_executable "$INSTALLED_EXECUTABLE")" + NEW_PID="$(printf '%s\n' "$candidate_pids" | awk -v old="$OLD_PID" '$1 != old {print; exit}')" + [ -n "$NEW_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null && break + sleep 0.5 +done +[ -n "$NEW_PID" ] || die "Sparkle did not relaunch the installed candidate" +[ "$NEW_PID" != "$OLD_PID" ] || die "Sparkle relaunch reused the previous PID" +! kill -0 "$OLD_PID" 2>/dev/null || die "previous app process survived the Sparkle install" +[ "$(processes_for_executable "$INSTALLED_EXECUTABLE" | awk 'NF {count++} END {print count+0}')" -eq 1 ] \ + || die "Sparkle relaunch produced an ambiguous candidate process" +printf '%s\n' "$NEW_PID" > "$EVIDENCE/relaunched.pid" +ps -ww -p "$NEW_PID" -o command= > "$EVIDENCE/relaunched-command.txt" +sleep 2 +kill -0 "$NEW_PID" 2>/dev/null || die "relaunched candidate exited during verification" + +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$INSTALL_APP/Contents/Info.plist")" = "$VERSION" ] \ + || die "Sparkle installed the wrong marketing version" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$INSTALL_APP/Contents/Info.plist")" = "$BUILD" ] \ + || die "Sparkle installed the wrong build" +"$ROOT/scripts/verify-release-sbom.py" --sbom "$SBOM" --app "$INSTALL_APP" \ + --version "$VERSION" --source-commit "$SOURCE_COMMIT" \ + > "$EVIDENCE/installed-sbom-verification.txt" \ + || die "Sparkle-installed app differs from the exact candidate tree" +codesign --verify --strict --deep "$INSTALL_APP" \ + || die "Sparkle-installed app signature is invalid" +xcrun stapler validate "$INSTALL_APP" > "$EVIDENCE/installed-stapler.txt" 2>&1 \ + || die "Sparkle-installed app lost its notarization ticket" +spctl -a -vv --type execute "$INSTALL_APP" \ + > "$EVIDENCE/installed-gatekeeper.txt" 2>&1 \ + || die "Gatekeeper rejected the Sparkle-installed app" +grep -F 'source=Notarized Developer ID' "$EVIDENCE/installed-gatekeeper.txt" >/dev/null \ + || die "Gatekeeper did not identify the Sparkle-installed app as Notarized Developer ID" +[ "$(cat "$SENTINEL")" = "$RUN_ID" ] \ + || die "application-support sentinel changed during the update" +[ "$(defaults read "$PREF_DOMAIN" dory.releaseUpdateSentinel)" = "$RUN_ID" ] \ + || die "preference sentinel changed during the update" +grep -F 'GET /appcast.xml ' "$EVIDENCE/feed-server.err" >/dev/null \ + || die "Sparkle did not fetch the loopback appcast" +grep -F "GET /$UPDATE_NAME " "$EVIDENCE/feed-server.err" >/dev/null \ + || die "Sparkle did not fetch the exact update archive" +for message in 'Downloading Update' 'Extracting Update' 'Installing Update' 'Installation Finished'; do + grep -F "$message" "$EVIDENCE/sparkle-install.err" >/dev/null \ + || die "Sparkle CLI did not report required phase: $message" +done +/usr/bin/log show --start "$SPARKLE_LOG_START" --style compact \ + --predicate 'subsystem == "org.sparkle-project.Sparkle"' \ + > "$EVIDENCE/sparkle-unified.log" \ + || die "could not read Sparkle's unified installer log" +if grep -Eq 'Skipping atomic rename/swap|Invoking fallback from failing to replace original item' \ + "$EVIDENCE/sparkle-unified.log"; then + die "Sparkle did not use the same-team atomic replacement path" +fi + +stop_pid "$NEW_PID" +NEW_PID="" +wait "$OLD_PID" 2>/dev/null || true +OLD_PID="" +clean_release_user_state +mkdir -p "$(dirname "$UPDATED_BACKUP")" +mv "$INSTALL_APP" "$UPDATED_BACKUP" +ditto "$PREVIOUS_BACKUP" "$INSTALL_APP" +codesign --verify --strict --deep "$INSTALL_APP" \ + || die "restored qualification fixture signature is invalid" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$INSTALL_APP/Contents/Info.plist")" = "$PREVIOUS_BUILD" ] \ + || die "qualification cleanup did not restore the lower-build fixture" +rm -rf "$INSTALL_ROOT" "$RUN_ROOT/previous" "$RUN_ROOT/updated" +[ ! -e "$STATE" ] && [ ! -e "$APP_SUPPORT" ] && [ ! -e "$PLIST" ] \ + || die "clean release-user Dory state survived cleanup" +if defaults read "$PREF_DOMAIN" >/dev/null 2>&1; then + die "clean release-user preferences survived cleanup" +fi +! launchctl print "$SERVICE" >/dev/null 2>&1 \ + || die "Dory service survived clean-user cleanup" +for process in Dory doryd dory-hv dory-vmm; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "$process survived clean-user cleanup" +done +! "$CANDIDATE_DOCKER" context inspect dory >/dev/null 2>&1 \ + || die "Dory Docker context survived clean-user cleanup" +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ -f "$profile" ] && grep -Fq '# >>> dory cli >>>' "$profile"; then + die "Dory shell integration survived clean-user cleanup: $profile" + fi +done +CLEAN_USER_ARMED=0 + +{ + echo status=PASS + echo release_qualifying=true + echo "run_id=$RUN_ID" + echo "workflow_run_id=${GITHUB_RUN_ID:-local}" + echo "workflow_run_attempt=${GITHUB_RUN_ATTEMPT:-local}" + echo "source_commit=$SOURCE_COMMIT" + echo "candidate_version=$VERSION" + echo "candidate_build=$BUILD" + echo "previous_fixture_build=$PREVIOUS_BUILD" + echo "candidate_tree_sha256=$CANDIDATE_TREE_SHA" + echo "update_zip_sha256=$UPDATE_ZIP_SHA" + echo "appcast_sha256=$APPCAST_SHA" + echo "release_manifest_sha256=$RELEASE_MANIFEST_SHA" + echo "sbom_sha256=$SBOM_SHA" + echo "gate_script_sha256=$GATE_SCRIPT_SHA" + echo "sparkle_version=$SPARKLE_VERSION" + echo "sparkle_revision=$SPARKLE_REVISION" + echo "sparkle_cli_sha256=$SPARKLE_CLI_SHA" + echo "sparkle_framework_sha256=$SPARKLE_FRAMEWORK_SHA" + echo "candidate_team=$CANDIDATE_TEAM" + echo "sparkle_cli_team=$SPARKLE_CLI_TEAM" + echo "sparkle_autoupdate_team=$SPARKLE_AUTOUPDATE_TEAM" + echo "old_pid=$(cat "$EVIDENCE/previous.pid")" + echo "relaunched_pid=$(cat "$EVIDENCE/relaunched.pid")" + echo old_process_terminated=PASS + echo different_relaunch_pid=PASS + echo loopback_appcast_fetched=PASS + echo exact_update_archive_fetched=PASS + echo ed25519_archive_verified=PASS + echo installed_tree_exact=PASS + echo installed_notarization_ticket=PASS + echo installed_gatekeeper=PASS + echo application_support_preserved=PASS + echo preferences_preserved=PASS + echo atomic_install_swap=PASS + echo sparkle_fallback_restoration_verified=PASS + echo qualification_fixture_restored=PASS + echo docker_context_removed=PASS + echo daemon_processes_stopped=PASS + echo initial_clean_user_state_restored=PASS + echo "completed_epoch=$(date +%s)" +} > "$EVIDENCE/manifest.txt" + +rm -rf "$DERIVED_DATA" "$FEED_ROOT" +trap - EXIT INT TERM +echo "Sparkle install/relaunch gate PASS: $EVIDENCE/manifest.txt" From 8e99268c6af0c583419a273beffb199128a75988 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:40:11 +0000 Subject: [PATCH 171/338] test(network): track VZ IPv6 gate --- .github/scripts/test-vz-native-ipv6-gate.py | 110 +++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/vz-native-ipv6-gate.sh | 981 ++++++++++++++++++++ 4 files changed, 1093 insertions(+) create mode 100755 .github/scripts/test-vz-native-ipv6-gate.py create mode 100755 scripts/vz-native-ipv6-gate.sh diff --git a/.github/scripts/test-vz-native-ipv6-gate.py b/.github/scripts/test-vz-native-ipv6-gate.py new file mode 100755 index 00000000..b03f9524 --- /dev/null +++ b/.github/scripts/test-vz-native-ipv6-gate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the physical VZ native-IPv6 gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "vz-native-ipv6-gate.sh" + + +class VZNativeIPv6GateTests(unittest.TestCase): + @staticmethod + def fixture(temporary: pathlib.Path) -> dict[str, pathlib.Path]: + paths = { + name: temporary / name + for name in ("dory-vmm", "gvproxy", "kernel", "rootfs", "docker") + } + for name, path in paths.items(): + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + if name in {"dory-vmm", "gvproxy", "docker"}: + path.chmod(0o755) + return paths + + @staticmethod + def invoke( + paths: dict[str, pathlib.Path], + temporary: pathlib.Path, + *, + workroot_name: str = "dory-vz-native-ipv6-evidence", + extra: tuple[str, ...] = (), + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary) + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + [ + str(GATE), + "--dory-vmm", str(paths["dory-vmm"]), + "--gvproxy", str(paths["gvproxy"]), + "--kernel", str(paths["kernel"]), + "--rootfs", str(paths["rootfs"]), + "--docker", str(paths["docker"]), + "--workroot", str(temporary / workroot_name), + *extra, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_optimizer_safe_and_closes_authority(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "physical Apple-silicon macOS is required", + "nested virtualization does not qualify", + "input has an indirect ancestor", + '"$VMM" = "$SOURCE_APP/Contents/Helpers/dory-vmm"', + "source=Notarized Developer ID", + "TeamIdentifier=864H636QW4", + "a pre-existing network helper would be replaced", + "pre-existing PF authority marker", + "pre-existing forwarding authority marker", + "--unregister-network-helper", + "network helper survived final cleanup", + "run authority already exists", + "source_network_helper_unregistered=PASS", + 'if [ "$SOURCE_ENABLED" != 1 ] || [ "$SOURCE_RESULT" != PASS ]', + "host_boot_session_unchanged=PASS", + "host_panic_report_absence=PASS", + ): + self.assertIn(contract, source) + + def test_indirect_input_fails_before_physical_host_probe(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-vz-ipv6-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + linked = temporary / "linked-dory-vmm" + linked.symlink_to(paths["dory-vmm"]) + paths["dory-vmm"] = linked + result = self.invoke(paths, temporary) + self.assertNotEqual(result.returncode, 0) + self.assertIn("input must be a direct file", result.stdout) + + def test_source_confirmation_and_workroot_authority_fail_before_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-vz-ipv6-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + confirmation = self.invoke( + paths, + temporary, + extra=("--app", str(temporary / "Dory.app"), "--source-confirm", "wrong"), + ) + workroot = self.invoke(paths, temporary, workroot_name="unscoped") + self.assertIn("exact confirmation token", confirmation.stdout) + self.assertIn("dedicated VZ gate name", workroot.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 013c1332..4e671261 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,6 +340,7 @@ jobs: python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py + python3 .github/scripts/test-vz-native-ipv6-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eaacdb2f..294675a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,7 @@ jobs: python3 .github/scripts/test-release-performance-gate.py python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py + python3 .github/scripts/test-vz-native-ipv6-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/vz-native-ipv6-gate.sh b/scripts/vz-native-ipv6-gate.sh new file mode 100755 index 00000000..211905e3 --- /dev/null +++ b/scripts/vz-native-ipv6-gate.sh @@ -0,0 +1,981 @@ +#!/bin/bash +# Proves the macOS 14 Virtualization.framework fallback has the same native IPv6 and published-port +# contract as dory-hv. Every mutable asset and Docker object is isolated under --workroot. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VMM="" +HV="" +GVPROXY="" +GVPROXY_PROVENANCE="" +PAYLOAD_INVENTORY="" +KERNEL="" +ROOTFS="" +DOCKER="" +WORKROOT="${TMPDIR:-/tmp}/dory-vz-native-ipv6-evidence" +EXTERNAL_IPV6="2606:4700:4700::1111" +REQUIRE_EXTERNAL=0 +REQUIRE_SONOMA=0 +KEEP=0 +FIXTURE_IMAGE="alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" +SSH_CLIENT_IMAGE="" +SOURCE_APP="" +SOURCE_LAN_HOST="" +SOURCE_LAN_PEER="" +SOURCE_TAILSCALE_HOST="" +SOURCE_TAILSCALE_PEER="" +SOURCE_SERVER_IMAGE="" +SOURCE_CONFIRM="" +SOURCE_ENABLED=0 +SOURCE_PRESSURE_MIB=960 +SOURCE_PRESSURE_ROUNDS=10 +SOURCE_PRIVILEGED_PORT="${DORY_VZ_SOURCE_PRIVILEGED_PORT:-80}" + +usage() { + cat <<'EOF' +Usage: scripts/vz-native-ipv6-gate.sh --dory-vmm PATH --gvproxy PATH --kernel PATH --rootfs PATH --docker PATH [options] + +Options: + --dory-hv PATH Helper used only to decompress .lzfse kernel/rootfs inputs + --workroot DIR Durable evidence root + --gvproxy-provenance PATH Signed-app gvproxy build provenance + --payload-inventory PATH Signed-app payload digest inventory + --external-ipv6 IP Real IPv6 TCP endpoint used on a host with IPv6 routing + --require-external Fail unless the host and container reach the IPv6 endpoint + --require-sonoma Fail unless this is macOS 14.x (the fallback's release target) + --keep-workload Preserve disposable VM files as well as evidence + --fixture-image REF Digest-pinned Alpine-compatible IPv6 fixture + --ssh-client-image REF Digest-pinned image containing sh and ssh-add + +Exact physical source-IP certification (all options are required together): + --app Dory.app Exact signed/notarized candidate app + --lan-host-address IPv4 Candidate Mac's physical-LAN address + --lan-peer-ssh USER@HOST Real noninteractive physical-LAN peer + --tailscale-host-address IPv4 Candidate Mac's Tailscale address + --tailscale-peer-ssh USER@HOST Real noninteractive Tailscale peer + --source-server-image REF Digest-pinned image containing python3 + --source-privileged-port PORT Free interface-specific TCP port below 1024 (default: 80) + --source-confirm TOKEN PHYSICAL-VZ-SOURCE-PRESERVATION +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --dory-vmm) VMM="${2:?missing path}"; shift 2 ;; + --dory-hv) HV="${2:?missing path}"; shift 2 ;; + --gvproxy) GVPROXY="${2:?missing path}"; shift 2 ;; + --gvproxy-provenance) GVPROXY_PROVENANCE="${2:?missing path}"; shift 2 ;; + --payload-inventory) PAYLOAD_INVENTORY="${2:?missing path}"; shift 2 ;; + --kernel) KERNEL="${2:?missing path}"; shift 2 ;; + --rootfs) ROOTFS="${2:?missing path}"; shift 2 ;; + --docker) DOCKER="${2:?missing path}"; shift 2 ;; + --workroot) WORKROOT="${2:?missing directory}"; shift 2 ;; + --external-ipv6) EXTERNAL_IPV6="${2:?missing address}"; shift 2 ;; + --require-external) REQUIRE_EXTERNAL=1; shift ;; + --require-sonoma) REQUIRE_SONOMA=1; shift ;; + --keep-workload) KEEP=1; shift ;; + --fixture-image) FIXTURE_IMAGE="${2:?missing image}"; shift 2 ;; + --ssh-client-image) SSH_CLIENT_IMAGE="${2:?missing image}"; shift 2 ;; + --app) SOURCE_APP="${2:?missing app}"; shift 2 ;; + --lan-host-address) SOURCE_LAN_HOST="${2:?missing address}"; shift 2 ;; + --lan-peer-ssh) SOURCE_LAN_PEER="${2:?missing peer}"; shift 2 ;; + --tailscale-host-address) SOURCE_TAILSCALE_HOST="${2:?missing address}"; shift 2 ;; + --tailscale-peer-ssh) SOURCE_TAILSCALE_PEER="${2:?missing peer}"; shift 2 ;; + --source-server-image) SOURCE_SERVER_IMAGE="${2:?missing image}"; shift 2 ;; + --source-privileged-port) SOURCE_PRIVILEGED_PORT="${2:?missing port}"; shift 2 ;; + --source-confirm) SOURCE_CONFIRM="${2:?missing token}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "VZ native IPv6 gate: unknown argument: $1" >&2; usage >&2; exit 64 ;; + esac +done + +for command in cmp codesign curl lsof nc netstat python3 shasum spctl ssh ssh-add sudo xcrun; do + command -v "$command" >/dev/null \ + || { echo "VZ native IPv6 gate: missing command: $command" >&2; exit 69; } +done + +direct_file() { + local path="$1" logical physical + [ -f "$path" ] && [ ! -L "$path" ] \ + || { echo "VZ native IPv6 gate: input must be a direct file: $path" >&2; exit 66; } + logical="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$path")" + physical="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")" + [ "$logical" = "$physical" ] \ + || { echo "VZ native IPv6 gate: input has an indirect ancestor: $path" >&2; exit 66; } + printf '%s\n' "$physical" +} +VMM="$(direct_file "$VMM")" +GVPROXY="$(direct_file "$GVPROXY")" +KERNEL="$(direct_file "$KERNEL")" +ROOTFS="$(direct_file "$ROOTFS")" +DOCKER="$(direct_file "$DOCKER")" +if [ -n "$HV" ]; then HV="$(direct_file "$HV")"; fi +if [ -n "$GVPROXY_PROVENANCE" ]; then + GVPROXY_PROVENANCE="$(direct_file "$GVPROXY_PROVENANCE")" +fi +if [ -n "$PAYLOAD_INVENTORY" ]; then + PAYLOAD_INVENTORY="$(direct_file "$PAYLOAD_INVENTORY")" +fi +[ -x "$VMM" ] && [ -x "$GVPROXY" ] && [ -x "$DOCKER" ] \ + || { echo "VZ native IPv6 gate: helper inputs must be executable" >&2; exit 66; } +printf '%s\n' "$FIXTURE_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || { echo "VZ native IPv6 gate: fixture image must be digest-pinned" >&2; exit 64; } +case "$FIXTURE_IMAGE" in *[[:space:]]*) echo "VZ native IPv6 gate: fixture image contains whitespace" >&2; exit 64 ;; esac +if [ -n "$SSH_CLIENT_IMAGE" ]; then + printf '%s\n' "$SSH_CLIENT_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || { echo "VZ native IPv6 gate: SSH client image must be digest-pinned" >&2; exit 64; } + [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ] \ + || { echo "VZ native IPv6 gate: SSH client certification requires a live SSH_AUTH_SOCK" >&2; exit 69; } + ssh-add -L >/dev/null 2>&1 \ + || { echo "VZ native IPv6 gate: SSH client certification requires a loaded identity" >&2; exit 69; } +fi + +for value in "$SOURCE_APP" "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" \ + "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" "$SOURCE_SERVER_IMAGE" "$SOURCE_CONFIRM"; do + [ -z "$value" ] || SOURCE_ENABLED=1 +done +if [ "$SOURCE_ENABLED" = 1 ]; then + [ "$SOURCE_CONFIRM" = PHYSICAL-VZ-SOURCE-PRESERVATION ] \ + || { echo "VZ native IPv6 gate: physical source certification requires its exact confirmation token" >&2; exit 64; } + for value in "$SOURCE_APP" "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" \ + "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" "$SOURCE_SERVER_IMAGE"; do + [ -n "$value" ] || { echo "VZ native IPv6 gate: every physical source option is required" >&2; exit 64; } + done + [ -d "$SOURCE_APP" ] && [ ! -L "$SOURCE_APP" ] && [ "$(basename "$SOURCE_APP")" = Dory.app ] \ + || { echo "VZ native IPv6 gate: candidate app must be a direct Dory.app" >&2; exit 66; } + SOURCE_APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$SOURCE_APP")" + SOURCE_APP="$(cd "$SOURCE_APP" && pwd -P)" + [ "$SOURCE_APP" = "$SOURCE_APP_LOGICAL" ] \ + || { echo "VZ native IPv6 gate: candidate app has an indirect ancestor" >&2; exit 66; } + printf '%s\n' "$SOURCE_SERVER_IMAGE" | grep -Eq '^.+@sha256:[0-9a-f]{64}$' \ + || { echo "VZ native IPv6 gate: source server image must be digest-pinned" >&2; exit 64; } + case "$SOURCE_SERVER_IMAGE" in *[[:space:]]*) echo "VZ native IPv6 gate: source server image contains whitespace" >&2; exit 64 ;; esac + for peer in "$SOURCE_LAN_PEER" "$SOURCE_TAILSCALE_PEER"; do + printf '%s\n' "$peer" | grep -Eq '^[A-Za-z0-9_.+-]+@[A-Za-z0-9_.:-]+$' \ + || { echo "VZ native IPv6 gate: source peer must be one safe USER@HOST authority" >&2; exit 64; } + done + case "$SOURCE_PRIVILEGED_PORT" in ''|*[!0-9]*) + echo "VZ native IPv6 gate: --source-privileged-port must be an integer" >&2; exit 64 ;; + esac + [ "$SOURCE_PRIVILEGED_PORT" -ge 1 ] && [ "$SOURCE_PRIVILEGED_PORT" -lt 1024 ] \ + || { echo "VZ native IPv6 gate: --source-privileged-port must be between 1 and 1023" >&2; exit 64; } + python3 - "$SOURCE_LAN_HOST" "$SOURCE_TAILSCALE_HOST" <<'PY' +import ipaddress, sys +for raw in sys.argv[1:]: + value = ipaddress.IPv4Address(raw) + if value.is_unspecified or value.is_multicast or value.is_loopback: + raise SystemExit("source address is not unicast") +PY + sudo -n true >/dev/null 2>&1 \ + || { echo "VZ native IPv6 gate: physical source certification requires noninteractive sudo" >&2; exit 77; } +fi + +case "$WORKROOT" in /*) ;; *) echo "VZ native IPv6 gate: workroot must be absolute" >&2; exit 64 ;; esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +case "$WORKROOT_NAME" in + dory-vz-sonoma|dory-vz-native-ipv6-evidence) ;; + *) echo "VZ native IPv6 gate: workroot must use a dedicated VZ gate name" >&2; exit 64 ;; +esac +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || { echo "VZ native IPv6 gate: workroot parent must be a direct directory" >&2; exit 64; } +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] || { echo "VZ native IPv6 gate: temporary authority is unavailable" >&2; exit 64; } +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +case "$TEMP_AUTHORITY" in /|"$HOME"|"$ROOT"|"$SOURCE_APP") echo "VZ native IPv6 gate: unsafe temporary authority" >&2; exit 64 ;; esac +case "$WORKROOT" in "$TEMP_AUTHORITY"/*) ;; *) echo "VZ native IPv6 gate: workroot must be inside runner temporary storage" >&2; exit 64 ;; esac +for input in "$VMM" "$HV" "$GVPROXY" "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY" \ + "$KERNEL" "$ROOTFS" "$DOCKER" "$SOURCE_APP"; do + [ -z "$input" ] && continue + case "$input/" in "$WORKROOT/"*) echo "VZ native IPv6 gate: workroot cannot contain an input" >&2; exit 64 ;; esac + case "$WORKROOT/" in "$input/"*) echo "VZ native IPv6 gate: workroot cannot be inside an input" >&2; exit 64 ;; esac +done +[ "$(uname -s)" = Darwin ] && [ "$(uname -m)" = arm64 ] \ + || { echo "VZ native IPv6 gate: physical Apple-silicon macOS is required" >&2; exit 69; } +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || { echo "VZ native IPv6 gate: Hypervisor.framework is unavailable" >&2; exit 69; } +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || { echo "VZ native IPv6 gate: nested virtualization does not qualify" >&2; exit 69; } +case "$(sysctl -n hw.model 2>/dev/null || printf unknown)" in + VirtualMac*) echo "VZ native IPv6 gate: a physical Mac is required" >&2; exit 69 ;; +esac +if [ -e "$WORKROOT" ] || [ -L "$WORKROOT" ]; then + [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || { echo "VZ native IPv6 gate: workroot must not be indirect" >&2; exit 64; } + [ "$(stat -f '%u' "$WORKROOT")" = "$(id -u)" ] \ + || { echo "VZ native IPv6 gate: workroot is not owned by this user" >&2; exit 64; } +else + mkdir "$WORKROOT" +fi +chmod 700 "$WORKROOT" + +OS_VERSION="$(sw_vers -productVersion)" +OS_MAJOR="${OS_VERSION%%.*}" +if [ "$REQUIRE_SONOMA" = 1 ] && [ "$OS_MAJOR" != 14 ]; then + echo "VZ native IPv6 gate: --require-sonoma needs macOS 14.x; found $OS_VERSION" >&2 + exit 69 +fi +if [ "$REQUIRE_SONOMA" = 1 ] && [ -z "$SSH_CLIENT_IMAGE" ]; then + echo "VZ native IPv6 gate: Sonoma release certification requires --ssh-client-image" >&2 + exit 64 +fi + +# shellcheck source=gvproxy-payload.sh +source "$ROOT/scripts/gvproxy-payload.sh" +dory_gvproxy_validate_overrides +if [ -n "$GVPROXY_PROVENANCE$PAYLOAD_INVENTORY" ]; then + [ -n "$GVPROXY_PROVENANCE" ] && [ -n "$PAYLOAD_INVENTORY" ] \ + || { echo "VZ native IPv6 gate: signed gvproxy provenance and payload inventory must be supplied together" >&2; exit 64; } + dory_verify_signed_gvproxy_payload "$GVPROXY" "$GVPROXY_PROVENANCE" "$PAYLOAD_INVENTORY" +else + dory_verify_gvproxy_payload \ + "$GVPROXY" "$(dory_gvproxy_version)" "$(dory_gvproxy_expected_sha256)" +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +WORK="$RUN_ROOT/work" +TEST_HOME="$WORK/home" +STATE="$WORK/s" +DRIVE="$TEST_HOME/Library/Application Support/Dory/Dory.dorydrive" +HANDOFF="$WORK/h.sock" +HANDOFF_JSON="$WORK/handoff.json" +PORT_FILE="$WORK/host-port" +VMM_PID="" +HANDOFF_PID="" +HOST_PID="" +GVPROXY_PID="" +HELPER_REGISTERED=0 +cleanup_privileged_helper() { + if [ "$HELPER_REGISTERED" -eq 1 ] && [ -n "$SOURCE_APP" ]; then + "$SOURCE_APP/Contents/MacOS/Dory" --unregister-network-helper >/dev/null 2>&1 || true + fi +} +trap cleanup_privileged_helper EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +[ ! -e "$RUN_ROOT" ] && [ ! -L "$RUN_ROOT" ] \ + || { echo "VZ native IPv6 gate: run authority already exists" >&2; exit 64; } +mkdir "$RUN_ROOT" +chmod 700 "$RUN_ROOT" +mkdir "$EVIDENCE" "$WORK" +if [ "$SOURCE_ENABLED" = 1 ]; then + for source_address in "$SOURCE_LAN_HOST" "$SOURCE_TAILSCALE_HOST"; do + owner_file="$EVIDENCE/source-privileged-owner-${source_address//[^A-Za-z0-9]/_}.txt" + if sudo -n lsof -nP -iTCP@"$source_address:$SOURCE_PRIVILEGED_PORT" -sTCP:LISTEN \ + > "$owner_file" 2>&1; then + echo "VZ native IPv6 gate: interface-specific privileged port is already owned: $source_address:$SOURCE_PRIVILEGED_PORT" >&2 + exit 1 + fi + done +fi +HOST_BOOT_EPOCH_BEFORE="$(/usr/sbin/sysctl -n kern.boottime \ + | sed -n 's/.*sec = \([0-9][0-9]*\).*/\1/p')" +printf '%s\n' "$HOST_BOOT_EPOCH_BEFORE" | grep -Eq '^[0-9]+$' \ + || { echo "VZ native IPv6 gate: could not capture host boot epoch" >&2; exit 1; } +PANIC_MARKER="$EVIDENCE/host-panic-window-start" +touch "$PANIC_MARKER" + +if [ "$SOURCE_ENABLED" = 1 ]; then + codesign --verify --strict --deep "$SOURCE_APP" + xcrun stapler validate "$SOURCE_APP" >/dev/null + source_assessment="$(spctl --assess --type execute --verbose=4 "$SOURCE_APP" 2>&1)" \ + || { echo "VZ native IPv6 gate: Gatekeeper rejected the exact app" >&2; exit 1; } + grep -F 'source=Notarized Developer ID' <<< "$source_assessment" >/dev/null \ + || { echo "VZ native IPv6 gate: app is not accepted as Notarized Developer ID" >&2; exit 1; } + source_signature="$(codesign -dv --verbose=4 "$SOURCE_APP" 2>&1)" + grep -F 'TeamIdentifier=864H636QW4' <<< "$source_signature" >/dev/null \ + || { echo "VZ native IPv6 gate: app has the wrong signing team" >&2; exit 1; } + [ "$VMM" = "$SOURCE_APP/Contents/Helpers/dory-vmm" ] \ + && [ "$HV" = "$SOURCE_APP/Contents/Helpers/dory-hv" ] \ + && [ "$GVPROXY" = "$SOURCE_APP/Contents/Helpers/gvproxy" ] \ + && [ "$DOCKER" = "$SOURCE_APP/Contents/Helpers/docker" ] \ + && [ "$GVPROXY_PROVENANCE" = "$SOURCE_APP/Contents/Resources/gvproxy-provenance.txt" ] \ + && [ "$PAYLOAD_INVENTORY" = "$SOURCE_APP/Contents/Resources/dory-payload-sha256.txt" ] \ + && [ "$KERNEL" = "$SOURCE_APP/Contents/Resources/dory-hv-kernel-arm64.lzfse" ] \ + && [ "$ROOTFS" = "$SOURCE_APP/Contents/Resources/dory-engine-rootfs-arm64.ext4.lzfse" ] \ + || { echo "VZ native IPv6 gate: release inputs are not exact embedded app artifacts" >&2; exit 1; } + if sudo -n /bin/launchctl print system/dev.dory.network-helper >/dev/null 2>&1; then + echo "VZ native IPv6 gate: a pre-existing network helper would be replaced" >&2 + exit 1 + fi + sudo -n test ! -e /var/run/dev.dory/pf-enable-token \ + || { echo "VZ native IPv6 gate: pre-existing PF authority marker" >&2; exit 1; } + sudo -n test ! -e /var/run/dev.dory/ipv4-forwarding-owner \ + || { echo "VZ native IPv6 gate: pre-existing forwarding authority marker" >&2; exit 1; } + HELPER_REGISTERED=1 + "$SOURCE_APP/Contents/MacOS/Dory" --register-network-helper \ + > "$EVIDENCE/network-helper-registration.txt" 2>&1 \ + || { echo "VZ native IPv6 gate: exact app network helper is not approved" >&2; exit 1; } + grep -qx 'network-helper=enabled' "$EVIDENCE/network-helper-registration.txt" \ + || { echo "VZ native IPv6 gate: exact app did not confirm its network helper" >&2; exit 1; } + sudo -n /sbin/pfctl -s References > "$EVIDENCE/pf-references-before.txt" 2>&1 \ + || { echo "VZ native IPv6 gate: could not capture PF reference baseline" >&2; exit 1; } + /usr/sbin/sysctl -n net.inet.ip.forwarding > "$EVIDENCE/ipv4-forwarding-before.txt" + + python3 - "$WORK/source-server.py" <<'PY' +import pathlib, sys +pathlib.Path(sys.argv[1]).write_text(r''' +import socket, threading + +def tcp(): + server = socket.socket(); server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("0.0.0.0", 8080)); server.listen(16) + while True: + connection, peer = server.accept() + token = connection.recv(1024).decode().strip() + print(f"tcp={peer[0]} token={token}", flush=True) + connection.sendall((token + "\n").encode()); connection.close() + +def udp(): + server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); server.bind(("0.0.0.0", 8080)) + while True: + payload, peer = server.recvfrom(2048); token = payload.decode().strip() + print(f"udp={peer[0]} token={token}", flush=True) + server.sendto((token + "\n").encode(), peer) + +threading.Thread(target=tcp, daemon=True).start(); udp() +''', encoding="utf-8") +PY + python3 - "$WORK/source-peer.py" <<'PY' +import pathlib, sys +pathlib.Path(sys.argv[1]).write_text(r''' +import socket, sys +host, port, loop_port, privileged_port, token, operation = sys.argv[1:] +port = int(port); loop_port = int(loop_port); privileged_port = int(privileged_port) + +def tcp_open(target_port, payload=None): + connection = socket.create_connection((host, target_port), timeout=4) + source = connection.getsockname()[0] + if payload is not None: + connection.sendall((payload + "\n").encode()) + if connection.recv(1024).decode().strip() != payload: + raise SystemExit("TCP echo payload mismatch") + connection.close(); return source + +if operation == "run": + print("tcp_source=" + tcp_open(port, token)) + print("privileged_tcp_source=" + tcp_open(privileged_port, token + "-privileged")) + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); udp.settimeout(4) + udp.connect((host, port)); source = udp.getsockname()[0] + udp.send((token + "\n").encode()) + if udp.recv(1024).decode().strip() != token: + raise SystemExit("UDP echo payload mismatch") + udp.close(); print("udp_source=" + source) + try: tcp_open(loop_port) + except OSError: print("loopback_isolated=PASS") + else: raise SystemExit("explicit loopback publication was reachable remotely") +elif operation == "closed": + try: tcp_open(port) + except OSError: print("tcp_unpublished=PASS") + else: raise SystemExit("unpublished TCP port remains reachable") + try: tcp_open(privileged_port) + except OSError: print("privileged_tcp_unpublished=PASS") + else: raise SystemExit("unpublished interface-specific privileged TCP port remains reachable") + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); udp.settimeout(2) + try: udp.sendto(b"closed", (host, port)); udp.recvfrom(1024) + except OSError: print("udp_unpublished=PASS") + else: raise SystemExit("unpublished UDP port remains reachable") +''', encoding="utf-8") +PY +fi + +prepare_asset() { + source_path="$1" + output_name="$2" + destination="$WORK/$output_name" + case "$source_path" in + *.lzfse) + [ -n "$HV" ] && [ -x "$HV" ] \ + || { echo "VZ native IPv6 gate: compressed inputs require executable --dory-hv" >&2; exit 66; } + "$HV" lzfse decompress "$source_path" "$destination" > "$EVIDENCE/decompress-$output_name.log" + ;; + *) + cp -c "$source_path" "$destination" 2>/dev/null || cp "$source_path" "$destination" + ;; + esac + printf '%s\n' "$destination" +} +KERNEL="$(prepare_asset "$KERNEL" kernel)" +ROOTFS="$(prepare_asset "$ROOTFS" rootfs.ext4)" + +stop_vmm() { + [ -n "$VMM_PID" ] || return 0 + if kill -0 "$VMM_PID" 2>/dev/null; then kill -TERM "$VMM_PID" 2>/dev/null || true; fi + for _ in $(seq 1 300); do + if ! kill -0 "$VMM_PID" 2>/dev/null; then + wait "$VMM_PID" 2>/dev/null || true + VMM_PID="" + if [ -n "$GVPROXY_PID" ] && kill -0 "$GVPROXY_PID" 2>/dev/null; then + echo "VZ native IPv6 gate: gvproxy survived graceful VMM shutdown" >&2 + return 1 + fi + GVPROXY_PID="" + return 0 + fi + sleep 0.1 + done + kill -KILL "$VMM_PID" 2>/dev/null || true + wait "$VMM_PID" 2>/dev/null || true + VMM_PID="" + echo "VZ native IPv6 gate: dory-vmm did not stop within 30 seconds" >&2 + return 1 +} + +cleanup() { + status=$? + set +e + stop_vmm + [ -z "$HANDOFF_PID" ] || kill "$HANDOFF_PID" 2>/dev/null || true + [ -z "$HANDOFF_PID" ] || wait "$HANDOFF_PID" 2>/dev/null || true + [ -z "$HOST_PID" ] || kill "$HOST_PID" 2>/dev/null || true + [ -z "$HOST_PID" ] || wait "$HOST_PID" 2>/dev/null || true + cleanup_privileged_helper + if [ "$KEEP" -ne 1 ]; then rm -rf "$WORK"; fi + trap - EXIT INT TERM + exit "$status" +} +trap cleanup EXIT + +python3 - "$PORT_FILE" <<'PY' & +import pathlib, socket, sys +s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) +s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("::1", 0)); s.listen(32) +pathlib.Path(sys.argv[1]).write_text(str(s.getsockname()[1])) +response = b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\ndory-ipv6-loop\n" +while True: + conn, _ = s.accept() + with conn: + conn.recv(65536); conn.sendall(response) +PY +HOST_PID=$! +for _ in $(seq 1 100); do [ -s "$PORT_FILE" ] && break; sleep 0.05; done +[ -s "$PORT_FILE" ] || { echo "VZ native IPv6 gate: host listener did not start" >&2; exit 1; } +HOST_PORT="$(cat "$PORT_FILE")" + +start_handoff_listener() { + rm -f "$HANDOFF" "$HANDOFF_JSON" + python3 - "$HANDOFF" "$HANDOFF_JSON" <<'PY' & +import json, os, pathlib, socket, sys +sock_path, output = sys.argv[1:] +try: os.unlink(sock_path) +except FileNotFoundError: pass +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.bind(sock_path); s.listen(1) +conn, _ = s.accept() +with conn: + chunks = [] + while True: + chunk = conn.recv(16384) + if not chunk: break + chunks.append(chunk) +payload = b"".join(chunks) +body = json.loads(payload) +if body.get("machineID") != "docker": + raise SystemExit("handoff machine identity mismatch") +if not body.get("dockerdSocketPath"): + raise SystemExit("handoff omitted the Docker socket path") +pathlib.Path(output).write_text(json.dumps(body, sort_keys=True) + "\n") +PY + HANDOFF_PID=$! + for _ in $(seq 1 100); do [ -S "$HANDOFF" ] && return 0; sleep 0.05; done + echo "VZ native IPv6 gate: handoff listener did not start" >&2 + exit 1 +} + +start_vmm() { + cycle="$1" + publish_host=127.0.0.1 + [ "$SOURCE_ENABLED" = 0 ] || publish_host=0.0.0.0 + start_handoff_listener + ssh_agent_args=() + if [ -n "$SSH_CLIENT_IMAGE" ]; then + ssh_agent_args=(--ssh-agent-socket "$SSH_AUTH_SOCK") + fi + HOME="$TEST_HOME" "$VMM" \ + --machine-id docker --state-dir "$STATE" --data-drive "$DRIVE" \ + --kernel "$KERNEL" --rootfs "$ROOTFS" --gvproxy "$GVPROXY" \ + --handoff-sock "$HANDOFF" --memory-mb 2048 --cpus 2 \ + --publish-host "$publish_host" \ + "${ssh_agent_args[@]}" \ + >"$EVIDENCE/vmm-$cycle.log" 2>&1 & + VMM_PID=$! + for _ in $(seq 1 240); do + kill -0 "$VMM_PID" 2>/dev/null || { + echo "VZ native IPv6 gate: dory-vmm exited during $cycle" >&2 + tail -n 120 "$EVIDENCE/vmm-$cycle.log" >&2 + exit 1 + } + if [ -s "$HANDOFF_JSON" ]; then + wait "$HANDOFF_PID"; HANDOFF_PID="" + DOCKER_SOCKET="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["dockerdSocketPath"])' "$HANDOFF_JSON")" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" info >/dev/null 2>&1 && { + GVPROXY_PID="$(pgrep -P "$VMM_PID" -f "$GVPROXY" | head -1 || true)" + [ -n "$GVPROXY_PID" ] || { echo "VZ native IPv6 gate: gvproxy child is missing" >&2; exit 1; } + cp "$HANDOFF_JSON" "$EVIDENCE/handoff-$cycle.json" + return 0 + } + fi + sleep 0.5 + done + echo "VZ native IPv6 gate: Docker readiness timed out during $cycle" >&2 + exit 1 +} + +verify_ssh_agent() { + cycle="$1" + [ -n "$SSH_CLIENT_IMAGE" ] || return 0 + gate_root="$WORK/ssh-agent-$cycle" + scripts/ssh-agent-forwarding-gate.sh \ + --socket "$DOCKER_SOCKET" \ + --docker "$DOCKER" \ + --image "$SSH_CLIENT_IMAGE" \ + --workroot "$gate_root" \ + --concurrency 8 \ + > "$EVIDENCE/ssh-agent-$cycle.log" 2>&1 + gate_manifest="$(find "$gate_root" -type f -name manifest.txt -print)" + [ "$(printf '%s\n' "$gate_manifest" | awk 'NF { count++ } END { print count + 0 }')" = 1 ] \ + || { echo "VZ native IPv6 gate: SSH-agent evidence is missing or ambiguous" >&2; exit 1; } + cp "$gate_manifest" "$EVIDENCE/ssh-agent-$cycle.txt" +} + +free_port() { + python3 - <<'PY' +import socket +s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close() +PY +} + +wait_http() { + url="$1" expected="$2" + for _ in $(seq 1 60); do + [ "$(curl -gfsS --connect-timeout 2 "$url" 2>/dev/null || true)" = "$expected" ] && return 0 + sleep 1 + done + return 1 +} + +bounded_capture() { + limit="$1"; stdout="$2"; stderr="$3"; shift 3 + "$@" > "$stdout" 2> "$stderr" & + bounded_pid=$! + bounded_started=$SECONDS + while kill -0 "$bounded_pid" 2>/dev/null; do + if [ $((SECONDS - bounded_started)) -ge "$limit" ]; then + kill -TERM "$bounded_pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$bounded_pid" 2>/dev/null || true + wait "$bounded_pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + if wait "$bounded_pid"; then return 0; else return $?; fi +} + +source_create_fixtures() { + cycle="$1" + SOURCE_SERVER_NAME="dory-vz-source-$cycle-$$" + SOURCE_LOOPBACK_NAME="dory-vz-source-loopback-$cycle-$$" + SOURCE_PRIVILEGED_NAME="dory-vz-source-privileged-$cycle-$$" + SOURCE_PRESSURE_NAME="dory-vz-source-pressure-$cycle-$$" + SOURCE_NETWORK_NAME="dory-vz-source-net-$cycle-$$" + SOURCE_SERVER_CODE="$(cat "$WORK/source-server.py")" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" pull "$SOURCE_SERVER_IMAGE" \ + > "$EVIDENCE/source-image-pull-$cycle.txt" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" network create \ + --label dev.dory.vz-source-certification=true "$SOURCE_NETWORK_NAME" >/dev/null + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" run -d --name "$SOURCE_SERVER_NAME" \ + --label dev.dory.vz-source-certification=true \ + --network "$SOURCE_NETWORK_NAME" \ + -p "$SOURCE_PORT:8080/tcp" -p "$SOURCE_PORT:8080/udp" \ + "$SOURCE_SERVER_IMAGE" python3 -c "$SOURCE_SERVER_CODE" \ + > "$EVIDENCE/source-container-$cycle.id" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" run -d --name "$SOURCE_LOOPBACK_NAME" \ + --label dev.dory.vz-source-certification=true \ + --network "$SOURCE_NETWORK_NAME" \ + -p "127.0.0.1:$SOURCE_LOOPBACK_PORT:8080/tcp" \ + "$SOURCE_SERVER_IMAGE" python3 -c "$SOURCE_SERVER_CODE" \ + > "$EVIDENCE/source-loopback-$cycle.id" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" run -d --name "$SOURCE_PRIVILEGED_NAME" \ + --label dev.dory.vz-source-certification=true \ + --network "$SOURCE_NETWORK_NAME" \ + -p "$SOURCE_LAN_HOST:$SOURCE_PRIVILEGED_PORT:8080/tcp" \ + -p "$SOURCE_TAILSCALE_HOST:$SOURCE_PRIVILEGED_PORT:8080/tcp" \ + "$SOURCE_SERVER_IMAGE" python3 -c "$SOURCE_SERVER_CODE" \ + > "$EVIDENCE/source-privileged-$cycle.id" + sleep 4 +} + +source_run_memory_pressure() { + cycle="$1" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" run -d --name "$SOURCE_PRESSURE_NAME" \ + --label dev.dory.vz-source-certification=true --network "$SOURCE_NETWORK_NAME" \ + --memory 1280m "$SOURCE_SERVER_IMAGE" python3 -c ' +import time +blocks = [] +for _ in range(60): + block = bytearray(16 * 1024 * 1024) + for offset in range(0, len(block), 4096): + block[offset] = 1 + blocks.append(block) +print("pressure_ready_mib=960", flush=True) +time.sleep(300) +' > "$EVIDENCE/source-pressure-$cycle.id" + for _ in $(seq 1 100); do + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" logs "$SOURCE_PRESSURE_NAME" 2>/dev/null \ + | grep -qx "pressure_ready_mib=$SOURCE_PRESSURE_MIB" && break + sleep 0.2 + done + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" logs "$SOURCE_PRESSURE_NAME" \ + > "$EVIDENCE/source-pressure-$cycle.log" 2>&1 + grep -qx "pressure_ready_mib=$SOURCE_PRESSURE_MIB" "$EVIDENCE/source-pressure-$cycle.log" \ + || { echo "VZ native IPv6 gate: memory-pressure workload did not become ready" >&2; return 1; } + + pressure_round=1 + while [ "$pressure_round" -le "$SOURCE_PRESSURE_ROUNDS" ]; do + [ "$(source_peer_round lan "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" \ + "memory-pressure-$pressure_round")" = "$SOURCE_LAN_FIRST" ] \ + || { echo "VZ native IPv6 gate: LAN source changed under memory pressure" >&2; return 1; } + [ "$(source_peer_round tailscale "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" \ + "memory-pressure-$pressure_round")" = "$SOURCE_TAILSCALE_FIRST" ] \ + || { echo "VZ native IPv6 gate: Tailscale source changed under memory pressure" >&2; return 1; } + bounded_capture 5 "$EVIDENCE/source-docker-version-pressure-$pressure_round.out" \ + "$EVIDENCE/source-docker-version-pressure-$pressure_round.err" \ + env DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" version \ + || { echo "VZ native IPv6 gate: Docker API stalled under memory pressure" >&2; return 1; } + bounded_capture 5 "$EVIDENCE/source-dns-pressure-$pressure_round.out" \ + "$EVIDENCE/source-dns-pressure-$pressure_round.err" \ + env DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" exec "$SOURCE_SERVER_NAME" \ + python3 -c 'import socket,sys; print(socket.getaddrinfo(sys.argv[1], 0, socket.AF_INET)[0][4][0])' \ + "$SOURCE_PRESSURE_NAME" \ + || { echo "VZ native IPv6 gate: Docker DNS stalled under memory pressure" >&2; return 1; } + bounded_capture 5 "$EVIDENCE/source-configd-pressure-$pressure_round.out" \ + "$EVIDENCE/source-configd-pressure-$pressure_round.err" /usr/sbin/scutil --dns \ + || { echo "VZ native IPv6 gate: configd stalled under memory pressure" >&2; return 1; } + sleep 3 + pressure_round=$((pressure_round + 1)) + done + pressure_state="$(DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" inspect \ + -f '{{.State.Running}} {{.State.OOMKilled}}' "$SOURCE_PRESSURE_NAME")" + [ "$pressure_state" = "true false" ] \ + || { echo "VZ native IPv6 gate: pressure workload was killed: $pressure_state" >&2; return 1; } + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" stats --no-stream \ + "$SOURCE_PRESSURE_NAME" "$SOURCE_SERVER_NAME" \ + > "$EVIDENCE/source-pressure-stats-$cycle.txt" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" rm -f "$SOURCE_PRESSURE_NAME" >/dev/null +} + +source_peer_round() { + mode="$1" host="$2" peer="$3" phase="$4" + token="dory-vz-$mode-$phase-$$" + output="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes "$peer" \ + python3 - "$host" "$SOURCE_PORT" "$SOURCE_LOOPBACK_PORT" \ + "$SOURCE_PRIVILEGED_PORT" "$token" run \ + < "$WORK/source-peer.py")" \ + || { echo "VZ native IPv6 gate: $mode source peer failed during $phase" >&2; return 1; } + printf '%s\n' "$output" > "$EVIDENCE/source-peer-$mode-$phase.txt" + grep -qx 'loopback_isolated=PASS' "$EVIDENCE/source-peer-$mode-$phase.txt" \ + || { echo "VZ native IPv6 gate: $mode loopback publication widened during $phase" >&2; return 1; } + tcp_source="$(sed -n 's/^tcp_source=//p' "$EVIDENCE/source-peer-$mode-$phase.txt")" + udp_source="$(sed -n 's/^udp_source=//p' "$EVIDENCE/source-peer-$mode-$phase.txt")" + privileged_source="$(sed -n 's/^privileged_tcp_source=//p' \ + "$EVIDENCE/source-peer-$mode-$phase.txt")" + [ -n "$tcp_source" ] && [ "$tcp_source" = "$udp_source" ] \ + && [ "$tcp_source" = "$privileged_source" ] \ + || { echo "VZ native IPv6 gate: $mode ordinary/privileged TCP or UDP source mismatch during $phase" >&2; return 1; } + case "$tcp_source" in + 127.*|192.168.127.1|192.168.127.2|192.168.127.253|192.168.215.253|192.168.215.254|"") + echo "VZ native IPv6 gate: $mode observed a Dory/loopback hop: $tcp_source" >&2; return 1 ;; + esac + for _ in $(seq 1 50); do + logs="$(DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" logs "$SOURCE_SERVER_NAME" 2>&1)" + printf '%s\n' "$logs" | grep -Fqx "tcp=$tcp_source token=$token" \ + && printf '%s\n' "$logs" | grep -Fqx "udp=$udp_source token=$token" && break + sleep 0.2 + done + printf '%s\n' "$logs" > "$EVIDENCE/source-container-$mode-$phase.log" + grep -Fqx "tcp=$tcp_source token=$token" "$EVIDENCE/source-container-$mode-$phase.log" \ + && grep -Fqx "udp=$udp_source token=$token" "$EVIDENCE/source-container-$mode-$phase.log" \ + || { echo "VZ native IPv6 gate: container lost $mode source identity during $phase" >&2; return 1; } + for _ in $(seq 1 50); do + privileged_logs="$(DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" logs \ + "$SOURCE_PRIVILEGED_NAME" 2>&1)" + printf '%s\n' "$privileged_logs" \ + | grep -Fqx "tcp=$privileged_source token=$token-privileged" && break + sleep 0.2 + done + printf '%s\n' "$privileged_logs" \ + > "$EVIDENCE/source-privileged-$mode-$phase.log" + grep -Fqx "tcp=$privileged_source token=$token-privileged" \ + "$EVIDENCE/source-privileged-$mode-$phase.log" \ + || { echo "VZ native IPv6 gate: interface-specific privileged port lost $mode source identity during $phase" >&2; return 1; } + printf '%s\n' "$tcp_source" +} + +source_remove_and_verify_closed() { + cycle="$1" + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" rm -f "$SOURCE_PRESSURE_NAME" \ + >/dev/null 2>&1 || true + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" rm -f \ + "$SOURCE_SERVER_NAME" "$SOURCE_LOOPBACK_NAME" "$SOURCE_PRIVILEGED_NAME" >/dev/null + for mode in lan tailscale; do + if [ "$mode" = lan ]; then host="$SOURCE_LAN_HOST"; peer="$SOURCE_LAN_PEER" + else host="$SOURCE_TAILSCALE_HOST"; peer="$SOURCE_TAILSCALE_PEER"; fi + closed_output="$EVIDENCE/source-unpublish-$mode-$cycle.txt" + passed=0 + for _ in $(seq 1 20); do + if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes "$peer" \ + python3 - "$host" "$SOURCE_PORT" "$SOURCE_LOOPBACK_PORT" \ + "$SOURCE_PRIVILEGED_PORT" "dory-vz-$cycle" closed \ + < "$WORK/source-peer.py" > "$closed_output" 2>&1; then + passed=1 + break + fi + sleep 0.5 + done + [ "$passed" = 1 ] && grep -qx 'tcp_unpublished=PASS' "$closed_output" \ + && grep -qx 'udp_unpublished=PASS' "$closed_output" \ + && grep -qx 'privileged_tcp_unpublished=PASS' "$closed_output" \ + || { echo "VZ native IPv6 gate: $mode listeners survived $cycle cleanup" >&2; return 1; } + done + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" network rm "$SOURCE_NETWORK_NAME" >/dev/null +} + +verify_cycle() { + cycle="$1" + export DOCKER_HOST="unix://$DOCKER_SOCKET" + "$DOCKER" network inspect bridge > "$EVIDENCE/bridge-$cycle.json" + "$DOCKER" run --rm "$FIXTURE_IMAGE" sh -ec " + ip -6 address show dev eth0 + ip -6 route show + nslookup -type=AAAA one.one.one.one + nslookup -type=AAAA registry-1.docker.io + test \"\$(wget -T 10 -qO- 'http://[fd7d:6f72:7900::1]:$HOST_PORT/')\" = dory-ipv6-loop + " > "$EVIDENCE/container-$cycle.txt" + python3 - "$EVIDENCE/bridge-$cycle.json" "$EVIDENCE/container-$cycle.txt" <<'PY' +import json, pathlib, sys +bridge = json.loads(pathlib.Path(sys.argv[1]).read_text())[0] +text = pathlib.Path(sys.argv[2]).read_text() +def require(condition, message): + if not condition: + raise SystemExit(message) + +require(bridge.get("EnableIPv6") is True, "Docker bridge IPv6 is disabled") +require(any(x.get("Subnet") == "fd7d:6f72:7901::/64" for x in bridge["IPAM"]["Config"]), + "Docker bridge subnet mismatch") +require("inet6 fd7d:6f72:7901::" in text, "container global IPv6 address is absent") +require("2606:4700:4700::" in text, "Cloudflare AAAA resolution is absent") +require("2600:1f18:" in text, "registry AAAA resolution is absent") +PY + + wildcard_name="dory-vz-wildcard-$cycle-$$" + wildcard_port="$(free_port)" + "$DOCKER" run -d --name "$wildcard_name" -p "$wildcard_port:8080" "$FIXTURE_IMAGE" sh -c \ + "while true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\ndory-port' | nc -l -p 8080; done" \ + > "$EVIDENCE/wildcard-$cycle.id" + wait_http "http://127.0.0.1:$wildcard_port/" dory-port \ + && wait_http "http://[::1]:$wildcard_port/" dory-port \ + || { echo "VZ native IPv6 gate: dual-stack localhost publication failed" >&2; exit 1; } + + ipv4_name="dory-vz-v4only-$cycle-$$" + ipv4_port="$(free_port)" + intent="{\"8080/tcp\":{\"$ipv4_port\":\"ipv4\"}}" + "$DOCKER" run -d --name "$ipv4_name" \ + --label "dev.dory.internal.loopback-port-intent=$intent" \ + -p "0.0.0.0:$ipv4_port:8080" "$FIXTURE_IMAGE" sh -c \ + "while true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: 7\\r\\nConnection: close\\r\\n\\r\\ndory-v4' | nc -l -p 8080; done" \ + > "$EVIDENCE/ipv4-only-$cycle.id" + wait_http "http://127.0.0.1:$ipv4_port/" dory-v4 \ + || { echo "VZ native IPv6 gate: explicit IPv4 loopback publication failed" >&2; exit 1; } + if curl -gfsS --connect-timeout 2 "http://[::1]:$ipv4_port/" >/dev/null 2>&1; then + echo "VZ native IPv6 gate: IPv4 loopback publication widened to IPv6" >&2 + exit 1 + fi + + "$DOCKER" rm -f "$wildcard_name" "$ipv4_name" >/dev/null + for _ in $(seq 1 20); do + if ! curl -fsS --connect-timeout 1 "http://127.0.0.1:$wildcard_port/" >/dev/null 2>&1 \ + && ! curl -gfsS --connect-timeout 1 "http://[::1]:$wildcard_port/" >/dev/null 2>&1 \ + && ! curl -fsS --connect-timeout 1 "http://127.0.0.1:$ipv4_port/" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + echo "VZ native IPv6 gate: removed container left a published listener" >&2 + exit 1 +} + +SOURCE_RESULT=SKIP +SOURCE_LAN_FIRST=SKIP +SOURCE_TAILSCALE_FIRST=SKIP +if [ "$SOURCE_ENABLED" = 1 ]; then + SOURCE_PORT="$(free_port)" + SOURCE_LOOPBACK_PORT="$(free_port)" + [ "$SOURCE_PORT" != "$SOURCE_LOOPBACK_PORT" ] || SOURCE_LOOPBACK_PORT="$(free_port)" +fi + +start_vmm first +DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" image inspect "$FIXTURE_IMAGE" >/dev/null 2>&1 \ + || DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" pull "$FIXTURE_IMAGE" > "$EVIDENCE/pull.log" +if [ -n "$SSH_CLIENT_IMAGE" ]; then + DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" image inspect "$SSH_CLIENT_IMAGE" >/dev/null 2>&1 \ + || DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" pull "$SSH_CLIENT_IMAGE" \ + > "$EVIDENCE/ssh-client-pull.log" +fi +verify_cycle first +verify_ssh_agent first +if [ "$SOURCE_ENABLED" = 1 ]; then + source_create_fixtures first + SOURCE_LAN_FIRST="$(source_peer_round lan "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" first)" + SOURCE_TAILSCALE_FIRST="$(source_peer_round tailscale "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" first)" + sudo -n /bin/launchctl kickstart -k system/dev.dory.network-helper \ + > "$EVIDENCE/source-helper-restart.txt" 2>&1 \ + || { echo "VZ native IPv6 gate: could not restart the privileged network helper" >&2; exit 1; } + sleep 5 + [ "$(source_peer_round lan "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" helper-restart)" = "$SOURCE_LAN_FIRST" ] \ + || { echo "VZ native IPv6 gate: LAN source changed after helper restart" >&2; exit 1; } + [ "$(source_peer_round tailscale "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" helper-restart)" = "$SOURCE_TAILSCALE_FIRST" ] \ + || { echo "VZ native IPv6 gate: Tailscale source changed after helper restart" >&2; exit 1; } + source_run_memory_pressure first + source_remove_and_verify_closed first +fi +stop_vmm +start_vmm restart +verify_cycle restart +verify_ssh_agent restart +if [ "$SOURCE_ENABLED" = 1 ]; then + source_create_fixtures restart + [ "$(source_peer_round lan "$SOURCE_LAN_HOST" "$SOURCE_LAN_PEER" engine-restart)" = "$SOURCE_LAN_FIRST" ] \ + || { echo "VZ native IPv6 gate: LAN source changed after VZ restart" >&2; exit 1; } + [ "$(source_peer_round tailscale "$SOURCE_TAILSCALE_HOST" "$SOURCE_TAILSCALE_PEER" engine-restart)" = "$SOURCE_TAILSCALE_FIRST" ] \ + || { echo "VZ native IPv6 gate: Tailscale source changed after VZ restart" >&2; exit 1; } + source_remove_and_verify_closed restart + SOURCE_RESULT=PASS +fi + +EXTERNAL_RESULT=SKIP +if nc -6 -z -w 10 "$EXTERNAL_IPV6" 443 >/dev/null 2>&1; then + if DOCKER_HOST="unix://$DOCKER_SOCKET" "$DOCKER" run --rm "$FIXTURE_IMAGE" \ + nc -z -w 15 "$EXTERNAL_IPV6" 443 > "$EVIDENCE/external-ipv6.out" 2> "$EVIDENCE/external-ipv6.err"; then + EXTERNAL_RESULT=PASS + else + echo "VZ native IPv6 gate: host IPv6 works but container TCP failed" >&2 + exit 1 + fi +elif [ "$REQUIRE_EXTERNAL" = 1 ]; then + echo "VZ native IPv6 gate: --require-external needs a real host IPv6 route" >&2 + exit 1 +fi + +stop_vmm +if [ "$SOURCE_ENABLED" = 1 ]; then + "$SOURCE_APP/Contents/MacOS/Dory" --unregister-network-helper \ + > "$EVIDENCE/source-network-helper-unregistration.txt" 2>&1 \ + || { echo "VZ native IPv6 gate: exact app could not unregister its network helper" >&2; exit 1; } + HELPER_REGISTERED=0 + if sudo -n /bin/launchctl print system/dev.dory.network-helper >/dev/null 2>&1; then + echo "VZ native IPv6 gate: network helper survived final cleanup" >&2 + exit 1 + fi + sudo -n test ! -e /var/run/dev.dory/pf-enable-token \ + || { echo "VZ native IPv6 gate: PF token survived final cleanup" >&2; exit 1; } + sudo -n test ! -e /var/run/dev.dory/ipv4-forwarding-owner \ + || { echo "VZ native IPv6 gate: forwarding marker survived final cleanup" >&2; exit 1; } + sudo -n /sbin/pfctl -a com.apple/dev.dory.lan -sn > "$EVIDENCE/source-pf-after.txt" 2>&1 || true + ! grep -Eq '(^|[[:space:]])rdr([[:space:]]|$)' "$EVIDENCE/source-pf-after.txt" \ + || { echo "VZ native IPv6 gate: PF redirects survived final cleanup" >&2; exit 1; } + ! netstat -rn -f inet | awk '$1 == "192.168.215.254" { found=1 } END { exit !found }' \ + || { echo "VZ native IPv6 gate: source-preserving route survived final cleanup" >&2; exit 1; } + sudo -n /sbin/pfctl -s References > "$EVIDENCE/pf-references-after.txt" 2>&1 + cmp "$EVIDENCE/pf-references-before.txt" "$EVIDENCE/pf-references-after.txt" \ + || { echo "VZ native IPv6 gate: PF reference set changed after cleanup" >&2; exit 1; } + /usr/sbin/sysctl -n net.inet.ip.forwarding > "$EVIDENCE/ipv4-forwarding-after.txt" + cmp "$EVIDENCE/ipv4-forwarding-before.txt" "$EVIDENCE/ipv4-forwarding-after.txt" \ + || { echo "VZ native IPv6 gate: IPv4 forwarding state changed after cleanup" >&2; exit 1; } +fi +cp "$STATE/gvproxy-dual-stack.yaml" "$EVIDENCE/gvproxy-dual-stack.yaml" +cp "$STATE/serial.log" "$EVIDENCE/serial.log" +HOST_BOOT_EPOCH_AFTER="$(/usr/sbin/sysctl -n kern.boottime \ + | sed -n 's/.*sec = \([0-9][0-9]*\).*/\1/p')" +[ "$HOST_BOOT_EPOCH_AFTER" = "$HOST_BOOT_EPOCH_BEFORE" ] \ + || { echo "VZ native IPv6 gate: host boot session changed during certification" >&2; exit 1; } +PANIC_REPORTS="$EVIDENCE/new-host-panic-reports.txt" +: > "$PANIC_REPORTS" +for report_root in /Library/Logs/DiagnosticReports "$HOME/Library/Logs/DiagnosticReports"; do + [ ! -d "$report_root" ] || find "$report_root" -type f -newer "$PANIC_MARKER" \ + \( -iname '*.panic' -o -iname '*panic*.ips' \) -print 2>/dev/null >> "$PANIC_REPORTS" \ + || true +done +[ ! -s "$PANIC_REPORTS" ] \ + || { echo "VZ native IPv6 gate: new host panic report appeared during certification" >&2; exit 1; } +SONOMA_RESULT="$([ "$OS_MAJOR" = 14 ] && echo PASS || echo SKIP)" +RELEASE_QUALIFYING=false +[ "$SONOMA_RESULT" = PASS ] && [ "$EXTERNAL_RESULT" = PASS ] && RELEASE_QUALIFYING=true +SSH_AGENT_RESULT=SKIP +if [ -n "$SSH_CLIENT_IMAGE" ]; then SSH_AGENT_RESULT=PASS; else RELEASE_QUALIFYING=false; fi +if [ "$SOURCE_ENABLED" != 1 ] || [ "$SOURCE_RESULT" != PASS ]; then RELEASE_QUALIFYING=false; fi +{ + echo status=PASS + echo architecture=arm64 + echo macos_version="$OS_VERSION" + echo sonoma="$SONOMA_RESULT" + echo dory_vmm_sha256="$(shasum -a 256 "$VMM" | awk '{print $1}')" + echo gvproxy_version="$(dory_gvproxy_version)" + echo gvproxy_sha256="$(dory_gvproxy_file_sha256 "$GVPROXY")" + echo gvproxy_build_sha256="$(dory_gvproxy_expected_sha256)" + echo fixture_image="$FIXTURE_IMAGE" + echo vz_file_handle_network=PASS + echo fresh_boot=PASS + echo restart=PASS + echo graceful_cleanup=PASS + echo host_boot_epoch_before="$HOST_BOOT_EPOCH_BEFORE" + echo host_boot_epoch_after="$HOST_BOOT_EPOCH_AFTER" + echo host_boot_session_unchanged=PASS + echo host_panic_report_absence=PASS + echo docker_bridge_ipv6=PASS + echo container_global_ipv6=PASS + echo dns_aaaa=PASS + echo registry_aaaa=PASS + echo ipv6_tcp_loopback=PASS + echo wildcard_ipv4_ipv6_loopback=PASS + echo explicit_ipv4_loopback=PASS + echo unpublish_cleanup=PASS + echo ssh_agent_forwarding="$SSH_AGENT_RESULT" + if [ "$SSH_AGENT_RESULT" = PASS ]; then + echo ssh_agent_fresh_boot=PASS + echo ssh_agent_restart=PASS + echo ssh_client_image="$SSH_CLIENT_IMAGE" + fi + echo physical_source_preservation="$SOURCE_RESULT" + if [ "$SOURCE_ENABLED" = 1 ]; then + echo app_executable_sha256="$(shasum -a 256 "$SOURCE_APP/Contents/MacOS/Dory" | awk '{print $1}')" + echo source_server_image="$SOURCE_SERVER_IMAGE" + echo lan_tcp_udp_source_preserved=PASS + echo tailscale_tcp_udp_source_preserved=PASS + echo explicit_loopback_remote_isolation=PASS + echo interface_specific_privileged_tcp=PASS + echo source_helper_restart_recovery=PASS + echo source_engine_restart_recovery=PASS + echo source_memory_pressure_lan=PASS + echo source_memory_pressure_tailscale=PASS + echo source_dns_pressure=PASS + echo source_configd_pressure_liveness=PASS + echo source_memory_pressure_mib="$SOURCE_PRESSURE_MIB" + echo source_memory_pressure_rounds="$SOURCE_PRESSURE_ROUNDS" + echo source_unpublish_cleanup=PASS + echo source_privileged_tcp_unpublish_cleanup=PASS + echo source_privileged_port="$SOURCE_PRIVILEGED_PORT" + echo source_pf_reference_cleanup=PASS + echo source_ipv4_forwarding_cleanup=PASS + echo source_network_helper_unregistered=PASS + fi + echo external_ipv6_tcp="$EXTERNAL_RESULT" + echo release_qualifying="$RELEASE_QUALIFYING" +} > "$EVIDENCE/manifest.txt" +echo "VZ native IPv6 gate: PASS ($EVIDENCE/manifest.txt)" From cc8f449594c4412f2ea12f6f6976329368b4040e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:44:54 +0000 Subject: [PATCH 172/338] feat(release): track packaging utilities --- .github/workflows/release.yml | 3 + .github/workflows/tests.yml | 3 + scripts/make-dmg.sh | 44 ++++++++ scripts/test-app-update-payload.sh | 100 +++++++++++++++++ scripts/test-make-dmg.sh | 19 ++++ .../test-verify-macos-deployment-targets.sh | 71 ++++++++++++ scripts/validate-app-update-payload.sh | 104 ++++++++++++++++++ scripts/verify-macos-deployment-targets.sh | 93 ++++++++++++++++ 8 files changed, 437 insertions(+) create mode 100755 scripts/make-dmg.sh create mode 100755 scripts/test-app-update-payload.sh create mode 100755 scripts/test-verify-macos-deployment-targets.sh create mode 100755 scripts/validate-app-update-payload.sh create mode 100755 scripts/verify-macos-deployment-targets.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e671261..b23b5e74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,6 +341,9 @@ jobs: python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-vz-native-ipv6-gate.py + bash scripts/test-make-dmg.sh + bash scripts/test-app-update-payload.sh + bash scripts/test-verify-macos-deployment-targets.sh python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 294675a5..1b11edb3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,6 +42,9 @@ jobs: python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-vz-native-ipv6-gate.py + bash scripts/test-make-dmg.sh + bash scripts/test-app-update-payload.sh + bash scripts/test-verify-macos-deployment-targets.sh python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 00000000..1d5ca78d --- /dev/null +++ b/scripts/make-dmg.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build a distributable .dmg from a built Dory.app. +# Usage: scripts/make-dmg.sh [out.dmg] +set -euo pipefail +cd "$(dirname "$0")/.." + +APP="${1:?usage: scripts/make-dmg.sh [out.dmg]}" +VERSION="${2:?usage: scripts/make-dmg.sh [out.dmg]}" +OUT="${3:-release-build/Dory-$VERSION.dmg}" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' \ + || { echo "make-dmg: version must be SemVer-like" >&2; exit 64; } +[ -d "$APP" ] && [ ! -L "$APP" ] && [ "$(basename "$APP")" = Dory.app ] \ + || { echo "make-dmg: input must be a direct Dory.app directory" >&2; exit 66; } +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP_PHYSICAL="$(cd "$APP" && pwd -P)" +[ "$APP_LOGICAL" = "$APP_PHYSICAL" ] \ + || { echo "make-dmg: input app has an indirect ancestor" >&2; exit 66; } +[ ! -L "$OUT" ] || { echo "make-dmg: output cannot be a symlink" >&2; exit 66; } +VOL="Dory $VERSION" +WORK="$(mktemp -d)"; STAGE="$WORK/stage" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$STAGE" +cp -R "$APP_PHYSICAL" "$STAGE/" +ln -s /Applications "$STAGE/Applications" + +# Volume icon from the app's icon set (best-effort — DMG is still valid without it). +ICONSET="$WORK/Dory.iconset"; mkdir -p "$ICONSET" +if cp Dory/Assets.xcassets/AppIcon.appiconset/icon_*.png "$ICONSET/" 2>/dev/null \ + && iconutil -c icns "$ICONSET" -o "$STAGE/.VolumeIcon.icns" 2>/dev/null; then + SetFile -a C "$STAGE" 2>/dev/null || true +fi + +mkdir -p "$(dirname "$OUT")"; rm -f "$OUT" +if command -v diskutil >/dev/null 2>&1 \ + && diskutil image create from --help >/dev/null 2>&1; then + # macOS 27 deprecates hdiutil's folder-image path. Its replacement also avoids the + # misleading ENOSPC failure hdiutil can return on large sparse VM payloads. + diskutil image create from --format UDZO --volumeName "$VOL" "$STAGE" "$OUT" >/dev/null +else + # Keep release builders on older supported macOS versions working until diskutil's + # image subcommand is available there. + hdiutil create -volname "$VOL" -srcfolder "$STAGE" -ov -format UDZO "$OUT" >/dev/null +fi +echo "$OUT" diff --git a/scripts/test-app-update-payload.sh b/scripts/test-app-update-payload.sh new file mode 100755 index 00000000..c3561862 --- /dev/null +++ b/scripts/test-app-update-payload.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-update-payload.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +APP="$TMP/Dory.app" +RESOURCES="$APP/Contents/Resources" +HELPERS="$APP/Contents/Helpers" +NETWORK_DAEMON_DIR="$APP/Contents/Library/LaunchDaemons" +NETWORK_DAEMON_PLIST="$NETWORK_DAEMON_DIR/dev.dory.network-helper.plist" +mkdir -p "$RESOURCES" "$HELPERS" "$NETWORK_DAEMON_DIR" +cp Config/dev.dory.network-helper.plist "$NETWORK_DAEMON_PLIST" + +ASSETS=( + dory-agent-linux-arm64 + dory-hv-kernel-arm64 + dory-hv-kernel-arm64.lzfse + dory-engine-rootfs-arm64.ext4.lzfse + dory-machine-rootfs-arm64.ext4 + dory-vm-kernel-arm64.lzfse + dory-vm-initfs-arm64.ext4.lzfse + dory-desktop-kernel-arm64.lzfse + kernel-build-arm64-desktop.stamp + dory-desktop-debian-rootfs-arm64.ext4.lzfse + dory-desktop-debian-build-arm64.stamp + dory-desktop-debian-packages-arm64.txt + dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse + dory-desktop-ubuntu-build-arm64.stamp + dory-desktop-ubuntu-packages-arm64.txt + dory-desktop-kali-rootfs-arm64.ext4.lzfse + dory-desktop-kali-build-arm64.stamp + dory-desktop-kali-packages-arm64.txt +) +for asset in "${ASSETS[@]}"; do + printf 'fixture\n' > "$RESOURCES/$asset" +done + +HELPER_ASSETS=( + doryd dorydctl dory-vmm dory-network-helper dory-dataplane-proxy dory-hv + gvproxy docker docker-buildx docker-compose kubectl dory dory-doctor +) +for helper in "${HELPER_ASSETS[@]}"; do + printf '#!/bin/sh\nexit 0\n' > "$HELPERS/$helper" + chmod 0755 "$HELPERS/$helper" +done + +scripts/validate-app-update-payload.sh "$APP" arm64 desktop >/dev/null +for asset in "${ASSETS[@]}"; do + rm "$RESOURCES/$asset" + if scripts/validate-app-update-payload.sh "$APP" arm64 desktop >/dev/null 2>&1; then + echo "app-update payload test failed: missing $asset was accepted" >&2 + exit 1 + fi + printf 'fixture\n' > "$RESOURCES/$asset" +done +for helper in "${HELPER_ASSETS[@]}"; do + rm "$HELPERS/$helper" + if scripts/validate-app-update-payload.sh "$APP" arm64 desktop >/dev/null 2>&1; then + echo "app-update payload test failed: missing $helper was accepted" >&2 + exit 1 + fi + printf '#!/bin/sh\nexit 0\n' > "$HELPERS/$helper" + chmod 0755 "$HELPERS/$helper" +done + +rm "$NETWORK_DAEMON_PLIST" +if scripts/validate-app-update-payload.sh "$APP" arm64 desktop >/dev/null 2>&1; then + echo "app-update payload test failed: missing privileged network daemon plist was accepted" >&2 + exit 1 +fi +cp Config/dev.dory.network-helper.plist "$NETWORK_DAEMON_PLIST" + +cp "$NETWORK_DAEMON_PLIST" "$TMP/network-helper.plist" +/usr/libexec/PlistBuddy -c 'Set :BundleProgram Contents/Helpers/not-dory-network-helper' "$NETWORK_DAEMON_PLIST" +if scripts/validate-app-update-payload.sh "$APP" arm64 desktop >/dev/null 2>&1; then + echo "app-update payload test failed: invalid privileged network daemon BundleProgram was accepted" >&2 + exit 1 +fi +cp "$TMP/network-helper.plist" "$NETWORK_DAEMON_PLIST" + +if scripts/validate-app-update-payload.sh "$APP" '../unsafe' desktop \ + >"$TMP/architecture.out" 2>&1; then + echo "app-update payload test failed: unsafe architecture input was accepted" >&2 + exit 1 +fi +grep -F 'unsupported guest architecture' "$TMP/architecture.out" >/dev/null + +APP_LINK="$TMP/linked-Dory.app" +ln -s "$APP" "$APP_LINK" +if scripts/validate-app-update-payload.sh "$APP_LINK" arm64 desktop \ + >"$TMP/indirect.out" 2>&1; then + echo "app-update payload test failed: indirect candidate app was accepted" >&2 + exit 1 +fi +grep -F 'input must be a direct Dory.app directory' "$TMP/indirect.out" >/dev/null + +echo "app-update payload tests passed" diff --git a/scripts/test-make-dmg.sh b/scripts/test-make-dmg.sh index a1db9bfc..ae1c53e2 100755 --- a/scripts/test-make-dmg.sh +++ b/scripts/test-make-dmg.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-make-dmg.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" trap 'rm -rf "$TMP"' EXIT BIN="$TMP/bin" APP="$TMP/Dory.app" @@ -72,4 +73,22 @@ fi [ -z "$(find "$FAIL_TMP" -mindepth 1 -print -quit)" ] \ || { echo "test-make-dmg: retained staging data after failure" >&2; exit 1; } +APP_LINK="$TMP/linked-Dory.app" +ln -s "$APP" "$APP_LINK" +if PATH="$BIN:/usr/bin:/bin" \ + "$ROOT/scripts/make-dmg.sh" "$APP_LINK" 0.3.0 "$TMP/linked.dmg" \ + >"$TMP/indirect.out" 2>&1; then + echo "test-make-dmg: accepted an indirect candidate app" >&2 + exit 1 +fi +grep -F 'input must be a direct Dory.app directory' "$TMP/indirect.out" >/dev/null + +if PATH="$BIN:/usr/bin:/bin" \ + "$ROOT/scripts/make-dmg.sh" "$APP" '../unsafe' "$TMP/unsafe.dmg" \ + >"$TMP/version.out" 2>&1; then + echo "test-make-dmg: accepted unsafe release version input" >&2 + exit 1 +fi +grep -F 'version must be SemVer-like' "$TMP/version.out" >/dev/null + echo "test-make-dmg: PASS" diff --git a/scripts/test-verify-macos-deployment-targets.sh b/scripts/test-verify-macos-deployment-targets.sh new file mode 100755 index 00000000..f2f55036 --- /dev/null +++ b/scripts/test-verify-macos-deployment-targets.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-deployment-targets.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +BIN="$TMP/bin" +APP="$TMP/Dory.app" +mkdir -p "$BIN" "$APP/Contents/MacOS" "$APP/Contents/Helpers" + +for executable in \ + MacOS/Dory Helpers/doryd Helpers/dorydctl Helpers/dory-vmm \ + Helpers/dory-network-helper Helpers/dory-dataplane-proxy Helpers/dory-hv; do + printf '#!/bin/sh\nexit 0\n' > "$APP/Contents/$executable" + chmod 0755 "$APP/Contents/$executable" +done + +cat > "$BIN/lipo" <<'SH' +#!/bin/sh +printf '%s\n' arm64 +SH +cat > "$BIN/xcrun" <<'SH' +#!/bin/sh +[ "${1:-}" = --find ] || exit 64 +case "${2:-}" in + vtool) printf '%s\n' "$DORY_TEST_BIN/vtool" ;; + otool) printf '%s\n' "$DORY_TEST_BIN/otool" ;; + *) exit 1 ;; +esac +SH +cat > "$BIN/vtool" <<'SH' +#!/bin/sh +last="" +for argument in "$@"; do last="$argument"; done +minimum=14.0 +case "$last" in *'/Helpers/dory-hv') minimum=15.0 ;; esac +case "$last" in + *'/Helpers/dory-vmm') [ "${DORY_TEST_BAD_VMM_TARGET:-0}" != 1 ] || minimum=15.0 ;; +esac +printf 'platform MACOS\nminos %s\n' "$minimum" +SH +cat > "$BIN/otool" <<'SH' +#!/bin/sh +exit 1 +SH +chmod 0755 "$BIN/lipo" "$BIN/xcrun" "$BIN/vtool" "$BIN/otool" + +PATH="$BIN:/usr/bin:/bin" DORY_TEST_BIN="$BIN" \ + "$ROOT/scripts/verify-macos-deployment-targets.sh" "$APP" arm64 >/dev/null + +if PATH="$BIN:/usr/bin:/bin" DORY_TEST_BIN="$BIN" DORY_TEST_BAD_VMM_TARGET=1 \ + "$ROOT/scripts/verify-macos-deployment-targets.sh" "$APP" arm64 \ + >"$TMP/bad-target.out" 2>&1; then + echo "test-deployment-targets: accepted a mismatched helper deployment target" >&2 + exit 1 +fi +grep -F 'dory-vmm (arm64) has minimum macOS 15.0; expected exactly 14.0' \ + "$TMP/bad-target.out" >/dev/null + +LINK="$TMP/linked-Dory.app" +ln -s "$APP" "$LINK" +if PATH="$BIN:/usr/bin:/bin" DORY_TEST_BIN="$BIN" \ + "$ROOT/scripts/verify-macos-deployment-targets.sh" "$LINK" arm64 \ + >"$TMP/indirect.out" 2>&1; then + echo "test-deployment-targets: accepted an indirect candidate app" >&2 + exit 1 +fi +grep -F 'input must be a direct Dory.app directory' "$TMP/indirect.out" >/dev/null + +echo "test-deployment-targets: PASS" diff --git a/scripts/validate-app-update-payload.sh b/scripts/validate-app-update-payload.sh new file mode 100755 index 00000000..2b23ec50 --- /dev/null +++ b/scripts/validate-app-update-payload.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Sparkle replaces Dory.app; an update must therefore remain bootable before the first engine boot +# and retain every asset needed to create a new machine after the old bundle is gone. +set -euo pipefail + +APP="${1:?usage: validate-app-update-payload.sh [guest-architectures] [core|lean|desktop]}" +ARCHES="${2:-arm64 amd64}" +EDITION="${3:-lean}" +[ -d "$APP" ] && [ ! -L "$APP" ] && [ "$(basename "$APP")" = Dory.app ] \ + || { echo "app-update payload error: input must be a direct Dory.app directory" >&2; exit 66; } +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP="$(cd "$APP" && pwd -P)" +[ "$APP" = "$APP_LOGICAL" ] \ + || { echo "app-update payload error: input app has an indirect ancestor" >&2; exit 66; } +RESOURCES="$APP/Contents/Resources" +HELPERS="$APP/Contents/Helpers" +NETWORK_DAEMON_PLIST="$APP/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" + +fail() { + echo "app-update payload error: $*" >&2 + exit 1 +} + +case "$EDITION" in + core|lean|desktop) ;; + *) fail "edition must be core, lean, or desktop" ;; +esac +for arch in $ARCHES; do + case "$arch" in arm64|amd64) ;; *) fail "unsupported guest architecture: $arch" ;; esac +done + +[ -d "$RESOURCES" ] || fail "missing $RESOURCES" +[ -d "$HELPERS" ] || fail "missing $HELPERS" +for helper in \ + doryd dorydctl dory-vmm dory-network-helper dory-dataplane-proxy dory-hv \ + gvproxy docker docker-buildx docker-compose dory dory-doctor; do + [ -x "$HELPERS/$helper" ] || fail "missing executable helper $helper" +done +if [ "$EDITION" = core ]; then + [ ! -e "$HELPERS/kubectl" ] || fail "Core update unexpectedly contains kubectl" + [ "$(/usr/libexec/PlistBuddy -c 'Print :DoryBundledComponents' "$APP/Contents/Info.plist" 2>/dev/null || true)" = $'Array {\n docker-core\n}' ] \ + || fail "Core update must declare only docker-core" +else + [ -x "$HELPERS/kubectl" ] || fail "missing executable helper kubectl" +fi +[ -s "$NETWORK_DAEMON_PLIST" ] || fail "missing privileged network daemon plist" +plutil -lint "$NETWORK_DAEMON_PLIST" >/dev/null \ + || fail "privileged network daemon plist is invalid" +[ "$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "$NETWORK_DAEMON_PLIST" 2>/dev/null)" = \ + "Contents/Helpers/dory-network-helper" ] \ + || fail "privileged network daemon BundleProgram does not reference the bundled helper" +[ "$(/usr/libexec/PlistBuddy -c 'Print :MachServices:dev.dory.network-helper' "$NETWORK_DAEMON_PLIST" 2>/dev/null)" = \ + "true" ] \ + || fail "privileged network daemon Mach service is missing" +for arch in $ARCHES; do + for relative in \ + "dory-agent-linux-$arch" \ + "dory-hv-kernel-$arch.lzfse" \ + "dory-engine-rootfs-$arch.ext4.lzfse" \ + "dory-vm-kernel-$arch.lzfse" \ + "dory-vm-initfs-$arch.ext4.lzfse"; do + [ -s "$RESOURCES/$relative" ] || fail "missing $relative for $arch" + done + if [ "$EDITION" = core ]; then + for relative in "dory-hv-kernel-$arch" "dory-machine-rootfs-$arch.ext4"; do + [ ! -e "$RESOURCES/$relative" ] || fail "Core update unexpectedly contains $relative" + done + else + for relative in "dory-hv-kernel-$arch" "dory-machine-rootfs-$arch.ext4"; do + [ -s "$RESOURCES/$relative" ] || fail "missing $relative for $arch" + done + fi +done +case " $ARCHES " in + *" arm64 "*) + if [ "$EDITION" = desktop ]; then + for relative in \ + dory-desktop-kernel-arm64.lzfse \ + kernel-build-arm64-desktop.stamp \ + dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + dory-desktop-debian-build-arm64.stamp \ + dory-desktop-debian-packages-arm64.txt \ + dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + dory-desktop-ubuntu-build-arm64.stamp \ + dory-desktop-ubuntu-packages-arm64.txt \ + dory-desktop-kali-rootfs-arm64.ext4.lzfse \ + dory-desktop-kali-build-arm64.stamp \ + dory-desktop-kali-packages-arm64.txt; do + [ -s "$RESOURCES/$relative" ] || fail "missing Apple Silicon Desktop Linux asset $relative" + done + else + for relative in \ + dory-desktop-kernel-arm64.lzfse \ + kernel-build-arm64-desktop.stamp \ + dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + dory-desktop-kali-rootfs-arm64.ext4.lzfse; do + [ ! -e "$RESOURCES/$relative" ] || fail "lean update unexpectedly contains $relative" + done + fi + ;; +esac + +echo "verified self-contained $EDITION app-update payload for:$ARCHES" diff --git a/scripts/verify-macos-deployment-targets.sh b/scripts/verify-macos-deployment-targets.sh new file mode 100755 index 00000000..c42660bb --- /dev/null +++ b/scripts/verify-macos-deployment-targets.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Fail a release when a shipped Mach-O slice requires a newer macOS than the runtime tier gate +# promises. Dory.app and its Virtualization.framework fallback support Sonoma; dory-hv is a +# separate macOS 15+ tier. +set -euo pipefail + +APP="${1:?usage: verify-macos-deployment-targets.sh [expected-architectures]}" +EXPECTED_ARCHES="${2:-${DORY_EXPECTED_HELPER_ARCHES:-}}" + +release_error() { + echo "deployment-target error: $*" >&2 + exit 1 +} + +[ -d "$APP" ] && [ ! -L "$APP" ] && [ "$(basename "$APP")" = Dory.app ] \ + || release_error "input must be a direct Dory.app directory" +APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$APP")" +APP="$(cd "$APP" && pwd -P)" +[ "$APP" = "$APP_LOGICAL" ] || release_error "input app has an indirect ancestor" +[ -n "$EXPECTED_ARCHES" ] || release_error "expected architectures are required" +for expected_arch in $EXPECTED_ARCHES; do + case "$expected_arch" in arm64|x86_64) ;; *) release_error "unsupported expected architecture: $expected_arch" ;; esac +done + +command -v lipo >/dev/null 2>&1 || release_error "lipo is required" +command -v xcrun >/dev/null 2>&1 || release_error "xcrun is required" +VTOOL="$(xcrun --find vtool 2>/dev/null || true)" +OTOOL="$(xcrun --find otool 2>/dev/null || command -v otool || true)" +[ -n "$VTOOL" ] || [ -n "$OTOOL" ] \ + || release_error "neither vtool nor otool is available" + +normalize_version() { + awk -F. '{ printf "%d.%d.%d\n", $1 + 0, $2 + 0, $3 + 0 }' <<<"$1" +} + +slice_minimum_macos() { + local binary="$1" arch="$2" output minimum="" + if [ -n "$VTOOL" ]; then + output="$($VTOOL -arch "$arch" -show-build "$binary" 2>/dev/null || true)" + minimum="$(awk ' + $1 == "platform" && $2 == "MACOS" { macos = 1; next } + macos && $1 == "minos" { print $2; exit } + $1 == "cmd" && $2 == "LC_VERSION_MIN_MACOSX" { legacy = 1; next } + legacy && $1 == "version" { print $2; exit } + ' <<<"$output")" + fi + if [ -z "$minimum" ] && [ -n "$OTOOL" ]; then + output="$($OTOOL -arch "$arch" -l "$binary" 2>/dev/null || true)" + minimum="$(awk ' + $1 == "cmd" && $2 == "LC_BUILD_VERSION" { build = 1; legacy = 0; next } + build && $1 == "platform" && ($2 == "1" || $2 == "MACOS") { macos = 1; next } + build && macos && $1 == "minos" { print $2; exit } + $1 == "cmd" && $2 == "LC_VERSION_MIN_MACOSX" { legacy = 1; build = 0; next } + legacy && $1 == "version" { print $2; exit } + ' <<<"$output")" + fi + [ -n "$minimum" ] || return 1 + printf '%s\n' "$minimum" +} + +verify_binary() { + local relative="$1" expected="$2" binary + local actual_arches arch minimum normalized_expected normalized_actual + binary="$APP/Contents/$relative" + [ -x "$binary" ] || release_error "missing executable $binary" + actual_arches="$(lipo -archs "$binary" 2>/dev/null || true)" + [ -n "$actual_arches" ] || release_error "$binary is not a Mach-O executable" + for arch in $EXPECTED_ARCHES; do + case " $actual_arches " in + *" $arch "*) ;; + *) release_error "$relative is missing expected $arch slice (has: $actual_arches)" ;; + esac + done + + normalized_expected="$(normalize_version "$expected")" + for arch in $actual_arches; do + minimum="$(slice_minimum_macos "$binary" "$arch" || true)" + [ -n "$minimum" ] \ + || release_error "could not read the macOS deployment target for $relative ($arch)" + normalized_actual="$(normalize_version "$minimum")" + [ "$normalized_actual" = "$normalized_expected" ] \ + || release_error "$relative ($arch) has minimum macOS $minimum; expected exactly $expected" + echo "==> Verified $relative ($arch) minimum macOS $minimum" + done +} + +verify_binary "MacOS/Dory" 14.0 +verify_binary "Helpers/doryd" 14.0 +verify_binary "Helpers/dorydctl" 14.0 +verify_binary "Helpers/dory-vmm" 14.0 +verify_binary "Helpers/dory-network-helper" 14.0 +verify_binary "Helpers/dory-dataplane-proxy" 14.0 +verify_binary "Helpers/dory-hv" 15.0 From ba6e5d35053f545470836a586e6e06556fa7874d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:46:44 +0000 Subject: [PATCH 173/338] feat(release): track release orchestrator --- .github/scripts/test-release-orchestrator.py | 89 ++ .github/workflows/release.yml | 2 + .github/workflows/tests.yml | 2 + scripts/release.sh | 1296 ++++++++++++++++++ scripts/test-dmg-distribution-signing.sh | 82 ++ 5 files changed, 1471 insertions(+) create mode 100755 .github/scripts/test-release-orchestrator.py create mode 100755 scripts/release.sh create mode 100755 scripts/test-dmg-distribution-signing.sh diff --git a/.github/scripts/test-release-orchestrator.py b/.github/scripts/test-release-orchestrator.py new file mode 100755 index 00000000..caa18918 --- /dev/null +++ b/.github/scripts/test-release-orchestrator.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Offline authority tests for the public release orchestrator.""" + +from __future__ import annotations + +import os +import pathlib +import shlex +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RELEASE = ROOT / "scripts" / "release.sh" + + +class ReleaseOrchestratorTests(unittest.TestCase): + @staticmethod + def run_bash(program: str) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + ["bash", "-c", program], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_binds_public_release_authority(self) -> None: + subprocess.run(["bash", "-n", str(RELEASE)], cwd=ROOT, check=True) + source = RELEASE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "release build directory must be a direct child of the checkout", + "release build authority is not owned by this user", + "public releases must use Dory signing team 864H636QW4", + "scripts/verify-clean-release-source.sh", + "scripts/verify-macos-deployment-targets.sh", + "scripts/validate-app-update-payload.sh", + "source=Notarized Developer ID", + "scripts/generate-release-sbom.py", + "scripts/verify-release-sbom.py", + "scripts/generate-appcast.sh", + "write_release_manifest", + ): + self.assertIn(contract, source) + + def test_missing_metadata_fails_before_release_mutation(self) -> None: + result = subprocess.run( + [str(RELEASE)], + cwd=ROOT, + env={**os.environ, "PYTHONOPTIMIZE": "2"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + self.assertEqual(result.returncode, 64) + self.assertIn("usage: scripts/release.sh", result.stdout) + + def test_recursive_build_cleanup_is_confined_to_checkout(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-orchestrator.") as raw: + outside = pathlib.Path(raw).resolve() / "release-build-escape" + result = self.run_bash( + "set -euo pipefail; " + "DORY_RELEASE_SOURCE_ONLY=1 " + f"DORY_RELEASE_BUILD_DIR={shlex.quote(str(outside))} " + "source scripts/release.sh 1.2.3 4; validate_release_build_dir" + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("direct child of the checkout", result.stdout) + + def test_public_release_rejects_replaceable_signing_team(self) -> None: + result = self.run_bash( + "set -euo pipefail; " + "DORY_RELEASE_SOURCE_ONLY=1 NOTARY_TEAM_ID=TESTTEAM " + "source scripts/release.sh 1.2.3 4; " + "DORY_PUBLIC_RELEASE=1 preflight_public_release" + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must use Dory signing team 864H636QW4", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b23b5e74..c70a8343 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -344,6 +344,8 @@ jobs: bash scripts/test-make-dmg.sh bash scripts/test-app-update-payload.sh bash scripts/test-verify-macos-deployment-targets.sh + python3 .github/scripts/test-release-orchestrator.py + bash scripts/test-dmg-distribution-signing.sh python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b11edb3..cfd59291 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,8 @@ jobs: bash scripts/test-make-dmg.sh bash scripts/test-app-update-payload.sh bash scripts/test-verify-macos-deployment-targets.sh + python3 .github/scripts/test-release-orchestrator.py + bash scripts/test-dmg-distribution-signing.sh python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..bc316fa1 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,1296 @@ +#!/bin/bash +# Dory release pipeline: archive + Developer ID sign -> notarize -> staple -> zip/dmg. +# +# Default release shape (Apple Silicon first): +# * Dory--arm64.zip Apple silicon app +# * Dory-.zip Compatibility alias for the arm64 app +# Intel/universal variants remain available for development builds but are not public defaults. +# +# Requires (one-time, your Apple Developer account -- the external gate): +# * A "Developer ID Application" certificate in your keychain. +# * A notarytool keychain profile: xcrun notarytool store-credentials dory-notary \ +# --apple-id you@example.com --team-id --password +# +# Then: scripts/release.sh 1.0.0 42 +set -euo pipefail + +# Prefer an explicit DEVELOPER_DIR; otherwise pick up a local Xcode install, else fall back to +# the Xcode already selected by xcode-select (CI runners set this themselves). +if [ -z "${DEVELOPER_DIR:-}" ]; then + for app in /Applications/Xcode-26.6.0-Release.Candidate.app \ + /Applications/Xcode_26.6.app /Applications/Xcode_26.6.0.app \ + /Applications/Xcode.app /Applications/Xcode-*.app "$HOME"/Applications/Xcode*.app; do + [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && { export DEVELOPER_DIR="$app/Contents/Developer"; break; } + done +fi +cd "$(dirname "${BASH_SOURCE[0]}")/.." +REPO_ROOT="$(pwd -P)" + +VERSION="${1:-}" +BUILD="${2:-${DORY_BUILD:-}}" +if [ -z "$VERSION" ] || [ -z "$BUILD" ]; then + echo "usage: scripts/release.sh " >&2 + exit 64 +fi +BUILD_DIR="${DORY_RELEASE_BUILD_DIR:-release-build}" +NOTARY_PROFILE="${DORY_NOTARY_PROFILE:-dory-notary}" +TEAM="${NOTARY_TEAM_ID:-864H636QW4}" +NOTARY_TEAM_ID="${NOTARY_TEAM_ID:-$TEAM}" +RELEASE_VARIANTS="${DORY_RELEASE_VARIANTS:-arm64}" +SIGN_IDENTITY="${DORY_SIGN_ID:-Developer ID Application}" +SOURCE_COMMIT="${DORY_RELEASE_SOURCE_COMMIT:-$(git rev-parse HEAD 2>/dev/null || true)}" +HOMEBREW_CASK="${DORY_HOMEBREW_CASK:-Casks/dory.rb}" +DERIVED_DATA_DIR="" + +notarize() { + if [ -n "${NOTARY_APPLE_ID:-}" ]; then + xcrun notarytool submit "$1" --apple-id "$NOTARY_APPLE_ID" --team-id "$NOTARY_TEAM_ID" --password "$NOTARY_PASSWORD" --wait + else + xcrun notarytool submit "$1" --keychain-profile "$NOTARY_PROFILE" --wait + fi +} + +validate_notary_credentials() { + if [ -n "${NOTARY_APPLE_ID:-}" ]; then + echo "==> Validating notarytool environment credentials..." + xcrun notarytool history \ + --apple-id "$NOTARY_APPLE_ID" \ + --team-id "$NOTARY_TEAM_ID" \ + --password "$NOTARY_PASSWORD" \ + --output-format json >/dev/null 2>&1 \ + || release_error "notarytool environment credentials are unavailable or invalid" + else + echo "==> Validating notarytool keychain profile '$NOTARY_PROFILE'..." + xcrun notarytool history \ + --keychain-profile "$NOTARY_PROFILE" \ + --output-format json >/dev/null 2>&1 \ + || release_error "notarytool keychain profile '$NOTARY_PROFILE' is unavailable or invalid; configure it with 'xcrun notarytool store-credentials $NOTARY_PROFILE --apple-id --team-id $NOTARY_TEAM_ID'" + fi +} + +sha256_file() { + shasum -a 256 "$1" | awk '{print $1}' +} + +file_size_bytes() { + wc -c < "$1" | tr -d '[:space:]' +} + +path_if_exists() { + [ -f "$1" ] && printf '%s' "$1" +} + +copy_alias() { + local source="$1" destination="$2" + [ -n "$source" ] && [ -f "$source" ] || return 0 + [ "$source" = "$destination" ] && return 0 + rm -f "$destination" + ln -f "$source" "$destination" 2>/dev/null || cp -p "$source" "$destination" +} + +assert_app_binary_arches() { + local binary="$1" expected="$2" archs arch + archs="$(lipo -archs "$binary")" + for arch in $expected; do + case " $archs " in + *" $arch "*) ;; + *) echo "release error: $binary missing $arch (archs: ${archs:-none})" >&2; exit 1 ;; + esac + done + echo "==> Verified app binary for $expected: $archs" +} + +configure_variant() { + local requested="$1" + VARIANT="$requested" + case "$requested" in + arm64|apple-silicon|silicon) + VARIANT="arm64" + VARIANT_SUFFIX="arm64" + XCODE_ARCHS="arm64" + BUNDLE_ARCHES="arm64" + HELPER_ARCHES="arm64" + HOST_CLI_ARCHES="arm64" + NATIVE_GUEST_ARCH="arm64" + ;; + x86_64|amd64|intel) + VARIANT="x86_64" + VARIANT_SUFFIX="x86_64" + XCODE_ARCHS="x86_64" + BUNDLE_ARCHES="amd64" + HELPER_ARCHES="x86_64" + HOST_CLI_ARCHES="x86_64" + NATIVE_GUEST_ARCH="amd64" + ;; + universal|fat) + VARIANT="universal" + VARIANT_SUFFIX="universal" + XCODE_ARCHS="arm64 x86_64" + BUNDLE_ARCHES="arm64 amd64" + HELPER_ARCHES="arm64 x86_64" + HOST_CLI_ARCHES="arm64 x86_64" + # Compatibility symlinks inside universal bundles are advisory; doryd selects arch-specific + # resources at runtime from Contents/Resources. + NATIVE_GUEST_ARCH="${DORY_UNIVERSAL_NATIVE_GUEST_ARCH:-arm64}" + ;; + *) + echo "release error: unknown DORY_RELEASE_VARIANTS entry '$requested' (use arm64, x86_64, universal)" >&2 + exit 1 + ;; + esac +} + +release_error() { + echo "release error: $*" >&2 + exit 1 +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || release_error "required tool '$1' not found" +} + +validate_release_build_dir() { + local logical parent physical_parent name candidate + logical="$(python3 - "$BUILD_DIR" <<'PY' +import os +import sys + +print(os.path.abspath(sys.argv[1])) +PY +)" + parent="$(dirname "$logical")" + name="$(basename "$logical")" + case "$name" in + release-build*) ;; + *) release_error "DORY_RELEASE_BUILD_DIR must be a dedicated release-build* directory: $logical" ;; + esac + [ -d "$parent" ] && [ ! -L "$parent" ] \ + || release_error "release build parent must be a direct directory: $parent" + physical_parent="$(cd "$parent" && pwd -P)" + [ "$parent" = "$physical_parent" ] \ + || release_error "release build parent has an indirect ancestor: $parent" + [ "$physical_parent" = "$REPO_ROOT" ] \ + || release_error "release build directory must be a direct child of the checkout: $logical" + candidate="$physical_parent/$name" + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + [ -d "$candidate" ] && [ ! -L "$candidate" ] \ + || release_error "release build authority must be a direct directory: $candidate" + [ "$(stat -f '%u' "$candidate")" = "$(id -u)" ] \ + || release_error "release build authority is not owned by this user: $candidate" + fi + BUILD_DIR="$candidate" + DERIVED_DATA_DIR="$BUILD_DIR/DerivedData" +} + +preflight_macos_floor() { + [ -f "$HOMEBREW_CASK" ] \ + || release_error "Homebrew cask is missing: $HOMEBREW_CASK" + grep -q 'depends_on macos: :sonoma' "$HOMEBREW_CASK" \ + || release_error "Homebrew cask must keep macOS 14 Sonoma support" + for appcast in \ + docs-build/appcast.xml docs-build/appcast-desktop.xml \ + website/public/appcast.xml website/public/appcast-desktop.xml; do + [ -f "$appcast" ] || continue + grep -q '14.0' "$appcast" \ + || release_error "$appcast must advertise Sparkle minimumSystemVersion 14.0" + done +} + +preflight_public_toolchain() { + local version + version="$(xcodebuild -version)" + printf '%s\n' "$version" | grep -Fx 'Xcode 26.6' >/dev/null \ + || release_error "public releases require the pinned Xcode 26.6 toolchain" + printf '%s\n' "$version" | grep -Eq '^Build version 17F(109|113)$' \ + || release_error "public releases require approved Xcode 26.6 build 17F109 or 17F113" +} + +preflight_public_release() { + [ "${DORY_PUBLIC_RELEASE:-0}" = "1" ] || return 0 + + [ "$TEAM" = 864H636QW4 ] && [ "$NOTARY_TEAM_ID" = 864H636QW4 ] \ + || release_error "public releases must use Dory signing team 864H636QW4" + + [ "$VERSION" = "${VERSION#v}" ] \ + || release_error "public release version must not include a leading v: $VERSION" + printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' \ + || release_error "public release version must be SemVer-like (for example 0.3.0): $VERSION" + case "$BUILD" in + ''|*[!0-9]*) release_error "public release build must be a positive integer: $BUILD" ;; + 0) release_error "public release build must be greater than zero" ;; + esac + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || release_error "public release source commit must be a full lowercase Git SHA: ${SOURCE_COMMIT:-missing}" + [ "$SOURCE_COMMIT" = "$(git rev-parse HEAD)" ] \ + || release_error "public release source commit $SOURCE_COMMIT does not match checkout $(git rev-parse HEAD)" + preflight_public_toolchain + + [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] \ + || release_error "public releases must bundle the engine" + [ "$RELEASE_VARIANTS" = "arm64" ] \ + || release_error "public releases must build exactly the Apple Silicon variant: arm64" + [ "${DORY_REQUIRE_BUNDLE_ASSETS:-1}" = "1" ] \ + || release_error "public releases must require every bundle asset" + [ "${DORY_REQUIRE_DEVELOPER_ID_SIGNATURES:-1}" = "1" ] \ + || release_error "public releases must require Developer ID signatures" + [ "${DORY_BUNDLE_VENUS:-1}" = "1" ] \ + || release_error "public full releases advertise the Apple-silicon Venus GPU payload and must bundle it" + [ "${DORY_BUNDLE_VENUS_REQUIRED:-0}" = "1" ] \ + || release_error "public full releases must fail when the advertised Venus renderer is unavailable" + [ "$SIGN_IDENTITY" != "-" ] \ + || release_error "public releases cannot use ad-hoc signing" + [ "${DORY_SKIP_NOTARIZE:-0}" != "1" ] \ + || release_error "public releases cannot skip notarization" + [ "${DORY_SKIP_SIGNING_PREFLIGHT:-0}" != "1" ] \ + || release_error "public releases cannot skip signing preflight" + [ "${DORY_ALLOW_ADHOC_SIGN:-0}" != "1" ] \ + || release_error "public releases cannot allow ad-hoc nested-code fallback" + [ "${DORY_ALLOW_UNVERIFIED_GUEST_ASSETS:-0}" != "1" ] \ + || release_error "public releases cannot use unverified guest assets" + [ "${DORY_ALLOW_MISSING_HOST_CLI:-0}" != "1" ] \ + || release_error "public releases cannot omit clean-Mac host CLIs" + [ "${DORY_BUILD_APPCAST:-1}" = "1" ] \ + || release_error "public releases must generate an appcast" + [ "${DORY_BUILD_APP_UPDATE:-1}" = "1" ] \ + || release_error "public releases must generate the app-update ZIP referenced by the appcast" + [ "${DORY_BUILD_DESKTOP_EDITION:-0}" = "0" ] \ + || release_error "focused public releases no longer ship a fixed Desktop edition" + [ "${DORY_BUILD_COMPONENTS:-1}" = "1" ] \ + || release_error "public releases must generate the signed focused component catalog" + [ "${DORY_RELEASE_RESUME_ACCEPTED_DESKTOP:-0}" != "1" ] \ + || release_error "the legacy Desktop-edition resume path cannot produce a focused release" + [ "${DORY_APPCAST_PREFER_APP_UPDATE:-1}" = "1" ] \ + || release_error "public releases must point Sparkle at the self-contained app-update ZIP" + [ "${DORY_BUILD_LITE:-0}" = "0" ] \ + || release_error "focused public releases ship one Docker Core app, not a separate lite app" + [ "${DORY_BUILD_RUNTIME:-1}" = "1" ] \ + || release_error "public releases must generate the documented headless runtime" + [ "${DORY_MAKE_DMG:-1}" = "1" ] \ + || release_error "public releases must generate DMGs" + [ -z "${DORY_APPCAST_ZIP:-}" ] \ + || release_error "public releases cannot redirect the appcast to an external ZIP override" + [ "${DORY_RELEASE_ASSET_BASE_URL:-https://github.com/Augani/dory/releases/download/v$VERSION}" = \ + "https://github.com/Augani/dory/releases/download/v$VERSION" ] \ + || release_error "public release assets must use the versioned Augani/dory GitHub release URL" + [ -z "${DORY_APPCAST_ARTIFACT_URL:-}" ] \ + || release_error "public releases cannot override the appcast artifact URL" + [ "${DORY_APPCAST_LINK:-https://augani.github.io/dory/appcast.xml}" = \ + "https://augani.github.io/dory/appcast.xml" ] \ + || release_error "public releases must use Dory's production Sparkle feed URL" + [ "${DORY_APPCAST_MINIMUM_SYSTEM_VERSION:-14.0}" = "14.0" ] \ + || release_error "public releases must keep the declared macOS 14 minimum" + [ "${DORY_APPCAST_TITLE:-Dory}" = "Dory" ] \ + || release_error "public releases must keep the Dory appcast identity" + [ -z "${DORY_APPCAST_PUBDATE:-}" ] \ + || release_error "public releases cannot override the appcast publication date" + [ -z "${DORY_SPARKLE_ED_SIGNATURE:-}" ] \ + || release_error "public releases must create the Sparkle signature from the configured private key" + [ -z "${DORY_SPARKLE_SIGN_UPDATE:-}" ] \ + || release_error "public releases must use sign_update from the release build's pinned Sparkle package" + + scripts/verify-clean-release-source.sh . >/dev/null \ + || release_error "public release source does not exactly match commit $SOURCE_COMMIT" +} + +release_host_guest_arch() { + [ "$(uname -m)" = x86_64 ] && printf '%s\n' amd64 || printf '%s\n' arm64 +} + +release_guest_arches() { + local requested arches="" + for requested in $RELEASE_VARIANTS; do + case "$requested" in + arm64|apple-silicon|silicon) arches="$arches arm64" ;; + x86_64|amd64|intel) arches="$arches amd64" ;; + universal|fat) arches="$arches arm64 amd64" ;; + *) release_error "unknown DORY_RELEASE_VARIANTS entry '$requested' (use arm64, x86_64, universal)" ;; + esac + done + for requested in arm64 amd64; do + case " $arches " in *" $requested "*) printf '%s\n' "$requested" ;; esac + done +} + +# Return the exact environment variable bundle-engine.sh will consult for a guest asset. The +# architecture-specific spelling wins; the unsuffixed compatibility spelling applies only to the +# host's native guest architecture. +guest_override_name() { + local prefix="$1" arch="$2" upper name + upper="$(printf '%s' "$arch" | tr '[:lower:]-' '[:upper:]_')" + name="${prefix}_${upper}" + if [ -n "${!name:-}" ]; then + printf '%s\n' "$name" + elif [ "$arch" = "$(release_host_guest_arch)" ] && [ -n "${!prefix:-}" ]; then + printf '%s\n' "$prefix" + fi +} + +# Fail in seconds, not after the full xcodebuild, when an engine-bundled release is requested on a +# runner that never built/fetched the guest assets. bundle-engine.sh remains the authoritative +# (hard-failing) check; this only covers the two classes every engine bundle needs. +preflight_guest_assets() { + [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] || return 0 + local arch name value hv_kernel vm_kernel initfs agent engine_rootfs gpu_kernel + local missing="" override_names="" kernel_verify_arches="" gpu_verify_arches="" initfs_verify_arches="" + + for arch in $(release_guest_arches); do + hv_kernel="$(guest_override_name DORY_HV_KERNEL "$arch")" + vm_kernel="$(guest_override_name DORY_KERNEL "$arch")" + initfs="$(guest_override_name DORY_INITFS "$arch")" + agent="$(guest_override_name DORY_GUEST_AGENT "$arch")" + engine_rootfs="$(guest_override_name DORY_ENGINE_ROOTFS "$arch")" + gpu_kernel="" + # Venus is currently advertised and verified only on Apple silicon. Never infer an untested + # Intel GPU guarantee merely because the public app also carries an x86_64 CPU slice. + if [ "${DORY_BUNDLE_VENUS:-1}" = "1" ] && [ "$arch" = arm64 ]; then + gpu_kernel="$(guest_override_name DORY_HV_GPU_KERNEL "$arch")" + [ -n "$gpu_kernel" ] || gpu_verify_arches="$gpu_verify_arches $arch" + fi + + for name in "$hv_kernel" "$vm_kernel" "$initfs" "$agent" "$engine_rootfs" "$gpu_kernel"; do + [ -n "$name" ] || continue + value="${!name}" + [ -f "$value" ] || release_error "$name does not name a regular file: $value" + case " $override_names " in *" $name "*) ;; *) override_names="$override_names $name" ;; esac + done + + # dory-hv and Virtualization.framework have independent kernel override surfaces. If either + # consumer still selects guest/out, the standard kernel must retain valid provenance. + if [ -z "$hv_kernel" ] || [ -z "$vm_kernel" ]; then + kernel_verify_arches="$kernel_verify_arches $arch" + fi + + # The standalone agent, VZ initfs, and engine rootfs also select independently. A DORY_INITFS + # override does not replace the guest/out agent or the engine-rootfs fallback. + if [ -z "$initfs" ] || [ -z "$agent" ]; then + initfs_verify_arches="$initfs_verify_arches $arch" + fi + if [ -z "$engine_rootfs" ]; then + if [ -f "guest/out/dory-engine-rootfs-$arch.ext4" ]; then + override_names="$override_names guest/out/dory-engine-rootfs-$arch.ext4(implicit)" + else + initfs_verify_arches="$initfs_verify_arches $arch" + fi + fi + done + + if [ -n "$override_names" ]; then + [ "${DORY_ALLOW_UNVERIFIED_GUEST_ASSETS:-0}" = "1" ] \ + || release_error "guest-asset overrides bypass source provenance:$override_names. Use verified guest/out assets, or explicitly set DORY_ALLOW_UNVERIFIED_GUEST_ASSETS=1 for a development-only release" + echo "==> WARNING: accepting unverified development guest assets:$override_names" + fi + + for arch in arm64 amd64; do + case " $kernel_verify_arches " in + *" $arch "*) + if [ "$arch" = arm64 ]; then + if [ -f guest/out/Image ]; then + DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh "$arch" >/dev/null \ + || release_error "$arch kernel is stale or missing required features" + else + missing="$missing arm64-kernel(guest/out/Image)" + fi + else + if [ -f guest/out/vmlinux-x86 ]; then + DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh "$arch" >/dev/null \ + || release_error "$arch kernel is stale or missing required features" + else + missing="$missing amd64-kernel(guest/out/vmlinux-x86)" + fi + fi + ;; + esac + case " $initfs_verify_arches " in + *" $arch "*) + if [ ! -f "guest/out/initfs-$arch.ext4" ]; then + missing="$missing $arch-initfs(guest/out/initfs-$arch.ext4)" + else + guest/initfs/verify-build.sh "$arch" >/dev/null \ + || release_error "$arch initfs/guest agent is stale" + fi + ;; + esac + case " $gpu_verify_arches " in + *" $arch "*) + if [ ! -f "guest/out/Image-gpu" ]; then + missing="$missing arm64-gpu-kernel(guest/out/Image-gpu)" + else + DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 >/dev/null \ + || release_error "arm64 GPU kernel is stale or missing required Venus features" + fi + ;; + esac + done + [ -z "$missing" ] || release_error "engine-bundled release needs guest assets on this runner; missing:$missing. Build them with guest/kernel/build.sh and guest/initfs/build.sh (or use the matching DORY_HV_KERNEL_*, DORY_KERNEL_*, DORY_INITFS_*, DORY_GUEST_AGENT_*, and DORY_ENGINE_ROOTFS_* overrides with the explicit development escape), or set DORY_BUNDLE_ENGINE=0 for an app-only dry-run" +} + +preflight_release() { + local requested + echo "==> Release preflight..." + for tool in xcodebuild codesign xcrun ditto file lipo shasum plutil security python3 tar unzip; do + require_tool "$tool" + done + validate_release_build_dir + preflight_public_release + preflight_macos_floor + preflight_guest_assets + if [ "${DORY_MAKE_DMG:-1}" = "1" ]; then + require_tool hdiutil + fi + if [ -z "$RELEASE_VARIANTS" ]; then + release_error "DORY_RELEASE_VARIANTS is empty" + fi + for requested in $RELEASE_VARIANTS; do + configure_variant "$requested" + done + + if [ "${DORY_SKIP_SIGNING_PREFLIGHT:-0}" != "1" ] && [ "$SIGN_IDENTITY" != "-" ]; then + security find-identity -v -p codesigning | grep -F "$SIGN_IDENTITY" >/dev/null \ + || release_error "codesigning identity '$SIGN_IDENTITY' not found; import the Developer ID Application certificate or set DORY_SKIP_SIGNING_PREFLIGHT=1 for local dry-runs" + fi + + if [ "${DORY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> WARNING: notarization disabled by DORY_SKIP_NOTARIZE=1; do not publish these artifacts." + else + require_tool spctl + if [ -n "${NOTARY_APPLE_ID:-}" ] || [ -n "${NOTARY_PASSWORD:-}" ]; then + [ -n "${NOTARY_APPLE_ID:-}" ] || release_error "NOTARY_APPLE_ID is required when using notarytool environment credentials" + [ -n "${NOTARY_TEAM_ID:-}" ] || release_error "NOTARY_TEAM_ID is required when using notarytool environment credentials" + [ -n "${NOTARY_PASSWORD:-}" ] || release_error "NOTARY_PASSWORD is required when using notarytool environment credentials" + else + echo "==> Using notarytool keychain profile '$NOTARY_PROFILE' (set NOTARY_APPLE_ID/NOTARY_TEAM_ID/NOTARY_PASSWORD in CI)." + fi + validate_notary_credentials + fi +} + +assert_file_exists() { + [ -f "$1" ] || release_error "$2 missing: $1" +} + +assert_executable_exists() { + [ -x "$1" ] || release_error "$2 missing or not executable: $1" +} + +assert_macho_arches() { + local binary="$1" expected="$2" archs arch + assert_executable_exists "$binary" "Mach-O executable" + archs="$(lipo -archs "$binary" 2>/dev/null || true)" + [ -n "$archs" ] || release_error "$binary is not a Mach-O binary" + for arch in $expected; do + case " $archs " in + *" $arch "*) ;; + *) release_error "$binary missing $arch (archs: ${archs:-none})" ;; + esac + done +} + +verify_codesign() { + local app="$1" + echo "==> Verifying code signature for $app..." + codesign --verify --strict --deep --verbose=2 "$app" + verify_developer_id_signature "$app" + if [ "${DORY_REQUIRE_DEVELOPER_ID_SIGNATURES:-1}" = "1" ] && [ "$SIGN_IDENTITY" != "-" ]; then + scripts/verify-distribution-signatures.sh "$app" "$TEAM" + fi +} + +verify_developer_id_signature() { + local path="$1" details + [ "${DORY_REQUIRE_DEVELOPER_ID_SIGNATURES:-1}" = "1" ] || return 0 + [ "$SIGN_IDENTITY" != "-" ] || return 0 + details="$(codesign -dv --verbose=4 "$path" 2>&1)" \ + || release_error "could not inspect code signature for $path" + printf '%s\n' "$details" | grep 'Authority=Developer ID Application' >/dev/null \ + || release_error "$path is not signed by a Developer ID Application certificate" +} + +validate_stapled_app() { + local app="$1" + echo "==> Validating stapled app ticket + Gatekeeper assessment..." + stapler_with_retry validate "$app" + spctl --assess --type execute --verbose=4 "$app" +} + +validate_stapled_dmg() { + local dmg="$1" assessment + echo "==> Validating stapled DMG ticket + Gatekeeper assessment..." + stapler_with_retry validate "$dmg" + assessment="$(spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg" 2>&1)" \ + || release_error "Gatekeeper rejected stapled DMG: $dmg" + printf '%s\n' "$assessment" + printf '%s\n' "$assessment" | grep -Fx 'source=Notarized Developer ID' >/dev/null \ + || release_error "stapled DMG is not accepted as Notarized Developer ID: $dmg" +} + +stapler_with_retry() { + local action="$1" target="$2" attempt=1 max_attempts=5 delay + while ! xcrun stapler "$action" "$target"; do + [ "$attempt" -lt "$max_attempts" ] \ + || release_error "stapler $action failed after $max_attempts attempts: $target" + delay=$((attempt * 5)) + echo "==> stapler $action attempt $attempt failed; retrying in ${delay}s..." >&2 + sleep "$delay" + attempt=$((attempt + 1)) + done +} + +verify_full_bundle() { + local app="$1" helpers resources frameworks launch_agent asset_arch helper cli_version + helpers="$app/Contents/Helpers" + resources="$app/Contents/Resources" + frameworks="$app/Contents/Frameworks" + launch_agent="$resources/dev.dory.doryd.plist" + + echo "==> Verifying full clean-Mac bundle payload..." + for helper in doryd dorydctl dory-vmm dory-network-helper dory-dataplane-proxy dory-hv gvproxy docker docker-buildx docker-compose dory dory-doctor; do + assert_executable_exists "$helpers/$helper" "bundled helper" + done + for helper in doryd dorydctl dory-vmm dory-network-helper dory-dataplane-proxy dory-hv; do + assert_macho_arches "$helpers/$helper" "$HELPER_ARCHES" + done + for helper in gvproxy docker docker-buildx docker-compose; do + assert_macho_arches "$helpers/$helper" "$HOST_CLI_ARCHES" + done + scripts/verify-macos-deployment-targets.sh "$app" "$HELPER_ARCHES" + for helper in doryd dorydctl dory-vmm dory-network-helper dory-dataplane-proxy dory-hv gvproxy docker docker-buildx docker-compose dory dory-doctor; do + verify_developer_id_signature "$helpers/$helper" + done + cli_version="$("$helpers/dory" version)" + [ "$cli_version" = "$VERSION" ] \ + || release_error "bundled dory CLI version $cli_version does not match $VERSION" + + for asset_arch in $BUNDLE_ARCHES; do + assert_file_exists "$resources/dory-agent-linux-$asset_arch" "guest agent" + assert_file_exists "$resources/dory-hv-kernel-$asset_arch.lzfse" "compressed dory-hv kernel" + assert_file_exists "$resources/dory-vm-kernel-$asset_arch.lzfse" "compressed VZ kernel" + assert_file_exists "$resources/dory-vm-initfs-$asset_arch.ext4.lzfse" "compressed VZ initfs" + assert_file_exists "$resources/dory-engine-rootfs-$asset_arch.ext4.lzfse" "engine rootfs" + assert_file_exists "$resources/dory-kernel-build-$asset_arch.stamp" "kernel provenance stamp" + assert_file_exists "$resources/dory-initfs-build-$asset_arch.stamp" "initfs provenance stamp" + if [ "$asset_arch" = arm64 ] && [ "${DORY_BUNDLE_VENUS:-1}" = "1" ]; then + assert_file_exists "$resources/dory-hv-kernel-gpu-arm64.lzfse" "Apple-silicon GPU kernel" + assert_file_exists "$resources/dory-kernel-build-arm64-gpu.stamp" "Apple-silicon GPU kernel provenance" + fi + done + assert_file_exists "$resources/dory-engine-rootfs.ext4.lzfse" "engine rootfs" + assert_file_exists "$resources/gvproxy-provenance.txt" "gvproxy provenance" + assert_file_exists "$resources/host-cli-provenance.txt" "host CLI provenance" + [ "$(stat -f '%Lp' "$resources/host-cli-provenance.txt")" = 644 ] \ + || release_error "host CLI provenance must use portable mode 0644" + if [ "${DORY_BUNDLE_VENUS:-1}" = "1" ]; then + assert_file_exists "$frameworks/libvirglrenderer.dylib" "patched VirGL/Venus renderer" + assert_file_exists "$frameworks/libMoltenVK.dylib" "MoltenVK renderer" + assert_file_exists "$resources/vulkan/icd.d/MoltenVK_icd.json" "MoltenVK ICD" + assert_file_exists "$resources/virglrenderer-provenance.txt" "VirGL renderer provenance" + assert_file_exists "$resources/moltenvk-provenance.txt" "MoltenVK provenance" + nm -gU "$frameworks/libvirglrenderer.dylib" \ + | grep '_dory_virglrenderer_macos_fragment_coord_fix$' >/dev/null \ + || release_error "bundled VirGL renderer is missing Dory's macOS shader compatibility fix" + nm -gU "$frameworks/libvirglrenderer.dylib" \ + | grep '_dory_virglrenderer_macos_venus_fence_fix$' >/dev/null \ + || release_error "bundled VirGL renderer is missing Dory's macOS Venus fence fix" + nm -gU "$frameworks/libvirglrenderer.dylib" \ + | grep -E '_virgl_renderer_resource_(get_map_ptr|map)$' >/dev/null \ + || release_error "bundled VirGL renderer has no host-visible Venus map entry point" + nm -gU "$frameworks/libMoltenVK.dylib" \ + | grep '_dory_moltenvk_spirv_native_array_fix$' >/dev/null \ + || release_error "bundled MoltenVK is missing Dory's SPIRV native-array fix" + otool -L "$frameworks/libvirglrenderer.dylib" \ + | grep '@loader_path/libMoltenVK\.dylib' >/dev/null \ + || release_error "bundled VirGL renderer is not linked to its MoltenVK sibling" + grep -q '^features=venus,macos-core-shader-compat-v2,macos-venus-fence-callback-v1,moltenvk-native-array-v1$' "$resources/virglrenderer-provenance.txt" \ + || release_error "VirGL renderer provenance does not declare Dory's shader and fence fixes" + grep -q '^features=spirv-native-function-arrays-v1$' "$resources/moltenvk-provenance.txt" \ + || release_error "MoltenVK provenance does not declare Dory's shader fix" + grep -Eq '^bundled_sha256=[0-9a-f]{64}$' "$resources/virglrenderer-provenance.txt" \ + || release_error "VirGL renderer provenance is missing the bundled dylib digest" + verify_developer_id_signature "$frameworks/libvirglrenderer.dylib" + verify_developer_id_signature "$frameworks/libMoltenVK.dylib" + fi + assert_file_exists "$resources/dory-payload-sha256.txt" "payload digest inventory" + assert_file_exists "$launch_agent" "bundled launchd plist" + plutil -lint "$launch_agent" >/dev/null + (cd "$app" && shasum -a 256 -c "Contents/Resources/dory-payload-sha256.txt" >/dev/null) \ + || release_error "$app payload digest inventory does not match bundled helpers/resources" +} + +verify_lean_bundle() { + local app="$1" resources included feed bundled + resources="$app/Contents/Resources" + included="$(/usr/libexec/PlistBuddy -c 'Print :DoryIncludesDesktopLinux' "$app/Contents/Info.plist" 2>/dev/null || true)" + feed="$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$app/Contents/Info.plist" 2>/dev/null || true)" + bundled="$(/usr/libexec/PlistBuddy -c 'Print :DoryBundledComponents' "$app/Contents/Info.plist" 2>/dev/null || true)" + [ "$included" = false ] || release_error "Core app must declare DoryIncludesDesktopLinux=false" + [ "$feed" = "https://augani.github.io/dory/appcast.xml" ] \ + || release_error "Core app must use the primary Sparkle feed" + [ "$bundled" = $'Array {\n docker-core\n}' ] \ + || release_error "Core app must declare only docker-core in DoryBundledComponents" + [ ! -e "$app/Contents/Helpers/kubectl" ] \ + || release_error "Core app unexpectedly contains kubectl" + for payload in \ + dory-hv-kernel-arm64 \ + dory-hv-kernel \ + dory-machine-rootfs-arm64.ext4 \ + dory-machine-rootfs.ext4; do + [ ! -e "$resources/$payload" ] || release_error "Core app unexpectedly contains $payload" + done + for payload in \ + dory-desktop-kernel-arm64.lzfse \ + kernel-build-arm64-desktop.stamp \ + dory-desktop-debian-rootfs-arm64.ext4.lzfse \ + dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse \ + dory-desktop-kali-rootfs-arm64.ext4.lzfse; do + [ ! -e "$resources/$payload" ] || release_error "Core app unexpectedly contains $payload" + done +} + +verify_desktop_bundle() { + local app="$1" resources included feed distro + resources="$app/Contents/Resources" + included="$(/usr/libexec/PlistBuddy -c 'Print :DoryIncludesDesktopLinux' "$app/Contents/Info.plist" 2>/dev/null || true)" + feed="$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$app/Contents/Info.plist" 2>/dev/null || true)" + [ "$included" = true ] || release_error "Desktop app must declare DoryIncludesDesktopLinux=true" + [ "$feed" = "https://augani.github.io/dory/appcast-desktop.xml" ] \ + || release_error "Desktop app must use the Desktop Sparkle feed" + assert_file_exists "$resources/dory-desktop-kernel-arm64.lzfse" "Desktop Linux kernel" + assert_file_exists "$resources/kernel-build-arm64-desktop.stamp" "Desktop Linux kernel provenance" + for distro in debian ubuntu kali; do + assert_file_exists "$resources/dory-desktop-$distro-rootfs-arm64.ext4.lzfse" "$distro Desktop image" + assert_file_exists "$resources/dory-desktop-$distro-build-arm64.stamp" "$distro Desktop provenance" + assert_file_exists "$resources/dory-desktop-$distro-packages-arm64.txt" "$distro Desktop package manifest" + done +} + +sign_app() { + local app="$1" + local entitlements="Dory/Dory.entitlements" + echo "==> Signing $(basename "$(dirname "$app")")/Dory.app (Developer ID + hardened runtime)..." + if [ "$SIGN_IDENTITY" = "-" ]; then + entitlements="$BUILD_DIR/local-adhoc-app.entitlements" + mkdir -p "$(dirname "$entitlements")" + /bin/cat > "$entitlements" <<'PLIST' + + + + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.network.server + + + +PLIST + fi + # NOT --deep: bundle-engine.sh already signed nested helpers with their own entitlements + # (dory-hv needs com.apple.security.hypervisor, dory-vmm needs virtualization), and --deep + # would re-sign them without those entitlements. + codesign --force --options runtime --timestamp --entitlements "$entitlements" --sign "$SIGN_IDENTITY" "$app" +} + +sign_dmg() { + local dmg="$1" + echo "==> Signing $(basename "$dmg") (Developer ID + secure timestamp)..." + if [ "$SIGN_IDENTITY" = "-" ]; then + codesign --force --sign - "$dmg" + else + codesign --force --timestamp --sign "$SIGN_IDENTITY" "$dmg" + fi +} + +verify_dmg_signature() { + local dmg="$1" details + codesign --verify --strict --verbose=2 "$dmg" \ + || release_error "disk image signature is invalid: $dmg" + [ "${DORY_REQUIRE_DEVELOPER_ID_SIGNATURES:-1}" = "1" ] || return 0 + [ "$SIGN_IDENTITY" != "-" ] || return 0 + details="$(codesign -d --verbose=4 "$dmg" 2>&1)" \ + || release_error "could not inspect disk image signature: $dmg" + printf '%s\n' "$details" | grep -F 'Authority=Developer ID Application:' >/dev/null \ + || release_error "disk image is not signed by a Developer ID Application certificate: $dmg" + printf '%s\n' "$details" | grep -F "TeamIdentifier=$TEAM" >/dev/null \ + || release_error "disk image is not signed by expected team $TEAM: $dmg" + printf '%s\n' "$details" | grep -E '^Timestamp=' >/dev/null \ + || release_error "disk image signature has no secure timestamp: $dmg" +} + +archive_variant() { + local variant="$1" archive="$2" + echo "==> Archiving + signing Dory $VERSION $variant (Developer ID, team $TEAM, archs: $XCODE_ARCHS)..." + xcodebuild -project Dory.xcodeproj -scheme Dory -configuration Release -scmProvider system \ + -destination 'generic/platform=macOS' -derivedDataPath "$DERIVED_DATA_DIR" -archivePath "$archive" \ + ARCHS="$XCODE_ARCHS" \ + ONLY_ACTIVE_ARCH=NO \ + MARKETING_VERSION="$VERSION" \ + CURRENT_PROJECT_VERSION="$BUILD" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$TEAM" \ + archive +} + +zip_app() { + local app="$1" zip="$2" + rm -f "$zip" + # Resource-fork metadata creates a top-level __MACOSX directory that violates the public + # archive contract and is unnecessary for Dory's signed application bundle. + COPYFILE_DISABLE=1 ditto -c -k --keepParent "$app" "$zip" +} + +finish_app_artifact() { + local app="$1" zip="$2" dmg="$3" + zip_app "$app" "$zip" + if [ "${DORY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> Skipping notarization for $zip (DORY_SKIP_NOTARIZE=1)" + else + echo "==> Notarizing $zip..." + notarize "$zip" + stapler_with_retry staple "$app" + validate_stapled_app "$app" + zip_app "$app" "$zip" + fi + + if [ "${DORY_MAKE_DMG:-1}" = "1" ]; then + echo "==> Building DMG $dmg..." + scripts/make-dmg.sh "$app" "$VERSION" "$dmg" + sign_dmg "$dmg" + verify_dmg_signature "$dmg" + if [ "${DORY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> Skipping notarization for $dmg (DORY_SKIP_NOTARIZE=1)" + else + echo "==> Notarizing $dmg..." + notarize "$dmg" + stapler_with_retry staple "$dmg" + validate_stapled_dmg "$dmg" + fi + fi +} + +finish_zip_update_artifact() { + local app="$1" zip="$2" + # The update app is copied from the already notarized and stapled direct-release app. Submitting + # that copy again creates a different stapling ticket in Contents/CodeResources, so Sparkle would + # install bytes that no longer match the direct ZIP, DMG, or exact-tree SBOM. Sparkle authenticates + # the ZIP with its EdDSA signature; Gatekeeper authenticates the unchanged signed/stapled app. + if [ "${DORY_SKIP_NOTARIZE:-0}" != "1" ]; then + validate_stapled_app "$app" + fi + zip_app "$app" "$zip" +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +artifact_kind() { + case "$1" in + *.cdx.json) printf '%s' "cyclonedx-json" ;; + *.dmg) printf '%s' "dmg" ;; + *.zip) printf '%s' "zip" ;; + *.tar.gz) printf '%s' "tar.gz" ;; + *) printf '%s' "file" ;; + esac +} + +write_release_manifest() { + local manifest="$BUILD_DIR/release-manifest.json" artifact first kind record_path + first=1 + { + echo "{" + echo " \"schemaVersion\": 2," + echo " \"version\": \"$(json_escape "$VERSION")\"," + echo " \"build\": \"$(json_escape "$BUILD")\"," + echo " \"sourceCommit\": \"$(json_escape "$SOURCE_COMMIT")\"," + echo " \"publicRelease\": $([ "${DORY_PUBLIC_RELEASE:-0}" = "1" ] && echo true || echo false)," + echo " \"bundleEngine\": $([ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] && echo true || echo false)," + echo " \"notarized\": $([ "${DORY_SKIP_NOTARIZE:-0}" = "1" ] && echo false || echo true)," + echo " \"variants\": \"$(json_escape "$RELEASE_VARIANTS")\"," + echo " \"artifacts\": [" + for artifact in "$@"; do + [ -n "$artifact" ] && [ -f "$artifact" ] || continue + kind="$(artifact_kind "$artifact")" + record_path="${artifact#$BUILD_DIR/}" + [ "$record_path" != "$artifact" ] \ + || release_error "release manifest artifact is outside the build directory: $artifact" + case "/$record_path/" in + *"/../"*|*"/./"*) release_error "release manifest artifact path is unsafe: $record_path" ;; + esac + if [ "$first" -eq 0 ]; then + echo "," + fi + first=0 + printf ' {"name":"%s","path":"%s","kind":"%s","bytes":%s,"sha256":"%s"}' \ + "$(json_escape "$(basename "$artifact")")" \ + "$(json_escape "$record_path")" \ + "$kind" \ + "$(file_size_bytes "$artifact")" \ + "$(sha256_file "$artifact")" + done + echo + echo " ]" + echo "}" + } > "$manifest" + printf '%s' "$manifest" +} + +build_appcast_enabled() { + local requested="${DORY_BUILD_APPCAST:-}" + if [ -z "$requested" ]; then + [ "${DORY_SKIP_NOTARIZE:-0}" = "1" ] && requested="0" || requested="1" + fi + case "$requested" in + 0|1) printf '%s' "$requested" ;; + *) release_error "DORY_BUILD_APPCAST must be 0 or 1" ;; + esac +} + +if [ "${DORY_RELEASE_SOURCE_ONLY:-0}" = "1" ]; then + if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0; else exit 0; fi +fi + +preflight_release +if [ "${DORY_RELEASE_PREFLIGHT_ONLY:-0}" = "1" ]; then + echo "==> Preflight-only mode passed." + exit 0 +fi + +if [ "${DORY_RELEASE_RESUME_ACCEPTED_DESKTOP:-0}" != "1" ]; then + rm -rf "$BUILD_DIR" +fi +mkdir -p "$BUILD_DIR" + +ZIPS=() +DMGS=() +FIRST_ARCHIVE="" +UNIVERSAL_ARCHIVE="" +UNIVERSAL_ZIP="" +UNIVERSAL_DMG="" +UNIVERSAL_APP="" +ARM64_APP="" +ARM64_ZIP="" +ARM64_DMG="" +DESKTOP_APP="" +DESKTOP_ZIP="" +DESKTOP_DMG="" +COMPONENT_OUTPUT_DIR="$BUILD_DIR/components/arm64" +COMPONENT_INPUT_DIR="$BUILD_DIR/component-inputs" +COMPONENT_KUBECTL="$COMPONENT_INPUT_DIR/kubectl" +COMPONENT_KUBECTL_PROVENANCE="$COMPONENT_INPUT_DIR/kubectl.provenance.txt" +COMPONENT_ASSETS=() + +if [ "${DORY_RELEASE_RESUME_ACCEPTED_DESKTOP:-0}" = "1" ]; then + echo "==> Resuming after accepted Desktop ZIP notarization..." + configure_variant arm64 + FIRST_ARCHIVE="$BUILD_DIR/Dory-arm64.xcarchive" + ARM64_APP="$BUILD_DIR/export-arm64/Dory.app" + ARM64_ZIP="$BUILD_DIR/Dory-$VERSION-arm64.zip" + ARM64_DMG="$BUILD_DIR/Dory-$VERSION-arm64.dmg" + DESKTOP_APP="$BUILD_DIR/export-desktop-arm64/Dory.app" + DESKTOP_ZIP="$BUILD_DIR/Dory-$VERSION-desktop-arm64.zip" + DESKTOP_DMG="$BUILD_DIR/Dory-$VERSION-desktop-arm64.dmg" + [ -d "$FIRST_ARCHIVE/Products/Applications/Dory.app" ] \ + || release_error "resume archive is missing: $FIRST_ARCHIVE" + for artifact in "$ARM64_ZIP" "$ARM64_DMG" "$DESKTOP_ZIP"; do + assert_file_exists "$artifact" "resume artifact" + done + [ -d "$ARM64_APP" ] || release_error "resume Lean app is missing: $ARM64_APP" + [ -d "$DESKTOP_APP" ] || release_error "resume Desktop app is missing: $DESKTOP_APP" + DORY_SPARKLE_SIGN_UPDATE="$DERIVED_DATA_DIR/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" + [ -x "$DORY_SPARKLE_SIGN_UPDATE" ] \ + || release_error "pinned Sparkle sign_update is missing from the resumed build" + export DORY_SPARKLE_SIGN_UPDATE + verify_full_bundle "$ARM64_APP" + verify_lean_bundle "$ARM64_APP" + verify_codesign "$ARM64_APP" + validate_stapled_app "$ARM64_APP" + verify_full_bundle "$DESKTOP_APP" + verify_desktop_bundle "$DESKTOP_APP" + verify_codesign "$DESKTOP_APP" + stapler_with_retry staple "$DESKTOP_APP" + validate_stapled_app "$DESKTOP_APP" + zip_app "$DESKTOP_APP" "$DESKTOP_ZIP" + echo "==> Building DMG $DESKTOP_DMG..." + scripts/make-dmg.sh "$DESKTOP_APP" "$VERSION" "$DESKTOP_DMG" + sign_dmg "$DESKTOP_DMG" + verify_dmg_signature "$DESKTOP_DMG" + echo "==> Notarizing $DESKTOP_DMG..." + notarize "$DESKTOP_DMG" + stapler_with_retry staple "$DESKTOP_DMG" + validate_stapled_dmg "$DESKTOP_DMG" + ZIPS+=("$ARM64_ZIP" "$DESKTOP_ZIP") + DMGS+=("$ARM64_DMG" "$DESKTOP_DMG") +else +for requested in $RELEASE_VARIANTS; do + configure_variant "$requested" + ARCHIVE="$BUILD_DIR/Dory-$VARIANT_SUFFIX.xcarchive" + EXPORT_DIR="$BUILD_DIR/export-$VARIANT_SUFFIX" + APP="$EXPORT_DIR/Dory.app" + ZIP="$BUILD_DIR/Dory-$VERSION-$VARIANT_SUFFIX.zip" + DMG="$BUILD_DIR/Dory-$VERSION-$VARIANT_SUFFIX.dmg" + + archive_variant "$VARIANT" "$ARCHIVE" + if [ -z "${DORY_SPARKLE_SIGN_UPDATE:-}" ]; then + DORY_SPARKLE_SIGN_UPDATE="$DERIVED_DATA_DIR/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" + [ -x "$DORY_SPARKLE_SIGN_UPDATE" ] \ + || release_error "pinned Sparkle sign_update was not produced by this release build" + export DORY_SPARKLE_SIGN_UPDATE + fi + [ -n "$FIRST_ARCHIVE" ] || FIRST_ARCHIVE="$ARCHIVE" + [ "$VARIANT" = "universal" ] && UNIVERSAL_ARCHIVE="$ARCHIVE" + + rm -rf "$EXPORT_DIR" + mkdir -p "$EXPORT_DIR" + cp -R "$ARCHIVE/Products/Applications/Dory.app" "$EXPORT_DIR/" + assert_app_binary_arches "$APP/Contents/MacOS/Dory" "$XCODE_ARCHS" + + # Engine bundling is the full-release default: users should be able to install Dory.app on a clean + # Mac without Docker Desktop, Colima, OrbStack, Homebrew, or Apple `container`. + if [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ]; then + echo "==> Bundling the self-contained engine for $VARIANT..." + # A release consumes the exact stamped initfs. Opportunistically rewriting it with host-found + # agent or toolbox binaries would make the signed guest vary by runner and escape provenance; + # add those tools to guest/initfs/PINS + build.sh before making them part of a release image. + DORY_BUNDLE_ARCHES="$BUNDLE_ARCHES" \ + DORY_SWIFTPM_HELPER_ARCHES="$HELPER_ARCHES" \ + DORY_HOST_CLI_ARCHES="$HOST_CLI_ARCHES" \ + DORY_BUNDLE_NATIVE_ARCH="$NATIVE_GUEST_ARCH" \ + DORY_COMPONENT_BUNDLE_MODE=core \ + DORY_COMPONENT_KUBECTL_OUTPUT="$COMPONENT_KUBECTL" \ + DORY_COMPONENT_KUBECTL_PROVENANCE_OUTPUT="$COMPONENT_KUBECTL_PROVENANCE" \ + DORY_DESKTOP_BUNDLE_MODE=none \ + DORY_SKIP_AGENT_INJECT=1 \ + DORY_SKIP_TOOLBOX_INJECT=1 \ + DORY_REQUIRE_BUNDLE_ASSETS="${DORY_REQUIRE_BUNDLE_ASSETS:-1}" \ + scripts/bundle-engine.sh "$APP" + else + echo "==> WARNING: producing a development app without bundled engine assets for $VARIANT." + fi + + scripts/sign-sparkle-for-distribution.sh "$APP" "$SIGN_IDENTITY" + sign_app "$APP" + if [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ]; then + verify_full_bundle "$APP" + verify_lean_bundle "$APP" + fi + verify_codesign "$APP" + finish_app_artifact "$APP" "$ZIP" "$DMG" + + if [ "$VARIANT" = arm64 ] \ + && [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] \ + && [ "${DORY_BUILD_COMPONENTS:-1}" = "1" ]; then + [ -f "$DMG" ] || release_error "focused component catalog requires the Core DMG" + assert_executable_exists "$COMPONENT_KUBECTL" "exported Kubernetes component" + assert_file_exists "$COMPONENT_KUBECTL_PROVENANCE" "Kubernetes component provenance" + assert_macho_arches "$COMPONENT_KUBECTL" arm64 + verify_developer_id_signature "$COMPONENT_KUBECTL" + echo "==> Building signed focused component catalog..." + scripts/build-components.py \ + --version "$VERSION" \ + --minimum-app-version "$VERSION" \ + --core-artifact "$DMG" \ + --core-app "$APP" \ + --kubectl "$COMPONENT_KUBECTL" \ + --output "$COMPONENT_OUTPUT_DIR" \ + --asset-base-url "https://github.com/Augani/dory/releases/download/v$VERSION" \ + --signer "$DORY_SPARKLE_SIGN_UPDATE" + assert_file_exists "$COMPONENT_OUTPUT_DIR/catalog.json" "component catalog" + assert_file_exists "$COMPONENT_OUTPUT_DIR/catalog.json.sig" "component catalog signature" + assert_file_exists "$COMPONENT_OUTPUT_DIR/catalog.json.sha256" "component catalog digest" + mkdir -p website/public/components/arm64 + cp "$COMPONENT_OUTPUT_DIR/catalog.json" website/public/components/arm64/catalog.json + cp "$COMPONENT_OUTPUT_DIR/catalog.json.sig" website/public/components/arm64/catalog.json.sig + cp "$COMPONENT_OUTPUT_DIR/catalog.json.sha256" website/public/components/arm64/catalog.json.sha256 + while IFS= read -r component_asset; do + COMPONENT_ASSETS+=("$component_asset") + done < <(find "$COMPONENT_OUTPUT_DIR" -maxdepth 1 -type f -print | LC_ALL=C sort) + fi + ZIPS+=("$ZIP") + [ -f "$DMG" ] && DMGS+=("$DMG") + + case "$VARIANT" in + arm64) + ARM64_APP="$APP" + ARM64_ZIP="$ZIP" + [ -f "$DMG" ] && ARM64_DMG="$DMG" + ;; + universal) + UNIVERSAL_ZIP="$ZIP" + [ -f "$DMG" ] && UNIVERSAL_DMG="$DMG" + UNIVERSAL_APP="$APP" + ;; + esac + + if [ "$VARIANT" = arm64 ] \ + && [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] \ + && [ "${DORY_BUILD_DESKTOP_EDITION:-0}" = "1" ]; then + DESKTOP_EXPORT_DIR="$BUILD_DIR/export-desktop-arm64" + DESKTOP_APP="$DESKTOP_EXPORT_DIR/Dory.app" + DESKTOP_ZIP="$BUILD_DIR/Dory-$VERSION-desktop-arm64.zip" + DESKTOP_DMG="$BUILD_DIR/Dory-$VERSION-desktop-arm64.dmg" + rm -rf "$DESKTOP_EXPORT_DIR" + mkdir -p "$DESKTOP_EXPORT_DIR" + cp -R "$ARCHIVE/Products/Applications/Dory.app" "$DESKTOP_EXPORT_DIR/" + assert_app_binary_arches "$DESKTOP_APP/Contents/MacOS/Dory" "$XCODE_ARCHS" + echo "==> Bundling the all-inclusive Desktop edition for Apple silicon..." + DORY_BUNDLE_ARCHES="$BUNDLE_ARCHES" \ + DORY_SWIFTPM_HELPER_ARCHES="$HELPER_ARCHES" \ + DORY_HOST_CLI_ARCHES="$HOST_CLI_ARCHES" \ + DORY_BUNDLE_NATIVE_ARCH="$NATIVE_GUEST_ARCH" \ + DORY_DESKTOP_BUNDLE_MODE=all \ + DORY_SKIP_AGENT_INJECT=1 \ + DORY_SKIP_TOOLBOX_INJECT=1 \ + DORY_REQUIRE_BUNDLE_ASSETS="${DORY_REQUIRE_BUNDLE_ASSETS:-1}" \ + scripts/bundle-engine.sh "$DESKTOP_APP" + scripts/sign-sparkle-for-distribution.sh "$DESKTOP_APP" "$SIGN_IDENTITY" + sign_app "$DESKTOP_APP" + verify_full_bundle "$DESKTOP_APP" + verify_desktop_bundle "$DESKTOP_APP" + verify_codesign "$DESKTOP_APP" + finish_app_artifact "$DESKTOP_APP" "$DESKTOP_ZIP" "$DESKTOP_DMG" + ZIPS+=("$DESKTOP_ZIP") + [ -f "$DESKTOP_DMG" ] && DMGS+=("$DESKTOP_DMG") + fi +done +fi + +# Keep the historic cask/download filenames as aliases for the public primary artifact. During the +# Apple-Silicon-first phase that is arm64; a future universal release can take precedence unchanged. +COMPAT_ZIP="" +COMPAT_DMG="" +PRIMARY_ZIP="${UNIVERSAL_ZIP:-$ARM64_ZIP}" +PRIMARY_DMG="${UNIVERSAL_DMG:-$ARM64_DMG}" +if [ -n "$PRIMARY_ZIP" ]; then + COMPAT_ZIP="$BUILD_DIR/Dory-$VERSION.zip" + copy_alias "$PRIMARY_ZIP" "$COMPAT_ZIP" + ZIPS+=("$COMPAT_ZIP") +fi +if [ -n "$PRIMARY_DMG" ]; then + COMPAT_DMG="$BUILD_DIR/Dory-$VERSION.dmg" + copy_alias "$PRIMARY_DMG" "$COMPAT_DMG" + DMGS+=("$COMPAT_DMG") +fi + +# ---- Extra release flavors --------------------------------------------------------------- +# lite: app only, from the public primary archive. +LITE_ZIP="" +if [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] && [ "${DORY_BUILD_LITE:-0}" = "1" ]; then + LITE_ARCHIVE="${UNIVERSAL_ARCHIVE:-$FIRST_ARCHIVE}" + if [ -n "$LITE_ARCHIVE" ] && [ -d "$LITE_ARCHIVE/Products/Applications/Dory.app" ]; then + echo "==> Building lite app (no bundled engine)..." + LITE_DIR="$BUILD_DIR/export-lite" + LITE_APP="$LITE_DIR/Dory.app" + rm -rf "$LITE_DIR" + mkdir -p "$LITE_DIR" + cp -R "$LITE_ARCHIVE/Products/Applications/Dory.app" "$LITE_DIR/" + scripts/sign-sparkle-for-distribution.sh "$LITE_APP" "$SIGN_IDENTITY" + sign_app "$LITE_APP" + verify_codesign "$LITE_APP" + LITE_ZIP="$BUILD_DIR/Dory-$VERSION-lite.zip" + zip_app "$LITE_APP" "$LITE_ZIP" + if [ "${DORY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> Skipping notarization for $LITE_ZIP (DORY_SKIP_NOTARIZE=1)" + else + echo "==> Notarizing lite app..." + notarize "$LITE_ZIP" + stapler_with_retry staple "$LITE_APP" + validate_stapled_app "$LITE_APP" + zip_app "$LITE_APP" "$LITE_ZIP" + fi + fi +fi + +# app-update: edition-specific, self-contained application updates. Sparkle replaces Dory.app, so +# each feed must retain the engine and the exact Lean or Desktop capability selected by the user. +APP_UPDATE_ZIP="" +DESKTOP_APP_UPDATE_ZIP="" +if [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] && [ "${DORY_BUILD_APP_UPDATE:-1}" = "1" ]; then + UPDATE_SOURCE_APP="${UNIVERSAL_APP:-$ARM64_APP}" + UPDATE_ARCHES="arm64" + [ -z "$UNIVERSAL_APP" ] || UPDATE_ARCHES="arm64 amd64" + if [ -n "$UPDATE_SOURCE_APP" ] && [ -d "$UPDATE_SOURCE_APP" ]; then + echo "==> Building self-contained app update bundle..." + UPDATE_DIR="$BUILD_DIR/export-app-update" + UPDATE_APP="$UPDATE_DIR/Dory.app" + rm -rf "$UPDATE_DIR" + mkdir -p "$UPDATE_DIR" + ditto "$UPDATE_SOURCE_APP" "$UPDATE_APP" + scripts/validate-app-update-payload.sh "$UPDATE_APP" "$UPDATE_ARCHES" core + # The SBOM inventories the exact notarized full app. Re-signing this copy would change the main + # executable and CodeResources, making Sparkle install different bytes than the direct app. + verify_codesign "$UPDATE_APP" + APP_UPDATE_ZIP="$BUILD_DIR/Dory-$VERSION-app-update.zip" + finish_zip_update_artifact "$UPDATE_APP" "$APP_UPDATE_ZIP" + fi + if [ -n "$DESKTOP_APP" ] && [ -d "$DESKTOP_APP" ]; then + echo "==> Building self-contained Desktop app update bundle..." + DESKTOP_UPDATE_DIR="$BUILD_DIR/export-desktop-app-update" + DESKTOP_UPDATE_APP="$DESKTOP_UPDATE_DIR/Dory.app" + rm -rf "$DESKTOP_UPDATE_DIR" + mkdir -p "$DESKTOP_UPDATE_DIR" + ditto "$DESKTOP_APP" "$DESKTOP_UPDATE_APP" + scripts/validate-app-update-payload.sh "$DESKTOP_UPDATE_APP" arm64 desktop + verify_codesign "$DESKTOP_UPDATE_APP" + DESKTOP_APP_UPDATE_ZIP="$BUILD_DIR/Dory-$VERSION-desktop-app-update.zip" + finish_zip_update_artifact "$DESKTOP_UPDATE_APP" "$DESKTOP_APP_UPDATE_ZIP" + fi +fi + +# Headless runtime is arm64 during the Apple-Silicon-first release phase. +RUNTIME_TAR="" +if [ "${DORY_BUNDLE_ENGINE:-1}" = "1" ] && [ "${DORY_BUILD_RUNTIME:-1}" = "1" ] && [ -n "$ARM64_APP" ]; then + echo "==> Packaging standalone engine runtime..." + RUNTIME_NAME="dory-engine-$VERSION-arm64" + RUNTIME_DIR="$BUILD_DIR/runtime/$RUNTIME_NAME" + rm -rf "$BUILD_DIR/runtime" + mkdir -p "$RUNTIME_DIR/bin" "$RUNTIME_DIR/share/dory" + cp "$ARM64_APP/Contents/Helpers/dory-hv" "$RUNTIME_DIR/bin/" + cp "$ARM64_APP/Contents/Helpers/gvproxy" "$RUNTIME_DIR/bin/" + cp "$ARM64_APP/Contents/Helpers/dory-dataplane-proxy" "$RUNTIME_DIR/bin/" + cp "$ARM64_APP/Contents/Resources/dory-hv-kernel-arm64.lzfse" "$RUNTIME_DIR/share/dory/" + [ -f "$ARM64_APP/Contents/Resources/dory-agent-linux-arm64" ] && cp "$ARM64_APP/Contents/Resources/dory-agent-linux-arm64" "$RUNTIME_DIR/share/dory/" + if [ -f "$ARM64_APP/Contents/Resources/dory-engine-rootfs-arm64.ext4.lzfse" ]; then + cp "$ARM64_APP/Contents/Resources/dory-engine-rootfs-arm64.ext4.lzfse" "$RUNTIME_DIR/share/dory/dory-engine-rootfs.ext4.lzfse" + elif [ -f "$ARM64_APP/Contents/Resources/dory-engine-rootfs.ext4.lzfse" ]; then + cp "$ARM64_APP/Contents/Resources/dory-engine-rootfs.ext4.lzfse" "$RUNTIME_DIR/share/dory/" + fi + cp scripts/runtime/dory-engine "$RUNTIME_DIR/dory-engine" + chmod 0755 "$RUNTIME_DIR/dory-engine" + cat > "$RUNTIME_DIR/README.md" < Generating exact app-tree CycloneDX SBOM..." + scripts/generate-release-sbom.py \ + --app "$SBOM_APP" --version "$VERSION" --source-commit "$SOURCE_COMMIT" --output "$SBOM" + scripts/verify-release-sbom.py \ + --sbom "$SBOM" --app "$SBOM_APP" --version "$VERSION" --source-commit "$SOURCE_COMMIT" +fi +if [ -n "$DESKTOP_APP" ] && [ -d "$DESKTOP_APP" ]; then + DESKTOP_SBOM="$BUILD_DIR/Dory-$VERSION-desktop.cdx.json" + echo "==> Generating exact Desktop app-tree CycloneDX SBOM..." + scripts/generate-release-sbom.py \ + --app "$DESKTOP_APP" --version "$VERSION" --source-commit "$SOURCE_COMMIT" --output "$DESKTOP_SBOM" + scripts/verify-release-sbom.py \ + --sbom "$DESKTOP_SBOM" --app "$DESKTOP_APP" --version "$VERSION" --source-commit "$SOURCE_COMMIT" +fi + +DEFAULT_ZIP="${COMPAT_ZIP:-${UNIVERSAL_ZIP:-${ZIPS[0]:-}}}" +DEFAULT_DMG="${COMPAT_DMG:-${UNIVERSAL_DMG:-}}" +DEFAULT_SHA256="" +[ -n "$DEFAULT_ZIP" ] && DEFAULT_SHA256="$(sha256_file "$DEFAULT_ZIP")" + +APPCAST_ZIP="$DEFAULT_ZIP" +if [ "${DORY_APPCAST_PREFER_APP_UPDATE:-1}" = "1" ] && [ -n "$APP_UPDATE_ZIP" ] && [ -f "$APP_UPDATE_ZIP" ]; then + APPCAST_ZIP="$APP_UPDATE_ZIP" +fi +if [ -n "${DORY_APPCAST_ZIP:-}" ]; then + [ -f "$DORY_APPCAST_ZIP" ] || release_error "DORY_APPCAST_ZIP does not exist: $DORY_APPCAST_ZIP" + APPCAST_ZIP="$DORY_APPCAST_ZIP" +fi + +APPCAST="" +DESKTOP_APPCAST="" +if [ "$(build_appcast_enabled)" = "1" ]; then + [ -n "$APPCAST_ZIP" ] || release_error "cannot generate Sparkle appcast without an app update artifact" + echo "==> Generating Sparkle appcast for $(basename "$APPCAST_ZIP")..." + APPCAST="$BUILD_DIR/appcast.xml" + scripts/generate-appcast.sh "$VERSION" "$BUILD" "$APPCAST_ZIP" "$APPCAST" "website/public/appcast.xml" >/dev/null + mkdir -p docs-build website/public + cp "$APPCAST" docs-build/appcast.xml + cp "$APPCAST" website/public/appcast.xml + preflight_macos_floor +fi + +echo "==> Done." +if [ "${#ZIPS[@]}" -gt 0 ]; then + for artifact in "${ZIPS[@]}"; do + [ -n "$artifact" ] && [ -f "$artifact" ] || continue + echo " $artifact (sha256: $(sha256_file "$artifact"))" + done +fi +if [ "${#DMGS[@]}" -gt 0 ]; then + for artifact in "${DMGS[@]}"; do + [ -n "$artifact" ] && [ -f "$artifact" ] || continue + echo " $artifact (sha256: $(sha256_file "$artifact"))" + done +fi +for artifact in "$LITE_ZIP" "$APP_UPDATE_ZIP" "$DESKTOP_APP_UPDATE_ZIP" "$RUNTIME_TAR" "$SBOM" "$DESKTOP_SBOM"; do + [ -n "$artifact" ] && [ -f "$artifact" ] || continue + echo " $artifact (sha256: $(sha256_file "$artifact"))" +done +for artifact in "${COMPONENT_ASSETS[@]}"; do + [ -f "$artifact" ] || continue + echo " $artifact (sha256: $(sha256_file "$artifact"))" +done + +MANIFEST_ARTIFACTS=() +if [ "${#ZIPS[@]}" -gt 0 ]; then + for artifact in "${ZIPS[@]}"; do + MANIFEST_ARTIFACTS+=("$artifact") + done +fi +if [ "${#DMGS[@]}" -gt 0 ]; then + for artifact in "${DMGS[@]}"; do + MANIFEST_ARTIFACTS+=("$artifact") + done +fi +MANIFEST_ARTIFACTS+=("$LITE_ZIP" "$APP_UPDATE_ZIP" "$DESKTOP_APP_UPDATE_ZIP" "$RUNTIME_TAR" "$APPCAST" "$DESKTOP_APPCAST" "$SBOM" "$DESKTOP_SBOM") +if [ "${#COMPONENT_ASSETS[@]}" -gt 0 ]; then + MANIFEST_ARTIFACTS+=("${COMPONENT_ASSETS[@]}") +fi +MANIFEST="$(write_release_manifest "${MANIFEST_ARTIFACTS[@]}")" +echo " $MANIFEST (release manifest)" +[ -n "$APPCAST" ] && echo " $APPCAST (Sparkle appcast)" + +# Expose outputs to a GitHub Actions step when running in CI. +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "zip=$DEFAULT_ZIP" + echo "sha256=$DEFAULT_SHA256" + echo "version=$VERSION" + echo "build=$BUILD" + echo "dmg=$DEFAULT_DMG" + echo "lite=$LITE_ZIP" + echo "app_update=$APP_UPDATE_ZIP" + echo "desktop_app_update=$DESKTOP_APP_UPDATE_ZIP" + echo "runtime=$RUNTIME_TAR" + echo "sbom=$SBOM" + echo "desktop_sbom=$DESKTOP_SBOM" + echo "manifest=$MANIFEST" + echo "appcast=$APPCAST" + echo "desktop_appcast=$DESKTOP_APPCAST" + echo "component_catalog=$(path_if_exists "$COMPONENT_OUTPUT_DIR/catalog.json")" + echo "component_catalog_signature=$(path_if_exists "$COMPONENT_OUTPUT_DIR/catalog.json.sig")" + echo "component_directory=$COMPONENT_OUTPUT_DIR" + echo "appcast_zip=$APPCAST_ZIP" + echo "zip_arm64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-arm64.zip")" + echo "zip_x86_64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-x86_64.zip")" + echo "zip_universal=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-universal.zip")" + echo "dmg_arm64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-arm64.dmg")" + echo "zip_desktop_arm64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-desktop-arm64.zip")" + echo "dmg_desktop_arm64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-desktop-arm64.dmg")" + echo "dmg_x86_64=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-x86_64.dmg")" + echo "dmg_universal=$(path_if_exists "$BUILD_DIR/Dory-$VERSION-universal.dmg")" + } >> "$GITHUB_OUTPUT" +fi diff --git a/scripts/test-dmg-distribution-signing.sh b/scripts/test-dmg-distribution-signing.sh new file mode 100755 index 00000000..db20a350 --- /dev/null +++ b/scripts/test-dmg-distribution-signing.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-dmg-signing.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +BIN="$TMP/bin" +LOG="$TMP/codesign.log" +DMG="$TMP/Dory.dmg" +mkdir -p "$BIN" +touch "$DMG" + +cat > "$BIN/codesign" <<'SH' +#!/bin/bash +set -euo pipefail +if [ "${1:-}" = "-d" ]; then + if [ "${DORY_TEST_BAD_DMG_SIGNATURE:-0}" = 1 ]; then + printf '%s\n' 'TeamIdentifier=not set' >&2 + else + printf '%s\n' \ + 'Authority=Developer ID Application: Test (TESTTEAM)' \ + 'Timestamp=14 Jul 2026 at 1:00:00 AM' \ + 'TeamIdentifier=TESTTEAM' >&2 + fi + exit 0 +fi +printf '%q ' "$@" >> "${DORY_TEST_CODESIGN_LOG:?}" +printf '\n' >> "$DORY_TEST_CODESIGN_LOG" +SH + +cat > "$BIN/xcrun" <<'SH' +#!/bin/bash +set -euo pipefail +[ "$1" = stapler ] && [ "$2" = validate ] +SH + +cat > "$BIN/spctl" <<'SH' +#!/bin/bash +set -euo pipefail +printf '%s\n' "${DORY_TEST_SPCTL_SOURCE:-source=Notarized Developer ID}" >&2 +SH +chmod 0755 "$BIN/codesign" "$BIN/xcrun" "$BIN/spctl" + +( + export PATH="$BIN:$PATH" DORY_RELEASE_SOURCE_ONLY=1 + export DORY_SIGN_ID='Developer ID Application' DORY_TEST_CODESIGN_LOG="$LOG" + source scripts/release.sh 0.3.0 18 + TEAM=TESTTEAM + sign_dmg "$DMG" + verify_dmg_signature "$DMG" + validate_stapled_dmg "$DMG" +) + +grep -F -- '--force --timestamp --sign Developer\ ID\ Application' "$LOG" >/dev/null \ + || { echo "test-dmg-distribution-signing: Developer ID timestamp signing missing" >&2; exit 1; } + +if ( + export PATH="$BIN:$PATH" DORY_RELEASE_SOURCE_ONLY=1 + export DORY_SIGN_ID='Developer ID Application' DORY_TEST_CODESIGN_LOG="$LOG" + export DORY_TEST_BAD_DMG_SIGNATURE=1 + source scripts/release.sh 0.3.0 18 + TEAM=TESTTEAM + verify_dmg_signature "$DMG" +) >"$TMP/bad-signature.out" 2>&1; then + echo "test-dmg-distribution-signing: invalid DMG signature was accepted" >&2 + exit 1 +fi + +if ( + export PATH="$BIN:$PATH" DORY_RELEASE_SOURCE_ONLY=1 + export DORY_SIGN_ID='Developer ID Application' DORY_TEST_CODESIGN_LOG="$LOG" + export DORY_TEST_SPCTL_SOURCE='source=no usable signature' + source scripts/release.sh 0.3.0 18 + TEAM=TESTTEAM + validate_stapled_dmg "$DMG" +) >"$TMP/bad-gatekeeper.out" 2>&1; then + echo "test-dmg-distribution-signing: unusable Gatekeeper source was accepted" >&2 + exit 1 +fi + +bash -n scripts/test-dmg-distribution-signing.sh +echo "test-dmg-distribution-signing: PASS" From 7c99c892b8d681b2c60142a869be11ec1c9b1384 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:48:10 +0000 Subject: [PATCH 174/338] test(ci): track app test umbrella --- .github/scripts/test-ci-umbrella.py | 49 +++++++ .github/workflows/release.yml | 1 + .github/workflows/tests.yml | 1 + scripts/ci-test.sh | 204 ++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100755 .github/scripts/test-ci-umbrella.py create mode 100755 scripts/ci-test.sh diff --git a/.github/scripts/test-ci-umbrella.py b/.github/scripts/test-ci-umbrella.py new file mode 100755 index 00000000..60799b62 --- /dev/null +++ b/.github/scripts/test-ci-umbrella.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Clean-checkout dependency and retry tests for scripts/ci-test.sh.""" + +from __future__ import annotations + +import pathlib +import re +import subprocess +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CI_TEST = ROOT / "scripts" / "ci-test.sh" + + +class CIUmbrellaTests(unittest.TestCase): + def test_source_is_shell_valid_and_keeps_retry_contract(self) -> None: + subprocess.run(["bash", "-n", str(CI_TEST)], cwd=ROOT, check=True) + source = CI_TEST.read_text(encoding="utf-8") + for contract in ( + "test-security-contracts.sh", + "test-destructive-action-contracts.sh", + "test-ci-test-support.sh", + "test-dmg-distribution-signing.sh", + "test-sleep-wake-evidence.sh", + "test-live-hostshare-integration.sh", + "test-agent-protocol-consumers.sh", + "for attempt in 1 2", + 'bash scripts/test.sh app -- -skip-testing:DoryUITests', + '"${passed:-0}" -ge 300', + "still not clean after retry", + "log path cannot be a symlink", + ): + self.assertIn(contract, source) + + def test_every_invoked_repository_script_is_tracked(self) -> None: + source = CI_TEST.read_text(encoding="utf-8") + references = sorted( + set(re.findall(r"scripts/[A-Za-z0-9_.-]+(?:\.sh|\.py)", source)) + ) + tracked = set( + subprocess.check_output(["git", "ls-files"], cwd=ROOT, text=True).splitlines() + ) + missing = [reference for reference in references if reference not in tracked] + self.assertEqual(missing, [], f"untracked ci-test dependencies: {missing}") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c70a8343..ddac6e3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -346,6 +346,7 @@ jobs: bash scripts/test-verify-macos-deployment-targets.sh python3 .github/scripts/test-release-orchestrator.py bash scripts/test-dmg-distribution-signing.sh + python3 .github/scripts/test-ci-umbrella.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfd59291..b68cfa8a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,6 +47,7 @@ jobs: bash scripts/test-verify-macos-deployment-targets.sh python3 .github/scripts/test-release-orchestrator.py bash scripts/test-dmg-distribution-signing.sh + python3 .github/scripts/test-ci-umbrella.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh new file mode 100755 index 00000000..48df7aff --- /dev/null +++ b/scripts/ci-test.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# CI gate: the full DoryTests suite must run to completion with zero failures. The retry +# handles shared-runner host deaths (too few tests ran), never real test failures. +set -uo pipefail +cd "$(dirname "$0")/.." +if [ -n "${DORY_CI_TEST_LOG:-}" ]; then + LOG="$DORY_CI_TEST_LOG" + [ ! -L "$LOG" ] || { echo "ci-test: log path cannot be a symlink" >&2; exit 64; } + [ -d "$(dirname "$LOG")" ] && [ ! -L "$(dirname "$LOG")" ] \ + || { echo "ci-test: log parent must be a direct directory" >&2; exit 64; } +else + LOG_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + [ -d "$LOG_AUTHORITY" ] && [ ! -L "$LOG_AUTHORITY" ] \ + || { echo "ci-test: temporary log authority must be a direct directory" >&2; exit 64; } + LOG="$(mktemp "$LOG_AUTHORITY/dory-ci-tests.XXXXXX.log")" \ + || { echo "ci-test: could not allocate a private test log" >&2; exit 1; } +fi + +# shellcheck source=ci-test-support.sh +source scripts/ci-test-support.sh + +if ! bash scripts/test-security-contracts.sh; then + echo "ci-test: security boundary contracts failed" >&2 + exit 1 +fi + +if ! bash scripts/test-destructive-action-contracts.sh; then + echo "ci-test: destructive action contracts failed" >&2 + exit 1 +fi + +if ! bash scripts/test-ci-test-support.sh; then + echo "ci-test: log parser tests failed" >&2 + exit 1 +fi + +# Release bundles must carry one exact, universal Dory dual-stack gvproxy payload. This gate is offline and tests +# checksum, architecture, version, override, and source-selection regressions. +if ! bash scripts/test-gvproxy-payload.sh; then + echo "ci-test: gvproxy payload policy tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-host-cli-payload.sh; then + echo "ci-test: host CLI payload policy tests failed" >&2 + exit 1 +fi + +if ! python3 scripts/test-transfer-helper-image.py; then + echo "ci-test: transfer-helper image archive tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-make-dmg.sh; then + echo "ci-test: disk-image packaging tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-dmg-distribution-signing.sh; then + echo "ci-test: disk-image distribution signing tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-verify-sparkle-update.sh; then + echo "ci-test: Sparkle signature/key compatibility tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-sparkle-distribution-signing.sh; then + echo "ci-test: Sparkle distribution signing tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-sparkle-install-evidence.sh; then + echo "ci-test: Sparkle install evidence tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-clean-release-source.sh; then + echo "ci-test: clean public-release source tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-clean-xcode-products.sh; then + echo "ci-test: Xcode test-host LaunchServices cleanup tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-readiness-offline.sh; then + echo "ci-test: readiness fail-closed regression tests failed" >&2 + exit 1 +fi + +if ! bash scripts/staged-readiness-resource-gate.sh; then + echo "ci-test: staged readiness/resource contract failed" >&2 + exit 1 +fi + +if ! bash scripts/corporate-connectivity-gate.sh; then + echo "ci-test: corporate connectivity contract failed" >&2 + exit 1 +fi + +if ! bash scripts/transactional-upgrade-gate.sh; then + echo "ci-test: transactional upgrade/rollback contract failed" >&2 + exit 1 +fi + +if ! bash scripts/test-data-drive-backup.sh; then + echo "ci-test: sparse data-drive backup/restore tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-data-drive-capacity.sh; then + echo "ci-test: data-drive capacity lifecycle tests failed" >&2 + exit 1 +fi + +if ! bash scripts/test-sleep-wake-evidence.sh; then + echo "ci-test: physical sleep/wake evidence verifier tests failed" >&2 + exit 1 +fi + +# Gate on the doctor helper's own tests: without `set -e` a failing exit here would be swallowed +# by the pipefail-only shell, letting CI pass on a broken diagnostic surface. +if ! bash scripts/test-dory-doctor.sh; then + echo "ci-test: dory-doctor test suite failed" >&2 + exit 1 +fi + +# Benchmark publication is a product surface too. This gate is entirely offline: it validates the +# external-network harness's argument checks, balanced schedule, metadata parsing, and failure rows. +if ! bash scripts/test-benchmark-external-network.sh; then + echo "ci-test: external-network benchmark tests failed" >&2 + exit 1 +fi +if ! bash scripts/test-benchmark-user-workflows.sh; then + echo "ci-test: user-workflow benchmark tests failed" >&2 + exit 1 +fi +if ! bash scripts/test-benchmark-developer-workflows.sh; then + echo "ci-test: developer-workflow benchmark and release-performance safety tests failed" >&2 + exit 1 +fi +if ! bash scripts/test-benchmark-campaign.sh; then + echo "ci-test: destructive benchmark campaign safety tests failed" >&2 + exit 1 +fi + +# The installed-engine host-share suite is deliberately disruptive, but its safety rails and +# guest-side coordination logic are testable without contacting Docker. Keep that offline contract +# in CI so the live gate cannot silently lose ownership checks, cleanup containment, or Bash 3.2 +# compatibility. +if ! bash scripts/test-live-hostshare-integration.sh; then + echo "ci-test: live host-share harness offline tests failed" >&2 + exit 1 +fi + +# Guest control has one authoritative handshake+mux+protobuf implementation. Shell tooling either +# reaches it through typed surfaces or fails closed for RPCs that do not exist yet. +if ! bash scripts/test-agent-protocol-consumers.sh; then + echo "ci-test: agent protocol consumer tests failed" >&2 + exit 1 +fi + +# Compatibility surface: structural tier runs without an engine, so gate CI on it too. +if ! bash scripts/compat-smoke.sh; then + echo "ci-test: compatibility smoke failed" >&2 + exit 1 +fi + +# Retry the whole suite on ANY non-clean attempt, not only when too few tests ran. A shared-runner +# host death is intermittent and can be *partial*: one xctest worker crashes, its tests all report +# "failed" at 0.000s while 300+ others still pass. The old gate treated that first-attempt cascade as +# a hard failure and exited before the retry, turning an infra flake into a red check. Real failures +# reproduce on the retry, so only fail if the suite is still not clean on the second attempt. +last_reason="" +for attempt in 1 2; do + # `test.sh` now has explicit suite modes. Keep this retry focused on the hosted app tests; Rust, + # Swift packages, gvproxy, and UI each have their own workflow steps and must not be repeated here. + bash scripts/test.sh app -- -skip-testing:DoryUITests 2>&1 | tee "$LOG" + test_rc=${PIPESTATUS[0]} + + passed="$(dory_ci_count_completed_tests "$LOG")" + failed="$(dory_ci_failure_lines "$LOG")" + + echo "ci-gate: attempt=$attempt exit=$test_rc completed=$passed" + if [ -n "$failed" ]; then printf 'ci-gate failure evidence:\n%s\n' "$failed"; fi + + if [ "$test_rc" -eq 0 ] && [ "${passed:-0}" -ge 300 ]; then + echo "ci-gate: OK — suite ran to completion with a successful test exit" + exit 0 + fi + + if [ "$test_rc" -ne 0 ]; then + last_reason="test command exited $test_rc" + [ -n "$failed" ] && last_reason="$last_reason; failure evidence:\n$failed" + else + last_reason="only $passed tests completed (shared-runner host death)" + fi + [ "$attempt" -lt 2 ] && echo "ci-gate: attempt $attempt not clean ($last_reason); retrying once" +done +printf 'ci-gate: FAIL — still not clean after retry. %b\n' "$last_reason" +exit 1 From e45c4a932d0118e650154bb30c7a5c895b2a9249 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:57:47 +0000 Subject: [PATCH 175/338] test(release): track interrupted upgrade gate --- .../test-interrupted-upgrade-rollback-gate.py | 165 +++++ .github/workflows/release.yml | 31 +- .github/workflows/tests.yml | 1 + scripts/interrupted-upgrade-rollback-gate.sh | 641 ++++++++++++++++++ 4 files changed, 828 insertions(+), 10 deletions(-) create mode 100755 .github/scripts/test-interrupted-upgrade-rollback-gate.py create mode 100755 scripts/interrupted-upgrade-rollback-gate.sh diff --git a/.github/scripts/test-interrupted-upgrade-rollback-gate.py b/.github/scripts/test-interrupted-upgrade-rollback-gate.py new file mode 100755 index 00000000..80505b87 --- /dev/null +++ b/.github/scripts/test-interrupted-upgrade-rollback-gate.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the physical interrupted-upgrade gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "interrupted-upgrade-rollback-gate.sh" +WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" +PUBLIC_KEY = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" + + +class InterruptedUpgradeRollbackGateTests(unittest.TestCase): + @staticmethod + def fixture(temporary: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]: + app = temporary / "Dory.app" + app.mkdir() + signer = temporary / "sign_update" + signer.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + signer.chmod(0o755) + return app, signer + + @staticmethod + def invoke( + temporary: pathlib.Path, + app: pathlib.Path, + signer: pathlib.Path, + *, + confirmation: str = "CLEAN-RELEASE-USER-INTERRUPTED-UPGRADE", + workroot_name: str = "dory-release-live-transactional-upgrade", + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "DORY_RELEASE_CLEAN_USER": "1", + "DORY_SPARKLE_PRIVATE_KEY": "test-private-key", + "PYTHONOPTIMIZE": "2", + "RUNNER_TEMP": str(temporary), + } + ) + return subprocess.run( + [ + str(GATE), + "--candidate-app", str(app), + "--sign-update", str(signer), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--fixture-image", "example.invalid/alpine@sha256:" + "b" * 64, + "--workroot", str(temporary / workroot_name), + "--confirm", confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_optimizer_safe_and_exactly_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "candidate app must be a direct Dory.app", + "sign_update must be a direct executable", + "nested virtualization is not release-qualifying", + "source=Notarized Developer ID", + "candidate is not signed by Dory team 864H636QW4", + "workroot must be inside runner temporary storage", + "run authority already exists", + '"schemaVersion": 2', + '"role": "qualification-evidence"', + '"virtualMachineQualification": {', + "2", + ".github/scripts/verify-ed25519-signature.swift", + "automaticRollback", + "component_catalog_signatures_verified=PASS", + "component_qualification_authority=PASS", + "durable_volume_sentinel_preserved=PASS", + "initial_clean_user_state_restored=PASS", + ): + self.assertIn(contract, source) + self.assertNotIn("codesign --force --deep --sign", source) + + def test_schema_two_catalog_fixture_has_complete_qualification_authority(self) -> None: + source = GATE.read_text(encoding="utf-8") + start = source.index("import base64, hashlib, json, pathlib, sys") + end = source.index("\nPY\n\nsign_file()", start) + generator = source[start:end] + with tempfile.TemporaryDirectory(prefix="dory-upgrade-catalog-test.") as raw: + root = pathlib.Path(raw).resolve() + (root / "component-v1.txt").write_text("generation-one\n", encoding="utf-8") + (root / "component-v2.txt").write_text("generation-two\n", encoding="utf-8") + prior_argv = sys.argv + try: + sys.argv = [ + "fixture-generator", str(root), "9.8.7", "a" * 40, + "https://127.0.0.1/catalog-v1.json", + "https://127.0.0.1/catalog-v2.json", + "b" * 64, "c" * 64, "Mac16,1", "26A123", PUBLIC_KEY, + ] + exec(compile(generator, str(GATE), "exec"), {}) + finally: + sys.argv = prior_argv + + first = json.loads((root / "catalog-v1.json").read_text(encoding="utf-8")) + second = json.loads((root / "catalog-v2.json").read_text(encoding="utf-8")) + manifest = json.loads( + (root / "virtual-machine-qualification.json").read_text(encoding="utf-8") + ) + self.assertEqual(first["schemaVersion"], 2) + self.assertEqual(second["schemaVersion"], 2) + self.assertLess(first["generatedAt"], second["generatedAt"]) + self.assertEqual( + first["virtualMachineQualification"]["manifestIdentity"], + manifest["manifestIdentity"], + ) + machines = next(item for item in first["components"] if item["id"] == "linux-machines") + self.assertEqual(machines["qualification"], [manifest["records"][0]["qualificationIdentity"]]) + self.assertEqual( + {asset["role"] for asset in machines["assets"]}, + {"qualification-evidence", "build-metadata"}, + ) + self.assertEqual(manifest["records"][0]["hostHardwareModelIdentifier"], "Mac16,1") + + def test_release_evidence_binding_is_optimizer_safe(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + block = workflow.split( + " - name: Verify interrupted transactional-upgrade evidence binding", 1 + )[1].split("\n - name:", 1)[0] + self.assertNotIn("assert ", block) + for contract in ( + '"component_catalog_schema": "2"', + '"component_catalog_signatures_verified"', + '"component_qualification_authority"', + "invalid or duplicate evidence row", + ): + self.assertIn(contract, block) + + def test_confirmation_indirect_candidate_and_workroot_fail_before_physical_probe(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-upgrade-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, signer = self.fixture(temporary) + confirmation = self.invoke(temporary, app, signer, confirmation="wrong") + linked = temporary / "linked-Dory.app" + linked.symlink_to(app, target_is_directory=True) + indirect = self.invoke(temporary, linked, signer) + workroot = self.invoke(temporary, app, signer, workroot_name="unscoped") + self.assertIn("requires --confirm", confirmation.stdout) + self.assertIn("candidate app must be a direct Dory.app", indirect.stdout) + self.assertIn("dedicated interrupted-upgrade name", workroot.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddac6e3b..96f2a9a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -347,6 +347,7 @@ jobs: python3 .github/scripts/test-release-orchestrator.py bash scripts/test-dmg-distribution-signing.sh python3 .github/scripts/test-ci-umbrella.py + python3 .github/scripts/test-interrupted-upgrade-rollback-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py @@ -1356,7 +1357,8 @@ jobs: payload = json.load(open(sys.argv[1], encoding="utf-8")) rows = payload["metadata"]["component"]["properties"] values = [row["value"] for row in rows if row["name"] == "dev.dory.app.tree.sha256"] - assert len(values) == 1 + if len(values) != 1: + raise SystemExit("release SBOM must contain exactly one app-tree digest") print(values[0]) PY )" @@ -1366,21 +1368,30 @@ jobs: values = {} for line in open(path, encoding="utf-8"): key, separator, value = line.rstrip("\n").partition("=") - assert separator and key not in values, line + if not separator or not key or key in values: + raise SystemExit(f"invalid or duplicate evidence row: {line.rstrip()}") values[key] = value - assert values["status"] == "PASS" - assert values["release_qualifying"] == "true" - assert values["source_commit"] == commit - assert values["candidate_version"] == version - assert values["candidate_build"] == build - assert values["candidate_tree_sha256"] == tree + expected = { + "status": "PASS", + "release_qualifying": "true", + "source_commit": commit, + "candidate_version": version, + "candidate_build": build, + "candidate_tree_sha256": tree, + "component_catalog_schema": "2", + } + for key, expected_value in expected.items(): + if values.get(key) != expected_value: + raise SystemExit(f"interruption evidence mismatch for {key}") for key in ( - "exact_last_good_app_restored", "signed_component_generation_restored", + "exact_last_good_app_restored", "component_catalog_signatures_verified", + "component_qualification_authority", "signed_component_generation_restored", "durable_data_not_downgraded", "durable_volume_sentinel_preserved", "preexisting_container_preserved", "published_port_preserved", "exact_smoke_failure_retained", "initial_clean_user_state_restored", ): - assert values[key] == "PASS", key + if values.get(key) != "PASS": + raise SystemExit(f"interruption proof did not pass: {key}") PY scripts/transactional-upgrade-gate.sh \ --record "$evidence/transaction.json" \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b68cfa8a..455e9b53 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,6 +48,7 @@ jobs: python3 .github/scripts/test-release-orchestrator.py bash scripts/test-dmg-distribution-signing.sh python3 .github/scripts/test-ci-umbrella.py + python3 .github/scripts/test-interrupted-upgrade-rollback-gate.py python3 .github/scripts/test-generate-appcast.py python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py diff --git a/scripts/interrupted-upgrade-rollback-gate.sh b/scripts/interrupted-upgrade-rollback-gate.sh new file mode 100755 index 00000000..a986eb58 --- /dev/null +++ b/scripts/interrupted-upgrade-rollback-gate.sh @@ -0,0 +1,641 @@ +#!/bin/bash +# Physical exact-candidate qualification for Dory's transactional updater. The exact notarized +# candidate is the last-good app. A same-team, Ed25519-signed higher-build fixture activates a +# second signed component generation and injects one post-install smoke failure; Dory must restore +# the exact app/component/config selection while preserving the volume, published port, and data. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CANDIDATE_APP="" +SPARKLE_SIGN_UPDATE="" +VERSION="" +BUILD="" +SOURCE_COMMIT="" +SIGNING_IDENTITY="${DORY_SIGN_ID:-Developer ID Application}" +FIXTURE_IMAGE="${DORY_RELEASE_FIXTURE_IMAGE:-}" +WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-interrupted-upgrade" +CONFIRM="" + +usage() { + cat <&2; exit 2; } +need_value() { [ "$2" -ge 2 ] || die "$1 requires a value"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --candidate-app) need_value "$1" "$#"; CANDIDATE_APP="$2"; shift 2 ;; + --sign-update) need_value "$1" "$#"; SPARKLE_SIGN_UPDATE="$2"; shift 2 ;; + --version) need_value "$1" "$#"; VERSION="$2"; shift 2 ;; + --build) need_value "$1" "$#"; BUILD="$2"; shift 2 ;; + --source-commit) need_value "$1" "$#"; SOURCE_COMMIT="$2"; shift 2 ;; + --fixture-image) need_value "$1" "$#"; FIXTURE_IMAGE="$2"; shift 2 ;; + --signing-identity) need_value "$1" "$#"; SIGNING_IDENTITY="$2"; shift 2 ;; + --workroot) need_value "$1" "$#"; WORKROOT="$2"; shift 2 ;; + --confirm) need_value "$1" "$#"; CONFIRM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +for pair in "candidate-app:$CANDIDATE_APP" "sign-update:$SPARKLE_SIGN_UPDATE" \ + "version:$VERSION" "build:$BUILD" "source-commit:$SOURCE_COMMIT" \ + "fixture-image:$FIXTURE_IMAGE"; do + [ -n "${pair#*:}" ] || die "--${pair%%:*} is required" +done +[ "$CONFIRM" = CLEAN-RELEASE-USER-INTERRUPTED-UPGRADE ] \ + || die "full execution requires --confirm CLEAN-RELEASE-USER-INTERRUPTED-UPGRADE" +[ "${DORY_RELEASE_CLEAN_USER:-0}" = 1 ] || die "DORY_RELEASE_CLEAN_USER=1 is required" +[ -n "${DORY_SPARKLE_PRIVATE_KEY:-}" ] || die "DORY_SPARKLE_PRIVATE_KEY is required" +printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' \ + || die "version must be SemVer-like" +case "$BUILD" in ''|*[!0-9]*) die "build must be a positive integer" ;; esac +[ "$BUILD" -gt 0 ] || die "build must be a positive integer" +printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || die "source commit must be a full lowercase Git SHA" +printf '%s\n' "$FIXTURE_IMAGE" | grep -Eq '@sha256:[0-9a-f]{64}$' \ + || die "fixture image must be immutable by sha256 digest" +case "$FIXTURE_IMAGE" in *[[:space:]]*) die "fixture image cannot contain whitespace" ;; esac +[ -d "$CANDIDATE_APP" ] && [ ! -L "$CANDIDATE_APP" ] \ + && [ "$(basename "$CANDIDATE_APP")" = Dory.app ] \ + || die "candidate app must be a direct Dory.app" +[ -f "$SPARKLE_SIGN_UPDATE" ] && [ ! -L "$SPARKLE_SIGN_UPDATE" ] \ + && [ -x "$SPARKLE_SIGN_UPDATE" ] || die "sign_update must be a direct executable" +for command in codesign curl ditto openssl plutil python3 security shasum spctl swift xcrun; do + command -v "$command" >/dev/null || die "missing command: $command" +done +CANDIDATE_APP_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$CANDIDATE_APP")" +CANDIDATE_APP="$(cd "$CANDIDATE_APP" && pwd -P)" +[ "$CANDIDATE_APP" = "$CANDIDATE_APP_LOGICAL" ] || die "candidate app has an indirect ancestor" +SIGN_UPDATE_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$SPARKLE_SIGN_UPDATE")" +SPARKLE_SIGN_UPDATE="$(cd "$(dirname "$SPARKLE_SIGN_UPDATE")" && pwd -P)/$(basename "$SPARKLE_SIGN_UPDATE")" +[ "$SPARKLE_SIGN_UPDATE" = "$SIGN_UPDATE_LOGICAL" ] || die "sign_update has an indirect ancestor" +case "$WORKROOT" in /*) ;; *) die "workroot must be absolute" ;; esac +WORKROOT_PARENT="$(dirname "$WORKROOT")" +WORKROOT_NAME="$(basename "$WORKROOT")" +case "$WORKROOT_NAME" in + dory-interrupted-upgrade|dory-release-live-transactional-upgrade) ;; + *) die "workroot must use the dedicated interrupted-upgrade name" ;; +esac +[ -d "$WORKROOT_PARENT" ] && [ ! -L "$WORKROOT_PARENT" ] \ + || die "workroot parent must be a direct directory" +WORKROOT_PARENT_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$WORKROOT_PARENT")" +WORKROOT_PARENT="$(cd "$WORKROOT_PARENT" && pwd -P)" +[ "$WORKROOT_PARENT" = "$WORKROOT_PARENT_LOGICAL" ] || die "workroot parent has an indirect ancestor" +WORKROOT="$WORKROOT_PARENT/$WORKROOT_NAME" +TEMP_AUTHORITY="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +[ -d "$TEMP_AUTHORITY" ] && [ ! -L "$TEMP_AUTHORITY" ] \ + || die "temporary authority must be a direct directory" +TEMP_AUTHORITY_LOGICAL="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$TEMP_AUTHORITY")" +TEMP_AUTHORITY="$(cd "$TEMP_AUTHORITY" && pwd -P)" +[ "$TEMP_AUTHORITY" = "$TEMP_AUTHORITY_LOGICAL" ] || die "temporary authority has an indirect ancestor" +case "$TEMP_AUTHORITY" in /|"$HOME"|"$ROOT"|"$CANDIDATE_APP") die "unsafe temporary authority" ;; esac +case "$WORKROOT" in "$TEMP_AUTHORITY"/*) ;; *) die "workroot must be inside runner temporary storage" ;; esac +for input in "$CANDIDATE_APP" "$SPARKLE_SIGN_UPDATE"; do + case "$input/" in "$WORKROOT/"*) die "workroot cannot contain an input" ;; esac + case "$WORKROOT/" in "$input/"*) die "workroot cannot be inside an input" ;; esac +done +[ "$(uname -s)" = Darwin ] && [ "$(uname -m)" = arm64 ] \ + || die "physical Apple silicon macOS is required" +[ "$(sysctl -n kern.hv_support 2>/dev/null || printf 0)" = 1 ] \ + || die "Apple virtualization support is unavailable" +[ "$(sysctl -in kern.hv_vmm_present 2>/dev/null || printf 0)" != 1 ] \ + || die "nested virtualization is not release-qualifying" +case "$(sysctl -n hw.model 2>/dev/null || true)" in VirtualMac*) die "nested VirtualMac is not release-qualifying" ;; esac +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$CANDIDATE_APP/Contents/Info.plist")" = "$VERSION" ] \ + || die "candidate marketing version mismatch" +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$CANDIDATE_APP/Contents/Info.plist")" = "$BUILD" ] \ + || die "candidate build mismatch" +codesign --verify --strict --deep "$CANDIDATE_APP" || die "candidate signature is invalid" +xcrun stapler validate "$CANDIDATE_APP" >/dev/null || die "candidate has no notarization ticket" +SOURCE_ASSESSMENT="$(spctl --assess --type execute --verbose=4 "$CANDIDATE_APP" 2>&1)" \ + || die "Gatekeeper rejected the candidate app" +printf '%s\n' "$SOURCE_ASSESSMENT" | grep -Fx 'source=Notarized Developer ID' >/dev/null \ + || die "candidate app is not accepted as Notarized Developer ID" +CANDIDATE_TEAM="$(codesign -dv --verbose=4 "$CANDIDATE_APP" 2>&1 | sed -n 's/^TeamIdentifier=//p')" +[ "$CANDIDATE_TEAM" = 864H636QW4 ] || die "candidate is not signed by Dory team 864H636QW4" + +STATE="$HOME/.dory" +APP_SUPPORT="$HOME/Library/Application Support/Dory" +PREF_DOMAIN="com.pythonxi.Dory" +PREF_PLIST="$HOME/Library/Preferences/$PREF_DOMAIN.plist" +SERVICE="gui/$(id -u)/dev.dory.doryd" +PLIST="$HOME/Library/LaunchAgents/dev.dory.doryd.plist" +for process in Dory doryd dory-hv dory-vmm; do + ! pgrep -u "$(id -u)" -x "$process" >/dev/null 2>&1 \ + || die "$process is already running; use the dedicated clean release user" +done +! launchctl print "$SERVICE" >/dev/null 2>&1 || die "Dory service is already loaded" +[ ! -e "$STATE" ] && [ ! -e "$APP_SUPPORT" ] && [ ! -e "$PLIST" ] \ + || die "existing Dory state would be touched" +if defaults read "$PREF_DOMAIN" >/dev/null 2>&1; then + die "existing Dory preferences would be touched" +fi +CANDIDATE_DOCKER="$CANDIDATE_APP/Contents/Helpers/docker" +PREVIOUS_CONTEXT="$("$CANDIDATE_DOCKER" context show 2>/dev/null || printf default)" +[ -n "$PREVIOUS_CONTEXT" ] || PREVIOUS_CONTEXT=default +! "$CANDIDATE_DOCKER" context inspect dory >/dev/null 2>&1 \ + || die "existing Docker context dory would be touched" +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ -f "$profile" ] && grep -Fq '# >>> dory cli >>>' "$profile"; then + die "existing Dory shell integration would be touched: $profile" + fi +done + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_ROOT="$WORKROOT/$RUN_ID" +EVIDENCE="$RUN_ROOT/evidence" +INSTALL_ROOT="$RUN_ROOT/install" +INSTALL_APP="$INSTALL_ROOT/Dory.app" +FAULT_ROOT="$RUN_ROOT/fault" +FAULT_APP="$FAULT_ROOT/Dory.app" +FEED_ROOT="$RUN_ROOT/feed" +TLS_ROOT="$RUN_ROOT/tls" +ARM_FILE="$RUN_ROOT/arm-update" +if [ -e "$WORKROOT" ] || [ -L "$WORKROOT" ]; then + [ -d "$WORKROOT" ] && [ ! -L "$WORKROOT" ] \ + || die "workroot must be a direct directory" + [ "$(stat -f '%u' "$WORKROOT")" = "$(id -u)" ] \ + || die "workroot is not owned by this user" +else + mkdir "$WORKROOT" +fi +chmod 700 "$WORKROOT" +[ ! -e "$RUN_ROOT" ] && [ ! -L "$RUN_ROOT" ] || die "run authority already exists" +mkdir "$RUN_ROOT" +mkdir "$EVIDENCE" "$INSTALL_ROOT" "$FAULT_ROOT" "$FEED_ROOT" "$TLS_ROOT" +chmod 700 "$RUN_ROOT" "$EVIDENCE" "$INSTALL_ROOT" "$FAULT_ROOT" "$FEED_ROOT" "$TLS_ROOT" + +APP_PID="" +SERVER_PID="" +TRUST_CN="Dory Upgrade Gate $RUN_ID" +CLEAN_USER_ARMED=1 + +stop_pid() { + local pid="$1" + [ -n "$pid" ] || return 0 + kill -TERM "$pid" >/dev/null 2>&1 || true + for _ in $(seq 1 80); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.25 + done + kill -KILL "$pid" >/dev/null 2>&1 || true +} + +stop_dory_processes() { + local pid + for pid in $(pgrep -u "$(id -u)" -x Dory 2>/dev/null || true); do + stop_pid "$pid" + done +} + +clean_release_user_state() { + local cli="$INSTALL_APP/Contents/Helpers/dory" docker="$INSTALL_APP/Contents/Helpers/docker" + [ -x "$cli" ] && "$cli" engine sleep >/dev/null 2>&1 || true + [ -x "$cli" ] && "$cli" uninstall >/dev/null 2>&1 || true + launchctl bootout "$SERVICE" >/dev/null 2>&1 || true + [ -x "$docker" ] && "$docker" context use "$PREVIOUS_CONTEXT" >/dev/null 2>&1 || true + [ -x "$docker" ] && "$docker" context rm -f dory >/dev/null 2>&1 || true + rm -f "$PLIST" + rm -rf "$STATE" "$APP_SUPPORT" + defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" + /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true + rm -f "$PREF_PLIST" +} + +cleanup() { + local status=$? + set +e + [ -z "$SERVER_PID" ] || { kill -TERM "$SERVER_PID" >/dev/null 2>&1; wait "$SERVER_PID" 2>/dev/null; } + stop_dory_processes + security delete-certificate -c "$TRUST_CN" "$LOGIN_KEYCHAIN" >/dev/null 2>&1 || true + [ "${CLEAN_USER_ARMED:-0}" != 1 ] || clean_release_user_state + trap - EXIT INT TERM + exit "$status" +} +LOGIN_KEYCHAIN="$(security login-keychain -d user | tr -d ' \"')" +[ -n "$LOGIN_KEYCHAIN" ] || die "could not resolve the login keychain" +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +SERVICE_PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +FEED_URL="https://127.0.0.1:$PORT/appcast.xml" +UPDATE_URL="https://127.0.0.1:$PORT/Dory-$VERSION-interruption-fixture.zip" +CATALOG_V1_URL="https://127.0.0.1:$PORT/catalog-v1.json" +CATALOG_V2_URL="https://127.0.0.1:$PORT/catalog-v2.json" + +python3 - "$TLS_ROOT/openssl.cnf" "$TRUST_CN" <<'PY' +import pathlib, sys +pathlib.Path(sys.argv[1]).write_text(f'''[req]\n+distinguished_name=dn\n+x509_extensions=v3\n+prompt=no\n+[dn]\n+CN={sys.argv[2]}\n+[v3]\n+basicConstraints=critical,CA:TRUE\n+keyUsage=critical,keyCertSign,digitalSignature\n+subjectAltName=IP:127.0.0.1\n+''', encoding='utf-8') +PY +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$TLS_ROOT/key.pem" -out "$TLS_ROOT/cert.pem" \ + -config "$TLS_ROOT/openssl.cnf" -extensions v3 >/dev/null 2>&1 +chmod 600 "$TLS_ROOT/key.pem" "$TLS_ROOT/cert.pem" +security add-trusted-cert -d -r trustRoot -k "$LOGIN_KEYCHAIN" "$TLS_ROOT/cert.pem" + +printf 'generation-one\n' > "$FEED_ROOT/component-v1.txt" +printf 'generation-two\n' > "$FEED_ROOT/component-v2.txt" +DORY_HV_SHA256="$(shasum -a 256 "$CANDIDATE_APP/Contents/Helpers/dory-hv" | awk '{print $1}')" +BUNDLED_KERNEL_SHA256="$(shasum -a 256 "$CANDIDATE_APP/Contents/Resources/dory-hv-kernel-arm64.lzfse" | awk '{print $1}')" +HOST_MODEL="$(sysctl -n hw.model)" +HOST_BUILD="$(sw_vers -buildVersion)" +CATALOG_PUBLIC_KEY='AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=' +python3 - "$FEED_ROOT" "$VERSION" "$SOURCE_COMMIT" "$CATALOG_V1_URL" "$CATALOG_V2_URL" \ + "$DORY_HV_SHA256" "$BUNDLED_KERNEL_SHA256" "$HOST_MODEL" "$HOST_BUILD" "$CATALOG_PUBLIC_KEY" <<'PY' +import base64, hashlib, json, pathlib, sys +( + root_raw, version, source_commit, url1, url2, helper_digest, media_digest, + host_model, host_build, public_key, +) = sys.argv[1:] +root = pathlib.Path(root_raw) +signing_key_id = hashlib.sha256(base64.b64decode(public_key, validate=True)).hexdigest() +qualification_identity = "release-gate-raw-linux-arm64-none-v1" +qualification_path = "virtual-machine-qualification.json" +manifest = { + "kind": "dev.dory.virtual-machine-qualification-manifest", + "schemaVersion": 1, + "manifestIdentity": f"dory-release-gate-{version}", + "catalogReleaseVersion": version, + "architecture": "arm64", + "signingKeyID": signing_key_id, + "records": [{ + "qualificationIdentity": qualification_identity, + "guest": {"family": "linux", "architecture": "arm64"}, + "bootMediaKind": "linux-kernel", + "bootMediaSource": "dory-bundled", + "immutableArtifactSHA256": media_digest, + "backend": "dory-hypervisor", + "backendImplementationIdentifier": "dory.raw-hv-linux.compatibility.v1", + "backendRuntimeBuildIdentifier": f"sha256:{helper_digest}", + "virtualHardwareABIVersion": 1, + "graphics": "none", + "devices": { + "networkAttachment": "shared-nat", "audioInput": False, + "audioOutput": False, "keyboard": False, "pointer": False, + "directorySharing": False, "clipboard": False, + "clockSynchronization": False, "dynamicDisplay": False, + "gracefulShutdown": False, + }, + "hostHardwareModelIdentifier": host_model, + "hostOperatingSystemBuild": host_build, + "components": [{ + "componentIdentifier": "dory-hv", + "buildIdentifier": f"sha256:{helper_digest}", + "artifactSHA256": helper_digest, + }], + "virtioGPUKernelAndDeviceSupportQualified": False, + "venusVulkanGuestRuntimeQualified": False, + }], +} +manifest_bytes = json.dumps( + manifest, separators=(",", ":"), sort_keys=True +).encode("utf-8") +(root / qualification_path).write_bytes(manifest_bytes) +manifest_digest = hashlib.sha256(manifest_bytes).hexdigest() +for generation, url in ((1, url1), (2, url2)): + asset = root / f"component-v{generation}.txt" + digest = hashlib.sha256(asset.read_bytes()).hexdigest() + provenance = { + "sourceCommit": source_commit, + "builder": "dory.interrupted-upgrade-gate", + "recipeDigest": digest, + "sbomDigest": digest, + "attestationDigest": manifest_digest, + } + assets = [ + {"path": qualification_path, + "url": url.rsplit('/', 1)[0] + f"/{qualification_path}", + "compression": "none", "downloadBytes": len(manifest_bytes), + "installedBytes": len(manifest_bytes), "sha256": manifest_digest, + "installedSHA256": manifest_digest, "executable": False, + "role": "qualification-evidence"}, + {"path": "gate/component.txt", + "url": url.rsplit('/', 1)[0] + f"/component-v{generation}.txt", + "compression": "none", "downloadBytes": asset.stat().st_size, + "installedBytes": asset.stat().st_size, "sha256": digest, + "installedSHA256": digest, "executable": False, + "role": "build-metadata"}, + ] + catalog = { + "kind": "dev.dory.component-catalog", "schemaVersion": 2, + "releaseVersion": version, + "generatedAt": f"2026-07-19T00:00:0{generation}Z", + "minimumAppVersion": version, "architecture": "arm64", + "components": [ + {"id": "docker-core", "version": version, "displayName": "Docker Core", + "summary": "Bundled exact candidate core", "dependencies": [], + "downloadBytes": 1, "installedBytes": 1, "assets": [], + "architectures": ["arm64"], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": [f"app.dory-core@{version}"], "requires": [], + "provenance": provenance, "qualification": []}, + {"id": "linux-machines", "version": f"{version}+gate.{generation}", + "displayName": "Release Gate Component", "summary": "Transactional generation fixture", + "dependencies": ["docker-core"], + "downloadBytes": sum(item["downloadBytes"] for item in assets), + "installedBytes": sum(item["installedBytes"] for item in assets), + "assets": assets, "architectures": ["arm64"], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": [f"component.linux-machines@{version}+gate.{generation}"], + "requires": [f"app.dory-core@{version}"], + "provenance": provenance, "qualification": [qualification_identity]} + ], + "virtualMachineQualification": { + "component": "linux-machines", "path": qualification_path, + "manifestIdentity": manifest["manifestIdentity"], + "manifestFormatVersion": manifest["schemaVersion"], + "signingKeyID": signing_key_id, + }, + } + (root / f"catalog-v{generation}.json").write_text( + json.dumps(catalog, separators=(",", ":"), sort_keys=True), encoding="utf-8") +PY + +sign_file() { + local path="$1" signature + signature="$(printf '%s' "$DORY_SPARKLE_PRIVATE_KEY" | "$SPARKLE_SIGN_UPDATE" --ed-key-file - -p "$path" | tail -n 1 | tr -d '\r\n')" + python3 - "$signature" <<'PY' +import base64, binascii, sys +try: + decoded = base64.b64decode(sys.argv[1], validate=True) +except (binascii.Error, ValueError) as error: + raise SystemExit(f"invalid Ed25519 signature encoding: {error}") +if len(decoded) != 64: + raise SystemExit("Ed25519 signature must decode to exactly 64 bytes") +PY + printf '%s\n' "$signature" +} +sign_file "$FEED_ROOT/catalog-v1.json" > "$FEED_ROOT/catalog-v1.json.sig" +sign_file "$FEED_ROOT/catalog-v2.json" > "$FEED_ROOT/catalog-v2.json.sig" +"$ROOT/.github/scripts/verify-ed25519-signature.swift" \ + "$CATALOG_PUBLIC_KEY" "$FEED_ROOT/catalog-v1.json.sig" "$FEED_ROOT/catalog-v1.json" +"$ROOT/.github/scripts/verify-ed25519-signature.swift" \ + "$CATALOG_PUBLIC_KEY" "$FEED_ROOT/catalog-v2.json.sig" "$FEED_ROOT/catalog-v2.json" + +ditto "$CANDIDATE_APP" "$FAULT_APP" +FAULT_BUILD=$((BUILD + 1)) +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $FAULT_BUILD" "$FAULT_APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :DoryUpgradeGateForceSmokeFailure bool true" "$FAULT_APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :DoryUpgradeGateComponentCatalogURL string $CATALOG_V2_URL" "$FAULT_APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :DoryUpgradeGateComponentID string linux-machines" "$FAULT_APP/Contents/Info.plist" +codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \ + --preserve-metadata=identifier,requirements,entitlements "$FAULT_APP" \ + > "$EVIDENCE/fault-signing.out" 2> "$EVIDENCE/fault-signing.err" +codesign --verify --strict --deep "$FAULT_APP" || die "fault fixture signature is invalid" +FAULT_TEAM="$(codesign -dv --verbose=4 "$FAULT_APP" 2>&1 | sed -n 's/^TeamIdentifier=//p')" +[ "$FAULT_TEAM" = "$CANDIDATE_TEAM" ] || die "fault fixture team differs from candidate" +UPDATE_ZIP="$FEED_ROOT/Dory-$VERSION-interruption-fixture.zip" +(cd "$FAULT_ROOT" && ditto -c -k --sequesterRsrc --keepParent Dory.app "$UPDATE_ZIP") +UPDATE_SIGNATURE="$(sign_file "$UPDATE_ZIP")" +printf '%s\n' "$UPDATE_SIGNATURE" > "$FEED_ROOT/update.zip.sig" +"$ROOT/.github/scripts/verify-ed25519-signature.swift" \ + "$CATALOG_PUBLIC_KEY" "$FEED_ROOT/update.zip.sig" "$UPDATE_ZIP" +UPDATE_LENGTH="$(wc -c < "$UPDATE_ZIP" | tr -d '[:space:]')" +python3 - "$FEED_ROOT/appcast.xml" "$VERSION" "$FAULT_BUILD" "$UPDATE_URL" "$UPDATE_SIGNATURE" "$UPDATE_LENGTH" <<'PY' +import html, pathlib, sys +path, version, build, url, signature, length = sys.argv[1:] +xml = f''' + +Dory interruption gatehttps://augani.github.io/dory/appcast.xml +Signature-bound physical rollback fixture.en +{html.escape(version)}Sun, 19 Jul 2026 00:00:00 +0000 +{build}{html.escape(version)} +14.0 +11 +12 + +''' +pathlib.Path(path).write_text(xml, encoding="utf-8") +PY + +python3 - "$PORT" "$FEED_ROOT" "$TLS_ROOT/cert.pem" "$TLS_ROOT/key.pem" \ + > "$EVIDENCE/feed-server.out" 2> "$EVIDENCE/feed-server.err" <<'PY' & +import http.server, os, ssl, sys +port, root, cert, key = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4] +os.chdir(root) +server = http.server.ThreadingHTTPServer(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain(cert, key) +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +PY +SERVER_PID=$! +for _ in $(seq 1 100); do + curl -fsS --max-time 2 "$FEED_URL" >/dev/null 2>&1 && break + sleep 0.1 +done +curl -fsS --max-time 5 "$FEED_URL" >/dev/null || die "trusted loopback HTTPS feed did not start" + +ditto "$CANDIDATE_APP" "$INSTALL_APP" +codesign --verify --strict --deep "$INSTALL_APP" || die "installed exact candidate changed" +defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true +defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool true +INSTALLED_EXECUTABLE="$INSTALL_APP/Contents/MacOS/Dory" +DORY_RELEASE_UPGRADE_GATE=INTERRUPT-LAST-GOOD-ROLLBACK \ +DORY_RELEASE_UPGRADE_FEED_URL="$FEED_URL" \ +DORY_RELEASE_UPGRADE_ARM_FILE="$ARM_FILE" \ + "$INSTALLED_EXECUTABLE" > "$EVIDENCE/last-good-app.out" 2> "$EVIDENCE/last-good-app.err" & +APP_PID=$! + +DOCKER="$INSTALL_APP/Contents/Helpers/docker" +DORYCTL="$INSTALL_APP/Contents/Helpers/dorydctl" +SOCKET="$HOME/.dory/docker.sock" +for _ in $(seq 1 480); do + "$DOCKER" -H "unix://$SOCKET" version >/dev/null 2>&1 && break + sleep 0.25 +done +"$DOCKER" -H "unix://$SOCKET" version > "$EVIDENCE/preupdate-docker-version.txt" \ + || die "exact candidate Docker engine did not become ready" + +DORY_COMPONENT_CATALOG_URL="$CATALOG_V1_URL" DORY_COMPONENT_APP_VERSION="$VERSION" \ + "$DORYCTL" component install linux-machines --json > "$EVIDENCE/component-v1-install.json" +DORY_COMPONENT_APP_VERSION="$VERSION" "$DORYCTL" component verify linux-machines --json --offline \ + > "$EVIDENCE/component-v1-verify.json" +COMPONENT_PATH="$("$DORYCTL" component path linux-machines gate/component.txt)" +[ "$(cat "$COMPONENT_PATH")" = generation-one ] || die "component generation one is not active" + +"$DOCKER" -H "unix://$SOCKET" pull "$FIXTURE_IMAGE" > "$EVIDENCE/fixture-pull.txt" +"$DOCKER" -H "unix://$SOCKET" volume create dory-upgrade-release-sentinel >/dev/null +"$DOCKER" -H "unix://$SOCKET" run --rm -v dory-upgrade-release-sentinel:/evidence "$FIXTURE_IMAGE" \ + sh -c 'printf "transactional-durable-sentinel\n" > /evidence/value' >/dev/null +SENTINEL_BEFORE="$("$DOCKER" -H "unix://$SOCKET" run --rm -v dory-upgrade-release-sentinel:/evidence:ro "$FIXTURE_IMAGE" sha256sum /evidence/value | awk '{print $1}')" +"$DOCKER" -H "unix://$SOCKET" run -d --name dory-upgrade-release-port \ + -p "127.0.0.1:$SERVICE_PORT:8080" "$FIXTURE_IMAGE" \ + sh -c 'while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done' \ + > "$EVIDENCE/port-container.id" +for _ in $(seq 1 80); do curl -fsS --max-time 2 "http://127.0.0.1:$SERVICE_PORT" >/dev/null 2>&1 && break; sleep 0.1; done +curl -fsS --max-time 3 "http://127.0.0.1:$SERVICE_PORT" | grep -qx ok \ + || die "pre-update published port fixture is not ready" + +: > "$ARM_FILE" +chmod 600 "$ARM_FILE" +TRANSACTION="" +for _ in $(seq 1 2400); do + TRANSACTION="$(find "$STATE/upgrades" -mindepth 2 -maxdepth 2 -name transaction.json -type f -print 2>/dev/null | head -n 1 || true)" + if [ -n "$TRANSACTION" ]; then + state="$(python3 - "$TRANSACTION" <<'PY' +import json, sys +print(json.load(open(sys.argv[1], encoding='utf-8')).get('state', '')) +PY +)" + [ "$state" != rolledBack ] || break + [ "$state" != recoveryRequired ] || die "fixture unexpectedly required schema recovery" + [ "$state" != failed ] || die "update stopped before exercising rollback" + fi + sleep 0.5 +done +[ -n "$TRANSACTION" ] || die "transaction journal was never created" +[ "$state" = rolledBack ] || die "automatic rollback did not complete (state=$state)" + +for _ in $(seq 1 480); do + [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INSTALL_APP/Contents/Info.plist" 2>/dev/null || true)" = "$BUILD" ] \ + && "$DOCKER" -H "unix://$SOCKET" version >/dev/null 2>&1 && break + sleep 0.25 +done +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INSTALL_APP/Contents/Info.plist")" = "$BUILD" ] \ + || die "last-good exact app was not restored" +if /usr/libexec/PlistBuddy -c 'Print :DoryUpgradeGateForceSmokeFailure' "$INSTALL_APP/Contents/Info.plist" >/dev/null 2>&1; then + die "fault fixture remained installed after rollback" +fi +codesign --verify --strict --deep "$INSTALL_APP" || die "restored last-good app signature is invalid" +"$DOCKER" -H "unix://$SOCKET" version > "$EVIDENCE/postrollback-docker-version.txt" \ + || die "Docker API did not survive rollback" +DORY_COMPONENT_APP_VERSION="$VERSION" "$DORYCTL" component verify linux-machines --json --offline \ + > "$EVIDENCE/component-postrollback-verify.json" +COMPONENT_PATH="$("$DORYCTL" component path linux-machines gate/component.txt)" +[ "$(cat "$COMPONENT_PATH")" = generation-one ] || die "prior component generation was not restored" +SENTINEL_AFTER="$("$DOCKER" -H "unix://$SOCKET" run --rm -v dory-upgrade-release-sentinel:/evidence:ro "$FIXTURE_IMAGE" sha256sum /evidence/value | awk '{print $1}')" +[ "$SENTINEL_AFTER" = "$SENTINEL_BEFORE" ] || die "durable sentinel changed during rollback" +"$DOCKER" -H "unix://$SOCKET" inspect -f '{{.State.Running}}' dory-upgrade-release-port | grep -qx true \ + || die "pre-update container is not running after rollback" +curl -fsS --max-time 3 "http://127.0.0.1:$SERVICE_PORT" | grep -qx ok \ + || die "pre-update published port is not reachable after rollback" + +CANDIDATE_TREE_SHA="$(python3 - "$CANDIDATE_APP" <<'PY' +import hashlib, os, pathlib, stat, sys +app = pathlib.Path(sys.argv[1]) +tree = hashlib.sha256() +for path in sorted(app.rglob('*'), key=lambda item: item.relative_to(app.parent).as_posix()): + relative = path.relative_to(app.parent).as_posix() + metadata = path.lstat() + if stat.S_ISREG(metadata.st_mode): + kind, size = 'regular', metadata.st_size + digest = hashlib.sha256(path.read_bytes()).hexdigest() + elif stat.S_ISLNK(metadata.st_mode): + kind = 'symlink' + encoded = os.readlink(path).encode('utf-8') + size, digest = len(encoded), hashlib.sha256(encoded).hexdigest() + else: + continue + mode = f'{stat.S_IMODE(metadata.st_mode):04o}' + tree.update(f'{relative}\0{kind}\0{mode}\0{size}\0{digest}\n'.encode()) +print(tree.hexdigest()) +PY +)" +TRANSACTION_SHA="$(shasum -a 256 "$TRANSACTION" | awk '{print $1}')" +INTERRUPTION_EVIDENCE="$EVIDENCE/interruption-evidence.json" +python3 - "$TRANSACTION" "$INTERRUPTION_EVIDENCE" "$SOURCE_COMMIT" "$CANDIDATE_TREE_SHA" \ + "$TRANSACTION_SHA" "$BUILD" "$FAULT_BUILD" "$SENTINEL_BEFORE" <<'PY' +import json, pathlib, sys +record_path, output, commit, app_sha, record_sha, prior, candidate, sentinel = sys.argv[1:] +record = json.loads(pathlib.Path(record_path).read_text(encoding='utf-8')) +payload = { + "schema": "dev.dory.upgrade.interruption-evidence", "version": 1, + "transactionID": str(record["id"]).lower(), "transactionSHA256": record_sha, + "sourceCommit": commit, "candidateAppSHA256": app_sha, + "priorBuild": prior, "candidateBuild": candidate, "finalAppBuild": prior, + "failureInjection": "post-install-smoke", "automaticRollback": True, + "componentUpdateActivatedBeforeFailure": True, + "componentGenerationRestored": True, + "durableDataWasRolledBack": False, + "durableSentinelBeforeSHA256": sentinel, + "durableSentinelAfterSHA256": sentinel, + "publishedPortRestored": True, "preexistingContainerRestored": True, + "recoveryExportVerified": False, +} +pathlib.Path(output).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding='utf-8') +PY +cp "$TRANSACTION" "$EVIDENCE/transaction.json" +scripts/transactional-upgrade-gate.sh \ + --record "$EVIDENCE/transaction.json" \ + --interruption-evidence "$INTERRUPTION_EVIDENCE" \ + --expect-state rolledBack > "$EVIDENCE/verification.txt" + +stop_dory_processes +APP_PID="" +clean_release_user_state +CLEAN_USER_ARMED=0 +[ ! -e "$STATE" ] && [ ! -e "$APP_SUPPORT" ] && [ ! -e "$PLIST" ] \ + || die "clean release-user Dory state survived cleanup" +! "$CANDIDATE_DOCKER" context inspect dory >/dev/null 2>&1 \ + || die "Dory Docker context survived clean-user cleanup" +for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ -f "$profile" ] && grep -Fq '# >>> dory cli >>>' "$profile"; then + die "Dory shell integration survived clean-user cleanup: $profile" + fi +done + +{ + echo status=PASS + echo release_qualifying=true + echo "source_commit=$SOURCE_COMMIT" + echo "candidate_version=$VERSION" + echo "candidate_build=$BUILD" + echo "fault_fixture_build=$FAULT_BUILD" + echo "candidate_tree_sha256=$CANDIDATE_TREE_SHA" + echo "transaction_sha256=$TRANSACTION_SHA" + echo "candidate_team=$CANDIDATE_TEAM" + echo exact_last_good_app_restored=PASS + echo component_catalog_schema=2 + echo component_catalog_signatures_verified=PASS + echo component_qualification_authority=PASS + echo signed_component_generation_restored=PASS + echo durable_data_not_downgraded=PASS + echo durable_volume_sentinel_preserved=PASS + echo preexisting_container_preserved=PASS + echo published_port_preserved=PASS + echo exact_smoke_failure_retained=PASS + echo initial_clean_user_state_restored=PASS + echo "completed_epoch=$(date +%s)" +} > "$EVIDENCE/manifest.txt" + +trap - EXIT INT TERM +[ -z "$SERVER_PID" ] || { kill -TERM "$SERVER_PID" >/dev/null 2>&1; wait "$SERVER_PID" 2>/dev/null || true; } +security delete-certificate -c "$TRUST_CN" "$LOGIN_KEYCHAIN" >/dev/null 2>&1 || true +echo "Interrupted transactional upgrade gate PASS: $EVIDENCE/manifest.txt" From d55734d314ac936219583fbcd975d3b9238b5967 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 07:58:33 +0000 Subject: [PATCH 176/338] test(release): align desktop rollback contract --- .github/scripts/verify-release-workflow-contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 17a6a036..f38822fc 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -77,8 +77,8 @@ def require(text: str, value: str, message: str) -> None: "com.apple.security.device.audio-input", 'grep -F "$VMM"', "machine desktop-update", - "rollback-pass", - "last-good snapshot", + 'body.get("snapshotID")', + '"provenance": "verified-update-bundle"', ): require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}") From d2c0790baeb5065dab46e965f79137cc9ae3a9a9 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:04:15 +0000 Subject: [PATCH 177/338] fix(release): harden workflow trust checks --- .../verify-release-workflow-contract.py | 17 ++++ .github/workflows/pages.yml | 30 +++++-- .github/workflows/release.yml | 84 +++++++++++-------- 3 files changed, 90 insertions(+), 41 deletions(-) diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index f38822fc..1315fa04 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -10,8 +10,15 @@ def require(text: str, value: str, message: str) -> None: workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") +pages_workflow = Path(".github/workflows/pages.yml").read_text(encoding="utf-8") publisher = Path("scripts/publish-release.sh").read_text(encoding="utf-8") +for name, source in (("release", workflow), ("pages", pages_workflow)): + if "assert " in source: + raise SystemExit( + f"release workflow contract: {name} workflow uses optimization-sensitive Python assert" + ) + candidate = workflow.split(" - name: Stage immutable public candidate", 1)[1].split( "\n homebrew_install_certification:", 1 )[0] @@ -83,6 +90,11 @@ def require(text: str, value: str, message: str) -> None: require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}") require(candidate, "release-build/components/arm64/*", "candidate omits component payloads") +require( + workflow, + "DORY_COMPONENT_CATALOG_SCHEMA: '2'", + "signed release build does not stamp component catalog schema 2 into the appcast", +) require(publication, "release-build/components/arm64/*", "GitHub release omits component payloads") for name in ("catalog.json", "catalog.json.sha256", "catalog.json.sig"): require(publication, f"release-build/components/arm64/{name}", f"metadata artifact omits {name}") @@ -95,6 +107,11 @@ def require(text: str, value: str, message: str) -> None: require(bump, 'grep -qF "sha256 \\"$S\\"" <<< "$remote"', "standalone tap checksum is not verified") require(final, "needs: [publish_release, publish-pages, bump-cask]", "no terminal publication gate") require(final, ".github/scripts/verify-public-release.py", "terminal publication verifier is not run") +require( + pages, + 'componentCatalogSchema\") == \"2\"', + "published appcast does not require component catalog schema 2", +) require(publisher, 'gh workflow run "$WORKFLOW"', "publisher does not dispatch the release workflow") require(publisher, 'gh run watch "$RUN_ID"', "publisher does not wait for the complete workflow") diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5f6aa878..3195bf69 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -41,23 +41,37 @@ jobs: sparkle = "http://www.andymatuschak.org/xml-namespaces/sparkle" dory = "https://augani.github.io/dory/appcast" + def require(condition, message): + if not condition: + raise SystemExit(message) + def release_item(path): item = ET.parse(path).getroot().find("./channel/item") - assert item is not None, f"{path} has no release item" - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", \ - f"{path} has an invalid macOS floor" + require(item is not None, f"{path} has no release item") + require( + item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", + f"{path} has an invalid macOS floor", + ) for name in ("dataSchemaVersion", "minimumReadableDataSchema", \ - "maximumReadableDataSchema", "componentCatalogSchema"): - assert item.findtext(f"{{{dory}}}{name}") == "1", \ - f"{path} has an invalid Dory upgrade schema contract" + "maximumReadableDataSchema"): + require( + item.findtext(f"{{{dory}}}{name}") == "1", + f"{path} has an invalid Dory data schema contract", + ) + require( + item.findtext(f"{{{dory}}}componentCatalogSchema") in {"1", "2"}, + f"{path} has an invalid component catalog schema contract", + ) return item live_item = release_item(sys.argv[1]) checked_item = release_item(sys.argv[2]) live_build = int(live_item.findtext(f"{{{sparkle}}}version")) checked_build = int(checked_item.findtext(f"{{{sparkle}}}version")) - assert live_build > checked_build, \ - f"live appcast build {live_build} is not newer than checked-in build {checked_build}" + require( + live_build > checked_build, + f"live appcast build {live_build} is not newer than checked-in build {checked_build}", + ) PY then cp "$live" website/public/appcast.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96f2a9a2..4c24c035 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,7 +214,8 @@ jobs: with open(sys.argv[1], encoding="utf-8") as handle: metadata = json.load(handle) keys = metadata.get("ssh_keys", []) - assert keys and all(key.startswith("ssh-") for key in keys), "GitHub SSH metadata is missing" + if not keys or not all(isinstance(key, str) and key.startswith("ssh-") for key in keys): + raise SystemExit("GitHub SSH metadata is missing") with open(sys.argv[2], "w", encoding="utf-8") as handle: for key in keys: handle.write(f"github.com {key}\n") @@ -544,10 +545,11 @@ jobs: ET.register_namespace("dory", dory) tree = ET.parse(sys.argv[1]) items = tree.getroot().findall("./channel/item") - assert items, "previous release appcast has no item" + if not items: + raise SystemExit("previous release appcast has no item") for item in items: - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", \ - "previous release appcast would regress the macOS 14 floor" + if item.findtext(f"{{{sparkle}}}minimumSystemVersion") != "14.0": + raise SystemExit("previous release appcast would regress the macOS 14 floor") # Releases before 0.4 all use data/component schema 1. Normalize their # historical items once so the first transactional updater can enforce an # exact contract without dropping the signed enclosure history. @@ -596,6 +598,7 @@ jobs: DORY_RELEASE_VARIANTS: 'arm64' DORY_BUILD_APPCAST: '1' DORY_BUILD_APP_UPDATE: '1' + DORY_COMPONENT_CATALOG_SCHEMA: '2' DORY_RELEASE_ASSET_BASE_URL: https://github.com/Augani/dory/releases/download/v${{ steps.ver.outputs.version }} DORY_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY || secrets.SPARKLE_ED_PRIVATE_KEY }} DORY_RELEASE_SOURCE_COMMIT: ${{ github.sha }} @@ -641,9 +644,13 @@ jobs: with open(manifest_path, encoding="utf-8") as handle: manifest = json.load(handle) expected_name = pathlib.Path(archive_path).name - records = {record["name"]: record for record in manifest["artifacts"]} - record = records[expected_name] - assert record["sha256"] == digest.hexdigest(), "Sparkle candidate ZIP differs from release manifest" + artifacts = manifest.get("artifacts") if isinstance(manifest, dict) else None + if not isinstance(artifacts, list): + raise SystemExit("release manifest artifact list is invalid") + matches = [record for record in artifacts + if isinstance(record, dict) and record.get("name") == expected_name] + if len(matches) != 1 or matches[0].get("sha256") != digest.hexdigest(): + raise SystemExit("Sparkle candidate ZIP differs from release manifest") PY ditto -x -k "$UPDATE_ZIP" "$candidate_root/extracted" test -d "$candidate_root/extracted/Dory.app" @@ -666,8 +673,10 @@ jobs: with open("Dory.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", encoding="utf-8") as handle: payload = json.load(handle) pins = [pin for pin in payload["pins"] if pin["identity"] == "sparkle"] - assert len(pins) == 1, "expected one Sparkle package pin" - assert pins[0]["state"].get("version") == "2.9.4", "unexpected Sparkle release pin" + if len(pins) != 1: + raise SystemExit("expected one Sparkle package pin") + if pins[0]["state"].get("version") != "2.9.4": + raise SystemExit("unexpected Sparkle release pin") print(pins[0]["state"]["revision"]) PY )" @@ -1214,31 +1223,36 @@ jobs: import hashlib, json, pathlib, sys manifest_path, release_path, update_path, sbom_path, version, build, commit = sys.argv[1:] manifest = json.loads(pathlib.Path(manifest_path).read_text(encoding="utf-8")) - assert manifest == {**manifest}, "performance manifest must be an object" - assert manifest["schemaVersion"] == 1 - assert manifest["kind"] == "dev.dory.performance-qualification" - assert manifest["status"] == "PASS" and manifest["releaseQualifying"] is True + def require(condition, message): + if not condition: + raise SystemExit(message) + require(isinstance(manifest, dict), "performance manifest must be an object") + require(manifest.get("schemaVersion") == 1, "performance manifest schema mismatch") + require(manifest.get("kind") == "dev.dory.performance-qualification", "performance manifest kind mismatch") + require(manifest.get("status") == "PASS" and manifest.get("releaseQualifying") is True, + "performance qualification did not pass") candidate = manifest["candidate"] - assert candidate["version"] == version - assert candidate["build"] == build - assert candidate["sourceCommit"] == commit + require(isinstance(candidate, dict), "performance candidate binding is invalid") + require(candidate.get("version") == version, "performance candidate version mismatch") + require(candidate.get("build") == build, "performance candidate build mismatch") + require(candidate.get("sourceCommit") == commit, "performance candidate source mismatch") def digest(path): value = hashlib.sha256() with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): value.update(chunk) return value.hexdigest() - assert candidate["releaseManifestSHA256"] == digest(release_path) - assert candidate["appUpdateSHA256"] == digest(update_path) - assert candidate["sbomSHA256"] == digest(sbom_path) + require(candidate.get("releaseManifestSHA256") == digest(release_path), "performance release manifest mismatch") + require(candidate.get("appUpdateSHA256") == digest(update_path), "performance app update mismatch") + require(candidate.get("sbomSHA256") == digest(sbom_path), "performance SBOM mismatch") sbom = json.loads(pathlib.Path(sbom_path).read_text(encoding="utf-8")) values = [row["value"] for row in sbom["metadata"]["component"]["properties"] if row["name"] == "dev.dory.app.tree.sha256"] - assert values == [candidate["appTreeSHA256"]] - assert manifest["campaigns"] == [ + require(values == [candidate.get("appTreeSHA256")], "performance app tree mismatch") + require(manifest.get("campaigns") == [ "isolated", "user-workflows", "developer-workflows", "registry-npm", "external-network" - ] - assert manifest["cleanup"] == "PASS" + ], "performance campaign set mismatch") + require(manifest.get("cleanup") == "PASS", "performance cleanup did not pass") PY - name: Verify Homebrew audit evidence binding env: @@ -1775,17 +1789,20 @@ jobs: sparkle = "http://www.andymatuschak.org/xml-namespaces/sparkle" dory = "https://augani.github.io/dory/appcast" item = ET.parse(path).getroot().find("./channel/item") - assert item is not None, "generated appcast has no current item" - assert item.findtext(f"{{{sparkle}}}shortVersionString") == version, "generated appcast version mismatch" - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", "generated appcast macOS floor mismatch" - assert item.findtext(f"{{{dory}}}dataSchemaVersion") == "1", "generated appcast data schema mismatch" - assert item.findtext(f"{{{dory}}}minimumReadableDataSchema") == "1", "generated appcast minimum readable schema mismatch" - assert item.findtext(f"{{{dory}}}maximumReadableDataSchema") == "1", "generated appcast maximum readable schema mismatch" - assert item.findtext(f"{{{dory}}}componentCatalogSchema") == "1", "generated appcast component schema mismatch" + def require(condition, message): + if not condition: + raise SystemExit(message) + require(item is not None, "generated appcast has no current item") + require(item.findtext(f"{{{sparkle}}}shortVersionString") == version, "generated appcast version mismatch") + require(item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", "generated appcast macOS floor mismatch") + require(item.findtext(f"{{{dory}}}dataSchemaVersion") == "1", "generated appcast data schema mismatch") + require(item.findtext(f"{{{dory}}}minimumReadableDataSchema") == "1", "generated appcast minimum readable schema mismatch") + require(item.findtext(f"{{{dory}}}maximumReadableDataSchema") == "1", "generated appcast maximum readable schema mismatch") + require(item.findtext(f"{{{dory}}}componentCatalogSchema") == "2", "generated appcast component schema mismatch") enclosure = item.find("enclosure") - assert enclosure is not None, "generated appcast has no enclosure" + require(enclosure is not None, "generated appcast has no enclosure") name = os.path.basename(urllib.parse.urlparse(enclosure.attrib["url"]).path) - assert name == f"Dory-{version}-app-update.zip", f"generated appcast points at {name}" + require(name == f"Dory-{version}-app-update.zip", f"generated appcast points at {name}") PY - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -1893,7 +1910,8 @@ jobs: with open(sys.argv[1], encoding="utf-8") as handle: keys = json.load(handle).get("ssh_keys", []) - assert keys and all(key.startswith("ssh-") for key in keys), "GitHub SSH metadata is missing" + if not keys or not all(isinstance(key, str) and key.startswith("ssh-") for key in keys): + raise SystemExit("GitHub SSH metadata is missing") with open(sys.argv[2], "w", encoding="utf-8") as handle: for key in keys: handle.write(f"github.com {key}\n") From a4e6d2227ab2a07c6413e3003a83d4199c11a755 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:11:44 +0000 Subject: [PATCH 178/338] test(vm): qualify Zed on native Venus --- .../scripts/test-desktop-linux-live-gate.py | 15 ++++ .../test-release-candidate-live-smoke.py | 7 ++ .../verify-release-workflow-contract.py | 5 ++ .github/workflows/release.yml | 1 + scripts/desktop-linux-live-gate.sh | 83 ++++++++++++++++++- scripts/release-candidate-live-smoke.sh | 30 +++++++ 6 files changed, 139 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index a9634827..56a1d9bb 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -3,6 +3,7 @@ from __future__ import annotations +import hashlib import os import pathlib import subprocess @@ -35,6 +36,14 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: '--runtime-installation "$runtime_installation"', '"provenance": "verified-update-bundle"', "stale-update-rejected", + "--zed-archive", + "zed-native-venus", + "/zed.app/libexec/zed-editor", + "zed --version", + "zed_native_venus=NOT-RUN", + "VK_DRIVER_FILES", + "LD_LIBRARY_PATH", + "-u ZED_ALLOW_EMULATED_GPU", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: @@ -84,6 +93,12 @@ def test_workroot_must_be_inside_runner_temp(self) -> None: str(assets[1]), "--ubuntu-update", str(assets[2]), + "--zed-archive", + str(assets[2]), + "--zed-version", + "1.16.1", + "--zed-sha256", + hashlib.sha256(assets[2].read_bytes()).hexdigest(), "--distro", "ubuntu", "--version", diff --git a/.github/scripts/test-release-candidate-live-smoke.py b/.github/scripts/test-release-candidate-live-smoke.py index 81c10f58..9fb5e6ba 100644 --- a/.github/scripts/test-release-candidate-live-smoke.py +++ b/.github/scripts/test-release-candidate-live-smoke.py @@ -30,6 +30,7 @@ def test_live_contract_binds_candidate_and_all_physical_gates(self) -> None: "required offline release fixture is missing", "ISOLATED-DORY-MACHINE-RESOURCES", "EXACT-CANDIDATE-DESKTOPS", + '--component-dir "$DESKTOP_COMPONENT_DIR"', "ISOLATED-EXTERNAL-APFS-BIND", "ISOLATED-DORY-BIND-LOCKS", "SLEEP-AND-WAKE-THIS-MAC", @@ -39,6 +40,12 @@ def test_live_contract_binds_candidate_and_all_physical_gates(self) -> None: 'READINESS_NONNATIVE_BUILD_IMAGE="$NONNATIVE_BUILD_IMAGE"', "live-manifest.txt", "live_candidate=PASS", + "zed-linux-aarch64.tar.gz", + 'ZED_VERSION="1.16.1"', + "releases/download/v$ZED_VERSION/zed-linux-aarch64.tar.gz", + "384499c75d75c6aab53110dbc1d8856f6f774baaa32dc57b9963f9e29f8d007b", + "zed_native_venus=PASS", + "signed desktop component candidate is unavailable or indirect", ): self.assertIn(proof, text, proof) for stale in ("alpine:latest", "nginx:alpine", "node:20-alpine", "assert "): diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 1315fa04..2c02467e 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -54,6 +54,11 @@ def require(text: str, value: str, message: str) -> None: "DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop", "physical release gate does not receive its same-commit desktop kernel", ) +require( + workflow, + "DORY_RELEASE_COMPONENT_DIR: ${{ github.workspace }}/release-build/components/arm64", + "physical release gate does not receive its signed component candidate", +) live_smoke = Path("scripts/release-candidate-live-smoke.sh").read_text(encoding="utf-8") desktop_gate_path = Path("scripts/desktop-linux-live-gate.sh") if not desktop_gate_path.stat().st_mode & 0o100: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c24c035..eb627bfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -709,6 +709,7 @@ jobs: DORY_RELEASE_CORPORATE_VPN_PROBE_HOST: ${{ vars.DORY_CORPORATE_VPN_PROBE_HOST }} DORY_RELEASE_CORPORATE_VPN_PROBE_URL: ${{ vars.DORY_CORPORATE_VPN_PROBE_URL }} DORY_RELEASE_TAILSCALE_EXIT_NODE: ${{ vars.DORY_TAILSCALE_EXIT_NODE }} + DORY_RELEASE_COMPONENT_DIR: ${{ github.workspace }}/release-build/components/arm64 DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-debian-rootfs-arm64.ext4 DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4 diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index a88ab266..207852a1 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -15,13 +15,16 @@ KALI_ROOTFS="" DEBIAN_UPDATE="" UBUNTU_UPDATE="" KALI_UPDATE="" +ZED_ARCHIVE="" +ZED_VERSION="" +ZED_SHA256="" DESKTOP_VERSION="" SELECTED_DISTRO="all" WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-desktop-linux-live" CONFIRM="" usage() { - echo "usage: desktop-linux-live-gate.sh --ctl PATH --component-dir PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2 + echo "usage: desktop-linux-live-gate.sh --ctl PATH --component-dir PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --zed-archive PATH --zed-version VERSION --zed-sha256 SHA256 --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2 exit 64 } @@ -36,6 +39,9 @@ while [ "$#" -gt 0 ]; do --debian-update) DEBIAN_UPDATE="${2:?missing path}"; shift 2 ;; --ubuntu-update) UBUNTU_UPDATE="${2:?missing path}"; shift 2 ;; --kali-update) KALI_UPDATE="${2:?missing path}"; shift 2 ;; + --zed-archive) ZED_ARCHIVE="${2:?missing path}"; shift 2 ;; + --zed-version) ZED_VERSION="${2:?missing version}"; shift 2 ;; + --zed-sha256) ZED_SHA256="${2:?missing digest}"; shift 2 ;; --distro) SELECTED_DISTRO="${2:?missing distro}"; shift 2 ;; --version) DESKTOP_VERSION="${2:?missing version}"; shift 2 ;; --workroot) WORKROOT="${2:?missing path}"; shift 2 ;; @@ -57,7 +63,7 @@ VMM="$HELPERS/dory-hv" VZ_VMM="$HELPERS/dory-vmm" [ -x "$VMM" ] || { echo "desktop live gate: accelerated candidate dory-hv is missing: $VMM" >&2; exit 66; } [ -x "$VZ_VMM" ] || { echo "desktop live gate: fallback candidate dory-vmm is missing: $VZ_VMM" >&2; exit 66; } -assets=("$KERNEL") +assets=("$KERNEL" "$ZED_ARCHIVE") case "$SELECTED_DISTRO" in all) assets+=("$DEBIAN_ROOTFS" "$UBUNTU_ROOTFS" "$KALI_ROOTFS" "$DEBIAN_UPDATE" "$UBUNTU_UPDATE" "$KALI_UPDATE") ;; debian) assets+=("$DEBIAN_ROOTFS" "$DEBIAN_UPDATE") ;; @@ -75,6 +81,7 @@ absolute_asset() { printf '%s/%s\n' "$asset_directory" "$(basename "$asset_input")" } KERNEL="$(absolute_asset "$KERNEL")" +ZED_ARCHIVE="$(absolute_asset "$ZED_ARCHIVE")" COMPONENT_DIR="$(cd "$COMPONENT_DIR" && pwd -P)" case "$SELECTED_DISTRO" in all) @@ -100,6 +107,12 @@ case "$SELECTED_DISTRO" in esac printf '%s\n' "$DESKTOP_VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$' \ || { echo "desktop live gate: invalid desktop version: $DESKTOP_VERSION" >&2; exit 64; } +printf '%s\n' "$ZED_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || { echo "desktop live gate: invalid Zed version: $ZED_VERSION" >&2; exit 64; } +printf '%s\n' "$ZED_SHA256" | grep -Eq '^[0-9a-f]{64}$' \ + || { echo "desktop live gate: invalid Zed digest" >&2; exit 64; } +[ "$(shasum -a 256 "$ZED_ARCHIVE" | awk '{print $1}')" = "$ZED_SHA256" ] \ + || { echo "desktop live gate: Zed archive digest mismatch" >&2; exit 66; } [ -n "${RUNNER_TEMP:-}" ] \ || { echo "desktop live gate: RUNNER_TEMP must identify the dedicated release workspace" >&2; exit 64; } case "$RUNNER_TEMP" in /*) ;; *) echo "desktop live gate: RUNNER_TEMP must be absolute" >&2; exit 64 ;; esac @@ -120,6 +133,8 @@ PY rm -rf "$WORKROOT" mkdir -p "$WORKROOT/share" "$WORKROOT/evidence" printf 'Dory desktop release gate\n' > "$WORKROOT/share/host-marker.txt" +cp "$ZED_ARCHIVE" "$WORKROOT/share/zed-linux-aarch64.tar.gz" +[ "$(shasum -a 256 "$WORKROOT/share/zed-linux-aarch64.tar.gz" | awk '{print $1}')" = "$ZED_SHA256" ] codesign --verify --strict "$VMM" codesign -d --entitlements :- "$VMM" > "$WORKROOT/evidence/dory-hv-entitlements.plist" 2>&1 grep -q 'com.apple.security.hypervisor' "$WORKROOT/evidence/dory-hv-entitlements.plist" @@ -131,6 +146,7 @@ grep -q 'com.apple.security.virtualization' "$WORKROOT/evidence/dory-vmm-entitle grep -q 'com.apple.security.device.audio-input' "$WORKROOT/evidence/dory-vmm-entitlements.plist" grep -q 'NSMicrophoneUsageDescription' "$HELPERS/../Info.plist" ACTIVE_MACHINE="" +ZED_QUALIFIED=0 cleanup() { result=$? @@ -505,6 +521,58 @@ PY /tmp/dory-release-settings.log /tmp/dory-release-terminal.log >&2 exit 1 " > "$WORKROOT/evidence/$distro-gtk-windows.json" + + assert_exec_token "$machine" zed-native-venus sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \\ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \\ + | grep -E '^(DISPLAY|DBUS_SESSION_BUS_ADDRESS|XAUTHORITY|XDG_RUNTIME_DIR|VK_DRIVER_FILES|LD_LIBRARY_PATH)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + dbus=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + vk_driver=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^VK_DRIVER_FILES=//p') + library_path=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^LD_LIBRARY_PATH=//p') + test -n \"\$display\" + test -n \"\$xauth\" + test -n \"\$vk_driver\" + test -n \"\$library_path\" + rm -rf /home/dorygate/.local/zed.app + runuser -u dorygate -- mkdir -p /home/dorygate/.local /home/dorygate/Projects/dory-gate + test \"\$(sha256sum /home/dorygate/Mac/zed-linux-aarch64.tar.gz | awk '{print \$1}')\" \\ + = '$ZED_SHA256' + printf 'fn main() { println!(\"Dory Venus gate\"); }\n' \\ + > /home/dorygate/Projects/dory-gate/main.rs + chown -R dorygate:dorygate /home/dorygate/Projects/dory-gate + runuser -u dorygate -- tar -xzf /home/dorygate/Mac/zed-linux-aarch64.tar.gz \\ + -C /home/dorygate/.local + test -x /home/dorygate/.local/zed.app/bin/zed + runuser -u dorygate -- /home/dorygate/.local/zed.app/bin/zed --version \\ + | grep -F '$ZED_VERSION' + runuser -u dorygate -- env -u ZED_ALLOW_EMULATED_GPU \\ + DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \\ + XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" \\ + VK_DRIVER_FILES=\"\$vk_driver\" LD_LIBRARY_PATH=\"\$library_path\" \\ + /home/dorygate/.local/zed.app/bin/zed /home/dorygate/Projects/dory-gate/main.rs \\ + >/tmp/dory-release-zed.log 2>&1 + for _ in \$(seq 1 60); do + zed_pid=\$(pgrep -n -u dorygate -f '/zed.app/libexec/zed-editor' || true) + if test -n \"\$zed_pid\" \\ + && tr '\\0' '\\n' <\"/proc/\$zed_pid/environ\" | grep -Fqx \"VK_DRIVER_FILES=\$vk_driver\" \\ + && tr '\\0' '\\n' <\"/proc/\$zed_pid/environ\" | grep -Fqx \"LD_LIBRARY_PATH=\$library_path\" \\ + && ! tr '\\0' '\\n' <\"/proc/\$zed_pid/environ\" | grep -q '^ZED_ALLOW_EMULATED_GPU=' \\ + && runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \\ + xwininfo -root -tree 2>/dev/null | grep -Eiq 'zed|dory-gate/main.rs'; then + echo zed-native-venus + exit 0 + fi + sleep 1 + done + cat /tmp/dory-release-zed.log >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-zed.json" + ZED_QUALIFIED=1 fi "$CTL" machine stop "$machine" > "$WORKROOT/evidence/$distro-stop.json" @@ -607,6 +675,13 @@ fi printf 'source_commit=%s\n' "${GITHUB_SHA:-local}" printf 'distros=%s\n' "$SELECTED_DISTRO" printf 'kernel_sha256=%s\n' "$(shasum -a 256 "$KERNEL" | awk '{print $1}')" + printf 'zed_version=%s\n' "$ZED_VERSION" + printf 'zed_sha256=%s\n' "$ZED_SHA256" + if [ "$ZED_QUALIFIED" = 1 ]; then + printf 'zed_native_venus=PASS\n' + else + printf 'zed_native_venus=NOT-RUN\n' + fi if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" @@ -622,4 +697,8 @@ fi printf 'status=PASS\n' } > "$WORKROOT/evidence/manifest.txt" +rm -f "$WORKROOT/share/zed-linux-aarch64.tar.gz" +[ "$SELECTED_DISTRO" != all ] && [ "$SELECTED_DISTRO" != ubuntu ] \ + || [ "$ZED_QUALIFIED" = 1 ] + echo "Desktop Linux exact-candidate live gate: PASS ($WORKROOT/evidence/manifest.txt)" diff --git a/scripts/release-candidate-live-smoke.sh b/scripts/release-candidate-live-smoke.sh index da25a697..21b2cf30 100755 --- a/scripts/release-candidate-live-smoke.sh +++ b/scripts/release-candidate-live-smoke.sh @@ -36,6 +36,7 @@ CORPORATE_PROBE_HOST="${DORY_RELEASE_CORPORATE_VPN_PROBE_HOST:-}" CORPORATE_PROBE_URL="${DORY_RELEASE_CORPORATE_VPN_PROBE_URL:-}" TAILSCALE_EXIT_NODE="${DORY_RELEASE_TAILSCALE_EXIT_NODE:-}" DESKTOP_KERNEL="${DORY_RELEASE_DESKTOP_KERNEL:-}" +DESKTOP_COMPONENT_DIR="${DORY_RELEASE_COMPONENT_DIR:-}" DESKTOP_DEBIAN_ROOTFS="${DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS:-}" DESKTOP_UBUNTU_ROOTFS="${DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS:-}" DESKTOP_KALI_ROOTFS="${DORY_RELEASE_DESKTOP_KALI_ROOTFS:-}" @@ -43,6 +44,12 @@ DESKTOP_DEBIAN_UPDATE="${DORY_RELEASE_DESKTOP_DEBIAN_UPDATE:-}" DESKTOP_UBUNTU_UPDATE="${DORY_RELEASE_DESKTOP_UBUNTU_UPDATE:-}" DESKTOP_KALI_UPDATE="${DORY_RELEASE_DESKTOP_KALI_UPDATE:-}" DESKTOP_VERSION="${DORY_RELEASE_DESKTOP_VERSION:-}" +# Official stable ARM64 artifact published by zed-industries/zed. The physical gate downloads this +# exact version and refuses any byte change before exposing it through the read-only guest share. +ZED_VERSION="1.16.1" +ZED_SHA256="384499c75d75c6aab53110dbc1d8856f6f774baaa32dc57b9963f9e29f8d007b" +ZED_URL="https://github.com/zed-industries/zed/releases/download/v$ZED_VERSION/zed-linux-aarch64.tar.gz" +ZED_ARCHIVE="" SOURCE_COMMIT="${DORY_RELEASE_SOURCE_COMMIT:-${GITHUB_SHA:-}}" APP_PID="" PREVIOUS_CONTEXT="default" @@ -83,6 +90,9 @@ cleanup() { rm -rf "$STATE" "$APP_SUPPORT" defaults delete "$PREF_DOMAIN" >/dev/null 2>&1 || true rm -f "$PREF_PLIST" + if [ -n "$ZED_ARCHIVE" ]; then + rm -f "$ZED_ARCHIVE" + fi /usr/bin/killall -u "$(/usr/bin/id -un)" cfprefsd >/dev/null 2>&1 || true rm -f "$PREF_PLIST" for index in "${!PROFILE_PATHS[@]}"; do @@ -156,6 +166,8 @@ if [ "$REQUIRED_ARCH" = arm64 ]; then [ -f "$asset" ] && [ ! -L "$asset" ] && [ -s "$asset" ] \ || fail "same-commit desktop release asset is unavailable or indirect: $asset" done + [ -d "$DESKTOP_COMPONENT_DIR" ] && [ ! -L "$DESKTOP_COMPONENT_DIR" ] \ + || fail "signed desktop component candidate is unavailable or indirect: $DESKTOP_COMPONENT_DIR" [ -n "$DESKTOP_VERSION" ] || fail "DORY_RELEASE_DESKTOP_VERSION is required" [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ] \ || fail "physical release qualification requires a live SSH_AUTH_SOCK" @@ -224,6 +236,15 @@ mkdir -p "$LOG_ROOT" trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM +if [ "$REQUIRED_ARCH" = arm64 ]; then + ZED_ARCHIVE="$RUNNER_TEMP/dory-zed-$ZED_VERSION-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}.tar.gz" + [ ! -e "$ZED_ARCHIVE" ] && [ ! -L "$ZED_ARCHIVE" ] \ + || fail "Zed fixture path already exists or is indirect: $ZED_ARCHIVE" + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 600 \ + "$ZED_URL" -o "$ZED_ARCHIVE" + [ "$(shasum -a 256 "$ZED_ARCHIVE" | awk '{print $1}')" = "$ZED_SHA256" ] \ + || fail "pinned Zed $ZED_VERSION ARM64 archive digest mismatch" +fi defaults write "$PREF_DOMAIN" dory.hasCompletedOnboarding -bool true defaults write "$PREF_DOMAIN" dory.keepDorydRunningAfterQuit -bool false if [ "$REQUIRED_ARCH" = arm64 ]; then @@ -345,6 +366,7 @@ if [ "$REQUIRED_ARCH" = arm64 ]; then scripts/desktop-linux-live-gate.sh \ --ctl "$APP/Contents/Helpers/dorydctl" \ + --component-dir "$DESKTOP_COMPONENT_DIR" \ --kernel "$DESKTOP_KERNEL" \ --debian-rootfs "$DESKTOP_DEBIAN_ROOTFS" \ --ubuntu-rootfs "$DESKTOP_UBUNTU_ROOTFS" \ @@ -352,6 +374,9 @@ if [ "$REQUIRED_ARCH" = arm64 ]; then --debian-update "$DESKTOP_DEBIAN_UPDATE" \ --ubuntu-update "$DESKTOP_UBUNTU_UPDATE" \ --kali-update "$DESKTOP_KALI_UPDATE" \ + --zed-archive "$ZED_ARCHIVE" \ + --zed-version "$ZED_VERSION" \ + --zed-sha256 "$ZED_SHA256" \ --version "$DESKTOP_VERSION" \ --workroot "$LOG_ROOT/desktop-linux" \ --confirm EXACT-CANDIDATE-DESKTOPS @@ -438,6 +463,11 @@ fi echo "dory_vmm_sha256=$(shasum -a 256 "$APP/Contents/Helpers/dory-vmm" | awk '{print $1}')" echo "fixture_image=$FIXTURE_IMAGE" echo "nonnative_build_image=$NONNATIVE_BUILD_IMAGE" + if [ "$REQUIRED_ARCH" = arm64 ]; then + echo "zed_version=$ZED_VERSION" + echo "zed_sha256=$ZED_SHA256" + echo "zed_native_venus=PASS" + fi echo "p0_smoke=PASS" echo "live_candidate=PASS" } > "$LOG_ROOT/live-manifest.txt" From bd5a1db7e2206c3372fc88def90dc3d3eb268131 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:22:35 +0000 Subject: [PATCH 179/338] fix(vm): replan restored workspaces --- .../DoryMachineTypedWriteAuthority.swift | 9 +++-- .../Sources/DorydKit/DorydService.swift | 24 ++++++++++++- .../DoryMachineTypedWriteAuthorityTests.swift | 34 +++++++++++++++++++ .../DorydKitTests/DorydServiceTests.swift | 29 ++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index 8a506fe2..ab30e756 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -225,9 +225,12 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha desktopEnvironment: replacement( guestIdentityIntent.desktop?.desktopEnvironment ), - clipboardPolicy: replacement(clipboardPolicy), - runtimePreference: replacement(runtimePreference), - graphicsPreference: replacement(graphicsPreference), + // Headless snapshots intentionally omit desktop-only values. Absence therefore + // means “not represented by this display contract,” not “reset the backend and + // graphics policy to desktop defaults.” + clipboardPolicy: update(clipboardPolicy), + runtimePreference: update(runtimePreference), + graphicsPreference: update(graphicsPreference), networkMode: .set(networkMode) ) } diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 069d6ef9..8f8478c0 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -676,7 +676,29 @@ public final class DorydService: NSObject, DorydControl { reply: @escaping (Bool, NSDictionary, String) -> Void ) { machineControl("\(machineID)/\(snapshotID)", action: "restore_snapshot", reply: reply) { manager, _ in - try manager.restoreSnapshot(machineID: machineID, snapshotID: snapshotID) + guard let source = manager.status(id: machineID) else { + throw MachineManagerError.unknownMachine(machineID) + } + let shouldRestart = source.state == .starting || source.state == .running + var status = try manager.restoreSnapshot( + machineID: machineID, + snapshotID: snapshotID + ) + if manager.configuredLaunchPolicy == .perWorkspaceAuthority { + guard let productionPlanningController else { + throw MachineManagerError.persistence( + "production planning controller is not configured" + ) + } + status = try manager.resolveAndPublishProductionPlan( + id: machineID, + controller: productionPlanningController + ) + if shouldRestart { + status = try manager.start(id: machineID) + } + } + return status } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 5e756650..dcedd18d 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -158,6 +158,40 @@ struct DoryMachineTypedWriteAuthorityTests { } } + @Test("headless snapshot replacement preserves desktop-only launch policy") + func headlessSnapshotPreservesLaunchPolicy() throws { + let migration = try DoryMachineConfigurationMigrationBridge.migrate( + DoryMachineConfiguration( + id: "headless-snapshot", + kernelPath: "/fixture/kernel", + rootfsPath: "/fixture/rootfs", + displayMode: .headless + ), + facts: DoryMachineConfigurationMigrationFacts( + guestArchitecture: .arm64, + systemDiskCapacityBytes: 32 * 1_024 * 1_024 * 1_024, + lifecycle: DoryVMLifecycleMetadata( + revision: 1, + createdAtUnixMilliseconds: 1_700_000_000_000, + updatedAtUnixMilliseconds: 1_700_000_000_000 + ) + ) + ) + let snapshot = try DoryMachineTypedSettingsSnapshot( + definition: migration.definition + ) + let restored = try snapshot.applyingAsReplacement( + to: migration.definition, + displayMode: .headless + ) + + #expect(snapshot.runtimePreference == nil) + #expect(snapshot.graphicsPreference == nil) + #expect(restored.backendPreference == migration.definition.backendPreference) + #expect(restored.graphics == migration.definition.graphics) + #expect(restored.clipboardPolicy == migration.definition.clipboardPolicy) + } + @Test("hostile and out of range guest fields fail closed") func hostileGuestFields() { let cases: [NSDictionary] = [ diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 10a96a69..4ce480a8 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1700,6 +1700,35 @@ final class DorydServiceTests: XCTestCase { .sharedNAT ) XCTAssertEqual(manager.status(id: "planned")?.runtimeIdentity.mode, .requiresReplanning) + + let managedRootfs = base + "/planned/rootfs.ext4" + let snapshotBytes = try Data(contentsOf: URL(fileURLWithPath: managedRootfs)) + XCTAssertFalse(snapshotBytes.isEmpty) + _ = try manager.snapshot(id: "planned", snapshotID: "before-restore") + var mutatedBytes = snapshotBytes + mutatedBytes[mutatedBytes.startIndex] ^= 0xff + try mutatedBytes.write(to: URL(fileURLWithPath: managedRootfs)) + + let restoreReply = expectation(description: "production restore planning rejection") + service.machineRestoreSnapshot("planned", snapshotID: "before-restore") { + ok, _, message in + XCTAssertFalse(ok) + XCTAssertTrue(message.contains("production planning failed closed"), message) + restoreReply.fulfill() + } + wait(for: [restoreReply], timeout: 5) + XCTAssertEqual(controller.captures.count, 3) + XCTAssertEqual( + controller.captures.last?.request.planning.definition.identity.id, + "planned" + ) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: managedRootfs)), snapshotBytes) + XCTAssertEqual(manager.status(id: "planned")?.state, .created) + XCTAssertEqual(manager.status(id: "planned")?.runtimeIdentity.mode, .requiresReplanning) + XCTAssertEqual( + manager.status(id: "planned")?.runtimeIdentity.invalidationReason, + .restoredSnapshot + ) } func testMachineExecOverXPCUsesMachineAgent() throws { From 92dbd14806d2566f1b9dbb50abf6bde30bc8c3f8 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:25:31 +0000 Subject: [PATCH 180/338] test(vm): gate exact snapshot recovery --- .../scripts/test-desktop-linux-live-gate.py | 7 ++ .../verify-release-workflow-contract.py | 5 + scripts/desktop-linux-live-gate.sh | 109 ++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 56a1d9bb..3dff1bf6 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -44,6 +44,13 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "VK_DRIVER_FILES", "LD_LIBRARY_PATH", "-u ZED_ALLOW_EMULATED_GPU", + 'machine snapshot "$machine"', + 'machine restore-snapshot "$machine"', + 'machine delete-snapshot "$machine"', + "recovery-exact-bytes-restored", + 'identity.get("mode") != "resolved-plan"', + "restore reused the snapshot's stale launch plan", + "snapshot_restore_exact_bytes=PASS", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 2c02467e..7b9852c0 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -91,6 +91,11 @@ def require(text: str, value: str, message: str) -> None: "machine desktop-update", 'body.get("snapshotID")', '"provenance": "verified-update-bundle"', + 'machine snapshot "$machine"', + 'machine restore-snapshot "$machine"', + "recovery-exact-bytes-restored", + "restore reused the snapshot's stale launch plan", + "snapshot_restore_exact_bytes=PASS", ): require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}") diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index 207852a1..cebd24ec 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -583,6 +583,114 @@ PY "cat /home/dorygate/.dory-release-marker; mountpoint -q /home/dorygate/Mac" \ > "$WORKROOT/evidence/$distro-persistence.json" + recovery_snapshot="recovery-$distro" + assert_exec_token "$machine" recovery-source-ready sh -lc " + set -eu + recovery_path=/home/dorygate/.dory-release-recovery.bin + recovery_sum=/home/dorygate/.dory-release-recovery.sha256 + dd if=/dev/urandom of=\"\$recovery_path\" bs=1M count=16 status=none + sha256sum \"\$recovery_path\" > \"\$recovery_sum\" + sync + echo recovery-source-ready + " > "$WORKROOT/evidence/$distro-recovery-source.json" + "$CTL" machine snapshot "$machine" --id "$recovery_snapshot" \ + --note "Exact release-candidate recovery proof" \ + > "$WORKROOT/evidence/$distro-recovery-snapshot.json" + snapshot_plan_sha="$(python3 - \ + "$WORKROOT/evidence/$distro-recovery-snapshot.json" \ + "$machine" "$recovery_snapshot" <<'PY' +import json +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +if not isinstance(body, dict) or body.get("machineID") != sys.argv[2] \ + or body.get("id") != sys.argv[3]: + raise SystemExit(f"snapshot returned the wrong authority: {body!r}") +if body.get("consistency") not in {"cold-stopped", "guest-quiesced"}: + raise SystemExit(f"snapshot omitted an exact consistency contract: {body!r}") +identity = body.get("runtimeIdentity") +if not isinstance(identity, dict) or identity.get("mode") != "resolved-plan" \ + or re.fullmatch(r"[0-9a-f]{64}", identity.get("planSHA256", "")) is None: + raise SystemExit(f"snapshot omitted resolved runtime authority: {body!r}") +artifacts = body.get("artifactEvidence") +if not isinstance(artifacts, dict): + raise SystemExit(f"snapshot omitted artifact evidence: {body!r}") +for key in ("rootfs", "kernel"): + artifact = artifacts.get(key) + if not isinstance(artifact, dict) \ + or not isinstance(artifact.get("byteCount"), int) \ + or artifact["byteCount"] <= 0 \ + or re.fullmatch(r"[0-9a-f]{64}", artifact.get("sha256", "")) is None: + raise SystemExit(f"snapshot has invalid {key} evidence: {body!r}") +print(identity["planSHA256"]) +PY + )" + printf '%s\n' "$snapshot_plan_sha" \ + > "$WORKROOT/evidence/$distro-recovery-snapshot-plan.txt" + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + assert_exec_token "$machine" recovery-source-mutated sh -lc " + set -eu + recovery_path=/home/dorygate/.dory-release-recovery.bin + recovery_sum=/home/dorygate/.dory-release-recovery.sha256 + printf 'mutated-after-snapshot\n' > \"\$recovery_path\" + if sha256sum -c \"\$recovery_sum\" >/dev/null 2>&1; then + echo 'recovery mutation did not change the payload' >&2 + exit 1 + fi + sync + echo recovery-source-mutated + " > "$WORKROOT/evidence/$distro-recovery-mutated.json" + "$CTL" machine restore-snapshot "$machine" "$recovery_snapshot" \ + > "$WORKROOT/evidence/$distro-recovery-restore.json" + restored_plan_sha="$(python3 - \ + "$WORKROOT/evidence/$distro-recovery-restore.json" \ + "$machine" "$snapshot_plan_sha" <<'PY' +import json +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +if not isinstance(body, dict) or body.get("id") != sys.argv[2] \ + or body.get("state") not in {"starting", "running"}: + raise SystemExit(f"restore did not resume the running machine: {body!r}") +identity = body.get("runtimeIdentity") +if not isinstance(identity, dict) or identity.get("mode") != "resolved-plan" \ + or re.fullmatch(r"[0-9a-f]{64}", identity.get("planSHA256", "")) is None: + raise SystemExit(f"restore did not publish fresh resolved authority: {body!r}") +if identity["planSHA256"] == sys.argv[3]: + raise SystemExit(f"restore reused the snapshot's stale launch plan: {body!r}") +print(identity["planSHA256"]) +PY + )" + printf '%s\n' "$restored_plan_sha" \ + > "$WORKROOT/evidence/$distro-recovery-restored-plan.txt" + wait_for_desktop "$machine" "$manager" "$session" + wait_for_running "$machine" + assert_exec_token "$machine" recovery-exact-bytes-restored sh -lc " + set -eu + sha256sum -c /home/dorygate/.dory-release-recovery.sha256 + echo recovery-exact-bytes-restored + " > "$WORKROOT/evidence/$distro-recovery-qualified.json" + "$CTL" machine delete-snapshot "$machine" "$recovery_snapshot" \ + > "$WORKROOT/evidence/$distro-recovery-delete.json" + "$CTL" machine snapshots "$machine" \ + > "$WORKROOT/evidence/$distro-recovery-remaining-snapshots.json" + python3 - "$WORKROOT/evidence/$distro-recovery-remaining-snapshots.json" \ + "$recovery_snapshot" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +if not isinstance(body, list) or any( + isinstance(row, dict) and row.get("id") == sys.argv[2] for row in body): + raise SystemExit(f"recovery snapshot survived deletion: {body!r}") +PY + "$CTL" machine desktop-update "$machine" \ --distro "$distro" --version "$update_version" \ --distribution-installation "$distribution_installation" \ @@ -682,6 +790,7 @@ fi else printf 'zed_native_venus=NOT-RUN\n' fi + printf 'snapshot_restore_exact_bytes=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" From 140dab35287ddf240db8daff00ee03ca5f4348bd Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:29:49 +0000 Subject: [PATCH 181/338] test(vm): prove graceful guest shutdown --- .../scripts/test-desktop-linux-live-gate.py | 5 ++ scripts/desktop-linux-live-gate.sh | 48 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 3dff1bf6..28126aba 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -51,6 +51,11 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: 'identity.get("mode") != "resolved-plan"', "restore reused the snapshot's stale launch plan", "snapshot_restore_exact_bytes=PASS", + "graceful-shutdown-armed", + "dory-release-graceful-shutdown.service", + "ExecStop=/bin/sh -c 'printf graceful-shutdown-pass", + "machine stop did not complete cleanly", + "graceful_shutdown=PASS", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index cebd24ec..aa25b3a0 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -431,6 +431,34 @@ PY echo system-pass " > "$WORKROOT/evidence/$distro-system.json" + # Arm a receipt that can only be written by systemd while the guest is performing an orderly + # shutdown. Merely terminating the VM helper cannot create this marker, so the subsequent boot + # distinguishes graceful guest shutdown from the host watchdog fallback. + assert_exec_token "$machine" graceful-shutdown-armed sh -lc " + set -eu + marker=/var/lib/dory/release-graceful-shutdown + unit=/etc/systemd/system/dory-release-graceful-shutdown.service + rm -f \"\$marker\" + printf '%s\n' \ + '[Unit]' \ + 'Description=Dory release gate graceful shutdown receipt' \ + 'After=multi-user.target' \ + '' \ + '[Service]' \ + 'Type=oneshot' \ + 'ExecStart=/bin/true' \ + \"ExecStop=/bin/sh -c 'printf graceful-shutdown-pass > \$marker; sync'\" \ + 'RemainAfterExit=yes' \ + '' \ + '[Install]' \ + 'WantedBy=multi-user.target' > \"\$unit\" + systemctl daemon-reload + systemctl enable --now dory-release-graceful-shutdown.service >/dev/null + systemctl is-active --quiet dory-release-graceful-shutdown.service + test ! -e \"\$marker\" + echo graceful-shutdown-armed + " > "$WORKROOT/evidence/$distro-graceful-shutdown-armed.json" + assert_exec_token "$machine" browser-window-mapped sh -lc " set -eu uid=\$(id -u dorygate) @@ -576,11 +604,26 @@ PY fi "$CTL" machine stop "$machine" > "$WORKROOT/evidence/$distro-stop.json" + python3 - "$WORKROOT/evidence/$distro-stop.json" "$machine" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +if not isinstance(body, dict) or body.get("id") != sys.argv[2] \ + or body.get("state") != "stopped": + raise SystemExit(f"machine stop did not complete cleanly: {body!r}") +PY "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-restart.json" wait_for_desktop "$machine" "$manager" "$session" wait_for_running "$machine" - assert_exec_token "$machine" persistence-pass sh -lc \ - "cat /home/dorygate/.dory-release-marker; mountpoint -q /home/dorygate/Mac" \ + assert_exec_token "$machine" graceful-shutdown-pass sh -lc " + set -eu + grep -Fqx graceful-shutdown-pass /var/lib/dory/release-graceful-shutdown + cat /home/dorygate/.dory-release-marker + mountpoint -q /home/dorygate/Mac + echo graceful-shutdown-pass + " \ > "$WORKROOT/evidence/$distro-persistence.json" recovery_snapshot="recovery-$distro" @@ -791,6 +834,7 @@ fi printf 'zed_native_venus=NOT-RUN\n' fi printf 'snapshot_restore_exact_bytes=PASS\n' + printf 'graceful_shutdown=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" From fe45f493e7c63d0df56d1160a16e3d0e6f59397b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:32:28 +0000 Subject: [PATCH 182/338] test(vm): qualify dynamic Retina resize --- .../scripts/test-desktop-linux-live-gate.py | 7 ++ scripts/desktop-linux-live-gate.sh | 97 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 28126aba..01bce241 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -56,6 +56,13 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "ExecStop=/bin/sh -c 'printf graceful-shutdown-pass", "machine stop did not complete cleanly", "graceful_shutdown=PASS", + "display-baseline-ready", + "Accessibility permission is required for display qualification", + "set size of front window of targetProcess to {960, 640}", + "dynamic-display-resized", + "guest display mode did not follow the host window resize", + "dynamic-display-restored", + "dynamic_retina_display=PASS", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index aa25b3a0..bad26831 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -459,6 +459,102 @@ PY echo graceful-shutdown-armed " > "$WORKROOT/evidence/$distro-graceful-shutdown-armed.json" + assert_exec_token "$machine" display-baseline-ready sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + mode=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xrandr --current | awk '\$2 ~ /\\*/ { print \$1; exit }') + printf '%s\n' \"\$mode\" | grep -Eq '^[0-9]+x[0-9]+$' + printf '%s\n' \"\$mode\" > /var/lib/dory/release-display-baseline + echo display-baseline-ready + " > "$WORKROOT/evidence/$distro-display-baseline.json" + + original_window_size="$(osascript - "$machine_pid" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + tell application "System Events" + if not UI elements enabled then error "Accessibility permission is required for display qualification" + set targetProcess to first process whose unix id is targetPID + set originalSize to size of front window of targetProcess + set size of front window of targetProcess to {960, 640} + return ((item 1 of originalSize) as text) & "x" & ((item 2 of originalSize) as text) + end tell +end run +APPLESCRIPT +)" + printf '%s\n' "$original_window_size" | grep -Eq '^[0-9]+x[0-9]+$' \ + || { echo "desktop live gate: invalid original window size: $original_window_size" >&2; exit 1; } + printf '%s\n' "$original_window_size" \ + > "$WORKROOT/evidence/$distro-display-original-window.txt" + + assert_exec_token "$machine" dynamic-display-resized sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + baseline=\$(cat /var/lib/dory/release-display-baseline) + for _ in \$(seq 1 30); do + current=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xrandr --current | awk '\$2 ~ /\\*/ { print \$1; exit }') + if printf '%s\n' \"\$current\" | grep -Eq '^[0-9]+x[0-9]+$' \ + && test \"\$current\" != \"\$baseline\"; then + printf '%s\n' \"\$current\" > /var/lib/dory/release-display-resized + echo dynamic-display-resized + exit 0 + fi + sleep 1 + done + echo 'guest display mode did not follow the host window resize' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-display-resized.json" + + original_window_width="${original_window_size%x*}" + original_window_height="${original_window_size#*x}" + osascript - "$machine_pid" "$original_window_width" "$original_window_height" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + set restoredWidth to (item 2 of argv) as integer + set restoredHeight to (item 3 of argv) as integer + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set size of front window of targetProcess to {restoredWidth, restoredHeight} + end tell +end run +APPLESCRIPT + + assert_exec_token "$machine" dynamic-display-restored sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + baseline=\$(cat /var/lib/dory/release-display-baseline) + for _ in \$(seq 1 30); do + current=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xrandr --current | awk '\$2 ~ /\\*/ { print \$1; exit }') + if test \"\$current\" = \"\$baseline\"; then + echo dynamic-display-restored + exit 0 + fi + sleep 1 + done + echo 'guest display mode did not return after restoring the host window' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-display-restored.json" + assert_exec_token "$machine" browser-window-mapped sh -lc " set -eu uid=\$(id -u dorygate) @@ -835,6 +931,7 @@ fi fi printf 'snapshot_restore_exact_bytes=PASS\n' printf 'graceful_shutdown=PASS\n' + printf 'dynamic_retina_display=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" From 05c8120c29c1809be5ee0d9bcd6e60aea2a3af60 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:33:47 +0000 Subject: [PATCH 183/338] test(vm): qualify bidirectional clipboard --- .../scripts/test-desktop-linux-live-gate.py | 6 +++ scripts/desktop-linux-live-gate.sh | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 01bce241..155f7428 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -63,6 +63,12 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "guest display mode did not follow the host window resize", "dynamic-display-restored", "dynamic_retina_display=PASS", + "clipboard-host-to-guest-pass", + "/usr/lib/dory/clipboard get 'text/plain;charset=utf-8'", + "clipboard-guest-source-ready", + "/usr/lib/dory/clipboard set 'text/plain;charset=utf-8'", + "guest clipboard did not reach the host", + "clipboard_bidirectional=PASS", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index bad26831..c9bcd64f 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -555,6 +555,58 @@ APPLESCRIPT exit 1 " > "$WORKROOT/evidence/$distro-display-restored.json" + host_to_guest_clipboard="dory-host-to-guest-$distro-$(uuidgen | tr '[:upper:]' '[:lower:]')" + osascript <<'APPLESCRIPT' +tell application "Finder" to activate +delay 0.25 +APPLESCRIPT + printf '%s' "$host_to_guest_clipboard" | pbcopy + osascript - "$machine_pid" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set frontmost of targetProcess to true + end tell +end run +APPLESCRIPT + assert_exec_token "$machine" clipboard-host-to-guest-pass sh -lc " + set -eu + for _ in \$(seq 1 30); do + actual=\$(/usr/lib/dory/clipboard get 'text/plain;charset=utf-8' 2>/dev/null || true) + if test \"\$actual\" = '$host_to_guest_clipboard'; then + echo clipboard-host-to-guest-pass + exit 0 + fi + sleep 1 + done + echo 'host clipboard did not reach the guest' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-clipboard-host-to-guest.json" + + guest_to_host_clipboard="dory-guest-to-host-$distro-$(uuidgen | tr '[:upper:]' '[:lower:]')" + assert_exec_token "$machine" clipboard-guest-source-ready sh -lc " + set -eu + printf '%s' '$guest_to_host_clipboard' \ + | /usr/lib/dory/clipboard set 'text/plain;charset=utf-8' + echo clipboard-guest-source-ready + " > "$WORKROOT/evidence/$distro-clipboard-guest-source.json" + osascript <<'APPLESCRIPT' +tell application "Finder" to activate +APPLESCRIPT + clipboard_guest_to_host_ok=0 + for _ in $(seq 1 30); do + if [ "$(pbpaste)" = "$guest_to_host_clipboard" ]; then + clipboard_guest_to_host_ok=1 + break + fi + sleep 1 + done + [ "$clipboard_guest_to_host_ok" = 1 ] \ + || { echo "desktop live gate: guest clipboard did not reach the host" >&2; exit 1; } + printf '%s\n' "$guest_to_host_clipboard" \ + > "$WORKROOT/evidence/$distro-clipboard-guest-to-host.txt" + assert_exec_token "$machine" browser-window-mapped sh -lc " set -eu uid=\$(id -u dorygate) @@ -932,6 +984,7 @@ fi printf 'snapshot_restore_exact_bytes=PASS\n' printf 'graceful_shutdown=PASS\n' printf 'dynamic_retina_display=PASS\n' + printf 'clipboard_bidirectional=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" From 34ff7d4d9cfb421fb7d33da31e1febd16b34e0bc Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:35:28 +0000 Subject: [PATCH 184/338] test(vm): qualify keyboard and pointer input --- .../scripts/test-desktop-linux-live-gate.py | 7 ++ scripts/desktop-linux-live-gate.sh | 68 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 155f7428..c195b169 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -69,6 +69,13 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "/usr/lib/dory/clipboard set 'text/plain;charset=utf-8'", "guest clipboard did not reach the host", "clipboard_bidirectional=PASS", + "input-window-ready", + "xterm -title DoryInputGate", + "click at {clickX, clickY}", + "keystroke inputToken", + "keyboard-pointer-input-pass", + "host keyboard/pointer input did not reach the guest exactly", + "keyboard_pointer_input=PASS", "workroot must be a strict child of RUNNER_TEMP", ) for proof in required: diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index c9bcd64f..2bef677c 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -607,6 +607,73 @@ APPLESCRIPT printf '%s\n' "$guest_to_host_clipboard" \ > "$WORKROOT/evidence/$distro-clipboard-guest-to-host.txt" + input_token="doryinputpass$distro" + assert_exec_token "$machine" input-window-ready sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + marker=/home/dorygate/.dory-release-input + reader=/tmp/dory-release-input-reader + rm -f \"\$marker\" + printf '%s' \ + 'IyEvYmluL3NoCklGUz0gcmVhZCAtciBsaW5lCnByaW50ZiAiJXMiICIkbGluZSIgPiAvaG9tZS9kb3J5Z2F0ZS8uZG9yeS1yZWxlYXNlLWlucHV0Cg==' \ + | base64 -d > \"\$reader\" + chmod 0755 \"\$reader\" + chown dorygate:dorygate \"\$reader\" + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + setsid -f xterm -title DoryInputGate -geometry 200x60+0+0 -e \"\$reader\" + for _ in \$(seq 1 30); do + if runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xwininfo -root -tree 2>/dev/null | grep -Fq 'DoryInputGate'; then + echo input-window-ready + exit 0 + fi + sleep 1 + done + echo 'guest input window did not map' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-input-window.json" + + osascript - "$machine_pid" "$input_token" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + set inputToken to item 2 of argv + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set frontmost of targetProcess to true + set targetWindow to front window of targetProcess + set windowPosition to position of targetWindow + set windowSize to size of targetWindow + set clickX to (item 1 of windowPosition) + ((item 1 of windowSize) div 2) + set clickY to (item 2 of windowPosition) + ((item 2 of windowSize) div 2) + delay 0.25 + click at {clickX, clickY} + delay 0.25 + keystroke inputToken + key code 36 + end tell +end run +APPLESCRIPT + + assert_exec_token "$machine" keyboard-pointer-input-pass sh -lc " + set -eu + marker=/home/dorygate/.dory-release-input + for _ in \$(seq 1 30); do + if test -f \"\$marker\" && test \"\$(cat \"\$marker\")\" = '$input_token'; then + echo keyboard-pointer-input-pass + exit 0 + fi + sleep 1 + done + echo 'host keyboard/pointer input did not reach the guest exactly' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-keyboard-pointer-input.json" + assert_exec_token "$machine" browser-window-mapped sh -lc " set -eu uid=\$(id -u dorygate) @@ -985,6 +1052,7 @@ fi printf 'graceful_shutdown=PASS\n' printf 'dynamic_retina_display=PASS\n' printf 'clipboard_bidirectional=PASS\n' + printf 'keyboard_pointer_input=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')" printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')" From 06f0f529ee76c35d65f8df9197f896c4dc125c0f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:46:32 +0000 Subject: [PATCH 185/338] feat(vm): render guest cursor shape --- .../Sources/DoryHV/VirtioGPU.swift | 137 +++++++++++-- .../Sources/dory-hv/DesktopMetalDisplay.swift | 93 +++++++++ .../Sources/dory-hv/DesktopMode.swift | 5 + .../Tests/DoryHVTests/DeviceLogicTests.swift | 183 ++++++++++++++++++ 4 files changed, 404 insertions(+), 14 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 301380a0..db41987f 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -119,6 +119,43 @@ public struct VirtioGPUScanoutFrame: Sendable, Equatable { } } +/// A copied guest cursor plane ready for the host window. Cursor bytes are never exposed through +/// guest-owned pointers: the device snapshots the complete 32-bit BGRA resource at the +/// UPDATE_CURSOR command boundary and carries the guest hotspot with it. +public struct VirtioGPUCursorUpdate: Sendable, Equatable { + public var scanoutID: UInt32 + public var resourceID: UInt32 + public var x: UInt32 + public var y: UInt32 + public var width: UInt32 + public var height: UInt32 + public var hotX: UInt32 + public var hotY: UInt32 + public var bytes: Data + + public init( + scanoutID: UInt32, + resourceID: UInt32, + x: UInt32, + y: UInt32, + width: UInt32, + height: UInt32, + hotX: UInt32, + hotY: UInt32, + bytes: Data + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.x = x + self.y = y + self.width = width + self.height = height + self.hotX = hotX + self.hotY = hotY + self.bytes = bytes + } +} + public protocol VirtioGPURenderer: AnyObject { var capsets: [VirtioGPUCapset] { get } func createContext(id: UInt32, flags: UInt32, name: String) throws @@ -238,6 +275,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private var pendingDisplayEvents: UInt32 = 0 private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? private let onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? + private let onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? private let renderer: VirtioGPURenderer? private let capsets: [VirtioGPUCapset] private let hostVisibleMemory: VirtioGPUHostVisibleMemory? @@ -283,9 +321,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi /// full-resource offset lets later scanout extraction use ordinary resource coordinates. private var rendererReadbackBuffers: [UInt32: Data] = [:] private var scanouts: [UInt32: ScanoutBinding] = [:] + private var cursorResourceID: UInt32? private var commandFailureCounts: [UInt32: Int] = [:] private let traceResourceLifecycle: Bool private var resourceTraceSequence: UInt64 = 0 + /// The cursor virtqueue reads resources created and retired on the control virtqueue. VCPU + /// kicks may arrive concurrently, so serialize command interpretation while leaving descriptor + /// dequeue/completion and renderer fence delivery on their existing independent locks. + private let commandLock = NSLock() // Real fence signalling: a fenced command's descriptor is held here and completed only when the // renderer signals the fence (from its own thread), per the virtio-gpu contract — responding @@ -380,7 +423,8 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1", onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil, - onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil + onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil, + onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil ) { let boundedScanoutCount = min(scanoutCount, 16) self.renderer = renderer @@ -398,6 +442,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi self.hostVisibleMemory = hostVisibleMemory self.onScanoutFrame = onScanoutFrame self.onScanoutResourceReleased = onScanoutResourceReleased + self.onCursorUpdate = onCursorUpdate renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) } @@ -460,7 +505,13 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi var interrupt = false while let chain = (try? virtqueue.pop()) ?? nil { let request = chain.readBytes() - let response = process(request: request, cursorQueue: queue == 1, transport: transport) + commandLock.lock() + let response = process( + request: request, + cursorQueue: queue == 1, + transport: transport + ) + commandLock.unlock() if queue == 0, deferForFence(request: request, response: response, chain: chain) { continue } @@ -554,12 +605,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi let command = request.leUInt32(at: 0) if cursorQueue { - switch command { - case Command.updateCursor, Command.moveCursor: - return responseHeader(type: Response.okNoData, request: request) - default: - return responseHeader(type: Response.errorInvalidParameter, request: request) - } + return cursorCommand(command, request: request) } switch command { @@ -881,6 +927,10 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) + if cursorResourceID == resourceID { + cursorResourceID = nil + onCursorUpdate?(nil) + } if var resource = resources2D[resourceID] { if let renderer { try renderer.detachBacking(resourceID: resourceID) @@ -976,6 +1026,10 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) + if cursorResourceID == resourceID { + cursorResourceID = nil + onCursorUpdate?(nil) + } resourceUUIDs.removeValue(forKey: resourceID) traceResourceEvent("unref-begin", contextID: request.leUInt32(at: 16), resourceID: resourceID) if resources2D.removeValue(forKey: resourceID) != nil { @@ -1249,11 +1303,57 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi && finalPixel <= resourceSize - UInt64(offset) - finalRow } - private func scanoutFrames( - resourceID: UInt32, - resource: Resource2D, - dirtyRect: VirtioGPURect - ) throws -> [VirtioGPUScanoutFrame] { + private func cursorCommand(_ command: UInt32, request: [UInt8]) -> [UInt8] { + scanoutCommand(request: request) { + guard command == Command.updateCursor || command == Command.moveCursor else { + throw VMError.invalidConfiguration("unsupported virtio-gpu cursor command") + } + try requireLength(request, 56) + let scanoutID = request.leUInt32(at: 24) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu cursor scanout") + } + if command == Command.moveCursor { + return responseHeader(type: Response.okNoData, request: request) + } + + let resourceID = request.leUInt32(at: 40) + if resourceID == 0 { + cursorResourceID = nil + onCursorUpdate?(nil) + return responseHeader(type: Response.okNoData, request: request) + } + guard let resource = resources2D[resourceID], + resource.format == 1, + resource.width > 0, resource.height > 0, + resource.width <= 256, resource.height <= 256 else { + throw VMError.invalidConfiguration( + "virtio-gpu cursor requires a bounded B8G8R8A8 2D resource" + ) + } + let hotX = request.leUInt32(at: 44) + let hotY = request.leUInt32(at: 48) + guard hotX < resource.width, hotY < resource.height else { + throw VMError.invalidConfiguration("virtio-gpu cursor hotspot is outside the image") + } + let pixels = try copiedBytes(for: resource) + cursorResourceID = resourceID + onCursorUpdate?(VirtioGPUCursorUpdate( + scanoutID: scanoutID, + resourceID: resourceID, + x: request.leUInt32(at: 28), + y: request.leUInt32(at: 32), + width: resource.width, + height: resource.height, + hotX: hotX, + hotY: hotY, + bytes: pixels + )) + return responseHeader(type: Response.okNoData, request: request) + } + } + + private func copiedBytes(for resource: Resource2D) throws -> Data { let resourceByteCount = Int(UInt64(resource.width) * UInt64(resource.height) * 4) var source = Data(capacity: resourceByteCount) for entry in resource.backing where source.count < resourceByteCount { @@ -1261,8 +1361,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi source.append(entry.pointer.assumingMemoryBound(to: UInt8.self), count: count) } guard source.count == resourceByteCount else { - throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete") + throw VMError.invalidConfiguration("virtio-gpu 2D backing is incomplete") } + return source + } + + private func scanoutFrames( + resourceID: UInt32, + resource: Resource2D, + dirtyRect: VirtioGPURect + ) throws -> [VirtioGPUScanoutFrame] { + let source = try copiedBytes(for: resource) let sourceStride = Int(resource.width) * 4 var frames = [VirtioGPUScanoutFrame]() diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index d832fa10..280216a7 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -71,6 +71,20 @@ final class DesktopFrameMailbox: @unchecked Sendable { } } +/// Moves copied cursor-plane updates from a vCPU thread to AppKit without retaining the guest +/// resource or touching NSCursor off the main thread. +final class DesktopCursorMailbox: @unchecked Sendable { + nonisolated(unsafe) weak var view: DesktopMetalView? + + func submit(_ update: VirtioGPUCursorUpdate?) { + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.view?.presentCursor(update) + } + } + } +} + @MainActor final class DesktopMetalView: MTKView, MTKViewDelegate { private let commandQueue: MTLCommandQueue @@ -79,6 +93,8 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { private var resourceTextures: [UInt32: MTLTexture] = [:] private var scanoutTexture: MTLTexture? private var scanoutSize = CGSize.zero + private var guestCursor = NSCursor.arrow + private var guestCursorUpdate: VirtioGPUCursorUpdate? private var tracking: NSTrackingArea? private var scrollAccumulator = VirtioInputScrollAccumulator() private var resizeGeneration: UInt64 = 0 @@ -145,6 +161,29 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { resourceTextures.removeValue(forKey: resourceID) } + func presentCursor(_ update: VirtioGPUCursorUpdate?) { + guestCursorUpdate = update + rebuildGuestCursor() + } + + private func rebuildGuestCursor(pixelSize: CGSize? = nil) { + guard let update = guestCursorUpdate else { + guestCursor = Self.transparentCursor + window?.invalidateCursorRects(for: self) + return + } + let effectivePixelSize = pixelSize ?? drawableSize + let cursorScale = bounds.width > 0 + ? max(1, effectivePixelSize.width / bounds.width) + : CGFloat(1) + guestCursor = Self.makeCursor(update, scale: cursorScale) ?? Self.transparentCursor + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: guestCursor) + } + private func presentUpdate(_ frame: VirtioGPUScanoutFrame) -> Bool { guard let pixelFormat = Self.pixelFormat(for: frame.format), let device, @@ -221,6 +260,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { } func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: size) } let width = UInt32(clamping: max(1, Int(size.width.rounded()))) let height = UInt32(clamping: max(1, Int(size.height.rounded()))) resizeGeneration &+= 1 @@ -355,6 +395,59 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { } } + private static let transparentCursor: NSCursor = { + let image = NSImage( + size: NSSize(width: 1, height: 1), + flipped: false, + drawingHandler: { _ in true } + ) + return NSCursor(image: image, hotSpot: .zero) + }() + + private static func makeCursor( + _ update: VirtioGPUCursorUpdate, + scale: CGFloat + ) -> NSCursor? { + guard update.width > 0, update.height > 0, + update.width <= 256, update.height <= 256, + update.hotX < update.width, update.hotY < update.height, + update.bytes.count == Int(update.width * update.height * 4), + let provider = CGDataProvider(data: update.bytes as CFData), + let image = CGImage( + width: Int(update.width), + height: Int(update.height), + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: Int(update.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo.byteOrder32Little.union(CGBitmapInfo( + rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue + )), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) else { + return nil + } + let cursorImage = NSImage( + cgImage: image, + size: NSSize( + width: CGFloat(update.width) / scale, + height: CGFloat(update.height) / scale + ) + ) + // VirtIO cursor hotspots are top-left based; NSCursor image coordinates are bottom-left. + let appKitHotY = update.height - 1 - update.hotY + return NSCursor( + image: cursorImage, + hotSpot: NSPoint( + x: CGFloat(update.hotX) / scale, + y: CGFloat(appKitHotY) / scale + ) + ) + } + private static func modifierFlag(macKeyCode: UInt16) -> NSEvent.ModifierFlags? { switch macKeyCode { case 54, 55: .command diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 943923a5..7f7f69c2 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -134,6 +134,7 @@ enum DesktopMode { self.input = VirtioInput() self.mailbox = DesktopFrameMailbox() + let cursorMailbox = DesktopCursorMailbox() let firstFrame = FirstFrameGate() self.firstFrame = firstFrame self.display = try DesktopMetalView( @@ -141,6 +142,7 @@ enum DesktopMode { input: input ) mailbox.view = display + cursorMailbox.view = display let renderer = resolvedGraphics.renderer let hostVisibleMemory = try renderer.map { _ in @@ -160,6 +162,9 @@ enum DesktopMode { }, onScanoutResourceReleased: { [mailbox] resourceID in mailbox.release(resourceID: resourceID) + }, + onCursorUpdate: { [cursorMailbox] update in + cursorMailbox.submit(update) } ) self.vsock = VirtioVsock(guestCID: 3) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 8b4c5d1e..1fbad06b 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -946,6 +946,172 @@ import Testing #expect(interruptCount == 1) } + @Test func cursorQueuePublishesCopiedShapeAndHotspotAndRejectsInvalidUpdates() throws { + let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) + let cursorBox = CursorUpdateBox() + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + scanoutCount: 1, + scanoutWidth: 2_560, + scanoutHeight: 1_600, + onCursorUpdate: { cursorBox.store($0) } + ) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: gpu, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descTable, + availRing: availRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + + var controlAvailableIndex = UInt16(0) + func submitControl(_ request: [UInt8]) throws -> [UInt8] { + try writeDescriptor( + memory, + index: 0, + addr: requestBuffer, + len: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeDescriptor( + memory, + index: 1, + addr: responseBuffer, + len: 512, + flags: 0x2, + next: 0 + ) + try memory.write(request, at: requestBuffer) + let slot = UInt64(controlAvailableIndex % 8) + try memory.write(UInt16(0), at: availRing + 4 + slot * 2) + controlAvailableIndex &+= 1 + try memory.write(controlAvailableIndex, at: availRing + 2) + gpu.handleKick(queue: 0, transport: transport) + let usedSlot = UInt64((controlAvailableIndex - 1) % 8) + let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8) + return try memory.readBytes(at: responseBuffer, count: Int(responseLength)) + } + + var create = gpuRequest(type: 0x0101, fenceID: 0, contextID: 0, ringIndex: 0) + create.appendLE(UInt32(7)) + create.appendLE(UInt32(1)) // B8G8R8A8_UNORM + create.appendLE(UInt32(2)) + create.appendLE(UInt32(2)) + #expect(leUInt32(try submitControl(create), at: 0) == 0x1100) + + let pixelBuffer = base + 0x6000 + let pixels: [UInt8] = [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ] + try memory.write(pixels, at: pixelBuffer) + var attach = gpuRequest(type: 0x0106, fenceID: 0, contextID: 0, ringIndex: 0) + attach.appendLE(UInt32(7)) + attach.appendLE(UInt32(1)) + attach.appendLE(pixelBuffer) + attach.appendLE(UInt32(pixels.count)) + attach.appendLE(UInt32(0)) + #expect(leUInt32(try submitControl(attach), at: 0) == 0x1100) + + let cursorDescriptorTable = base + 0x7000 + let cursorAvailableRing = base + 0x8000 + let cursorUsedRing = base + 0x9000 + let cursorRequestBuffer = base + 0xA000 + let cursorResponseBuffer = base + 0xB000 + transport.queues[1].configure( + size: 8, + descriptorTable: cursorDescriptorTable, + availRing: cursorAvailableRing, + usedRing: cursorUsedRing + ) + transport.queues[1].setReady(true) + + var cursorAvailableIndex = UInt16(0) + func writeCursorDescriptor( + index: UInt64, + address: UInt64, + length: UInt32, + flags: UInt16, + next: UInt16 + ) throws { + let descriptor = cursorDescriptorTable + index * 16 + try memory.write(address, at: descriptor) + try memory.write(length, at: descriptor + 8) + try memory.write(flags, at: descriptor + 12) + try memory.write(next, at: descriptor + 14) + } + func submitCursor(_ request: [UInt8]) throws -> [UInt8] { + try writeCursorDescriptor( + index: 0, + address: cursorRequestBuffer, + length: UInt32(request.count), + flags: 0x1, + next: 1 + ) + try writeCursorDescriptor( + index: 1, + address: cursorResponseBuffer, + length: 512, + flags: 0x2, + next: 0 + ) + try memory.write(request, at: cursorRequestBuffer) + let slot = UInt64(cursorAvailableIndex % 8) + try memory.write(UInt16(0), at: cursorAvailableRing + 4 + slot * 2) + cursorAvailableIndex &+= 1 + try memory.write(cursorAvailableIndex, at: cursorAvailableRing + 2) + gpu.handleKick(queue: 1, transport: transport) + let usedSlot = UInt64((cursorAvailableIndex - 1) % 8) + let responseLength = try memory.read( + UInt32.self, + at: cursorUsedRing + 8 + usedSlot * 8 + ) + return try memory.readBytes(at: cursorResponseBuffer, count: Int(responseLength)) + } + + func cursorRequest(resourceID: UInt32, hotX: UInt32, hotY: UInt32) -> [UInt8] { + var request = gpuRequest(type: 0x0300, fenceID: 0, contextID: 0, ringIndex: 0) + request.appendLE(UInt32(0)) // scanout_id + request.appendLE(UInt32(111)) + request.appendLE(UInt32(222)) + request.appendLE(UInt32(0)) + request.appendLE(resourceID) + request.appendLE(hotX) + request.appendLE(hotY) + request.appendLE(UInt32(0)) + return request + } + + #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 7, hotX: 1, hotY: 0)), at: 0) == 0x1100) + let update = try #require(cursorBox.values.last ?? nil) + #expect(update.scanoutID == 0) + #expect(update.resourceID == 7) + #expect(update.x == 111) + #expect(update.y == 222) + #expect(update.width == 2) + #expect(update.height == 2) + #expect(update.hotX == 1) + #expect(update.hotY == 0) + #expect(Array(update.bytes) == pixels) + + try memory.write([UInt8](repeating: 0xFF, count: pixels.count), at: pixelBuffer) + #expect(Array(update.bytes) == pixels) // callback owns a stable copy, never guest memory + + let countBeforeInvalid = cursorBox.values.count + #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 7, hotX: 2, hotY: 0)), at: 0) == 0x1202) + #expect(cursorBox.values.count == countBeforeInvalid) + + #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 0, hotX: 0, hotY: 0)), at: 0) == 0x1100) + #expect(cursorBox.values.count == countBeforeInvalid + 1) + #expect(cursorBox.values.last! == nil) + } + @Test func scanoutDisableAcceptsTheEmptyRectangleUsedDuringModesets() throws { let gpu = VirtioGPU( hostMemoryBase: 0x1_0000_0000, @@ -1566,6 +1732,23 @@ private final class ScanoutFrameBox: @unchecked Sendable { } } +private final class CursorUpdateBox: @unchecked Sendable { + private let lock = NSLock() + private var updates = [VirtioGPUCursorUpdate?]() + + func store(_ update: VirtioGPUCursorUpdate?) { + lock.lock() + updates.append(update) + lock.unlock() + } + + var values: [VirtioGPUCursorUpdate?] { + lock.lock() + defer { lock.unlock() } + return updates + } +} + private final class ScanoutResourceBox: @unchecked Sendable { private let lock = NSLock() private var resourceID: UInt32? From 7445d00ec28229be1e4e7b26d6beded2e8ec1777 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:50:36 +0000 Subject: [PATCH 186/338] feat(vm): qualify full-screen display --- .../scripts/test-desktop-linux-live-gate.py | 7 ++ .../Sources/dory-hv/DesktopMode.swift | 30 +++++++ scripts/desktop-linux-live-gate.sh | 90 +++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index c195b169..1672e26f 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -63,6 +63,13 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "guest display mode did not follow the host window resize", "dynamic-display-restored", "dynamic_retina_display=PASS", + 'keystroke "f" using {command down, control down}', + 'attribute "AXFullScreen"', + "fullscreen-display-resized", + "guest display mode did not follow the full-screen host window", + "fullscreen-display-restored", + "guest display mode did not restore after leaving full screen", + "fullscreen_display=PASS", "clipboard-host-to-guest-pass", "/usr/lib/dory/clipboard get 'text/plain;charset=utf-8'", "clipboard-guest-source-ready", diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 7f7f69c2..d1a6b303 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -310,6 +310,7 @@ enum DesktopMode { func run() throws { application.setActivationPolicy(.regular) application.delegate = self + installApplicationMenu() installSignalHandlers() window.makeKeyAndOrderFront(nil) application.activate() @@ -319,6 +320,35 @@ enum DesktopMode { if let stopError { throw stopError } } + private func installApplicationMenu() { + let mainMenu = NSMenu() + + let applicationItem = NSMenuItem(title: "Dory Linux", action: nil, keyEquivalent: "") + let applicationMenu = NSMenu(title: "Dory Linux") + applicationMenu.addItem( + withTitle: "Quit Dory Linux", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + applicationItem.submenu = applicationMenu + mainMenu.addItem(applicationItem) + + let viewItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "") + let viewMenu = NSMenu(title: "View") + let fullScreenItem = NSMenuItem( + title: "Enter Full Screen", + action: #selector(NSWindow.toggleFullScreen(_:)), + keyEquivalent: "f" + ) + fullScreenItem.keyEquivalentModifierMask = [.command, .control] + fullScreenItem.target = window + viewMenu.addItem(fullScreenItem) + viewItem.submenu = viewMenu + mainMenu.addItem(viewItem) + + application.mainMenu = mainMenu + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { window.makeKeyAndOrderFront(nil) return true diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index 2bef677c..94c5c027 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -555,6 +555,95 @@ APPLESCRIPT exit 1 " > "$WORKROOT/evidence/$distro-display-restored.json" + fullscreen_window_size="$(osascript - "$machine_pid" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set frontmost of targetProcess to true + keystroke "f" using {command down, control down} + repeat 80 times + delay 0.25 + set targetWindow to front window of targetProcess + if value of attribute "AXFullScreen" of targetWindow is true then + set fullSize to size of targetWindow + return ((item 1 of fullSize) as text) & "x" & ((item 2 of fullSize) as text) + end if + end repeat + error "Dory display did not enter full screen" + end tell +end run +APPLESCRIPT +)" + printf '%s\n' "$fullscreen_window_size" | grep -Eq '^[0-9]+x[0-9]+$' \ + || { echo "desktop live gate: invalid full-screen window size: $fullscreen_window_size" >&2; exit 1; } + printf '%s\n' "$fullscreen_window_size" \ + > "$WORKROOT/evidence/$distro-display-fullscreen-window.txt" + + assert_exec_token "$machine" fullscreen-display-resized sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + baseline=\$(cat /var/lib/dory/release-display-baseline) + for _ in \$(seq 1 30); do + current=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xrandr --current | awk '\$2 ~ /\\*/ { print \$1; exit }') + if printf '%s\n' \"\$current\" | grep -Eq '^[0-9]+x[0-9]+$' \ + && test \"\$current\" != \"\$baseline\"; then + printf '%s\n' \"\$current\" > /var/lib/dory/release-display-fullscreen + echo fullscreen-display-resized + exit 0 + fi + sleep 1 + done + echo 'guest display mode did not follow the full-screen host window' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-display-fullscreen.json" + + osascript - "$machine_pid" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set frontmost of targetProcess to true + keystroke "f" using {command down, control down} + repeat 80 times + delay 0.25 + if value of attribute "AXFullScreen" of front window of targetProcess is false then return + end repeat + error "Dory display did not leave full screen" + end tell +end run +APPLESCRIPT + + assert_exec_token "$machine" fullscreen-display-restored sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + baseline=\$(cat /var/lib/dory/release-display-baseline) + for _ in \$(seq 1 30); do + current=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xrandr --current | awk '\$2 ~ /\\*/ { print \$1; exit }') + if test \"\$current\" = \"\$baseline\"; then + echo fullscreen-display-restored + exit 0 + fi + sleep 1 + done + echo 'guest display mode did not restore after leaving full screen' >&2 + exit 1 + " > "$WORKROOT/evidence/$distro-display-fullscreen-restored.json" + host_to_guest_clipboard="dory-host-to-guest-$distro-$(uuidgen | tr '[:upper:]' '[:lower:]')" osascript <<'APPLESCRIPT' tell application "Finder" to activate @@ -1051,6 +1140,7 @@ fi printf 'snapshot_restore_exact_bytes=PASS\n' printf 'graceful_shutdown=PASS\n' printf 'dynamic_retina_display=PASS\n' + printf 'fullscreen_display=PASS\n' printf 'clipboard_bidirectional=PASS\n' printf 'keyboard_pointer_input=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then From 77e8b39a2f7f4998e7be4c1ceaebdb5a5b16f085 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:52:16 +0000 Subject: [PATCH 187/338] test(vm): qualify guest cursor shape --- .../scripts/test-desktop-linux-live-gate.py | 9 ++ scripts/desktop-linux-live-gate.sh | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 1672e26f..15219f0d 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -70,6 +70,15 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "fullscreen-display-restored", "guest display mode did not restore after leaving full screen", "fullscreen_display=PASS", + "cursor-left-ready", + "xsetroot -cursor_name left_ptr", + "cursor-crosshair-ready", + "xsetroot -cursor_name crosshair", + 'screencapture -C -x -R"$cursor_region"', + "guest cursor shape did not change the captured macOS cursor", + "cursor-shapes.sha256", + "cursor-restored", + "cursor_shape=PASS", "clipboard-host-to-guest-pass", "/usr/lib/dory/clipboard get 'text/plain;charset=utf-8'", "clipboard-guest-source-ready", diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index 94c5c027..4dcbd53c 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -644,6 +644,101 @@ APPLESCRIPT exit 1 " > "$WORKROOT/evidence/$distro-display-fullscreen-restored.json" + assert_exec_token "$machine" cursor-left-ready sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xsetroot -cursor_name left_ptr + echo cursor-left-ready + " > "$WORKROOT/evidence/$distro-cursor-left.json" + + cursor_point="$(osascript - "$machine_pid" <<'APPLESCRIPT' +on run argv + set targetPID to (item 1 of argv) as integer + tell application "System Events" + set targetProcess to first process whose unix id is targetPID + set frontmost of targetProcess to true + set targetWindow to front window of targetProcess + set windowPosition to position of targetWindow + set windowSize to size of targetWindow + set cursorX to (item 1 of windowPosition) + ((item 1 of windowSize) div 2) + set cursorY to (item 2 of windowPosition) + ((item 2 of windowSize) div 2) + click at {cursorX, cursorY} + delay 0.5 + return (cursorX as text) & "," & (cursorY as text) + end tell +end run +APPLESCRIPT +)" + printf '%s\n' "$cursor_point" | grep -Eq '^[0-9]+,[0-9]+$' \ + || { echo "desktop live gate: invalid cursor point: $cursor_point" >&2; exit 1; } + cursor_x="${cursor_point%,*}" + cursor_y="${cursor_point#*,}" + cursor_left=$((cursor_x > 48 ? cursor_x - 48 : 0)) + cursor_top=$((cursor_y > 48 ? cursor_y - 48 : 0)) + cursor_region="$cursor_left,$cursor_top,96,96" + cursor_left_capture="$WORKROOT/evidence/$distro-cursor-left.png" + cursor_crosshair_capture="$WORKROOT/evidence/$distro-cursor-crosshair.png" + screencapture -C -x -R"$cursor_region" "$cursor_left_capture" + [ -s "$cursor_left_capture" ] \ + || { echo "desktop live gate: screen recording permission is required for cursor qualification" >&2; exit 1; } + + assert_exec_token "$machine" cursor-crosshair-ready sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xsetroot -cursor_name crosshair + echo cursor-crosshair-ready + " > "$WORKROOT/evidence/$distro-cursor-crosshair.json" + osascript - "$cursor_x" "$cursor_y" <<'APPLESCRIPT' +on run argv + set cursorX to (item 1 of argv) as integer + set cursorY to (item 2 of argv) as integer + tell application "System Events" + click at {cursorX + 1, cursorY} + click at {cursorX, cursorY} + delay 0.5 + end tell +end run +APPLESCRIPT + screencapture -C -x -R"$cursor_region" "$cursor_crosshair_capture" + [ -s "$cursor_crosshair_capture" ] \ + || { echo "desktop live gate: crosshair cursor capture is empty" >&2; exit 1; } + if cmp -s "$cursor_left_capture" "$cursor_crosshair_capture"; then + echo "desktop live gate: guest cursor shape did not change the captured macOS cursor" >&2 + exit 1 + fi + { + shasum -a 256 "$cursor_left_capture" + shasum -a 256 "$cursor_crosshair_capture" + } > "$WORKROOT/evidence/$distro-cursor-shapes.sha256" + + assert_exec_token "$machine" cursor-restored sh -lc " + set -eu + uid=\$(id -u dorygate) + runtime=/run/user/\$uid + session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \ + DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \ + | grep -E '^(DISPLAY|XAUTHORITY)=') + display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p') + xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p') + runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \ + xsetroot -cursor_name left_ptr + echo cursor-restored + " > "$WORKROOT/evidence/$distro-cursor-restored.json" + host_to_guest_clipboard="dory-host-to-guest-$distro-$(uuidgen | tr '[:upper:]' '[:lower:]')" osascript <<'APPLESCRIPT' tell application "Finder" to activate @@ -1141,6 +1236,7 @@ fi printf 'graceful_shutdown=PASS\n' printf 'dynamic_retina_display=PASS\n' printf 'fullscreen_display=PASS\n' + printf 'cursor_shape=PASS\n' printf 'clipboard_bidirectional=PASS\n' printf 'keyboard_pointer_input=PASS\n' if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then From 4d204983c83e5428272303cf4e8a0d8f14f461dc Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 08:55:06 +0000 Subject: [PATCH 188/338] fix(vm): release input on focus loss --- .../Sources/DoryHV/VirtioInput.swift | 26 +++++++++++++++++++ .../Sources/dory-hv/DesktopMetalDisplay.swift | 20 +++++++++++--- .../Sources/dory-hv/DesktopMode.swift | 8 ++++++ .../Tests/DoryHVTests/DeviceLogicTests.swift | 16 ++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift index 4e51408c..dbbb37cd 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift @@ -65,6 +65,32 @@ public struct VirtioInputScrollAccumulator: Sendable { } } +/// Tracks key and pointer-button state at the host display boundary. AppKit can deactivate a +/// window without delivering the matching keyUp/mouseUp events, so the frontend drains this state +/// as one atomic release frame whenever its window or application loses focus. +public struct VirtioInputPressedState: Sendable { + private var pressedCodes = Set() + + public init() {} + + public mutating func record(_ event: VirtioInputEvent) { + guard event.type == 1 else { return } + if event.value == 0 { + pressedCodes.remove(event.code) + } else if event.value > 0 { + pressedCodes.insert(event.code) + } + } + + public mutating func releaseFrame() -> [VirtioInputEvent] { + let releases = pressedCodes.sorted().map { + VirtioInputEvent(type: 1, code: $0, value: 0) + } + pressedCodes.removeAll(keepingCapacity: true) + return releases + } +} + /// A combined virtio keyboard, absolute pointer, buttons, and high-resolution wheel device. /// Host input is submitted as whole evdev frames ending in SYN_REPORT; a frame waits until the /// guest has posted enough receive buffers, so Dory never delivers half of a pointer update. diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index 280216a7..823b0716 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -97,6 +97,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { private var guestCursorUpdate: VirtioGPUCursorUpdate? private var tracking: NSTrackingArea? private var scrollAccumulator = VirtioInputScrollAccumulator() + private var pressedInput = VirtioInputPressedState() private var resizeGeneration: UInt64 = 0 var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? var onMacShortcut: ((NSEvent) -> Bool)? @@ -288,7 +289,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.keyDown(with: event) return } - input.send(frame: [VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1)]) + sendTracked([VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1)]) } override func keyUp(with event: NSEvent) { @@ -296,7 +297,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.keyUp(with: event) return } - input.send(frame: [VirtioInputEvent(type: 1, code: code, value: 0)]) + sendTracked([VirtioInputEvent(type: 1, code: code, value: 0)]) } override func flagsChanged(with event: NSEvent) { @@ -305,7 +306,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.flagsChanged(with: event) return } - input.send(frame: [ + sendTracked([ VirtioInputEvent(type: 1, code: code, value: event.modifierFlags.contains(flag) ? 1 : 0) ]) } @@ -339,7 +340,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { verticalDelta: event.scrollingDeltaY, hasPreciseDeltas: event.hasPreciseScrollingDeltas ) - if !events.isEmpty { input.send(frame: events) } + if !events.isEmpty { sendTracked(events) } } private func sendPointer( @@ -359,6 +360,17 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { if let button { events.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) } + sendTracked(events) + } + + func releasePressedInput() { + let releases = pressedInput.releaseFrame() + scrollAccumulator = VirtioInputScrollAccumulator() + if !releases.isEmpty { input.send(frame: releases) } + } + + private func sendTracked(_ events: [VirtioInputEvent]) { + for event in events { pressedInput.record(event) } input.send(frame: events) } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index d1a6b303..3d7abf5a 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -364,6 +364,14 @@ enum DesktopMode { return false } + func windowDidResignKey(_ notification: Notification) { + display.releasePressedInput() + } + + func applicationDidResignActive(_ notification: Notification) { + display.releasePressedInput() + } + private func startMachine() { let machine = self.machine DispatchQueue.global(qos: .userInteractive).async { [weak self] in diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 1fbad06b..ae0ef6ea 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -2281,6 +2281,22 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { ]) } + @Test func focusLossReleasesEveryPressedKeyAndButtonExactlyOnce() { + var state = VirtioInputPressedState() + state.record(VirtioInputEvent(type: 1, code: 42, value: 1)) + state.record(VirtioInputEvent(type: 1, code: 30, value: 1)) + state.record(VirtioInputEvent(type: 1, code: 30, value: 2)) + state.record(VirtioInputEvent(type: 1, code: 272, value: 1)) + state.record(VirtioInputEvent(type: 3, code: 0, value: 16_000)) + state.record(VirtioInputEvent(type: 1, code: 42, value: 0)) + + #expect(state.releaseFrame() == [ + VirtioInputEvent(type: 1, code: 30, value: 0), + VirtioInputEvent(type: 1, code: 272, value: 0), + ]) + #expect(state.releaseFrame().isEmpty) + } + @Test func waitsForEnoughGuestBuffersBeforePublishingWholeInputFrame() throws { let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) let input = VirtioInput() From 07e85e2d3a5c9aded0be7e4ff9d1022ef84224eb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:14:39 +0000 Subject: [PATCH 189/338] feat(vm): bind resolved display geometry --- .../Sources/dory-hv/DesktopMode.swift | 32 +++++++++++++-- .../DoryHVTests/DesktopDisplayPlanTests.swift | 40 +++++++++++++++++++ .../DoryVirtualMachineCapabilities.swift | 22 ++++++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 17 ++++++-- .../DoryVMMDesktopApplication.swift | 6 ++- ...yDaemonVirtualMachineRuntimePlanning.swift | 6 +++ .../LinuxMachineBackendAdapters.swift | 10 +++++ .../DoryVirtualMachineCapabilitiesTests.swift | 1 + ...ePlanningTransactionCoordinatorTests.swift | 2 +- ...onVirtualMachineRuntimePlanningTests.swift | 9 ++++- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 10 +++++ .../DorydKitTests/MachineBackendTests.swift | 23 +++++++++++ ...eManagerResolvedPlanIntegrationTests.swift | 13 ++++-- 13 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 3d7abf5a..e3b7c50f 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -50,6 +50,29 @@ enum DesktopMode { var attachesNetworkDevice: Bool { true } } + struct DisplayPlan: Equatable { + var widthPixels: UInt32 + var heightPixels: UInt32 + + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { + let display = resolvedDevices?.display ?? DoryVMMDisplayDefaults.capability + guard display.isValid else { + throw VMError.bootFailure( + "resolved display geometry is outside the supported pixel bounds" + ) + } + widthPixels = display.widthPixels + heightPixels = display.heightPixels + } + + var windowSize: NSSize { + NSSize( + width: max(1, CGFloat(widthPixels) / 2), + height: max(1, CGFloat(heightPixels) / 2) + ) + } + } + private struct ResolvedGraphics { var backend: DoryDesktopGraphicsBackend var renderer: VirglRenderer? @@ -101,6 +124,7 @@ enum DesktopMode { ) self.graphicsBackend = resolvedGraphics.backend let networkPlan = try NetworkPlan(resolvedDevices: configuration.resolvedDevices) + let displayPlan = try DisplayPlan(resolvedDevices: configuration.resolvedDevices) if let devices = configuration.resolvedDevices { guard devices.keyboard == devices.pointer else { throw VMError.bootFailure( @@ -138,7 +162,7 @@ enum DesktopMode { let firstFrame = FirstFrameGate() self.firstFrame = firstFrame self.display = try DesktopMetalView( - frame: NSRect(x: 0, y: 0, width: 1_280, height: 800), + frame: NSRect(origin: .zero, size: displayPlan.windowSize), input: input ) mailbox.view = display @@ -151,8 +175,8 @@ enum DesktopMode { let gpu = VirtioGPU( hostMemoryBase: GuestLayout.daxWindowBase, scanoutCount: 1, - scanoutWidth: 2_560, - scanoutHeight: 1_600, + scanoutWidth: displayPlan.widthPixels, + scanoutHeight: displayPlan.heightPixels, renderer: renderer, hostVisibleMemory: hostVisibleMemory, traceResourceLifecycle: configuration.environment["DORY_GPU_TRACE_RESOURCES"] == "1", @@ -288,7 +312,7 @@ enum DesktopMode { } self.window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 800), + contentRect: NSRect(origin: .zero, size: displayPlan.windowSize), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift new file mode 100644 index 00000000..4ef3a255 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift @@ -0,0 +1,40 @@ +import DoryHV +import DoryOperations +import Testing +@testable import dory_hv + +@Suite struct DesktopDisplayPlanTests { + @Test func legacyLaunchRetainsTheEstablishedRetinaGeometry() throws { + let plan = try DesktopMode.DisplayPlan(resolvedDevices: nil) + + #expect(plan.widthPixels == 2_560) + #expect(plan.heightPixels == 1_600) + #expect(plan.windowSize.width == 1_280) + #expect(plan.windowSize.height == 800) + } + + @Test func resolvedLaunchUsesTheExactInitialPixelGeometry() throws { + let plan = try DesktopMode.DisplayPlan(resolvedDevices: .init( + display: .init(widthPixels: 1_920, heightPixels: 1_080) + )) + + #expect(plan.widthPixels == 1_920) + #expect(plan.heightPixels == 1_080) + #expect(plan.windowSize.width == 960) + #expect(plan.windowSize.height == 540) + } + + @Test func invalidResolvedGeometryFailsClosed() { + for display in [ + DoryVirtualMachineDisplayCapabilityRequest(widthPixels: 0, heightPixels: 1_080), + DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: DoryVirtualMachineDisplayCapabilityRequest.maximumDimensionPixels + 1, + heightPixels: 1_080 + ), + ] { + #expect(throws: VMError.self) { + _ = try DesktopMode.DisplayPlan(resolvedDevices: .init(display: display)) + } + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index b1241a67..485b2990 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -107,8 +107,28 @@ public enum DoryVirtualMachineNetworkAttachmentMode: String, Codable, Sendable, case isolated } +public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equatable, Hashable { + public static let maximumDimensionPixels: UInt32 = 16_384 + + public var widthPixels: UInt32 + public var heightPixels: UInt32 + + public init(widthPixels: UInt32, heightPixels: UInt32) { + self.widthPixels = widthPixels + self.heightPixels = heightPixels + } + + public var isValid: Bool { + (1...Self.maximumDimensionPixels).contains(widthPixels) + && (1...Self.maximumDimensionPixels).contains(heightPixels) + } +} + public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equatable, Hashable { public var networkAttachment: DoryVirtualMachineNetworkAttachmentMode + /// Exact initial display geometry. `nil` is retained only for historical capability records + /// that predate display binding; newly planned desktop workspaces always carry this value. + public var display: DoryVirtualMachineDisplayCapabilityRequest? public var audioInput: Bool public var audioOutput: Bool public var keyboard: Bool @@ -121,6 +141,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa public init( networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, + display: DoryVirtualMachineDisplayCapabilityRequest? = nil, audioInput: Bool = false, audioOutput: Bool = false, keyboard: Bool = false, @@ -132,6 +153,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa gracefulShutdown: Bool = false ) { self.networkAttachment = networkAttachment + self.display = display self.audioInput = audioInput self.audioOutput = audioOutput self.keyboard = keyboard diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 2d919751..0bc42ced 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -14,6 +14,11 @@ public enum DoryVMMDisplayDefaults { /// A 2x backing surface for the default 1280x800-point desktop window. public static let widthInPixels = 2_560 public static let heightInPixels = 1_600 + + public static let capability = DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: UInt32(widthInPixels), + heightPixels: UInt32(heightInPixels) + ) } public struct DoryVMMArguments: Sendable, Equatable { @@ -391,7 +396,7 @@ public enum DoryVZConfigurationBuilder { ) } if spec.displayMode != .desktop, - devices.audioInput || devices.audioOutput || devices.keyboard + devices.display != nil || devices.audioInput || devices.audioOutput || devices.keyboard || devices.pointer || devices.clipboard { throw DoryVZMachineError.validation( "resolved desktop devices cannot be attached to a headless VZ launch" @@ -449,10 +454,16 @@ public enum DoryVZConfigurationBuilder { if spec.displayMode == .desktop { let devices = spec.resolvedDevices + let display = devices?.display ?? DoryVMMDisplayDefaults.capability + guard display.isValid else { + throw DoryVZMachineError.validation( + "resolved display geometry is outside the supported pixel bounds" + ) + } let graphics = VZVirtioGraphicsDeviceConfiguration() graphics.scanouts = [VZVirtioGraphicsScanoutConfiguration( - widthInPixels: DoryVMMDisplayDefaults.widthInPixels, - heightInPixels: DoryVMMDisplayDefaults.heightInPixels + widthInPixels: Int(display.widthPixels), + heightInPixels: Int(display.heightPixels) )] configuration.graphicsDevices = [graphics] if devices?.keyboard != false { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index 9e7291f3..4e4cc54d 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -25,7 +25,11 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow self.runtime = runtime dynamicDisplayEnabled = resolvedDevices?.dynamicDisplay ?? true - let windowSize = NSSize(width: 1_280, height: 800) + let display = resolvedDevices?.display ?? DoryVMMDisplayDefaults.capability + let windowSize = NSSize( + width: max(1, CGFloat(display.widthPixels) / 2), + height: max(1, CGFloat(display.heightPixels) / 2) + ) let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize)) machineView.virtualMachine = runtime.machine.virtualMachineForDisplay // Apple's automatic path currently requests the view's point size on Retina displays. diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 9adac5fb..42525cbf 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -522,6 +522,12 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda } return DoryVirtualMachineDeviceCapabilityRequest( networkAttachment: networkAttachment, + display: definition.display.enabled + ? DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: definition.display.widthPixels, + heightPixels: definition.display.heightPixels + ) + : nil, audioInput: definition.audio.inputEnabled, audioOutput: definition.audio.outputEnabled, keyboard: definition.input.keyboardEnabled, diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index b0bdd18e..155ad3ea 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -403,6 +403,9 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable guard machine.displayMode == .desktop else { return "The current raw-HV machine path is implemented only for desktop Linux." } + if let display = capability.request.devices.display, !display.isValid { + return "The raw-HV display geometry is outside the supported pixel bounds." + } guard machine.installerISOPath == nil else { return "The raw-HV machine path cannot boot attached installer media." } @@ -469,6 +472,13 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ executableIsAvailable: executableIsAvailable, operations: operations, validateMachine: { machine, capability in + if machine.displayMode == .headless, + capability.request.devices.display != nil { + return "A headless VZ machine cannot attach a resolved display." + } + if let display = capability.request.devices.display, !display.isValid { + return "The VZ display geometry is outside the supported pixel bounds." + } switch capability.request.bootMedia.kind { case .linuxKernel: guard machine.bootMode == .linuxKernel, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index f255cf2b..27f4f711 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -898,6 +898,7 @@ struct VirtualMachineCapabilitiesTests { DoryVirtualMachineCapabilityDescriptor.self, from: data ) + #expect(descriptor.request.devices.display == nil) #expect(descriptor.schemaVersion == 1) #expect(descriptor.request.devices == .minimumBootable) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index f099f758..9ca8ad98 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -487,7 +487,7 @@ private final class TransactionFixture: @unchecked Sendable { source: .bundledByDory, artifactSHA256: transactionDigest("a") ) - let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) let runtimeEvidence = DoryVirtualMachineRuntimeQualificationEvidence( qualificationIdentity: "runtime-qualification-1", qualificationReportSHA256: transactionDigest("b"), diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 06373b8d..594eb48a 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -14,10 +14,17 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) #expect(devices.networkAttachment == .disconnected) + #expect(devices.display == DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: definition.display.widthPixels, + heightPixels: definition.display.heightPixels + )) #expect(devices.audioInput == definition.audio.inputEnabled) #expect(devices.audioOutput == definition.audio.outputEnabled) #expect(devices.keyboard == definition.input.keyboardEnabled) #expect(devices.pointer == definition.input.pointerEnabled) + + definition.display = .disabled + #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition).display == nil) } @Test("planning constructs persists and exposes an exact plan digest") @@ -288,7 +295,7 @@ private final class Fixture { source: .bundledByDory, artifactSHA256: digest("a") ) - let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) plannerRequest = DoryVirtualMachineBackendPlanRequest( guest: definition.guest, bootMedia: media, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 093a29a2..23f3aa02 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -661,6 +661,10 @@ final class DoryVMMKitTests: XCTestCase { FileManager.default.createFile(atPath: rootfs, contents: nil) XCTAssertEqual(truncate(rootfs, 1024 * 1024), 0) let devices = DoryVirtualMachineDeviceCapabilityRequest( + display: DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: 1_920, + heightPixels: 1_080 + ), audioInput: false, audioOutput: true, keyboard: true, @@ -687,6 +691,12 @@ final class DoryVMMKitTests: XCTestCase { ) XCTAssertEqual(configuration.graphicsDevices.count, 1) + let graphics = try XCTUnwrap( + configuration.graphicsDevices.first as? VZVirtioGraphicsDeviceConfiguration + ) + let scanout = try XCTUnwrap(graphics.scanouts.first) + XCTAssertEqual(scanout.widthInPixels, 1_920) + XCTAssertEqual(scanout.heightInPixels, 1_080) XCTAssertEqual(configuration.keyboards.count, 1) XCTAssertTrue(configuration.pointingDevices.isEmpty) XCTAssertEqual(configuration.audioDevices.count, 1) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 0b07a36a..482bf039 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -235,6 +235,29 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible) } + func testRawAdapterRejectsInvalidResolvedDisplayGeometry() { + let backend = availableRawBackend(operations: recordingOperations().operations) + var descriptor = capabilityDescriptor( + backend: .doryHypervisor, + media: .linuxKernel + ) + descriptor.request.devices.display = DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: 0, + heightPixels: 1_080 + ) + descriptor.resolvedDevices = descriptor.request.devices + let result = backend.plan(MachineBackendPlanRequest( + machine: rawMachine(), + capabilityPlan: DoryVirtualMachineBackendPlanResult( + selectedDescriptor: descriptor, + evaluatedDescriptors: [descriptor], + failure: nil + ) + )) + + XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible) + } + func testVZInstallerPlanRequiresAttachedInstallerMedia() { let backend = availableVZBackend(operations: recordingOperations().operations) var machine = vzInstallerMachine() diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 475c8d7a..3986b42f 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -156,10 +156,17 @@ struct MachineManagerResolvedPlanIntegrationTests { let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) let registry = try rawRegistry(operations: operations, executablePath: helper) let helperSHA256 = try fileSHA256(path: helper) + let devices = DoryVirtualMachineDeviceCapabilityRequest( + display: DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: 1_920, + heightPixels: 1_080 + ) + ) let resolver = ClosureLaunchResolver { request in let resolution = try exactResolution( request: request, - componentSHA256: helperSHA256 + componentSHA256: helperSHA256, + devices: devices ) plans.set(resolution.resolvedPlan) return resolution @@ -188,7 +195,7 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(try JSONDecoder().decode( DoryVirtualMachineDeviceCapabilityRequest.self, from: deviceData - ) == .minimumBootable) + ) == devices) #expect(arguments.contains("DORY_DESKTOP_GRAPHICS=virgl")) #expect(arguments.contains("DORY_DESKTOP_VMM=accelerated")) #expect(!arguments.contains("DORY_DESKTOP_GRAPHICS=auto")) @@ -1458,12 +1465,12 @@ struct MachineManagerResolvedPlanIntegrationTests { private func exactResolution( request: DoryDaemonVirtualMachineLaunchPlanRequest, componentSHA256: String? = nil, + devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable, preSpawnRevalidation: @escaping @Sendable () throws -> Void = {} ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution { let definitionDigest = SHA256.hash(data: request.canonicalDefinitionData) .map { String(format: "%02x", $0) }.joined() let artifact = digest("a") - let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable let media = DoryBootMedia( kind: request.definition.boot.devices[0].kind, source: .userProvided, From ece44bc924b64668a59d4a41ff32d7d9ef11de1f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:18:54 +0000 Subject: [PATCH 190/338] fix(vm): recover host audio device changes --- .../Sources/dory-hv/DesktopAudioBackend.swift | 138 ++++++++++++++++-- .../DesktopAudioRecoveryTests.swift | 69 +++++++++ 2 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift index 88189162..4af078f6 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -3,6 +3,28 @@ import DoryHV import Foundation +enum DoryMacAudioConfigurationRecoveryAction: Equatable { + case none + case restartOutput + case rebuildInput +} + +struct DoryMacAudioConfigurationRecoveryState: Equatable { + var outputConfigured: Bool + var outputRunning: Bool + var inputConfigured: Bool + var inputRunning: Bool + + func action(for direction: VirtioSoundDirection) -> DoryMacAudioConfigurationRecoveryAction { + switch direction { + case .output: + outputConfigured && outputRunning ? .restartOutput : .none + case .input: + inputConfigured && inputRunning ? .rebuildInput : .none + } + } +} + /// Bridges the raw Hypervisor.framework virtio-snd device to Core Audio. Guest PCM remains the /// standard signed 16-bit interleaved format while AVAudioEngine performs host sample-rate and /// device conversion. @@ -19,6 +41,11 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { var completion: @Sendable (Data?, UInt32) -> Void } + private struct PlaybackRequest { + var byteCount: Int + var completion: @Sendable (Bool, UInt32) -> Void + } + private final class ConverterInput: @unchecked Sendable { let buffer: AVAudioPCMBuffer var served = false @@ -32,10 +59,12 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { // Keep playback and capture on independent graphs. PipeWire may prepare and start them in // either order; mutating a shared running graph to add the other direction can leave an // AVAudioPlayerNode permanently scheduled but never rendered. - private let outputEngine = AVAudioEngine() - private let inputEngine = AVAudioEngine() + let outputEngine = AVAudioEngine() + let inputEngine = AVAudioEngine() private let player = AVAudioPlayerNode() private let log: @Sendable (String) -> Void + private let notificationCenter: NotificationCenter + private var configurationObservers = [NSObjectProtocol]() private var outputParameters: VirtioSoundPCMParameters? private var inputParameters: VirtioSoundPCMParameters? @@ -50,12 +79,43 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private var captureFallbackLogged = false private var nextCaptureFallbackUptime: TimeInterval = 0 private var queuedPlaybackBytes = 0 + private var playbackRequests = [UInt64: PlaybackRequest]() + private var nextPlaybackRequestID: UInt64 = 1 + private var observedConfigurationChanges = 0 - init(log: @escaping @Sendable (String) -> Void) { + init( + log: @escaping @Sendable (String) -> Void, + notificationCenter: NotificationCenter = .default + ) { self.log = log + self.notificationCenter = notificationCenter outputEngine.attach(player) + configurationObservers = [ + notificationCenter.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: outputEngine, + queue: nil + ) { [weak self] _ in + self?.scheduleConfigurationRecovery(for: .output) + }, + notificationCenter.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: inputEngine, + queue: nil + ) { [weak self] _ in + self?.scheduleConfigurationRecovery(for: .input) + }, + ] } + deinit { + for observer in configurationObservers { + notificationCenter.removeObserver(observer) + } + } + + var configurationChangeCount: Int { queue.sync { observedConfigurationChanges } } + func configure( streamID: Int, direction: VirtioSoundDirection, @@ -66,7 +126,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { switch direction { case .output: player.stop() - queuedPlaybackBytes = 0 + failPlaybackRequests() outputEngine.disconnectNodeOutput(player) guard let format = Self.floatFormat(parameters) else { return false } outputEngine.connect(player, to: outputEngine.mainMixerNode, format: format) @@ -150,7 +210,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { player.stop() outputEngine.stop() outputRunning = false - queuedPlaybackBytes = 0 + failPlaybackRequests() outputParameters = nil case .input: inputRunning = false @@ -174,6 +234,13 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { let buffer = Self.playbackBuffer(data: data, parameters: parameters) else { return false } + if outputRunning, !startOutputEngine() { return false } + let requestID = nextPlaybackRequestID + nextPlaybackRequestID &+= 1 + playbackRequests[requestID] = PlaybackRequest( + byteCount: data.count, + completion: completion + ) queuedPlaybackBytes += data.count // The virtio descriptor protects the guest-owned period, not the audible speaker // timeline. We have already copied that period into an AVAudioPCMBuffer, so complete @@ -184,9 +251,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { [weak self] _ in guard let self else { return } self.queue.async { - self.queuedPlaybackBytes = max(0, self.queuedPlaybackBytes - data.count) - let latency = UInt32(clamping: self.queuedPlaybackBytes) - Self.deliver { completion(true, latency) } + self.completePlayback(requestID: requestID, success: true) } } // AVAudioPlayerNode stops after an empty queue. Linux commonly starts the PCM stream @@ -231,7 +296,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { inputParameters = nil outputRunning = false inputRunning = false - queuedPlaybackBytes = 0 + failPlaybackRequests() captureBytes.removeAll(keepingCapacity: false) nextCaptureFallbackUptime = 0 failCaptureRequests() @@ -267,6 +332,46 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { } } + private func scheduleConfigurationRecovery(for direction: VirtioSoundDirection) { + queue.async { [weak self] in + guard let self else { return } + observedConfigurationChanges += 1 + let state = DoryMacAudioConfigurationRecoveryState( + outputConfigured: outputParameters != nil, + outputRunning: outputRunning, + inputConfigured: inputParameters != nil, + inputRunning: inputRunning + ) + switch state.action(for: direction) { + case .none: + return + case .restartOutput: + player.stop() + outputEngine.stop() + failPlaybackRequests() + if startOutputEngine() { + log("Mac audio output recovered after the host device configuration changed") + } else { + log("Mac audio output is waiting for a usable host device after configuration changed") + } + case .rebuildInput: + removeInputTap() + inputEngine.stop() + captureBytes.removeAll(keepingCapacity: true) + captureTapCount = 0 + captureFallbackLogged = false + nextCaptureFallbackUptime = 0 + if startInputWhenAuthorized() { + log("Mac audio input recovered after the host device configuration changed") + } else { + log("Mac audio input is unavailable after the host device configuration changed") + } + satisfyCaptureRequests() + armCaptureFallbacks() + } + } + } + private func installInputTapAndStartEngine() -> Bool { guard inputRunning, let parameters = inputParameters else { return false } if !inputTapInstalled { @@ -403,6 +508,21 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { for request in requests { Self.deliver { request.completion(nil, 0) } } } + private func completePlayback(requestID: UInt64, success: Bool) { + guard let request = playbackRequests.removeValue(forKey: requestID) else { return } + queuedPlaybackBytes = max(0, queuedPlaybackBytes - request.byteCount) + let latency = UInt32(clamping: queuedPlaybackBytes) + Self.deliver { request.completion(success, latency) } + } + + private func failPlaybackRequests() { + let requestIDs = playbackRequests.keys.sorted() + for requestID in requestIDs { + completePlayback(requestID: requestID, success: false) + } + queuedPlaybackBytes = 0 + } + private static func valid(_ parameters: VirtioSoundPCMParameters) -> Bool { parameters.bytesPerSample == 2 && (parameters.channels == 1 || parameters.channels == 2) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift new file mode 100644 index 00000000..5e31abd3 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift @@ -0,0 +1,69 @@ +@preconcurrency import AVFAudio +import DoryHV +import Foundation +import Testing +@testable import dory_hv + +@Suite("Desktop audio configuration recovery") +struct DesktopAudioRecoveryTests { + @Test("only configured running streams recover") + func recoveryPolicyRequiresConfiguredRunningStream() { + let stopped = DoryMacAudioConfigurationRecoveryState( + outputConfigured: true, + outputRunning: false, + inputConfigured: true, + inputRunning: false + ) + #expect(stopped.action(for: .output) == .none) + #expect(stopped.action(for: .input) == .none) + + let output = DoryMacAudioConfigurationRecoveryState( + outputConfigured: true, + outputRunning: true, + inputConfigured: false, + inputRunning: false + ) + #expect(output.action(for: .output) == .restartOutput) + #expect(output.action(for: .input) == .none) + + let input = DoryMacAudioConfigurationRecoveryState( + outputConfigured: false, + outputRunning: false, + inputConfigured: true, + inputRunning: true + ) + #expect(input.action(for: .output) == .none) + #expect(input.action(for: .input) == .rebuildInput) + } + + @Test("configuration notifications are scoped to the owned audio engines") + func observesOnlyOwnedAudioEngines() { + let notificationCenter = NotificationCenter() + let backend = DoryMacAudioBackend( + log: { _ in }, + notificationCenter: notificationCenter + ) + + notificationCenter.post( + name: .AVAudioEngineConfigurationChange, + object: AVAudioEngine() + ) + Thread.sleep(forTimeInterval: 0.02) + #expect(backend.configurationChangeCount == 0) + + notificationCenter.post( + name: .AVAudioEngineConfigurationChange, + object: backend.outputEngine + ) + notificationCenter.post( + name: .AVAudioEngineConfigurationChange, + object: backend.inputEngine + ) + + let deadline = Date().addingTimeInterval(1) + while backend.configurationChangeCount < 2, Date() < deadline { + Thread.sleep(forTimeInterval: 0.005) + } + #expect(backend.configurationChangeCount == 2) + } +} From 648a99e7bb3b88ba6bd97b823d5a1b02986f3224 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:22:35 +0000 Subject: [PATCH 191/338] fix(vm): bound host audio queues --- .../Sources/dory-hv/DesktopAudioBackend.swift | 93 +++++++++++++++++-- .../DesktopAudioRecoveryTests.swift | 89 ++++++++++++++++++ 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift index 4af078f6..6f064b2c 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -25,6 +25,38 @@ struct DoryMacAudioConfigurationRecoveryState: Equatable { } } +struct DoryMacAudioQueueCapacity { + static let maximumBufferBytes = 4 * 1_024 * 1_024 + static let maximumPeriodBytes = 1 * 1_024 * 1_024 + + static func accepts(parameters: VirtioSoundPCMParameters) -> Bool { + parameters.bufferBytes > 0 + && parameters.bufferBytes <= maximumBufferBytes + && parameters.periodBytes > 0 + && parameters.periodBytes <= maximumPeriodBytes + && parameters.periodBytes <= parameters.bufferBytes + && parameters.bufferBytes % parameters.periodBytes == 0 + } + + static func accepts(currentBytes: Int, requestBytes: Int, capacityBytes: Int) -> Bool { + currentBytes >= 0 + && requestBytes > 0 + && capacityBytes > 0 + && currentBytes <= capacityBytes + && requestBytes <= capacityBytes - currentBytes + } +} + +struct DoryMacAudioRuntimeMetrics: Equatable { + var queuedPlaybackBytes: Int + var pendingCaptureBytes: Int + var bufferedCaptureBytes: Int + var droppedPlaybackPeriods: UInt64 + var droppedCapturePeriods: UInt64 + var discardedCaptureBytes: UInt64 + var configurationChanges: Int +} + /// Bridges the raw Hypervisor.framework virtio-snd device to Core Audio. Guest PCM remains the /// standard signed 16-bit interleaved format while AVAudioEngine performs host sample-rate and /// device conversion. @@ -74,6 +106,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private var permissionRequestInFlight = false private var captureBytes = Data() private var captureRequests = [CaptureRequest]() + private var pendingCaptureBytes = 0 private var nextCaptureRequestID: UInt64 = 1 private var captureTapCount = 0 private var captureFallbackLogged = false @@ -82,6 +115,9 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private var playbackRequests = [UInt64: PlaybackRequest]() private var nextPlaybackRequestID: UInt64 = 1 private var observedConfigurationChanges = 0 + private var droppedPlaybackPeriods: UInt64 = 0 + private var droppedCapturePeriods: UInt64 = 0 + private var discardedCaptureBytes: UInt64 = 0 init( log: @escaping @Sendable (String) -> Void, @@ -116,6 +152,20 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { var configurationChangeCount: Int { queue.sync { observedConfigurationChanges } } + var runtimeMetrics: DoryMacAudioRuntimeMetrics { + queue.sync { + DoryMacAudioRuntimeMetrics( + queuedPlaybackBytes: queuedPlaybackBytes, + pendingCaptureBytes: pendingCaptureBytes, + bufferedCaptureBytes: captureBytes.count, + droppedPlaybackPeriods: droppedPlaybackPeriods, + droppedCapturePeriods: droppedCapturePeriods, + discardedCaptureBytes: discardedCaptureBytes, + configurationChanges: observedConfigurationChanges + ) + } + } + func configure( streamID: Int, direction: VirtioSoundDirection, @@ -230,8 +280,18 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { completion: @escaping @Sendable (Bool, UInt32) -> Void ) -> Bool { queue.sync { - guard outputParameters == parameters, - let buffer = Self.playbackBuffer(data: data, parameters: parameters) else { + guard outputParameters == parameters else { + return false + } + guard DoryMacAudioQueueCapacity.accepts( + currentBytes: queuedPlaybackBytes, + requestBytes: data.count, + capacityBytes: parameters.bufferBytes + ) else { + droppedPlaybackPeriods &+= 1 + return false + } + guard let buffer = Self.playbackBuffer(data: data, parameters: parameters) else { return false } if outputRunning, !startOutputEngine() { return false } @@ -272,8 +332,17 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { // Linux primes capture descriptors while the PCM is prepared, before PCM_START. Keep // those requests pending just as playback keeps its pre-roll buffers scheduled. guard byteCount > 0, inputParameters == parameters else { return false } + guard DoryMacAudioQueueCapacity.accepts( + currentBytes: pendingCaptureBytes, + requestBytes: byteCount, + capacityBytes: parameters.bufferBytes + ) else { + droppedCapturePeriods &+= 1 + return false + } let requestID = nextCaptureRequestID nextCaptureRequestID &+= 1 + pendingCaptureBytes += byteCount captureRequests.append(CaptureRequest( id: requestID, byteCount: byteCount, @@ -443,9 +512,14 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private func appendCapture(_ data: Data) { captureBytes.append(data) - // Bound stale input if PipeWire temporarily stops submitting receive buffers. - if captureBytes.count > 4 * 1_024 * 1_024 { - captureBytes.removeFirst(captureBytes.count - 4 * 1_024 * 1_024) + // Keep only the newest negotiated buffer when PipeWire temporarily stops submitting + // receive descriptors. Old microphone frames are less useful than current audio after a + // stall, and the guest-controlled PCM contract must remain a hard memory bound. + let capacity = max(0, inputParameters?.bufferBytes ?? 0) + if captureBytes.count > capacity { + let discarded = captureBytes.count - capacity + discardedCaptureBytes &+= UInt64(discarded) + captureBytes.removeFirst(discarded) } satisfyCaptureRequests() } @@ -453,6 +527,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private func satisfyCaptureRequests() { while let request = captureRequests.first, captureBytes.count >= request.byteCount { captureRequests.removeFirst() + pendingCaptureBytes = max(0, pendingCaptureBytes - request.byteCount) let data = Data(captureBytes.prefix(request.byteCount)) captureBytes.removeFirst(request.byteCount) let latency = UInt32(clamping: captureBytes.count) @@ -493,6 +568,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { return } let request = self.captureRequests.remove(at: pendingIndex) + self.pendingCaptureBytes = max(0, self.pendingCaptureBytes - request.byteCount) if !self.captureFallbackLogged { self.captureFallbackLogged = true self.log("Mac microphone frames are pending; Linux capture is using paced silence") @@ -505,6 +581,8 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private func failCaptureRequests() { let requests = captureRequests captureRequests.removeAll(keepingCapacity: false) + pendingCaptureBytes = 0 + droppedCapturePeriods &+= UInt64(requests.count) for request in requests { Self.deliver { request.completion(nil, 0) } } } @@ -517,6 +595,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private func failPlaybackRequests() { let requestIDs = playbackRequests.keys.sorted() + droppedPlaybackPeriods &+= UInt64(requestIDs.count) for requestID in requestIDs { completePlayback(requestID: requestID, success: false) } @@ -527,9 +606,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { parameters.bytesPerSample == 2 && (parameters.channels == 1 || parameters.channels == 2) && (parameters.sampleRate == 44_100 || parameters.sampleRate == 48_000) - && parameters.bufferBytes > 0 - && parameters.periodBytes > 0 - && parameters.periodBytes <= parameters.bufferBytes + && DoryMacAudioQueueCapacity.accepts(parameters: parameters) && parameters.periodBytes % parameters.bytesPerFrame == 0 } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift index 5e31abd3..cf701a38 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift @@ -6,6 +6,94 @@ import Testing @Suite("Desktop audio configuration recovery") struct DesktopAudioRecoveryTests { + @Test("negotiated PCM buffers are hard queue bounds") + func queueCapacityIsBoundedWithoutOverflow() { + #expect(DoryMacAudioQueueCapacity.accepts(parameters: VirtioSoundPCMParameters( + bufferBytes: 16_384, + periodBytes: 4_096, + sampleRate: 48_000, + channels: 2 + ))) + #expect(!DoryMacAudioQueueCapacity.accepts(parameters: VirtioSoundPCMParameters( + bufferBytes: DoryMacAudioQueueCapacity.maximumBufferBytes + 1, + periodBytes: 4_096, + sampleRate: 48_000, + channels: 2 + ))) + #expect(!DoryMacAudioQueueCapacity.accepts(parameters: VirtioSoundPCMParameters( + bufferBytes: 16_384, + periodBytes: 5_000, + sampleRate: 48_000, + channels: 2 + ))) + #expect(DoryMacAudioQueueCapacity.accepts( + currentBytes: 0, + requestBytes: 4_096, + capacityBytes: 8_192 + )) + #expect(DoryMacAudioQueueCapacity.accepts( + currentBytes: 4_096, + requestBytes: 4_096, + capacityBytes: 8_192 + )) + #expect(!DoryMacAudioQueueCapacity.accepts( + currentBytes: 4_097, + requestBytes: 4_096, + capacityBytes: 8_192 + )) + #expect(!DoryMacAudioQueueCapacity.accepts( + currentBytes: Int.max, + requestBytes: 1, + capacityBytes: Int.max + )) + #expect(!DoryMacAudioQueueCapacity.accepts( + currentBytes: 0, + requestBytes: 0, + capacityBytes: 8_192 + )) + } + + @Test("backend rejects playback and capture beyond the negotiated buffer") + func backendEnforcesNegotiatedQueueCapacity() { + let backend = DoryMacAudioBackend(log: { _ in }) + let parameters = VirtioSoundPCMParameters( + bufferBytes: 8, + periodBytes: 4, + sampleRate: 48_000, + channels: 2 + ) + #expect(backend.configure( + streamID: 0, + direction: .output, + parameters: parameters + )) + #expect(backend.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + #expect(backend.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + #expect(!backend.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + + #expect(backend.configure( + streamID: 1, + direction: .input, + parameters: parameters + )) + #expect(backend.requestCapture(byteCount: 4, parameters: parameters) { _, _ in }) + #expect(backend.requestCapture(byteCount: 4, parameters: parameters) { _, _ in }) + #expect(!backend.requestCapture(byteCount: 4, parameters: parameters) { _, _ in }) + + let queued = backend.runtimeMetrics + #expect(queued.queuedPlaybackBytes == 8) + #expect(queued.pendingCaptureBytes == 8) + #expect(queued.droppedPlaybackPeriods == 1) + #expect(queued.droppedCapturePeriods == 1) + + backend.reset() + let reset = backend.runtimeMetrics + #expect(reset.queuedPlaybackBytes == 0) + #expect(reset.pendingCaptureBytes == 0) + #expect(reset.droppedPlaybackPeriods == 3) + #expect(reset.droppedCapturePeriods == 3) + } + @Test("only configured running streams recover") func recoveryPolicyRequiresConfiguredRunningStream() { let stopped = DoryMacAudioConfigurationRecoveryState( @@ -65,5 +153,6 @@ struct DesktopAudioRecoveryTests { Thread.sleep(forTimeInterval: 0.005) } #expect(backend.configurationChangeCount == 2) + #expect(backend.runtimeMetrics.configurationChanges == 2) } } From cf4156ba6877a0dd6b3a3bf2f8ae13390eb79d5f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:24:27 +0000 Subject: [PATCH 192/338] fix(vm): fail denied microphone capture --- .../Sources/dory-hv/DesktopAudioBackend.swift | 39 +++++++++++--- .../DesktopAudioRecoveryTests.swift | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift index 6f064b2c..a4965435 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -96,6 +96,10 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private let player = AVAudioPlayerNode() private let log: @Sendable (String) -> Void private let notificationCenter: NotificationCenter + private let microphoneAuthorizationStatus: @Sendable () -> AVAuthorizationStatus + private let requestMicrophoneAccess: @Sendable ( + @escaping @Sendable (Bool) -> Void + ) -> Void private var configurationObservers = [NSObjectProtocol]() private var outputParameters: VirtioSoundPCMParameters? @@ -121,10 +125,20 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { init( log: @escaping @Sendable (String) -> Void, - notificationCenter: NotificationCenter = .default + notificationCenter: NotificationCenter = .default, + microphoneAuthorizationStatus: @escaping @Sendable () -> AVAuthorizationStatus = { + AVCaptureDevice.authorizationStatus(for: .audio) + }, + requestMicrophoneAccess: @escaping @Sendable ( + @escaping @Sendable (Bool) -> Void + ) -> Void = { completion in + AVCaptureDevice.requestAccess(for: .audio, completionHandler: completion) + } ) { self.log = log self.notificationCenter = notificationCenter + self.microphoneAuthorizationStatus = microphoneAuthorizationStatus + self.requestMicrophoneAccess = requestMicrophoneAccess outputEngine.attach(player) configurationObservers = [ notificationCenter.addObserver( @@ -229,7 +243,10 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { case .input: guard inputParameters != nil else { return false } inputRunning = true - guard startInputWhenAuthorized() else { return false } + guard startInputWhenAuthorized() else { + failCaptureRequests() + return false + } satisfyCaptureRequests() armCaptureFallbacks() return true @@ -373,19 +390,25 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { } private func startInputWhenAuthorized() -> Bool { - switch AVCaptureDevice.authorizationStatus(for: .audio) { + switch microphoneAuthorizationStatus() { case .authorized: return installInputTapAndStartEngine() case .notDetermined: guard !permissionRequestInFlight else { return true } permissionRequestInFlight = true log("requesting Mac microphone access; Linux capture will provide paced silence until permission is resolved") - AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + requestMicrophoneAccess { [weak self] granted in guard let self else { return } self.queue.async { self.permissionRequestInFlight = false guard self.inputRunning else { return } - if granted, self.installInputTapAndStartEngine() { return } + guard granted else { + self.inputRunning = false + self.log("microphone access was denied; Linux capture requests were stopped") + self.failCaptureRequests() + return + } + if self.installInputTapAndStartEngine() { return } self.log("microphone access is unavailable; Linux capture will continue with paced silence") self.armCaptureFallbacks() } @@ -436,7 +459,11 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { log("Mac audio input is unavailable after the host device configuration changed") } satisfyCaptureRequests() - armCaptureFallbacks() + if inputRunning { + armCaptureFallbacks() + } else { + failCaptureRequests() + } } } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift index cf701a38..41a72942 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift @@ -94,6 +94,40 @@ struct DesktopAudioRecoveryTests { #expect(reset.droppedCapturePeriods == 3) } + @Test("denied microphone permission fails primed capture descriptors") + func deniedMicrophonePermissionFailsPendingCapture() { + let completion = DispatchSemaphore(value: 0) + let result = LockedCaptureCompletion() + let backend = DoryMacAudioBackend( + log: { _ in }, + microphoneAuthorizationStatus: { .denied }, + requestMicrophoneAccess: { _ in + Issue.record("denied authorization must not request microphone access") + } + ) + let parameters = VirtioSoundPCMParameters( + bufferBytes: 8, + periodBytes: 4, + sampleRate: 48_000, + channels: 2 + ) + #expect(backend.configure( + streamID: 1, + direction: .input, + parameters: parameters + )) + #expect(backend.requestCapture(byteCount: 4, parameters: parameters) { data, _ in + result.record(data) + completion.signal() + }) + + #expect(!backend.start(streamID: 1, direction: .input)) + #expect(completion.wait(timeout: .now() + 1) == .success) + #expect(result.snapshot == (completed: true, dataWasNil: true)) + #expect(backend.runtimeMetrics.pendingCaptureBytes == 0) + #expect(backend.runtimeMetrics.droppedCapturePeriods == 1) + } + @Test("only configured running streams recover") func recoveryPolicyRequiresConfiguredRunningStream() { let stopped = DoryMacAudioConfigurationRecoveryState( @@ -156,3 +190,22 @@ struct DesktopAudioRecoveryTests { #expect(backend.runtimeMetrics.configurationChanges == 2) } } + +private final class LockedCaptureCompletion: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + private var dataWasNil = false + + func record(_ data: Data?) { + lock.lock() + completed = true + dataWasNil = data == nil + lock.unlock() + } + + var snapshot: (completed: Bool, dataWasNil: Bool) { + lock.lock() + defer { lock.unlock() } + return (completed, dataWasNil) + } +} From 53eb556c60232acbacb564903083cd586abe188a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:26:15 +0000 Subject: [PATCH 193/338] fix(vm): latch microphone permission denial --- .../Sources/dory-hv/DesktopAudioBackend.swift | 14 +++++++- .../DesktopAudioRecoveryTests.swift | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift index a4965435..5e08446c 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -107,6 +107,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private var outputRunning = false private var inputRunning = false private var inputTapInstalled = false + private var microphoneAccessDenied = false private var permissionRequestInFlight = false private var captureBytes = Data() private var captureRequests = [CaptureRequest]() @@ -203,6 +204,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { captureTapCount = 0 captureFallbackLogged = false nextCaptureFallbackUptime = 0 + microphoneAccessDenied = false inputParameters = parameters inputRunning = false } @@ -262,6 +264,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { outputRunning = false case .input: inputRunning = false + microphoneAccessDenied = false removeInputTap() inputEngine.stop() nextCaptureFallbackUptime = 0 @@ -281,6 +284,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { outputParameters = nil case .input: inputRunning = false + microphoneAccessDenied = false removeInputTap() inputEngine.stop() inputParameters = nil @@ -348,7 +352,9 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { queue.sync { // Linux primes capture descriptors while the PCM is prepared, before PCM_START. Keep // those requests pending just as playback keeps its pre-roll buffers scheduled. - guard byteCount > 0, inputParameters == parameters else { return false } + guard byteCount > 0, + inputParameters == parameters, + !microphoneAccessDenied else { return false } guard DoryMacAudioQueueCapacity.accepts( currentBytes: pendingCaptureBytes, requestBytes: byteCount, @@ -382,6 +388,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { inputParameters = nil outputRunning = false inputRunning = false + microphoneAccessDenied = false failPlaybackRequests() captureBytes.removeAll(keepingCapacity: false) nextCaptureFallbackUptime = 0 @@ -392,8 +399,10 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { private func startInputWhenAuthorized() -> Bool { switch microphoneAuthorizationStatus() { case .authorized: + microphoneAccessDenied = false return installInputTapAndStartEngine() case .notDetermined: + microphoneAccessDenied = false guard !permissionRequestInFlight else { return true } permissionRequestInFlight = true log("requesting Mac microphone access; Linux capture will provide paced silence until permission is resolved") @@ -404,6 +413,7 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { guard self.inputRunning else { return } guard granted else { self.inputRunning = false + self.microphoneAccessDenied = true self.log("microphone access was denied; Linux capture requests were stopped") self.failCaptureRequests() return @@ -415,10 +425,12 @@ final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { } return true case .denied, .restricted: + microphoneAccessDenied = true log("microphone access is denied; enable it for Dory in System Settings > Privacy & Security > Microphone") inputRunning = false return false @unknown default: + microphoneAccessDenied = true inputRunning = false return false } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift index 41a72942..8df4a5da 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopAudioRecoveryTests.swift @@ -124,6 +124,41 @@ struct DesktopAudioRecoveryTests { #expect(!backend.start(streamID: 1, direction: .input)) #expect(completion.wait(timeout: .now() + 1) == .success) #expect(result.snapshot == (completed: true, dataWasNil: true)) + #expect(!backend.requestCapture(byteCount: 4, parameters: parameters) { _, _ in }) + #expect(backend.runtimeMetrics.pendingCaptureBytes == 0) + #expect(backend.runtimeMetrics.droppedCapturePeriods == 1) + } + + @Test("permission prompt denial latches capture unavailable") + func asynchronousMicrophoneDenialRejectsLaterCapture() { + let completion = DispatchSemaphore(value: 0) + let result = LockedCaptureCompletion() + let backend = DoryMacAudioBackend( + log: { _ in }, + microphoneAuthorizationStatus: { .notDetermined }, + requestMicrophoneAccess: { callback in callback(false) } + ) + let parameters = VirtioSoundPCMParameters( + bufferBytes: 8, + periodBytes: 4, + sampleRate: 48_000, + channels: 2 + ) + #expect(backend.configure( + streamID: 1, + direction: .input, + parameters: parameters + )) + #expect(backend.requestCapture(byteCount: 4, parameters: parameters) { data, _ in + result.record(data) + completion.signal() + }) + + // The guest may enter PCM_RUNNING while macOS displays its asynchronous permission prompt. + #expect(backend.start(streamID: 1, direction: .input)) + #expect(completion.wait(timeout: .now() + 1) == .success) + #expect(result.snapshot == (completed: true, dataWasNil: true)) + #expect(!backend.requestCapture(byteCount: 4, parameters: parameters) { _, _ in }) #expect(backend.runtimeMetrics.pendingCaptureBytes == 0) #expect(backend.runtimeMetrics.droppedCapturePeriods == 1) } From 81c4b6cd500af6a3f9315dc5805bab6219db7c72 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:36:53 +0000 Subject: [PATCH 194/338] feat(vm): bind display scale contract --- .../Sources/dory-hv/DesktopMetalDisplay.swift | 20 ++++++-- .../Sources/dory-hv/DesktopMode.swift | 11 ++-- .../DoryHVTests/DesktopDisplayPlanTests.swift | 28 ++++++++++ .../DoryVirtualMachineCapabilities.swift | 42 ++++++++++++++- .../DoryVirtualMachineDefinition.swift | 51 ++++++++++++++++++- .../DoryVMMDesktopApplication.swift | 13 +++-- ...yDaemonVirtualMachineRuntimePlanning.swift | 4 +- .../DoryVirtualMachineDefinitionTests.swift | 20 ++++++++ ...onVirtualMachineRuntimePlanningTests.swift | 4 +- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 4 +- 10 files changed, 175 insertions(+), 22 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index 823b0716..fdc22b63 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -90,6 +90,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { private let commandQueue: MTLCommandQueue private let pipeline: MTLRenderPipelineState private let input: VirtioInput + private let guestBackingScaleFactor: CGFloat private var resourceTextures: [UInt32: MTLTexture] = [:] private var scanoutTexture: MTLTexture? private var scanoutSize = CGSize.zero @@ -102,12 +103,17 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? var onMacShortcut: ((NSEvent) -> Bool)? - init(frame: NSRect, input: VirtioInput) throws { + init( + frame: NSRect, + input: VirtioInput, + guestBackingScaleFactor: CGFloat = 2 + ) throws { guard let device = MTLCreateSystemDefaultDevice(), let commandQueue = device.makeCommandQueue() else { throw DesktopMetalDisplayError.metalUnavailable } self.input = input + self.guestBackingScaleFactor = guestBackingScaleFactor self.commandQueue = commandQueue do { let library = try device.makeLibrary(source: Self.shaderSource, options: nil) @@ -260,10 +266,14 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { commandBuffer.commit() } - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { - if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: size) } - let width = UInt32(clamping: max(1, Int(size.width.rounded()))) - let height = UInt32(clamping: max(1, Int(size.height.rounded()))) + func mtkView(_ view: MTKView, drawableSizeWillChange _: CGSize) { + let guestPixelSize = CGSize( + width: max(1, bounds.width * guestBackingScaleFactor), + height: max(1, bounds.height * guestBackingScaleFactor) + ) + if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: guestPixelSize) } + let width = UInt32(clamping: max(1, Int(guestPixelSize.width.rounded()))) + let height = UInt32(clamping: max(1, Int(guestPixelSize.height.rounded()))) resizeGeneration &+= 1 let generation = resizeGeneration // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index e3b7c50f..08343737 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -53,6 +53,8 @@ enum DesktopMode { struct DisplayPlan: Equatable { var widthPixels: UInt32 var heightPixels: UInt32 + var backingScaleFactor: UInt8 + var guestUIScaleFactor: UInt8 init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { let display = resolvedDevices?.display ?? DoryVMMDisplayDefaults.capability @@ -63,12 +65,14 @@ enum DesktopMode { } widthPixels = display.widthPixels heightPixels = display.heightPixels + backingScaleFactor = display.backingScaleFactor + guestUIScaleFactor = display.guestUIScaleFactor } var windowSize: NSSize { NSSize( - width: max(1, CGFloat(widthPixels) / 2), - height: max(1, CGFloat(heightPixels) / 2) + width: max(1, CGFloat(widthPixels) / CGFloat(backingScaleFactor)), + height: max(1, CGFloat(heightPixels) / CGFloat(backingScaleFactor)) ) } } @@ -163,7 +167,8 @@ enum DesktopMode { self.firstFrame = firstFrame self.display = try DesktopMetalView( frame: NSRect(origin: .zero, size: displayPlan.windowSize), - input: input + input: input, + guestBackingScaleFactor: CGFloat(displayPlan.backingScaleFactor) ) mailbox.view = display cursorMailbox.view = display diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift index 4ef3a255..cfdc42b3 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift @@ -9,6 +9,8 @@ import Testing #expect(plan.widthPixels == 2_560) #expect(plan.heightPixels == 1_600) + #expect(plan.backingScaleFactor == 2) + #expect(plan.guestUIScaleFactor == 2) #expect(plan.windowSize.width == 1_280) #expect(plan.windowSize.height == 800) } @@ -24,6 +26,22 @@ import Testing #expect(plan.windowSize.height == 540) } + @Test func resolvedBackingScaleIsNotInferredFromHostDensity() throws { + let plan = try DesktopMode.DisplayPlan(resolvedDevices: .init( + display: .init( + widthPixels: 1_920, + heightPixels: 1_080, + backingScaleFactor: 1, + guestUIScaleFactor: 2 + ) + )) + + #expect(plan.windowSize.width == 1_920) + #expect(plan.windowSize.height == 1_080) + #expect(plan.backingScaleFactor == 1) + #expect(plan.guestUIScaleFactor == 2) + } + @Test func invalidResolvedGeometryFailsClosed() { for display in [ DoryVirtualMachineDisplayCapabilityRequest(widthPixels: 0, heightPixels: 1_080), @@ -31,6 +49,16 @@ import Testing widthPixels: DoryVirtualMachineDisplayCapabilityRequest.maximumDimensionPixels + 1, heightPixels: 1_080 ), + DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: 1_920, + heightPixels: 1_080, + backingScaleFactor: 0 + ), + DoryVirtualMachineDisplayCapabilityRequest( + widthPixels: 1_920, + heightPixels: 1_080, + guestUIScaleFactor: 3 + ), ] { #expect(throws: VMError.self) { _ = try DesktopMode.DisplayPlan(resolvedDevices: .init(display: display)) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 485b2990..9d680324 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -112,15 +112,55 @@ public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equ public var widthPixels: UInt32 public var heightPixels: UInt32 + public var backingScaleFactor: UInt8 + public var guestUIScaleFactor: UInt8 - public init(widthPixels: UInt32, heightPixels: UInt32) { + public init( + widthPixels: UInt32, + heightPixels: UInt32, + backingScaleFactor: UInt8 = 2, + guestUIScaleFactor: UInt8 = 2 + ) { self.widthPixels = widthPixels self.heightPixels = heightPixels + self.backingScaleFactor = backingScaleFactor + self.guestUIScaleFactor = guestUIScaleFactor } public var isValid: Bool { (1...Self.maximumDimensionPixels).contains(widthPixels) && (1...Self.maximumDimensionPixels).contains(heightPixels) + && (1...4).contains(backingScaleFactor) + && (1...2).contains(guestUIScaleFactor) + } + + private enum CodingKeys: String, CodingKey { + case widthPixels + case heightPixels + case backingScaleFactor + case guestUIScaleFactor + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + widthPixels = try container.decode(UInt32.self, forKey: .widthPixels) + heightPixels = try container.decode(UInt32.self, forKey: .heightPixels) + backingScaleFactor = try container.decodeIfPresent( + UInt8.self, + forKey: .backingScaleFactor + ) ?? 2 + guestUIScaleFactor = try container.decodeIfPresent( + UInt8.self, + forKey: .guestUIScaleFactor + ) ?? 2 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(widthPixels, forKey: .widthPixels) + try container.encode(heightPixels, forKey: .heightPixels) + try container.encode(backingScaleFactor, forKey: .backingScaleFactor) + try container.encode(guestUIScaleFactor, forKey: .guestUIScaleFactor) } } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index d37c20df..75f338e5 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -185,25 +185,68 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { public var widthPixels: UInt32 public var heightPixels: UInt32 public var pixelsPerInch: UInt16 + /// Guest framebuffer pixels per host window point. This is independent of desktop UI scale. + public var backingScaleFactor: UInt8 + /// Guest toolkit/UI scale. This does not change framebuffer pixel dimensions. + public var guestUIScaleFactor: UInt8 public init( enabled: Bool = true, widthPixels: UInt32 = 1_920, heightPixels: UInt32 = 1_080, - pixelsPerInch: UInt16 = 110 + pixelsPerInch: UInt16 = 110, + backingScaleFactor: UInt8 = 2, + guestUIScaleFactor: UInt8 = 2 ) { self.enabled = enabled self.widthPixels = widthPixels self.heightPixels = heightPixels self.pixelsPerInch = pixelsPerInch + self.backingScaleFactor = backingScaleFactor + self.guestUIScaleFactor = guestUIScaleFactor } public static let disabled = DoryVMDisplayConfiguration( enabled: false, widthPixels: 0, heightPixels: 0, - pixelsPerInch: 0 + pixelsPerInch: 0, + backingScaleFactor: 0, + guestUIScaleFactor: 0 ) + + private enum CodingKeys: String, CodingKey { + case enabled + case widthPixels + case heightPixels + case pixelsPerInch + case backingScaleFactor + case guestUIScaleFactor + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decode(Bool.self, forKey: .enabled) + widthPixels = try container.decode(UInt32.self, forKey: .widthPixels) + heightPixels = try container.decode(UInt32.self, forKey: .heightPixels) + pixelsPerInch = try container.decode(UInt16.self, forKey: .pixelsPerInch) + // Historical definitions implemented the same 2x/2x behavior but did not name the two + // independent scales. Preserve those exact bytes as the established compatibility default. + backingScaleFactor = try container.decodeIfPresent(UInt8.self, forKey: .backingScaleFactor) + ?? (enabled ? 2 : 0) + guestUIScaleFactor = try container.decodeIfPresent(UInt8.self, forKey: .guestUIScaleFactor) + ?? (enabled ? 2 : 0) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(enabled, forKey: .enabled) + try container.encode(widthPixels, forKey: .widthPixels) + try container.encode(heightPixels, forKey: .heightPixels) + try container.encode(pixelsPerInch, forKey: .pixelsPerInch) + try container.encode(backingScaleFactor, forKey: .backingScaleFactor) + try container.encode(guestUIScaleFactor, forKey: .guestUIScaleFactor) + } } public struct DoryVMAudioConfiguration: Codable, Sendable, Equatable { @@ -852,9 +895,13 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { let dimensionsArePositive = display.widthPixels > 0 && display.heightPixels > 0 && display.pixelsPerInch > 0 + && (1...4).contains(display.backingScaleFactor) + && (1...2).contains(display.guestUIScaleFactor) let dimensionsAreZero = display.widthPixels == 0 && display.heightPixels == 0 && display.pixelsPerInch == 0 + && display.backingScaleFactor == 0 + && display.guestUIScaleFactor == 0 if (display.enabled && !dimensionsArePositive) || (!display.enabled && !dimensionsAreZero) { issues.append(issue(.invalidDisplayConfiguration, "display")) } diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index 4e4cc54d..5afcc118 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -11,6 +11,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow private let window: NSWindow private let clipboard: DoryDesktopClipboardCoordinator? private let dynamicDisplayEnabled: Bool + private let backingScaleFactor: CGFloat private var pendingDisplayResize: DispatchWorkItem? private var requestedPixelSize: CGSize? private var stopError: String? @@ -26,9 +27,10 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow dynamicDisplayEnabled = resolvedDevices?.dynamicDisplay ?? true let display = resolvedDevices?.display ?? DoryVMMDisplayDefaults.capability + backingScaleFactor = CGFloat(display.backingScaleFactor) let windowSize = NSSize( - width: max(1, CGFloat(display.widthPixels) / 2), - height: max(1, CGFloat(display.heightPixels) / 2) + width: max(1, CGFloat(display.widthPixels) / backingScaleFactor), + height: max(1, CGFloat(display.heightPixels) / backingScaleFactor) ) let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize)) machineView.virtualMachine = runtime.machine.virtualMachineForDisplay @@ -179,10 +181,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow viewSize: CGSize, backingScaleFactor: CGFloat ) -> CGSize { - // Keep the Linux desktop at a 2x render scale even on a 1x host display. Retina screens - // map those pixels directly; lower-density screens get a supersampled image instead of a - // visibly coarse guest framebuffer. - let scale = max(2, backingScaleFactor) + let scale = max(1, backingScaleFactor) return CGSize( width: max(1, (viewSize.width * scale).rounded()), height: max(1, (viewSize.height * scale).rounded()) @@ -205,7 +204,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow pendingDisplayResize = nil let size = Self.targetPixelSize( viewSize: machineView.bounds.size, - backingScaleFactor: window.backingScaleFactor + backingScaleFactor: backingScaleFactor ) guard size != requestedPixelSize else { return } do { diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 42525cbf..0c81095a 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -525,7 +525,9 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda display: definition.display.enabled ? DoryVirtualMachineDisplayCapabilityRequest( widthPixels: definition.display.widthPixels, - heightPixels: definition.display.heightPixels + heightPixels: definition.display.heightPixels, + backingScaleFactor: definition.display.backingScaleFactor, + guestUIScaleFactor: definition.display.guestUIScaleFactor ) : nil, audioInput: definition.audio.inputEnabled, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 041fd940..0bba3600 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -25,6 +25,8 @@ struct DoryVirtualMachineDefinitionTests { #expect(json.contains("\"namespace\":\"boot\"")) #expect(json.contains("\"clipboardPolicy\"")) #expect(json.contains("\"guestIdentityIntent\"")) + #expect(json.contains("\"backingScaleFactor\":2")) + #expect(json.contains("\"guestUIScaleFactor\":2")) #expect(!json.contains("\"bootMedia\"")) #expect(!json.contains("artifactID")) #expect(!json.contains("hostLocationID")) @@ -53,6 +55,8 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional)) #expect(decoded.schemaVersion == 3) #expect(decoded.storage.allSatisfy { $0.source == .userProvided }) + #expect(decoded.display.backingScaleFactor == 2) + #expect(decoded.display.guestUIScaleFactor == 2) #expect(decoded.isValid) } @@ -251,6 +255,8 @@ struct DoryVirtualMachineDefinitionTests { #expect(migrated.graphics.acceptableLevels == [.hostAcceleratedDisplay, .software]) #expect(migrated.storage[0].source == .userProvided) #expect(migrated.lifecycle.createdAtUnixMilliseconds == 1_700_000_000_000) + #expect(migrated.display.backingScaleFactor == 2) + #expect(migrated.display.guestUIScaleFactor == 2) #expect(migrated.isValid) let upgraded = try JSONEncoder().encode(migrated) @@ -583,6 +589,20 @@ struct DoryVirtualMachineDefinitionTests { #expect(definition.isValid) } + @Test("display backing and guest UI scales are distinct bounded intent") + func displayScaleValidation() { + var definition = linuxDefinition() + definition.display.backingScaleFactor = 1 + definition.display.guestUIScaleFactor = 2 + #expect(definition.isValid) + + definition.display.backingScaleFactor = 0 + #expect(has(.invalidDisplayConfiguration, "display", in: definition.validate())) + definition.display.backingScaleFactor = 2 + definition.display.guestUIScaleFactor = 3 + #expect(has(.invalidDisplayConfiguration, "display", in: definition.validate())) + } + private func linuxDefinition( kind: DoryBootMediaKind = .installedLinuxBootBundle ) -> DoryVirtualMachineDefinition { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 594eb48a..eb700525 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -16,7 +16,9 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(devices.networkAttachment == .disconnected) #expect(devices.display == DoryVirtualMachineDisplayCapabilityRequest( widthPixels: definition.display.widthPixels, - heightPixels: definition.display.heightPixels + heightPixels: definition.display.heightPixels, + backingScaleFactor: definition.display.backingScaleFactor, + guestUIScaleFactor: definition.display.guestUIScaleFactor )) #expect(devices.audioInput == definition.audio.inputEnabled) #expect(devices.audioOutput == definition.audio.outputEnabled) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 23f3aa02..e162b988 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -114,7 +114,7 @@ final class DoryVMMKitTests: XCTestCase { ) } - func testDesktopWindowUsesRetinaBackingPixels() { + func testDesktopWindowUsesTheResolvedBackingScale() { XCTAssertEqual( DoryVMMDesktopApplication.targetPixelSize( viewSize: CGSize(width: 1_280, height: 800), @@ -127,7 +127,7 @@ final class DoryVMMKitTests: XCTestCase { viewSize: CGSize(width: 1_024, height: 768), backingScaleFactor: 1 ), - CGSize(width: 2_048, height: 1_536) + CGSize(width: 1_024, height: 768) ) } From 3bc24631c7598db7b3bb461af2e835b7e7ea5113 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:39:15 +0000 Subject: [PATCH 195/338] feat(vm): apply resolved guest UI scale --- .../Sources/dory-hv/DesktopMode.swift | 14 +++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 21 +++++++++- .../DoryVMMKit/DoryVMMGuestDisplayScale.swift | 28 +++++++++++++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 13 ++++++ .../usr/lib/dory/configure-display | 41 +++++++++++++++---- guest/desktop/verify-build.sh | 8 +++- 6 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryVMMGuestDisplayScale.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 08343737..0b5da8dd 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -565,6 +565,20 @@ enum DesktopMode { directSocketPath: configuration.agentSocketPath )) let info = try control.info() + if let display = configuration.resolvedDevices?.display { + guard let command = DoryVMMGuestDisplayScale.persistenceCommand( + scaleFactor: display.guestUIScaleFactor + ) else { + throw VMError.bootFailure( + "resolved guest UI scale is not supported by Dory Tools" + ) + } + try requireSuccess(control.exec( + argv: command, + timeoutMs: 10_000, + outputLimitBytes: 64 * 1_024 + ), operation: "persist guest UI scale") + } try requireSuccess(control.exec( argv: ["/usr/lib/dory/configure-machine"], env: configuration.environment.sorted(by: { $0.key < $1.key }).map { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 0bc42ced..4707b94d 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1243,7 +1243,8 @@ public enum DoryVMMMain { from: agentConnection, displayMode: displayMode, environment: environment, - shares: shares + shares: shares, + resolvedDevices: resolvedDevices ) // For the Docker VM, this handoff means VM + guest-agent readiness. doryd owns the next // ordered stages (data mount, route/resolver, dockerd /version, and host socket), so a VMM @@ -1269,7 +1270,8 @@ public enum DoryVMMMain { from connection: VZVirtioSocketConnection, displayMode: DoryMachineDisplayMode, environment: [String: String], - shares: [DoryMachineShareConfiguration] + shares: [DoryMachineShareConfiguration], + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? ) throws -> DoryAgentInfo { let fd = dup(connection.fileDescriptor) guard fd >= 0 else { @@ -1280,6 +1282,21 @@ public enum DoryVMMMain { let info = try control.info() guard displayMode == .desktop else { return info } + if let display = resolvedDevices?.display { + guard let command = DoryVMMGuestDisplayScale.persistenceCommand( + scaleFactor: display.guestUIScaleFactor + ) else { + throw DoryVZMachineError.validation( + "resolved guest UI scale is not supported by Dory Tools" + ) + } + try requireSuccessfulDesktopExec(control.exec( + argv: command, + timeoutMs: 10_000, + outputLimitBytes: 64 * 1_024 + ), operation: "persist guest UI scale") + } + try requireSuccessfulDesktopExec(control.exec( argv: ["/usr/lib/dory/configure-machine"], env: environment.sorted(by: { $0.key < $1.key }).map { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGuestDisplayScale.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGuestDisplayScale.swift new file mode 100644 index 00000000..30d5c4cb --- /dev/null +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGuestDisplayScale.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum DoryVMMGuestDisplayScale { + public static let supportedScaleFactors: ClosedRange = 1...2 + + /// Returns an argument-separated command that atomically publishes the resolved UI scale in + /// the managed guest. The value is never interpolated into shell source. + public static func persistenceCommand(scaleFactor: UInt8) -> [String]? { + guard supportedScaleFactors.contains(scaleFactor) else { return nil } + return [ + "/bin/sh", + "-c", + """ + set -eu + umask 022 + mkdir -p /var/lib/dory + temporary="/var/lib/dory/.guest-ui-scale.$$" + trap 'rm -f "$temporary"' EXIT HUP INT TERM + printf '%s\n' "$1" > "$temporary" + chmod 0644 "$temporary" + mv -f "$temporary" /var/lib/dory/guest-ui-scale + trap - EXIT HUP INT TERM + """, + "dory-guest-display-scale", + String(scaleFactor), + ] + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index e162b988..3d75d8ad 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -131,6 +131,19 @@ final class DoryVMMKitTests: XCTestCase { ) } + func testGuestUIScalePersistenceKeepsTheValueOutOfShellSource() throws { + let command = try XCTUnwrap( + DoryVMMGuestDisplayScale.persistenceCommand(scaleFactor: 1) + ) + XCTAssertEqual(command[0...1], ["/bin/sh", "-c"]) + XCTAssertEqual(command[3], "dory-guest-display-scale") + XCTAssertEqual(command[4], "1") + XCTAssertFalse(command[2].contains("guest-ui-scale 1")) + XCTAssertTrue(command[2].contains("\"$1\"")) + XCTAssertNil(DoryVMMGuestDisplayScale.persistenceCommand(scaleFactor: 0)) + XCTAssertNil(DoryVMMGuestDisplayScale.persistenceCommand(scaleFactor: 3)) + } + func testVZSSHAgentBridgeRejectsNonSocketSymlinkAndWrongOwner() throws { let root = "/tmp/dory-vz-ssh-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: false) diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display index c970b485..625b7456 100644 --- a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display @@ -10,6 +10,26 @@ set_xfce_value() { fi } +guest_ui_scale() { + local scale + scale="$(cat /var/lib/dory/guest-ui-scale 2>/dev/null || printf '2\n')" + case "$scale" in + 1|2) printf '%s\n' "$scale" ;; + *) printf '2\n' ;; + esac +} + +apply_gnome_scale() { + local scale="$1" + gsettings set org.gnome.desktop.interface scaling-factor "$scale" + gsettings set org.gnome.desktop.interface text-scaling-factor 1.0 + gsettings set org.gnome.desktop.interface cursor-size "$((16 * scale))" +} + +apply_xfce_scale() { + set_xfce_value /Gdk/WindowScalingFactor int "$1" +} + configure_gnome() { # GNOME autostart begins before Ubuntu's shell has finished loading its vendor defaults. Wait # until the shell is reachable so those defaults cannot overwrite Dory's display and launcher @@ -28,9 +48,7 @@ configure_gnome() { done fi - gsettings set org.gnome.desktop.interface scaling-factor 2 - gsettings set org.gnome.desktop.interface text-scaling-factor 1.0 - gsettings set org.gnome.desktop.interface cursor-size 32 + apply_gnome_scale "$(guest_ui_scale)" # Software scanout benefits from fewer compositor effects. Both qualified accelerated modes can # retain Ubuntu's normal motion without coupling the desktop policy to a particular renderer. if [ "$(cat /run/dory/graphics-backend 2>/dev/null || printf software)" != software ]; then @@ -59,9 +77,18 @@ configure_gnome() { } follow_preferred_display_mode() { - local current_mode output_name preferred_mode snapshot + local desktop="$1" current_mode output_name preferred_mode snapshot + local applied_scale="" scale while :; do + scale="$(guest_ui_scale)" + if [[ "$scale" != "$applied_scale" ]]; then + case "$desktop" in + gnome) apply_gnome_scale "$scale" ;; + xfce) apply_xfce_scale "$scale" ;; + esac + applied_scale="$scale" + fi if snapshot="$(xrandr --query 2>/dev/null)"; then output_name="$(awk '$2 == "connected" { print $1; exit }' <<<"$snapshot")" preferred_mode="$(awk '$1 ~ /^[0-9]+x[0-9]+$/ && index($0, "+") { print $1; exit }' <<<"$snapshot")" @@ -81,15 +108,15 @@ for _ in $(seq 1 100); do && gsettings writable org.gnome.desktop.interface scaling-factor 2>/dev/null \ | grep -qx true; then configure_gnome - follow_preferred_display_mode + follow_preferred_display_mode gnome fi if xfconf-query --channel xsettings --list >/dev/null 2>&1; then - set_xfce_value /Gdk/WindowScalingFactor int 2 + apply_xfce_scale "$(guest_ui_scale)" set_xfce_value /Xft/Antialias int 1 set_xfce_value /Xft/Hinting int 1 set_xfce_value /Xft/HintStyle string hintslight set_xfce_value /Xft/RGBA string rgb - follow_preferred_display_mode + follow_preferred_display_mode xfce fi sleep 0.1 done diff --git a/guest/desktop/verify-build.sh b/guest/desktop/verify-build.sh index 1d29ff12..fe905ec8 100755 --- a/guest/desktop/verify-build.sh +++ b/guest/desktop/verify-build.sh @@ -308,7 +308,7 @@ grep -Fq 'xrandr --output "$output_name" --mode "$preferred_mode"' <<<"$DISPLAY_ || fail "dynamic desktop resizing is not configured" case "$DISTRO" in ubuntu) - grep -Fq 'gsettings set org.gnome.desktop.interface scaling-factor 2' <<<"$DISPLAY_CONFIGURATION" \ + grep -Fq 'gsettings set org.gnome.desktop.interface scaling-factor "$scale"' <<<"$DISPLAY_CONFIGURATION" \ || fail "GNOME Retina scaling is not configured" grep -Fq '/run/dory/graphics-backend' <<<"$DISPLAY_CONFIGURATION" \ || fail "GNOME does not select effects based on the active graphics backend" @@ -346,7 +346,7 @@ case "$DISTRO" in fi ;; *) - grep -Fq 'set_xfce_value /Gdk/WindowScalingFactor int 2' <<<"$DISPLAY_CONFIGURATION" \ + grep -Fq 'apply_xfce_scale "$(guest_ui_scale)"' <<<"$DISPLAY_CONFIGURATION" \ || fail "Xfce Retina scaling is not configured" grep -q $'^xfce4\t' "$PACKAGES" || fail "Xfce package provenance is missing" grep -q $'^lightdm\t' "$PACKAGES" || fail "LightDM package provenance is missing" @@ -357,6 +357,10 @@ case "$DISTRO" in fi ;; esac +grep -Fq '/var/lib/dory/guest-ui-scale' <<<"$DISPLAY_CONFIGURATION" \ + || fail "the resolved guest UI scale is not consumed" +grep -Fq 'apply_xfce_scale "$scale"' <<<"$DISPLAY_CONFIGURATION" \ + || fail "Xfce does not refresh the resolved guest UI scale" grep -q $'^spice-vdagent\t' "$PACKAGES" || fail "SPICE package provenance is missing" grep -q $'^x11-utils\t' "$PACKAGES" || fail "X11 window qualification tools are missing" grep -q $'^wl-clipboard\t' "$PACKAGES" || fail "Wayland clipboard package provenance is missing" From 60e4716dcc7d82bb01074c7ef3db1df39c255ef0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 09:51:02 +0000 Subject: [PATCH 196/338] fix(vm): bind host share roots before launch --- .../Sources/DorydKit/MachineManager.swift | 139 ++++++++++++++- .../MachineManagerShareAuthorityTests.swift | 168 ++++++++++++++++++ .../DorydKitTests/MachineManagerTests.swift | 8 +- 3 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerShareAuthorityTests.swift diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7e522f20..84338e7e 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -898,6 +898,7 @@ public final class MachineManager: @unchecked Sendable { private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)? #if DEBUG private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)? + private var shareAuthorityPreSpawnTestHook: (@Sendable () throws -> Void)? #endif public init( @@ -1571,7 +1572,11 @@ public final class MachineManager: @unchecked Sendable { : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } - let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil) + let status = try spawnPreparedMachine( + prepared.machine, + shareAuthorities: prepared.shareAuthorities, + launchBinding: nil + ) if let lifecycle, status.state != .starting { _ = completeCommittedLifecycle( lifecycle, @@ -1676,7 +1681,8 @@ public final class MachineManager: @unchecked Sendable { devices: resolved.resolvedPlan.devices, planRevision: resolved.resolvedPlan.planRevision, planSHA256: resolved.resolvedPlanSHA256, - preSpawnAuthorization: preSpawnAuthorization + preSpawnAuthorization: preSpawnAuthorization, + shareAuthorities: prepared.shareAuthorities ) defer { pendingResolvedStart = nil } let operation = registry.start(resolved.backendPlan) @@ -1807,6 +1813,7 @@ public final class MachineManager: @unchecked Sendable { pendingResolvedStart = nil return MachineBackendRuntimeObservation(try spawnPreparedMachine( authorization.machine, + shareAuthorities: authorization.shareAuthorities, launchBinding: binding, preSpawnAuthorization: authorization.preSpawnAuthorization, resolvedPlan: authorization.plan @@ -2184,6 +2191,9 @@ public final class MachineManager: @unchecked Sendable { ) } try Self.validateLaunchConfiguration(runtimeMachine) + let shareAuthorities = try Self.captureShareRuntimeAuthorities( + runtimeMachine.shares + ) try validateManagedMachineArtifacts(runtimeMachine) if !requiresAuthoritativeDefinition { try validateRuntimeAvailability(runtimeMachine) @@ -2199,7 +2209,8 @@ public final class MachineManager: @unchecked Sendable { authoritativeMachine: authoritativeMachine, definition: authoritativeDefinition, canonicalDefinitionData: definitionData, - authoritativeLegacyData: authoritativeLegacyData + authoritativeLegacyData: authoritativeLegacyData, + shareAuthorities: shareAuthorities ) } @@ -2247,6 +2258,7 @@ public final class MachineManager: @unchecked Sendable { private func spawnPreparedMachine( _ preparedMachine: DoryMachineConfiguration, + shareAuthorities: [DoryMachineShareRuntimeAuthority], launchBinding: MachineBackendLaunchBinding?, preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil, resolvedPlan: DoryResolvedMachinePlan? = nil @@ -2299,8 +2311,16 @@ public final class MachineManager: @unchecked Sendable { launchBinding: launchBinding ) } +#if DEBUG + try shareAuthorityPreSpawnTestHook?() +#endif + var launchMachine = entry.configuration + launchMachine.shares = try Self.revalidateShareRuntimeAuthorities( + shareAuthorities, + expectedShares: entry.configuration.shares + ) processConfiguration = try self.processConfiguration( - for: entry.configuration, + for: launchMachine, handoffPath: handoffPath, resolvedLaunchBinding: launchBinding ) @@ -7114,14 +7134,100 @@ public final class MachineManager: @unchecked Sendable { guard tags.insert(share.tag).inserted else { throw MachineManagerError.invalidShare(share.tag) } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: share.hostPath, isDirectory: &isDirectory), - isDirectory.boolValue else { - throw MachineManagerError.invalidShare(share.hostPath) + } + _ = try captureShareRuntimeAuthorities(shares) + } + + /// Captures the directory object behind each persisted share path. The canonical path is what + /// crosses the helper boundary; the original spelling is retained only so a symlink or mount + /// alias cannot be redirected between preparation and spawn. + private static func captureShareRuntimeAuthorities( + _ shares: [DoryMachineShareConfiguration] + ) throws -> [DoryMachineShareRuntimeAuthority] { + try shares.map { share in + let canonicalPath = try canonicalShareRoot(share.hostPath) + let identity = try openedShareRootIdentity( + canonicalPath: canonicalPath, + reportedPath: share.hostPath + ) + return DoryMachineShareRuntimeAuthority( + share: share, + canonicalPath: canonicalPath, + device: identity.device, + inode: identity.inode + ) + } + } + + /// Reopens every root immediately before helper argument construction. This does not infer + /// authority from a path that happens to exist: both the original resolver spelling and the + /// canonical directory entry must still name the exact captured inode. + private static func revalidateShareRuntimeAuthorities( + _ authorities: [DoryMachineShareRuntimeAuthority], + expectedShares: [DoryMachineShareConfiguration] + ) throws -> [DoryMachineShareConfiguration] { + guard authorities.map(\.share) == expectedShares else { + throw MachineManagerError.persistence( + "machine share configuration changed after launch validation" + ) + } + return try authorities.map { authority in + guard try canonicalShareRoot(authority.share.hostPath) + == authority.canonicalPath else { + throw MachineManagerError.invalidShare(authority.share.hostPath) + } + let identity = try openedShareRootIdentity( + canonicalPath: authority.canonicalPath, + reportedPath: authority.share.hostPath + ) + guard identity.device == authority.device, + identity.inode == authority.inode else { + throw MachineManagerError.invalidShare(authority.share.hostPath) } + var launchShare = authority.share + launchShare.hostPath = authority.canonicalPath + return launchShare } } + private static func canonicalShareRoot(_ path: String) throws -> String { + var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard realpath(path, &resolved) != nil else { + throw MachineManagerError.invalidShare(path) + } + let canonicalBytes = resolved.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + let canonical = String(decoding: canonicalBytes, as: UTF8.self) + guard canonical.hasPrefix("/"), canonical != "/" else { + throw MachineManagerError.invalidShare(path) + } + return canonical + } + + private static func openedShareRootIdentity( + canonicalPath: String, + reportedPath: String + ) throws -> (device: UInt64, inode: UInt64) { + let descriptor = open( + canonicalPath, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { + throw MachineManagerError.invalidShare(reportedPath) + } + defer { close(descriptor) } + var opened = stat() + var named = stat() + guard fstat(descriptor, &opened) == 0, + (opened.st_mode & S_IFMT) == S_IFDIR, + lstat(canonicalPath, &named) == 0, + (named.st_mode & S_IFMT) == S_IFDIR, + opened.st_dev == named.st_dev, + opened.st_ino == named.st_ino else { + throw MachineManagerError.invalidShare(reportedPath) + } + return (UInt64(opened.st_dev), UInt64(opened.st_ino)) + } + private static func validateEnvironment(_ environment: [String: String]) throws { for (key, value) in environment { guard isValidEnvironmentKey(key) else { @@ -8716,6 +8822,14 @@ public final class MachineManager: @unchecked Sendable { private func injectLifecycleFault(_ point: MachineLifecycleFaultPoint) throws { try lifecycleFaultInjector?(point) } + + func installShareAuthorityPreSpawnHookForTesting( + _ hook: @escaping @Sendable () throws -> Void + ) { + operationLock.lock() + shareAuthorityPreSpawnTestHook = hook + operationLock.unlock() + } #endif private static func recoverInterruptedLifecycleOperations( @@ -9823,6 +9937,14 @@ private struct PreparedMachineStart { var definition: DoryVirtualMachineDefinition? var canonicalDefinitionData: Data? var authoritativeLegacyData: Data? + var shareAuthorities: [DoryMachineShareRuntimeAuthority] +} + +private struct DoryMachineShareRuntimeAuthority { + var share: DoryMachineShareConfiguration + var canonicalPath: String + var device: UInt64 + var inode: UInt64 } private struct MachineWorkspaceAuthority { @@ -9845,6 +9967,7 @@ private struct PendingResolvedMachineStart { var planRevision: UInt64 var planSHA256: String var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization + var shareAuthorities: [DoryMachineShareRuntimeAuthority] } private struct MachineEntry { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerShareAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerShareAuthorityTests.swift new file mode 100644 index 00000000..381de99b --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerShareAuthorityTests.swift @@ -0,0 +1,168 @@ +import Darwin +import Foundation +@testable import DorydKit +import XCTest + +final class MachineManagerShareAuthorityTests: XCTestCase { + func testShareRootReplacementBeforeSpawnIsRejected() throws { + let base = temporaryRoot("replacement") + let share = base + "/share" + let displaced = base + "/captured-share" + try FileManager.default.createDirectory( + atPath: share, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(atPath: base) } + + let starter = LockedCounter() + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: base + "/state", + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: false + ), + processStarter: { _ in starter.increment() } + ) + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: share, + guestPath: "/workspace" + )] + )) + manager.installShareAuthorityPreSpawnHookForTesting { + try FileManager.default.moveItem(atPath: share, toPath: displaced) + try FileManager.default.createDirectory(atPath: share, withIntermediateDirectories: false) + } + + XCTAssertThrowsError(try manager.start(id: "dev")) { error in + XCTAssertEqual(error as? MachineManagerError, .invalidShare(share)) + } + XCTAssertEqual(starter.value, 0) + XCTAssertNil(manager.status(id: "dev")?.pid) + } + + func testHelperReceivesCapturedCanonicalShareRoot() throws { + let base = temporaryRoot("canonical") + let realShare = base + "/real-share" + let aliasShare = base + "/share-alias" + let argumentsPath = base + "/arguments" + let helperPath = base + "/helper.sh" + try FileManager.default.createDirectory( + atPath: realShare, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + atPath: aliasShare, + withDestinationPath: realShare + ) + try """ + #!/bin/sh + printf '%s\n' "$@" > "\(argumentsPath).tmp" + mv "\(argumentsPath).tmp" "\(argumentsPath)" + sleep 30 + """.write(toFile: helperPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: helperPath + ) + defer { try? FileManager.default.removeItem(atPath: base) } + + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: helperPath, + stateDirectory: base + "/state", + requiresReadyHandoff: false + )) + defer { try? manager.delete(id: "dev") } + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: aliasShare, + guestPath: "/workspace", + readOnly: true + )] + )) + + _ = try manager.start(id: "dev") + let rows = try waitForLines(at: argumentsPath) + let shareFlag = try XCTUnwrap(rows.firstIndex(of: "--share")) + let decoded = try DoryMachineShareConfiguration(argument: rows[shareFlag + 1]) + XCTAssertEqual( + decoded.hostPath, + try canonicalPath(realShare) + ) + XCTAssertEqual(decoded.guestPath, "/workspace") + XCTAssertTrue(decoded.readOnly) + } + + func testFilesystemRootCannotBeShared() { + let state = temporaryRoot("root") + defer { try? FileManager.default.removeItem(atPath: state) } + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: state + )) + XCTAssertThrowsError(try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + shares: [DoryMachineShareConfiguration( + tag: "root", + hostPath: "/", + guestPath: "/host" + )] + ))) { error in + XCTAssertEqual(error as? MachineManagerError, .invalidShare("/")) + } + } + + private func temporaryRoot(_ suffix: String) -> String { + "/tmp/dory-share-authority-\(suffix)-\(getpid())-\(UUID().uuidString)" + } + + private func canonicalPath(_ path: String) throws -> String { + var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard realpath(path, &resolved) != nil else { + throw POSIXError(.ENOENT) + } + let bytes = resolved.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + + private func waitForLines(at path: String) throws -> [String] { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let text = try? String(contentsOfFile: path, encoding: .utf8), + !text.isEmpty { + return text.split(separator: "\n").map(String.init) + } + usleep(10_000) + } + throw NSError( + domain: "MachineManagerShareAuthorityTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "helper arguments were not recorded"] + ) + } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func increment() { + lock.withLock { count += 1 } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 3233982d..fdf8c591 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -2084,11 +2084,17 @@ final class MachineManagerTests: XCTestCase { let rows = args.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) let shareFlagIndex = try XCTUnwrap(rows.firstIndex(of: "--share")) let wireShare = rows[shareFlagIndex + 1] + var resolvedSharePath = [CChar](repeating: 0, count: Int(PATH_MAX)) + XCTAssertNotNil(realpath(sharePath, &resolvedSharePath)) + let canonicalSharePath = String( + decoding: resolvedSharePath.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, + as: UTF8.self + ) XCTAssertEqual( try DoryMachineShareConfiguration(argument: wireShare), DoryMachineShareConfiguration( tag: "src", - hostPath: sharePath, + hostPath: canonicalSharePath, guestPath: "/workspace/client: app 日本語" ) ) From c0d4c629928ae05f2e25d30da3f6173748e12b27 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:09:17 +0000 Subject: [PATCH 197/338] feat(vm): persist host share authorization --- Dory/Models/AppStore.swift | 8 +- Dory/Runtime/Doryd/DorydClient.swift | 9 +- DoryTests/DorydClientTests.swift | 13 +- .../DoryMachineConfigurationMigration.swift | 49 +++++- .../Sources/DorydKit/DorydService.swift | 22 ++- .../Sources/DorydKit/MachineManager.swift | 117 +++++++++++++- dory-core-swift/Sources/dorydctl/main.swift | 45 ++++-- .../DorydKitTests/DorydServiceTests.swift | 35 +++++ .../MachineManagerShareAuthorityTests.swift | 148 ++++++++++++++++++ .../DorydKitTests/MachineManagerTests.swift | 17 ++ 10 files changed, 432 insertions(+), 31 deletions(-) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index d388b652..42f855a5 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5237,11 +5237,17 @@ final class AppStore { let host = mount.host.trimmingCharacters(in: .whitespacesAndNewlines) let guest = mount.guest.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, !guest.isEmpty else { return nil } + let bookmark = try? URL(fileURLWithPath: host).bookmarkData( + options: [.minimalBookmark], + includingResourceValuesForKeys: [.fileResourceIdentifierKey, .volumeIdentifierKey], + relativeTo: nil + ) return DorydMachineShareConfiguration( tag: "doryapp\(index)", hostPath: host, guestPath: guest, - readOnly: mount.readOnly + readOnly: mount.readOnly, + authorizationBookmark: bookmark ) } } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 19c40843..1b7666dd 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -81,14 +81,19 @@ nonisolated struct DorydMachineShareConfiguration: Sendable, Equatable { var hostPath: String var guestPath: String var readOnly: Bool + var authorizationBookmark: Data? = nil var xpcDictionary: NSDictionary { - [ + let dictionary = NSMutableDictionary(dictionary: [ "tag": tag, "hostPath": hostPath, "guestPath": guestPath, "readOnly": readOnly, - ] + ]) + if let authorizationBookmark { + dictionary["authorizationBookmark"] = authorizationBookmark as NSData + } + return dictionary } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 07462cf8..6531c69c 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -393,6 +393,7 @@ struct DorydClientTests { let dockerAgentPorts = try await client.dockerAgentPorts() let dockerAgentTelemetry = try await client.dockerAgentTelemetry() let stopped = try await client.engineStop() + let shareBookmark = Data([0x44, 0x4f, 0x52, 0x59]) let createdMachine = try await client.machineCreate(DorydMachineConfiguration( id: "dev", kernelPath: "/tmp/kernel", @@ -402,7 +403,13 @@ struct DorydClientTests { address: "192.168.215.40", displayMode: .desktop, shares: [ - DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), + DorydMachineShareConfiguration( + tag: "src", + hostPath: "/Users/me/src", + guestPath: "/workspace/src", + readOnly: true, + authorizationBookmark: shareBookmark + ), ], typedSettings: DorydMachineTypedSettings( guestIdentityIntent: DoryVMGuestIdentityIntent( @@ -527,6 +534,10 @@ struct DorydClientTests { #expect(dockerAgentPorts.added == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentTelemetry.memTotalKB == 2048) #expect(stopped == DorydCommandResult(ok: true, message: "")) + let createShares = try #require( + service.latestMachineCreateConfig?["shares"] as? [NSDictionary] + ) + #expect(createShares.first?["authorizationBookmark"] as? Data == shareBookmark) #expect(createdMachine.state == "created") #expect(createdMachine.displayMode == .desktop) #expect(startedMachine.pid == 1234) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index e7a78693..a7ff09b1 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -70,10 +70,22 @@ public struct DoryMachineConfigurationArtifactBinding: Sendable, Equatable { public struct DoryMachineConfigurationShareBinding: Sendable, Equatable { public var reference: DoryVMResolverReference public var hostPath: String + public var authorizationBookmark: Data? + public var authorizationVolumeUUID: String? + public var authorizationFileIdentifier: Data? - public init(reference: DoryVMResolverReference, hostPath: String) { + public init( + reference: DoryVMResolverReference, + hostPath: String, + authorizationBookmark: Data? = nil, + authorizationVolumeUUID: String? = nil, + authorizationFileIdentifier: Data? = nil + ) { self.reference = reference self.hostPath = hostPath + self.authorizationBookmark = authorizationBookmark + self.authorizationVolumeUUID = authorizationVolumeUUID + self.authorizationFileIdentifier = authorizationFileIdentifier } } @@ -156,6 +168,12 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { shareBindings.first { $0.reference == reference }?.hostPath } + public func shareBinding( + for reference: DoryVMResolverReference + ) -> DoryMachineConfigurationShareBinding? { + shareBindings.first { $0.reference == reference } + } + /// Reconstruct the legacy projection. Unsupported v2-only changes fail rather than being /// silently dropped, while fields represented by both contracts are written from typed v2 /// values. An untouched definition reconstructs an equal legacy value and canonical bytes. @@ -240,14 +258,17 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { try applySandboxPolicy(to: &environment) let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in - guard let hostPath = hostPath(for: share.hostLocation) else { + guard let binding = shareBinding(for: share.hostLocation) else { throw DoryMachineConfigurationMigrationError.unresolvedShare(share.hostLocation) } let migrated = DoryMachineShareConfiguration( tag: share.id, - hostPath: hostPath, + hostPath: binding.hostPath, guestPath: share.guestMountPath, - readOnly: share.readOnly + readOnly: share.readOnly, + authorizationBookmark: binding.authorizationBookmark, + authorizationVolumeUUID: binding.authorizationVolumeUUID, + authorizationFileIdentifier: binding.authorizationFileIdentifier ) do { try migrated.validate() @@ -610,13 +631,29 @@ public enum DoryMachineConfigurationMigrationBridge { var shareBindings: [DoryMachineConfigurationShareBinding] = [] let shares = configuration.shares.enumerated().map { index, share in + let hostAuthorityIdentity: String + if let volumeUUID = share.authorizationVolumeUUID, + let fileIdentifier = share.authorizationFileIdentifier { + hostAuthorityIdentity = volumeUUID.lowercased() + "\u{0}" + + fileIdentifier.map { String(format: "%02x", $0) }.joined() + } else if let bookmark = share.authorizationBookmark { + hostAuthorityIdentity = SHA256.hash(data: bookmark) + .map { String(format: "%02x", $0) } + .joined() + } else { + hostAuthorityIdentity = share.hostPath + } let reference = stableReference( namespace: "legacy-share", - identity: configuration.id + "\u{0}\(index)\u{0}" + share.tag + "\u{0}" + share.hostPath + identity: configuration.id + "\u{0}\(index)\u{0}" + share.tag + "\u{0}" + + hostAuthorityIdentity ) shareBindings.append(DoryMachineConfigurationShareBinding( reference: reference, - hostPath: share.hostPath + hostPath: share.hostPath, + authorizationBookmark: share.authorizationBookmark, + authorizationVolumeUUID: share.authorizationVolumeUUID, + authorizationFileIdentifier: share.authorizationFileIdentifier )) return DoryVMShare( id: share.tag, diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 8f8478c0..5bf4f0fb 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1744,7 +1744,11 @@ private extension NSDictionary { tag: try row.requiredString("tag"), hostPath: try row.requiredString("hostPath"), guestPath: try row.requiredString("guestPath"), - readOnly: row.optionalBool("readOnly") ?? false + readOnly: row.optionalBool("readOnly") ?? false, + authorizationBookmark: try row.optionalData( + "authorizationBookmark", + maximumBytes: 1_048_576 + ) ) try share.validate() return share @@ -1755,6 +1759,22 @@ private extension NSDictionary { self[key] as? String } + func optionalData(_ key: String, maximumBytes: Int) throws -> Data? { + guard let value = self[key] else { return nil } + let data: Data + if let swiftData = value as? Data { + data = swiftData + } else if let nsData = value as? NSData { + data = nsData as Data + } else { + throw XPCRemoteConfigError.invalid(key) + } + guard !data.isEmpty, data.count <= maximumBytes else { + throw XPCRemoteConfigError.invalid(key) + } + return data + } + func optionalBool(_ key: String) -> Bool? { if let value = self[key] as? Bool { return value diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 84338e7e..fef08cb4 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -92,17 +92,30 @@ public struct DoryMachineShareConfiguration: Sendable, Equatable, Hashable, Coda public var hostPath: String public var guestPath: String public var readOnly: Bool + /// Persistent opaque identity created by the user-facing selector. It is daemon-resolved at + /// every start and is deliberately omitted from helper arguments and public status payloads. + public var authorizationBookmark: Data? + /// Daemon-sealed object identity. These fields prevent bookmark path fallback from silently + /// authorizing a replacement directory when the selected directory moved or was removed. + public var authorizationVolumeUUID: String? + public var authorizationFileIdentifier: Data? public init( tag: String, hostPath: String, guestPath: String, - readOnly: Bool = false + readOnly: Bool = false, + authorizationBookmark: Data? = nil, + authorizationVolumeUUID: String? = nil, + authorizationFileIdentifier: Data? = nil ) { self.tag = tag self.hostPath = hostPath self.guestPath = guestPath self.readOnly = readOnly + self.authorizationBookmark = authorizationBookmark + self.authorizationVolumeUUID = authorizationVolumeUUID + self.authorizationFileIdentifier = authorizationFileIdentifier } public init(argument: String) throws { @@ -184,6 +197,15 @@ public struct DoryMachineShareConfiguration: Sendable, Equatable, Hashable, Coda guard guestPath.hasPrefix("/"), guestPath != "/", !guestPath.contains("\0") else { throw MachineManagerError.invalidShare(guestPath) } + guard authorizationBookmark.map({ !$0.isEmpty && $0.count <= 1_048_576 }) ?? true else { + throw MachineManagerError.invalidShare(hostPath) + } + guard authorizationVolumeUUID.map({ !$0.isEmpty && $0.utf8.count <= 128 }) ?? true, + authorizationFileIdentifier.map({ !$0.isEmpty && $0.count <= 4_096 }) ?? true, + authorizationBookmark != nil + || (authorizationVolumeUUID == nil && authorizationFileIdentifier == nil) else { + throw MachineManagerError.invalidShare(hostPath) + } } private static func encodeWireField(_ value: String) -> String { @@ -1133,6 +1155,7 @@ public final class MachineManager: @unchecked Sendable { "sandbox policy is supported only for headless Linux machines" ) } + machine.shares = try Self.sealShareAuthorizations(machine.shares) try Self.validateLaunchConfiguration(machine) lock.lock() let exists = machines[machine.id] != nil || deletingMachineIDs.contains(machine.id) @@ -3029,7 +3052,7 @@ public final class MachineManager: @unchecked Sendable { updated.address = try Self.normalizedAddress(address) } if updatesShares { - updated.shares = shares ?? [] + updated.shares = try Self.sealShareAuthorizations(shares ?? []) } if updatesEnvironment { updated.environment = environment ?? [:] @@ -7145,7 +7168,8 @@ public final class MachineManager: @unchecked Sendable { _ shares: [DoryMachineShareConfiguration] ) throws -> [DoryMachineShareRuntimeAuthority] { try shares.map { share in - let canonicalPath = try canonicalShareRoot(share.hostPath) + let authorizedPath = try authorizedShareRoot(share) + let canonicalPath = try canonicalShareRoot(authorizedPath) let identity = try openedShareRootIdentity( canonicalPath: canonicalPath, reportedPath: share.hostPath @@ -7159,6 +7183,30 @@ public final class MachineManager: @unchecked Sendable { } } + private static func sealShareAuthorizations( + _ shares: [DoryMachineShareConfiguration] + ) throws -> [DoryMachineShareConfiguration] { + try shares.map { share in + guard let bookmark = share.authorizationBookmark else { return share } + let resolution = try resolveShareBookmark( + bookmark, + reportedPath: share.hostPath + ) + guard share.authorizationVolumeUUID.map({ + $0 == resolution.volumeUUID + }) ?? true, + share.authorizationFileIdentifier.map({ + $0 == resolution.fileIdentifier + }) ?? true else { + throw MachineManagerError.invalidShare(share.hostPath) + } + var sealed = share + sealed.authorizationVolumeUUID = resolution.volumeUUID + sealed.authorizationFileIdentifier = resolution.fileIdentifier + return sealed + } + } + /// Reopens every root immediately before helper argument construction. This does not infer /// authority from a path that happens to exist: both the original resolver spelling and the /// canonical directory entry must still name the exact captured inode. @@ -7172,7 +7220,8 @@ public final class MachineManager: @unchecked Sendable { ) } return try authorities.map { authority in - guard try canonicalShareRoot(authority.share.hostPath) + let authorizedPath = try authorizedShareRoot(authority.share) + guard try canonicalShareRoot(authorizedPath) == authority.canonicalPath else { throw MachineManagerError.invalidShare(authority.share.hostPath) } @@ -7203,6 +7252,66 @@ public final class MachineManager: @unchecked Sendable { return canonical } + private static func authorizedShareRoot( + _ share: DoryMachineShareConfiguration + ) throws -> String { + guard let bookmark = share.authorizationBookmark else { + return share.hostPath + } + guard let expectedVolumeUUID = share.authorizationVolumeUUID, + let expectedFileIdentifier = share.authorizationFileIdentifier else { + throw MachineManagerError.invalidShare(share.hostPath) + } + let resolution = try resolveShareBookmark( + bookmark, + reportedPath: share.hostPath + ) + guard resolution.volumeUUID == expectedVolumeUUID, + resolution.fileIdentifier == expectedFileIdentifier else { + throw MachineManagerError.invalidShare(share.hostPath) + } + return resolution.path + } + + private static func resolveShareBookmark( + _ bookmark: Data, + reportedPath: String + ) throws -> (path: String, volumeUUID: String, fileIdentifier: Data) { + var stale = false + let resolved: URL + do { + resolved = try URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI, .withoutMounting], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + } catch { + throw MachineManagerError.invalidShare(reportedPath) + } + // A move commonly marks a bookmark stale even when it resolves the original object. The + // daemon-sealed volume/file identity decides whether that resolution is authorized; stale + // path fallback to a replacement object therefore remains fail-closed. + _ = stale + let values: URLResourceValues + do { + values = try resolved.resourceValues(forKeys: [ + .fileResourceIdentifierKey, + .volumeUUIDStringKey, + ]) + } catch { + throw MachineManagerError.invalidShare(reportedPath) + } + guard let volumeUUID = values.volumeUUIDString, + !volumeUUID.isEmpty, + let identifier = values.fileResourceIdentifier as? NSData, + identifier.length > 0, + identifier.length <= 4_096 else { + throw MachineManagerError.invalidShare(reportedPath) + } + return (resolved.path, volumeUUID, identifier as Data) + } + private static func openedShareRootIdentity( canonicalPath: String, reportedPath: String diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 7b02cf54..6fe13588 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -27,6 +27,33 @@ enum DorydCtlError: Error, CustomStringConvertible { } } +private func machineShareXPCDictionary( + _ share: DoryMachineShareConfiguration +) throws -> NSDictionary { + let bookmark: Data + do { + bookmark = try URL(fileURLWithPath: share.hostPath).bookmarkData( + options: [.minimalBookmark], + includingResourceValuesForKeys: [ + .fileResourceIdentifierKey, + .volumeIdentifierKey, + ], + relativeTo: nil + ) + } catch { + throw DorydCtlError.usage( + "cannot authorize host share \(share.hostPath): \(error)" + ) + } + return [ + "tag": share.tag, + "hostPath": share.hostPath, + "guestPath": share.guestPath, + "readOnly": share.readOnly, + "authorizationBookmark": bookmark as NSData, + ] +} + final class ReplyBox: @unchecked Sendable { private let lock = NSLock() private let semaphore = DispatchSemaphore(value: 0) @@ -1183,14 +1210,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { config["address"] = address } if !shares.isEmpty { - config["shares"] = shares.map { share in - [ - "tag": share.tag, - "hostPath": share.hostPath, - "guestPath": share.guestPath, - "readOnly": share.readOnly, - ] as NSDictionary - } + config["shares"] = try shares.map(machineShareXPCDictionary) } mergeMachineTypedSettings(typedSettings, into: &config) if let sandboxPolicy { @@ -1400,14 +1420,7 @@ func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) thro let shareValues = try cursor.optionValues("--share") if !shareValues.isEmpty { let shares = try shareValues.map { try DoryMachineShareConfiguration(argument: $0) } - config["shares"] = shares.map { share in - [ - "tag": share.tag, - "hostPath": share.hostPath, - "guestPath": share.guestPath, - "readOnly": share.readOnly, - ] as NSDictionary - } + config["shares"] = try shares.map(machineShareXPCDictionary) } if cursor.values.contains("--clear-shares") { guard config["shares"] == nil else { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 4ce480a8..babdf869 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1076,6 +1076,14 @@ final class DorydServiceTests: XCTestCase { let base = "/tmp/doryd-service-machine-\(getpid())-\(UInt32.random(in: 0.. String { "/tmp/dory-share-authority-\(suffix)-\(getpid())-\(UUID().uuidString)" } @@ -152,6 +287,19 @@ final class MachineManagerShareAuthorityTests: XCTestCase { userInfo: [NSLocalizedDescriptionKey: "helper arguments were not recorded"] ) } + + private func writeArgumentRecorder(helperPath: String, outputPath: String) throws { + try """ + #!/bin/sh + printf '%s\n' "$@" > "\(outputPath).tmp" + mv "\(outputPath).tmp" "\(outputPath)" + sleep 30 + """.write(toFile: helperPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: helperPath + ) + } } private final class LockedCounter: @unchecked Sendable { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index fdf8c591..1f1205a0 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -48,6 +48,23 @@ final class MachineManagerTests: XCTestCase { ) XCTAssertThrowsError(try DoryMachineShareConfiguration(argument: "dory-share-v1.invalid")) XCTAssertThrowsError(try DoryMachineShareConfiguration(argument: "{\"tag\":1}")) + + let authorized = DoryMachineShareConfiguration( + tag: "src", + hostPath: "/tmp/src", + guestPath: "/workspace/src", + authorizationBookmark: Data([1, 2, 3]), + authorizationVolumeUUID: "volume", + authorizationFileIdentifier: Data([4, 5, 6]) + ) + let helperShare = try DoryMachineShareConfiguration(argument: authorized.argumentValue) + XCTAssertNil(helperShare.authorizationBookmark) + XCTAssertNil(helperShare.authorizationVolumeUUID) + XCTAssertNil(helperShare.authorizationFileIdentifier) + XCTAssertEqual(try JSONDecoder().decode( + DoryMachineShareConfiguration.self, + from: JSONEncoder().encode(authorized) + ), authorized) } func testCreateStartStopDeleteMachineProcess() throws { From 27adb114d545355a14e5bb60d10ad3589efef3b4 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:12:43 +0000 Subject: [PATCH 198/338] fix(vm): require authorization for new shares --- .../Sources/DorydKit/DorydService.swift | 3 +++ .../Tests/DorydKitTests/DorydServiceTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 5bf4f0fb..de85a115 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -1750,6 +1750,9 @@ private extension NSDictionary { maximumBytes: 1_048_576 ) ) + guard share.authorizationBookmark != nil else { + throw XPCRemoteConfigError.invalid("authorizationBookmark") + } try share.validate() return share } diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index babdf869..dc209257 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1272,6 +1272,20 @@ final class DorydServiceTests: XCTestCase { } wait(for: [malformedShare], timeout: 5) + let pathOnlyShare = expectation(description: "machineUpdate rejects path-only share") + proxy.machineUpdate("dev", config: [ + "shares": [[ + "tag": "src", + "hostPath": share, + "guestPath": "/workspace/src", + ] as NSDictionary], + ]) { ok, _, message in + XCTAssertFalse(ok) + XCTAssertTrue(message.contains("authorizationBookmark"), message) + pathOnlyShare.fulfill() + } + wait(for: [pathOnlyShare], timeout: 5) + let clearAddress = expectation(description: "machineUpdate clear address reply") proxy.machineUpdate("dev", config: [ "address": "", From a79728d6ed487565679827f88c68547ca813746c Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:17:45 +0000 Subject: [PATCH 199/338] fix(vm): preserve stable share tags --- Dory/Features/Machines/MachinesView.swift | 15 ++++++-- Dory/Models/AppStore.swift | 39 +++++++++++++++---- Dory/Runtime/Machines/MachineService.swift | 9 ++++- DoryTests/DorydClientTests.swift | 44 ++++++++++++++++++++-- 4 files changed, 92 insertions(+), 15 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 706e3eb2..88cf42b9 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -615,6 +615,7 @@ private struct MachineEditSheet: View { var host = "" var guest = "" var readOnly = false + var shareTag: String? = nil } @State private var mountRows: [MountRow] = [] @@ -667,7 +668,9 @@ private struct MachineEditSheet: View { runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic networkMode = typedSettings.networkMode ?? .sharedNAT - mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) } + mountRows = settings.mounts.map { + MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly, shareTag: $0.shareTag) + } } private var header: some View { @@ -958,7 +961,12 @@ private struct MachineEditSheet: View { let host = row.host.trimmingCharacters(in: .whitespaces) let guest = row.guest.trimmingCharacters(in: .whitespaces) guard !host.isEmpty, !guest.isEmpty else { return nil } - return MountPair(host: host, guest: guest, readOnly: row.readOnly) + return MountPair( + host: host, + guest: guest, + readOnly: row.readOnly, + shareTag: row.shareTag + ) } typedSettings.networkMode = networkMode if displayMode == .desktop, machine.bootMode != .efi { @@ -973,7 +981,8 @@ private struct MachineEditSheet: View { return MountPair( host: mount.host, guest: updatedHome + String(mount.guest.dropFirst(previousHome.count)), - readOnly: mount.readOnly + readOnly: mount.readOnly, + shareTag: mount.shareTag ) } } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 42f855a5..ac75ea6b 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5229,27 +5229,52 @@ final class AppStore { } nonisolated private static func mountPair(fromDoryd share: DorydMachineShareConfiguration) -> MountPair { - MountPair(host: share.hostPath, guest: share.guestPath, readOnly: share.readOnly) + MountPair( + host: share.hostPath, + guest: share.guestPath, + readOnly: share.readOnly, + shareTag: share.tag + ) } - nonisolated private static func dorydShares(from mounts: [MountPair]) -> [DorydMachineShareConfiguration] { - mounts.enumerated().compactMap { index, mount in + nonisolated static func dorydShares(from mounts: [MountPair]) -> [DorydMachineShareConfiguration] { + var usedTags = Set(mounts.compactMap { mount -> String? in + let tag = mount.shareTag?.trimmingCharacters(in: .whitespacesAndNewlines) + return tag?.isEmpty == false ? tag : nil + }) + var nextGeneratedTag = 0 + var shares: [DorydMachineShareConfiguration] = [] + + for mount in mounts { let host = mount.host.trimmingCharacters(in: .whitespacesAndNewlines) let guest = mount.guest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, !guest.isEmpty else { return nil } + guard !host.isEmpty, !guest.isEmpty else { continue } + let explicitTag = mount.shareTag?.trimmingCharacters(in: .whitespacesAndNewlines) + let tag: String + if let explicitTag, !explicitTag.isEmpty { + tag = explicitTag + } else { + while usedTags.contains("doryapp\(nextGeneratedTag)") { + nextGeneratedTag += 1 + } + tag = "doryapp\(nextGeneratedTag)" + nextGeneratedTag += 1 + usedTags.insert(tag) + } let bookmark = try? URL(fileURLWithPath: host).bookmarkData( options: [.minimalBookmark], includingResourceValuesForKeys: [.fileResourceIdentifierKey, .volumeIdentifierKey], relativeTo: nil ) - return DorydMachineShareConfiguration( - tag: "doryapp\(index)", + shares.append(DorydMachineShareConfiguration( + tag: tag, hostPath: host, guestPath: guest, readOnly: mount.readOnly, authorizationBookmark: bookmark - ) + )) } + return shares } func machineTerminalCommand(_ machine: Machine) -> String? { diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index eed8562a..22d088a1 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -1,6 +1,13 @@ import Foundation -nonisolated struct MountPair: Sendable, Hashable { var host: String; var guest: String; var readOnly: Bool = false } +nonisolated struct MountPair: Sendable, Hashable { + var host: String + var guest: String + var readOnly: Bool = false + /// Stable daemon-owned VirtioFS share identity. New mounts leave this nil until their + /// first write assigns a collision-free tag; subsequent reads and edits preserve it. + var shareTag: String? = nil +} nonisolated struct PortPair: Sendable, Hashable { var host: Int; var guest: Int } nonisolated enum MachineDisplayMode: String, Sendable, Hashable, CaseIterable { case headless diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 6531c69c..beb5d150 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -5,6 +5,23 @@ import Testing @Suite(.serialized) struct DorydClientTests { + @Test func dorydSharesPreserveStableTagsAndAllocateAroundExistingIdentity() { + let mounts = [ + MountPair(host: "/tmp/first", guest: "/workspace/first", shareTag: "doryapp0"), + MountPair(host: "/tmp/new-a", guest: "/workspace/new-a"), + MountPair(host: "/tmp/stable", guest: "/workspace/stable", shareTag: "project-src"), + MountPair(host: "/tmp/new-b", guest: "/workspace/new-b"), + ] + + let shares = AppStore.dorydShares(from: mounts) + + #expect(shares.map(\.tag) == ["doryapp0", "doryapp1", "project-src", "doryapp2"]) + #expect( + AppStore.dorydShares(from: [mounts[2], mounts[0]]).map(\.tag) + == ["project-src", "doryapp0"] + ) + } + @MainActor @Test func machineTransferUsesPrivateStageAndRejectsMalformedEvidence() async throws { let root = URL(fileURLWithPath: "/tmp/dory-client-transfer-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 10:21:43 +0000 Subject: [PATCH 200/338] fix(diagnostics): omit private machine paths --- .../DoryMachineDiagnosticsProjection.swift | 20 +++++++--- ...oryMachineDiagnosticsProjectionTests.swift | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift index cf25c25f..f80ee918 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift @@ -2,10 +2,10 @@ import Foundation /// Produces the machine shape that is safe to print, log, or place in a support bundle. /// -/// Legacy machine configuration can contain arbitrary host/guest environment values. Those values -/// are launch authority, not diagnostics, and an opaque credential cannot be identified reliably -/// from its bytes. The public CLI therefore removes environment containers structurally instead of -/// attempting value-pattern redaction. +/// Legacy machine configuration can contain arbitrary host/guest environment values. Host share +/// and helper-socket paths also reveal private local filesystem structure. Those values are launch +/// authority, not diagnostics, and cannot be made safe through value-pattern redaction. The public +/// CLI therefore removes the corresponding fields structurally. public enum DoryMachineDiagnosticsProjection { private static let environmentKeys: Set = [ "env", @@ -13,6 +13,14 @@ public enum DoryMachineDiagnosticsProjection { "environment_variables", "environmentvariables", ] + private static let hostPathKeys: Set = [ + "hostpath", + "handoffsocketpath", + "agentsocketpath", + "dockerdsocketpath", + "shellsocketpath", + "controlsocketpath", + ] public static func supportSafeMachineStatus(_ status: NSDictionary) -> NSDictionary { sanitize(status) as? NSDictionary ?? [:] @@ -27,7 +35,9 @@ public enum DoryMachineDiagnosticsProjection { let result = NSMutableDictionary(capacity: dictionary.count) for (rawKey, item) in dictionary { guard let key = rawKey as? String else { continue } - if environmentKeys.contains(key.lowercased()) { continue } + let normalizedKey = key.lowercased() + if environmentKeys.contains(normalizedKey) + || hostPathKeys.contains(normalizedKey) { continue } result[key] = sanitize(item) } return result.copy() as? NSDictionary ?? [:] diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift index d1e4256e..1987342c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift @@ -52,4 +52,42 @@ struct DoryMachineDiagnosticsProjectionTests { #expect(projected["environment_variables"] == nil) #expect(projected["environmentVariables"] == nil) } + + @Test("support projection omits host share and daemon socket paths") + func omitsHostPaths() throws { + let privateRoot = "/Users/private-account/Confidential Client" + let status: NSDictionary = [ + "id": "dev", + "handoffSocketPath": privateRoot + "/handoff.sock", + "agentSocketPath": privateRoot + "/agent.sock", + "dockerdSocketPath": privateRoot + "/docker.sock", + "shellSocketPath": privateRoot + "/shell.sock", + "controlSocketPath": privateRoot + "/control.sock", + "shares": [ + [ + "tag": "project-src", + "hostPath": privateRoot, + "guestPath": "/workspace/src", + "readOnly": true, + ] as NSDictionary, + ], + ] + + let projected = DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status) + let data = try JSONSerialization.data(withJSONObject: projected) + let text = String(decoding: data, as: UTF8.self) + let shares = try #require(projected["shares"] as? NSArray) + let share = try #require(shares.firstObject as? NSDictionary) + + #expect(projected["handoffSocketPath"] == nil) + #expect(projected["agentSocketPath"] == nil) + #expect(projected["dockerdSocketPath"] == nil) + #expect(projected["shellSocketPath"] == nil) + #expect(projected["controlSocketPath"] == nil) + #expect(share["hostPath"] == nil) + #expect(share["tag"] as? String == "project-src") + #expect(share["guestPath"] as? String == "/workspace/src") + #expect(share["readOnly"] as? Bool == true) + #expect(!text.contains(privateRoot)) + } } From 6c61a33280b6dae5155376c7c5a0b2228a7bf15b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:26:24 +0000 Subject: [PATCH 201/338] fix(app): reject malformed share status --- Dory/Runtime/Doryd/DorydClient.swift | 73 +++++++++++++++++++++------- DoryTests/DorydClientTests.swift | 51 +++++++++++++++++++ 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 1b7666dd..31eff059 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -2074,6 +2074,9 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let agentHandshake = machineAgentHandshake(from: dictionary) else { return nil } + guard let shares = machineShares(from: dictionary["shares"]) else { + return nil + } return DorydMachineStatus( id: id, state: state, @@ -2099,7 +2102,7 @@ nonisolated final class DorydClient: @unchecked Sendable { installerMediaAttached: (dictionary["installerMediaAttached"] as? Bool) ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue ?? false, - shares: machineShares(from: dictionary["shares"]), + shares: shares, environment: environment, typedSettings: typedSettings.value, runtimeIdentity: runtimeIdentity, @@ -2392,31 +2395,65 @@ nonisolated final class DorydClient: @unchecked Sendable { return identity } - nonisolated private static func machineShares(from value: Any?) -> [DorydMachineShareConfiguration] { - let rows: [NSDictionary] - if let swiftRows = value as? [NSDictionary] { - rows = swiftRows - } else if let nsRows = value as? NSArray { - rows = nsRows.compactMap { $0 as? NSDictionary } - } else { - return [] - } - return rows.compactMap { row in - guard let tag = row["tag"] as? String, + /// An absent field is compatible with an older daemon. Once present, every row is a durable + /// device-identity claim; silently dropping a malformed row could make the next app edit + /// delete a share the user never removed. + nonisolated private static func machineShares( + from value: Any? + ) -> [DorydMachineShareConfiguration]? { + guard let value else { return [] } + guard let rawRows = value as? NSArray else { return nil } + var shares: [DorydMachineShareConfiguration] = [] + var tags: Set = [] + shares.reserveCapacity(rawRows.count) + for rawRow in rawRows { + guard let row = rawRow as? NSDictionary, + let keys = row.allKeys as? [String], + Set(keys).isSubset(of: ["tag", "hostPath", "guestPath", "readOnly", "mode"]), + keys.count == Set(keys).count, + let tag = row["tag"] as? String, + !tag.isEmpty, + tag.utf8.count < 36, + tag.allSatisfy({ + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." + }), + tags.insert(tag).inserted, let hostPath = row["hostPath"] as? String, - let guestPath = row["guestPath"] as? String else { + hostPath.hasPrefix("/"), + !hostPath.contains("\0"), + let guestPath = row["guestPath"] as? String, + guestPath.hasPrefix("/"), + guestPath != "/", + !guestPath.contains("\0") else { + return nil + } + let encodedMode = row["mode"] + guard encodedMode == nil + || (encodedMode as? String).map({ $0 == "ro" || $0 == "rw" }) == true else { + return nil + } + let readOnly: Bool + if let encoded = row["readOnly"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { + return nil + } + readOnly = number.boolValue + } else { + readOnly = (encodedMode as? String) == "ro" + } + if let mode = encodedMode as? String, + (mode == "ro") != readOnly { return nil } - let readOnly = (row["readOnly"] as? Bool) - ?? (row["readOnly"] as? NSNumber)?.boolValue - ?? ((row["mode"] as? String) == "ro") - return DorydMachineShareConfiguration( + shares.append(DorydMachineShareConfiguration( tag: tag, hostPath: hostPath, guestPath: guestPath, readOnly: readOnly - ) + )) } + return shares } nonisolated private static func machineEnvironment(from value: Any?) -> [String: String] { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index beb5d150..67abf133 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -839,6 +839,43 @@ struct DorydClientTests { } } + @Test func machineSharesUseAbsentOnlyCompatibilityAndRejectMalformedClaims() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = try #require((try await client.machineList()).first) + #expect(valid.shares.map(\.tag) == ["src"]) + + service.setMachineShares("dev", nil) + #expect(try await client.machineList().first?.shares == []) + + let base: [String: Any] = [ + "tag": "src", + "hostPath": "/Users/me/src", + "guestPath": "/workspace/src", + "readOnly": true, + ] + let malformed: [Any] = [ + true, + [["tag": "src", "guestPath": "/workspace/src", "readOnly": true]], + [base.merging(["unexpected": true]) { _, new in new }], + [base.merging(["readOnly": "true"]) { _, new in new }], + [base.merging(["mode": "rw"]) { _, new in new }], + [base, base], + ] + for claim in malformed { + service.setMachineShares("dev", claim) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + } + } + @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws { let malformedEvidence = [ "schemaVersion": 1, @@ -3022,6 +3059,20 @@ private final class FakeDorydService: NSObject, DorydControlXPC { machines[machineID] = current.copy() as? NSDictionary } + func setMachineShares(_ machineID: String, _ shares: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let shares { + current["shares"] = shares + } else { + current.removeObject(forKey: "shares") + } + machines[machineID] = current.copy() as? NSDictionary + } + func setMachineAgentHandshake( _ machineID: String, protocolVersion: Any?, From 3de141cb6669a7a85ea1e0fb6d7af1b252855c34 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:33:37 +0000 Subject: [PATCH 202/338] feat(agent): add bounded guest file reads --- dory-core/agent/src/dispatch.rs | 2 + dory-core/agent/src/handler.rs | 2 + dory-core/agent/src/sync_apply.rs | 276 ++++++++++++++++++++++++++- dory-core/pb/proto/agent.proto | 29 +++ dory-core/remote/src/agent_client.rs | 78 +++++++- 5 files changed, 383 insertions(+), 4 deletions(-) diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index 479dfbff..33c54c6f 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -25,6 +25,7 @@ pub fn agent_capabilities() -> Vec { ("exec", 1), ("exec-stdin", 1), ("ports-watch", 1), + ("sync-pull", 1), ("sync-push", 2), ("telemetry", 1), ]; @@ -200,6 +201,7 @@ mod tests { ("exec", 1), ("exec-stdin", 1), ("ports-watch", 1), + ("sync-pull", 1), ("sync-push", 2), ("telemetry", 1), ]; diff --git a/dory-core/agent/src/handler.rs b/dory-core/agent/src/handler.rs index 0738d2d7..9d007763 100644 --- a/dory-core/agent/src/handler.rs +++ b/dory-core/agent/src/handler.rs @@ -27,6 +27,8 @@ pub async fn handle(req_bytes: &[u8]) -> Vec { Some(Method::SyncPutChunk(r)) => wrap(sync_apply::put_chunk(r).await, Res::SyncPutChunk), Some(Method::SyncDelete(r)) => wrap(sync_apply::delete(r).await, Res::SyncDelete), Some(Method::SyncTree(r)) => wrap(sync_apply::tree(r).await, Res::SyncTree), + Some(Method::SyncReadTree(r)) => wrap(sync_apply::read_tree(r).await, Res::SyncReadTree), + Some(Method::SyncGetChunk(r)) => wrap(sync_apply::get_chunk(r).await, Res::SyncGetChunk), Some(Method::Exec(r)) => wrap_exec(exec::run(r).await), Some(Method::SnapshotQuiesce(r)) => AgentResponse { result: Some(Res::SnapshotQuiesce(snapshot_quiesce::run(r).await)), diff --git a/dory-core/agent/src/sync_apply.rs b/dory-core/agent/src/sync_apply.rs index ffe402e4..77f08eb4 100644 --- a/dory-core/agent/src/sync_apply.rs +++ b/dory-core/agent/src/sync_apply.rs @@ -10,8 +10,9 @@ use std::sync::OnceLock; use dory_pb::agent::{ SyncDeleteRequest, SyncDeleteResponse, SyncFileEntry, SyncFileStatusRequest, - SyncFileStatusResponse, SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, - SyncPutChunkResponse, SyncTreeRequest, SyncTreeResponse, + SyncFileStatusResponse, SyncGetChunkRequest, SyncGetChunkResponse, SyncManifestRequest, + SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse, SyncReadTreeRequest, + SyncReadTreeResponse, SyncTreeRequest, SyncTreeResponse, }; const STAGING_DIR: &str = ".dory-sync-tmp"; @@ -37,6 +38,10 @@ pub enum SyncError { ChunkRangeOverflow, #[error("invalid sync tree authority")] InvalidTree, + #[error("invalid sync read authority")] + InvalidRead, + #[error("source file changed during sync read")] + SourceChanged, #[error(transparent)] Io(#[from] std::io::Error), } @@ -50,6 +55,8 @@ impl SyncError { SyncError::HashMismatch => 422, SyncError::ChunkRangeOverflow => 400, SyncError::InvalidTree => 400, + SyncError::InvalidRead => 400, + SyncError::SourceChanged => 409, SyncError::Io(_) => 500, } } @@ -77,6 +84,170 @@ pub async fn manifest(req: SyncManifestRequest) -> Result Result { + let root = canonical_root(&req.root).await?; + let _root_guard = root_lock(&root).write().await; + let snapshot = + tokio::task::spawn_blocking(move || dory_sync::walk_tree_excluding(&root, &[STAGING_DIR])) + .await + .map_err(|error| SyncError::Io(std::io::Error::other(error)))??; + let files = snapshot + .manifest + .entries + .into_iter() + .map(|entry| SyncFileEntry { + path: entry.path, + size: entry.size, + mtime_ns: entry.mtime_ns, + mode: entry.mode, + hash: entry.hash.to_vec(), + }) + .collect(); + let directories = snapshot + .directories + .into_iter() + .map(|entry| dory_pb::agent::SyncDirectoryEntry { + path: entry.path, + mode: entry.mode, + }) + .collect(); + Ok(SyncReadTreeResponse { files, directories }) +} + +/// Return one bounded range from a regular source file. Path confinement rejects absolute, +/// traversal, and symlink components. Size is checked on every request; the host verifies the +/// manifest digest after the last response to detect same-size concurrent mutation. +pub async fn get_chunk(req: SyncGetChunkRequest) -> Result { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + + if req.max_bytes == 0 || req.max_bytes as usize > dory_sync::CHUNK_BYTES { + return Err(SyncError::InvalidRead); + } + let root = canonical_root(&req.root).await?; + let _root_guard = root_lock(&root).read().await; + let _path_guard = path_lock(&root, &req.path).lock().await; + // The agent may run with more privilege than the guest desktop user. Open every component + // relative to directory descriptors with O_NOFOLLOW instead of validating a pathname and then + // reopening it; otherwise a guest-side rename could turn a checked path into an escape. + let root_for_open = root.clone(); + let path_for_open = req.path.clone(); + let source = + tokio::task::spawn_blocking(move || open_regular_beneath(&root_for_open, &path_for_open)) + .await + .map_err(|error| SyncError::Io(std::io::Error::other(error)))??; + let metadata = source.metadata()?; + if !metadata.is_file() || metadata.len() != req.expected_size || req.offset > req.expected_size + { + return Err(SyncError::SourceChanged); + } + + let remaining = req.expected_size - req.offset; + let count = remaining.min(req.max_bytes as u64) as usize; + let mut file = tokio::fs::File::from_std(source); + file.seek(std::io::SeekFrom::Start(req.offset)).await?; + let mut data = vec![0; count]; + file.read_exact(&mut data).await?; + let next_offset = req + .offset + .checked_add(data.len() as u64) + .ok_or(SyncError::ChunkRangeOverflow)?; + let after = file.metadata().await?; + if !after.is_file() || after.len() != req.expected_size { + return Err(SyncError::SourceChanged); + } + Ok(SyncGetChunkResponse { + data, + next_offset, + eof: next_offset == req.expected_size, + }) +} + +#[cfg(unix)] +fn open_regular_beneath(root: &Path, rel: &str) -> Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::path::Component; + + if !root.is_absolute() || rel.is_empty() || rel.starts_with('/') { + return Err(SyncError::PathEscape); + } + + fn open_at( + parent: &std::fs::File, + name: &[u8], + directory: bool, + ) -> Result { + let name = CString::new(name).map_err(|_| SyncError::PathEscape)?; + let mut flags = libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW; + if directory { + flags |= libc::O_DIRECTORY; + } + let descriptor = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ELOOP) { + Err(SyncError::PathEscape) + } else { + Err(SyncError::Io(error)) + }; + } + Ok(unsafe { std::fs::File::from_raw_fd(descriptor) }) + } + + let slash = CString::new("/").expect("static path has no NUL"); + let descriptor = unsafe { + libc::open( + slash.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(SyncError::Io(std::io::Error::last_os_error())); + } + let mut directory = unsafe { std::fs::File::from_raw_fd(descriptor) }; + for component in root.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = open_at(&directory, name.as_bytes(), true)?; + } + _ => return Err(SyncError::PathEscape), + } + } + + let components = rel.split('/').collect::>(); + if components + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + { + return Err(SyncError::PathEscape); + } + for component in &components[..components.len() - 1] { + directory = open_at(&directory, component.as_bytes(), true)?; + } + let file = open_at( + &directory, + components + .last() + .expect("non-empty relative path") + .as_bytes(), + false, + )?; + if !file.metadata()?.is_file() { + return Err(SyncError::SourceChanged); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_regular_beneath(_root: &Path, _rel: &str) -> Result { + Err(SyncError::InvalidRead) +} + pub async fn file_status(req: SyncFileStatusRequest) -> Result { let root = canonical_root(&req.root).await?; let _root_guard = root_lock(&root).read().await; @@ -849,6 +1020,107 @@ mod tests { } } + #[tokio::test] + async fn read_tree_and_chunks_preserve_topology_and_bound_reads() { + let root = TempRoot::new("read-tree"); + fs::create_dir_all(root.path.join("empty/nested")).unwrap(); + fs::create_dir_all(root.path.join(STAGING_DIR)).unwrap(); + fs::write(root.path.join(STAGING_DIR).join("private"), b"hidden").unwrap(); + fs::write(root.path.join("hello.txt"), b"hello").unwrap(); + + let snapshot = read_tree(SyncReadTreeRequest { root: root.root() }) + .await + .unwrap(); + assert_eq!( + snapshot + .files + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["hello.txt"] + ); + assert_eq!( + snapshot + .directories + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["empty", "empty/nested"] + ); + + let first = get_chunk(SyncGetChunkRequest { + root: root.root(), + path: "hello.txt".into(), + offset: 0, + max_bytes: 2, + expected_size: 5, + }) + .await + .unwrap(); + assert_eq!(first.data, b"he"); + assert_eq!(first.next_offset, 2); + assert!(!first.eof); + + let last = get_chunk(SyncGetChunkRequest { + root: root.root(), + path: "hello.txt".into(), + offset: first.next_offset, + max_bytes: dory_sync::CHUNK_BYTES as u32, + expected_size: 5, + }) + .await + .unwrap(); + assert_eq!(last.data, b"llo"); + assert_eq!(last.next_offset, 5); + assert!(last.eof); + + fs::write(root.path.join("hello.txt"), b"changed").unwrap(); + assert!(matches!( + get_chunk(SyncGetChunkRequest { + root: root.root(), + path: "hello.txt".into(), + offset: 0, + max_bytes: 1, + expected_size: 5, + }) + .await, + Err(SyncError::SourceChanged) + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn read_chunks_reject_escape_symlinks_and_unbounded_requests() { + use std::os::unix::fs::symlink; + + let root = TempRoot::new("read-reject"); + fs::write(root.path.join("real"), b"content").unwrap(); + symlink("real", root.path.join("alias")).unwrap(); + + for path in ["../outside", "/etc/passwd", "alias"] { + assert!(get_chunk(SyncGetChunkRequest { + root: root.root(), + path: path.into(), + offset: 0, + max_bytes: 1, + expected_size: 7, + }) + .await + .is_err()); + } + assert!(matches!( + get_chunk(SyncGetChunkRequest { + root: root.root(), + path: "real".into(), + offset: 0, + max_bytes: dory_sync::CHUNK_BYTES as u32 + 1, + expected_size: 7, + }) + .await, + Err(SyncError::InvalidRead) + )); + } + #[cfg(unix)] #[tokio::test] async fn tree_reconciles_type_conflicts_extras_and_empty_directories() { diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index be460244..06b3d4e8 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -176,6 +176,31 @@ message SyncTreeResponse { uint32 directories_created = 2; } +// Read-only counterpart used for guest-to-host transfers. The manifest is a point-in-time content +// authority; the host must hash the fully assembled destination and reject it if guest bytes change +// while they are being read. Empty directories are explicit so a pull never invents sentinel files. +message SyncReadTreeRequest { string root = 1; } +message SyncReadTreeResponse { + repeated SyncFileEntry files = 1; + repeated SyncDirectoryEntry directories = 2; +} + +// Read one bounded range from a regular file observed in SyncReadTreeResponse. `expected_size` +// makes growth/truncation fail before bytes are accepted; the manifest SHA-256 remains the final +// authority for same-size concurrent mutation. Responses are capped by the agent at CHUNK_BYTES. +message SyncGetChunkRequest { + string root = 1; + string path = 2; + uint64 offset = 3; + uint32 max_bytes = 4; + uint64 expected_size = 5; +} +message SyncGetChunkResponse { + bytes data = 1; + uint64 next_offset = 2; + bool eof = 3; +} + message AgentRequest { oneof method { ClockSyncRequest clock_sync = 1; @@ -189,6 +214,8 @@ message AgentRequest { ExecRequest exec = 9; SnapshotQuiesceRequest snapshot_quiesce = 10; SyncTreeRequest sync_tree = 11; + SyncReadTreeRequest sync_read_tree = 12; + SyncGetChunkRequest sync_get_chunk = 13; } } @@ -206,5 +233,7 @@ message AgentResponse { ExecResponse exec = 10; SnapshotQuiesceResponse snapshot_quiesce = 11; SyncTreeResponse sync_tree = 12; + SyncReadTreeResponse sync_read_tree = 13; + SyncGetChunkResponse sync_get_chunk = 14; } } diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 3295cf70..3006ba62 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -13,8 +13,9 @@ use dory_pb::agent::{ self, agent_request::Method, agent_response::Result as Res, AgentRequest, AgentResponse, ClockSyncRequest, ExecEnv, ExecRequest, ExecResponse, InfoRequest, PortsWatchRequest, SnapshotQuiesceRequest, SnapshotQuiesceResponse, SyncDeleteRequest, SyncDeleteResponse, - SyncFileStatusRequest, SyncFileStatusResponse, SyncManifestRequest, SyncManifestResponse, - SyncPutChunkRequest, SyncPutChunkResponse, TelemetryRequest, TelemetryResponse, + SyncFileStatusRequest, SyncFileStatusResponse, SyncGetChunkRequest, SyncGetChunkResponse, + SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse, + SyncReadTreeRequest, SyncReadTreeResponse, TelemetryRequest, TelemetryResponse, }; use dory_proto::handshake::{handshake, Hello}; use dory_proto::mux::Mux; @@ -258,6 +259,32 @@ impl AgentClient { _ => Err(RemoteError::UnexpectedVariant), } } + + pub async fn sync_read_tree( + &self, + req: SyncReadTreeRequest, + ) -> Result { + match self + .call_with_deadline(Method::SyncReadTree(req), SYNC_MANIFEST_DEADLINE) + .await? + { + Res::SyncReadTree(response) => Ok(response), + _ => Err(RemoteError::UnexpectedVariant), + } + } + + pub async fn sync_get_chunk( + &self, + req: SyncGetChunkRequest, + ) -> Result { + match self + .call_with_deadline(Method::SyncGetChunk(req), SYNC_IO_DEADLINE) + .await? + { + Res::SyncGetChunk(response) => Ok(response), + _ => Err(RemoteError::UnexpectedVariant), + } + } } #[cfg(test)] @@ -311,6 +338,29 @@ mod tests { stdout_truncated: false, stderr_truncated: false, }), + Some(Method::SyncReadTree(_)) => Res::SyncReadTree(agent::SyncReadTreeResponse { + files: vec![agent::SyncFileEntry { + path: "report.txt".into(), + size: 6, + mtime_ns: 1, + mode: 0o644, + hash: vec![7; dory_sync::HASH_LEN], + }], + directories: vec![agent::SyncDirectoryEntry { + path: "empty".into(), + mode: 0o755, + }], + }), + Some(Method::SyncGetChunk(request)) => { + let data = b"report"; + let start = request.offset as usize; + let end = (start + request.max_bytes as usize).min(data.len()); + Res::SyncGetChunk(agent::SyncGetChunkResponse { + data: data[start..end].to_vec(), + next_offset: end as u64, + eof: end == data.len(), + }) + } _ => Res::Error(agent::RpcError { code: 400, message: "unsupported in this fake".into(), @@ -353,6 +403,30 @@ mod tests { .unwrap(); assert_eq!(exec.exit_code, 0); assert_eq!(exec.stdout, b"exec-ok"); + + let tree = client + .sync_read_tree(SyncReadTreeRequest { + root: "/home/dory/Downloads".into(), + }) + .await + .unwrap(); + assert_eq!(tree.files.len(), 1); + assert_eq!(tree.files[0].path, "report.txt"); + assert_eq!(tree.directories[0].path, "empty"); + + let chunk = client + .sync_get_chunk(SyncGetChunkRequest { + root: "/home/dory/Downloads".into(), + path: "report.txt".into(), + offset: 2, + max_bytes: 2, + expected_size: 6, + }) + .await + .unwrap(); + assert_eq!(chunk.data, b"po"); + assert_eq!(chunk.next_offset, 4); + assert!(!chunk.eof); } #[tokio::test] From 117aeeb2ac726a3d132ac644dd3a85ae3b2fd8ce Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:43:21 +0000 Subject: [PATCH 203/338] feat(core): stage verified guest file pulls --- dory-core/Cargo.lock | 1 + dory-core/remote/Cargo.toml | 1 + dory-core/remote/src/lib.rs | 6 + dory-core/remote/src/sync_pull.rs | 915 ++++++++++++++++++++++++ dory-core/remote/tests/sync_pull_e2e.rs | 105 +++ 5 files changed, 1028 insertions(+) create mode 100644 dory-core/remote/src/sync_pull.rs create mode 100644 dory-core/remote/tests/sync_pull_e2e.rs diff --git a/dory-core/Cargo.lock b/dory-core/Cargo.lock index 8fc485bd..89d1d2e8 100644 --- a/dory-core/Cargo.lock +++ b/dory-core/Cargo.lock @@ -772,6 +772,7 @@ dependencies = [ "dory-pb", "dory-proto", "dory-sync", + "libc", "prost", "rand", "russh", diff --git a/dory-core/remote/Cargo.toml b/dory-core/remote/Cargo.toml index 4ee3c697..3133d2f9 100644 --- a/dory-core/remote/Cargo.toml +++ b/dory-core/remote/Cargo.toml @@ -11,6 +11,7 @@ dory-sync = { path = "../sync" } tokio = { workspace = true, features = ["fs"] } thiserror = { workspace = true } prost = { workspace = true } +libc = "0.2" russh = { version = "0.60.3", default-features = false, features = ["aws-lc-rs", "flate2"] } [dev-dependencies] diff --git a/dory-core/remote/src/lib.rs b/dory-core/remote/src/lib.rs index 78321a20..130cac56 100644 --- a/dory-core/remote/src/lib.rs +++ b/dory-core/remote/src/lib.rs @@ -8,17 +8,23 @@ //! run `AgentClient` over it. Host-key verification is mandatory ([`ssh::HostKeyPolicy`]). //! - [`sync_push`] — host-authoritative chunked file sync (D5): manifest diff, chunked puts, //! deletes; the guest-side reconciler lives in `dory-sync`. +//! - [`sync_pull`] — bounded guest-to-host staging with exact manifest digest verification. pub mod agent_client; pub mod error; pub mod keys; pub mod ssh; +pub mod sync_pull; pub mod sync_push; pub use agent_client::AgentClient; pub use error::RemoteError; pub use keys::{private_key_from_openssh, public_key_from_openssh}; pub use ssh::{AgentEndpoint, HostKeyPolicy, SshAgent, SshConfig}; +pub use sync_pull::{ + pull, pull_observed, PullLimits, PullObserver, PullPhase, PullProgress, PullStats, SourceChunk, + SyncSource, +}; pub use sync_push::{ push, push_observed, PushObserver, PushPhase, PushProgress, PushStats, SyncTarget, }; diff --git a/dory-core/remote/src/sync_pull.rs b/dory-core/remote/src/sync_pull.rs new file mode 100644 index 00000000..31bea0fd --- /dev/null +++ b/dory-core/remote/src/sync_pull.rs @@ -0,0 +1,915 @@ +//! Guest-to-host tree transfer. The guest supplies a point-in-time manifest and bounded chunks; +//! the host materializes them only inside a newly-created private staging root, verifies every +//! complete SHA-256, and removes the entire root on cancellation or failure. Callers publish the +//! verified staging tree separately, so partial guest bytes never become user-visible authority. + +use std::collections::HashSet; +use std::path::Path; + +use dory_pb::agent::{SyncGetChunkRequest, SyncReadTreeRequest}; +use dory_sync::{DirectoryEntry, FileEntry, Hash, TreeSnapshot, CHUNK_BYTES, HASH_LEN}; +use tokio::io::AsyncWriteExt; + +use crate::{AgentClient, RemoteError}; + +const PRIVATE_TEMP_DIRECTORY: &str = ".dory-pull-tmp"; +const MAX_PATH_BYTES: usize = 4 * 1024; +const MAX_TOTAL_PATH_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PullLimits { + pub max_files: u64, + pub max_directories: u64, + pub max_bytes: u64, +} + +impl Default for PullLimits { + fn default() -> Self { + Self { + max_files: 100_000, + max_directories: 100_000, + max_bytes: 32 * 1024 * 1024 * 1024, + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PullStats { + pub files_received: u64, + pub directories_received: u64, + pub bytes_received: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullPhase { + Preparing, + Transferring, + Finalizing, + Completed, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullProgress { + pub phase: PullPhase, + pub files_total: u64, + pub files_completed: u64, + pub bytes_total: u64, + pub bytes_completed: u64, + pub current_path: Option, +} + +impl Default for PullProgress { + fn default() -> Self { + Self { + phase: PullPhase::Preparing, + files_total: 0, + files_completed: 0, + bytes_total: 0, + bytes_completed: 0, + current_path: None, + } + } +} + +pub trait PullObserver: Send + Sync { + fn update(&self, progress: &PullProgress); + fn is_cancelled(&self) -> bool; +} + +struct IgnorePullProgress; + +impl PullObserver for IgnorePullProgress { + fn update(&self, _progress: &PullProgress) {} + fn is_cancelled(&self) -> bool { + false + } +} + +pub trait SyncSource { + fn read_tree( + &self, + root: &str, + ) -> impl std::future::Future> + Send; + fn get_chunk( + &self, + root: &str, + file: &FileEntry, + offset: u64, + max_bytes: u32, + ) -> impl std::future::Future> + Send; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceChunk { + pub data: Vec, + pub next_offset: u64, + pub eof: bool, +} + +pub async fn pull( + remote_root: &str, + local_root: &Path, + source: &S, + limits: PullLimits, +) -> Result { + pull_observed(remote_root, local_root, source, limits, &IgnorePullProgress).await +} + +pub async fn pull_observed( + remote_root: &str, + local_root: &Path, + source: &S, + limits: PullLimits, + observer: &O, +) -> Result { + let mut progress = PullProgress::default(); + observer.update(&progress); + let result = pull_inner( + remote_root, + local_root, + source, + limits, + observer, + &mut progress, + ) + .await; + if result.is_err() { + if progress.phase != PullPhase::Cancelled { + progress.phase = PullPhase::Failed; + } + progress.current_path = None; + observer.update(&progress); + } + result +} + +async fn pull_inner( + remote_root: &str, + local_root: &Path, + source: &S, + limits: PullLimits, + observer: &O, + progress: &mut PullProgress, +) -> Result { + require_not_cancelled(observer, progress)?; + validate_new_local_root(local_root)?; + let snapshot = source.read_tree(remote_root).await?; + let authority = validate_snapshot(snapshot, limits)?; + progress.files_total = authority.files.len() as u64; + progress.bytes_total = authority.bytes_total; + observer.update(progress); + require_not_cancelled(observer, progress)?; + + // A create-new failure never authorizes cleanup of whatever won the race. Once our private + // directory exists, every subsequent failure removes only that directory before returning. + create_private_root(local_root).await?; + let result = async { + sync_directory(local_root.parent().ok_or(RemoteError::Decode)?).await?; + materialize( + remote_root, + local_root, + source, + &authority, + observer, + progress, + ) + .await + } + .await; + if result.is_err() { + let _ = cleanup_private_root(local_root).await; + } + result +} + +struct ValidatedSnapshot { + files: Vec, + directories: Vec, + bytes_total: u64, +} + +fn validate_snapshot( + snapshot: TreeSnapshot, + limits: PullLimits, +) -> Result { + let file_count = + u64::try_from(snapshot.manifest.entries.len()).map_err(|_| RemoteError::Decode)?; + let directory_count = + u64::try_from(snapshot.directories.len()).map_err(|_| RemoteError::Decode)?; + if file_count > limits.max_files || directory_count > limits.max_directories { + return Err(RemoteError::Decode); + } + + let mut previous_file: Option<&str> = None; + let mut previous_directory: Option<&str> = None; + let mut total_path_bytes = 0usize; + let mut bytes_total = 0u64; + let mut file_paths = HashSet::with_capacity(snapshot.manifest.entries.len()); + let mut directory_paths = HashSet::with_capacity(snapshot.directories.len()); + + for directory in &snapshot.directories { + validate_relative_path(&directory.path)?; + if previous_directory.is_some_and(|previous| previous >= directory.path.as_str()) + || directory.path == PRIVATE_TEMP_DIRECTORY + || directory + .path + .starts_with(&format!("{PRIVATE_TEMP_DIRECTORY}/")) + { + return Err(RemoteError::Decode); + } + previous_directory = Some(&directory.path); + total_path_bytes = total_path_bytes + .checked_add(directory.path.len()) + .ok_or(RemoteError::Decode)?; + directory_paths.insert(directory.path.clone()); + } + for file in &snapshot.manifest.entries { + validate_relative_path(&file.path)?; + if previous_file.is_some_and(|previous| previous >= file.path.as_str()) + || file.path == PRIVATE_TEMP_DIRECTORY + || file.path.starts_with(&format!("{PRIVATE_TEMP_DIRECTORY}/")) + { + return Err(RemoteError::Decode); + } + previous_file = Some(&file.path); + if file.mtime_ns < 0 { + return Err(RemoteError::Decode); + } + total_path_bytes = total_path_bytes + .checked_add(file.path.len()) + .ok_or(RemoteError::Decode)?; + bytes_total = bytes_total + .checked_add(file.size) + .ok_or(RemoteError::Decode)?; + file_paths.insert(file.path.clone()); + } + if total_path_bytes > MAX_TOTAL_PATH_BYTES || bytes_total > limits.max_bytes { + return Err(RemoteError::Decode); + } + if file_paths.iter().any(|path| directory_paths.contains(path)) { + return Err(RemoteError::Decode); + } + + for directory in &directory_paths { + if let Some(parent) = parent_path(directory) { + if !directory_paths.contains(parent) || file_paths.contains(parent) { + return Err(RemoteError::Decode); + } + } + } + for file in &file_paths { + if let Some(parent) = parent_path(file) { + if !directory_paths.contains(parent) || file_paths.contains(parent) { + return Err(RemoteError::Decode); + } + } + } + + Ok(ValidatedSnapshot { + files: snapshot.manifest.entries, + directories: snapshot.directories, + bytes_total, + }) +} + +async fn materialize( + remote_root: &str, + local_root: &Path, + source: &S, + snapshot: &ValidatedSnapshot, + observer: &O, + progress: &mut PullProgress, +) -> Result { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + for directory in &snapshot.directories { + let path = local_root.join(&directory.path); + tokio::fs::create_dir(&path).await?; + #[cfg(unix)] + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).await?; + } + let temp_root = local_root.join(PRIVATE_TEMP_DIRECTORY); + tokio::fs::create_dir(&temp_root).await?; + #[cfg(unix)] + tokio::fs::set_permissions(&temp_root, std::fs::Permissions::from_mode(0o700)).await?; + + progress.phase = PullPhase::Transferring; + observer.update(progress); + let mut stats = PullStats { + directories_received: snapshot.directories.len() as u64, + ..PullStats::default() + }; + + for (index, entry) in snapshot.files.iter().enumerate() { + require_not_cancelled(observer, progress)?; + progress.current_path = Some(entry.path.clone()); + observer.update(progress); + let staged = temp_root.join(format!("{index:016x}")); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged) + .await?; + let mut offset = 0u64; + loop { + require_not_cancelled(observer, progress)?; + let chunk = source + .get_chunk( + remote_root, + entry, + offset, + u32::try_from(CHUNK_BYTES).map_err(|_| RemoteError::Decode)?, + ) + .await?; + let expected_next = offset + .checked_add(chunk.data.len() as u64) + .ok_or(RemoteError::Decode)?; + if chunk.data.len() > CHUNK_BYTES + || chunk.next_offset != expected_next + || chunk.next_offset > entry.size + || chunk.eof != (chunk.next_offset == entry.size) + || (chunk.data.is_empty() && !chunk.eof) + { + return Err(RemoteError::Decode); + } + file.write_all(&chunk.data).await?; + offset = chunk.next_offset; + progress.bytes_completed = stats + .bytes_received + .checked_add(offset) + .ok_or(RemoteError::Decode)?; + observer.update(progress); + if chunk.eof { + break; + } + } + file.flush().await?; + file.sync_all().await?; + drop(file); + let staged_for_hash = staged.clone(); + let hash = tokio::task::spawn_blocking(move || dory_sync::hash_file(&staged_for_hash)) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + if hash != entry.hash { + return Err(RemoteError::SourceChanged); + } + apply_file_metadata_and_sync(&staged, entry.mode & 0o777, entry.mtime_ns).await?; + let destination = local_root.join(&entry.path); + tokio::fs::rename(&staged, &destination).await?; + sync_directory(destination.parent().ok_or(RemoteError::Decode)?).await?; + stats.files_received = stats + .files_received + .checked_add(1) + .ok_or(RemoteError::Decode)?; + stats.bytes_received = stats + .bytes_received + .checked_add(entry.size) + .ok_or(RemoteError::Decode)?; + progress.files_completed = stats.files_received; + progress.bytes_completed = stats.bytes_received; + observer.update(progress); + } + + progress.phase = PullPhase::Finalizing; + progress.current_path = None; + observer.update(progress); + tokio::fs::remove_dir(&temp_root).await?; + for directory in snapshot.directories.iter().rev() { + let path = local_root.join(&directory.path); + apply_directory_mode_and_sync(&path, directory.mode & 0o777).await?; + } + sync_directory(local_root).await?; + progress.phase = PullPhase::Completed; + progress.current_path = None; + progress.files_completed = progress.files_total; + progress.bytes_completed = progress.bytes_total; + observer.update(progress); + Ok(stats) +} + +fn validate_new_local_root(local_root: &Path) -> Result<(), RemoteError> { + if !local_root.is_absolute() || local_root.file_name().is_none() { + return Err(RemoteError::Decode); + } + let parent = local_root.parent().ok_or(RemoteError::Decode)?; + let metadata = std::fs::symlink_metadata(parent)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(RemoteError::Decode); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 { + return Err(RemoteError::Decode); + } + } + match std::fs::symlink_metadata(local_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(RemoteError::Decode), + Err(error) => Err(RemoteError::Io(error)), + } +} + +async fn create_private_root(local_root: &Path) -> Result<(), RemoteError> { + let path = local_root.to_path_buf(); + tokio::task::spawn_blocking(move || { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(path) + } + #[cfg(not(unix))] + { + std::fs::create_dir(path) + } + }) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + Ok(()) +} + +async fn apply_file_metadata_and_sync( + path: &Path, + mode: u32, + mtime_ns: i64, +) -> Result<(), RemoteError> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + let modified = std::time::UNIX_EPOCH + .checked_add(std::time::Duration::from_nanos( + u64::try_from(mtime_ns) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidData))?, + )) + .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidData))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(mode))?; + file.set_times(std::fs::FileTimes::new().set_modified(modified))?; + file.sync_all() + } + #[cfg(not(unix))] + { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + file.set_times(std::fs::FileTimes::new().set_modified(modified))?; + file.sync_all() + } + }) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + Ok(()) +} + +async fn apply_directory_mode_and_sync(path: &Path, mode: u32) -> Result<(), RemoteError> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let directory = std::fs::File::open(path)?; + directory.set_permissions(std::fs::Permissions::from_mode(mode))?; + directory.sync_all() + } + #[cfg(not(unix))] + { + std::fs::File::open(path)?.sync_all() + } + }) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + Ok(()) +} + +async fn sync_directory(path: &Path) -> Result<(), RemoteError> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || std::fs::File::open(path)?.sync_all()) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + Ok(()) +} + +async fn cleanup_private_root(path: &Path) -> Result<(), RemoteError> { + let path = path.to_path_buf(); + let parent = path.parent().map(Path::to_path_buf); + tokio::task::spawn_blocking(move || { + #[cfg(unix)] + fn make_directories_private_and_writable(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(std::io::Error::from(std::io::ErrorKind::InvalidData)); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let metadata = std::fs::symlink_metadata(entry.path())?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + make_directories_private_and_writable(&entry.path())?; + } + } + Ok(()) + } + + #[cfg(unix)] + make_directories_private_and_writable(&path)?; + std::fs::remove_dir_all(&path)?; + if let Some(parent) = parent { + std::fs::File::open(parent)?.sync_all()?; + } + Ok::<(), std::io::Error>(()) + }) + .await + .map_err(|error| RemoteError::Io(std::io::Error::other(error)))??; + Ok(()) +} + +fn validate_relative_path(path: &str) -> Result<(), RemoteError> { + if path.is_empty() + || path.len() > MAX_PATH_BYTES + || path.starts_with('/') + || path.contains('\0') + || path + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(RemoteError::Decode); + } + Ok(()) +} + +fn parent_path(path: &str) -> Option<&str> { + path.rsplit_once('/').map(|(parent, _)| parent) +} + +fn require_not_cancelled( + observer: &O, + progress: &mut PullProgress, +) -> Result<(), RemoteError> { + if !observer.is_cancelled() { + return Ok(()); + } + progress.phase = PullPhase::Cancelled; + progress.current_path = None; + observer.update(progress); + Err(RemoteError::Cancelled) +} + +impl SyncSource for AgentClient { + async fn read_tree(&self, root: &str) -> Result { + let response = self + .sync_read_tree(SyncReadTreeRequest { + root: root.to_string(), + }) + .await?; + let mut files = Vec::with_capacity(response.files.len()); + for entry in response.files { + let hash: Hash = entry + .hash + .as_slice() + .try_into() + .map_err(|_| RemoteError::Decode)?; + files.push(FileEntry { + path: entry.path, + size: entry.size, + mtime_ns: entry.mtime_ns, + mode: entry.mode, + hash, + }); + } + let directories = response + .directories + .into_iter() + .map(|entry| DirectoryEntry { + path: entry.path, + mtime_ns: 0, + mode: entry.mode, + }) + .collect(); + Ok(TreeSnapshot { + manifest: dory_sync::Manifest { entries: files }, + directories, + }) + } + + async fn get_chunk( + &self, + root: &str, + file: &FileEntry, + offset: u64, + max_bytes: u32, + ) -> Result { + let response = self + .sync_get_chunk(SyncGetChunkRequest { + root: root.to_string(), + path: file.path.clone(), + offset, + max_bytes, + expected_size: file.size, + }) + .await?; + Ok(SourceChunk { + data: response.data, + next_offset: response.next_offset, + eof: response.eof, + }) + } +} + +const _: () = assert!(HASH_LEN == 32); + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + + struct TempParent { + path: PathBuf, + } + + impl TempParent { + fn new(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "dory-pull-{}-{}-{}", + std::process::id(), + tag, + rand::random::() + )); + std::fs::create_dir(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + Self { path } + } + } + + impl Drop for TempParent { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + struct FakeSource { + snapshot: TreeSnapshot, + contents: HashMap>, + corrupt_path: Option, + bogus_offsets: bool, + } + + impl SyncSource for FakeSource { + async fn read_tree(&self, _root: &str) -> Result { + Ok(self.snapshot.clone()) + } + + async fn get_chunk( + &self, + _root: &str, + file: &FileEntry, + offset: u64, + max_bytes: u32, + ) -> Result { + let contents = self.contents.get(&file.path).ok_or(RemoteError::Decode)?; + let start = usize::try_from(offset).map_err(|_| RemoteError::Decode)?; + let end = (start + max_bytes as usize).min(contents.len()); + let mut data = contents[start..end].to_vec(); + if self.corrupt_path.as_deref() == Some(&file.path) && !data.is_empty() { + data[0] ^= 0xff; + } + Ok(SourceChunk { + data, + next_offset: if self.bogus_offsets { + end as u64 + 1 + } else { + end as u64 + }, + eof: end == contents.len(), + }) + } + } + + fn source(files: &[(&str, &[u8])], directories: &[&str]) -> FakeSource { + let mut contents = HashMap::new(); + let entries = files + .iter() + .map(|(path, bytes)| { + contents.insert((*path).to_string(), bytes.to_vec()); + FileEntry { + path: (*path).to_string(), + size: bytes.len() as u64, + mtime_ns: 0, + mode: 0o100640, + hash: dory_sync::hash_bytes(bytes), + } + }) + .collect(); + FakeSource { + snapshot: TreeSnapshot { + manifest: dory_sync::Manifest { entries }, + directories: directories + .iter() + .map(|path| DirectoryEntry { + path: (*path).to_string(), + mtime_ns: 0, + mode: 0o040750, + }) + .collect(), + }, + contents, + corrupt_path: None, + bogus_offsets: false, + } + } + + #[cfg(unix)] + #[tokio::test] + async fn pull_materializes_verified_files_and_empty_directories() { + use std::os::unix::fs::PermissionsExt; + + let parent = TempParent::new("success"); + let destination = parent.path.join("verified"); + let source = source( + &[("hello.txt", b"hello"), ("nested/report.txt", b"report")], + &["empty", "nested"], + ); + + let stats = pull( + "/home/dory/Downloads/export", + &destination, + &source, + PullLimits::default(), + ) + .await + .unwrap(); + + assert_eq!(stats.files_received, 2); + assert_eq!(stats.directories_received, 2); + assert_eq!(stats.bytes_received, 11); + assert_eq!( + std::fs::read(destination.join("hello.txt")).unwrap(), + b"hello" + ); + assert_eq!( + std::fs::read(destination.join("nested/report.txt")).unwrap(), + b"report" + ); + assert!(destination.join("empty").is_dir()); + assert_eq!( + std::fs::metadata(destination.join("hello.txt")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o640 + ); + } + + #[tokio::test] + async fn digest_or_protocol_mismatch_removes_the_unpublished_root() { + for bogus_offsets in [false, true] { + let parent = TempParent::new("mismatch"); + let destination = parent.path.join("rejected"); + let mut source = source(&[("payload", b"payload")], &[]); + source.corrupt_path = (!bogus_offsets).then(|| "payload".into()); + source.bogus_offsets = bogus_offsets; + + assert!(pull("/guest", &destination, &source, PullLimits::default()) + .await + .is_err()); + assert!(!destination.exists()); + } + } + + #[tokio::test] + async fn malformed_or_over_limit_authority_writes_nothing() { + let cases = [ + source(&[("../escape", b"x")], &[]), + source(&[("missing/file", b"x")], &[]), + source(&[(PRIVATE_TEMP_DIRECTORY, b"x")], &[]), + ]; + for source in cases { + let parent = TempParent::new("authority"); + let destination = parent.path.join("rejected"); + assert!(pull("/guest", &destination, &source, PullLimits::default()) + .await + .is_err()); + assert!(!destination.exists()); + } + + let parent = TempParent::new("limit"); + let destination = parent.path.join("rejected"); + let source = source(&[("large", b"1234")], &[]); + assert!(pull( + "/guest", + &destination, + &source, + PullLimits { + max_bytes: 3, + ..PullLimits::default() + }, + ) + .await + .is_err()); + assert!(!destination.exists()); + } + + struct CancellingObserver { + cancel: AtomicBool, + updates: Mutex>, + } + + impl PullObserver for CancellingObserver { + fn update(&self, progress: &PullProgress) { + self.updates.lock().unwrap().push(progress.clone()); + if progress.phase == PullPhase::Transferring { + self.cancel.store(true, Ordering::Release); + } + } + + fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::Acquire) + } + } + + #[tokio::test] + async fn cancellation_is_terminal_and_cleans_partial_bytes() { + let parent = TempParent::new("cancel"); + let destination = parent.path.join("cancelled"); + let source = source(&[("payload", b"payload")], &[]); + let observer = CancellingObserver { + cancel: AtomicBool::new(false), + updates: Mutex::new(Vec::new()), + }; + + assert!(matches!( + pull_observed( + "/guest", + &destination, + &source, + PullLimits::default(), + &observer + ) + .await, + Err(RemoteError::Cancelled) + )); + assert!(!destination.exists()); + assert_eq!( + observer.updates.lock().unwrap().last().unwrap().phase, + PullPhase::Cancelled + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn destination_authority_is_private_create_new_and_cleanup_handles_mode_zero() { + use std::os::unix::fs::PermissionsExt; + + let parent = TempParent::new("destination"); + let source = source(&[("payload", b"payload")], &[]); + let existing = parent.path.join("existing"); + std::fs::create_dir(&existing).unwrap(); + std::fs::write(existing.join("keep"), b"owned elsewhere").unwrap(); + assert!(pull("/guest", &existing, &source, PullLimits::default()) + .await + .is_err()); + assert_eq!( + std::fs::read(existing.join("keep")).unwrap(), + b"owned elsewhere" + ); + + let public_parent = TempParent::new("public-parent"); + std::fs::set_permissions(&public_parent.path, std::fs::Permissions::from_mode(0o755)) + .unwrap(); + let rejected = public_parent.path.join("rejected"); + assert!(pull("/guest", &rejected, &source, PullLimits::default()) + .await + .is_err()); + assert!(!rejected.exists()); + + let doomed = parent.path.join("doomed"); + std::fs::create_dir(&doomed).unwrap(); + std::fs::create_dir(doomed.join("closed")).unwrap(); + std::fs::write(doomed.join("closed/file"), b"partial").unwrap(); + std::fs::set_permissions( + doomed.join("closed"), + std::fs::Permissions::from_mode(0o000), + ) + .unwrap(); + cleanup_private_root(&doomed).await.unwrap(); + assert!(!doomed.exists()); + } +} diff --git a/dory-core/remote/tests/sync_pull_e2e.rs b/dory-core/remote/tests/sync_pull_e2e.rs new file mode 100644 index 00000000..1a48a274 --- /dev/null +++ b/dory-core/remote/tests/sync_pull_e2e.rs @@ -0,0 +1,105 @@ +//! End-to-end guest-to-host transfer over the real agent RPC spine. The source is served by the +//! production agent handler; the host pull driver must reconstruct a digest-exact private tree, +//! preserve empty directories, and exclude the agent's private push staging namespace. + +use std::fs; +use std::path::PathBuf; + +use dory_remote::{pull, AgentClient, PullLimits}; +use dory_sync::{walk_tree, walk_tree_excluding}; +use tokio::net::{TcpListener, TcpStream}; + +struct PrivateTemp { + path: PathBuf, +} + +impl PrivateTemp { + fn new(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "dory-pull-e2e-{}-{}-{}", + std::process::id(), + tag, + rand::random::() + )); + fs::create_dir(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + } + Self { path } + } + + fn write(&self, relative: &str, contents: &[u8]) { + let path = self.path.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } +} + +impl Drop for PrivateTemp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +async fn connect_agent() -> AgentClient { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = dory_agent::daemon::serve(listener).await; + }); + let stream = TcpStream::connect(address).await.unwrap(); + AgentClient::connect(stream, "doryd-pull-e2e") + .await + .unwrap() +} + +#[tokio::test] +async fn pull_reconstructs_the_real_agent_tree_with_exact_content() { + let client = connect_agent().await; + let guest = PrivateTemp::new("guest"); + let host_parent = PrivateTemp::new("host"); + let host = host_parent.path.join("verified"); + + guest.write("README.md", b"guest export"); + guest.write("nested/big.bin", &vec![0x5a; 700 * 1024]); + fs::create_dir_all(guest.path.join("empty/deep")).unwrap(); + guest.write(".dory-sync-tmp/private", b"must not leave the guest"); + + let stats = pull( + &guest.path.to_string_lossy(), + &host, + &client, + PullLimits::default(), + ) + .await + .unwrap(); + + assert_eq!(stats.files_received, 2); + assert_eq!(stats.directories_received, 3); + assert_eq!(stats.bytes_received, 12 + 700 * 1024); + assert_eq!(fs::read(host.join("README.md")).unwrap(), b"guest export"); + assert_eq!( + fs::read(host.join("nested/big.bin")).unwrap(), + vec![0x5a; 700 * 1024] + ); + assert!(host.join("empty/deep").is_dir()); + assert!(!host.join(".dory-sync-tmp").exists()); + + let expected = walk_tree_excluding(&guest.path, &[".dory-sync-tmp"]).unwrap(); + let actual = walk_tree(&host).unwrap(); + assert_eq!(actual.manifest, expected.manifest); + assert_eq!( + actual + .directories + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + expected + .directories + .iter() + .map(|entry| entry.path.as_str()) + .collect::>() + ); +} From 816e3da52e0fa091fc5d6c161df0013ac895563a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:49:27 +0000 Subject: [PATCH 204/338] feat(core): bridge guest file pulls to Swift --- .../Sources/DoryCore/DoryCore.swift | 167 ++++++++++++++++++ .../Tests/DoryCoreTests/DoryCoreTests.swift | 54 ++++++ dory-core/ffi/src/agent_forward.rs | 115 +++++++++++- dory-core/ffi/src/remote.rs | 106 ++++++++++- 4 files changed, 438 insertions(+), 4 deletions(-) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index faee91a8..b58daf17 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -390,6 +390,137 @@ private extension DoryPushPhase { } } +public struct DoryPullLimits: Sendable, Equatable, Hashable { + public var maxFiles: UInt64 + public var maxDirectories: UInt64 + public var maxBytes: UInt64 + + public init( + maxFiles: UInt64 = 100_000, + maxDirectories: UInt64 = 100_000, + maxBytes: UInt64 = 32 * 1024 * 1024 * 1024 + ) { + self.maxFiles = maxFiles + self.maxDirectories = maxDirectories + self.maxBytes = maxBytes + } +} + +public struct DoryPullStats: Sendable, Equatable { + public var filesReceived: UInt64 + public var directoriesReceived: UInt64 + public var bytesReceived: UInt64 + + public init(filesReceived: UInt64, directoriesReceived: UInt64, bytesReceived: UInt64) { + self.filesReceived = filesReceived + self.directoriesReceived = directoriesReceived + self.bytesReceived = bytesReceived + } + + fileprivate init(_ raw: PullStatsFfi) { + self.init( + filesReceived: raw.filesReceived, + directoriesReceived: raw.directoriesReceived, + bytesReceived: raw.bytesReceived + ) + } +} + +public enum DoryPullPhase: String, Sendable, Equatable, Hashable { + case preparing + case transferring + case finalizing + case completed + case cancelled + case failed + + public var isTerminal: Bool { + switch self { + case .completed, .cancelled, .failed: + true + case .preparing, .transferring, .finalizing: + false + } + } +} + +public struct DoryPullProgress: Sendable, Equatable, Hashable { + public var phase: DoryPullPhase + public var filesTotal: UInt64 + public var filesCompleted: UInt64 + public var bytesTotal: UInt64 + public var bytesCompleted: UInt64 + public var currentPath: String? + + public init( + phase: DoryPullPhase, + filesTotal: UInt64, + filesCompleted: UInt64, + bytesTotal: UInt64, + bytesCompleted: UInt64, + currentPath: String? + ) { + self.phase = phase + self.filesTotal = filesTotal + self.filesCompleted = filesCompleted + self.bytesTotal = bytesTotal + self.bytesCompleted = bytesCompleted + self.currentPath = currentPath + } + + public var fractionCompleted: Double { + if phase == .completed { return 1 } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } + + fileprivate init(_ raw: PullProgressFfi) { + self.init( + phase: DoryPullPhase(raw.phase), + filesTotal: raw.filesTotal, + filesCompleted: raw.filesCompleted, + bytesTotal: raw.bytesTotal, + bytesCompleted: raw.bytesCompleted, + currentPath: raw.currentPath + ) + } +} + +/// Single-use cancellation/progress authority for one guest-to-host transfer. +public final class DoryPullControl: @unchecked Sendable { + fileprivate let raw: PullControl + + public init() { + raw = newPullControl() + } + + public func cancel() { + raw.cancel() + } + + public func progress() -> DoryPullProgress { + DoryPullProgress(raw.progress()) + } +} + +private extension DoryPullPhase { + init(_ raw: PullPhaseFfi) { + switch raw { + case .preparing: self = .preparing + case .transferring: self = .transferring + case .finalizing: self = .finalizing + case .completed: self = .completed + case .cancelled: self = .cancelled + case .failed: self = .failed + } + } +} + public enum DoryRemoteHostKey: Sendable, Equatable, Hashable { case pinned(opensshPublicKey: String) case knownHosts(path: String, host: String, port: UInt16) @@ -533,6 +664,42 @@ public final class DoryAgentControlHandle: @unchecked Sendable { ) } + public func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits = DoryPullLimits() + ) throws -> DoryPullStats { + let raw = try withControl { + try $0.pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + maxFiles: limits.maxFiles, + maxDirectories: limits.maxDirectories, + maxBytes: limits.maxBytes + ) + } + return DoryPullStats(raw) + } + + public func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits = DoryPullLimits(), + control: DoryPullControl + ) throws -> DoryPullStats { + let raw = try withControl { + try $0.pullControlled( + remoteRoot: remoteRoot, + localRoot: localRoot, + maxFiles: limits.maxFiles, + maxDirectories: limits.maxDirectories, + maxBytes: limits.maxBytes, + control: control.raw + ) + } + return DoryPullStats(raw) + } + public func snapshotFreeze(receiptID: String) throws -> String { try withControl { try $0.snapshotFreeze(receiptId: receiptID) } } diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift index a55a9192..f80b7352 100644 --- a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift @@ -64,4 +64,58 @@ final class DoryCoreTests: XCTestCase { 1 ) } + + func testPullControlStartsWithBoundedPreparingProgress() { + let control = DoryPullControl() + + XCTAssertEqual( + control.progress(), + DoryPullProgress( + phase: .preparing, + filesTotal: 0, + filesCompleted: 0, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ) + ) + XCTAssertFalse(control.progress().phase.isTerminal) + XCTAssertEqual(control.progress().fractionCompleted, 0) + } + + func testPullProgressFractionPrefersBytesAndIsBounded() { + XCTAssertEqual( + DoryPullProgress( + phase: .transferring, + filesTotal: 4, + filesCompleted: 3, + bytesTotal: 100, + bytesCompleted: 25, + currentPath: "src/main.swift" + ).fractionCompleted, + 0.25 + ) + XCTAssertEqual( + DoryPullProgress( + phase: .transferring, + filesTotal: 4, + filesCompleted: 5, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ).fractionCompleted, + 1 + ) + XCTAssertEqual( + DoryPullProgress( + phase: .completed, + filesTotal: 0, + filesCompleted: 0, + bytesTotal: 0, + bytesCompleted: 0, + currentPath: nil + ).fractionCompleted, + 1 + ) + } } diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 9fd44106..852a5cb9 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -10,12 +10,15 @@ use std::sync::Arc; use dory_proto::channels::PORT_CONTROL; use dory_proto::preamble::{write_preamble, Direction, Preamble}; -use dory_remote::{push, push_observed, AgentClient}; +use dory_remote::{ + pull as pull_tree, pull_observed as pull_tree_observed, push, push_observed, AgentClient, + PullLimits, +}; use tokio::net::UnixStream; use crate::remote::{ - exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, PushControl, - PushStatsFfi, RemoteFfiError, TelemetryFfi, + exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, PullControl, + PullStatsFfi, PushControl, PushStatsFfi, RemoteFfiError, TelemetryFfi, }; #[derive(uniffi::Record)] @@ -207,6 +210,65 @@ impl AgentControl { }) } + /// Pull a guest tree into a new daemon-private host staging root. File bytes remain in Rust; + /// Swift supplies only roots and explicit resource bounds. + pub fn pull( + &self, + remote_root: String, + local_root: String, + max_files: u64, + max_directories: u64, + max_bytes: u64, + ) -> Result { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let stats = runtime.block_on(pull_tree( + &remote_root, + std::path::Path::new(&local_root), + &self.client, + PullLimits { + max_files, + max_directories, + max_bytes, + }, + ))?; + Ok(PullStatsFfi { + files_received: stats.files_received, + directories_received: stats.directories_received, + bytes_received: stats.bytes_received, + }) + } + + pub fn pull_controlled( + &self, + remote_root: String, + local_root: String, + max_files: u64, + max_directories: u64, + max_bytes: u64, + control: Arc, + ) -> Result { + control.claim()?; + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let stats = runtime.block_on(pull_tree_observed( + &remote_root, + std::path::Path::new(&local_root), + &self.client, + PullLimits { + max_files, + max_directories, + max_bytes, + }, + control.as_ref(), + ))?; + Ok(PullStatsFfi { + files_received: stats.files_received, + directories_received: stats.directories_received, + bytes_received: stats.bytes_received, + }) + } + pub fn snapshot_freeze(&self, receipt_id: String) -> Result { snapshot_quiesce( self, @@ -319,6 +381,7 @@ mod tests { use super::*; use dory_proto::preamble::read_preamble; use std::os::fd::IntoRawFd; + use std::os::unix::fs::PermissionsExt; use std::sync::mpsc; use tokio::net::UnixListener; @@ -403,6 +466,7 @@ mod tests { let remote_root = transfer_root.join("remote"); std::fs::create_dir_all(&local_root).unwrap(); std::fs::create_dir_all(&remote_root).unwrap(); + std::fs::set_permissions(&transfer_root, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::write(local_root.join("payload.txt"), b"agent-push").unwrap(); let transfer_control = crate::remote::new_push_control(); let stats = control @@ -437,6 +501,51 @@ mod tests { .err() .expect("single-use control must reject reuse"); assert!(reuse_error.to_string().contains("already used")); + + let pulled_root = transfer_root.join("pulled"); + let pull_control = crate::remote::new_pull_control(); + let pulled = control + .pull_controlled( + remote_root.to_string_lossy().into_owned(), + pulled_root.to_string_lossy().into_owned(), + 100, + 100, + 1024 * 1024, + pull_control.clone(), + ) + .expect("pull"); + assert_eq!(pulled.files_received, 1); + assert_eq!(pulled.directories_received, 0); + assert_eq!(pulled.bytes_received, 10); + assert_eq!( + std::fs::read(pulled_root.join("payload.txt")).unwrap(), + b"agent-push" + ); + let pull_progress = pull_control.progress(); + assert!(matches!( + pull_progress.phase, + crate::remote::PullPhaseFfi::Completed + )); + assert_eq!(pull_progress.files_total, 1); + assert_eq!(pull_progress.files_completed, 1); + assert_eq!(pull_progress.bytes_total, 10); + assert_eq!(pull_progress.bytes_completed, 10); + assert!(pull_progress.current_path.is_none()); + let pull_reuse_error = control + .pull_controlled( + remote_root.to_string_lossy().into_owned(), + transfer_root + .join("pulled-again") + .to_string_lossy() + .into_owned(), + 100, + 100, + 1024 * 1024, + pull_control, + ) + .err() + .expect("single-use pull control must reject reuse"); + assert!(pull_reuse_error.to_string().contains("already used")); let _ = std::fs::remove_dir_all(&transfer_root); let _ = std::fs::remove_file(&path); } diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs index 745f4c44..f345c848 100644 --- a/dory-core/ffi/src/remote.rs +++ b/dory-core/ffi/src/remote.rs @@ -14,7 +14,8 @@ use std::sync::{Arc, Mutex}; use dory_remote::{ private_key_from_openssh, public_key_from_openssh, push, push_observed, AgentEndpoint, - HostKeyPolicy, PushObserver, PushPhase, PushProgress, SshAgent, SshConfig, + HostKeyPolicy, PullObserver, PullPhase, PullProgress, PushObserver, PushPhase, PushProgress, + SshAgent, SshConfig, }; #[derive(Debug, uniffi::Error, thiserror::Error)] @@ -85,6 +86,13 @@ pub struct PushStatsFfi { pub files_deleted: u64, } +#[derive(uniffi::Record)] +pub struct PullStatsFfi { + pub files_received: u64, + pub directories_received: u64, + pub bytes_received: u64, +} + #[derive(Debug, Clone, Copy, uniffi::Enum)] pub enum PushPhaseFfi { Preparing, @@ -185,6 +193,102 @@ impl From for PushProgressFfi { } } +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum PullPhaseFfi { + Preparing, + Transferring, + Finalizing, + Completed, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct PullProgressFfi { + pub phase: PullPhaseFfi, + pub files_total: u64, + pub files_completed: u64, + pub bytes_total: u64, + pub bytes_completed: u64, + pub current_path: Option, +} + +/// Single-use cancellation/progress authority for one guest-to-host transfer. +#[derive(uniffi::Object)] +pub struct PullControl { + claimed: AtomicBool, + cancelled: AtomicBool, + progress: Mutex, +} + +#[uniffi::export] +pub fn new_pull_control() -> Arc { + Arc::new(PullControl { + claimed: AtomicBool::new(false), + cancelled: AtomicBool::new(false), + progress: Mutex::new(PullProgress::default()), + }) +} + +#[uniffi::export] +impl PullControl { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + pub fn progress(&self) -> PullProgressFfi { + self.progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + .into() + } +} + +impl PullControl { + pub(crate) fn claim(&self) -> Result<(), RemoteFfiError> { + self.claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| RemoteFfiError::Failed { + message: "file transfer control was already used".into(), + }) + } +} + +impl PullObserver for PullControl { + fn update(&self, progress: &PullProgress) { + *self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = progress.clone(); + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +impl From for PullProgressFfi { + fn from(progress: PullProgress) -> Self { + Self { + phase: match progress.phase { + PullPhase::Preparing => PullPhaseFfi::Preparing, + PullPhase::Transferring => PullPhaseFfi::Transferring, + PullPhase::Finalizing => PullPhaseFfi::Finalizing, + PullPhase::Completed => PullPhaseFfi::Completed, + PullPhase::Cancelled => PullPhaseFfi::Cancelled, + PullPhase::Failed => PullPhaseFfi::Failed, + }, + files_total: progress.files_total, + files_completed: progress.files_completed, + bytes_total: progress.bytes_total, + bytes_completed: progress.bytes_completed, + current_path: progress.current_path, + } + } +} + #[derive(uniffi::Record)] pub struct TelemetryFfi { pub mem_total_kb: u64, From 5789bad2dbf4febcb336093e7223c73341917d02 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 10:52:09 +0000 Subject: [PATCH 205/338] feat(agent): expose guest file pulls to daemon --- .../Sources/DorydKit/AgentControl.swift | 61 +++++++++++++++++ .../DorydKitTests/AgentControlTests.swift | 65 ++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index b4600807..4b30124e 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -31,6 +31,17 @@ public protocol AgentControlClient: Sendable { remoteRoot: String, control: DoryPushControl ) throws -> DoryPushStats + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits + ) throws -> DoryPullStats + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + control: DoryPullControl + ) throws -> DoryPullStats func snapshotFreeze(receiptID: String) throws -> String func snapshotThaw(receiptID: String) throws func exec( @@ -74,6 +85,30 @@ public extension AgentControlClient { throw AgentControlError.capabilityUnavailable("sync-push-control") } + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits + ) throws -> DoryPullStats { + _ = remoteRoot + _ = localRoot + _ = limits + throw AgentControlError.capabilityUnavailable("sync-pull") + } + + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + control: DoryPullControl + ) throws -> DoryPullStats { + _ = remoteRoot + _ = localRoot + _ = limits + _ = control + throw AgentControlError.capabilityUnavailable("sync-pull-control") + } + func snapshotThaw(receiptID: String) throws { _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") @@ -173,6 +208,32 @@ public final class AgentControl: @unchecked Sendable { ) } + public func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits = DoryPullLimits() + ) throws -> DoryPullStats { + try client(requiring: "sync-pull").pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits + ) + } + + public func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits = DoryPullLimits(), + control: DoryPullControl + ) throws -> DoryPullStats { + try client(requiring: "sync-pull").pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits, + control: control + ) + } + public func snapshotFreeze(receiptID: String) throws -> String { try client(requiring: "snapshot-quiesce", minimumVersion: 2) .snapshotFreeze(receiptID: receiptID) diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index 3519bfc4..6a1e539f 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -18,8 +18,8 @@ final class AgentControlTests: XCTestCase { let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") XCTAssertEqual(info.capabilities.map(\.id), [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push", - "telemetry", + "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-pull", + "sync-push", "telemetry", ]) XCTAssertEqual(counter.value, 1) @@ -40,6 +40,26 @@ final class AgentControlTests: XCTestCase { DoryPushStats(filesSent: 1, bytesSent: 12, filesDeleted: 0) ) XCTAssertEqual(fake.controlledPushes, 1) + let pullLimits = DoryPullLimits(maxFiles: 7, maxDirectories: 8, maxBytes: 9) + XCTAssertEqual( + try control.pull( + remoteRoot: "/guest/source", + localRoot: "/tmp/pulled", + limits: pullLimits + ), + DoryPullStats(filesReceived: 2, directoriesReceived: 1, bytesReceived: 12) + ) + XCTAssertEqual( + try control.pull( + remoteRoot: "/guest/source", + localRoot: "/tmp/pulled", + limits: pullLimits, + control: DoryPullControl() + ), + DoryPullStats(filesReceived: 2, directoriesReceived: 1, bytesReceived: 12) + ) + XCTAssertEqual(fake.controlledPulls, 1) + XCTAssertEqual(fake.pullLimits, [pullLimits, pullLimits]) let receiptID = String(repeating: "a", count: 32) XCTAssertEqual(try control.snapshotFreeze(receiptID: receiptID), receiptID) try control.snapshotThaw(receiptID: receiptID) @@ -128,6 +148,8 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda private var closes = 0 private var telemetryCallCount = 0 private var controlledPushCallCount = 0 + private var controlledPullCallCount = 0 + private var receivedPullLimits: [DoryPullLimits] = [] private var freezeReceipts: [String] = [] private var thawReceipts: [String] = [] @@ -139,6 +161,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda DoryAgentCapability(id: "exec-stdin", version: 1), DoryAgentCapability(id: "ports-watch", version: 1), DoryAgentCapability(id: "snapshot-quiesce", version: 2), + DoryAgentCapability(id: "sync-pull", version: 1), DoryAgentCapability(id: "sync-push", version: 1), DoryAgentCapability(id: "telemetry", version: 1), ] @@ -171,6 +194,18 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return controlledPushCallCount } + var controlledPulls: Int { + lock.lock() + defer { lock.unlock() } + return controlledPullCallCount + } + + var pullLimits: [DoryPullLimits] { + lock.lock() + defer { lock.unlock() } + return receivedPullLimits + } + var snapshotFreezeReceipts: [String] { lock.lock() defer { lock.unlock() } @@ -245,6 +280,32 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return try push(localRoot: localRoot, remoteRoot: remoteRoot) } + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits + ) throws -> DoryPullStats { + XCTAssertEqual(remoteRoot, "/guest/source") + XCTAssertEqual(localRoot, "/tmp/pulled") + lock.lock() + receivedPullLimits.append(limits) + lock.unlock() + return DoryPullStats(filesReceived: 2, directoriesReceived: 1, bytesReceived: 12) + } + + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + control: DoryPullControl + ) throws -> DoryPullStats { + _ = control + lock.lock() + controlledPullCallCount += 1 + lock.unlock() + return try pull(remoteRoot: remoteRoot, localRoot: localRoot, limits: limits) + } + func snapshotThaw(receiptID: String) throws { lock.lock() thawReceipts.append(receiptID) From e21253a6577e36fcc8b668c137b327aea39b743d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:04:42 +0000 Subject: [PATCH 206/338] fix(agent): bound guest file read authority --- dory-core/agent/src/sync_apply.rs | 195 ++++++++++++++++++++++----- dory-core/pb/proto/agent.proto | 7 +- dory-core/remote/src/agent_client.rs | 3 + dory-core/remote/src/sync_pull.rs | 14 +- dory-core/sync/src/lib.rs | 4 +- dory-core/sync/src/manifest.rs | 61 ++++++++- 6 files changed, 244 insertions(+), 40 deletions(-) diff --git a/dory-core/agent/src/sync_apply.rs b/dory-core/agent/src/sync_apply.rs index 77f08eb4..f73bfdb1 100644 --- a/dory-core/agent/src/sync_apply.rs +++ b/dory-core/agent/src/sync_apply.rs @@ -88,12 +88,30 @@ pub async fn manifest(req: SyncManifestRequest) -> Result Result { - let root = canonical_root(&req.root).await?; + let root = validated_read_root_path(&req.root)?; let _root_guard = root_lock(&root).write().await; - let snapshot = - tokio::task::spawn_blocking(move || dory_sync::walk_tree_excluding(&root, &[STAGING_DIR])) - .await - .map_err(|error| SyncError::Io(std::io::Error::other(error)))??; + let limits = dory_sync::TreeLimits { + max_files: req.max_files, + max_directories: req.max_directories, + max_bytes: req.max_bytes, + }; + let snapshot = tokio::task::spawn_blocking(move || { + let directory = open_absolute_directory_nofollow(&root)?; + #[cfg(target_os = "linux")] + let descriptor_root = { + use std::os::fd::AsRawFd; + PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd())) + }; + #[cfg(not(target_os = "linux"))] + let descriptor_root = root; + #[cfg(not(target_os = "linux"))] + let _ = &directory; + let snapshot = + dory_sync::walk_tree_excluding_bounded(&descriptor_root, &[STAGING_DIR], limits)?; + Ok::<_, SyncError>(snapshot) + }) + .await + .map_err(|error| SyncError::Io(std::io::Error::other(error)))??; let files = snapshot .manifest .entries @@ -126,7 +144,7 @@ pub async fn get_chunk(req: SyncGetChunkRequest) -> Result dory_sync::CHUNK_BYTES { return Err(SyncError::InvalidRead); } - let root = canonical_root(&req.root).await?; + let root = validated_read_root_path(&req.root)?; let _root_guard = root_lock(&root).read().await; let _path_guard = path_lock(&root, &req.path).lock().await; // The agent may run with more privilege than the guest desktop user. Open every component @@ -169,8 +187,6 @@ pub async fn get_chunk(req: SyncGetChunkRequest) -> Result Result { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd}; - use std::os::unix::ffi::OsStrExt; - use std::path::Component; if !root.is_absolute() || rel.is_empty() || rel.starts_with('/') { return Err(SyncError::PathEscape); @@ -189,7 +205,10 @@ fn open_regular_beneath(root: &Path, rel: &str) -> Result Result {} - Component::Normal(name) => { - directory = open_at(&directory, name.as_bytes(), true)?; - } - _ => return Err(SyncError::PathEscape), - } - } + let mut directory = open_absolute_directory_nofollow(root)?; let components = rel.split('/').collect::>(); if components @@ -243,6 +243,92 @@ fn open_regular_beneath(root: &Path, rel: &str) -> Result Result { + use std::path::Component; + + if root.is_empty() || root.as_bytes().contains(&0) { + return Err(SyncError::PathEscape); + } + let path = PathBuf::from(root); + if !path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::RootDir | Component::Normal(_))) + { + return Err(SyncError::PathEscape); + } + #[cfg(target_os = "linux")] + return Ok(path); + + #[cfg(not(target_os = "linux"))] + { + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + return Err(SyncError::PathEscape); + } + Ok(std::fs::canonicalize(path)?) + } +} + +#[cfg(unix)] +fn open_absolute_directory_nofollow(root: &Path) -> Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::path::Component; + + fn open_directory_at(parent: &std::fs::File, name: &[u8]) -> Result { + let name = CString::new(name).map_err(|_| SyncError::PathEscape)?; + let descriptor = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY, + ) + }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + return if matches!( + error.raw_os_error(), + Some(libc::ELOOP) | Some(libc::ENOTDIR) + ) { + Err(SyncError::PathEscape) + } else { + Err(SyncError::Io(error)) + }; + } + Ok(unsafe { std::fs::File::from_raw_fd(descriptor) }) + } + + let root = validated_read_root_path(&root.to_string_lossy())?; + let slash = CString::new("/").expect("static path has no NUL"); + let descriptor = unsafe { + libc::open( + slash.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(SyncError::Io(std::io::Error::last_os_error())); + } + let mut directory = unsafe { std::fs::File::from_raw_fd(descriptor) }; + for component in root.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = open_directory_at(&directory, name.as_bytes())?; + } + _ => return Err(SyncError::PathEscape), + } + } + Ok(directory) +} + +#[cfg(not(unix))] +fn open_absolute_directory_nofollow(_root: &Path) -> Result { + Err(SyncError::InvalidRead) +} + #[cfg(not(unix))] fn open_regular_beneath(_root: &Path, _rel: &str) -> Result { Err(SyncError::InvalidRead) @@ -1028,9 +1114,14 @@ mod tests { fs::write(root.path.join(STAGING_DIR).join("private"), b"hidden").unwrap(); fs::write(root.path.join("hello.txt"), b"hello").unwrap(); - let snapshot = read_tree(SyncReadTreeRequest { root: root.root() }) - .await - .unwrap(); + let snapshot = read_tree(SyncReadTreeRequest { + root: root.root(), + max_files: 10, + max_directories: 10, + max_bytes: 1024, + }) + .await + .unwrap(); assert_eq!( snapshot .files @@ -1088,6 +1179,46 @@ mod tests { )); } + #[cfg(unix)] + #[tokio::test] + async fn read_tree_rejects_symlink_roots_and_enforces_limits_before_reply() { + use std::os::unix::fs::symlink; + + let root = TempRoot::new("read-root-authority"); + fs::write(root.path.join("one"), b"12345").unwrap(); + fs::write(root.path.join("two"), b"67890").unwrap(); + + let over_files = read_tree(SyncReadTreeRequest { + root: root.root(), + max_files: 1, + max_directories: 10, + max_bytes: 100, + }) + .await; + assert!(over_files.is_err()); + + let over_bytes = read_tree(SyncReadTreeRequest { + root: root.root(), + max_files: 10, + max_directories: 10, + max_bytes: 9, + }) + .await; + assert!(over_bytes.is_err()); + + let link = root.path.with_extension("symlink"); + symlink(&root.path, &link).unwrap(); + let symlink_result = read_tree(SyncReadTreeRequest { + root: link.to_string_lossy().into_owned(), + max_files: 10, + max_directories: 10, + max_bytes: 100, + }) + .await; + let _ = fs::remove_file(&link); + assert!(matches!(symlink_result, Err(SyncError::PathEscape))); + } + #[cfg(unix)] #[tokio::test] async fn read_chunks_reject_escape_symlinks_and_unbounded_requests() { diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 06b3d4e8..3f016fc2 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -179,7 +179,12 @@ message SyncTreeResponse { // Read-only counterpart used for guest-to-host transfers. The manifest is a point-in-time content // authority; the host must hash the fully assembled destination and reject it if guest bytes change // while they are being read. Empty directories are explicit so a pull never invents sentinel files. -message SyncReadTreeRequest { string root = 1; } +message SyncReadTreeRequest { + string root = 1; + uint64 max_files = 2; + uint64 max_directories = 3; + uint64 max_bytes = 4; +} message SyncReadTreeResponse { repeated SyncFileEntry files = 1; repeated SyncDirectoryEntry directories = 2; diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 3006ba62..dc219e89 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -407,6 +407,9 @@ mod tests { let tree = client .sync_read_tree(SyncReadTreeRequest { root: "/home/dory/Downloads".into(), + max_files: 10, + max_directories: 10, + max_bytes: 1024, }) .await .unwrap(); diff --git a/dory-core/remote/src/sync_pull.rs b/dory-core/remote/src/sync_pull.rs index 31bea0fd..c916ae29 100644 --- a/dory-core/remote/src/sync_pull.rs +++ b/dory-core/remote/src/sync_pull.rs @@ -91,6 +91,7 @@ pub trait SyncSource { fn read_tree( &self, root: &str, + limits: PullLimits, ) -> impl std::future::Future> + Send; fn get_chunk( &self, @@ -155,7 +156,7 @@ async fn pull_inner( ) -> Result { require_not_cancelled(observer, progress)?; validate_new_local_root(local_root)?; - let snapshot = source.read_tree(remote_root).await?; + let snapshot = source.read_tree(remote_root, limits).await?; let authority = validate_snapshot(snapshot, limits)?; progress.files_total = authority.files.len() as u64; progress.bytes_total = authority.bytes_total; @@ -566,10 +567,13 @@ fn require_not_cancelled( } impl SyncSource for AgentClient { - async fn read_tree(&self, root: &str) -> Result { + async fn read_tree(&self, root: &str, limits: PullLimits) -> Result { let response = self .sync_read_tree(SyncReadTreeRequest { root: root.to_string(), + max_files: limits.max_files, + max_directories: limits.max_directories, + max_bytes: limits.max_bytes, }) .await?; let mut files = Vec::with_capacity(response.files.len()); @@ -672,7 +676,11 @@ mod tests { } impl SyncSource for FakeSource { - async fn read_tree(&self, _root: &str) -> Result { + async fn read_tree( + &self, + _root: &str, + _limits: PullLimits, + ) -> Result { Ok(self.snapshot.clone()) } diff --git a/dory-core/sync/src/lib.rs b/dory-core/sync/src/lib.rs index 217937c8..f8f69da0 100644 --- a/dory-core/sync/src/lib.rs +++ b/dory-core/sync/src/lib.rs @@ -9,8 +9,8 @@ mod plan; pub use hash::{hash_bytes, hash_file, Hash, HASH_LEN}; pub use manifest::{ - walk_manifest, walk_manifest_excluding, walk_tree, walk_tree_excluding, DirectoryEntry, - FileEntry, Manifest, TreeSnapshot, + walk_manifest, walk_manifest_excluding, walk_tree, walk_tree_excluding, + walk_tree_excluding_bounded, DirectoryEntry, FileEntry, Manifest, TreeLimits, TreeSnapshot, }; pub use plan::{plan, SyncPlan}; diff --git a/dory-core/sync/src/manifest.rs b/dory-core/sync/src/manifest.rs index 19177fcd..e33d368e 100644 --- a/dory-core/sync/src/manifest.rs +++ b/dory-core/sync/src/manifest.rs @@ -39,6 +39,20 @@ pub struct TreeSnapshot { pub directories: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TreeLimits { + pub max_files: u64, + pub max_directories: u64, + pub max_bytes: u64, +} + +#[derive(Default)] +struct TreeUsage { + files: u64, + directories: u64, + bytes: u64, +} + /// Walk `root` recursively and build a manifest of every regular file, with a content hash. Symlinks /// and special files are skipped (only regular files replicate). Entries are sorted by path so a /// host and remote produce byte-identical ordering and the reconciler diff is stable. @@ -48,7 +62,7 @@ pub fn walk_manifest(root: &Path) -> std::io::Result { /// Walk `root` once and capture both regular-file content authority and directory topology. pub fn walk_tree(root: &Path) -> std::io::Result { - walk_tree_impl(root, &[], false) + walk_tree_impl(root, &[], false, None) } /// Walk a tree while skipping named direct children of `root` before reading their metadata or @@ -69,16 +83,29 @@ pub fn walk_tree_excluding( root: &Path, excluded_root_children: &[&str], ) -> std::io::Result { - walk_tree_impl(root, excluded_root_children, true) + walk_tree_impl(root, excluded_root_children, true, None) +} + +/// Bounded directory-aware walk for untrusted guest trees. Limits are enforced before hashing a +/// file or descending into a directory, keeping the agent's CPU and response allocation bounded +/// by the same authority the host requested. +pub fn walk_tree_excluding_bounded( + root: &Path, + excluded_root_children: &[&str], + limits: TreeLimits, +) -> std::io::Result { + walk_tree_impl(root, excluded_root_children, true, Some(limits)) } fn walk_tree_impl( root: &Path, excluded_root_children: &[&str], tolerate_not_found: bool, + limits: Option, ) -> std::io::Result { let mut files = Vec::new(); let mut directories = Vec::new(); + let mut usage = TreeUsage::default(); walk_into( root, root, @@ -86,6 +113,8 @@ fn walk_tree_impl( tolerate_not_found, &mut files, &mut directories, + limits, + &mut usage, )?; files.sort_by(|a, b| a.path.cmp(&b.path)); directories.sort_by(|a, b| a.path.cmp(&b.path)); @@ -102,6 +131,8 @@ fn walk_into( tolerate_not_found: bool, files: &mut Vec, directories: &mut Vec, + limits: Option, + usage: &mut TreeUsage, ) -> std::io::Result<()> { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, @@ -133,6 +164,13 @@ fn walk_into( }; let file_type = meta.file_type(); if file_type.is_dir() { + usage.directories = usage + .directories + .checked_add(1) + .ok_or_else(tree_limit_error)?; + if limits.is_some_and(|limits| usage.directories > limits.max_directories) { + return Err(tree_limit_error()); + } directories.push(DirectoryEntry { path: relative_slash(root, &path), mtime_ns: mtime_ns(&meta), @@ -145,8 +183,20 @@ fn walk_into( tolerate_not_found, files, directories, + limits, + usage, )?; } else if file_type.is_file() { + usage.files = usage.files.checked_add(1).ok_or_else(tree_limit_error)?; + usage.bytes = usage + .bytes + .checked_add(meta.len()) + .ok_or_else(tree_limit_error)?; + if limits.is_some_and(|limits| { + usage.files > limits.max_files || usage.bytes > limits.max_bytes + }) { + return Err(tree_limit_error()); + } let rel = relative_slash(root, &path); let hash = match hash_file(&path) { Ok(hash) => hash, @@ -168,6 +218,13 @@ fn walk_into( Ok(()) } +fn tree_limit_error() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "sync tree exceeds requested limits", + ) +} + /// Path relative to `root`, forward-slash separated (so a macOS host and a Linux guest agree). fn relative_slash(root: &Path, path: &Path) -> String { let rel = path.strip_prefix(root).unwrap_or(path); From a1c360ca0cc983063629026df599e4a906b723f3 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:08:05 +0000 Subject: [PATCH 207/338] feat(vm): stage bounded guest file exports --- .../DoryMachineFileTransferStager.swift | 87 +++++- .../DorydKit/DoryMachineFileTransfer.swift | 228 ++++++++++++++ .../Sources/DorydKit/MachineManager.swift | 280 +++++++++++++++++- .../DoryMachineFileTransferStagerTests.swift | 34 +++ .../MachineManagerFileTransferTests.swift | 243 ++++++++++++++- 5 files changed, 859 insertions(+), 13 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift index edd4de39..9c64a552 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift @@ -85,6 +85,7 @@ public enum DoryMachineFileTransferStager { private static let stagingDirectoryName = "dory-machine-transfer-imports" private static let clientStagePrefix = "transfer-" private static let daemonStagePrefix = "owned-" + private static let daemonExportPrefix = "export-" public static var defaultStagingDirectory: URL { URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) @@ -304,6 +305,33 @@ public enum DoryMachineFileTransferStager { return claimedPath } + /// Reserves a syntactically exact, currently absent output path for one daemon-owned guest + /// export. The Rust pull engine creates the leaf with create-new semantics; this method owns + /// only the private parent namespace and never follows a caller-selected path. + package static func reserveDaemonExportRoot( + operationID: String, + ownerProcessID: pid_t = getpid() + ) throws -> String { + guard isValidOperationID(operationID), ownerProcessID > 0 else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + try ensurePrivateStagingDirectory() + let baseDescriptor = try openPrivateStagingDirectory() + defer { close(baseDescriptor) } + let name = daemonExportPrefix + String(ownerProcessID) + "-" + operationID + var info = stat() + guard fstatat(baseDescriptor, name, &info, AT_SYMLINK_NOFOLLOW) != 0, + errno == ENOENT else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + return defaultStagingDirectory.appendingPathComponent(name, isDirectory: true).path + } + + package static func isDaemonExportRoot(_ path: String) -> Bool { + guard let root = managedRoot(path), root.kind == .export else { return false } + return isPrivateManagedRoot(root) + } + /// Removes a syntactically exact managed handoff. Missing roots are already clean and succeed. package static func removeManagedStagingRoot(_ path: String) throws { guard managedRoot(path) != nil else { @@ -319,6 +347,7 @@ public enum DoryMachineFileTransferStager { (status.st_mode & 0o077) == 0 else { throw DoryMachineFileTransferStagingError.unsafeStagingDirectory } + try makeManagedTreeRemovable(path) try FileManager.default.removeItem(atPath: path) let baseDescriptor = try openPrivateStagingDirectory() defer { close(baseDescriptor) } @@ -345,6 +374,7 @@ public enum DoryMachineFileTransferStager { private enum ManagedStageKind { case client case daemon + case export } private struct ManagedStageRoot { @@ -366,13 +396,21 @@ public enum DoryMachineFileTransferStager { uuid.uuidString.lowercased() == suffix else { return nil } return ManagedStageRoot(name: name, kind: .client) } - guard daemonOwnerProcessID(name) != nil else { return nil } - return ManagedStageRoot(name: name, kind: .daemon) + if daemonOwnerProcessID(name, prefix: daemonStagePrefix) != nil { + return ManagedStageRoot(name: name, kind: .daemon) + } + guard daemonOwnerProcessID(name, prefix: daemonExportPrefix) != nil else { return nil } + return ManagedStageRoot(name: name, kind: .export) } private static func daemonOwnerProcessID(_ name: String) -> pid_t? { - guard name.hasPrefix(daemonStagePrefix) else { return nil } - let fields = name.dropFirst(daemonStagePrefix.count).split(separator: "-", maxSplits: 1) + daemonOwnerProcessID(name, prefix: daemonStagePrefix) + ?? daemonOwnerProcessID(name, prefix: daemonExportPrefix) + } + + private static func daemonOwnerProcessID(_ name: String, prefix: String) -> pid_t? { + guard name.hasPrefix(prefix) else { return nil } + let fields = name.dropFirst(prefix.count).split(separator: "-", maxSplits: 1) guard fields.count == 2, let owner = pid_t(fields[0]), owner > 0, isValidOperationID(String(fields[1])) else { return nil } @@ -413,6 +451,47 @@ public enum DoryMachineFileTransferStager { return descriptor } + private static func ensurePrivateStagingDirectory() throws { + if mkdir(defaultStagingDirectory.path, mode_t(0o700)) != 0, errno != EEXIST { + throw DoryMachineFileTransferStagingError.io("directory creation", errno) + } + let descriptor = try openPrivateStagingDirectory() + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("directory sync", errno) + } + } + + /// A completed pull preserves guest directory modes, including mode 000. Restore owner-only + /// traversal before recursive cleanup without ever following a symlink. The managed root is + /// already confined to Dory's private namespace and every child comes from a no-symlink pull. + private static func makeManagedTreeRemovable(_ path: String) throws { + var info = stat() + guard lstat(path, &info) == 0 else { + if errno == ENOENT { return } + throw DoryMachineFileTransferStagingError.io("cleanup stat", errno) + } + guard (info.st_mode & S_IFMT) == S_IFDIR else { return } + guard fchmodat(AT_FDCWD, path, mode_t(0o700), AT_SYMLINK_NOFOLLOW) == 0 else { + throw DoryMachineFileTransferStagingError.io("cleanup permissions", errno) + } + let children: [String] + do { + children = try FileManager.default.contentsOfDirectory(atPath: path) + } catch let error as CocoaError { + throw DoryMachineFileTransferStagingError.io( + "cleanup directory read", + Int32(error.errorCode) + ) + } + for child in children { + try makeManagedTreeRemovable( + URL(fileURLWithPath: path, isDirectory: true) + .appendingPathComponent(child, isDirectory: false).path + ) + } + } + private static func processExists(_ processID: pid_t) -> Bool { if kill(processID, 0) == 0 { return true } return errno != ESRCH diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift index 0daf51a9..6d8c8874 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift @@ -115,8 +115,10 @@ public struct DoryMachineFileTransferOperationStatus: Sendable, Equatable { public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStringConvertible { case invalidPrivateStagingRoot + case invalidGuestSource(String) case transferAlreadyInProgress(String) case unknownTransfer(String, String) + case exportNotComplete(String, String) case guestAccountUnavailable(String) case guestPreparationFailed(String) case transferFailed(String) @@ -126,10 +128,14 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri switch self { case .invalidPrivateStagingRoot: "file transfer staging root is invalid or not private" + case let .invalidGuestSource(machineID): + "guest file source is invalid for machine: \(machineID)" case let .transferAlreadyInProgress(machineID): "a file transfer is already in progress for machine: \(machineID)" case let .unknownTransfer(machineID, operationID): "unknown file transfer \(operationID) for machine: \(machineID)" + case let .exportNotComplete(machineID, operationID): + "guest file export \(operationID) is not complete for machine: \(machineID)" case let .guestAccountUnavailable(machineID): "managed guest account is unavailable for machine: \(machineID)" case let .guestPreparationFailed(machineID): @@ -142,6 +148,90 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri } } +/// A verified guest tree staged under a daemon-owned private root. The staging path is an opaque, +/// short-lived handoff capability and is returned only for the exact completed operation. +public struct DoryMachineGuestFileExportResult: Sendable, Equatable { + public var exportID: String + public var privateStagingRoot: String + public var filesReceived: UInt64 + public var directoriesReceived: UInt64 + public var bytesReceived: UInt64 + + public init( + exportID: String, + privateStagingRoot: String, + filesReceived: UInt64, + directoriesReceived: UInt64, + bytesReceived: UInt64 + ) { + self.exportID = exportID + self.privateStagingRoot = privateStagingRoot + self.filesReceived = filesReceived + self.directoriesReceived = directoriesReceived + self.bytesReceived = bytesReceived + } +} + +public struct DoryMachineGuestFileExportOperationStatus: Sendable, Equatable { + public var operationID: String + public var machineID: String + public var phase: DoryMachineFileTransferPhase + public var filesTotal: UInt64 + public var filesCompleted: UInt64 + public var bytesTotal: UInt64 + public var bytesCompleted: UInt64 + public var currentPath: String? + public var result: DoryMachineGuestFileExportResult? + public var failure: DoryMachineFileTransferFailure? + + public init( + operationID: String, + machineID: String, + phase: DoryMachineFileTransferPhase, + filesTotal: UInt64, + filesCompleted: UInt64, + bytesTotal: UInt64, + bytesCompleted: UInt64, + currentPath: String?, + result: DoryMachineGuestFileExportResult?, + failure: DoryMachineFileTransferFailure? + ) { + self.operationID = operationID + self.machineID = machineID + self.phase = phase + self.filesTotal = filesTotal + self.filesCompleted = filesCompleted + self.bytesTotal = bytesTotal + self.bytesCompleted = bytesCompleted + self.currentPath = currentPath + self.result = result + self.failure = failure + } + + public var fractionCompleted: Double { + if phase == .completed { return 1 } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + +extension DoryMachineGuestFileExportResult { + init(exportID: String, privateStagingRoot: String, stats: DoryPullStats) { + self.init( + exportID: exportID, + privateStagingRoot: privateStagingRoot, + filesReceived: stats.filesReceived, + directoriesReceived: stats.directoriesReceived, + bytesReceived: stats.bytesReceived + ) + } +} + extension DoryMachineFileTransferResult { init(transferID: String, guestDestination: String, stats: DoryPushStats) { self.init( @@ -270,6 +360,138 @@ final class MachineFileTransferOperation: @unchecked Sendable { } } +final class MachineGuestFileExportOperation: @unchecked Sendable { + let operationID: String + let machineID: String + let privateStagingRoot: String + let control = DoryPullControl() + + private let lock = NSLock() + private var phase: DoryMachineFileTransferPhase = .preparing + private var cancellationRequested = false + private var result: DoryMachineGuestFileExportResult? + private var failure: DoryMachineFileTransferFailure? + private var finishedAt: Date? + + init(operationID: String, machineID: String, privateStagingRoot: String) { + self.operationID = operationID + self.machineID = machineID + self.privateStagingRoot = privateStagingRoot + } + + var isCancellationRequested: Bool { + lock.lock() + defer { lock.unlock() } + return cancellationRequested + } + + var isTerminal: Bool { + lock.lock() + defer { lock.unlock() } + return phase.isTerminal + } + + var terminalDate: Date? { + lock.lock() + defer { lock.unlock() } + return finishedAt + } + + func requestCancellation() { + lock.lock() + if !phase.isTerminal { + cancellationRequested = true + phase = .cancelling + } + lock.unlock() + control.cancel() + } + + func setTransferring() { + lock.lock() + if !phase.isTerminal { + phase = cancellationRequested ? .cancelling : .transferring + } + lock.unlock() + } + + func setFinalizing() { + lock.lock() + if !phase.isTerminal { + phase = cancellationRequested ? .cancelling : .finalizing + } + lock.unlock() + } + + func complete(_ result: DoryMachineGuestFileExportResult) { + lock.lock() + self.result = result + phase = .completed + finishedAt = Date() + lock.unlock() + } + + func cancel() { + lock.lock() + cancellationRequested = true + phase = .cancelled + finishedAt = Date() + lock.unlock() + } + + func fail(_ failure: DoryMachineFileTransferFailure) { + lock.lock() + self.failure = failure + phase = .failed + finishedAt = Date() + lock.unlock() + } + + func completedResult() -> DoryMachineGuestFileExportResult? { + lock.lock() + defer { lock.unlock() } + guard phase == .completed else { return nil } + return result + } + + func status() -> DoryMachineGuestFileExportOperationStatus { + let pullProgress = control.progress() + lock.lock() + defer { lock.unlock() } + let filesCompleted = result?.filesReceived ?? pullProgress.filesCompleted + let bytesCompleted = result?.bytesReceived ?? pullProgress.bytesCompleted + let visiblePhase: DoryMachineFileTransferPhase + if phase == .preparing || phase == .transferring || phase == .finalizing { + switch pullProgress.phase { + case .preparing: + visiblePhase = phase + case .transferring: + visiblePhase = .transferring + case .finalizing, .completed: + visiblePhase = .finalizing + case .cancelled: + visiblePhase = .cancelling + case .failed: + visiblePhase = phase + } + } else { + visiblePhase = phase + } + return DoryMachineGuestFileExportOperationStatus( + operationID: operationID, + machineID: machineID, + phase: visiblePhase, + filesTotal: max(pullProgress.filesTotal, result?.filesReceived ?? 0), + filesCompleted: filesCompleted, + bytesTotal: max(pullProgress.bytesTotal, result?.bytesReceived ?? 0), + bytesCompleted: bytesCompleted, + currentPath: visiblePhase.isTerminal ? nil : pullProgress.currentPath, + result: result, + failure: failure + ) + } +} + enum DoryMachineFileTransferCancellation: Error { case cancelled } @@ -279,3 +501,9 @@ enum MachineFileTransferTerminalOutcome { case cancelled case failed(DoryMachineFileTransferFailure) } + +enum MachineGuestFileExportTerminalOutcome { + case completed(DoryMachineGuestFileExportResult) + case cancelled + case failed(DoryMachineFileTransferFailure) +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index fef08cb4..3dbfed17 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -901,6 +901,8 @@ public final class MachineManager: @unchecked Sendable { private var machines: [String: MachineEntry] = [:] private var fileTransferOperations: [String: MachineFileTransferOperation] = [:] private var activeFileTransferByMachine: [String: String] = [:] + private var guestFileExportOperations: [String: MachineGuestFileExportOperation] = [:] + private var activeGuestFileExportByMachine: [String: String] = [:] private var deletingMachineIDs: Set = [] private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:] private var resolvedLaunchRegistry: BackendRegistry? @@ -5198,7 +5200,8 @@ public final class MachineManager: @unchecked Sendable { let transferID = Self.makeMachineFileTransferID() fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) - if activeFileTransferByMachine[id] != nil { + if activeFileTransferByMachine[id] != nil + || activeGuestFileExportByMachine[id] != nil { fileTransferLock.unlock() throw DoryMachineFileTransferError.transferAlreadyInProgress(id) } @@ -5244,7 +5247,8 @@ public final class MachineManager: @unchecked Sendable { let transferID = Self.makeMachineFileTransferID() fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) - if activeFileTransferByMachine[id] != nil { + if activeFileTransferByMachine[id] != nil + || activeGuestFileExportByMachine[id] != nil { fileTransferLock.unlock() throw DoryMachineFileTransferError.transferAlreadyInProgress(id) } @@ -5360,6 +5364,218 @@ public final class MachineManager: @unchecked Sendable { return operation.status() } + /// Starts a bounded guest-to-host export into Dory's private staging namespace. The caller + /// supplies no host path: the output root is derived from the operation ID and created with + /// create-new semantics by the Rust pull engine. Guest reads are restricted to the configured + /// non-root account's home tree. + public func beginGuestFileExport( + id: String, + guestSource: String + ) throws -> DoryMachineGuestFileExportOperationStatus { + guard let machineStatus = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + guard machineStatus.state == .running, + machineStatus.agentSocketPath != nil else { + throw MachineManagerError.agentUnavailable(id) + } + guard machineStatus.supportsAgentCapability("sync-pull") else { + throw MachineManagerError.agentCapabilityUnavailable(id, "sync-pull") + } + let validatedGuestSource = try Self.validatedGuestExportSource( + guestSource, + machineStatus: machineStatus, + machineID: id + ) + + let exportID = Self.makeMachineFileTransferID() + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + if activeFileTransferByMachine[id] != nil + || activeGuestFileExportByMachine[id] != nil { + fileTransferLock.unlock() + throw DoryMachineFileTransferError.transferAlreadyInProgress(id) + } + let privateStagingRoot: String + do { + privateStagingRoot = try DoryMachineFileTransferStager.reserveDaemonExportRoot( + operationID: exportID + ) + } catch { + fileTransferLock.unlock() + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + let operation = MachineGuestFileExportOperation( + operationID: exportID, + machineID: id, + privateStagingRoot: privateStagingRoot + ) + guestFileExportOperations[exportID] = operation + activeGuestFileExportByMachine[id] = exportID + fileTransferLock.unlock() + + fileTransferQueue.async { [self, operation] in + var outcome: MachineGuestFileExportTerminalOutcome + do { + let result = try performGuestFileExport( + id: id, + guestSource: validatedGuestSource, + privateStagingRoot: privateStagingRoot, + exportID: exportID, + operation: operation + ) + outcome = operation.isCancellationRequested ? .cancelled : .completed(result) + } catch { + outcome = operation.isCancellationRequested + ? .cancelled + : .failed(Self.fileTransferFailure(error, machineID: id)) + } + if case .completed = outcome { + // Ownership remains with the exact terminal operation until the client consumes + // or discards it. Failed and cancelled roots never escape the daemon. + } else { + do { + try DoryMachineFileTransferStager.removeManagedStagingRoot( + privateStagingRoot + ) + } catch { + outcome = .failed(.init( + code: .transferFailed, + message: "Guest file export cleanup failed for \(id)." + )) + } + } + fileTransferLock.lock() + switch outcome { + case let .completed(result): + operation.complete(result) + case .cancelled: + operation.cancel() + case let .failed(failure): + operation.fail(failure) + } + if activeGuestFileExportByMachine[id] == exportID { + activeGuestFileExportByMachine.removeValue(forKey: id) + } + fileTransferLock.unlock() + } + return operation.status() + } + + public func guestFileExportStatus( + id: String, + operationID: String + ) throws -> DoryMachineGuestFileExportOperationStatus { + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + let operation = guestFileExportOperations[operationID] + fileTransferLock.unlock() + guard let operation, operation.machineID == id else { + throw DoryMachineFileTransferError.unknownTransfer(id, operationID) + } + return operation.status() + } + + public func currentGuestFileExportStatus( + id: String + ) -> DoryMachineGuestFileExportOperationStatus? { + fileTransferLock.lock() + pruneFileTransferOperationsLocked(now: Date()) + guard let operationID = activeGuestFileExportByMachine[id], + let operation = guestFileExportOperations[operationID] else { + activeGuestFileExportByMachine.removeValue(forKey: id) + fileTransferLock.unlock() + return nil + } + fileTransferLock.unlock() + return operation.status() + } + + public func cancelGuestFileExport( + id: String, + operationID: String + ) throws -> DoryMachineGuestFileExportOperationStatus { + fileTransferLock.lock() + let operation = guestFileExportOperations[operationID] + fileTransferLock.unlock() + guard let operation, operation.machineID == id else { + throw DoryMachineFileTransferError.unknownTransfer(id, operationID) + } + operation.requestCancellation() + return operation.status() + } + + /// Deletes the private result after the client has copied it to its user-selected destination. + /// Only a completed exact operation can authorize this cleanup. + public func discardGuestFileExport( + id: String, + operationID: String + ) throws { + fileTransferLock.lock() + let operation = guestFileExportOperations[operationID] + fileTransferLock.unlock() + guard let operation, operation.machineID == id else { + throw DoryMachineFileTransferError.unknownTransfer(id, operationID) + } + guard let result = operation.completedResult() else { + throw DoryMachineFileTransferError.exportNotComplete(id, operationID) + } + do { + try DoryMachineFileTransferStager.removeManagedStagingRoot( + result.privateStagingRoot + ) + } catch { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + fileTransferLock.lock() + if guestFileExportOperations[operationID] === operation { + guestFileExportOperations.removeValue(forKey: operationID) + } + fileTransferLock.unlock() + } + + private func performGuestFileExport( + id: String, + guestSource: String, + privateStagingRoot: String, + exportID: String, + operation: MachineGuestFileExportOperation + ) throws -> DoryMachineGuestFileExportResult { + try Self.requireTransferNotCancelled(operation) + operation.setTransferring() + let limits = DoryPullLimits( + maxFiles: UInt64(DoryMachineFileTransferStager.maximumFileCount), + maxDirectories: UInt64( + DoryMachineFileTransferStager.maximumEntryCount + - DoryMachineFileTransferStager.maximumFileCount + ), + maxBytes: DoryMachineFileTransferStager.maximumTransferBytes + ) + let stats: DoryPullStats + do { + stats = try withAgentClient(id: id, requiredCapability: "sync-pull") { client in + try client.pull( + remoteRoot: guestSource, + localRoot: privateStagingRoot, + limits: limits, + control: operation.control + ) + } + } catch { + throw DoryMachineFileTransferError.transferFailed(id) + } + try Self.requireTransferNotCancelled(operation) + operation.setFinalizing() + guard DoryMachineFileTransferStager.isDaemonExportRoot(privateStagingRoot) else { + throw DoryMachineFileTransferError.invalidPrivateStagingRoot + } + return DoryMachineGuestFileExportResult( + exportID: exportID, + privateStagingRoot: privateStagingRoot, + stats: stats + ) + } + private func performStagedFileTransfer( id: String, privateStagingRoot: String, @@ -5511,6 +5727,37 @@ public final class MachineManager: @unchecked Sendable { } } + private static func requireTransferNotCancelled( + _ operation: MachineGuestFileExportOperation + ) throws { + if operation.isCancellationRequested { + throw DoryMachineFileTransferCancellation.cancelled + } + } + + private static func validatedGuestExportSource( + _ requestedPath: String, + machineStatus: DoryMachineStatus, + machineID: String + ) throws -> String { + guard requestedPath.utf8.count > 1, + requestedPath.utf8.count <= 4_096, + !requestedPath.contains("\0"), + let account = machineStatus.typedSettings?.guestIdentityIntent.account, + let username = account.username, + DoryVMGuestAccountIntent.isValidUsername(username), + username != "root" else { + throw DoryMachineFileTransferError.invalidGuestSource(machineID) + } + let standardized = URL(fileURLWithPath: requestedPath).standardizedFileURL.path + let guestHome = "/home/\(username)" + guard requestedPath == standardized, + standardized == guestHome || standardized.hasPrefix(guestHome + "/") else { + throw DoryMachineFileTransferError.invalidGuestSource(machineID) + } + return standardized + } + private static func fileTransferFailure( _ error: Error, machineID: String @@ -5523,8 +5770,8 @@ public final class MachineManager: @unchecked Sendable { return .init(code: .guestPreparationFailed, message: "Could not prepare the guest destination.") case .guestFinalizationFailed: return .init(code: .guestFinalizationFailed, message: "Could not finalize guest file ownership.") - case .invalidPrivateStagingRoot, .transferAlreadyInProgress, .unknownTransfer, - .transferFailed, nil: + case .invalidPrivateStagingRoot, .invalidGuestSource, .transferAlreadyInProgress, + .unknownTransfer, .exportNotComplete, .transferFailed, nil: return .init(code: .transferFailed, message: "File transfer failed for \(machineID).") } } @@ -5535,6 +5782,7 @@ public final class MachineManager: @unchecked Sendable { guard let finishedAt = operation.terminalDate else { return true } return finishedAt >= expiration } + pruneGuestFileExportOperationsLocked(now: now) if fileTransferOperations.count <= 128 { return } let removable = fileTransferOperations .filter { $0.value.isTerminal } @@ -5544,6 +5792,30 @@ public final class MachineManager: @unchecked Sendable { } } + private func pruneGuestFileExportOperationsLocked(now: Date) { + let expiration = now.addingTimeInterval(-60 * 60) + let expired = guestFileExportOperations.filter { _, operation in + guard let finishedAt = operation.terminalDate else { return false } + return finishedAt < expiration + } + for (operationID, operation) in expired { + try? DoryMachineFileTransferStager.removeManagedStagingRoot( + operation.privateStagingRoot + ) + guestFileExportOperations.removeValue(forKey: operationID) + } + if guestFileExportOperations.count <= 128 { return } + let removable = guestFileExportOperations + .filter { $0.value.isTerminal } + .sorted { ($0.value.terminalDate ?? .distantFuture) < ($1.value.terminalDate ?? .distantFuture) } + for (operationID, operation) in removable.prefix(guestFileExportOperations.count - 128) { + try? DoryMachineFileTransferStager.removeManagedStagingRoot( + operation.privateStagingRoot + ) + guestFileExportOperations.removeValue(forKey: operationID) + } + } + private static func guestNumericIdentity( client: any AgentControlClient, program: String, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift index ddb52ff3..103c3503 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift @@ -145,6 +145,36 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { } } + func testDaemonExportReservationUsesOnlyThePrivateManagedNamespace() throws { + let operationID = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let root = try DoryMachineFileTransferStager.reserveDaemonExportRoot( + operationID: operationID + ) + defer { try? DoryMachineFileTransferStager.removeManagedStagingRoot(root) } + + XCTAssertFalse(FileManager.default.fileExists(atPath: root)) + XCTAssertTrue(root.hasSuffix("/export-\(getpid())-\(operationID)")) + try FileManager.default.createDirectory( + atPath: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.createDirectory( + atPath: root + "/mode-zero", + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try Data("private".utf8).write( + to: URL(fileURLWithPath: root + "/mode-zero/file") + ) + XCTAssertEqual(chmod(root + "/mode-zero", 0o000), 0) + XCTAssertTrue(DoryMachineFileTransferStager.isDaemonExportRoot(root)) + XCTAssertFalse(DoryMachineFileTransferStager.isDaemonExportRoot(fixtureLikePath(operationID))) + + try DoryMachineFileTransferStager.removeManagedStagingRoot(root) + XCTAssertFalse(FileManager.default.fileExists(atPath: root)) + } + private func permissions(_ path: String) throws -> mode_t { var info = stat() guard lstat(path, &info) == 0 else { @@ -152,6 +182,10 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { } return info.st_mode } + + private func fixtureLikePath(_ operationID: String) -> String { + "/tmp/export-\(getpid())-\(operationID)" + } } private struct StagerFixture { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift index 67f09aa2..1058a4d1 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -266,6 +266,122 @@ final class MachineManagerFileTransferTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: claimed)) } + func testGuestExportStagesOnlyAHomeTreeAndRequiresExplicitDiscard() throws { + let fixture = try makeRunningFixture(tag: "guest-export") + defer { fixture.cleanup() } + + let started = try fixture.manager.beginGuestFileExport( + id: "desktop", + guestSource: "/home/alice/Documents/project" + ) + let completed = try waitForGuestExport( + manager: fixture.manager, + machineID: "desktop", + operationID: started.operationID + ) + + XCTAssertEqual(completed.phase, .completed) + XCTAssertEqual(completed.result?.exportID, started.operationID) + XCTAssertEqual(completed.result?.filesReceived, 1) + XCTAssertEqual(completed.result?.directoriesReceived, 0) + XCTAssertEqual(completed.result?.bytesReceived, 12) + XCTAssertEqual(completed.filesCompleted, 1) + XCTAssertEqual(completed.bytesCompleted, 12) + XCTAssertEqual(completed.fractionCompleted, 1) + XCTAssertNil(completed.currentPath) + let root = try XCTUnwrap(completed.result?.privateStagingRoot) + XCTAssertTrue(root.contains("/export-\(getpid())-")) + XCTAssertEqual( + try Data(contentsOf: URL(fileURLWithPath: root + "/guest-file.txt")), + Data("guest-export".utf8) + ) + XCTAssertEqual(fixture.agent.pulls.map(\.remoteRoot), [ + "/home/alice/Documents/project", + ]) + + try fixture.manager.discardGuestFileExport( + id: "desktop", + operationID: started.operationID + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: root)) + XCTAssertThrowsError(try fixture.manager.guestFileExportStatus( + id: "desktop", + operationID: started.operationID + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .unknownTransfer("desktop", started.operationID) + ) + } + } + + func testGuestExportRejectsTraversalAndPathsOutsideManagedHome() throws { + let fixture = try makeRunningFixture(tag: "guest-export-paths") + defer { fixture.cleanup() } + + for path in [ + "/etc", + "/home/alice/../bob/private", + "/home/alice-other/private", + "/home/alice/Documents/", + ] { + XCTAssertThrowsError(try fixture.manager.beginGuestFileExport( + id: "desktop", + guestSource: path + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .invalidGuestSource("desktop") + ) + } + } + XCTAssertTrue(fixture.agent.pulls.isEmpty) + } + + func testGuestExportCancellationRemovesPrivateOutputAndFencesPush() throws { + let fixture = try makeRunningFixture( + tag: "guest-export-cancel", + blockControlledPull: true + ) + defer { + fixture.agent.releaseControlledPull() + fixture.cleanup() + } + + let started = try fixture.manager.beginGuestFileExport( + id: "desktop", + guestSource: "/home/alice/Documents" + ) + XCTAssertTrue(fixture.agent.waitForControlledPull()) + XCTAssertThrowsError(try fixture.manager.beginStagedFileTransfer( + id: "desktop", + privateStagingRoot: fixture.stagingRoot + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferError, + .transferAlreadyInProgress("desktop") + ) + } + let cancelling = try fixture.manager.cancelGuestFileExport( + id: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(cancelling.phase, .cancelling) + fixture.agent.releaseControlledPull() + + let cancelled = try waitForGuestExport( + manager: fixture.manager, + machineID: "desktop", + operationID: started.operationID + ) + XCTAssertEqual(cancelled.phase, .cancelled) + XCTAssertNil(cancelled.result) + XCTAssertNil(cancelled.failure) + let localRoot = try XCTUnwrap(fixture.agent.pulls.first?.localRoot) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRoot)) + XCTAssertNil(fixture.manager.currentGuestFileExportStatus(id: "desktop")) + } + private func waitForTransfer( manager: MachineManager, machineID: String, @@ -288,10 +404,34 @@ final class MachineManagerFileTransferTests: XCTestCase { ) } + private func waitForGuestExport( + manager: MachineManager, + machineID: String, + operationID: String + ) throws -> DoryMachineGuestFileExportOperationStatus { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let status = try manager.guestFileExportStatus( + id: machineID, + operationID: operationID + ) + if status.phase.isTerminal { + return status + } + Thread.sleep(forTimeInterval: 0.01) + } + return try manager.guestFileExportStatus( + id: machineID, + operationID: operationID + ) + } + private func makeRunningFixture( tag: String, failPush: Bool = false, - blockControlledPush: Bool = false + blockControlledPush: Bool = false, + failPull: Bool = false, + blockControlledPull: Bool = false ) throws -> TransferFixture { let suffix = "\(getpid())-\(UInt32.random(in: 0.. Bool { controlledPushStarted.wait(timeout: .now() + 2) == .success } @@ -437,6 +603,14 @@ private final class TransferAgentRecorder: @unchecked Sendable { controlledPushRelease.signal() } + func waitForControlledPull() -> Bool { + controlledPullStarted.wait(timeout: .now() + 2) == .success + } + + func releaseControlledPull() { + controlledPullRelease.signal() + } + func connect(socketPath: String) throws -> any AgentControlClient { _ = socketPath return TransferAgentClient(owner: self) @@ -483,6 +657,39 @@ private final class TransferAgentRecorder: @unchecked Sendable { } return try push(localRoot: localRoot, remoteRoot: remoteRoot) } + + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + controlled: Bool + ) throws -> DoryPullStats { + lock.lock() + recordedPulls.append(Pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits + )) + lock.unlock() + if controlled { + controlledPullStarted.signal() + } + try FileManager.default.createDirectory( + atPath: localRoot, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try Data("guest-export".utf8).write( + to: URL(fileURLWithPath: localRoot + "/guest-file.txt") + ) + if controlled, blockControlledPull { + _ = controlledPullRelease.wait(timeout: .now() + 2) + } + if failPull { + throw AgentControlError.capabilityUnavailable("injected-pull-failure") + } + return DoryPullStats(filesReceived: 1, directoriesReceived: 0, bytesReceived: 12) + } } private final class TransferAgentClient: AgentControlClient, @unchecked Sendable { @@ -520,6 +727,32 @@ private final class TransferAgentClient: AgentControlClient, @unchecked Sendable control: control ) } + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits + ) throws -> DoryPullStats { + try owner.pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits, + controlled: false + ) + } + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + control: DoryPullControl + ) throws -> DoryPullStats { + _ = control + return try owner.pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits, + controlled: true + ) + } func exec( argv: [String], cwd: String, From 1c555c4193e4d6f881590bab4f1cecfa6d1a7664 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:14:55 +0000 Subject: [PATCH 208/338] feat(xpc): expose guest file exports --- Dory/Runtime/Doryd/DorydClient.swift | 5 + DoryTests/DorydClientTests.swift | 48 +++++ .../Sources/DorydKit/DorydControl.swift | 5 + .../Sources/DorydKit/DorydService.swift | 198 ++++++++++++++++++ .../DorydKitTests/DorydServiceTests.swift | 193 +++++++++++++++++ 5 files changed, 449 insertions(+) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 31eff059..fe502ab5 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -30,6 +30,11 @@ nonisolated protocol DorydControlXPC { func machineTransferCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportDiscard(_ machineID: String, operationID: String, reply: @escaping (Bool, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 67abf133..48ff34c7 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -3621,6 +3621,54 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) } + func machineGuestExportStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + _ = request + reply(false, [:], "guest file export is not configured") + } + + func machineGuestExportCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + reply(true, ["schema": UInt16(1), "active": false], "") + } + + func machineGuestExportStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + _ = operationID + reply(false, [:], "guest file export is not configured") + } + + func machineGuestExportCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + _ = operationID + reply(false, [:], "guest file export is not configured") + } + + func machineGuestExportDiscard( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, String) -> Void + ) { + _ = machineID + _ = operationID + reply(false, "guest file export is not configured") + } + func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let recipe = request["recipe"] as? String ?? "rust" lock.lock() diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index ef584601..7922e9cb 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -30,6 +30,11 @@ import Foundation func machineTransferCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportDiscard(_ machineID: String, operationID: String, reply: @escaping (Bool, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index de85a115..1aeab818 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -562,6 +562,138 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineGuestExportStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let parsedRequest = try MachineGuestExportRequest(xpcDictionary: request) + let status = try machineManager.beginGuestFileExport( + id: machineID, + guestSource: parsedRequest.guestSource + ) + incidentWriter?.record( + type: "machine.guest_file_export_started", + detail: "\(machineID) \(status.operationID)" + ) + reply( + true, + status.xpcDictionary(exposesCompletedResult: false), + "" + ) + } catch { + incidentWriter?.record( + type: "machine.guest_file_export_start_failed", + detail: "\(machineID): \(error)" + ) + reply(false, [:], String(describing: error)) + } + } + + public func machineGuestExportStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + guard MachineTransferRequest.isValidOperationID(operationID) else { + reply(false, [:], "invalid guest file export operation identifier") + return + } + do { + let status = try machineManager.guestFileExportStatus( + id: machineID, + operationID: operationID + ) + reply(true, status.xpcDictionary, "") + } catch { + reply(false, [:], String(describing: error)) + } + } + + public func machineGuestExportCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + let operation = machineManager.currentGuestFileExportStatus(id: machineID) + var body: [String: Any] = [ + "schema": UInt16(1), + "active": operation != nil, + ] + if let operation { + body["operation"] = operation.xpcDictionary + } + reply(true, body as NSDictionary, "") + } + + public func machineGuestExportCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + guard MachineTransferRequest.isValidOperationID(operationID) else { + reply(false, [:], "invalid guest file export operation identifier") + return + } + do { + let status = try machineManager.cancelGuestFileExport( + id: machineID, + operationID: operationID + ) + incidentWriter?.record( + type: "machine.guest_file_export_cancel", + detail: "\(machineID) \(operationID)" + ) + reply(true, status.xpcDictionary, "") + } catch { + reply(false, [:], String(describing: error)) + } + } + + public func machineGuestExportDiscard( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, String) -> Void + ) { + guard let machineManager else { + reply(false, "machine manager is not configured") + return + } + guard MachineTransferRequest.isValidOperationID(operationID) else { + reply(false, "invalid guest file export operation identifier") + return + } + do { + try machineManager.discardGuestFileExport( + id: machineID, + operationID: operationID + ) + incidentWriter?.record( + type: "machine.guest_file_export_discard", + detail: "\(machineID) \(operationID)" + ) + reply(true, "") + } catch { + reply(false, String(describing: error)) + } + } + public func machineProvision( _ machineID: String, request: NSDictionary, @@ -1426,6 +1558,26 @@ private struct MachineTransferRequest: Sendable { } } +private struct MachineGuestExportRequest: Sendable { + var guestSource: String + + init(xpcDictionary dictionary: NSDictionary) throws { + guard let keys = dictionary.allKeys as? [String], + Set(keys) == ["schema", "guestSource"], + let schema = dictionary["schema"] as? NSNumber, + CFGetTypeID(schema) != CFBooleanGetTypeID(), + schema.uint16Value == 1, + schema.doubleValue == 1, + let source = dictionary["guestSource"] as? String, + source.hasPrefix("/"), + source.utf8.count <= 4_096, + !source.contains("\0") else { + throw XPCRemoteConfigError.invalid("machineGuestExport") + } + guestSource = source + } +} + private struct MachineProvisionRequest { var recipeID: String @@ -1612,6 +1764,52 @@ private extension DoryMachineFileTransferOperationStatus { } } +private extension DoryMachineGuestFileExportResult { + var xpcDictionary: NSDictionary { + [ + "schema": UInt16(1), + "exportID": exportID, + "privateStagingRoot": privateStagingRoot, + "filesReceived": filesReceived, + "directoriesReceived": directoriesReceived, + "bytesReceived": bytesReceived, + ] + } +} + +private extension DoryMachineGuestFileExportOperationStatus { + var xpcDictionary: NSDictionary { + xpcDictionary(exposesCompletedResult: true) + } + + func xpcDictionary(exposesCompletedResult: Bool) -> NSDictionary { + var dictionary: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase.rawValue, + "filesTotal": filesTotal, + "filesCompleted": filesCompleted, + "bytesTotal": bytesTotal, + "bytesCompleted": bytesCompleted, + ] + if let currentPath { + dictionary["currentPath"] = currentPath + } + if exposesCompletedResult, let result { + dictionary["result"] = result.xpcDictionary + } + if let failure { + dictionary["failure"] = [ + "schema": UInt16(1), + "code": failure.code.rawValue, + "message": failure.message, + ] as NSDictionary + } + return dictionary as NSDictionary + } +} + private extension DomainRoute { init(xpcDictionary dictionary: NSDictionary) throws { guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index dc209257..5d3efac1 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1940,6 +1940,7 @@ final class DorydServiceTests: XCTestCase { agentProtocolVersion: DoryCore.protocolVersion(), agentCapabilities: [ DoryAgentCapability(id: "exec", version: 1), + DoryAgentCapability(id: "sync-pull", version: 1), DoryAgentCapability(id: "sync-push", version: 1), ], agentSocketPath: "/run/agent.sock" @@ -2082,6 +2083,157 @@ final class DorydServiceTests: XCTestCase { invalidStatus.fulfill() } wait(for: [invalidStatus], timeout: 5) + + let malformedGuestExports: [NSDictionary] = [ + [ + "schema": UInt16(2), + "guestSource": "/home/developer/Documents", + ], + [ + "schema": UInt16(1), + "guestSource": "/home/developer/Documents", + "unexpected": true, + ], + [ + "schema": UInt16(1), + "guestSource": "Documents", + ], + [ + "schema": true, + "guestSource": "/home/developer/Documents", + ], + ] + for malformed in malformedGuestExports { + let rejected = expectation(description: "malformed guest export rejected") + proxy.machineGuestExportStart("dev", request: malformed) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(body.isEqual(to: [:])) + XCTAssertTrue(message.contains("machineGuestExport"), message) + rejected.fulfill() + } + wait(for: [rejected], timeout: 5) + } + + let guestExportStart = expectation(description: "machineGuestExportStart reply") + var exportOperationID = "" + proxy.machineGuestExportStart("dev", request: [ + "schema": UInt16(1), + "guestSource": "/home/developer/Documents", + ]) { ok, body, message in + XCTAssertTrue(ok, message) + exportOperationID = body["operationID"] as? String ?? "" + XCTAssertEqual(exportOperationID.utf8.count, 32) + XCTAssertEqual(body["machineID"] as? String, "dev") + XCTAssertEqual((body["schema"] as? NSNumber)?.uint16Value, 1) + XCTAssertNil(body["result"]) + XCTAssertFalse(body.description.contains("/home/developer/Documents")) + XCTAssertFalse(body.description.contains("/export-")) + guestExportStart.fulfill() + } + wait(for: [guestExportStart], timeout: 5) + + var guestExportTerminalBody: NSDictionary? + let guestExportDeadline = Date().addingTimeInterval(5) + while guestExportTerminalBody == nil, Date() < guestExportDeadline { + let statusReply = expectation(description: "machineGuestExportStatus reply") + proxy.machineGuestExportStatus( + "dev", + operationID: exportOperationID + ) { ok, body, message in + XCTAssertTrue(ok, message) + if let phase = body["phase"] as? String, + ["completed", "cancelled", "failed"].contains(phase) { + guestExportTerminalBody = body + } + statusReply.fulfill() + } + wait(for: [statusReply], timeout: 5) + if guestExportTerminalBody == nil { + Thread.sleep(forTimeInterval: 0.01) + } + } + let exportBody = try XCTUnwrap(guestExportTerminalBody) + XCTAssertEqual(exportBody["phase"] as? String, "completed") + XCTAssertEqual( + Set(exportBody.allKeys.compactMap { $0 as? String }), + [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", "result", + ] + ) + XCTAssertEqual(exportBody["filesTotal"] as? UInt64, 1) + XCTAssertEqual(exportBody["filesCompleted"] as? UInt64, 1) + XCTAssertEqual(exportBody["bytesTotal"] as? UInt64, 12) + XCTAssertEqual(exportBody["bytesCompleted"] as? UInt64, 12) + let exportResult = try XCTUnwrap(exportBody["result"] as? NSDictionary) + XCTAssertEqual( + Set(exportResult.allKeys.compactMap { $0 as? String }), + [ + "schema", "exportID", "privateStagingRoot", "filesReceived", + "directoriesReceived", "bytesReceived", + ] + ) + XCTAssertEqual(exportResult["exportID"] as? String, exportOperationID) + XCTAssertEqual(exportResult["filesReceived"] as? UInt64, 1) + XCTAssertEqual(exportResult["directoriesReceived"] as? UInt64, 1) + XCTAssertEqual(exportResult["bytesReceived"] as? UInt64, 12) + let privateExportRoot = try XCTUnwrap( + exportResult["privateStagingRoot"] as? String + ) + XCTAssertTrue(privateExportRoot.contains("/export-")) + XCTAssertTrue(FileManager.default.fileExists( + atPath: privateExportRoot + "/nested/guest.txt" + )) + + let currentGuestExport = expectation( + description: "machineGuestExportCurrent inactive reply" + ) + proxy.machineGuestExportCurrent("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual( + Set(body.allKeys.compactMap { $0 as? String }), + ["schema", "active"] + ) + XCTAssertEqual(body["active"] as? Bool, false) + XCTAssertFalse(body.description.contains(privateExportRoot)) + currentGuestExport.fulfill() + } + wait(for: [currentGuestExport], timeout: 5) + + let discardGuestExport = expectation(description: "machineGuestExportDiscard reply") + proxy.machineGuestExportDiscard("dev", operationID: exportOperationID) { ok, message in + XCTAssertTrue(ok, message) + discardGuestExport.fulfill() + } + wait(for: [discardGuestExport], timeout: 5) + XCTAssertFalse(FileManager.default.fileExists(atPath: privateExportRoot)) + + let discardedStatus = expectation(description: "discarded guest export is unknown") + proxy.machineGuestExportStatus( + "dev", + operationID: exportOperationID + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(body.isEqual(to: [:])) + XCTAssertTrue(message.contains("unknown file transfer"), message) + XCTAssertFalse(message.contains(privateExportRoot)) + discardedStatus.fulfill() + } + wait(for: [discardedStatus], timeout: 5) + + let invalidGuestExportStatus = expectation( + description: "invalid guest export status rejected" + ) + proxy.machineGuestExportStatus( + "dev", + operationID: "NOT-AN-OPERATION" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(body.isEqual(to: [:])) + XCTAssertEqual(message, "invalid guest file export operation identifier") + invalidGuestExportStatus.fulfill() + } + wait(for: [invalidGuestExportStatus], timeout: 5) } func testMachineProvisionOverXPCInstallsRecipeThroughMachineAgent() throws { @@ -2418,6 +2570,47 @@ private final class ServiceFakeAgentControlClient: AgentControlClient, @unchecke return try push(localRoot: localRoot, remoteRoot: remoteRoot) } + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits + ) throws -> DoryPullStats { + _ = remoteRoot + _ = limits + try FileManager.default.createDirectory( + atPath: localRoot, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.createDirectory( + atPath: localRoot + "/nested", + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try Data("guest-export".utf8).write( + to: URL(fileURLWithPath: localRoot + "/nested/guest.txt") + ) + return DoryPullStats( + filesReceived: 1, + directoriesReceived: 1, + bytesReceived: 12 + ) + } + + func pull( + remoteRoot: String, + localRoot: String, + limits: DoryPullLimits, + control: DoryPullControl + ) throws -> DoryPullStats { + _ = control + return try pull( + remoteRoot: remoteRoot, + localRoot: localRoot, + limits: limits + ) + } + func exec( argv: [String], cwd: String, From 84336d96cf02f3dd2c8cc1d9baba0f0dd1fecf0f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:22:46 +0000 Subject: [PATCH 209/338] feat(app): decode guest file exports --- Dory/Runtime/Doryd/DorydClient.swift | 332 +++++++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 299 +++++++++++++++++++++++- 2 files changed, 620 insertions(+), 11 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index fe502ab5..a609599b 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -1082,6 +1082,47 @@ nonisolated private struct DorydMachineFileTransferCurrent: Sendable { var operation: DorydMachineFileTransferOperation? } +/// A daemon-owned, short-lived handoff containing bytes verified by the guest pull protocol. +/// The private root is available only on an exact completed operation and must be discarded after +/// the app materializes it into a user-selected destination. +nonisolated struct DorydMachineGuestFileExportResult: Sendable, Equatable { + var exportID: String + var privateStagingRoot: String + var filesReceived: UInt64 + var directoriesReceived: UInt64 + var bytesReceived: UInt64 +} + +nonisolated struct DorydMachineGuestFileExportOperation: Sendable, Equatable { + var operationID: String + var machineID: String + var phase: DorydMachineFileTransferPhase + var filesTotal: UInt64 + var filesCompleted: UInt64 + var bytesTotal: UInt64 + var bytesCompleted: UInt64 + var currentPath: String? + var result: DorydMachineGuestFileExportResult? + var failure: DorydMachineFileTransferFailure? + + var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + +nonisolated private struct DorydMachineGuestFileExportCurrent: Sendable { + var operation: DorydMachineGuestFileExportOperation? +} + nonisolated struct DorydRemoteMachineStatus: Sendable, Equatable { var id: String var state: String @@ -1598,6 +1639,94 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineGuestExportStart( + _ machineID: String, + guestSource: String + ) async throws -> DorydMachineGuestFileExportOperation { + let request: NSDictionary = [ + "schema": UInt16(1), + "guestSource": guestSource, + ] + let accepted: DorydMachineGuestFileExportOperation = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineGuestExportStart(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation( + from: dictionary, + allowsOmittedCompletedResult: true + ), operation.machineID == machineID else { + return nil + } + return operation + } + if accepted.phase == .completed, accepted.result == nil { + return try await machineGuestExportStatus( + machineID, + operationID: accepted.operationID + ) + } + return accepted + } + + func machineGuestExportStatus( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineGuestFileExportOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineGuestExportStatus(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation + } + } + + func machineGuestExportCurrent( + _ machineID: String + ) async throws -> DorydMachineGuestFileExportOperation? { + let current: DorydMachineGuestFileExportCurrent = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineGuestExportCurrent(machineID, reply: reply) + } decode: { dictionary in + Self.machineGuestFileExportCurrent(from: dictionary, machineID: machineID) + } + return current.operation + } + + func machineGuestExportCancel( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineGuestFileExportOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineGuestExportCancel(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation + } + } + + func machineGuestExportDiscard( + _ machineID: String, + operationID: String + ) async throws -> DorydCommandResult { + try await command { proxy, reply in + proxy.machineGuestExportDiscard( + machineID, + operationID: operationID, + reply: reply + ) + } + } + func machineStats(_ machineID: String) async throws -> DorydMachineStats { try await withTimeout(atLeast: 10).statusCommand { proxy, reply in proxy.machineStats(machineID, reply: reply) @@ -2883,9 +3012,11 @@ nonisolated final class DorydClient: @unchecked Sendable { let rawPhase = dictionary["phase"] as? String, let phase = DorydMachineFileTransferPhase(rawValue: rawPhase), let filesTotal = strictUInt64(dictionary["filesTotal"]), + filesTotal <= UInt64(DoryMachineFileTransferStager.maximumFileCount), let filesCompleted = strictUInt64(dictionary["filesCompleted"]), filesCompleted <= filesTotal, let bytesTotal = strictUInt64(dictionary["bytesTotal"]), + bytesTotal <= DoryMachineFileTransferStager.maximumTransferBytes, let bytesCompleted = strictUInt64(dictionary["bytesCompleted"]), bytesCompleted <= bytesTotal else { return nil @@ -3000,6 +3131,207 @@ nonisolated final class DorydClient: @unchecked Sendable { return DorydMachineFileTransferCurrent(operation: nil) } + nonisolated private static func machineGuestFileExportResult( + from dictionary: NSDictionary + ) -> DorydMachineGuestFileExportResult? { + guard Set(dictionary.allKeys.compactMap { $0 as? String }) == [ + "schema", "exportID", "privateStagingRoot", "filesReceived", + "directoriesReceived", "bytesReceived", + ], + dictionary.allKeys.count == 6, + strictUInt64(dictionary["schema"]) == 1, + let exportID = dictionary["exportID"] as? String, + isValidMachineTransferOperationID(exportID), + let privateStagingRoot = dictionary["privateStagingRoot"] as? String, + isValidMachineGuestExportRoot( + privateStagingRoot, + exportID: exportID + ), + let filesReceived = strictUInt64(dictionary["filesReceived"]), + filesReceived <= UInt64(DoryMachineFileTransferStager.maximumFileCount), + let directoriesReceived = strictUInt64(dictionary["directoriesReceived"]), + let bytesReceived = strictUInt64(dictionary["bytesReceived"]), + bytesReceived <= DoryMachineFileTransferStager.maximumTransferBytes else { + return nil + } + let (entriesReceived, overflow) = filesReceived.addingReportingOverflow( + directoriesReceived + ) + guard !overflow, + entriesReceived <= UInt64(DoryMachineFileTransferStager.maximumEntryCount) else { + return nil + } + return DorydMachineGuestFileExportResult( + exportID: exportID, + privateStagingRoot: privateStagingRoot, + filesReceived: filesReceived, + directoriesReceived: directoriesReceived, + bytesReceived: bytesReceived + ) + } + + nonisolated private static func machineGuestFileExportOperation( + from dictionary: NSDictionary, + allowsOmittedCompletedResult: Bool = false + ) -> DorydMachineGuestFileExportOperation? { + let requiredKeys: Set = [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", + ] + let optionalKeys: Set = ["currentPath", "result", "failure"] + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys).isSuperset(of: requiredKeys), + Set(rawKeys).isSubset(of: requiredKeys.union(optionalKeys)), + strictUInt64(dictionary["schema"]) == 1, + let operationID = dictionary["operationID"] as? String, + isValidMachineTransferOperationID(operationID), + let machineID = dictionary["machineID"] as? String, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + let rawPhase = dictionary["phase"] as? String, + let phase = DorydMachineFileTransferPhase(rawValue: rawPhase), + let filesTotal = strictUInt64(dictionary["filesTotal"]), + filesTotal <= UInt64(DoryMachineFileTransferStager.maximumFileCount), + let filesCompleted = strictUInt64(dictionary["filesCompleted"]), + filesCompleted <= filesTotal, + let bytesTotal = strictUInt64(dictionary["bytesTotal"]), + bytesTotal <= DoryMachineFileTransferStager.maximumTransferBytes, + let bytesCompleted = strictUInt64(dictionary["bytesCompleted"]), + bytesCompleted <= bytesTotal else { + return nil + } + let currentPath: String? + if let rawCurrentPath = dictionary["currentPath"] { + guard let value = rawCurrentPath as? String, + isSafeMachineTransferRelativePath(value), + !phase.isTerminal else { + return nil + } + currentPath = value + } else { + currentPath = nil + } + let result: DorydMachineGuestFileExportResult? + if let rawResult = dictionary["result"] { + guard let row = rawResult as? NSDictionary, + let decoded = machineGuestFileExportResult(from: row), + decoded.exportID == operationID else { + return nil + } + result = decoded + } else { + result = nil + } + let failure: DorydMachineFileTransferFailure? + if let rawFailure = dictionary["failure"] { + guard let row = rawFailure as? NSDictionary, + Set(row.allKeys.compactMap { $0 as? String }) + == ["schema", "code", "message"], + row.allKeys.count == 3, + strictUInt64(row["schema"]) == 1, + let rawCode = row["code"] as? String, + let code = DorydMachineFileTransferFailureCode(rawValue: rawCode), + let message = row["message"] as? String, + !message.isEmpty, + message.utf8.count <= 1_024, + !message.contains("\0") else { + return nil + } + failure = DorydMachineFileTransferFailure(code: code, message: message) + } else { + failure = nil + } + + switch phase { + case .completed: + guard failure == nil, + filesCompleted == filesTotal, + bytesCompleted == bytesTotal else { + return nil + } + if let result { + guard filesTotal == result.filesReceived, + bytesTotal == result.bytesReceived else { + return nil + } + } else if !allowsOmittedCompletedResult { + return nil + } + case .failed: + guard failure != nil, result == nil else { return nil } + case .cancelled: + guard result == nil, failure == nil else { return nil } + case .preparing, .transferring, .finalizing, .cancelling: + guard result == nil, failure == nil else { return nil } + } + return DorydMachineGuestFileExportOperation( + operationID: operationID, + machineID: machineID, + phase: phase, + filesTotal: filesTotal, + filesCompleted: filesCompleted, + bytesTotal: bytesTotal, + bytesCompleted: bytesCompleted, + currentPath: currentPath, + result: result, + failure: failure + ) + } + + nonisolated private static func machineGuestFileExportCurrent( + from dictionary: NSDictionary, + machineID: String + ) -> DorydMachineGuestFileExportCurrent? { + guard let keys = dictionary.allKeys as? [String], + keys.count == dictionary.allKeys.count, + strictUInt64(dictionary["schema"]) == 1, + let activeNumber = dictionary["active"] as? NSNumber, + CFGetTypeID(activeNumber) == CFBooleanGetTypeID() else { + return nil + } + if activeNumber.boolValue { + guard Set(keys) == ["schema", "active", "operation"], + let row = dictionary["operation"] as? NSDictionary, + let operation = machineGuestFileExportOperation(from: row), + operation.machineID == machineID, + !operation.phase.isTerminal else { + return nil + } + return DorydMachineGuestFileExportCurrent(operation: operation) + } + guard Set(keys) == ["schema", "active"] else { return nil } + return DorydMachineGuestFileExportCurrent(operation: nil) + } + + nonisolated private static func isValidMachineGuestExportRoot( + _ value: String, + exportID: String + ) -> Bool { + guard value.hasPrefix("/"), + value.utf8.count <= 4_096, + !value.contains("\0") else { + return false + } + let url = URL(fileURLWithPath: value, isDirectory: true) + guard url.standardizedFileURL.path == value, + url.deletingLastPathComponent().standardizedFileURL + == DoryMachineFileTransferStager.defaultStagingDirectory.standardizedFileURL else { + return false + } + let name = url.lastPathComponent + let prefix = "export-" + let suffix = "-" + exportID + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return false } + let processStart = name.index(name.startIndex, offsetBy: prefix.count) + let processEnd = name.index(name.endIndex, offsetBy: -suffix.count) + guard processStart < processEnd, + let processID = UInt64(name[processStart.. 0 else { + return false + } + return true + } + nonisolated private static func isValidMachineTransferOperationID(_ value: String) -> Bool { value.utf8.count == 32 && value.utf8.allSatisfy { byte in (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 48ff34c7..97978a28 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -175,6 +175,169 @@ struct DorydClientTests { service.setMachineTransferOperationResponse(nil) } + @MainActor + @Test func machineGuestExportUsesExactEvidenceAndRejectsHostPathSubstitution() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let guestSource = "/home/developer/Documents/project" + let started = try await client.machineGuestExportStart( + "dev", + guestSource: guestSource + ) + #expect(started.machineID == "dev") + #expect(started.phase == .preparing) + #expect(started.result == nil) + #expect( + Set(service.latestMachineGuestExportRequest?.allKeys.compactMap { + $0 as? String + } ?? []) == ["schema", "guestSource"] + ) + #expect( + service.latestMachineGuestExportRequest?["guestSource"] as? String + == guestSource + ) + #expect( + (service.latestMachineGuestExportRequest?["schema"] as? NSNumber)?.uint16Value + == 1 + ) + + service.setMachineGuestExportCurrentResponse([ + "schema": UInt16(1), + "active": true, + "operation": service.machineGuestExportOperationResponse( + operationID: started.operationID, + phase: "transferring" + ), + ]) + let current = try #require(try await client.machineGuestExportCurrent("dev")) + #expect(current.operationID == started.operationID) + #expect(current.phase == .transferring) + + service.setMachineGuestExportCurrentResponse([ + "schema": UInt16(1), + "active": false, + ]) + #expect(try await client.machineGuestExportCurrent("dev") == nil) + service.setMachineGuestExportCurrentResponse([ + "schema": UInt16(1), + "active": false, + "operation": service.machineGuestExportOperationResponse( + operationID: started.operationID, + phase: "transferring" + ), + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineGuestExportCurrent("dev") + } + service.setMachineGuestExportCurrentResponse(nil) + + let completed = try await client.machineGuestExportStatus( + "dev", + operationID: started.operationID + ) + #expect(completed.phase == .completed) + #expect(completed.result?.exportID == started.operationID) + #expect(completed.result?.filesReceived == 1) + #expect(completed.result?.directoriesReceived == 1) + #expect(completed.result?.bytesReceived == 12) + #expect( + completed.result?.privateStagingRoot.hasSuffix("-" + started.operationID) + == true + ) + #expect(completed.fractionCompleted == 1) + + let cancelled = try await client.machineGuestExportCancel( + "dev", + operationID: started.operationID + ) + #expect(cancelled.phase == .cancelled) + #expect(cancelled.result == nil) + #expect(service.machineGuestExportCancelCount == 1) + let discarded = try await client.machineGuestExportDiscard( + "dev", + operationID: started.operationID + ) + #expect(discarded.ok) + #expect(service.machineGuestExportDiscardCount == 1) + + let raceID = String(repeating: "e", count: 32) + let completedRace = service.machineGuestExportOperationResponse( + operationID: raceID, + phase: "completed" + ) + service.setMachineGuestExportStartResponse(completedRace.removing("result")) + service.setMachineGuestExportOperationResponse(completedRace) + let raced = try await client.machineGuestExportStart( + "dev", + guestSource: guestSource + ) + #expect(raced.phase == .completed) + #expect(raced.result?.exportID == raceID) + service.setMachineGuestExportStartResponse(nil) + + let completedRow = service.machineGuestExportOperationResponse( + operationID: started.operationID, + phase: "completed" + ) + let resultRow = try #require(completedRow["result"] as? NSDictionary) + let otherID = String(repeating: "f", count: 32) + let malformed: [NSDictionary] = [ + completedRow.adding("unexpected", true), + completedRow.removing("result"), + completedRow.replacing( + "result", + with: resultRow.replacing( + "privateStagingRoot", + with: "/tmp/host-secret" + ) + ), + completedRow.replacing( + "result", + with: resultRow.adding("unexpected", true) + ), + completedRow.replacing( + "result", + with: resultRow.replacing("filesReceived", with: true) + ), + completedRow.replacing( + "result", + with: resultRow + .replacing("exportID", with: otherID) + .replacing( + "privateStagingRoot", + with: DoryMachineFileTransferStager.defaultStagingDirectory + .appendingPathComponent( + "export-\(getpid())-\(otherID)", + isDirectory: true + ).path + ) + ), + completedRow.replacing( + "result", + with: resultRow.replacing( + "directoriesReceived", + with: UInt64(DoryMachineFileTransferStager.maximumEntryCount) + ) + ), + ] + for row in malformed { + service.setMachineGuestExportOperationResponse(row) + await #expect(throws: (any Error).self) { + _ = try await client.machineGuestExportStatus( + "dev", + operationID: started.operationID + ) + } + } + service.setMachineGuestExportOperationResponse(nil) + } + @Test func legacyDesktopDefaultsAndTogglesRemainFieldLocalInTypedEdits() throws { let defaults = DorydMachineTypedSettings( legacyEnvironment: [:], @@ -2961,6 +3124,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineTransferOperationResponseOverride: NSDictionary? private var _machineTransferCurrentResponseOverride: NSDictionary? private var _machineTransferCancelCount = 0 + private var _latestMachineGuestExportRequest: NSDictionary? + private var _machineGuestExportStartResponseOverride: NSDictionary? + private var _machineGuestExportOperationResponseOverride: NSDictionary? + private var _machineGuestExportCurrentResponseOverride: NSDictionary? + private var _machineGuestExportCancelCount = 0 + private var _machineGuestExportDiscardCount = 0 private var runtimeIdentityOverride: NSDictionary? private var artifactEvidenceOverride: NSDictionary? private var installedDesktopPayloadReceiptOverride: NSDictionary? @@ -3009,6 +3178,43 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) -> NSDictionary { Self.transferOperationRow(operationID: operationID, phase: phase) } + + var latestMachineGuestExportRequest: NSDictionary? { + lock.lock(); defer { lock.unlock() } + return _latestMachineGuestExportRequest + } + + var machineGuestExportCancelCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineGuestExportCancelCount + } + + var machineGuestExportDiscardCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineGuestExportDiscardCount + } + + func setMachineGuestExportStartResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportStartResponseOverride = response + } + + func setMachineGuestExportOperationResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportOperationResponseOverride = response + } + + func setMachineGuestExportCurrentResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportCurrentResponseOverride = response + } + + func machineGuestExportOperationResponse( + operationID: String, + phase: String + ) -> NSDictionary { + Self.guestExportOperationRow(operationID: operationID, phase: phase) + } var engineStopCount: Int { lock.lock(); defer { lock.unlock() } return _engineStopCount @@ -3626,9 +3832,19 @@ private final class FakeDorydService: NSObject, DorydControlXPC { request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void ) { - _ = machineID - _ = request - reply(false, [:], "guest file export is not configured") + lock.lock() + _latestMachineGuestExportRequest = request + let override = _machineGuestExportStartResponseOverride + lock.unlock() + reply( + true, + override ?? Self.guestExportOperationRow( + operationID: String(repeating: "d", count: 32), + machineID: machineID, + phase: "preparing" + ), + "" + ) } func machineGuestExportCurrent( @@ -3636,7 +3852,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply: @escaping (Bool, NSDictionary, String) -> Void ) { _ = machineID - reply(true, ["schema": UInt16(1), "active": false], "") + lock.lock() + let override = _machineGuestExportCurrentResponseOverride + lock.unlock() + reply(true, override ?? ["schema": UInt16(1), "active": false], "") } func machineGuestExportStatus( @@ -3644,9 +3863,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { - _ = machineID - _ = operationID - reply(false, [:], "guest file export is not configured") + lock.lock() + let override = _machineGuestExportOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.guestExportOperationRow( + operationID: operationID, + machineID: machineID, + phase: "completed" + ), + "" + ) } func machineGuestExportCancel( @@ -3654,9 +3882,16 @@ private final class FakeDorydService: NSObject, DorydControlXPC { operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { - _ = machineID - _ = operationID - reply(false, [:], "guest file export is not configured") + let cancelled = Self.guestExportOperationRow( + operationID: operationID, + machineID: machineID, + phase: "cancelled" + ) + lock.lock() + _machineGuestExportCancelCount += 1 + _machineGuestExportOperationResponseOverride = cancelled + lock.unlock() + reply(true, cancelled, "") } func machineGuestExportDiscard( @@ -3666,7 +3901,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) { _ = machineID _ = operationID - reply(false, "guest file export is not configured") + lock.lock() + _machineGuestExportDiscardCount += 1 + lock.unlock() + reply(true, "") } func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { @@ -4336,6 +4574,39 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return row as NSDictionary } + private static func guestExportOperationRow( + operationID: String, + machineID: String = "dev", + phase: String + ) -> NSDictionary { + var row: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase, + "filesTotal": UInt64(phase == "completed" ? 1 : 0), + "filesCompleted": UInt64(phase == "completed" ? 1 : 0), + "bytesTotal": UInt64(phase == "completed" ? 12 : 0), + "bytesCompleted": UInt64(phase == "completed" ? 12 : 0), + ] + if phase == "completed" { + let root = DoryMachineFileTransferStager.defaultStagingDirectory + .appendingPathComponent( + "export-\(getpid())-\(operationID)", + isDirectory: true + ).path + row["result"] = [ + "schema": UInt16(1), + "exportID": operationID, + "privateStagingRoot": root, + "filesReceived": UInt64(1), + "directoriesReceived": UInt64(1), + "bytesReceived": UInt64(12), + ] as NSDictionary + } + return row as NSDictionary + } + private static func execRow(stdout: String = "", stderr: String = "", exitCode: Int32 = 0) -> NSDictionary { [ "exitCode": exitCode, @@ -4436,6 +4707,12 @@ private extension NSDictionary { adding(key, value) } + func removing(_ key: String) -> NSDictionary { + var copy = stringKeyedCopy + copy.removeValue(forKey: key) + return copy as NSDictionary + } + var stringKeyedCopy: [String: Any] { var copy: [String: Any] = [:] for (key, value) in self { From ddf8117407b1ce7392e25b47af349405f4ca3545 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:29:10 +0000 Subject: [PATCH 210/338] feat(app): materialize guest file exports --- .../DoryMachineFileTransferStager.swift | 302 ++++++++++++++++++ .../DoryMachineFileTransferStagerTests.swift | 147 +++++++++ 2 files changed, 449 insertions(+) diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift index 9c64a552..0b1254a8 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineFileTransferStager.swift @@ -29,6 +29,25 @@ public struct DoryStagedMachineFileTransfer: Sendable, Equatable { } } +public struct DoryMaterializedMachineGuestFileExport: Sendable, Equatable { + public let rootURL: URL + public let fileCount: UInt64 + public let directoryCount: UInt64 + public let byteCount: UInt64 + + fileprivate init( + rootURL: URL, + fileCount: UInt64, + directoryCount: UInt64, + byteCount: UInt64 + ) { + self.rootURL = rootURL + self.fileCount = fileCount + self.directoryCount = directoryCount + self.byteCount = byteCount + } +} + public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, CustomStringConvertible { case emptySelection @@ -41,6 +60,8 @@ public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, case transferTooLarge case sourceChanged(String) case unsafeStagingDirectory + case exportEvidenceMismatch + case destinationExists(String) case io(String, Int32) public var description: String { @@ -65,6 +86,10 @@ public enum DoryMachineFileTransferStagingError: Error, Sendable, Equatable, "selected file changed while it was being staged: \(name)" case .unsafeStagingDirectory: "file transfer staging directory is not private" + case .exportEvidenceMismatch: + "guest file export no longer matches the verified transfer" + case let .destinationExists(name): + "a file or folder named \(name) already exists" case let .io(operation, code): "file transfer staging \(operation) failed: \(String(cString: strerror(code)))" } @@ -240,6 +265,199 @@ public enum DoryMachineFileTransferStager { ) } + /// Copies one exact daemon-owned guest export into a newly-created child of a user-selected + /// directory. The source and destination are traversed by descriptor without following + /// symlinks; the destination is published only after every byte and count matches the daemon's + /// completed evidence. Existing user files are never overwritten. + public static func materializeGuestExport( + privateStagingRoot: String, + exportID: String, + expectedFileCount: UInt64, + expectedDirectoryCount: UInt64, + expectedByteCount: UInt64, + destinationDirectory: URL, + destinationName: String + ) throws -> DoryMaterializedMachineGuestFileExport { + let (expectedEntryCount, expectedEntryOverflow) = expectedFileCount + .addingReportingOverflow(expectedDirectoryCount) + guard isValidOperationID(exportID), + isValidFileName(destinationName, atRoot: true), + let managed = managedRoot(privateStagingRoot), + managed.kind == .export, + managed.name.hasSuffix("-" + exportID), + expectedFileCount <= UInt64(maximumFileCount), + !expectedEntryOverflow, + expectedEntryCount <= UInt64(maximumEntryCount), + expectedByteCount <= maximumTransferBytes else { + throw DoryMachineFileTransferStagingError.exportEvidenceMismatch + } + + try makeManagedTreeReadable(privateStagingRoot) + let stagingDescriptor = try openPrivateStagingDirectory() + defer { close(stagingDescriptor) } + let sourceDescriptor = openat( + stagingDescriptor, + managed.name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard sourceDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("export open", errno) + } + defer { close(sourceDescriptor) } + guard isPrivateDirectory(descriptor: sourceDescriptor) else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + var sourceSnapshot = stat() + guard fstat(sourceDescriptor, &sourceSnapshot) == 0 else { + throw DoryMachineFileTransferStagingError.io("export stat", errno) + } + + let securityScope = destinationDirectory.startAccessingSecurityScopedResource() + defer { + if securityScope { destinationDirectory.stopAccessingSecurityScopedResource() } + } + let destinationDescriptor = open( + destinationDirectory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard destinationDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("destination open", errno) + } + defer { close(destinationDescriptor) } + var destinationInfo = stat() + guard fstat(destinationDescriptor, &destinationInfo) == 0, + (destinationInfo.st_mode & S_IFMT) == S_IFDIR else { + throw DoryMachineFileTransferStagingError.io("destination stat", errno) + } + + var existing = stat() + if fstatat( + destinationDescriptor, + destinationName, + &existing, + AT_SYMLINK_NOFOLLOW + ) == 0 { + throw DoryMachineFileTransferStagingError.destinationExists(destinationName) + } + guard errno == ENOENT else { + throw DoryMachineFileTransferStagingError.io("destination lookup", errno) + } + + let temporaryName = ".dory-export-" + UUID().uuidString.lowercased() + guard mkdirat(destinationDescriptor, temporaryName, mode_t(0o700)) == 0 else { + throw DoryMachineFileTransferStagingError.io("destination creation", errno) + } + var published = false + defer { + if !published { + try? removeCreatedTree( + parentDescriptor: destinationDescriptor, + name: temporaryName + ) + } + } + let temporaryDescriptor = openat( + destinationDescriptor, + temporaryName, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard temporaryDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("destination open", errno) + } + defer { close(temporaryDescriptor) } + + var state = StageState() + for childName in try directoryEntryNames(descriptor: sourceDescriptor) { + guard isValidFileName(childName, atRoot: true) else { + throw DoryMachineFileTransferStagingError.unsupportedFile(childName) + } + let childDescriptor = openat( + sourceDescriptor, + childName, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard childDescriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("export entry open", errno) + } + defer { close(childDescriptor) } + var childSnapshot = stat() + guard fstat(childDescriptor, &childSnapshot) == 0 else { + throw DoryMachineFileTransferStagingError.io("export entry stat", errno) + } + switch childSnapshot.st_mode & S_IFMT { + case S_IFREG: + try stageFile( + sourceDescriptor: childDescriptor, + sourceSnapshot: childSnapshot, + destinationDirectory: temporaryDescriptor, + name: childName, + displayPath: childName, + state: &state + ) + case S_IFDIR: + try stageDirectory( + sourceDescriptor: childDescriptor, + sourceSnapshot: childSnapshot, + destinationDirectory: temporaryDescriptor, + name: childName, + displayPath: childName, + depth: 1, + state: &state + ) + default: + throw DoryMachineFileTransferStagingError.unsupportedFile(childName) + } + } + try revalidateSource( + descriptor: sourceDescriptor, + expected: sourceSnapshot, + displayPath: "guest export" + ) + guard UInt64(state.fileCount) == expectedFileCount, + UInt64(state.directoryCount) == expectedDirectoryCount, + state.byteCount == expectedByteCount else { + throw DoryMachineFileTransferStagingError.exportEvidenceMismatch + } + guard fsync(temporaryDescriptor) == 0, fsync(destinationDescriptor) == 0 else { + throw DoryMachineFileTransferStagingError.io("destination sync", errno) + } + let renameResult = temporaryName.withCString { temporaryName in + destinationName.withCString { destinationName in + renameatx_np( + destinationDescriptor, + temporaryName, + destinationDescriptor, + destinationName, + UInt32(RENAME_EXCL) + ) + } + } + guard renameResult == 0 else { + if errno == EEXIST { + throw DoryMachineFileTransferStagingError.destinationExists(destinationName) + } + throw DoryMachineFileTransferStagingError.io("destination publish", errno) + } + if fsync(destinationDescriptor) != 0 { + let code = errno + try? removeCreatedTree( + parentDescriptor: destinationDescriptor, + name: destinationName + ) + throw DoryMachineFileTransferStagingError.io("destination sync", code) + } + published = true + return DoryMaterializedMachineGuestFileExport( + rootURL: destinationDirectory.appendingPathComponent( + destinationName, + isDirectory: true + ), + fileCount: expectedFileCount, + directoryCount: expectedDirectoryCount, + byteCount: expectedByteCount + ) + } + /// Returns true only for an app-authored handoff in Dory's exact shared staging namespace. /// Arbitrary owner-private directories are intentionally not accepted as transfer authority. package static func isClientStagingRoot(_ path: String) -> Bool { @@ -492,6 +710,90 @@ public enum DoryMachineFileTransferStager { } } + /// Normalizes guest-provided modes so the app can descriptor-copy a completed export. The + /// pull engine creates every entry as the daemon user without symlinks, but preserves guest + /// permission bits; mode 000 therefore needs an owner-only read/traverse grant here. + private static func makeManagedTreeReadable(_ path: String) throws { + var info = stat() + guard lstat(path, &info) == 0 else { + throw DoryMachineFileTransferStagingError.io("export stat", errno) + } + guard info.st_uid == geteuid() else { + throw DoryMachineFileTransferStagingError.unsafeStagingDirectory + } + switch info.st_mode & S_IFMT { + case S_IFREG: + guard fchmodat(AT_FDCWD, path, mode_t(0o600), AT_SYMLINK_NOFOLLOW) == 0 else { + throw DoryMachineFileTransferStagingError.io("export permissions", errno) + } + case S_IFDIR: + guard fchmodat(AT_FDCWD, path, mode_t(0o700), AT_SYMLINK_NOFOLLOW) == 0 else { + throw DoryMachineFileTransferStagingError.io("export permissions", errno) + } + let descriptor = open( + path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("export directory open", errno) + } + defer { close(descriptor) } + for childName in try directoryEntryNames(descriptor: descriptor) { + guard isValidFileName(childName, atRoot: false) else { + throw DoryMachineFileTransferStagingError.unsupportedFile(childName) + } + try makeManagedTreeReadable( + URL(fileURLWithPath: path, isDirectory: true) + .appendingPathComponent(childName, isDirectory: false).path + ) + } + default: + throw DoryMachineFileTransferStagingError.unsupportedFile( + URL(fileURLWithPath: path).lastPathComponent + ) + } + } + + /// Deletes only an entry beneath an already-open directory. Symlinks and special files are + /// unlinked as entries and never followed; directories are traversed through O_NOFOLLOW. + private static func removeCreatedTree( + parentDescriptor: Int32, + name: String + ) throws { + var info = stat() + guard fstatat(parentDescriptor, name, &info, AT_SYMLINK_NOFOLLOW) == 0 else { + if errno == ENOENT { return } + throw DoryMachineFileTransferStagingError.io("cleanup stat", errno) + } + if (info.st_mode & S_IFMT) != S_IFDIR { + guard unlinkat(parentDescriptor, name, 0) == 0 else { + throw DoryMachineFileTransferStagingError.io("cleanup unlink", errno) + } + return + } + + let descriptor = openat( + parentDescriptor, + name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { + throw DoryMachineFileTransferStagingError.io("cleanup directory open", errno) + } + do { + for childName in try directoryEntryNames(descriptor: descriptor) { + try removeCreatedTree(parentDescriptor: descriptor, name: childName) + } + } catch { + close(descriptor) + throw error + } + close(descriptor) + guard unlinkat(parentDescriptor, name, AT_REMOVEDIR) == 0 else { + throw DoryMachineFileTransferStagingError.io("cleanup directory removal", errno) + } + } + private static func processExists(_ processID: pid_t) -> Bool { if kill(processID, 0) == 0 { return true } return errno != ESRCH diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift index 103c3503..683606fe 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineFileTransferStagerTests.swift @@ -175,6 +175,137 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: root)) } + func testMaterializesVerifiedGuestExportWithoutOverwritingDestination() throws { + let fixture = try StagerFixture(tag: "guest-export-success") + defer { fixture.cleanup() } + let exportID = operationID() + let root = try DoryMachineFileTransferStager.reserveDaemonExportRoot( + operationID: exportID + ) + defer { try? DoryMachineFileTransferStager.removeManagedStagingRoot(root) } + try FileManager.default.createDirectory( + atPath: root + "/folder", + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try Data("guest-export".utf8).write( + to: URL(fileURLWithPath: root + "/folder/file.txt") + ) + XCTAssertEqual(chmod(root + "/folder/file.txt", 0o000), 0) + XCTAssertEqual(chmod(root + "/folder", 0o000), 0) + + let materialized = try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: root, + exportID: exportID, + expectedFileCount: 1, + expectedDirectoryCount: 1, + expectedByteCount: 12, + destinationDirectory: fixture.destination, + destinationName: "Documents" + ) + + XCTAssertEqual(materialized.fileCount, 1) + XCTAssertEqual(materialized.directoryCount, 1) + XCTAssertEqual(materialized.byteCount, 12) + XCTAssertEqual(materialized.rootURL, fixture.destination.appendingPathComponent( + "Documents", + isDirectory: true + )) + XCTAssertEqual( + try Data(contentsOf: materialized.rootURL.appendingPathComponent("folder/file.txt")), + Data("guest-export".utf8) + ) + XCTAssertEqual(try permissions(materialized.rootURL.path) & 0o777, 0o700) + XCTAssertEqual(try permissions(materialized.rootURL.path + "/folder") & 0o777, 0o700) + XCTAssertEqual( + try permissions(materialized.rootURL.path + "/folder/file.txt") & 0o777, + 0o600 + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: root)) + XCTAssertThrowsError(try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: root, + exportID: exportID, + expectedFileCount: 1, + expectedDirectoryCount: 1, + expectedByteCount: 12, + destinationDirectory: fixture.destination, + destinationName: "Documents" + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .destinationExists("Documents") + ) + } + XCTAssertTrue(try hiddenExportTemporaryNames(in: fixture.destination).isEmpty) + } + + func testMaterializationRejectsMismatchedEvidenceAndSymlinksWithoutPublishing() throws { + let fixture = try StagerFixture(tag: "guest-export-rejection") + defer { fixture.cleanup() } + let exportID = operationID() + let root = try DoryMachineFileTransferStager.reserveDaemonExportRoot( + operationID: exportID + ) + defer { try? DoryMachineFileTransferStager.removeManagedStagingRoot(root) } + try FileManager.default.createDirectory( + atPath: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let source = root + "/file" + try Data("verified".utf8).write(to: URL(fileURLWithPath: source)) + + XCTAssertThrowsError(try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: root, + exportID: exportID, + expectedFileCount: 1, + expectedDirectoryCount: 0, + expectedByteCount: 9, + destinationDirectory: fixture.destination, + destinationName: "Mismatch" + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .exportEvidenceMismatch + ) + } + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.destination.appendingPathComponent("Mismatch").path + )) + XCTAssertTrue(try hiddenExportTemporaryNames(in: fixture.destination).isEmpty) + + XCTAssertEqual(unlink(source), 0) + XCTAssertEqual(symlink("/tmp", source), 0) + XCTAssertThrowsError(try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: root, + exportID: exportID, + expectedFileCount: 1, + expectedDirectoryCount: 0, + expectedByteCount: 8, + destinationDirectory: fixture.destination, + destinationName: "Symlink" + )) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.destination.appendingPathComponent("Symlink").path + )) + XCTAssertTrue(try hiddenExportTemporaryNames(in: fixture.destination).isEmpty) + + XCTAssertThrowsError(try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: fixture.root.appendingPathComponent("arbitrary").path, + exportID: exportID, + expectedFileCount: 0, + expectedDirectoryCount: 0, + expectedByteCount: 0, + destinationDirectory: fixture.destination, + destinationName: "Arbitrary" + )) { error in + XCTAssertEqual( + error as? DoryMachineFileTransferStagingError, + .exportEvidenceMismatch + ) + } + } + private func permissions(_ path: String) throws -> mode_t { var info = stat() guard lstat(path, &info) == 0 else { @@ -186,18 +317,34 @@ final class DoryMachineFileTransferStagerTests: XCTestCase { private func fixtureLikePath(_ operationID: String) -> String { "/tmp/export-\(getpid())-\(operationID)" } + + private func operationID() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + + private func hiddenExportTemporaryNames(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: directory.path).filter { + $0.hasPrefix(".dory-export-") + } + } } private struct StagerFixture { var root: URL var source: URL var staging: URL + var destination: URL init(tag: String) throws { root = URL(fileURLWithPath: "/tmp/dory-transfer-stager-\(tag)-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 11:35:11 +0000 Subject: [PATCH 211/338] feat(transfer): recover completed guest exports --- Dory/Runtime/Doryd/DorydClient.swift | 2 +- DoryTests/DorydClientTests.swift | 20 ++++++++++--- .../Sources/DorydKit/MachineManager.swift | 18 ++++++++---- .../DorydKitTests/DorydServiceTests.swift | 28 ++++++++++++++++--- .../MachineManagerFileTransferTests.swift | 7 +++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index a609599b..df180750 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -3294,7 +3294,7 @@ nonisolated final class DorydClient: @unchecked Sendable { let row = dictionary["operation"] as? NSDictionary, let operation = machineGuestFileExportOperation(from: row), operation.machineID == machineID, - !operation.phase.isTerminal else { + !operation.phase.isTerminal || operation.phase == .completed else { return nil } return DorydMachineGuestFileExportCurrent(operation: operation) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 97978a28..647cf723 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -252,6 +252,22 @@ struct DorydClientTests { ) #expect(completed.fractionCompleted == 1) + let completedRow = service.machineGuestExportOperationResponse( + operationID: started.operationID, + phase: "completed" + ) + service.setMachineGuestExportCurrentResponse([ + "schema": UInt16(1), + "active": true, + "operation": completedRow, + ]) + let recoveredCompleted = try #require( + try await client.machineGuestExportCurrent("dev") + ) + #expect(recoveredCompleted.phase == .completed) + #expect(recoveredCompleted.result?.exportID == started.operationID) + service.setMachineGuestExportCurrentResponse(nil) + let cancelled = try await client.machineGuestExportCancel( "dev", operationID: started.operationID @@ -281,10 +297,6 @@ struct DorydClientTests { #expect(raced.result?.exportID == raceID) service.setMachineGuestExportStartResponse(nil) - let completedRow = service.machineGuestExportOperationResponse( - operationID: started.operationID, - phase: "completed" - ) let resultRow = try #require(completedRow["result"] as? NSDictionary) let otherID = String(repeating: "f", count: 32) let malformed: [NSDictionary] = [ diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 3dbfed17..24548796 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5481,14 +5481,20 @@ public final class MachineManager: @unchecked Sendable { ) -> DoryMachineGuestFileExportOperationStatus? { fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) - guard let operationID = activeGuestFileExportByMachine[id], - let operation = guestFileExportOperations[operationID] else { - activeGuestFileExportByMachine.removeValue(forKey: id) + if let operationID = activeGuestFileExportByMachine[id], + let operation = guestFileExportOperations[operationID] { fileTransferLock.unlock() - return nil - } + return operation.status() + } + activeGuestFileExportByMachine.removeValue(forKey: id) + // A completed export remains an app-recoverable handoff until explicit discard. This + // closes the reconnect window between the daemon finishing its verified pull and the app + // materializing those bytes into a user-selected destination. + let recoverable = guestFileExportOperations.values + .filter { $0.machineID == id && $0.completedResult() != nil } + .max { ($0.terminalDate ?? .distantPast) < ($1.terminalDate ?? .distantPast) } fileTransferLock.unlock() - return operation.status() + return recoverable?.status() } public func cancelGuestFileExport( diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 5d3efac1..4d1caf3f 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -2186,16 +2186,21 @@ final class DorydServiceTests: XCTestCase { )) let currentGuestExport = expectation( - description: "machineGuestExportCurrent inactive reply" + description: "machineGuestExportCurrent completed reply" ) proxy.machineGuestExportCurrent("dev") { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual( Set(body.allKeys.compactMap { $0 as? String }), - ["schema", "active"] + ["schema", "active", "operation"] + ) + XCTAssertEqual(body["active"] as? Bool, true) + let operation = body["operation"] as? NSDictionary + XCTAssertEqual(operation?["phase"] as? String, "completed") + XCTAssertEqual( + (operation?["result"] as? NSDictionary)?["privateStagingRoot"] as? String, + privateExportRoot ) - XCTAssertEqual(body["active"] as? Bool, false) - XCTAssertFalse(body.description.contains(privateExportRoot)) currentGuestExport.fulfill() } wait(for: [currentGuestExport], timeout: 5) @@ -2208,6 +2213,21 @@ final class DorydServiceTests: XCTestCase { wait(for: [discardGuestExport], timeout: 5) XCTAssertFalse(FileManager.default.fileExists(atPath: privateExportRoot)) + let currentDiscardedGuestExport = expectation( + description: "machineGuestExportCurrent inactive after discard" + ) + proxy.machineGuestExportCurrent("dev") { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual( + Set(body.allKeys.compactMap { $0 as? String }), + ["schema", "active"] + ) + XCTAssertEqual(body["active"] as? Bool, false) + XCTAssertFalse(body.description.contains(privateExportRoot)) + currentDiscardedGuestExport.fulfill() + } + wait(for: [currentDiscardedGuestExport], timeout: 5) + let discardedStatus = expectation(description: "discarded guest export is unknown") proxy.machineGuestExportStatus( "dev", diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift index 1058a4d1..a6c441bd 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -298,12 +298,19 @@ final class MachineManagerFileTransferTests: XCTestCase { XCTAssertEqual(fixture.agent.pulls.map(\.remoteRoot), [ "/home/alice/Documents/project", ]) + let recovered = try XCTUnwrap( + fixture.manager.currentGuestFileExportStatus(id: "desktop") + ) + XCTAssertEqual(recovered.operationID, started.operationID) + XCTAssertEqual(recovered.phase, .completed) + XCTAssertEqual(recovered.result, completed.result) try fixture.manager.discardGuestFileExport( id: "desktop", operationID: started.operationID ) XCTAssertFalse(FileManager.default.fileExists(atPath: root)) + XCTAssertNil(fixture.manager.currentGuestFileExportStatus(id: "desktop")) XCTAssertThrowsError(try fixture.manager.guestFileExportStatus( id: "desktop", operationID: started.operationID From fa5d62504c5af654f9c2d5ee447f2105b2579c45 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 11:48:34 +0000 Subject: [PATCH 212/338] feat(app): receive files from machines --- Dory/Features/Machines/MachinesView.swift | 176 ++++++++++++ Dory/Models/AppStore.swift | 313 ++++++++++++++++++++++ DoryTests/DorydClientTests.swift | 141 +++++++++- 3 files changed, 629 insertions(+), 1 deletion(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 88cf42b9..a95a13db 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -134,6 +134,9 @@ private struct MachineCard: View { private var fileTransfer: DorydMachineFileTransferOperation? { store.machineFileTransfer(for: machine.name) } + private var guestFileExport: DorydMachineGuestFileExportOperation? { + store.machineGuestFileExport(for: machine.name) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -179,6 +182,9 @@ private struct MachineCard: View { if let fileTransfer { fileTransferProgress(fileTransfer) .padding(.bottom, 12) + } else if let guestFileExport { + guestFileExportProgress(guestFileExport) + .padding(.bottom, 12) } if let command = store.machineTerminalCommand(machine) { @@ -386,6 +392,104 @@ private struct MachineCard: View { return fileTransferTitle(operation) } + private func guestFileExportProgress( + _ operation: DorydMachineGuestFileExportOperation + ) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: operation.phase == .cancelling + ? "xmark.circle" : "square.and.arrow.down.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.accentText) + Text(guestFileExportTitle(operation)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + Spacer(minLength: 8) + Text(operation.fractionCompleted, format: .percent.precision(.fractionLength(0))) + .font(.mono(10.5, weight: .semibold)) + .foregroundStyle(p.text2) + } + + ProgressView(value: operation.fractionCompleted) + .progressViewStyle(.linear) + .tint(p.accent) + + HStack(spacing: 8) { + Text(guestFileExportDetail(operation)) + .font(.system(size: 10.5)) + .foregroundStyle(p.text3) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if operation.phase == .completed { + Button("Save…") { savePendingGuestFileExport() } + .accessibilityIdentifier("machine-export-save-\(machine.name)") + Button("Discard") { + Task { await store.discardGuestFileExport(from: machine) } + } + .accessibilityIdentifier("machine-export-discard-\(machine.name)") + } else if !operation.phase.isTerminal { + Button(operation.phase == .cancelling ? "Cancelling…" : "Cancel") { + Task { await store.cancelGuestFileExport(from: machine) } + } + .disabled(operation.phase == .cancelling) + .accessibilityIdentifier("machine-export-cancel-\(machine.name)") + } + } + .buttonStyle(.borderless) + .font(.system(size: 10.5, weight: .semibold)) + } + .padding(10) + .background(p.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.accent.opacity(0.24))) + .accessibilityElement(children: .contain) + .accessibilityLabel("File export from \(machine.name)") + .accessibilityValue(guestFileExportDetail(operation)) + } + + private func guestFileExportTitle( + _ operation: DorydMachineGuestFileExportOperation + ) -> String { + switch operation.phase { + case .preparing: "Preparing export" + case .transferring: "Receiving files" + case .finalizing: "Verifying files" + case .cancelling: "Cancelling export" + case .completed: "Files ready to save" + case .cancelled: "Export cancelled" + case .failed: "Export failed" + } + } + + private func guestFileExportDetail( + _ operation: DorydMachineGuestFileExportOperation + ) -> String { + if operation.phase == .completed, let result = operation.result { + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesReceived), + countStyle: .file + ) + let fileLabel = result.filesReceived == 1 ? "file" : "files" + return "\(result.filesReceived) \(fileLabel) · \(bytes)" + } + if let currentPath = operation.currentPath { + return currentPath + } + if operation.bytesTotal > 0 { + let completed = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesCompleted), + countStyle: .file + ) + let total = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesTotal), + countStyle: .file + ) + return "\(completed) of \(total)" + } + return guestFileExportTitle(operation) + } + private var overflowMenu: some View { Menu { if isActive { @@ -406,6 +510,11 @@ private struct MachineCard: View { ? "Copy selected files and folders into this machine's Downloads folder" : "Copy selected files into this machine's Downloads folder" ) + Button { selectAndReceiveFiles() } label: { + Label("Receive Files or Folder…", systemImage: "square.and.arrow.down") + } + .disabled(!store.canExportGuestFiles(from: machine)) + .help("Copy a file or folder from this machine into a folder on your Mac") } Divider() } @@ -473,6 +582,73 @@ private struct MachineCard: View { Task { await store.transferFiles(selected, to: machine) } } + private func selectAndReceiveFiles() { + guard store.canExportGuestFiles(from: machine), + let guestSource = promptForGuestSource() else { + return + } + let name = guestExportName(for: guestSource) + guard let destination = selectGuestExportDestination(suggestedName: name) else { + return + } + Task { + await store.exportGuestFiles( + guestSource, + from: machine, + to: destination + ) + } + } + + private func savePendingGuestFileExport() { + let name = store.suggestedGuestFileExportName(for: machine.name) + guard let destination = selectGuestExportDestination(suggestedName: name) else { + return + } + Task { await store.saveGuestFileExport(from: machine, to: destination) } + } + + private func promptForGuestSource() -> String? { + let home = "/home/\(machine.username)" + let alert = NSAlert() + alert.messageText = "Receive files from \(machine.name)" + alert.informativeText = "Enter a file or folder inside \(home). Dory verifies the guest transfer before saving it on your Mac." + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + let field = NSTextField(string: home + "/Documents") + field.placeholderString = home + "/Documents/project" + field.frame = NSRect(x: 0, y: 0, width: 390, height: 24) + alert.accessoryView = field + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let value = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func selectGuestExportDestination(suggestedName: String) -> URL? { + let panel = NSSavePanel() + panel.title = "Save files from \(machine.name)" + panel.message = "Dory creates a new folder at this location and never overwrites an existing item." + panel.prompt = "Save" + panel.canCreateDirectories = true + panel.nameFieldStringValue = suggestedName + panel.directoryURL = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first + guard panel.runModal() == .OK else { return nil } + return panel.url + } + + private func guestExportName(for guestSource: String) -> String { + let name = URL(fileURLWithPath: guestSource).lastPathComponent + guard !name.isEmpty, + name != ".dory-sync-tmp", + name.utf8.count <= 255 else { + return "\(machine.name)-export" + } + return name + } + private var statusPill: some View { HStack(spacing: 5) { Circle().fill(machine.status.dotColor(p)).frame(width: 6, height: 6) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index ac75ea6b..3ea3307a 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5113,12 +5113,21 @@ final class AppStore { private(set) var busyMachines: Set = [] private(set) var machineFileTransfers: [String: DorydMachineFileTransferOperation] = [:] + private(set) var machineGuestFileExports: [String: DorydMachineGuestFileExportOperation] = [:] private var recoveringMachineFileTransfers: Set = [] + private var recoveringMachineGuestFileExports: Set = [] + private var machineGuestFileExportSuggestedNames: [String: String] = [:] var machineBusy: Bool { !busyMachines.isEmpty } func isMachineBusy(_ name: String) -> Bool { busyMachines.contains(name) } func machineFileTransfer(for name: String) -> DorydMachineFileTransferOperation? { machineFileTransfers[name] } + func machineGuestFileExport(for name: String) -> DorydMachineGuestFileExportOperation? { + machineGuestFileExports[name] + } + func suggestedGuestFileExportName(for name: String) -> String { + machineGuestFileExportSuggestedNames[name] ?? "\(name)-export" + } static let importBusyKey = "__dory_import__" var machineCreationTitle = "" var machineCreationLog = "" @@ -5151,6 +5160,7 @@ final class AppStore { self.machines[index].memoryDisplay = Self.machineMemoryDisplay(stats) } recoverActiveFileTransfer(for: machine) + recoverGuestFileExport(for: machine) } return machines } catch { @@ -5333,6 +5343,16 @@ final class AppStore { } } + func canExportGuestFiles(from machine: Machine) -> Bool { + runtimeOwnedByDoryd + && machine.status == .running + && machine.agentProtocolVersion == 1 + && machine.username != "root" + && machine.agentCapabilities.contains { + $0.id == "sync-pull" && $0.version >= 1 + } + } + func canRepairMachineTools(_ machine: Machine) -> Bool { runtimeOwnedByDoryd && machine.displayMode == .desktop @@ -5386,6 +5406,49 @@ final class AppStore { } } + func cancelGuestFileExport(from machine: Machine) async { + guard let operation = machineGuestFileExports[machine.name], + !operation.phase.isTerminal else { + return + } + do { + let updated = try await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operation.operationID + ) + guard machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + return + } + machineGuestFileExports[machine.name] = updated + } catch { + actionError = "Could not cancel the file export from \(machine.name): \(Self.userFacingError(error))" + } + } + + func discardGuestFileExport(from machine: Machine) async { + guard let operation = machineGuestFileExports[machine.name], + operation.phase == .completed else { + return + } + do { + let discarded = try await dorydClient.machineGuestExportDiscard( + machine.name, + operationID: operation.operationID + ) + guard discarded.ok, + machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + showSettingsSuccess("Discarded the pending file export from \(machine.name).") + } catch { + actionError = "Could not discard the file export from \(machine.name): \(Self.userFacingError(error))" + } + } + private func recoverActiveFileTransfer(for machine: Machine) { guard machineFileTransfers[machine.name] == nil, !busyMachines.contains(machine.name), @@ -5465,6 +5528,256 @@ final class AppStore { } } + private func recoverGuestFileExport(for machine: Machine) { + guard machineGuestFileExports[machine.name] == nil, + recoveringMachineGuestFileExports.insert(machine.name).inserted else { + return + } + Task { [weak self] in + await self?.recoverGuestFileExport(machineID: machine.name) + } + } + + private func recoverGuestFileExport(machineID: String) async { + defer { recoveringMachineGuestFileExports.remove(machineID) } + let discovered: DorydMachineGuestFileExportOperation? + do { + discovered = try await dorydClient.machineGuestExportCurrent(machineID) + } catch { + return + } + guard var operation = discovered, + machineGuestFileExports[machineID] == nil, + machineFileTransfers[machineID] == nil else { + return + } + + let operationID = operation.operationID + machineGuestFileExportSuggestedNames[machineID] = "\(machineID)-export" + machineGuestFileExports[machineID] = operation + if operation.phase == .completed { + showSettingsSuccess("Files from \(machineID) are ready to save.") + return + } + guard !operation.phase.isTerminal, !busyMachines.contains(machineID) else { + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + return + } + + busyMachines.insert(machineID) + defer { busyMachines.remove(machineID) } + do { + operation = try await awaitGuestFileExport( + machineID: machineID, + operationID: operationID, + initial: operation + ) + switch operation.phase { + case .completed: + showSettingsSuccess("Files from \(machineID) are ready to save.") + case .cancelled: + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + showSettingsSuccess("Cancelled the file export from \(machineID).") + case .failed: + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + let message = operation.failure?.message + ?? "The daemon reported a failed file export." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + return + } catch { + actionError = "Could not restore the file export from \(machineID): \(Self.userFacingError(error))" + } + } + + @discardableResult + func exportGuestFiles( + _ guestSource: String, + from machine: Machine, + to destinationURL: URL + ) async -> URL? { + guard canExportGuestFiles(from: machine) else { + actionError = "Start \(machine.name) and update Dory Tools before receiving files." + return nil + } + guard !busyMachines.contains(machine.name), + !recoveringMachineGuestFileExports.contains(machine.name), + machineGuestFileExports[machine.name] == nil else { + return nil + } + let suggestedName = destinationURL.lastPathComponent + guard !suggestedName.isEmpty else { + actionError = "Choose a name for the received files." + return nil + } + + busyMachines.insert(machine.name) + defer { busyMachines.remove(machine.name) } + actionError = nil + var operationID: String? + do { + var operation = try await dorydClient.machineGuestExportStart( + machine.name, + guestSource: guestSource + ) + operationID = operation.operationID + machineGuestFileExportSuggestedNames[machine.name] = suggestedName + machineGuestFileExports[machine.name] = operation + operation = try await awaitGuestFileExport( + machineID: machine.name, + operationID: operation.operationID, + initial: operation + ) + + switch operation.phase { + case .completed: + return await saveCompletedGuestFileExport( + from: machine, + operation: operation, + to: destinationURL + ) + case .cancelled: + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + showSettingsSuccess("Cancelled the file export from \(machine.name).") + return nil + case .failed: + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + let message = operation.failure?.message + ?? "The daemon reported a failed file export." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + if let operationID { + _ = try? await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operationID + ) + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + return nil + } catch { + if let operationID, + machineGuestFileExports[machine.name]?.phase.isTerminal == false { + _ = try? await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operationID + ) + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + } + actionError = "Could not receive files from \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + + @discardableResult + func saveGuestFileExport( + from machine: Machine, + to destinationURL: URL + ) async -> URL? { + guard let operation = machineGuestFileExports[machine.name], + operation.phase == .completed else { + return nil + } + actionError = nil + return await saveCompletedGuestFileExport( + from: machine, + operation: operation, + to: destinationURL + ) + } + + private func awaitGuestFileExport( + machineID: String, + operationID: String, + initial: DorydMachineGuestFileExportOperation + ) async throws -> DorydMachineGuestFileExportOperation { + var operation = initial + while !operation.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + operation = try await dorydClient.machineGuestExportStatus( + machineID, + operationID: operationID + ) + guard operation.operationID == operationID, + machineGuestFileExports[machineID]?.operationID == operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports[machineID] = operation + } + return operation + } + + private func saveCompletedGuestFileExport( + from machine: Machine, + operation: DorydMachineGuestFileExportOperation, + to destinationURL: URL + ) async -> URL? { + guard machineGuestFileExports[machine.name]?.operationID == operation.operationID, + operation.phase == .completed, + let result = operation.result, + result.exportID == operation.operationID else { + actionError = "Could not save files from \(machine.name): The completed export evidence is invalid." + return nil + } + do { + let materialized = try await Task.detached(priority: .userInitiated) { + try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: result.privateStagingRoot, + exportID: result.exportID, + expectedFileCount: result.filesReceived, + expectedDirectoryCount: result.directoriesReceived, + expectedByteCount: result.bytesReceived, + destinationDirectory: destinationURL.deletingLastPathComponent(), + destinationName: destinationURL.lastPathComponent + ) + }.value + do { + let discarded = try await dorydClient.machineGuestExportDiscard( + machine.name, + operationID: operation.operationID + ) + guard discarded.ok, + machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + } catch { + actionError = "Saved files from \(machine.name) to \(materialized.rootURL.path), but Dory could not remove its private staging copy." + return materialized.rootURL + } + let fileLabel = result.filesReceived == 1 ? "file" : "files" + let folderLabel = result.directoriesReceived == 1 ? "folder" : "folders" + let itemSummary = result.directoriesReceived == 0 + ? "\(result.filesReceived) \(fileLabel)" + : "\(result.filesReceived) \(fileLabel) and \(result.directoriesReceived) \(folderLabel)" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesReceived), + countStyle: .file + ) + showSettingsSuccess( + "Saved \(itemSummary) (\(bytes)) from \(machine.name) to \(materialized.rootURL.path)." + ) + return materialized.rootURL + } catch { + actionError = "Could not save files from \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + @discardableResult func transferFiles( _ fileURLs: [URL], diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 647cf723..d34bcd92 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1469,8 +1469,18 @@ struct DorydClientTests { @MainActor @Test func appStoreRecoversAnActiveDaemonFileTransferAfterReconnect() async throws { + let base = "/tmp/dory-transfer-recovery-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 12:55:58 +0000 Subject: [PATCH 213/338] feat(vm): surface guest integration health --- Dory/Features/Machines/MachinesView.swift | 284 ++++++++++++ Dory/Models/AppStore.swift | 1 + Dory/Models/Models.swift | 102 +++-- Dory/Runtime/Doryd/DorydClient.swift | 156 ++++++- DoryTests/DorydClientTests.swift | 141 +++++- .../DoryGuestIntegrationHealth.swift | 423 ++++++++++++++++++ .../Sources/DorydKit/DorydService.swift | 31 ++ .../Sources/DorydKit/HealthReporter.swift | 98 ++-- .../Sources/DorydKit/MachineManager.swift | 44 ++ .../DoryGuestIntegrationHealthTests.swift | 276 ++++++++++++ .../DorydKitTests/DorydServiceTests.swift | 17 + .../DorydKitTests/HealthReporterTests.swift | 38 +- 12 files changed, 1503 insertions(+), 108 deletions(-) create mode 100644 dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index a95a13db..665d4b43 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -125,6 +125,7 @@ private struct MachineCard: View { let machine: Machine @State private var confirmingDelete = false @State private var confirmingToolsRepair = false + @State private var showingIntegrationHealth = false @State private var isTransferDropTargeted = false private var isRunning: Bool { machine.status == .running } @@ -279,6 +280,9 @@ private struct MachineCard: View { && store.canTransferFiles(to: machine) && !store.isMachineBusy(machine.name) } + .sheet(isPresented: $showingIntegrationHealth) { + MachineIntegrationHealthSheet(machine: machine) + } .confirmationDialog("Delete machine \(machine.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { Button("Delete", role: .destructive) { store.deleteMachine(machine) } Button("Cancel", role: .cancel) {} @@ -534,6 +538,9 @@ private struct MachineCard: View { Button { store.openMachineEdit(machine) } label: { Label("Edit…", systemImage: "slider.horizontal.3") } + Button { showingIntegrationHealth = true } label: { + Label("Integration Health…", systemImage: "stethoscope") + } if store.canRepairMachineTools(machine) { Button { confirmingToolsRepair = true } label: { Label("Repair Dory Tools…", systemImage: "wrench.and.screwdriver") @@ -770,6 +777,283 @@ private func logoName(for distro: String) -> String? { return nil } +private struct MachineIntegrationHealthSheet: View { + @Environment(AppStore.self) private var store + @Environment(\.dismiss) private var dismiss + @Environment(\.palette) private var p + let machine: Machine + @State private var confirmingRepair = false + + private var health: DoryGuestIntegrationHealth { machine.integrationHealthProjection } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: healthIcon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(healthColor) + .frame(width: 42, height: 42) + .background(healthBackground, in: RoundedRectangle(cornerRadius: 11)) + VStack(alignment: .leading, spacing: 4) { + Text("Integration Health") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(p.text) + Text(machine.name) + .font(.mono(12, weight: .semibold)) + .foregroundStyle(p.text3) + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(20) + + Divider().overlay(p.border) + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + healthSummary + + VStack(alignment: .leading, spacing: 9) { + Text("INTEGRATIONS") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(p.text3) + .tracking(0.7) + ForEach(health.features, id: \.id) { feature in + featureRow(feature) + } + } + } + .padding(20) + } + + if store.canRepairMachineTools(machine) { + Divider().overlay(p.border) + HStack { + Text("Repair reinstalls the active signed Dory Tools payload with rollback.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + Spacer() + Button("Repair Dory Tools…") { confirmingRepair = true } + .disabled(store.isMachineBusy(machine.name)) + } + .padding(16) + } + } + .frame(width: 610, height: 650) + .background(p.bgContent) + .confirmationDialog( + "Repair Dory Tools in \(machine.name)?", + isPresented: $confirmingRepair, + titleVisibility: .visible + ) { + Button("Repair Dory Tools") { + store.repairMachineTools(machine) + dismiss() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Dory will create a last-good snapshot, reinstall the active signed desktop and tools payload, restart the machine, and roll back automatically if verification fails.") + } + } + + private var healthSummary: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(healthTitle) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(healthColor) + Text(health.runtimeAuthority.rawValue) + .font(.mono(10, weight: .semibold)) + .foregroundStyle(p.text2) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(p.pill, in: Capsule()) + } + Text(healthDescription) + .font(.system(size: 12)) + .foregroundStyle(p.text2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 16) { + summaryValue("TOOLS BUILD", health.agentBuild ?? "Not connected") + summaryValue( + "PROTOCOL", + health.agentProtocolVersion.map(String.init) ?? "—" + ) + summaryValue( + "ACTIVE", + "\(health.features.filter { $0.state == .active }.count)/\(health.features.count)" + ) + } + } + .padding(14) + .background(healthBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(p.border)) + } + + private func summaryValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(p.text3) + .tracking(0.5) + Text(value) + .font(.mono(11, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func featureRow(_ feature: DoryGuestIntegrationFeatureHealth) -> some View { + HStack(spacing: 10) { + Image(systemName: featureIcon(feature.state)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(featureColor(feature.state)) + .frame(width: 24, height: 24) + .background(featureBackground(feature.state), in: RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(featureTitle(feature.id)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(p.text) + if feature.required { + Text("REQUIRED") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(p.text3) + } + } + Text("\(feature.provider.rawValue) · \(featureVersion(feature))") + .font(.mono(9.5)) + .foregroundStyle(p.text3) + } + Spacer() + Text(featureStateTitle(feature.state)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(featureColor(feature.state)) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border)) + } + + private var healthTitle: String { + switch health.state { + case .inactive: "Inactive" + case .missingTools: "Dory Tools missing" + case .incompatible: "Dory Tools incompatible" + case .degraded: "Integration degraded" + case .compatibility: "Compatibility mode" + case .healthy: "All required integrations healthy" + } + } + + private var healthDescription: String { + switch health.state { + case .inactive: "The workspace is stopped. Live guest-integration evidence is intentionally inactive." + case .missingTools: "The running guest has not completed a valid Dory Tools handshake." + case .incompatible: "The running guest reported a tools protocol this version of Dory cannot use." + case .degraded: "One or more required capabilities are unavailable, outdated, or need a current runtime plan." + case .compatibility: "Guest capabilities are negotiated, but host runtime integrations are not backed by a qualified resolved plan." + case .healthy: "The daemon verified the runtime authority and every required integration is active." + } + } + + private var healthIcon: String { + switch health.state { + case .healthy: "checkmark.seal.fill" + case .inactive: "pause.circle.fill" + case .compatibility: "arrow.triangle.2.circlepath" + default: "exclamationmark.triangle.fill" + } + } + + private var healthColor: Color { + switch health.state { + case .healthy: p.green + case .inactive, .compatibility: p.text2 + default: p.amber + } + } + + private var healthBackground: Color { + switch health.state { + case .healthy: p.greenWeak + case .inactive, .compatibility: p.pill + default: p.amberWeak + } + } + + private func featureTitle(_ id: DoryGuestIntegrationCapabilityID) -> String { + switch id { + case .readiness: "Guest readiness" + case .gracefulShutdown: "Graceful shutdown" + case .reboot: "Guest reboot" + case .clockSynchronization: "Clock synchronization" + case .health: "Health reporting" + case .displayTopology: "Display topology" + case .displayResize: "Dynamic display resize" + case .clipboardText: "Text clipboard" + case .clipboardImage: "Image clipboard" + case .sharedFolderDiscovery: "Shared-folder discovery" + case .sharedFolderMountStatus: "Shared-folder mount status" + case .fileTransferPush: "Host-to-guest transfer" + case .fileTransferPull: "Guest-to-host transfer" + case .networkIdentity: "Network identity" + case .processLaunch: "Process launch" + case .processInput: "Process input" + case .listenPorts: "Port discovery" + case .telemetry: "Telemetry" + case .snapshotQuiesce: "Snapshot freeze/thaw" + case .packageUpdate: "Tools update" + } + } + + private func featureVersion(_ feature: DoryGuestIntegrationFeatureHealth) -> String { + guard let minimum = feature.minimumVersion else { return "runtime-qualified" } + if let negotiated = feature.negotiatedVersion { + return "v\(negotiated), requires v\(minimum)+" + } + return "requires v\(minimum)+" + } + + private func featureStateTitle(_ state: DoryGuestIntegrationFeatureState) -> String { + switch state { + case .inactive: "Inactive" + case .active: "Active" + case .unavailable: "Unavailable" + case .updateRequired: "Update required" + case .unqualified: "Unqualified" + } + } + + private func featureIcon(_ state: DoryGuestIntegrationFeatureState) -> String { + switch state { + case .active: "checkmark" + case .inactive: "pause.fill" + case .updateRequired: "arrow.clockwise" + case .unavailable, .unqualified: "exclamationmark" + } + } + + private func featureColor(_ state: DoryGuestIntegrationFeatureState) -> Color { + switch state { + case .active: p.green + case .inactive, .unqualified: p.text3 + case .unavailable, .updateRequired: p.amber + } + } + + private func featureBackground(_ state: DoryGuestIntegrationFeatureState) -> Color { + switch state { + case .active: p.greenWeak + case .inactive, .unqualified: p.pill + case .unavailable, .updateRequired: p.amberWeak + } + } +} + private struct MachineEditSheet: View { @Environment(AppStore.self) private var store @Environment(\.palette) private var p diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 3ea3307a..3b528757 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5228,6 +5228,7 @@ final class AppStore { agentBuild: status.agentBuild, agentProtocolVersion: status.agentProtocolVersion, agentCapabilities: status.agentCapabilities, + integrationHealth: status.integrationHealth, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index d0364744..3c885460 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -1,3 +1,4 @@ +import DoryOperations import SwiftUI enum AppSection: String, CaseIterable, Identifiable, Sendable { @@ -305,6 +306,7 @@ struct Machine: Identifiable, Hashable, Sendable { var agentBuild: String? = nil var agentProtocolVersion: UInt32? = nil var agentCapabilities: [DorydAgentCapability] = [] + var integrationHealth: DoryGuestIntegrationHealth? = nil var mounts: [MountPair] = [] var id: String { name } @@ -375,73 +377,91 @@ struct Machine: Identifiable, Hashable, Sendable { return evidence } + var integrationHealthProjection: DoryGuestIntegrationHealth { + if let integrationHealth, integrationHealth.isValid { + return integrationHealth + } + let authority: DoryGuestIntegrationRuntimeAuthority + switch runtimeIdentity.mode { + case "resolved-plan": authority = .resolvedPlan + case "requires-replanning": authority = .requiresReplanning + default: authority = .legacyCompatibility + } + return DoryGuestIntegrationHealth.evaluate( + machineIsRunning: status == .running, + runtimeAuthority: authority, + desktopIntegrationsExpected: displayMode == .desktop, + clipboardTextExpected: displayMode == .desktop, + clipboardImageExpected: displayMode == .desktop, + sharedFoldersExpected: !mounts.isEmpty, + // Older daemons did not expose the plan's exact device contract. The compatibility + // projection therefore stays fail-closed instead of inferring host integrations. + qualifiedRuntimeFeatures: [], + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + agentCapabilities: agentCapabilities.map { + DoryGuestIntegrationNegotiatedCapability(id: $0.id, version: $0.version) + } + ) + } + private var toolsRuntimeEvidence: MachineRuntimeEvidence { - guard let agentBuild, !agentBuild.isEmpty else { + let health = integrationHealthProjection + switch health.state { + case .inactive: return MachineRuntimeEvidence( id: "tools", - label: "Tools unavailable", + label: "Tools inactive", systemImage: "wrench.and.screwdriver", - tone: .warning, - detail: "The guest has not reported a Dory Tools handshake" + tone: .standard, + detail: "Integration checks resume when the workspace is running" ) - } - guard let agentProtocolVersion else { + case .missingTools: return MachineRuntimeEvidence( id: "tools", - label: "Tools unversioned", + label: "Tools unavailable", systemImage: "wrench.and.screwdriver", tone: .warning, - detail: "\(agentBuild) does not report a protocol contract" + detail: "The guest has not reported a valid Dory Tools handshake" ) - } - guard agentProtocolVersion == 1 else { + case .incompatible: return MachineRuntimeEvidence( id: "tools", label: "Tools incompatible", systemImage: "exclamationmark.triangle.fill", tone: .warning, - detail: "\(agentBuild) uses unsupported protocol \(agentProtocolVersion)" + detail: "\(health.agentBuild ?? "Dory Tools") uses unsupported protocol \(health.agentProtocolVersion ?? 0)" ) - } - guard !agentCapabilities.isEmpty else { + case .degraded: + let unavailable = health.features + .filter { $0.required && $0.state != .active } + .map(\.id.rawValue) return MachineRuntimeEvidence( id: "tools", - label: "Tools need an update", + label: "Tools partially ready", systemImage: "arrow.triangle.2.circlepath", tone: .warning, - detail: "\(agentBuild) does not report versioned capabilities" + detail: unavailable.isEmpty + ? "The workspace needs a current resolved runtime plan" + : "Unavailable: \(unavailable.joined(separator: ", "))" ) - } - let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) }) - let required: [(id: String, version: UInt32)] = [ - ("clock-sync", 1), - ("exec", 1), - ("exec-stdin", 1), - ("ports-watch", 1), - ("snapshot-quiesce", 2), - ("sync-push", 2), - ("telemetry", 1), - ] - let missing = required.compactMap { requirement in - (versions[requirement.id] ?? 0) < requirement.version - ? "\(requirement.id)@\(requirement.version)" : nil - } - guard missing.isEmpty else { + case .compatibility: return MachineRuntimeEvidence( id: "tools", - label: "Tools partially ready", + label: "Tools compatibility", systemImage: "wrench.and.screwdriver", - tone: .warning, - detail: "\(agentBuild) is missing \(missing.joined(separator: ", "))" + tone: .standard, + detail: "\(health.agentBuild ?? "Dory Tools") · guest capabilities negotiated; runtime integrations unqualified" + ) + case .healthy: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools ready", + systemImage: "wrench.and.screwdriver.fill", + tone: .positive, + detail: "\(health.agentBuild ?? "Dory Tools") · \(health.features.filter { $0.state == .active }.count) active integrations" ) } - return MachineRuntimeEvidence( - id: "tools", - label: "Tools ready", - systemImage: "wrench.and.screwdriver.fill", - tone: .positive, - detail: "\(agentBuild) · \(agentCapabilities.count) versioned capabilities" - ) } private static func backendLabel(_ backend: String) -> String { diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index df180750..4ef8e10b 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -822,6 +822,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var agentBuild: String? var agentProtocolVersion: UInt32? = nil var agentCapabilities: [DorydAgentCapability] = [] + var integrationHealth: DoryGuestIntegrationHealth? = nil var agentSocketPath: String? var dockerdSocketPath: String? var shellSocketPath: String? @@ -2211,15 +2212,37 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let shares = machineShares(from: dictionary["shares"]) else { return nil } + let displayMode = (dictionary["displayMode"] as? String) + .flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless + let agentBuild = nonEmptyString(dictionary["agentBuild"]) + let clipboardPolicy = typedSettings.value?.clipboardPolicy + ?? (displayMode == .desktop + ? DoryVMClipboardPolicy.legacyDesktop(.bidirectional) + : .disabled) + guard let integrationHealth = machineIntegrationHealth( + from: dictionary, + machineIsRunning: state == "running", + desktopIntegrationsExpected: displayMode == .desktop, + clipboardTextExpected: clipboardPolicy.text != .off, + clipboardImageExpected: clipboardPolicy.image != .off, + sharedFoldersExpected: !shares.isEmpty, + expectedRuntimeIdentityMode: runtimeIdentity.mode, + expectedAgentBuild: state == "running" ? agentBuild : nil, + expectedAgentProtocolVersion: state == "running" + ? agentHandshake.protocolVersion : nil + ) else { + return nil + } return DorydMachineStatus( id: id, state: state, pid: int32(dictionary["pid"]), lastError: nonEmptyString(dictionary["lastError"]), handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]), - agentBuild: nonEmptyString(dictionary["agentBuild"]), + agentBuild: agentBuild, agentProtocolVersion: agentHandshake.protocolVersion, agentCapabilities: agentHandshake.capabilities, + integrationHealth: integrationHealth.value, agentSocketPath: nonEmptyString(dictionary["agentSocketPath"]), dockerdSocketPath: nonEmptyString(dictionary["dockerdSocketPath"]), shellSocketPath: nonEmptyString(dictionary["shellSocketPath"]), @@ -2231,7 +2254,7 @@ nonisolated final class DorydClient: @unchecked Sendable { memoryMB: uint64(dictionary["memoryMB"]), currentBalloonTargetMB: uint64(dictionary["currentBalloonTargetMB"]), cpuCount: int(dictionary["cpuCount"]), - displayMode: (dictionary["displayMode"] as? String).flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless, + displayMode: displayMode, bootMode: (dictionary["bootMode"] as? String).flatMap(MachineBootMode.init(rawValue:)) ?? .linuxKernel, installerMediaAttached: (dictionary["installerMediaAttached"] as? Bool) ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue @@ -2249,6 +2272,135 @@ nonisolated final class DorydClient: @unchecked Sendable { var capabilities: [DorydAgentCapability] } + private struct ParsedMachineIntegrationHealth { + var value: DoryGuestIntegrationHealth? + } + + /// Older daemons may omit the projection. Once present, this is a capability claim used for + /// repair and product support decisions, so malformed/future shapes reject the machine row. + nonisolated private static func machineIntegrationHealth( + from dictionary: NSDictionary, + machineIsRunning: Bool, + desktopIntegrationsExpected: Bool, + clipboardTextExpected: Bool, + clipboardImageExpected: Bool, + sharedFoldersExpected: Bool, + expectedRuntimeIdentityMode: String, + expectedAgentBuild: String?, + expectedAgentProtocolVersion: UInt32? + ) -> ParsedMachineIntegrationHealth? { + guard let encoded = dictionary["integrationHealth"] else { + return ParsedMachineIntegrationHealth(value: nil) + } + guard let value = encoded as? NSDictionary, + let rawKeys = value.allKeys as? [String], + rawKeys.count == Set(rawKeys).count, + Set(rawKeys).isSuperset(of: [ + "schemaVersion", "state", "runtimeAuthority", "features", + ]), + Set(rawKeys).isSubset(of: [ + "schemaVersion", "state", "runtimeAuthority", "agentBuild", + "agentProtocolVersion", "features", + ]), + let schema = strictUInt64(value["schemaVersion"]), + schema <= UInt16.max, + let rawState = value["state"] as? String, + let state = DoryGuestIntegrationHealthState(rawValue: rawState), + let rawAuthority = value["runtimeAuthority"] as? String, + let authority = DoryGuestIntegrationRuntimeAuthority(rawValue: rawAuthority), + let rawFeatures = value["features"] as? NSArray else { + return nil + } + let agentBuild: String? + if let encodedBuild = value["agentBuild"] { + guard let build = encodedBuild as? String else { return nil } + agentBuild = build + } else { + agentBuild = nil + } + let agentProtocolVersion: UInt32? + if let encodedProtocol = value["agentProtocolVersion"] { + guard let version = strictUInt64(encodedProtocol), + version <= UInt32.max else { return nil } + agentProtocolVersion = UInt32(version) + } else { + agentProtocolVersion = nil + } + + var features: [DoryGuestIntegrationFeatureHealth] = [] + features.reserveCapacity(rawFeatures.count) + for rawFeature in rawFeatures { + guard let feature = rawFeature as? NSDictionary, + let keys = feature.allKeys as? [String], + keys.count == Set(keys).count, + Set(keys).isSuperset(of: ["id", "provider", "required", "state"]), + Set(keys).isSubset(of: [ + "id", "provider", "required", "minimumVersion", + "negotiatedVersion", "state", + ]), + let rawID = feature["id"] as? String, + let id = DoryGuestIntegrationCapabilityID(rawValue: rawID), + let rawProvider = feature["provider"] as? String, + let provider = DoryGuestIntegrationFeatureProvider(rawValue: rawProvider), + let requiredNumber = feature["required"] as? NSNumber, + CFGetTypeID(requiredNumber) == CFBooleanGetTypeID(), + let rawFeatureState = feature["state"] as? String, + let featureState = DoryGuestIntegrationFeatureState( + rawValue: rawFeatureState + ) else { + return nil + } + func version(_ key: String) -> UInt32? { + guard let encoded = feature[key], + let value = strictUInt64(encoded), + value <= UInt32.max else { return nil } + return UInt32(value) + } + let minimumVersion = feature["minimumVersion"] == nil + ? nil : version("minimumVersion") + let negotiatedVersion = feature["negotiatedVersion"] == nil + ? nil : version("negotiatedVersion") + if feature["minimumVersion"] != nil && minimumVersion == nil { return nil } + if feature["negotiatedVersion"] != nil && negotiatedVersion == nil { return nil } + features.append(DoryGuestIntegrationFeatureHealth( + id: id, + provider: provider, + required: requiredNumber.boolValue, + minimumVersion: minimumVersion, + negotiatedVersion: negotiatedVersion, + state: featureState + )) + } + let health = DoryGuestIntegrationHealth( + schemaVersion: UInt16(schema), + state: state, + runtimeAuthority: authority, + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + features: features + ) + let expectedAuthority: DoryGuestIntegrationRuntimeAuthority + switch expectedRuntimeIdentityMode { + case "resolved-plan": expectedAuthority = .resolvedPlan + case "requires-replanning": expectedAuthority = .requiresReplanning + case "legacy-compatibility": expectedAuthority = .legacyCompatibility + default: return nil + } + guard health.isValid( + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: clipboardTextExpected, + clipboardImageExpected: clipboardImageExpected, + sharedFoldersExpected: sharedFoldersExpected + ), + health.runtimeAuthority == expectedAuthority, + health.agentBuild == expectedAgentBuild, + health.agentProtocolVersion == expectedAgentProtocolVersion, + machineIsRunning ? health.state != .inactive : health.state == .inactive else { + return nil + } + return ParsedMachineIntegrationHealth(value: health) + } + nonisolated private static func machineAgentHandshake( from dictionary: NSDictionary ) -> ParsedMachineAgentHandshake? { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index d34bcd92..2f170be9 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -927,7 +927,7 @@ struct DorydClientTests { "telemetry", ]) #expect(machine.runtimeEvidence.map(\.label) == [ - "Supported", "Raw HV", "Qualified 3D", "Tools ready", + "Supported", "Raw HV", "Qualified 3D", "Tools partially ready", ]) #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.detail == "runtime-qualification-1") @@ -949,7 +949,7 @@ struct DorydClientTests { var legacyHandshake = machine legacyHandshake.agentProtocolVersion = nil legacyHandshake.agentCapabilities = [] - #expect(legacyHandshake.runtimeEvidence.last?.label == "Tools unversioned") + #expect(legacyHandshake.runtimeEvidence.last?.label == "Tools unavailable") var partialHandshake = machine partialHandshake.agentCapabilities = [DorydAgentCapability(id: "exec", version: 1)] @@ -962,7 +962,9 @@ struct DorydClientTests { ? DorydAgentCapability(id: $0.id, version: 1) : $0 } #expect(oldQuiesceHandshake.runtimeEvidence.last?.label == "Tools partially ready") - #expect(oldQuiesceHandshake.runtimeEvidence.last?.detail.contains("snapshot-quiesce@2") == true) + #expect(oldQuiesceHandshake.integrationHealthProjection.features.first { + $0.id == .snapshotQuiesce + }?.state == .updateRequired) var oldSyncHandshake = machine oldSyncHandshake.agentCapabilities = machine.agentCapabilities.map { @@ -970,13 +972,110 @@ struct DorydClientTests { ? DorydAgentCapability(id: $0.id, version: 1) : $0 } #expect(oldSyncHandshake.runtimeEvidence.last?.label == "Tools partially ready") - #expect(oldSyncHandshake.runtimeEvidence.last?.detail.contains("sync-push@2") == true) + #expect(oldSyncHandshake.integrationHealthProjection.features.first { + $0.id == .fileTransferPush + }?.state == .updateRequired) var incompatibleHandshake = machine incompatibleHandshake.agentProtocolVersion = 2 #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") } + @Test func machineIntegrationHealthUsesExactPresentShapeAndRejectsContradictions() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity()) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: true, + sharedFoldersExpected: true, + qualifiedRuntimeFeatures: [ + .clipboardImage, .clipboardText, .displayResize, .gracefulShutdown, + .sharedFolderDiscovery, .sharedFolderMountStatus, + ], + agentBuild: "agent-test", + agentProtocolVersion: 1, + agentCapabilities: [ + .init(id: "clock-sync", version: 1), + .init(id: "exec", version: 1), + .init(id: "exec-stdin", version: 1), + .init(id: "ports-watch", version: 1), + .init(id: "snapshot-quiesce", version: 2), + .init(id: "sync-push", version: 2), + .init(id: "telemetry", version: 1), + ] + ) + let validDictionary = try integrationHealthDictionary(health) + service.setMachineIntegrationHealth("dev", validDictionary) + + let status = try #require((try await client.machineList()).first) + #expect(status.integrationHealth == health) + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.integrationHealthProjection == health) + #expect(machine.runtimeEvidence.last?.label == "Tools ready") + + let inactive = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: false, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: true, + sharedFoldersExpected: true, + qualifiedRuntimeFeatures: [], + agentBuild: "stale-agent-build", + agentProtocolVersion: 1, + agentCapabilities: [] + ) + service.setMachineState("dev", "paused") + service.setMachineIntegrationHealth( + "dev", + try integrationHealthDictionary(inactive) + ) + let paused = try #require((try await client.machineList()).first) + #expect(paused.integrationHealth?.state == .inactive) + + service.setMachineState("dev", "running") + + let malformed = NSMutableDictionary(dictionary: validDictionary) + malformed["unexpected"] = true + service.setMachineIntegrationHealth("dev", malformed) + do { + _ = try await client.machineList() + Issue.record("present integration health with unknown fields must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + let truncated = NSMutableDictionary(dictionary: validDictionary) + let features = try #require(validDictionary["features"] as? [NSDictionary]) + truncated["features"] = Array(features.dropLast()) + service.setMachineIntegrationHealth("dev", truncated) + do { + _ = try await client.machineList() + Issue.record("truncated integration health must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + let contradictory = NSMutableDictionary(dictionary: validDictionary) + contradictory["runtimeAuthority"] = "legacy-compatibility" + service.setMachineIntegrationHealth("dev", contradictory) + do { + _ = try await client.machineList() + Issue.record("integration health contradicting runtime authority must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + } + @Test func machineCapabilityHandshakeRejectsMalformedPresentClaims() async throws { let listener = NSXPCListener.anonymous() let service = FakeDorydService() @@ -1369,6 +1468,15 @@ struct DorydClientTests { ] as NSDictionary } + private func integrationHealthDictionary( + _ health: DoryGuestIntegrationHealth + ) throws -> NSDictionary { + let data = try JSONEncoder().encode(health) + return try #require( + JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary + ) + } + @MainActor @Test func healthRecoveryUsesDaemonOwnedSubsystemRepair() async { let listener = NSXPCListener.anonymous() @@ -3450,6 +3558,31 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } machines[machineID] = current } + + func setMachineIntegrationHealth(_ machineID: String, _ health: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let health { + current["integrationHealth"] = health + } else { + current.removeObject(forKey: "integrationHealth") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineState(_ machineID: String, _ state: String) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current["state"] = state + machines[machineID] = current.copy() as? NSDictionary + } + var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount diff --git a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift new file mode 100644 index 00000000..dc8d8df0 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift @@ -0,0 +1,423 @@ +import Foundation + +/// Durable launch authority available to the guest-integration health evaluator. The evaluator +/// deliberately distinguishes a qualified resolved plan from compatibility operation so the UI +/// never presents a legacy inference as a negotiated integration. +public enum DoryGuestIntegrationRuntimeAuthority: String, Codable, Sendable, Hashable { + case legacyCompatibility = "legacy-compatibility" + case resolvedPlan = "resolved-plan" + case requiresReplanning = "requires-replanning" +} + +public enum DoryGuestIntegrationHealthState: String, Codable, Sendable, Hashable { + case inactive + case missingTools = "missing-tools" + case incompatible + case degraded + case compatibility + case healthy +} + +public enum DoryGuestIntegrationFeatureProvider: String, Codable, Sendable, Hashable { + case guestAgent = "guest-agent" + case resolvedRuntime = "resolved-runtime" +} + +public enum DoryGuestIntegrationFeatureState: String, Codable, Sendable, Hashable { + case inactive + case active + case unavailable + case updateRequired = "update-required" + case unqualified +} + +public struct DoryGuestIntegrationNegotiatedCapability: Codable, Sendable, Equatable, Hashable { + public var id: String + public var version: UInt32 + + public init(id: String, version: UInt32) { + self.id = id + self.version = version + } + + public var isValid: Bool { + version > 0 && id.utf8.count <= 63 + && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil + } +} + +public struct DoryGuestIntegrationFeatureHealth: Codable, Sendable, Equatable, Hashable { + public var id: DoryGuestIntegrationCapabilityID + public var provider: DoryGuestIntegrationFeatureProvider + public var required: Bool + public var minimumVersion: UInt32? + public var negotiatedVersion: UInt32? + public var state: DoryGuestIntegrationFeatureState + + public init( + id: DoryGuestIntegrationCapabilityID, + provider: DoryGuestIntegrationFeatureProvider, + required: Bool, + minimumVersion: UInt32? = nil, + negotiatedVersion: UInt32? = nil, + state: DoryGuestIntegrationFeatureState + ) { + self.id = id + self.provider = provider + self.required = required + self.minimumVersion = minimumVersion + self.negotiatedVersion = negotiatedVersion + self.state = state + } + + public var isValid: Bool { + switch provider { + case .guestAgent: + guard let minimumVersion, minimumVersion > 0 else { return false } + switch state { + case .active: + guard let negotiatedVersion else { return false } + return negotiatedVersion >= minimumVersion + case .updateRequired: + guard let negotiatedVersion else { return false } + return negotiatedVersion > 0 && negotiatedVersion < minimumVersion + case .inactive, .unavailable: + return negotiatedVersion == nil + case .unqualified: + return false + } + case .resolvedRuntime: + return minimumVersion == nil && negotiatedVersion == nil + } + } + +} + +/// Immutable, non-secret projection of the integrations negotiated for one workspace. It is +/// recomputed by the daemon from its exact runtime authority and live guest handshake; it is never +/// persisted as a second source of truth. +public struct DoryGuestIntegrationHealth: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + public static let supportedAgentProtocolVersion: UInt32 = 1 + + public var schemaVersion: UInt16 + public var state: DoryGuestIntegrationHealthState + public var runtimeAuthority: DoryGuestIntegrationRuntimeAuthority + public var agentBuild: String? + public var agentProtocolVersion: UInt32? + public var features: [DoryGuestIntegrationFeatureHealth] + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + state: DoryGuestIntegrationHealthState, + runtimeAuthority: DoryGuestIntegrationRuntimeAuthority, + agentBuild: String?, + agentProtocolVersion: UInt32?, + features: [DoryGuestIntegrationFeatureHealth] + ) { + self.schemaVersion = schemaVersion + self.state = state + self.runtimeAuthority = runtimeAuthority + self.agentBuild = agentBuild + self.agentProtocolVersion = agentProtocolVersion + self.features = features + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + features == features.sorted(by: { $0.id.rawValue < $1.id.rawValue }), + Set(features.map(\.id)).count == features.count, + features.allSatisfy(\.isValid), + agentBuild.map(Self.isValidAgentBuild) ?? true else { + return false + } + guard features.filter({ $0.provider == .resolvedRuntime }).allSatisfy({ feature in + if state == .inactive { return feature.state == .inactive } + switch runtimeAuthority { + case .resolvedPlan: + return feature.state == .active || feature.state == .unavailable + case .requiresReplanning: + return feature.state == .updateRequired + case .legacyCompatibility: + return feature.state == .unqualified + } + }) else { + return false + } + let requiredAreActive = features + .filter(\.required) + .allSatisfy { $0.state == .active } + let requiredAgentFeaturesAreActive = features + .filter { $0.required && $0.provider == .guestAgent } + .allSatisfy { $0.state == .active } + switch state { + case .inactive: + return agentBuild == nil && agentProtocolVersion == nil + && features.allSatisfy { $0.state == .inactive } + case .missingTools: + return agentBuild == nil && agentProtocolVersion == nil + case .incompatible: + return agentBuild != nil + && agentProtocolVersion != nil + && (agentProtocolVersion != Self.supportedAgentProtocolVersion + || features.filter { $0.provider == .guestAgent } + .allSatisfy { $0.state == .unavailable }) + case .degraded: + return agentBuild != nil + && agentProtocolVersion == Self.supportedAgentProtocolVersion + && (!requiredAreActive || runtimeAuthority == .requiresReplanning) + case .compatibility: + return agentBuild != nil + && agentProtocolVersion == Self.supportedAgentProtocolVersion + && requiredAgentFeaturesAreActive + && runtimeAuthority == .legacyCompatibility + case .healthy: + return agentBuild != nil + && agentProtocolVersion == Self.supportedAgentProtocolVersion + && requiredAreActive + && runtimeAuthority == .resolvedPlan + } + } + + /// Validates the complete feature inventory for the workspace shape. This prevents a + /// structurally valid but truncated projection from being interpreted as healthy. + public func isValid( + desktopIntegrationsExpected: Bool, + clipboardTextExpected: Bool, + clipboardImageExpected: Bool, + sharedFoldersExpected: Bool + ) -> Bool { + isValid && features.map(\.id) == Self.featureRequirements( + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: clipboardTextExpected, + clipboardImageExpected: clipboardImageExpected, + sharedFoldersExpected: sharedFoldersExpected + ).map(\.id) + } + + public static func evaluate( + machineIsRunning: Bool, + runtimeAuthority: DoryGuestIntegrationRuntimeAuthority, + desktopIntegrationsExpected: Bool, + clipboardTextExpected: Bool, + clipboardImageExpected: Bool, + sharedFoldersExpected: Bool, + qualifiedRuntimeFeatures: Set, + agentBuild: String?, + agentProtocolVersion: UInt32?, + agentCapabilities: [DoryGuestIntegrationNegotiatedCapability] + ) -> Self { + let build = agentBuild.flatMap { isValidAgentBuild($0) ? $0 : nil } + guard machineIsRunning else { + let features = featureRequirements( + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: clipboardTextExpected, + clipboardImageExpected: clipboardImageExpected, + sharedFoldersExpected: sharedFoldersExpected + ).map { requirement in + feature( + requirement, + runtimeAuthority: runtimeAuthority, + machineIsRunning: false, + qualifiedRuntimeFeatures: [], + supportedAgentProtocol: false, + capabilities: [:] + ) + } + return Self( + state: .inactive, + runtimeAuthority: runtimeAuthority, + agentBuild: nil, + agentProtocolVersion: nil, + features: features + ) + } + + let capabilitiesAreCanonical = agentCapabilities.allSatisfy(\.isValid) + && agentCapabilities == agentCapabilities.sorted { $0.id < $1.id } + && Set(agentCapabilities.map(\.id)).count == agentCapabilities.count + let capabilityVersions = capabilitiesAreCanonical + ? Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) }) + : [:] + let protocolIsSupported = agentProtocolVersion == supportedAgentProtocolVersion + let handshakeIsSupported = protocolIsSupported && capabilitiesAreCanonical + let features = featureRequirements( + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: clipboardTextExpected, + clipboardImageExpected: clipboardImageExpected, + sharedFoldersExpected: sharedFoldersExpected + ).map { requirement in + feature( + requirement, + runtimeAuthority: runtimeAuthority, + machineIsRunning: true, + qualifiedRuntimeFeatures: qualifiedRuntimeFeatures, + supportedAgentProtocol: handshakeIsSupported, + capabilities: capabilityVersions + ) + } + + let state: DoryGuestIntegrationHealthState + if build == nil || agentProtocolVersion == nil { + state = .missingTools + } else if !handshakeIsSupported { + state = .incompatible + } else { + let requiredAgentFeaturesAreActive = features + .filter { $0.required && $0.provider == .guestAgent } + .allSatisfy { $0.state == .active } + let requiredFeaturesAreActive = features + .filter(\.required) + .allSatisfy { $0.state == .active } + if !requiredAgentFeaturesAreActive || runtimeAuthority == .requiresReplanning { + state = .degraded + } else if runtimeAuthority == .legacyCompatibility { + state = .compatibility + } else if !requiredFeaturesAreActive { + state = .degraded + } else { + state = .healthy + } + } + return Self( + state: state, + runtimeAuthority: runtimeAuthority, + agentBuild: build, + agentProtocolVersion: agentProtocolVersion, + features: features + ) + } + + private struct FeatureRequirement { + var id: DoryGuestIntegrationCapabilityID + var provider: DoryGuestIntegrationFeatureProvider + var required: Bool + var minimumVersion: UInt32? + var agentCapabilityID: String? + } + + private static func featureRequirements( + desktopIntegrationsExpected: Bool, + clipboardTextExpected: Bool, + clipboardImageExpected: Bool, + sharedFoldersExpected: Bool + ) -> [FeatureRequirement] { + var requirements: [FeatureRequirement] = [ + .init(id: .readiness, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: nil), + .init(id: .clockSynchronization, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "clock-sync"), + .init(id: .processLaunch, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "exec"), + .init(id: .processInput, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "exec-stdin"), + .init(id: .listenPorts, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "ports-watch"), + .init(id: .telemetry, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "telemetry"), + .init(id: .fileTransferPull, provider: .guestAgent, required: false, + minimumVersion: 1, agentCapabilityID: "sync-pull"), + .init(id: .fileTransferPush, provider: .guestAgent, required: false, + minimumVersion: 2, agentCapabilityID: "sync-push"), + .init(id: .snapshotQuiesce, provider: .guestAgent, required: false, + minimumVersion: 2, agentCapabilityID: "snapshot-quiesce"), + .init(id: .gracefulShutdown, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil), + ] + if desktopIntegrationsExpected { + requirements.append(.init( + id: .displayResize, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil + )) + } + if clipboardTextExpected { + requirements.append(.init( + id: .clipboardText, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil + )) + } + if clipboardImageExpected { + requirements.append(.init( + id: .clipboardImage, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil + )) + } + if sharedFoldersExpected { + requirements.append(contentsOf: [ + .init(id: .sharedFolderDiscovery, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil), + .init(id: .sharedFolderMountStatus, provider: .resolvedRuntime, required: true, + minimumVersion: nil, agentCapabilityID: nil), + ]) + } + return requirements.sorted { $0.id.rawValue < $1.id.rawValue } + } + + private static func feature( + _ requirement: FeatureRequirement, + runtimeAuthority: DoryGuestIntegrationRuntimeAuthority, + machineIsRunning: Bool, + qualifiedRuntimeFeatures: Set, + supportedAgentProtocol: Bool, + capabilities: [String: UInt32] + ) -> DoryGuestIntegrationFeatureHealth { + guard machineIsRunning else { + return DoryGuestIntegrationFeatureHealth( + id: requirement.id, + provider: requirement.provider, + required: requirement.required, + minimumVersion: requirement.minimumVersion, + state: .inactive + ) + } + switch requirement.provider { + case .guestAgent: + let negotiatedVersion: UInt32? + if !supportedAgentProtocol { + negotiatedVersion = nil + } else if requirement.id == .readiness { + negotiatedVersion = supportedAgentProtocol + ? supportedAgentProtocolVersion : nil + } else { + negotiatedVersion = requirement.agentCapabilityID.flatMap { capabilities[$0] } + } + let state: DoryGuestIntegrationFeatureState + if !supportedAgentProtocol { + state = .unavailable + } else if let minimumVersion = requirement.minimumVersion, + let negotiatedVersion { + state = negotiatedVersion >= minimumVersion ? .active : .updateRequired + } else { + state = .unavailable + } + return DoryGuestIntegrationFeatureHealth( + id: requirement.id, + provider: requirement.provider, + required: requirement.required, + minimumVersion: requirement.minimumVersion, + negotiatedVersion: negotiatedVersion, + state: state + ) + case .resolvedRuntime: + let state: DoryGuestIntegrationFeatureState + switch runtimeAuthority { + case .resolvedPlan: + state = qualifiedRuntimeFeatures.contains(requirement.id) + ? .active : .unavailable + case .requiresReplanning: state = .updateRequired + case .legacyCompatibility: state = .unqualified + } + return DoryGuestIntegrationFeatureHealth( + id: requirement.id, + provider: requirement.provider, + required: requirement.required, + state: state + ) + } + } + + private static func isValidAgentBuild(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 128 + && value.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7e } + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 1aeab818..9f3a8c6c 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -2094,6 +2094,7 @@ private extension DoryMachineStatus { ["id": $0.id, "version": $0.version] as NSDictionary } } + dictionary["integrationHealth"] = integrationHealth.xpcDictionary if let agentSocketPath { dictionary["agentSocketPath"] = agentSocketPath } @@ -2328,6 +2329,36 @@ private extension DoryAgentInfo { } } +private extension DoryGuestIntegrationHealth { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "state": state.rawValue, + "runtimeAuthority": runtimeAuthority.rawValue, + "features": features.map(\.xpcDictionary), + ] + if let agentBuild { dictionary["agentBuild"] = agentBuild } + if let agentProtocolVersion { + dictionary["agentProtocolVersion"] = agentProtocolVersion + } + return dictionary as NSDictionary + } +} + +private extension DoryGuestIntegrationFeatureHealth { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "id": id.rawValue, + "provider": provider.rawValue, + "required": required, + "state": state.rawValue, + ] + if let minimumVersion { dictionary["minimumVersion"] = minimumVersion } + if let negotiatedVersion { dictionary["negotiatedVersion"] = negotiatedVersion } + return dictionary as NSDictionary + } +} + private extension DoryTelemetry { var xpcDictionary: NSDictionary { [ diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index d48b2e26..571a8bc2 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -1968,8 +1968,30 @@ public final class HealthReporter: @unchecked Sendable { } static func machineToolsCheck(_ status: DoryMachineStatus) -> HealthCheck { - var data = ["state": status.state.rawValue] - guard [.running, .paused].contains(status.state) else { + let health = status.integrationHealth + var data = [ + "state": status.state.rawValue, + "integration_state": health.state.rawValue, + "runtime_authority": health.runtimeAuthority.rawValue, + "features": health.features.map { + var value = "\($0.id.rawValue)=\($0.state.rawValue)" + if let negotiated = $0.negotiatedVersion { value += "@\(negotiated)" } + return value + }.joined(separator: ","), + ] + if let build = health.agentBuild { data["build"] = build } + if let version = health.agentProtocolVersion { + data["protocol_version"] = String(version) + } + let unavailableRequired = health.features.filter { + $0.required && $0.state != .active + }.map(\.id.rawValue) + if !unavailableRequired.isEmpty { + data["unavailable_required"] = unavailableRequired.joined(separator: ",") + } + + switch health.state { + case .inactive: return HealthCheck( id: "machine.local.\(status.id).tools", status: .skip, @@ -1978,8 +2000,7 @@ public final class HealthReporter: @unchecked Sendable { detail: "\(status.id) is not resident", data: data ) - } - guard let build = status.agentBuild, !build.isEmpty else { + case .missingTools: return HealthCheck( id: "machine.local.\(status.id).tools", status: .warn, @@ -1989,27 +2010,7 @@ public final class HealthReporter: @unchecked Sendable { action: "Install or repair the qualified Dory Tools pack in this workspace.", data: data ) - } - data["build"] = build - guard let protocolVersion = status.agentProtocolVersion else { - return HealthCheck( - id: "machine.local.\(status.id).tools", - status: .warn, - code: "machine.tools.unversioned", - title: "Dory Tools unversioned", - detail: "\(status.id) reported \(build) without a protocol contract", - action: "Update the workspace's Dory Tools pack.", - data: data - ) - } - data["protocol_version"] = String(protocolVersion) - let capabilities = status.agentCapabilities - let canonical = capabilities.allSatisfy(\.isValid) - && capabilities == capabilities.sorted { $0.id < $1.id } - && Set(capabilities.map(\.id)).count == capabilities.count - guard protocolVersion == DoryCore.protocolVersion(), canonical else { - data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" } - .joined(separator: ",") + case .incompatible: return HealthCheck( id: "machine.local.\(status.id).tools", status: .fail, @@ -2019,43 +2020,38 @@ public final class HealthReporter: @unchecked Sendable { action: "Stop the workspace and repair its Dory Tools pack before using integrations.", data: data ) - } - guard !capabilities.isEmpty else { + case .degraded: return HealthCheck( id: "machine.local.\(status.id).tools", status: .warn, - code: "machine.tools.legacy_handshake", - title: "Dory Tools need an update", - detail: "\(status.id) reported protocol \(protocolVersion) without versioned capabilities", - action: "Update the workspace's Dory Tools pack to enable integration health.", + code: "machine.tools.partial", + title: "Dory Tools integrations are degraded", + detail: unavailableRequired.isEmpty + ? "\(status.id) needs a current resolved runtime plan" + : "\(status.id) cannot provide \(unavailableRequired.joined(separator: ", "))", + action: "Update or repair Dory Tools, then replan the workspace if runtime authority is stale.", data: data ) - } - - let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"] - let versions = Dictionary(uniqueKeysWithValues: capabilities.map { ($0.id, $0.version) }) - let missing = required.filter { (versions[$0] ?? 0) < 1 } - data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" }.joined(separator: ",") - if !missing.isEmpty { - data["missing_capabilities"] = missing.joined(separator: ",") + case .compatibility: return HealthCheck( id: "machine.local.\(status.id).tools", status: .warn, - code: "machine.tools.partial", - title: "Dory Tools integrations are partial", - detail: "\(status.id) is missing \(missing.joined(separator: ", "))", - action: "Update or repair the workspace's Dory Tools pack.", + code: "machine.tools.compatibility", + title: "Dory Tools running in compatibility mode", + detail: "\(status.id) negotiated guest capabilities without qualified runtime integration authority", + action: "Replan this workspace before relying on host runtime integrations.", + data: data + ) + case .healthy: + return HealthCheck( + id: "machine.local.\(status.id).tools", + status: .pass, + code: "machine.tools.ready", + title: "Dory Tools ready", + detail: "\(status.id) has \(health.features.filter { $0.state == .active }.count) active integrations", data: data ) } - return HealthCheck( - id: "machine.local.\(status.id).tools", - status: .pass, - code: "machine.tools.ready", - title: "Dory Tools ready", - detail: "\(status.id) reported \(build) with \(capabilities.count) versioned capabilities", - data: data - ) } /// Emits exact, non-secret launch evidence for one workspace. Environment values, host paths, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 24548796..e3336391 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -423,6 +423,50 @@ public struct DoryMachineStatus: Sendable, Equatable { self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt } + + public var integrationHealth: DoryGuestIntegrationHealth { + let authority: DoryGuestIntegrationRuntimeAuthority + switch runtimeIdentity.mode { + case .legacyCompatibility: authority = .legacyCompatibility + case .resolvedPlan: authority = .resolvedPlan + case .requiresReplanning: authority = .requiresReplanning + } + let clipboardPolicy = typedSettings?.clipboardPolicy + ?? (displayMode == .desktop + ? DoryVMClipboardPolicy.legacyDesktop(.bidirectional) + : .disabled) + var qualifiedRuntimeFeatures: Set = [] + if let devices = runtimeIdentity.resolvedPlan?.devices { + if devices.gracefulShutdown { + qualifiedRuntimeFeatures.insert(.gracefulShutdown) + } + if devices.dynamicDisplay { + qualifiedRuntimeFeatures.insert(.displayResize) + } + if devices.clipboard { + qualifiedRuntimeFeatures.formUnion([.clipboardText, .clipboardImage]) + } + if devices.directorySharing { + qualifiedRuntimeFeatures.formUnion([ + .sharedFolderDiscovery, .sharedFolderMountStatus, + ]) + } + } + return DoryGuestIntegrationHealth.evaluate( + machineIsRunning: state == .running, + runtimeAuthority: authority, + desktopIntegrationsExpected: displayMode == .desktop, + clipboardTextExpected: clipboardPolicy.text != .off, + clipboardImageExpected: clipboardPolicy.image != .off, + sharedFoldersExpected: !shares.isEmpty, + qualifiedRuntimeFeatures: qualifiedRuntimeFeatures, + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + agentCapabilities: agentCapabilities.map { + DoryGuestIntegrationNegotiatedCapability(id: $0.id, version: $0.version) + } + ) + } } public enum DoryMachineSnapshotConsistency: String, Sendable, Equatable, Hashable, Codable { diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift new file mode 100644 index 00000000..d4886ed1 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift @@ -0,0 +1,276 @@ +import Testing +@testable import DoryOperations + +@Suite("Dory guest integration health") +struct DoryGuestIntegrationHealthTests { + @Test("resolved running workspace reports exact healthy integrations") + func healthyResolvedWorkspace() throws { + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + sharedFoldersExpected: true, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + + #expect(health.state == .healthy) + #expect(health.isValid) + #expect(health.features.map(\.id.rawValue) == health.features.map(\.id.rawValue).sorted()) + #expect(health.features.first { $0.id == .clipboardText }?.state == .active) + #expect(health.features.first { $0.id == .fileTransferPull }?.state == .active) + #expect(health.features.first { $0.id == .snapshotQuiesce }?.negotiatedVersion == 2) + } + + @Test("stopped workspace is inactive without retaining a stale live handshake") + func stoppedWorkspace() { + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: false, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + + #expect(health.state == .inactive) + #expect(health.agentBuild == nil) + #expect(health.agentProtocolVersion == nil) + #expect(health.features.allSatisfy { $0.state == .inactive }) + #expect(health.isValid) + } + + @Test("missing and incompatible tools are distinct fail-closed states") + func missingAndIncompatibleTools() { + let missing = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: nil, + agentProtocolVersion: nil, + agentCapabilities: [] + ) + #expect(missing.state == .missingTools) + #expect(missing.features.first { $0.id == .clockSynchronization }?.state == .unavailable) + #expect(missing.isValid) + + let incompatible = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/9.0.0", + agentProtocolVersion: 9, + agentCapabilities: currentCapabilities + ) + #expect(incompatible.state == .incompatible) + #expect(incompatible.features.filter { $0.provider == .guestAgent } + .allSatisfy { $0.state == .unavailable }) + #expect(incompatible.isValid) + + let malformed = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: [ + .init(id: "exec", version: 1), + .init(id: "exec", version: 2), + ] + ) + #expect(malformed.state == .incompatible) + #expect(malformed.isValid) + } + + @Test("missing required capabilities and stale runtime authority degrade independently") + func degradedWorkspace() { + let missingCore = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities.filter { $0.id != "telemetry" } + ) + #expect(missingCore.state == .degraded) + #expect(missingCore.features.first { $0.id == .telemetry }?.state == .unavailable) + #expect(missingCore.isValid) + + let replanning = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .requiresReplanning, + desktopIntegrationsExpected: true, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + #expect(replanning.state == .degraded) + #expect(replanning.features.first { $0.id == .displayResize }?.state == .updateRequired) + #expect(replanning.isValid) + } + + @Test("resolved authority cannot qualify runtime integrations absent from the pinned plan") + func resolvedRuntimeFeaturesArePlanBound() { + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: false, + sharedFoldersExpected: true, + qualifiedRuntimeFeatures: [.gracefulShutdown], + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + + #expect(health.state == .degraded) + #expect(health.features.first { $0.id == .gracefulShutdown }?.state == .active) + #expect(health.features.first { $0.id == .displayResize }?.state == .unavailable) + #expect(health.features.first { $0.id == .clipboardText }?.state == .unavailable) + #expect(health.features.contains { $0.id == .clipboardImage } == false) + #expect(health.features.first { $0.id == .sharedFolderMountStatus }?.state == .unavailable) + #expect(health.isValid) + } + + @Test("legacy runtime stays visibly compatibility-only") + func compatibilityWorkspace() { + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .legacyCompatibility, + desktopIntegrationsExpected: true, + sharedFoldersExpected: true, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + + #expect(health.state == .compatibility) + #expect(health.features.first { $0.id == .clipboardImage }?.state == .unqualified) + #expect(health.features.first { $0.id == .clockSynchronization }?.state == .active) + #expect(health.isValid) + } + + @Test("invalid wire-shaped health claims are rejected structurally") + func invalidClaims() throws { + var health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + #expect(health.isValid) + + health.features.reverse() + #expect(!health.isValid) + + health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + health.features.removeLast() + #expect(health.isValid) + #expect(!health.isValid( + desktopIntegrationsExpected: false, + clipboardTextExpected: false, + clipboardImageExpected: false, + sharedFoldersExpected: false + )) + + health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: false, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + health.state = .healthy + let telemetryIndex = try #require(health.features.firstIndex { $0.id == .telemetry }) + health.features[telemetryIndex].state = .unavailable + health.features[telemetryIndex].negotiatedVersion = nil + #expect(!health.isValid) + + health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .legacyCompatibility, + desktopIntegrationsExpected: true, + sharedFoldersExpected: false, + agentBuild: "dory-agent/0.4.5", + agentProtocolVersion: 1, + agentCapabilities: currentCapabilities + ) + let displayIndex = try #require( + health.features.firstIndex { $0.id == .displayResize } + ) + health.features[displayIndex].state = .active + #expect(!health.isValid) + } + + private var currentCapabilities: [DoryGuestIntegrationNegotiatedCapability] { + [ + .init(id: "clock-sync", version: 1), + .init(id: "exec", version: 1), + .init(id: "exec-stdin", version: 1), + .init(id: "ports-watch", version: 1), + .init(id: "snapshot-quiesce", version: 2), + .init(id: "sync-pull", version: 1), + .init(id: "sync-push", version: 2), + .init(id: "telemetry", version: 1), + ] + } +} + +private extension DoryGuestIntegrationHealth { + static func evaluate( + machineIsRunning: Bool, + runtimeAuthority: DoryGuestIntegrationRuntimeAuthority, + desktopIntegrationsExpected: Bool, + sharedFoldersExpected: Bool, + agentBuild: String?, + agentProtocolVersion: UInt32?, + agentCapabilities: [DoryGuestIntegrationNegotiatedCapability] + ) -> Self { + var qualifiedRuntimeFeatures: Set = [ + .gracefulShutdown, + ] + if desktopIntegrationsExpected { + qualifiedRuntimeFeatures.formUnion([ + .clipboardImage, .clipboardText, .displayResize, + ]) + } + if sharedFoldersExpected { + qualifiedRuntimeFeatures.formUnion([ + .sharedFolderDiscovery, .sharedFolderMountStatus, + ]) + } + return evaluate( + machineIsRunning: machineIsRunning, + runtimeAuthority: runtimeAuthority, + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: desktopIntegrationsExpected, + clipboardImageExpected: desktopIntegrationsExpected, + sharedFoldersExpected: sharedFoldersExpected, + qualifiedRuntimeFeatures: qualifiedRuntimeFeatures, + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + agentCapabilities: agentCapabilities + ) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 4d1caf3f..a56f54ab 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -765,6 +765,23 @@ final class DorydServiceTests: XCTestCase { let capabilities = status?["agentCapabilities"] as? [NSDictionary] XCTAssertEqual(capabilities?.first?["id"] as? String, "exec") XCTAssertEqual((capabilities?.first?["version"] as? NSNumber)?.uint32Value, 1) + let health = status?["integrationHealth"] as? NSDictionary + XCTAssertEqual((health?["schemaVersion"] as? NSNumber)?.uint16Value, 1) + XCTAssertEqual(health?["state"] as? String, "degraded") + XCTAssertEqual(health?["runtimeAuthority"] as? String, "legacy-compatibility") + let features = health?["features"] as? [NSDictionary] + XCTAssertEqual( + features?.compactMap { $0["id"] as? String }, + features?.compactMap { $0["id"] as? String }.sorted() + ) + XCTAssertEqual( + features?.first { $0["id"] as? String == "telemetry" }?["state"] as? String, + "active" + ) + XCTAssertEqual( + features?.first { $0["id"] as? String == "clock-sync" }?["state"] as? String, + "unavailable" + ) machineStatus.fulfill() } wait(for: [machineStatus], timeout: 5) diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index f59e56b1..cbdf914f 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -443,31 +443,49 @@ final class HealthReporterTests: XCTestCase { ) == true) } - func testMachineToolsHealthRequiresCanonicalVersionedCapabilities() { + func testMachineToolsHealthUsesDaemonIntegrationProjection() throws { let capabilities = [ "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry", ].map { DoryAgentCapability(id: $0, version: 1) } + let plan = healthResolvedPlan() + let resolvedIdentity = try DoryMachineRuntimeIdentity( + resolvedPlan: plan, + planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan) + ) let ready = HealthReporter.machineToolsCheck(DoryMachineStatus( id: "ready", state: .running, agentBuild: "dory-agent/1.0", agentProtocolVersion: DoryCore.protocolVersion(), - agentCapabilities: capabilities + agentCapabilities: capabilities, + runtimeIdentity: resolvedIdentity )) XCTAssertEqual(ready.status, .pass) XCTAssertEqual(ready.code, "machine.tools.ready") - XCTAssertEqual(ready.data["capabilities"], capabilities.map { - "\($0.id)@\($0.version)" - }.joined(separator: ",")) + XCTAssertEqual(ready.data["integration_state"], "healthy") + XCTAssertEqual(ready.data["runtime_authority"], "resolved-plan") + XCTAssertTrue(ready.data["features"]?.contains("telemetry=active@1") == true) - let legacy = HealthReporter.machineToolsCheck(DoryMachineStatus( - id: "legacy", + let degraded = HealthReporter.machineToolsCheck(DoryMachineStatus( + id: "degraded", state: .running, agentBuild: "dory-agent/0.9", agentProtocolVersion: DoryCore.protocolVersion() )) - XCTAssertEqual(legacy.status, .warn) - XCTAssertEqual(legacy.code, "machine.tools.legacy_handshake") + XCTAssertEqual(degraded.status, .warn) + XCTAssertEqual(degraded.code, "machine.tools.partial") + XCTAssertTrue(degraded.data["unavailable_required"]?.contains("clock-sync") == true) + + let compatibility = HealthReporter.machineToolsCheck(DoryMachineStatus( + id: "compatibility", + state: .running, + agentBuild: "dory-agent/1.0", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: capabilities + )) + XCTAssertEqual(compatibility.status, .warn) + XCTAssertEqual(compatibility.code, "machine.tools.compatibility") + XCTAssertEqual(compatibility.data["integration_state"], "compatibility") let invalid = HealthReporter.machineToolsCheck(DoryMachineStatus( id: "invalid", @@ -762,7 +780,7 @@ final class HealthReporterTests: XCTestCase { private func healthResolvedPlan() -> DoryResolvedMachinePlan { let artifact = healthDigest("a") let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) - let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + let devices = DoryVirtualMachineDeviceCapabilityRequest(gracefulShutdown: true) let media = DoryBootMedia( kind: .installedLinuxBootBundle, source: .bundledByDory, From 9de2afb99e7f7e1e836ad980fce841757fc755df Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 13:40:51 +0000 Subject: [PATCH 214/338] feat(vm): add host-only networking --- Dory/Features/Machines/MachinesView.swift | 5 +- Dory/Features/Sheets/NewMachineSheet.swift | 5 +- .../DoryHV/GVProxyDesktopLaunchPlan.swift | 15 +- .../Sources/dory-hv/DesktopMode.swift | 20 +- .../DoryHVTests/DesktopNetworkPlanTests.swift | 25 +- .../GVProxyDesktopLaunchPlanTests.swift | 13 + docs/virtual-workspace-platform.md | 11 +- .../DoryVirtualMachineCapabilities.swift | 12 +- .../DoryVirtualMachineDefinition.swift | 2 + .../Sources/DoryVMMKit/DoryVMM.swift | 19 +- .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 22 +- .../DoryMachineTypedWriteAuthority.swift | 5 +- .../Sources/DorydKit/DorydConfiguration.swift | 10 +- dory-core-swift/Sources/dorydctl/main.swift | 2 +- .../DoryVirtualMachineCapabilitiesTests.swift | 23 ++ .../DoryMachineTypedWriteAuthorityTests.swift | 12 + .../Tests/DorydKitTests/DoryVMMKitTests.swift | 59 +++++ .../DorydConfigurationTests.swift | 1 + patches/gvproxy-host-only.patch | 223 ++++++++++++++++++ 19 files changed, 454 insertions(+), 30 deletions(-) create mode 100644 patches/gvproxy-host-only.patch diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 665d4b43..f9fc9563 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1227,6 +1227,7 @@ private struct MachineEditSheet: View { sectionLabel("NETWORK") Picker("Network", selection: $networkMode) { Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Host-only").tag(DoryVMNetworkMode.isolated) Text("Disconnected").tag(DoryVMNetworkMode.disconnected) } .labelsHidden() @@ -1234,7 +1235,9 @@ private struct MachineEditSheet: View { .accessibilityIdentifier("edit-machine-network-mode") Text(networkMode == .disconnected ? "Disconnected keeps the virtual adapter present with its link down." - : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + : networkMode == .isolated + ? "Host-only allows private Mac-to-machine connectivity with no external route." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") .font(.system(size: 11)).foregroundStyle(p.text3) } } diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index b4e61dc2..f9fddeb8 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -520,6 +520,7 @@ struct NewMachineSheet: View { sectionLabel("NETWORK") Picker("Network", selection: $networkMode) { Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Host-only").tag(DoryVMNetworkMode.isolated) Text("Disconnected").tag(DoryVMNetworkMode.disconnected) } .labelsHidden() @@ -527,7 +528,9 @@ struct NewMachineSheet: View { .accessibilityIdentifier("new-machine-network-mode") Text(networkMode == .disconnected ? "Disconnected keeps the virtual adapter present with its link down." - : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + : networkMode == .isolated + ? "Host-only allows private Mac-to-machine connectivity with no external route." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") .font(.system(size: 11)).foregroundStyle(p.text3) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift index 8644902d..7ba3febb 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift @@ -4,16 +4,27 @@ /// gvproxy on private Unix sockets—and explicitly disabling its legacy SSH forward—prevents one /// desktop from claiming a host-global port needed by another desktop or by its own restart. package enum GVProxyDesktopLaunchPlan { + package static let hostOnlyConfigurationYAML = """ + stack: + connectivity: host-only + + """ + package static func arguments( mtu: Int, datapathSocket: String, - apiSocket: String + apiSocket: String, + configurationPath: String? = nil ) -> [String] { - [ + var arguments = [ "-mtu", String(mtu), "-listen-vfkit", "unixgram://\(datapathSocket)", "-listen", "unix://\(apiSocket)", "-ssh-port", "-1", ] + if let configurationPath { + arguments.append(contentsOf: ["-config", configurationPath]) + } + return arguments } } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 0b5da8dd..776027f8 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -31,6 +31,7 @@ enum DesktopMode { enum NetworkPlan: Equatable { case sharedNAT + case hostOnly case disconnected init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { @@ -39,15 +40,20 @@ enum DesktopMode { self = .sharedNAT case .disconnected: self = .disconnected - case .bridged, .isolated: + case .isolated: + self = .hostOnly + case .bridged: throw VMError.bootFailure( "resolved device contract contains a network mode not implemented by raw-HV" ) } } - var startsGVProxy: Bool { self == .sharedNAT } + var startsGVProxy: Bool { self != .disconnected } var attachesNetworkDevice: Bool { true } + var gvproxyConfigurationYAML: String? { + self == .hostOnly ? GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML : nil + } } struct DisplayPlan: Equatable { @@ -662,15 +668,23 @@ enum DesktopMode { let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" + let configurationPath = plan.gvproxyConfigurationYAML.map { _ in + "\(runtimeDirectory)/\(token)-network.yaml" + } let socketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] + + [configurationPath].compactMap { $0 } for path in socketPaths { unlink(path) } + if let configurationPath, let yaml = plan.gvproxyConfigurationYAML { + try yaml.write(toFile: configurationPath, atomically: true, encoding: .utf8) + } let process = Process() process.executableURL = URL(fileURLWithPath: gvproxyPath) process.arguments = GVProxyDesktopLaunchPlan.arguments( mtu: DoryNetworkMTU.resolved(), datapathSocket: gvproxySocket, - apiSocket: apiSocket + apiSocket: apiSocket, + configurationPath: configurationPath ) process.standardOutput = FileHandle.standardError process.standardError = FileHandle.standardError diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift index 2e79751c..457c2a28 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopNetworkPlanTests.swift @@ -27,16 +27,21 @@ import Testing #expect(plan.attachesNetworkDevice) } - @Test func unimplementedNetworkModesFailClosed() { - for mode in [ - DoryVirtualMachineNetworkAttachmentMode.bridged, - .isolated, - ] { - #expect(throws: VMError.self) { - _ = try DesktopMode.NetworkPlan( - resolvedDevices: .init(networkAttachment: mode) - ) - } + @Test func isolatedUsesHostOnlyGVProxyWithoutExternalConnectivity() throws { + let plan = try DesktopMode.NetworkPlan( + resolvedDevices: .init(networkAttachment: .isolated) + ) + #expect(plan == .hostOnly) + #expect(plan.startsGVProxy) + #expect(plan.attachesNetworkDevice) + #expect(plan.gvproxyConfigurationYAML?.contains("connectivity: host-only") == true) + } + + @Test func bridgedFailsClosedUntilQualified() { + #expect(throws: VMError.self) { + _ = try DesktopMode.NetworkPlan( + resolvedDevices: .init(networkAttachment: .bridged) + ) } } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift index ebb30c90..70fc410d 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift @@ -17,4 +17,17 @@ import Testing ]) #expect(!arguments.contains("2222")) } + + @Test func hostOnlyNetworkingUsesTheAuditedConnectivityConfiguration() { + let arguments = GVProxyDesktopLaunchPlan.arguments( + mtu: 1_500, + datapathSocket: "/private/dory/machine-b/gv.sock", + apiSocket: "/private/dory/machine-b/api.sock", + configurationPath: "/private/dory/machine-b/network.yaml" + ) + + #expect(arguments.suffix(2) == ["-config", "/private/dory/machine-b/network.yaml"]) + #expect(GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML.contains("connectivity: host-only")) + #expect(!GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML.contains("connectivity: nat")) + } } diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 56c0faae..1e74735d 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -512,10 +512,15 @@ Each NIC has a stable MAC, MTU, DNS policy, address policy, firewall/ingress pol Port forwards are resources with conflict detection and lifecycle reconciliation. Network changes must survive host sleep, Wi-Fi/interface changes, VPN route changes, and daemon restarts. -The first adapter continues to use the provenance-pinned `gvproxy` launch path in +The Linux VZ and raw-HV adapters use the provenance-pinned `gvproxy` launch path in [`GVProxyDesktopLaunchPlan`](../Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift) -and existing Dory DNS/routing. Bridged and host-only modes remain unavailable until an adapter -reports and passes those capabilities; the UI must not imply that NAT is bridged networking. +and existing Dory DNS/routing. Shared/NAT, host-only, and disconnected are exact resolved device +contracts on both adapters. Host-only uses the audited `host-only-connectivity-v1` gvproxy policy: +guest TCP/UDP cannot open arbitrary host-network sockets, upstream DNS resolution is disabled, and +only explicit virtual-host mappings remain reachable. The historical schema value `isolated` is +retained on the wire for compatibility while product surfaces call it **Host-only**. Bridged mode +remains unavailable until an adapter and physical-host qualification prove it; the UI must not +imply that NAT or host-only is bridged networking. ### Display and graphics diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 9d680324..52f4c5bc 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -1399,7 +1399,17 @@ public enum DoryAppleSiliconCapabilityEvaluator { message: "The selected backend does not implement disconnected networking." ) } - case .bridged, .isolated: + case .isolated: + guard request.guest.family == .linux, + request.backend == .appleVirtualizationFramework + || request.backend == .doryHypervisor else { + return unavailable( + tier: tier, + code: .networkAttachmentUnsupported, + message: "The selected backend does not implement host-only networking." + ) + } + case .bridged: return unavailable( tier: tier, code: .networkAttachmentUnsupported, diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 75f338e5..93039d93 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -177,6 +177,8 @@ public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable { case disconnected case sharedNAT = "shared-nat" case bridged + /// Deterministic host-only connectivity. The historical wire value remains `isolated` so + /// existing schema-v2/v3 definitions and canonical plan digests stay readable. case isolated } diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 4707b94d..ebfae787 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -379,6 +379,7 @@ public enum DoryVZConfigurationBuilder { } if let devices = spec.resolvedDevices { guard devices.networkAttachment == .sharedNAT + || devices.networkAttachment == .isolated || devices.networkAttachment == .disconnected else { throw DoryVZMachineError.validation( "resolved device contract contains a device not implemented by this VZ launch" @@ -390,6 +391,12 @@ public enum DoryVZConfigurationBuilder { "disconnected networking cannot attach a host network backend" ) } + if devices.networkAttachment == .isolated, + !(networkAttachment is VZFileHandleNetworkDeviceAttachment) || !spec.nativeIPv6 { + throw DoryVZMachineError.validation( + "host-only networking requires the restricted gvproxy attachment" + ) + } guard devices.directorySharing == !spec.shares.isEmpty else { throw DoryVZMachineError.validation( "resolved directory-sharing contract does not match the launch shares" @@ -1081,16 +1088,24 @@ public enum DoryVMMMain { } else { dataDrive = nil } + let requestedNetwork = resolvedDevices?.networkAttachment ?? .sharedNAT + let usesGVProxy = machineID == "docker" || requestedNetwork == .isolated let gvproxyNetwork: DoryVMMGVProxyNetwork? - if machineID == "docker" { + if usesGVProxy, requestedNetwork != .disconnected { guard let gvproxyPath else { throw DoryVMMArgumentError.missingGVProxy } guard publishHost == "127.0.0.1" || publishHost == "0.0.0.0" else { throw DoryVMMArgumentError.invalidPublishHost(publishHost) } + if requestedNetwork == .isolated, publishHost != "127.0.0.1" { + throw DoryVZMachineError.validation( + "host-only networking cannot publish a source-preserving LAN route" + ) + } gvproxyNetwork = try DoryVMMGVProxyNetwork( gvproxyPath: gvproxyPath, stateDirectory: stateDirectory, - sourcePreservingLAN: publishHost == "0.0.0.0" + networkAttachment: requestedNetwork, + sourcePreservingLAN: requestedNetwork == .sharedNAT && publishHost == "0.0.0.0" ) } else { gvproxyNetwork = nil diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift index 2676dad9..d91c920a 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift @@ -1,5 +1,6 @@ import Darwin import DoryCore +import DoryOperations import Foundation @preconcurrency import Virtualization @@ -13,10 +14,13 @@ struct DoryVMMNativeIPv6Plan: Sendable, Equatable { static let hostGateway = "fd7d:6f72:7900::1" static let guestMAC = "5a:94:ef:e4:0c:ee" + var hostOnly = false + var gvproxyYAML: String { - """ + let connectivity = hostOnly ? " connectivity: host-only\n" : "" + return """ stack: - ipv6Subnet: \(Self.virtualNetwork) + \(connectivity) ipv6Subnet: \(Self.virtualNetwork) ipv6GatewayIP: \(Self.hostGateway) nat: "192.168.127.254": "127.0.0.1" @@ -55,10 +59,20 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { private let lock = NSLock() private var stopped = false - init(gvproxyPath: String, stateDirectory: String, sourcePreservingLAN: Bool = false) throws { + init( + gvproxyPath: String, + stateDirectory: String, + networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, + sourcePreservingLAN: Bool = false + ) throws { guard FileManager.default.isExecutableFile(atPath: gvproxyPath) else { throw DoryVZMachineError.missingFile(gvproxyPath) } + guard networkAttachment == .sharedNAT || networkAttachment == .isolated else { + throw DoryVZMachineError.validation( + "gvproxy implements only shared-NAT and host-only network attachments" + ) + } try FileManager.default.createDirectory(atPath: stateDirectory, withIntermediateDirectories: true) localSocketPath = stateDirectory + "/vmm-net.sock" @@ -71,7 +85,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { try Self.validateUnixPath(path) unlink(path) } - try DoryVMMNativeIPv6Plan().gvproxyYAML.write( + try DoryVMMNativeIPv6Plan(hostOnly: networkAttachment == .isolated).gvproxyYAML.write( toFile: configurationPath, atomically: true, encoding: .utf8 diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index ab30e756..0093032c 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -376,7 +376,10 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { patch.graphicsPreference = .set(preference) } if let raw = try takeOption("--network", from: &arguments) { - guard let mode = DoryVMNetworkMode(rawValue: raw) else { + let mode = raw == "host-only" + ? DoryVMNetworkMode.isolated + : DoryVMNetworkMode(rawValue: raw) + guard let mode else { throw DoryMachineTypedWriteAuthorityError.invalidField("--network") } patch.networkMode = .set(mode) diff --git a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift index 031875f7..1e95f2ba 100644 --- a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift +++ b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift @@ -275,12 +275,20 @@ public struct DorydEnvironment: Sendable { guard let drive = dataDrive() else { return nil } stateDirectory = drive.machinesDirectory } + var baseArguments = splitArguments(string("DORYD_VMM_ARGS") ?? "") + if !baseArguments.contains("--gvproxy"), + let gvproxy = executablePath( + firstOf: ["DORYD_GVPROXY", "DORY_GVPROXY"], + fallbackCandidates: gvproxyCandidates() + ) { + baseArguments.append(contentsOf: ["--gvproxy", gvproxy]) + } return MachineManagerConfiguration( vmmExecutablePath: helper, acceleratedDesktopExecutablePath: acceleratedDesktop?.executablePath, stateDirectory: stateDirectory, runtimeDirectory: string("DORYD_MACHINE_RUNTIME_DIR") ?? "\(home)/.dory/machines", - baseArguments: splitArguments(string("DORYD_VMM_ARGS") ?? ""), + baseArguments: baseArguments, acceleratedDesktopBaseArguments: acceleratedDesktop?.arguments ?? [], passMachineArguments: bool("DORYD_VMM_PASS_MACHINE_ARGS", default: true), logDirectory: string("DORYD_MACHINE_LOG_DIR") ?? "\(stateDirectory)/logs", diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 6fe13588..69dd45cf 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -191,7 +191,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|disconnected|isolated|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|resume|restart|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 27f4f711..4fcfd5d3 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -818,6 +818,14 @@ struct VirtualMachineCapabilitiesTests { graphics: .software, devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected) ) + let hostOnly = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .isolated) + ) let qualifiedVZSharing = evaluate( family: .linux, media: .virtualDisk, @@ -839,6 +847,8 @@ struct VirtualMachineCapabilitiesTests { ) var qualifiedRawDisconnectedDevices = qualifiedRawDesktopDevices qualifiedRawDisconnectedDevices.networkAttachment = .disconnected + var qualifiedRawHostOnlyDevices = qualifiedRawDesktopDevices + qualifiedRawHostOnlyDevices.networkAttachment = .isolated let qualifiedRawDesktop = evaluate( family: .linux, media: .installedLinuxBootBundle, @@ -857,6 +867,15 @@ struct VirtualMachineCapabilitiesTests { mediaArtifactSHA256: Self.guestArtifactSHA256, devices: qualifiedRawDisconnectedDevices ) + let qualifiedRawHostOnly = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: qualifiedRawHostOnlyDevices + ) let unsupportedAudioShape = evaluate( family: .linux, media: .installedLinuxBootBundle, @@ -870,12 +889,16 @@ struct VirtualMachineCapabilitiesTests { #expect(resolved.resolvedDevices == .minimumBootable) #expect(disconnected.availability.isUsable) #expect(disconnected.resolvedDevices?.networkAttachment == .disconnected) + #expect(hostOnly.availability.isUsable) + #expect(hostOnly.resolvedDevices?.networkAttachment == .isolated) #expect(qualifiedVZSharing.availability.isUsable) #expect(qualifiedVZSharing.resolvedDevices?.directorySharing == true) #expect(qualifiedRawDesktop.availability.isUsable) #expect(qualifiedRawDesktop.resolvedDevices == qualifiedRawDesktopDevices) #expect(qualifiedRawDisconnected.availability.isUsable) #expect(qualifiedRawDisconnected.resolvedDevices == qualifiedRawDisconnectedDevices) + #expect(qualifiedRawHostOnly.availability.isUsable) + #expect(qualifiedRawHostOnly.resolvedDevices == qualifiedRawHostOnlyDevices) #expect(unsupportedAudioShape.availability.reason?.code == .audioOutputUnsupported) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index dcedd18d..9dcecb72 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -359,6 +359,18 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.xpcDictionary["env"] == nil) } + @Test("CLI presents host-only while preserving the historical isolated wire identity") + func cliHostOnlyAlias() throws { + var arguments = ["--network", "host-only"] + let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( + &arguments, + allowsClears: false + ) + #expect(patch.networkMode == .set(.isolated)) + #expect(patch.xpcDictionary["networkMode"] as? String == "isolated") + #expect(arguments.isEmpty) + } + @Test("CLI clear groups are explicit and conflict with replacement values") func cliClearSemantics() throws { var clearing = [ diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 3d75d8ad..ba22614e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -348,6 +348,13 @@ final class DoryVMMKitTests: XCTestCase { ) } + func testHostOnlyGVProxyPlanDisablesExternalConnectivity() { + let plan = DoryVMMNativeIPv6Plan(hostOnly: true) + XCTAssertTrue(plan.gvproxyYAML.contains("connectivity: host-only")) + XCTAssertTrue(plan.gvproxyYAML.contains("192.168.127.254\": \"127.0.0.1")) + XCTAssertTrue(plan.gvproxyYAML.contains("\"fd7d:6f72:7900::1\": \"::1\"")) + } + func testExitAfterHandoffKeepsContractShimMode() throws { let arguments = try parseDoryVMMArguments([ "--machine-id", "dev", @@ -515,6 +522,58 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(configuration.storageDevices.count, 1) } + func testHostOnlyVZConfigurationRequiresFileHandleDatapath() throws { + let base = "/tmp/dory-vmm-host-only-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 14:25:23 +0000 Subject: [PATCH 215/338] feat(vm): bind stable network identity --- LINUX_DESKTOP_PARITY.md | 2 +- .../DoryHV/GVProxyDesktopLaunchPlan.swift | 13 +++ .../Sources/DoryHV/VirtioNet.swift | 43 ++++++- .../Sources/dory-hv/DesktopMode.swift | 34 +++++- .../DoryHVTests/DirectIPBridgeTests.swift | 21 +++- .../GVProxyDesktopLaunchPlanTests.swift | 17 +++ .../Tests/DoryHVTests/VirtioNetTests.swift | 36 ++++++ docs/virtual-workspace-platform.md | 7 ++ .../Sources/DoryCore/DirectIPBridge.swift | 24 +++- .../DoryCore/SourcePreservingLANPlan.swift | 8 +- .../DoryVirtualMachineCapabilities.swift | 106 +++++++++++++++++- ...yVirtualMachineQualificationManifest.swift | 3 +- .../Sources/DoryVMMKit/DoryVMM.swift | 31 ++++- .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 19 +++- .../DoryVMMKit/DoryVMMPortForwarder.swift | 9 +- ...yDaemonVirtualMachineRuntimePlanning.swift | 1 + .../DorydKit/DoryResolvedMachinePlan.swift | 2 +- ...rcePreservingLANPrivilegedController.swift | 39 ++++++- .../SourcePreservingLANPlanTests.swift | 4 +- .../DoryVirtualMachineCapabilitiesTests.swift | 63 +++++++++++ ...onVirtualMachineProductionTrustTests.swift | 1 + ...onVirtualMachineRuntimePlanningTests.swift | 3 + .../DoryResolvedMachinePlanTests.swift | 12 ++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 49 ++++++++ ...eservingLANPrivilegedControllerTests.swift | 52 ++++++++- 25 files changed, 557 insertions(+), 42 deletions(-) diff --git a/LINUX_DESKTOP_PARITY.md b/LINUX_DESKTOP_PARITY.md index db4d97a1..145c8e61 100644 --- a/LINUX_DESKTOP_PARITY.md +++ b/LINUX_DESKTOP_PARITY.md @@ -32,7 +32,7 @@ Primary references: | Shared folders | Add/remove read-only or read-write Mac folders, stable guest paths, permissions, large-file tests, file watching, and safe runtime updates | Scoped VirtioFS shares exist; runtime mutation and complete compatibility qualification are missing | | Audio | Speaker output and microphone input, device/permission handling, mute, reconnect, and host sleep/wake recovery | Output and microphone devices plus signing/privacy declarations added; exact-candidate capture, controls, and recovery qualification pending | | Devices | USB storage plus qualified physical USB attachment/detachment and remembered routing; camera and removable-media behavior is explicit | Host discovery only; passthrough missing | -| Networking | NAT/shared networking, bridged networking, host-only networking, stable addressing, DNS/VPN behavior, port forwarding, and offline recovery | NAT and Dory addressing exist; per-machine bridged and host-only modes are missing | +| Networking | NAT/shared networking, bridged networking, host-only networking, stable addressing, DNS/VPN behavior, port forwarding, and offline recovery | Shared/NAT, host-only, and disconnected profiles now have exact backend-neutral contracts; new resolved plans bind a stable locally administered MAC and exact MTU across gvproxy, VZ, raw-HV, and source-preserving LAN forwarding. Qualified bridged networking and the signed-candidate stress matrix remain open. | | Lifecycle | Graceful stop, pause/resume, durable suspend-to-disk, host sleep/wake, crash recovery, and resource reconfiguration | Managed guests shut down through the Dory agent; arbitrary EFI guests now fall back to Virtualization.framework's native virtual power-button request so filesystems can flush without guest tools. Pause foundations and disk persistence exist; saved machine state is missing | | Data safety | Consistent snapshots, restore, clone, linked clone, export/import, and corruption/interruption recovery | Snapshot, restore, clone, and export/import include EFI machine identity/NVRAM with transactional rollback and bundle integrity checks; linked clones and live-state consistency need work | | Guest tools | Versioned Dory guest tools update automatically and provide clipboard, resize, shares, time sync, drag/drop, telemetry, and compatibility health | Agent and Dory-owned integration files, including clipboard adapters, ship in one offline, deterministic desktop-update transaction. The signed package list is provenance and a compatibility preflight, not permission to run a hidden distribution upgrade; drag/drop and a unified compatibility-health surface remain missing | diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift index 7ba3febb..cdad2866 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift @@ -10,6 +10,19 @@ package enum GVProxyDesktopLaunchPlan { """ + /// Pins gvproxy's deterministic guest lease to the MAC persisted in the resolved plan. + /// Without this binding an adapter could expose one MAC while DHCP authority retained the + /// historical global default. + package static func configurationYAML(hostOnly: Bool, guestMAC: String) -> String { + let connectivity = hostOnly ? " connectivity: host-only\n" : "" + return """ + stack: + \(connectivity) dhcpStaticLeases: + 192.168.127.2: \(guestMAC) + + """ + } + package static func arguments( mtu: Int, datapathSocket: String, diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift index f772304c..f1cb20a4 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift @@ -19,7 +19,7 @@ public struct VirtioNetStatistics: Equatable, Sendable { public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 1 public let queueCount = 2 // 0 = receive, 1 = transmit - public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_NET_F_MAC + public let deviceFeatures: UInt64 /// gvproxy's canonical vfkit guest MAC; its DHCP hands this MAC 192.168.127.2. public static let guestMAC: [UInt8] = [0x5A, 0x94, 0xEF, 0xE4, 0x0C, 0xEE] @@ -32,6 +32,8 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { private static let maximumDeferredReceiveFrames = 256 private let socketFD: Int32 private let localSocketPath: String + private let macAddress: [UInt8] + private let maximumTransmissionUnit: UInt16? private var receiveSource: (any DispatchSourceRead)? private weak var transport: VirtioMMIOTransport? private let receiveQueue = DispatchQueue(label: "dory-hv.net.rx") @@ -47,7 +49,15 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { private let receiveDrops = Atomic(0) private let receiveTruncations = Atomic(0) - public init(socketPath: String, remotePath: String) throws { + public init( + socketPath: String, + remotePath: String, + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16? = nil + ) throws { + guard macAddress.count == 6 else { + throw VMError.invalidConfiguration("a virtio-net MAC address must contain six bytes") + } try Self.validateSocketPath(socketPath) try Self.validateSocketPath(remotePath) let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) @@ -109,6 +119,9 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { } self.socketFD = descriptor self.localSocketPath = socketPath + self.macAddress = macAddress + self.maximumTransmissionUnit = maximumTransmissionUnit + self.deviceFeatures = (1 << 5) | (maximumTransmissionUnit == nil ? 0 : (1 << 3)) } deinit { @@ -120,7 +133,14 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { } } - public var configSpace: [UInt8] { Self.guestMAC } + public var configSpace: [UInt8] { + guard let maximumTransmissionUnit else { return macAddress } + // virtio_net_config uses fixed offsets: MAC[0...5], status[6...7], max queue pairs + // [8...9], and MTU[10...11]. Only MAC and MTU are negotiated here. + return macAddress + [0, 0, 0, 0] + + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), + UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] + } public func deviceReady(transport: VirtioMMIOTransport) { self.transport = transport @@ -316,14 +336,25 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { public final class VirtioDisconnectedNet: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 1 public let queueCount = 2 - public let deviceFeatures: UInt64 = (1 << 5) | (1 << 16) // MAC + STATUS + public let deviceFeatures: UInt64 public let configSpace: [UInt8] - public init(macAddress: [UInt8] = VirtioNet.guestMAC) { + public init( + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16? = nil + ) { precondition(macAddress.count == 6, "a virtio-net MAC address must contain six bytes") // virtio_net_config.mac followed by little-endian status. A zero status keeps LINK_UP // clear, which Linux reports as NO-CARRIER while retaining the interface. - configSpace = macAddress + [0, 0] + deviceFeatures = (1 << 5) | (1 << 16) + | (maximumTransmissionUnit == nil ? 0 : (1 << 3)) + if let maximumTransmissionUnit { + configSpace = macAddress + [0, 0, 0, 0] + + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), + UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] + } else { + configSpace = macAddress + [0, 0] + } } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 776027f8..16780d69 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -134,6 +134,12 @@ enum DesktopMode { ) self.graphicsBackend = resolvedGraphics.backend let networkPlan = try NetworkPlan(resolvedDevices: configuration.resolvedDevices) + let networkInterface = configuration.resolvedDevices?.networkInterface + if let networkInterface, !networkInterface.isValid { + throw VMError.bootFailure( + "resolved network interface identity or MTU is invalid" + ) + } let displayPlan = try DisplayPlan(resolvedDevices: configuration.resolvedDevices) if let devices = configuration.resolvedDevices { guard devices.keyboard == devices.pointer else { @@ -214,6 +220,7 @@ enum DesktopMode { let token = String(configuration.machineID.prefix(12)) let networkRuntime = try Self.prepareNetwork( plan: networkPlan, + networkInterface: networkInterface, gvproxyPath: configuration.gvproxyPath, runtimeDirectory: runtimeDirectory, token: token @@ -651,15 +658,20 @@ enum DesktopMode { private static func prepareNetwork( plan: NetworkPlan, + networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest?, gvproxyPath: String, runtimeDirectory: String, token: String ) throws -> NetworkRuntime { if plan == .disconnected { + let mac = networkInterface?.macAddressOctets ?? VirtioNet.guestMAC return NetworkRuntime( process: nil, socketPaths: [], - backend: VirtioDisconnectedNet() + backend: VirtioDisconnectedNet( + macAddress: mac, + maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit + ) ) } guard plan.startsGVProxy, plan.attachesNetworkDevice else { @@ -668,20 +680,30 @@ enum DesktopMode { let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" - let configurationPath = plan.gvproxyConfigurationYAML.map { _ in + let configurationYAML: String? + if let networkInterface { + configurationYAML = GVProxyDesktopLaunchPlan.configurationYAML( + hostOnly: plan == .hostOnly, + guestMAC: networkInterface.macAddress + ) + } else { + configurationYAML = plan.gvproxyConfigurationYAML + } + let configurationPath = configurationYAML.map { _ in "\(runtimeDirectory)/\(token)-network.yaml" } let socketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] + [configurationPath].compactMap { $0 } for path in socketPaths { unlink(path) } - if let configurationPath, let yaml = plan.gvproxyConfigurationYAML { + if let configurationPath, let yaml = configurationYAML { try yaml.write(toFile: configurationPath, atomically: true, encoding: .utf8) } let process = Process() process.executableURL = URL(fileURLWithPath: gvproxyPath) process.arguments = GVProxyDesktopLaunchPlan.arguments( - mtu: DoryNetworkMTU.resolved(), + mtu: networkInterface.map { Int($0.maximumTransmissionUnit) } + ?? DoryNetworkMTU.resolved(), datapathSocket: gvproxySocket, apiSocket: apiSocket, configurationPath: configurationPath @@ -696,7 +718,9 @@ enum DesktopMode { socketPaths: socketPaths, backend: try VirtioNet( socketPath: vmNetworkSocket, - remotePath: gvproxySocket + remotePath: gvproxySocket, + macAddress: networkInterface?.macAddressOctets ?? VirtioNet.guestMAC, + maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit ) ) } catch { diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift index 2192ea41..64f0bdd7 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift @@ -26,13 +26,30 @@ struct DirectIPBridgeTests { let packet = ipv4Packet(source: "10.0.0.10", destination: "192.168.215.42", protocolNumber: 1) let frame = try #require(bridge.ethernetFrameForGvproxy(packet)) - #expect(Array(frame.prefix(6)) == DirectIPPacketBridge.guestMAC) + #expect(Array(frame.prefix(6)) == DirectIPPacketBridge.defaultGuestMAC) #expect(Array(frame.dropFirst(6).prefix(6)) == DirectIPPacketBridge.bridgeMAC) #expect(Array(frame.dropFirst(12).prefix(2)) == [0x08, 0x00]) #expect(bridge.ipv4PacketFromGvproxyFrame(frame) == packet) #expect(bridge.wrapInboundPacketForUtun(packet) == DirectIPPacketBridge.utunIPv4Header + packet) } + @Test func framesIngressForThePlanOwnedGuestMAC() throws { + let mac: [UInt8] = [0x02, 0x11, 0x22, 0x33, 0x44, 0x55] + let bridge = try DirectIPPacketBridge( + subnetCIDR: "192.168.215.0/24", + gateway: "192.168.127.2", + guestMAC: mac + ) + let packet = ipv4Packet( + source: "10.0.0.10", + destination: "192.168.215.42", + protocolNumber: 6 + ) + + let frame = try #require(bridge.ethernetFrameForGvproxy(packet)) + #expect(Array(frame.prefix(6)) == mac) + } + @Test func validatesBridgeConfigurationInputs() throws { #expect(throws: DirectIPBridgeError.invalidCIDR("192.168.215.0/33")) { _ = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/33", gateway: "192.168.127.2") @@ -75,7 +92,7 @@ struct DirectIPBridgeTests { == .injectIPv6ToGvproxy(packet: packet, destination: destination)) let frame = try #require(bridge.ethernetFrameForGvproxyIPv6(packet)) - #expect(Array(frame.prefix(6)) == DirectIPPacketBridge.guestMAC) + #expect(Array(frame.prefix(6)) == DirectIPPacketBridge.defaultGuestMAC) #expect(Array(frame.dropFirst(6).prefix(6)) == DirectIPPacketBridge.bridgeMAC) #expect(Array(frame.dropFirst(12).prefix(2)) == [0x86, 0xdd]) #expect(bridge.ipv6PacketFromGvproxyFrame(frame) == packet) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift index 70fc410d..b03f882e 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift @@ -30,4 +30,21 @@ import Testing #expect(GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML.contains("connectivity: host-only")) #expect(!GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML.contains("connectivity: nat")) } + + @Test func resolvedNICPinsTheExactDHCPLease() { + let shared = GVProxyDesktopLaunchPlan.configurationYAML( + hostOnly: false, + guestMAC: "02:11:22:33:44:55" + ) + #expect(shared.contains("dhcpStaticLeases:")) + #expect(shared.contains("192.168.127.2: 02:11:22:33:44:55")) + #expect(!shared.contains("connectivity: host-only")) + + let hostOnly = GVProxyDesktopLaunchPlan.configurationYAML( + hostOnly: true, + guestMAC: "02:aa:bb:cc:dd:ee" + ) + #expect(hostOnly.contains("connectivity: host-only")) + #expect(hostOnly.contains("192.168.127.2: 02:aa:bb:cc:dd:ee")) + } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift index 66fb4efc..a2e21822 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetTests.swift @@ -4,6 +4,19 @@ import Testing @testable import DoryHV @Suite struct VirtioNetTests { + @Test func disconnectedDeviceBindsResolvedMACAndMTU() { + let mac: [UInt8] = [0x02, 0x11, 0x22, 0x33, 0x44, 0x55] + let device = VirtioDisconnectedNet( + macAddress: mac, + maximumTransmissionUnit: 1_280 + ) + + #expect(device.deviceFeatures & (1 << 3) != 0) + #expect(Array(device.configSpace.prefix(6)) == mac) + #expect(Array(device.configSpace[6..<10]) == [0, 0, 0, 0]) + #expect(Array(device.configSpace[10..<12]) == [0, 5]) + } + @Test func disconnectedDeviceAdvertisesStableMACWithCarrierDown() { let device = VirtioDisconnectedNet() @@ -119,6 +132,29 @@ import Testing #expect(device.statistics.receiveTruncations == 0) } + @Test func connectedDeviceBindsResolvedMACAndMTU() throws { + let directory = "/tmp/dvn-contract-\(getpid())-\(UInt32.random(in: 0.. Data? { guard DirectIPv4Packet(bytes: packet) != nil else { return nil } var frame = Data() - frame.append(contentsOf: Self.guestMAC) + frame.append(contentsOf: guestMAC) frame.append(contentsOf: Self.bridgeMAC) frame.append(contentsOf: [0x08, 0x00]) frame.append(packet) @@ -344,7 +355,7 @@ public struct DirectIPPacketBridge: Sendable { public func ethernetFrameForGvproxyIPv6(_ packet: Data) -> Data? { guard DirectIPv6Packet(bytes: packet) != nil else { return nil } var frame = Data() - frame.append(contentsOf: Self.guestMAC) + frame.append(contentsOf: guestMAC) frame.append(contentsOf: Self.bridgeMAC) frame.append(contentsOf: [0x86, 0xdd]) frame.append(packet) @@ -421,7 +432,8 @@ public final class DirectIPBridge: @unchecked Sendable { subnetCIDR: configuration.subnetCIDR, gateway: configuration.gateway, ipv6SubnetCIDR: configuration.ipv6SubnetCIDR, - ipv6Gateway: configuration.ipv6Gateway + ipv6Gateway: configuration.ipv6Gateway, + guestMAC: configuration.guestMACAddress ) self.log = log queue.setSpecific(key: queueKey, value: 1) diff --git a/dory-core-swift/Sources/DoryCore/SourcePreservingLANPlan.swift b/dory-core-swift/Sources/DoryCore/SourcePreservingLANPlan.swift index 37099b39..73d186bb 100644 --- a/dory-core-swift/Sources/DoryCore/SourcePreservingLANPlan.swift +++ b/dory-core-swift/Sources/DoryCore/SourcePreservingLANPlan.swift @@ -10,6 +10,7 @@ public enum SourcePreservingLANPlan { public static let hostBridgeIPv4 = defaultBridgeNetwork.lanHostAddress public static let guestReturnGatewayIPv4 = "192.168.127.253" public static let bridgeMAC = "5a:94:ef:d0:12:01" + public static let defaultGuestMAC = "5a:94:ef:e4:0c:ee" public static let connectionMark = "0xd072" public static let policyRoutingTable = 215 public static let policyRoutingPriority = 215 @@ -99,7 +100,7 @@ public enum SourcePreservingLANOperation: String, Sendable, Codable { } public struct SourcePreservingLANRequest: Sendable, Equatable, Codable { - public static let schemaVersion = 3 + public static let schemaVersion = 4 public var version: Int public var operation: SourcePreservingLANOperation @@ -108,6 +109,7 @@ public struct SourcePreservingLANRequest: Sendable, Equatable, Codable { public var bindings: Set public var mtu: Int public var bridgeSubnetCIDR: String + public var guestMACAddress: String public init( operation: SourcePreservingLANOperation, @@ -115,7 +117,8 @@ public struct SourcePreservingLANRequest: Sendable, Equatable, Codable { gvproxySocketPath: String? = nil, bindings: Set = [], mtu: Int = DoryNetworkMTU.resolved(), - bridgeSubnetCIDR: String = DoryIPv4BridgeNetwork.defaultCIDR + bridgeSubnetCIDR: String = DoryIPv4BridgeNetwork.defaultCIDR, + guestMACAddress: String = SourcePreservingLANPlan.defaultGuestMAC ) { self.version = Self.schemaVersion self.operation = operation @@ -124,6 +127,7 @@ public struct SourcePreservingLANRequest: Sendable, Equatable, Codable { self.bindings = bindings self.mtu = mtu self.bridgeSubnetCIDR = bridgeSubnetCIDR + self.guestMACAddress = guestMACAddress.lowercased() } } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 52f4c5bc..e1e766de 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -107,6 +107,70 @@ public enum DoryVirtualMachineNetworkAttachmentMode: String, Codable, Sendable, case isolated } +/// Exact guest-visible identity for the primary network function. +/// +/// The value is persisted in the resolved device contract rather than inferred by a backend, so +/// changing adapters cannot silently change the guest's interface identity or effective MTU. +/// Historical plans decode this value as `nil` and retain their adapter-specific ABI. +public struct DoryVirtualMachineNetworkInterfaceCapabilityRequest: + Codable, Sendable, Equatable, Hashable +{ + public static let minimumMTU: UInt16 = 1_280 + public static let maximumMTU: UInt16 = 9_000 + public static let defaultMTU: UInt16 = 1_280 + + public var id: String + public var macAddress: String + public var maximumTransmissionUnit: UInt16 + + public init( + id: String = "nic0", + macAddress: String, + maximumTransmissionUnit: UInt16 = Self.defaultMTU + ) { + self.id = id + self.macAddress = macAddress.lowercased() + self.maximumTransmissionUnit = maximumTransmissionUnit + } + + public var macAddressOctets: [UInt8]? { + let components = macAddress.split(separator: ":", omittingEmptySubsequences: false) + guard components.count == 6 else { return nil } + let octets = components.compactMap { component -> UInt8? in + guard component.utf8.count == 2 else { return nil } + return UInt8(component, radix: 16) + } + return octets.count == 6 ? octets : nil + } + + public var isValid: Bool { + guard id == "nic0", + macAddress == macAddress.lowercased(), + let octets = macAddressOctets, + (Self.minimumMTU...Self.maximumMTU).contains(maximumTransmissionUnit) else { + return false + } + return octets[0] & 0x01 == 0 + && octets[0] & 0x02 == 0x02 + && octets.contains(where: { $0 != 0 }) + && octets.contains(where: { $0 != 0xFF }) + } + + /// Derives a stable locally administered unicast address without making a cryptographic trust + /// claim. The same identity is consumed by every backend and by gvproxy's exact DHCP lease. + public static func stable(machineID: String) -> Self { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in machineID.utf8 { + hash ^= UInt64(byte) + hash &*= 0x0000_0100_0000_01B3 + } + var octets = (0..<6).map { UInt8(truncatingIfNeeded: hash >> UInt64($0 * 8)) } + octets[0] = (octets[0] | 0x02) & 0xFE + let mac = octets.map { String(format: "%02x", $0) }.joined(separator: ":") + return Self(macAddress: mac) + } +} + public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equatable, Hashable { public static let maximumDimensionPixels: UInt32 = 16_384 @@ -166,6 +230,9 @@ public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equatable, Hashable { public var networkAttachment: DoryVirtualMachineNetworkAttachmentMode + /// `nil` is compatibility-only for resolved plans authored before stable NIC identity was + /// introduced. New production planning always supplies this exact contract. + public var networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? /// Exact initial display geometry. `nil` is retained only for historical capability records /// that predate display binding; newly planned desktop workspaces always carry this value. public var display: DoryVirtualMachineDisplayCapabilityRequest? @@ -181,6 +248,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa public init( networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, + networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? = nil, display: DoryVirtualMachineDisplayCapabilityRequest? = nil, audioInput: Bool = false, audioOutput: Bool = false, @@ -193,6 +261,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa gracefulShutdown: Bool = false ) { self.networkAttachment = networkAttachment + self.networkInterface = networkInterface self.display = display self.audioInput = audioInput self.audioOutput = audioOutput @@ -205,6 +274,34 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa self.gracefulShutdown = gracefulShutdown } + /// Compares the device ABI covered by a signed runtime qualification. + /// + /// A qualification record binds the NIC identifier and MTU, while the resolved machine plan + /// owns the per-machine locally administered MAC. This prevents a catalog from having to + /// enumerate every workspace identity without weakening the plan's exact MAC binding. + public func matchesRuntimeQualificationContract( + _ other: DoryVirtualMachineDeviceCapabilityRequest + ) -> Bool { + var lhs = self + var rhs = other + let lhsNetwork = lhs.networkInterface + let rhsNetwork = rhs.networkInterface + lhs.networkInterface = nil + rhs.networkInterface = nil + guard lhs == rhs else { return false } + switch (lhsNetwork, rhsNetwork) { + case (nil, nil): + return true + case let (.some(lhs), .some(rhs)): + return lhs.isValid + && rhs.isValid + && lhs.id == rhs.id + && lhs.maximumTransmissionUnit == rhs.maximumTransmissionUnit + default: + return false + } + } + /// The smallest currently implemented cross-backend device contract. public static let minimumBootable = DoryVirtualMachineDeviceCapabilityRequest() } @@ -1245,7 +1342,7 @@ public enum DoryAppleSiliconCapabilityEvaluator { evidence.bootMediaKind == request.bootMedia.kind, evidence.backend == request.backend, evidence.graphics == request.graphics, - evidence.devices == request.devices, + evidence.devices.matchesRuntimeQualificationContract(request.devices), evidence.virtualHardwareABIVersion == request.virtualHardwareABIVersion else { return unavailable( tier: .supported, @@ -1386,6 +1483,13 @@ public enum DoryAppleSiliconCapabilityEvaluator { tier: DoryCapabilitySupportTier ) -> DoryCapabilityAvailability? { let devices = request.devices + if let networkInterface = devices.networkInterface, !networkInterface.isValid { + return unavailable( + tier: tier, + code: .networkAttachmentUnsupported, + message: "The resolved network interface identity or MTU is invalid." + ) + } switch devices.networkAttachment { case .sharedNAT: break diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift index 6c4d57f9..3e4187eb 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift @@ -194,7 +194,7 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable { && $0.backendRuntimeBuildIdentifier == backendRuntimeBuildIdentifier && $0.virtualHardwareABIVersion == request.virtualHardwareABIVersion && $0.graphics == request.graphics - && $0.devices == request.devices + && $0.devices.matchesRuntimeQualificationContract(request.devices) && $0.hostHardwareModelIdentifier == hostHardwareModelIdentifier && $0.hostOperatingSystemBuild == hostOperatingSystemBuild && $0.components == components @@ -401,6 +401,7 @@ public enum DoryVirtualMachineQualificationAuthorityResolver { && !record.backendImplementationIdentifier.isEmpty && !record.backendRuntimeBuildIdentifier.isEmpty && record.virtualHardwareABIVersion > 0 + && (record.devices.networkInterface?.isValid ?? true) && !record.hostHardwareModelIdentifier.isEmpty && !record.hostOperatingSystemBuild.isEmpty && !record.components.isEmpty diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index ebfae787..8865b770 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -378,6 +378,12 @@ public enum DoryVZConfigurationBuilder { } } if let devices = spec.resolvedDevices { + if let networkInterface = devices.networkInterface, + !networkInterface.isValid { + throw DoryVZMachineError.validation( + "resolved network interface identity or MTU is invalid" + ) + } guard devices.networkAttachment == .sharedNAT || devices.networkAttachment == .isolated || devices.networkAttachment == .disconnected else { @@ -450,9 +456,10 @@ public enum DoryVZConfigurationBuilder { if spec.resolvedDevices?.networkAttachment != .disconnected { network.attachment = networkAttachment ?? VZNATNetworkDeviceAttachment() } - let macAddressString = networkAttachment != nil - ? DoryVMMNativeIPv6Plan.guestMAC - : stableNetworkMACAddress(machineID: spec.machineID) + let macAddressString = spec.resolvedDevices?.networkInterface?.macAddress + ?? (networkAttachment != nil + ? DoryVMMNativeIPv6Plan.guestMAC + : stableNetworkMACAddress(machineID: spec.machineID)) guard let macAddress = VZMACAddress(string: macAddressString) else { throw DoryVZMachineError.validation("could not derive stable network identity") } @@ -1089,7 +1096,11 @@ public enum DoryVMMMain { dataDrive = nil } let requestedNetwork = resolvedDevices?.networkAttachment ?? .sharedNAT - let usesGVProxy = machineID == "docker" || requestedNetwork == .isolated + // An exact NIC contract uses the same gvproxy datapath on every supported attachment so + // the backend can enforce both its MAC lease and MTU rather than delegating them to VZ NAT. + let usesGVProxy = machineID == "docker" + || requestedNetwork == .isolated + || resolvedDevices?.networkInterface != nil let gvproxyNetwork: DoryVMMGVProxyNetwork? if usesGVProxy, requestedNetwork != .disconnected { guard let gvproxyPath else { throw DoryVMMArgumentError.missingGVProxy } @@ -1105,6 +1116,7 @@ public enum DoryVMMMain { gvproxyPath: gvproxyPath, stateDirectory: stateDirectory, networkAttachment: requestedNetwork, + networkInterface: resolvedDevices?.networkInterface, sourcePreservingLAN: requestedNetwork == .sharedNAT && publishHost == "0.0.0.0" ) } else { @@ -1122,7 +1134,12 @@ public enum DoryVMMMain { operation: .activate, sessionID: sessionID, gvproxySocketPath: lanDatapathSocketPath, - bridgeSubnetCIDR: bridgeNetwork.cidr + mtu: resolvedDevices?.networkInterface.map { + Int($0.maximumTransmissionUnit) + } ?? DoryNetworkMTU.resolved(), + bridgeSubnetCIDR: bridgeNetwork.cidr, + guestMACAddress: resolvedDevices?.networkInterface?.macAddress + ?? SourcePreservingLANPlan.defaultGuestMAC )) guard response.status == "active" else { throw DoryVZMachineError.validation("source-preserving LAN helper did not activate") @@ -1229,7 +1246,9 @@ public enum DoryVMMMain { sourcePreservingLANSessionID: sourcePreservingLANSessionID, sourcePreservingLANGVProxySocketPath: sourcePreservingLANClient == nil ? nil : $0.lanDatapathSocketPath, - bridgeSubnetCIDR: bridgeNetwork.cidr + bridgeSubnetCIDR: bridgeNetwork.cidr, + guestMACAddress: resolvedDevices?.networkInterface?.macAddress + ?? SourcePreservingLANPlan.defaultGuestMAC ) } ) diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift index d91c920a..27501daf 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift @@ -15,6 +15,7 @@ struct DoryVMMNativeIPv6Plan: Sendable, Equatable { static let guestMAC = "5a:94:ef:e4:0c:ee" var hostOnly = false + var guestMAC = Self.guestMAC var gvproxyYAML: String { let connectivity = hostOnly ? " connectivity: host-only\n" : "" @@ -22,6 +23,8 @@ struct DoryVMMNativeIPv6Plan: Sendable, Equatable { stack: \(connectivity) ipv6Subnet: \(Self.virtualNetwork) ipv6GatewayIP: \(Self.hostGateway) + dhcpStaticLeases: + 192.168.127.2: \(guestMAC) nat: "192.168.127.254": "127.0.0.1" "\(Self.hostGateway)": "::1" @@ -63,6 +66,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { gvproxyPath: String, stateDirectory: String, networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, + networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? = nil, sourcePreservingLAN: Bool = false ) throws { guard FileManager.default.isExecutableFile(atPath: gvproxyPath) else { @@ -85,7 +89,16 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { try Self.validateUnixPath(path) unlink(path) } - try DoryVMMNativeIPv6Plan(hostOnly: networkAttachment == .isolated).gvproxyYAML.write( + if let networkInterface, !networkInterface.isValid { + throw DoryVZMachineError.validation("resolved network interface identity is invalid") + } + let guestMAC = networkInterface?.macAddress ?? DoryVMMNativeIPv6Plan.guestMAC + let mtu = networkInterface.map { Int($0.maximumTransmissionUnit) } + ?? DoryNetworkMTU.resolved() + try DoryVMMNativeIPv6Plan( + hostOnly: networkAttachment == .isolated, + guestMAC: guestMAC + ).gvproxyYAML.write( toFile: configurationPath, atomically: true, encoding: .utf8 @@ -94,7 +107,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { let child = Process() child.executableURL = URL(fileURLWithPath: gvproxyPath) child.arguments = [ - "-mtu", String(DoryNetworkMTU.resolved()), + "-mtu", String(mtu), "-listen-vfkit", "unixgram://\(datapathSocketPath)", "-listen", "unix://\(apiSocketPath)", "-config", configurationPath, @@ -139,7 +152,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) fileHandle = handle let networkAttachment = VZFileHandleNetworkDeviceAttachment(fileHandle: handle) - networkAttachment.maximumTransmissionUnit = 1500 + networkAttachment.maximumTransmissionUnit = Int(mtu) attachment = networkAttachment try Self.publishUnixForward( localPath: shutdownSocketPath, diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMPortForwarder.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMPortForwarder.swift index 0c9ef373..eaaa5349 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMPortForwarder.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMPortForwarder.swift @@ -12,6 +12,7 @@ final class DoryVMMPortForwarder: @unchecked Sendable { private let sourcePreservingLANSessionID: String? private let sourcePreservingLANGVProxySocketPath: String? private let bridgeSubnetCIDR: String + private let guestMACAddress: String private let log: @Sendable (String) -> Void private let queue = DispatchQueue(label: "dev.dory.dory-vmm.port-forwarder") private let queueKey = DispatchSpecificKey() @@ -30,6 +31,7 @@ final class DoryVMMPortForwarder: @unchecked Sendable { sourcePreservingLANSessionID: String? = nil, sourcePreservingLANGVProxySocketPath: String? = nil, bridgeSubnetCIDR: String = DoryIPv4BridgeNetwork.defaultCIDR, + guestMACAddress: String = SourcePreservingLANPlan.defaultGuestMAC, log: @escaping @Sendable (String) -> Void = { _ in } ) { self.dockerSocketPath = dockerSocketPath @@ -39,6 +41,7 @@ final class DoryVMMPortForwarder: @unchecked Sendable { self.sourcePreservingLANSessionID = sourcePreservingLANSessionID self.sourcePreservingLANGVProxySocketPath = sourcePreservingLANGVProxySocketPath self.bridgeSubnetCIDR = bridgeSubnetCIDR + self.guestMACAddress = guestMACAddress self.log = log queue.setSpecific(key: queueKey, value: 1) timer = DispatchSource.makeTimerSource(queue: queue) @@ -88,7 +91,8 @@ final class DoryVMMPortForwarder: @unchecked Sendable { operation: .refresh, sessionID: sessionID, bindings: ports, - bridgeSubnetCIDR: bridgeSubnetCIDR + bridgeSubnetCIDR: bridgeSubnetCIDR, + guestMACAddress: guestMACAddress )) if recoveringLANSession { recoveringLANSession = false @@ -129,7 +133,8 @@ final class DoryVMMPortForwarder: @unchecked Sendable { sessionID: sessionID, gvproxySocketPath: socketPath, bindings: bindings, - bridgeSubnetCIDR: bridgeSubnetCIDR + bridgeSubnetCIDR: bridgeSubnetCIDR, + guestMACAddress: guestMACAddress )) guard response.status == "active" else { logLANFailure("source-preserving LAN recovery failed closed: unexpected status \(response.status)") diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 0c81095a..fa8950df 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -522,6 +522,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda } return DoryVirtualMachineDeviceCapabilityRequest( networkAttachment: networkAttachment, + networkInterface: .stable(machineID: definition.identity.id), display: definition.display.enabled ? DoryVirtualMachineDisplayCapabilityRequest( widthPixels: definition.display.widthPixels, diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 58172452..0e2c2217 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -1132,7 +1132,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { runtime.backendRuntimeBuildID == backendRuntimeBuildIdentifier, runtime.virtualHardwareABIVersion == virtualHardwareABIVersion, runtime.graphics == graphics, - runtime.devices == devices else { + runtime.devices.matchesRuntimeQualificationContract(devices) else { issues.append(DoryResolvedMachinePlanValidationIssue( code: .runtimeQualificationMismatch, field: "qualificationEvidence.runtime" diff --git a/dory-core-swift/Sources/DorydKit/SourcePreservingLANPrivilegedController.swift b/dory-core-swift/Sources/DorydKit/SourcePreservingLANPrivilegedController.swift index 0e3cfa6a..00abdd2b 100644 --- a/dory-core-swift/Sources/DorydKit/SourcePreservingLANPrivilegedController.swift +++ b/dory-core-swift/Sources/DorydKit/SourcePreservingLANPrivilegedController.swift @@ -7,6 +7,7 @@ public enum SourcePreservingLANPrivilegedError: Error, Sendable, Equatable, Cust case unsupportedVersion(Int) case invalidMTU(Int) case invalidBridgeSubnet(String) + case invalidGuestMAC(String) case invalidSessionID case invalidSocketPath case socketUnavailable(String) @@ -24,6 +25,7 @@ public enum SourcePreservingLANPrivilegedError: Error, Sendable, Equatable, Cust case .unsupportedVersion(let value): "unsupported source-preserving LAN request version: \(value)" case .invalidMTU(let value): "invalid source-preserving LAN MTU: \(value)" case .invalidBridgeSubnet(let value): "invalid source-preserving LAN bridge subnet: \(value)" + case .invalidGuestMAC(let value): "invalid source-preserving LAN guest MAC: \(value)" case .invalidSessionID: "invalid source-preserving LAN session identifier" case .invalidSocketPath: "invalid gvproxy socket path" case .socketUnavailable(let path): "gvproxy socket is unavailable: \(path)" @@ -78,6 +80,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable private var activeInterfaceName: String? private var activePFToken: String? private var activeBridgeNetwork: DoryIPv4BridgeNetwork? + private var activeGuestMACAddress: String? public init( enforceRoot: Bool = true, @@ -142,6 +145,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable let sessionOwner = activeClientUID let hasBridge = activeBridge != nil let hasBridgeNetwork = activeBridgeNetwork != nil + let hasGuestMACAddress = activeGuestMACAddress != nil lock.unlock() if let sessionID, hasBridge { guard sessionOwner == clientUID else { @@ -155,7 +159,8 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable expectedSessionID: sessionID, expectedClientUID: clientUID ) - } else if sessionID != nil || sessionOwner != nil || hasBridge || hasBridgeNetwork { + } else if sessionID != nil || sessionOwner != nil || hasBridge || hasBridgeNetwork + || hasGuestMACAddress { throw SourcePreservingLANPrivilegedError.sessionConflict } else { acceptingSessions = false @@ -180,6 +185,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable let existingBridge = activeBridge let existingInterfaceName = activeInterfaceName let existingBridgeNetwork = activeBridgeNetwork + let existingGuestMACAddress = activeGuestMACAddress lock.unlock() let bridgeNetwork: DoryIPv4BridgeNetwork do { @@ -201,6 +207,9 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable guard existingBridgeNetwork == bridgeNetwork else { throw SourcePreservingLANPrivilegedError.sessionConflict } + guard existingGuestMACAddress == request.guestMACAddress else { + throw SourcePreservingLANPrivilegedError.sessionConflict + } _ = try applyPF( bindings: request.bindings, guestIngressIPv4: bridgeNetwork.lanGuestIngressAddress, @@ -210,7 +219,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable } _ = try cleanupActiveSession(expectedSessionID: existingSessionID) } else if existingSessionID != nil || existingClientUID != nil || existingBridge != nil - || existingBridgeNetwork != nil { + || existingBridgeNetwork != nil || existingGuestMACAddress != nil { throw SourcePreservingLANPrivilegedError.sessionConflict } @@ -222,7 +231,8 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable gateway: bridgeNetwork.lanHostAddress, gvproxySocketPath: gvproxySocketPath, localSocketPath: localSocket, - interfaceNamePath: interfaceFile + interfaceNamePath: interfaceFile, + guestMACAddress: try Self.parseGuestMAC(request.guestMACAddress) ) let bridge = try bridgeFactory(configuration) let bridgeID = ObjectIdentifier(bridge) @@ -234,7 +244,8 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable ) } lock.lock() - guard activeSessionID == nil, activeBridge == nil, activeBridgeNetwork == nil else { + guard activeSessionID == nil, activeBridge == nil, activeBridgeNetwork == nil, + activeGuestMACAddress == nil else { lock.unlock() throw SourcePreservingLANPrivilegedError.sessionConflict } @@ -244,6 +255,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable activeInterfaceName = nil activePFToken = nil activeBridgeNetwork = bridgeNetwork + activeGuestMACAddress = request.guestMACAddress lock.unlock() do { @@ -330,6 +342,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable let sessionOwner = activeClientUID let interfaceName = activeInterfaceName let bridgeNetwork = activeBridgeNetwork + let guestMACAddress = activeGuestMACAddress lock.unlock() guard matches else { throw SourcePreservingLANPrivilegedError.noActiveSession } guard sessionOwner == clientUID else { @@ -342,6 +355,9 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable guard try DoryIPv4BridgeNetwork(request.bridgeSubnetCIDR).cidr == bridgeNetwork.cidr else { throw SourcePreservingLANPrivilegedError.sessionConflict } + guard request.guestMACAddress == guestMACAddress else { + throw SourcePreservingLANPrivilegedError.sessionConflict + } _ = try applyPF( bindings: request.bindings, guestIngressIPv4: bridgeNetwork.lanGuestIngressAddress, @@ -453,6 +469,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable activeInterfaceName = nil activePFToken = nil activeBridgeNetwork = nil + activeGuestMACAddress = nil lock.unlock() return interfaceName } @@ -766,6 +783,7 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable } catch { throw SourcePreservingLANPrivilegedError.invalidBridgeSubnet(request.bridgeSubnetCIDR) } + _ = try parseGuestMAC(request.guestMACAddress) guard request.sessionID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9.-]{0,63}/) != nil, !request.sessionID.contains(".."), request.bindings.count <= 4_096 else { @@ -773,6 +791,19 @@ public final class SourcePreservingLANPrivilegedController: @unchecked Sendable } } + private static func parseGuestMAC(_ value: String) throws -> [UInt8] { + let parts = value.split(separator: ":", omittingEmptySubsequences: false) + let octets = parts.compactMap { part -> UInt8? in + guard part.utf8.count == 2 else { return nil } + return UInt8(part, radix: 16) + } + guard value == value.lowercased(), octets.count == 6, + octets[0] & 0x01 == 0, octets[0] & 0x02 == 0x02 else { + throw SourcePreservingLANPrivilegedError.invalidGuestMAC(value) + } + return octets + } + private static func validateGVProxySocket(_ path: String, clientUID: uid_t) throws { guard path.hasPrefix("/"), !path.contains("\0"), !path.contains("\n"), path.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { diff --git a/dory-core-swift/Tests/DoryCoreTests/SourcePreservingLANPlanTests.swift b/dory-core-swift/Tests/DoryCoreTests/SourcePreservingLANPlanTests.swift index 30e493a3..a2679334 100644 --- a/dory-core-swift/Tests/DoryCoreTests/SourcePreservingLANPlanTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/SourcePreservingLANPlanTests.swift @@ -66,7 +66,8 @@ final class SourcePreservingLANPlanTests: XCTestCase { gvproxySocketPath: "/tmp/gvproxy.sock", bindings: [PublishedPortBinding(protocol: .tcp, port: 8080, hostIP: "0.0.0.0")], mtu: 1_400, - bridgeSubnetCIDR: "10.44.16.0/20" + bridgeSubnetCIDR: "10.44.16.0/20", + guestMACAddress: "02:11:22:33:44:55" ) let decoded = try JSONDecoder().decode( SourcePreservingLANRequest.self, @@ -76,6 +77,7 @@ final class SourcePreservingLANPlanTests: XCTestCase { XCTAssertEqual(decoded.version, SourcePreservingLANRequest.schemaVersion) XCTAssertEqual(decoded.mtu, 1_400) XCTAssertEqual(decoded.bridgeSubnetCIDR, "10.44.16.0/20") + XCTAssertEqual(decoded.guestMACAddress, "02:11:22:33:44:55") } func testCustomBridgeSubnetOwnsReservedLANIngressAddresses() throws { diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 4fcfd5d3..8ce154b8 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -12,6 +12,69 @@ struct VirtualMachineCapabilitiesTests { private static let inspectionReportSHA256 = String(repeating: "f", count: 64) private static let provenanceReceiptSHA256 = String(repeating: "1", count: 64) private static let runtimeQualificationReportSHA256 = String(repeating: "2", count: 64) + + @Test("stable NIC identity is deterministic, local, unicast, and bounded") + func stableNetworkInterfaceIdentity() { + let first = DoryVirtualMachineNetworkInterfaceCapabilityRequest.stable( + machineID: "machine-a" + ) + let repeated = DoryVirtualMachineNetworkInterfaceCapabilityRequest.stable( + machineID: "machine-a" + ) + let other = DoryVirtualMachineNetworkInterfaceCapabilityRequest.stable( + machineID: "machine-b" + ) + + #expect(first == repeated) + #expect(first != other) + #expect(first.isValid) + #expect(first.id == "nic0") + #expect(first.maximumTransmissionUnit == 1_280) + #expect(first.macAddressOctets?.first.map { $0 & 0x03 } == 0x02) + #expect(!DoryVirtualMachineNetworkInterfaceCapabilityRequest( + macAddress: "01:00:00:00:00:00" + ).isValid) + #expect(!DoryVirtualMachineNetworkInterfaceCapabilityRequest( + macAddress: "02:11:22:33:44:55", + maximumTransmissionUnit: 1_279 + ).isValid) + } + + @Test("runtime qualification binds NIC type and MTU but not per-machine MAC") + func networkInterfaceQualificationContract() { + let first = DoryVirtualMachineDeviceCapabilityRequest( + networkInterface: .init(macAddress: "02:00:00:00:00:01") + ) + let second = DoryVirtualMachineDeviceCapabilityRequest( + networkInterface: .init(macAddress: "02:00:00:00:00:02") + ) + let changedMTU = DoryVirtualMachineDeviceCapabilityRequest( + networkInterface: .init( + macAddress: "02:00:00:00:00:02", + maximumTransmissionUnit: 1_500 + ) + ) + + #expect(first.matchesRuntimeQualificationContract(second)) + #expect(!first.matchesRuntimeQualificationContract(changedMTU)) + #expect(!first.matchesRuntimeQualificationContract(.minimumBootable)) + } + + @Test("historical device contracts decode without fabricating NIC authority") + func historicalDeviceContractWithoutNetworkInterface() throws { + let data = try JSONEncoder().encode(DoryVirtualMachineDeviceCapabilityRequest()) + var object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + object.removeValue(forKey: "networkInterface") + let historical = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode( + DoryVirtualMachineDeviceCapabilityRequest.self, + from: historical + ) + #expect(decoded.networkInterface == nil) + } private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification( auditEvidence: DorySignedArtifactQualificationEvidence( manifestIdentity: "dory-linux-desktop-arm64-gpu-v1", diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 82e69206..0a49cee5 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -1145,6 +1145,7 @@ private final class ProductionTrustFixture: @unchecked Sendable { let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation) let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable let headlessDevices = DoryVirtualMachineDeviceCapabilityRequest( + networkInterface: .init(macAddress: "02:00:00:00:00:01"), clockSynchronization: true, gracefulShutdown: true ) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index eb700525..58923b16 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -14,6 +14,9 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) #expect(devices.networkAttachment == .disconnected) + #expect(devices.networkInterface + == .stable(machineID: definition.identity.id)) + #expect(devices.networkInterface?.maximumTransmissionUnit == 1_280) #expect(devices.display == DoryVirtualMachineDisplayCapabilityRequest( widthPixels: definition.display.widthPixels, heightPixels: definition.display.heightPixels, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift index a67e02ed..b0923528 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift @@ -108,6 +108,18 @@ struct DoryResolvedMachinePlanTests { #expect(codes.contains(.resourceAdmissionMismatch)) } + @Test("NIC identity and MTU are exact start gates") + func networkInterfaceEvidenceMismatch() { + var plan = supportedPlan() + plan.devices.networkInterface = .stable(machineID: plan.machineID) + var input = exactInput(for: plan) + input.runtimeEvidence.devices.networkInterface = .stable(machineID: "substituted-machine") + + let result = DoryResolvedMachinePlanStartValidator.revalidate(plan, against: input) + #expect(!result.mayStart) + #expect(result.issues.contains { $0.code == .deviceContractMismatch }) + } + @Test("immutable media and qualification evidence mismatches are exact") func immutableEvidenceMismatch() { let plan = supportedPlan() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index ba22614e..2c6b63af 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -348,6 +348,15 @@ final class DoryVMMKitTests: XCTestCase { ) } + func testGVProxyPlanPinsResolvedGuestMACLease() { + let plan = DoryVMMNativeIPv6Plan( + hostOnly: false, + guestMAC: "02:11:22:33:44:55" + ) + XCTAssertTrue(plan.gvproxyYAML.contains("dhcpStaticLeases:")) + XCTAssertTrue(plan.gvproxyYAML.contains("192.168.127.2: 02:11:22:33:44:55")) + } + func testHostOnlyGVProxyPlanDisablesExternalConnectivity() { let plan = DoryVMMNativeIPv6Plan(hostOnly: true) XCTAssertTrue(plan.gvproxyYAML.contains("connectivity: host-only")) @@ -522,6 +531,46 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(configuration.storageDevices.count, 1) } + func testResolvedVZConfigurationUsesPlanOwnedNICIdentity() throws { + let base = "/tmp/dory-vmm-nic-contract-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 16:19:23 +0000 Subject: [PATCH 216/338] feat(vm): add durable VZ suspend and restore --- Dory/Features/Machines/MachinesView.swift | 6 +- Dory/Models/AppStore.swift | 26 +- Dory/Models/Models.swift | 5 +- Dory/Runtime/Doryd/DorydClient.swift | 76 +- Dory/Runtime/MigrationAssistant.swift | 2 +- ...rationOperationPlanBuilder+Canonical.swift | 2 +- Dory/Shim/DockerShim.swift | 2 +- Dory/Shim/ShimContainerMapping.swift | 6 +- DoryTests/DorydClientTests.swift | 124 +++- docs/virtual-workspace-platform.md | 15 +- .../DoryWorkspaceLifecycleOperation.swift | 20 +- .../Sources/DoryVMMKit/DoryVMM.swift | 231 ++++++- .../DorydKit/DoryMachineSavedState.swift | 450 ++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 21 + .../Sources/DorydKit/HvProcess.swift | 36 + .../LinuxMachineBackendAdapters.swift | 1 + .../Sources/DorydKit/MachineBackend.swift | 1 + .../Sources/DorydKit/MachineManager.swift | 653 +++++++++++++++++- .../Sources/DorydKit/VmmControl.swift | 63 +- dory-core-swift/Sources/dorydctl/main.swift | 9 +- ...DoryWorkspaceLifecycleOperationTests.swift | 54 ++ .../DoryMachineSavedStateTests.swift | 201 ++++++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 5 + .../DorydKitTests/DorydServiceTests.swift | 9 + ...eManagerResolvedPlanIntegrationTests.swift | 9 +- ...ineManagerSavedStateIntegrationTests.swift | 415 +++++++++++ .../DorydKitTests/MachineManagerTests.swift | 9 + 28 files changed, 2392 insertions(+), 60 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineSavedState.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineSavedStateTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerSavedStateIntegrationTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index f9fc9563..3bcd7ccc 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -130,6 +130,7 @@ private struct MachineCard: View { private var isRunning: Bool { machine.status == .running } private var isPaused: Bool { machine.status == .paused } + private var isSuspended: Bool { machine.status == .suspended } private var isActive: Bool { isRunning || isPaused } private var hasAssignedAddress: Bool { DoryDNS.ipv4Bytes(machine.ip) != nil } private var fileTransfer: DorydMachineFileTransferOperation? { @@ -213,7 +214,7 @@ private struct MachineCard: View { HStack(spacing: 8) { actionButton( isRunning ? "stop.fill" : "play.fill", - isRunning ? "Stop" : (isPaused ? "Resume" : "Start"), + isRunning ? "Stop" : ((isPaused || isSuspended) ? "Resume" : "Start"), prominent: !isRunning ) { store.toggleMachine(machine) @@ -497,6 +498,9 @@ private struct MachineCard: View { private var overflowMenu: some View { Menu { if isActive { + Button { store.suspendMachine(machine) } label: { + Label("Suspend", systemImage: "moon.zzz") + } Button { store.restartMachine(machine) } label: { Label("Restart", systemImage: "arrow.clockwise") } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 3b528757..617ae7e1 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5187,6 +5187,8 @@ final class AppStore { runState = .running case "paused", "starting": runState = .paused + case "suspended": + runState = .suspended default: runState = .stopped } @@ -6019,6 +6021,8 @@ final class AppStore { _ = try await dorydClient.machineStop(name) case .paused: _ = try await dorydClient.machineResume(name) + case .suspended: + _ = try await dorydClient.machineResume(name) case .stopped: _ = try await dorydClient.machineStart(name) } @@ -6026,6 +6030,7 @@ final class AppStore { let action = switch previousState { case .running: "stop" case .paused: "resume" + case .suspended: "restore" case .stopped: "start" } actionError = "Could not \(action) \(name): \(error)" @@ -6049,8 +6054,27 @@ final class AppStore { } } + func suspendMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status == .running || machine.status == .paused else { + return + } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machineSuspend(machine.name) + } catch { + actionError = "Could not suspend \(machine.name): \(error)" + } + await refreshMachines() + } + } + func restartMachine(_ machine: Machine) { - guard requireDorydMachines(), machine.status != .stopped else { return } + guard requireDorydMachines(), machine.status == .running || machine.status == .paused else { + return + } guard !busyMachines.contains(machine.name) else { return } busyMachines.insert(machine.name) Task { diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 3c885460..9d0e6ce9 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -41,12 +41,13 @@ enum AppSection: String, CaseIterable, Identifiable, Sendable { } enum RunState: String, Sendable { - case running, paused, stopped + case running, paused, suspended, stopped var label: String { switch self { case .running: "Running" case .paused: "Paused" + case .suspended: "Suspended" case .stopped: "Stopped" } } @@ -55,6 +56,7 @@ enum RunState: String, Sendable { switch self { case .running: p.green case .paused: p.amber + case .suspended: p.accent case .stopped: p.text3 } } @@ -63,6 +65,7 @@ enum RunState: String, Sendable { switch self { case .running: p.greenWeak case .paused: p.amberWeak + case .suspended: p.accentSoft case .stopped: p.pill } } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 4ef8e10b..f824f2a1 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -18,6 +18,7 @@ nonisolated protocol DorydControlXPC { func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -842,6 +843,15 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var typedSettings: DorydMachineTypedSettings? = nil var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil + var savedState: DorydMachineSavedStateSummary? = nil +} + +nonisolated struct DorydMachineSavedStateSummary: Sendable, Equatable { + var stateFileSHA256: String + var stateFileByteCount: UInt64 + var hostHardwareModel: String + var hostOperatingSystemBuild: String + var createdAtUnixMilliseconds: Int64 } nonisolated struct DorydMachineExecResult: Sendable, Equatable { @@ -1463,6 +1473,14 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineSuspend(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 15 * 60).statusCommand { proxy, reply in + proxy.machineSuspend(machineID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + func machineResume(_ machineID: String) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 30).statusCommand { proxy, reply in proxy.machineResume(machineID, reply: reply) @@ -2212,6 +2230,11 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let shares = machineShares(from: dictionary["shares"]) else { return nil } + guard let savedState = machineSavedState(from: dictionary["savedState"]), + state != "suspended" || savedState.value != nil, + savedState.value == nil || ["suspended", "starting", "running"].contains(state) else { + return nil + } let displayMode = (dictionary["displayMode"] as? String) .flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless let agentBuild = nonEmptyString(dictionary["agentBuild"]) @@ -2263,10 +2286,51 @@ nonisolated final class DorydClient: @unchecked Sendable { environment: environment, typedSettings: typedSettings.value, runtimeIdentity: runtimeIdentity, - installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, + savedState: savedState.value ) } + private struct ParsedMachineSavedState { + var value: DorydMachineSavedStateSummary? + } + + nonisolated private static func machineSavedState( + from raw: Any? + ) -> ParsedMachineSavedState? { + guard let raw else { return ParsedMachineSavedState(value: nil) } + let expectedKeys: Set = Set([ + "schemaVersion", "backend", "stateFileSHA256", "stateFileByteCount", + "hostHardwareModel", "hostOperatingSystemBuild", + "createdAtUnixMilliseconds", "portable", + ]) + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == expectedKeys, + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["backend"] as? String == "apple-virtualization-framework", + let digest = dictionary["stateFileSHA256"] as? String, + digest.count == 64, + digest.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }), + let byteCount = strictUInt64(dictionary["stateFileByteCount"]), byteCount > 0, + let hostModel = nonEmptyString(dictionary["hostHardwareModel"]), + hostModel.utf8.count <= 256, !hostModel.contains("\0"), + let hostBuild = nonEmptyString(dictionary["hostOperatingSystemBuild"]), + hostBuild.utf8.count <= 256, !hostBuild.contains("\0"), + let created = strictInt64(dictionary["createdAtUnixMilliseconds"]), created > 0, + let portable = dictionary["portable"] as? Bool, portable == false else { + return nil + } + return ParsedMachineSavedState(value: DorydMachineSavedStateSummary( + stateFileSHA256: digest, + stateFileByteCount: byteCount, + hostHardwareModel: hostModel, + hostOperatingSystemBuild: hostBuild, + createdAtUnixMilliseconds: created + )) + } + private struct ParsedMachineAgentHandshake { var protocolVersion: UInt32? var capabilities: [DorydAgentCapability] @@ -3731,6 +3795,16 @@ nonisolated final class DorydClient: @unchecked Sendable { return decoded } + nonisolated private static func strictInt64(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let decoded = number.int64Value + guard number.stringValue == String(decoded) else { return nil } + return decoded + } + nonisolated private static func uint32(_ value: Any?) -> UInt32? { if let number = value as? NSNumber { return number.uint32Value diff --git a/Dory/Runtime/MigrationAssistant.swift b/Dory/Runtime/MigrationAssistant.swift index 6e130000..e4d3035f 100644 --- a/Dory/Runtime/MigrationAssistant.swift +++ b/Dory/Runtime/MigrationAssistant.swift @@ -1905,7 +1905,7 @@ enum MigrationAssistant { } else { try await target.start(containerID: created.id) } - case .paused: + case .paused, .suspended: if hasFixedHostPort(created.spec) { summary.containersAwaitingSourcePorts.append(created.container.name) summary.warnings.append( diff --git a/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift b/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift index 2deb7b9e..3e2d0039 100644 --- a/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift +++ b/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift @@ -116,7 +116,7 @@ extension MigrationOperationPlanBuilder { } switch container.status { case .running: return .running - case .paused: return .paused + case .paused, .suspended: return .paused case .stopped: return .exited } } diff --git a/Dory/Shim/DockerShim.swift b/Dory/Shim/DockerShim.swift index eb854e64..de2f72e1 100644 --- a/Dory/Shim/DockerShim.swift +++ b/Dory/Shim/DockerShim.swift @@ -2112,7 +2112,7 @@ struct DockerShim: Sendable { private static func containerStateStatus(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } diff --git a/Dory/Shim/ShimContainerMapping.swift b/Dory/Shim/ShimContainerMapping.swift index 597e978c..0b86ea86 100644 --- a/Dory/Shim/ShimContainerMapping.swift +++ b/Dory/Shim/ShimContainerMapping.swift @@ -87,7 +87,7 @@ enum ShimContainerMapping { static func state(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } @@ -95,7 +95,7 @@ enum ShimContainerMapping { static func statusText(_ container: Container) -> String { switch container.status { case .running: "Up \(container.uptime)" - case .paused: "Paused" + case .paused, .suspended: "Paused" case .stopped: "Exited" } } @@ -278,7 +278,7 @@ enum DockerListFilters { private static func statusValue(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 2f170be9..c0a50cf3 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -565,6 +565,60 @@ struct DorydClientTests { } } + @Test func machineListRequiresExactSavedStateEvidenceForSuspendedRows() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint) + let valid: NSDictionary = [ + "schemaVersion": 1, + "backend": "apple-virtualization-framework", + "stateFileSHA256": String(repeating: "b", count: 64), + "stateFileByteCount": UInt64(8192), + "hostHardwareModel": "Mac16,1", + "hostOperatingSystemBuild": "25G90", + "createdAtUnixMilliseconds": Int64(1_787_318_400_000), + "portable": false, + ] + service.setMachineState("dev", "suspended") + service.setMachineSavedState("dev", valid) + + let suspended = try #require((try await client.machineList()).first { $0.id == "dev" }) + #expect(suspended.state == "suspended") + #expect(suspended.savedState?.stateFileSHA256 == String(repeating: "b", count: 64)) + #expect(suspended.savedState?.stateFileByteCount == 8192) + + let malformed = valid.mutableCopy() as! NSMutableDictionary + malformed["unknown"] = "claim" + service.setMachineSavedState("dev", malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let wrongWireType = valid.mutableCopy() as! NSMutableDictionary + wrongWireType["schemaVersion"] = "1" + service.setMachineSavedState("dev", wrongWireType) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let nonStringKey = valid.mutableCopy() as! NSMutableDictionary + nonStringKey[NSNumber(value: 9)] = "claim" + service.setMachineSavedState("dev", nonStringKey) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + service.setMachineSavedState("dev", nil) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + @MainActor @Test func readsDoctorJSONAndIncidentsOverXPC() async throws { let listener = NSXPCListener.anonymous() @@ -1942,6 +1996,20 @@ struct DorydClientTests { } #expect(service.machineResumeCount == 1) + machine = try #require(store.machines.first { $0.name == "dev" }) + store.suspendMachine(machine) + try await waitUntil { + store.machines.first { $0.name == "dev" }?.status == .suspended + } + #expect(service.machineSuspendCount == 1) + + machine = try #require(store.machines.first { $0.name == "dev" }) + store.toggleMachine(machine) + try await waitUntil { + store.machines.first { $0.name == "dev" }?.status == .running + } + #expect(service.machineResumeCount == 2) + machine = try #require(store.machines.first { $0.name == "dev" }) store.restartMachine(machine) try await waitUntil { service.machineRestartCount == 1 } @@ -3357,6 +3425,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineStartCount = 0 private var _machineStopCount = 0 private var _machinePauseCount = 0 + private var _machineSuspendCount = 0 private var _machineResumeCount = 0 private var _machineRestartCount = 0 private var _machineDeleteCount = 0 @@ -3583,6 +3652,20 @@ private final class FakeDorydService: NSObject, DorydControlXPC { machines[machineID] = current.copy() as? NSDictionary } + func setMachineSavedState(_ machineID: String, _ savedState: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let savedState { + current["savedState"] = savedState + } else { + current.removeObject(forKey: "savedState") + } + machines[machineID] = current.copy() as? NSDictionary + } + var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount @@ -3591,6 +3674,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.lock(); defer { lock.unlock() } return _machinePauseCount } + var machineSuspendCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineSuspendCount + } var machineResumeCount: Int { lock.lock(); defer { lock.unlock() } return _machineResumeCount @@ -3887,6 +3974,26 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "suspended", + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]), + savedState: Self.savedStateRow + ) + _machineSuspendCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let current = machines[machineID] @@ -4700,7 +4807,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { address: String? = nil, displayMode: String = "headless", shares: [NSDictionary] = [], - environment: [NSDictionary] = [] + environment: [NSDictionary] = [], + savedState: NSDictionary? = nil ) -> NSDictionary { var row: [String: Any] = [ "id": id, @@ -4731,9 +4839,23 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } row["shares"] = shares row["env"] = environment + if let savedState { + row["savedState"] = savedState + } return row as NSDictionary } + private static let savedStateRow: NSDictionary = [ + "schemaVersion": 1, + "backend": "apple-virtualization-framework", + "stateFileSHA256": String(repeating: "a", count: 64), + "stateFileByteCount": UInt64(4096), + "hostHardwareModel": "Mac16,1", + "hostOperatingSystemBuild": "25G90", + "createdAtUnixMilliseconds": Int64(1_787_318_400_000), + "portable": false, + ] + private static func shareRows(_ value: Any?) -> [NSDictionary] { if let rows = value as? [NSDictionary] { return rows diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 071b097b..c1f94ddd 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -471,8 +471,11 @@ Rules: - resource/device changes declare whether they are live, require restart, or require an explicit device-ABI migration. -The current `DoryMachineState` and `HvProcess` restart/readiness behavior are the compatibility -implementation while this model is introduced. +`DoryMachineState`, `HvProcess`, and the workspace operation journal implement this transition +model. Durable suspend is currently qualified only for the Apple Virtualization backend: the +daemon saves an exact VZ state file, terminates the helper, and restores only after the machine +configuration, runtime identity, host model, OS build, and state digest all revalidate. Raw-HV +machines reject durable suspend instead of presenting pause or cold stop as equivalent behavior. ## Device and subsystem decisions @@ -614,6 +617,14 @@ Snapshot metadata contains: - guest-tools build and quiesce receipt; - media identity, host qualification key, creation operation, and checksum tree. +Backend saved state is a separate same-host execution artifact, not a portable snapshot. It is +stored under the machine's private state namespace, bound to the exact runtime identity and +authoritative configuration, rehashed immediately before helper spawn, and removed only after +restore readiness succeeds. Stopping a suspended machine explicitly discards that execution state +and returns to a cold-stopped condition. Snapshot export never includes the VZ state payload; a +portable archive contains disk/firmware snapshot artifacts and reports that a fresh backend start +is required on the destination. + Restore is transactional: verify all content, snapshot current replaceable state or retain a rollback reference, materialize to temporary names, fsync, atomically publish, then boot-verify. Export uses a versioned archive with a signed/checksummed manifest and never assumes another host diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift index 4d24f1a6..76ac0338 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift @@ -464,6 +464,7 @@ public struct DoryWorkspaceLifecycleJournalBinding: Sendable, Equatable { public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { public static let oldestSupportedSchemaVersion: UInt16 = 1 public static let schemaVersion: UInt16 = 2 + public static let savedStateResourceID = "saved-state-v1" public var schemaVersion: UInt16 public var sourceSchemaVersion: UInt16 @@ -472,9 +473,9 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { public var source: DoryWorkspaceLifecycleCondition public var target: DoryWorkspaceLifecycleCondition public var targetWorkspaceID: String? - /// Stable snapshot or clone identity needed for deterministic recovery; never a host path. + /// Stable snapshot, saved-state, or clone identity needed for deterministic recovery; never a host path. public var targetResourceID: String? - /// Exact content authority for snapshot/restore resources when supplied by the executor. + /// Exact content authority for snapshot, saved-state, and restore resources. public var targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? public var createdAtUnixMilliseconds: Int64 public var deadlineUnixMilliseconds: Int64 @@ -564,6 +565,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } if sourceSchemaVersion == Self.schemaVersion, kind == .restoring, + targetResourceID != Self.savedStateResourceID, source.runtime?.policy == .requireResolvedPlan, target.runtime?.authorizationState != .requiresReplanning { add(.invalidCondition, "target.runtime.authorizationState") @@ -572,6 +574,10 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { kind != .importing, kind != .provisioning, kind != .resolving { add(.invalidCondition, "target.runtime.policy") } + if kind == .restoring, targetResourceID == Self.savedStateResourceID, + !Self.hasSameRuntimePlan(source.runtime, target.runtime) { + add(.invalidCondition, "target.runtime") + } if sourceSchemaVersion == Self.schemaVersion { if source.state != .absent, source.configurationAuthority == nil { add(.invalidCondition, "source.configurationAuthority") @@ -591,7 +597,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } else if targetWorkspaceID != nil { add(.invalidTargetWorkspace, "targetWorkspaceID") } - let requiresResourceID = kind == .snapshotting || kind == .restoring || kind == .cloning + let requiresResourceID = kind == .snapshotting || kind == .suspending + || kind == .restoring || kind == .cloning if requiresResourceID { if sourceSchemaVersion == Self.schemaVersion, targetResourceID.map(DoryOperationJournalStore.isToken) != true { @@ -600,7 +607,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { } else if targetResourceID != nil { add(.invalidTargetWorkspace, "targetResourceID") } - let requiresSnapshotAuthority = kind == .snapshotting || kind == .restoring + let requiresSnapshotAuthority = kind == .snapshotting || kind == .suspending + || kind == .restoring if sourceSchemaVersion == Self.schemaVersion, requiresSnapshotAuthority { if targetSnapshotAuthority?.isValid != true || targetResourceID == nil { add(.invalidCondition, "targetSnapshotAuthority") @@ -868,7 +876,9 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable { case .resolving: source != .absent && source != .deleting && target == source case .starting: source == .stopped && target == .running - case .stopping: (source == .running || source == .paused) && target == .stopped + case .stopping: + (source == .running || source == .paused || source == .suspended) + && target == .stopped case .pausing: source == .running && target == .paused case .resuming: source == .paused && target == .running case .suspending: diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 8865b770..6765d326 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -38,6 +38,7 @@ public struct DoryVMMArguments: Sendable, Equatable { public var agentSocketPath: String? public var shellSocketPath: String? public var controlSocketPath: String? + public var restoreStatePath: String? public var agentBuild = "dory-vmm/handoff-shim" public var detail = "helper handoff ready" public var memoryMB: UInt64 = 2048 @@ -168,6 +169,8 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { parsed.shellSocketPath = try value(after: argument, from: raw, index: &index) case "--control-sock": parsed.controlSocketPath = try value(after: argument, from: raw, index: &index) + case "--restore-state": + parsed.restoreStatePath = try value(after: argument, from: raw, index: &index) case "--agent-build": parsed.agentBuild = try value(after: argument, from: raw, index: &index) case "--detail": @@ -969,6 +972,7 @@ public enum DoryVMMMain { publishHost: arguments.publishHost, bridgeSubnetCIDR: arguments.bridgeSubnetCIDR, dataDriveRoot: arguments.dataDriveRoot, + restoreStatePath: arguments.restoreStatePath, onRuntimeCreated: { coordinator.attach($0) } ) } @@ -1063,6 +1067,7 @@ public enum DoryVMMMain { publishHost: String, bridgeSubnetCIDR: String, dataDriveRoot: String?, + restoreStatePath: String?, onRuntimeCreated: (DoryVMMRuntime) -> Void ) throws -> DoryVMMRuntime { try FileManager.default.createDirectory(atPath: stateDirectory, withIntermediateDirectories: true) @@ -1182,7 +1187,11 @@ public enum DoryVMMMain { ) try validate(configuration: configuration) let machine = DoryVZMachine(configuration: configuration, label: machineID) - try machine.start() + if let restoreStatePath { + try machine.restoreMachineState(from: restoreStatePath) + } else { + try machine.start() + } let sshAgentBridge: DoryVZHostSSHAgentBridge? let sandboxSSHAgentDenied = environment["DORY_SANDBOX"] == "1" && environment["DORY_SANDBOX_SSH_AGENT"] != "1" @@ -1198,7 +1207,11 @@ public enum DoryVMMMain { sshAgentBridge = nil } - let controlServer = try DoryVMMControlServer(machine: machine, localSocketPath: controlSocketPath) + let controlServer = try DoryVMMControlServer( + machine: machine, + localSocketPath: controlSocketPath, + stateDirectory: stateDirectory + ) let dockerdProxy = try DoryVZPortUnixProxy( machine: machine, guestPort: DoryGuestPorts.docker, @@ -1255,6 +1268,10 @@ public enum DoryVMMMain { runtime.portForwarder?.start() onRuntimeCreated(runtime) + if restoreStatePath != nil { + try machine.resume() + } + if bootMode == .efi { try sendHandoff( machineID: machineID, @@ -1278,7 +1295,8 @@ public enum DoryVMMMain { displayMode: displayMode, environment: environment, shares: shares, - resolvedDevices: resolvedDevices + resolvedDevices: resolvedDevices, + restoringSavedState: restoreStatePath != nil ) // For the Docker VM, this handoff means VM + guest-agent readiness. doryd owns the next // ordered stages (data mount, route/resolver, dockerd /version, and host socket), so a VMM @@ -1305,7 +1323,8 @@ public enum DoryVMMMain { displayMode: DoryMachineDisplayMode, environment: [String: String], shares: [DoryMachineShareConfiguration], - resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + restoringSavedState: Bool ) throws -> DoryAgentInfo { let fd = dup(connection.fileDescriptor) guard fd >= 0 else { @@ -1314,6 +1333,7 @@ public enum DoryVMMMain { let control = try DoryCore.connectAgentControlOverFD(fd) defer { control.close() } let info = try control.info() + if restoringSavedState { return info } guard displayMode == .desktop else { return info } if let display = resolvedDevices?.display { @@ -1726,11 +1746,13 @@ private final class DoryVZMachineStopObserver: NSObject, VZVirtualMachineDelegat public final class DoryVZMachine: @unchecked Sendable { private let queue: DispatchQueue + private let configuration: VZVirtualMachineConfiguration private let virtualMachine: VZVirtualMachine private let stopObserver: DoryVZMachineStopObserver public init(configuration: VZVirtualMachineConfiguration, label: String) { self.queue = DispatchQueue(label: "dev.dory.dory-vmm.\(label)") + self.configuration = configuration self.stopObserver = DoryVZMachineStopObserver() self.virtualMachine = VZVirtualMachine(configuration: configuration, queue: queue) self.queue.sync { @@ -1748,6 +1770,115 @@ public final class DoryVZMachine: @unchecked Sendable { try box.wait() } + public func pause() throws { + let box = BlockingResultBox() + queue.async { [self] in + guard virtualMachine.state == .running else { + box.complete(.failure(DoryVZMachineError.validation( + "virtual machine is not running" + ))) + return + } + virtualMachine.pause { box.complete($0) } + } + try box.wait() + } + + public func resume() throws { + let box = BlockingResultBox() + queue.async { [self] in + guard virtualMachine.state == .paused else { + box.complete(.failure(DoryVZMachineError.validation( + "virtual machine is not paused" + ))) + return + } + virtualMachine.resume { box.complete($0) } + } + try box.wait() + } + + /// Saves the current VZ execution state and reports whether this call temporarily paused a + /// running machine. The control server uses that fact to restore the original power state if + /// it cannot deliver the successful response to doryd. + @discardableResult + public func saveMachineState(to path: String) throws -> Bool { + let url = URL(fileURLWithPath: path) + let box = BlockingResultBox() + queue.async { [self] in + do { + try configuration.validateSaveRestoreSupport() + } catch { + box.complete(.failure(error)) + return + } + let save = { (resumeOnFailure: Bool) in + self.virtualMachine.saveMachineStateTo(url: url) { error in + guard let error else { + box.complete(.success(resumeOnFailure)) + return + } + guard resumeOnFailure else { + box.complete(.failure(error)) + return + } + // Saving a running machine requires an internal pause. If persistence fails, + // undo that pause so the daemon's still-running lifecycle state remains true. + self.virtualMachine.resume { resumeResult in + switch resumeResult { + case .success: + box.complete(.failure(error)) + case .failure(let resumeError): + box.complete(.failure(DoryVZMachineError.validation( + "saved-state write failed (\(error)); virtual machine resume also failed (\(resumeError))" + ))) + } + } + } + } + switch virtualMachine.state { + case .paused: + save(false) + case .running: + virtualMachine.pause { result in + switch result { + case .success: save(true) + case .failure(let error): box.complete(.failure(error)) + } + } + default: + box.complete(.failure(DoryVZMachineError.validation( + "virtual machine must be running or paused before saving state" + ))) + } + } + return try box.wait() + } + + public func restoreMachineState(from path: String) throws { + let url = URL(fileURLWithPath: path) + let box = BlockingResultBox() + queue.async { [self] in + do { + try configuration.validateSaveRestoreSupport() + } catch { + box.complete(.failure(error)) + return + } + guard virtualMachine.state == .stopped else { + box.complete(.failure(DoryVZMachineError.validation( + "virtual machine must be stopped before restoring state" + ))) + return + } + virtualMachine.restoreMachineStateFrom(url: url) { error in + if let error { box.complete(.failure(error)) } + else { box.complete(.success(())) } + } + } + try box.wait() + } + var virtualMachineForDisplay: VZVirtualMachine { virtualMachine } @@ -1999,14 +2130,20 @@ final class DoryVZHostSSHAgentBridge: NSObject, VZVirtioSocketListenerDelegate, private final class DoryVMMControlServer: @unchecked Sendable { private let machine: DoryVZMachine private let localSocketPath: String + private let stateDirectory: String private let queue: DispatchQueue private let lock = NSLock() private var listenerFD: Int32 = -1 private var running = false - init(machine: DoryVZMachine, localSocketPath: String) throws { + init( + machine: DoryVZMachine, + localSocketPath: String, + stateDirectory: String + ) throws { self.machine = machine self.localSocketPath = localSocketPath + self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path self.queue = DispatchQueue(label: "dev.dory.dory-vmm.control") } @@ -2075,9 +2212,14 @@ private final class DoryVMMControlServer: @unchecked Sendable { private func handle(clientFD: Int32) { defer { close(clientFD) } let response: VmmControlResponse + var exitAfterResponse = false + var resumeAfterResponseFailure = false do { let request = try readRequest(from: clientFD) - response = try handle(request: request) + let handled = try handle(request: request) + response = handled.response + exitAfterResponse = handled.exitAfterResponse + resumeAfterResponseFailure = handled.resumeAfterResponseFailure } catch { response = VmmControlResponse(ok: false, message: "\(error)") } @@ -2085,20 +2227,89 @@ private final class DoryVMMControlServer: @unchecked Sendable { try writeResponse(response, to: clientFD) } catch { FileHandle.standardError.write(Data("dory-vmm: control response failed: \(error)\n".utf8)) + if resumeAfterResponseFailure { + do { + try machine.resume() + } catch { + FileHandle.standardError.write(Data( + "dory-vmm: failed to resume after saved-state response failure: \(error)\n".utf8 + )) + // The daemon cannot safely continue to advertise this helper as running when + // the internally-paused VZ machine could not be resumed. + exit(1) + } + } + return + } + if exitAfterResponse { + // The caller marks this helper exit as expected before issuing the command. Delay + // until the accepted response has reached the socket buffer, then tear down the VZ + // process; the daemon verifies, fsyncs, hashes, and publishes the completed file. + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.05) { + exit(0) + } } } - private func handle(request: VmmControlRequest) throws -> VmmControlResponse { + private struct HandledControlResponse { + var response: VmmControlResponse + var exitAfterResponse = false + var resumeAfterResponseFailure = false + } + + private func handle(request: VmmControlRequest) throws -> HandledControlResponse { switch request.command { case "setBalloonTarget": guard let targetMB = request.targetMB, targetMB > 0 else { - return VmmControlResponse(ok: false, message: "missing positive targetMB") + return HandledControlResponse( + response: VmmControlResponse(ok: false, message: "missing positive targetMB") + ) } let appliedMB = try machine.setBalloonTarget(memoryMB: targetMB) - return VmmControlResponse(ok: true, targetMB: appliedMB) + return HandledControlResponse( + response: VmmControlResponse(ok: true, targetMB: appliedMB) + ) + case "pauseMachine": + try machine.pause() + return HandledControlResponse(response: VmmControlResponse(ok: true)) + case "resumeMachine": + try machine.resume() + return HandledControlResponse(response: VmmControlResponse(ok: true)) + case "saveMachineState": + guard let path = request.statePath, + let accepted = acceptedSavedStatePath(path) else { + return HandledControlResponse( + response: VmmControlResponse( + ok: false, + message: "saved-state path is outside the private machine state directory" + ) + ) + } + let pausedRunningMachine = try machine.saveMachineState(to: accepted) + return HandledControlResponse( + response: VmmControlResponse(ok: true), + exitAfterResponse: true, + resumeAfterResponseFailure: pausedRunningMachine + ) default: - return VmmControlResponse(ok: false, message: "unknown VMM control command: \(request.command)") + return HandledControlResponse( + response: VmmControlResponse( + ok: false, + message: "unknown VMM control command: \(request.command)" + ) + ) + } + } + + private func acceptedSavedStatePath(_ path: String) -> String? { + guard path.hasPrefix("/"), !path.contains("\0") else { return nil } + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + let allowedDirectory = stateDirectory + "/saved-state-v1" + guard (canonical as NSString).deletingLastPathComponent == allowedDirectory, + (canonical as NSString).lastPathComponent.hasPrefix("state.tmp-") else { + return nil } + return canonical } private func isRunning(listenerFD: Int32) -> Bool { diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineSavedState.swift b/dory-core-swift/Sources/DorydKit/DoryMachineSavedState.swift new file mode 100644 index 00000000..9edb568f --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineSavedState.swift @@ -0,0 +1,450 @@ +import CryptoKit +import Darwin +import DoryOperations +import Foundation + +public struct DoryMachineSavedStateManifest: Codable, Sendable, Equatable { + public static let currentSchemaVersion: UInt16 = 1 + public static let stateFileName = "state.bin" + + public var schemaVersion: UInt16 + public var machineID: String + public var backend: DoryVirtualizationBackendIdentity + public var stateFileName: String + public var stateFileSHA256: String + public var stateFileByteCount: UInt64 + public var authoritativeConfigurationSHA256: String + public var runtimeIdentity: DoryMachineRuntimeIdentity + public var hostHardwareModel: String + public var hostOperatingSystemBuild: String + public var createdAtUnixMilliseconds: Int64 + + public init( + machineID: String, + stateFileSHA256: String, + stateFileByteCount: UInt64, + authoritativeConfigurationSHA256: String, + runtimeIdentity: DoryMachineRuntimeIdentity, + hostHardwareModel: String, + hostOperatingSystemBuild: String, + createdAtUnixMilliseconds: Int64 + ) { + schemaVersion = Self.currentSchemaVersion + self.machineID = machineID + backend = .appleVirtualizationFramework + stateFileName = Self.stateFileName + self.stateFileSHA256 = stateFileSHA256.lowercased() + self.stateFileByteCount = stateFileByteCount + self.authoritativeConfigurationSHA256 = authoritativeConfigurationSHA256.lowercased() + self.runtimeIdentity = runtimeIdentity + self.hostHardwareModel = hostHardwareModel + self.hostOperatingSystemBuild = hostOperatingSystemBuild + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + } + + public var isStructurallyValid: Bool { + schemaVersion == Self.currentSchemaVersion + && Self.isMachineID(machineID) + && backend == .appleVirtualizationFramework + && stateFileName == Self.stateFileName + && Self.isSHA256(stateFileSHA256) + && stateFileByteCount > 0 + && Self.isSHA256(authoritativeConfigurationSHA256) + && runtimeIdentity.validate().isEmpty + && Self.isBoundedHostValue(hostHardwareModel) + && Self.isBoundedHostValue(hostOperatingSystemBuild) + && createdAtUnixMilliseconds > 0 + } + + private static func isSHA256(_ value: String) -> Bool { + value.count == 64 && value.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + private static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + } + + private static func isBoundedHostValue(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 && !value.contains("\0") + } +} + +/// Compact, non-authoritative status projection. The full manifest deliberately remains on disk +/// because it contains the complete resolved plan and is too large to copy through every +/// `MachineEntry`/status operation. Launch and recovery always re-read and validate that durable +/// manifest; this value is only safe evidence for status and UI presentation. +public struct DoryMachineSavedStateStatus: Sendable, Equatable { + public var schemaVersion: UInt16 + public var backend: DoryVirtualizationBackendIdentity + public var stateFileSHA256: String + public var stateFileByteCount: UInt64 + public var hostHardwareModel: String + public var hostOperatingSystemBuild: String + public var createdAtUnixMilliseconds: Int64 + + public init(manifest: DoryMachineSavedStateManifest) { + schemaVersion = manifest.schemaVersion + backend = manifest.backend + stateFileSHA256 = manifest.stateFileSHA256 + stateFileByteCount = manifest.stateFileByteCount + hostHardwareModel = manifest.hostHardwareModel + hostOperatingSystemBuild = manifest.hostOperatingSystemBuild + createdAtUnixMilliseconds = manifest.createdAtUnixMilliseconds + } +} + +public enum DoryMachineSavedStateInspection: Sendable, Equatable { + case absent + case valid(DoryMachineSavedStateManifest) + case invalid(String) +} + +/// Durable same-host saved-state authority. The VZ payload is host-encrypted and is therefore +/// deliberately excluded from portable exports. A manifest is accepted only when its bytes, +/// current machine authority, runtime plan, host model, and OS build all still match exactly. +public struct DoryMachineSavedStateStore: Sendable { + public static let directoryName = DoryWorkspaceLifecycleOperation.savedStateResourceID + public static let manifestFileName = "manifest.json" + public static let temporaryStatePrefix = "state.tmp-" + + private let root: String + + public init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + } + + public func temporaryStatePath(machineID: String, nonce: UUID = UUID()) throws -> String { + guard Self.isMachineID(machineID) else { + throw DoryMachineSavedStateError.invalidMachineID + } + let directory = try prepareDirectory(machineID: machineID) + return directory + "/" + Self.temporaryStatePrefix + + nonce.uuidString.lowercased() + } + + public func publish( + temporaryStatePath: String, + machineID: String, + authoritativeConfigurationData: Data, + runtimeIdentity: DoryMachineRuntimeIdentity, + now: Date = Date() + ) throws -> DoryMachineSavedStateManifest { + let directory = try prepareDirectory(machineID: machineID) + let canonicalTemporary = URL(fileURLWithPath: temporaryStatePath).standardizedFileURL.path + guard (canonicalTemporary as NSString).deletingLastPathComponent == directory, + (canonicalTemporary as NSString).lastPathComponent.hasPrefix(Self.temporaryStatePrefix), + Self.isPrivateRegularFile(canonicalTemporary) else { + throw DoryMachineSavedStateError.invalidTemporaryState + } + let snapshot = try Self.hashStablePrivateFile(canonicalTemporary) + let host = DoryInstallerHostRuntime.current + let manifest = DoryMachineSavedStateManifest( + machineID: machineID, + stateFileSHA256: snapshot.sha256, + stateFileByteCount: snapshot.byteCount, + authoritativeConfigurationSHA256: Self.sha256(authoritativeConfigurationData), + runtimeIdentity: runtimeIdentity, + hostHardwareModel: host.hardwareModel, + hostOperatingSystemBuild: host.operatingSystemBuild, + createdAtUnixMilliseconds: Int64((now.timeIntervalSince1970 * 1_000).rounded()) + ) + guard manifest.isStructurallyValid else { + throw DoryMachineSavedStateError.invalidManifest + } + let statePath = directory + "/" + DoryMachineSavedStateManifest.stateFileName + let manifestPath = directory + "/" + Self.manifestFileName + guard !Self.pathExists(statePath), !Self.pathExists(manifestPath) else { + throw DoryMachineSavedStateError.alreadyPublished + } + guard rename(canonicalTemporary, statePath) == 0 else { + throw DoryMachineSavedStateError.system("rename state", errno) + } + do { + try Self.syncFile(statePath) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(manifest) + let temporaryManifest = directory + "/.manifest.tmp-" + UUID().uuidString.lowercased() + try Self.writeDurablePrivateData(data, path: temporaryManifest) + guard rename(temporaryManifest, manifestPath) == 0 else { + _ = unlink(temporaryManifest) + throw DoryMachineSavedStateError.system("rename manifest", errno) + } + try Self.syncDirectory(directory) + return manifest + } catch { + _ = unlink(manifestPath) + _ = unlink(statePath) + try? Self.syncDirectory(directory) + throw error + } + } + + public func inspect( + machineID: String, + authoritativeConfigurationData: Data, + runtimeIdentity: DoryMachineRuntimeIdentity + ) -> DoryMachineSavedStateInspection { + guard Self.isMachineID(machineID) else { + return .invalid("saved-state machine identity is invalid") + } + let directory = directoryPath(machineID: machineID) + let statePath = directory + "/" + DoryMachineSavedStateManifest.stateFileName + let manifestPath = directory + "/" + Self.manifestFileName + let hasState = Self.pathExists(statePath) + let hasManifest = Self.pathExists(manifestPath) + guard hasState || hasManifest else { return .absent } + guard Self.isPrivateDirectory(directory), + hasState, hasManifest, + let data = Self.readPrivateFile(manifestPath), + let manifest = try? JSONDecoder().decode( + DoryMachineSavedStateManifest.self, + from: data + ), + manifest.isStructurallyValid, + manifest.machineID == machineID, + manifest.runtimeIdentity == runtimeIdentity, + manifest.authoritativeConfigurationSHA256 + == Self.sha256(authoritativeConfigurationData) else { + return .invalid("saved-state authority is incomplete or does not match the machine") + } + let host = DoryInstallerHostRuntime.current + guard manifest.hostHardwareModel == host.hardwareModel, + manifest.hostOperatingSystemBuild == host.operatingSystemBuild else { + return .invalid("saved state belongs to a different host model or OS build") + } + do { + let snapshot = try Self.hashStablePrivateFile(statePath) + guard snapshot.sha256 == manifest.stateFileSHA256, + snapshot.byteCount == manifest.stateFileByteCount else { + return .invalid("saved-state bytes do not match their manifest") + } + return .valid(manifest) + } catch { + return .invalid("saved-state bytes cannot be verified") + } + } + + public func statePath(machineID: String) -> String { + directoryPath(machineID: machineID) + "/" + DoryMachineSavedStateManifest.stateFileName + } + + public func remove(machineID: String) throws { + let directory = directoryPath(machineID: machineID) + guard Self.pathExists(directory) else { return } + guard Self.isPrivateDirectory(directory) else { + throw DoryMachineSavedStateError.invalidDirectory + } + let entries = try FileManager.default.contentsOfDirectory(atPath: directory) + for entry in entries { + guard entry == Self.manifestFileName + || entry == DoryMachineSavedStateManifest.stateFileName + || entry.hasPrefix(Self.temporaryStatePrefix) + || entry.hasPrefix(".manifest.tmp-") else { + throw DoryMachineSavedStateError.invalidDirectory + } + let path = directory + "/" + entry + guard unlink(path) == 0 || errno == ENOENT else { + throw DoryMachineSavedStateError.system("unlink", errno) + } + } + guard rmdir(directory) == 0 || errno == ENOENT else { + throw DoryMachineSavedStateError.system("rmdir", errno) + } + try Self.syncDirectory(root + "/" + machineID) + } + + private func prepareDirectory(machineID: String) throws -> String { + let machineDirectory = root + "/" + machineID + guard Self.isPrivateDirectory(machineDirectory) else { + throw DoryMachineSavedStateError.invalidDirectory + } + let directory = directoryPath(machineID: machineID) + if mkdir(directory, 0o700) != 0, errno != EEXIST { + throw DoryMachineSavedStateError.system("mkdir", errno) + } + guard Self.isPrivateDirectory(directory) else { + throw DoryMachineSavedStateError.invalidDirectory + } + try Self.syncDirectory(machineDirectory) + return directory + } + + private func directoryPath(machineID: String) -> String { + root + "/" + machineID + "/" + Self.directoryName + } + + private static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + } + + private static func hashStablePrivateFile( + _ path: String + ) throws -> (sha256: String, byteCount: UInt64) { + let fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard fd >= 0 else { throw DoryMachineSavedStateError.system("open", errno) } + defer { close(fd) } + var before = stat() + guard fstat(fd, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == getuid(), + before.st_nlink == 1, + (before.st_mode & 0o077) == 0, + before.st_size > 0 else { + throw DoryMachineSavedStateError.invalidTemporaryState + } + var hasher = SHA256() + var bytes: UInt64 = 0 + var buffer = [UInt8](repeating: 0, count: 1024 * 1024) + while true { + let count = read(fd, &buffer, buffer.count) + if count == 0 { break } + if count < 0 { + if errno == EINTR { continue } + throw DoryMachineSavedStateError.system("read", errno) + } + hasher.update(data: Data(buffer.prefix(count))) + bytes += UInt64(count) + } + var after = stat() + guard fstat(fd, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw DoryMachineSavedStateError.invalidTemporaryState + } + return ( + hasher.finalize().map { String(format: "%02x", $0) }.joined(), + bytes + ) + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isPrivateRegularFile(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFREG + && info.st_uid == getuid() + && info.st_nlink == 1 + && (info.st_mode & 0o077) == 0 + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private static func pathExists(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + } + + private static func readPrivateFile(_ path: String) -> Data? { + let fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard fd >= 0 else { return nil } + defer { close(fd) } + var before = stat() + let maximumManifestBytes: off_t = 1024 * 1024 + guard fstat(fd, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == getuid(), + before.st_nlink == 1, + (before.st_mode & 0o077) == 0, + before.st_size >= 0, + before.st_size <= maximumManifestBytes else { + return nil + } + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: false) + guard let data = try? handle.read(upToCount: Int(maximumManifestBytes) + 1), + data.count <= maximumManifestBytes else { + return nil + } + var after = stat() + guard fstat(fd, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + return nil + } + return data + } + + private static func syncFile(_ path: String) throws { + let fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard fd >= 0 else { throw DoryMachineSavedStateError.system("open", errno) } + defer { close(fd) } + guard fsync(fd) == 0 else { + throw DoryMachineSavedStateError.system("fsync", errno) + } + } + + private static func syncDirectory(_ path: String) throws { + let fd = open(path, O_RDONLY | O_CLOEXEC | O_DIRECTORY) + guard fd >= 0 else { throw DoryMachineSavedStateError.system("open directory", errno) } + defer { close(fd) } + guard fsync(fd) == 0 else { + throw DoryMachineSavedStateError.system("fsync directory", errno) + } + } + + private static func writeDurablePrivateData(_ data: Data, path: String) throws { + let fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard fd >= 0 else { throw DoryMachineSavedStateError.system("create", errno) } + var success = false + defer { + close(fd) + if !success { _ = unlink(path) } + } + try data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + var offset = 0 + while offset < raw.count { + let count = write(fd, base.advanced(by: offset), raw.count - offset) + if count < 0 { + if errno == EINTR { continue } + throw DoryMachineSavedStateError.system("write", errno) + } + offset += count + } + } + guard fsync(fd) == 0 else { + throw DoryMachineSavedStateError.system("fsync", errno) + } + success = true + } +} + +public enum DoryMachineSavedStateError: Error, Sendable, CustomStringConvertible { + case invalidMachineID + case invalidDirectory + case invalidTemporaryState + case invalidManifest + case alreadyPublished + case system(String, Int32) + + public var description: String { + switch self { + case .invalidMachineID: "saved-state machine ID is invalid" + case .invalidDirectory: "saved-state directory is not private or has unexpected content" + case .invalidTemporaryState: "temporary saved-state payload is invalid" + case .invalidManifest: "saved-state manifest is invalid" + case .alreadyPublished: "a saved state is already published" + case let .system(operation, code): + "saved-state \(operation) failed: \(String(cString: strerror(code)))" + } + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 7922e9cb..47530e31 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -18,6 +18,7 @@ import Foundation func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 9f3a8c6c..166fc7d8 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -296,6 +296,15 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineSuspend( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + machineControl(machineID, action: "suspend", reply: reply) { manager, id in + try manager.suspend(id: id) + } + } + public func machineResume( _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void @@ -2141,6 +2150,18 @@ private extension DoryMachineStatus { dictionary["installedDesktopPayloadReceipt"] = installedDesktopPayloadReceipt.xpcDictionary } + if let savedState { + dictionary["savedState"] = [ + "schemaVersion": savedState.schemaVersion, + "backend": savedState.backend.rawValue, + "stateFileSHA256": savedState.stateFileSHA256, + "stateFileByteCount": savedState.stateFileByteCount, + "hostHardwareModel": savedState.hostHardwareModel, + "hostOperatingSystemBuild": savedState.hostOperatingSystemBuild, + "createdAtUnixMilliseconds": savedState.createdAtUnixMilliseconds, + "portable": false, + ] as NSDictionary + } return dictionary as NSDictionary } } diff --git a/dory-core-swift/Sources/DorydKit/HvProcess.swift b/dory-core-swift/Sources/DorydKit/HvProcess.swift index 521fe8d6..e2125d72 100644 --- a/dory-core-swift/Sources/DorydKit/HvProcess.swift +++ b/dory-core-swift/Sources/DorydKit/HvProcess.swift @@ -96,6 +96,7 @@ public final class HvProcess: @unchecked Sendable { private var restartCount = 0 private var restartPending = false private var restartsEnabled = true + private var expectedExitPreviousRestartsEnabled: Bool? private var lastTerminationStatus: Int32? private var lastLaunchError: String? @@ -160,6 +161,7 @@ public final class HvProcess: @unchecked Sendable { restartCount = 0 restartPending = false restartsEnabled = true + expectedExitPreviousRestartsEnabled = nil lastTerminationStatus = nil lastLaunchError = nil try launchLocked() @@ -235,6 +237,7 @@ public final class HvProcess: @unchecked Sendable { restartCount += 1 } restartPending = shouldRestart + expectedExitPreviousRestartsEnabled = nil delay = configuration.restartPolicy.delay(forAttempt: restartCount) lock.unlock() try? oldLog?.close() @@ -288,6 +291,39 @@ public final class HvProcess: @unchecked Sendable { lock.unlock() } + /// Marks the next helper exit as an expected lifecycle transition without sending a signal. + /// Used after a saved-state command has been accepted: dory-vmm exits itself only after the + /// VZ payload is complete. The caller must cancel this expectation if the command fails. + public func prepareForExpectedExit() -> Bool { + lock.lock() + defer { lock.unlock() } + guard process?.isRunning == true, !stopping else { return false } + stopping = true + expectedExitPreviousRestartsEnabled = nil + expectedExitPreviousRestartsEnabled = restartsEnabled + restartsEnabled = false + restartPending = false + return true + } + + public func cancelExpectedExit() { + lock.lock() + if process?.isRunning == true { + stopping = false + restartsEnabled = expectedExitPreviousRestartsEnabled ?? restartsEnabled + } + expectedExitPreviousRestartsEnabled = nil + lock.unlock() + } + + public func waitForExpectedExit(timeout: TimeInterval) -> Bool { + lock.lock() + let waiter = terminationWaiter + lock.unlock() + guard let waiter else { return true } + return waiter.wait(timeout: .now() + max(0, timeout)) == .success + } + public var isSuspended: Bool { lock.lock() defer { lock.unlock() } diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index 155ad3ea..44c6ecbf 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -66,6 +66,7 @@ private extension MachineBackendRuntimeState { case .starting: self = .starting case .running: self = .running case .paused: self = .paused + case .suspended: self = .suspended case .stopped: self = .stopped case .failed: self = .failed } diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index b137da6b..1620e8cb 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -170,6 +170,7 @@ public enum MachineBackendRuntimeState: String, Codable, Sendable, Equatable, Ha case starting case running case paused + case suspended case stopped case failed } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index e3336391..e1075456 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -322,6 +322,7 @@ public enum DoryMachineState: String, Sendable, Equatable { case starting case running case paused + case suspended case stopped case failed } @@ -358,6 +359,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var diagnosticOverrides: [DoryMachineDiagnosticOverride] public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? + public var savedState: DoryMachineSavedStateStatus? public init( id: String, @@ -391,7 +393,8 @@ public struct DoryMachineStatus: Sendable, Equatable { virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion ), - installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil, + savedState: DoryMachineSavedStateStatus? = nil ) { self.id = id self.state = state @@ -422,6 +425,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.diagnosticOverrides = diagnosticOverrides self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt + self.savedState = savedState } public var integrationHealth: DoryGuestIntegrationHealth { @@ -928,6 +932,8 @@ public final class MachineManager: @unchecked Sendable { private let configuration: MachineManagerConfiguration private let agentConnector: AgentConnector private let balloonController: any MachineBalloonControlling + private let vzLifecycleController: any MachineVZLifecycleControlling + private let savedStateStore: DoryMachineSavedStateStore private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore @@ -973,6 +979,7 @@ public final class MachineManager: @unchecked Sendable { configuration: MachineManagerConfiguration, launchPolicy: DoryMachineLaunchPolicy = .legacyCompatibility, balloonController: any MachineBalloonControlling = UnixMachineBalloonController(), + vzLifecycleController: any MachineVZLifecycleControlling = UnixMachineVZLifecycleController(), agentConnector: @escaping AgentConnector = { socketPath in try LocalAgentControl.connect(socketPath: socketPath) }, @@ -981,12 +988,14 @@ public final class MachineManager: @unchecked Sendable { self.configuration = configuration self.launchPolicy = launchPolicy self.balloonController = balloonController + self.vzLifecycleController = vzLifecycleController self.agentConnector = agentConnector self.processStarter = processStarter self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory) self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore( root: configuration.stateDirectory ) + self.savedStateStore = DoryMachineSavedStateStore(root: configuration.stateDirectory) do { let store = try DoryOperationJournalStore( home: configuration.lifecycleJournalHome @@ -1030,7 +1039,8 @@ public final class MachineManager: @unchecked Sendable { self.machines = Self.loadPersistedMachines( configuration: configuration, launchPolicy: launchPolicy, - runtimeIdentityStore: runtimeIdentityStore + runtimeIdentityStore: runtimeIdentityStore, + savedStateStore: savedStateStore ) for (machineID, diagnostic) in lifecycleRecoveryDiagnostics { machines[machineID]?.lastError = diagnostic @@ -1540,6 +1550,36 @@ public final class MachineManager: @unchecked Sendable { operationLock.lock() defer { operationLock.unlock() } try requireNoActivePlanningMutation(id: id) + lock.lock() + guard let startEntry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + let isDurablySuspended = startEntry.state == .suspended + lock.unlock() + if let authoritativeData = Self.readPrivateMetadata(path: machineConfigPath(id: id)) { + switch savedStateStore.inspect( + machineID: id, + authoritativeConfigurationData: authoritativeData, + runtimeIdentity: startEntry.runtimeIdentity + ) { + case .absent: + break + case .valid where isDurablySuspended: + break + case .valid: + throw MachineManagerError.persistence( + "saved-state authority exists outside the suspended lifecycle state" + ) + case .invalid(let detail): + throw MachineManagerError.persistence(detail) + } + } + if isDurablySuspended { + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + return try restoreSavedStateImplementation(id: id) + } try refreshResolvedAdmissionForStartIfNeeded(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } @@ -2363,7 +2403,8 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw error } - let processConfiguration: HvProcessConfiguration + let processLaunch: (configuration: HvProcessConfiguration, + backend: DoryVirtualizationBackendIdentity) do { // This single-use daemon-owned check deliberately sits after all persisted // machine/plan validation and immediately before any path-derived launch arguments @@ -2383,15 +2424,46 @@ public final class MachineManager: @unchecked Sendable { #if DEBUG try shareAuthorityPreSpawnTestHook?() #endif + if let restoreStatePath = entry.pendingRestoreStatePath { + guard restoreStatePath == savedStateStore.statePath(machineID: id), + let expectedStatus = entry.savedStateStatus, + let authoritativeData = Self.readPrivateMetadata( + path: machineConfigPath(id: id) + ) else { + throw MachineManagerError.persistence( + "saved-state authority is unavailable immediately before spawn" + ) + } + switch savedStateStore.inspect( + machineID: id, + authoritativeConfigurationData: authoritativeData, + runtimeIdentity: entry.runtimeIdentity + ) { + case .valid(let current) + where DoryMachineSavedStateStatus(manifest: current) == expectedStatus: + break + case .valid: + throw MachineManagerError.persistence( + "saved-state authority changed immediately before spawn" + ) + case .absent: + throw MachineManagerError.persistence( + "saved-state payload disappeared immediately before spawn" + ) + case .invalid(let detail): + throw MachineManagerError.persistence(detail) + } + } var launchMachine = entry.configuration launchMachine.shares = try Self.revalidateShareRuntimeAuthorities( shareAuthorities, expectedShares: entry.configuration.shares ) - processConfiguration = try self.processConfiguration( + processLaunch = try self.processConfiguration( for: launchMachine, handoffPath: handoffPath, - resolvedLaunchBinding: launchBinding + resolvedLaunchBinding: launchBinding, + restoreStatePath: entry.pendingRestoreStatePath ) } catch { handoffServer?.stop() @@ -2406,7 +2478,7 @@ public final class MachineManager: @unchecked Sendable { throw error } let process = HvProcess( - configuration: processConfiguration, + configuration: processLaunch.configuration, unexpectedTerminationHandler: { [weak self] termination in self?.handleUnexpectedMachineProcessTermination( machineID: id, @@ -2423,6 +2495,7 @@ public final class MachineManager: @unchecked Sendable { entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = resolvedPlan + entry.activeBackend = processLaunch.backend let requiresAdmissionCommit = resolvedPlan != nil && productionResourceAdmissionLedger != nil entry.state = configuration.requiresReadyHandoff || requiresAdmissionCommit @@ -2440,6 +2513,7 @@ public final class MachineManager: @unchecked Sendable { machines[id]?.launchID = nil machines[id]?.runtimeAddress = nil machines[id]?.activeResolvedPlan = nil + machines[id]?.activeBackend = nil machines[id]?.state = .failed machines[id]?.lastError = "\(error)" lock.unlock() @@ -2554,6 +2628,7 @@ public final class MachineManager: @unchecked Sendable { entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s" let admissionPlan = entry.activeResolvedPlan entry.activeResolvedPlan = nil + entry.activeBackend = nil self.machines[id] = entry self.lock.unlock() process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) @@ -2587,6 +2662,7 @@ public final class MachineManager: @unchecked Sendable { entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = nil + entry.activeBackend = nil entry.state = .failed entry.lastError = "dory-vmm \(termination.description)" machines[machineID] = entry @@ -2648,6 +2724,12 @@ public final class MachineManager: @unchecked Sendable { try requireNoActivePlanningMutation(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + lock.lock() + let isDurablySuspended = machines[id]?.state == .suspended + lock.unlock() + if isDurablySuspended { + return try restoreSavedStateImplementation(id: id) + } let identity = try currentRuntimeIdentity(id: id) switch identity.mode { case .legacyCompatibility: @@ -2675,6 +2757,288 @@ public final class MachineManager: @unchecked Sendable { } } + public func suspend(id: String) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + try requireNoActivePlanningMutation(id: id) + let directMutation = try retainDirectWorkspaceMutationLock(id: id) + defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + guard entry.state == .running || entry.state == .paused, + let process = entry.process, + entry.activeBackend == .appleVirtualizationFramework, + let controlSocketPath = entry.handoff?.ready.controlSocketPath else { + lock.unlock() + throw MachineManagerError.persistence( + "durable suspend requires a running Apple Virtualization backend with VZ control" + ) + } + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan + let sourceState: DoryWorkspaceLifecycleState = entry.state == .paused ? .paused : .running + let authoritativeData = Self.readPrivateMetadata(path: machineConfigPath(id: id)) + lock.unlock() + guard let authoritativeData, + (try? JSONDecoder().decode(DoryMachineConfiguration.self, from: authoritativeData)) + == machine else { + throw MachineManagerError.persistence( + "machine metadata changed before saved-state suspension" + ) + } + let authority = try Self.savedStateIntentAuthority( + machine: machine, + runtimeIdentity: runtimeIdentity, + authoritativeConfigurationData: authoritativeData + ) + let lifecycle = try beginLifecycleSuspend( + machine: machine, + runtimeIdentity: runtimeIdentity, + sourceState: sourceState, + savedStateAuthority: authority + ) + let temporaryPath = try savedStateStore.temporaryStatePath(machineID: id) + var committed = false + var helperExited = false + do { + try advanceLifecycleToPublishing(lifecycle) + guard process.prepareForExpectedExit() else { + throw MachineManagerError.persistence( + "VMM helper cannot enter saved-state exit mode" + ) + } + do { + try vzLifecycleController.saveMachineState( + socketPath: controlSocketPath, + statePath: temporaryPath + ) + } catch { + if !process.isRunning + || process.waitForExpectedExit(timeout: 0.25) { + helperExited = true + } + process.cancelExpectedExit() + throw error + } + guard process.waitForExpectedExit(timeout: 30), + process.terminationStatus == 0 else { + process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) + helperExited = true + throw MachineManagerError.persistence( + "VMM helper did not exit cleanly after saving state" + ) + } + helperExited = true + let manifest = try savedStateStore.publish( + temporaryStatePath: temporaryPath, + machineID: id, + authoritativeConfigurationData: authoritativeData, + runtimeIdentity: runtimeIdentity + ) + lock.lock() + guard var current = machines[id], current.process === process, + current.configuration == machine, + current.runtimeIdentity == runtimeIdentity else { + lock.unlock() + throw MachineManagerError.persistence( + "machine authority changed while saved state was being published" + ) + } + current.process = nil + current.handoffServer?.stop() + current.handoffServer = nil + current.handoff = nil + current.launchID = nil + current.runtimeAddress = nil + current.currentBalloonTargetMB = nil + current.activeResolvedPlan = nil + current.activeBackend = nil + current.pendingRestoreStatePath = nil + current.state = .suspended + current.savedStateStatus = DoryMachineSavedStateStatus(manifest: manifest) + current.lastError = nil + machines[id] = current + lock.unlock() + committed = true + do { + try markResolvedAdmissionStopped(plan: admissionPlan) + } catch { + // The VZ payload and suspended machine state are already durable. Retaining an + // over-counted running lease is fail-safe; the next restore or daemon restart + // reconciles it before reserving CPU/RAM again. + lock.lock() + machines[id]?.lastError = + "saved state committed but resource admission requires reconciliation: \(error)" + lock.unlock() + } + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "saved state committed; suspension journal requires recovery" + ) + return status(id: id) ?? DoryMachineStatus(id: id, state: .suspended) + } catch { + _ = unlink(temporaryPath) + if committed { + activeLifecycleOperations.removeValue(forKey: id) + lifecycle.releaseLease() + } else { + if helperExited { + try? savedStateStore.remove(machineID: id) + lock.lock() + if var current = machines[id], + current.configuration == machine, + current.runtimeIdentity == runtimeIdentity { + current.process = nil + current.handoffServer?.stop() + current.handoffServer = nil + current.handoff = nil + current.launchID = nil + current.runtimeAddress = nil + current.currentBalloonTargetMB = nil + current.activeResolvedPlan = nil + current.activeBackend = nil + current.pendingRestoreStatePath = nil + current.savedStateStatus = nil + current.state = .failed + current.lastError = "saved-state suspension failed after the VMM exited: \(error)" + machines[id] = current + } + lock.unlock() + try? markResolvedAdmissionStopped(plan: admissionPlan) + } + failLifecycle(lifecycle, stepID: "suspend.failed") + } + throw error + } + } + + private func restoreSavedStateImplementation(id: String) throws -> DoryMachineStatus { + lock.lock() + guard var entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + guard entry.state == .suspended, entry.process == nil else { + lock.unlock() + throw MachineManagerError.persistence("machine \(id) is not durably suspended") + } + let machine = entry.configuration + let runtimeIdentity = entry.runtimeIdentity + lock.unlock() + + let durableIdentity = try currentDurableRuntimeIdentity(id: id) + guard durableIdentity == runtimeIdentity, + let authoritativeData = Self.readPrivateMetadata(path: machineConfigPath(id: id)) else { + throw MachineManagerError.persistence( + "saved-state runtime or configuration authority changed" + ) + } + let manifest: DoryMachineSavedStateManifest + switch savedStateStore.inspect( + machineID: id, + authoritativeConfigurationData: authoritativeData, + runtimeIdentity: runtimeIdentity + ) { + case .valid(let accepted): manifest = accepted + case .absent: + throw MachineManagerError.persistence("saved-state payload is missing") + case .invalid(let detail): + throw MachineManagerError.persistence(detail) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let authority = DoryWorkspaceSnapshotAuthority( + descriptorSHA256: Self.sha256(data: try encoder.encode(manifest)), + artifactEvidenceSHA256: manifest.stateFileSHA256 + ) + let lifecycle = try beginLifecycleOperation( + kind: .restoring, + source: lifecycleCondition( + machine: machine, + state: .suspended, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .running, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: DoryWorkspaceLifecycleOperation.savedStateResourceID, + targetSnapshotAuthority: authority, + readiness: true + ) + do { + try advanceLifecycleToPublishing(lifecycle) + try refreshResolvedAdmissionForStartIfNeeded(id: id) + lock.lock() + guard entry.configuration == machine, + entry.runtimeIdentity == runtimeIdentity, + machines[id]?.state == .suspended else { + lock.unlock() + throw MachineManagerError.persistence( + "machine changed before saved-state restoration" + ) + } + entry.pendingRestoreStatePath = savedStateStore.statePath(machineID: id) + machines[id] = entry + lock.unlock() + + let running = try startAndWaitUntilReady(id: id, journalLifecycle: false) + guard running.state == .running else { + throw MachineManagerError.persistence( + "saved-state restore did not reach backend readiness" + ) + } + try savedStateStore.remove(machineID: id) + lock.lock() + machines[id]?.pendingRestoreStatePath = nil + machines[id]?.savedStateStatus = nil + lock.unlock() + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "restored machine is running with an unfinished saved-state journal" + ) + return status(id: id) ?? running + } catch { + lock.lock() + let active = machines[id]?.process?.isRunningOrRestarting == true + lock.unlock() + if active { + _ = try? stopImplementation(id: id, journalLifecycle: false) + } + let savedStateIsValid: Bool + if case .valid = savedStateStore.inspect( + machineID: id, + authoritativeConfigurationData: authoritativeData, + runtimeIdentity: runtimeIdentity + ) { + savedStateIsValid = true + } else { + savedStateIsValid = false + } + lock.lock() + if savedStateIsValid, var current = machines[id] { + current.state = .suspended + current.pendingRestoreStatePath = nil + machines[id] = current + } else if var current = machines[id] { + current.state = .failed + current.pendingRestoreStatePath = nil + current.savedStateStatus = nil + current.lastError = "saved-state restoration failed and its authority is invalid" + machines[id] = current + } + lock.unlock() + failLifecycle(lifecycle, stepID: "saved-state-restore.failed") + throw error + } + } + public func restart(id: String) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } @@ -2749,14 +3113,22 @@ public final class MachineManager: @unchecked Sendable { : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } - guard process.suspend() else { + let usedVZPause = entry.activeBackend == .appleVirtualizationFramework + && entry.handoff?.ready.controlSocketPath != nil + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZPause { + try vzLifecycleController.pause(socketPath: socketPath) + } else if !process.suspend() { throw MachineManagerError.persistence("could not pause machine \(id)") } lock.lock() guard var current = machines[id], current.process === process, current.state == .running else { lock.unlock() - _ = process.resume() + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZPause { + try? vzLifecycleController.resume(socketPath: socketPath) + } else { + _ = process.resume() + } throw MachineManagerError.persistence( "machine \(id) changed while pause was being committed" ) @@ -2799,14 +3171,22 @@ public final class MachineManager: @unchecked Sendable { : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } - guard process.resume() else { + let usedVZResume = entry.activeBackend == .appleVirtualizationFramework + && entry.handoff?.ready.controlSocketPath != nil + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZResume { + try vzLifecycleController.resume(socketPath: socketPath) + } else if !process.resume() { throw MachineManagerError.persistence("could not resume machine \(id)") } lock.lock() guard var current = machines[id], current.process === process, current.state == .paused else { lock.unlock() - _ = process.suspend() + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZResume { + try? vzLifecycleController.pause(socketPath: socketPath) + } else { + _ = process.suspend() + } throw MachineManagerError.persistence( "machine \(id) changed while resume was being committed" ) @@ -2839,13 +3219,14 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.unknownMachine(id) } let sourceState = lifecycleState(for: entry.state) + let wasSuspended = entry.state == .suspended let wasActive = [.starting, .running, .paused].contains(entry.state) && entry.process != nil let machine = entry.configuration let runtimeIdentity = entry.runtimeIdentity let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan lock.unlock() - let lifecycle = try journalLifecycle && wasActive + let lifecycle = try journalLifecycle && (wasActive || wasSuspended) ? beginLifecycleStop( machine: machine, runtimeIdentity: runtimeIdentity, @@ -2855,6 +3236,12 @@ public final class MachineManager: @unchecked Sendable { var stopCommitted = false do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + // Stopping a suspended VM means discarding its same-host execution state and + // returning to a cold-stopped machine. Retire that authority before publishing the + // stopped state so a failed removal leaves the machine truthfully suspended. + if wasSuspended { + try savedStateStore.remove(machineID: id) + } lock.lock() guard let current = machines[id] else { lock.unlock() @@ -2870,6 +3257,10 @@ public final class MachineManager: @unchecked Sendable { entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = nil + entry.activeBackend = nil + if wasSuspended { + entry.savedStateStatus = nil + } entry.state = .stopped machines[id] = entry lock.unlock() @@ -2922,6 +3313,7 @@ public final class MachineManager: @unchecked Sendable { ) } for id in machines.keys { + if machines[id]?.state == .suspended { continue } machines[id]?.process = nil machines[id]?.handoffServer = nil machines[id]?.handoff = nil @@ -2929,6 +3321,7 @@ public final class MachineManager: @unchecked Sendable { machines[id]?.runtimeAddress = nil machines[id]?.currentBalloonTargetMB = nil machines[id]?.activeResolvedPlan = nil + machines[id]?.activeBackend = nil machines[id]?.state = .stopped } lock.unlock() @@ -4365,7 +4758,8 @@ public final class MachineManager: @unchecked Sendable { ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: - entry.configuration.effectiveInstalledDesktopPayloadReceipt + entry.configuration.effectiveInstalledDesktopPayloadReceipt, + savedState: entry.savedStateStatus ) } return DoryMachineStatus( @@ -4403,7 +4797,8 @@ public final class MachineManager: @unchecked Sendable { ), runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: - entry.configuration.effectiveInstalledDesktopPayloadReceipt + entry.configuration.effectiveInstalledDesktopPayloadReceipt, + savedState: entry.savedStateStatus ) } @@ -4446,26 +4841,35 @@ public final class MachineManager: @unchecked Sendable { private func processConfiguration( for machine: DoryMachineConfiguration, handoffPath: String?, - resolvedLaunchBinding: MachineBackendLaunchBinding? - ) throws -> HvProcessConfiguration { + resolvedLaunchBinding: MachineBackendLaunchBinding?, + restoreStatePath: String? + ) throws -> (configuration: HvProcessConfiguration, + backend: DoryVirtualizationBackendIdentity) { let target = try processTarget( for: machine, resolvedLaunchBinding: resolvedLaunchBinding ) - return HvProcessConfiguration( + let process = HvProcessConfiguration( executablePath: target.executablePath, arguments: try processArguments( for: machine, handoffPath: handoffPath, baseArguments: target.baseArguments, acceleratedDesktop: target.acceleratedDesktop, - resolvedLaunchBinding: resolvedLaunchBinding + resolvedLaunchBinding: resolvedLaunchBinding, + restoreStatePath: restoreStatePath ), logPath: "\(configuration.logDirectory)/\(machine.id).log", restartPolicy: configuration.requiresReadyHandoff ? configuration.startupRestartPolicy : .none ) + return ( + process, + resolvedLaunchBinding?.backend.identity + ?? (target.acceleratedDesktop + ? .doryHypervisor : .appleVirtualizationFramework) + ) } private func processTarget( @@ -4560,7 +4964,8 @@ public final class MachineManager: @unchecked Sendable { handoffPath: String?, baseArguments: [String], acceleratedDesktop: Bool, - resolvedLaunchBinding: MachineBackendLaunchBinding? + resolvedLaunchBinding: MachineBackendLaunchBinding?, + restoreStatePath: String? ) throws -> [String] { guard configuration.passMachineArguments else { return baseArguments @@ -4600,6 +5005,15 @@ public final class MachineManager: @unchecked Sendable { if let handoffPath { arguments.append(contentsOf: ["--handoff-sock", handoffPath]) } + if let restoreStatePath { + guard !acceleratedDesktop, + restoreStatePath == savedStateStore.statePath(machineID: machine.id) else { + throw MachineManagerError.persistence( + "saved state cannot be restored by the selected backend or path" + ) + } + arguments.append(contentsOf: ["--restore-state", restoreStatePath]) + } if let resolvedLaunchBinding { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -6109,6 +6523,11 @@ public final class MachineManager: @unchecked Sendable { "machine \(id) must be resumed or stopped before this mutation" ) } + guard entry.state != .suspended else { + throw MachineManagerError.persistence( + "machine \(id) must be restored before this mutation" + ) + } let active = [.starting, .running].contains(entry.state) && entry.process != nil return (entry.configuration, active) } @@ -7300,6 +7719,7 @@ public final class MachineManager: @unchecked Sendable { entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil + entry.activeBackend = nil processToStop = entry.process break } @@ -7321,6 +7741,7 @@ public final class MachineManager: @unchecked Sendable { entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil + entry.activeBackend = nil processToStop = entry.process } machines[machineID] = entry @@ -7363,6 +7784,7 @@ public final class MachineManager: @unchecked Sendable { current.launchID = nil current.runtimeAddress = nil current.activeResolvedPlan = nil + current.activeBackend = nil machines[machineID] = current } lock.unlock() @@ -8091,7 +8513,8 @@ public final class MachineManager: @unchecked Sendable { private static func loadPersistedMachines( configuration: MachineManagerConfiguration, launchPolicy: DoryMachineLaunchPolicy, - runtimeIdentityStore: DoryMachineRuntimeIdentityStore + runtimeIdentityStore: DoryMachineRuntimeIdentityStore, + savedStateStore: DoryMachineSavedStateStore ) -> [String: MachineEntry] { let root = configuration.stateDirectory guard let ids = try? FileManager.default.contentsOfDirectory(atPath: root) else { @@ -8142,9 +8565,35 @@ public final class MachineManager: @unchecked Sendable { store: runtimeIdentityStore ) } + let savedState = savedStateStore.inspect( + machineID: id, + authoritativeConfigurationData: data, + runtimeIdentity: identity + ) + let recoveredState: DoryMachineState + let savedStateError: String? + switch savedState { + case .absent: + recoveredState = .stopped + savedStateError = nil + case .valid: + recoveredState = .suspended + savedStateError = nil + case .invalid(let detail): + recoveredState = .failed + savedStateError = detail + } + let recoveredStatus: DoryMachineSavedStateStatus? + if case .valid(let manifest) = savedState { + recoveredStatus = DoryMachineSavedStateStatus(manifest: manifest) + } else { + recoveredStatus = nil + } loaded[id] = MachineEntry( configuration: machine, - state: .stopped, + state: recoveredState, + lastError: savedStateError, + savedStateStatus: recoveredStatus, runtimeIdentity: identity ) } @@ -8740,6 +9189,7 @@ public final class MachineManager: @unchecked Sendable { switch state { case .starting, .running: .running case .paused: .paused + case .suspended: .suspended case .created, .stopped: .stopped case .failed: .failed } @@ -8931,6 +9381,54 @@ public final class MachineManager: @unchecked Sendable { ) } + private func beginLifecycleSuspend( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity, + sourceState: DoryWorkspaceLifecycleState, + savedStateAuthority: DoryWorkspaceSnapshotAuthority + ) throws -> MachineLifecycleJournalContext { + try beginLifecycleOperation( + kind: .suspending, + source: lifecycleCondition( + machine: machine, + state: sourceState, + runtimeIdentity: runtimeIdentity + ), + target: lifecycleCondition( + machine: machine, + state: .suspended, + runtimeIdentity: runtimeIdentity + ), + targetResourceID: DoryMachineSavedStateStore.directoryName, + targetSnapshotAuthority: savedStateAuthority, + readiness: false + ) + } + + private static func savedStateIntentAuthority( + machine: DoryMachineConfiguration, + runtimeIdentity: DoryMachineRuntimeIdentity, + authoritativeConfigurationData: Data + ) throws -> DoryWorkspaceSnapshotAuthority { + let host = DoryInstallerHostRuntime.current + let intent = MachineSavedStateIntentAuthority( + machineID: machine.id, + configurationSHA256: Self.sha256(data: authoritativeConfigurationData), + runtimeIdentity: runtimeIdentity, + hostHardwareModel: host.hardwareModel, + hostOperatingSystemBuild: host.operatingSystemBuild, + backend: .appleVirtualizationFramework + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let descriptor = try encoder.encode(intent) + let runtime = try encoder.encode(runtimeIdentity) + return DoryWorkspaceSnapshotAuthority( + descriptorSHA256: Self.sha256(data: descriptor), + artifactEvidenceSHA256: Self.sha256(data: runtime) + ) + } + private func beginLifecycleSnapshot( machine: DoryMachineConfiguration, runtimeIdentity: DoryMachineRuntimeIdentity, @@ -9012,7 +9510,7 @@ public final class MachineManager: @unchecked Sendable { readiness: Bool ) throws -> MachineLifecycleJournalContext { let machineID = source.workspaceID - if kind == .snapshotting || kind == .restoring { + if kind == .snapshotting || kind == .suspending || kind == .restoring { guard targetSnapshotAuthority != nil else { throw MachineManagerError.persistence( "snapshot lifecycle operation is missing immutable artifact authority" @@ -9309,6 +9807,11 @@ public final class MachineManager: @unchecked Sendable { machineID: id, configuration: configuration ) { + if operation.source.state == .suspended { + try DoryMachineSavedStateStore( + root: configuration.stateDirectory + ).remove(machineID: id) + } try completeRecoveredLifecycle(lease) diagnostics[id] = "interrupted stop completed during daemon recovery" } else { @@ -9343,6 +9846,23 @@ public final class MachineManager: @unchecked Sendable { diagnostics[id] = "interrupted snapshot was rolled back" } case .restoring: + if operation.targetResourceID + == DoryWorkspaceLifecycleOperation.savedStateResourceID { + if recoveredSavedState( + machineID: id, + operation: operation, + configuration: configuration + ) { + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = + "interrupted saved-state restore returned to suspended" + } else { + try failRecoveredLifecycle(lease, rolledBack: false) + diagnostics[id] = + "interrupted saved-state restore requires repair" + } + break + } if recoveryState.phase.indexForMachineLifecycle >= DoryOperationPhase.validating.indexForMachineLifecycle { if let snapshot = recoveredSnapshot( @@ -9420,7 +9940,24 @@ public final class MachineManager: @unchecked Sendable { try failRecoveredLifecycle(lease, rolledBack: false) diagnostics[id] = "interrupted resume was recovered as stopped after helper cleanup" - case .importing, .provisioning, .suspending, .cloning, .updating, .repairing: + case .suspending: + if recoveredSavedState( + machineID: id, + operation: operation, + configuration: configuration + ) { + try completeRecoveredLifecycle(lease) + diagnostics[id] = + "published saved state completed during daemon recovery" + } else { + try? DoryMachineSavedStateStore( + root: configuration.stateDirectory + ).remove(machineID: id) + try failRecoveredLifecycle(lease, rolledBack: true) + diagnostics[id] = + "interrupted saved-state suspension was rolled back" + } + case .importing, .provisioning, .cloning, .updating, .repairing: try failRecoveredLifecycle(lease, rolledBack: false) diagnostics[id] = "unsupported interrupted lifecycle mutation requires repair" } @@ -9434,6 +9971,66 @@ public final class MachineManager: @unchecked Sendable { return diagnostics } + private static func recoveredSavedState( + machineID: String, + operation: DoryWorkspaceLifecycleOperation, + configuration: MachineManagerConfiguration + ) -> Bool { + guard operation.targetResourceID + == DoryWorkspaceLifecycleOperation.savedStateResourceID, + let expectedAuthority = operation.targetSnapshotAuthority, + let data = readPrivateMetadata( + path: "\(configuration.stateDirectory)/\(machineID)/machine.json" + ), + let machine = try? JSONDecoder().decode( + DoryMachineConfiguration.self, + from: data + ), machine.id == machineID, + let runtime = operation.target.runtime else { + return false + } + let identity: DoryMachineRuntimeIdentity? + switch runtime.authorizationState { + case .legacyCompatibility: + identity = .legacyCompatibility( + virtualHardwareABIVersion: runtime.virtualHardwareABIVersion + ) + case .resolvedPlan: + identity = try? DoryMachineRuntimeIdentityStore( + root: configuration.stateDirectory + ).readIfPresent(machineID: machineID, authoritativeLegacyData: data) + case .requiresReplanning: + identity = nil + } + guard let identity else { return false } + let inspection = DoryMachineSavedStateStore( + root: configuration.stateDirectory + ).inspect( + machineID: machineID, + authoritativeConfigurationData: data, + runtimeIdentity: identity + ) + guard case .valid(let manifest) = inspection else { return false } + switch operation.kind { + case .suspending: + return (try? savedStateIntentAuthority( + machine: machine, + runtimeIdentity: identity, + authoritativeConfigurationData: data + )) == expectedAuthority + case .restoring: + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let manifestData = try? encoder.encode(manifest) else { return false } + return DoryWorkspaceSnapshotAuthority( + descriptorSHA256: sha256(data: manifestData), + artifactEvidenceSHA256: manifest.stateFileSHA256 + ) == expectedAuthority + default: + return false + } + } + private static func lifecycleConfigurationMatches( _ authority: DoryWorkspaceConfigurationAuthority?, machineID: String, @@ -10401,6 +10998,15 @@ private struct PendingResolvedMachineStart { var shareAuthorities: [DoryMachineShareRuntimeAuthority] } +private struct MachineSavedStateIntentAuthority: Codable { + var machineID: String + var configurationSHA256: String + var runtimeIdentity: DoryMachineRuntimeIdentity + var hostHardwareModel: String + var hostOperatingSystemBuild: String + var backend: DoryVirtualizationBackendIdentity +} + private struct MachineEntry { var configuration: DoryMachineConfiguration var state: DoryMachineState @@ -10412,6 +11018,9 @@ private struct MachineEntry { var currentBalloonTargetMB: UInt64? var lastError: String? var activeResolvedPlan: DoryResolvedMachinePlan? + var activeBackend: DoryVirtualizationBackendIdentity? + var pendingRestoreStatePath: String? + var savedStateStatus: DoryMachineSavedStateStatus? var runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion diff --git a/dory-core-swift/Sources/DorydKit/VmmControl.swift b/dory-core-swift/Sources/DorydKit/VmmControl.swift index 93521c68..a9d2c255 100644 --- a/dory-core-swift/Sources/DorydKit/VmmControl.swift +++ b/dory-core-swift/Sources/DorydKit/VmmControl.swift @@ -4,15 +4,24 @@ import Foundation public struct VmmControlRequest: Sendable, Equatable, Codable { public var command: String public var targetMB: UInt64? + public var statePath: String? - public init(command: String, targetMB: UInt64? = nil) { + public init(command: String, targetMB: UInt64? = nil, statePath: String? = nil) { self.command = command self.targetMB = targetMB + self.statePath = statePath } public static func setBalloonTarget(_ targetMB: UInt64) -> VmmControlRequest { VmmControlRequest(command: "setBalloonTarget", targetMB: targetMB) } + + public static let pauseMachine = VmmControlRequest(command: "pauseMachine") + public static let resumeMachine = VmmControlRequest(command: "resumeMachine") + + public static func saveMachineState(to statePath: String) -> VmmControlRequest { + VmmControlRequest(command: "saveMachineState", statePath: statePath) + } } public struct VmmControlResponse: Sendable, Equatable, Codable { @@ -54,6 +63,48 @@ public protocol MachineBalloonControlling: Sendable { func setBalloonTarget(socketPath: String, targetMB: UInt64) throws } +/// Daemon-side client for lifecycle operations that must execute inside the VZ helper process. +/// Raw-HV helpers intentionally do not expose this socket and therefore cannot claim saved-state +/// support. Paths are daemon-owned private workspace paths, never client input. +public protocol MachineVZLifecycleControlling: Sendable { + func pause(socketPath: String) throws + func resume(socketPath: String) throws + func saveMachineState(socketPath: String, statePath: String) throws +} + +public struct UnixMachineVZLifecycleController: MachineVZLifecycleControlling { + public init() {} + + public func pause(socketPath: String) throws { + try requireAccepted(.pauseMachine, socketPath: socketPath) + } + + public func resume(socketPath: String) throws { + try requireAccepted(.resumeMachine, socketPath: socketPath) + } + + public func saveMachineState(socketPath: String, statePath: String) throws { + try requireAccepted( + .saveMachineState(to: statePath), + socketPath: socketPath, + timeoutSeconds: 10 * 60 + ) + } + + private func requireAccepted( + _ request: VmmControlRequest, + socketPath: String, + timeoutSeconds: TimeInterval = 5 + ) throws { + let response = try VmmControlClient.send( + socketPath: socketPath, + request: request, + timeoutSeconds: timeoutSeconds + ) + guard response.ok else { throw VmmControlError.rejected(response.message) } + } +} + public struct UnixMachineBalloonController: MachineBalloonControlling { public init() {} @@ -69,15 +120,17 @@ public struct UnixMachineBalloonController: MachineBalloonControlling { } public enum VmmControlClient { - private static let socketTimeoutSeconds: TimeInterval = 5 - - public static func send(socketPath: String, request: VmmControlRequest) throws -> VmmControlResponse { + public static func send( + socketPath: String, + request: VmmControlRequest, + timeoutSeconds: TimeInterval = 5 + ) throws -> VmmControlResponse { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw VmmControlError.syscall("socket", errno) } defer { close(fd) } // Bound write/read so a wedged dory-vmm can't block the reconcile thread forever. - try setSocketTimeouts(fd: fd, seconds: socketTimeoutSeconds) + try setSocketTimeouts(fd: fd, seconds: timeoutSeconds) var address = try unixAddress(path: socketPath) let connected = withUnsafePointer(to: &address) { pointer in diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 69dd45cf..9f94ab4f 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -193,7 +193,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine stats NAME dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] - dorydctl [global] machine start|stop|pause|resume|restart|delete NAME + dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE @@ -1124,7 +1124,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1234,6 +1234,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { case "pause": let name = try cursor.take("usage: dorydctl machine pause NAME") try emitJSON(try client.statusCommand { $0.machinePause(name, reply: $1) }) + case "suspend": + let name = try cursor.take("usage: dorydctl machine suspend NAME") + try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { + $0.machineSuspend(name, reply: $1) + }) case "resume": let name = try cursor.take("usage: dorydctl machine resume NAME") try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) }) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift index c8ecd5e3..4f1a7ce6 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift @@ -213,6 +213,60 @@ struct DoryWorkspaceLifecycleOperationTests { #expect(deleteDefined.validate().isEmpty) } + @Test("durable suspend and same-plan saved-state restore bind exact content authority") + func durableSavedStateContract() { + let resolved = resolvedCondition() + let authority = snapshotAuthority() + var suspend = DoryWorkspaceLifecycleOperation( + kind: .suspending, + source: condition(.running, resolved: resolved), + target: condition(.suspended, resolved: resolved), + targetResourceID: DoryWorkspaceLifecycleOperation.savedStateResourceID, + targetSnapshotAuthority: authority, + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("save-machine-state", 50_000)], + cancellationPolicy: .rollbackRequired, + recovery: .init(disposition: .rollback, stepIDs: ["save-machine-state"]) + ) + #expect(suspend.validate().isEmpty) + + suspend.targetSnapshotAuthority = nil + #expect(has(.invalidCondition, "targetSnapshotAuthority", suspend)) + + var restore = DoryWorkspaceLifecycleOperation( + kind: .restoring, + source: condition(.suspended, resolved: resolved), + target: condition(.running, resolved: resolved), + targetResourceID: DoryWorkspaceLifecycleOperation.savedStateResourceID, + targetSnapshotAuthority: authority, + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("restore-machine-state", 50_000)], + readinessGates: [ + .init(kind: .backendRunning, deadlineOffsetMilliseconds: 55_000), + ], + cancellationPolicy: .rollbackRequired, + recovery: .init(disposition: .rollback, stepIDs: ["restore-machine-state"]) + ) + #expect(restore.validate().isEmpty) + + restore.target.runtime?.runtimeIdentityDigest = String(repeating: "8", count: 64) + #expect(has(.invalidCondition, "target.runtime", restore)) + + let discard = DoryWorkspaceLifecycleOperation( + kind: .stopping, + source: condition(.suspended, resolved: resolved), + target: condition(.stopped, resolved: resolved), + createdAtUnixMilliseconds: 1_700_000_000_000, + deadlineUnixMilliseconds: 1_700_000_060_000, + steps: [step("discard-saved-state", 50_000)], + cancellationPolicy: .rollbackRequired, + recovery: .init(disposition: .rollback, stepIDs: ["discard-saved-state"]) + ) + #expect(discard.validate().isEmpty) + } + @Test("operation identifiers reject path secret and control-like values") func identifierSafety() { var operation = makeOperation() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineSavedStateTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineSavedStateTests.swift new file mode 100644 index 00000000..7304e8a5 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineSavedStateTests.swift @@ -0,0 +1,201 @@ +import Darwin +import Foundation +import Testing +@testable import DorydKit + +@Suite("Durable machine saved-state authority", .serialized) +struct DoryMachineSavedStateTests { + @Test("published state is exact host configuration runtime and content authority") + func publishInspectAndTamper() throws { + try withStore { store, root in + let configuration = Data("authoritative-machine-json".utf8) + let runtime = DoryMachineRuntimeIdentity.legacyCompatibility( + virtualHardwareABIVersion: 1 + ) + let temporary = try store.temporaryStatePath(machineID: "dev.one") + try writePrivate(Data("saved-vz-state".utf8), to: temporary) + + let manifest = try store.publish( + temporaryStatePath: temporary, + machineID: "dev.one", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + #expect(manifest.isStructurallyValid) + #expect(manifest.machineID == "dev.one") + #expect(manifest.backend == .appleVirtualizationFramework) + #expect(manifest.stateFileByteCount == UInt64(Data("saved-vz-state".utf8).count)) + #expect(manifest.createdAtUnixMilliseconds == 1_700_000_000_000) + #expect(store.inspect( + machineID: "dev.one", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) == .valid(manifest)) + + guard case .invalid = store.inspect( + machineID: "dev.one", + authoritativeConfigurationData: Data("changed-machine-json".utf8), + runtimeIdentity: runtime + ) else { + Issue.record("configuration drift must invalidate the saved state") + return + } + guard case .invalid = store.inspect( + machineID: "dev.one", + authoritativeConfigurationData: configuration, + runtimeIdentity: .requiresReplanning( + virtualHardwareABIVersion: 1, + reason: .definitionChanged + ) + ) else { + Issue.record("runtime identity drift must invalidate the saved state") + return + } + + let statePath = store.statePath(machineID: "dev.one") + try writeReplacingPrivate(Data("tampered-state".utf8), to: statePath) + guard case .invalid = store.inspect( + machineID: "dev.one", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) else { + Issue.record("content tamper must invalidate the saved state") + return + } + + try store.remove(machineID: "dev.one") + #expect(store.inspect( + machineID: "dev.one", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) == .absent) + #expect(!FileManager.default.fileExists( + atPath: root + "/dev.one/" + DoryMachineSavedStateStore.directoryName + )) + } + } + + @Test("store rejects unsafe IDs and non-private helper output") + func rejectsUnsafeInputs() throws { + try withStore(machineID: "dev") { store, _ in + #expect(throws: DoryMachineSavedStateError.self) { + _ = try store.temporaryStatePath(machineID: "../dev") + } + #expect(throws: DoryMachineSavedStateError.self) { + _ = try store.temporaryStatePath(machineID: ".hidden") + } + + let temporary = try store.temporaryStatePath(machineID: "dev") + _ = FileManager.default.createFile( + atPath: temporary, + contents: Data("saved-state".utf8), + attributes: [.posixPermissions: 0o644] + ) + #expect(throws: DoryMachineSavedStateError.self) { + _ = try store.publish( + temporaryStatePath: temporary, + machineID: "dev", + authoritativeConfigurationData: Data("machine".utf8), + runtimeIdentity: .legacyCompatibility( + virtualHardwareABIVersion: 1 + ) + ) + } + } + } + + @Test("inspection rejects hard-linked files and substituted state directories") + func rejectsLinkSubstitution() throws { + let configuration = Data("machine-authority".utf8) + let runtime = DoryMachineRuntimeIdentity.legacyCompatibility( + virtualHardwareABIVersion: 1 + ) + try withStore(machineID: "linked") { store, root in + let temporary = try store.temporaryStatePath(machineID: "linked") + try writePrivate(Data("saved-state".utf8), to: temporary) + _ = try store.publish( + temporaryStatePath: temporary, + machineID: "linked", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) + let statePath = store.statePath(machineID: "linked") + let foreignLink = root + "/linked/foreign-state-link" + #expect(link(statePath, foreignLink) == 0) + guard case .invalid = store.inspect( + machineID: "linked", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) else { + Issue.record("a hard-linked state payload must fail closed") + return + } + } + + try withStore(machineID: "redirected") { store, root in + let temporary = try store.temporaryStatePath(machineID: "redirected") + try writePrivate(Data("saved-state".utf8), to: temporary) + _ = try store.publish( + temporaryStatePath: temporary, + machineID: "redirected", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) + let directory = root + "/redirected/" + + DoryMachineSavedStateStore.directoryName + let displaced = root + "/redirected/displaced-state" + #expect(rename(directory, displaced) == 0) + #expect(symlink(displaced, directory) == 0) + guard case .invalid = store.inspect( + machineID: "redirected", + authoritativeConfigurationData: configuration, + runtimeIdentity: runtime + ) else { + Issue.record("a symlinked saved-state directory must fail closed") + return + } + } + } + + private func withStore( + machineID: String = "dev.one", + _ body: (DoryMachineSavedStateStore, String) throws -> Void + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-saved-state-\(UUID().uuidString)").path + try FileManager.default.createDirectory( + atPath: root + "/" + machineID, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + defer { try? FileManager.default.removeItem(atPath: root) } + try body(DoryMachineSavedStateStore(root: root), root) + } + + private func writePrivate(_ data: Data, to path: String) throws { + guard FileManager.default.createFile( + atPath: path, + contents: data, + attributes: [.posixPermissions: 0o600] + ) else { + throw CocoaError(.fileWriteUnknown) + } + } + + private func writeReplacingPrivate(_ data: Data, to path: String) throws { + let fd = open(path, O_WRONLY | O_TRUNC | O_CLOEXEC | O_NOFOLLOW) + guard fd >= 0 else { throw CocoaError(.fileWriteUnknown) } + defer { close(fd) } + try data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + var offset = 0 + while offset < raw.count { + let written = write(fd, base.advanced(by: offset), raw.count - offset) + guard written > 0 else { throw CocoaError(.fileWriteUnknown) } + offset += written + } + } + guard fsync(fd) == 0 else { throw CocoaError(.fileWriteUnknown) } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 2c6b63af..b5b54f72 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -210,6 +210,7 @@ final class DoryVMMKitTests: XCTestCase { "--agent-sock", "/tmp/agent.sock", "--shell-sock", "/tmp/shell.sock", "--control-sock", "/tmp/control.sock", + "--restore-state", "/tmp/dory-machine-dev/saved-state-v1/state.bin", "--share", "src=/tmp/src:/workspace/src:ro", "--env", "APP_ENV=dev", ]) @@ -236,6 +237,10 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(arguments.agentSocketPath, "/tmp/agent.sock") XCTAssertEqual(arguments.shellSocketPath, "/tmp/shell.sock") XCTAssertEqual(arguments.controlSocketPath, "/tmp/control.sock") + XCTAssertEqual( + arguments.restoreStatePath, + "/tmp/dory-machine-dev/saved-state-v1/state.bin" + ) XCTAssertEqual(arguments.shares, [ DoryMachineShareConfiguration(tag: "src", hostPath: "/tmp/src", guestPath: "/workspace/src", readOnly: true), ]) diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index a56f54ab..90ddb148 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1192,6 +1192,15 @@ final class DorydServiceTests: XCTestCase { } wait(for: [resume], timeout: 5) + let suspend = expectation(description: "machineSuspend fail-closed reply") + proxy.machineSuspend("dev") { ok, body, message in + XCTAssertFalse(ok) + XCTAssertTrue(message.contains("durable suspend requires"), message) + XCTAssertEqual(body.count, 0) + suspend.fulfill() + } + wait(for: [suspend], timeout: 5) + let restart = expectation(description: "machineRestart reply") proxy.machineRestart("dev") { ok, body, message in XCTAssertTrue(ok, message) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 3986b42f..13a5c7d4 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -181,11 +181,14 @@ struct MachineManagerResolvedPlanIntegrationTests { _ = try manager.start(id: "dev") #expect(starter.count == 1) let deadline = Date().addingTimeInterval(2) - while !FileManager.default.fileExists(atPath: capture), Date() < deadline { + var arguments: [String] = [] + while Date() < deadline { + if let contents = try? String(contentsOfFile: capture, encoding: .utf8) { + arguments = contents.split(separator: "\n").map(String.init) + if arguments.contains("--resolved-devices") { break } + } Thread.sleep(forTimeInterval: 0.01) } - let arguments = try String(contentsOfFile: capture, encoding: .utf8) - .split(separator: "\n").map(String.init) func value(after flag: String) throws -> String { let index = try #require(arguments.firstIndex(of: flag)) return arguments[index + 1] diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerSavedStateIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerSavedStateIntegrationTests.swift new file mode 100644 index 00000000..8cf49cf6 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerSavedStateIntegrationTests.swift @@ -0,0 +1,415 @@ +import Darwin +import Foundation +import XCTest +@testable import DorydKit + +final class MachineManagerSavedStateIntegrationTests: XCTestCase { + func testVZSavedStateSurvivesRestartAndRestoresExactlyOnce() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + + var firstManager: MachineManager? = fixture.manager(controller: controller) + _ = try startAndAcceptHandoff(try XCTUnwrap(firstManager), fixture: fixture) + let suspended = try XCTUnwrap(firstManager).suspend(id: fixture.machineID) + XCTAssertEqual(suspended.state, .suspended) + XCTAssertEqual(controller.saveCount, 1) + XCTAssertNotNil(suspended.savedState) + XCTAssertNil(suspended.pid) + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.savedStatePath + )) + firstManager = nil + + let recovered = fixture.manager(controller: controller) + let recoveredStatus = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(recoveredStatus.state, .suspended) + XCTAssertEqual(recoveredStatus.savedState, suspended.savedState) + + try? FileManager.default.removeItem(atPath: fixture.exitMarker) + let result = SavedStateLockedResult() + DispatchQueue.global(qos: .userInitiated).async { + result.store(Result { try recovered.resume(id: fixture.machineID) }) + } + let restoring = try waitForStatus(recovered, id: fixture.machineID) { + $0.state == .starting && $0.handoffSocketPath != nil + } + try sendVmmHandoff( + path: try XCTUnwrap(restoring.handoffSocketPath), + ready: VmmReadyMessage( + machineID: fixture.machineID, + controlSocketPath: fixture.controlSocket + ), + fileDescriptors: [] + ) + let restored = try waitForResult(result).get() + XCTAssertEqual(restored.state, .running) + XCTAssertNil(restored.savedState) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.savedStateDirectory + )) + let arguments = try String(contentsOfFile: fixture.argumentsLog, encoding: .utf8) + .split(separator: "\n").map(String.init) + let restoreIndex = try XCTUnwrap(arguments.firstIndex(of: "--restore-state")) + XCTAssertEqual(arguments[restoreIndex + 1], fixture.savedStatePath) + + _ = try recovered.stop(id: fixture.machineID) + try recovered.delete(id: fixture.machineID) + } + + func testTamperedSavedStateFailsClosedAfterDaemonRestart() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + var manager: MachineManager? = fixture.manager(controller: controller) + _ = try startAndAcceptHandoff(try XCTUnwrap(manager), fixture: fixture) + _ = try XCTUnwrap(manager).suspend(id: fixture.machineID) + manager = nil + + let fd = open(fixture.savedStatePath, O_WRONLY | O_TRUNC | O_CLOEXEC | O_NOFOLLOW) + XCTAssertGreaterThanOrEqual(fd, 0) + if fd >= 0 { + defer { close(fd) } + let tampered = Data("tampered-vz-state".utf8) + tampered.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + XCTAssertEqual(write(fd, base, raw.count), raw.count) + } + XCTAssertEqual(fsync(fd), 0) + } + + let recovered = fixture.manager(controller: controller) + let status = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(status.state, .failed) + XCTAssertNil(status.savedState) + XCTAssertTrue(status.lastError?.contains("saved-state") == true) + XCTAssertThrowsError(try recovered.start(id: fixture.machineID)) + try recovered.delete(id: fixture.machineID) + } + + func testRawHypervisorCannotClaimDurableSuspend() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + let manager = fixture.manager( + controller: controller, + acceleratedDesktopExecutablePath: fixture.executable + ) + try manager.create(DoryMachineConfiguration( + id: fixture.machineID, + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + displayMode: .desktop + )) + _ = try startAndAcceptHandoff(manager, fixture: fixture, create: false) + + XCTAssertThrowsError(try manager.suspend(id: fixture.machineID)) { error in + XCTAssertTrue("\(error)".contains("Apple Virtualization backend")) + } + XCTAssertEqual(controller.saveCount, 0) + XCTAssertEqual(manager.status(id: fixture.machineID)?.state, .running) + _ = try manager.stop(id: fixture.machineID) + try manager.delete(id: fixture.machineID) + } + + func testStoppingSuspendedMachineDiscardsSavedStateAndNextStartIsCold() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + let manager = fixture.manager(controller: controller) + _ = try startAndAcceptHandoff(manager, fixture: fixture) + _ = try manager.suspend(id: fixture.machineID) + + let stopped = try manager.stop(id: fixture.machineID) + XCTAssertEqual(stopped.state, .stopped) + XCTAssertNil(stopped.savedState) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.savedStateDirectory)) + + let restarted = try startAndAcceptHandoff(manager, fixture: fixture, create: false) + XCTAssertEqual(restarted.state, .running) + let arguments = try String(contentsOfFile: fixture.argumentsLog, encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertFalse(arguments.contains("--restore-state")) + + _ = try manager.stop(id: fixture.machineID) + try manager.delete(id: fixture.machineID) + } + + func testSavedStateMutationAtFinalPreSpawnBoundaryFailsClosed() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + let manager = fixture.manager(controller: controller) + _ = try startAndAcceptHandoff(manager, fixture: fixture) + _ = try manager.suspend(id: fixture.machineID) + manager.installShareAuthorityPreSpawnHookForTesting { + let fd = open( + fixture.savedStatePath, + O_WRONLY | O_TRUNC | O_CLOEXEC | O_NOFOLLOW + ) + guard fd >= 0 else { throw CocoaError(.fileWriteUnknown) } + defer { close(fd) } + let replacement = Data("changed-after-validation".utf8) + try replacement.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + guard write(fd, base, raw.count) == raw.count, fsync(fd) == 0 else { + throw CocoaError(.fileWriteUnknown) + } + } + } + + XCTAssertThrowsError(try manager.resume(id: fixture.machineID)) + let failed = try XCTUnwrap(manager.status(id: fixture.machineID)) + XCTAssertEqual(failed.state, .failed) + XCTAssertNil(failed.savedState) + let arguments = try String(contentsOfFile: fixture.argumentsLog, encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertFalse(arguments.contains("--restore-state")) + + try manager.delete(id: fixture.machineID) + } + + func testRejectedSaveCommandLeavesRunningHelperOwnedByMachine() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let manager = fixture.manager(controller: RejectingSavedStateController()) + let running = try startAndAcceptHandoff(manager, fixture: fixture) + XCTAssertEqual(running.state, .running) + XCTAssertNotNil(running.pid) + + XCTAssertThrowsError(try manager.suspend(id: fixture.machineID)) { error in + XCTAssertTrue("\(error)".contains("fixture rejected saved-state command")) + } + let retained = try XCTUnwrap(manager.status(id: fixture.machineID)) + XCTAssertEqual(retained.state, .running) + XCTAssertNotNil(retained.pid) + XCTAssertNil(retained.savedState) + + _ = try manager.stop(id: fixture.machineID) + try manager.delete(id: fixture.machineID) + } + + func testInterruptedSuspendedStopCompletesColdStateOnRestart() throws { + let fixture = try SavedStateMachineFixture(name: #function) + defer { fixture.remove() } + let controller = RecordingSavedStateController(exitMarker: fixture.exitMarker) + var manager: MachineManager? = fixture.manager(controller: controller) + _ = try startAndAcceptHandoff(try XCTUnwrap(manager), fixture: fixture) + _ = try XCTUnwrap(manager).suspend(id: fixture.machineID) + try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in + if point == .stopAfterProcessStop { throw MachineLifecycleInjectedCrash() } + } + + XCTAssertThrowsError(try XCTUnwrap(manager).stop(id: fixture.machineID)) { error in + XCTAssertTrue(error is MachineLifecycleInjectedCrash) + } + manager = nil + + let recovered = fixture.manager(controller: controller) + let status = try XCTUnwrap(recovered.status(id: fixture.machineID)) + XCTAssertEqual(status.state, .stopped) + XCTAssertNil(status.savedState) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.savedStateDirectory)) + try recovered.delete(id: fixture.machineID) + } + + private func startAndAcceptHandoff( + _ manager: MachineManager, + fixture: SavedStateMachineFixture, + create: Bool = true + ) throws -> DoryMachineStatus { + if create { + try manager.create(DoryMachineConfiguration( + id: fixture.machineID, + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath + )) + } + try? FileManager.default.removeItem(atPath: fixture.exitMarker) + let starting = try manager.start(id: fixture.machineID) + try sendVmmHandoff( + path: try XCTUnwrap(starting.handoffSocketPath), + ready: VmmReadyMessage( + machineID: fixture.machineID, + controlSocketPath: fixture.controlSocket + ), + fileDescriptors: [] + ) + return try waitForStatus(manager, id: fixture.machineID) { $0.state == .running } + } + + private func waitForStatus( + _ manager: MachineManager, + id: String, + timeout: TimeInterval = 5, + predicate: (DoryMachineStatus) -> Bool + ) throws -> DoryMachineStatus { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let status = manager.status(id: id), predicate(status) { return status } + Thread.sleep(forTimeInterval: 0.01) + } + XCTFail("timed out waiting for machine state") + throw SavedStateTestError.timeout + } + + private func waitForResult( + _ result: SavedStateLockedResult, + timeout: TimeInterval = 10 + ) throws -> Result { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let value = result.value { return value } + Thread.sleep(forTimeInterval: 0.01) + } + XCTFail("timed out waiting for saved-state restore") + throw SavedStateTestError.timeout + } +} + +private struct SavedStateMachineFixture { + let base: String + let machineID = "saved-vm" + let executable: String + let exitMarker: String + let argumentsLog: String + let controlSocket: String + + init(name: String) throws { + base = "/tmp/dory-saved-state-\(getpid())-\(name.hashValue.magnitude)-\(UInt32.random(in: 0...UInt32.max))" + executable = base + "/fake-vmm.sh" + exitMarker = base + "/helper-exit" + argumentsLog = base + "/arguments.log" + controlSocket = base + "/control.sock" + try FileManager.default.createDirectory( + atPath: base, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let script = """ + #!/bin/sh + printf '%s\\n' "$@" > '\(argumentsLog)' + while [ ! -f '\(exitMarker)' ]; do + sleep 0.01 + done + exit 0 + """ + guard FileManager.default.createFile( + atPath: executable, + contents: Data(script.utf8), + attributes: [.posixPermissions: 0o700] + ) else { + throw CocoaError(.fileWriteUnknown) + } + } + + var savedStateDirectory: String { + base + "/state/" + machineID + "/" + DoryMachineSavedStateStore.directoryName + } + + var savedStatePath: String { + savedStateDirectory + "/" + DoryMachineSavedStateManifest.stateFileName + } + + func manager( + controller: any MachineVZLifecycleControlling, + acceleratedDesktopExecutablePath: String? = nil + ) -> MachineManager { + MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: executable, + acceleratedDesktopExecutablePath: acceleratedDesktopExecutablePath, + stateDirectory: base + "/state", + runtimeDirectory: base + "/runtime", + lifecycleJournalHome: base + "/journal", + passMachineArguments: true, + requiresReadyHandoff: true, + handoffReadyTimeoutSeconds: 5, + desktopHandoffReadyTimeoutSeconds: 5, + startupRestartPolicy: .none + ), + vzLifecycleController: controller + ) + } + + func remove() { + try? FileManager.default.removeItem(atPath: base) + } +} + +private struct RejectingSavedStateController: MachineVZLifecycleControlling { + func pause(socketPath: String) throws {} + func resume(socketPath: String) throws {} + func saveMachineState(socketPath: String, statePath: String) throws { + throw MachineManagerError.persistence("fixture rejected saved-state command") + } +} + +private final class RecordingSavedStateController: MachineVZLifecycleControlling, + @unchecked Sendable { + private let lock = NSLock() + private let exitMarker: String + private var saves = 0 + + init(exitMarker: String) { + self.exitMarker = exitMarker + } + + var saveCount: Int { + lock.lock() + defer { lock.unlock() } + return saves + } + + func pause(socketPath: String) throws {} + func resume(socketPath: String) throws {} + + func saveMachineState(socketPath: String, statePath: String) throws { + let fd = open( + statePath, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0o600 + ) + guard fd >= 0 else { throw CocoaError(.fileWriteUnknown) } + let payload = Data("vz-saved-state".utf8) + defer { close(fd) } + try payload.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + guard write(fd, base, raw.count) == raw.count else { + throw CocoaError(.fileWriteUnknown) + } + } + guard fsync(fd) == 0 else { throw CocoaError(.fileWriteUnknown) } + lock.lock() + saves += 1 + lock.unlock() + guard FileManager.default.createFile( + atPath: exitMarker, + contents: Data(), + attributes: [.posixPermissions: 0o600] + ) else { + throw CocoaError(.fileWriteUnknown) + } + } +} + +private final class SavedStateLockedResult: @unchecked Sendable { + private let lock = NSLock() + private var stored: Result? + + var value: Result? { + lock.lock() + defer { lock.unlock() } + return stored + } + + func store(_ value: Result) { + lock.lock() + stored = value + lock.unlock() + } +} + +private enum SavedStateTestError: Error { + case timeout +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 1f1205a0..6234aa68 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -4014,6 +4014,7 @@ final class MachineManagerTests: XCTestCase { passMachineArguments: false, requiresReadyHandoff: true ), + vzLifecycleController: NoopMachineVZLifecycleController(), agentConnector: connector.connect(socketPath:) ) defer { @@ -4212,6 +4213,14 @@ final class MachineManagerTests: XCTestCase { } } +private struct NoopMachineVZLifecycleController: MachineVZLifecycleControlling { + func pause(socketPath: String) throws {} + func resume(socketPath: String) throws {} + func saveMachineState(socketPath: String, statePath: String) throws { + throw MachineManagerError.persistence("saved state is not available in this test") + } +} + private func sendSnapshotQuiesceHandoff(path: String, machineID: String) throws { try sendVmmHandoff( path: path, From de074d951428a980144442b94ecface2fca7f37d Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 16:23:11 +0000 Subject: [PATCH 217/338] fix(sync): keep manifest walk clippy-clean --- dory-core/sync/src/manifest.rs | 40 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/dory-core/sync/src/manifest.rs b/dory-core/sync/src/manifest.rs index e33d368e..dc0ee0d2 100644 --- a/dory-core/sync/src/manifest.rs +++ b/dory-core/sync/src/manifest.rs @@ -53,6 +53,11 @@ struct TreeUsage { bytes: u64, } +struct TreeBudget { + limits: Option, + usage: TreeUsage, +} + /// Walk `root` recursively and build a manifest of every regular file, with a content hash. Symlinks /// and special files are skipped (only regular files replicate). Entries are sorted by path so a /// host and remote produce byte-identical ordering and the reconciler diff is stable. @@ -105,7 +110,10 @@ fn walk_tree_impl( ) -> std::io::Result { let mut files = Vec::new(); let mut directories = Vec::new(); - let mut usage = TreeUsage::default(); + let mut budget = TreeBudget { + limits, + usage: TreeUsage::default(), + }; walk_into( root, root, @@ -113,8 +121,7 @@ fn walk_tree_impl( tolerate_not_found, &mut files, &mut directories, - limits, - &mut usage, + &mut budget, )?; files.sort_by(|a, b| a.path.cmp(&b.path)); directories.sort_by(|a, b| a.path.cmp(&b.path)); @@ -131,8 +138,7 @@ fn walk_into( tolerate_not_found: bool, files: &mut Vec, directories: &mut Vec, - limits: Option, - usage: &mut TreeUsage, + budget: &mut TreeBudget, ) -> std::io::Result<()> { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, @@ -164,11 +170,15 @@ fn walk_into( }; let file_type = meta.file_type(); if file_type.is_dir() { - usage.directories = usage + budget.usage.directories = budget + .usage .directories .checked_add(1) .ok_or_else(tree_limit_error)?; - if limits.is_some_and(|limits| usage.directories > limits.max_directories) { + if budget + .limits + .is_some_and(|limits| budget.usage.directories > limits.max_directories) + { return Err(tree_limit_error()); } directories.push(DirectoryEntry { @@ -183,17 +193,21 @@ fn walk_into( tolerate_not_found, files, directories, - limits, - usage, + budget, )?; } else if file_type.is_file() { - usage.files = usage.files.checked_add(1).ok_or_else(tree_limit_error)?; - usage.bytes = usage + budget.usage.files = budget + .usage + .files + .checked_add(1) + .ok_or_else(tree_limit_error)?; + budget.usage.bytes = budget + .usage .bytes .checked_add(meta.len()) .ok_or_else(tree_limit_error)?; - if limits.is_some_and(|limits| { - usage.files > limits.max_files || usage.bytes > limits.max_bytes + if budget.limits.is_some_and(|limits| { + budget.usage.files > limits.max_files || budget.usage.bytes > limits.max_bytes }) { return Err(tree_limit_error()); } From c67860dcd1355571f01e8c00ef3d4d12d4dfbf4e Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 18:44:47 +0000 Subject: [PATCH 218/338] feat(vm): preflight portable machine imports --- Dory/Models/AppStore.swift | 42 +++- Dory/Runtime/Doryd/DorydClient.swift | 198 +++++++++++++++++- DoryTests/DorydClientTests.swift | 87 ++++++++ docs/virtual-workspace-platform.md | 8 + ...onVirtualMachineProductionActivation.swift | 22 +- .../DoryMachineImportAssessment.swift | 127 +++++++++++ .../Sources/DorydKit/DorydControl.swift | 2 + .../Sources/DorydKit/DorydService.swift | 110 +++++++++- .../Sources/DorydKit/MachineManager.swift | 190 ++++++++++++++++- dory-core-swift/Sources/doryd/main.swift | 3 + dory-core-swift/Sources/dorydctl/main.swift | 25 ++- ...onVirtualMachineProductionTrustTests.swift | 4 + .../DorydKitTests/DorydServiceTests.swift | 35 +++- ...eManagerResolvedPlanIntegrationTests.swift | 22 ++ .../DorydKitTests/MachineManagerTests.swift | 70 +++++++ 15 files changed, 931 insertions(+), 14 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineImportAssessment.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 617ae7e1..bdb080ca 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -7050,7 +7050,47 @@ final class AppStore { Task { defer { busyMachines.remove(Self.importBusyKey) } do { - let snapshot = try await dorydClient.machineImportSnapshot(from: url.path) + let assessment = try await dorydClient.machineAssessSnapshotImport( + from: url.path + ) + appendMachineCreationLog( + "Verified complete archive \(assessment.contentID.prefix(12))… " + + "(\(assessment.architecture), ABI " + + "\(assessment.virtualHardwareABIVersion))." + ) + appendMachineCreationLog( + "Portability: \(assessment.disposition.rawValue)." + ) + let unavailableComponents = assessment.components.filter { + $0.availability != .available + } + if !unavailableComponents.isEmpty { + appendMachineCreationLog( + "Required components: " + unavailableComponents.map { + "\($0.componentIdentifier)@\($0.buildIdentifier) " + + "(\($0.availability.rawValue))" + }.joined(separator: ", ") + ) + } + switch assessment.disposition { + case .ready, .requiresReplanning: + break + case .requiresComponents: + throw DorydClientError.daemon( + "Import requires unavailable or different Dory components: " + + unavailableComponents.map(\.componentIdentifier) + .joined(separator: ", ") + ) + case .unavailable: + throw DorydClientError.daemon( + "This archive is not portable to the current host (" + + assessment.issues.joined(separator: ", ") + ")." + ) + } + let snapshot = try await dorydClient.machineImportSnapshot( + from: url.path, + expectedContentID: assessment.contentID + ) appendMachineCreationLog("Verified snapshot \(snapshot.id).") let newName: String do { diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index f824f2a1..10293de3 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -44,7 +44,9 @@ nonisolated protocol DorydControlXPC { func machineRestoreSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeleteSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, String) -> Void) func machineExportSnapshot(_ machineID: String, snapshotID: String, path: String, reply: @escaping (Bool, String) -> Void) + func machineAssessSnapshotImport(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineImportSnapshot(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineImportSnapshot(_ path: String, expectedContentID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) func machineBackupSet(_ schedule: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupRemove(_ machineID: String, reply: @escaping (Bool, String) -> Void) @@ -612,6 +614,17 @@ nonisolated private extension String { } } + var isSafeMachineIdentifier: Bool { + let bytes = Array(utf8) + guard (1...63).contains(bytes.count) else { return false } + return bytes.allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 45 || byte == 46 || byte == 95 + } + } + var isSafeComponentInstallationName: Bool { let bytes = Array(utf8) guard (1...255).contains(bytes.count), @@ -892,6 +905,43 @@ nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable { var restoredRunningState: Bool } +nonisolated enum DorydMachineImportDisposition: String, Sendable, Equatable { + case ready + case requiresComponents = "requires-components" + case requiresReplanning = "requires-replanning" + case unavailable +} + +nonisolated enum DorydMachineImportComponentAvailability: String, Sendable, Equatable { + case available + case mismatched + case missing +} + +nonisolated struct DorydMachineImportComponentAssessment: Sendable, Equatable { + var componentIdentifier: String + var buildIdentifier: String + var artifactSHA256: String + var availability: DorydMachineImportComponentAvailability +} + +nonisolated struct DorydMachineImportAssessment: Sendable, Equatable { + var schemaVersion: UInt16 + var contentID: String + var sourceMachineID: String + var sourceSnapshotID: String + var architecture: String + var bootMode: String + var diskSizeBytes: UInt64 + var virtualHardwareABIVersion: UInt16 + var sourceRuntimeMode: String + var sourceBackend: DoryVirtualizationBackendIdentity? + var portable: Bool + var disposition: DorydMachineImportDisposition + var issues: [String] + var components: [DorydMachineImportComponentAssessment] +} + nonisolated struct DorydMachineSnapshot: Sendable, Equatable { var id: String var machineID: String @@ -1846,14 +1896,41 @@ nonisolated final class DorydClient: @unchecked Sendable { } } - func machineImportSnapshot(from path: String) async throws -> DorydMachineSnapshot { + func machineAssessSnapshotImport( + from path: String + ) async throws -> DorydMachineImportAssessment { + try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineAssessSnapshotImport(path, reply: reply) + } decode: { + Self.machineImportAssessment(from: $0) + } + } + + func machineImportSnapshot( + from path: String, + expectedContentID: String + ) async throws -> DorydMachineSnapshot { try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineImportSnapshot(path, reply: reply) + proxy.machineImportSnapshot( + path, + expectedContentID: expectedContentID, + reply: reply + ) } decode: { Self.machineSnapshot(from: $0) } } + /// Compatibility entry for older call sites. New product flows assess first and use the + /// content-bound overload above. + func machineImportSnapshot(from path: String) async throws -> DorydMachineSnapshot { + let assessment = try await machineAssessSnapshotImport(from: path) + return try await machineImportSnapshot( + from: path, + expectedContentID: assessment.contentID + ) + } + func machineBackupSchedules() async throws -> [DorydMachineBackupStatus] { try await call { proxy, finish in proxy.machineBackupSchedules { rows, error in @@ -2914,6 +2991,123 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineImportAssessment( + from dictionary: NSDictionary + ) -> DorydMachineImportAssessment? { + let requiredKeys: Set = [ + "schemaVersion", "contentID", "sourceMachineID", "sourceSnapshotID", + "architecture", "bootMode", "diskSizeBytes", "virtualHardwareABIVersion", + "sourceRuntimeMode", "portable", "disposition", "issues", "components", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: ["sourceBackend"]), + let schemaVersion = uint16(dictionary["schemaVersion"]), + schemaVersion == 1, + let contentID = dictionary["contentID"] as? String, + contentID.isLowercaseSHA256, + let sourceMachineID = dictionary["sourceMachineID"] as? String, + sourceMachineID.isSafeMachineIdentifier, + let sourceSnapshotID = dictionary["sourceSnapshotID"] as? String, + sourceSnapshotID.isSafeMachineIdentifier, + let architecture = dictionary["architecture"] as? String, + ["arm64", "x86_64"].contains(architecture), + let bootMode = dictionary["bootMode"] as? String, + ["linux-kernel", "efi"].contains(bootMode), + let diskSizeBytes = uint64(dictionary["diskSizeBytes"]), diskSizeBytes > 0, + let virtualHardwareABIVersion = uint16( + dictionary["virtualHardwareABIVersion"] + ), virtualHardwareABIVersion > 0, + let sourceRuntimeMode = dictionary["sourceRuntimeMode"] as? String, + ["legacy-compatibility", "resolved-plan", "requires-replanning"] + .contains(sourceRuntimeMode), + let portable = dictionary["portable"] as? Bool, + let dispositionRaw = dictionary["disposition"] as? String, + let disposition = DorydMachineImportDisposition(rawValue: dispositionRaw), + let issueRows = dictionary["issues"] as? NSArray, + let issues = issueRows as? [String], + Set(issues).count == issues.count, + issues.allSatisfy({ Self.machineImportIssueCodes.contains($0) }), + let componentRows = dictionary["components"] as? NSArray else { + return nil + } + let sourceBackend: DoryVirtualizationBackendIdentity? + if let rawBackend = dictionary["sourceBackend"] { + guard let encoded = rawBackend as? String, + let decoded = DoryVirtualizationBackendIdentity(rawValue: encoded) else { + return nil + } + sourceBackend = decoded + } else { + sourceBackend = nil + } + var components: [DorydMachineImportComponentAssessment] = [] + for row in componentRows { + guard let component = row as? NSDictionary, + let componentKeys = component.allKeys as? [String], + componentKeys.count == 4, + Set(componentKeys) == [ + "componentIdentifier", "buildIdentifier", "artifactSHA256", + "availability", + ], + let componentIdentifier = component["componentIdentifier"] as? String, + componentIdentifier.isSafeEvidenceIdentifier, + let buildIdentifier = component["buildIdentifier"] as? String, + buildIdentifier.isSafeEvidenceIdentifier, + let artifactSHA256 = component["artifactSHA256"] as? String, + artifactSHA256.isLowercaseSHA256, + let availabilityRaw = component["availability"] as? String, + let availability = DorydMachineImportComponentAvailability( + rawValue: availabilityRaw + ) else { + return nil + } + components.append(DorydMachineImportComponentAssessment( + componentIdentifier: componentIdentifier, + buildIdentifier: buildIdentifier, + artifactSHA256: artifactSHA256, + availability: availability + )) + } + let hasUnavailableComponents = components.contains { + $0.availability != .available + } + guard Set(components.map(\.componentIdentifier)).count == components.count, + (sourceRuntimeMode == "resolved-plan") == (sourceBackend != nil), + (sourceRuntimeMode == "resolved-plan") == !components.isEmpty, + portable == (disposition != .unavailable), + (disposition == .requiresComponents) == (portable && hasUnavailableComponents), + disposition == .unavailable || !hasUnavailableComponents, + disposition != .ready || sourceRuntimeMode == "legacy-compatibility" else { + return nil + } + return DorydMachineImportAssessment( + schemaVersion: schemaVersion, + contentID: contentID, + sourceMachineID: sourceMachineID, + sourceSnapshotID: sourceSnapshotID, + architecture: architecture, + bootMode: bootMode, + diskSizeBytes: diskSizeBytes, + virtualHardwareABIVersion: virtualHardwareABIVersion, + sourceRuntimeMode: sourceRuntimeMode, + sourceBackend: sourceBackend, + portable: portable, + disposition: disposition, + issues: issues, + components: components + ) + } + + nonisolated private static let machineImportIssueCodes: Set = [ + "architecture-mismatch", "virtual-hardware-abi-mismatch", + "backend-runtime-differs", "missing-components", "mismatched-components", + "resolved-plan-requires-replanning", "source-requires-replanning", + "legacy-requires-migration", + ] + nonisolated private static func machineSnapshot(from dictionary: NSDictionary) -> DorydMachineSnapshot? { guard let id = dictionary["id"] as? String, let machineID = dictionary["machineID"] as? String, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index c0a50cf3..622ccce7 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -619,6 +619,47 @@ struct DorydClientTests { } } + @MainActor + @Test func machineImportAssessmentRequiresExactClosedEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = FakeDorydService.importAssessmentRow() + service.setMachineImportAssessment(valid) + let assessment = try await client.machineAssessSnapshotImport( + from: "/tmp/dev.dorymachine" + ) + #expect(assessment.contentID == String(repeating: "a", count: 64)) + #expect(assessment.disposition == .ready) + #expect(assessment.portable) + + let unknown = valid.mutableCopy() as! NSMutableDictionary + unknown["unexpected"] = "claim" + service.setMachineImportAssessment(unknown) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + + let wrongWireType = valid.mutableCopy() as! NSMutableDictionary + wrongWireType["diskSizeBytes"] = "4096" + service.setMachineImportAssessment(wrongWireType) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + + let contradictory = valid.mutableCopy() as! NSMutableDictionary + contradictory["disposition"] = "requires-components" + service.setMachineImportAssessment(contradictory) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + } + @MainActor @Test func readsDoctorJSONAndIncidentsOverXPC() async throws { let listener = NSXPCListener.anonymous() @@ -3456,6 +3497,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineGuestExportStartResponseOverride: NSDictionary? private var _machineGuestExportOperationResponseOverride: NSDictionary? private var _machineGuestExportCurrentResponseOverride: NSDictionary? + private var _machineImportAssessmentOverride: NSDictionary? private var _machineGuestExportCancelCount = 0 private var _machineGuestExportDiscardCount = 0 private var runtimeIdentityOverride: NSDictionary? @@ -3537,6 +3579,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { _machineGuestExportCurrentResponseOverride = response } + func setMachineImportAssessment(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineImportAssessmentOverride = response + } + func machineGuestExportOperationResponse( operationID: String, phase: String @@ -4428,6 +4475,16 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, "") } + func machineAssessSnapshotImport( + _ path: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineImportAssessmentOverride ?? Self.importAssessmentRow() + lock.unlock() + reply(true, row, "") + } + func machineImportSnapshot(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { let row = Self.snapshotRow( id: "imported", @@ -4441,6 +4498,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineImportSnapshot( + _ path: String, + expectedContentID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard expectedContentID == String(repeating: "a", count: 64) else { + reply(false, [:], "machine bundle changed after import assessment") + return + } + machineImportSnapshot(path, reply: reply) + } + func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) { lock.lock() let rows = backupStatuses.keys.sorted().compactMap { backupStatuses[$0] } @@ -5024,6 +5093,24 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] as NSDictionary } + static func importAssessmentRow() -> NSDictionary { + [ + "schemaVersion": 1, + "contentID": String(repeating: "a", count: 64), + "sourceMachineID": "dev", + "sourceSnapshotID": "imported", + "architecture": "arm64", + "bootMode": "linux-kernel", + "diskSizeBytes": 4_096, + "virtualHardwareABIVersion": 1, + "sourceRuntimeMode": "legacy-compatibility", + "portable": true, + "disposition": "ready", + "issues": [] as [String], + "components": [] as [NSDictionary], + ] + } + private static func snapshotRow(id: String, machineID: String, note: String, createdISO: String) -> NSDictionary { [ "id": id, diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index c1f94ddd..fb021ec9 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -631,6 +631,14 @@ Export uses a versioned archive with a signed/checksummed manifest and never ass supports the original backend. Import first reports portability and missing components; it does not partially create a workspace. +The daemon implements that first phase as a read-only, content-bound assessment. It opens the +archive without following symlinks, streams and verifies every declared disk/kernel/firmware body, +checks metadata, architecture, hardware ABI, backend runtime, and exact component evidence, and +returns a path-free content ID plus one of `ready`, `requires-components`, `requires-replanning`, or +`unavailable`. The later import must present that same content ID and reverify the archive before +writing any snapshot artifact; incompatible or missing-evidence assessments cannot cross the XPC +import boundary. + Extend the proven snapshot/export logic in `MachineManager`, rather than maintaining the older container-image snapshots in [`MachineSnapshot`](../Dory/Runtime/Machines/MachineSnapshot.swift) as a second VM truth. diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift index 76132f02..c9e055e9 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift @@ -45,6 +45,7 @@ public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable { public let backendRuntimeBuildIdentifiers: [ DoryVirtualizationBackendIdentity: String ] + public let machineImportEnvironment: DoryMachineImportEnvironment public var inventory: any DoryDaemonVirtualMachineTrustInventory { planning.inventory @@ -57,12 +58,14 @@ public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable { DoryDaemonVirtualMachineProductionPlanningController, backendRuntimeBuildIdentifiers: [ DoryVirtualizationBackendIdentity: String - ] + ], + machineImportEnvironment: DoryMachineImportEnvironment ) { self.machineManager = machineManager self.planning = planning self.planningController = planningController self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers + self.machineImportEnvironment = machineImportEnvironment } } @@ -231,14 +234,23 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { ) } + let backendRuntimeBuildIdentifiers = Dictionary( + uniqueKeysWithValues: material.runtimes.map { + ($0.descriptor.identity, $0.runtimeBuildIdentifier) + } + ) return .activated(DoryDaemonVirtualMachineProductionActivationContext( machineManager: machineManager, planning: planning, planningController: planningController, - backendRuntimeBuildIdentifiers: Dictionary( - uniqueKeysWithValues: material.runtimes.map { - ($0.descriptor.identity, $0.runtimeBuildIdentifier) - } + backendRuntimeBuildIdentifiers: backendRuntimeBuildIdentifiers, + machineImportEnvironment: DoryMachineImportEnvironment( + backendRuntimeBuildIdentifiers: backendRuntimeBuildIdentifiers, + backendComponents: Dictionary( + uniqueKeysWithValues: material.runtimes.map { + ($0.descriptor.identity, $0.componentEvidence) + } + ) ) )) } diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineImportAssessment.swift b/dory-core-swift/Sources/DorydKit/DoryMachineImportAssessment.swift new file mode 100644 index 00000000..936c896e --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineImportAssessment.swift @@ -0,0 +1,127 @@ +import DoryOperations +import Foundation + +public enum DoryMachineImportDisposition: String, Codable, Sendable, Hashable { + case ready + case requiresComponents = "requires-components" + case requiresReplanning = "requires-replanning" + case unavailable +} + +public enum DoryMachineImportIssueCode: String, Codable, Sendable, Hashable { + case architectureMismatch = "architecture-mismatch" + case virtualHardwareABIMismatch = "virtual-hardware-abi-mismatch" + case backendRuntimeDiffers = "backend-runtime-differs" + case missingComponents = "missing-components" + case mismatchedComponents = "mismatched-components" + case resolvedPlanRequiresReplanning = "resolved-plan-requires-replanning" + case sourceRequiresReplanning = "source-requires-replanning" + case legacyRequiresMigration = "legacy-requires-migration" +} + +public enum DoryMachineImportComponentAvailability: String, Codable, Sendable, Hashable { + case available + case mismatched + case missing +} + +public struct DoryMachineImportComponentAssessment: + Codable, Sendable, Equatable, Hashable +{ + public var componentIdentifier: String + public var buildIdentifier: String + public var artifactSHA256: String + public var availability: DoryMachineImportComponentAvailability + + public init( + componentIdentifier: String, + buildIdentifier: String, + artifactSHA256: String, + availability: DoryMachineImportComponentAvailability + ) { + self.componentIdentifier = componentIdentifier + self.buildIdentifier = buildIdentifier + self.artifactSHA256 = artifactSHA256 + self.availability = availability + } +} + +/// Destination-owned evidence used to assess a portable archive. It is deliberately path-free: +/// import compatibility is based on verified runtime/component identities, never caller paths. +public struct DoryMachineImportEnvironment: Sendable, Equatable { + public var backendRuntimeBuildIdentifiers: [DoryVirtualizationBackendIdentity: String] + public var backendComponents: + [DoryVirtualizationBackendIdentity: [DoryResolvedBackendComponentEvidence]] + + public init( + backendRuntimeBuildIdentifiers: [DoryVirtualizationBackendIdentity: String] = [:], + backendComponents: + [DoryVirtualizationBackendIdentity: [DoryResolvedBackendComponentEvidence]] = [:] + ) { + self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers + self.backendComponents = backendComponents.mapValues { + $0.sorted { lhs, rhs in + if lhs.componentIdentifier == rhs.componentIdentifier { + return lhs.buildIdentifier < rhs.buildIdentifier + } + return lhs.componentIdentifier < rhs.componentIdentifier + } + } + } + + public static let unverified = Self() +} + +/// A complete, read-only verification result for one portable machine archive. `contentID` binds +/// the metadata and every declared artifact digest after the daemon has streamed and verified all +/// artifact bodies. No path or secret crosses XPC. +public struct DoryMachineImportAssessment: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var contentID: String + public var sourceMachineID: String + public var sourceSnapshotID: String + public var architecture: String + public var bootMode: DoryMachineBootMode + public var diskSizeBytes: UInt64 + public var virtualHardwareABIVersion: UInt16 + public var sourceRuntimeMode: DoryMachineRuntimeIdentityMode + public var sourceBackend: DoryVirtualizationBackendIdentity? + public var portable: Bool + public var disposition: DoryMachineImportDisposition + public var issues: [DoryMachineImportIssueCode] + public var components: [DoryMachineImportComponentAssessment] + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + contentID: String, + sourceMachineID: String, + sourceSnapshotID: String, + architecture: String, + bootMode: DoryMachineBootMode, + diskSizeBytes: UInt64, + virtualHardwareABIVersion: UInt16, + sourceRuntimeMode: DoryMachineRuntimeIdentityMode, + sourceBackend: DoryVirtualizationBackendIdentity?, + portable: Bool, + disposition: DoryMachineImportDisposition, + issues: [DoryMachineImportIssueCode], + components: [DoryMachineImportComponentAssessment] + ) { + self.schemaVersion = schemaVersion + self.contentID = contentID + self.sourceMachineID = sourceMachineID + self.sourceSnapshotID = sourceSnapshotID + self.architecture = architecture + self.bootMode = bootMode + self.diskSizeBytes = diskSizeBytes + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.sourceRuntimeMode = sourceRuntimeMode + self.sourceBackend = sourceBackend + self.portable = portable + self.disposition = disposition + self.issues = issues + self.components = components + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 47530e31..3b0fdb85 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -44,7 +44,9 @@ import Foundation func machineRestoreSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeleteSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, String) -> Void) func machineExportSnapshot(_ machineID: String, snapshotID: String, path: String, reply: @escaping (Bool, String) -> Void) + func machineAssessSnapshotImport(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineImportSnapshot(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineImportSnapshot(_ path: String, expectedContentID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) func machineBackupSet(_ schedule: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupRemove(_ machineID: String, reply: @escaping (Bool, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 166fc7d8..e8118e0d 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -10,6 +10,7 @@ public final class DorydService: NSObject, DorydControl { private let machineManager: MachineManager? private let productionPlanningController: (any DoryDaemonVirtualMachineProductionPlanningControlling)? + private let machineImportEnvironment: DoryMachineImportEnvironment private let machineBackupScheduler: MachineBackupScheduler? private let remoteManager: RemoteMachineManager? private let networkingController: NetworkingController? @@ -30,6 +31,7 @@ public final class DorydService: NSObject, DorydControl { machineManager: MachineManager? = nil, productionPlanningController: (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil, + machineImportEnvironment: DoryMachineImportEnvironment = .unverified, machineBackupScheduler: MachineBackupScheduler? = nil, remoteManager: RemoteMachineManager? = nil, networkingController: NetworkingController? = nil, @@ -47,6 +49,7 @@ public final class DorydService: NSObject, DorydControl { self.dockerTier = dockerTier self.machineManager = machineManager self.productionPlanningController = productionPlanningController + self.machineImportEnvironment = machineImportEnvironment self.machineBackupScheduler = machineBackupScheduler self.remoteManager = remoteManager self.networkingController = networkingController @@ -878,6 +881,25 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineAssessSnapshotImport( + _ path: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let assessment = try machineManager.assessSnapshotImport( + fromPath: path, + environment: machineImportEnvironment + ) + reply(true, assessment.xpcDictionary, "") + } catch { + reply(false, [:], "\(error)") + } + } + public func machineImportSnapshot( _ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void @@ -887,7 +909,20 @@ public final class DorydService: NSObject, DorydControl { return } do { - let snapshot = try machineManager.importSnapshot(fromPath: path) + let assessment = try machineManager.assessSnapshotImport( + fromPath: path, + environment: machineImportEnvironment + ) + guard assessment.disposition == .ready + || assessment.disposition == .requiresReplanning else { + throw MachineManagerError.persistence( + "machine import preflight rejected: \(assessment.issues.map(\.rawValue).joined(separator: ", "))" + ) + } + let snapshot = try machineManager.importSnapshot( + fromPath: path, + expectedContentID: assessment.contentID + ) incidentWriter?.record(type: "machine.import_snapshot", detail: "\(snapshot.machineID) \(snapshot.id)") reply(true, snapshot.xpcDictionary, "") } catch { @@ -896,6 +931,50 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineImportSnapshot( + _ path: String, + expectedContentID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + guard expectedContentID.count == 64, + expectedContentID.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) else { + throw MachineManagerError.persistence("invalid machine import content identifier") + } + let assessment = try machineManager.assessSnapshotImport( + fromPath: path, + environment: machineImportEnvironment + ) + guard assessment.contentID == expectedContentID else { + throw MachineManagerError.persistence( + "machine bundle changed after import assessment" + ) + } + guard assessment.disposition == .ready + || assessment.disposition == .requiresReplanning else { + throw MachineManagerError.persistence( + "machine import preflight rejected: \(assessment.issues.map(\.rawValue).joined(separator: ", "))" + ) + } + let snapshot = try machineManager.importSnapshot( + fromPath: path, + expectedContentID: expectedContentID + ) + incidentWriter?.record( + type: "machine.import_snapshot", + detail: "\(snapshot.machineID) \(snapshot.id) \(expectedContentID)" + ) + reply(true, snapshot.xpcDictionary, "") + } catch { + incidentWriter?.record(type: "machine.import_snapshot_failed", detail: "\(error)") + reply(false, [:], "\(error)") + } + } + public func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) { guard let machineBackupScheduler else { reply([], "machine backup scheduler is not configured") @@ -2166,6 +2245,35 @@ private extension DoryMachineStatus { } } +private extension DoryMachineImportAssessment { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "contentID": contentID, + "sourceMachineID": sourceMachineID, + "sourceSnapshotID": sourceSnapshotID, + "architecture": architecture, + "bootMode": bootMode.rawValue, + "diskSizeBytes": diskSizeBytes, + "virtualHardwareABIVersion": virtualHardwareABIVersion, + "sourceRuntimeMode": sourceRuntimeMode.rawValue, + "portable": portable, + "disposition": disposition.rawValue, + "issues": issues.map(\.rawValue), + "components": components.map { component in + [ + "componentIdentifier": component.componentIdentifier, + "buildIdentifier": component.buildIdentifier, + "artifactSHA256": component.artifactSHA256, + "availability": component.availability.rawValue, + ] as NSDictionary + }, + ] + if let sourceBackend { dictionary["sourceBackend"] = sourceBackend.rawValue } + return dictionary as NSDictionary + } +} + private extension DoryInstalledDesktopPayloadReceipt { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index e1075456..dba08c10 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -4584,7 +4584,112 @@ public final class MachineManager: @unchecked Sendable { } } + public func assessSnapshotImport( + fromPath path: String, + environment: DoryMachineImportEnvironment = .unverified + ) throws -> DoryMachineImportAssessment { + operationLock.lock() + defer { operationLock.unlock() } + let bundle = try MachineSnapshotBundle.verifyDescriptor(fromPath: path) + let snapshot = bundle.snapshot + guard Self.isValidID(snapshot.machineID), Self.isValidID(snapshot.id) else { + throw MachineManagerError.persistence("invalid snapshot metadata") + } + try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount) + try Self.validateSnapshotRuntimeIdentity(snapshot) + + var issues: [DoryMachineImportIssueCode] = [] + let architectureMatches = snapshot.architecture == configuration.guestArchitecture + if !architectureMatches { issues.append(.architectureMismatch) } + let abiMatches = snapshot.runtimeIdentity.virtualHardwareABIVersion + == DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion + if !abiMatches { issues.append(.virtualHardwareABIMismatch) } + + var components: [DoryMachineImportComponentAssessment] = [] + if let plan = snapshot.runtimeIdentity.resolvedPlan { + let available = environment.backendComponents[plan.backend] ?? [] + let exactAvailable = Set(available) + let availableIdentifiers = Set(available.map(\.componentIdentifier)) + components = plan.components.map { required in + let availability: DoryMachineImportComponentAvailability + if exactAvailable.contains(required) { + availability = .available + } else if availableIdentifiers.contains(required.componentIdentifier) { + availability = .mismatched + } else { + availability = .missing + } + return DoryMachineImportComponentAssessment( + componentIdentifier: required.componentIdentifier, + buildIdentifier: required.buildIdentifier, + artifactSHA256: required.artifactSHA256, + availability: availability + ) + } + if components.contains(where: { $0.availability == .missing }) { + issues.append(.missingComponents) + } + if components.contains(where: { $0.availability == .mismatched }) { + issues.append(.mismatchedComponents) + } + if environment.backendRuntimeBuildIdentifiers[plan.backend] + != plan.backendRuntimeBuildIdentifier { + issues.append(.backendRuntimeDiffers) + } + } + + let portable = architectureMatches && abiMatches + let disposition: DoryMachineImportDisposition + if !portable { + disposition = .unavailable + } else if components.contains(where: { $0.availability != .available }) { + disposition = .requiresComponents + } else { + switch snapshot.runtimeIdentity.mode { + case .resolvedPlan: + // Portable archives are checksummed, not a signed authority transfer. The exact + // source plan is useful evidence, but the destination must mint its own media, + // host-qualification, resource-admission, and plan authority. + issues.append(.resolvedPlanRequiresReplanning) + disposition = .requiresReplanning + case .requiresReplanning: + issues.append(.sourceRequiresReplanning) + disposition = .requiresReplanning + case .legacyCompatibility: + if launchPolicy == .perWorkspaceAuthority { + issues.append(.legacyRequiresMigration) + disposition = .requiresReplanning + } else { + disposition = .ready + } + } + } + + return DoryMachineImportAssessment( + contentID: MachineSnapshotBundle.digestHex(bundle.contentID), + sourceMachineID: snapshot.machineID, + sourceSnapshotID: snapshot.id, + architecture: snapshot.architecture, + bootMode: snapshot.bootMode, + diskSizeBytes: UInt64(snapshot.sizeBytes), + virtualHardwareABIVersion: snapshot.runtimeIdentity.virtualHardwareABIVersion, + sourceRuntimeMode: snapshot.runtimeIdentity.mode, + sourceBackend: snapshot.runtimeIdentity.backend, + portable: portable, + disposition: disposition, + issues: issues, + components: components + ) + } + public func importSnapshot(fromPath path: String) throws -> DoryMachineSnapshot { + try importSnapshot(fromPath: path, expectedContentID: nil) + } + + public func importSnapshot( + fromPath path: String, + expectedContentID: String? + ) throws -> DoryMachineSnapshot { operationLock.lock() defer { operationLock.unlock() } var extractedRootfsPath: String? @@ -4593,7 +4698,14 @@ public final class MachineManager: @unchecked Sendable { var extractedNVRAMPath: String? var importedMachineID: String? do { - let bundle = try MachineSnapshotBundle.readDescriptor(fromPath: path) + let bundle = try MachineSnapshotBundle.verifyDescriptor(fromPath: path) + if let expectedContentID { + guard expectedContentID == MachineSnapshotBundle.digestHex(bundle.contentID) else { + throw MachineManagerError.persistence( + "machine bundle changed after import assessment" + ) + } + } var snapshot = bundle.snapshot guard Self.isValidID(snapshot.machineID), Self.isValidID(snapshot.id) else { throw MachineManagerError.persistence("invalid snapshot metadata") @@ -10273,6 +10385,16 @@ public final class MachineManager: @unchecked Sendable { } private enum MachineSnapshotBundle { + private struct FileSnapshot: Equatable { + var device: UInt64 + var inode: UInt64 + var size: Int64 + var modifiedSeconds: Int64 + var modifiedNanoseconds: Int64 + var changedSeconds: Int64 + var changedNanoseconds: Int64 + } + private struct Header { var snapshot: DoryMachineSnapshot var rootfsOffset: UInt64 @@ -10465,10 +10587,39 @@ private enum MachineSnapshotBundle { } } - static func readDescriptor(fromPath path: String) throws -> (snapshot: DoryMachineSnapshot, contentID: Data) { + static func verifyDescriptor( + fromPath path: String + ) throws -> (snapshot: DoryMachineSnapshot, contentID: Data) { let input = try openRegularFileForReading(path: path) defer { try? input.close() } + let before = try fileSnapshot(input) let header = try readHeader(from: input) + var artifacts: [(UInt64, UInt64, Data)] = [ + (header.rootfsOffset, header.rootfsLength, header.rootfsDigest), + (header.kernelOffset, header.kernelLength, header.kernelDigest), + ] + if let offset = header.machineIdentifierOffset, + let length = header.machineIdentifierLength, + let digest = header.machineIdentifierDigest { + artifacts.append((offset, length, digest)) + } + if let offset = header.nvramOffset, + let length = header.nvramLength, + let digest = header.nvramDigest { + artifacts.append((offset, length, digest)) + } + for (offset, length, expectedDigest) in artifacts { + try input.seek(toOffset: offset) + guard try hashExactly(from: input, byteCount: length) == expectedDigest else { + throw MachineManagerError.persistence("corrupt dory machine bundle artifact") + } + } + let after = try fileSnapshot(input) + guard before == after else { + throw MachineManagerError.persistence( + "machine bundle changed during import assessment" + ) + } return (header.snapshot, header.contentID) } @@ -10726,10 +10877,43 @@ private enum MachineSnapshotBundle { ) } - private static func digestHex(_ digest: Data) -> String { + static func digestHex(_ digest: Data) -> String { digest.map { String(format: "%02x", $0) }.joined() } + private static func fileSnapshot(_ handle: FileHandle) throws -> FileSnapshot { + var info = stat() + guard fstat(handle.fileDescriptor, &info) == 0 else { + throw MachineManagerError.persistence( + "could not inspect machine bundle file: \(String(cString: strerror(errno)))" + ) + } + return FileSnapshot( + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino), + size: info.st_size, + modifiedSeconds: Int64(info.st_mtimespec.tv_sec), + modifiedNanoseconds: Int64(info.st_mtimespec.tv_nsec), + changedSeconds: Int64(info.st_ctimespec.tv_sec), + changedNanoseconds: Int64(info.st_ctimespec.tv_nsec) + ) + } + + private static func hashExactly(from input: FileHandle, byteCount: UInt64) throws -> Data { + var remaining = byteCount + var hasher = SHA256() + while remaining > 0 { + let requested = Int(min(remaining, UInt64(copyChunkSize))) + let chunk = try input.read(upToCount: requested) ?? Data() + guard !chunk.isEmpty else { + throw MachineManagerError.persistence("truncated dory machine bundle payload") + } + hasher.update(data: chunk) + remaining -= UInt64(chunk.count) + } + return Data(hasher.finalize()) + } + private static func openRegularFileForReading( path: String, requirePrivateOwnership: Bool = false diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index 7858b97c..c1dfaf8c 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -69,6 +69,7 @@ let desktopUpdateArtifactResolver = DoryComponentStoreDesktopUpdateArtifactResol ) var productionPlanningController: (any DoryDaemonVirtualMachineProductionPlanningControlling)? +var machineImportEnvironment = DoryMachineImportEnvironment.unverified let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in let readiness = DoryDaemonVirtualMachineProductionTrustFactory().activate( store: DoryComponentStore(drive: dataDrive), @@ -81,6 +82,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co .appending("(production planning recovered and activated)\n").utf8 )) productionPlanningController = context.planningController + machineImportEnvironment = context.machineImportEnvironment context.machineManager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver) return context.machineManager case let .unavailable(failure): @@ -257,6 +259,7 @@ let service = DorydService( dockerTier: dockerTier, machineManager: machineManager, productionPlanningController: productionPlanningController, + machineImportEnvironment: machineImportEnvironment, machineBackupScheduler: machineBackupScheduler, remoteManager: remoteManager, networkingController: networkingController, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 9f94ab4f..96c813cf 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -204,6 +204,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine restore-snapshot NAME SNAPSHOT_ID dorydctl [global] machine delete-snapshot NAME SNAPSHOT_ID dorydctl [global] machine export-snapshot NAME SNAPSHOT_ID PATH + dorydctl [global] machine inspect-import PATH dorydctl [global] machine import-snapshot PATH dorydctl [global] machine backup status [NAME] dorydctl [global] machine backup schedule NAME [--frequency hourly|daily|weekly] [--keep N] [--verify-every N] @@ -1313,13 +1314,35 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { proxy, reply in proxy.machineExportSnapshot(name, snapshotID: snapshotID, path: path, reply: reply) }) + case "inspect-import": + let path = try cursor.take("usage: dorydctl machine inspect-import PATH") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("unexpected inspect-import argument: \(cursor.values[0])") + } + let assessment = try client.withTimeout(atLeast: machineFileMutationTimeout) + .statusCommand { proxy, reply in + proxy.machineAssessSnapshotImport(path, reply: reply) + } + try emitJSON(assessment) case "import-snapshot": let path = try cursor.take("usage: dorydctl machine import-snapshot PATH") guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected import-snapshot argument: \(cursor.values[0])") } + let assessment = try client.withTimeout(atLeast: machineFileMutationTimeout) + .statusCommand { proxy, reply in + proxy.machineAssessSnapshotImport(path, reply: reply) + } + guard let disposition = assessment["disposition"] as? String, + let contentID = assessment["contentID"] as? String else { + throw DorydCtlError.daemon("invalid machine import assessment") + } + guard disposition == "ready" || disposition == "requires-replanning" else { + let issues = (assessment["issues"] as? [String])?.joined(separator: ", ") ?? disposition + throw DorydCtlError.daemon("machine import preflight rejected: \(issues)") + } let imported = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in - proxy.machineImportSnapshot(path, reply: reply) + proxy.machineImportSnapshot(path, expectedContentID: contentID, reply: reply) } try emitJSON(imported) case "backup": diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 0a49cee5..22dfe48d 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -360,6 +360,10 @@ struct DoryDaemonVirtualMachineProductionTrustTests { #expect(context.planning.workspaces.root == context.machineManager.managedStateDirectory) #expect(context.inventory is DoryProductionDaemonVirtualMachineTrustInventory) + #expect(context.machineImportEnvironment.backendRuntimeBuildIdentifiers[.doryHypervisor] + == fixture.runtimeBuildIdentifier) + #expect(context.machineImportEnvironment.backendComponents[.doryHypervisor]?.first? + .artifactSHA256 == fixture.helperDigest) #expect(trustFloor.activationCount == 1) } diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 90ddb148..7c42928f 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -2471,8 +2471,41 @@ final class DorydServiceTests: XCTestCase { } wait(for: [deleteReply], timeout: 5) + var assessedContentID = "" + let assessmentReply = expectation(description: "machineAssessSnapshotImport reply") + proxy.machineAssessSnapshotImport(bundle) { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["schemaVersion"] as? Int, 1) + XCTAssertEqual(body["sourceMachineID"] as? String, "dev") + XCTAssertEqual(body["sourceSnapshotID"] as? String, "s1") + XCTAssertEqual(body["disposition"] as? String, "ready") + XCTAssertEqual(body["portable"] as? Bool, true) + XCTAssertEqual((body["components"] as? NSArray)?.count, 0) + assessedContentID = body["contentID"] as? String ?? "" + XCTAssertEqual(assessedContentID.count, 64) + assessmentReply.fulfill() + } + wait(for: [assessmentReply], timeout: 5) + XCTAssertTrue(try manager.listSnapshots(machineID: "dev").isEmpty) + + let staleReply = expectation(description: "stale machineImportSnapshot reply") + proxy.machineImportSnapshot( + bundle, + expectedContentID: String(repeating: "0", count: 64) + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("changed after import assessment")) + staleReply.fulfill() + } + wait(for: [staleReply], timeout: 5) + XCTAssertTrue(try manager.listSnapshots(machineID: "dev").isEmpty) + let importReply = expectation(description: "machineImportSnapshot reply") - proxy.machineImportSnapshot(bundle) { ok, body, message in + proxy.machineImportSnapshot( + bundle, + expectedContentID: assessedContentID + ) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["id"] as? String, "s1") XCTAssertEqual(body["machineID"] as? String, "dev") diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 13a5c7d4..4d49abb6 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -73,6 +73,28 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(snapshot.runtimeIdentity == status.runtimeIdentity) #expect(snapshot.artifactEvidence?.rootfs.sha256.count == 64) #expect(snapshot.artifactEvidence?.kernel.sha256.count == 64) + let bundle = state + "/resolved.dorymachine" + try manager.exportSnapshot( + machineID: "dev", + snapshotID: snapshot.id, + toPath: bundle + ) + let component = try #require(snapshot.runtimeIdentity.components.first) + let exactAssessment = try manager.assessSnapshotImport( + fromPath: bundle, + environment: DoryMachineImportEnvironment( + backendRuntimeBuildIdentifiers: [.doryHypervisor: "raw-runtime-1"], + backendComponents: [.doryHypervisor: [component]] + ) + ) + #expect(exactAssessment.disposition == .requiresReplanning) + #expect(exactAssessment.portable) + #expect(exactAssessment.components.map(\.availability) == [.available]) + #expect(exactAssessment.issues == [.resolvedPlanRequiresReplanning]) + let missingAssessment = try manager.assessSnapshotImport(fromPath: bundle) + #expect(missingAssessment.disposition == .requiresComponents) + #expect(missingAssessment.components.map(\.availability) == [.missing]) + #expect(missingAssessment.issues.contains(.missingComponents)) var tamperedIdentity = snapshot.runtimeIdentity tamperedIdentity.resolvedPlanSHA256 = String(repeating: "0", count: 64) #expect(tamperedIdentity.validate().contains { $0.code == .planDigestMismatch }) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 6234aa68..568373a7 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -780,6 +780,76 @@ final class MachineManagerTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: machineJSON)), raw) } + func testImportAssessmentVerifiesEveryArtifactBeforeWritingAndBindsContentID() throws { + let base = "/tmp/dory-machine-import-assessment-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 19:20:07 +0000 Subject: [PATCH 219/338] feat(vm): publish durable machine event cursors --- Dory/Models/AppStore.swift | 55 +- Dory/Runtime/Doryd/DorydClient.swift | 231 +++++++ DoryTests/DorydClientTests.swift | 179 ++++++ docs/virtual-workspace-platform.md | 10 + .../DorydKit/DoryMachineEventStream.swift | 596 ++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 78 +++ .../DoryMachineEventStreamTests.swift | 239 +++++++ .../DorydKitTests/DorydServiceTests.swift | 79 +++ 9 files changed, 1463 insertions(+), 5 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index bdb080ca..dd9e86a0 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5112,6 +5112,7 @@ final class AppStore { } private(set) var busyMachines: Set = [] + @ObservationIgnored private var machineEventSequence: UInt64 = 0 private(set) var machineFileTransfers: [String: DorydMachineFileTransferOperation] = [:] private(set) var machineGuestFileExports: [String: DorydMachineGuestFileExportOperation] = [:] private var recoveringMachineFileTransfers: Set = [] @@ -5135,20 +5136,51 @@ final class AppStore { var machineCreated: Machine? func loadMachines() { - guard runtimeOwnedByDoryd else { machines = []; return } - Task { await refreshMachines() } + guard runtimeOwnedByDoryd else { + machineEventSequence = 0 + machines = [] + return + } + Task { await refreshMachines(useEventCursor: true) } } @discardableResult - private func refreshMachines() async -> [Machine] { + private func refreshMachines(useEventCursor: Bool = false) async -> [Machine] { guard runtimeOwnedByDoryd else { + machineEventSequence = 0 machines = [] dns.replaceHostIPs([:]) return [] } + var eventBatch: DorydMachineEventBatch? + var requiresMachineSnapshot = true + let requestedEventSequence = machineEventSequence + if useEventCursor { + eventBatch = try? await dorydClient.machineEvents( + afterSequence: requestedEventSequence + ) + if let eventBatch { + requiresMachineSnapshot = eventBatch.snapshotRequired + || !eventBatch.events.isEmpty + if !requiresMachineSnapshot { + advanceMachineEventSequence( + eventBatch.headSequence, + requestedSequence: requestedEventSequence + ) + } + } + } do { - machines = try await dorydClient.machineList().map { - Self.machine(fromDoryd: $0, domainSuffix: domainSuffix) + if requiresMachineSnapshot { + machines = try await dorydClient.machineList().map { + Self.machine(fromDoryd: $0, domainSuffix: domainSuffix) + } + if let eventBatch { + advanceMachineEventSequence( + eventBatch.headSequence, + requestedSequence: requestedEventSequence + ) + } } for machine in machines where machine.status == .running { Task { [weak self] in @@ -5170,6 +5202,19 @@ final class AppStore { } } + private func advanceMachineEventSequence( + _ headSequence: UInt64, + requestedSequence: UInt64 + ) { + // Async refreshes can overlap at suspension points. Never let an older response move a + // newer cursor backwards; allow a daemon-reset snapshot to lower only the cursor that made + // that exact request. + if machineEventSequence == requestedSequence + || headSequence > machineEventSequence { + machineEventSequence = headSequence + } + } + nonisolated static func machineDNSName(name: String, suffix: String) -> String { "\(name).\(suffix)".lowercased() } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 10293de3..82572674 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -24,6 +24,7 @@ nonisolated protocol DorydControlXPC { func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) + func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -905,6 +906,46 @@ nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable { var restoredRunningState: Bool } +nonisolated enum DorydMachineEventKind: String, Sendable, Equatable { + case updated + case removed +} + +nonisolated struct DorydMachineEventStatus: Sendable, Equatable { + var machineID: String + var configurationRevision: String + var observedRevision: String + var state: String + var hasFailure: Bool + var memoryMB: UInt64 + var cpuCount: Int + var displayMode: String + var bootMode: String + var installerMediaAttached: Bool + var shareCount: Int + var integrationHealth: String + var runtimeMode: String + var virtualHardwareABIVersion: UInt16 + var planRevision: UInt64? + var planSHA256: String? + var backend: DoryVirtualizationBackendIdentity? + var savedStateSHA256: String? +} + +nonisolated struct DorydMachineEvent: Sendable, Equatable { + var sequence: UInt64 + var observedAtUnixMilliseconds: Int64 + var machineID: String + var kind: DorydMachineEventKind + var status: DorydMachineEventStatus? +} + +nonisolated struct DorydMachineEventBatch: Sendable, Equatable { + var headSequence: UInt64 + var snapshotRequired: Bool + var events: [DorydMachineEvent] +} + nonisolated enum DorydMachineImportDisposition: String, Sendable, Equatable { case ready case requiresComponents = "requires-components" @@ -1969,6 +2010,14 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineEvents(afterSequence: UInt64) async throws -> DorydMachineEventBatch { + try await statusCommand { proxy, reply in + proxy.machineEvents(afterSequence, reply: reply) + } decode: { + Self.machineEventBatch(from: $0, afterSequence: afterSequence) + } + } + func machineList() async throws -> [DorydMachineStatus] { try await call { proxy, finish in proxy.machineList { rows, error in @@ -2991,6 +3040,188 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineEventBatch( + from dictionary: NSDictionary, + afterSequence: UInt64 + ) -> DorydMachineEventBatch? { + guard let keys = dictionary.allKeys as? [String], + keys.count == 4, + Set(keys) == [ + "schemaVersion", "headSequence", "snapshotRequired", "events", + ], + uint16(dictionary["schemaVersion"]) == 1, + let headSequence = uint64(dictionary["headSequence"]), + let snapshotNumber = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshotNumber) == CFBooleanGetTypeID(), + let rows = dictionary["events"] as? NSArray else { + return nil + } + var events: [DorydMachineEvent] = [] + for rawRow in rows { + guard let row = rawRow as? NSDictionary, + let event = machineEvent(from: row) else { return nil } + events.append(event) + } + let snapshotRequired = snapshotNumber.boolValue + guard events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count else { + return nil + } + if snapshotRequired { + guard events.isEmpty else { return nil } + } else if events.isEmpty { + guard headSequence == afterSequence else { return nil } + } else { + guard afterSequence < UInt64.max, + events.first?.sequence == afterSequence + 1, + events.last?.sequence == headSequence, + zip(events, events.dropFirst()).allSatisfy({ lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + }) else { + return nil + } + } + return DorydMachineEventBatch( + headSequence: headSequence, + snapshotRequired: snapshotRequired, + events: events + ) + } + + nonisolated private static func machineEvent( + from dictionary: NSDictionary + ) -> DorydMachineEvent? { + let requiredKeys: Set = [ + "schemaVersion", "sequence", "observedAtUnixMilliseconds", "machineID", + "kind", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: ["status"]), + uint16(dictionary["schemaVersion"]) == 1, + let sequence = uint64(dictionary["sequence"]), sequence > 0, + let observedAt = int64(dictionary["observedAtUnixMilliseconds"]), + observedAt > 0, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let kindValue = dictionary["kind"] as? String, + let kind = DorydMachineEventKind(rawValue: kindValue) else { + return nil + } + let status = (dictionary["status"] as? NSDictionary).flatMap( + machineEventStatus(from:) + ) + guard (kind == .updated && status?.machineID == machineID) + || (kind == .removed && dictionary["status"] == nil) else { + return nil + } + return DorydMachineEvent( + sequence: sequence, + observedAtUnixMilliseconds: observedAt, + machineID: machineID, + kind: kind, + status: status + ) + } + + nonisolated private static func machineEventStatus( + from dictionary: NSDictionary + ) -> DorydMachineEventStatus? { + let requiredKeys: Set = [ + "schemaVersion", "machineID", "configurationRevision", "observedRevision", + "state", "hasFailure", "memoryMB", "cpuCount", "displayMode", "bootMode", + "installerMediaAttached", "shareCount", "integrationHealth", "runtimeMode", + "virtualHardwareABIVersion", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: [ + "planRevision", "planSHA256", "backend", "savedStateSHA256", + ]), + uint16(dictionary["schemaVersion"]) == 1, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let configurationRevision = dictionary["configurationRevision"] as? String, + configurationRevision.isLowercaseSHA256, + let observedRevision = dictionary["observedRevision"] as? String, + observedRevision.isLowercaseSHA256, + let state = dictionary["state"] as? String, + Self.machineEventStates.contains(state), + let failureNumber = dictionary["hasFailure"] as? NSNumber, + CFGetTypeID(failureNumber) == CFBooleanGetTypeID(), + let memoryMB = uint64(dictionary["memoryMB"]), memoryMB > 0, + let cpuCount = int(dictionary["cpuCount"]), cpuCount > 0, + let displayMode = dictionary["displayMode"] as? String, + ["headless", "desktop"].contains(displayMode), + let bootMode = dictionary["bootMode"] as? String, + ["linux-kernel", "efi"].contains(bootMode), + let installerNumber = dictionary["installerMediaAttached"] as? NSNumber, + CFGetTypeID(installerNumber) == CFBooleanGetTypeID(), + let shareCount = int(dictionary["shareCount"]), shareCount >= 0, + let integrationHealth = dictionary["integrationHealth"] as? String, + Self.machineIntegrationHealthStates.contains(integrationHealth), + let runtimeMode = dictionary["runtimeMode"] as? String, + Self.machineRuntimeModes.contains(runtimeMode), + let abi = uint16(dictionary["virtualHardwareABIVersion"]), abi > 0 else { + return nil + } + let planRevision = dictionary["planRevision"].flatMap { uint64($0) } + let planSHA256 = dictionary["planSHA256"] as? String + let backend = (dictionary["backend"] as? String).flatMap( + DoryVirtualizationBackendIdentity.init(rawValue:) + ) + let savedStateSHA256 = dictionary["savedStateSHA256"] as? String + guard (dictionary["planRevision"] == nil) == (planRevision == nil), + (dictionary["planSHA256"] == nil) + || planSHA256?.isLowercaseSHA256 == true, + (dictionary["backend"] == nil) == (backend == nil), + (dictionary["savedStateSHA256"] == nil) + || savedStateSHA256?.isLowercaseSHA256 == true else { + return nil + } + if runtimeMode == "resolved-plan" { + guard planRevision.map({ $0 > 0 }) == true, + planSHA256 != nil, + backend != nil else { return nil } + } else { + guard planRevision == nil, planSHA256 == nil, backend == nil else { return nil } + } + return DorydMachineEventStatus( + machineID: machineID, + configurationRevision: configurationRevision, + observedRevision: observedRevision, + state: state, + hasFailure: failureNumber.boolValue, + memoryMB: memoryMB, + cpuCount: cpuCount, + displayMode: displayMode, + bootMode: bootMode, + installerMediaAttached: installerNumber.boolValue, + shareCount: shareCount, + integrationHealth: integrationHealth, + runtimeMode: runtimeMode, + virtualHardwareABIVersion: abi, + planRevision: planRevision, + planSHA256: planSHA256, + backend: backend, + savedStateSHA256: savedStateSHA256 + ) + } + + nonisolated private static let machineEventStates: Set = [ + "created", "starting", "running", "paused", "suspended", "stopped", "failed", + ] + nonisolated private static let machineIntegrationHealthStates: Set = [ + "inactive", "missing-tools", "incompatible", "degraded", "compatibility", "healthy", + ] + nonisolated private static let machineRuntimeModes: Set = [ + "legacy-compatibility", "resolved-plan", "requires-replanning", + ] + nonisolated private static func machineImportAssessment( from dictionary: NSDictionary ) -> DorydMachineImportAssessment? { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 622ccce7..24099051 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -619,6 +619,118 @@ struct DorydClientTests { } } + @MainActor + @Test func machineEventCursorRequiresExactOrderedSafeEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = FakeDorydService.machineEventBatchRow() + service.setMachineEventBatch(valid) + let batch = try await client.machineEvents(afterSequence: 4) + #expect(batch.headSequence == 5) + #expect(!batch.snapshotRequired) + #expect(batch.events.map(\.sequence) == [5]) + #expect(batch.events.first?.status?.state == "running") + + let eventRows = valid["events"] as! [NSDictionary] + let invalidStatusEvent = eventRows[0].mutableCopy() as! NSMutableDictionary + let invalidStatus = (invalidStatusEvent["status"] as! NSDictionary) + .mutableCopy() as! NSMutableDictionary + invalidStatus["hostPath"] = "/private/source" + invalidStatusEvent["status"] = invalidStatus + let invalidStatusBatch = valid.mutableCopy() as! NSMutableDictionary + invalidStatusBatch["events"] = [invalidStatusEvent] + service.setMachineEventBatch(invalidStatusBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + + let gap = valid.mutableCopy() as! NSMutableDictionary + let gapEvent = eventRows[0].mutableCopy() as! NSMutableDictionary + gapEvent["sequence"] = UInt64(6) + gap["headSequence"] = UInt64(6) + gap["events"] = [gapEvent] + service.setMachineEventBatch(gap) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + + let contradictory = valid.mutableCopy() as! NSMutableDictionary + contradictory["snapshotRequired"] = true + service.setMachineEventBatch(contradictory) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + } + + @MainActor + @Test func appStoreUsesMachineEventCursorWithSnapshotFallback() async throws { + let base = "/tmp/dory-events-app-\(getpid())-\(UInt32.random(in: 0..= 1 + && service.machineListCount >= 1 + && store.machines.contains(where: { $0.name == "dev" }) + } + + let initialLists = service.machineListCount + let initialQueries = service.machineEventQueryCount + service.setMachineEventBatch([ + "schemaVersion": UInt16(1), + "headSequence": UInt64(1), + "snapshotRequired": false, + "events": [] as [NSDictionary], + ]) + store.loadMachines() + try await waitUntil { service.machineEventQueryCount > initialQueries } + try await Task.sleep(for: .milliseconds(50)) + #expect(service.machineListCount == initialLists) + + let beforeChangedList = service.machineListCount + service.setMachineEventBatch( + FakeDorydService.machineEventBatchRow(sequence: 2) + ) + store.loadMachines() + try await waitUntil { service.machineListCount > beforeChangedList } + + let beforeFallbackList = service.machineListCount + service.setMachineEventBatch([:]) + store.loadMachines() + try await waitUntil { service.machineListCount > beforeFallbackList } + } + @MainActor @Test func machineImportAssessmentRequiresExactClosedEvidence() async throws { let listener = NSXPCListener.anonymous() @@ -3498,6 +3610,9 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineGuestExportOperationResponseOverride: NSDictionary? private var _machineGuestExportCurrentResponseOverride: NSDictionary? private var _machineImportAssessmentOverride: NSDictionary? + private var _machineEventBatchOverride: NSDictionary? + private var _machineEventQueryCount = 0 + private var _machineListCount = 0 private var _machineGuestExportCancelCount = 0 private var _machineGuestExportDiscardCount = 0 private var runtimeIdentityOverride: NSDictionary? @@ -3584,6 +3699,21 @@ private final class FakeDorydService: NSObject, DorydControlXPC { _machineImportAssessmentOverride = response } + func setMachineEventBatch(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineEventBatchOverride = response + } + + var machineEventQueryCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineEventQueryCount + } + + var machineListCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineListCount + } + func machineGuestExportOperationResponse( operationID: String, phase: String @@ -4139,11 +4269,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC { func machineList(reply: @escaping (NSArray, String) -> Void) { lock.lock() + _machineListCount += 1 let rows = machines.keys.sorted().compactMap { machines[$0] } lock.unlock() reply(rows as NSArray, "") } + func machineEvents( + _ afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _machineEventQueryCount += 1 + let row = _machineEventBatchOverride ?? [ + "schemaVersion": UInt16(1), + "headSequence": afterSequence, + "snapshotRequired": afterSequence == 0, + "events": [] as [NSDictionary], + ] + lock.unlock() + reply(true, row, "") + } + func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let total = (Self.uint64(machines[machineID]?["memoryMB"]) ?? 2048) * 1_048_576 @@ -5093,6 +5240,38 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] as NSDictionary } + static func machineEventBatchRow(sequence: UInt64 = 5) -> NSDictionary { + [ + "schemaVersion": UInt16(1), + "headSequence": sequence, + "snapshotRequired": false, + "events": [[ + "schemaVersion": UInt16(1), + "sequence": sequence, + "observedAtUnixMilliseconds": Int64(1_000), + "machineID": "dev", + "kind": "updated", + "status": [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "configurationRevision": String(repeating: "a", count: 64), + "observedRevision": String(repeating: "b", count: 64), + "state": "running", + "hasFailure": false, + "memoryMB": UInt64(2_048), + "cpuCount": 2, + "displayMode": "headless", + "bootMode": "linux-kernel", + "installerMediaAttached": false, + "shareCount": 0, + "integrationHealth": "missing-tools", + "runtimeMode": "legacy-compatibility", + "virtualHardwareABIVersion": UInt16(1), + ] as NSDictionary, + ] as NSDictionary] as [NSDictionary], + ] + } + static func importAssessmentRow() -> NSDictionary { [ "schemaVersion": 1, diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index fb021ec9..2efc5699 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -202,6 +202,16 @@ Control-plane responsibilities: - authorize access to files selected through the app and materialize security-scoped bookmarks; - refuse unsupported or unqualified combinations rather than applying hidden workarounds. +The current Linux bridge publishes this status boundary through +[`DoryMachineEventStore`](../dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift). The +daemon reconciles immutable, secret-free machine projections into an owner-only, fsync-durable, +bounded journal with a cross-process monotonic sequence. A zero, future, or expired cursor requests +an exact machine-list snapshot instead of returning a partial history. Configuration changes are +tracked by a digest of file identity rather than configuration contents; observed agent, address, +socket-availability, and balloon changes use a separate non-secret revision digest. The app advances +its cursor only after a required snapshot succeeds and falls back to the existing list call when +talking to an older daemon or when event evidence is malformed. + [`MachineManager`](../dory-core-swift/Sources/DorydKit/MachineManager.swift) becomes the migration host for a `WorkspaceCoordinator`, `WorkspaceRepository`, `OperationJournal`, and `BackendRegistry`. Its existing persistence, helper supervision, readiness handoff, snapshot logic, diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift b/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift new file mode 100644 index 00000000..396e2f06 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift @@ -0,0 +1,596 @@ +import Darwin +import CryptoKit +import DoryOperations +import Foundation + +public enum DoryMachineEventKind: String, Codable, Sendable, Hashable { + case updated + case removed +} + +/// A bounded, non-secret status projection used to decide when clients must refresh their full +/// immutable machine list. Host paths, environment values, error text, and process identifiers are +/// deliberately absent from both the durable journal and XPC event rows. +public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var machineID: String + /// SHA-256 of non-secret filesystem identity for the authoritative machine configuration. + /// This changes when persisted configuration bytes are replaced or edited without placing + /// environment values or host paths in the event journal. + public var configurationRevision: String + /// Digest of bounded, non-secret observed fields that are intentionally not duplicated in the + /// public event row. This makes agent, address, socket-availability, and balloon changes + /// observable without persisting their values. + public var observedRevision: String + public var state: String + public var hasFailure: Bool + public var memoryMB: UInt64 + public var cpuCount: Int + public var displayMode: String + public var bootMode: String + public var installerMediaAttached: Bool + public var shareCount: Int + public var integrationHealth: String + public var runtimeMode: String + public var virtualHardwareABIVersion: UInt16 + public var planRevision: UInt64? + public var planSHA256: String? + public var backend: String? + public var savedStateSHA256: String? + + init( + status: DoryMachineStatus, + configurationRevision: String, + observedRevision: String + ) { + schemaVersion = Self.currentSchemaVersion + machineID = status.id + self.configurationRevision = configurationRevision + self.observedRevision = observedRevision + state = status.state.rawValue + hasFailure = status.state == .failed || status.lastError != nil + memoryMB = status.memoryMB + cpuCount = status.cpuCount + displayMode = status.displayMode.rawValue + bootMode = status.bootMode.rawValue + installerMediaAttached = status.installerMediaAttached + shareCount = status.shares.count + integrationHealth = status.integrationHealth.state.rawValue + runtimeMode = status.runtimeIdentity.mode.rawValue + virtualHardwareABIVersion = status.runtimeIdentity.virtualHardwareABIVersion + planRevision = status.runtimeIdentity.planRevision + planSHA256 = status.runtimeIdentity.resolvedPlanSHA256 + backend = status.runtimeIdentity.backend?.rawValue + savedStateSHA256 = status.savedState?.stateFileSHA256 + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + Self.isMachineID(machineID), + Self.isSHA256(configurationRevision), + Self.isSHA256(observedRevision), + DoryMachineState(rawValue: state) != nil, + memoryMB > 0, + cpuCount > 0, + DoryMachineDisplayMode(rawValue: displayMode) != nil, + DoryMachineBootMode(rawValue: bootMode) != nil, + shareCount >= 0, + DoryGuestIntegrationHealthState(rawValue: integrationHealth) != nil, + DoryMachineRuntimeIdentityMode(rawValue: runtimeMode) != nil, + virtualHardwareABIVersion > 0, + savedStateSHA256.map(Self.isSHA256) ?? true else { + return false + } + if runtimeMode == DoryMachineRuntimeIdentityMode.resolvedPlan.rawValue { + return planRevision.map({ $0 > 0 }) == true + && planSHA256.map(Self.isSHA256) == true + && backend.flatMap(DoryVirtualizationBackendIdentity.init(rawValue:)) != nil + } + return planRevision == nil && planSHA256 == nil && backend == nil + } + + private static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } + + fileprivate static func observedRevisionPayload(status: DoryMachineStatus) throws -> Data { + let input = DoryMachineEventObservedRevisionInput( + agentBuild: status.agentBuild, + agentProtocolVersion: status.agentProtocolVersion, + agentCapabilities: status.agentCapabilities + .map { "\($0.id)@\($0.version)" } + .sorted(), + effectiveAddress: status.address, + configuredAddress: status.configuredAddress, + runtimeAddress: status.runtimeAddress, + handoffFDCount: status.handoffFDCount, + currentBalloonTargetMB: status.currentBalloonTargetMB, + hasAgentSocket: status.agentSocketPath != nil, + hasDockerSocket: status.dockerdSocketPath != nil, + hasShellSocket: status.shellSocketPath != nil, + hasControlSocket: status.controlSocketPath != nil + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(input) + } +} + +private struct DoryMachineEventObservedRevisionInput: Codable { + var agentBuild: String? + var agentProtocolVersion: UInt32? + var agentCapabilities: [String] + var effectiveAddress: String? + var configuredAddress: String? + var runtimeAddress: String? + var handoffFDCount: Int + var currentBalloonTargetMB: UInt64 + var hasAgentSocket: Bool + var hasDockerSocket: Bool + var hasShellSocket: Bool + var hasControlSocket: Bool +} + +public struct DoryMachineEvent: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var sequence: UInt64 + public var observedAtUnixMilliseconds: Int64 + public var machineID: String + public var kind: DoryMachineEventKind + public var status: DoryMachineEventStatus? + + public init( + sequence: UInt64, + observedAtUnixMilliseconds: Int64, + machineID: String, + kind: DoryMachineEventKind, + status: DoryMachineEventStatus? + ) { + schemaVersion = Self.currentSchemaVersion + self.sequence = sequence + self.observedAtUnixMilliseconds = observedAtUnixMilliseconds + self.machineID = machineID + self.kind = kind + self.status = status + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + sequence > 0, + observedAtUnixMilliseconds > 0, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !machineID.hasPrefix(".") else { + return false + } + switch kind { + case .updated: + return status?.machineID == machineID && status?.isValid == true + case .removed: + return status == nil + } + } +} + +public struct DoryMachineEventBatch: Sendable, Equatable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var headSequence: UInt64 + public var snapshotRequired: Bool + public var events: [DoryMachineEvent] + + public init( + headSequence: UInt64, + snapshotRequired: Bool, + events: [DoryMachineEvent] + ) { + schemaVersion = Self.currentSchemaVersion + self.headSequence = headSequence + self.snapshotRequired = snapshotRequired + self.events = events + } +} + +enum DoryMachineEventStoreError: Error, Equatable { + case invalidRoot + case invalidStatus + case invalidRecord + case sequenceExhausted + case filesystem(String) +} + +private struct DoryMachineEventStoreRecord: Codable, Sendable, Equatable { + static let currentSchemaVersion: UInt16 = 1 + + var schemaVersion: UInt16 = Self.currentSchemaVersion + var headSequence: UInt64 + var projections: [String: DoryMachineEventStatus] + var events: [DoryMachineEvent] + + var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + projections.allSatisfy({ key, value in + key == value.machineID && value.isValid + }), + events.allSatisfy(\.isValid), + events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count else { + return false + } + if events.isEmpty { return headSequence == 0 } + guard events.last?.sequence == headSequence, + let first = events.first?.sequence else { return false } + let offset = UInt64(events.count - 1) + guard headSequence >= offset else { return false } + let expectedFirst = headSequence - offset + return first == expectedFirst + && zip(events, events.dropFirst()).allSatisfy { lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + } + } +} + +/// Cross-process durable event cursor for daemon machine projections. Reconciliation compares a +/// fresh immutable MachineManager list under the service's query lock, publishes one deterministic +/// event per changed workspace, and retains a bounded replay tail. A stale cursor receives an +/// explicit snapshot requirement instead of a partial history. +final class DoryMachineEventStore: @unchecked Sendable { + static let recordFileName = ".machine-events-v1.json" + static let lockFileName = ".machine-events-v1.lock" + static let keyFileName = ".machine-events-v1.key" + static let keyTemporaryPrefix = ".machine-events-v1.key.tmp-" + static let temporaryPrefix = ".machine-events-v1.tmp-" + private static let maximumRecordBytes: Int64 = 16 * 1_024 * 1_024 + + let root: String + private let historyLimit: Int + private let now: @Sendable () -> Int64 + private let lock = NSLock() + + init( + root: String, + historyLimit: Int = 2_048, + now: @escaping @Sendable () -> Int64 = { + Int64((Date().timeIntervalSince1970 * 1_000).rounded()) + } + ) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + self.historyLimit = max(1, historyLimit) + self.now = now + } + + func reconcile( + statuses: [DoryMachineStatus], + afterSequence: UInt64 + ) throws -> DoryMachineEventBatch { + lock.lock() + defer { lock.unlock() } + return try withFileLock { + var record = try readRecord() + let observationKey = try loadOrCreateObservationKey() + let projections = try statuses.map { status in + DoryMachineEventStatus( + status: status, + configurationRevision: try configurationRevision( + machineID: status.id + ), + observedRevision: try observedRevision( + status: status, + key: observationKey + ) + ) + } + guard projections.allSatisfy(\.isValid), + Set(projections.map(\.machineID)).count == projections.count else { + throw DoryMachineEventStoreError.invalidStatus + } + let current = Dictionary(uniqueKeysWithValues: projections.map { + ($0.machineID, $0) + }) + let changedIDs = Set(record.projections.keys).union(current.keys).filter { + record.projections[$0] != current[$0] + }.sorted() + if !changedIDs.isEmpty { + let timestamp = now() + guard timestamp > 0 else { + throw DoryMachineEventStoreError.invalidRecord + } + for machineID in changedIDs { + guard record.headSequence < UInt64.max else { + throw DoryMachineEventStoreError.sequenceExhausted + } + record.headSequence += 1 + if let projection = current[machineID] { + record.events.append(DoryMachineEvent( + sequence: record.headSequence, + observedAtUnixMilliseconds: timestamp, + machineID: machineID, + kind: .updated, + status: projection + )) + } else { + record.events.append(DoryMachineEvent( + sequence: record.headSequence, + observedAtUnixMilliseconds: timestamp, + machineID: machineID, + kind: .removed, + status: nil + )) + } + } + record.projections = current + if record.events.count > historyLimit { + record.events.removeFirst(record.events.count - historyLimit) + } + guard record.isValid else { + throw DoryMachineEventStoreError.invalidRecord + } + try publish(record) + } + + let oldest = record.events.first?.sequence + let fellBehind = oldest.map { oldestSequence in + oldestSequence > 1 && afterSequence < oldestSequence - 1 + } ?? false + let snapshotRequired = afterSequence == 0 + || afterSequence > record.headSequence + || fellBehind + let events = snapshotRequired ? [] : record.events.filter { + $0.sequence > afterSequence + } + return DoryMachineEventBatch( + headSequence: record.headSequence, + snapshotRequired: snapshotRequired, + events: events + ) + } + } + + private func configurationRevision(machineID: String) throws -> String { + guard machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !machineID.hasPrefix(".") else { + throw DoryMachineEventStoreError.invalidStatus + } + let machineDirectory = root + "/" + machineID + var directoryInfo = stat() + guard lstat(machineDirectory, &directoryInfo) == 0, + (directoryInfo.st_mode & S_IFMT) == S_IFDIR, + directoryInfo.st_uid == getuid(), + (directoryInfo.st_mode & 0o077) == 0 else { + throw DoryMachineEventStoreError.invalidStatus + } + let path = machineDirectory + "/machine.json" + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { + throw DoryMachineEventStoreError.invalidStatus + } + defer { _ = close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size > 0 else { + throw DoryMachineEventStoreError.invalidStatus + } + let authority = [ + String(info.st_dev), + String(info.st_ino), + String(info.st_size), + String(info.st_ctimespec.tv_sec), + String(info.st_ctimespec.tv_nsec), + ].joined(separator: ":") + return SHA256.hash(data: Data(authority.utf8)).map { + String(format: "%02x", $0) + }.joined() + } + + private func observedRevision( + status: DoryMachineStatus, + key: SymmetricKey + ) throws -> String { + let payload = try DoryMachineEventStatus.observedRevisionPayload(status: status) + return HMAC.authenticationCode(for: payload, using: key).map { + String(format: "%02x", $0) + }.joined() + } + + private func loadOrCreateObservationKey() throws -> SymmetricKey { + let path = root + "/" + Self.keyFileName + var info = stat() + if lstat(path, &info) == 0 { + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw Self.filesystem("open machine event key") } + defer { _ = close(descriptor) } + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size == 32 else { + throw DoryMachineEventStoreError.invalidRecord + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + guard data.count == 32 else { + throw DoryMachineEventStoreError.invalidRecord + } + return SymmetricKey(data: data) + } + guard errno == ENOENT else { throw Self.filesystem("inspect machine event key") } + + let key = SymmetricKey(size: .bits256) + let data = key.withUnsafeBytes { Data($0) } + let temporary = root + "/" + Self.keyTemporaryPrefix + + UUID().uuidString.lowercased() + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("create machine event key") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try write(data, to: descriptor, operation: "write machine event key") + guard fchmod(descriptor, mode_t(0o600)) == 0, fsync(descriptor) == 0 else { + throw Self.filesystem("sync machine event key") + } + guard rename(temporary, path) == 0 else { + throw Self.filesystem("publish machine event key") + } + removeTemporary = false + try Self.syncDirectory(root) + return key + } + + private func readRecord() throws -> DoryMachineEventStoreRecord { + let path = root + "/" + Self.recordFileName + var info = stat() + if lstat(path, &info) != 0 { + guard errno == ENOENT else { throw Self.filesystem("inspect machine event record") } + return DoryMachineEventStoreRecord( + headSequence: 0, + projections: [:], + events: [] + ) + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw Self.filesystem("open machine event record") } + defer { _ = close(descriptor) } + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size > 0, + info.st_size <= Self.maximumRecordBytes else { + throw DoryMachineEventStoreError.invalidRecord + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + guard Int64(data.count) == info.st_size, + let record = try? JSONDecoder().decode( + DoryMachineEventStoreRecord.self, + from: data + ), record.isValid, + record.events.count <= historyLimit else { + throw DoryMachineEventStoreError.invalidRecord + } + return record + } + + private func publish(_ record: DoryMachineEventStoreRecord) throws { + guard record.isValid else { throw DoryMachineEventStoreError.invalidRecord } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(record) + guard !data.isEmpty, data.count <= Int(Self.maximumRecordBytes) else { + throw DoryMachineEventStoreError.invalidRecord + } + let path = root + "/" + Self.recordFileName + let temporary = root + "/" + Self.temporaryPrefix + + UUID().uuidString.lowercased() + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("create machine event record") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try write(data, to: descriptor, operation: "write machine event record") + guard fchmod(descriptor, mode_t(0o600)) == 0, fsync(descriptor) == 0 else { + throw Self.filesystem("sync machine event record") + } + guard rename(temporary, path) == 0 else { + throw Self.filesystem("publish machine event record") + } + removeTemporary = false + try Self.syncDirectory(root) + } + + private func withFileLock(_ body: () throws -> T) throws -> T { + guard Self.isPrivateDirectory(root) else { + throw DoryMachineEventStoreError.invalidRoot + } + let path = root + "/" + Self.lockFileName + let descriptor = open( + path, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("open machine event lock") } + defer { _ = close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0 else { + throw DoryMachineEventStoreError.invalidRecord + } + while flock(descriptor, LOCK_EX) != 0 { + if errno == EINTR { continue } + throw Self.filesystem("acquire machine event lock") + } + defer { _ = flock(descriptor, LOCK_UN) } + return try body() + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private func write(_ data: Data, to descriptor: Int32, operation: String) throws { + try data.withUnsafeBytes { bytes in + var remaining = bytes.count + guard var pointer = bytes.baseAddress else { + throw DoryMachineEventStoreError.invalidRecord + } + while remaining > 0 { + let count = Darwin.write(descriptor, pointer, remaining) + if count > 0 { + remaining -= count + pointer = pointer.advanced(by: count) + } else if count < 0, errno == EINTR { + continue + } else { + throw Self.filesystem(operation) + } + } + } + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { throw filesystem("open machine event directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { throw filesystem("sync machine event directory") } + } + + private static func filesystem(_ operation: String) -> DoryMachineEventStoreError { + .filesystem("\(operation): \(String(cString: strerror(errno)))") + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 3b0fdb85..96c3639d 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -24,6 +24,7 @@ import Foundation func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) + func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index e8118e0d..939650f7 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -11,6 +11,7 @@ public final class DorydService: NSObject, DorydControl { private let productionPlanningController: (any DoryDaemonVirtualMachineProductionPlanningControlling)? private let machineImportEnvironment: DoryMachineImportEnvironment + private let machineEventStore: DoryMachineEventStore? private let machineBackupScheduler: MachineBackupScheduler? private let remoteManager: RemoteMachineManager? private let networkingController: NetworkingController? @@ -23,6 +24,7 @@ public final class DorydService: NSObject, DorydControl { private let healthReporter: HealthReporter private let incidentWriter: IncidentWriter? private let runtimeModeLock = NSLock() + private let machineEventQueryLock = NSLock() public init( socketPath: String, @@ -50,6 +52,9 @@ public final class DorydService: NSObject, DorydControl { self.machineManager = machineManager self.productionPlanningController = productionPlanningController self.machineImportEnvironment = machineImportEnvironment + self.machineEventStore = machineManager.map { + DoryMachineEventStore(root: $0.managedStateDirectory) + } self.machineBackupScheduler = machineBackupScheduler self.remoteManager = remoteManager self.networkingController = networkingController @@ -390,6 +395,27 @@ public final class DorydService: NSObject, DorydControl { reply(machineManager.list().map(\.xpcDictionary) as NSArray, "") } + public func machineEvents( + _ afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager, let machineEventStore else { + reply(false, [:], "machine event stream is not configured") + return + } + machineEventQueryLock.lock() + defer { machineEventQueryLock.unlock() } + do { + let batch = try machineEventStore.reconcile( + statuses: machineManager.list(), + afterSequence: afterSequence + ) + reply(true, batch.xpcDictionary, "") + } catch { + reply(false, [:], "machine event stream is unavailable: \(error)") + } + } + public func machineStats( _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void @@ -2274,6 +2300,58 @@ private extension DoryMachineImportAssessment { } } +private extension DoryMachineEventBatch { + var xpcDictionary: NSDictionary { + [ + "schemaVersion": schemaVersion, + "headSequence": headSequence, + "snapshotRequired": snapshotRequired, + "events": events.map(\.xpcDictionary), + ] + } +} + +private extension DoryMachineEvent { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "sequence": sequence, + "observedAtUnixMilliseconds": observedAtUnixMilliseconds, + "machineID": machineID, + "kind": kind.rawValue, + ] + if let status { dictionary["status"] = status.xpcDictionary } + return dictionary as NSDictionary + } +} + +private extension DoryMachineEventStatus { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "machineID": machineID, + "configurationRevision": configurationRevision, + "observedRevision": observedRevision, + "state": state, + "hasFailure": hasFailure, + "memoryMB": memoryMB, + "cpuCount": cpuCount, + "displayMode": displayMode, + "bootMode": bootMode, + "installerMediaAttached": installerMediaAttached, + "shareCount": shareCount, + "integrationHealth": integrationHealth, + "runtimeMode": runtimeMode, + "virtualHardwareABIVersion": virtualHardwareABIVersion, + ] + if let planRevision { dictionary["planRevision"] = planRevision } + if let planSHA256 { dictionary["planSHA256"] = planSHA256 } + if let backend { dictionary["backend"] = backend } + if let savedStateSHA256 { dictionary["savedStateSHA256"] = savedStateSHA256 } + return dictionary as NSDictionary + } +} + private extension DoryInstalledDesktopPayloadReceipt { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift new file mode 100644 index 00000000..12540656 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift @@ -0,0 +1,239 @@ +import Foundation +import Testing +@testable import DorydKit + +@Suite("Durable machine event stream") +struct DoryMachineEventStreamTests { + @Test("ordered status and removal events survive daemon restart without secrets") + func orderedDurableEvents() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineEventStore( + root: root.path, + now: { 1_000 } + ) + try writeMachineConfiguration(root: root, bytes: Data("first".utf8)) + var status = machineStatus(state: .stopped) + + let initial = try store.reconcile(statuses: [status], afterSequence: 0) + #expect(initial.snapshotRequired) + #expect(initial.headSequence == 1) + #expect(initial.events.isEmpty) + + let unchanged = try store.reconcile( + statuses: [status], + afterSequence: initial.headSequence + ) + #expect(!unchanged.snapshotRequired) + #expect(unchanged.events.isEmpty) + + status.state = .running + status.pid = 44 + status.environment = ["TOKEN": "opaque-secret-value"] + status.shares = [DoryMachineShareConfiguration( + tag: "project", + hostPath: "/Users/private/source", + guestPath: "/workspace", + readOnly: false + )] + let changed = try store.reconcile(statuses: [status], afterSequence: 1) + #expect(changed.headSequence == 2) + #expect(changed.events.map(\.sequence) == [2]) + #expect(changed.events.first?.kind == .updated) + #expect(changed.events.first?.status?.state == "running") + #expect(changed.events.first?.status?.shareCount == 1) + + let priorRevision = try #require( + changed.events.first?.status?.configurationRevision + ) + try writeMachineConfiguration(root: root, bytes: Data("second".utf8)) + let configurationChanged = try store.reconcile( + statuses: [status], + afterSequence: 2 + ) + #expect(configurationChanged.events.map(\.sequence) == [3]) + #expect( + configurationChanged.events.first?.status?.configurationRevision + != priorRevision + ) + + status.runtimeAddress = "192.0.2.44" + let observedChanged = try store.reconcile( + statuses: [status], + afterSequence: 3 + ) + #expect(observedChanged.events.map(\.sequence) == [4]) + #expect(!String(describing: observedChanged).contains("192.0.2.44")) + + let persisted = try String( + contentsOf: root.appendingPathComponent(DoryMachineEventStore.recordFileName), + encoding: .utf8 + ) + #expect(!persisted.contains("opaque-secret-value")) + #expect(!persisted.contains("/Users/private/source")) + #expect(!persisted.contains("192.0.2.44")) + #expect(!persisted.contains("pid")) + + let restarted = DoryMachineEventStore(root: root.path, now: { 1_001 }) + let removed = try restarted.reconcile(statuses: [], afterSequence: 4) + #expect(removed.headSequence == 5) + #expect(removed.events.map(\.kind) == [.removed]) + #expect(removed.events.first?.machineID == "dev") + #expect(removed.events.first?.status == nil) + } + + @Test("bounded history explicitly requires a fresh snapshot for stale and future cursors") + func boundedHistoryReset() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineEventStore( + root: root.path, + historyLimit: 2, + now: { 2_000 } + ) + try writeMachineConfiguration(root: root, bytes: Data("bounded".utf8)) + var status = machineStatus(state: .created) + _ = try store.reconcile(statuses: [status], afterSequence: 0) + for state in [DoryMachineState.starting, .running, .paused] { + status.state = state + _ = try store.reconcile(statuses: [status], afterSequence: 1) + } + + let stale = try store.reconcile(statuses: [status], afterSequence: 1) + #expect(stale.headSequence == 4) + #expect(stale.snapshotRequired) + #expect(stale.events.isEmpty) + + let future = try store.reconcile(statuses: [status], afterSequence: 99) + #expect(future.snapshotRequired) + #expect(future.events.isEmpty) + + let replay = try store.reconcile(statuses: [status], afterSequence: 2) + #expect(!replay.snapshotRequired) + #expect(replay.events.map(\.sequence) == [3, 4]) + } + + @Test("corrupt symlinked and hard-linked event authority fails closed") + func filesystemAuthorityFailsClosed() throws { + for variant in ["corrupt", "symlink", "hardlink"] { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineEventStore(root: root.path, now: { 3_000 }) + try writeMachineConfiguration(root: root, bytes: Data("authority".utf8)) + _ = try store.reconcile( + statuses: [machineStatus(state: .stopped)], + afterSequence: 0 + ) + let record = root.appendingPathComponent(DoryMachineEventStore.recordFileName) + switch variant { + case "corrupt": + try Data("{}".utf8).write(to: record) + case "symlink": + let target = root.appendingPathComponent("foreign.json") + try Data("{}".utf8).write(to: target) + try FileManager.default.removeItem(at: record) + try FileManager.default.createSymbolicLink( + atPath: record.path, + withDestinationPath: target.path + ) + case "hardlink": + let alias = root.appendingPathComponent("record-alias.json") + #expect(link(record.path, alias.path) == 0) + default: + Issue.record("unexpected variant") + } + #expect(throws: DoryMachineEventStoreError.self) { + _ = try store.reconcile( + statuses: [machineStatus(state: .running)], + afterSequence: 1 + ) + } + } + } + + @Test("two store instances serialize one monotonic event authority") + func crossInstanceSerialization() async throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let first = DoryMachineEventStore(root: root.path, now: { 4_000 }) + let second = DoryMachineEventStore(root: root.path, now: { 4_001 }) + try writeMachineConfiguration(root: root, bytes: Data("shared".utf8)) + _ = try first.reconcile( + statuses: [machineStatus(state: .stopped)], + afterSequence: 0 + ) + let results = try await withThrowingTaskGroup( + of: DoryMachineEventBatch.self + ) { group in + group.addTask { + try first.reconcile( + statuses: [machineStatus(state: .running)], + afterSequence: 1 + ) + } + group.addTask { + try second.reconcile( + statuses: [machineStatus(state: .paused)], + afterSequence: 1 + ) + } + var values: [DoryMachineEventBatch] = [] + for try await value in group { values.append(value) } + return values + } + #expect(results.count == 2) + let final = try DoryMachineEventStore(root: root.path).reconcile( + statuses: [machineStatus(state: .paused)], + afterSequence: 1 + ) + #expect(final.headSequence >= 2) + #expect(final.events.map(\.sequence) == Array(2...final.headSequence)) + } + + private func privateTemporaryDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-machine-events-\(UUID().uuidString.lowercased())", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + return root + } + + private func writeMachineConfiguration(root: URL, bytes: Data) throws { + let directory = root.appendingPathComponent("dev", isDirectory: true) + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + } + let path = directory.appendingPathComponent("machine.json").path + let temporary = directory.appendingPathComponent("machine.tmp").path + #expect(FileManager.default.createFile( + atPath: temporary, + contents: bytes, + attributes: [.posixPermissions: 0o600] + )) + if rename(temporary, path) != 0 { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: nil + ) + } + } + + private func machineStatus(state: DoryMachineState) -> DoryMachineStatus { + DoryMachineStatus( + id: "dev", + state: state, + memoryMB: 2_048, + cpuCount: 2 + ) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 7c42928f..28b352f0 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1089,6 +1089,85 @@ final class DorydServiceTests: XCTestCase { wait(for: [statusReply], timeout: 5) } + func testMachineEventsOverXPCAreOrderedDurableAndSecretFree() throws { + let base = "/tmp/doryd-service-events-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 19:58:05 +0000 Subject: [PATCH 220/338] feat(vm): persist structured machine failures --- Dory/Models/AppStore.swift | 2 + Dory/Models/Models.swift | 54 +++ Dory/Runtime/Doryd/DorydClient.swift | 232 ++++++++- DoryTests/DorydClientTests.swift | 120 +++++ docs/virtual-workspace-platform.md | 10 + .../DoryMachineDiagnosticsProjection.swift | 6 +- .../DorydKit/DoryMachineEventStream.swift | 111 +++++ .../Sources/DorydKit/DoryMachineFailure.swift | 371 ++++++++++++++ .../Sources/DorydKit/DorydService.swift | 35 ++ .../Sources/DorydKit/HealthReporter.swift | 25 + .../Sources/DorydKit/MachineManager.swift | 452 ++++++++++++++++-- ...oryMachineDiagnosticsProjectionTests.swift | 29 ++ .../DoryMachineEventStreamTests.swift | 47 ++ .../DoryMachineFailureTests.swift | 133 ++++++ .../DorydKitTests/DorydServiceTests.swift | 51 ++ .../DorydKitTests/HealthReporterTests.swift | 39 ++ ...agerLifecycleJournalIntegrationTests.swift | 20 + ...ineManagerSavedStateIntegrationTests.swift | 10 + 18 files changed, 1701 insertions(+), 46 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineFailure.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineFailureTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index dd9e86a0..b1ea8002 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5268,6 +5268,8 @@ final class AppStore { loginShell: isCustomLinux ? "" : (isDesktop ? "/bin/bash" : "/bin/sh"), shellSocketPath: status.shellSocketPath ?? "", processID: status.pid, + failure: status.failure, + activeOperation: status.activeOperation, displayMode: status.displayMode, bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 9d0e6ce9..ea60546b 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -302,6 +302,8 @@ struct Machine: Identifiable, Hashable, Sendable { var sshPort: Int? = nil var shellSocketPath: String = "" var processID: Int32? = nil + var failure: DorydMachineFailure? = nil + var activeOperation: DorydMachineOperationSummary? = nil var displayMode: MachineDisplayMode = .headless var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false @@ -319,6 +321,23 @@ struct Machine: Identifiable, Hashable, Sendable { var runtimeEvidence: [MachineRuntimeEvidence] { var evidence: [MachineRuntimeEvidence] = [] + if let failure { + evidence.append(MachineRuntimeEvidence( + id: "failure", + label: Self.failureLabel(failure.code), + systemImage: "exclamationmark.octagon.fill", + tone: .warning, + detail: Self.failureDetail(failure) + )) + } else if let activeOperation { + evidence.append(MachineRuntimeEvidence( + id: "operation", + label: activeOperation.kind.rawValue.capitalized, + systemImage: "arrow.triangle.2.circlepath", + tone: .standard, + detail: "Operation \(activeOperation.operationID.prefix(8))…" + )) + } switch runtimeIdentity.mode { case "resolved-plan": evidence.append(MachineRuntimeEvidence( @@ -380,6 +399,41 @@ struct Machine: Identifiable, Hashable, Sendable { return evidence } + private static func failureLabel(_ code: DorydMachineFailureCode) -> String { + switch code { + case .lifecycleOperationFailed: "Operation failed" + case .lifecycleRecoveryRequired: "Recovery required" + case .workspaceAuthorityInvalid: "Planning required" + case .backendLaunchFailed: "Backend launch failed" + case .readinessHandoffFailed: "Readiness failed" + case .readinessTimedOut: "Readiness timed out" + case .helperExited: "VM helper exited" + case .savedStateInvalid: "Saved state invalid" + case .resourceAdmissionRejected: "Resources changed" + case .desktopUpdateRecoveryRequired: "Update recovery required" + case .desktopUpdateRolledBack: "Update rolled back" + case .deletionFailed: "Deletion failed" + case .diagnosticPersistenceFailed: "Diagnostics unavailable" + case .unclassified: "Machine failure" + } + } + + private static func failureDetail(_ failure: DorydMachineFailure) -> String { + let recovery: String + switch failure.recoveryDisposition { + case .retry: recovery = "Retry the operation" + case .replan: recovery = "Replan this workspace" + case .repair: recovery = "Run repair and review diagnostics" + case .rollbackCompleted: recovery = "Rollback completed" + case .deleteWorkspace: recovery = "Delete and recreate the workspace" + case .inspectDiagnostics: recovery = "Review diagnostics" + } + if let operationID = failure.operationID { + return "\(recovery) · operation \(operationID.prefix(8))…" + } + return recovery + } + var integrationHealthProjection: DoryGuestIntegrationHealth { if let integrationHealth, integrationHealth.isValid { return integrationHealth diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 82572674..59d8fcdc 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -828,11 +828,103 @@ nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable { } } +nonisolated enum DorydMachineFailureCode: String, Sendable, Equatable, Hashable { + case lifecycleOperationFailed = "lifecycle-operation-failed" + case lifecycleRecoveryRequired = "lifecycle-recovery-required" + case workspaceAuthorityInvalid = "workspace-authority-invalid" + case backendLaunchFailed = "backend-launch-failed" + case readinessHandoffFailed = "readiness-handoff-failed" + case readinessTimedOut = "readiness-timed-out" + case helperExited = "helper-exited" + case savedStateInvalid = "saved-state-invalid" + case resourceAdmissionRejected = "resource-admission-rejected" + case desktopUpdateRecoveryRequired = "desktop-update-recovery-required" + case desktopUpdateRolledBack = "desktop-update-rolled-back" + case deletionFailed = "deletion-failed" + case diagnosticPersistenceFailed = "diagnostic-persistence-failed" + case unclassified +} + +nonisolated enum DorydMachineFailureCauseCode: String, Sendable, Equatable, Hashable { + case configurationAuthority = "configuration-authority" + case runtimeAuthority = "runtime-authority" + case artifactAuthority = "artifact-authority" + case componentAuthority = "component-authority" + case hostQualification = "host-qualification" + case resourceAdmission = "resource-admission" + case processExit = "process-exit" + case readinessGate = "readiness-gate" + case journal + case filesystem + case guestAgent = "guest-agent" + case unknown +} + +nonisolated enum DorydMachineRecoveryDisposition: String, Sendable, Equatable, Hashable { + case retry + case replan + case repair + case rollbackCompleted = "rollback-completed" + case deleteWorkspace = "delete-workspace" + case inspectDiagnostics = "inspect-diagnostics" +} + +nonisolated enum DorydMachineFailureEvidenceKind: String, Sendable, Equatable, Hashable { + case operation + case plan + case backend + case component + case media + case snapshot + case savedState = "saved-state" + case journal + case hostQualification = "host-qualification" +} + +nonisolated struct DorydMachineFailureEvidenceReference: Sendable, Equatable, Hashable { + var kind: DorydMachineFailureEvidenceKind + var identifier: String +} + +nonisolated struct DorydMachineFailure: Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var code: DorydMachineFailureCode + var occurredAtUnixMilliseconds: Int64 + var operationID: String? + var causalChain: [DorydMachineFailureCauseCode] + var recoveryDisposition: DorydMachineRecoveryDisposition + var evidenceReferences: [DorydMachineFailureEvidenceReference] +} + +nonisolated enum DorydMachineOperationKind: String, Sendable, Equatable, Hashable { + case importing + case provisioning + case resolving + case starting + case stopping + case pausing + case resuming + case suspending + case restoring + case snapshotting + case cloning + case updating + case repairing + case deleting +} + +nonisolated struct DorydMachineOperationSummary: Sendable, Equatable, Hashable { + var operationID: String + var kind: DorydMachineOperationKind +} + nonisolated struct DorydMachineStatus: Sendable, Equatable { var id: String var state: String var pid: Int32? var lastError: String? + var failure: DorydMachineFailure? = nil + var activeOperation: DorydMachineOperationSummary? = nil var handoffSocketPath: String? var agentBuild: String? var agentProtocolVersion: UInt32? = nil @@ -917,6 +1009,10 @@ nonisolated struct DorydMachineEventStatus: Sendable, Equatable { var observedRevision: String var state: String var hasFailure: Bool + var failureCode: DorydMachineFailureCode? + var recoveryDisposition: DorydMachineRecoveryDisposition? + var operationID: String? + var operationKind: DorydMachineOperationKind? var memoryMB: UInt64 var cpuCount: Int var displayMode: String @@ -2361,6 +2457,12 @@ nonisolated final class DorydClient: @unchecked Sendable { savedState.value == nil || ["suspended", "starting", "running"].contains(state) else { return nil } + guard let failure = machineFailure(from: dictionary["failure"]), + let activeOperation = machineActiveOperation( + from: dictionary["activeOperation"] + ) else { + return nil + } let displayMode = (dictionary["displayMode"] as? String) .flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless let agentBuild = nonEmptyString(dictionary["agentBuild"]) @@ -2387,6 +2489,8 @@ nonisolated final class DorydClient: @unchecked Sendable { state: state, pid: int32(dictionary["pid"]), lastError: nonEmptyString(dictionary["lastError"]), + failure: failure.value, + activeOperation: activeOperation.value, handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]), agentBuild: agentBuild, agentProtocolVersion: agentHandshake.protocolVersion, @@ -2417,6 +2521,106 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + private struct ParsedMachineFailure { + var value: DorydMachineFailure? + } + + nonisolated private static func machineFailure( + from raw: Any? + ) -> ParsedMachineFailure? { + guard let raw else { return ParsedMachineFailure(value: nil) } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count else { + return nil + } + let requiredKeys: Set = [ + "schemaVersion", "code", "occurredAtUnixMilliseconds", "causalChain", + "recoveryDisposition", "evidenceReferences", + ] + let keys = Set(rawKeys) + guard keys == requiredKeys || keys == requiredKeys.union(["operationID"]), + strictUInt64(dictionary["schemaVersion"]) == 1, + let rawCode = dictionary["code"] as? String, + let code = DorydMachineFailureCode(rawValue: rawCode), + let occurred = strictInt64(dictionary["occurredAtUnixMilliseconds"]), + occurred > 0, + let rawCauses = dictionary["causalChain"] as? [String], + !rawCauses.isEmpty, rawCauses.count <= 8, + rawCauses.count == (dictionary["causalChain"] as? NSArray)?.count, + let causes = Optional(rawCauses.compactMap( + DorydMachineFailureCauseCode.init(rawValue:) + )), causes.count == rawCauses.count, + let rawRecovery = dictionary["recoveryDisposition"] as? String, + let recovery = DorydMachineRecoveryDisposition(rawValue: rawRecovery), + let rawEvidence = dictionary["evidenceReferences"] as? NSArray, + rawEvidence.count <= 16 else { + return nil + } + var evidence: [DorydMachineFailureEvidenceReference] = [] + for row in rawEvidence { + guard let row = row as? NSDictionary, + let evidenceKeys = row.allKeys as? [String], + evidenceKeys.count == row.allKeys.count, + Set(evidenceKeys) == ["kind", "identifier"], + let rawKind = row["kind"] as? String, + let kind = DorydMachineFailureEvidenceKind(rawValue: rawKind), + let identifier = row["identifier"] as? String, + identifier.utf8.count <= 256, + identifier.wholeMatch( + of: /[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}/ + ) != nil else { + return nil + } + evidence.append(.init(kind: kind, identifier: identifier)) + } + guard Set(evidence).count == evidence.count else { return nil } + let operationID = dictionary["operationID"] as? String + guard dictionary["operationID"] == nil || operationID != nil, + operationID.map(isMachineOperationID) ?? true else { + return nil + } + return ParsedMachineFailure(value: DorydMachineFailure( + schemaVersion: 1, + code: code, + occurredAtUnixMilliseconds: occurred, + operationID: operationID, + causalChain: causes, + recoveryDisposition: recovery, + evidenceReferences: evidence + )) + } + + private struct ParsedMachineActiveOperation { + var value: DorydMachineOperationSummary? + } + + nonisolated private static func machineActiveOperation( + from raw: Any? + ) -> ParsedMachineActiveOperation? { + guard let raw else { return ParsedMachineActiveOperation(value: nil) } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["operationID", "kind"], + let operationID = dictionary["operationID"] as? String, + isMachineOperationID(operationID), + let rawKind = dictionary["kind"] as? String, + let kind = DorydMachineOperationKind(rawValue: rawKind) else { + return nil + } + return ParsedMachineActiveOperation(value: .init( + operationID: operationID, + kind: kind + )) + } + + nonisolated private static func isMachineOperationID(_ value: String) -> Bool { + value.wholeMatch( + of: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) != nil + } + private struct ParsedMachineSavedState { var value: DorydMachineSavedStateSummary? } @@ -3140,6 +3344,7 @@ nonisolated final class DorydClient: @unchecked Sendable { guard rawKeys.count == keys.count, requiredKeys.isSubset(of: keys), keys.subtracting(requiredKeys).isSubset(of: [ + "failureCode", "recoveryDisposition", "operationID", "operationKind", "planRevision", "planSHA256", "backend", "savedStateSHA256", ]), uint16(dictionary["schemaVersion"]) == 1, @@ -3170,6 +3375,16 @@ nonisolated final class DorydClient: @unchecked Sendable { return nil } let planRevision = dictionary["planRevision"].flatMap { uint64($0) } + let failureCode = (dictionary["failureCode"] as? String).flatMap( + DorydMachineFailureCode.init(rawValue:) + ) + let recoveryDisposition = (dictionary["recoveryDisposition"] as? String).flatMap( + DorydMachineRecoveryDisposition.init(rawValue:) + ) + let operationID = dictionary["operationID"] as? String + let operationKind = (dictionary["operationKind"] as? String).flatMap( + DorydMachineOperationKind.init(rawValue:) + ) let planSHA256 = dictionary["planSHA256"] as? String let backend = (dictionary["backend"] as? String).flatMap( DoryVirtualizationBackendIdentity.init(rawValue:) @@ -3180,9 +3395,20 @@ nonisolated final class DorydClient: @unchecked Sendable { || planSHA256?.isLowercaseSHA256 == true, (dictionary["backend"] == nil) == (backend == nil), (dictionary["savedStateSHA256"] == nil) - || savedStateSHA256?.isLowercaseSHA256 == true else { + || savedStateSHA256?.isLowercaseSHA256 == true, + (dictionary["failureCode"] == nil) == (failureCode == nil), + (dictionary["recoveryDisposition"] == nil) == (recoveryDisposition == nil), + (dictionary["operationID"] == nil) == (operationID == nil), + (dictionary["operationKind"] == nil) == (operationKind == nil), + operationID.map(isMachineOperationID) ?? true, + (operationID == nil) == (operationKind == nil) else { return nil } + if failureNumber.boolValue { + guard failureCode != nil, recoveryDisposition != nil else { return nil } + } else { + guard failureCode == nil, recoveryDisposition == nil else { return nil } + } if runtimeMode == "resolved-plan" { guard planRevision.map({ $0 > 0 }) == true, planSHA256 != nil, @@ -3196,6 +3422,10 @@ nonisolated final class DorydClient: @unchecked Sendable { observedRevision: observedRevision, state: state, hasFailure: failureNumber.boolValue, + failureCode: failureCode, + recoveryDisposition: recoveryDisposition, + operationID: operationID, + operationKind: operationKind, memoryMB: memoryMB, cpuCount: cpuCount, displayMode: displayMode, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 24099051..26b8e507 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -619,6 +619,74 @@ struct DorydClientTests { } } + @Test func machineListRequiresExactStructuredFailureAndOperationEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let operationID = "01234567-89ab-4cde-8fab-0123456789ab" + let validFailure: NSDictionary = [ + "schemaVersion": UInt16(1), + "code": "readiness-timed-out", + "occurredAtUnixMilliseconds": Int64(1_787_318_400_000), + "operationID": operationID, + "causalChain": ["readiness-gate"], + "recoveryDisposition": "retry", + "evidenceReferences": [[ + "kind": "journal", + "identifier": operationID, + ] as NSDictionary], + ] + let activeOperation: NSDictionary = [ + "operationID": operationID, + "kind": "starting", + ] + service.setMachineFailure( + "dev", + validFailure, + activeOperation: activeOperation + ) + + let valid = try #require((try await client.machineList()).first) + #expect(valid.failure?.code == .readinessTimedOut) + #expect(valid.failure?.causalChain == [.readinessGate]) + #expect(valid.failure?.recoveryDisposition == .retry) + #expect(valid.failure?.operationID == operationID) + #expect(valid.activeOperation?.operationID == operationID) + #expect(valid.activeOperation?.kind == .starting) + + let unknown = validFailure.mutableCopy() as! NSMutableDictionary + unknown["detail"] = "/private/opaque" + service.setMachineFailure("dev", unknown, activeOperation: activeOperation) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let pathEvidence = validFailure.mutableCopy() as! NSMutableDictionary + pathEvidence["evidenceReferences"] = [[ + "kind": "journal", "identifier": "/private/journal", + ] as NSDictionary] + service.setMachineFailure("dev", pathEvidence, activeOperation: activeOperation) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + service.setMachineFailure( + "dev", + validFailure, + activeOperation: [ + "operationID": operationID, + "kind": "future-operation", + ] as NSDictionary + ) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + @MainActor @Test func machineEventCursorRequiresExactOrderedSafeEvidence() async throws { let listener = NSXPCListener.anonymous() @@ -637,6 +705,35 @@ struct DorydClientTests { #expect(batch.events.map(\.sequence) == [5]) #expect(batch.events.first?.status?.state == "running") + let failureBatch = valid.mutableCopy() as! NSMutableDictionary + let failureEvent = (valid["events"] as! [NSDictionary])[0] + .mutableCopy() as! NSMutableDictionary + let failureStatus = (failureEvent["status"] as! NSDictionary) + .mutableCopy() as! NSMutableDictionary + failureStatus["hasFailure"] = true + failureStatus["failureCode"] = "helper-exited" + failureStatus["recoveryDisposition"] = "retry" + failureStatus["operationID"] = "01234567-89ab-4cde-8fab-0123456789ab" + failureStatus["operationKind"] = "starting" + failureEvent["status"] = failureStatus + failureBatch["events"] = [failureEvent] + service.setMachineEventBatch(failureBatch) + let failed = try await client.machineEvents(afterSequence: 4) + #expect(failed.events.first?.status?.failureCode == .helperExited) + #expect(failed.events.first?.status?.recoveryDisposition == .retry) + #expect(failed.events.first?.status?.operationKind == .starting) + + let truncatedFailureBatch = failureBatch.mutableCopy() as! NSMutableDictionary + let truncatedEvent = failureEvent.mutableCopy() as! NSMutableDictionary + let truncatedStatus = failureStatus.mutableCopy() as! NSMutableDictionary + truncatedStatus.removeObject(forKey: "recoveryDisposition") + truncatedEvent["status"] = truncatedStatus + truncatedFailureBatch["events"] = [truncatedEvent] + service.setMachineEventBatch(truncatedFailureBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + let eventRows = valid["events"] as! [NSDictionary] let invalidStatusEvent = eventRows[0].mutableCopy() as! NSMutableDictionary let invalidStatus = (invalidStatusEvent["status"] as! NSDictionary) @@ -3843,6 +3940,29 @@ private final class FakeDorydService: NSObject, DorydControlXPC { machines[machineID] = current.copy() as? NSDictionary } + func setMachineFailure( + _ machineID: String, + _ failure: Any?, + activeOperation: Any? = nil + ) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let failure { + current["failure"] = failure + } else { + current.removeObject(forKey: "failure") + } + if let activeOperation { + current["activeOperation"] = activeOperation + } else { + current.removeObject(forKey: "activeOperation") + } + machines[machineID] = current.copy() as? NSDictionary + } + var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 2efc5699..0d456a53 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -703,6 +703,16 @@ Build on `HealthReporter`, `IncidentWriter`, raw-HV serial logs, `DorydMachineSt VMM handoff. Replace string-only `lastError` as the primary diagnostic with stable error codes, causal chains, recovery disposition, and relevant evidence references. +The current daemon now persists a bounded, owner-only structured failure authority with stable +failure/cause/recovery enums, operation IDs, and path-free evidence references. Machine status, +the durable monotonic event cursor, XPC, `HealthReporter`, and Dory.app project that authority +without relying on free-form error text. `lastError` remains a same-user compatibility field for +older clients, but public CLI/support projections omit it along with environment values and host +paths. Active lifecycle journals expose their operation ID and kind until completion, and terminal +failures retain the originating operation ID across daemon restart. Full operation-ID propagation +through backend/helper/guest/component boundaries, device-level telemetry, and a bounded +per-workspace flight recorder remain required before this section is complete. + ## Qualification and release gates Code existence is not support. A capability may be advertised only when the exact release diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift index f80ee918..b5416b2a 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift @@ -21,6 +21,9 @@ public enum DoryMachineDiagnosticsProjection { "shellsocketpath", "controlsocketpath", ] + private static let freeFormDiagnosticKeys: Set = [ + "lasterror", + ] public static func supportSafeMachineStatus(_ status: NSDictionary) -> NSDictionary { sanitize(status) as? NSDictionary ?? [:] @@ -37,7 +40,8 @@ public enum DoryMachineDiagnosticsProjection { guard let key = rawKey as? String else { continue } let normalizedKey = key.lowercased() if environmentKeys.contains(normalizedKey) - || hostPathKeys.contains(normalizedKey) { continue } + || hostPathKeys.contains(normalizedKey) + || freeFormDiagnosticKeys.contains(normalizedKey) { continue } result[key] = sanitize(item) } return result.copy() as? NSDictionary ?? [:] diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift b/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift index 396e2f06..0ac6b29e 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineEventStream.swift @@ -26,6 +26,10 @@ public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { public var observedRevision: String public var state: String public var hasFailure: Bool + public var failureCode: String? + public var recoveryDisposition: String? + public var operationID: String? + public var operationKind: String? public var memoryMB: UInt64 public var cpuCount: Int public var displayMode: String @@ -40,6 +44,32 @@ public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { public var backend: String? public var savedStateSHA256: String? + private enum CodingKeys: String, CodingKey { + case schemaVersion + case machineID + case configurationRevision + case observedRevision + case state + case hasFailure + case failureCode + case recoveryDisposition + case operationID + case operationKind + case memoryMB + case cpuCount + case displayMode + case bootMode + case installerMediaAttached + case shareCount + case integrationHealth + case runtimeMode + case virtualHardwareABIVersion + case planRevision + case planSHA256 + case backend + case savedStateSHA256 + } + init( status: DoryMachineStatus, configurationRevision: String, @@ -51,6 +81,12 @@ public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { self.observedRevision = observedRevision state = status.state.rawValue hasFailure = status.state == .failed || status.lastError != nil + failureCode = status.failure?.code.rawValue + ?? (hasFailure ? DoryMachineFailureCode.unclassified.rawValue : nil) + recoveryDisposition = status.failure?.recoveryDisposition.rawValue + ?? (hasFailure ? DoryMachineRecoveryDisposition.inspectDiagnostics.rawValue : nil) + operationID = status.activeOperationID + operationKind = status.activeOperationKind memoryMB = status.memoryMB cpuCount = status.cpuCount displayMode = status.displayMode.rawValue @@ -66,6 +102,60 @@ public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { savedStateSHA256 = status.savedState?.stateFileSHA256 } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion) + machineID = try container.decode(String.self, forKey: .machineID) + configurationRevision = try container.decode( + String.self, + forKey: .configurationRevision + ) + observedRevision = try container.decode(String.self, forKey: .observedRevision) + state = try container.decode(String.self, forKey: .state) + hasFailure = try container.decode(Bool.self, forKey: .hasFailure) + failureCode = try container.decodeIfPresent(String.self, forKey: .failureCode) + recoveryDisposition = try container.decodeIfPresent( + String.self, + forKey: .recoveryDisposition + ) + operationID = try container.decodeIfPresent(String.self, forKey: .operationID) + operationKind = try container.decodeIfPresent(String.self, forKey: .operationKind) + memoryMB = try container.decode(UInt64.self, forKey: .memoryMB) + cpuCount = try container.decode(Int.self, forKey: .cpuCount) + displayMode = try container.decode(String.self, forKey: .displayMode) + bootMode = try container.decode(String.self, forKey: .bootMode) + installerMediaAttached = try container.decode( + Bool.self, + forKey: .installerMediaAttached + ) + shareCount = try container.decode(Int.self, forKey: .shareCount) + integrationHealth = try container.decode(String.self, forKey: .integrationHealth) + runtimeMode = try container.decode(String.self, forKey: .runtimeMode) + virtualHardwareABIVersion = try container.decode( + UInt16.self, + forKey: .virtualHardwareABIVersion + ) + planRevision = try container.decodeIfPresent(UInt64.self, forKey: .planRevision) + planSHA256 = try container.decodeIfPresent(String.self, forKey: .planSHA256) + backend = try container.decodeIfPresent(String.self, forKey: .backend) + savedStateSHA256 = try container.decodeIfPresent( + String.self, + forKey: .savedStateSHA256 + ) + + // Schema-v1 records written before structured failures contained only hasFailure. Treat + // the exact absence of both later fields as bounded legacy evidence, then republish the + // normalized projection on the next change. A partial or explicit malformed shape still + // fails `isValid` and therefore fails the durable journal closed. + if hasFailure, + !container.contains(.failureCode), + !container.contains(.recoveryDisposition) { + failureCode = DoryMachineFailureCode.unclassified.rawValue + recoveryDisposition = DoryMachineRecoveryDisposition + .inspectDiagnostics.rawValue + } + } + public var isValid: Bool { guard schemaVersion == Self.currentSchemaVersion, Self.isMachineID(machineID), @@ -83,6 +173,27 @@ public struct DoryMachineEventStatus: Codable, Sendable, Equatable, Hashable { savedStateSHA256.map(Self.isSHA256) ?? true else { return false } + if hasFailure { + guard failureCode.flatMap(DoryMachineFailureCode.init(rawValue:)) != nil, + recoveryDisposition.flatMap( + DoryMachineRecoveryDisposition.init(rawValue:) + ) != nil else { + return false + } + } else if failureCode != nil || recoveryDisposition != nil { + return false + } + if (operationID == nil) != (operationKind == nil) { return false } + if let operationID, + operationID.wholeMatch( + of: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) == nil { + return false + } + if let operationKind, + DoryWorkspaceMutationKind(rawValue: operationKind) == nil { + return false + } if runtimeMode == DoryMachineRuntimeIdentityMode.resolvedPlan.rawValue { return planRevision.map({ $0 > 0 }) == true && planSHA256.map(Self.isSHA256) == true diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFailure.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFailure.swift new file mode 100644 index 00000000..5ac72844 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFailure.swift @@ -0,0 +1,371 @@ +import Darwin +import Foundation + +public enum DoryMachineFailureCode: String, Codable, Sendable, CaseIterable { + case lifecycleOperationFailed = "lifecycle-operation-failed" + case lifecycleRecoveryRequired = "lifecycle-recovery-required" + case workspaceAuthorityInvalid = "workspace-authority-invalid" + case backendLaunchFailed = "backend-launch-failed" + case readinessHandoffFailed = "readiness-handoff-failed" + case readinessTimedOut = "readiness-timed-out" + case helperExited = "helper-exited" + case savedStateInvalid = "saved-state-invalid" + case resourceAdmissionRejected = "resource-admission-rejected" + case desktopUpdateRecoveryRequired = "desktop-update-recovery-required" + case desktopUpdateRolledBack = "desktop-update-rolled-back" + case deletionFailed = "deletion-failed" + case diagnosticPersistenceFailed = "diagnostic-persistence-failed" + case unclassified = "unclassified" +} + +public enum DoryMachineFailureCauseCode: String, Codable, Sendable, CaseIterable { + case configurationAuthority = "configuration-authority" + case runtimeAuthority = "runtime-authority" + case artifactAuthority = "artifact-authority" + case componentAuthority = "component-authority" + case hostQualification = "host-qualification" + case resourceAdmission = "resource-admission" + case processExit = "process-exit" + case readinessGate = "readiness-gate" + case journal = "journal" + case filesystem = "filesystem" + case guestAgent = "guest-agent" + case unknown +} + +public enum DoryMachineRecoveryDisposition: String, Codable, Sendable, CaseIterable { + case retry + case replan + case repair + case rollbackCompleted = "rollback-completed" + case deleteWorkspace = "delete-workspace" + case inspectDiagnostics = "inspect-diagnostics" +} + +public enum DoryMachineFailureEvidenceKind: String, Codable, Sendable, CaseIterable { + case operation + case plan + case backend + case component + case media + case snapshot + case savedState = "saved-state" + case journal + case hostQualification = "host-qualification" +} + +public struct DoryMachineFailureEvidenceReference: + Codable, Sendable, Equatable, Hashable +{ + public var kind: DoryMachineFailureEvidenceKind + public var identifier: String + + public init(kind: DoryMachineFailureEvidenceKind, identifier: String) { + self.kind = kind + self.identifier = identifier + } + + public var isValid: Bool { + identifier.utf8.count <= 256 + && identifier.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}/) != nil + } +} + +/// Stable, path-free failure evidence. Human-readable compatibility detail remains outside this +/// authority because arbitrary error descriptions can contain host paths, process arguments, or +/// guest-controlled strings. +public struct DoryMachineFailure: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var code: DoryMachineFailureCode + public var occurredAtUnixMilliseconds: Int64 + public var operationID: String? + public var causalChain: [DoryMachineFailureCauseCode] + public var recoveryDisposition: DoryMachineRecoveryDisposition + public var evidenceReferences: [DoryMachineFailureEvidenceReference] + + public init( + code: DoryMachineFailureCode, + occurredAtUnixMilliseconds: Int64 = Int64( + max(1, (Date().timeIntervalSince1970 * 1_000).rounded()) + ), + operationID: String? = nil, + causalChain: [DoryMachineFailureCauseCode], + recoveryDisposition: DoryMachineRecoveryDisposition, + evidenceReferences: [DoryMachineFailureEvidenceReference] = [] + ) { + schemaVersion = Self.currentSchemaVersion + self.code = code + self.occurredAtUnixMilliseconds = occurredAtUnixMilliseconds + self.operationID = operationID + self.causalChain = causalChain + self.recoveryDisposition = recoveryDisposition + self.evidenceReferences = evidenceReferences + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + occurredAtUnixMilliseconds > 0, + !causalChain.isEmpty, + causalChain.count <= 8, + evidenceReferences.count <= 16, + evidenceReferences.allSatisfy(\.isValid), + Set(evidenceReferences).count == evidenceReferences.count else { + return false + } + return operationID.map(Self.isOperationID) ?? true + } + + public var compatibilitySummary: String { + switch code { + case .lifecycleOperationFailed: + "The requested lifecycle operation failed." + case .lifecycleRecoveryRequired: + "A durable lifecycle operation requires recovery." + case .workspaceAuthorityInvalid: + "Workspace launch authority is invalid and must be repaired or replanned." + case .backendLaunchFailed: + "The selected virtualization backend could not start." + case .readinessHandoffFailed: + "The VM helper did not provide a valid readiness handoff." + case .readinessTimedOut: + "The VM did not pass its readiness gates before the deadline." + case .helperExited: + "The VM helper exited unexpectedly." + case .savedStateInvalid: + "The durable saved state is missing, changed, or incompatible." + case .resourceAdmissionRejected: + "The VM no longer satisfies its resource admission authority." + case .desktopUpdateRecoveryRequired: + "The installed desktop update requires recovery." + case .desktopUpdateRolledBack: + "The interrupted desktop update was rolled back." + case .deletionFailed: + "Workspace deletion failed and was rolled back." + case .diagnosticPersistenceFailed: + "Machine diagnostics could not be persisted safely." + case .unclassified: + "The machine failed with an unclassified diagnostic." + } + } + + private static func isOperationID(_ value: String) -> Bool { + value.wholeMatch( + of: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) != nil + } +} + +enum DoryMachineFailureStoreError: Error, Equatable { + case invalidRoot + case invalidMachineID + case invalidRecord + case revisionExhausted + case filesystem(String) +} + +private struct DoryMachineFailureStoreRecord: Codable, Equatable { + static let currentSchemaVersion: UInt16 = 1 + + var schemaVersion: UInt16 = Self.currentSchemaVersion + var revision: UInt64 + var failures: [String: DoryMachineFailure] + + var isValid: Bool { + schemaVersion == Self.currentSchemaVersion + && failures.allSatisfy { machineID, failure in + DoryMachineFailureStore.isMachineID(machineID) && failure.isValid + } + } +} + +/// Owner-only, cross-process durable authority for the latest structured failure of each local +/// workspace. The record contains no free-form detail, environment data, or host paths. +final class DoryMachineFailureStore: @unchecked Sendable { + static let recordFileName = ".machine-failures-v1.json" + static let lockFileName = ".machine-failures-v1.lock" + static let temporaryPrefix = ".machine-failures-v1.tmp-" + private static let maximumRecordBytes: Int64 = 4 * 1_024 * 1_024 + + private let root: String + private let lock = NSLock() + + init(root: String) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + } + + func failures() throws -> [String: DoryMachineFailure] { + try synchronized { try readRecord().failures } + } + + func set(_ failure: DoryMachineFailure, for machineID: String) throws { + guard Self.isMachineID(machineID) else { + throw DoryMachineFailureStoreError.invalidMachineID + } + guard failure.isValid else { throw DoryMachineFailureStoreError.invalidRecord } + try synchronized { + var record = try readRecord() + guard record.revision < UInt64.max else { + throw DoryMachineFailureStoreError.revisionExhausted + } + record.revision += 1 + record.failures[machineID] = failure + try publish(record) + } + } + + func clear(_ machineID: String) throws { + guard Self.isMachineID(machineID) else { + throw DoryMachineFailureStoreError.invalidMachineID + } + try synchronized { + var record = try readRecord() + guard record.failures[machineID] != nil else { return } + guard record.revision < UInt64.max else { + throw DoryMachineFailureStoreError.revisionExhausted + } + record.revision += 1 + record.failures.removeValue(forKey: machineID) + try publish(record) + } + } + + private func synchronized(_ body: () throws -> T) throws -> T { + lock.lock() + defer { lock.unlock() } + return try withFileLock(body) + } + + private func readRecord() throws -> DoryMachineFailureStoreRecord { + let path = root + "/" + Self.recordFileName + var info = stat() + if lstat(path, &info) != 0 { + guard errno == ENOENT else { throw Self.filesystem("inspect failure record") } + return DoryMachineFailureStoreRecord(revision: 0, failures: [:]) + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw Self.filesystem("open failure record") } + defer { _ = close(descriptor) } + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size > 0, + info.st_size <= Self.maximumRecordBytes else { + throw DoryMachineFailureStoreError.invalidRecord + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data = try handle.readToEnd() ?? Data() + guard Int64(data.count) == info.st_size, + let record = try? JSONDecoder().decode( + DoryMachineFailureStoreRecord.self, + from: data + ), record.isValid else { + throw DoryMachineFailureStoreError.invalidRecord + } + return record + } + + private func publish(_ record: DoryMachineFailureStoreRecord) throws { + guard record.isValid else { throw DoryMachineFailureStoreError.invalidRecord } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(record) + guard !data.isEmpty, data.count <= Int(Self.maximumRecordBytes) else { + throw DoryMachineFailureStoreError.invalidRecord + } + let path = root + "/" + Self.recordFileName + let temporary = root + "/" + Self.temporaryPrefix + + UUID().uuidString.lowercased() + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("create failure record") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var remaining = bytes.count + guard var pointer = bytes.baseAddress else { + throw DoryMachineFailureStoreError.invalidRecord + } + while remaining > 0 { + let count = Darwin.write(descriptor, pointer, remaining) + if count > 0 { + remaining -= count + pointer = pointer.advanced(by: count) + } else if count < 0, errno == EINTR { + continue + } else { + throw Self.filesystem("write failure record") + } + } + } + guard fchmod(descriptor, mode_t(0o600)) == 0, fsync(descriptor) == 0 else { + throw Self.filesystem("sync failure record") + } + guard rename(temporary, path) == 0 else { + throw Self.filesystem("publish failure record") + } + removeTemporary = false + try Self.syncDirectory(root) + } + + private func withFileLock(_ body: () throws -> T) throws -> T { + guard Self.isPrivateDirectory(root) else { + throw DoryMachineFailureStoreError.invalidRoot + } + let path = root + "/" + Self.lockFileName + let descriptor = open( + path, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("open failure lock") } + defer { _ = close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0 else { + throw DoryMachineFailureStoreError.invalidRecord + } + while flock(descriptor, LOCK_EX) != 0 { + if errno == EINTR { continue } + throw Self.filesystem("acquire failure lock") + } + defer { _ = flock(descriptor, LOCK_UN) } + return try body() + } + + fileprivate static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { throw filesystem("open failure directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { throw filesystem("sync failure directory") } + } + + private static func filesystem(_ operation: String) -> DoryMachineFailureStoreError { + .filesystem("\(operation): \(String(cString: strerror(errno)))") + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 939650f7..1e1f16b1 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -2194,6 +2194,15 @@ private extension DoryMachineStatus { if let pid { dictionary["pid"] = pid } + if let failure, failure.isValid { + dictionary["failure"] = failure.xpcDictionary + } + if let activeOperationID, let activeOperationKind { + dictionary["activeOperation"] = [ + "operationID": activeOperationID, + "kind": activeOperationKind, + ] as NSDictionary + } if let handoffSocketPath { dictionary["handoffSocketPath"] = handoffSocketPath } @@ -2271,6 +2280,26 @@ private extension DoryMachineStatus { } } +private extension DoryMachineFailure { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "code": code.rawValue, + "occurredAtUnixMilliseconds": occurredAtUnixMilliseconds, + "causalChain": causalChain.map(\.rawValue), + "recoveryDisposition": recoveryDisposition.rawValue, + "evidenceReferences": evidenceReferences.map { reference in + [ + "kind": reference.kind.rawValue, + "identifier": reference.identifier, + ] as NSDictionary + }, + ] + if let operationID { dictionary["operationID"] = operationID } + return dictionary as NSDictionary + } +} + private extension DoryMachineImportAssessment { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ @@ -2344,6 +2373,12 @@ private extension DoryMachineEventStatus { "runtimeMode": runtimeMode, "virtualHardwareABIVersion": virtualHardwareABIVersion, ] + if let failureCode { dictionary["failureCode"] = failureCode } + if let recoveryDisposition { + dictionary["recoveryDisposition"] = recoveryDisposition + } + if let operationID { dictionary["operationID"] = operationID } + if let operationKind { dictionary["operationKind"] = operationKind } if let planRevision { dictionary["planRevision"] = planRevision } if let planSHA256 { dictionary["planSHA256"] = planSHA256 } if let backend { dictionary["backend"] = backend } diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 571a8bc2..eb6f5902 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -2080,6 +2080,27 @@ public final class HealthReporter: @unchecked Sendable { if let reason = identity.invalidationReason { data["replanning_reason"] = reason.rawValue } + if let failure = status.failure, failure.isValid { + data["failure_code"] = failure.code.rawValue + data["failure_occurred_at_ms"] = String( + failure.occurredAtUnixMilliseconds + ) + data["failure_recovery"] = failure.recoveryDisposition.rawValue + data["failure_causes"] = failure.causalChain + .map(\.rawValue) + .joined(separator: ",") + if let operationID = failure.operationID { + data["failure_operation_id"] = operationID + } + data["failure_evidence"] = failure.evidenceReferences.map { + "\($0.kind.rawValue):\($0.identifier)" + }.joined(separator: ",") + } + if let operationID = status.activeOperationID, + let operationKind = status.activeOperationKind { + data["active_operation_id"] = operationID + data["active_operation_kind"] = operationKind + } if issues.isEmpty, let plan = identity.resolvedPlan { data["plan_sha256"] = identity.resolvedPlanSHA256 data["plan_revision"] = String(plan.planRevision) @@ -2143,6 +2164,10 @@ public final class HealthReporter: @unchecked Sendable { healthStatus = .warn code = "machine.requires_replanning" action = "Resolve and approve a current launch plan before starting this workspace." + } else if let failure = status.failure { + healthStatus = status.state == .failed ? .fail : .warn + code = "machine.failure.\(failure.code.rawValue)" + action = "Follow the recorded recovery disposition before the next mutation." } else if status.state == .failed { healthStatus = .fail code = "machine.runtime_failed" diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index dba08c10..fd5e425e 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -332,6 +332,9 @@ public struct DoryMachineStatus: Sendable, Equatable { public var state: DoryMachineState public var pid: Int32? public var lastError: String? + public var failure: DoryMachineFailure? + public var activeOperationID: String? + public var activeOperationKind: String? public var handoffSocketPath: String? public var agentBuild: String? public var agentProtocolVersion: UInt32? @@ -366,6 +369,9 @@ public struct DoryMachineStatus: Sendable, Equatable { state: DoryMachineState, pid: Int32? = nil, lastError: String? = nil, + failure: DoryMachineFailure? = nil, + activeOperationID: String? = nil, + activeOperationKind: String? = nil, handoffSocketPath: String? = nil, agentBuild: String? = nil, agentProtocolVersion: UInt32? = nil, @@ -400,6 +406,9 @@ public struct DoryMachineStatus: Sendable, Equatable { self.state = state self.pid = pid self.lastError = lastError + self.failure = failure + self.activeOperationID = activeOperationID + self.activeOperationKind = activeOperationKind self.handoffSocketPath = handoffSocketPath self.agentBuild = agentBuild self.agentProtocolVersion = agentProtocolVersion @@ -937,6 +946,7 @@ public final class MachineManager: @unchecked Sendable { private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore + private let failureStore: DoryMachineFailureStore private let launchPolicy: DoryMachineLaunchPolicy private let lifecycleJournalStore: DoryOperationJournalStore? private let lifecycleJournalInitializationError: String? @@ -995,6 +1005,7 @@ public final class MachineManager: @unchecked Sendable { self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore( root: configuration.stateDirectory ) + self.failureStore = DoryMachineFailureStore(root: configuration.stateDirectory) self.savedStateStore = DoryMachineSavedStateStore(root: configuration.stateDirectory) do { let store = try DoryOperationJournalStore( @@ -1042,8 +1053,52 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentityStore: runtimeIdentityStore, savedStateStore: savedStateStore ) + do { + let failures = try failureStore.failures() + for (machineID, failure) in failures where machines[machineID] != nil { + machines[machineID]?.failure = failure + machines[machineID]?.lastError = failure.compatibilitySummary + } + } catch { + for machineID in machines.keys { + let failure = DoryMachineFailure( + code: .diagnosticPersistenceFailed, + causalChain: [.filesystem], + recoveryDisposition: .repair + ) + machines[machineID]?.failure = failure + machines[machineID]?.lastError = failure.compatibilitySummary + } + } + // Saved-state inspection runs while reconstructing the machine table, before the + // instance can publish a durable structured diagnostic. Convert that bounded restart + // failure here after loading any previously persisted failure authority. Existing + // records win; an invalid store has already failed closed above. + for machineID in machines.keys { + guard var entry = machines[machineID], + entry.state == .failed, + entry.failure == nil, + let diagnostic = entry.lastError else { continue } + setFailure( + on: &entry, + code: .savedStateInvalid, + message: diagnostic, + causes: [.artifactAuthority, .runtimeAuthority], + recoveryDisposition: .repair + ) + machines[machineID] = entry + } for (machineID, diagnostic) in lifecycleRecoveryDiagnostics { - machines[machineID]?.lastError = diagnostic + if var entry = machines[machineID] { + setFailure( + on: &entry, + code: .lifecycleRecoveryRequired, + message: diagnostic, + causes: [.journal], + recoveryDisposition: .repair + ) + machines[machineID] = entry + } } reconcileLoadedWorkspaceProjections() recoverInterruptedDesktopUpdates() @@ -1262,6 +1317,13 @@ public final class MachineManager: @unchecked Sendable { } catch { throw MachineManagerError.persistence("could not create machine state root: \(error)") } + do { + try failureStore.clear(machine.id) + } catch { + throw MachineManagerError.persistence( + "could not prepare machine diagnostic authority: \(error)" + ) + } let statePath = machineStateDirectory(id: machine.id) guard mkdir(statePath, 0o700) == 0 else { if errno == EEXIST { @@ -2500,7 +2562,7 @@ public final class MachineManager: @unchecked Sendable { && productionResourceAdmissionLedger != nil entry.state = configuration.requiresReadyHandoff || requiresAdmissionCommit ? .starting : .running - entry.lastError = nil + clearFailure(on: &entry) machines[id] = entry lock.unlock() @@ -2508,14 +2570,23 @@ public final class MachineManager: @unchecked Sendable { try processStarter(process) } catch { lock.lock() - machines[id]?.handoffServer?.stop() - machines[id]?.handoffServer = nil - machines[id]?.launchID = nil - machines[id]?.runtimeAddress = nil - machines[id]?.activeResolvedPlan = nil - machines[id]?.activeBackend = nil - machines[id]?.state = .failed - machines[id]?.lastError = "\(error)" + if var current = machines[id] { + current.handoffServer?.stop() + current.handoffServer = nil + current.launchID = nil + current.runtimeAddress = nil + current.activeResolvedPlan = nil + current.activeBackend = nil + current.state = .failed + setFailure( + on: ¤t, + code: .backendLaunchFailed, + message: "\(error)", + causes: [.processExit], + recoveryDisposition: .retry + ) + machines[id] = current + } lock.unlock() process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) do { try markResolvedAdmissionStopped(plan: resolvedPlan) } @@ -2540,10 +2611,17 @@ public final class MachineManager: @unchecked Sendable { } catch { process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds) lock.lock() - if machines[id]?.launchID == launchID { - machines[id]?.state = .failed - machines[id]?.lastError = "\(error)" - machines[id]?.activeResolvedPlan = nil + if var current = machines[id], current.launchID == launchID { + current.state = .failed + current.activeResolvedPlan = nil + setFailure( + on: ¤t, + code: .resourceAdmissionRejected, + message: "\(error)", + causes: [.resourceAdmission], + recoveryDisposition: .repair + ) + machines[id] = current } lock.unlock() try? markResolvedAdmissionStopped(plan: resolvedPlan) @@ -2625,7 +2703,18 @@ public final class MachineManager: @unchecked Sendable { entry.runtimeAddress = nil entry.currentBalloonTargetMB = nil entry.state = .failed - entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s" + self.setFailure( + on: &entry, + code: .readinessTimedOut, + message: "vmm ready handoff timed out after \(Int(timeout))s", + causes: [.readinessGate], + recoveryDisposition: .retry + ) + // A terminal machine state must never advertise the journal as still active. The + // durable failure retained the operation ID above; journal settlement follows under + // the workspace operation fence. + entry.activeOperationID = nil + entry.activeOperationKind = nil let admissionPlan = entry.activeResolvedPlan entry.activeResolvedPlan = nil entry.activeBackend = nil @@ -2664,7 +2753,15 @@ public final class MachineManager: @unchecked Sendable { entry.activeResolvedPlan = nil entry.activeBackend = nil entry.state = .failed - entry.lastError = "dory-vmm \(termination.description)" + setFailure( + on: &entry, + code: .helperExited, + message: "dory-vmm \(termination.description)", + causes: [.processExit], + recoveryDisposition: .retry + ) + entry.activeOperationID = nil + entry.activeOperationKind = nil machines[machineID] = entry lock.unlock() @@ -2861,7 +2958,7 @@ public final class MachineManager: @unchecked Sendable { current.pendingRestoreStatePath = nil current.state = .suspended current.savedStateStatus = DoryMachineSavedStateStatus(manifest: manifest) - current.lastError = nil + clearFailure(on: ¤t) machines[id] = current lock.unlock() committed = true @@ -2872,8 +2969,16 @@ public final class MachineManager: @unchecked Sendable { // over-counted running lease is fail-safe; the next restore or daemon restart // reconciles it before reserving CPU/RAM again. lock.lock() - machines[id]?.lastError = - "saved state committed but resource admission requires reconciliation: \(error)" + if var current = machines[id] { + setFailure( + on: ¤t, + code: .resourceAdmissionRejected, + message: "saved state committed but resource admission requires reconciliation: \(error)", + causes: [.resourceAdmission], + recoveryDisposition: .repair + ) + machines[id] = current + } lock.unlock() } _ = completeCommittedLifecycle( @@ -2905,7 +3010,13 @@ public final class MachineManager: @unchecked Sendable { current.pendingRestoreStatePath = nil current.savedStateStatus = nil current.state = .failed - current.lastError = "saved-state suspension failed after the VMM exited: \(error)" + setFailure( + on: ¤t, + code: .savedStateInvalid, + message: "saved-state suspension failed after the VMM exited: \(error)", + causes: [.artifactAuthority, .processExit], + recoveryDisposition: .repair + ) machines[id] = current } lock.unlock() @@ -3030,7 +3141,13 @@ public final class MachineManager: @unchecked Sendable { current.state = .failed current.pendingRestoreStatePath = nil current.savedStateStatus = nil - current.lastError = "saved-state restoration failed and its authority is invalid" + setFailure( + on: ¤t, + code: .savedStateInvalid, + message: "saved-state restoration failed and its authority is invalid", + causes: [.artifactAuthority, .runtimeAuthority], + recoveryDisposition: .repair + ) machines[id] = current } lock.unlock() @@ -3134,7 +3251,7 @@ public final class MachineManager: @unchecked Sendable { ) } current.state = .paused - current.lastError = nil + clearFailure(on: ¤t) machines[id] = current lock.unlock() if let lifecycle { @@ -3192,7 +3309,7 @@ public final class MachineManager: @unchecked Sendable { ) } current.state = .running - current.lastError = nil + clearFailure(on: ¤t) machines[id] = current lock.unlock() if let lifecycle { @@ -3262,6 +3379,7 @@ public final class MachineManager: @unchecked Sendable { entry.savedStateStatus = nil } entry.state = .stopped + clearFailure(on: &entry) machines[id] = entry lock.unlock() @@ -3291,7 +3409,16 @@ public final class MachineManager: @unchecked Sendable { lifecycle.releaseLease() } lock.lock() - machines[id]?.lastError = "machine stopped but resource settlement requires recovery: \(error)" + if var current = machines[id] { + setFailure( + on: ¤t, + code: .resourceAdmissionRejected, + message: "machine stopped but resource settlement requires recovery: \(error)", + causes: [.resourceAdmission], + recoveryDisposition: .repair + ) + machines[id] = current + } lock.unlock() throw error } @@ -3323,6 +3450,10 @@ public final class MachineManager: @unchecked Sendable { machines[id]?.activeResolvedPlan = nil machines[id]?.activeBackend = nil machines[id]?.state = .stopped + if var entry = machines[id] { + clearFailure(on: &entry) + machines[id] = entry + } } lock.unlock() @@ -3399,6 +3530,7 @@ public final class MachineManager: @unchecked Sendable { // in-memory entry whose storage no longer exists. Recovery can deterministically // finish the still-durable deleting operation on the next daemon start. deletionCommitted = true + try? failureStore.clear(id) try releaseResolvedAdmissionStorage( machineID: id, plan: sourceEntry.runtimeIdentity.resolvedPlan @@ -3432,7 +3564,13 @@ public final class MachineManager: @unchecked Sendable { restored.handoff = nil restored.currentBalloonTargetMB = nil restored.state = .stopped - restored.lastError = "delete failed: \(error)" + setFailure( + on: &restored, + code: .deletionFailed, + message: "delete failed: \(error)", + causes: [.filesystem, .journal], + recoveryDisposition: .retry + ) lock.lock() deletingMachineIDs.remove(id) if machines[id] == nil { machines[id] = restored } @@ -4470,8 +4608,16 @@ public final class MachineManager: @unchecked Sendable { // launch authority. Keep the conservative running reservation for daemon // restart reconciliation instead of falsely rolling back the restore. lock.lock() - machines[machineID]?.lastError = - "snapshot restored; resource settlement requires recovery: \(error)" + if var current = machines[machineID] { + setFailure( + on: ¤t, + code: .resourceAdmissionRejected, + message: "snapshot restored; resource settlement requires recovery: \(error)", + causes: [.resourceAdmission], + recoveryDisposition: .repair + ) + machines[machineID] = current + } lock.unlock() } } @@ -4851,6 +4997,15 @@ public final class MachineManager: @unchecked Sendable { id: id, state: .failed, lastError: entry.lastError ?? "dory-vmm process exited", + failure: entry.failure ?? DoryMachineFailure( + code: .helperExited, + operationID: entry.activeOperationID?.uuidString.lowercased(), + causalChain: [.processExit], + recoveryDisposition: .retry, + evidenceReferences: failureEvidenceReferences(for: entry) + ), + activeOperationID: entry.activeOperationID?.uuidString.lowercased(), + activeOperationKind: entry.activeOperationKind?.rawValue, address: entry.configuration.address, configuredAddress: entry.configuration.address, memoryMB: entry.configuration.memoryMB, @@ -4879,6 +5034,9 @@ public final class MachineManager: @unchecked Sendable { state: entry.state, pid: entry.process?.pid, lastError: entry.lastError, + failure: entry.failure, + activeOperationID: entry.activeOperationID?.uuidString.lowercased(), + activeOperationKind: entry.activeOperationKind?.rawValue, handoffSocketPath: entry.handoffServer?.path, agentBuild: entry.handoff?.ready.agentBuild, agentProtocolVersion: entry.handoff?.ready.agentProtocolVersion, @@ -4914,6 +5072,86 @@ public final class MachineManager: @unchecked Sendable { ) } + private func failureEvidenceReferences( + for entry: MachineEntry + ) -> [DoryMachineFailureEvidenceReference] { + var references: [DoryMachineFailureEvidenceReference] = [] + if let operationID = entry.activeOperationID?.uuidString.lowercased() { + references.append(.init(kind: .operation, identifier: operationID)) + } + if let planSHA256 = entry.runtimeIdentity.resolvedPlanSHA256 { + references.append(.init(kind: .plan, identifier: planSHA256)) + } + if let backend = entry.runtimeIdentity.backend?.rawValue { + references.append(.init(kind: .backend, identifier: backend)) + } + if let savedStateSHA256 = entry.savedStateStatus?.stateFileSHA256 { + references.append(.init(kind: .savedState, identifier: savedStateSHA256)) + } + return references + } + + private func setFailure( + on entry: inout MachineEntry, + code: DoryMachineFailureCode, + message: String, + causes: [DoryMachineFailureCauseCode], + recoveryDisposition: DoryMachineRecoveryDisposition, + extraEvidence: [DoryMachineFailureEvidenceReference] = [] + ) { + let failure = DoryMachineFailure( + code: code, + operationID: entry.activeOperationID?.uuidString.lowercased(), + causalChain: causes, + recoveryDisposition: recoveryDisposition, + evidenceReferences: failureEvidenceReferences(for: entry) + extraEvidence + ) + do { + try failureStore.set(failure, for: entry.configuration.id) + entry.failure = failure + entry.lastError = message + } catch { + entry.failure = DoryMachineFailure( + code: .diagnosticPersistenceFailed, + operationID: entry.activeOperationID?.uuidString.lowercased(), + causalChain: [.filesystem], + recoveryDisposition: .repair, + evidenceReferences: failureEvidenceReferences(for: entry) + ) + entry.lastError = "\(message); structured diagnostics could not be persisted" + } + } + + private func clearFailure(on entry: inout MachineEntry) { + do { + try failureStore.clear(entry.configuration.id) + entry.failure = nil + entry.lastError = nil + } catch { + entry.failure = DoryMachineFailure( + code: .diagnosticPersistenceFailed, + operationID: entry.activeOperationID?.uuidString.lowercased(), + causalChain: [.filesystem], + recoveryDisposition: .repair, + evidenceReferences: failureEvidenceReferences(for: entry) + ) + entry.lastError = "Structured machine diagnostics could not be cleared safely." + } + } + + private func clearActiveOperation( + machineID: String, + operationID: UUID + ) { + lock.lock() + if var entry = machines[machineID], entry.activeOperationID == operationID { + entry.activeOperationID = nil + entry.activeOperationKind = nil + machines[machineID] = entry + } + lock.unlock() + } + private func nativeTypedSettingsSnapshot( id: String ) -> DoryMachineTypedSettingsSnapshot? { @@ -5415,7 +5653,13 @@ public final class MachineManager: @unchecked Sendable { lock.lock() if var entry = machines[machineID] { entry.state = .failed - entry.lastError = "Desktop update recovery is required: the durable update journal is invalid." + setFailure( + on: &entry, + code: .desktopUpdateRecoveryRequired, + message: "Desktop update recovery is required: the durable update journal is invalid.", + causes: [.journal], + recoveryDisposition: .repair + ) machines[machineID] = entry } lock.unlock() @@ -5431,7 +5675,16 @@ public final class MachineManager: @unchecked Sendable { try removeDesktopUpdateJournal(machineID: machineID) } catch { lock.lock() - machines[machineID]?.lastError = "Legacy desktop update committed, but journal cleanup requires retry: \(error)" + if var entry = machines[machineID] { + setFailure( + on: &entry, + code: .desktopUpdateRecoveryRequired, + message: "Legacy desktop update committed, but journal cleanup requires retry: \(error)", + causes: [.journal, .filesystem], + recoveryDisposition: .retry + ) + machines[machineID] = entry + } lock.unlock() } continue @@ -5450,7 +5703,13 @@ public final class MachineManager: @unchecked Sendable { lock.lock() if var entry = machines[machineID] { entry.state = .failed - entry.lastError = "Desktop update recovery is required: committed component evidence does not match machine state." + setFailure( + on: &entry, + code: .desktopUpdateRecoveryRequired, + message: "Desktop update recovery is required: committed component evidence does not match machine state.", + causes: [.componentAuthority, .journal], + recoveryDisposition: .repair + ) machines[machineID] = entry } lock.unlock() @@ -5460,7 +5719,16 @@ public final class MachineManager: @unchecked Sendable { try removeDesktopUpdateJournal(machineID: machineID) } catch { lock.lock() - machines[machineID]?.lastError = "Desktop update committed, but journal cleanup requires retry: \(error)" + if var entry = machines[machineID] { + setFailure( + on: &entry, + code: .desktopUpdateRecoveryRequired, + message: "Desktop update committed, but journal cleanup requires retry: \(error)", + causes: [.journal, .filesystem], + recoveryDisposition: .retry + ) + machines[machineID] = entry + } lock.unlock() } continue @@ -5507,7 +5775,16 @@ public final class MachineManager: @unchecked Sendable { try removeDesktopUpdateJournal(machineID: machineID) lock.lock() if var entry = machines[machineID] { - entry.lastError = "An interrupted desktop update was rolled back to \(journal.snapshotID). Start the machine when ready." + setFailure( + on: &entry, + code: .desktopUpdateRolledBack, + message: "An interrupted desktop update was rolled back to \(journal.snapshotID). Start the machine when ready.", + causes: [.journal], + recoveryDisposition: .rollbackCompleted, + extraEvidence: [ + .init(kind: .snapshot, identifier: journal.snapshotID), + ] + ) machines[machineID] = entry } lock.unlock() @@ -5515,7 +5792,13 @@ public final class MachineManager: @unchecked Sendable { lock.lock() if var entry = machines[machineID] { entry.state = .failed - entry.lastError = "Interrupted desktop update recovery failed: \(error)" + setFailure( + on: &entry, + code: .desktopUpdateRecoveryRequired, + message: "Interrupted desktop update recovery failed: \(error)", + causes: [.journal, .artifactAuthority], + recoveryDisposition: .repair + ) machines[machineID] = entry } lock.unlock() @@ -6675,7 +6958,15 @@ public final class MachineManager: @unchecked Sendable { entry.configuration = configuration entry.currentBalloonTargetMB = nil entry.runtimeIdentity = identity - if let identityPersistenceError { entry.lastError = identityPersistenceError } + if let identityPersistenceError { + setFailure( + on: &entry, + code: .workspaceAuthorityInvalid, + message: identityPersistenceError, + causes: [.runtimeAuthority, .filesystem], + recoveryDisposition: .replan + ) + } machines[configuration.id] = entry } @@ -6799,7 +7090,15 @@ public final class MachineManager: @unchecked Sendable { lock.lock() if var entry = machines[id] { entry.runtimeIdentity = effectiveIdentity - if let persistenceError { entry.lastError = persistenceError } + if let persistenceError { + setFailure( + on: &entry, + code: .workspaceAuthorityInvalid, + message: persistenceError, + causes: [.runtimeAuthority, .filesystem], + recoveryDisposition: .replan + ) + } machines[id] = entry } lock.unlock() @@ -6864,7 +7163,7 @@ public final class MachineManager: @unchecked Sendable { ) } entry.runtimeIdentity = identity - entry.lastError = nil + clearFailure(on: &entry) machines[machineID] = entry lock.unlock() } @@ -7827,7 +8126,13 @@ public final class MachineManager: @unchecked Sendable { case let .success(handoff): guard handoff.ready.machineID == machineID else { entry.state = .failed - entry.lastError = "handoff machine id mismatch: \(handoff.ready.machineID)" + setFailure( + on: &entry, + code: .readinessHandoffFailed, + message: "handoff machine id mismatch: \(handoff.ready.machineID)", + causes: [.readinessGate, .guestAgent], + recoveryDisposition: .repair + ) entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil @@ -7836,7 +8141,7 @@ public final class MachineManager: @unchecked Sendable { break } entry.handoff = handoff - entry.lastError = nil + clearFailure(on: &entry) requiresAdmissionCommit = admissionPlan != nil && productionResourceAdmissionLedger != nil if requiresAdmissionCommit { @@ -7849,7 +8154,13 @@ public final class MachineManager: @unchecked Sendable { } case let .failure(error): entry.state = .failed - entry.lastError = "\(error)" + setFailure( + on: &entry, + code: .readinessHandoffFailed, + message: "\(error)", + causes: [.readinessGate], + recoveryDisposition: .retry + ) entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil @@ -7870,7 +8181,7 @@ public final class MachineManager: @unchecked Sendable { if var current = machines[machineID], current.launchID == launchID, current.state == .starting { current.state = .running - current.lastError = nil + clearFailure(on: ¤t) current.process?.disableRestarts() agentSocketPath = current.handoff?.ready.agentSocketPath machines[machineID] = current @@ -7891,7 +8202,13 @@ public final class MachineManager: @unchecked Sendable { if var current = machines[machineID], current.launchID == launchID { processToStop = current.process current.state = .failed - current.lastError = "resource admission rejected readiness: \(error)" + setFailure( + on: ¤t, + code: .resourceAdmissionRejected, + message: "resource admission rejected readiness: \(error)", + causes: [.resourceAdmission, .readinessGate], + recoveryDisposition: .repair + ) current.handoff = nil current.launchID = nil current.runtimeAddress = nil @@ -9698,6 +10015,13 @@ public final class MachineManager: @unchecked Sendable { } let context = MachineLifecycleJournalContext(operation: operation, lease: lease) activeLifecycleOperations[machineID] = context + lock.lock() + if var entry = machines[machineID] { + entry.activeOperationID = operation.operationID + entry.activeOperationKind = kind + machines[machineID] = entry + } + lock.unlock() return context } @@ -9747,6 +10071,10 @@ public final class MachineManager: @unchecked Sendable { ) } activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) + clearActiveOperation( + machineID: context.operation.source.workspaceID, + operationID: context.operation.operationID + ) context.releaseLease() } @@ -9765,11 +10093,21 @@ public final class MachineManager: @unchecked Sendable { activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) context.releaseLease() lock.lock() - if machines[context.operation.source.workspaceID] != nil { - machines[context.operation.source.workspaceID]?.lastError = - "\(diagnostic): \(error)" + if var entry = machines[context.operation.source.workspaceID] { + setFailure( + on: &entry, + code: .lifecycleRecoveryRequired, + message: "\(diagnostic): \(error)", + causes: [.journal], + recoveryDisposition: .repair + ) + machines[context.operation.source.workspaceID] = entry } lock.unlock() + clearActiveOperation( + machineID: context.operation.source.workspaceID, + operationID: context.operation.operationID + ) return false } } @@ -9781,6 +10119,10 @@ public final class MachineManager: @unchecked Sendable { ) { defer { activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) + clearActiveOperation( + machineID: context.operation.source.workspaceID, + operationID: context.operation.operationID + ) context.releaseLease() } guard var current = try? context.lease.read().state, @@ -9802,6 +10144,25 @@ public final class MachineManager: @unchecked Sendable { stepID: stepID, recoveryAction: rolledBack ? "rollback" : nil ) + lock.lock() + let machineID = context.operation.source.workspaceID + if var entry = machines[machineID], entry.failure == nil { + setFailure( + on: &entry, + code: .lifecycleOperationFailed, + message: "Workspace \(context.operation.kind.rawValue) failed at \(stepID).", + causes: [.journal], + recoveryDisposition: rolledBack ? .rollbackCompleted : .retry, + extraEvidence: [ + .init( + kind: .journal, + identifier: context.operation.operationID.uuidString.lowercased() + ), + ] + ) + machines[machineID] = entry + } + lock.unlock() } private func completeActiveStartLifecycle(id: String) { @@ -11201,6 +11562,9 @@ private struct MachineEntry { var runtimeAddress: String? var currentBalloonTargetMB: UInt64? var lastError: String? + var failure: DoryMachineFailure? = nil + var activeOperationID: UUID? = nil + var activeOperationKind: DoryWorkspaceMutationKind? = nil var activeResolvedPlan: DoryResolvedMachinePlan? var activeBackend: DoryVirtualizationBackendIdentity? var pendingRestoreStatePath: String? diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift index 1987342c..71a49890 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift @@ -90,4 +90,33 @@ struct DoryMachineDiagnosticsProjectionTests { #expect(share["readOnly"] as? Bool == true) #expect(!text.contains(privateRoot)) } + + @Test("support projection drops free-form errors and retains structured recovery evidence") + func omitsFreeFormErrors() throws { + let status: NSDictionary = [ + "id": "dev", + "lastError": "helper failed at /Users/private-account with opaque-secret", + "failure": [ + "schemaVersion": UInt16(1), + "code": "helper-exited", + "occurredAtUnixMilliseconds": Int64(1_787_318_400_000), + "causalChain": ["process-exit"], + "recoveryDisposition": "retry", + "evidenceReferences": [[ + "kind": "backend", "identifier": "dory.raw-hv-linux.v1", + ] as NSDictionary], + ] as NSDictionary, + ] + + let projected = DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status) + let data = try JSONSerialization.data(withJSONObject: projected) + let text = String(decoding: data, as: UTF8.self) + let failure = try #require(projected["failure"] as? NSDictionary) + + #expect(projected["lastError"] == nil) + #expect(failure["code"] as? String == "helper-exited") + #expect(failure["recoveryDisposition"] as? String == "retry") + #expect(!text.contains("opaque-secret")) + #expect(!text.contains("/Users/private-account")) + } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift index 12540656..f6bdf2e3 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineEventStreamTests.swift @@ -113,6 +113,53 @@ struct DoryMachineEventStreamTests { #expect(replay.events.map(\.sequence) == [3, 4]) } + @Test("pre-structured schema-v1 failure events normalize across daemon restart") + func legacyFailureNormalization() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineEventStore(root: root.path, now: { 1_500 }) + try writeMachineConfiguration(root: root, bytes: Data("legacy-failure".utf8)) + var status = machineStatus(state: .failed) + status.lastError = "historical free-form failure" + _ = try store.reconcile(statuses: [status], afterSequence: 0) + + let recordURL = root.appendingPathComponent(DoryMachineEventStore.recordFileName) + let data = try Data(contentsOf: recordURL) + let record = try #require( + try JSONSerialization.jsonObject( + with: data, + options: .mutableContainers + ) as? NSMutableDictionary + ) + let projections = try #require(record["projections"] as? NSMutableDictionary) + let projection = try #require( + (projections["dev"] as? NSDictionary)?.mutableCopy() + as? NSMutableDictionary + ) + projection.removeObject(forKey: "failureCode") + projection.removeObject(forKey: "recoveryDisposition") + projections["dev"] = projection + let events = try #require(record["events"] as? [NSDictionary]) + let event = try #require(events.first?.mutableCopy() as? NSMutableDictionary) + let eventStatus = try #require( + (event["status"] as? NSDictionary)?.mutableCopy() + as? NSMutableDictionary + ) + eventStatus.removeObject(forKey: "failureCode") + eventStatus.removeObject(forKey: "recoveryDisposition") + event["status"] = eventStatus + record["events"] = [event] + try JSONSerialization.data(withJSONObject: record).write(to: recordURL) + + let restarted = DoryMachineEventStore(root: root.path, now: { 1_501 }) + let removed = try restarted.reconcile(statuses: [], afterSequence: 1) + #expect(removed.events.map(\.kind) == [.removed]) + let normalized = try String(contentsOf: recordURL, encoding: .utf8) + #expect(normalized.contains("\"failureCode\" : \"unclassified\"")) + #expect(normalized.contains("\"recoveryDisposition\" : \"inspect-diagnostics\"")) + #expect(!normalized.contains("historical free-form failure")) + } + @Test("corrupt symlinked and hard-linked event authority fails closed") func filesystemAuthorityFailsClosed() throws { for variant in ["corrupt", "symlink", "hardlink"] { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineFailureTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineFailureTests.swift new file mode 100644 index 00000000..a07216e3 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineFailureTests.swift @@ -0,0 +1,133 @@ +import Darwin +import Foundation +import Testing +@testable import DorydKit + +@Suite("Structured machine failure authority", .serialized) +struct DoryMachineFailureTests { + @Test("failure contract is bounded and excludes path-shaped evidence") + func validation() { + let valid = failure(operationID: "12345678-1234-1234-1234-123456789abc") + #expect(valid.isValid) + + var changed = valid + changed.causalChain = [] + #expect(!changed.isValid) + changed = valid + changed.operationID = "not-an-operation" + #expect(!changed.isValid) + changed = valid + changed.evidenceReferences = [ + .init(kind: .media, identifier: "/Users/private/image.iso"), + ] + #expect(!changed.isValid) + } + + @Test("failure survives restart and clearing is durable") + func persistenceAndClear() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFailureStore(root: root.path) + let expected = failure() + try store.set(expected, for: "dev") + + let restarted = DoryMachineFailureStore(root: root.path) + #expect(try restarted.failures()["dev"] == expected) + let persisted = try String( + contentsOf: root.appendingPathComponent( + DoryMachineFailureStore.recordFileName + ), + encoding: .utf8 + ) + #expect(!persisted.contains("/Users/")) + #expect(!persisted.contains("opaque-secret")) + + try restarted.clear("dev") + #expect(try DoryMachineFailureStore(root: root.path).failures().isEmpty) + } + + @Test("two daemon instances serialize independent machine failures") + func crossInstanceSerialization() async throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let first = DoryMachineFailureStore(root: root.path) + let second = DoryMachineFailureStore(root: root.path) + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try first.set(self.failure(), for: "first") } + group.addTask { + try second.set( + self.failure(code: .helperExited), + for: "second" + ) + } + try await group.waitForAll() + } + let failures = try first.failures() + #expect(failures.keys.sorted() == ["first", "second"]) + #expect(failures["second"]?.code == .helperExited) + } + + @Test("corrupt symlinked and hard-linked failure records fail closed") + func filesystemAuthority() throws { + for variant in ["corrupt", "symlink", "hardlink"] { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFailureStore(root: root.path) + try store.set(failure(), for: "dev") + let record = root.appendingPathComponent( + DoryMachineFailureStore.recordFileName + ) + switch variant { + case "corrupt": + try Data("{}".utf8).write(to: record) + case "symlink": + let target = root.appendingPathComponent("foreign.json") + try Data("{}".utf8).write(to: target) + try FileManager.default.removeItem(at: record) + try FileManager.default.createSymbolicLink( + atPath: record.path, + withDestinationPath: target.path + ) + case "hardlink": + #expect(link( + record.path, + root.appendingPathComponent("alias.json").path + ) == 0) + default: + Issue.record("unexpected fixture") + } + #expect(throws: DoryMachineFailureStoreError.self) { + _ = try store.failures() + } + } + } + + private func failure( + code: DoryMachineFailureCode = .readinessTimedOut, + operationID: String? = nil + ) -> DoryMachineFailure { + DoryMachineFailure( + code: code, + occurredAtUnixMilliseconds: 1_000, + operationID: operationID, + causalChain: code == .helperExited ? [.processExit] : [.readinessGate], + recoveryDisposition: .retry, + evidenceReferences: [ + .init(kind: .plan, identifier: String(repeating: "a", count: 64)), + ] + ) + } + + private func privateTemporaryDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-machine-failures-\(UUID().uuidString.lowercased())", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + return root + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 28b352f0..62816c50 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1168,6 +1168,57 @@ final class DorydServiceTests: XCTestCase { wait(for: [removed], timeout: 5) } + func testMachineStatusPublishesStructuredFailureWithoutFreeFormEvidence() throws { + let base = "/tmp/dsf-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 20:58:39 +0000 Subject: [PATCH 221/338] feat(vm): add durable machine flight recorder --- Dory/Models/AppStore.swift | 2 + Dory/Models/Models.swift | 21 + Dory/Runtime/Doryd/DorydClient.swift | 275 ++++++++++ DoryTests/DorydClientTests.swift | 132 +++++ .../DorydKit/DoryMachineFlightRecorder.swift | 491 ++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 71 +++ .../Sources/DorydKit/HealthReporter.swift | 27 +- .../Sources/DorydKit/MachineManager.swift | 383 ++++++++++++-- dory-core-swift/Sources/dorydctl/main.swift | 25 +- .../DoryMachineFlightRecorderTests.swift | 168 ++++++ .../DorydKitTests/DorydServiceTests.swift | 27 + .../DorydKitTests/HealthReporterTests.swift | 22 + ...agerLifecycleJournalIntegrationTests.swift | 45 ++ 14 files changed, 1631 insertions(+), 59 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineFlightRecorderTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index b1ea8002..3c664927 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5270,6 +5270,8 @@ final class AppStore { processID: status.pid, failure: status.failure, activeOperation: status.activeOperation, + flightRecorderHeadSequence: status.flightRecorderHeadSequence, + flightRecorderAvailable: status.flightRecorderAvailable, displayMode: status.displayMode, bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index ea60546b..546c2689 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -304,6 +304,8 @@ struct Machine: Identifiable, Hashable, Sendable { var processID: Int32? = nil var failure: DorydMachineFailure? = nil var activeOperation: DorydMachineOperationSummary? = nil + var flightRecorderHeadSequence: UInt64 = 0 + var flightRecorderAvailable: Bool = false var displayMode: MachineDisplayMode = .headless var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false @@ -338,6 +340,25 @@ struct Machine: Identifiable, Hashable, Sendable { detail: "Operation \(activeOperation.operationID.prefix(8))…" )) } + if recipe == "doryd" { + if !flightRecorderAvailable { + evidence.append(MachineRuntimeEvidence( + id: "flight-recorder", + label: "Recorder unavailable", + systemImage: "waveform.path.ecg.rectangle", + tone: .warning, + detail: "Durable workspace diagnostics need repair" + )) + } else if flightRecorderHeadSequence > 0 { + evidence.append(MachineRuntimeEvidence( + id: "flight-recorder", + label: "Flight recorder", + systemImage: "waveform.path.ecg.rectangle", + tone: .standard, + detail: "Durable through event \(flightRecorderHeadSequence)" + )) + } + } switch runtimeIdentity.mode { case "resolved-plan": evidence.append(MachineRuntimeEvidence( diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 59d8fcdc..c53233a5 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -25,6 +25,7 @@ nonisolated protocol DorydControlXPC { func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineFlightRecorder(_ machineID: String, afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -925,6 +926,8 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var lastError: String? var failure: DorydMachineFailure? = nil var activeOperation: DorydMachineOperationSummary? = nil + var flightRecorderHeadSequence: UInt64 = 0 + var flightRecorderAvailable: Bool = false var handoffSocketPath: String? var agentBuild: String? var agentProtocolVersion: UInt32? = nil @@ -1042,6 +1045,48 @@ nonisolated struct DorydMachineEventBatch: Sendable, Equatable { var events: [DorydMachineEvent] } +nonisolated enum DorydMachineFlightEventKind: String, Sendable, Equatable { + case workspaceCreated = "workspace-created" + case operationStarted = "operation-started" + case operationPhase = "operation-phase" + case backendSpawned = "backend-spawned" + case readinessAccepted = "readiness-accepted" + case readinessRejected = "readiness-rejected" + case resourceTransition = "resource-transition" + case processExited = "process-exited" + case failureRecorded = "failure-recorded" + case operationCompleted = "operation-completed" + case operationFailed = "operation-failed" + case recoveryRequired = "recovery-required" + case workspaceDeleted = "workspace-deleted" +} + +nonisolated struct DorydMachineFlightEvent: Sendable, Equatable { + var sequence: UInt64 + var occurredAtUnixMilliseconds: Int64 + var machineID: String + var operationID: String? + var operationKind: DorydMachineOperationKind? + var kind: DorydMachineFlightEventKind + var phase: String? + var machineState: String? + var failureCode: DorydMachineFailureCode? + var recoveryDisposition: DorydMachineRecoveryDisposition? + var backend: DoryVirtualizationBackendIdentity? + var virtualHardwareABIVersion: UInt16? + var planSHA256: String? + var durationMilliseconds: UInt64? + var deadlineUnixMilliseconds: Int64? + var evidenceReferences: [DorydMachineFailureEvidenceReference] +} + +nonisolated struct DorydMachineFlightRecorderBatch: Sendable, Equatable { + var machineID: String + var headSequence: UInt64 + var snapshotRequired: Bool + var events: [DorydMachineFlightEvent] +} + nonisolated enum DorydMachineImportDisposition: String, Sendable, Equatable { case ready case requiresComponents = "requires-components" @@ -2114,6 +2159,25 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineFlightRecorder( + machineID: String, + afterSequence: UInt64 + ) async throws -> DorydMachineFlightRecorderBatch { + try await statusCommand { proxy, reply in + proxy.machineFlightRecorder( + machineID, + afterSequence: afterSequence, + reply: reply + ) + } decode: { + Self.machineFlightRecorderBatch( + from: $0, + machineID: machineID, + afterSequence: afterSequence + ) + } + } + func machineList() async throws -> [DorydMachineStatus] { try await call { proxy, finish in proxy.machineList { rows, error in @@ -2460,6 +2524,9 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let failure = machineFailure(from: dictionary["failure"]), let activeOperation = machineActiveOperation( from: dictionary["activeOperation"] + ), + let flightRecorder = machineFlightRecorderSummary( + from: dictionary["flightRecorder"] ) else { return nil } @@ -2491,6 +2558,8 @@ nonisolated final class DorydClient: @unchecked Sendable { lastError: nonEmptyString(dictionary["lastError"]), failure: failure.value, activeOperation: activeOperation.value, + flightRecorderHeadSequence: flightRecorder.headSequence, + flightRecorderAvailable: flightRecorder.available, handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]), agentBuild: agentBuild, agentProtocolVersion: agentHandshake.protocolVersion, @@ -2595,6 +2664,36 @@ nonisolated final class DorydClient: @unchecked Sendable { var value: DorydMachineOperationSummary? } + private struct ParsedMachineFlightRecorderSummary { + var headSequence: UInt64 + var available: Bool + } + + nonisolated private static func machineFlightRecorderSummary( + from raw: Any? + ) -> ParsedMachineFlightRecorderSummary? { + // Absence means an older daemon; never claim recorder availability without evidence. + guard let raw else { + return ParsedMachineFlightRecorderSummary( + headSequence: 0, + available: false + ) + } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["headSequence", "available"], + let headSequence = strictUInt64(dictionary["headSequence"]), + let available = dictionary["available"] as? NSNumber, + CFGetTypeID(available) == CFBooleanGetTypeID() else { + return nil + } + return ParsedMachineFlightRecorderSummary( + headSequence: headSequence, + available: available.boolValue + ) + } + nonisolated private static func machineActiveOperation( from raw: Any? ) -> ParsedMachineActiveOperation? { @@ -3292,6 +3391,178 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineFlightRecorderBatch( + from dictionary: NSDictionary, + machineID: String, + afterSequence: UInt64 + ) -> DorydMachineFlightRecorderBatch? { + guard machineID.isSafeMachineIdentifier, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "schemaVersion", "machineID", "headSequence", "snapshotRequired", "events", + ], + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let headSequence = strictUInt64(dictionary["headSequence"]), + let snapshot = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshot) == CFBooleanGetTypeID(), + let rows = dictionary["events"] as? NSArray, + rows.count <= 256 else { + return nil + } + var events: [DorydMachineFlightEvent] = [] + for raw in rows { + guard let row = raw as? NSDictionary, + let event = machineFlightEvent(from: row), + event.machineID == machineID else { + return nil + } + events.append(event) + } + guard events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count, + zip(events, events.dropFirst()).allSatisfy({ lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + }) else { + return nil + } + if events.isEmpty { + guard headSequence == 0 || (!snapshot.boolValue && headSequence == afterSequence) else { + return nil + } + } else { + guard events.last?.sequence == headSequence else { return nil } + if !snapshot.boolValue { + guard afterSequence < UInt64.max, + events.first?.sequence == afterSequence + 1 else { return nil } + } + } + return DorydMachineFlightRecorderBatch( + machineID: machineID, + headSequence: headSequence, + snapshotRequired: snapshot.boolValue, + events: events + ) + } + + nonisolated private static func machineFlightEvent( + from dictionary: NSDictionary + ) -> DorydMachineFlightEvent? { + let required: Set = [ + "schemaVersion", "sequence", "occurredAtUnixMilliseconds", "machineID", + "kind", "evidenceReferences", + ] + let optional: Set = [ + "operationID", "operationKind", "phase", "machineState", "failureCode", + "recoveryDisposition", "backend", "virtualHardwareABIVersion", "planSHA256", + "durationMilliseconds", "deadlineUnixMilliseconds", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + required.isSubset(of: keys), + keys.subtracting(required).isSubset(of: optional), + strictUInt64(dictionary["schemaVersion"]) == 1, + let sequence = strictUInt64(dictionary["sequence"]), sequence > 0, + let occurredAt = strictInt64(dictionary["occurredAtUnixMilliseconds"]), + occurredAt > 0, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let rawKind = dictionary["kind"] as? String, + let kind = DorydMachineFlightEventKind(rawValue: rawKind), + let rawEvidence = dictionary["evidenceReferences"] as? NSArray, + rawEvidence.count <= 16 else { + return nil + } + var evidence: [DorydMachineFailureEvidenceReference] = [] + for raw in rawEvidence { + guard let row = raw as? NSDictionary, + let rowKeys = row.allKeys as? [String], + rowKeys.count == row.allKeys.count, + Set(rowKeys) == ["kind", "identifier"], + let rawEvidenceKind = row["kind"] as? String, + let evidenceKind = DorydMachineFailureEvidenceKind( + rawValue: rawEvidenceKind + ), + let identifier = row["identifier"] as? String, + identifier.utf8.count <= 256, + identifier.wholeMatch( + of: /[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}/ + ) != nil else { + return nil + } + evidence.append(.init(kind: evidenceKind, identifier: identifier)) + } + guard Set(evidence).count == evidence.count else { return nil } + + let operationID = dictionary["operationID"] as? String + let operationKind = (dictionary["operationKind"] as? String).flatMap( + DorydMachineOperationKind.init(rawValue:) + ) + let phase = dictionary["phase"] as? String + let machineState = dictionary["machineState"] as? String + let failureCode = (dictionary["failureCode"] as? String).flatMap( + DorydMachineFailureCode.init(rawValue:) + ) + let recovery = (dictionary["recoveryDisposition"] as? String).flatMap( + DorydMachineRecoveryDisposition.init(rawValue:) + ) + let backend = (dictionary["backend"] as? String).flatMap( + DoryVirtualizationBackendIdentity.init(rawValue:) + ) + let abi = dictionary["virtualHardwareABIVersion"] + .flatMap { strictUInt64($0) } + .flatMap { UInt16(exactly: $0) } + let planSHA256 = dictionary["planSHA256"] as? String + let duration = dictionary["durationMilliseconds"].flatMap(strictUInt64) + let deadline = dictionary["deadlineUnixMilliseconds"].flatMap(strictInt64) + guard (dictionary["operationID"] == nil) == (operationID == nil), + (dictionary["operationKind"] == nil) == (operationKind == nil), + (operationID == nil) == (operationKind == nil), + operationID.map(isMachineOperationID) ?? true, + (dictionary["phase"] == nil) + || phase.map(machineFlightPhases.contains) == true, + (dictionary["machineState"] == nil) + || machineState.map(machineEventStates.contains) == true, + (dictionary["failureCode"] == nil) == (failureCode == nil), + (dictionary["recoveryDisposition"] == nil) == (recovery == nil), + (failureCode == nil) == (recovery == nil), + (dictionary["backend"] == nil) == (backend == nil), + (dictionary["virtualHardwareABIVersion"] == nil) == (abi == nil), + abi.map({ $0 > 0 }) ?? true, + (dictionary["planSHA256"] == nil) + || planSHA256?.isLowercaseSHA256 == true, + (dictionary["durationMilliseconds"] == nil) == (duration == nil), + duration.map({ $0 <= 31 * 24 * 60 * 60 * 1_000 }) ?? true, + (dictionary["deadlineUnixMilliseconds"] == nil) == (deadline == nil), + deadline.map({ $0 > 0 }) ?? true else { + return nil + } + if [.failureRecorded, .readinessRejected, .operationFailed, .recoveryRequired] + .contains(kind), failureCode == nil { + return nil + } + return DorydMachineFlightEvent( + sequence: sequence, + occurredAtUnixMilliseconds: occurredAt, + machineID: machineID, + operationID: operationID, + operationKind: operationKind, + kind: kind, + phase: phase, + machineState: machineState, + failureCode: failureCode, + recoveryDisposition: recovery, + backend: backend, + virtualHardwareABIVersion: abi, + planSHA256: planSHA256, + durationMilliseconds: duration, + deadlineUnixMilliseconds: deadline, + evidenceReferences: evidence + ) + } + nonisolated private static func machineEvent( from dictionary: NSDictionary ) -> DorydMachineEvent? { @@ -3445,6 +3716,10 @@ nonisolated final class DorydClient: @unchecked Sendable { nonisolated private static let machineEventStates: Set = [ "created", "starting", "running", "paused", "suspended", "stopped", "failed", ] + nonisolated private static let machineFlightPhases: Set = [ + "planned", "quiescing", "staging", "verifying", "readyToPublish", + "publishing", "validating", "completed", + ] nonisolated private static let machineIntegrationHealthStates: Set = [ "inactive", "missing-tools", "incompatible", "degraded", "compatibility", "healthy", ] diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 26b8e507..b348de6e 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -687,6 +687,37 @@ struct DorydClientTests { } } + @Test func machineListRequiresExactFlightRecorderSummary() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + service.setMachineFlightRecorderSummary("dev", [ + "headSequence": UInt64(17), + "available": true, + ] as NSDictionary) + let current = try #require((try await client.machineList()).first) + #expect(current.flightRecorderHeadSequence == 17) + #expect(current.flightRecorderAvailable) + + service.setMachineFlightRecorderSummary("dev", nil) + let oldDaemon = try #require((try await client.machineList()).first) + #expect(oldDaemon.flightRecorderHeadSequence == 0) + #expect(!oldDaemon.flightRecorderAvailable) + + service.setMachineFlightRecorderSummary("dev", [ + "headSequence": "17", + "available": true, + ] as NSDictionary) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + @MainActor @Test func machineEventCursorRequiresExactOrderedSafeEvidence() async throws { let listener = NSXPCListener.anonymous() @@ -828,6 +859,65 @@ struct DorydClientTests { try await waitUntil { service.machineListCount > beforeFallbackList } } + @MainActor + @Test func machineFlightRecorderRequiresExactPathFreeCursorEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let event: NSDictionary = [ + "schemaVersion": UInt16(1), + "sequence": UInt64(1), + "occurredAtUnixMilliseconds": Int64(1_000), + "machineID": "dev", + "kind": "workspace-created", + "evidenceReferences": [] as [NSDictionary], + ] + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "headSequence": UInt64(1), + "snapshotRequired": false, + "events": [event], + ] + service.setMachineFlightRecorderBatch(valid) + let batch = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + #expect(batch.headSequence == 1) + #expect(batch.events.first?.kind == .workspaceCreated) + + let leakedEvent = event.mutableCopy() as! NSMutableDictionary + leakedEvent["detail"] = "/private/opaque" + let leaked = valid.mutableCopy() as! NSMutableDictionary + leaked["events"] = [leakedEvent] + service.setMachineFlightRecorderBatch(leaked) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + + let gapEvent = event.mutableCopy() as! NSMutableDictionary + gapEvent["sequence"] = UInt64(2) + let gap = valid.mutableCopy() as! NSMutableDictionary + gap["headSequence"] = UInt64(2) + gap["events"] = [gapEvent] + service.setMachineFlightRecorderBatch(gap) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + } + @MainActor @Test func machineImportAssessmentRequiresExactClosedEvidence() async throws { let listener = NSXPCListener.anonymous() @@ -3708,6 +3798,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineGuestExportCurrentResponseOverride: NSDictionary? private var _machineImportAssessmentOverride: NSDictionary? private var _machineEventBatchOverride: NSDictionary? + private var _machineFlightRecorderBatchOverride: NSDictionary? private var _machineEventQueryCount = 0 private var _machineListCount = 0 private var _machineGuestExportCancelCount = 0 @@ -3963,6 +4054,20 @@ private final class FakeDorydService: NSObject, DorydControlXPC { machines[machineID] = current.copy() as? NSDictionary } + func setMachineFlightRecorderSummary(_ machineID: String, _ summary: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let summary { + current["flightRecorder"] = summary + } else { + current.removeObject(forKey: "flightRecorder") + } + machines[machineID] = current.copy() as? NSDictionary + } + var machineStopCount: Int { lock.lock(); defer { lock.unlock() } return _machineStopCount @@ -4071,6 +4176,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.unlock() } + func setMachineFlightRecorderBatch(_ response: NSDictionary?) { + lock.lock() + _machineFlightRecorderBatchOverride = response + lock.unlock() + } + func setEngineStartResult(ok: Bool, message: String = "") { lock.lock() _engineStartOK = ok @@ -4411,6 +4522,23 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineFlightRecorder( + _ machineID: String, + afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineFlightRecorderBatchOverride ?? [ + "schemaVersion": UInt16(1), + "machineID": machineID, + "headSequence": UInt64(0), + "snapshotRequired": afterSequence > 0, + "events": [] as [NSDictionary], + ] + lock.unlock() + reply(true, row, "") + } + func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let total = (Self.uint64(machines[machineID]?["memoryMB"]) ?? 2048) * 1_048_576 @@ -5154,6 +5282,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode, + "flightRecorder": [ + "headSequence": UInt64(0), + "available": true, + ] as NSDictionary, ] if let pid { row["pid"] = pid } if let agentBuild { diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift new file mode 100644 index 00000000..9f338b02 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift @@ -0,0 +1,491 @@ +import Darwin +import DoryOperations +import Foundation + +public enum DoryMachineFlightEventKind: String, Codable, Sendable, CaseIterable { + case workspaceCreated = "workspace-created" + case operationStarted = "operation-started" + case operationPhase = "operation-phase" + case backendSpawned = "backend-spawned" + case readinessAccepted = "readiness-accepted" + case readinessRejected = "readiness-rejected" + case resourceTransition = "resource-transition" + case processExited = "process-exited" + case failureRecorded = "failure-recorded" + case operationCompleted = "operation-completed" + case operationFailed = "operation-failed" + case recoveryRequired = "recovery-required" + case workspaceDeleted = "workspace-deleted" +} + +/// One bounded, path-free flight-recorder row. It deliberately carries only stable identifiers +/// and classified state. Human-readable error text, host paths, process arguments, environment +/// values, guest payloads, and raw telemetry never enter this authority. +public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var sequence: UInt64 + public var occurredAtUnixMilliseconds: Int64 + public var machineID: String + public var operationID: String? + public var operationKind: String? + public var kind: DoryMachineFlightEventKind + public var phase: String? + public var machineState: String? + public var failureCode: DoryMachineFailureCode? + public var recoveryDisposition: DoryMachineRecoveryDisposition? + public var backend: DoryVirtualizationBackendIdentity? + public var virtualHardwareABIVersion: UInt16? + public var planSHA256: String? + public var durationMilliseconds: UInt64? + public var deadlineUnixMilliseconds: Int64? + public var evidenceReferences: [DoryMachineFailureEvidenceReference] + + public init( + sequence: UInt64, + occurredAtUnixMilliseconds: Int64, + machineID: String, + operationID: String? = nil, + operationKind: String? = nil, + kind: DoryMachineFlightEventKind, + phase: String? = nil, + machineState: String? = nil, + failureCode: DoryMachineFailureCode? = nil, + recoveryDisposition: DoryMachineRecoveryDisposition? = nil, + backend: DoryVirtualizationBackendIdentity? = nil, + virtualHardwareABIVersion: UInt16? = nil, + planSHA256: String? = nil, + durationMilliseconds: UInt64? = nil, + deadlineUnixMilliseconds: Int64? = nil, + evidenceReferences: [DoryMachineFailureEvidenceReference] = [] + ) { + schemaVersion = Self.currentSchemaVersion + self.sequence = sequence + self.occurredAtUnixMilliseconds = occurredAtUnixMilliseconds + self.machineID = machineID + self.operationID = operationID + self.operationKind = operationKind + self.kind = kind + self.phase = phase + self.machineState = machineState + self.failureCode = failureCode + self.recoveryDisposition = recoveryDisposition + self.backend = backend + self.virtualHardwareABIVersion = virtualHardwareABIVersion + self.planSHA256 = planSHA256 + self.durationMilliseconds = durationMilliseconds + self.deadlineUnixMilliseconds = deadlineUnixMilliseconds + self.evidenceReferences = evidenceReferences + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + sequence > 0, + occurredAtUnixMilliseconds > 0, + DoryMachineFlightRecorderStore.isMachineID(machineID), + (operationID == nil) == (operationKind == nil), + operationID.map(Self.isOperationID) ?? true, + operationKind.flatMap(DoryWorkspaceMutationKind.init(rawValue:)) + .map({ _ in true }) ?? (operationKind == nil), + phase.flatMap(DoryOperationPhase.init(rawValue:)) + .map({ _ in true }) ?? (phase == nil), + machineState.flatMap(DoryMachineState.init(rawValue:)) + .map({ _ in true }) ?? (machineState == nil), + virtualHardwareABIVersion.map({ $0 > 0 }) ?? true, + planSHA256.map(Self.isSHA256) ?? true, + durationMilliseconds.map({ $0 <= 31 * 24 * 60 * 60 * 1_000 }) ?? true, + deadlineUnixMilliseconds.map({ $0 > 0 }) ?? true, + evidenceReferences.count <= 16, + evidenceReferences.allSatisfy(\.isValid), + Set(evidenceReferences).count == evidenceReferences.count else { + return false + } + if failureCode == nil, recoveryDisposition != nil { return false } + if kind == .failureRecorded || kind == .readinessRejected + || kind == .operationFailed || kind == .recoveryRequired { + return failureCode != nil && recoveryDisposition != nil + } + return true + } + + private static func isOperationID(_ value: String) -> Bool { + value.wholeMatch( + of: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) != nil + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + +public struct DoryMachineFlightRecorderBatch: Codable, Sendable, Equatable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var machineID: String + public var headSequence: UInt64 + public var snapshotRequired: Bool + public var events: [DoryMachineFlightEvent] + + public init( + machineID: String, + headSequence: UInt64, + snapshotRequired: Bool, + events: [DoryMachineFlightEvent] + ) { + schemaVersion = Self.currentSchemaVersion + self.machineID = machineID + self.headSequence = headSequence + self.snapshotRequired = snapshotRequired + self.events = events + } +} + +enum DoryMachineFlightRecorderStoreError: Error, Equatable { + case invalidRoot + case invalidMachineID + case invalidEvent + case invalidRecord + case capacityExceeded + case sequenceExhausted + case revisionExhausted + case filesystem(String) +} + +private struct DoryMachineFlightRecorderLog: Codable, Sendable, Equatable { + var headSequence: UInt64 + var events: [DoryMachineFlightEvent] + + var isValid: Bool { + guard events.count <= DoryMachineFlightRecorderStore.maximumEventsPerWorkspace, + events.allSatisfy(\.isValid), + events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count else { + return false + } + if events.isEmpty { return headSequence == 0 } + guard events.last?.sequence == headSequence, + let first = events.first?.sequence else { return false } + let offset = UInt64(events.count - 1) + guard headSequence >= offset, first == headSequence - offset else { return false } + return zip(events, events.dropFirst()).allSatisfy { lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + } + } +} + +private struct DoryMachineFlightRecorderRecord: Codable, Sendable, Equatable { + static let currentSchemaVersion: UInt16 = 1 + + var schemaVersion: UInt16 = Self.currentSchemaVersion + var revision: UInt64 + var logs: [String: DoryMachineFlightRecorderLog] + + var isValid: Bool { + schemaVersion == Self.currentSchemaVersion + && logs.count <= DoryMachineFlightRecorderStore.maximumWorkspaceCount + && logs.allSatisfy { machineID, log in + DoryMachineFlightRecorderStore.isMachineID(machineID) + && log.isValid + && log.events.allSatisfy { $0.machineID == machineID } + } + } +} + +/// Cross-process, owner-only, bounded flight recorder for local workspaces. Sequence numbers are +/// monotonic per workspace and survive daemon restart. Publication uses an fsync'd temporary file, +/// atomic rename, and parent-directory fsync under one root-wide flock. +final class DoryMachineFlightRecorderStore: @unchecked Sendable { + static let recordFileName = ".machine-flight-recorder-v1.json" + static let lockFileName = ".machine-flight-recorder-v1.lock" + static let temporaryPrefix = ".machine-flight-recorder-v1.tmp-" + static let maximumWorkspaceCount = 256 + static let maximumEventsPerWorkspace = 256 + private static let maximumRecordBytes: Int64 = 16 * 1_024 * 1_024 + + private let root: String + private let now: @Sendable () -> Int64 + private let lock = NSLock() + + init( + root: String, + now: @escaping @Sendable () -> Int64 = { + Int64(max(1, (Date().timeIntervalSince1970 * 1_000).rounded())) + } + ) { + self.root = URL(fileURLWithPath: root).standardizedFileURL.path + self.now = now + } + + @discardableResult + func append( + machineID: String, + operationID: String? = nil, + operationKind: String? = nil, + kind: DoryMachineFlightEventKind, + phase: String? = nil, + machineState: String? = nil, + failureCode: DoryMachineFailureCode? = nil, + recoveryDisposition: DoryMachineRecoveryDisposition? = nil, + backend: DoryVirtualizationBackendIdentity? = nil, + virtualHardwareABIVersion: UInt16? = nil, + planSHA256: String? = nil, + durationMilliseconds: UInt64? = nil, + deadlineUnixMilliseconds: Int64? = nil, + evidenceReferences: [DoryMachineFailureEvidenceReference] = [] + ) throws -> DoryMachineFlightEvent { + guard Self.isMachineID(machineID) else { + throw DoryMachineFlightRecorderStoreError.invalidMachineID + } + return try synchronized { + var record = try readRecord() + if record.logs[machineID] == nil, + record.logs.count >= Self.maximumWorkspaceCount { + // Retain active workspace history, but do not let tombstoned workspaces consume + // recorder capacity forever. Eviction is deterministic across daemon instances. + guard let evicted = record.logs + .filter({ $0.value.events.last?.kind == .workspaceDeleted }) + .min(by: { lhs, rhs in + let lhsTime = lhs.value.events.last?.occurredAtUnixMilliseconds ?? 0 + let rhsTime = rhs.value.events.last?.occurredAtUnixMilliseconds ?? 0 + return lhsTime == rhsTime ? lhs.key < rhs.key : lhsTime < rhsTime + })?.key else { + throw DoryMachineFlightRecorderStoreError.capacityExceeded + } + record.logs.removeValue(forKey: evicted) + } + var log = record.logs[machineID] + ?? DoryMachineFlightRecorderLog(headSequence: 0, events: []) + guard log.headSequence < UInt64.max else { + throw DoryMachineFlightRecorderStoreError.sequenceExhausted + } + let event = DoryMachineFlightEvent( + sequence: log.headSequence + 1, + occurredAtUnixMilliseconds: now(), + machineID: machineID, + operationID: operationID, + operationKind: operationKind, + kind: kind, + phase: phase, + machineState: machineState, + failureCode: failureCode, + recoveryDisposition: recoveryDisposition, + backend: backend, + virtualHardwareABIVersion: virtualHardwareABIVersion, + planSHA256: planSHA256, + durationMilliseconds: durationMilliseconds, + deadlineUnixMilliseconds: deadlineUnixMilliseconds, + evidenceReferences: evidenceReferences + ) + guard event.isValid else { + throw DoryMachineFlightRecorderStoreError.invalidEvent + } + log.headSequence = event.sequence + log.events.append(event) + if log.events.count > Self.maximumEventsPerWorkspace { + log.events.removeFirst(log.events.count - Self.maximumEventsPerWorkspace) + } + guard record.revision < UInt64.max else { + throw DoryMachineFlightRecorderStoreError.revisionExhausted + } + record.revision += 1 + record.logs[machineID] = log + try publish(record) + return event + } + } + + func batch(machineID: String, afterSequence: UInt64) throws + -> DoryMachineFlightRecorderBatch { + guard Self.isMachineID(machineID) else { + throw DoryMachineFlightRecorderStoreError.invalidMachineID + } + return try synchronized { + let log = try readRecord().logs[machineID] + ?? DoryMachineFlightRecorderLog(headSequence: 0, events: []) + let oldest = log.events.first?.sequence + let fellBehind = oldest.map { first in + first > 1 && afterSequence < first - 1 + } ?? false + let snapshotRequired = afterSequence > log.headSequence || fellBehind + return DoryMachineFlightRecorderBatch( + machineID: machineID, + headSequence: log.headSequence, + snapshotRequired: snapshotRequired, + events: snapshotRequired + ? log.events + : log.events.filter { $0.sequence > afterSequence } + ) + } + } + + func headSequences() throws -> [String: UInt64] { + try synchronized { + try readRecord().logs.mapValues(\.headSequence) + } + } + + func clear(machineID: String) throws { + guard Self.isMachineID(machineID) else { + throw DoryMachineFlightRecorderStoreError.invalidMachineID + } + try synchronized { + var record = try readRecord() + guard record.logs[machineID] != nil else { return } + guard record.revision < UInt64.max else { + throw DoryMachineFlightRecorderStoreError.revisionExhausted + } + record.revision += 1 + record.logs.removeValue(forKey: machineID) + try publish(record) + } + } + + private func synchronized(_ body: () throws -> T) throws -> T { + lock.lock() + defer { lock.unlock() } + return try withFileLock(body) + } + + private func readRecord() throws -> DoryMachineFlightRecorderRecord { + let path = root + "/" + Self.recordFileName + var info = stat() + if lstat(path, &info) != 0 { + guard errno == ENOENT else { throw Self.filesystem("inspect flight recorder") } + return DoryMachineFlightRecorderRecord(revision: 0, logs: [:]) + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw Self.filesystem("open flight recorder") } + defer { _ = close(descriptor) } + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0, + info.st_size > 0, + info.st_size <= Self.maximumRecordBytes else { + throw DoryMachineFlightRecorderStoreError.invalidRecord + } + let data = try FileHandle( + fileDescriptor: descriptor, + closeOnDealloc: false + ).readToEnd() ?? Data() + guard Int64(data.count) == info.st_size, + let record = try? JSONDecoder().decode( + DoryMachineFlightRecorderRecord.self, + from: data + ), record.isValid else { + throw DoryMachineFlightRecorderStoreError.invalidRecord + } + return record + } + + private func publish(_ record: DoryMachineFlightRecorderRecord) throws { + guard record.isValid else { throw DoryMachineFlightRecorderStoreError.invalidRecord } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(record) + guard !data.isEmpty, data.count <= Int(Self.maximumRecordBytes) else { + throw DoryMachineFlightRecorderStoreError.invalidRecord + } + let path = root + "/" + Self.recordFileName + let temporary = root + "/" + Self.temporaryPrefix + + UUID().uuidString.lowercased() + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("create flight recorder") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var remaining = bytes.count + guard var pointer = bytes.baseAddress else { + throw DoryMachineFlightRecorderStoreError.invalidRecord + } + while remaining > 0 { + let count = Darwin.write(descriptor, pointer, remaining) + if count > 0 { + remaining -= count + pointer = pointer.advanced(by: count) + } else if count < 0, errno == EINTR { + continue + } else { + throw Self.filesystem("write flight recorder") + } + } + } + guard fchmod(descriptor, mode_t(0o600)) == 0, fsync(descriptor) == 0 else { + throw Self.filesystem("sync flight recorder") + } + guard rename(temporary, path) == 0 else { + throw Self.filesystem("publish flight recorder") + } + removeTemporary = false + try Self.syncDirectory(root) + } + + private func withFileLock(_ body: () throws -> T) throws -> T { + guard Self.isPrivateDirectory(root) else { + throw DoryMachineFlightRecorderStoreError.invalidRoot + } + let path = root + "/" + Self.lockFileName + let descriptor = open( + path, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("open flight-recorder lock") } + defer { _ = close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + (info.st_mode & 0o077) == 0 else { + throw DoryMachineFlightRecorderStoreError.invalidRecord + } + while flock(descriptor, LOCK_EX) != 0 { + if errno == EINTR { continue } + throw Self.filesystem("acquire flight-recorder lock") + } + defer { _ = flock(descriptor, LOCK_UN) } + return try body() + } + + fileprivate static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { throw filesystem("open flight-recorder directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { + throw filesystem("sync flight-recorder directory") + } + } + + private static func filesystem( + _ operation: String + ) -> DoryMachineFlightRecorderStoreError { + .filesystem("\(operation): \(String(cString: strerror(errno)))") + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 96c3639d..e65197aa 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -25,6 +25,7 @@ import Foundation func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineFlightRecorder(_ machineID: String, afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 1e1f16b1..1e48764f 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -416,6 +416,26 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineFlightRecorder( + _ machineID: String, + afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine flight recorder is not configured") + return + } + do { + let batch = try machineManager.flightRecorder( + id: machineID, + afterSequence: afterSequence + ) + reply(true, batch.xpcDictionary, "") + } catch { + reply(false, [:], "machine flight recorder is unavailable: \(error)") + } + } + public func machineStats( _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void @@ -2203,6 +2223,10 @@ private extension DoryMachineStatus { "kind": activeOperationKind, ] as NSDictionary } + dictionary["flightRecorder"] = [ + "headSequence": flightRecorderHeadSequence, + "available": flightRecorderAvailable, + ] as NSDictionary if let handoffSocketPath { dictionary["handoffSocketPath"] = handoffSocketPath } @@ -2340,6 +2364,53 @@ private extension DoryMachineEventBatch { } } +private extension DoryMachineFlightRecorderBatch { + var xpcDictionary: NSDictionary { + [ + "schemaVersion": schemaVersion, + "machineID": machineID, + "headSequence": headSequence, + "snapshotRequired": snapshotRequired, + "events": events.map(\.xpcDictionary), + ] + } +} + +private extension DoryMachineFlightEvent { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "sequence": sequence, + "occurredAtUnixMilliseconds": occurredAtUnixMilliseconds, + "machineID": machineID, + "kind": kind.rawValue, + "evidenceReferences": evidenceReferences.map { + ["kind": $0.kind.rawValue, "identifier": $0.identifier] as NSDictionary + }, + ] + if let operationID { dictionary["operationID"] = operationID } + if let operationKind { dictionary["operationKind"] = operationKind } + if let phase { dictionary["phase"] = phase } + if let machineState { dictionary["machineState"] = machineState } + if let failureCode { dictionary["failureCode"] = failureCode.rawValue } + if let recoveryDisposition { + dictionary["recoveryDisposition"] = recoveryDisposition.rawValue + } + if let backend { dictionary["backend"] = backend.rawValue } + if let virtualHardwareABIVersion { + dictionary["virtualHardwareABIVersion"] = virtualHardwareABIVersion + } + if let planSHA256 { dictionary["planSHA256"] = planSHA256 } + if let durationMilliseconds { + dictionary["durationMilliseconds"] = durationMilliseconds + } + if let deadlineUnixMilliseconds { + dictionary["deadlineUnixMilliseconds"] = deadlineUnixMilliseconds + } + return dictionary as NSDictionary + } +} + private extension DoryMachineEvent { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index eb6f5902..70d95a10 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -1963,10 +1963,35 @@ public final class HealthReporter: @unchecked Sendable { ) } return [summary] + statuses.flatMap { - [Self.machineEvidenceCheck($0), Self.machineToolsCheck($0)] + [ + Self.machineEvidenceCheck($0), + Self.machineFlightRecorderCheck($0), + Self.machineToolsCheck($0), + ] } } + static func machineFlightRecorderCheck(_ status: DoryMachineStatus) -> HealthCheck { + HealthCheck( + id: "machine.local.\(status.id).flight-recorder", + status: status.flightRecorderAvailable ? .pass : .warn, + code: status.flightRecorderAvailable + ? "machine.flight_recorder.ready" + : "machine.flight_recorder.unavailable", + title: "Workspace flight recorder", + detail: status.flightRecorderAvailable + ? "\(status.id) flight recorder is durable through sequence \(status.flightRecorderHeadSequence)" + : "\(status.id) flight recorder authority could not be read or persisted", + action: status.flightRecorderAvailable + ? nil + : "Repair the private machine state directory before the next lifecycle mutation.", + data: [ + "available": status.flightRecorderAvailable ? "true" : "false", + "head_sequence": String(status.flightRecorderHeadSequence), + ] + ) + } + static func machineToolsCheck(_ status: DoryMachineStatus) -> HealthCheck { let health = status.integrationHealth var data = [ diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index fd5e425e..61260aa5 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -335,6 +335,8 @@ public struct DoryMachineStatus: Sendable, Equatable { public var failure: DoryMachineFailure? public var activeOperationID: String? public var activeOperationKind: String? + public var flightRecorderHeadSequence: UInt64 + public var flightRecorderAvailable: Bool public var handoffSocketPath: String? public var agentBuild: String? public var agentProtocolVersion: UInt32? @@ -372,6 +374,8 @@ public struct DoryMachineStatus: Sendable, Equatable { failure: DoryMachineFailure? = nil, activeOperationID: String? = nil, activeOperationKind: String? = nil, + flightRecorderHeadSequence: UInt64 = 0, + flightRecorderAvailable: Bool = false, handoffSocketPath: String? = nil, agentBuild: String? = nil, agentProtocolVersion: UInt32? = nil, @@ -409,6 +413,8 @@ public struct DoryMachineStatus: Sendable, Equatable { self.failure = failure self.activeOperationID = activeOperationID self.activeOperationKind = activeOperationKind + self.flightRecorderHeadSequence = flightRecorderHeadSequence + self.flightRecorderAvailable = flightRecorderAvailable self.handoffSocketPath = handoffSocketPath self.agentBuild = agentBuild self.agentProtocolVersion = agentProtocolVersion @@ -947,6 +953,7 @@ public final class MachineManager: @unchecked Sendable { private let workspaceRepository: DoryWorkspaceRepository private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore private let failureStore: DoryMachineFailureStore + private let flightRecorderStore: DoryMachineFlightRecorderStore private let launchPolicy: DoryMachineLaunchPolicy private let lifecycleJournalStore: DoryOperationJournalStore? private let lifecycleJournalInitializationError: String? @@ -1006,6 +1013,9 @@ public final class MachineManager: @unchecked Sendable { root: configuration.stateDirectory ) self.failureStore = DoryMachineFailureStore(root: configuration.stateDirectory) + self.flightRecorderStore = DoryMachineFlightRecorderStore( + root: configuration.stateDirectory + ) self.savedStateStore = DoryMachineSavedStateStore(root: configuration.stateDirectory) do { let store = try DoryOperationJournalStore( @@ -1053,6 +1063,17 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentityStore: runtimeIdentityStore, savedStateStore: savedStateStore ) + do { + let heads = try flightRecorderStore.headSequences() + for machineID in machines.keys { + machines[machineID]?.flightRecorderHeadSequence = heads[machineID] ?? 0 + machines[machineID]?.flightRecorderAvailable = true + } + } catch { + for machineID in machines.keys { + machines[machineID]?.flightRecorderAvailable = false + } + } do { let failures = try failureStore.failures() for (machineID, failure) in failures where machines[machineID] != nil { @@ -1097,6 +1118,11 @@ public final class MachineManager: @unchecked Sendable { causes: [.journal], recoveryDisposition: .repair ) + appendFlightEvent( + on: &entry, + kind: .recoveryRequired, + failure: entry.failure + ) machines[machineID] = entry } } @@ -1319,9 +1345,10 @@ public final class MachineManager: @unchecked Sendable { } do { try failureStore.clear(machine.id) + try flightRecorderStore.clear(machineID: machine.id) } catch { throw MachineManagerError.persistence( - "could not prepare machine diagnostic authority: \(error)" + "could not prepare machine diagnostic and flight-recorder authority: \(error)" ) } let statePath = machineStateDirectory(id: machine.id) @@ -1399,11 +1426,13 @@ public final class MachineManager: @unchecked Sendable { } try Self.syncDirectory(path: statePath) lock.lock() - machines[machine.id] = MachineEntry( + var createdEntry = MachineEntry( configuration: preparedMachine, state: .created, runtimeIdentity: initialRuntimeIdentity ) + appendFlightEvent(on: &createdEntry, kind: .workspaceCreated) + machines[machine.id] = createdEntry lock.unlock() let typedSettingsSnapshot = try nativeDefinition.map( DoryMachineTypedSettingsSnapshot.init @@ -1414,6 +1443,8 @@ public final class MachineManager: @unchecked Sendable { return DoryMachineStatus( id: preparedMachine.id, state: .created, + flightRecorderHeadSequence: createdEntry.flightRecorderHeadSequence, + flightRecorderAvailable: createdEntry.flightRecorderAvailable, address: preparedMachine.address, configuredAddress: preparedMachine.address, memoryMB: preparedMachine.memoryMB, @@ -1748,11 +1779,17 @@ public final class MachineManager: @unchecked Sendable { shareAuthorities: prepared.shareAuthorities, launchBinding: nil ) - if let lifecycle, status.state != .starting { - _ = completeCommittedLifecycle( - lifecycle, - diagnostic: "running machine has an unfinished start journal" - ) + if let lifecycle { + if status.state == .failed { + failLifecycle(lifecycle, stepID: "start.helper-exited") + return self.status(id: id) ?? status + } + if status.state != .starting { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "running machine has an unfinished start journal" + ) + } } return status } catch { @@ -1878,11 +1915,17 @@ public final class MachineManager: @unchecked Sendable { plan: resolved.resolvedPlan, planSHA256: resolved.resolvedPlanSHA256 ) - if let lifecycle, status.state != .starting { - _ = completeCommittedLifecycle( - lifecycle, - diagnostic: "running machine has an unfinished resolved-start journal" - ) + if let lifecycle { + if status.state == .failed { + failLifecycle(lifecycle, stepID: "start.helper-exited") + return self.status(id: id) ?? status + } + if status.state != .starting { + _ = completeCommittedLifecycle( + lifecycle, + diagnostic: "running machine has an unfinished resolved-start journal" + ) + } } return status } catch { @@ -2558,6 +2601,7 @@ public final class MachineManager: @unchecked Sendable { entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = resolvedPlan entry.activeBackend = processLaunch.backend + entry.readinessAcceptedPendingPublication = false let requiresAdmissionCommit = resolvedPlan != nil && productionResourceAdmissionLedger != nil entry.state = configuration.requiresReadyHandoff || requiresAdmissionCommit @@ -2598,6 +2642,12 @@ public final class MachineManager: @unchecked Sendable { } throw error } + lock.lock() + if var current = machines[id], current.process === process { + appendFlightEvent(on: ¤t, kind: .backendSpawned) + machines[id] = current + } + lock.unlock() if configuration.requiresReadyHandoff { scheduleHandoffTimeout(id: id, process: process, timeout: handoffReadyTimeout) } else if requiresAdmissionCommit, let resolvedPlan { @@ -2606,6 +2656,10 @@ public final class MachineManager: @unchecked Sendable { lock.lock() if machines[id]?.launchID == launchID { machines[id]?.state = .running + if var current = machines[id] { + appendFlightEvent(on: ¤t, kind: .resourceTransition) + machines[id] = current + } } lock.unlock() } catch { @@ -2648,6 +2702,7 @@ public final class MachineManager: @unchecked Sendable { ) let deadline = Date().addingTimeInterval(handoffReadyTimeout + 1) while Date() < deadline { + publishAcceptedReadiness(id: id) guard let current = status(id: id) else { throw MachineManagerError.unknownMachine(id) } @@ -2710,6 +2765,11 @@ public final class MachineManager: @unchecked Sendable { causes: [.readinessGate], recoveryDisposition: .retry ) + self.appendFlightEvent( + on: &entry, + kind: .readinessRejected, + failure: entry.failure + ) // A terminal machine state must never advertise the journal as still active. The // durable failure retained the operation ID above; journal settlement follows under // the workspace operation fence. @@ -2752,7 +2812,6 @@ public final class MachineManager: @unchecked Sendable { entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = nil entry.activeBackend = nil - entry.state = .failed setFailure( on: &entry, code: .helperExited, @@ -2760,8 +2819,6 @@ public final class MachineManager: @unchecked Sendable { causes: [.processExit], recoveryDisposition: .retry ) - entry.activeOperationID = nil - entry.activeOperationKind = nil machines[machineID] = entry lock.unlock() @@ -2769,6 +2826,19 @@ public final class MachineManager: @unchecked Sendable { try? markResolvedAdmissionStopped(plan: admissionPlan) failActiveStartLifecycle(id: machineID, stepID: "start.helper-exited") operationLock.unlock() + lock.lock() + if var current = machines[machineID], current.failure?.code == .helperExited { + current.state = .failed + current.activeOperationID = nil + current.activeOperationKind = nil + appendFlightEvent( + on: ¤t, + kind: .processExited, + failure: current.failure + ) + machines[machineID] = current + } + lock.unlock() handoffServer?.stop() } @@ -3493,10 +3563,12 @@ public final class MachineManager: @unchecked Sendable { try advanceLifecycleToPublishing(lifecycle) _ = try stopImplementation(id: id, journalLifecycle: false) lock.lock() - guard machines.removeValue(forKey: id) != nil else { + guard var deletingEntry = machines[id] else { lock.unlock() throw MachineManagerError.unknownMachine(id) } + appendFlightEvent(on: &deletingEntry, kind: .workspaceDeleted) + machines.removeValue(forKey: id) deletingMachineIDs.insert(id) lock.unlock() @@ -4985,6 +5057,27 @@ public final class MachineManager: @unchecked Sendable { return resolvedLaunchIdentities[id] } + /// Returns the retained, path-free flight-recorder tail after a per-workspace cursor. A stale + /// or future cursor receives the full retained snapshot with `snapshotRequired == true`. + public func flightRecorder( + id: String, + afterSequence: UInt64 + ) throws -> DoryMachineFlightRecorderBatch { + operationLock.lock() + defer { operationLock.unlock() } + lock.lock() + let exists = machines[id] != nil + lock.unlock() + if !exists { + let heads = try flightRecorderStore.headSequences() + guard heads[id] != nil else { throw MachineManagerError.unknownMachine(id) } + } + return try flightRecorderStore.batch( + machineID: id, + afterSequence: afterSequence + ) + } + private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { let typedSettings = nativeTypedSettingsSnapshot(id: id) ?? DoryMachineTypedSettingsSnapshot( @@ -5006,6 +5099,8 @@ public final class MachineManager: @unchecked Sendable { ), activeOperationID: entry.activeOperationID?.uuidString.lowercased(), activeOperationKind: entry.activeOperationKind?.rawValue, + flightRecorderHeadSequence: entry.flightRecorderHeadSequence, + flightRecorderAvailable: entry.flightRecorderAvailable, address: entry.configuration.address, configuredAddress: entry.configuration.address, memoryMB: entry.configuration.memoryMB, @@ -5037,6 +5132,8 @@ public final class MachineManager: @unchecked Sendable { failure: entry.failure, activeOperationID: entry.activeOperationID?.uuidString.lowercased(), activeOperationKind: entry.activeOperationKind?.rawValue, + flightRecorderHeadSequence: entry.flightRecorderHeadSequence, + flightRecorderAvailable: entry.flightRecorderAvailable, handoffSocketPath: entry.handoffServer?.path, agentBuild: entry.handoff?.ready.agentBuild, agentProtocolVersion: entry.handoff?.ready.agentProtocolVersion, @@ -5091,6 +5188,64 @@ public final class MachineManager: @unchecked Sendable { return references } + private func appendFlightEvent( + on entry: inout MachineEntry, + kind: DoryMachineFlightEventKind, + phase: DoryOperationPhase? = nil, + failure: DoryMachineFailure? = nil, + durationMilliseconds: UInt64? = nil, + deadlineUnixMilliseconds: Int64? = nil + ) { + do { + let event = try flightRecorderStore.append( + machineID: entry.configuration.id, + operationID: entry.activeOperationID?.uuidString.lowercased(), + operationKind: entry.activeOperationKind?.rawValue, + kind: kind, + phase: phase?.rawValue, + machineState: entry.state.rawValue, + failureCode: failure?.code, + recoveryDisposition: failure?.recoveryDisposition, + backend: entry.activeBackend ?? entry.runtimeIdentity.backend, + virtualHardwareABIVersion: + entry.runtimeIdentity.virtualHardwareABIVersion, + planSHA256: entry.runtimeIdentity.resolvedPlanSHA256, + durationMilliseconds: durationMilliseconds, + deadlineUnixMilliseconds: deadlineUnixMilliseconds, + evidenceReferences: failureEvidenceReferences(for: entry) + ) + entry.flightRecorderHeadSequence = event.sequence + entry.flightRecorderAvailable = true + } catch { + // Recorder persistence is deliberately independent from lifecycle authority. Mark it + // unavailable in status without recursively trying to persist another diagnostic. + entry.flightRecorderAvailable = false + } + } + + private func appendFlightEvent( + machineID: String, + kind: DoryMachineFlightEventKind, + phase: DoryOperationPhase? = nil, + failure: DoryMachineFailure? = nil, + durationMilliseconds: UInt64? = nil, + deadlineUnixMilliseconds: Int64? = nil + ) { + lock.lock() + if var entry = machines[machineID] { + appendFlightEvent( + on: &entry, + kind: kind, + phase: phase, + failure: failure, + durationMilliseconds: durationMilliseconds, + deadlineUnixMilliseconds: deadlineUnixMilliseconds + ) + machines[machineID] = entry + } + lock.unlock() + } + private func setFailure( on entry: inout MachineEntry, code: DoryMachineFailureCode, @@ -5120,6 +5275,11 @@ public final class MachineManager: @unchecked Sendable { ) entry.lastError = "\(message); structured diagnostics could not be persisted" } + appendFlightEvent( + on: &entry, + kind: .failureRecorded, + failure: entry.failure + ) } private func clearFailure(on entry: inout MachineEntry) { @@ -8110,10 +8270,10 @@ public final class MachineManager: @unchecked Sendable { ) { var handoffServer: VmmHandoffServer? var processToStop: HvProcess? - var agentSocketPath: String? var lifecycleReadinessSucceeded = false var admissionPlan: DoryResolvedMachinePlan? var requiresAdmissionCommit = false + var lifecycleFailureStepID: String? lock.lock() guard var entry = machines[machineID], entry.launchID == launchID else { lock.unlock() @@ -8133,10 +8293,16 @@ public final class MachineManager: @unchecked Sendable { causes: [.readinessGate, .guestAgent], recoveryDisposition: .repair ) + appendFlightEvent( + on: &entry, + kind: .readinessRejected, + failure: entry.failure + ) entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil entry.activeBackend = nil + entry.readinessAcceptedPendingPublication = false processToStop = entry.process break } @@ -8144,14 +8310,9 @@ public final class MachineManager: @unchecked Sendable { clearFailure(on: &entry) requiresAdmissionCommit = admissionPlan != nil && productionResourceAdmissionLedger != nil - if requiresAdmissionCommit { - entry.state = .starting - } else { - entry.state = .running - entry.process?.disableRestarts() - agentSocketPath = handoff.ready.agentSocketPath - lifecycleReadinessSucceeded = true - } + entry.state = .starting + lifecycleReadinessSucceeded = !requiresAdmissionCommit + entry.readinessAcceptedPendingPublication = lifecycleReadinessSucceeded case let .failure(error): entry.state = .failed setFailure( @@ -8161,10 +8322,16 @@ public final class MachineManager: @unchecked Sendable { causes: [.readinessGate], recoveryDisposition: .retry ) + appendFlightEvent( + on: &entry, + kind: .readinessRejected, + failure: entry.failure + ) entry.launchID = nil entry.runtimeAddress = nil entry.activeResolvedPlan = nil entry.activeBackend = nil + entry.readinessAcceptedPendingPublication = false processToStop = entry.process } machines[machineID] = entry @@ -8173,29 +8340,20 @@ public final class MachineManager: @unchecked Sendable { handoffServer?.stop() processToStop?.stop() - operationLock.lock() if requiresAdmissionCommit, let admissionPlan { do { try markResolvedAdmissionRunning(plan: admissionPlan) lock.lock() if var current = machines[machineID], current.launchID == launchID, - current.state == .starting { - current.state = .running - clearFailure(on: ¤t) - current.process?.disableRestarts() - agentSocketPath = current.handoff?.ready.agentSocketPath + current.state == .starting, current.handoff != nil { + current.readinessAcceptedPendingPublication = true machines[machineID] = current lifecycleReadinessSucceeded = true } lock.unlock() - if lifecycleReadinessSucceeded { - completeActiveStartLifecycle(id: machineID) - } else { + if !lifecycleReadinessSucceeded { try markResolvedAdmissionStopped(plan: admissionPlan) - failActiveStartLifecycle( - id: machineID, - stepID: "start.readiness-state-changed" - ) + lifecycleFailureStepID = "start.readiness-state-changed" } } catch { lock.lock() @@ -8209,26 +8367,80 @@ public final class MachineManager: @unchecked Sendable { causes: [.resourceAdmission, .readinessGate], recoveryDisposition: .repair ) + appendFlightEvent( + on: ¤t, + kind: .readinessRejected, + failure: current.failure + ) current.handoff = nil current.launchID = nil current.runtimeAddress = nil current.activeResolvedPlan = nil current.activeBackend = nil + current.readinessAcceptedPendingPublication = false machines[machineID] = current } lock.unlock() try? markResolvedAdmissionStopped(plan: admissionPlan) - failActiveStartLifecycle(id: machineID, stepID: "start.admission-failed") + lifecycleFailureStepID = "start.admission-failed" } - } else if lifecycleReadinessSucceeded { - completeActiveStartLifecycle(id: machineID) - } else { + } else if !lifecycleReadinessSucceeded { try? markResolvedAdmissionStopped(plan: admissionPlan) - failActiveStartLifecycle(id: machineID, stepID: "start.readiness-failed") + lifecycleFailureStepID = "start.readiness-failed" + } + + // A synchronous update/restore can own operationLock while it waits for this callback. + // Publish the accepted readiness token before blocking on that lock; the waiting owner can + // terminalize its start journal and publish `.running` itself. Ordinary asynchronous starts + // acquire the lock here and preserve journal-before-status ordering. + operationLock.lock() + if lifecycleReadinessSucceeded { + publishAcceptedReadiness(id: machineID) + } else if let lifecycleFailureStepID { + failActiveStartLifecycle(id: machineID, stepID: lifecycleFailureStepID) } operationLock.unlock() processToStop?.stop() + } + + /// Completes an accepted readiness transition while the caller owns operationLock. This can + /// run either on the handoff callback or on a synchronous mutation that is already waiting for + /// readiness, avoiding a cross-thread operation-lock deadlock without exposing `.running` + /// before the start journal has reached its terminal/recovery boundary. + private func publishAcceptedReadiness(id machineID: String) { + lock.lock() + guard let pending = machines[machineID], + pending.state == .starting, + pending.readinessAcceptedPendingPublication, + pending.handoff != nil, + let launchID = pending.launchID else { + lock.unlock() + return + } + lock.unlock() + + completeActiveStartLifecycle(id: machineID) + + var agentSocketPath: String? + lock.lock() + if var current = machines[machineID], current.launchID == launchID, + current.state == .starting, + current.readinessAcceptedPendingPublication, + current.handoff != nil { + current.state = .running + current.readinessAcceptedPendingPublication = false + current.process?.disableRestarts() + agentSocketPath = current.handoff?.ready.agentSocketPath + if current.activeResolvedPlan != nil, + productionResourceAdmissionLedger != nil { + appendFlightEvent(on: ¤t, kind: .resourceTransition) + } + appendFlightEvent(on: ¤t, kind: .readinessAccepted) + machines[machineID] = current + } + lock.unlock() + if let agentSocketPath { DispatchQueue.global(qos: .utility).async { [weak self] in self?.discoverRuntimeAddress( @@ -10019,6 +10231,12 @@ public final class MachineManager: @unchecked Sendable { if var entry = machines[machineID] { entry.activeOperationID = operation.operationID entry.activeOperationKind = kind + appendFlightEvent( + on: &entry, + kind: .operationStarted, + phase: .planned, + deadlineUnixMilliseconds: deadline + ) machines[machineID] = entry } lock.unlock() @@ -10043,6 +10261,13 @@ public final class MachineManager: @unchecked Sendable { expectedRevision: current.revision, stepID: "lifecycle.\(phase.rawValue)" ) + appendFlightEvent( + machineID: context.operation.source.workspaceID, + kind: .operationPhase, + phase: phase, + deadlineUnixMilliseconds: + context.operation.deadlineUnixMilliseconds + ) } } @@ -10055,6 +10280,13 @@ public final class MachineManager: @unchecked Sendable { expectedRevision: current.revision, stepID: "lifecycle.validated" ) + appendFlightEvent( + machineID: context.operation.source.workspaceID, + kind: .operationPhase, + phase: .validating, + deadlineUnixMilliseconds: + context.operation.deadlineUnixMilliseconds + ) } #if DEBUG // Persist the post-commit boundary before exercising the terminal journal write. Recovery @@ -10070,6 +10302,12 @@ public final class MachineManager: @unchecked Sendable { stepID: "lifecycle.completed" ) } + appendFlightEvent( + machineID: context.operation.source.workspaceID, + kind: .operationCompleted, + phase: .completed, + durationMilliseconds: lifecycleDurationMilliseconds(context.operation) + ) activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID) clearActiveOperation( machineID: context.operation.source.workspaceID, @@ -10101,6 +10339,12 @@ public final class MachineManager: @unchecked Sendable { causes: [.journal], recoveryDisposition: .repair ) + appendFlightEvent( + on: &entry, + kind: .recoveryRequired, + failure: entry.failure, + durationMilliseconds: lifecycleDurationMilliseconds(context.operation) + ) machines[context.operation.source.workspaceID] = entry } lock.unlock() @@ -10146,25 +10390,47 @@ public final class MachineManager: @unchecked Sendable { ) lock.lock() let machineID = context.operation.source.workspaceID - if var entry = machines[machineID], entry.failure == nil { - setFailure( - on: &entry, - code: .lifecycleOperationFailed, - message: "Workspace \(context.operation.kind.rawValue) failed at \(stepID).", - causes: [.journal], - recoveryDisposition: rolledBack ? .rollbackCompleted : .retry, - extraEvidence: [ - .init( - kind: .journal, - identifier: context.operation.operationID.uuidString.lowercased() - ), - ] - ) + if var entry = machines[machineID] { + if entry.failure == nil { + setFailure( + on: &entry, + code: .lifecycleOperationFailed, + message: "Workspace \(context.operation.kind.rawValue) failed at \(stepID).", + causes: [.journal], + recoveryDisposition: rolledBack ? .rollbackCompleted : .retry, + extraEvidence: [ + .init( + kind: .journal, + identifier: context.operation.operationID.uuidString.lowercased() + ), + ] + ) + } + if let failure = entry.failure { + appendFlightEvent( + on: &entry, + kind: .operationFailed, + phase: current.phase, + failure: failure, + durationMilliseconds: lifecycleDurationMilliseconds(context.operation) + ) + } machines[machineID] = entry } lock.unlock() } + private func lifecycleDurationMilliseconds( + _ operation: DoryWorkspaceLifecycleOperation + ) -> UInt64 { + let now = Int64(max(0, (Date().timeIntervalSince1970 * 1_000).rounded())) + guard now > operation.createdAtUnixMilliseconds else { return 0 } + return min( + UInt64(now - operation.createdAtUnixMilliseconds), + UInt64(31 * 24 * 60 * 60 * 1_000) + ) + } + private func completeActiveStartLifecycle(id: String) { guard let context = activeLifecycleOperations[id], context.operation.kind == .starting else { return @@ -11565,6 +11831,9 @@ private struct MachineEntry { var failure: DoryMachineFailure? = nil var activeOperationID: UUID? = nil var activeOperationKind: DoryWorkspaceMutationKind? = nil + var flightRecorderHeadSequence: UInt64 = 0 + var flightRecorderAvailable: Bool = true + var readinessAcceptedPendingPublication: Bool = false var activeResolvedPlan: DoryResolvedMachinePlan? var activeBackend: DoryVirtualizationBackendIdentity? var pendingRestoreStatePath: String? diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 96c813cf..8c4a2f6f 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -191,6 +191,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME + dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME @@ -1125,7 +1126,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|flight-recorder|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1147,6 +1148,28 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { proxy.machineStats(name, reply: reply) } try emitJSON(stats) + case "flight-recorder": + let name = try cursor.take( + "usage: dorydctl machine flight-recorder NAME [--after SEQUENCE]" + ) + let after: UInt64 + if let raw = try cursor.optionValue("--after") { + guard let value = UInt64(raw) else { + throw DorydCtlError.usage("--after must be an unsigned integer") + } + after = value + } else { + after = 0 + } + guard cursor.values.isEmpty else { + throw DorydCtlError.usage( + "unexpected machine flight-recorder argument: \(cursor.values[0])" + ) + } + let batch: NSDictionary = try client.statusCommand { proxy, reply in + proxy.machineFlightRecorder(name, afterSequence: after, reply: reply) + } + try emitJSON(batch) case "create": let name = try cursor.take("usage: dorydctl machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N])") let installerISO = try cursor.optionValue("--installer-iso") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineFlightRecorderTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineFlightRecorderTests.swift new file mode 100644 index 00000000..fad33422 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineFlightRecorderTests.swift @@ -0,0 +1,168 @@ +import Darwin +import Foundation +import Testing +@testable import DorydKit + +@Suite("Durable machine flight recorder", .serialized) +struct DoryMachineFlightRecorderTests { + @Test("events are bounded path-free and monotonic across restart") + func persistenceAndBounds() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFlightRecorderStore(root: root.path, now: { 1_000 }) + let operationID = "01234567-89ab-4cde-8fab-0123456789ab" + for index in 0..<(DoryMachineFlightRecorderStore.maximumEventsPerWorkspace + 2) { + _ = try store.append( + machineID: "dev", + operationID: operationID, + operationKind: "starting", + kind: .operationPhase, + phase: index.isMultiple(of: 2) ? "staging" : "verifying", + machineState: "starting", + backend: .doryHypervisor, + virtualHardwareABIVersion: 1, + planSHA256: String(repeating: "a", count: 64), + evidenceReferences: [ + .init(kind: .operation, identifier: operationID), + ] + ) + } + let restarted = DoryMachineFlightRecorderStore(root: root.path) + let batch = try restarted.batch(machineID: "dev", afterSequence: 0) + #expect(batch.snapshotRequired) + #expect(batch.headSequence == 258) + #expect(batch.events.count == 256) + #expect(batch.events.first?.sequence == 3) + #expect(batch.events.last?.sequence == 258) + + let persisted = try String( + contentsOf: root.appendingPathComponent( + DoryMachineFlightRecorderStore.recordFileName + ), + encoding: .utf8 + ) + #expect(!persisted.contains("/Users/")) + #expect(!persisted.contains("opaque-secret")) + } + + @Test("stale and future cursors receive the complete retained snapshot") + func cursorRecovery() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFlightRecorderStore(root: root.path, now: { 2_000 }) + for _ in 0...DoryMachineFlightRecorderStore.maximumEventsPerWorkspace { + _ = try store.append(machineID: "dev", kind: .workspaceCreated) + } + let stale = try store.batch(machineID: "dev", afterSequence: 0) + #expect(stale.snapshotRequired) + #expect(stale.events.count == 256) + let future = try store.batch(machineID: "dev", afterSequence: 999) + #expect(future.snapshotRequired) + #expect(future.events == stale.events) + let current = try store.batch( + machineID: "dev", + afterSequence: stale.headSequence + ) + #expect(!current.snapshotRequired) + #expect(current.events.isEmpty) + } + + @Test("two daemon instances serialize one workspace sequence") + func crossInstanceSerialization() async throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let first = DoryMachineFlightRecorderStore(root: root.path, now: { 3_000 }) + let second = DoryMachineFlightRecorderStore(root: root.path, now: { 3_001 }) + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + _ = try first.append(machineID: "dev", kind: .backendSpawned) + } + group.addTask { + _ = try second.append(machineID: "dev", kind: .readinessAccepted) + } + try await group.waitForAll() + } + let batch = try first.batch(machineID: "dev", afterSequence: 0) + #expect(batch.headSequence == 2) + #expect(batch.events.map(\.sequence) == [1, 2]) + #expect(Set(batch.events.map(\.kind)) == [.backendSpawned, .readinessAccepted]) + } + + @Test("invalid path evidence and corrupt filesystem authority fail closed") + func validationAndFilesystemAuthority() throws { + let invalid = DoryMachineFlightEvent( + sequence: 1, + occurredAtUnixMilliseconds: 1, + machineID: "dev", + kind: .failureRecorded, + failureCode: .helperExited, + recoveryDisposition: .retry, + evidenceReferences: [ + .init(kind: .journal, identifier: "/private/journal"), + ] + ) + #expect(!invalid.isValid) + + for variant in ["corrupt", "symlink", "hardlink"] { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFlightRecorderStore(root: root.path) + _ = try store.append(machineID: "dev", kind: .workspaceCreated) + let record = root.appendingPathComponent( + DoryMachineFlightRecorderStore.recordFileName + ) + switch variant { + case "corrupt": + try Data("{}".utf8).write(to: record) + case "symlink": + let target = root.appendingPathComponent("foreign.json") + try Data("{}".utf8).write(to: target) + try FileManager.default.removeItem(at: record) + try FileManager.default.createSymbolicLink( + atPath: record.path, + withDestinationPath: target.path + ) + case "hardlink": + #expect(link( + record.path, + root.appendingPathComponent("alias.json").path + ) == 0) + default: + Issue.record("unexpected fixture") + } + #expect(throws: DoryMachineFlightRecorderStoreError.self) { + _ = try store.batch(machineID: "dev", afterSequence: 0) + } + } + } + + @Test("deleted workspace history is the only capacity-eviction candidate") + func deletedWorkspaceEviction() throws { + let root = try privateTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineFlightRecorderStore(root: root.path, now: { 10_000 }) + for index in 0.. URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-flight-recorder-\(UUID().uuidString.lowercased())", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + return root + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 62816c50..dc7b712f 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1154,6 +1154,24 @@ final class DorydServiceTests: XCTestCase { } wait(for: [running], timeout: 5) + var flightHead: UInt64 = 0 + let flight = expectation(description: "machineFlightRecorder reply") + proxy.machineFlightRecorder("dev", afterSequence: 0) { ok, body, message in + XCTAssertTrue(ok, message) + XCTAssertEqual(body["schemaVersion"] as? Int, 1) + XCTAssertEqual(body["machineID"] as? String, "dev") + XCTAssertEqual(body["snapshotRequired"] as? Bool, false) + let rows = body["events"] as? [NSDictionary] + XCTAssertTrue(rows?.contains { $0["kind"] as? String == "workspace-created" } == true) + XCTAssertTrue(rows?.contains { $0["kind"] as? String == "backend-spawned" } == true) + XCTAssertFalse(body.description.contains("opaque-secret")) + XCTAssertFalse(body.description.contains(base)) + flightHead = (body["headSequence"] as? NSNumber)?.uint64Value ?? 0 + XCTAssertGreaterThan(flightHead, 0) + flight.fulfill() + } + wait(for: [flight], timeout: 5) + _ = try manager.stop(id: "dev") try manager.delete(id: "dev") let removed = expectation(description: "removed machineEvents reply") @@ -1166,6 +1184,15 @@ final class DorydServiceTests: XCTestCase { removed.fulfill() } wait(for: [removed], timeout: 5) + + let deletedFlight = expectation(description: "deleted machine flight recorder reply") + proxy.machineFlightRecorder("dev", afterSequence: flightHead) { ok, body, message in + XCTAssertTrue(ok, message) + let rows = body["events"] as? [NSDictionary] + XCTAssertTrue(rows?.contains { $0["kind"] as? String == "workspace-deleted" } == true) + deletedFlight.fulfill() + } + wait(for: [deletedFlight], timeout: 5) } func testMachineStatusPublishesStructuredFailureWithoutFreeFormEvidence() throws { diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index eedf904e..d07d9523 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -6,6 +6,28 @@ import Foundation import XCTest final class HealthReporterTests: XCTestCase { + func testMachineFlightRecorderHealthPublishesOnlyAvailabilityAndCursor() { + let ready = HealthReporter.machineFlightRecorderCheck(DoryMachineStatus( + id: "dev", + state: .running, + flightRecorderHeadSequence: 42, + flightRecorderAvailable: true + )) + XCTAssertEqual(ready.status, .pass) + XCTAssertEqual(ready.code, "machine.flight_recorder.ready") + XCTAssertEqual(ready.data, ["available": "true", "head_sequence": "42"]) + + let unavailable = HealthReporter.machineFlightRecorderCheck(DoryMachineStatus( + id: "dev", + state: .failed, + flightRecorderHeadSequence: 41, + flightRecorderAvailable: false + )) + XCTAssertEqual(unavailable.status, .warn) + XCTAssertEqual(unavailable.code, "machine.flight_recorder.unavailable") + XCTAssertFalse(unavailable.detail.contains("/")) + } + func testHostDiskRequiresLowPercentageAndLowAbsoluteFreeSpaceToFail() { let gib: UInt64 = 1024 * 1024 * 1024 let total = 1_000 * gib diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift index a2f90412..95fe668e 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift @@ -6,6 +6,51 @@ import Foundation import XCTest final class MachineManagerLifecycleJournalIntegrationTests: XCTestCase { + func testFlightRecorderCapturesLifecycleAndSurvivesRestartAndDeletion() throws { + let fixture = try LifecycleFixture(name: #function) + defer { fixture.cleanup() } + let manager = fixture.makeManager() + let created = try fixture.createMachine(manager) + XCTAssertTrue(created.flightRecorderAvailable) + XCTAssertEqual(created.flightRecorderHeadSequence, 1) + + XCTAssertEqual(try manager.start(id: fixture.machineID).state, .running) + XCTAssertEqual(try manager.stop(id: fixture.machineID).state, .stopped) + let beforeRestart = try manager.flightRecorder( + id: fixture.machineID, + afterSequence: 0 + ) + XCTAssertFalse(beforeRestart.snapshotRequired) + XCTAssertEqual(beforeRestart.headSequence, beforeRestart.events.last?.sequence) + XCTAssertTrue(beforeRestart.events.allSatisfy(\.isValid)) + XCTAssertTrue(beforeRestart.events.contains { $0.kind == .workspaceCreated }) + XCTAssertTrue(beforeRestart.events.contains { $0.kind == .backendSpawned }) + XCTAssertTrue(beforeRestart.events.contains { $0.kind == .operationCompleted }) + XCTAssertTrue(beforeRestart.events.contains { + $0.kind == .operationPhase && $0.phase == DoryOperationPhase.publishing.rawValue + }) + + let restarted = fixture.makeManager() + let status = try XCTUnwrap(restarted.status(id: fixture.machineID)) + XCTAssertTrue(status.flightRecorderAvailable) + XCTAssertEqual(status.flightRecorderHeadSequence, beforeRestart.headSequence) + XCTAssertEqual( + try restarted.flightRecorder( + id: fixture.machineID, + afterSequence: beforeRestart.headSequence + ).events, + [] + ) + + try restarted.delete(id: fixture.machineID) + let tombstone = try restarted.flightRecorder( + id: fixture.machineID, + afterSequence: beforeRestart.headSequence + ) + XCTAssertTrue(tombstone.events.contains { $0.kind == .workspaceDeleted }) + XCTAssertNil(restarted.status(id: fixture.machineID)) + } + func testPauseAndResumePublishExactLifecycleTransitions() throws { let fixture = try LifecycleFixture(name: #function) defer { fixture.cleanup() } From bdc0031f54fcfcdf8c6934951661e4ca13144737 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 21:26:46 +0000 Subject: [PATCH 222/338] feat(vm): expose secure serial console --- Dory/Features/Machines/MachinesView.swift | 216 +++++++++++ Dory/Models/AppStore.swift | 22 ++ Dory/Runtime/Doryd/DorydClient.swift | 127 ++++++ DoryTests/DorydClientTests.swift | 140 +++++++ docs/virtual-workspace-platform.md | 11 +- .../DorydKit/DoryMachineSerialConsole.swift | 364 ++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 2 + .../Sources/DorydKit/DorydService.swift | 105 +++++ .../Sources/DorydKit/MachineManager.swift | 62 +++ dory-core-swift/Sources/dorydctl/main.swift | 77 +++- .../DoryMachineSerialConsoleTests.swift | 248 ++++++++++++ .../DorydKitTests/DorydServiceTests.swift | 112 ++++++ ...ManagerSerialConsoleIntegrationTests.swift | 174 +++++++++ 13 files changed, 1656 insertions(+), 4 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineSerialConsole.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineSerialConsoleTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerSerialConsoleIntegrationTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 3bcd7ccc..ae6134fb 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -126,6 +126,7 @@ private struct MachineCard: View { @State private var confirmingDelete = false @State private var confirmingToolsRepair = false @State private var showingIntegrationHealth = false + @State private var showingSerialConsole = false @State private var isTransferDropTargeted = false private var isRunning: Bool { machine.status == .running } @@ -158,6 +159,7 @@ private struct MachineCard: View { } Spacer(minLength: 8) statusPill + serialConsoleButton overflowMenu } @@ -284,6 +286,9 @@ private struct MachineCard: View { .sheet(isPresented: $showingIntegrationHealth) { MachineIntegrationHealthSheet(machine: machine) } + .sheet(isPresented: $showingSerialConsole) { + MachineSerialConsoleSheet(machine: machine) + } .confirmationDialog("Delete machine \(machine.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { Button("Delete", role: .destructive) { store.deleteMachine(machine) } Button("Cancel", role: .cancel) {} @@ -545,6 +550,9 @@ private struct MachineCard: View { Button { showingIntegrationHealth = true } label: { Label("Integration Health…", systemImage: "stethoscope") } + Button { showingSerialConsole = true } label: { + Label("Serial Console…", systemImage: "text.line.first.and.arrowtriangle.forward") + } if store.canRepairMachineTools(machine) { Button { confirmingToolsRepair = true } label: { Label("Repair Dory Tools…", systemImage: "wrench.and.screwdriver") @@ -572,6 +580,18 @@ private struct MachineCard: View { .disabled(store.isMachineBusy(machine.name) || !store.canUseMachineArtifacts(machine)) } + private var serialConsoleButton: some View { + Button { showingSerialConsole = true } label: { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(p.text2) + .frame(width: 22, height: 22) + } + .buttonStyle(.plain) + .help("Open serial console") + .accessibilityLabel("Open serial console for \(machine.name)") + } + private func selectAndSendFiles() { guard store.canTransferFiles(to: machine) else { return } let supportsFolders = store.canTransferFolders(to: machine) @@ -781,6 +801,202 @@ private func logoName(for distro: String) -> String? { return nil } +private struct MachineSerialConsoleSheet: View { + @Environment(AppStore.self) private var store + @Environment(\.dismiss) private var dismiss + @Environment(\.palette) private var p + + let machine: Machine + + @State private var cursor = DorydMachineSerialConsoleCursor() + @State private var consoleBytes = Data() + @State private var displayedStartOffset: UInt64 = 0 + @State private var inputAvailable = false + @State private var input = "" + @State private var errorMessage: String? + @State private var isSending = false + @State private var hasConnected = false + + private let maximumDisplayedBytes = 1_024 * 1_024 + + private var consoleText: String { + guard !consoleBytes.isEmpty else { + return "Waiting for serial output…" + } + return String(decoding: consoleBytes, as: UTF8.self) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(p.border) + console + Divider().overlay(p.border) + inputBar + } + .frame(minWidth: 720, idealWidth: 820, minHeight: 500, idealHeight: 620) + .background(p.bgContent) + .task(id: machine.id) { + while !Task.isCancelled { + await refresh() + try? await Task.sleep(for: .milliseconds(inputAvailable ? 250 : 750)) + } + } + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(p.accentText) + .frame(width: 38, height: 38) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Serial Console") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(p.text) + Text(machine.name) + .font(.mono(11.5, weight: .semibold)) + .foregroundStyle(p.text3) + } + Spacer() + statusPill + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(16) + } + + private var statusPill: some View { + let title = errorMessage != nil ? "RETRYING" : (hasConnected ? "CONNECTED" : "CONNECTING") + let color = errorMessage != nil ? p.amber : (hasConnected ? p.green : p.text3) + let background = errorMessage != nil ? p.amberWeak : (hasConnected ? p.greenWeak : p.pill) + return HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(title) + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + } + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(background, in: Capsule()) + } + + private var console: some View { + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + Text(consoleText) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(p.monoText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 400, alignment: .topLeading) + .padding(14) + Color.clear.frame(width: 1, height: 1).id("serial-console-end") + } + .background(p.monoBg) + .onChange(of: cursor.offset) { + proxy.scrollTo("serial-console-end", anchor: .bottom) + } + } + .overlay(alignment: .topTrailing) { + if let errorMessage { + Text(errorMessage) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(p.amber) + .lineLimit(2) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(p.monoBg.opacity(0.94), in: RoundedRectangle(cornerRadius: 7)) + .padding(10) + } + } + } + + private var inputBar: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + Text("offset \(displayedStartOffset)–\(cursor.offset)") + .font(.mono(9.5, weight: .medium)) + .foregroundStyle(p.text3) + Spacer() + Text(inputAvailable ? "INPUT AVAILABLE" : "READ-ONLY ON THIS BACKEND") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(inputAvailable ? p.green : p.text3) + .tracking(0.45) + } + + HStack(spacing: 8) { + TextField(inputAvailable ? "Send input to the guest" : "Serial input is unavailable", text: $input) + .textFieldStyle(.plain) + .font(.system(size: 11.5, design: .monospaced)) + .padding(.horizontal, 10) + .frame(height: 32) + .background(p.bgInput, in: RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(p.border)) + .disabled(!inputAvailable || isSending) + .onSubmit { sendInput() } + Button("Send") { sendInput() } + .disabled(!canSend) + } + } + .padding(12) + .background(p.bgWindow) + } + + private var canSend: Bool { + inputAvailable && !isSending && !input.isEmpty && pendingInputData.count <= 4 * 1_024 + } + + private var pendingInputData: Data { + var data = Data(input.utf8) + if data.last != 0x0A { data.append(0x0A) } + return data + } + + private func refresh() async { + do { + let batch = try await store.readMachineSerialConsole(machine, cursor: cursor) + if batch.snapshotRequired || batch.generation != cursor.generation { + consoleBytes = batch.bytes + displayedStartOffset = batch.startOffset + } else if !batch.bytes.isEmpty { + consoleBytes.append(batch.bytes) + } + if consoleBytes.count > maximumDisplayedBytes { + let excess = consoleBytes.count - maximumDisplayedBytes + consoleBytes.removeFirst(excess) + displayedStartOffset += UInt64(excess) + } + cursor = batch.cursor + inputAvailable = batch.inputAvailable + errorMessage = nil + hasConnected = true + } catch { + errorMessage = String(describing: error) + inputAvailable = false + } + } + + private func sendInput() { + guard canSend else { return } + let data = pendingInputData + isSending = true + Task { + defer { isSending = false } + do { + try await store.writeMachineSerialConsole(machine, data: data) + input = "" + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } + } +} + private struct MachineIntegrationHealthSheet: View { @Environment(AppStore.self) private var store @Environment(\.dismiss) private var dismiss diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 3c664927..c64ecd95 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5350,6 +5350,28 @@ final class AppStore { runtimeOwnedByDoryd && !machine.shellSocketPath.isEmpty && HostTools.userFacingDoryCommand() != nil } + func readMachineSerialConsole( + _ machine: Machine, + cursor: DorydMachineSerialConsoleCursor, + limit: UInt32 = 64 * 1_024 + ) async throws -> DorydMachineSerialConsoleBatch { + guard runtimeOwnedByDoryd else { + throw DorydClientError.daemon(Self.dorydMachineManagerRequired("machine serial console")) + } + return try await dorydClient.machineSerialConsole( + machineID: machine.name, + cursor: cursor, + limit: limit + ) + } + + func writeMachineSerialConsole(_ machine: Machine, data: Data) async throws { + guard runtimeOwnedByDoryd else { + throw DorydClientError.daemon(Self.dorydMachineManagerRequired("machine serial console")) + } + _ = try await dorydClient.writeMachineSerialConsole(machineID: machine.name, data: data) + } + func canOpenMachineDesktop(_ machine: Machine) -> Bool { runtimeOwnedByDoryd && machine.displayMode == .desktop diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index c53233a5..32c46c3d 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -26,6 +26,8 @@ nonisolated protocol DorydControlXPC { func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineFlightRecorder(_ machineID: String, afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleRead(_ machineID: String, cursor: NSDictionary, limit: UInt32, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -1087,6 +1089,35 @@ nonisolated struct DorydMachineFlightRecorderBatch: Sendable, Equatable { var events: [DorydMachineFlightEvent] } +nonisolated struct DorydMachineSerialConsoleCursor: Sendable, Equatable, Hashable { + var generation: String? = nil + var offset: UInt64 = 0 + + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": UInt16(1), + "offset": offset, + ] + if let generation { dictionary["generation"] = generation } + return dictionary as NSDictionary + } +} + +nonisolated struct DorydMachineSerialConsoleBatch: Sendable, Equatable { + var machineID: String + var generation: String? + var startOffset: UInt64 + var nextOffset: UInt64 + var totalBytes: UInt64 + var snapshotRequired: Bool + var inputAvailable: Bool + var bytes: Data + + var cursor: DorydMachineSerialConsoleCursor { + DorydMachineSerialConsoleCursor(generation: generation, offset: nextOffset) + } +} + nonisolated enum DorydMachineImportDisposition: String, Sendable, Equatable { case ready case requiresComponents = "requires-components" @@ -2178,6 +2209,40 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineSerialConsole( + machineID: String, + cursor: DorydMachineSerialConsoleCursor = .init(), + limit: UInt32 = 64 * 1_024 + ) async throws -> DorydMachineSerialConsoleBatch { + try await statusCommand { proxy, reply in + proxy.machineSerialConsoleRead( + machineID, + cursor: cursor.xpcDictionary, + limit: limit, + reply: reply + ) + } decode: { + Self.machineSerialConsoleBatch( + from: $0, + machineID: machineID, + cursor: cursor, + limit: limit + ) + } + } + + func writeMachineSerialConsole( + machineID: String, + data: Data + ) async throws -> DorydCommandResult { + guard !data.isEmpty, data.count <= 4 * 1_024 else { + throw DorydClientError.daemon("machine serial console input is invalid") + } + return try await command { proxy, reply in + proxy.machineSerialConsoleWrite(machineID, data: data as NSData, reply: reply) + } + } + func machineList() async throws -> [DorydMachineStatus] { try await call { proxy, finish in proxy.machineList { rows, error in @@ -3446,6 +3511,68 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineSerialConsoleBatch( + from dictionary: NSDictionary, + machineID: String, + cursor: DorydMachineSerialConsoleCursor, + limit: UInt32 + ) -> DorydMachineSerialConsoleBatch? { + let required: Set = [ + "schemaVersion", "machineID", "startOffset", "nextOffset", "totalBytes", + "snapshotRequired", "inputAvailable", "bytesBase64", + ] + let optional: Set = ["generation"] + guard machineID.isSafeMachineIdentifier, + limit > 0, limit <= 64 * 1_024, + let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + required.isSubset(of: keys), + keys.subtracting(required).isSubset(of: optional), + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let startOffset = strictUInt64(dictionary["startOffset"]), + let nextOffset = strictUInt64(dictionary["nextOffset"]), + let totalBytes = strictUInt64(dictionary["totalBytes"]), + let snapshot = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshot) == CFBooleanGetTypeID(), + let input = dictionary["inputAvailable"] as? NSNumber, + CFGetTypeID(input) == CFBooleanGetTypeID(), + let encoded = dictionary["bytesBase64"] as? String, + let bytes = Data(base64Encoded: encoded), + bytes.base64EncodedString() == encoded, + bytes.count <= Int(limit), + startOffset <= nextOffset, + nextOffset <= totalBytes, + nextOffset - startOffset == UInt64(bytes.count), + dictionary["generation"] == nil + || dictionary["generation"] is String else { + return nil + } + let generation = dictionary["generation"] as? String + guard generation.map(\.isLowercaseSHA256) ?? true else { return nil } + if generation == nil { + guard startOffset == 0, nextOffset == 0, totalBytes == 0, bytes.isEmpty else { + return nil + } + } + if !snapshot.boolValue { + guard generation == cursor.generation, startOffset == cursor.offset else { + return nil + } + } + return DorydMachineSerialConsoleBatch( + machineID: machineID, + generation: generation, + startOffset: startOffset, + nextOffset: nextOffset, + totalBytes: totalBytes, + snapshotRequired: snapshot.boolValue, + inputAvailable: input.boolValue, + bytes: bytes + ) + } + nonisolated private static func machineFlightEvent( from dictionary: NSDictionary ) -> DorydMachineFlightEvent? { diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index b348de6e..db10c2ba 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -918,6 +918,86 @@ struct DorydClientTests { } } + @MainActor + @Test func machineSerialConsoleRequiresExactBoundedCursorEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let generation = String(repeating: "a", count: 64) + service.setMachineSerialConsoleBatch([ + "schemaVersion": UInt16(1), + "machineID": "dev", + "generation": generation, + "startOffset": UInt64(0), + "nextOffset": UInt64(4), + "totalBytes": UInt64(4), + "snapshotRequired": true, + "inputAvailable": false, + "bytesBase64": Data("boot".utf8).base64EncodedString(), + ]) + let initial = try await client.machineSerialConsole(machineID: "dev", limit: 64) + #expect(initial.bytes == Data("boot".utf8)) + #expect(initial.snapshotRequired) + #expect(initial.cursor.generation == generation) + #expect(initial.cursor.offset == 4) + #expect(service.latestMachineSerialConsoleCursor?["offset"] as? UInt64 == 0) + + service.setMachineSerialConsoleBatch([ + "schemaVersion": UInt16(1), + "machineID": "dev", + "generation": generation, + "startOffset": UInt64(4), + "nextOffset": UInt64(10), + "totalBytes": UInt64(10), + "snapshotRequired": false, + "inputAvailable": true, + "bytesBase64": Data("ready\n".utf8).base64EncodedString(), + ]) + let appended = try await client.machineSerialConsole( + machineID: "dev", + cursor: initial.cursor, + limit: 64 + ) + #expect(appended.bytes == Data("ready\n".utf8)) + #expect(!appended.snapshotRequired) + #expect(appended.inputAvailable) + + let valid = service.machineSerialConsoleBatchResponse + for malformed in [ + valid.adding("hostPath", "/private/opaque"), + valid.replacing("bytesBase64", with: "not-base64"), + valid.replacing("startOffset", with: UInt64(3)), + valid.replacing("snapshotRequired", with: 0), + ] { + service.setMachineSerialConsoleBatch(malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineSerialConsole( + machineID: "dev", + cursor: initial.cursor, + limit: 64 + ) + } + } + + service.setMachineSerialConsoleBatch(nil) + let write = try await client.writeMachineSerialConsole( + machineID: "dev", + data: Data("recovery\n".utf8) + ) + #expect(write.ok) + #expect(service.latestMachineSerialConsoleInput == Data("recovery\n".utf8)) + await #expect(throws: (any Error).self) { + _ = try await client.writeMachineSerialConsole( + machineID: "dev", + data: Data(repeating: 1, count: 4 * 1_024 + 1) + ) + } + } + @MainActor @Test func machineImportAssessmentRequiresExactClosedEvidence() async throws { let listener = NSXPCListener.anonymous() @@ -3799,6 +3879,9 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineImportAssessmentOverride: NSDictionary? private var _machineEventBatchOverride: NSDictionary? private var _machineFlightRecorderBatchOverride: NSDictionary? + private var _machineSerialConsoleBatchOverride: NSDictionary? + private var _latestMachineSerialConsoleCursor: NSDictionary? + private var _latestMachineSerialConsoleInput: Data? private var _machineEventQueryCount = 0 private var _machineListCount = 0 private var _machineGuestExportCancelCount = 0 @@ -4182,6 +4265,30 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.unlock() } + func setMachineSerialConsoleBatch(_ response: NSDictionary?) { + lock.lock() + _machineSerialConsoleBatchOverride = response + lock.unlock() + } + + var machineSerialConsoleBatchResponse: NSDictionary { + lock.lock() + defer { lock.unlock() } + return _machineSerialConsoleBatchOverride ?? [:] + } + + var latestMachineSerialConsoleCursor: NSDictionary? { + lock.lock() + defer { lock.unlock() } + return _latestMachineSerialConsoleCursor + } + + var latestMachineSerialConsoleInput: Data? { + lock.lock() + defer { lock.unlock() } + return _latestMachineSerialConsoleInput + } + func setEngineStartResult(ok: Bool, message: String = "") { lock.lock() _engineStartOK = ok @@ -4539,6 +4646,39 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineSerialConsoleRead( + _ machineID: String, + cursor: NSDictionary, + limit: UInt32, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineSerialConsoleCursor = cursor + let row = _machineSerialConsoleBatchOverride ?? [ + "schemaVersion": UInt16(1), + "machineID": machineID, + "startOffset": UInt64(0), + "nextOffset": UInt64(0), + "totalBytes": UInt64(0), + "snapshotRequired": false, + "inputAvailable": false, + "bytesBase64": "", + ] + lock.unlock() + reply(true, row, "") + } + + func machineSerialConsoleWrite( + _ machineID: String, + data: NSData, + reply: @escaping (Bool, String) -> Void + ) { + lock.lock() + _latestMachineSerialConsoleInput = data as Data + lock.unlock() + reply(true, "") + } + func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let total = (Self.uint64(machines[machineID]?["memoryMB"]) ?? 2048) * 1_048_576 diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 0d456a53..122f3594 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -709,9 +709,14 @@ the durable monotonic event cursor, XPC, `HealthReporter`, and Dory.app project without relying on free-form error text. `lastError` remains a same-user compatibility field for older clients, but public CLI/support projections omit it along with environment values and host paths. Active lifecycle journals expose their operation ID and kind until completion, and terminal -failures retain the originating operation ID across daemon restart. Full operation-ID propagation -through backend/helper/guest/component boundaries, device-level telemetry, and a bounded -per-workspace flight recorder remain required before this section is complete. +failures retain the originating operation ID across daemon restart. Each workspace also has an +owner-only, crash-durable, size-bounded flight recorder with a monotonic cursor. Serial/firmware +output is available independently of Dory Tools through a generation-and-offset cursor in Dory.app, +`dorydctl`, and XPC; reads are bounded and path-free, while input is accepted only through the +private VMM console socket and is reported read-only for the current raw-HV UART. Console bytes are +never copied into status, incidents, flight-recorder events, diagnostics, or support bundles. Full +operation-ID propagation through backend/helper/guest/component boundaries and device-level +telemetry remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineSerialConsole.swift b/dory-core-swift/Sources/DorydKit/DoryMachineSerialConsole.swift new file mode 100644 index 00000000..176eb54b --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineSerialConsole.swift @@ -0,0 +1,364 @@ +import CryptoKit +import Darwin +import Foundation + +public struct DoryMachineSerialConsoleCursor: Codable, Sendable, Equatable, Hashable { + public var generation: String? + public var offset: UInt64 + + public init(generation: String? = nil, offset: UInt64 = 0) { + self.generation = generation + self.offset = offset + } + + public var isValid: Bool { + generation.map(Self.isSHA256) ?? (offset == 0) + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + +/// One bounded serial-console read. Console bytes are explicit user-requested guest output and +/// are never copied into status, diagnostics, incidents, flight-recorder events, or support +/// bundles. `generation` changes when the underlying log is replaced; clients must discard their +/// prior cursor when `snapshotRequired` is true. +public struct DoryMachineSerialConsoleBatch: Codable, Sendable, Equatable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var machineID: String + public var generation: String? + public var startOffset: UInt64 + public var nextOffset: UInt64 + public var totalBytes: UInt64 + public var snapshotRequired: Bool + public var inputAvailable: Bool + public var bytes: Data + + public init( + machineID: String, + generation: String?, + startOffset: UInt64, + nextOffset: UInt64, + totalBytes: UInt64, + snapshotRequired: Bool, + inputAvailable: Bool, + bytes: Data + ) { + schemaVersion = Self.currentSchemaVersion + self.machineID = machineID + self.generation = generation + self.startOffset = startOffset + self.nextOffset = nextOffset + self.totalBytes = totalBytes + self.snapshotRequired = snapshotRequired + self.inputAvailable = inputAvailable + self.bytes = bytes + } + + public var cursor: DoryMachineSerialConsoleCursor { + DoryMachineSerialConsoleCursor(generation: generation, offset: nextOffset) + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !machineID.hasPrefix("."), + generation.map(Self.isSHA256) ?? true, + bytes.count <= DoryMachineSerialConsoleAuthority.maximumReadBytes, + startOffset <= nextOffset, + nextOffset <= totalBytes, + nextOffset - startOffset == UInt64(bytes.count) else { + return false + } + if generation == nil { + return startOffset == 0 && nextOffset == 0 && totalBytes == 0 && bytes.isEmpty + } + return true + } + + private static func isSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} + +enum DoryMachineSerialConsoleError: Error, Equatable { + case invalidMachineID + case invalidCursor + case invalidLimit + case invalidLogAuthority + case logChangedDuringRead + case inputUnavailable + case invalidInput + case filesystem(String) +} + +/// Secure access to the helper-owned serial log and optional private interactive socket. +/// +/// Reads use `pread` on an owner-only, regular, non-linked file and return at most 64 KiB. A +/// replacement/truncation changes the generation or forces a fresh snapshot. Writes are capped at +/// 4 KiB, connect only to the manager-derived owner-only Unix socket, and have a bounded deadline. +enum DoryMachineSerialConsoleAuthority { + static let logFileName = "serial.log" + static let socketFileName = "console.sock" + static let maximumReadBytes = 64 * 1_024 + static let maximumWriteBytes = 4 * 1_024 + private static let socketTimeoutMilliseconds: Int32 = 1_000 + + static func read( + machineID: String, + machineDirectory: String, + consoleSocketPath: String, + cursor: DoryMachineSerialConsoleCursor, + limit: Int + ) throws -> DoryMachineSerialConsoleBatch { + guard isMachineID(machineID) else { + throw DoryMachineSerialConsoleError.invalidMachineID + } + guard cursor.isValid else { throw DoryMachineSerialConsoleError.invalidCursor } + guard (1...maximumReadBytes).contains(limit) else { + throw DoryMachineSerialConsoleError.invalidLimit + } + guard isPrivateDirectory(machineDirectory) else { + throw DoryMachineSerialConsoleError.invalidLogAuthority + } + + let inputAvailable = isPrivateConsoleSocket(consoleSocketPath) + let path = machineDirectory + "/" + logFileName + var pathInfo = stat() + if lstat(path, &pathInfo) != 0 { + guard errno == ENOENT else { throw filesystem("inspect serial console") } + return DoryMachineSerialConsoleBatch( + machineID: machineID, + generation: nil, + startOffset: 0, + nextOffset: 0, + totalBytes: 0, + snapshotRequired: cursor.generation != nil || cursor.offset != 0, + inputAvailable: inputAvailable, + bytes: Data() + ) + } + + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw filesystem("open serial console") } + defer { _ = close(descriptor) } + + var before = stat() + guard fstat(descriptor, &before) == 0, + isPrivateRegularFile(before), + before.st_size >= 0 else { + throw DoryMachineSerialConsoleError.invalidLogAuthority + } + let identity = FileIdentity(before) + let generation = identity.generation(machineID: machineID) + let sizeBefore = UInt64(before.st_size) + let snapshotRequired = cursor.generation != generation || cursor.offset > sizeBefore + let startOffset = snapshotRequired + ? sizeBefore - min(sizeBefore, UInt64(limit)) + : cursor.offset + let requested = Int(min(UInt64(limit), sizeBefore - startOffset)) + var bytes = [UInt8](repeating: 0, count: requested) + var received = 0 + while received < requested { + let count = bytes.withUnsafeMutableBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return pread( + descriptor, + base.advanced(by: received), + requested - received, + off_t(startOffset + UInt64(received)) + ) + } + if count > 0 { + received += count + } else if count == 0 { + break + } else if errno == EINTR { + continue + } else { + throw filesystem("read serial console") + } + } + if received < bytes.count { bytes.removeLast(bytes.count - received) } + + var after = stat() + guard fstat(descriptor, &after) == 0, + isPrivateRegularFile(after), + FileIdentity(after) == identity, + after.st_size >= 0, + UInt64(after.st_size) >= startOffset + UInt64(received) else { + throw DoryMachineSerialConsoleError.logChangedDuringRead + } + let batch = DoryMachineSerialConsoleBatch( + machineID: machineID, + generation: generation, + startOffset: startOffset, + nextOffset: startOffset + UInt64(received), + totalBytes: UInt64(after.st_size), + snapshotRequired: snapshotRequired, + inputAvailable: inputAvailable, + bytes: Data(bytes) + ) + guard batch.isValid else { throw DoryMachineSerialConsoleError.invalidLogAuthority } + return batch + } + + static func write(_ data: Data, consoleSocketPath: String) throws { + guard !data.isEmpty, data.count <= maximumWriteBytes else { + throw DoryMachineSerialConsoleError.invalidInput + } + guard isPrivateConsoleSocket(consoleSocketPath) else { + throw DoryMachineSerialConsoleError.inputUnavailable + } + + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw filesystem("create serial console client") } + defer { _ = close(descriptor) } + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + throw filesystem("configure serial console client") + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw filesystem("configure serial console client") + } + + var address = try socketAddress(path: consoleSocketPath) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + Darwin.connect(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + if result != 0 { + guard errno == EINPROGRESS else { throw filesystem("connect serial console") } + try wait(descriptor: descriptor, events: Int16(POLLOUT)) + var socketError: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &length + ) == 0, socketError == 0 else { + if socketError != 0 { errno = socketError } + throw filesystem("connect serial console") + } + } + + var offset = 0 + while offset < data.count { + let sent = data.withUnsafeBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return Darwin.send( + descriptor, + base.advanced(by: offset), + data.count - offset, + MSG_NOSIGNAL + ) + } + if sent > 0 { + offset += sent + } else if sent < 0, errno == EINTR { + continue + } else if sent < 0, errno == EAGAIN || errno == EWOULDBLOCK { + try wait(descriptor: descriptor, events: Int16(POLLOUT)) + } else { + throw filesystem("write serial console") + } + } + _ = shutdown(descriptor, SHUT_WR) + } + + static func isPrivateConsoleSocket(_ path: String) -> Bool { + let directory = (path as NSString).deletingLastPathComponent + guard isPrivateDirectory(directory) else { return false } + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFSOCK + && info.st_uid == getuid() + && info.st_nlink == 1 + && (info.st_mode & 0o077) == 0 + } + + private struct FileIdentity: Equatable { + var device: UInt64 + var inode: UInt64 + var birthSeconds: Int64 + var birthNanoseconds: Int64 + + init(_ info: stat) { + device = UInt64(info.st_dev) + inode = UInt64(info.st_ino) + birthSeconds = Int64(info.st_birthtimespec.tv_sec) + birthNanoseconds = Int64(info.st_birthtimespec.tv_nsec) + } + + func generation(machineID: String) -> String { + let material = Data( + "serial-console-v1\0\(machineID)\0\(device)\0\(inode)\0\(birthSeconds)\0\(birthNanoseconds)" + .utf8 + ) + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } + } + + private static func isMachineID(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } + + private static func isPrivateDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 + && (info.st_mode & S_IFMT) == S_IFDIR + && info.st_uid == getuid() + && (info.st_mode & 0o077) == 0 + } + + private static func isPrivateRegularFile(_ info: stat) -> Bool { + (info.st_mode & S_IFMT) == S_IFREG + && info.st_uid == getuid() + && info.st_nlink == 1 + && (info.st_mode & 0o077) == 0 + } + + private static func socketAddress(path: String) throws -> sockaddr_un { + let bytes = Array(path.utf8CString) + guard bytes.count <= MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { + throw DoryMachineSerialConsoleError.inputUnavailable + } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + return address + } + + private static func wait(descriptor: Int32, events: Int16) throws { + var item = pollfd(fd: descriptor, events: events, revents: 0) + while true { + let result = poll(&item, 1, socketTimeoutMilliseconds) + if result > 0 { + guard item.revents & (Int16(POLLERR) | Int16(POLLHUP) | Int16(POLLNVAL)) == 0 else { + throw DoryMachineSerialConsoleError.inputUnavailable + } + return + } + if result == 0 { throw DoryMachineSerialConsoleError.inputUnavailable } + if errno == EINTR { continue } + throw filesystem("wait for serial console") + } + } + + private static func filesystem(_ operation: String) -> DoryMachineSerialConsoleError { + .filesystem("\(operation): \(String(cString: strerror(errno)))") + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index e65197aa..45dd81c1 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -26,6 +26,8 @@ import Foundation func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineFlightRecorder(_ machineID: String, afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleRead(_ machineID: String, cursor: NSDictionary, limit: UInt32, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 1e48764f..6c0f2c1d 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -436,6 +436,56 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineSerialConsoleRead( + _ machineID: String, + cursor: NSDictionary, + limit: UInt32, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine serial console is not configured") + return + } + do { + let request = try MachineSerialConsoleCursorRequest(xpcDictionary: cursor) + guard limit > 0, + limit <= UInt32(DoryMachineSerialConsoleAuthority.maximumReadBytes) else { + throw XPCRemoteConfigError.invalid("machineSerialConsole.limit") + } + let batch = try machineManager.serialConsole( + id: machineID, + cursor: request.cursor, + limit: Int(limit) + ) + reply(true, batch.xpcDictionary, "") + } catch { + reply(false, [:], "machine serial console is unavailable: \(error)") + } + } + + public func machineSerialConsoleWrite( + _ machineID: String, + data: NSData, + reply: @escaping (Bool, String) -> Void + ) { + guard let machineManager else { + reply(false, "machine serial console is not configured") + return + } + let bytes = data as Data + guard !bytes.isEmpty, + bytes.count <= DoryMachineSerialConsoleAuthority.maximumWriteBytes else { + reply(false, "machine serial console input is invalid") + return + } + do { + try machineManager.writeSerialConsole(id: machineID, data: bytes) + reply(true, "") + } catch { + reply(false, "machine serial console input is unavailable: \(error)") + } + } + public func machineStats( _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void @@ -1664,6 +1714,44 @@ private struct MachineExecRequest { } } +private struct MachineSerialConsoleCursorRequest { + var cursor: DoryMachineSerialConsoleCursor + + init(xpcDictionary dictionary: NSDictionary) throws { + let required: Set = ["schemaVersion", "offset"] + let optional: Set = ["generation"] + guard let rawKeys = dictionary.allKeys as? [String] else { + throw XPCRemoteConfigError.invalid("machineSerialConsole.cursor") + } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + required.isSubset(of: keys), + keys.subtracting(required).isSubset(of: optional), + let schema = Self.strictUInt64(dictionary["schemaVersion"]), schema == 1, + let offset = Self.strictUInt64(dictionary["offset"]), + dictionary["generation"] == nil + || dictionary["generation"] is String else { + throw XPCRemoteConfigError.invalid("machineSerialConsole.cursor") + } + cursor = DoryMachineSerialConsoleCursor( + generation: dictionary["generation"] as? String, + offset: offset + ) + guard cursor.isValid else { + throw XPCRemoteConfigError.invalid("machineSerialConsole.cursor") + } + } + + private static func strictUInt64(_ value: Any?) -> UInt64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + let type = String(cString: number.objCType) + guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"] + .contains(type) else { return nil } + return UInt64(number.stringValue) + } +} + private struct MachineTransferRequest: Sendable { var privateStagingRoot: String @@ -2376,6 +2464,23 @@ private extension DoryMachineFlightRecorderBatch { } } +private extension DoryMachineSerialConsoleBatch { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": schemaVersion, + "machineID": machineID, + "startOffset": startOffset, + "nextOffset": nextOffset, + "totalBytes": totalBytes, + "snapshotRequired": snapshotRequired, + "inputAvailable": inputAvailable, + "bytesBase64": bytes.base64EncodedString(), + ] + if let generation { dictionary["generation"] = generation } + return dictionary as NSDictionary + } +} + private extension DoryMachineFlightEvent { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 61260aa5..cd6f571d 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5078,6 +5078,64 @@ public final class MachineManager: @unchecked Sendable { ) } + /// Reads a bounded cursor-based tail of the helper-owned serial/firmware console. This path + /// does not depend on the guest agent and remains available after a failed or stopped boot. + public func serialConsole( + id: String, + cursor: DoryMachineSerialConsoleCursor = .init(), + limit: Int = 64 * 1_024 + ) throws -> DoryMachineSerialConsoleBatch { + operationLock.lock() + defer { operationLock.unlock() } + lock.lock() + let state = machines[id]?.state + lock.unlock() + guard let state else { throw MachineManagerError.unknownMachine(id) } + do { + var batch = try DoryMachineSerialConsoleAuthority.read( + machineID: id, + machineDirectory: machineStateDirectory(id: id), + consoleSocketPath: consoleSocketPath(id: id), + cursor: cursor, + limit: limit + ) + batch.inputAvailable = [.starting, .running].contains(state) + && batch.inputAvailable + return batch + } catch { + throw MachineManagerError.persistence( + "machine serial console is unavailable: \(error)" + ) + } + } + + /// Sends one bounded input frame to a helper's private recovery-console socket. Raw-HV's + /// current UART is output-only, so its missing socket is reported as unavailable rather than + /// pretending that input was accepted. + public func writeSerialConsole(id: String, data: Data) throws { + operationLock.lock() + defer { operationLock.unlock() } + lock.lock() + let state = machines[id]?.state + lock.unlock() + guard let state else { throw MachineManagerError.unknownMachine(id) } + guard [.starting, .running].contains(state) else { + throw MachineManagerError.persistence( + "machine serial console input requires a starting or running machine" + ) + } + do { + try DoryMachineSerialConsoleAuthority.write( + data, + consoleSocketPath: consoleSocketPath(id: id) + ) + } catch { + throw MachineManagerError.persistence( + "machine serial console input is unavailable: \(error)" + ) + } + } + private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus { let typedSettings = nativeTypedSettingsSnapshot(id: id) ?? DoryMachineTypedSettingsSnapshot( @@ -5614,6 +5672,10 @@ public final class MachineManager: @unchecked Sendable { "\(machineRuntimeDirectory(id: id))/h.sock" } + private func consoleSocketPath(id: String) -> String { + "\(machineRuntimeDirectory(id: id))/\(DoryMachineSerialConsoleAuthority.socketFileName)" + } + private func desktopUpdateJournalPath(machineID: String) -> String { "\(machineStateDirectory(id: machineID))/\(Self.desktopUpdateJournalName)" } diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 8c4a2f6f..ae3124d3 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -192,6 +192,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine status NAME dorydctl [global] machine stats NAME dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] + dorydctl [global] machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT] dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME @@ -1126,7 +1127,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|flight-recorder|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|flight-recorder|console|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1170,6 +1171,80 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { proxy.machineFlightRecorder(name, afterSequence: after, reply: reply) } try emitJSON(batch) + case "console": + let name = try cursor.take( + "usage: dorydctl machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT]" + ) + let input = try cursor.optionValue("--input") + let generation = try cursor.optionValue("--generation") + let rawAfter = try cursor.optionValue("--after") + let rawLimit = try cursor.optionValue("--limit") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage( + "unexpected machine console argument: \(cursor.values[0])" + ) + } + if let input { + guard generation == nil, rawAfter == nil, rawLimit == nil else { + throw DorydCtlError.usage( + "--input cannot be combined with cursor or limit options" + ) + } + let data = Data(input.utf8) + guard !data.isEmpty, data.count <= 4 * 1_024 else { + throw DorydCtlError.usage("--input must contain 1...4096 UTF-8 bytes") + } + let result: NSDictionary = try client.command { proxy, reply in + proxy.machineSerialConsoleWrite(name, data: data as NSData, reply: reply) + } + try emitJSON(result) + return + } + let after: UInt64 + if let rawAfter { + guard let value = UInt64(rawAfter) else { + throw DorydCtlError.usage("--after must be an unsigned integer") + } + after = value + } else { + after = 0 + } + guard (generation == nil) == (rawAfter == nil) else { + throw DorydCtlError.usage( + "--generation and --after must be supplied together" + ) + } + if let generation { + guard generation.utf8.count == 64, + generation.utf8.allSatisfy({ byte in + (48...57).contains(byte) || (97...102).contains(byte) + }) else { + throw DorydCtlError.usage("--generation must be a lowercase SHA-256 digest") + } + } + let limit: UInt32 + if let rawLimit { + guard let value = UInt32(rawLimit), (1...(64 * 1_024)).contains(value) else { + throw DorydCtlError.usage("--limit must be between 1 and 65536 bytes") + } + limit = value + } else { + limit = 64 * 1_024 + } + var cursorDictionary: [String: Any] = [ + "schemaVersion": UInt16(1), + "offset": after, + ] + if let generation { cursorDictionary["generation"] = generation } + let batch: NSDictionary = try client.statusCommand { proxy, reply in + proxy.machineSerialConsoleRead( + name, + cursor: cursorDictionary as NSDictionary, + limit: limit, + reply: reply + ) + } + try emitJSON(batch) case "create": let name = try cursor.take("usage: dorydctl machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N])") let installerISO = try cursor.optionValue("--installer-iso") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineSerialConsoleTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineSerialConsoleTests.swift new file mode 100644 index 00000000..4620df8d --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineSerialConsoleTests.swift @@ -0,0 +1,248 @@ +import Darwin +import Foundation +import Testing +@testable import DorydKit + +@Suite("Machine serial console authority") +struct DoryMachineSerialConsoleTests { + @Test("bounded cursor reads survive append and force snapshots after truncation") + func cursorReads() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + try fixture.writeLog(Data("boot-one\nboot-two\n".utf8)) + + let initial = try fixture.read(limit: 9) + #expect(initial.snapshotRequired) + #expect(initial.bytes == Data("boot-two\n".utf8)) + #expect(initial.startOffset == 9) + #expect(initial.nextOffset == 18) + #expect(initial.totalBytes == 18) + #expect(initial.isValid) + + try fixture.appendLog(Data("ready\n".utf8)) + let appended = try fixture.read(cursor: initial.cursor, limit: 64) + #expect(!appended.snapshotRequired) + #expect(appended.bytes == Data("ready\n".utf8)) + #expect(appended.startOffset == initial.nextOffset) + #expect(appended.nextOffset == appended.totalBytes) + + try fixture.writeLog(Data("reset\n".utf8)) + let reset = try fixture.read(cursor: appended.cursor, limit: 64) + #expect(reset.snapshotRequired) + #expect(reset.bytes == Data("reset\n".utf8)) + #expect(reset.startOffset == 0) + #expect(reset.nextOffset == 6) + } + + @Test("missing log is an empty authority and replacement changes generation") + func missingAndReplacement() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + + let missing = try fixture.read(limit: 64) + #expect(missing.generation == nil) + #expect(missing.bytes.isEmpty) + #expect(!missing.snapshotRequired) + + try fixture.writeLog(Data("first".utf8)) + let first = try fixture.read(limit: 64) + #expect(first.snapshotRequired) + try FileManager.default.removeItem(atPath: fixture.logPath) + try fixture.writeLog(Data("second".utf8)) + let second = try fixture.read(cursor: first.cursor, limit: 64) + #expect(second.snapshotRequired) + #expect(second.generation != first.generation) + #expect(second.bytes == Data("second".utf8)) + } + + @Test("symlink hard-link and public log substitutions fail closed") + func rejectsUnsafeLogAuthority() throws { + for mode in UnsafeLogMode.allCases { + let fixture = try Fixture() + defer { fixture.cleanup() } + let outside = fixture.root + "/outside" + FileManager.default.createFile(atPath: outside, contents: Data("secret".utf8)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: outside + ) + switch mode { + case .symlink: + try FileManager.default.createSymbolicLink( + atPath: fixture.logPath, + withDestinationPath: outside + ) + case .hardLink: + try FileManager.default.linkItem(atPath: outside, toPath: fixture.logPath) + case .publicFile: + try fixture.writeLog(Data("public".utf8)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: fixture.logPath + ) + } + #expect(throws: DoryMachineSerialConsoleError.self) { + _ = try fixture.read(limit: 64) + } + } + } + + @Test("input is bounded and reaches only the private console socket") + func privateConsoleInput() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let listener = try fixture.makeConsoleListener() + defer { + _ = close(listener) + _ = unlink(fixture.consoleSocketPath) + } + + let before = try fixture.read(limit: 64) + #expect(before.inputAvailable) + let payload = Data("recovery\n".utf8) + try DoryMachineSerialConsoleAuthority.write( + payload, + consoleSocketPath: fixture.consoleSocketPath + ) + let accepted = accept(listener, nil, nil) + #expect(accepted >= 0) + guard accepted >= 0 else { return } + defer { _ = close(accepted) } + var bytes = [UInt8](repeating: 0, count: 64) + let count = Darwin.read(accepted, &bytes, bytes.count) + #expect(count == payload.count) + #expect(Data(bytes.prefix(max(0, count))) == payload) + + #expect(throws: DoryMachineSerialConsoleError.self) { + try DoryMachineSerialConsoleAuthority.write( + Data(repeating: 1, count: DoryMachineSerialConsoleAuthority.maximumWriteBytes + 1), + consoleSocketPath: fixture.consoleSocketPath + ) + } + } + + @Test("invalid cursors limits and absent input fail closed") + func rejectsInvalidRequests() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + try fixture.writeLog(Data("boot".utf8)) + #expect(throws: DoryMachineSerialConsoleError.self) { + _ = try fixture.read( + cursor: DoryMachineSerialConsoleCursor(generation: "not-a-digest", offset: 1), + limit: 64 + ) + } + #expect(throws: DoryMachineSerialConsoleError.self) { + _ = try fixture.read(limit: DoryMachineSerialConsoleAuthority.maximumReadBytes + 1) + } + #expect(throws: DoryMachineSerialConsoleError.self) { + try DoryMachineSerialConsoleAuthority.write( + Data("x".utf8), + consoleSocketPath: fixture.consoleSocketPath + ) + } + } + + private enum UnsafeLogMode: CaseIterable { + case symlink + case hardLink + case publicFile + } + + private final class Fixture { + let root: String + let machineID = "console-machine" + let machineDirectory: String + let runtimeDirectory: String + let logPath: String + let consoleSocketPath: String + + init() throws { + // Keep the Unix-socket path below Darwin's sockaddr_un limit. + root = "/tmp/dory-console-\(UUID().uuidString.prefix(12).lowercased())" + machineDirectory = root + "/machine" + runtimeDirectory = root + "/runtime" + logPath = machineDirectory + "/serial.log" + consoleSocketPath = runtimeDirectory + "/console.sock" + try FileManager.default.createDirectory( + atPath: machineDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.createDirectory( + atPath: runtimeDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: root + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: machineDirectory + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: runtimeDirectory + ) + } + + func cleanup() { + try? FileManager.default.removeItem(atPath: root) + } + + func writeLog(_ data: Data) throws { + guard FileManager.default.createFile(atPath: logPath, contents: data) else { + throw CocoaError(.fileWriteUnknown) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: logPath + ) + } + + func appendLog(_ data: Data) throws { + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: logPath)) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.synchronize() + } + + func read( + cursor: DoryMachineSerialConsoleCursor = .init(), + limit: Int + ) throws -> DoryMachineSerialConsoleBatch { + try DoryMachineSerialConsoleAuthority.read( + machineID: machineID, + machineDirectory: machineDirectory, + consoleSocketPath: consoleSocketPath, + cursor: cursor, + limit: limit + ) + } + + func makeConsoleListener() throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(consoleSocketPath.utf8CString) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + Darwin.bind(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + guard result == 0, chmod(consoleSocketPath, 0o600) == 0, + listen(descriptor, 1) == 0 else { + _ = close(descriptor) + throw POSIXError(.EIO) + } + return descriptor + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index dc7b712f..34b8fd13 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1195,6 +1195,118 @@ final class DorydServiceTests: XCTestCase { wait(for: [deletedFlight], timeout: 5) } + func testMachineSerialConsoleOverXPCIsBoundedCursorBasedAndExactShape() throws { + let base = "/tmp/doryd-service-console-\(getpid())-\(UInt32.random(in: 0..= 0 else { return } + defer { _ = close(accepted) } + var buffer = [UInt8](repeating: 0, count: 64) + let count = Darwin.read(accepted, &buffer, buffer.count) + XCTAssertEqual(count, payload.count) + XCTAssertEqual(Data(buffer.prefix(max(0, count))), payload) + } + + private final class ManagerFixture { + let machineID = "dev" + let stateRoot: String + let runtimeRoot: String + let manager: MachineManager + + var machineDirectory: String { stateRoot + "/" + machineID } + var serialLogPath: String { machineDirectory + "/serial.log" } + var consoleSocketPath: String { + let material = Data("\(stateRoot)\0\(machineID)".utf8) + let token = SHA256.hash(data: material).prefix(12).map { + String(format: "%02x", $0) + }.joined() + return runtimeRoot + "/" + token + "/console.sock" + } + + init() throws { + let suffix = "\(getpid())-\(UInt32.random(in: 0.. Int32 { + let directory = (consoleSocketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory + ) + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(consoleSocketPath.utf8CString) + guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + _ = close(descriptor) + throw POSIXError(.ENAMETOOLONG) + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + Darwin.bind(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + guard result == 0, chmod(consoleSocketPath, 0o600) == 0, + listen(descriptor, 1) == 0 else { + _ = close(descriptor) + throw POSIXError(.EIO) + } + return descriptor + } + } +} From 28b985cbf0dc4aa21810d0f02b3730b6528a0f82 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 22:01:44 +0000 Subject: [PATCH 223/338] feat(vm): propagate launch operation identity --- .../Sources/dory-hv/DesktopMode.swift | 44 +++++++- .../Sources/dory-hv/main.swift | 9 ++ docs/virtual-workspace-platform.md | 8 +- .../Sources/DoryVMMKit/DoryVMM.swift | 79 ++++++++++++- .../LinuxMachineBackendAdapters.swift | 12 +- .../Sources/DorydKit/MachineBackend.swift | 27 ++++- .../Sources/DorydKit/MachineManager.swift | 74 +++++++++++-- .../Sources/DorydKit/VmmHandoff.swift | 31 ++++++ ...onVirtualMachineRuntimePlanningTests.swift | 2 +- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 33 ++++++ .../DorydKitTests/DoryVMMShimTests.swift | 3 + .../DorydKitTests/DorydServiceTests.swift | 5 + .../DorydKitTests/MachineBackendTests.swift | 7 +- .../MachineManagerFileTransferTests.swift | 12 +- ...agerLifecycleJournalIntegrationTests.swift | 20 +++- ...eManagerResolvedPlanIntegrationTests.swift | 3 +- ...ineManagerSavedStateIntegrationTests.swift | 11 +- .../DorydKitTests/MachineManagerTests.swift | 104 ++++++++++++++++-- .../Tests/DorydKitTests/VmmHandoffTests.swift | 35 +++++- 19 files changed, 466 insertions(+), 53 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 16780d69..17565a28 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -10,6 +10,7 @@ import Foundation enum DesktopMode { struct Configuration { var machineID: String + var operationID: UUID var stateDirectory: String var kernelPath: String var initrdPath: String? @@ -127,6 +128,11 @@ enum DesktopMode { ) self.stateLock = try EngineStateDirectoryLock(stateDirectory: configuration.stateDirectory) self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log") + try Self.appendBootSessionMarker( + to: serialLog, + machineID: configuration.machineID, + operationID: configuration.operationID + ) let resolvedGraphics = try Self.resolveGraphics( environment: configuration.environment, requireVulkan: configuration.genericGuest, @@ -163,6 +169,7 @@ enum DesktopMode { initrdPath: configuration.initrdPath, commandLine: Self.kernelCommandLine( machineID: configuration.machineID, + operationID: configuration.operationID, rootDevice: configuration.rootDevice, graphicsBackend: resolvedGraphics.backend, genericGuest: configuration.genericGuest @@ -446,6 +453,9 @@ enum DesktopMode { path: configuration.handoffSocketPath, ready: VmmReadyMessage( machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), agentBuild: "dory-hv/generic-linux", detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed" ) @@ -460,6 +470,9 @@ enum DesktopMode { path: configuration.handoffSocketPath, ready: VmmReadyMessage( machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), agentBuild: info.agentBuild, agentProtocolVersion: info.protocolVersion, agentCapabilities: info.capabilities, @@ -578,6 +591,21 @@ enum DesktopMode { directSocketPath: configuration.agentSocketPath )) let info = try control.info() + let operationToken = DoryOperationIdentity.canonical( + configuration.operationID + ) + try requireSuccess(control.exec( + argv: [ + "/bin/sh", "-c", + "mkdir -p /run/dory && chmod 700 /run/dory && umask 077 && printf '%s\\n' \"$DORY_OPERATION_ID\" > /run/dory/operation-id", + ], + env: [DoryExecEnvironment( + key: "DORY_OPERATION_ID", + value: operationToken + )], + timeoutMs: 10_000, + outputLimitBytes: 16 * 1_024 + ), operation: "bind lifecycle operation") if let display = configuration.resolvedDevices?.display { guard let command = DoryVMMGuestDisplayScale.persistenceCommand( scaleFactor: display.guestUIScaleFactor @@ -592,9 +620,11 @@ enum DesktopMode { outputLimitBytes: 64 * 1_024 ), operation: "persist guest UI scale") } + var guestEnvironment = configuration.environment + guestEnvironment["DORY_OPERATION_ID"] = operationToken try requireSuccess(control.exec( argv: ["/usr/lib/dory/configure-machine"], - env: configuration.environment.sorted(by: { $0.key < $1.key }).map { + env: guestEnvironment.sorted(by: { $0.key < $1.key }).map { DoryExecEnvironment(key: $0.key, value: $0.value) }, timeoutMs: 30_000, @@ -834,6 +864,7 @@ enum DesktopMode { private static func kernelCommandLine( machineID: String, + operationID: UUID, rootDevice: String, graphicsBackend: DoryDesktopGraphicsBackend, genericGuest: Bool @@ -846,6 +877,7 @@ enum DesktopMode { "rootwait", "panic=1", "dory.machine_id=\(machineID)", + "dory.operation_id=\(DoryOperationIdentity.canonical(operationID))", graphicsBackend.kernelArgument, ] if genericGuest { @@ -877,6 +909,16 @@ enum DesktopMode { return handle } + private static func appendBootSessionMarker( + to log: FileHandle, + machineID: String, + operationID: UUID + ) throws { + let marker = "\n--- DORY BOOT \(Date().formatted(.iso8601)) machine=\(machineID) operation=\(DoryOperationIdentity.canonical(operationID)) runtime=raw-hv-desktop ---\n" + try log.write(contentsOf: Data(marker.utf8)) + try log.synchronize() + } + private static func error(for reason: GuestStopReason) -> Error? { switch reason { case .powerOff: nil diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index e2b9a492..c7cd2a57 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -421,6 +421,7 @@ case "boot": } case "desktop": var machineID: String? + var operationID: UUID? var stateDirectory: String? var kernel: String? var initrd: String? @@ -442,6 +443,12 @@ case "desktop": while let argument = iterator.next() { switch argument { case "--machine-id": machineID = iterator.next() + case "--operation-id": + guard let value = iterator.next(), + let parsed = DoryOperationIdentity.parseCanonical(value) else { + fail("desktop --operation-id requires a canonical lowercase UUID") + } + operationID = parsed case "--state-dir": stateDirectory = iterator.next() case "--kernel": kernel = iterator.next() case "--initrd": initrd = iterator.next() @@ -494,6 +501,7 @@ case "desktop": } } guard let machineID, !machineID.isEmpty else { fail("desktop requires --machine-id") } + guard let operationID else { fail("desktop requires --operation-id") } guard let stateDirectory, !stateDirectory.isEmpty else { fail("desktop requires --state-dir") } guard let kernel else { fail("desktop requires --kernel") } if genericGuest, initrd == nil { fail("desktop --generic-guest requires --initrd") } @@ -505,6 +513,7 @@ case "desktop": do { try DesktopMode.run(.init( machineID: machineID, + operationID: operationID, stateDirectory: stateDirectory, kernelPath: kernel, initrdPath: initrd, diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 122f3594..602afd77 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -715,8 +715,12 @@ output is available independently of Dory Tools through a generation-and-offset `dorydctl`, and XPC; reads are bounded and path-free, while input is accepted only through the private VMM console socket and is reported read-only for the current raw-HV UART. Console bytes are never copied into status, incidents, flight-recorder events, diagnostics, or support bundles. Full -operation-ID propagation through backend/helper/guest/component boundaries and device-level -telemetry remain required before this section is complete. +start-operation propagation now carries the daemon's durable lifecycle UUID through the backend +registry, exact adapter launch binding, helper arguments, serial boot marker, Linux kernel/boot +context, guest-agent configuration, and the readiness echo; an absent, malformed, stale, or +different readiness UUID is rejected before the machine can become running. UI-originated +round-trip identity plus stop/pause/resume, component-installer, and device-event propagation and +device-level telemetry remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 6765d326..68d6ad44 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -23,6 +23,7 @@ public enum DoryVMMDisplayDefaults { public struct DoryVMMArguments: Sendable, Equatable { public var machineID: String? + public var operationID: UUID? public var stateDirectory: String? public var dataDriveRoot: String? public var kernelPath: String? @@ -68,6 +69,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case missingValue(String) case invalidInteger(String, String) case missingMachineID + case missingOperationID case missingHandoffSocket case missingStateDirectory case missingKernel @@ -79,6 +81,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case invalidEnvironment(String) case invalidResolvedGraphics(String) case invalidResolvedDevices(String) + case invalidOperationID(String) public var description: String { switch self { @@ -88,6 +91,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid integer for \(flag): \(value)" case .missingMachineID: return "missing --machine-id" + case .missingOperationID: + return "missing --operation-id" case .missingHandoffSocket: return "missing --handoff-sock" case .missingStateDirectory: @@ -110,6 +115,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid --resolved-graphics value: \(value)" case let .invalidResolvedDevices(value): return "invalid --resolved-devices value: \(value)" + case let .invalidOperationID(value): + return "invalid --operation-id value: \(value)" } } } @@ -123,6 +130,12 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { switch argument { case "--machine-id": parsed.machineID = try value(after: argument, from: raw, index: &index) + case "--operation-id": + let rawValue = try value(after: argument, from: raw, index: &index) + guard let operationID = DoryOperationIdentity.parseCanonical(rawValue) else { + throw DoryVMMArgumentError.invalidOperationID(rawValue) + } + parsed.operationID = operationID case "--state-dir": parsed.stateDirectory = try value(after: argument, from: raw, index: &index) case "--data-drive": @@ -245,6 +258,7 @@ private func intValue(after flag: String, from raw: [String], index: inout Array public struct DoryVZMachineSpec: Sendable, Equatable { public var machineID: String + public var operationID: UUID? public var stateDirectory: String public var kernelPath: String public var rootfsPath: String @@ -265,6 +279,7 @@ public struct DoryVZMachineSpec: Sendable, Equatable { public init( machineID: String, + operationID: UUID? = nil, stateDirectory: String, kernelPath: String, rootfsPath: String, @@ -284,6 +299,7 @@ public struct DoryVZMachineSpec: Sendable, Equatable { bridgeSubnetCIDR: String = DoryIPv4BridgeNetwork.defaultCIDR ) { self.machineID = machineID + self.operationID = operationID self.stateDirectory = stateDirectory self.kernelPath = kernelPath self.rootfsPath = rootfsPath @@ -427,7 +443,10 @@ public enum DoryVZConfigurationBuilder { throw DoryVZMachineError.missingFile(spec.kernelPath) } let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: spec.kernelPath)) - bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine(machineID: spec.machineID) + bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine( + machineID: spec.machineID, + operationID: spec.operationID + ) configuration.bootLoader = bootLoader case .efi: guard spec.displayMode == .desktop else { @@ -694,8 +713,18 @@ public enum DoryVZConfigurationBuilder { return store } - public static func defaultKernelCommandLine(machineID: String) -> String { - "console=hvc0 root=/dev/vda rw rootwait panic=1 dory.machine_id=\(machineID)" + public static func defaultKernelCommandLine( + machineID: String, + operationID: UUID? = nil + ) -> String { + var arguments = [ + "console=hvc0", "root=/dev/vda", "rw", "rootwait", "panic=1", + "dory.machine_id=\(machineID)", + ] + if let operationID { + arguments.append("dory.operation_id=\(DoryOperationIdentity.canonical(operationID))") + } + return arguments.joined(separator: " ") } private static func prepareBootConfigShare( @@ -710,6 +739,7 @@ public enum DoryVZConfigurationBuilder { let script = guestBootScript( shares: spec.shares, environment: spec.environment, + operationID: spec.operationID, allowDockerDataFormat: allowDockerDataFormat, nativeIPv6: spec.nativeIPv6, sourcePreservingLAN: spec.sourcePreservingLAN, @@ -731,6 +761,7 @@ public enum DoryVZConfigurationBuilder { private static func guestBootScript( shares: [DoryMachineShareConfiguration], environment: [String: String], + operationID: UUID?, allowDockerDataFormat: Bool, nativeIPv6: Bool, sourcePreservingLAN: Bool, @@ -757,6 +788,17 @@ public enum DoryVZConfigurationBuilder { "", ": > /var/log/dory-mounts.log", ] + if let operationID { + let token = DoryOperationIdentity.canonical(operationID) + lines += [ + "mkdir -p /run/dory", + "chmod 700 /run/dory", + "printf '%s\\n' \(shellQuote(token)) > /run/dory/operation-id", + "chmod 600 /run/dory/operation-id", + "export DORY_OPERATION_ID=\(shellQuote(token))", + "", + ] + } for (key, value) in environment.sorted(by: { $0.key < $1.key }) { guard key.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil else { continue } lines.append("export \(key)=\(shellQuote(value))") @@ -900,6 +942,9 @@ public enum DoryVMMMain { guard let machineID = arguments.machineID else { throw DoryVMMArgumentError.missingMachineID } + guard let operationID = arguments.operationID else { + throw DoryVMMArgumentError.missingOperationID + } guard let handoffSocketPath = arguments.handoffSocketPath else { throw DoryVMMArgumentError.missingHandoffSocket } @@ -911,6 +956,7 @@ public enum DoryVMMMain { case .immediateHandoff: try sendHandoff( machineID: machineID, + operationID: operationID, handoffSocketPath: handoffSocketPath, agentBuild: arguments.agentBuild, agentSocketPath: arguments.agentSocketPath, @@ -948,6 +994,7 @@ public enum DoryVMMMain { shutdownCoordinator = coordinator runtime = try runVirtualMachine( machineID: machineID, + operationID: operationID, stateDirectory: stateDirectory, kernelPath: kernelPath, rootfsPath: rootfsPath, @@ -1015,6 +1062,7 @@ public enum DoryVMMMain { private static func sendHandoff( machineID: String, + operationID: UUID, handoffSocketPath: String, agentBuild: String?, agentProtocolVersion: UInt32? = nil, @@ -1029,6 +1077,7 @@ public enum DoryVMMMain { path: handoffSocketPath, ready: VmmReadyMessage( machineID: machineID, + operationID: DoryOperationIdentity.canonical(operationID), agentBuild: agentBuild, agentProtocolVersion: agentProtocolVersion, agentCapabilities: agentCapabilities, @@ -1043,6 +1092,7 @@ public enum DoryVMMMain { private static func runVirtualMachine( machineID: String, + operationID: UUID, stateDirectory: String, kernelPath: String, rootfsPath: String, @@ -1081,6 +1131,7 @@ public enum DoryVMMMain { try appendBootSessionMarker( to: serialLog, machineID: machineID, + operationID: operationID, bootMode: bootMode, cpuCount: cpuCount, memoryMB: memoryMB, @@ -1161,6 +1212,7 @@ public enum DoryVMMMain { } let spec = DoryVZMachineSpec( machineID: machineID, + operationID: operationID, stateDirectory: stateDirectory, kernelPath: kernelPath, rootfsPath: rootfsPath, @@ -1275,6 +1327,7 @@ public enum DoryVMMMain { if bootMode == .efi { try sendHandoff( machineID: machineID, + operationID: operationID, handoffSocketPath: handoffSocketPath, agentBuild: "dory-vmm/efi", agentSocketPath: nil, @@ -1292,6 +1345,7 @@ public enum DoryVMMMain { defer { agentConnection.close() } let agentInfo = try prepareAgent( from: agentConnection, + operationID: operationID, displayMode: displayMode, environment: environment, shares: shares, @@ -1303,6 +1357,7 @@ public enum DoryVMMMain { // process can never collapse all of them into one misleading "running" bit. try sendHandoff( machineID: machineID, + operationID: operationID, handoffSocketPath: handoffSocketPath, agentBuild: agentInfo.agentBuild, agentProtocolVersion: agentInfo.protocolVersion, @@ -1320,6 +1375,7 @@ public enum DoryVMMMain { private static func prepareAgent( from connection: VZVirtioSocketConnection, + operationID: UUID, displayMode: DoryMachineDisplayMode, environment: [String: String], shares: [DoryMachineShareConfiguration], @@ -1333,6 +1389,16 @@ public enum DoryVMMMain { let control = try DoryCore.connectAgentControlOverFD(fd) defer { control.close() } let info = try control.info() + let operationToken = DoryOperationIdentity.canonical(operationID) + try requireSuccessfulDesktopExec(control.exec( + argv: [ + "/bin/sh", "-c", + "mkdir -p /run/dory && chmod 700 /run/dory && umask 077 && printf '%s\\n' \"$DORY_OPERATION_ID\" > /run/dory/operation-id", + ], + env: [DoryExecEnvironment(key: "DORY_OPERATION_ID", value: operationToken)], + timeoutMs: 10_000, + outputLimitBytes: 16 * 1_024 + ), operation: "bind lifecycle operation") if restoringSavedState { return info } guard displayMode == .desktop else { return info } @@ -1351,9 +1417,11 @@ public enum DoryVMMMain { ), operation: "persist guest UI scale") } + var guestEnvironment = environment + guestEnvironment["DORY_OPERATION_ID"] = operationToken try requireSuccessfulDesktopExec(control.exec( argv: ["/usr/lib/dory/configure-machine"], - env: environment.sorted(by: { $0.key < $1.key }).map { + env: guestEnvironment.sorted(by: { $0.key < $1.key }).map { DoryExecEnvironment(key: $0.key, value: $0.value) }, timeoutMs: 30_000, @@ -1414,13 +1482,14 @@ public enum DoryVMMMain { private static func appendBootSessionMarker( to log: FileHandle, machineID: String, + operationID: UUID, bootMode: DoryMachineBootMode, cpuCount: Int, memoryMB: UInt64, runtimeProfile: String ) throws { let timestamp = Date().formatted(.iso8601) - let marker = "\n--- DORY BOOT \(timestamp) machine=\(machineID) mode=\(bootMode.rawValue) cpus=\(cpuCount) memoryMB=\(memoryMB) runtime=\(runtimeProfile) ---\n" + let marker = "\n--- DORY BOOT \(timestamp) machine=\(machineID) operation=\(DoryOperationIdentity.canonical(operationID)) mode=\(bootMode.rawValue) cpus=\(cpuCount) memoryMB=\(memoryMB) runtime=\(runtimeProfile) ---\n" try log.write(contentsOf: Data(marker.utf8)) try log.synchronize() } diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index 44c6ecbf..c1c29bac 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -186,7 +186,8 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { ) } - func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { + func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { + let plan = request.plan guard plan.backend == descriptor, plan.capability.request.backend == descriptor.identity else { return failedOperation( @@ -223,6 +224,7 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { } return performAuthorizedStart(MachineBackendLaunchBinding( machineID: plan.machine.id, + operationID: request.operationID, backend: descriptor, componentIdentifier: componentIdentifier, executablePath: executablePath, @@ -438,7 +440,9 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable public func probe() -> MachineBackendProbeResult { core.probe() } public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) } - public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) } + public func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { + core.start(request) + } public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) } public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) } public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) } @@ -522,7 +526,9 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @ public func probe() -> MachineBackendProbeResult { core.probe() } public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) } - public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) } + public func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { + core.start(request) + } public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) } public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) } public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) } diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index 1620e8cb..99e8f03a 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -209,6 +209,9 @@ public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashab /// desired state or portable plan evidence. public struct MachineBackendLaunchBinding: Sendable, Equatable { public var machineID: String + /// The durable lifecycle operation authorizing this launch. Adapters must carry this exact + /// identifier back to MachineManager; they may not allocate a helper-local replacement. + public var operationID: UUID public var backend: MachineBackendDescriptor public var componentIdentifier: String public var executablePath: String @@ -219,6 +222,7 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { public init( machineID: String, + operationID: UUID, backend: MachineBackendDescriptor, componentIdentifier: String, executablePath: String, @@ -226,6 +230,7 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { devices: DoryVirtualMachineDeviceCapabilityRequest ) { self.machineID = machineID + self.operationID = operationID self.backend = backend self.componentIdentifier = componentIdentifier self.executablePath = executablePath @@ -234,6 +239,18 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { } } +/// One non-persisted start dispatch. The resolved backend plan remains immutable while the +/// lifecycle operation identifier is minted only when doryd begins the durable start mutation. +public struct MachineBackendStartRequest: Sendable, Equatable { + public var plan: MachineBackendPlan + public var operationID: UUID + + public init(plan: MachineBackendPlan, operationID: UUID) { + self.plan = plan + self.operationID = operationID + } +} + public enum MachineBackendLifecycleOperation: String, Codable, Sendable, Equatable, Hashable { case start case stop @@ -269,7 +286,7 @@ public protocol MachineBackend: Sendable { func probe() -> MachineBackendProbeResult func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult - func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult + func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult @@ -336,11 +353,11 @@ public struct BackendRegistry: Sendable { return backend.plan(request) } - public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { - guard let backend = backends[plan.backend.identity] else { - return missingBackendResult(operation: .start, identity: plan.backend.identity) + public func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { + guard let backend = backends[request.plan.backend.identity] else { + return missingBackendResult(operation: .start, identity: request.plan.backend.identity) } - return backend.start(plan) + return backend.start(request) } public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index cd6f571d..614451dc 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1772,12 +1772,14 @@ public final class MachineManager: @unchecked Sendable { targetIdentity: identity ) : nil + let operationID = try launchOperationID(id: id, lifecycle: lifecycle) do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } let status = try spawnPreparedMachine( prepared.machine, shareAuthorities: prepared.shareAuthorities, - launchBinding: nil + launchBinding: nil, + operationID: operationID ) if let lifecycle { if status.state == .failed { @@ -1876,6 +1878,7 @@ public final class MachineManager: @unchecked Sendable { targetIdentity: runtimeIdentity ) : nil + let operationID = try launchOperationID(id: id, lifecycle: lifecycle) do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } @@ -1887,13 +1890,17 @@ public final class MachineManager: @unchecked Sendable { runtimeComponents: resolved.resolvedPlan.components, graphics: resolved.resolvedPlan.graphics, devices: resolved.resolvedPlan.devices, + operationID: operationID, planRevision: resolved.resolvedPlan.planRevision, planSHA256: resolved.resolvedPlanSHA256, preSpawnAuthorization: preSpawnAuthorization, shareAuthorities: prepared.shareAuthorities ) defer { pendingResolvedStart = nil } - let operation = registry.start(resolved.backendPlan) + let operation = registry.start(MachineBackendStartRequest( + plan: resolved.backendPlan, + operationID: operationID + )) guard operation.isSuccess, operation.backend == resolved.resolvedPlan.backend, operation.observation?.machineID == id else { @@ -1992,6 +1999,7 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } guard let authorization = pendingResolvedStart, authorization.machine.id == binding.machineID, + authorization.operationID == binding.operationID, authorization.backend == binding.backend, binding.backend.identity == expectedBackend, authorization.graphics == binding.graphics, @@ -2029,11 +2037,29 @@ public final class MachineManager: @unchecked Sendable { authorization.machine, shareAuthorities: authorization.shareAuthorities, launchBinding: binding, + operationID: authorization.operationID, preSpawnAuthorization: authorization.preSpawnAuthorization, resolvedPlan: authorization.plan )) } + private func launchOperationID( + id: String, + lifecycle: MachineLifecycleJournalContext? + ) throws -> UUID { + if let lifecycle { + return lifecycle.operation.operationID + } + lock.lock() + defer { lock.unlock() } + guard let operationID = machines[id]?.activeOperationID else { + throw MachineManagerError.persistence( + "machine launch is missing a durable lifecycle operation identifier" + ) + } + return operationID + } + private func validateResolvedLaunch( _ resolved: DoryDaemonVirtualMachineLaunchPlanResolution, machine: DoryMachineConfiguration, @@ -2474,6 +2500,7 @@ public final class MachineManager: @unchecked Sendable { _ preparedMachine: DoryMachineConfiguration, shareAuthorities: [DoryMachineShareRuntimeAuthority], launchBinding: MachineBackendLaunchBinding?, + operationID: UUID, preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil, resolvedPlan: DoryResolvedMachinePlan? = nil ) throws -> DoryMachineStatus { @@ -2488,6 +2515,12 @@ public final class MachineManager: @unchecked Sendable { "machine configuration changed after launch validation" ) } + guard entry.activeOperationID == operationID else { + lock.unlock() + throw MachineManagerError.persistence( + "machine launch operation authority changed before helper spawn" + ) + } if entry.process?.isRunning == true { lock.unlock() throw MachineManagerError.alreadyRunning(preparedMachine.id) @@ -2499,7 +2532,12 @@ public final class MachineManager: @unchecked Sendable { do { handoffServer = try handoffPath.map { path in let server = VmmHandoffServer(path: path) { [weak self] result in - self?.handleHandoff(machineID: id, launchID: launchID, result: result) + self?.handleHandoff( + machineID: id, + launchID: launchID, + expectedOperationID: operationID, + result: result + ) } try server.start() return server @@ -2566,6 +2604,7 @@ public final class MachineManager: @unchecked Sendable { ) processLaunch = try self.processConfiguration( for: launchMachine, + operationID: operationID, handoffPath: handoffPath, resolvedLaunchBinding: launchBinding, restoreStatePath: entry.pendingRestoreStatePath @@ -3100,7 +3139,7 @@ public final class MachineManager: @unchecked Sendable { private func restoreSavedStateImplementation(id: String) throws -> DoryMachineStatus { lock.lock() - guard var entry = machines[id] else { + guard let entry = machines[id] else { lock.unlock() throw MachineManagerError.unknownMachine(id) } @@ -3157,16 +3196,18 @@ public final class MachineManager: @unchecked Sendable { try advanceLifecycleToPublishing(lifecycle) try refreshResolvedAdmissionForStartIfNeeded(id: id) lock.lock() - guard entry.configuration == machine, - entry.runtimeIdentity == runtimeIdentity, - machines[id]?.state == .suspended else { + guard var current = machines[id], + current.configuration == machine, + current.runtimeIdentity == runtimeIdentity, + current.state == .suspended, + current.activeOperationID == lifecycle.operation.operationID else { lock.unlock() throw MachineManagerError.persistence( "machine changed before saved-state restoration" ) } - entry.pendingRestoreStatePath = savedStateStore.statePath(machineID: id) - machines[id] = entry + current.pendingRestoreStatePath = savedStateStore.statePath(machineID: id) + machines[id] = current lock.unlock() let running = try startAndWaitUntilReady(id: id, journalLifecycle: false) @@ -5408,6 +5449,7 @@ public final class MachineManager: @unchecked Sendable { private func processConfiguration( for machine: DoryMachineConfiguration, + operationID: UUID, handoffPath: String?, resolvedLaunchBinding: MachineBackendLaunchBinding?, restoreStatePath: String? @@ -5421,6 +5463,7 @@ public final class MachineManager: @unchecked Sendable { executablePath: target.executablePath, arguments: try processArguments( for: machine, + operationID: operationID, handoffPath: handoffPath, baseArguments: target.baseArguments, acceleratedDesktop: target.acceleratedDesktop, @@ -5529,6 +5572,7 @@ public final class MachineManager: @unchecked Sendable { private func processArguments( for machine: DoryMachineConfiguration, + operationID: UUID, handoffPath: String?, baseArguments: [String], acceleratedDesktop: Bool, @@ -5546,6 +5590,7 @@ public final class MachineManager: @unchecked Sendable { : nil var arguments = baseArguments + [ "--machine-id", machine.id, + "--operation-id", operationID.uuidString.lowercased(), "--state-dir", machineStateDirectory(id: machine.id), "--dockerd-sock", "\(machineRuntimeDirectory(id: machine.id))/d.sock", "--agent-sock", "\(machineRuntimeDirectory(id: machine.id))/a.sock", @@ -8328,6 +8373,7 @@ public final class MachineManager: @unchecked Sendable { private func handleHandoff( machineID: String, launchID: UUID, + expectedOperationID: UUID, result: Result ) { var handoffServer: VmmHandoffServer? @@ -8337,7 +8383,8 @@ public final class MachineManager: @unchecked Sendable { var requiresAdmissionCommit = false var lifecycleFailureStepID: String? lock.lock() - guard var entry = machines[machineID], entry.launchID == launchID else { + guard var entry = machines[machineID], entry.launchID == launchID, + entry.activeOperationID == expectedOperationID else { lock.unlock() return } @@ -8346,12 +8393,14 @@ public final class MachineManager: @unchecked Sendable { admissionPlan = entry.activeResolvedPlan switch result { case let .success(handoff): - guard handoff.ready.machineID == machineID else { + let expectedOperationToken = expectedOperationID.uuidString.lowercased() + guard handoff.ready.machineID == machineID, + handoff.ready.operationID == expectedOperationToken else { entry.state = .failed setFailure( on: &entry, code: .readinessHandoffFailed, - message: "handoff machine id mismatch: \(handoff.ready.machineID)", + message: "handoff launch authority did not match the active machine operation", causes: [.readinessGate, .guestAgent], recoveryDisposition: .repair ) @@ -11865,6 +11914,7 @@ private struct PendingResolvedMachineStart { var runtimeComponents: [DoryResolvedBackendComponentEvidence] var graphics: DoryGraphicsAccelerationLevel var devices: DoryVirtualMachineDeviceCapabilityRequest + var operationID: UUID var planRevision: UInt64 var planSHA256: String var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization diff --git a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift index 73e07f07..e7d5dc54 100644 --- a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift +++ b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift @@ -2,8 +2,27 @@ import Darwin import DoryCore import Foundation +/// Canonical textual form used when a durable lifecycle UUID crosses process or guest boundaries. +/// Requiring the lowercase RFC 4122 spelling prevents multiple log/cursor identities for one UUID. +public enum DoryOperationIdentity { + public static func canonical(_ operationID: UUID) -> String { + operationID.uuidString.lowercased() + } + + public static func parseCanonical(_ value: String) -> UUID? { + guard value.utf8.count == 36, + value == value.lowercased(), + let parsed = UUID(uuidString: value), + canonical(parsed) == value else { + return nil + } + return parsed + } +} + public struct VmmReadyMessage: Sendable, Equatable, Codable { public var machineID: String + public var operationID: String? public var agentBuild: String? public var agentProtocolVersion: UInt32? public var agentCapabilities: [DoryAgentCapability] @@ -15,6 +34,7 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable { public init( machineID: String, + operationID: String? = nil, agentBuild: String? = nil, agentProtocolVersion: UInt32? = nil, agentCapabilities: [DoryAgentCapability] = [], @@ -25,6 +45,7 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable { detail: String? = nil ) { self.machineID = machineID + self.operationID = operationID self.agentBuild = agentBuild self.agentProtocolVersion = agentProtocolVersion self.agentCapabilities = agentCapabilities @@ -34,6 +55,10 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable { self.controlSocketPath = controlSocketPath self.detail = detail } + + public var hasValidOperationIdentity: Bool { + operationID.flatMap(DoryOperationIdentity.parseCanonical) != nil + } } public final class VmmHandoff: @unchecked Sendable { @@ -57,6 +82,7 @@ public enum VmmHandoffError: Error, Sendable, CustomStringConvertible { case syscall(String, Int32) case emptyMessage case invalidJSON(String) + case invalidReadyMessage public var description: String { switch self { @@ -68,6 +94,8 @@ public enum VmmHandoffError: Error, Sendable, CustomStringConvertible { return "empty VMM handoff message" case let .invalidJSON(message): return "invalid VMM handoff JSON: \(message)" + case .invalidReadyMessage: + return "invalid VMM readiness message" } } } @@ -230,6 +258,9 @@ public final class VmmHandoffServer: @unchecked Sendable { } catch { throw VmmHandoffError.invalidJSON("\(error)") } + guard ready.hasValidOperationIdentity else { + throw VmmHandoffError.invalidReadyMessage + } return VmmHandoff(ready: ready, fileDescriptors: fileDescriptors(from: Array(control.prefix(controlLength)))) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 58923b16..20578dcf 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -457,7 +457,7 @@ private struct SubstitutingBackend: MachineBackend { ) } - func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { + func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { unsupported(.start) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index b5b54f72..bca698d5 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -192,6 +192,7 @@ final class DoryVMMKitTests: XCTestCase { func testParsesDorydMachineArgumentsAsVirtualMachineMode() throws { let arguments = try parseDoryVMMArguments([ "--machine-id", "dev", + "--operation-id", "01234567-89ab-4cde-8f01-23456789abcd", "--state-dir", "/tmp/dory-machine-dev", "--data-drive", "/Volumes/Work/Dory.dorydrive", "--kernel", "/tmp/vmlinux", @@ -216,6 +217,10 @@ final class DoryVMMKitTests: XCTestCase { ]) XCTAssertEqual(arguments.machineID, "dev") + XCTAssertEqual( + arguments.operationID, + UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd") + ) XCTAssertEqual(arguments.stateDirectory, "/tmp/dory-machine-dev") XCTAssertEqual(arguments.dataDriveRoot, "/Volumes/Work/Dory.dorydrive") XCTAssertEqual(arguments.kernelPath, "/tmp/vmlinux") @@ -372,6 +377,7 @@ final class DoryVMMKitTests: XCTestCase { func testExitAfterHandoffKeepsContractShimMode() throws { let arguments = try parseDoryVMMArguments([ "--machine-id", "dev", + "--operation-id", "01234567-89ab-4cde-8f01-23456789abcd", "--handoff-sock", "/tmp/handoff.sock", "--exit-after-handoff", ]) @@ -382,6 +388,7 @@ final class DoryVMMKitTests: XCTestCase { func testMissingKernelAndRootfsDoesNotImplicitlyEnterShimMode() throws { let arguments = try parseDoryVMMArguments([ "--machine-id", "dev", + "--operation-id", "01234567-89ab-4cde-8f01-23456789abcd", "--state-dir", "/tmp/dory-machine-dev", "--handoff-sock", "/tmp/handoff.sock", ]) @@ -392,7 +399,25 @@ final class DoryVMMKitTests: XCTestCase { } } + func testOperationIDRequiresCanonicalLowercaseUUID() throws { + XCTAssertThrowsError(try parseDoryVMMArguments([ + "--operation-id", "01234567-89AB-4CDE-8F01-23456789ABCD", + ])) { error in + XCTAssertEqual( + error as? DoryVMMArgumentError, + .invalidOperationID("01234567-89AB-4CDE-8F01-23456789ABCD") + ) + } + var arguments = DoryVMMArguments() + arguments.machineID = "dev" + arguments.handoffSocketPath = "/tmp/handoff.sock" + XCTAssertThrowsError(try DoryVMMMain.run(arguments)) { error in + XCTAssertEqual(error as? DoryVMMArgumentError, .missingOperationID) + } + } + func testBuildsVZConfigurationWithRootfsVsockBalloonNetworkAndSerial() throws { + let operationID = UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd")! let base = "/tmp/dory-vmm-config-\(getpid())-\(UInt32.random(in: 0.. Date: Fri, 21 Aug 2026 22:37:05 +0000 Subject: [PATCH 224/338] feat(vm): round-trip start operation identity --- Dory/Runtime/Doryd/DorydClient.swift | 12 +++++- DoryTests/DorydClientTests.swift | 27 +++++++++++++- docs/virtual-workspace-platform.md | 14 ++++--- .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 18 ++++++++- .../Sources/DorydKit/MachineManager.swift | 37 ++++++++++++++++--- dory-core-swift/Sources/dorydctl/main.swift | 5 ++- .../DorydKitTests/DorydServiceTests.swift | 23 +++++++++++- ...eManagerResolvedPlanIntegrationTests.swift | 15 +++++++- .../DorydKitTests/MachineManagerTests.swift | 30 +++++++++++++-- 10 files changed, 160 insertions(+), 22 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 32c46c3d..c8c8ce39 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -16,6 +16,7 @@ nonisolated protocol DorydControlXPC { func dockerAgentTelemetry(reply: @escaping (NSDictionary, String) -> Void) func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStart(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -1712,9 +1713,16 @@ nonisolated final class DorydClient: @unchecked Sendable { } } - func machineStart(_ machineID: String) async throws -> DorydMachineStatus { + func machineStart( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineStart(machineID, reply: reply) + proxy.machineStart( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) } decode: { Self.machineStatus(from: $0) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index db10c2ba..e144f921 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1096,7 +1096,15 @@ struct DorydClientTests { networkMode: .disconnected ) )) - let startedMachine = try await client.machineStart("dev") + let startOperationID = UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd")! + let startedMachine = try await client.machineStart( + "dev", + operationID: startOperationID + ) + #expect( + service.latestMachineStartOperationID + == startOperationID.uuidString.lowercased() + ) let pausedMachine = try await client.machinePause("dev") let resumedMachine = try await client.machineResume("dev") let restartedMachine = try await client.machineRestart("dev") @@ -3843,6 +3851,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) ] private var _machineStartCount = 0 + private var _latestMachineStartOperationID: String? private var _machineStopCount = 0 private var _machinePauseCount = 0 private var _machineSuspendCount = 0 @@ -4008,6 +4017,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return _machineStartCount } + var latestMachineStartOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineStartOperationID + } + func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) { lock.lock() defer { lock.unlock() } @@ -4448,6 +4462,17 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineStart( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineStartOperationID = operationID + lock.unlock() + machineStart(machineID, reply: reply) + } + func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let current = machines[machineID] diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 602afd77..1eb729ab 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -715,12 +715,14 @@ output is available independently of Dory Tools through a generation-and-offset `dorydctl`, and XPC; reads are bounded and path-free, while input is accepted only through the private VMM console socket and is reported read-only for the current raw-HV UART. Console bytes are never copied into status, incidents, flight-recorder events, diagnostics, or support bundles. Full -start-operation propagation now carries the daemon's durable lifecycle UUID through the backend -registry, exact adapter launch binding, helper arguments, serial boot marker, Linux kernel/boot -context, guest-agent configuration, and the readiness echo; an absent, malformed, stale, or -different readiness UUID is rejected before the machine can become running. UI-originated -round-trip identity plus stop/pause/resume, component-installer, and device-event propagation and -device-level telemetry remain required before this section is complete. +start-operation propagation now carries the durable lifecycle UUID accepted by `doryd` through +the backend registry, exact adapter launch binding, helper arguments, serial boot marker, Linux +kernel/boot context, guest-agent configuration, and the readiness echo; an absent, malformed, +stale, or different readiness UUID is rejected before the machine can become running. The app and +CLI mint the canonical start UUID and round-trip it through the XPC boundary into that exact +durable operation; the retained legacy start selector mints at the daemon boundary for upgrade +compatibility. Stop/pause/resume, component-installer, and device-event propagation and device-level +telemetry remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 45dd81c1..af4da732 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -16,6 +16,7 @@ import Foundation func dockerAgentClockSync(reply: @escaping (NSDictionary, String) -> Void) func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStart(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 6c0f2c1d..d62eeb1e 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -281,8 +281,24 @@ public final class DorydService: NSObject, DorydControl { _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { + machineStart( + machineID, + operationID: DoryOperationIdentity.canonical(UUID()), + reply: reply + ) + } + + public func machineStart( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let parsedOperationID = DoryOperationIdentity.parseCanonical(operationID) else { + reply(false, [:], "machine start requires a canonical operation ID") + return + } machineControl(machineID, action: "start", reply: reply) { manager, id in - try manager.start(id: id) + try manager.start(id: id, operationID: parsedOperationID) } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 614451dc..7e93d7dc 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1639,9 +1639,17 @@ public final class MachineManager: @unchecked Sendable { } @discardableResult - public func start(id: String) throws -> DoryMachineStatus { + public func start( + id: String, + operationID: UUID? = nil + ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + if operationID == UUID(uuidString: "00000000-0000-0000-0000-000000000000") { + throw MachineManagerError.persistence( + "machine start operation identifier cannot be zero" + ) + } try requireNoActivePlanningMutation(id: id) lock.lock() guard let startEntry = machines[id] else { @@ -1676,18 +1684,24 @@ public final class MachineManager: @unchecked Sendable { try refreshResolvedAdmissionForStartIfNeeded(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } - return try startImplementation(id: id, journalLifecycle: true) + return try startImplementation( + id: id, + journalLifecycle: true, + requestedOperationID: operationID + ) } private func startImplementation( id: String, - journalLifecycle: Bool + journalLifecycle: Bool, + requestedOperationID: UUID? = nil ) throws -> DoryMachineStatus { switch launchPolicy { case .legacyCompatibility: return try startLegacyMachine( id: id, journalLifecycle: journalLifecycle, + requestedOperationID: requestedOperationID, expectedDurableIdentity: nil ) case .requireResolvedPlan: @@ -1706,6 +1720,7 @@ public final class MachineManager: @unchecked Sendable { planStore: planStore, revisionProvider: revisionProvider, journalLifecycle: journalLifecycle, + requestedOperationID: requestedOperationID, expectedRuntimeIdentity: nil ) case .perWorkspaceAuthority: @@ -1715,6 +1730,7 @@ public final class MachineManager: @unchecked Sendable { return try startLegacyMachine( id: id, journalLifecycle: journalLifecycle, + requestedOperationID: requestedOperationID, expectedDurableIdentity: identity ) case .requiresReplanning: @@ -1737,6 +1753,7 @@ public final class MachineManager: @unchecked Sendable { planStore: planStore, revisionProvider: revisionProvider, journalLifecycle: journalLifecycle, + requestedOperationID: requestedOperationID, expectedRuntimeIdentity: identity ) } @@ -1746,6 +1763,7 @@ public final class MachineManager: @unchecked Sendable { private func startLegacyMachine( id: String, journalLifecycle: Bool, + requestedOperationID: UUID?, expectedDurableIdentity: DoryMachineRuntimeIdentity? ) throws -> DoryMachineStatus { let prepared = try prepareMachineStartWithLifecycle( @@ -1769,7 +1787,8 @@ public final class MachineManager: @unchecked Sendable { let lifecycle = try journalLifecycle ? beginLifecycleStart( machine: prepared.authoritativeMachine, - targetIdentity: identity + targetIdentity: identity, + operationID: requestedOperationID ) : nil let operationID = try launchOperationID(id: id, lifecycle: lifecycle) @@ -1807,6 +1826,7 @@ public final class MachineManager: @unchecked Sendable { planStore: any DoryResolvedMachinePlanStoring, revisionProvider: ResolvedPlanRevisionProvider, journalLifecycle: Bool, + requestedOperationID: UUID?, expectedRuntimeIdentity: DoryMachineRuntimeIdentity? ) throws -> DoryMachineStatus { let prepared = try prepareMachineStartWithLifecycle( @@ -1875,7 +1895,8 @@ public final class MachineManager: @unchecked Sendable { let lifecycle = try journalLifecycle ? beginLifecycleStart( machine: prepared.authoritativeMachine, - targetIdentity: runtimeIdentity + targetIdentity: runtimeIdentity, + operationID: requestedOperationID ) : nil let operationID = try launchOperationID(id: id, lifecycle: lifecycle) @@ -10018,10 +10039,12 @@ public final class MachineManager: @unchecked Sendable { private func beginLifecycleStart( machine: DoryMachineConfiguration, - targetIdentity: DoryMachineRuntimeIdentity + targetIdentity: DoryMachineRuntimeIdentity, + operationID: UUID? ) throws -> MachineLifecycleJournalContext { let sourceIdentity = try currentRuntimeIdentity(id: machine.id) return try beginLifecycleOperation( + operationID: operationID, kind: .starting, source: lifecycleCondition( machine: machine, @@ -10254,6 +10277,7 @@ public final class MachineManager: @unchecked Sendable { } private func beginLifecycleOperation( + operationID: UUID? = nil, kind: DoryWorkspaceMutationKind, source: DoryWorkspaceLifecycleCondition, target: DoryWorkspaceLifecycleCondition, @@ -10284,6 +10308,7 @@ public final class MachineManager: @unchecked Sendable { let deadlineDelta: Int64 = 15 * 60 * 1_000 let deadline = created > Int64.max - deadlineDelta ? Int64.max : created + deadlineDelta let operation = DoryWorkspaceLifecycleOperation( + operationID: operationID ?? UUID(), kind: kind, source: source, target: target, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index ae3124d3..401953b1 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -1324,7 +1324,10 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitJSON(status) case "start": let name = try cursor.take("usage: dorydctl machine start NAME") - try emitJSON(try client.statusCommand { $0.machineStart(name, reply: $1) }) + let operationID = DoryOperationIdentity.canonical(UUID()) + try emitJSON(try client.statusCommand { + $0.machineStart(name, operationID: operationID, reply: $1) + }) case "stop": let name = try cursor.take("usage: dorydctl machine stop NAME") try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 7d1a7ed9..6cae08e3 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1432,8 +1432,25 @@ final class DorydServiceTests: XCTestCase { } wait(for: [create], timeout: 5) + let invalidStart = expectation(description: "machineStart invalid operation reply") + proxy.machineStart( + "dev", + operationID: "01234567-89AB-4CDE-8F01-23456789ABCD" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("canonical operation ID"), message) + invalidStart.fulfill() + } + wait(for: [invalidStart], timeout: 5) + XCTAssertEqual(manager.status(id: "dev")?.state, .created) + + let startOperationID = UUID( + uuidString: "01234567-89ab-4cde-8f01-23456789abcd" + )! + let startOperationToken = DoryOperationIdentity.canonical(startOperationID) let start = expectation(description: "machineStart reply") - proxy.machineStart("dev") { ok, body, message in + proxy.machineStart("dev", operationID: startOperationToken) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "running") XCTAssertNotNil(body["pid"]) @@ -1444,6 +1461,10 @@ final class DorydServiceTests: XCTestCase { start.fulfill() } wait(for: [start], timeout: 5) + let startEvents = try manager.flightRecorder(id: "dev", afterSequence: 0).events + .filter { $0.operationKind == DoryWorkspaceMutationKind.starting.rawValue } + XCTAssertFalse(startEvents.isEmpty) + XCTAssertTrue(startEvents.allSatisfy { $0.operationID == startOperationToken }) let pause = expectation(description: "machinePause reply") proxy.machinePause("dev") { ok, body, message in diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index d7cd5936..646dbb3d 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -29,7 +29,16 @@ struct MachineManagerResolvedPlanIntegrationTests { expectedPlanRevision: { _ in 1 } ) - let status = try manager.start(id: "dev") + let requestedOperationID = UUID( + uuidString: "01234567-89ab-4cde-8f01-23456789abcd" + )! + let requestedOperationToken = DoryOperationIdentity.canonical( + requestedOperationID + ) + let status = try manager.start( + id: "dev", + operationID: requestedOperationID + ) #expect(status.state == .running) #expect(starter.count == 1) #expect(resolver.callCount == 1) @@ -46,6 +55,10 @@ struct MachineManagerResolvedPlanIntegrationTests { ) #expect(status.runtimeIdentity.virtualHardwareABIVersion == 1) #expect(status.runtimeIdentity.components.count == 1) + let startEvents = try manager.flightRecorder(id: "dev", afterSequence: 0).events + .filter { $0.operationKind == DoryWorkspaceMutationKind.starting.rawValue } + #expect(!startEvents.isEmpty) + #expect(startEvents.allSatisfy { $0.operationID == requestedOperationToken }) let service = DorydService( socketPath: state + "/service.sock", machineManager: manager diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 5d9176e5..7a462096 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3327,7 +3327,13 @@ final class MachineManagerTests: XCTestCase { rootfsPath: doryTestRootfsPath )) - let starting = try manager.start(id: "dev") + let requestedOperationID = UUID( + uuidString: "01234567-89ab-4cde-8f01-23456789abcd" + )! + let starting = try manager.start( + id: "dev", + operationID: requestedOperationID + ) XCTAssertEqual(starting.state, .starting) let handoffPath = try XCTUnwrap(starting.handoffSocketPath) @@ -3702,7 +3708,18 @@ final class MachineManagerTests: XCTestCase { rootfsPath: doryTestRootfsPath, displayMode: .desktop )) - let starting = try manager.start(id: "dev") + XCTAssertThrowsError(try manager.start( + id: "dev", + operationID: UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: capture)) + let requestedOperationID = UUID( + uuidString: "fedcba98-7654-4321-8fed-cba987654321" + )! + let starting = try manager.start( + id: "dev", + operationID: requestedOperationID + ) for _ in 0..<100 where !FileManager.default.fileExists(atPath: capture) { Thread.sleep(forTimeInterval: 0.01) } @@ -3714,7 +3731,14 @@ final class MachineManagerTests: XCTestCase { } XCTAssertEqual(try value(after: "--state-dir"), durable + "/dev") - XCTAssertEqual(try value(after: "--operation-id"), starting.activeOperationID) + XCTAssertEqual( + starting.activeOperationID, + requestedOperationID.uuidString.lowercased() + ) + XCTAssertEqual( + try value(after: "--operation-id"), + requestedOperationID.uuidString.lowercased() + ) XCTAssertEqual(try value(after: "--display-mode"), "desktop") for flag in ["--dockerd-sock", "--agent-sock", "--shell-sock", "--control-sock"] { let path = try value(after: flag) From 386cd894ec2fb049b3a03fdcbd7f8c9246e1760a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 23:03:47 +0000 Subject: [PATCH 225/338] feat(vm): bind runtime lifecycle operation IDs --- Dory/Runtime/Doryd/DorydClient.swift | 36 ++- DoryTests/DorydClientTests.swift | 81 ++++++- docs/virtual-workspace-platform.md | 8 +- .../Sources/DorydKit/DorydControl.swift | 3 + .../Sources/DorydKit/DorydService.swift | 54 ++++- .../LinuxMachineBackendAdapters.swift | 49 +++- .../Sources/DorydKit/MachineBackend.swift | 41 +++- .../Sources/DorydKit/MachineManager.swift | 218 +++++++++++++++--- dory-core-swift/Sources/dorydctl/main.swift | 13 +- ...ePlanningTransactionCoordinatorTests.swift | 12 +- ...neProductionPlanningCompositionTests.swift | 12 +- ...onVirtualMachineRuntimePlanningTests.swift | 12 +- .../DorydKitTests/DorydServiceTests.swift | 71 +++++- .../DorydKitTests/MachineBackendTests.swift | 69 ++++-- ...eManagerResolvedPlanIntegrationTests.swift | 36 ++- .../DorydKitTests/MachineManagerTests.swift | 18 ++ 16 files changed, 639 insertions(+), 94 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index c8c8ce39..0b00ad68 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -18,9 +18,12 @@ nonisolated protocol DorydControlXPC { func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStop(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) @@ -1728,17 +1731,31 @@ nonisolated final class DorydClient: @unchecked Sendable { } } - func machineStop(_ machineID: String) async throws -> DorydMachineStatus { + func machineStop( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 30).statusCommand { proxy, reply in - proxy.machineStop(machineID, reply: reply) + proxy.machineStop( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) } decode: { Self.machineStatus(from: $0) } } - func machinePause(_ machineID: String) async throws -> DorydMachineStatus { + func machinePause( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 30).statusCommand { proxy, reply in - proxy.machinePause(machineID, reply: reply) + proxy.machinePause( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) } decode: { Self.machineStatus(from: $0) } @@ -1752,9 +1769,16 @@ nonisolated final class DorydClient: @unchecked Sendable { } } - func machineResume(_ machineID: String) async throws -> DorydMachineStatus { + func machineResume( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 30).statusCommand { proxy, reply in - proxy.machineResume(machineID, reply: reply) + proxy.machineResume( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) } decode: { Self.machineStatus(from: $0) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index e144f921..f8504e39 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1105,8 +1105,24 @@ struct DorydClientTests { service.latestMachineStartOperationID == startOperationID.uuidString.lowercased() ) - let pausedMachine = try await client.machinePause("dev") - let resumedMachine = try await client.machineResume("dev") + let pauseOperationID = UUID(uuidString: "12345678-9abc-4def-8012-3456789abcde")! + let pausedMachine = try await client.machinePause( + "dev", + operationID: pauseOperationID + ) + #expect( + service.latestMachinePauseOperationID + == pauseOperationID.uuidString.lowercased() + ) + let resumeOperationID = UUID(uuidString: "23456789-abcd-4ef0-8123-456789abcdef")! + let resumedMachine = try await client.machineResume( + "dev", + operationID: resumeOperationID + ) + #expect( + service.latestMachineResumeOperationID + == resumeOperationID.uuidString.lowercased() + ) let restartedMachine = try await client.machineRestart("dev") let machineStats = try await client.machineStats("dev") let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) @@ -1133,7 +1149,15 @@ struct DorydClientTests { let completedBackup = try await client.machineBackupRun(machineID: "dev") let removedBackup = try await client.machineBackupRemove(machineID: "dev") let deletedSnapshot = try await client.machineDeleteSnapshot(machineID: "dev", snapshotID: "s1") - let stoppedMachine = try await client.machineStop("dev") + let stopOperationID = UUID(uuidString: "3456789a-bcde-4f01-8234-56789abcdef0")! + let stoppedMachine = try await client.machineStop( + "dev", + operationID: stopOperationID + ) + #expect( + service.latestMachineStopOperationID + == stopOperationID.uuidString.lowercased() + ) let updatedMachine = try await client.machineUpdate( "dev", memoryMB: 4096, @@ -3853,9 +3877,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineStartCount = 0 private var _latestMachineStartOperationID: String? private var _machineStopCount = 0 + private var _latestMachineStopOperationID: String? private var _machinePauseCount = 0 + private var _latestMachinePauseOperationID: String? private var _machineSuspendCount = 0 private var _machineResumeCount = 0 + private var _latestMachineResumeOperationID: String? private var _machineRestartCount = 0 private var _machineDeleteCount = 0 private var _machineDeleteOK = true @@ -4022,6 +4049,21 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return _latestMachineStartOperationID } + var latestMachineStopOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineStopOperationID + } + + var latestMachinePauseOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachinePauseOperationID + } + + var latestMachineResumeOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineResumeOperationID + } + func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) { lock.lock() defer { lock.unlock() } @@ -4492,6 +4534,17 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineStop( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineStopOperationID = operationID + lock.unlock() + machineStop(machineID, reply: reply) + } + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let current = machines[machineID] @@ -4514,6 +4567,17 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machinePause( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachinePauseOperationID = operationID + lock.unlock() + machinePause(machineID, reply: reply) + } + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let current = machines[machineID] @@ -4556,6 +4620,17 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineResume( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineResumeOperationID = operationID + lock.unlock() + machineResume(machineID, reply: reply) + } + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() let current = machines[machineID] diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 1eb729ab..3a7b569e 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -721,8 +721,12 @@ kernel/boot context, guest-agent configuration, and the readiness echo; an absen stale, or different readiness UUID is rejected before the machine can become running. The app and CLI mint the canonical start UUID and round-trip it through the XPC boundary into that exact durable operation; the retained legacy start selector mints at the daemon boundary for upgrade -compatibility. Stop/pause/resume, component-installer, and device-event propagation and device-level -telemetry remain required before this section is complete. +compatibility. Stop, pause, and resume now also accept a caller-minted canonical UUID, reject +malformed or zero identities before mutation, preserve the exact UUID in the durable lifecycle +journal and flight recorder, and carry it through the selected backend adapter; the legacy XPC +selectors mint at the daemon boundary for rolling upgrades. Helper/guest acknowledgement for those +three operations, component-installer and device-event propagation, and device-level telemetry +remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index af4da732..9d42251e 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -18,9 +18,12 @@ import Foundation func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStop(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index d62eeb1e..bda238d3 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -306,8 +306,24 @@ public final class DorydService: NSObject, DorydControl { _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { + machineStop( + machineID, + operationID: DoryOperationIdentity.canonical(UUID()), + reply: reply + ) + } + + public func machineStop( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let parsedOperationID = DoryOperationIdentity.parseCanonical(operationID) else { + reply(false, [:], "machine stop requires a canonical operation ID") + return + } machineControl(machineID, action: "stop", reply: reply) { manager, id in - try manager.stop(id: id) + try manager.stop(id: id, operationID: parsedOperationID) } } @@ -315,8 +331,24 @@ public final class DorydService: NSObject, DorydControl { _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { + machinePause( + machineID, + operationID: DoryOperationIdentity.canonical(UUID()), + reply: reply + ) + } + + public func machinePause( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let parsedOperationID = DoryOperationIdentity.parseCanonical(operationID) else { + reply(false, [:], "machine pause requires a canonical operation ID") + return + } machineControl(machineID, action: "pause", reply: reply) { manager, id in - try manager.pause(id: id) + try manager.pause(id: id, operationID: parsedOperationID) } } @@ -333,8 +365,24 @@ public final class DorydService: NSObject, DorydControl { _ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void ) { + machineResume( + machineID, + operationID: DoryOperationIdentity.canonical(UUID()), + reply: reply + ) + } + + public func machineResume( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let parsedOperationID = DoryOperationIdentity.parseCanonical(operationID) else { + reply(false, [:], "machine resume requires a canonical operation ID") + return + } machineControl(machineID, action: "resume", reply: reply) { manager, id in - try manager.resume(id: id) + try manager.resume(id: id, operationID: parsedOperationID) } } diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index c1c29bac..ec2df94e 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -1,7 +1,12 @@ import DoryOperations import Foundation -public typealias MachineBackendLifecycleHandler = @Sendable (String) throws -> MachineBackendRuntimeObservation +public typealias MachineBackendLegacyStartHandler = @Sendable ( + String +) throws -> MachineBackendRuntimeObservation +public typealias MachineBackendLifecycleHandler = @Sendable ( + MachineBackendRuntimeRequest +) throws -> MachineBackendRuntimeObservation public typealias MachineBackendAuthorizedStartHandler = @Sendable ( MachineBackendLaunchBinding ) throws -> MachineBackendRuntimeObservation @@ -9,14 +14,14 @@ public typealias MachineBackendAuthorizedStartHandler = @Sendable ( /// Hooks implemented by the existing machine launcher. Keeping them injectable lets the seam be /// qualified before MachineManager starts consuming BackendRegistry. public struct MachineBackendCompatibilityOperations: Sendable { - public var start: MachineBackendLifecycleHandler + public var start: MachineBackendLegacyStartHandler public var authorizedStart: MachineBackendAuthorizedStartHandler public var stop: MachineBackendLifecycleHandler public var pause: MachineBackendLifecycleHandler public var resume: MachineBackendLifecycleHandler public init( - start: @escaping MachineBackendLifecycleHandler, + start: @escaping MachineBackendLegacyStartHandler, stop: @escaping MachineBackendLifecycleHandler, pause: @escaping MachineBackendLifecycleHandler, resume: @escaping MachineBackendLifecycleHandler @@ -188,6 +193,13 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { let plan = request.plan + guard request.hasValidOperationIdentity else { + return failedOperation( + .start, + code: .lifecycleOperationIdentityInvalid, + message: "The start request has no valid durable operation identity." + ) + } guard plan.backend == descriptor, plan.capability.request.backend == descriptor.identity else { return failedOperation( @@ -234,6 +246,13 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { } func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.hasValidOperationIdentity else { + return failedOperation( + .stop, + code: .lifecycleOperationIdentityInvalid, + message: "The stop request has no valid durable operation identity." + ) + } guard request.backend == descriptor.identity else { return failedOperation( .stop, @@ -244,10 +263,17 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { guard descriptor.lifecycle.stop else { return unsupportedOperation(.stop) } - return perform(.stop, machineID: request.machineID, operation: operations.stop) + return perform(.stop, request: request, operation: operations.stop) } func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.hasValidOperationIdentity else { + return failedOperation( + .pause, + code: .lifecycleOperationIdentityInvalid, + message: "The pause request has no valid durable operation identity." + ) + } guard request.backend == descriptor.identity else { return failedOperation( .pause, @@ -258,10 +284,17 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { guard descriptor.lifecycle.pause else { return unsupportedOperation(.pause) } - return perform(.pause, machineID: request.machineID, operation: operations.pause) + return perform(.pause, request: request, operation: operations.pause) } func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { + guard request.hasValidOperationIdentity else { + return failedOperation( + .resume, + code: .lifecycleOperationIdentityInvalid, + message: "The resume request has no valid durable operation identity." + ) + } guard request.backend == descriptor.identity else { return failedOperation( .resume, @@ -272,7 +305,7 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { guard descriptor.lifecycle.resume else { return unsupportedOperation(.resume) } - return perform(.resume, machineID: request.machineID, operation: operations.resume) + return perform(.resume, request: request, operation: operations.resume) } private func unavailableProbe( @@ -308,14 +341,14 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { private func perform( _ operation: MachineBackendLifecycleOperation, - machineID: String, + request: MachineBackendRuntimeRequest, operation handler: MachineBackendLifecycleHandler ) -> MachineBackendOperationResult { do { return MachineBackendOperationResult( operation: operation, backend: descriptor.identity, - observation: try handler(machineID), + observation: try handler(request), failure: nil ) } catch { diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index 99e8f03a..da8fa386 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -74,6 +74,7 @@ public enum MachineBackendFailureCode: String, Codable, Sendable, Equatable, Has case bootMediaUnsupported = "boot-media-unsupported" case machineConfigurationIncompatible = "machine-configuration-incompatible" case lifecycleOperationUnsupported = "lifecycle-operation-unsupported" + case lifecycleOperationIdentityInvalid = "lifecycle-operation-identity-invalid" case lifecycleOperationFailed = "lifecycle-operation-failed" } @@ -197,10 +198,21 @@ public struct MachineBackendRuntimeObservation: Codable, Sendable, Equatable, Ha public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashable { public var machineID: String public var backend: DoryVirtualizationBackendIdentity + /// Durable lifecycle authority minted before dispatch. Adapters must carry this exact UUID + /// back into the manager operation; allocating an adapter-local replacement is forbidden. + public var operationID: UUID + public var hasValidOperationIdentity: Bool { + operationID.uuidString != "00000000-0000-0000-0000-000000000000" + } - public init(machineID: String, backend: DoryVirtualizationBackendIdentity) { + public init( + machineID: String, + backend: DoryVirtualizationBackendIdentity, + operationID: UUID + ) { self.machineID = machineID self.backend = backend + self.operationID = operationID } } @@ -244,6 +256,9 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { public struct MachineBackendStartRequest: Sendable, Equatable { public var plan: MachineBackendPlan public var operationID: UUID + public var hasValidOperationIdentity: Bool { + operationID.uuidString != "00000000-0000-0000-0000-000000000000" + } public init(plan: MachineBackendPlan, operationID: UUID) { self.plan = plan @@ -354,6 +369,18 @@ public struct BackendRegistry: Sendable { } public func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { + guard request.hasValidOperationIdentity else { + return MachineBackendOperationResult( + operation: .start, + backend: request.plan.backend.identity, + observation: nil, + failure: MachineBackendFailure( + code: .lifecycleOperationIdentityInvalid, + backend: request.plan.backend.identity, + message: "The start request has no valid durable operation identity." + ) + ) + } guard let backend = backends[request.plan.backend.identity] else { return missingBackendResult(operation: .start, identity: request.plan.backend.identity) } @@ -388,6 +415,18 @@ public struct BackendRegistry: Sendable { request: MachineBackendRuntimeRequest, action: (any MachineBackend) -> MachineBackendOperationResult ) -> MachineBackendOperationResult { + guard request.hasValidOperationIdentity else { + return MachineBackendOperationResult( + operation: operation, + backend: request.backend, + observation: nil, + failure: MachineBackendFailure( + code: .lifecycleOperationIdentityInvalid, + backend: request.backend, + message: "The runtime request has no valid durable operation identity." + ) + ) + } guard let backend = backends[request.backend] else { return missingBackendResult(operation: operation, identity: request.backend) } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 7e93d7dc..f4db04e7 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -924,6 +924,9 @@ public final class MachineManager: @unchecked Sendable { ".dory-native-create-precommit-v1" private static let nativeCreationCommittedMarkerName = ".dory-native-create-committed-v1" + private static let zeroLifecycleOperationID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! private static let machineRestoreBackupMarker = ".restore-" private static let snapshotDeletionQuarantinePrefix = ".dory-snapshot-delete-" private static let snapshotDiskTemporaryMarker = ".ext4.tmp-" @@ -1208,30 +1211,35 @@ public final class MachineManager: @unchecked Sendable { expectedBackend: backend ) }, - stop: { [weak self] id in + stop: { [weak self] request in guard let self else { throw MachineManagerError.persistence("machine manager is unavailable") } - return MachineBackendRuntimeObservation(try self.stop(id: id)) + return MachineBackendRuntimeObservation( + try self.performAuthorizedResolvedBackendStop( + request: request, + expectedBackend: backend + ) + ) }, - pause: { [weak self] id in + pause: { [weak self] request in guard let self else { throw MachineManagerError.persistence("machine manager is unavailable") } return MachineBackendRuntimeObservation( try self.performAuthorizedResolvedBackendPause( - id: id, + request: request, expectedBackend: backend ) ) }, - resume: { [weak self] id in + resume: { [weak self] request in guard let self else { throw MachineManagerError.persistence("machine manager is unavailable") } return MachineBackendRuntimeObservation( try self.performAuthorizedResolvedBackendResume( - id: id, + request: request, expectedBackend: backend ) ) @@ -1645,11 +1653,10 @@ public final class MachineManager: @unchecked Sendable { ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } - if operationID == UUID(uuidString: "00000000-0000-0000-0000-000000000000") { - throw MachineManagerError.persistence( - "machine start operation identifier cannot be zero" - ) - } + let durableOperationID = try Self.lifecycleOperationID( + operationID, + action: "start" + ) try requireNoActivePlanningMutation(id: id) lock.lock() guard let startEntry = machines[id] else { @@ -1679,7 +1686,10 @@ public final class MachineManager: @unchecked Sendable { if isDurablySuspended { let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } - return try restoreSavedStateImplementation(id: id) + return try restoreSavedStateImplementation( + id: id, + requestedOperationID: durableOperationID + ) } try refreshResolvedAdmissionForStartIfNeeded(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) @@ -1687,10 +1697,23 @@ public final class MachineManager: @unchecked Sendable { return try startImplementation( id: id, journalLifecycle: true, - requestedOperationID: operationID + requestedOperationID: durableOperationID ) } + private static func lifecycleOperationID( + _ requested: UUID?, + action: String + ) throws -> UUID { + let operationID = requested ?? UUID() + guard operationID != zeroLifecycleOperationID else { + throw MachineManagerError.persistence( + "machine \(action) operation identifier cannot be zero" + ) + } + return operationID + } + private func startImplementation( id: String, journalLifecycle: Bool, @@ -2903,25 +2926,73 @@ public final class MachineManager: @unchecked Sendable { } public func stop(id: String) throws -> DoryMachineStatus { + try stop(id: id, operationID: UUID()) + } + + public func stop( + id: String, + operationID: UUID + ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + let durableOperationID = try Self.lifecycleOperationID( + operationID, + action: "stop" + ) try requireNoActivePlanningMutation(id: id) try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-stop") let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } - return try stopImplementation(id: id, journalLifecycle: true) + let identity = try currentRuntimeIdentity(id: id) + guard identity.mode == .resolvedPlan else { + return try stopImplementation( + id: id, + journalLifecycle: true, + requestedOperationID: durableOperationID + ) + } + guard let backend = identity.resolvedPlan?.backend, + let registry = resolvedLaunchRegistry else { + throw MachineManagerError.persistence( + "resolved stop infrastructure is not installed" + ) + } + let result = registry.stop(.init( + machineID: id, + backend: backend, + operationID: durableOperationID + )) + guard result.isSuccess, result.observation?.machineID == id, + result.observation?.state == .stopped else { + throw MachineManagerError.persistence( + "resolved backend stop failed: " + + (result.failure?.message ?? "backend returned no stopped observation") + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } - public func pause(id: String) throws -> DoryMachineStatus { + public func pause( + id: String, + operationID: UUID? = nil + ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + let durableOperationID = try Self.lifecycleOperationID( + operationID, + action: "pause" + ) try requireNoActivePlanningMutation(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } let identity = try currentRuntimeIdentity(id: id) switch identity.mode { case .legacyCompatibility: - return try pauseImplementation(id: id, journalLifecycle: true) + return try pauseImplementation( + id: id, + journalLifecycle: true, + requestedOperationID: durableOperationID + ) case .requiresReplanning: throw MachineManagerError.persistence( "machine \(id) requires replanning and cannot be paused" @@ -2933,7 +3004,11 @@ public final class MachineManager: @unchecked Sendable { "resolved pause infrastructure is not installed" ) } - let result = registry.pause(.init(machineID: id, backend: backend)) + let result = registry.pause(.init( + machineID: id, + backend: backend, + operationID: durableOperationID + )) guard result.isSuccess, result.observation?.machineID == id, result.observation?.state == .paused else { throw MachineManagerError.persistence( @@ -2945,9 +3020,16 @@ public final class MachineManager: @unchecked Sendable { } } - public func resume(id: String) throws -> DoryMachineStatus { + public func resume( + id: String, + operationID: UUID? = nil + ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + let durableOperationID = try Self.lifecycleOperationID( + operationID, + action: "resume" + ) try requireNoActivePlanningMutation(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } @@ -2955,12 +3037,19 @@ public final class MachineManager: @unchecked Sendable { let isDurablySuspended = machines[id]?.state == .suspended lock.unlock() if isDurablySuspended { - return try restoreSavedStateImplementation(id: id) + return try restoreSavedStateImplementation( + id: id, + requestedOperationID: durableOperationID + ) } let identity = try currentRuntimeIdentity(id: id) switch identity.mode { case .legacyCompatibility: - return try resumeImplementation(id: id, journalLifecycle: true) + return try resumeImplementation( + id: id, + journalLifecycle: true, + requestedOperationID: durableOperationID + ) case .requiresReplanning: throw MachineManagerError.persistence( "machine \(id) requires replanning and cannot be resumed" @@ -2972,7 +3061,11 @@ public final class MachineManager: @unchecked Sendable { "resolved resume infrastructure is not installed" ) } - let result = registry.resume(.init(machineID: id, backend: backend)) + let result = registry.resume(.init( + machineID: id, + backend: backend, + operationID: durableOperationID + )) guard result.isSuccess, result.observation?.machineID == id, result.observation?.state == .running else { throw MachineManagerError.persistence( @@ -3158,7 +3251,10 @@ public final class MachineManager: @unchecked Sendable { } } - private func restoreSavedStateImplementation(id: String) throws -> DoryMachineStatus { + private func restoreSavedStateImplementation( + id: String, + requestedOperationID: UUID? = nil + ) throws -> DoryMachineStatus { lock.lock() guard let entry = machines[id] else { lock.unlock() @@ -3198,6 +3294,7 @@ public final class MachineManager: @unchecked Sendable { artifactEvidenceSHA256: manifest.stateFileSHA256 ) let lifecycle = try beginLifecycleOperation( + operationID: requestedOperationID, kind: .restoring, source: lifecycleCondition( machine: machine, @@ -3313,37 +3410,67 @@ public final class MachineManager: @unchecked Sendable { return try startImplementation(id: id, journalLifecycle: true) } + private func performAuthorizedResolvedBackendStop( + request: MachineBackendRuntimeRequest, + expectedBackend: DoryVirtualizationBackendIdentity + ) throws -> DoryMachineStatus { + let identity = try currentRuntimeIdentity(id: request.machineID) + guard identity.mode == .resolvedPlan, + request.backend == expectedBackend, + identity.resolvedPlan?.backend == expectedBackend else { + throw MachineManagerError.persistence( + "resolved stop adapter does not match durable runtime authority" + ) + } + return try stopImplementation( + id: request.machineID, + journalLifecycle: true, + requestedOperationID: request.operationID + ) + } + private func performAuthorizedResolvedBackendPause( - id: String, + request: MachineBackendRuntimeRequest, expectedBackend: DoryVirtualizationBackendIdentity ) throws -> DoryMachineStatus { - let identity = try currentRuntimeIdentity(id: id) + let identity = try currentRuntimeIdentity(id: request.machineID) guard identity.mode == .resolvedPlan, + request.backend == expectedBackend, identity.resolvedPlan?.backend == expectedBackend else { throw MachineManagerError.persistence( "resolved pause adapter does not match durable runtime authority" ) } - return try pauseImplementation(id: id, journalLifecycle: true) + return try pauseImplementation( + id: request.machineID, + journalLifecycle: true, + requestedOperationID: request.operationID + ) } private func performAuthorizedResolvedBackendResume( - id: String, + request: MachineBackendRuntimeRequest, expectedBackend: DoryVirtualizationBackendIdentity ) throws -> DoryMachineStatus { - let identity = try currentRuntimeIdentity(id: id) + let identity = try currentRuntimeIdentity(id: request.machineID) guard identity.mode == .resolvedPlan, + request.backend == expectedBackend, identity.resolvedPlan?.backend == expectedBackend else { throw MachineManagerError.persistence( "resolved resume adapter does not match durable runtime authority" ) } - return try resumeImplementation(id: id, journalLifecycle: true) + return try resumeImplementation( + id: request.machineID, + journalLifecycle: true, + requestedOperationID: request.operationID + ) } private func pauseImplementation( id: String, - journalLifecycle: Bool + journalLifecycle: Bool, + requestedOperationID: UUID? = nil ) throws -> DoryMachineStatus { lock.lock() guard let entry = machines[id] else { @@ -3358,7 +3485,11 @@ public final class MachineManager: @unchecked Sendable { let runtimeIdentity = entry.runtimeIdentity lock.unlock() let lifecycle = try journalLifecycle - ? beginLifecyclePause(machine: machine, runtimeIdentity: runtimeIdentity) + ? beginLifecyclePause( + machine: machine, + runtimeIdentity: runtimeIdentity, + operationID: requestedOperationID + ) : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } @@ -3401,7 +3532,8 @@ public final class MachineManager: @unchecked Sendable { private func resumeImplementation( id: String, - journalLifecycle: Bool + journalLifecycle: Bool, + requestedOperationID: UUID? = nil ) throws -> DoryMachineStatus { lock.lock() guard let entry = machines[id] else { @@ -3416,7 +3548,11 @@ public final class MachineManager: @unchecked Sendable { let runtimeIdentity = entry.runtimeIdentity lock.unlock() let lifecycle = try journalLifecycle - ? beginLifecycleResume(machine: machine, runtimeIdentity: runtimeIdentity) + ? beginLifecycleResume( + machine: machine, + runtimeIdentity: runtimeIdentity, + operationID: requestedOperationID + ) : nil do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } @@ -3460,7 +3596,8 @@ public final class MachineManager: @unchecked Sendable { private func stopImplementation( id: String, journalLifecycle: Bool, - preserveResolvedAdmissionForRestart: Bool = false + preserveResolvedAdmissionForRestart: Bool = false, + requestedOperationID: UUID? = nil ) throws -> DoryMachineStatus { lock.lock() guard var entry = machines[id] else { @@ -3479,7 +3616,8 @@ public final class MachineManager: @unchecked Sendable { ? beginLifecycleStop( machine: machine, runtimeIdentity: runtimeIdentity, - sourceState: sourceState + sourceState: sourceState, + operationID: requestedOperationID ) : nil var stopCommitted = false @@ -10095,9 +10233,11 @@ public final class MachineManager: @unchecked Sendable { private func beginLifecycleStop( machine: DoryMachineConfiguration, runtimeIdentity: DoryMachineRuntimeIdentity, - sourceState: DoryWorkspaceLifecycleState + sourceState: DoryWorkspaceLifecycleState, + operationID: UUID? ) throws -> MachineLifecycleJournalContext { try beginLifecycleOperation( + operationID: operationID, kind: .stopping, source: lifecycleCondition( machine: machine, @@ -10116,9 +10256,11 @@ public final class MachineManager: @unchecked Sendable { private func beginLifecyclePause( machine: DoryMachineConfiguration, - runtimeIdentity: DoryMachineRuntimeIdentity + runtimeIdentity: DoryMachineRuntimeIdentity, + operationID: UUID? ) throws -> MachineLifecycleJournalContext { try beginLifecycleOperation( + operationID: operationID, kind: .pausing, source: lifecycleCondition( machine: machine, @@ -10137,9 +10279,11 @@ public final class MachineManager: @unchecked Sendable { private func beginLifecycleResume( machine: DoryMachineConfiguration, - runtimeIdentity: DoryMachineRuntimeIdentity + runtimeIdentity: DoryMachineRuntimeIdentity, + operationID: UUID? ) throws -> MachineLifecycleJournalContext { try beginLifecycleOperation( + operationID: operationID, kind: .resuming, source: lifecycleCondition( machine: machine, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 401953b1..69da5632 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -1330,12 +1330,16 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { }) case "stop": let name = try cursor.take("usage: dorydctl machine stop NAME") + let operationID = DoryOperationIdentity.canonical(UUID()) try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { - $0.machineStop(name, reply: $1) + $0.machineStop(name, operationID: operationID, reply: $1) }) case "pause": let name = try cursor.take("usage: dorydctl machine pause NAME") - try emitJSON(try client.statusCommand { $0.machinePause(name, reply: $1) }) + let operationID = DoryOperationIdentity.canonical(UUID()) + try emitJSON(try client.statusCommand { + $0.machinePause(name, operationID: operationID, reply: $1) + }) case "suspend": let name = try cursor.take("usage: dorydctl machine suspend NAME") try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { @@ -1343,7 +1347,10 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { }) case "resume": let name = try cursor.take("usage: dorydctl machine resume NAME") - try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) }) + let operationID = DoryOperationIdentity.canonical(UUID()) + try emitJSON(try client.statusCommand { + $0.machineResume(name, operationID: operationID, reply: $1) + }) case "restart": let name = try cursor.take("usage: dorydctl machine restart NAME") try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index 9ca8ad98..418a325f 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -569,9 +569,15 @@ private final class TransactionFixture: @unchecked Sendable { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, - pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, - resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } + stop: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .stopped) + }, + pause: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .paused) + }, + resume: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .running) + } ), executableIsAvailable: { _ in true } )]) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift index f26ab775..05e32a29 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift @@ -173,9 +173,15 @@ private final class CompositionFixture: @unchecked Sendable { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, - pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, - resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } + stop: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .stopped) + }, + pause: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .paused) + }, + resume: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .running) + } ), executableIsAvailable: { _ in true } ) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 20578dcf..9d90f6c7 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -369,9 +369,15 @@ private final class Fixture { executablePath: "/fixture/dory-hv", operations: MachineBackendCompatibilityOperations( start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }, - stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }, - pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) }, - resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) } + stop: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .stopped) + }, + pause: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .paused) + }, + resume: { request in + MachineBackendRuntimeObservation(machineID: request.machineID, state: .running) + } ), executableIsAvailable: { _ in true } )]) diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 6cae08e3..5960b41e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1466,8 +1466,25 @@ final class DorydServiceTests: XCTestCase { XCTAssertFalse(startEvents.isEmpty) XCTAssertTrue(startEvents.allSatisfy { $0.operationID == startOperationToken }) + let invalidPause = expectation(description: "machinePause invalid operation reply") + proxy.machinePause( + "dev", + operationID: "12345678-9ABC-4DEF-8012-3456789ABCDE" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("canonical operation ID"), message) + invalidPause.fulfill() + } + wait(for: [invalidPause], timeout: 5) + XCTAssertEqual(manager.status(id: "dev")?.state, .running) + + let pauseOperationID = UUID( + uuidString: "12345678-9abc-4def-8012-3456789abcde" + )! + let pauseOperationToken = DoryOperationIdentity.canonical(pauseOperationID) let pause = expectation(description: "machinePause reply") - proxy.machinePause("dev") { ok, body, message in + proxy.machinePause("dev", operationID: pauseOperationToken) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "paused") XCTAssertNotNil(body["pid"]) @@ -1475,14 +1492,40 @@ final class DorydServiceTests: XCTestCase { } wait(for: [pause], timeout: 5) + let invalidResume = expectation(description: "machineResume invalid operation reply") + proxy.machineResume( + "dev", + operationID: "23456789-ABCD-4EF0-8123-456789ABCDEF" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("canonical operation ID"), message) + invalidResume.fulfill() + } + wait(for: [invalidResume], timeout: 5) + XCTAssertEqual(manager.status(id: "dev")?.state, .paused) + + let resumeOperationID = UUID( + uuidString: "23456789-abcd-4ef0-8123-456789abcdef" + )! + let resumeOperationToken = DoryOperationIdentity.canonical(resumeOperationID) let resume = expectation(description: "machineResume reply") - proxy.machineResume("dev") { ok, body, message in + proxy.machineResume("dev", operationID: resumeOperationToken) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "running") XCTAssertNotNil(body["pid"]) resume.fulfill() } wait(for: [resume], timeout: 5) + let lifecycleEvents = try manager.flightRecorder(id: "dev", afterSequence: 0).events + XCTAssertTrue(lifecycleEvents.contains { + $0.operationKind == DoryWorkspaceMutationKind.pausing.rawValue + && $0.operationID == pauseOperationToken + }) + XCTAssertTrue(lifecycleEvents.contains { + $0.operationKind == DoryWorkspaceMutationKind.resuming.rawValue + && $0.operationID == resumeOperationToken + }) let suspend = expectation(description: "machineSuspend fail-closed reply") proxy.machineSuspend("dev") { ok, body, message in @@ -1517,13 +1560,35 @@ final class DorydServiceTests: XCTestCase { } wait(for: [list], timeout: 5) + let invalidStop = expectation(description: "machineStop invalid operation reply") + proxy.machineStop( + "dev", + operationID: "3456789A-BCDE-4F01-8234-56789ABCDEF0" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("canonical operation ID"), message) + invalidStop.fulfill() + } + wait(for: [invalidStop], timeout: 5) + XCTAssertEqual(manager.status(id: "dev")?.state, .running) + + let stopOperationID = UUID( + uuidString: "3456789a-bcde-4f01-8234-56789abcdef0" + )! + let stopOperationToken = DoryOperationIdentity.canonical(stopOperationID) let stop = expectation(description: "machineStop reply") - proxy.machineStop("dev") { ok, body, message in + proxy.machineStop("dev", operationID: stopOperationToken) { ok, body, message in XCTAssertTrue(ok, message) XCTAssertEqual(body["state"] as? String, "stopped") stop.fulfill() } wait(for: [stop], timeout: 5) + let stopEvents = try manager.flightRecorder(id: "dev", afterSequence: 0).events + XCTAssertTrue(stopEvents.contains { + $0.operationKind == DoryWorkspaceMutationKind.stopping.rawValue + && $0.operationID == stopOperationToken + }) let update = expectation(description: "machineUpdate reply") proxy.machineUpdate("dev", config: [ diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 1304f8eb..766f2a0d 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -107,13 +107,16 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(recorder.launchBindings.first?.graphics, .hostAcceleratedDisplay) XCTAssertEqual(recorder.launchBindings.first?.devices, .minimumBootable) + let stopOperationID = UUID(uuidString: "12345678-9abc-4def-8012-3456789abcde")! let stopped = registry.stop(MachineBackendRuntimeRequest( machineID: plan.machine.id, - backend: .doryHypervisor + backend: .doryHypervisor, + operationID: stopOperationID )) XCTAssertTrue(stopped.isSuccess) XCTAssertEqual(stopped.observation?.state, .stopped) XCTAssertEqual(recorder.events, ["start:raw-linux", "stop:raw-linux"]) + XCTAssertEqual(recorder.runtimeRequests.map(\.operationID), [stopOperationID]) } func testRegistryPlansVZInstallerWithPlannerSelection() throws { @@ -281,19 +284,41 @@ final class MachineBackendTests: XCTestCase { func testPauseAndResumeDispatchThroughTheSelectedBackend() { let recorder = recordingOperations() let backend = availableRawBackend(operations: recorder.operations) - let request = MachineBackendRuntimeRequest( + let zeroOperationID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! + let invalid = backend.pause(MachineBackendRuntimeRequest( machineID: "raw-linux", - backend: .doryHypervisor + backend: .doryHypervisor, + operationID: zeroOperationID + )) + XCTAssertEqual(invalid.failure?.code, .lifecycleOperationIdentityInvalid) + XCTAssertTrue(recorder.events.isEmpty) + let pauseOperationID = UUID(uuidString: "23456789-abcd-4ef0-8123-456789abcdef")! + let pauseRequest = MachineBackendRuntimeRequest( + machineID: "raw-linux", + backend: .doryHypervisor, + operationID: pauseOperationID ) - let paused = backend.pause(request) + let paused = backend.pause(pauseRequest) XCTAssertTrue(paused.isSuccess) XCTAssertEqual(paused.observation?.state, .paused) - let resumed = backend.resume(request) + let resumeOperationID = UUID(uuidString: "3456789a-bcde-4f01-8234-56789abcdef0")! + let resumeRequest = MachineBackendRuntimeRequest( + machineID: "raw-linux", + backend: .doryHypervisor, + operationID: resumeOperationID + ) + let resumed = backend.resume(resumeRequest) XCTAssertTrue(resumed.isSuccess) XCTAssertEqual(resumed.observation?.state, .running) XCTAssertEqual(recorder.events, ["pause:raw-linux", "resume:raw-linux"]) + XCTAssertEqual( + recorder.runtimeRequests.map(\.operationID), + [pauseOperationID, resumeOperationID] + ) } func testBackendPlanRoundTripsWithPlannerCapabilityEvidence() throws { @@ -394,6 +419,7 @@ private final class OperationRecorder: @unchecked Sendable { private let lock = NSLock() private var recordedEvents: [String] = [] private var recordedLaunchBindings: [MachineBackendLaunchBinding] = [] + private var recordedRuntimeRequests: [MachineBackendRuntimeRequest] = [] var events: [String] { lock.withLock { recordedEvents } @@ -403,6 +429,10 @@ private final class OperationRecorder: @unchecked Sendable { lock.withLock { recordedLaunchBindings } } + var runtimeRequests: [MachineBackendRuntimeRequest] { + lock.withLock { recordedRuntimeRequests } + } + lazy var operations = MachineBackendCompatibilityOperations( authorizedStart: { [weak self] binding in self?.append(binding) @@ -412,28 +442,31 @@ private final class OperationRecorder: @unchecked Sendable { processIdentifier: 42 ) }, - stop: { [weak self] id in - self?.append("stop:\(id)") - return MachineBackendRuntimeObservation(machineID: id, state: .stopped) + stop: { [weak self] request in + self?.append(request, event: "stop") + return MachineBackendRuntimeObservation(machineID: request.machineID, state: .stopped) }, - pause: { [weak self] id in - self?.append("pause:\(id)") - return MachineBackendRuntimeObservation(machineID: id, state: .paused) + pause: { [weak self] request in + self?.append(request, event: "pause") + return MachineBackendRuntimeObservation(machineID: request.machineID, state: .paused) }, - resume: { [weak self] id in - self?.append("resume:\(id)") - return MachineBackendRuntimeObservation(machineID: id, state: .running) + resume: { [weak self] request in + self?.append(request, event: "resume") + return MachineBackendRuntimeObservation(machineID: request.machineID, state: .running) } ) - private func append(_ event: String) { - lock.withLock { recordedEvents.append(event) } - } - private func append(_ binding: MachineBackendLaunchBinding) { lock.withLock { recordedLaunchBindings.append(binding) recordedEvents.append("start:\(binding.machineID)") } } + + private func append(_ request: MachineBackendRuntimeRequest, event: String) { + lock.withLock { + recordedRuntimeRequests.append(request) + recordedEvents.append("\(event):\(request.machineID)") + } + } } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 646dbb3d..209ecb2d 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -81,7 +81,41 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(!xpcText.contains("/bin/sleep")) #expect(!xpcText.contains(state)) #expect(FileManager.default.fileExists(atPath: state + "/dev/machine.json")) - _ = try manager.stop(id: "dev") + let pauseOperationID = UUID( + uuidString: "12345678-9abc-4def-8012-3456789abcde" + )! + #expect(try manager.pause( + id: "dev", + operationID: pauseOperationID + ).state == .paused) + let resumeOperationID = UUID( + uuidString: "23456789-abcd-4ef0-8123-456789abcdef" + )! + #expect(try manager.resume( + id: "dev", + operationID: resumeOperationID + ).state == .running) + let stopOperationID = UUID( + uuidString: "3456789a-bcde-4f01-8234-56789abcdef0" + )! + #expect(try manager.stop( + id: "dev", + operationID: stopOperationID + ).state == .stopped) + let lifecycleEvents = try manager.flightRecorder( + id: "dev", + afterSequence: 0 + ).events + for (kind, operationID) in [ + (DoryWorkspaceMutationKind.pausing, pauseOperationID), + (DoryWorkspaceMutationKind.resuming, resumeOperationID), + (DoryWorkspaceMutationKind.stopping, stopOperationID), + ] { + #expect(lifecycleEvents.contains { + $0.operationKind == kind.rawValue + && $0.operationID == DoryOperationIdentity.canonical(operationID) + }) + } let snapshot = try manager.snapshot(id: "dev", snapshotID: "evidence") #expect(snapshot.runtimeIdentity == status.runtimeIdentity) #expect(snapshot.artifactEvidence?.rootfs.sha256.count == 64) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 7a462096..98e70d51 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -127,12 +127,25 @@ final class MachineManagerTests: XCTestCase { let running = try manager.start(id: "dev") let runningPID = try XCTUnwrap(running.pid) XCTAssertEqual(running.state, .running) + let zeroOperationID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! + XCTAssertThrowsError(try manager.pause( + id: "dev", + operationID: zeroOperationID + )) + XCTAssertEqual(manager.status(id: "dev")?.state, .running) let paused = try manager.pause(id: "dev") XCTAssertEqual(paused.state, .paused) XCTAssertEqual(paused.pid, runningPID) XCTAssertEqual(kill(runningPID, 0), 0) XCTAssertThrowsError(try manager.pause(id: "dev")) + XCTAssertThrowsError(try manager.resume( + id: "dev", + operationID: zeroOperationID + )) + XCTAssertEqual(manager.status(id: "dev")?.state, .paused) let resumed = try manager.resume(id: "dev") XCTAssertEqual(resumed.state, .running) @@ -141,6 +154,11 @@ final class MachineManagerTests: XCTestCase { XCTAssertThrowsError(try manager.resume(id: "dev")) _ = try manager.pause(id: "dev") + XCTAssertThrowsError(try manager.stop( + id: "dev", + operationID: zeroOperationID + )) + XCTAssertEqual(manager.status(id: "dev")?.state, .paused) let stopped = try manager.stop(id: "dev") XCTAssertEqual(stopped.state, .stopped) XCTAssertNil(stopped.pid) From 426a3b7086677e47e497f80d28dc9f8a7ef87c27 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Fri, 21 Aug 2026 23:42:13 +0000 Subject: [PATCH 226/338] feat(vm): acknowledge lifecycle operations end to end --- Dory/Features/Machines/MachinesView.swift | 1 + .../Sources/dory-hv/DesktopMode.swift | 9 + .../Sources/dory-hv/main.swift | 6 +- docs/virtual-workspace-platform.md | 11 +- .../Sources/DoryCore/DoryCore.swift | 26 ++ .../DoryGuestIntegrationHealth.swift | 2 + .../DoryGuestIntegrationPackage.swift | 1 + .../Sources/DoryVMMKit/DoryVMM.swift | 40 ++- .../Sources/DorydKit/AgentControl.swift | 23 ++ .../DorydKit/DoryMachineFlightRecorder.swift | 4 + .../Sources/DorydKit/MachineManager.swift | 221 ++++++++++++- .../Sources/DorydKit/VmmControl.swift | 255 ++++++++++++++- .../DoryGuestIntegrationHealthTests.swift | 1 + .../DorydKitTests/AgentControlTests.swift | 41 ++- .../DorydKitTests/HealthReporterTests.swift | 3 +- .../DorydKitTests/MachineManagerTests.swift | 296 +++++++++++++++++- .../VmmLifecycleReceiptTests.swift | 88 ++++++ dory-core/agent/src/dispatch.rs | 85 +++++ dory-core/ffi/src/agent_forward.rs | 41 +++ dory-core/pb/proto/agent.proto | 22 ++ dory-core/remote/src/agent_client.rs | 50 ++- 21 files changed, 1190 insertions(+), 36 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/VmmLifecycleReceiptTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index ae6134fb..6eb2d23f 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1224,6 +1224,7 @@ private struct MachineIntegrationHealthSheet: View { case .processLaunch: "Process launch" case .processInput: "Process input" case .listenPorts: "Port discovery" + case .lifecycleReceipt: "Lifecycle acknowledgement" case .telemetry: "Telemetry" case .snapshotQuiesce: "Snapshot freeze/thaw" case .packageUpdate: "Tools update" diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 17565a28..edcde7eb 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -21,6 +21,7 @@ enum DesktopMode { var handoffSocketPath: String var agentSocketPath: String var shellSocketPath: String + var controlSocketPath: String var sshAgentSocketPath: String? var memoryMB: UInt64 var cpuCount: Int @@ -116,12 +117,16 @@ enum DesktopMode { private let sshAgentBridge: HostSSHAgentBridge? private let clipboard: DoryDesktopClipboardCoordinator? private let firstFrame: FirstFrameGate + private let lifecycleReceiptServer: VmmLifecycleReceiptServer private var signalSources = [DispatchSourceSignal]() private var stopError: Error? private var stopping = false init(configuration: Configuration) throws { self.configuration = configuration + self.lifecycleReceiptServer = VmmLifecycleReceiptServer( + socketPath: configuration.controlSocketPath + ) try FileManager.default.createDirectory( atPath: configuration.stateDirectory, withIntermediateDirectories: true @@ -357,6 +362,7 @@ enum DesktopMode { } func run() throws { + try lifecycleReceiptServer.start() application.setActivationPolicy(.regular) application.delegate = self installApplicationMenu() @@ -457,6 +463,7 @@ enum DesktopMode { configuration.operationID ), agentBuild: "dory-hv/generic-linux", + controlSocketPath: configuration.controlSocketPath, detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed" ) ) @@ -478,6 +485,7 @@ enum DesktopMode { agentCapabilities: info.capabilities, agentSocketPath: configuration.agentSocketPath, shellSocketPath: configuration.shellSocketPath, + controlSocketPath: configuration.controlSocketPath, detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" ) ) @@ -543,6 +551,7 @@ enum DesktopMode { } private func cleanup() { + lifecycleReceiptServer.stop() clipboard?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index c7cd2a57..3a0c193c 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -432,6 +432,7 @@ case "desktop": var handoffSocket: String? var agentSocket: String? var shellSocket: String? + var controlSocket: String? var sshAgentSocket: String? var memoryMB: UInt64 = 6_144 var cpus = 6 @@ -494,7 +495,8 @@ case "desktop": guard let mode = iterator.next(), ["linux-kernel", "efi-installed"].contains(mode) else { fail("raw-HV desktop requires --boot-mode linux-kernel or efi-installed") } - case "--control-sock", "--dockerd-sock": + case "--control-sock": controlSocket = iterator.next() + case "--dockerd-sock": _ = iterator.next() // accepted for dory-vmm command-line compatibility default: fail("unknown desktop option \(argument)") @@ -510,6 +512,7 @@ case "desktop": guard let handoffSocket else { fail("desktop requires --handoff-sock") } guard let agentSocket else { fail("desktop requires --agent-sock") } guard let shellSocket else { fail("desktop requires --shell-sock") } + guard let controlSocket else { fail("desktop requires --control-sock") } do { try DesktopMode.run(.init( machineID: machineID, @@ -524,6 +527,7 @@ case "desktop": handoffSocketPath: handoffSocket, agentSocketPath: agentSocket, shellSocketPath: shellSocket, + controlSocketPath: controlSocket, sshAgentSocketPath: sshAgentSocket, memoryMB: memoryMB, cpuCount: cpus, diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 3a7b569e..dd19d3cf 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -724,9 +724,14 @@ durable operation; the retained legacy start selector mints at the daemon bounda compatibility. Stop, pause, and resume now also accept a caller-minted canonical UUID, reject malformed or zero identities before mutation, preserve the exact UUID in the durable lifecycle journal and flight recorder, and carry it through the selected backend adapter; the legacy XPC -selectors mint at the daemon boundary for rolling upgrades. Helper/guest acknowledgement for those -three operations, component-installer and device-event propagation, and device-level telemetry -remain required before this section is complete. +selectors mint at the daemon boundary for rolling upgrades. Stop, pause, and resume now also carry +that exact UUID to the live helper and negotiated guest agent. The VZ helper returns the receipt +only around its actual pause/resume transition; raw-HV exposes an owner-only receipt socket around +daemon-owned signals; and the guest echoes the same action and UUID through `lifecycle-receipt@1`. +Mismatches reject the mutation, resolved-plan readiness requires the helper receipt channel, and +older guests without the capability use an explicit flight-recorder compatibility event rather +than claiming acknowledgement. Component-installer and device-event propagation, plus device-level +telemetry, remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index b58daf17..8aa0579a 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -169,6 +169,20 @@ public struct DoryAgentInfo: Sendable, Equatable { } } +public enum DoryLifecycleReceiptAction: String, Sendable, Equatable, Hashable, Codable { + case preparePause = "prepare-pause" + case resumed + case prepareStop = "prepare-stop" + + fileprivate var ffiValue: LifecycleReceiptActionFfi { + switch self { + case .preparePause: .preparePause + case .resumed: .resumed + case .prepareStop: .prepareStop + } + } +} + public struct DoryListenPort: Sendable, Equatable, Hashable { public var `protocol`: String public var port: UInt32 @@ -708,6 +722,18 @@ public final class DoryAgentControlHandle: @unchecked Sendable { try withControl { try $0.snapshotThaw(receiptId: receiptID) } } + public func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String { + try withControl { + try $0.lifecycleReceipt( + action: action.ffiValue, + operationId: operationID + ) + } + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift index dc8d8df0..a4107907 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationHealth.swift @@ -313,6 +313,8 @@ public struct DoryGuestIntegrationHealth: Codable, Sendable, Equatable, Hashable minimumVersion: 1, agentCapabilityID: "exec-stdin"), .init(id: .listenPorts, provider: .guestAgent, required: true, minimumVersion: 1, agentCapabilityID: "ports-watch"), + .init(id: .lifecycleReceipt, provider: .guestAgent, required: true, + minimumVersion: 1, agentCapabilityID: "lifecycle-receipt"), .init(id: .telemetry, provider: .guestAgent, required: true, minimumVersion: 1, agentCapabilityID: "telemetry"), .init(id: .fileTransferPull, provider: .guestAgent, required: false, diff --git a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift index 916ad7ed..a11fcd34 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryGuestIntegrationPackage.swift @@ -20,6 +20,7 @@ public enum DoryGuestIntegrationCapabilityID: String, Codable, Sendable, CaseIte case processLaunch = "exec" case processInput = "exec-stdin" case listenPorts = "ports-watch" + case lifecycleReceipt = "lifecycle-receipt" case telemetry case snapshotQuiesce = "snapshot-quiesce" case packageUpdate = "package-update" diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 68d6ad44..8317f29b 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -2339,11 +2339,27 @@ private final class DoryVMMControlServer: @unchecked Sendable { response: VmmControlResponse(ok: true, targetMB: appliedMB) ) case "pauseMachine": + let receipt = try lifecycleReceipt( + request, + expectedAction: .preparePause + ) try machine.pause() - return HandledControlResponse(response: VmmControlResponse(ok: true)) + return HandledControlResponse(response: receipt) case "resumeMachine": + let receipt = try lifecycleReceipt(request, expectedAction: .resumed) try machine.resume() - return HandledControlResponse(response: VmmControlResponse(ok: true)) + return HandledControlResponse(response: receipt) + case "acknowledgeLifecycle": + guard let action = request.lifecycleAction else { + return HandledControlResponse(response: VmmControlResponse( + ok: false, + message: "missing lifecycle action" + )) + } + return HandledControlResponse(response: try lifecycleReceipt( + request, + expectedAction: action + )) case "saveMachineState": guard let path = request.statePath, let accepted = acceptedSavedStatePath(path) else { @@ -2370,6 +2386,26 @@ private final class DoryVMMControlServer: @unchecked Sendable { } } + private func lifecycleReceipt( + _ request: VmmControlRequest, + expectedAction: DoryLifecycleReceiptAction + ) throws -> VmmControlResponse { + guard request.lifecycleAction == expectedAction, + let operationID = request.operationID, + DoryOperationIdentity.parseCanonical(operationID) != nil, + operationID != "00000000-0000-0000-0000-000000000000" else { + return VmmControlResponse( + ok: false, + message: "invalid VMM lifecycle operation authority" + ) + } + return VmmControlResponse( + ok: true, + lifecycleAction: expectedAction, + operationID: operationID + ) + } + private func acceptedSavedStatePath(_ path: String) -> String? { guard path.hasPrefix("/"), !path.contains("\0") else { return nil } let canonical = URL(fileURLWithPath: path).standardizedFileURL.path diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift index 4b30124e..f3525f9f 100644 --- a/dory-core-swift/Sources/DorydKit/AgentControl.swift +++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift @@ -44,6 +44,10 @@ public protocol AgentControlClient: Sendable { ) throws -> DoryPullStats func snapshotFreeze(receiptID: String) throws -> String func snapshotThaw(receiptID: String) throws + func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String func exec( argv: [String], cwd: String, @@ -63,6 +67,15 @@ public protocol AgentControlClient: Sendable { } public extension AgentControlClient { + func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String { + _ = action + _ = operationID + throw AgentControlError.capabilityUnavailable("lifecycle-receipt") + } + func snapshotFreeze(receiptID: String) throws -> String { _ = receiptID throw AgentControlError.capabilityUnavailable("snapshot-quiesce") @@ -244,6 +257,16 @@ public final class AgentControl: @unchecked Sendable { .snapshotThaw(receiptID: receiptID) } + public func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String { + try client(requiring: "lifecycle-receipt").lifecycleReceipt( + action: action, + operationID: operationID + ) + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift index 9f338b02..3696e567 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift @@ -9,6 +9,10 @@ public enum DoryMachineFlightEventKind: String, Codable, Sendable, CaseIterable case backendSpawned = "backend-spawned" case readinessAccepted = "readiness-accepted" case readinessRejected = "readiness-rejected" + case guestLifecycleAcknowledged = "guest-lifecycle-acknowledged" + case guestLifecycleUnavailable = "guest-lifecycle-unavailable" + case helperLifecycleAcknowledged = "helper-lifecycle-acknowledged" + case helperLifecycleUnavailable = "helper-lifecycle-unavailable" case resourceTransition = "resource-transition" case processExited = "process-exited" case failureRecorded = "failure-recorded" diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index f4db04e7..f24e82bb 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -3467,6 +3467,69 @@ public final class MachineManager: @unchecked Sendable { ) } + @discardableResult + private func acknowledgeGuestLifecycle( + entry: MachineEntry, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws -> Bool { + let canonical = DoryOperationIdentity.canonical(operationID) + guard let ready = entry.handoff?.ready, + ready.agentCapabilities.contains(where: { + $0.id == "lifecycle-receipt" && $0.version >= 1 + }), + let socketPath = ready.agentSocketPath else { + appendFlightEvent( + machineID: entry.configuration.id, + kind: .guestLifecycleUnavailable + ) + return false + } + let client = try agentConnector(socketPath) + defer { client.close() } + let receipt = try client.lifecycleReceipt( + action: action, + operationID: canonical + ) + guard receipt == canonical else { + throw MachineManagerError.persistence( + "guest returned a mismatched lifecycle operation receipt" + ) + } + appendFlightEvent( + machineID: entry.configuration.id, + kind: .guestLifecycleAcknowledged + ) + return true + } + + private func acknowledgeHelperLifecycle( + entry: MachineEntry, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws { + guard let socketPath = entry.handoff?.ready.controlSocketPath else { + appendFlightEvent( + machineID: entry.configuration.id, + kind: .helperLifecycleUnavailable + ) + return + } + try vzLifecycleController.acknowledgeLifecycle( + socketPath: socketPath, + action: action, + operationID: operationID + ) + appendFlightEvent( + machineID: entry.configuration.id, + kind: .helperLifecycleAcknowledged + ) + } + + private func recordHelperLifecycleAcknowledged(machineID: String) { + appendFlightEvent(machineID: machineID, kind: .helperLifecycleAcknowledged) + } + private func pauseImplementation( id: String, journalLifecycle: Bool, @@ -3491,21 +3554,50 @@ public final class MachineManager: @unchecked Sendable { operationID: requestedOperationID ) : nil + let operationID = lifecycle?.operation.operationID + ?? requestedOperationID + ?? entry.activeOperationID do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } + if let operationID { + _ = try acknowledgeGuestLifecycle( + entry: entry, + action: .preparePause, + operationID: operationID + ) + } let usedVZPause = entry.activeBackend == .appleVirtualizationFramework && entry.handoff?.ready.controlSocketPath != nil - if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZPause { - try vzLifecycleController.pause(socketPath: socketPath) - } else if !process.suspend() { - throw MachineManagerError.persistence("could not pause machine \(id)") + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZPause, + let operationID { + try vzLifecycleController.pause( + socketPath: socketPath, + operationID: operationID + ) + recordHelperLifecycleAcknowledged(machineID: id) + } else { + if let operationID { + try acknowledgeHelperLifecycle( + entry: entry, + action: .preparePause, + operationID: operationID + ) + } + guard process.suspend() else { + throw MachineManagerError.persistence("could not pause machine \(id)") + } } lock.lock() guard var current = machines[id], current.process === process, current.state == .running else { lock.unlock() if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZPause { - try? vzLifecycleController.resume(socketPath: socketPath) + if let operationID { + try? vzLifecycleController.resume( + socketPath: socketPath, + operationID: operationID + ) + } } else { _ = process.resume() } @@ -3554,24 +3646,46 @@ public final class MachineManager: @unchecked Sendable { operationID: requestedOperationID ) : nil + let operationID = lifecycle?.operation.operationID + ?? requestedOperationID + ?? entry.activeOperationID + var resumedBackend = false do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } let usedVZResume = entry.activeBackend == .appleVirtualizationFramework && entry.handoff?.ready.controlSocketPath != nil - if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZResume { - try vzLifecycleController.resume(socketPath: socketPath) - } else if !process.resume() { - throw MachineManagerError.persistence("could not resume machine \(id)") + if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZResume, + let operationID { + try vzLifecycleController.resume( + socketPath: socketPath, + operationID: operationID + ) + resumedBackend = true + recordHelperLifecycleAcknowledged(machineID: id) + } else { + guard process.resume() else { + throw MachineManagerError.persistence("could not resume machine \(id)") + } + resumedBackend = true + if let operationID { + try acknowledgeHelperLifecycle( + entry: entry, + action: .resumed, + operationID: operationID + ) + } + } + if let operationID { + _ = try acknowledgeGuestLifecycle( + entry: entry, + action: .resumed, + operationID: operationID + ) } lock.lock() guard var current = machines[id], current.process === process, current.state == .paused else { lock.unlock() - if let socketPath = entry.handoff?.ready.controlSocketPath, usedVZResume { - try? vzLifecycleController.pause(socketPath: socketPath) - } else { - _ = process.suspend() - } throw MachineManagerError.persistence( "machine \(id) changed while resume was being committed" ) @@ -3588,6 +3702,18 @@ public final class MachineManager: @unchecked Sendable { } return status(id: id) ?? DoryMachineStatus(id: id, state: .running) } catch { + if resumedBackend { + if let socketPath = entry.handoff?.ready.controlSocketPath, + entry.activeBackend == .appleVirtualizationFramework, + let operationID { + try? vzLifecycleController.pause( + socketPath: socketPath, + operationID: operationID + ) + } else { + _ = process.suspend() + } + } if let lifecycle { failLifecycle(lifecycle, stepID: "resume.failed") } throw error } @@ -3620,7 +3746,11 @@ public final class MachineManager: @unchecked Sendable { operationID: requestedOperationID ) : nil + let operationID = lifecycle?.operation.operationID + ?? requestedOperationID + ?? entry.activeOperationID var stopCommitted = false + var resumedPausedBackend = false do { if let lifecycle { try advanceLifecycleToPublishing(lifecycle) } // Stopping a suspended VM means discarding its same-host execution state and @@ -3629,6 +3759,35 @@ public final class MachineManager: @unchecked Sendable { if wasSuspended { try savedStateStore.remove(machineID: id) } + if entry.state == .paused, let operationID { + if let socketPath = entry.handoff?.ready.controlSocketPath, + entry.activeBackend == .appleVirtualizationFramework { + try vzLifecycleController.resume( + socketPath: socketPath, + operationID: operationID + ) + recordHelperLifecycleAcknowledged(machineID: id) + } else { + guard entry.process?.resume() == true else { + throw MachineManagerError.persistence( + "could not resume paused machine \(id) for graceful stop" + ) + } + } + resumedPausedBackend = true + } + if wasActive, let operationID { + _ = try acknowledgeGuestLifecycle( + entry: entry, + action: .prepareStop, + operationID: operationID + ) + try acknowledgeHelperLifecycle( + entry: entry, + action: .prepareStop, + operationID: operationID + ) + } lock.lock() guard let current = machines[id] else { lock.unlock() @@ -3692,6 +3851,18 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() throw error } + if resumedPausedBackend { + if let socketPath = entry.handoff?.ready.controlSocketPath, + entry.activeBackend == .appleVirtualizationFramework, + let operationID { + try? vzLifecycleController.pause( + socketPath: socketPath, + operationID: operationID + ) + } else { + _ = entry.process?.suspend() + } + } if let lifecycle { failLifecycle(lifecycle, stepID: "stop.failed") } throw error } @@ -8576,6 +8747,28 @@ public final class MachineManager: @unchecked Sendable { processToStop = entry.process break } + guard admissionPlan == nil || handoff.ready.controlSocketPath != nil else { + entry.state = .failed + setFailure( + on: &entry, + code: .readinessHandoffFailed, + message: "resolved helper omitted lifecycle receipt authority", + causes: [.readinessGate, .runtimeAuthority], + recoveryDisposition: .repair + ) + appendFlightEvent( + on: &entry, + kind: .readinessRejected, + failure: entry.failure + ) + entry.launchID = nil + entry.runtimeAddress = nil + entry.activeResolvedPlan = nil + entry.activeBackend = nil + entry.readinessAcceptedPendingPublication = false + processToStop = entry.process + break + } entry.handoff = handoff clearFailure(on: &entry) requiresAdmissionCommit = admissionPlan != nil diff --git a/dory-core-swift/Sources/DorydKit/VmmControl.swift b/dory-core-swift/Sources/DorydKit/VmmControl.swift index a9d2c255..df74b6c2 100644 --- a/dory-core-swift/Sources/DorydKit/VmmControl.swift +++ b/dory-core-swift/Sources/DorydKit/VmmControl.swift @@ -1,23 +1,58 @@ import Darwin +import DoryCore import Foundation public struct VmmControlRequest: Sendable, Equatable, Codable { public var command: String public var targetMB: UInt64? public var statePath: String? + public var lifecycleAction: DoryLifecycleReceiptAction? + public var operationID: String? - public init(command: String, targetMB: UInt64? = nil, statePath: String? = nil) { + public init( + command: String, + targetMB: UInt64? = nil, + statePath: String? = nil, + lifecycleAction: DoryLifecycleReceiptAction? = nil, + operationID: String? = nil + ) { self.command = command self.targetMB = targetMB self.statePath = statePath + self.lifecycleAction = lifecycleAction + self.operationID = operationID } public static func setBalloonTarget(_ targetMB: UInt64) -> VmmControlRequest { VmmControlRequest(command: "setBalloonTarget", targetMB: targetMB) } - public static let pauseMachine = VmmControlRequest(command: "pauseMachine") - public static let resumeMachine = VmmControlRequest(command: "resumeMachine") + public static func pauseMachine(operationID: UUID) -> VmmControlRequest { + VmmControlRequest( + command: "pauseMachine", + lifecycleAction: .preparePause, + operationID: DoryOperationIdentity.canonical(operationID) + ) + } + + public static func resumeMachine(operationID: UUID) -> VmmControlRequest { + VmmControlRequest( + command: "resumeMachine", + lifecycleAction: .resumed, + operationID: DoryOperationIdentity.canonical(operationID) + ) + } + + public static func acknowledgeLifecycle( + _ action: DoryLifecycleReceiptAction, + operationID: UUID + ) -> VmmControlRequest { + VmmControlRequest( + command: "acknowledgeLifecycle", + lifecycleAction: action, + operationID: DoryOperationIdentity.canonical(operationID) + ) + } public static func saveMachineState(to statePath: String) -> VmmControlRequest { VmmControlRequest(command: "saveMachineState", statePath: statePath) @@ -28,11 +63,21 @@ public struct VmmControlResponse: Sendable, Equatable, Codable { public var ok: Bool public var message: String public var targetMB: UInt64? + public var lifecycleAction: DoryLifecycleReceiptAction? + public var operationID: String? - public init(ok: Bool, message: String = "", targetMB: UInt64? = nil) { + public init( + ok: Bool, + message: String = "", + targetMB: UInt64? = nil, + lifecycleAction: DoryLifecycleReceiptAction? = nil, + operationID: String? = nil + ) { self.ok = ok self.message = message self.targetMB = targetMB + self.lifecycleAction = lifecycleAction + self.operationID = operationID } } @@ -69,18 +114,78 @@ public protocol MachineBalloonControlling: Sendable { public protocol MachineVZLifecycleControlling: Sendable { func pause(socketPath: String) throws func resume(socketPath: String) throws + func pause(socketPath: String, operationID: UUID) throws + func resume(socketPath: String, operationID: UUID) throws + func acknowledgeLifecycle( + socketPath: String, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws func saveMachineState(socketPath: String, statePath: String) throws } +public extension MachineVZLifecycleControlling { + func pause(socketPath: String, operationID: UUID) throws { + _ = operationID + try pause(socketPath: socketPath) + } + + func resume(socketPath: String, operationID: UUID) throws { + _ = operationID + try resume(socketPath: socketPath) + } + + func acknowledgeLifecycle( + socketPath: String, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws { + _ = socketPath + _ = action + _ = operationID + } +} + public struct UnixMachineVZLifecycleController: MachineVZLifecycleControlling { public init() {} public func pause(socketPath: String) throws { - try requireAccepted(.pauseMachine, socketPath: socketPath) + throw VmmControlError.rejected("pause operation identity is required") } public func resume(socketPath: String) throws { - try requireAccepted(.resumeMachine, socketPath: socketPath) + throw VmmControlError.rejected("resume operation identity is required") + } + + public func pause(socketPath: String, operationID: UUID) throws { + try requireAccepted( + .pauseMachine(operationID: operationID), + socketPath: socketPath, + expectedAction: .preparePause, + expectedOperationID: operationID + ) + } + + public func resume(socketPath: String, operationID: UUID) throws { + try requireAccepted( + .resumeMachine(operationID: operationID), + socketPath: socketPath, + expectedAction: .resumed, + expectedOperationID: operationID + ) + } + + public func acknowledgeLifecycle( + socketPath: String, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws { + try requireAccepted( + .acknowledgeLifecycle(action, operationID: operationID), + socketPath: socketPath, + expectedAction: action, + expectedOperationID: operationID + ) } public func saveMachineState(socketPath: String, statePath: String) throws { @@ -94,7 +199,9 @@ public struct UnixMachineVZLifecycleController: MachineVZLifecycleControlling { private func requireAccepted( _ request: VmmControlRequest, socketPath: String, - timeoutSeconds: TimeInterval = 5 + timeoutSeconds: TimeInterval = 5, + expectedAction: DoryLifecycleReceiptAction? = nil, + expectedOperationID: UUID? = nil ) throws { let response = try VmmControlClient.send( socketPath: socketPath, @@ -102,6 +209,15 @@ public struct UnixMachineVZLifecycleController: MachineVZLifecycleControlling { timeoutSeconds: timeoutSeconds ) guard response.ok else { throw VmmControlError.rejected(response.message) } + if let expectedAction, let expectedOperationID { + let canonical = DoryOperationIdentity.canonical(expectedOperationID) + guard response.lifecycleAction == expectedAction, + response.operationID == canonical else { + throw VmmControlError.rejected( + "VMM helper returned a mismatched lifecycle operation receipt" + ) + } + } } } @@ -222,3 +338,128 @@ private func readAll(from fd: Int32) throws -> Data { } } } + +/// Raw-HV helper-side receipt endpoint. Unlike the VZ control server it never mutates VM state; +/// it proves that the exact operation identity reached the live helper immediately around the +/// daemon-owned signal transition. +public final class VmmLifecycleReceiptServer: @unchecked Sendable { + private let socketPath: String + private let queue = DispatchQueue(label: "dev.dory.helper-lifecycle-receipt") + private let lock = NSLock() + private var listenerFD: Int32 = -1 + private var running = false + private var boundIdentity: (device: dev_t, inode: ino_t)? + + public init(socketPath: String) { + self.socketPath = socketPath + } + + public func start() throws { + lock.lock() + guard listenerFD < 0 else { + lock.unlock() + return + } + lock.unlock() + try FileManager.default.createDirectory( + atPath: (socketPath as NSString).deletingLastPathComponent, + withIntermediateDirectories: true + ) + unlink(socketPath) + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw VmmControlError.syscall("socket", errno) } + do { + var address = try unixAddress(path: socketPath) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + Darwin.bind(fd, raw, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw VmmControlError.syscall("bind", errno) } + guard chmod(socketPath, 0o600) == 0 else { + throw VmmControlError.syscall("chmod", errno) + } + guard listen(fd, 16) == 0 else { + throw VmmControlError.syscall("listen", errno) + } + var info = stat() + guard lstat(socketPath, &info) == 0 else { + throw VmmControlError.syscall("lstat", errno) + } + lock.lock() + listenerFD = fd + running = true + boundIdentity = (info.st_dev, info.st_ino) + lock.unlock() + queue.async { [weak self] in self?.acceptLoop(listenerFD: fd) } + } catch { + close(fd) + unlink(socketPath) + throw error + } + } + + public func stop() { + lock.lock() + let fd = listenerFD + let identity = boundIdentity + listenerFD = -1 + running = false + boundIdentity = nil + lock.unlock() + if fd >= 0 { close(fd) } + if let identity { + var info = stat() + if lstat(socketPath, &info) == 0, + info.st_dev == identity.device, + info.st_ino == identity.inode { + unlink(socketPath) + } + } + } + + private func acceptLoop(listenerFD: Int32) { + while isRunning(listenerFD: listenerFD) { + let client = accept(listenerFD, nil, nil) + guard client >= 0 else { continue } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.handle(clientFD: client) + } + } + } + + private func handle(clientFD: Int32) { + defer { close(clientFD) } + let response: VmmControlResponse + do { + try setSocketTimeouts(fd: clientFD, seconds: 5) + let data = try readAll(from: clientFD) + let request = try JSONDecoder().decode(VmmControlRequest.self, from: data) + guard request.command == "acknowledgeLifecycle", + let action = request.lifecycleAction, + let operationID = request.operationID, + DoryOperationIdentity.parseCanonical(operationID) != nil, + operationID != "00000000-0000-0000-0000-000000000000" else { + throw VmmControlError.rejected("invalid helper lifecycle receipt request") + } + response = VmmControlResponse( + ok: true, + lifecycleAction: action, + operationID: operationID + ) + } catch { + response = VmmControlResponse(ok: false, message: "\(error)") + } + if let encoded = try? JSONEncoder().encode(response) { + try? writeAll(encoded, to: clientFD) + } + } + + private func isRunning(listenerFD: Int32) -> Bool { + lock.lock() + defer { lock.unlock() } + return running && self.listenerFD == listenerFD + } + + deinit { stop() } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift index d4886ed1..3b982a28 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryGuestIntegrationHealthTests.swift @@ -228,6 +228,7 @@ struct DoryGuestIntegrationHealthTests { .init(id: "clock-sync", version: 1), .init(id: "exec", version: 1), .init(id: "exec-stdin", version: 1), + .init(id: "lifecycle-receipt", version: 1), .init(id: "ports-watch", version: 1), .init(id: "snapshot-quiesce", version: 2), .init(id: "sync-pull", version: 1), diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift index 6a1e539f..c7880c4c 100644 --- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift @@ -18,8 +18,8 @@ final class AgentControlTests: XCTestCase { let info = try control.info() XCTAssertEqual(info.agentBuild, "fake-agent") XCTAssertEqual(info.capabilities.map(\.id), [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-pull", - "sync-push", "telemetry", + "clock-sync", "exec", "exec-stdin", "lifecycle-receipt", "ports-watch", + "snapshot-quiesce", "sync-pull", "sync-push", "telemetry", ]) XCTAssertEqual(counter.value, 1) @@ -65,6 +65,18 @@ final class AgentControlTests: XCTestCase { try control.snapshotThaw(receiptID: receiptID) XCTAssertEqual(fake.snapshotFreezeReceipts, [receiptID]) XCTAssertEqual(fake.snapshotThawReceipts, [receiptID]) + let operationID = "12345678-1234-4234-8234-123456789abc" + XCTAssertEqual( + try control.lifecycleReceipt( + action: .preparePause, + operationID: operationID + ), + operationID + ) + XCTAssertEqual( + fake.lifecycleReceipts, + [.init(action: .preparePause, operationID: operationID)] + ) let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp") XCTAssertEqual(exec.exitCode, 0) XCTAssertEqual(String(data: exec.stdout, encoding: .utf8), "ok\n") @@ -152,6 +164,11 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda private var receivedPullLimits: [DoryPullLimits] = [] private var freezeReceipts: [String] = [] private var thawReceipts: [String] = [] + struct LifecycleReceipt: Equatable { + var action: DoryLifecycleReceiptAction + var operationID: String + } + private var receivedLifecycleReceipts: [LifecycleReceipt] = [] init( protocolVersion: UInt32 = DoryCore.protocolVersion(), @@ -159,6 +176,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda DoryAgentCapability(id: "clock-sync", version: 1), DoryAgentCapability(id: "exec", version: 1), DoryAgentCapability(id: "exec-stdin", version: 1), + DoryAgentCapability(id: "lifecycle-receipt", version: 1), DoryAgentCapability(id: "ports-watch", version: 1), DoryAgentCapability(id: "snapshot-quiesce", version: 2), DoryAgentCapability(id: "sync-pull", version: 1), @@ -218,6 +236,12 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda return thawReceipts } + var lifecycleReceipts: [LifecycleReceipt] { + lock.lock() + defer { lock.unlock() } + return receivedLifecycleReceipts + } + func info() throws -> DoryAgentInfo { DoryAgentInfo( protocolVersion: protocolVersion, @@ -312,6 +336,19 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda lock.unlock() } + func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String { + lock.lock() + receivedLifecycleReceipts.append(.init( + action: action, + operationID: operationID + )) + lock.unlock() + return operationID + } + func exec( argv: [String], cwd: String, diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index d07d9523..f729beec 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -467,7 +467,8 @@ final class HealthReporterTests: XCTestCase { func testMachineToolsHealthUsesDaemonIntegrationProjection() throws { let capabilities = [ - "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry", + "clock-sync", "exec", "exec-stdin", "lifecycle-receipt", "ports-watch", + "sync-push", "telemetry", ].map { DoryAgentCapability(id: $0, version: 1) } let plan = healthResolvedPlan() let resolvedIdentity = try DoryMachineRuntimeIdentity( diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 98e70d51..3cf1672d 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -164,6 +164,202 @@ final class MachineManagerTests: XCTestCase { XCTAssertNil(stopped.pid) } + func testLifecycleOperationsRequireExactGuestAndHelperReceipts() throws { + let base = "/tmp/dory-machine-lifecycle-receipts-\(getpid())-\(UInt32.random(in: 0.. any AgentControlClient { lock.lock() paths.append(socketPath) @@ -4711,6 +4982,22 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable { thaws += 1 lock.unlock() } + + func recordLifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) -> String { + lock.lock() + recordedLifecycleReceipts.append(.init( + action: action, + operationID: operationID + )) + let mismatches = mismatchedLifecycleAction == action + lock.unlock() + return mismatches + ? "ffffffff-ffff-4fff-8fff-ffffffffffff" + : operationID + } } private final class RecordingMachineAgentClient: AgentControlClient, @unchecked Sendable { @@ -4765,6 +5052,13 @@ private final class RecordingMachineAgentClient: AgentControlClient, @unchecked recorder.recordSnapshotThaw() } + func lifecycleReceipt( + action: DoryLifecycleReceiptAction, + operationID: String + ) throws -> String { + recorder.recordLifecycleReceipt(action: action, operationID: operationID) + } + func exec( argv: [String], cwd: String, diff --git a/dory-core-swift/Tests/DorydKitTests/VmmLifecycleReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/VmmLifecycleReceiptTests.swift new file mode 100644 index 00000000..3ed67929 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/VmmLifecycleReceiptTests.swift @@ -0,0 +1,88 @@ +import DoryCore +@testable import DorydKit +import Darwin +import Foundation +import XCTest + +final class VmmLifecycleReceiptTests: XCTestCase { + func testRawHelperReceiptServerEchoesExactLifecycleAuthority() throws { + let root = "/tmp/dory-helper-receipt-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let server = VmmLifecycleReceiptServer(socketPath: socketPath) + defer { + server.stop() + try? FileManager.default.removeItem(atPath: root) + } + try server.start() + + let operationID = try XCTUnwrap(UUID( + uuidString: "12345678-1234-4234-8234-123456789abc" + )) + let controller = UnixMachineVZLifecycleController() + try controller.acknowledgeLifecycle( + socketPath: socketPath, + action: .prepareStop, + operationID: operationID + ) + + let unsupported = try VmmControlClient.send( + socketPath: socketPath, + request: .pauseMachine(operationID: operationID) + ) + XCTAssertFalse(unsupported.ok) + } + + func testRawHelperReceiptServerRejectsMalformedAndZeroAuthorities() throws { + let root = "/tmp/dory-helper-receipt-invalid-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let server = VmmLifecycleReceiptServer(socketPath: socketPath) + defer { + server.stop() + try? FileManager.default.removeItem(atPath: root) + } + try server.start() + + for operationID in [ + "12345678-1234-4234-8234-123456789ABC", + "00000000-0000-0000-0000-000000000000", + "not-a-uuid", + ] { + let response = try VmmControlClient.send( + socketPath: socketPath, + request: VmmControlRequest( + command: "acknowledgeLifecycle", + lifecycleAction: .preparePause, + operationID: operationID + ) + ) + XCTAssertFalse(response.ok, operationID) + XCTAssertNil(response.operationID) + } + } + + func testStoppingOldReceiptServerDoesNotUnlinkReplacementSocket() throws { + let root = "/tmp/dory-helper-receipt-replace-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let old = VmmLifecycleReceiptServer(socketPath: socketPath) + let replacement = VmmLifecycleReceiptServer(socketPath: socketPath) + defer { + old.stop() + replacement.stop() + try? FileManager.default.removeItem(atPath: root) + } + try old.start() + XCTAssertEqual(unlink(socketPath), 0) + try replacement.start() + + old.stop() + + let operationID = try XCTUnwrap(UUID( + uuidString: "67896789-6789-4789-8789-678967896789" + )) + try UnixMachineVZLifecycleController().acknowledgeLifecycle( + socketPath: socketPath, + action: .preparePause, + operationID: operationID + ) + } +} diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index 33c54c6f..c31d3bed 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -24,6 +24,7 @@ pub fn agent_capabilities() -> Vec { ("clock-sync", 1), ("exec", 1), ("exec-stdin", 1), + ("lifecycle-receipt", 1), ("ports-watch", 1), ("sync-pull", 1), ("sync-push", 2), @@ -62,6 +63,9 @@ pub fn handle_method(method: Option) -> agent::AgentResponse { Some(Method::Info(_)) => Res::Info(info()), Some(Method::PortsWatch(_)) => Res::PortsWatch(ports_watch()), Some(Method::Telemetry(_)) => Res::Telemetry(telemetry()), + Some(Method::LifecycleReceipt(request)) => { + return lifecycle_receipt(request); + } Some(_) => return err(500, "async method must be dispatched via the async handler"), None => return err(400, "empty method"), }; @@ -70,6 +74,44 @@ pub fn handle_method(method: Option) -> agent::AgentResponse { } } +fn lifecycle_receipt(request: agent::LifecycleReceiptRequest) -> agent::AgentResponse { + use agent::lifecycle_receipt_request::Action; + + let Ok(action) = Action::try_from(request.action) else { + return err(422, "unknown lifecycle receipt action"); + }; + if action == Action::Unspecified { + return err(422, "lifecycle receipt action is required"); + } + if !is_canonical_nonzero_uuid(&request.operation_id) { + return err( + 422, + "lifecycle receipt operation_id must be a nonzero canonical lowercase UUID", + ); + } + agent::AgentResponse { + result: Some(Res::LifecycleReceipt(agent::LifecycleReceiptResponse { + acknowledged: true, + action: action as i32, + operation_id: request.operation_id, + })), + } +} + +fn is_canonical_nonzero_uuid(value: &str) -> bool { + if value.len() != 36 || value == "00000000-0000-0000-0000-000000000000" { + return false; + } + value + .as_bytes() + .iter() + .enumerate() + .all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_digit() || (b'a'..=b'f').contains(byte), + }) +} + fn info() -> agent::InfoResponse { agent::InfoResponse { proto_version: dory_proto::handshake::PROTO_VERSION, @@ -200,6 +242,7 @@ mod tests { ("clock-sync", 1), ("exec", 1), ("exec-stdin", 1), + ("lifecycle-receipt", 1), ("ports-watch", 1), ("sync-pull", 1), ("sync-push", 2), @@ -249,6 +292,48 @@ mod tests { assert!(matches!(resp.result, Some(Res::Error(_)))); } + #[test] + fn lifecycle_receipt_echoes_exact_canonical_operation_authority() { + use agent::lifecycle_receipt_request::Action; + + let operation_id = "12345678-1234-4234-8234-123456789abc"; + let out = dispatch(&encode(agent::agent_request::Method::LifecycleReceipt( + agent::LifecycleReceiptRequest { + action: Action::PreparePause as i32, + operation_id: operation_id.into(), + }, + ))); + let resp = agent::AgentResponse::decode(out.as_slice()).unwrap(); + match resp.result { + Some(Res::LifecycleReceipt(receipt)) => { + assert!(receipt.acknowledged); + assert_eq!(receipt.action, Action::PreparePause as i32); + assert_eq!(receipt.operation_id, operation_id); + } + other => panic!("expected LifecycleReceipt, got {other:?}"), + } + } + + #[test] + fn lifecycle_receipt_rejects_noncanonical_or_zero_authority() { + use agent::lifecycle_receipt_request::Action; + + for operation_id in [ + "12345678-1234-4234-8234-123456789ABC", + "00000000-0000-0000-0000-000000000000", + "not-a-uuid", + ] { + let out = dispatch(&encode(agent::agent_request::Method::LifecycleReceipt( + agent::LifecycleReceiptRequest { + action: Action::PrepareStop as i32, + operation_id: operation_id.into(), + }, + ))); + let resp = agent::AgentResponse::decode(out.as_slice()).unwrap(); + assert!(matches!(resp.result, Some(Res::Error(_)))); + } + } + /// The whole assembled RPC spine over an in-memory connection: handshake -> mux -> dispatch -> /// protobuf, exactly as doryd <-> the guest agent will talk, minus the vsock transport. #[tokio::test] diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 852a5cb9..006c407b 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -41,6 +41,13 @@ pub struct PortsWatchFfi { pub removed: Vec, } +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum LifecycleReceiptActionFfi { + PreparePause, + Resumed, + PrepareStop, +} + #[derive(uniffi::Object)] pub struct AgentControl { runtime: std::sync::Mutex>, @@ -286,6 +293,33 @@ impl AgentControl { .map(|_| ()) } + pub fn lifecycle_receipt( + &self, + action: LifecycleReceiptActionFfi, + operation_id: String, + ) -> Result { + use dory_pb::agent::lifecycle_receipt_request::Action; + + let action = match action { + LifecycleReceiptActionFfi::PreparePause => Action::PreparePause, + LifecycleReceiptActionFfi::Resumed => Action::Resumed, + LifecycleReceiptActionFfi::PrepareStop => Action::PrepareStop, + }; + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let receipt = + runtime.block_on(self.client.lifecycle_receipt(action, operation_id.clone()))?; + if !receipt.acknowledged + || receipt.action != action as i32 + || receipt.operation_id != operation_id + { + return Err(failed( + "guest returned a mismatched lifecycle operation receipt", + )); + } + Ok(receipt.operation_id) + } + pub fn exec( &self, argv: Vec, @@ -433,6 +467,13 @@ mod tests { .expect("clock sync"); let _ = control.ports_watch().expect("ports watch"); let _ = control.telemetry().expect("telemetry"); + let operation_id = "12345678-1234-4234-8234-123456789abc"; + assert_eq!( + control + .lifecycle_receipt(LifecycleReceiptActionFfi::PreparePause, operation_id.into()) + .expect("lifecycle receipt"), + operation_id + ); let exec = control .exec( vec!["/bin/sh".into(), "-lc".into(), "printf ffi-exec".into()], diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index 3f016fc2..dfee7b91 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -79,6 +79,26 @@ message SnapshotQuiesceResponse { string receipt_id = 3; } +// Acknowledge one host lifecycle operation at the guest boundary. This does not itself pause or +// terminate the VM: it proves that the exact durable operation identity reached the negotiated +// guest agent immediately before/after the corresponding helper transition. +message LifecycleReceiptRequest { + enum Action { + ACTION_UNSPECIFIED = 0; + PREPARE_PAUSE = 1; + RESUMED = 2; + PREPARE_STOP = 3; + } + Action action = 1; + // Canonical lowercase RFC 4122 UUID. The all-zero UUID is never a valid operation authority. + string operation_id = 2; +} +message LifecycleReceiptResponse { + bool acknowledged = 1; + LifecycleReceiptRequest.Action action = 2; + string operation_id = 3; +} + // Run a bounded non-interactive command in the guest. This is the primitive doryd uses for // VM-machine provisioning and smoke checks; streaming terminals/debug shells can layer on a // separate channel later. @@ -221,6 +241,7 @@ message AgentRequest { SyncTreeRequest sync_tree = 11; SyncReadTreeRequest sync_read_tree = 12; SyncGetChunkRequest sync_get_chunk = 13; + LifecycleReceiptRequest lifecycle_receipt = 14; } } @@ -240,5 +261,6 @@ message AgentResponse { SyncTreeResponse sync_tree = 12; SyncReadTreeResponse sync_read_tree = 13; SyncGetChunkResponse sync_get_chunk = 14; + LifecycleReceiptResponse lifecycle_receipt = 15; } } diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index dc219e89..6e46d843 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -11,11 +11,12 @@ use std::time::Duration; use dory_pb::agent::{ self, agent_request::Method, agent_response::Result as Res, AgentRequest, AgentResponse, - ClockSyncRequest, ExecEnv, ExecRequest, ExecResponse, InfoRequest, PortsWatchRequest, - SnapshotQuiesceRequest, SnapshotQuiesceResponse, SyncDeleteRequest, SyncDeleteResponse, - SyncFileStatusRequest, SyncFileStatusResponse, SyncGetChunkRequest, SyncGetChunkResponse, - SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse, - SyncReadTreeRequest, SyncReadTreeResponse, TelemetryRequest, TelemetryResponse, + ClockSyncRequest, ExecEnv, ExecRequest, ExecResponse, InfoRequest, LifecycleReceiptRequest, + LifecycleReceiptResponse, PortsWatchRequest, SnapshotQuiesceRequest, SnapshotQuiesceResponse, + SyncDeleteRequest, SyncDeleteResponse, SyncFileStatusRequest, SyncFileStatusResponse, + SyncGetChunkRequest, SyncGetChunkResponse, SyncManifestRequest, SyncManifestResponse, + SyncPutChunkRequest, SyncPutChunkResponse, SyncReadTreeRequest, SyncReadTreeResponse, + TelemetryRequest, TelemetryResponse, }; use dory_proto::handshake::{handshake, Hello}; use dory_proto::mux::Mux; @@ -144,6 +145,23 @@ impl AgentClient { } } + pub async fn lifecycle_receipt( + &self, + action: agent::lifecycle_receipt_request::Action, + operation_id: String, + ) -> Result { + match self + .call(Method::LifecycleReceipt(LifecycleReceiptRequest { + action: action as i32, + operation_id, + })) + .await? + { + Res::LifecycleReceipt(receipt) => Ok(receipt), + _ => Err(RemoteError::UnexpectedVariant), + } + } + pub async fn exec( &self, argv: Vec, @@ -338,6 +356,13 @@ mod tests { stdout_truncated: false, stderr_truncated: false, }), + Some(Method::LifecycleReceipt(request)) => { + Res::LifecycleReceipt(agent::LifecycleReceiptResponse { + acknowledged: true, + action: request.action, + operation_id: request.operation_id, + }) + } Some(Method::SyncReadTree(_)) => Res::SyncReadTree(agent::SyncReadTreeResponse { files: vec![agent::SyncFileEntry { path: "report.txt".into(), @@ -404,6 +429,21 @@ mod tests { assert_eq!(exec.exit_code, 0); assert_eq!(exec.stdout, b"exec-ok"); + let operation_id = "12345678-1234-4234-8234-123456789abc"; + let receipt = client + .lifecycle_receipt( + agent::lifecycle_receipt_request::Action::PrepareStop, + operation_id.into(), + ) + .await + .unwrap(); + assert!(receipt.acknowledged); + assert_eq!( + receipt.action, + agent::lifecycle_receipt_request::Action::PrepareStop as i32 + ); + assert_eq!(receipt.operation_id, operation_id); + let tree = client .sync_read_tree(SyncReadTreeRequest { root: "/home/dory/Downloads".into(), From d98c6aa96083e89928c4418bb1213d1c43fea197 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 00:13:43 +0000 Subject: [PATCH 227/338] feat(components): propagate component operation identity --- Dory/Features/Components/ComponentsView.swift | 47 ++++-- Dory/Models/AppStore.swift | 13 +- Dory/Runtime/Doryd/DorydClient.swift | 18 +++ DoryTests/DorydClientTests.swift | 62 +++++++- docs/virtual-workspace-platform.md | 9 +- .../DoryOperations/DoryComponents.swift | 64 +++++++- ...DorySignedComponentCandidateImporter.swift | 18 ++- .../DoryInstalledDesktopPayloadReceipt.swift | 3 + .../Sources/DorydKit/DorydService.swift | 24 ++- .../Sources/DorydKit/MachineManager.swift | 146 +++++++++++++++++- dory-core-swift/Sources/dorydctl/main.swift | 37 ++++- .../DoryComponentsTests.swift | 117 ++++++++++++++ ...ignedComponentCandidateImporterTests.swift | 29 +++- .../DorydKitTests/DorydServiceTests.swift | 27 ++++ .../DorydKitTests/MachineManagerTests.swift | 25 +++ scripts/desktop-linux-live-gate.sh | 18 ++- 16 files changed, 615 insertions(+), 42 deletions(-) diff --git a/Dory/Features/Components/ComponentsView.swift b/Dory/Features/Components/ComponentsView.swift index c58436b1..fec1e600 100644 --- a/Dory/Features/Components/ComponentsView.swift +++ b/Dory/Features/Components/ComponentsView.swift @@ -10,6 +10,7 @@ struct ComponentsView: View { @State private var catalogData = Data() @State private var statuses: [DoryComponentStatus] = [] @State private var progress: [DoryComponentID: DoryComponentProgress] = [:] + @State private var activeOperationIDs: [DoryComponentID: UUID] = [:] @State private var busy: Set = [] @State private var pendingRemoval: DoryComponentID? @State private var errorMessage: String? @@ -223,6 +224,8 @@ struct ComponentsView: View { .tint(p.accent) Text("\(currentProgress.phase.rawValue.capitalized) · \(formatted(currentProgress.completedBytes)) of \(formatted(currentProgress.totalBytes))") .font(.system(size: 10.5)).foregroundStyle(p.text3) + Text("Operation \(currentProgress.operationID.uuidString.lowercased().prefix(8))…") + .font(.system(size: 9.5, design: .monospaced)).foregroundStyle(p.text3) } } @@ -263,6 +266,11 @@ struct ComponentsView: View { Spacer(minLength: 0) Text(status.installedVersion.map { "v\($0)" } ?? "v\(status.availableVersion)") .font(.system(size: 10.5, weight: .medium)).foregroundStyle(p.text3) + if let operationID = status.installationOperationID { + Text("op \(operationID.prefix(8))…") + .font(.system(size: 9.5, design: .monospaced)).foregroundStyle(p.text3) + .help("Installed by component operation \(operationID)") + } } } @@ -411,13 +419,18 @@ struct ComponentsView: View { @MainActor @discardableResult private func install(_ id: DoryComponentID, showSuccess: Bool = true) async -> Bool { guard let catalog, !catalogData.isEmpty else { return false } - var operationIDs: Set = [id] + let operationID = UUID() + var affectedComponents: Set = [id] busy.insert(id) + activeOperationIDs[id] = operationID errorMessage = nil defer { - for operationID in operationIDs { - busy.remove(operationID) - progress[operationID] = nil + for componentID in affectedComponents { + busy.remove(componentID) + if activeOperationIDs[componentID] == operationID { + activeOperationIDs[componentID] = nil + progress[componentID] = nil + } } } do { @@ -432,15 +445,28 @@ struct ComponentsView: View { (try? store.verify(release.id)) != nil { continue } - operationIDs.insert(release.id) + affectedComponents.insert(release.id) busy.insert(release.id) - _ = try await installer.install(release, catalogData: catalogData) { update in - Task { @MainActor in self.progress[release.id] = update } + activeOperationIDs[release.id] = operationID + _ = try await installer.install( + release, + catalogData: catalogData, + operationID: operationID + ) { update in + Task { @MainActor in + guard self.activeOperationIDs[release.id] == update.operationID else { + return + } + self.progress[release.id] = update + } } activatedComponents.insert(release.id) busy.remove(release.id) } - let desktopUpdates = try await appStore.updateManagedDesktops(affectedBy: activatedComponents) + let desktopUpdates = try await appStore.updateManagedDesktops( + affectedBy: activatedComponents, + operationID: operationID + ) statuses = store.list(catalog: catalog, catalogDigest: digest) HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) if showSuccess { @@ -448,7 +474,10 @@ struct ComponentsView: View { ? "" : " Updated " + String(desktopUpdates.count) + " existing desktop" + (desktopUpdates.count == 1 ? "." : "s.") - appStore.showSettingsSuccess("\(displayName(id)) is installed and verified." + updated) + appStore.showSettingsSuccess( + "\(displayName(id)) is installed and verified (operation " + + "\(operationID.uuidString.lowercased().prefix(8))…)." + updated + ) } return true } catch { diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index c64ecd95..ca44e82e 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -1630,7 +1630,9 @@ final class AppStore { var checks: [DoryUpgradeSmokeCheck] = [] if Bundle.main.object(forInfoDictionaryKey: "DoryUpgradeGateForceSmokeFailure") as? Bool == true { do { - let component = try await applyReleaseGateComponentUpdate() + let component = try await applyReleaseGateComponentUpdate( + operationID: record.id + ) checks.append(.init( id: "release-gate.component-update", passed: true, @@ -1713,7 +1715,9 @@ final class AppStore { return checks } - private func applyReleaseGateComponentUpdate() async throws -> DoryInstalledComponent { + private func applyReleaseGateComponentUpdate( + operationID: UUID + ) async throws -> DoryInstalledComponent { guard let rawURL = Bundle.main.object(forInfoDictionaryKey: "DoryUpgradeGateComponentCatalogURL") as? String, let url = URL(string: rawURL), url.scheme?.lowercased() == "https", ["127.0.0.1", "::1"].contains(url.host ?? ""), @@ -1742,7 +1746,8 @@ final class AppStore { } return try await DoryComponentInstaller(store: store).install( release, - catalogData: fetched.data + catalogData: fetched.data, + operationID: operationID ) { _ in } } @@ -6232,6 +6237,7 @@ final class AppStore { /// last-good snapshot, reboot qualification, and automatic rollback transaction. func updateManagedDesktops( affectedBy components: Set, + operationID: UUID = UUID(), force: Bool = false, machineID: String? = nil ) async throws -> [DorydDesktopUpdateResult] { @@ -6313,6 +6319,7 @@ final class AppStore { do { let result = try await dorydClient.machineDesktopUpdate( status.id, + operationID: operationID, distro: distro.rawValue, version: targetVersion, distributionInstallationName: distroRelease.installationName, diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 0b00ad68..3b177a1c 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -997,6 +997,7 @@ nonisolated struct DorydMachineProvisionResult: Sendable, Equatable { } nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable { + var operationID: String? var machineID: String var distro: String var version: String @@ -2059,12 +2060,14 @@ nonisolated final class DorydClient: @unchecked Sendable { func machineDesktopUpdate( _ machineID: String, + operationID: UUID = UUID(), distro: String, version: String, distributionInstallationName: String, runtimeInstallationName: String ) async throws -> DorydDesktopUpdateResult { let request: NSDictionary = [ + "operationID": operationID.uuidString.lowercased(), "distro": distro, "version": version, "distributionInstallationName": distributionInstallationName, @@ -3417,6 +3420,20 @@ nonisolated final class DorydClient: @unchecked Sendable { } nonisolated private static func desktopUpdateResult(from dictionary: NSDictionary) -> DorydDesktopUpdateResult? { + let operationID: String? + if let rawOperationID = dictionary["operationID"] { + guard let value = rawOperationID as? String else { return nil } + operationID = value + } else { + operationID = nil + } + if let operationID { + guard let parsed = UUID(uuidString: operationID), + parsed != UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)), + operationID == parsed.uuidString.lowercased() else { + return nil + } + } guard let machineID = dictionary["machineID"] as? String, let distro = dictionary["distro"] as? String, let version = dictionary["version"] as? String, @@ -3429,6 +3446,7 @@ nonisolated final class DorydClient: @unchecked Sendable { return nil } return DorydDesktopUpdateResult( + operationID: operationID, machineID: machineID, distro: distro, version: version, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index f8504e39..1bd08e34 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -5,6 +5,28 @@ import Testing @Suite(.serialized) struct DorydClientTests { + @MainActor + @Test func desktopUpdateRejectsPresentMalformedOperationIdentity() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineDesktopUpdateOperationIDResponse(NSNumber(value: 7)) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + await #expect(throws: (any Error).self) { + _ = try await client.machineDesktopUpdate( + "dev", + distro: "ubuntu", + version: "1.0.0", + distributionInstallationName: "ubuntu-1", + runtimeInstallationName: "runtime-1" + ) + } + } + @Test func dorydSharesPreserveStableTagsAndAllocateAroundExistingIdentity() { let mounts = [ MountPair(host: "/tmp/first", guest: "/workspace/first", shareTag: "doryapp0"), @@ -1127,6 +1149,17 @@ struct DorydClientTests { let machineStats = try await client.machineStats("dev") let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) let provisionedMachine = try await client.machineProvision("dev", recipe: "rust") + let desktopUpdateOperationID = UUID( + uuidString: "456789ab-cdef-4012-8345-6789abcdef01" + )! + let desktopUpdate = try await client.machineDesktopUpdate( + "dev", + operationID: desktopUpdateOperationID, + distro: "ubuntu", + version: "24.04+runtime.1", + distributionInstallationName: "ubuntu-installation", + runtimeInstallationName: "runtime-installation" + ) let snapshot = try await client.machineSnapshot( "dev", note: "before", @@ -1232,6 +1265,11 @@ struct DorydClientTests { #expect(dockerAgentPorts.added == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentTelemetry.memTotalKB == 2048) #expect(stopped == DorydCommandResult(ok: true, message: "")) + #expect( + service.latestMachineDesktopUpdateOperationID + == desktopUpdateOperationID.uuidString.lowercased() + ) + #expect(desktopUpdate.operationID == desktopUpdateOperationID.uuidString.lowercased()) let createShares = try #require( service.latestMachineCreateConfig?["shares"] as? [NSDictionary] ) @@ -3902,6 +3940,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _latestMachineCreateConfig: NSDictionary? private var _latestMachineUpdateConfig: NSDictionary? private var _latestMachineProvisionRecipe: String? + private var _latestMachineDesktopUpdateOperationID: String? + private var _machineDesktopUpdateOperationIDResponseOverride: Any? private var _latestMachineTransferRequest: NSDictionary? private var _latestMachineTransferStartRequest: NSDictionary? private var _machineTransferResponseOverride: NSDictionary? @@ -4064,6 +4104,17 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return _latestMachineResumeOperationID } + var latestMachineDesktopUpdateOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineDesktopUpdateOperationID + } + + func setMachineDesktopUpdateOperationIDResponse(_ value: Any) { + lock.lock() + _machineDesktopUpdateOperationIDResponseOverride = value + lock.unlock() + } + func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) { lock.lock() defer { lock.unlock() } @@ -5006,8 +5057,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ) { lock.lock() let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped") + _latestMachineDesktopUpdateOperationID = request["operationID"] as? String + let operationIDResponse = _machineDesktopUpdateOperationIDResponseOverride + ?? request["operationID"] lock.unlock() - reply(true, [ + let response = NSMutableDictionary(dictionary: [ "machineID": machineID, "distro": request["distro"] as? String ?? "ubuntu", "version": request["version"] as? String ?? "test", @@ -5016,7 +5070,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "snapshotID": "du-test", "restoredRunningState": false, "status": current, - ] as NSDictionary, "") + ] as NSDictionary) + if let operationIDResponse { + response["operationID"] = operationIDResponse + } + reply(true, response, "") } func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index dd19d3cf..e1c001bc 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -730,8 +730,13 @@ only around its actual pause/resume transition; raw-HV exposes an owner-only rec daemon-owned signals; and the guest echoes the same action and UUID through `lifecycle-receipt@1`. Mismatches reject the mutation, resolved-plan readiness requires the helper receipt channel, and older guests without the capability use an explicit flight-recorder compatibility event rather -than claiming acknowledgement. Component-installer and device-event propagation, plus device-level -telemetry, remain required before this section is complete. +than claiming acknowledgement. Component install and update operations now also retain one +caller-minted canonical UUID across Dory.app, `dorydctl`, signed candidate dependency imports, +immutable installed-generation records, the desktop-update XPC request, the crash-recovery journal, +incident evidence, and the per-workspace flight recorder. Malformed or zero identifiers fail before +mutation, legacy clients receive an explicit daemon-minted compatibility identity, stale UI progress +is ignored, and the live release gate verifies the returned identity. Device-event propagation and +device-level telemetry remain required before this section is complete. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift index 4f610444..0b74cb1c 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift @@ -288,6 +288,7 @@ public enum DoryComponentError: Error, Sendable, Equatable, CustomStringConverti case incompatibleAppVersion(required: String, actual: String) case unknownComponent(String) case coreCannotBeChanged + case invalidOperationID case missingDependency(DoryComponentID) case componentInUse(DoryComponentID) case invalidAsset(String) @@ -307,6 +308,7 @@ public enum DoryComponentError: Error, Sendable, Equatable, CustomStringConverti "component catalog requires Dory \(required) or newer; this app is \(actual)" case .unknownComponent(let id): "unknown Dory component: \(id)" case .coreCannotBeChanged: "Docker Core is part of Dory.app and cannot be installed or removed separately" + case .invalidOperationID: "component operation identifier must be a nonzero UUID" case .missingDependency(let id): "install \(id.rawValue) first" case .componentInUse(let id): "remove dependent component \(id.rawValue) first" case .invalidAsset(let path): "invalid component asset: \(path)" @@ -640,6 +642,9 @@ public struct DoryInstalledComponent: Codable, Sendable, Equatable { public let id: DoryComponentID public let version: String public let installationName: String + /// Canonical caller-owned operation identity that published this immutable generation. + /// Schema-v2 records written before operation propagation omit this field and remain readable. + public let installationOperationID: String? public let catalogDigest: String public let installedAt: String public let assets: [DoryComponentAsset] @@ -648,6 +653,7 @@ public struct DoryInstalledComponent: Codable, Sendable, Equatable { init( release: DoryComponentRelease, installationName: String, + operationID: UUID, catalogDigest: String, installedAt: Date, assetFingerprints: [DoryComponentAssetFingerprint] @@ -657,6 +663,7 @@ public struct DoryInstalledComponent: Codable, Sendable, Equatable { id = release.id version = release.version self.installationName = installationName + installationOperationID = operationID.uuidString.lowercased() self.catalogDigest = catalogDigest self.installedAt = DoryComponentStore.timestamp(installedAt) assets = release.assets @@ -666,11 +673,21 @@ public struct DoryInstalledComponent: Codable, Sendable, Equatable { var isStructurallyValid: Bool { kind == Self.kind && schemaVersion == Self.schemaVersion && id.isRemovable && DoryComponentCatalogVerifier.safeRelativePath(installationName) + && (installationOperationID.map(Self.isCanonicalOperationID) ?? true) && catalogDigest.count == 64 && catalogDigest.allSatisfy(\.isHexDigit) && DoryComponentCatalogVerifier.validTimestamp(installedAt) && !assets.isEmpty && assetFingerprints.map(\.path) == assets.map(\.path) } + + private static func isCanonicalOperationID(_ value: String) -> Bool { + guard let id = UUID(uuidString: value), id != UUID.zero else { return false } + return value == id.uuidString.lowercased() + } +} + +private extension UUID { + static let zero = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) } public struct DoryComponentAssetFingerprint: Codable, Sendable, Equatable { @@ -699,6 +716,7 @@ public struct DoryComponentStatus: Codable, Sendable, Equatable, Identifiable { public let summary: String public let availableVersion: String public let installedVersion: String? + public let installationOperationID: String? public let state: DoryComponentState public let downloadBytes: UInt64 public let installedBytes: UInt64 @@ -768,6 +786,7 @@ public struct DoryComponentStore: Sendable { summary: release.summary, availableVersion: release.version, installedVersion: release.version, + installationOperationID: nil, state: .bundled, downloadBytes: release.downloadBytes, installedBytes: release.installedBytes, @@ -804,6 +823,7 @@ public struct DoryComponentStore: Sendable { summary: release.summary, availableVersion: release.version, installedVersion: installed?.version, + installationOperationID: installed?.installationOperationID, state: state, downloadBytes: release.downloadBytes, installedBytes: release.installedBytes, @@ -874,8 +894,28 @@ public struct DoryComponentStore: Sendable { downloadedAssets: [String: String], installedAt: Date = Date(), fileManager: FileManager = .default + ) throws -> DoryInstalledComponent { + try install( + release, + catalogDigest: catalogDigest, + downloadedAssets: downloadedAssets, + operationID: UUID(), + installedAt: installedAt, + fileManager: fileManager + ) + } + + @discardableResult + public func install( + _ release: DoryComponentRelease, + catalogDigest: String, + downloadedAssets: [String: String], + operationID: UUID, + installedAt: Date = Date(), + fileManager: FileManager = .default ) throws -> DoryInstalledComponent { guard release.id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + guard operationID != .zero else { throw DoryComponentError.invalidOperationID } guard catalogDigest.count == 64, catalogDigest.allSatisfy(\.isHexDigit) else { throw DoryComponentError.invalidCatalog("catalog digest is invalid") } @@ -896,9 +936,9 @@ public struct DoryComponentStore: Sendable { return current } - let operationID = UUID().uuidString.lowercased() - let installationName = "\(release.version)-\(operationID)" - let staging = stagingRoot + "/\(release.id.rawValue)-\(operationID)" + let canonicalOperationID = operationID.uuidString.lowercased() + let installationName = "\(release.version)-\(canonicalOperationID)" + let staging = stagingRoot + "/\(release.id.rawValue)-\(canonicalOperationID)" let payload = staging + "/payload" let destination = installationRoot(id: release.id, name: installationName) var published = false @@ -936,6 +976,7 @@ public struct DoryComponentStore: Sendable { let record = DoryInstalledComponent( release: release, installationName: installationName, + operationID: operationID, catalogDigest: catalogDigest, installedAt: installedAt, assetFingerprints: try release.assets.map { @@ -1430,6 +1471,7 @@ public struct DoryComponentProgress: Sendable, Equatable { case complete } + public let operationID: UUID public let component: DoryComponentID public let phase: Phase public let completedBytes: UInt64 @@ -1450,15 +1492,18 @@ public actor DoryComponentInstaller { public func install( _ release: DoryComponentRelease, catalogData: Data, + operationID: UUID = UUID(), progress: @escaping Progress = { _ in } ) async throws -> DoryInstalledComponent { guard release.id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + guard operationID != .zero else { throw DoryComponentError.invalidOperationID } try store.prepare() var downloaded: [String: String] = [:] var completed: UInt64 = 0 for asset in release.assets { let completedBeforeAsset = completed progress(DoryComponentProgress( + operationID: operationID, component: release.id, phase: .downloading, completedBytes: completed, @@ -1466,6 +1511,7 @@ public actor DoryComponentInstaller { )) let path = try await download(asset) { assetBytes in progress(DoryComponentProgress( + operationID: operationID, component: release.id, phase: .downloading, completedBytes: completedBeforeAsset + assetBytes, @@ -1476,18 +1522,28 @@ public actor DoryComponentInstaller { downloaded[asset.path] = path } progress(DoryComponentProgress( + operationID: operationID, component: release.id, phase: .verifying, completedBytes: release.downloadBytes, totalBytes: release.downloadBytes )) + progress(DoryComponentProgress( + operationID: operationID, + component: release.id, + phase: .installing, + completedBytes: release.downloadBytes, + totalBytes: release.downloadBytes + )) let installed = try store.install( release, catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), - downloadedAssets: downloaded + downloadedAssets: downloaded, + operationID: operationID ) for path in downloaded.values { try? FileManager.default.removeItem(atPath: path) } progress(DoryComponentProgress( + operationID: operationID, component: release.id, phase: .complete, completedBytes: release.downloadBytes, diff --git a/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift b/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift index 75c0f7da..06768d21 100644 --- a/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift +++ b/dory-core-swift/Sources/DoryOperations/DorySignedComponentCandidateImporter.swift @@ -3,10 +3,16 @@ import Darwin import Foundation public struct DorySignedComponentCandidateImportResult: Sendable, Equatable { + public let operationID: UUID public let catalogDigest: String public let installed: [DoryInstalledComponent] - public init(catalogDigest: String, installed: [DoryInstalledComponent]) { + public init( + operationID: UUID, + catalogDigest: String, + installed: [DoryInstalledComponent] + ) { + self.operationID = operationID self.catalogDigest = catalogDigest self.installed = installed } @@ -50,9 +56,13 @@ public struct DorySignedComponentCandidateImporter: Sendable { public func install( _ id: DoryComponentID, - from candidateDirectory: String + from candidateDirectory: String, + operationID: UUID = UUID() ) throws -> DorySignedComponentCandidateImportResult { guard id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + guard operationID != UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) else { + throw DoryComponentError.invalidOperationID + } let root = try Self.candidateDirectory(candidateDirectory) let catalogPath = try Self.candidateFile("catalog.json", in: root) let digestPath = try Self.candidateFile("catalog.json.sha256", in: root) @@ -136,12 +146,14 @@ public struct DorySignedComponentCandidateImporter: Sendable { let component = try store.install( release, catalogDigest: catalogDigest, - downloadedAssets: sourcesByComponent[release.id] ?? [:] + downloadedAssets: sourcesByComponent[release.id] ?? [:], + operationID: operationID ) try store.verify(component) installed.append(component) } return DorySignedComponentCandidateImportResult( + operationID: operationID, catalogDigest: catalogDigest, installed: installed ) diff --git a/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift index 45d74b1a..f05f5987 100644 --- a/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift +++ b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift @@ -254,17 +254,20 @@ public struct DoryInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, /// Stable, caller-visible selection identity. Host paths never cross XPC. public struct DoryDesktopUpdateRequest: Sendable, Equatable { + public var operationID: UUID public var distro: String public var version: String public var distributionInstallationName: String public var runtimeInstallationName: String public init( + operationID: UUID = UUID(), distro: String, version: String, distributionInstallationName: String, runtimeInstallationName: String ) { + self.operationID = operationID self.distro = distro self.version = version self.distributionInstallationName = distributionInstallationName diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index bda238d3..e9b02c25 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -912,13 +912,16 @@ public final class DorydService: NSObject, DorydControl { let result = try machineManager.updateDesktop(id: machineID, request: parsedRequest) incidentWriter?.record( type: "machine.desktop_update", - detail: machineID + " " + result.distro + " " + result.version + " snapshot=" + result.snapshotID + detail: machineID + " " + result.distro + " " + result.version + + " operation=" + result.operationID + " snapshot=" + result.snapshotID ) reply.reply(true, result.xpcDictionary, "") } catch { incidentWriter?.record( type: "machine.desktop_update_failed", - detail: machineID + ": " + String(describing: error) + detail: machineID + " operation=" + + parsedRequest.operationID.uuidString.lowercased() + + ": " + String(describing: error) ) reply.reply(false, [:], String(describing: error)) } @@ -1877,13 +1880,25 @@ private struct MachineProvisionRequest { private extension DoryDesktopUpdateRequest { init(xpcDictionary dictionary: NSDictionary) throws { - let allowed: Set = [ + let legacyKeys: Set = [ "distro", "version", "distributionInstallationName", "runtimeInstallationName", ] - guard let keys = dictionary.allKeys as? [String], Set(keys) == allowed else { + let currentKeys = legacyKeys.union(["operationID"]) + guard let keys = dictionary.allKeys as? [String], + Set(keys) == legacyKeys || Set(keys) == currentKeys else { throw XPCRemoteConfigError.invalid("desktopUpdateAuthority") } + let operationID: UUID + if let rawOperationID = dictionary["operationID"] as? String { + guard let parsed = DoryOperationIdentity.parseCanonical(rawOperationID) else { + throw XPCRemoteConfigError.invalid("desktopUpdateAuthority.operationID") + } + operationID = parsed + } else { + operationID = UUID() + } self.init( + operationID: operationID, distro: try dictionary.requiredString("distro"), version: try dictionary.requiredString("version"), distributionInstallationName: try dictionary.requiredString( @@ -2929,6 +2944,7 @@ private extension DoryMachineSnapshot { private extension DoryDesktopUpdateResult { var xpcDictionary: NSDictionary { [ + "operationID": operationID, "machineID": machineID, "distro": distro, "version": version, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index f24e82bb..d18701c9 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -723,6 +723,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable { } public struct DoryDesktopUpdateResult: Sendable, Equatable { + public var operationID: String public var machineID: String public var distro: String public var version: String @@ -733,6 +734,7 @@ public struct DoryDesktopUpdateResult: Sendable, Equatable { public var restoredRunningState: Bool public init( + operationID: String, machineID: String, distro: String, version: String, @@ -742,6 +744,7 @@ public struct DoryDesktopUpdateResult: Sendable, Equatable { status: DoryMachineStatus, restoredRunningState: Bool ) { + self.operationID = operationID self.machineID = machineID self.distro = distro self.version = version @@ -762,6 +765,7 @@ private enum DesktopUpdateJournalStage: String, Codable, Sendable { private struct DesktopUpdateJournal: Codable, Sendable, Equatable { var schema: Int + var operationID: String? var machineID: String var distro: String var version: String @@ -772,7 +776,7 @@ private struct DesktopUpdateJournal: Codable, Sendable, Equatable { var updateAuthority: DoryInstalledDesktopPayloadReceipt? var isValid: Bool { - guard [1, 2].contains(schema), + guard [1, 2, 3].contains(schema), Self.isValidIdentifier(machineID), Self.isValidIdentifier(snapshotID), ["debian", "kali", "ubuntu"].contains(distro), @@ -780,13 +784,18 @@ private struct DesktopUpdateJournal: Codable, Sendable, Equatable { return false } if schema == 1 { - return sourceConfigurationSHA256 == nil && updateAuthority == nil + return operationID == nil + && sourceConfigurationSHA256 == nil && updateAuthority == nil } - return sourceConfigurationSHA256.map(Self.isLowercaseSHA256) == true + let authorityIsValid = sourceConfigurationSHA256.map(Self.isLowercaseSHA256) == true && updateAuthority?.isValid == true && updateAuthority?.provenance == .verifiedUpdateBundle && updateAuthority?.distributionIdentifier == distro && updateAuthority?.releaseVersion == version + if schema == 2 { + return operationID == nil && authorityIsValid + } + return operationID.map(Self.isCanonicalOperationID) == true && authorityIsValid } private static func isLowercaseSHA256(_ value: String) -> Bool { @@ -795,6 +804,14 @@ private struct DesktopUpdateJournal: Codable, Sendable, Equatable { } } + private static func isCanonicalOperationID(_ value: String) -> Bool { + guard let id = UUID(uuidString: value), + id != UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) else { + return false + } + return value == id.uuidString.lowercased() + } + private static func isValidIdentifier(_ value: String) -> Bool { value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil } @@ -4586,6 +4603,13 @@ public final class MachineManager: @unchecked Sendable { try requireNoActivePlanningMutation(id: id) let directMutation = try retainDirectWorkspaceMutationLock(id: id) defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) } + let canonicalOperationID = request.operationID.uuidString.lowercased() + guard request.operationID + != UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) else { + throw MachineManagerError.persistence( + "desktop update requires a nonzero operation identifier" + ) + } let (original, originallyRunning) = try configurationAndRunningState(id: id) guard original.bootMode == .linuxKernel else { @@ -4632,7 +4656,8 @@ public final class MachineManager: @unchecked Sendable { snapshotID: snapshotID ) var journal = DesktopUpdateJournal( - schema: 2, + schema: 3, + operationID: canonicalOperationID, machineID: id, distro: request.distro, version: request.version, @@ -4645,6 +4670,20 @@ public final class MachineManager: @unchecked Sendable { updateAuthority: authority.receipt ) try persistDesktopUpdateJournal(journal) + appendFlightEvent( + machineID: id, + operationID: request.operationID, + operationKind: .updating, + kind: .operationStarted, + phase: .planned + ) + appendFlightEvent( + machineID: id, + operationID: request.operationID, + operationKind: .updating, + kind: .operationPhase, + phase: .staging + ) let token = String(bundleSHA256.prefix(12)) let mountPath = "/mnt/dory-update-" + token @@ -4666,6 +4705,13 @@ public final class MachineManager: @unchecked Sendable { } journal.stage = .installing try persistDesktopUpdateJournal(journal) + appendFlightEvent( + machineID: id, + operationID: request.operationID, + operationKind: .updating, + kind: .operationPhase, + phase: .publishing + ) try requireSuccessfulDesktopUpdateExec( id: id, @@ -4741,6 +4787,13 @@ public final class MachineManager: @unchecked Sendable { } journal.stage = .qualifying try persistDesktopUpdateJournal(journal) + appendFlightEvent( + machineID: id, + operationID: request.operationID, + operationKind: .updating, + kind: .operationPhase, + phase: .validating + ) _ = try startAndWaitUntilReady(id: id) try qualifyUpdatedDesktop( id: id, @@ -4758,7 +4811,15 @@ public final class MachineManager: @unchecked Sendable { journal.stage = .committed try persistDesktopUpdateJournal(journal) try removeDesktopUpdateJournal(machineID: id) + appendFlightEvent( + machineID: id, + operationID: request.operationID, + operationKind: .updating, + kind: .operationCompleted, + phase: .completed + ) return DoryDesktopUpdateResult( + operationID: canonicalOperationID, machineID: id, distro: request.distro, version: request.version, @@ -4780,11 +4841,21 @@ public final class MachineManager: @unchecked Sendable { } try removeDesktopUpdateJournal(machineID: id) } catch { + appendDesktopUpdateFailureFlightEvent( + machineID: id, + operationID: request.operationID, + recoveryDisposition: .repair + ) throw MachineManagerError.persistence( "desktop update " + request.version + " failed: " + String(describing: updateError) + "; automatic rollback failed: " + String(describing: error) ) } + appendDesktopUpdateFailureFlightEvent( + machineID: id, + operationID: request.operationID, + recoveryDisposition: .rollbackCompleted + ) throw MachineManagerError.persistence( "desktop update " + request.version + " failed: " + String(describing: updateError) + "; last-good snapshot " + snapshot.id + " was restored" @@ -5675,6 +5746,73 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() } + /// Records a caller-owned root operation that is intentionally broader than the nested + /// lifecycle journals it invokes (for example component install -> snapshot -> guest update + /// -> restart qualification). This keeps one correlation identity without replacing the + /// independently durable IDs of those nested mutations. + private func appendFlightEvent( + machineID: String, + operationID: UUID, + operationKind: DoryWorkspaceMutationKind, + kind: DoryMachineFlightEventKind, + phase: DoryOperationPhase? = nil, + failure: DoryMachineFailure? = nil + ) { + lock.lock() + defer { lock.unlock() } + guard var entry = machines[machineID] else { return } + do { + let event = try flightRecorderStore.append( + machineID: machineID, + operationID: operationID.uuidString.lowercased(), + operationKind: operationKind.rawValue, + kind: kind, + phase: phase?.rawValue, + machineState: entry.state.rawValue, + failureCode: failure?.code, + recoveryDisposition: failure?.recoveryDisposition, + backend: entry.activeBackend ?? entry.runtimeIdentity.backend, + virtualHardwareABIVersion: + entry.runtimeIdentity.virtualHardwareABIVersion, + planSHA256: entry.runtimeIdentity.resolvedPlanSHA256, + evidenceReferences: failure?.evidenceReferences + ?? failureEvidenceReferences(for: entry) + ) + entry.flightRecorderHeadSequence = event.sequence + entry.flightRecorderAvailable = true + } catch { + entry.flightRecorderAvailable = false + } + machines[machineID] = entry + } + + private func appendDesktopUpdateFailureFlightEvent( + machineID: String, + operationID: UUID, + recoveryDisposition: DoryMachineRecoveryDisposition + ) { + let failure = DoryMachineFailure( + code: recoveryDisposition == .rollbackCompleted + ? .desktopUpdateRolledBack : .desktopUpdateRecoveryRequired, + operationID: operationID.uuidString.lowercased(), + causalChain: [.unknown], + recoveryDisposition: recoveryDisposition, + evidenceReferences: [ + .init( + kind: .operation, + identifier: operationID.uuidString.lowercased() + ), + ] + ) + appendFlightEvent( + machineID: machineID, + operationID: operationID, + operationKind: .updating, + kind: .operationFailed, + failure: failure + ) + } + private func setFailure( on entry: inout MachineEntry, code: DoryMachineFailureCode, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 69da5632..4af35c71 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -360,6 +360,7 @@ private func componentStatusJSON(_ status: DoryComponentStatus) -> NSDictionary "state": status.state.rawValue, "availableVersion": status.availableVersion, "installedVersion": status.installedVersion ?? NSNull(), + "installationOperationID": status.installationOperationID ?? NSNull(), "downloadBytes": NSNumber(value: status.downloadBytes), "installedBytes": NSNumber(value: status.installedBytes), "dependencies": status.dependencies.map(\.rawValue), @@ -369,12 +370,14 @@ private func componentStatusJSON(_ status: DoryComponentStatus) -> NSDictionary private func componentResultJSON( action: String, catalog: ComponentCatalogBundle, - statuses: [DoryComponentStatus] + statuses: [DoryComponentStatus], + operationID: UUID? = nil ) -> NSDictionary { [ "schema": "dev.dory.components", - "schemaVersion": 1, + "schemaVersion": 2, "action": action, + "operationID": operationID?.uuidString.lowercased() ?? NSNull(), "catalogVersion": catalog.catalog.releaseVersion, "catalogDigest": DoryComponentCatalogVerifier.digest(catalog.data), "architecture": catalog.catalog.architecture, @@ -438,17 +441,24 @@ private func runComponent(cursor: inout ArgumentCursor) throws { store: store, appVersion: componentAppVersion() ) - let result = try importer.install(id, from: candidateDirectory) + let operationID = UUID() + let result = try importer.install( + id, + from: candidateDirectory, + operationID: operationID + ) if json { try emitJSON([ "schema": "dev.dory.component-candidate-import", - "schemaVersion": 1, + "schemaVersion": 2, + "operationID": result.operationID.uuidString.lowercased(), "catalogDigest": result.catalogDigest, "installations": Dictionary(uniqueKeysWithValues: result.installed.map { ($0.id.rawValue, $0.installationName) }), ] as NSDictionary) } else { + print("operation\t\(result.operationID.uuidString.lowercased())") for component in result.installed { print("\(component.id.rawValue)\t\(component.installationName)") } @@ -480,6 +490,7 @@ private func runComponent(cursor: inout ArgumentCursor) throws { } guard id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } let installer = DoryComponentInstaller(store: store) + let operationID = UUID() for release in try componentInstallationOrder(id, catalog: catalog.catalog) { let current = try store.installedComponent(release.id) if current?.version == release.version, current?.catalogDigest == digest, @@ -488,7 +499,12 @@ private func runComponent(cursor: inout ArgumentCursor) throws { } let showProgress = !json _ = try awaitComponentOperation { - try await installer.install(release, catalogData: catalog.data) { update in + try await installer.install( + release, + catalogData: catalog.data, + operationID: operationID + ) { update in + guard update.operationID == operationID else { return } guard showProgress else { return } let message = "\r\(release.displayName): \(update.phase.rawValue) " + "\(componentBytes(update.completedBytes)) / \(componentBytes(update.totalBytes))" @@ -499,9 +515,14 @@ private func runComponent(cursor: inout ArgumentCursor) throws { } let statuses = store.list(catalog: catalog.catalog, catalogDigest: digest) if json { - try emitJSON(componentResultJSON(action: subcommand, catalog: catalog, statuses: statuses)) + try emitJSON(componentResultJSON( + action: subcommand, + catalog: catalog, + statuses: statuses, + operationID: operationID + )) } else { - print("\(rawID) is installed and verified.") + print("\(rawID) is installed and verified (operation \(operationID.uuidString.lowercased())).") } case "verify": let rawID = cursor.values.isEmpty ? "all" : try cursor.take("usage: dorydctl component verify [ID|all] [--json] [--offline]") @@ -1639,7 +1660,9 @@ func runMachineDesktopUpdate(cursor: inout ArgumentCursor, client: DorydCtlClien guard cursor.values.isEmpty else { throw DorydCtlError.usage("unexpected machine desktop-update argument: " + cursor.values[0]) } + let operationID = UUID() let request: NSDictionary = [ + "operationID": operationID.uuidString.lowercased(), "distro": distro, "version": version, "distributionInstallationName": distributionInstallationName, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift index bd05154d..9e4edd76 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift @@ -307,6 +307,106 @@ final class DoryComponentsTests: XCTestCase { } } + func testInstallerPropagatesCallerOperationIdentityIntoProgressAndDurableStatus() async throws { + let fixture = try Fixture(name: "operation-identity") + defer { fixture.cleanup() } + let payload = Data("operation-bound payload".utf8) + let source = try fixture.write(payload, name: "operation-source") + let asset = DoryComponentAsset( + path: "kubectl", + url: source.absoluteString, + downloadBytes: UInt64(payload.count), + installedBytes: UInt64(payload.count), + sha256: digest(payload), + installedSHA256: digest(payload) + ) + let component = DoryComponentRelease( + id: .kubernetes, + version: "1.0.0", + displayName: "Kubernetes", + summary: "Operation propagation fixture", + dependencies: [.dockerCore], + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset] + ) + let catalog = catalog(components: [core(), component]) + let catalogData = try encoded(catalog) + let operationID = try XCTUnwrap(UUID( + uuidString: "01234567-89ab-4cde-8f01-23456789abcd" + )) + let recorder = ProgressRecorder() + + let installed = try await DoryComponentInstaller(store: fixture.store).install( + component, + catalogData: catalogData, + operationID: operationID + ) { recorder.append($0) } + + XCTAssertEqual(installed.installationOperationID, operationID.uuidString.lowercased()) + XCTAssertTrue(installed.installationName.hasSuffix(operationID.uuidString.lowercased())) + XCTAssertEqual( + recorder.values.map(\.phase), + [.downloading, .downloading, .verifying, .installing, .complete] + ) + XCTAssertTrue(recorder.values.allSatisfy { $0.operationID == operationID }) + XCTAssertEqual( + fixture.store.list( + catalog: catalog, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + ).first(where: { $0.id == .kubernetes })?.installationOperationID, + operationID.uuidString.lowercased() + ) + + let encodedRecord = try JSONEncoder().encode(installed) + var legacyObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: encodedRecord) as? [String: Any] + ) + legacyObject.removeValue(forKey: "installationOperationID") + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let legacy = try JSONDecoder().decode(DoryInstalledComponent.self, from: legacyData) + XCTAssertNil(legacy.installationOperationID) + XCTAssertTrue(legacy.isStructurallyValid) + } + + func testInstallerRejectsZeroOperationIdentityBeforeDownloading() async throws { + let fixture = try Fixture(name: "zero-operation") + defer { fixture.cleanup() } + let payload = Data("must not download".utf8) + let source = try fixture.write(payload, name: "zero-source") + let asset = DoryComponentAsset( + path: "kubectl", + url: source.absoluteString, + downloadBytes: UInt64(payload.count), + installedBytes: UInt64(payload.count), + sha256: digest(payload), + installedSHA256: digest(payload) + ) + let component = DoryComponentRelease( + id: .kubernetes, + version: "1.0.0", + displayName: "Kubernetes", + summary: "Zero operation fixture", + dependencies: [.dockerCore], + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset] + ) + let zero = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + do { + _ = try await DoryComponentInstaller(store: fixture.store).install( + component, + catalogData: try encoded(catalog(components: [core(), component])), + operationID: zero + ) + XCTFail("zero operation identity should be rejected") + } catch { + XCTAssertEqual(error as? DoryComponentError, .invalidOperationID) + } + XCTAssertNil(try fixture.store.installedComponent(.kubernetes)) + } + func testSelectionSnapshotRestoresPriorVerifiedGeneration() throws { let fixture = try Fixture(name: "selection-rollback") defer { fixture.cleanup() } @@ -719,4 +819,21 @@ final class DoryComponentsTests: XCTestCase { try? FileManager.default.removeItem(at: root) } } + + private final class ProgressRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [DoryComponentProgress] = [] + + var values: [DoryComponentProgress] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func append(_ progress: DoryComponentProgress) { + lock.lock() + recorded.append(progress) + lock.unlock() + } + } } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift index ab63849b..92ec6e39 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DorySignedComponentCandidateImporterTests.swift @@ -10,9 +10,18 @@ struct DorySignedComponentCandidateImporterTests { let fixture = try Fixture() defer { fixture.cleanup() } let candidate = try fixture.candidate() - let result = try candidate.importer.install(.desktopUbuntu, from: candidate.directory.path) + let operationID = UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd")! + let result = try candidate.importer.install( + .desktopUbuntu, + from: candidate.directory.path, + operationID: operationID + ) + #expect(result.operationID == operationID) #expect(result.installed.map(\.id) == [.linuxDesktop, .desktopUbuntu]) + #expect(result.installed.allSatisfy { + $0.installationOperationID == operationID.uuidString.lowercased() + }) #expect(try fixture.store.verify(.linuxDesktop).installationName == result.installed[0].installationName) #expect(try fixture.store.verify(.desktopUbuntu).installationName @@ -24,6 +33,24 @@ struct DorySignedComponentCandidateImporterTests { )?.catalog == candidate.catalog) } + @Test("zero operation identity is rejected before candidate bytes become active") + func rejectsZeroOperationIdentity() throws { + let fixture = try Fixture() + defer { fixture.cleanup() } + let candidate = try fixture.candidate() + let zero = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + #expect(throws: DoryComponentError.invalidOperationID) { + try candidate.importer.install( + .desktopUbuntu, + from: candidate.directory.path, + operationID: zero + ) + } + #expect(try fixture.store.installedComponent(.linuxDesktop) == nil) + #expect(try fixture.store.installedComponent(.desktopUbuntu) == nil) + } + @Test("asset mutation is rejected without activating the component") func rejectsMutatedAsset() throws { let fixture = try Fixture() diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 5960b41e..41fc0b01 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1805,6 +1805,33 @@ final class DorydServiceTests: XCTestCase { mixed.fulfill() } wait(for: [mixed], timeout: 2) + + let malformedOperation = expectation(description: "malformed operation rejected") + service.machineDesktopUpdate("dev", request: [ + "operationID": "01234567-89AB-4CDE-8F01-23456789ABCD", + "distro": "ubuntu", + "version": "24.04+runtime.7", + "distributionInstallationName": "ubuntu-installation", + "runtimeInstallationName": "runtime-installation", + ]) { ok, _, message in + XCTAssertFalse(ok) + XCTAssertTrue(message.contains("desktopUpdateAuthority.operationID")) + malformedOperation.fulfill() + } + wait(for: [malformedOperation], timeout: 2) + + let legacyRequest = expectation(description: "legacy request mints at daemon boundary") + service.machineDesktopUpdate("dev", request: [ + "distro": "ubuntu", + "version": "24.04+runtime.7", + "distributionInstallationName": "ubuntu-installation", + "runtimeInstallationName": "runtime-installation", + ]) { ok, _, message in + XCTAssertFalse(ok) + XCTAssertFalse(message.contains("desktopUpdateAuthority"), message) + legacyRequest.fulfill() + } + wait(for: [legacyRequest], timeout: 2) } func testMachineWritesRequireTypedIntentAndPreserveLegacyEnvironmentFieldLocally() throws { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 3cf1672d..e3921efa 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -489,10 +489,14 @@ final class MachineManagerTests: XCTestCase { environment: ["DORY_DESKTOP_DISTRO": "ubuntu", "PRESERVE": "yes"] )) + let operationID = try XCTUnwrap(UUID( + uuidString: "01234567-89ab-4cde-8f01-23456789abcd" + )) let result = try runDesktopUpdate( manager: manager, id: "dev", request: DoryDesktopUpdateRequest( + operationID: operationID, distro: "ubuntu", version: "1.2.3+runtime.4.5.6", distributionInstallationName: "ubuntu-installation", @@ -501,6 +505,15 @@ final class MachineManagerTests: XCTestCase { ).get() XCTAssertEqual(result.status.state, .stopped) + XCTAssertEqual(result.operationID, operationID.uuidString.lowercased()) + let updateEvents = try manager.flightRecorder(id: "dev", afterSequence: 0).events + .filter { $0.operationID == operationID.uuidString.lowercased() } + XCTAssertEqual( + updateEvents.map(\.kind), + [.operationStarted, .operationPhase, .operationPhase, .operationPhase, + .operationCompleted] + ) + XCTAssertTrue(updateEvents.allSatisfy { $0.operationKind == "updating" }) XCTAssertEqual(result.version, "1.2.3+runtime.4.5.6") XCTAssertEqual(result.inputSHA256, String(repeating: "a", count: 64)) XCTAssertEqual( @@ -590,10 +603,14 @@ final class MachineManagerTests: XCTestCase { environment: ["DORY_DESKTOP_DISTRO": "ubuntu", "PRESERVE": "yes"] )) + let operationID = try XCTUnwrap(UUID( + uuidString: "12345678-9abc-4def-8012-3456789abcde" + )) let result = try runDesktopUpdate( manager: manager, id: "dev", request: DoryDesktopUpdateRequest( + operationID: operationID, distro: "ubuntu", version: "1.2.3+runtime.4.5.6", distributionInstallationName: "ubuntu-installation", @@ -619,6 +636,14 @@ final class MachineManagerTests: XCTestCase { XCTAssertTrue(status.shares.isEmpty) XCTAssertFalse(FileManager.default.fileExists(atPath: state + "/dev/desktop-update.json")) XCTAssertEqual(try manager.listSnapshots(machineID: "dev").count, 1) + let failedEvent = try XCTUnwrap( + manager.flightRecorder(id: "dev", afterSequence: 0).events.last { + $0.operationID == operationID.uuidString.lowercased() + && $0.kind == .operationFailed + } + ) + XCTAssertEqual(failedEvent.failureCode, .desktopUpdateRolledBack) + XCTAssertEqual(failedEvent.recoveryDisposition, .rollbackCompleted) } func testDesktopUpdateRejectsStagedBundleMutationBeforeGuestCommit() throws { diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh index 4dcbd53c..aca69242 100755 --- a/scripts/desktop-linux-live-gate.sh +++ b/scripts/desktop-linux-live-gate.sh @@ -268,11 +268,17 @@ import sys result_path, catalog_path, component_id, expected_version = sys.argv[1:] result = json.loads(pathlib.Path(result_path).read_text(encoding="utf-8")) if not isinstance(result, dict) or set(result) != { - "catalogDigest", "installations", "schema", "schemaVersion" + "catalogDigest", "installations", "operationID", "schema", "schemaVersion" }: raise SystemExit("component import response has an unexpected shape") -if result["schema"] != "dev.dory.component-candidate-import" or result["schemaVersion"] != 1: +if result["schema"] != "dev.dory.component-candidate-import" or result["schemaVersion"] != 2: raise SystemExit("component import response has an unexpected contract") +operation_id = result["operationID"] +if not isinstance(operation_id, str) or re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + operation_id, +) is None: + raise SystemExit("component import response has an invalid operation identity") digest = result["catalogDigest"] if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: raise SystemExit("component import response has an invalid catalog digest") @@ -1140,11 +1146,17 @@ PY > "$WORKROOT/evidence/$distro-desktop-update.json" python3 - "$WORKROOT/evidence/$distro-desktop-update.json" "$update_version" \ "$catalog_digest" "$distribution_installation" "$runtime_installation" <<'PY' -import json, sys +import json, re, sys with open(sys.argv[1], encoding="utf-8") as handle: body = json.load(handle) if not isinstance(body, dict) or body.get("version") != sys.argv[2]: raise SystemExit(f"desktop update returned the wrong version: {body!r}") +operation_id = body.get("operationID") +if not isinstance(operation_id, str) or re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + operation_id, +) is None: + raise SystemExit(f"desktop update omitted its operation identity: {body!r}") status = body.get("status") if not isinstance(status, dict) or status.get("state") != "running": raise SystemExit(f"desktop update did not restore running state: {body!r}") From 901a4c69a88d6148183ae1d03fb21edadd5423b4 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 00:23:39 +0000 Subject: [PATCH 228/338] feat(vm): expose helper device telemetry --- .../Sources/DoryHV/VirtioMMIO.swift | 44 +++ .../Sources/dory-hv/DesktopMode.swift | 144 +++++++++- .../Tests/DoryHVTests/DeviceLogicTests.swift | 25 ++ .../Sources/DoryVMMKit/DoryVMM.swift | 65 +++++ .../DorydKit/DoryDeviceTelemetry.swift | 263 ++++++++++++++++++ .../Sources/DorydKit/VmmControl.swift | 63 ++++- .../DoryDeviceTelemetryTests.swift | 125 +++++++++ 7 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift index a7839ecf..a183458e 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift @@ -1,4 +1,27 @@ import Foundation +import Synchronization + +public struct VirtioMMIOTransportStatistics: Equatable, Sendable { + public var queueNotifications: UInt64 + public var queueStateChanges: UInt64 + public var usedInterrupts: UInt64 + public var configurationInterrupts: UInt64 + public var deviceResets: UInt64 + + public init( + queueNotifications: UInt64, + queueStateChanges: UInt64, + usedInterrupts: UInt64, + configurationInterrupts: UInt64, + deviceResets: UInt64 + ) { + self.queueNotifications = queueNotifications + self.queueStateChanges = queueStateChanges + self.usedInterrupts = usedInterrupts + self.configurationInterrupts = configurationInterrupts + self.deviceResets = deviceResets + } +} /// Defines which layer serializes queue notifications with device lifecycle changes. public enum VirtioKickSynchronization: Equatable, Sendable { @@ -76,6 +99,11 @@ public final class VirtioMMIOTransport: MMIODevice { private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)] + private let queueNotificationCount = Atomic(0) + private let queueStateChangeCount = Atomic(0) + private let usedInterruptCount = Atomic(0) + private let configurationInterruptCount = Atomic(0) + private let deviceResetCount = Atomic(0) private static let magic: UInt64 = 0x7472_6976 // "virt" private static let vendor: UInt64 = 0x792D_726F_64 // "dor-y" @@ -97,6 +125,7 @@ public final class VirtioMMIOTransport: MMIODevice { /// Signals a used-buffer interrupt to the guest. public func notifyUsed() { + usedInterruptCount.wrappingAdd(1, ordering: .relaxed) interruptLock.lock() interruptStatus |= 1 interruptLock.unlock() @@ -107,6 +136,7 @@ public final class VirtioMMIOTransport: MMIODevice { /// this from a used-ring interrupt through ISR bit 1 and use ConfigGeneration to take a /// coherent snapshot when the host changes multiple fields during one resize. public func notifyConfigChange() { + configurationInterruptCount.wrappingAdd(1, ordering: .relaxed) registerLock.lock() configGeneration &+= 1 interruptLock.lock() @@ -172,6 +202,7 @@ public final class VirtioMMIOTransport: MMIODevice { let shouldKick = queue >= 0 && queue < queues.count registerLock.unlock() if shouldKick { + queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } return @@ -195,6 +226,7 @@ public final class VirtioMMIOTransport: MMIODevice { } case 0x044: withSelectedQueue { index in + queueStateChangeCount.wrappingAdd(1, ordering: .relaxed) let ready = value & 1 == 1 if ready { let layout = pendingQueueLayout[index] @@ -216,6 +248,7 @@ public final class VirtioMMIOTransport: MMIODevice { case 0x050: let queue = Int(value) if queue < queues.count { + queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } case 0x064: @@ -245,6 +278,7 @@ public final class VirtioMMIOTransport: MMIODevice { } private func resetDevice() { + deviceResetCount.wrappingAdd(1, ordering: .relaxed) backend.deviceReset(transport: self) for queue in queues { queue.reset() } pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: backend.queueCount) @@ -253,6 +287,16 @@ public final class VirtioMMIOTransport: MMIODevice { driverFeatures = 0 } + public var statistics: VirtioMMIOTransportStatistics { + VirtioMMIOTransportStatistics( + queueNotifications: queueNotificationCount.load(ordering: .relaxed), + queueStateChanges: queueStateChangeCount.load(ordering: .relaxed), + usedInterrupts: usedInterruptCount.load(ordering: .relaxed), + configurationInterrupts: configurationInterruptCount.load(ordering: .relaxed), + deviceResets: deviceResetCount.load(ordering: .relaxed) + ) + } + private func withSelectedQueue(_ body: (Int) -> Void) { guard queueSelect < queues.count else { return } body(queueSelect) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index edcde7eb..d66d2735 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -7,6 +7,140 @@ import DorydKit import DoryVMMKit import Foundation +private final class RawDeviceTelemetryRegistry: @unchecked Sendable { + private struct Entry { + var id: String + var kind: DoryDeviceTelemetryKind + var transport: VirtioMMIOTransport + var network: VirtioNet? + var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + } + + private let machineID: String + private let operationID: String + private let lock = NSLock() + private var sampleSequence: UInt64 = 0 + private var entries = [Entry]() + + init(machineID: String, operationID: UUID) { + self.machineID = machineID + self.operationID = DoryOperationIdentity.canonical(operationID) + } + + func register(slot: Int, backend: any VirtioDeviceBackend, transport: VirtioMMIOTransport) { + let kind: DoryDeviceTelemetryKind + let unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + switch backend { + case is VirtioBlk: + kind = .storage + unavailable = [ + (.storageFlushes, .count), + (.maximumStorageFlushLatencyNanoseconds, .nanoseconds), + ] + case is VirtioGPU: + kind = .graphics + unavailable = [ + (.displayFrames, .count), + (.displayDrops, .count), + (.graphicsFences, .count), + (.graphicsDeviceLosses, .count), + ] + case is VirtioSound: + kind = .audio + unavailable = [(.audioDrops, .count)] + case is VirtioFS: + kind = .sharedDirectory + unavailable = [ + (.shareInvalidations, .count), + (.shareInvalidationFailures, .count), + ] + case is VirtioNet, is VirtioDisconnectedNet: + kind = .network + unavailable = backend is VirtioNet ? [] : [ + (.transmittedFrames, .count), + (.transmittedBytes, .bytes), + (.transmitDrops, .count), + (.receivedFrames, .count), + (.receivedBytes, .bytes), + (.receiveDeferred, .count), + (.receiveDrops, .count), + (.receiveTruncations, .count), + ] + case is VirtioBalloon: + kind = .balloon + unavailable = [] + case is VirtioRng: + kind = .entropy + unavailable = [] + case is VirtioInput: + kind = .input + unavailable = [] + case is VirtioVsock: + kind = .socket + unavailable = [] + default: + kind = .platform + unavailable = [] + } + let entry = Entry( + id: "virtio-\(kind.rawValue)-\(slot)", + kind: kind, + transport: transport, + network: backend as? VirtioNet, + unavailableMetrics: unavailable + ) + lock.withLock { entries.append(entry) } + } + + func snapshot() -> DoryDeviceTelemetrySnapshot { + let captured: (UInt64, [Entry]) = lock.withLock { + sampleSequence &+= 1 + return (sampleSequence, entries) + } + let unavailableReason = "raw-HV backend does not expose this counter yet" + let devices = captured.1.map { entry in + let transport = entry.transport.statistics + var metrics: [DoryDeviceTelemetryMetric] = [ + .measured(.queueNotifications, value: transport.queueNotifications), + .measured(.queueStateChanges, value: transport.queueStateChanges), + .measured(.usedInterrupts, value: transport.usedInterrupts), + .measured(.configurationInterrupts, value: transport.configurationInterrupts), + .measured(.deviceResets, value: transport.deviceResets), + ] + if let network = entry.network?.statistics { + metrics.append(contentsOf: [ + .measured(.transmittedFrames, value: network.transmitPackets), + .measured(.transmittedBytes, unit: .bytes, value: network.transmitBytes), + .measured(.transmitDrops, value: network.transmitDrops), + .measured(.receivedFrames, value: network.receivePackets), + .measured(.receivedBytes, unit: .bytes, value: network.receiveBytes), + .measured(.receiveDeferred, value: network.receiveDeferred), + .measured(.receiveDrops, value: network.receiveDrops), + .measured(.receiveTruncations, value: network.receiveTruncations), + ]) + } + metrics.append(contentsOf: entry.unavailableMetrics.map { + .unavailable($0.0, unit: $0.1, reason: unavailableReason) + }) + return DoryDeviceTelemetryDevice( + id: entry.id, + kind: entry.kind, + health: .healthy, + metrics: metrics + ) + } + return DoryDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: captured.0, + sampledAtUnixMilliseconds: UInt64(Date().timeIntervalSince1970 * 1_000), + monotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + devices: devices + ) + } +} + enum DesktopMode { struct Configuration { var machineID: String @@ -117,6 +251,7 @@ enum DesktopMode { private let sshAgentBridge: HostSSHAgentBridge? private let clipboard: DoryDesktopClipboardCoordinator? private let firstFrame: FirstFrameGate + private let deviceTelemetry: RawDeviceTelemetryRegistry private let lifecycleReceiptServer: VmmLifecycleReceiptServer private var signalSources = [DispatchSourceSignal]() private var stopError: Error? @@ -124,8 +259,14 @@ enum DesktopMode { init(configuration: Configuration) throws { self.configuration = configuration + let deviceTelemetry = RawDeviceTelemetryRegistry( + machineID: configuration.machineID, + operationID: configuration.operationID + ) + self.deviceTelemetry = deviceTelemetry self.lifecycleReceiptServer = VmmLifecycleReceiptServer( - socketPath: configuration.controlSocketPath + socketPath: configuration.controlSocketPath, + deviceTelemetryProvider: { deviceTelemetry.snapshot() } ) try FileManager.default.createDirectory( atPath: configuration.stateDirectory, @@ -271,6 +412,7 @@ enum DesktopMode { } for (slot, backend) in backends.enumerated() { let transport = Self.attachBackend(backend, to: machine, slot: slot) + deviceTelemetry.register(slot: slot, backend: backend, transport: transport) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in guard let gpu, let transport else { return } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index ae0ef6ea..620311b7 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -529,6 +529,31 @@ import Testing func handleKick(queue: Int, transport: VirtioMMIOTransport) {} } + @Test func publishesMonotonicTransportTelemetryCounters() throws { + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let backend = Backend(sharedMemoryRegions: []) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + + transport.write(offset: 0x030, value: 0, width: 4) + transport.write(offset: 0x044, value: 1, width: 4) + transport.write(offset: 0x050, value: 0, width: 4) + transport.notifyUsed() + transport.notifyConfigChange() + transport.write(offset: 0x070, value: 0, width: 4) + + #expect(transport.statistics == VirtioMMIOTransportStatistics( + queueNotifications: 1, + queueStateChanges: 1, + usedInterrupts: 1, + configurationInterrupts: 1, + deviceResets: 1 + )) + } + private final class KickOverlapProbe: @unchecked Sendable { let entered = DispatchSemaphore(value: 0) let release = DispatchSemaphore(value: 0) diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 8317f29b..57476288 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1261,6 +1261,8 @@ public enum DoryVMMMain { let controlServer = try DoryVMMControlServer( machine: machine, + machineID: machineID, + operationID: operationID, localSocketPath: controlSocketPath, stateDirectory: stateDirectory ) @@ -2198,19 +2200,26 @@ final class DoryVZHostSSHAgentBridge: NSObject, VZVirtioSocketListenerDelegate, private final class DoryVMMControlServer: @unchecked Sendable { private let machine: DoryVZMachine + private let machineID: String + private let operationID: String private let localSocketPath: String private let stateDirectory: String private let queue: DispatchQueue private let lock = NSLock() private var listenerFD: Int32 = -1 private var running = false + private var telemetrySampleSequence: UInt64 = 0 init( machine: DoryVZMachine, + machineID: String, + operationID: UUID, localSocketPath: String, stateDirectory: String ) throws { self.machine = machine + self.machineID = machineID + self.operationID = DoryOperationIdentity.canonical(operationID) self.localSocketPath = localSocketPath self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path self.queue = DispatchQueue(label: "dev.dory.dory-vmm.control") @@ -2328,6 +2337,22 @@ private final class DoryVMMControlServer: @unchecked Sendable { private func handle(request: VmmControlRequest) throws -> HandledControlResponse { switch request.command { + case "deviceTelemetry": + guard request.targetMB == nil, + request.statePath == nil, + request.lifecycleAction == nil, + request.operationID == nil else { + return HandledControlResponse(response: VmmControlResponse( + ok: false, + message: "invalid VMM device telemetry request" + )) + } + return HandledControlResponse( + response: VmmControlResponse( + ok: true, + deviceTelemetry: deviceTelemetrySnapshot() + ) + ) case "setBalloonTarget": guard let targetMB = request.targetMB, targetMB > 0 else { return HandledControlResponse( @@ -2386,6 +2411,46 @@ private final class DoryVMMControlServer: @unchecked Sendable { } } + private func deviceTelemetrySnapshot() -> DoryDeviceTelemetrySnapshot { + lock.lock() + telemetrySampleSequence &+= 1 + let sequence = telemetrySampleSequence + lock.unlock() + let unavailableReason = "Virtualization.framework does not expose stable per-device counters" + let metrics = DoryDeviceTelemetryMetricKind.allCases.map { kind in + let unit: DoryDeviceTelemetryMetricUnit + switch kind { + case .transmittedBytes, .receivedBytes: + unit = .bytes + case .maximumStorageFlushLatencyNanoseconds: + unit = .nanoseconds + default: + unit = .count + } + return DoryDeviceTelemetryMetric.unavailable( + kind, + unit: unit, + reason: unavailableReason + ) + } + return DoryDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: .appleVirtualizationFramework, + sampleSequence: sequence, + sampledAtUnixMilliseconds: UInt64(Date().timeIntervalSince1970 * 1_000), + monotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + devices: [ + DoryDeviceTelemetryDevice( + id: "virtualization-framework", + kind: .platform, + health: .unavailable, + metrics: metrics + ), + ] + ) + } + private func lifecycleReceipt( _ request: VmmControlRequest, expectedAction: DoryLifecycleReceiptAction diff --git a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift new file mode 100644 index 00000000..0b864ea4 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift @@ -0,0 +1,263 @@ +import DoryCore +import DoryOperations +import Foundation + +public enum DoryDeviceTelemetryKind: String, Codable, Sendable, CaseIterable, Hashable { + case platform + case storage + case network + case graphics + case display + case audio + case sharedDirectory = "shared-directory" + case balloon + case entropy + case input + case socket +} + +public enum DoryDeviceTelemetryHealth: String, Codable, Sendable, CaseIterable, Hashable { + case healthy + case degraded + case failed + case unavailable +} + +public enum DoryDeviceTelemetryMetricKind: String, Codable, Sendable, CaseIterable, Hashable { + case queueNotifications = "queue-notifications" + case queueStateChanges = "queue-state-changes" + case usedInterrupts = "used-interrupts" + case configurationInterrupts = "configuration-interrupts" + case deviceResets = "device-resets" + case transmittedFrames = "transmitted-frames" + case transmittedBytes = "transmitted-bytes" + case transmitDrops = "transmit-drops" + case receivedFrames = "received-frames" + case receivedBytes = "received-bytes" + case receiveDeferred = "receive-deferred" + case receiveDrops = "receive-drops" + case receiveTruncations = "receive-truncations" + case reconnects + case displayFrames = "display-frames" + case displayDrops = "display-drops" + case audioDrops = "audio-drops" + case storageFlushes = "storage-flushes" + case maximumStorageFlushLatencyNanoseconds = "maximum-storage-flush-latency-nanoseconds" + case graphicsFences = "graphics-fences" + case graphicsDeviceLosses = "graphics-device-losses" + case shareInvalidations = "share-invalidations" + case shareInvalidationFailures = "share-invalidation-failures" +} + +public enum DoryDeviceTelemetryMetricUnit: String, Codable, Sendable, CaseIterable, Hashable { + case count + case bytes + case nanoseconds +} + +public enum DoryDeviceTelemetryMetricAvailability: String, Codable, Sendable, Hashable { + case measured + case unavailable +} + +public struct DoryDeviceTelemetryMetric: Codable, Sendable, Equatable, Hashable { + public var kind: DoryDeviceTelemetryMetricKind + public var unit: DoryDeviceTelemetryMetricUnit + public var availability: DoryDeviceTelemetryMetricAvailability + public var value: UInt64? + public var unavailableReason: String? + + public init( + kind: DoryDeviceTelemetryMetricKind, + unit: DoryDeviceTelemetryMetricUnit, + availability: DoryDeviceTelemetryMetricAvailability, + value: UInt64? = nil, + unavailableReason: String? = nil + ) { + self.kind = kind + self.unit = unit + self.availability = availability + self.value = value + self.unavailableReason = unavailableReason + } + + public static func measured( + _ kind: DoryDeviceTelemetryMetricKind, + unit: DoryDeviceTelemetryMetricUnit = .count, + value: UInt64 + ) -> Self { + Self(kind: kind, unit: unit, availability: .measured, value: value) + } + + public static func unavailable( + _ kind: DoryDeviceTelemetryMetricKind, + unit: DoryDeviceTelemetryMetricUnit = .count, + reason: String + ) -> Self { + Self( + kind: kind, + unit: unit, + availability: .unavailable, + unavailableReason: reason + ) + } + + public var isValid: Bool { + switch availability { + case .measured: + return value != nil && unavailableReason == nil + case .unavailable: + return value == nil && Self.isValidText(unavailableReason, maximumUTF8Bytes: 256) + } + } + + private static func isValidText(_ value: String?, maximumUTF8Bytes: Int) -> Bool { + guard let value, !value.isEmpty, value.utf8.count <= maximumUTF8Bytes else { return false } + return !value.contains("\0") && !value.contains("\n") && !value.contains("\r") + } +} + +public struct DoryDeviceTelemetryDevice: Codable, Sendable, Equatable, Hashable { + public var id: String + public var kind: DoryDeviceTelemetryKind + public var health: DoryDeviceTelemetryHealth + public var metrics: [DoryDeviceTelemetryMetric] + + public init( + id: String, + kind: DoryDeviceTelemetryKind, + health: DoryDeviceTelemetryHealth, + metrics: [DoryDeviceTelemetryMetric] + ) { + self.id = id + self.kind = kind + self.health = health + self.metrics = metrics + } + + public var isValid: Bool { + guard !id.isEmpty, + id.utf8.count <= 128, + !id.contains("\0"), + !id.contains("\n"), + !id.contains("\r"), + !metrics.isEmpty, + metrics.allSatisfy(\.isValid), + Set(metrics.map(\.kind)).count == metrics.count else { + return false + } + if health == .unavailable { + return metrics.allSatisfy { $0.availability == .unavailable } + } + return metrics.contains { $0.availability == .measured } + } +} + +public enum DoryDeviceTelemetryEventKind: String, Codable, Sendable, CaseIterable, Hashable { + case queueStall = "queue-stall" + case reset + case graphicsFenceTimeout = "graphics-fence-timeout" + case graphicsDeviceLoss = "graphics-device-loss" + case networkReconnect = "network-reconnect" + case audioDrop = "audio-drop" + case storageFlushSlow = "storage-flush-slow" + case shareInvalidationFailure = "share-invalidation-failure" +} + +public struct DoryDeviceTelemetryEvent: Codable, Sendable, Equatable, Hashable { + public var sequence: UInt64 + public var monotonicNanoseconds: UInt64 + public var deviceID: String + public var kind: DoryDeviceTelemetryEventKind + public var occurrences: UInt64 + + public init( + sequence: UInt64, + monotonicNanoseconds: UInt64, + deviceID: String, + kind: DoryDeviceTelemetryEventKind, + occurrences: UInt64 = 1 + ) { + self.sequence = sequence + self.monotonicNanoseconds = monotonicNanoseconds + self.deviceID = deviceID + self.kind = kind + self.occurrences = occurrences + } + + public var isValid: Bool { + sequence > 0 + && monotonicNanoseconds > 0 + && occurrences > 0 + && !deviceID.isEmpty + && deviceID.utf8.count <= 128 + && !deviceID.contains("\0") + && !deviceID.contains("\n") + && !deviceID.contains("\r") + } +} + +public struct DoryDeviceTelemetrySnapshot: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var machineID: String + public var operationID: String + public var backend: DoryVirtualizationBackendIdentity + public var sampleSequence: UInt64 + public var sampledAtUnixMilliseconds: UInt64 + public var monotonicNanoseconds: UInt64 + public var devices: [DoryDeviceTelemetryDevice] + public var events: [DoryDeviceTelemetryEvent] + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + machineID: String, + operationID: String, + backend: DoryVirtualizationBackendIdentity, + sampleSequence: UInt64, + sampledAtUnixMilliseconds: UInt64, + monotonicNanoseconds: UInt64, + devices: [DoryDeviceTelemetryDevice], + events: [DoryDeviceTelemetryEvent] = [] + ) { + self.schemaVersion = schemaVersion + self.machineID = machineID + self.operationID = operationID + self.backend = backend + self.sampleSequence = sampleSequence + self.sampledAtUnixMilliseconds = sampledAtUnixMilliseconds + self.monotonicNanoseconds = monotonicNanoseconds + self.devices = devices + self.events = events + } + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + !machineID.isEmpty, + machineID.utf8.count <= 128, + !machineID.contains("\0"), + !machineID.contains("\n"), + !machineID.contains("\r"), + sampleSequence > 0, + sampledAtUnixMilliseconds > 0, + monotonicNanoseconds > 0, + DoryOperationIdentity.parseCanonical(operationID) != nil, + operationID != "00000000-0000-0000-0000-000000000000", + !devices.isEmpty, + devices.allSatisfy(\.isValid), + Set(devices.map(\.id)).count == devices.count, + events.allSatisfy(\.isValid) else { + return false + } + var lastEventSequence: UInt64 = 0 + for event in events { + guard event.sequence > lastEventSequence, + devices.contains(where: { $0.id == event.deviceID }) else { + return false + } + lastEventSequence = event.sequence + } + return true + } +} diff --git a/dory-core-swift/Sources/DorydKit/VmmControl.swift b/dory-core-swift/Sources/DorydKit/VmmControl.swift index df74b6c2..cb17f32d 100644 --- a/dory-core-swift/Sources/DorydKit/VmmControl.swift +++ b/dory-core-swift/Sources/DorydKit/VmmControl.swift @@ -65,19 +65,47 @@ public struct VmmControlResponse: Sendable, Equatable, Codable { public var targetMB: UInt64? public var lifecycleAction: DoryLifecycleReceiptAction? public var operationID: String? + public var deviceTelemetry: DoryDeviceTelemetrySnapshot? public init( ok: Bool, message: String = "", targetMB: UInt64? = nil, lifecycleAction: DoryLifecycleReceiptAction? = nil, - operationID: String? = nil + operationID: String? = nil, + deviceTelemetry: DoryDeviceTelemetrySnapshot? = nil ) { self.ok = ok self.message = message self.targetMB = targetMB self.lifecycleAction = lifecycleAction self.operationID = operationID + self.deviceTelemetry = deviceTelemetry + } +} + +public protocol MachineDeviceTelemetryControlling: Sendable { + func snapshot(socketPath: String) throws -> DoryDeviceTelemetrySnapshot +} + +public struct UnixMachineDeviceTelemetryController: MachineDeviceTelemetryControlling { + public init() {} + + public func snapshot(socketPath: String) throws -> DoryDeviceTelemetrySnapshot { + let response = try VmmControlClient.send( + socketPath: socketPath, + request: VmmControlRequest(command: "deviceTelemetry") + ) + guard response.ok, + let snapshot = response.deviceTelemetry, + snapshot.isValid else { + throw VmmControlError.rejected( + response.message.isEmpty + ? "VMM helper returned invalid device telemetry" + : response.message + ) + } + return snapshot } } @@ -339,9 +367,9 @@ private func readAll(from fd: Int32) throws -> Data { } } -/// Raw-HV helper-side receipt endpoint. Unlike the VZ control server it never mutates VM state; -/// it proves that the exact operation identity reached the live helper immediately around the -/// daemon-owned signal transition. +/// Raw-HV helper-side control endpoint. Lifecycle requests never mutate VM state here; they prove +/// that the exact operation identity reached the live helper around the daemon-owned signal +/// transition. Telemetry requests return a bounded snapshot supplied by the live device graph. public final class VmmLifecycleReceiptServer: @unchecked Sendable { private let socketPath: String private let queue = DispatchQueue(label: "dev.dory.helper-lifecycle-receipt") @@ -349,9 +377,14 @@ public final class VmmLifecycleReceiptServer: @unchecked Sendable { private var listenerFD: Int32 = -1 private var running = false private var boundIdentity: (device: dev_t, inode: ino_t)? + private let deviceTelemetryProvider: (@Sendable () throws -> DoryDeviceTelemetrySnapshot)? - public init(socketPath: String) { + public init( + socketPath: String, + deviceTelemetryProvider: (@Sendable () throws -> DoryDeviceTelemetrySnapshot)? = nil + ) { self.socketPath = socketPath + self.deviceTelemetryProvider = deviceTelemetryProvider } public func start() throws { @@ -435,6 +468,26 @@ public final class VmmLifecycleReceiptServer: @unchecked Sendable { try setSocketTimeouts(fd: clientFD, seconds: 5) let data = try readAll(from: clientFD) let request = try JSONDecoder().decode(VmmControlRequest.self, from: data) + if request.command == "deviceTelemetry" { + guard request.targetMB == nil, + request.statePath == nil, + request.lifecycleAction == nil, + request.operationID == nil else { + throw VmmControlError.rejected("invalid helper device telemetry request") + } + guard let deviceTelemetryProvider else { + throw VmmControlError.rejected("helper device telemetry is unavailable") + } + let snapshot = try deviceTelemetryProvider() + guard snapshot.isValid else { + throw VmmControlError.rejected("helper produced invalid device telemetry") + } + response = VmmControlResponse(ok: true, deviceTelemetry: snapshot) + if let encoded = try? JSONEncoder().encode(response) { + try? writeAll(encoded, to: clientFD) + } + return + } guard request.command == "acknowledgeLifecycle", let action = request.lifecycleAction, let operationID = request.operationID, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift new file mode 100644 index 00000000..588221ad --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift @@ -0,0 +1,125 @@ +import DoryCore +import DoryOperations +@testable import DorydKit +import Darwin +import Foundation +import XCTest + +final class DoryDeviceTelemetryTests: XCTestCase { + private let operationID = "12345678-1234-4234-8234-123456789abc" + + func testSnapshotRequiresExactMeasuredAndUnavailableShapes() { + let device = DoryDeviceTelemetryDevice( + id: "virtio-network-1", + kind: .network, + health: .healthy, + metrics: [ + .measured(.receivedFrames, value: 7), + .unavailable(.reconnects, reason: "counter is not exposed"), + ] + ) + let snapshot = DoryDeviceTelemetrySnapshot( + machineID: "machine-a", + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: 1, + sampledAtUnixMilliseconds: 1, + monotonicNanoseconds: 1, + devices: [device], + events: [ + DoryDeviceTelemetryEvent( + sequence: 1, + monotonicNanoseconds: 1, + deviceID: device.id, + kind: .networkReconnect + ), + ] + ) + + XCTAssertTrue(snapshot.isValid) + + var duplicateMetric = snapshot + duplicateMetric.devices[0].metrics.append(.measured(.receivedFrames, value: 8)) + XCTAssertFalse(duplicateMetric.isValid) + + var falseZero = snapshot + falseZero.devices[0].metrics[1].value = 0 + XCTAssertFalse(falseZero.isValid) + + var unknownEventDevice = snapshot + unknownEventDevice.events[0].deviceID = "not-present" + XCTAssertFalse(unknownEventDevice.isValid) + } + + func testUnavailableDeviceCannotPresentMeasuredZero() { + let device = DoryDeviceTelemetryDevice( + id: "virtualization-framework", + kind: .platform, + health: .unavailable, + metrics: [.measured(.deviceResets, value: 0)] + ) + XCTAssertFalse(device.isValid) + } + + func testRawControlServerReturnsExactHelperTelemetry() throws { + let root = "/tmp/dory-helper-telemetry-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let expected = DoryDeviceTelemetrySnapshot( + machineID: "raw-machine", + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: 3, + sampledAtUnixMilliseconds: 4, + monotonicNanoseconds: 5, + devices: [ + DoryDeviceTelemetryDevice( + id: "virtio-storage-0", + kind: .storage, + health: .healthy, + metrics: [.measured(.queueNotifications, value: 9)] + ), + ] + ) + let server = VmmLifecycleReceiptServer( + socketPath: socketPath, + deviceTelemetryProvider: { expected } + ) + defer { + server.stop() + try? FileManager.default.removeItem(atPath: root) + } + try server.start() + + XCTAssertEqual( + try UnixMachineDeviceTelemetryController().snapshot(socketPath: socketPath), + expected + ) + } + + func testControlServerRejectsInvalidProviderSnapshot() throws { + let root = "/tmp/dory-helper-telemetry-invalid-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let invalid = DoryDeviceTelemetrySnapshot( + machineID: "raw-machine", + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: 0, + sampledAtUnixMilliseconds: 4, + monotonicNanoseconds: 5, + devices: [] + ) + let server = VmmLifecycleReceiptServer( + socketPath: socketPath, + deviceTelemetryProvider: { invalid } + ) + defer { + server.stop() + try? FileManager.default.removeItem(atPath: root) + } + try server.start() + + XCTAssertThrowsError( + try UnixMachineDeviceTelemetryController().snapshot(socketPath: socketPath) + ) + } +} From 12b9c58ca0ee2d356652097b6a00bd6587b03eb6 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 00:48:57 +0000 Subject: [PATCH 229/338] feat(vm): publish device telemetry --- Dory/Runtime/Doryd/DorydClient.swift | 274 +++++++++++++++++- DoryTests/DorydClientTests.swift | 133 +++++++++ .../DorydKit/DoryDeviceTelemetry.swift | 28 +- .../DorydKit/DoryMachineFlightRecorder.swift | 36 +++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 74 +++++ .../Sources/DorydKit/MachineManager.swift | 100 +++++++ dory-core-swift/Sources/dorydctl/main.swift | 15 +- .../DoryDeviceTelemetryTests.swift | 16 + .../DorydKitTests/DorydServiceTests.swift | 174 +++++++++++ .../DorydKitTests/MachineManagerTests.swift | 129 +++++++++ 11 files changed, 973 insertions(+), 7 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 3b177a1c..de97c474 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -33,6 +33,7 @@ nonisolated protocol DorydControlXPC { func machineSerialConsoleRead(_ machineID: String, cursor: NSDictionary, limit: UInt32, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -990,6 +991,53 @@ nonisolated struct DorydMachineStats: Sendable, Equatable { var uptimeSeconds: Double } +nonisolated enum DorydDeviceTelemetryKind: String, Sendable, Equatable, CaseIterable { + case platform, storage, network, graphics, display, audio, balloon, entropy, input, socket + case sharedDirectory = "shared-directory" +} + +nonisolated enum DorydDeviceTelemetryHealth: String, Sendable, Equatable { + case healthy, degraded, failed, unavailable +} + +nonisolated enum DorydDeviceTelemetryMetricAvailability: String, Sendable, Equatable { + case measured, unavailable +} + +nonisolated struct DorydDeviceTelemetryMetric: Sendable, Equatable { + var kind: String + var unit: String + var availability: DorydDeviceTelemetryMetricAvailability + var value: UInt64? + var unavailableReason: String? +} + +nonisolated struct DorydDeviceTelemetryDevice: Sendable, Equatable { + var id: String + var kind: DorydDeviceTelemetryKind + var health: DorydDeviceTelemetryHealth + var metrics: [DorydDeviceTelemetryMetric] +} + +nonisolated struct DorydDeviceTelemetryEvent: Sendable, Equatable { + var sequence: UInt64 + var monotonicNanoseconds: UInt64 + var deviceID: String + var kind: String + var occurrences: UInt64 +} + +nonisolated struct DorydDeviceTelemetrySnapshot: Sendable, Equatable { + var machineID: String + var operationID: String + var backend: DoryVirtualizationBackendIdentity + var sampleSequence: UInt64 + var sampledAtUnixMilliseconds: UInt64 + var monotonicNanoseconds: UInt64 + var devices: [DorydDeviceTelemetryDevice] + var events: [DorydDeviceTelemetryEvent] +} + nonisolated struct DorydMachineProvisionResult: Sendable, Equatable { var recipeID: String var install: DorydMachineExecResult @@ -1060,6 +1108,7 @@ nonisolated enum DorydMachineFlightEventKind: String, Sendable, Equatable { case readinessAccepted = "readiness-accepted" case readinessRejected = "readiness-rejected" case resourceTransition = "resource-transition" + case deviceHealthEvent = "device-health-event" case processExited = "process-exited" case failureRecorded = "failure-recorded" case operationCompleted = "operation-completed" @@ -1084,6 +1133,10 @@ nonisolated struct DorydMachineFlightEvent: Sendable, Equatable { var planSHA256: String? var durationMilliseconds: UInt64? var deadlineUnixMilliseconds: Int64? + var deviceID: String? + var deviceEventKind: String? + var deviceEventSequence: UInt64? + var deviceEventOccurrences: UInt64? var evidenceReferences: [DorydMachineFailureEvidenceReference] } @@ -2050,6 +2103,15 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineDeviceTelemetry(_ machineID: String) async throws + -> DorydDeviceTelemetrySnapshot { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineDeviceTelemetry(machineID, reply: reply) + } decode: { + Self.machineDeviceTelemetry(from: $0, machineID: machineID) + } + } + func machineProvision(_ machineID: String, recipe: String) async throws -> DorydMachineProvisionResult { try await withTimeout(atLeast: Self.machineProvisionControlTimeout).statusCommand { proxy, reply in proxy.machineProvision(machineID, request: ["recipe": recipe] as NSDictionary, reply: reply) @@ -3407,6 +3469,161 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineDeviceTelemetry( + from dictionary: NSDictionary, + machineID: String + ) -> DorydDeviceTelemetrySnapshot? { + guard machineID.isSafeMachineIdentifier, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "schemaVersion", "machineID", "operationID", "backend", "sampleSequence", + "sampledAtUnixMilliseconds", "monotonicNanoseconds", "devices", "events", + ], + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let operationID = dictionary["operationID"] as? String, + isMachineOperationID(operationID), + let rawBackend = dictionary["backend"] as? String, + let backend = DoryVirtualizationBackendIdentity(rawValue: rawBackend), + let sampleSequence = strictUInt64(dictionary["sampleSequence"]), + sampleSequence > 0, + let sampledAt = strictUInt64(dictionary["sampledAtUnixMilliseconds"]), + sampledAt > 0, + let monotonic = strictUInt64(dictionary["monotonicNanoseconds"]), + monotonic > 0, + let rawDevices = dictionary["devices"] as? NSArray, + rawDevices.count > 0, rawDevices.count <= 64, + let rawEvents = dictionary["events"] as? NSArray, + rawEvents.count <= 256 else { + return nil + } + let devices = rawDevices.compactMap { raw -> DorydDeviceTelemetryDevice? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryDevice(from: row) + } + let events = rawEvents.compactMap { raw -> DorydDeviceTelemetryEvent? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryEvent(from: row) + } + guard devices.count == rawDevices.count, + events.count == rawEvents.count, + Set(devices.map(\.id)).count == devices.count, + events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count, + events.allSatisfy({ event in devices.contains { $0.id == event.deviceID } }) else { + return nil + } + return DorydDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: backend, + sampleSequence: sampleSequence, + sampledAtUnixMilliseconds: sampledAt, + monotonicNanoseconds: monotonic, + devices: devices, + events: events + ) + } + + nonisolated private static func machineDeviceTelemetryDevice( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryDevice? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["id", "kind", "health", "metrics"], + let id = dictionary["id"] as? String, + !id.isEmpty, id.utf8.count <= 128, + !id.contains("\0"), !id.contains("\n"), !id.contains("\r"), + let rawKind = dictionary["kind"] as? String, + let kind = DorydDeviceTelemetryKind(rawValue: rawKind), + let rawHealth = dictionary["health"] as? String, + let health = DorydDeviceTelemetryHealth(rawValue: rawHealth), + let rawMetrics = dictionary["metrics"] as? NSArray, + rawMetrics.count > 0, rawMetrics.count <= 23 else { + return nil + } + let metrics = rawMetrics.compactMap { raw -> DorydDeviceTelemetryMetric? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryMetric(from: row) + } + guard metrics.count == rawMetrics.count, + Set(metrics.map(\.kind)).count == metrics.count, + health != .unavailable + ? metrics.contains(where: { $0.availability == .measured }) + : metrics.allSatisfy({ $0.availability == .unavailable }) else { + return nil + } + return DorydDeviceTelemetryDevice(id: id, kind: kind, health: health, metrics: metrics) + } + + nonisolated private static func machineDeviceTelemetryMetric( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryMetric? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + let kind = dictionary["kind"] as? String, + deviceTelemetryMetricKinds.contains(kind), + let unit = dictionary["unit"] as? String, + unit == deviceTelemetryMetricUnit(for: kind), + let rawAvailability = dictionary["availability"] as? String, + let availability = DorydDeviceTelemetryMetricAvailability( + rawValue: rawAvailability + ) else { + return nil + } + switch availability { + case .measured: + guard Set(rawKeys) == ["kind", "unit", "availability", "value"], + let value = strictUInt64(dictionary["value"]) else { return nil } + return DorydDeviceTelemetryMetric( + kind: kind, + unit: unit, + availability: availability, + value: value + ) + case .unavailable: + guard Set(rawKeys) == ["kind", "unit", "availability", "unavailableReason"], + let reason = dictionary["unavailableReason"] as? String, + !reason.isEmpty, reason.utf8.count <= 256, + !reason.contains("\0"), !reason.contains("\n"), !reason.contains("\r") else { + return nil + } + return DorydDeviceTelemetryMetric( + kind: kind, + unit: unit, + availability: availability, + unavailableReason: reason + ) + } + } + + nonisolated private static func machineDeviceTelemetryEvent( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryEvent? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "sequence", "monotonicNanoseconds", "deviceID", "kind", "occurrences", + ], + let sequence = strictUInt64(dictionary["sequence"]), sequence > 0, + let monotonic = strictUInt64(dictionary["monotonicNanoseconds"]), monotonic > 0, + let deviceID = dictionary["deviceID"] as? String, + !deviceID.isEmpty, deviceID.utf8.count <= 128, + let kind = dictionary["kind"] as? String, + deviceTelemetryEventKinds.contains(kind), + let occurrences = strictUInt64(dictionary["occurrences"]), occurrences > 0 else { + return nil + } + return DorydDeviceTelemetryEvent( + sequence: sequence, + monotonicNanoseconds: monotonic, + deviceID: deviceID, + kind: kind, + occurrences: occurrences + ) + } + nonisolated private static func machineProvisionResult(from dictionary: NSDictionary) -> DorydMachineProvisionResult? { let decodedRecipeID = (dictionary["recipeID"] as? String) ?? (dictionary["recipe"] as? String) guard let recipeID = decodedRecipeID, @@ -3633,7 +3850,8 @@ nonisolated final class DorydClient: @unchecked Sendable { let optional: Set = [ "operationID", "operationKind", "phase", "machineState", "failureCode", "recoveryDisposition", "backend", "virtualHardwareABIVersion", "planSHA256", - "durationMilliseconds", "deadlineUnixMilliseconds", + "durationMilliseconds", "deadlineUnixMilliseconds", "deviceID", + "deviceEventKind", "deviceEventSequence", "deviceEventOccurrences", ] guard let rawKeys = dictionary.allKeys as? [String] else { return nil } let keys = Set(rawKeys) @@ -3694,6 +3912,10 @@ nonisolated final class DorydClient: @unchecked Sendable { let planSHA256 = dictionary["planSHA256"] as? String let duration = dictionary["durationMilliseconds"].flatMap(strictUInt64) let deadline = dictionary["deadlineUnixMilliseconds"].flatMap(strictInt64) + let deviceID = dictionary["deviceID"] as? String + let deviceEventKind = dictionary["deviceEventKind"] as? String + let deviceEventSequence = dictionary["deviceEventSequence"].flatMap(strictUInt64) + let deviceEventOccurrences = dictionary["deviceEventOccurrences"].flatMap(strictUInt64) guard (dictionary["operationID"] == nil) == (operationID == nil), (dictionary["operationKind"] == nil) == (operationKind == nil), (operationID == nil) == (operationKind == nil), @@ -3713,13 +3935,32 @@ nonisolated final class DorydClient: @unchecked Sendable { (dictionary["durationMilliseconds"] == nil) == (duration == nil), duration.map({ $0 <= 31 * 24 * 60 * 60 * 1_000 }) ?? true, (dictionary["deadlineUnixMilliseconds"] == nil) == (deadline == nil), - deadline.map({ $0 > 0 }) ?? true else { + deadline.map({ $0 > 0 }) ?? true, + (dictionary["deviceID"] == nil) == (deviceID == nil), + deviceID.map({ !$0.isEmpty && $0.utf8.count <= 128 }) ?? true, + (dictionary["deviceEventKind"] == nil) == (deviceEventKind == nil), + deviceEventKind.map(deviceTelemetryEventKinds.contains) ?? true, + (dictionary["deviceEventSequence"] == nil) == (deviceEventSequence == nil), + deviceEventSequence.map({ $0 > 0 }) ?? true, + (dictionary["deviceEventOccurrences"] == nil) == (deviceEventOccurrences == nil), + deviceEventOccurrences.map({ $0 > 0 }) ?? true else { return nil } if [.failureRecorded, .readinessRejected, .operationFailed, .recoveryRequired] .contains(kind), failureCode == nil { return nil } + let deviceFields = [ + deviceID != nil, + deviceEventKind != nil, + deviceEventSequence != nil, + deviceEventOccurrences != nil, + ] + guard kind == .deviceHealthEvent + ? deviceFields.allSatisfy({ $0 }) + : deviceFields.allSatisfy({ !$0 }) else { + return nil + } return DorydMachineFlightEvent( sequence: sequence, occurredAtUnixMilliseconds: occurredAt, @@ -3736,6 +3977,10 @@ nonisolated final class DorydClient: @unchecked Sendable { planSHA256: planSHA256, durationMilliseconds: duration, deadlineUnixMilliseconds: deadline, + deviceID: deviceID, + deviceEventKind: deviceEventKind, + deviceEventSequence: deviceEventSequence, + deviceEventOccurrences: deviceEventOccurrences, evidenceReferences: evidence ) } @@ -3903,6 +4148,31 @@ nonisolated final class DorydClient: @unchecked Sendable { nonisolated private static let machineRuntimeModes: Set = [ "legacy-compatibility", "resolved-plan", "requires-replanning", ] + nonisolated private static let deviceTelemetryMetricKinds: Set = [ + "queue-notifications", "queue-state-changes", "used-interrupts", + "configuration-interrupts", "device-resets", "transmitted-frames", + "transmitted-bytes", "transmit-drops", "received-frames", "received-bytes", + "receive-deferred", "receive-drops", "receive-truncations", "reconnects", + "display-frames", "display-drops", "audio-drops", "storage-flushes", + "maximum-storage-flush-latency-nanoseconds", "graphics-fences", + "graphics-device-losses", "share-invalidations", "share-invalidation-failures", + ] + nonisolated private static let deviceTelemetryEventKinds: Set = [ + "queue-stall", "reset", "graphics-fence-timeout", "graphics-device-loss", + "network-reconnect", "audio-drop", "storage-flush-slow", + "share-invalidation-failure", + ] + + nonisolated private static func deviceTelemetryMetricUnit(for kind: String) -> String { + switch kind { + case "transmitted-bytes", "received-bytes": + "bytes" + case "maximum-storage-flush-latency-nanoseconds": + "nanoseconds" + default: + "count" + } + } nonisolated private static func machineImportAssessment( from dictionary: NSDictionary diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 1bd08e34..9fa8ecee 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -938,6 +938,122 @@ struct DorydClientTests { afterSequence: 0 ) } + + let deviceEvent = event.mutableCopy() as! NSMutableDictionary + deviceEvent["kind"] = "device-health-event" + deviceEvent["operationID"] = "12345678-1234-4234-8234-123456789abc" + deviceEvent["operationKind"] = "starting" + deviceEvent["deviceID"] = "virtio-network-7" + deviceEvent["deviceEventKind"] = "queue-stall" + deviceEvent["deviceEventSequence"] = UInt64(1) + deviceEvent["deviceEventOccurrences"] = UInt64(2) + let deviceBatch = valid.mutableCopy() as! NSMutableDictionary + deviceBatch["events"] = [deviceEvent] + service.setMachineFlightRecorderBatch(deviceBatch) + let deviceFlight = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + #expect(deviceFlight.events.first?.kind == .deviceHealthEvent) + #expect(deviceFlight.events.first?.deviceID == "virtio-network-7") + #expect(deviceFlight.events.first?.deviceEventKind == "queue-stall") + #expect(deviceFlight.events.first?.deviceEventOccurrences == 2) + + deviceEvent.removeObject(forKey: "deviceEventOccurrences") + service.setMachineFlightRecorderBatch(deviceBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + } + + @MainActor + @Test func machineDeviceTelemetryRequiresExactBoundedLaunchEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let operationID = "12345678-1234-4234-8234-123456789abc" + let metric: NSDictionary = [ + "kind": "receive-drops", + "unit": "count", + "availability": "measured", + "value": UInt64(2), + ] + let device: NSDictionary = [ + "id": "virtio-network-7", + "kind": "network", + "health": "degraded", + "metrics": [metric], + ] + let event: NSDictionary = [ + "sequence": UInt64(1), + "monotonicNanoseconds": UInt64(20), + "deviceID": "virtio-network-7", + "kind": "queue-stall", + "occurrences": UInt64(2), + ] + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "operationID": operationID, + "backend": "apple-virtualization-framework", + "sampleSequence": UInt64(1), + "sampledAtUnixMilliseconds": UInt64(10), + "monotonicNanoseconds": UInt64(20), + "devices": [device], + "events": [event], + ] + service.setMachineDeviceTelemetryResponse(valid) + let snapshot = try await client.machineDeviceTelemetry("dev") + #expect(snapshot.operationID == operationID) + #expect(snapshot.backend == .appleVirtualizationFramework) + #expect(snapshot.devices.first?.metrics.first?.value == 2) + #expect(snapshot.events.first?.kind == "queue-stall") + #expect(snapshot.events.first?.occurrences == 2) + + let unavailableWithValue: NSDictionary = [ + "kind": "receive-drops", + "unit": "count", + "availability": "unavailable", + "unavailableReason": "framework API unavailable", + "value": UInt64(0), + ] + let invalidDevice = device.mutableCopy() as! NSMutableDictionary + invalidDevice["metrics"] = [unavailableWithValue] + let invalidMetricShape = valid.mutableCopy() as! NSMutableDictionary + invalidMetricShape["devices"] = [invalidDevice] + + let wrongUnitMetric = metric.mutableCopy() as! NSMutableDictionary + wrongUnitMetric["unit"] = "bytes" + let wrongUnitDevice = device.mutableCopy() as! NSMutableDictionary + wrongUnitDevice["metrics"] = [wrongUnitMetric] + let invalidMetricUnit = valid.mutableCopy() as! NSMutableDictionary + invalidMetricUnit["devices"] = [wrongUnitDevice] + + let orphanEvent = event.mutableCopy() as! NSMutableDictionary + orphanEvent["deviceID"] = "virtio-storage-9" + let invalidEventAuthority = valid.mutableCopy() as! NSMutableDictionary + invalidEventAuthority["events"] = [orphanEvent] + + for malformed in [ + valid.adding("hostPath", "/private/opaque"), + valid.replacing("sampleSequence", with: true), + invalidMetricShape, + invalidMetricUnit, + invalidEventAuthority, + ] { + service.setMachineDeviceTelemetryResponse(malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineDeviceTelemetry("dev") + } + } } @MainActor @@ -3955,6 +4071,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineImportAssessmentOverride: NSDictionary? private var _machineEventBatchOverride: NSDictionary? private var _machineFlightRecorderBatchOverride: NSDictionary? + private var _machineDeviceTelemetryResponseOverride: NSDictionary? private var _machineSerialConsoleBatchOverride: NSDictionary? private var _latestMachineSerialConsoleCursor: NSDictionary? private var _latestMachineSerialConsoleInput: Data? @@ -4372,6 +4489,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.unlock() } + func setMachineDeviceTelemetryResponse(_ response: NSDictionary?) { + lock.lock() + _machineDeviceTelemetryResponseOverride = response + lock.unlock() + } + func setMachineSerialConsoleBatch(_ response: NSDictionary?) { lock.lock() _machineSerialConsoleBatchOverride = response @@ -4797,6 +4920,16 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineDeviceTelemetry( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineDeviceTelemetryResponseOverride ?? [:] + lock.unlock() + reply(true, row, "") + } + func machineSerialConsoleRead( _ machineID: String, cursor: NSDictionary, diff --git a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift index 0b864ea4..1d92818b 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift @@ -47,6 +47,17 @@ public enum DoryDeviceTelemetryMetricKind: String, Codable, Sendable, CaseIterab case graphicsDeviceLosses = "graphics-device-losses" case shareInvalidations = "share-invalidations" case shareInvalidationFailures = "share-invalidation-failures" + + public var expectedUnit: DoryDeviceTelemetryMetricUnit { + switch self { + case .transmittedBytes, .receivedBytes: + .bytes + case .maximumStorageFlushLatencyNanoseconds: + .nanoseconds + default: + .count + } + } } public enum DoryDeviceTelemetryMetricUnit: String, Codable, Sendable, CaseIterable, Hashable { @@ -83,26 +94,32 @@ public struct DoryDeviceTelemetryMetric: Codable, Sendable, Equatable, Hashable public static func measured( _ kind: DoryDeviceTelemetryMetricKind, - unit: DoryDeviceTelemetryMetricUnit = .count, + unit: DoryDeviceTelemetryMetricUnit? = nil, value: UInt64 ) -> Self { - Self(kind: kind, unit: unit, availability: .measured, value: value) + Self( + kind: kind, + unit: unit ?? kind.expectedUnit, + availability: .measured, + value: value + ) } public static func unavailable( _ kind: DoryDeviceTelemetryMetricKind, - unit: DoryDeviceTelemetryMetricUnit = .count, + unit: DoryDeviceTelemetryMetricUnit? = nil, reason: String ) -> Self { Self( kind: kind, - unit: unit, + unit: unit ?? kind.expectedUnit, availability: .unavailable, unavailableReason: reason ) } public var isValid: Bool { + guard unit == kind.expectedUnit else { return false } switch availability { case .measured: return value != nil && unavailableReason == nil @@ -142,6 +159,7 @@ public struct DoryDeviceTelemetryDevice: Codable, Sendable, Equatable, Hashable !id.contains("\n"), !id.contains("\r"), !metrics.isEmpty, + metrics.count <= DoryDeviceTelemetryMetricKind.allCases.count, metrics.allSatisfy(\.isValid), Set(metrics.map(\.kind)).count == metrics.count else { return false @@ -245,8 +263,10 @@ public struct DoryDeviceTelemetrySnapshot: Codable, Sendable, Equatable, Hashabl DoryOperationIdentity.parseCanonical(operationID) != nil, operationID != "00000000-0000-0000-0000-000000000000", !devices.isEmpty, + devices.count <= 64, devices.allSatisfy(\.isValid), Set(devices.map(\.id)).count == devices.count, + events.count <= 256, events.allSatisfy(\.isValid) else { return false } diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift index 3696e567..b3994a86 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFlightRecorder.swift @@ -14,6 +14,7 @@ public enum DoryMachineFlightEventKind: String, Codable, Sendable, CaseIterable case helperLifecycleAcknowledged = "helper-lifecycle-acknowledged" case helperLifecycleUnavailable = "helper-lifecycle-unavailable" case resourceTransition = "resource-transition" + case deviceHealthEvent = "device-health-event" case processExited = "process-exited" case failureRecorded = "failure-recorded" case operationCompleted = "operation-completed" @@ -44,6 +45,10 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { public var planSHA256: String? public var durationMilliseconds: UInt64? public var deadlineUnixMilliseconds: Int64? + public var deviceID: String? + public var deviceEventKind: DoryDeviceTelemetryEventKind? + public var deviceEventSequence: UInt64? + public var deviceEventOccurrences: UInt64? public var evidenceReferences: [DoryMachineFailureEvidenceReference] public init( @@ -62,6 +67,10 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { planSHA256: String? = nil, durationMilliseconds: UInt64? = nil, deadlineUnixMilliseconds: Int64? = nil, + deviceID: String? = nil, + deviceEventKind: DoryDeviceTelemetryEventKind? = nil, + deviceEventSequence: UInt64? = nil, + deviceEventOccurrences: UInt64? = nil, evidenceReferences: [DoryMachineFailureEvidenceReference] = [] ) { schemaVersion = Self.currentSchemaVersion @@ -80,6 +89,10 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { self.planSHA256 = planSHA256 self.durationMilliseconds = durationMilliseconds self.deadlineUnixMilliseconds = deadlineUnixMilliseconds + self.deviceID = deviceID + self.deviceEventKind = deviceEventKind + self.deviceEventSequence = deviceEventSequence + self.deviceEventOccurrences = deviceEventOccurrences self.evidenceReferences = evidenceReferences } @@ -100,6 +113,9 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { planSHA256.map(Self.isSHA256) ?? true, durationMilliseconds.map({ $0 <= 31 * 24 * 60 * 60 * 1_000 }) ?? true, deadlineUnixMilliseconds.map({ $0 > 0 }) ?? true, + deviceID.map(Self.isDeviceID) ?? true, + deviceEventSequence.map({ $0 > 0 }) ?? true, + deviceEventOccurrences.map({ $0 > 0 }) ?? true, evidenceReferences.count <= 16, evidenceReferences.allSatisfy(\.isValid), Set(evidenceReferences).count == evidenceReferences.count else { @@ -110,6 +126,13 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { || kind == .operationFailed || kind == .recoveryRequired { return failureCode != nil && recoveryDisposition != nil } + let hasDeviceEvent = deviceID != nil || deviceEventKind != nil + || deviceEventSequence != nil || deviceEventOccurrences != nil + if kind == .deviceHealthEvent { + return deviceID != nil && deviceEventKind != nil + && deviceEventSequence != nil && deviceEventOccurrences != nil + } + if hasDeviceEvent { return false } return true } @@ -124,6 +147,11 @@ public struct DoryMachineFlightEvent: Codable, Sendable, Equatable, Hashable { (48...57).contains($0) || (97...102).contains($0) } } + + private static func isDeviceID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 128 + && !value.contains("\0") && !value.contains("\n") && !value.contains("\r") + } } public struct DoryMachineFlightRecorderBatch: Codable, Sendable, Equatable { @@ -240,6 +268,10 @@ final class DoryMachineFlightRecorderStore: @unchecked Sendable { planSHA256: String? = nil, durationMilliseconds: UInt64? = nil, deadlineUnixMilliseconds: Int64? = nil, + deviceID: String? = nil, + deviceEventKind: DoryDeviceTelemetryEventKind? = nil, + deviceEventSequence: UInt64? = nil, + deviceEventOccurrences: UInt64? = nil, evidenceReferences: [DoryMachineFailureEvidenceReference] = [] ) throws -> DoryMachineFlightEvent { guard Self.isMachineID(machineID) else { @@ -283,6 +315,10 @@ final class DoryMachineFlightRecorderStore: @unchecked Sendable { planSHA256: planSHA256, durationMilliseconds: durationMilliseconds, deadlineUnixMilliseconds: deadlineUnixMilliseconds, + deviceID: deviceID, + deviceEventKind: deviceEventKind, + deviceEventSequence: deviceEventSequence, + deviceEventOccurrences: deviceEventOccurrences, evidenceReferences: evidenceReferences ) guard event.isValid else { diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 9d42251e..621f40f4 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -33,6 +33,7 @@ import Foundation func machineSerialConsoleRead(_ machineID: String, cursor: NSDictionary, limit: UInt32, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index e9b02c25..4e740f1d 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -566,6 +566,22 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineDeviceTelemetry( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let snapshot = try machineManager.deviceTelemetry(id: machineID) + reply(true, snapshot.xpcDictionary, "") + } catch { + reply(false, [:], "\(error)") + } + } + public func machineExec( _ machineID: String, request: NSDictionary, @@ -2591,10 +2607,68 @@ private extension DoryMachineFlightEvent { if let deadlineUnixMilliseconds { dictionary["deadlineUnixMilliseconds"] = deadlineUnixMilliseconds } + if let deviceID { dictionary["deviceID"] = deviceID } + if let deviceEventKind { dictionary["deviceEventKind"] = deviceEventKind.rawValue } + if let deviceEventSequence { dictionary["deviceEventSequence"] = deviceEventSequence } + if let deviceEventOccurrences { + dictionary["deviceEventOccurrences"] = deviceEventOccurrences + } return dictionary as NSDictionary } } +private extension DoryDeviceTelemetrySnapshot { + var xpcDictionary: NSDictionary { + [ + "schemaVersion": schemaVersion, + "machineID": machineID, + "operationID": operationID, + "backend": backend.rawValue, + "sampleSequence": sampleSequence, + "sampledAtUnixMilliseconds": sampledAtUnixMilliseconds, + "monotonicNanoseconds": monotonicNanoseconds, + "devices": devices.map(\.xpcDictionary), + "events": events.map(\.xpcDictionary), + ] + } +} + +private extension DoryDeviceTelemetryDevice { + var xpcDictionary: NSDictionary { + [ + "id": id, + "kind": kind.rawValue, + "health": health.rawValue, + "metrics": metrics.map(\.xpcDictionary), + ] + } +} + +private extension DoryDeviceTelemetryMetric { + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "kind": kind.rawValue, + "unit": unit.rawValue, + "availability": availability.rawValue, + ] + if let value { dictionary["value"] = value } + if let unavailableReason { dictionary["unavailableReason"] = unavailableReason } + return dictionary as NSDictionary + } +} + +private extension DoryDeviceTelemetryEvent { + var xpcDictionary: NSDictionary { + [ + "sequence": sequence, + "monotonicNanoseconds": monotonicNanoseconds, + "deviceID": deviceID, + "kind": kind.rawValue, + "occurrences": occurrences, + ] + } +} + private extension DoryMachineEvent { var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d18701c9..99be5067 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -828,6 +828,8 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert case agentCapabilityUnavailable(String, String) case balloonUnavailable(String) case balloonApplyFailed(String, String) + case deviceTelemetryUnavailable(String) + case deviceTelemetryRejected(String, String) case invalidAddress(String) case invalidShare(String) case invalidEnvironment(String) @@ -855,6 +857,10 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert return "machine balloon control is unavailable: \(id)" case let .balloonApplyFailed(id, message): return "machine balloon control failed for \(id): \(message)" + case let .deviceTelemetryUnavailable(id): + return "machine device telemetry is unavailable: \(id)" + case let .deviceTelemetryRejected(id, message): + return "machine device telemetry was rejected for \(id): \(message)" case let .invalidAddress(address): return "invalid machine address: \(address)" case let .invalidShare(share): @@ -967,6 +973,7 @@ public final class MachineManager: @unchecked Sendable { private let configuration: MachineManagerConfiguration private let agentConnector: AgentConnector private let balloonController: any MachineBalloonControlling + private let deviceTelemetryController: any MachineDeviceTelemetryControlling private let vzLifecycleController: any MachineVZLifecycleControlling private let savedStateStore: DoryMachineSavedStateStore private let processStarter: ProcessStarter @@ -1016,6 +1023,8 @@ public final class MachineManager: @unchecked Sendable { configuration: MachineManagerConfiguration, launchPolicy: DoryMachineLaunchPolicy = .legacyCompatibility, balloonController: any MachineBalloonControlling = UnixMachineBalloonController(), + deviceTelemetryController: any MachineDeviceTelemetryControlling = + UnixMachineDeviceTelemetryController(), vzLifecycleController: any MachineVZLifecycleControlling = UnixMachineVZLifecycleController(), agentConnector: @escaping AgentConnector = { socketPath in try LocalAgentControl.connect(socketPath: socketPath) @@ -1025,6 +1034,7 @@ public final class MachineManager: @unchecked Sendable { self.configuration = configuration self.launchPolicy = launchPolicy self.balloonController = balloonController + self.deviceTelemetryController = deviceTelemetryController self.vzLifecycleController = vzLifecycleController self.agentConnector = agentConnector self.processStarter = processStarter @@ -2701,6 +2711,8 @@ public final class MachineManager: @unchecked Sendable { entry.currentBalloonTargetMB = nil entry.activeResolvedPlan = resolvedPlan entry.activeBackend = processLaunch.backend + entry.lastDeviceTelemetrySampleSequence = 0 + entry.lastDeviceTelemetryEventSequence = 0 entry.readinessAcceptedPendingPublication = false let requiresAdmissionCommit = resolvedPlan != nil && productionResourceAdmissionLedger != nil @@ -6679,6 +6691,92 @@ public final class MachineManager: @unchecked Sendable { } } + public func deviceTelemetry(id: String) throws -> DoryDeviceTelemetrySnapshot { + let socketPath: String + let launchID: UUID + let operationID: String + let backend: DoryVirtualizationBackendIdentity + lock.lock() + guard let entry = machines[id], + [.running, .paused].contains(entry.state), + entry.process?.isRunning == true, + let currentLaunchID = entry.launchID, + let currentSocketPath = entry.handoff?.ready.controlSocketPath, + let currentOperationID = entry.handoff?.ready.operationID, + DoryOperationIdentity.parseCanonical(currentOperationID) != nil, + let currentBackend = entry.activeBackend else { + lock.unlock() + throw MachineManagerError.deviceTelemetryUnavailable(id) + } + socketPath = currentSocketPath + launchID = currentLaunchID + operationID = currentOperationID + backend = currentBackend + lock.unlock() + + let snapshot: DoryDeviceTelemetrySnapshot + do { + snapshot = try deviceTelemetryController.snapshot(socketPath: socketPath) + } catch { + throw MachineManagerError.deviceTelemetryRejected(id, "\(error)") + } + guard snapshot.isValid, + snapshot.machineID == id, + snapshot.operationID == operationID, + snapshot.backend == backend else { + throw MachineManagerError.deviceTelemetryRejected( + id, + "helper snapshot does not match the live launch authority" + ) + } + + lock.lock() + defer { lock.unlock() } + guard var entry = machines[id], + [.running, .paused].contains(entry.state), + entry.process?.isRunning == true, + entry.launchID == launchID, + entry.handoff?.ready.controlSocketPath == socketPath, + entry.handoff?.ready.operationID == operationID, + entry.activeBackend == backend, + snapshot.sampleSequence > entry.lastDeviceTelemetrySampleSequence else { + throw MachineManagerError.deviceTelemetryRejected( + id, + "live launch changed or telemetry sequence did not advance" + ) + } + entry.lastDeviceTelemetrySampleSequence = snapshot.sampleSequence + for event in snapshot.events + where event.sequence > entry.lastDeviceTelemetryEventSequence { + do { + let recorded = try flightRecorderStore.append( + machineID: id, + operationID: operationID, + operationKind: DoryWorkspaceMutationKind.starting.rawValue, + kind: .deviceHealthEvent, + machineState: entry.state.rawValue, + backend: backend, + virtualHardwareABIVersion: + entry.runtimeIdentity.virtualHardwareABIVersion, + planSHA256: entry.runtimeIdentity.resolvedPlanSHA256, + deviceID: event.deviceID, + deviceEventKind: event.kind, + deviceEventSequence: event.sequence, + deviceEventOccurrences: event.occurrences, + evidenceReferences: failureEvidenceReferences(for: entry) + ) + entry.flightRecorderHeadSequence = recorded.sequence + entry.flightRecorderAvailable = true + entry.lastDeviceTelemetryEventSequence = event.sequence + } catch { + entry.flightRecorderAvailable = false + break + } + } + machines[id] = entry + return snapshot + } + public func memorySnapshots() -> [GuestMemorySnapshot] { list().compactMap { status in if status.state == .paused { @@ -12445,6 +12543,8 @@ private struct MachineEntry { var activeOperationKind: DoryWorkspaceMutationKind? = nil var flightRecorderHeadSequence: UInt64 = 0 var flightRecorderAvailable: Bool = true + var lastDeviceTelemetrySampleSequence: UInt64 = 0 + var lastDeviceTelemetryEventSequence: UInt64 = 0 var readinessAcceptedPendingPublication: Bool = false var activeResolvedPlan: DoryResolvedMachinePlan? var activeBackend: DoryVirtualizationBackendIdentity? diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 4af35c71..c8b3458d 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -191,6 +191,7 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine list dorydctl [global] machine status NAME dorydctl [global] machine stats NAME + dorydctl [global] machine device-telemetry NAME dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] dorydctl [global] machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT] dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] @@ -1148,7 +1149,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|flight-recorder|console|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|device-telemetry|flight-recorder|console|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1170,6 +1171,18 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { proxy.machineStats(name, reply: reply) } try emitJSON(stats) + case "device-telemetry": + let name = try cursor.take("usage: dorydctl machine device-telemetry NAME") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage( + "unexpected machine device-telemetry argument: \(cursor.values[0])" + ) + } + let telemetry: NSDictionary = try client.withTimeout(atLeast: 10) + .statusCommand { proxy, reply in + proxy.machineDeviceTelemetry(name, reply: reply) + } + try emitJSON(telemetry) case "flight-recorder": let name = try cursor.take( "usage: dorydctl machine flight-recorder NAME [--after SEQUENCE]" diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift index 588221ad..0526638b 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift @@ -46,6 +46,22 @@ final class DoryDeviceTelemetryTests: XCTestCase { falseZero.devices[0].metrics[1].value = 0 XCTAssertFalse(falseZero.isValid) + var wrongUnit = snapshot + wrongUnit.devices[0].metrics[0].unit = .bytes + XCTAssertFalse(wrongUnit.isValid) + + XCTAssertEqual( + DoryDeviceTelemetryMetric.measured(.receivedBytes, value: 7).unit, + .bytes + ) + XCTAssertEqual( + DoryDeviceTelemetryMetric.unavailable( + .maximumStorageFlushLatencyNanoseconds, + reason: "not exposed" + ).unit, + .nanoseconds + ) + var unknownEventDevice = snapshot unknownEventDevice.events[0].deviceID = "not-present" XCTAssertFalse(unknownEventDevice.isValid) diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 41fc0b01..1deecc4c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1197,6 +1197,155 @@ final class DorydServiceTests: XCTestCase { wait(for: [deletedFlight], timeout: 5) } + func testMachineDeviceTelemetryOverXPCIsExactAndLaunchBound() throws { + let base = "/tmp/doryd-service-device-telemetry-\(getpid())-\(UInt32.random(in: 0.. DoryDeviceTelemetrySnapshot { + try lock.withLock { + storedSocketPaths.append(socketPath) + guard let storedValue else { + throw VmmControlError.rejected("test telemetry is missing") + } + return storedValue + } + } +} + private final class ServiceRepairCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index e3921efa..3a319b54 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -4613,6 +4613,111 @@ final class MachineManagerTests: XCTestCase { wait(for: [lockReleased], timeout: 1) XCTAssertEqual(manager.status(id: "dev")?.state, .created) } + + func testDeviceTelemetryBindsLiveLaunchAndPersistsClassifiedEvents() throws { + let base = "/tmp/dory-machine-device-telemetry-\(getpid())-\(UInt32.random(in: 0.. DoryDeviceTelemetrySnapshot { + try lock.withLock { + recordedSocketPaths.append(socketPath) + guard let storedValue else { + throw VmmControlError.rejected("test telemetry is missing") + } + return storedValue + } + } +} + private final class RecordingMachineVZLifecycleController: MachineVZLifecycleControlling, @unchecked Sendable { struct Receipt: Equatable { From 1243d35e7bffe999e167465221435ca7d93afee0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:03:32 +0000 Subject: [PATCH 230/338] feat(vm): sample device health events --- .../Sources/dory-hv/DesktopMode.swift | 174 +++++++++++++----- .../DesktopDeviceTelemetryTests.swift | 64 +++++++ docs/virtual-workspace-platform.md | 11 +- .../DoryMachineDeviceTelemetryMonitor.swift | 127 +++++++++++++ .../Sources/DorydKit/MachineManager.swift | 8 + dory-core-swift/Sources/doryd/main.swift | 22 +++ ...ryMachineDeviceTelemetryMonitorTests.swift | 86 +++++++++ .../DorydKitTests/MachineManagerTests.swift | 23 +++ 8 files changed, 470 insertions(+), 45 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDeviceTelemetryMonitor.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineDeviceTelemetryMonitorTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index d66d2735..6d130742 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -7,21 +7,29 @@ import DorydKit import DoryVMMKit import Foundation -private final class RawDeviceTelemetryRegistry: @unchecked Sendable { +final class RawDeviceTelemetryRegistry: @unchecked Sendable { private struct Entry { var id: String var kind: DoryDeviceTelemetryKind var transport: VirtioMMIOTransport var network: VirtioNet? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + var previousTransportStatistics: VirtioMMIOTransportStatistics? + var consecutiveUncompletedNotificationSamples: UInt8 + var queueStallReported: Bool } private let machineID: String private let operationID: String private let lock = NSLock() private var sampleSequence: UInt64 = 0 + private var eventSequence: UInt64 = 0 + private var eventHistory = [DoryDeviceTelemetryEvent]() private var entries = [Entry]() + private static let queueStallSampleThreshold: UInt8 = 3 + private static let maximumEventHistory = 256 + init(machineID: String, operationID: UUID) { self.machineID = machineID self.operationID = DoryOperationIdentity.canonical(operationID) @@ -87,57 +95,137 @@ private final class RawDeviceTelemetryRegistry: @unchecked Sendable { kind: kind, transport: transport, network: backend as? VirtioNet, - unavailableMetrics: unavailable + unavailableMetrics: unavailable, + previousTransportStatistics: nil, + consecutiveUncompletedNotificationSamples: 0, + queueStallReported: false ) lock.withLock { entries.append(entry) } } func snapshot() -> DoryDeviceTelemetrySnapshot { - let captured: (UInt64, [Entry]) = lock.withLock { + lock.withLock { sampleSequence &+= 1 - return (sampleSequence, entries) - } - let unavailableReason = "raw-HV backend does not expose this counter yet" - let devices = captured.1.map { entry in - let transport = entry.transport.statistics - var metrics: [DoryDeviceTelemetryMetric] = [ - .measured(.queueNotifications, value: transport.queueNotifications), - .measured(.queueStateChanges, value: transport.queueStateChanges), - .measured(.usedInterrupts, value: transport.usedInterrupts), - .measured(.configurationInterrupts, value: transport.configurationInterrupts), - .measured(.deviceResets, value: transport.deviceResets), - ] - if let network = entry.network?.statistics { - metrics.append(contentsOf: [ - .measured(.transmittedFrames, value: network.transmitPackets), - .measured(.transmittedBytes, unit: .bytes, value: network.transmitBytes), - .measured(.transmitDrops, value: network.transmitDrops), - .measured(.receivedFrames, value: network.receivePackets), - .measured(.receivedBytes, unit: .bytes, value: network.receiveBytes), - .measured(.receiveDeferred, value: network.receiveDeferred), - .measured(.receiveDrops, value: network.receiveDrops), - .measured(.receiveTruncations, value: network.receiveTruncations), - ]) + let sampledAtUnixMilliseconds = UInt64(Date().timeIntervalSince1970 * 1_000) + let monotonicNanoseconds = DispatchTime.now().uptimeNanoseconds + let unavailableReason = "raw-HV backend does not expose this counter yet" + var devices = [DoryDeviceTelemetryDevice]() + devices.reserveCapacity(entries.count) + + for index in entries.indices { + let transport = entries[index].transport.statistics + var health = DoryDeviceTelemetryHealth.healthy + if let previous = entries[index].previousTransportStatistics { + let resetCount = Self.monotonicDelta( + current: transport.deviceResets, + previous: previous.deviceResets + ) + if resetCount > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .reset, + occurrences: resetCount, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + + let notificationsAdvanced = + transport.queueNotifications > previous.queueNotifications + let completionOrLifecycleAdvanced = + transport.usedInterrupts > previous.usedInterrupts + || transport.queueStateChanges > previous.queueStateChanges + || transport.deviceResets > previous.deviceResets + if completionOrLifecycleAdvanced { + entries[index].consecutiveUncompletedNotificationSamples = 0 + entries[index].queueStallReported = false + } else if notificationsAdvanced + || entries[index].consecutiveUncompletedNotificationSamples > 0 { + if entries[index].consecutiveUncompletedNotificationSamples < UInt8.max { + entries[index].consecutiveUncompletedNotificationSamples += 1 + } + } + if entries[index].consecutiveUncompletedNotificationSamples + >= Self.queueStallSampleThreshold { + health = .degraded + if !entries[index].queueStallReported { + appendEvent( + deviceID: entries[index].id, + kind: .queueStall, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + entries[index].queueStallReported = true + } + } + } + entries[index].previousTransportStatistics = transport + + var metrics: [DoryDeviceTelemetryMetric] = [ + .measured(.queueNotifications, value: transport.queueNotifications), + .measured(.queueStateChanges, value: transport.queueStateChanges), + .measured(.usedInterrupts, value: transport.usedInterrupts), + .measured(.configurationInterrupts, value: transport.configurationInterrupts), + .measured(.deviceResets, value: transport.deviceResets), + ] + if let network = entries[index].network?.statistics { + metrics.append(contentsOf: [ + .measured(.transmittedFrames, value: network.transmitPackets), + .measured(.transmittedBytes, value: network.transmitBytes), + .measured(.transmitDrops, value: network.transmitDrops), + .measured(.receivedFrames, value: network.receivePackets), + .measured(.receivedBytes, value: network.receiveBytes), + .measured(.receiveDeferred, value: network.receiveDeferred), + .measured(.receiveDrops, value: network.receiveDrops), + .measured(.receiveTruncations, value: network.receiveTruncations), + ]) + } + metrics.append(contentsOf: entries[index].unavailableMetrics.map { + .unavailable($0.0, unit: $0.1, reason: unavailableReason) + }) + devices.append(DoryDeviceTelemetryDevice( + id: entries[index].id, + kind: entries[index].kind, + health: health, + metrics: metrics + )) } - metrics.append(contentsOf: entry.unavailableMetrics.map { - .unavailable($0.0, unit: $0.1, reason: unavailableReason) - }) - return DoryDeviceTelemetryDevice( - id: entry.id, - kind: entry.kind, - health: .healthy, - metrics: metrics + + return DoryDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: sampleSequence, + sampledAtUnixMilliseconds: sampledAtUnixMilliseconds, + monotonicNanoseconds: monotonicNanoseconds, + devices: devices, + events: eventHistory ) } - return DoryDeviceTelemetrySnapshot( - machineID: machineID, - operationID: operationID, - backend: .doryHypervisor, - sampleSequence: captured.0, - sampledAtUnixMilliseconds: UInt64(Date().timeIntervalSince1970 * 1_000), - monotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, - devices: devices - ) + } + + private func appendEvent( + deviceID: String, + kind: DoryDeviceTelemetryEventKind, + occurrences: UInt64, + monotonicNanoseconds: UInt64 + ) { + guard eventSequence < UInt64.max else { return } + eventSequence += 1 + eventHistory.append(DoryDeviceTelemetryEvent( + sequence: eventSequence, + monotonicNanoseconds: monotonicNanoseconds, + deviceID: deviceID, + kind: kind, + occurrences: occurrences + )) + if eventHistory.count > Self.maximumEventHistory { + eventHistory.removeFirst(eventHistory.count - Self.maximumEventHistory) + } + } + + private static func monotonicDelta(current: UInt64, previous: UInt64) -> UInt64 { + current >= previous ? current - previous : 0 } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift new file mode 100644 index 00000000..59ef9280 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -0,0 +1,64 @@ +import DoryHV +import Foundation +import Testing +@testable import dory_hv + +@Suite struct DesktopDeviceTelemetryTests { + private final class SilentDevice: VirtioDeviceBackend { + let deviceID: UInt32 = 0x7fff + let deviceFeatures: UInt64 = 0 + let queueCount = 1 + let configSpace = [UInt8]() + + func handleKick(queue: Int, transport: VirtioMMIOTransport) {} + } + + @Test func derivesBoundedResetAndSustainedQueueStallEvents() throws { + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let backend = SilentDevice() + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let operationID = UUID() + let registry = RawDeviceTelemetryRegistry(machineID: "raw-dev", operationID: operationID) + registry.register(slot: 7, backend: backend, transport: transport) + + let baseline = registry.snapshot() + #expect(baseline.isValid) + #expect(baseline.events.isEmpty) + #expect(baseline.devices.first?.health == .healthy) + + transport.write(offset: 0x050, value: 0, width: 4) + _ = registry.snapshot() + _ = registry.snapshot() + let stalled = registry.snapshot() + #expect(stalled.isValid) + #expect(stalled.devices.first?.health == .degraded) + #expect(stalled.events.map(\.kind) == [.queueStall]) + #expect(stalled.events.first?.deviceID == "virtio-platform-7") + + transport.notifyUsed() + let recovered = registry.snapshot() + #expect(recovered.devices.first?.health == .healthy) + #expect(recovered.events.map(\.kind) == [.queueStall]) + + transport.write(offset: 0x070, value: 0, width: 4) + let reset = registry.snapshot() + #expect(reset.isValid) + #expect(reset.devices.first?.health == .degraded) + #expect(reset.events.map(\.kind) == [.queueStall, .reset]) + #expect(reset.events.last?.occurrences == 1) + + var bounded = reset + for _ in 0..<300 { + transport.write(offset: 0x070, value: 0, width: 4) + bounded = registry.snapshot() + } + #expect(bounded.isValid) + #expect(bounded.events.count == 256) + #expect(bounded.events.first?.sequence == 47) + #expect(bounded.events.last?.sequence == 302) + } +} diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index e1c001bc..eebf5914 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -735,8 +735,15 @@ caller-minted canonical UUID across Dory.app, `dorydctl`, signed candidate depen immutable installed-generation records, the desktop-update XPC request, the crash-recovery journal, incident evidence, and the per-workspace flight recorder. Malformed or zero identifiers fail before mutation, legacy clients receive an explicit daemon-minted compatibility identity, stale UI progress -is ignored, and the live release gate verifies the returned identity. Device-event propagation and -device-level telemetry remain required before this section is complete. +is ignored, and the live release gate verifies the returned identity. Device telemetry now uses a +versioned, path-free helper snapshot bound to the exact workspace, start operation, backend, and +live launch. The daemon autonomously samples running and paused machines, persists new classified +events in the workspace flight recorder, and exposes the same bounded evidence through XPC, the app +client, and `dorydctl`. Raw-HV derives reset and sustained queue-stall events from monotonic VirtIO +transport counters and retains a bounded event history; Virtualization.framework counters that do +not have a stable public authority remain explicitly unavailable rather than being reported as zero. +Backend-specific producers for GPU loss/fences, display/audio drops, storage latency, network +reconnects, and share invalidation remain release gates for those individual capability claims. ## Qualification and release gates diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDeviceTelemetryMonitor.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDeviceTelemetryMonitor.swift new file mode 100644 index 00000000..de848916 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDeviceTelemetryMonitor.swift @@ -0,0 +1,127 @@ +import Foundation + +public protocol DoryMachineDeviceTelemetrySampling: Sendable { + func activeMachineIDsForDeviceTelemetry() -> [String] + func sampleDeviceTelemetry(machineID: String) throws +} + +extension MachineManager: DoryMachineDeviceTelemetrySampling { + public func activeMachineIDsForDeviceTelemetry() -> [String] { + list() + .filter { [.running, .paused].contains($0.state) } + .map(\.id) + .sorted() + } + + public func sampleDeviceTelemetry(machineID: String) throws { + _ = try deviceTelemetry(id: machineID) + } +} + +public struct DoryMachineDeviceTelemetrySamplingResult: Sendable, Equatable { + public var attemptedMachineIDs: [String] + public var sampledMachineIDs: [String] + public var failedMachineIDs: [String] + + public init( + attemptedMachineIDs: [String], + sampledMachineIDs: [String], + failedMachineIDs: [String] + ) { + self.attemptedMachineIDs = attemptedMachineIDs + self.sampledMachineIDs = sampledMachineIDs + self.failedMachineIDs = failedMachineIDs + } +} + +public enum DoryMachineDeviceTelemetryMonitorEvent: Sendable, Equatable { + case samplingFailed(machineID: String) + case samplingRecovered(machineID: String) +} + +/// Daemon-owned sampler that drains helper event history into each workspace flight recorder. +/// Failures are reported only on state transitions so an unavailable helper cannot flood incidents. +public final class DoryMachineDeviceTelemetryMonitor: @unchecked Sendable { + private let machines: any DoryMachineDeviceTelemetrySampling + private let interval: TimeInterval + private let eventHandler: @Sendable (DoryMachineDeviceTelemetryMonitorEvent) -> Void + private let queue = DispatchQueue(label: "dev.dory.machine-device-telemetry", qos: .utility) + private let timerLock = NSLock() + private let reconcileLock = NSLock() + private var timer: DispatchSourceTimer? + private var failedMachineIDs = Set() + + public init( + machines: any DoryMachineDeviceTelemetrySampling, + interval: TimeInterval = 5, + eventHandler: @escaping @Sendable (DoryMachineDeviceTelemetryMonitorEvent) -> Void = { _ in } + ) { + self.machines = machines + self.interval = max(0.25, interval) + self.eventHandler = eventHandler + } + + @discardableResult + public func reconcileNow() -> DoryMachineDeviceTelemetrySamplingResult { + reconcileLock.lock() + defer { reconcileLock.unlock() } + + let attempted = Array(Set(machines.activeMachineIDsForDeviceTelemetry())).sorted() + let attemptedSet = Set(attempted) + var sampled = [String]() + var failed = [String]() + for machineID in attempted { + do { + try machines.sampleDeviceTelemetry(machineID: machineID) + sampled.append(machineID) + } catch { + failed.append(machineID) + } + } + + let nextFailed = Set(failed) + for machineID in nextFailed.subtracting(failedMachineIDs).sorted() { + eventHandler(.samplingFailed(machineID: machineID)) + } + for machineID in failedMachineIDs.subtracting(nextFailed) + .intersection(attemptedSet) + .sorted() { + eventHandler(.samplingRecovered(machineID: machineID)) + } + failedMachineIDs = nextFailed + return DoryMachineDeviceTelemetrySamplingResult( + attemptedMachineIDs: attempted, + sampledMachineIDs: sampled, + failedMachineIDs: failed + ) + } + + public func start() { + timerLock.lock() + guard timer == nil else { + timerLock.unlock() + return + } + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(250)) + source.setEventHandler { [weak self] in + _ = self?.reconcileNow() + } + timer = source + timerLock.unlock() + source.resume() + } + + public func stop() { + timerLock.lock() + let source = timer + timer = nil + timerLock.unlock() + source?.setEventHandler {} + source?.cancel() + } + + deinit { + stop() + } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 99be5067..f7c25c6b 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -6746,6 +6746,7 @@ public final class MachineManager: @unchecked Sendable { ) } entry.lastDeviceTelemetrySampleSequence = snapshot.sampleSequence + var flightRecorderPersistenceFailed = false for event in snapshot.events where event.sequence > entry.lastDeviceTelemetryEventSequence { do { @@ -6770,10 +6771,17 @@ public final class MachineManager: @unchecked Sendable { entry.lastDeviceTelemetryEventSequence = event.sequence } catch { entry.flightRecorderAvailable = false + flightRecorderPersistenceFailed = true break } } machines[id] = entry + if flightRecorderPersistenceFailed { + throw MachineManagerError.deviceTelemetryRejected( + id, + "flight recorder could not persist device event evidence" + ) + } return snapshot } diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index c1dfaf8c..998f141b 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -129,6 +129,22 @@ let kubernetesRouteProvider = networkingController.map { _ in } let incidentPath = env["DORY_INCIDENTS"] ?? "\(dorydEnvironment.home)/.dory/incidents.jsonl" let incidentWriter = IncidentWriter(path: incidentPath) +let machineDeviceTelemetryMonitor = machineManager.map { manager in + DoryMachineDeviceTelemetryMonitor(machines: manager) { event in + switch event { + case let .samplingFailed(machineID): + incidentWriter.record( + type: "machine.device_telemetry_unavailable", + detail: machineID + ) + case let .samplingRecovered(machineID): + incidentWriter.record( + type: "machine.device_telemetry_recovered", + detail: machineID + ) + } + } +} let machineBackupScheduler: MachineBackupScheduler? if let machineManager { do { @@ -286,6 +302,7 @@ private let shutdownCoordinator = DorydShutdownCoordinator( networkingController: networkingController, dockerTier: dockerTier, machineManager: machineManager, + machineDeviceTelemetryMonitor: machineDeviceTelemetryMonitor, machineBackupScheduler: machineBackupScheduler, sandboxTTLReconciler: sandboxTTLReconciler, remoteManager: remoteManager, @@ -337,6 +354,7 @@ shutdownCoordinator.exitIfRequested() listener.resume() FileHandle.standardError.write(Data("doryd: serving XPC \(machServiceName)\n".utf8)) sandboxTTLReconciler?.start() +machineDeviceTelemetryMonitor?.start() machineBackupScheduler?.start() corporateConnectivity.start() @@ -386,6 +404,7 @@ private final class DorydShutdownCoordinator { private let networkingController: NetworkingController? private let dockerTier: DockerTier? private let machineManager: MachineManager? + private let machineDeviceTelemetryMonitor: DoryMachineDeviceTelemetryMonitor? private let machineBackupScheduler: MachineBackupScheduler? private let sandboxTTLReconciler: SandboxTTLReconciler? private let remoteManager: RemoteMachineManager @@ -411,6 +430,7 @@ private final class DorydShutdownCoordinator { networkingController: NetworkingController?, dockerTier: DockerTier?, machineManager: MachineManager?, + machineDeviceTelemetryMonitor: DoryMachineDeviceTelemetryMonitor?, machineBackupScheduler: MachineBackupScheduler?, sandboxTTLReconciler: SandboxTTLReconciler?, remoteManager: RemoteMachineManager, @@ -427,6 +447,7 @@ private final class DorydShutdownCoordinator { self.networkingController = networkingController self.dockerTier = dockerTier self.machineManager = machineManager + self.machineDeviceTelemetryMonitor = machineDeviceTelemetryMonitor self.machineBackupScheduler = machineBackupScheduler self.sandboxTTLReconciler = sandboxTTLReconciler self.remoteManager = remoteManager @@ -463,6 +484,7 @@ private final class DorydShutdownCoordinator { networkingController?.stop() remoteManager.disconnectAll() sandboxTTLReconciler?.stop() + machineDeviceTelemetryMonitor?.stop() machineBackupScheduler?.stop() machineManager?.stopAll() FileHandle.standardError.write(Data("doryd: shutdown complete\n".utf8)) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDeviceTelemetryMonitorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDeviceTelemetryMonitorTests.swift new file mode 100644 index 00000000..471a236a --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDeviceTelemetryMonitorTests.swift @@ -0,0 +1,86 @@ +import DorydKit +import Foundation +import Testing + +@Suite struct DoryMachineDeviceTelemetryMonitorTests { + @Test func samplesActiveMachinesAndReportsOnlyFailureTransitions() { + let machines = FakeDeviceTelemetrySampler( + activeMachineIDs: ["paused", "running", "running"], + failedMachineIDs: ["paused"] + ) + let events = LockedMonitorEvents() + let monitor = DoryMachineDeviceTelemetryMonitor(machines: machines) { event in + events.append(event) + } + + let first = monitor.reconcileNow() + #expect(first == DoryMachineDeviceTelemetrySamplingResult( + attemptedMachineIDs: ["paused", "running"], + sampledMachineIDs: ["running"], + failedMachineIDs: ["paused"] + )) + #expect(events.value == [.samplingFailed(machineID: "paused")]) + #expect(machines.sampledMachineIDs == ["paused", "running"]) + + _ = monitor.reconcileNow() + #expect(events.value == [.samplingFailed(machineID: "paused")]) + + machines.failedMachineIDs = [] + let recovered = monitor.reconcileNow() + #expect(recovered.failedMachineIDs.isEmpty) + #expect(events.value == [ + .samplingFailed(machineID: "paused"), + .samplingRecovered(machineID: "paused"), + ]) + } +} + +private final class FakeDeviceTelemetrySampler: + DoryMachineDeviceTelemetrySampling, + @unchecked Sendable { + private let lock = NSLock() + private var storedActiveMachineIDs: [String] + private var storedFailedMachineIDs: Set + private var storedSampledMachineIDs = [String]() + + init(activeMachineIDs: [String], failedMachineIDs: Set) { + storedActiveMachineIDs = activeMachineIDs + storedFailedMachineIDs = failedMachineIDs + } + + var failedMachineIDs: Set { + get { lock.withLock { storedFailedMachineIDs } } + set { lock.withLock { storedFailedMachineIDs = newValue } } + } + + var sampledMachineIDs: [String] { + lock.withLock { storedSampledMachineIDs } + } + + func activeMachineIDsForDeviceTelemetry() -> [String] { + lock.withLock { storedActiveMachineIDs } + } + + func sampleDeviceTelemetry(machineID: String) throws { + let shouldFail = lock.withLock { + storedSampledMachineIDs.append(machineID) + return storedFailedMachineIDs.contains(machineID) + } + if shouldFail { + throw CocoaError(.fileReadUnknown) + } + } +} + +private final class LockedMonitorEvents: @unchecked Sendable { + private let lock = NSLock() + private var events = [DoryMachineDeviceTelemetryMonitorEvent]() + + var value: [DoryMachineDeviceTelemetryMonitorEvent] { + lock.withLock { events } + } + + func append(_ event: DoryMachineDeviceTelemetryMonitorEvent) { + lock.withLock { events.append(event) } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 3a319b54..48c4dbce 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -4717,6 +4717,29 @@ final class MachineManagerTests: XCTestCase { ) ) } + + let recorderPath = base + "/" + DoryMachineFlightRecorderStore.recordFileName + try Data("{".utf8).write(to: URL(fileURLWithPath: recorderPath)) + var unpersistable = snapshot + unpersistable.sampleSequence = 2 + unpersistable.events = [ + DoryDeviceTelemetryEvent( + sequence: 2, + monotonicNanoseconds: 30, + deviceID: device.id, + kind: .reset + ), + ] + telemetry.value = unpersistable + XCTAssertThrowsError(try manager.deviceTelemetry(id: "dev")) { error in + XCTAssertEqual( + error as? MachineManagerError, + .deviceTelemetryRejected( + "dev", + "flight recorder could not persist device event evidence" + ) + ) + } } } From e2ed49b08204815bb1916fc12151da411bd410c6 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:10:04 +0000 Subject: [PATCH 231/338] feat(vm): measure storage flush health --- .../Sources/DoryHV/VirtioBlk.swift | 84 ++++++++++++++++++- .../Sources/dory-hv/DesktopMode.swift | 33 +++++++- .../DesktopDeviceTelemetryTests.swift | 52 +++++++++++- .../Tests/DoryHVTests/DeviceLogicTests.swift | 31 +++++++ docs/virtual-workspace-platform.md | 10 ++- 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift index 78a1fe31..380b671c 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift @@ -1,6 +1,36 @@ import Darwin import Foundation +public struct VirtioBlkStatistics: Equatable, Sendable { + public var flushes: UInt64 + public var maximumFlushLatencyNanoseconds: UInt64 + public var slowFlushes: UInt64 + + public init( + flushes: UInt64, + maximumFlushLatencyNanoseconds: UInt64, + slowFlushes: UInt64 + ) { + self.flushes = flushes + self.maximumFlushLatencyNanoseconds = maximumFlushLatencyNanoseconds + self.slowFlushes = slowFlushes + } +} + +struct VirtioBlkFlushTelemetryConfiguration { + var slowThresholdNanoseconds: UInt64 + var synchronize: (Int32) -> Int32 + var monotonicNanoseconds: () -> UInt64 + + static var production: Self { + Self( + slowThresholdNanoseconds: 250_000_000, + synchronize: { descriptor in Darwin.fsync(descriptor) }, + monotonicNanoseconds: { DispatchTime.now().uptimeNanoseconds } + ) + } +} + /// virtio-blk backed by a raw disk image. Requests use zero-copy pread/pwrite straight into guest /// RAM; disk I/O is drained on dedicated ordered workers so the kicking vCPU is not parked inside /// host file syscalls during metadata-heavy workloads. The device exposes a small multiqueue setup @@ -36,6 +66,11 @@ public final class VirtioBlk: VirtioDeviceBackend { private let requestCondition = NSCondition() private var inFlightTransfers = 0 private var flushActive = false + private let flushTelemetry: VirtioBlkFlushTelemetryConfiguration + private let statisticsLock = NSLock() + private var flushCount: UInt64 = 0 + private var maximumFlushLatencyNanoseconds: UInt64 = 0 + private var slowFlushCount: UInt64 = 0 private enum Feature { static let readOnly: UInt64 = 1 << 5 // VIRTIO_BLK_F_RO @@ -70,13 +105,33 @@ public final class VirtioBlk: VirtioDeviceBackend { case unsupported = 2 } - public init( + public convenience init( path: String, identity: String, readOnly: Bool = false, asyncIO: Bool? = nil, queueCount requestedQueueCount: Int? = nil, discard: Bool? = nil + ) throws { + try self.init( + path: path, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + init( + path: String, + identity: String, + readOnly: Bool = false, + asyncIO: Bool? = nil, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration ) throws { let descriptor = open(path, readOnly ? O_RDONLY : O_RDWR) guard descriptor >= 0 else { @@ -92,6 +147,7 @@ public final class VirtioBlk: VirtioDeviceBackend { self.identity = identity self.readOnly = readOnly self.asyncIO = asyncIO ?? Self.asyncIOEnabledFromEnvironment() + self.flushTelemetry = flushTelemetry // Discard/write-zeroes only make sense on a writable image; keep them off for read-only shares. self.discardEnabled = !readOnly && (discard ?? Self.discardEnabledFromEnvironment()) // F_PUNCHHOLE requires fs-block alignment; capture the backing filesystem's block size so @@ -246,7 +302,7 @@ public final class VirtioBlk: VirtioDeviceBackend { return status } - private func flush() -> RequestStatus { + func flush() -> RequestStatus { requestCondition.lock() while flushActive { requestCondition.wait() @@ -257,7 +313,19 @@ public final class VirtioBlk: VirtioDeviceBackend { } requestCondition.unlock() - let status: RequestStatus = fsync(fileDescriptor) == 0 ? .ok : .ioError + let startedAt = flushTelemetry.monotonicNanoseconds() + let status: RequestStatus = flushTelemetry.synchronize(fileDescriptor) == 0 ? .ok : .ioError + let finishedAt = flushTelemetry.monotonicNanoseconds() + let duration = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsLock.withLock { + if flushCount < UInt64.max { + flushCount += 1 + } + maximumFlushLatencyNanoseconds = max(maximumFlushLatencyNanoseconds, duration) + if duration >= flushTelemetry.slowThresholdNanoseconds, slowFlushCount < UInt64.max { + slowFlushCount += 1 + } + } requestCondition.lock() flushActive = false @@ -266,6 +334,16 @@ public final class VirtioBlk: VirtioDeviceBackend { return status } + public var statistics: VirtioBlkStatistics { + statisticsLock.withLock { + VirtioBlkStatistics( + flushes: flushCount, + maximumFlushLatencyNanoseconds: maximumFlushLatencyNanoseconds, + slowFlushes: slowFlushCount + ) + } + } + private func transfer( _ segments: ArraySlice, from sector: UInt64, diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 6d130742..df74347a 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -12,9 +12,11 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var id: String var kind: DoryDeviceTelemetryKind var transport: VirtioMMIOTransport + var storage: VirtioBlk? var network: VirtioNet? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? + var previousStorageStatistics: VirtioBlkStatistics? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -41,10 +43,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { switch backend { case is VirtioBlk: kind = .storage - unavailable = [ - (.storageFlushes, .count), - (.maximumStorageFlushLatencyNanoseconds, .nanoseconds), - ] + unavailable = [] case is VirtioGPU: kind = .graphics unavailable = [ @@ -94,9 +93,11 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { id: "virtio-\(kind.rawValue)-\(slot)", kind: kind, transport: transport, + storage: backend as? VirtioBlk, network: backend as? VirtioNet, unavailableMetrics: unavailable, previousTransportStatistics: nil, + previousStorageStatistics: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -180,6 +181,30 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { .measured(.receiveTruncations, value: network.receiveTruncations), ]) } + if let storage = entries[index].storage?.statistics { + metrics.append(contentsOf: [ + .measured(.storageFlushes, value: storage.flushes), + .measured( + .maximumStorageFlushLatencyNanoseconds, + value: storage.maximumFlushLatencyNanoseconds + ), + ]) + let previousSlowFlushes = entries[index].previousStorageStatistics?.slowFlushes ?? 0 + let newSlowFlushes = Self.monotonicDelta( + current: storage.slowFlushes, + previous: previousSlowFlushes + ) + if newSlowFlushes > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .storageFlushSlow, + occurrences: newSlowFlushes, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + entries[index].previousStorageStatistics = storage + } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) }) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index 59ef9280..e0331cb6 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -1,6 +1,6 @@ -import DoryHV import Foundation import Testing +@testable import DoryHV @testable import dory_hv @Suite struct DesktopDeviceTelemetryTests { @@ -61,4 +61,54 @@ import Testing #expect(bounded.events.first?.sequence == 47) #expect(bounded.events.last?.sequence == 302) } + + @Test func publishesMeasuredStorageFlushesAndSlowFlushEvents() throws { + let path = NSTemporaryDirectory() + "/dory-storage-telemetry-\(UUID().uuidString).img" + try Data(repeating: 0, count: 4096).write(to: URL(fileURLWithPath: path)) + defer { try? FileManager.default.removeItem(atPath: path) } + + final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var values: [UInt64] = [100, 500] + + func next() -> UInt64 { + lock.withLock { values.removeFirst() } + } + } + let clock = Clock() + let backend = try VirtioBlk( + path: path, + identity: "storage", + queueCount: 1, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration( + slowThresholdNanoseconds: 400, + synchronize: { _ in 0 }, + monotonicNanoseconds: { clock.next() } + ) + ) + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let registry = RawDeviceTelemetryRegistry(machineID: "raw-storage", operationID: UUID()) + registry.register(slot: 2, backend: backend, transport: transport) + + #expect(backend.flush() == .ok) + let snapshot = registry.snapshot() + let storage = try #require(snapshot.devices.first) + #expect(storage.id == "virtio-storage-2") + #expect(storage.health == .degraded) + #expect(storage.metrics.first { $0.kind == .storageFlushes }?.value == 1) + #expect(storage.metrics.first { + $0.kind == .maximumStorageFlushLatencyNanoseconds + }?.value == 400) + #expect(snapshot.events.map(\.kind) == [.storageFlushSlow]) + #expect(snapshot.events.first?.occurrences == 1) + + let stable = registry.snapshot() + #expect(stable.devices.first?.health == .healthy) + #expect(stable.events.map(\.kind) == [.storageFlushSlow]) + } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 620311b7..7d05a5fa 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -148,6 +148,37 @@ import Testing #expect(rw.deviceFeatures & (1 << 5) == 0) // writable image: no RO bit } + @Test func recordsFlushCountMaximumLatencyAndSlowThreshold() throws { + let path = try makeDisk() + defer { try? FileManager.default.removeItem(atPath: path) } + final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var values: [UInt64] = [10, 310] + + func next() -> UInt64 { + lock.withLock { values.removeFirst() } + } + } + let clock = Clock() + let block = try VirtioBlk( + path: path, + identity: "test", + queueCount: 1, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration( + slowThresholdNanoseconds: 300, + synchronize: { _ in 0 }, + monotonicNanoseconds: { clock.next() } + ) + ) + + #expect(block.flush() == .ok) + #expect(block.statistics == VirtioBlkStatistics( + flushes: 1, + maximumFlushLatencyNanoseconds: 300, + slowFlushes: 1 + )) + } + @Test func discardCanBeDisabled() throws { let path = try makeDisk() defer { try? FileManager.default.removeItem(atPath: path) } diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index eebf5914..08dd467c 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -740,10 +740,12 @@ versioned, path-free helper snapshot bound to the exact workspace, start operati live launch. The daemon autonomously samples running and paused machines, persists new classified events in the workspace flight recorder, and exposes the same bounded evidence through XPC, the app client, and `dorydctl`. Raw-HV derives reset and sustained queue-stall events from monotonic VirtIO -transport counters and retains a bounded event history; Virtualization.framework counters that do -not have a stable public authority remain explicitly unavailable rather than being reported as zero. -Backend-specific producers for GPU loss/fences, display/audio drops, storage latency, network -reconnects, and share invalidation remain release gates for those individual capability claims. +transport counters and retains a bounded event history. Its block backend also counts serialized +guest flushes, measures the maximum host `fsync` latency, and emits a classified slow-flush event +for every flush taking at least 250 ms. Virtualization.framework counters that do not have a stable +public authority remain explicitly unavailable rather than being reported as zero. Backend-specific +producers for GPU loss/fences, display/audio drops, network reconnects, and share invalidation remain +release gates for those individual capability claims. ## Qualification and release gates From 89db29277017126f763683797fd83f0d51bec700 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:12:38 +0000 Subject: [PATCH 232/338] feat(vm): publish audio drop health --- .../Sources/dory-hv/DesktopAudioBackend.swift | 2 +- .../Sources/dory-hv/DesktopMode.swift | 45 +++++++++++++++++-- .../DesktopDeviceTelemetryTests.swift | 41 +++++++++++++++++ docs/virtual-workspace-platform.md | 9 ++-- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift index 5e08446c..57439d43 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -47,7 +47,7 @@ struct DoryMacAudioQueueCapacity { } } -struct DoryMacAudioRuntimeMetrics: Equatable { +struct DoryMacAudioRuntimeMetrics: Equatable, Sendable { var queuedPlaybackBytes: Int var pendingCaptureBytes: Int var bufferedCaptureBytes: Int diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index df74347a..07296b96 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -14,9 +14,11 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var transport: VirtioMMIOTransport var storage: VirtioBlk? var network: VirtioNet? + var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? var previousStorageStatistics: VirtioBlkStatistics? + var previousAudioDrops: UInt64? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -37,7 +39,12 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { self.operationID = DoryOperationIdentity.canonical(operationID) } - func register(slot: Int, backend: any VirtioDeviceBackend, transport: VirtioMMIOTransport) { + func register( + slot: Int, + backend: any VirtioDeviceBackend, + transport: VirtioMMIOTransport, + audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil + ) { let kind: DoryDeviceTelemetryKind let unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] switch backend { @@ -54,7 +61,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { ] case is VirtioSound: kind = .audio - unavailable = [(.audioDrops, .count)] + unavailable = audioMetrics == nil ? [(.audioDrops, .count)] : [] case is VirtioFS: kind = .sharedDirectory unavailable = [ @@ -95,9 +102,11 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { transport: transport, storage: backend as? VirtioBlk, network: backend as? VirtioNet, + audioMetrics: audioMetrics, unavailableMetrics: unavailable, previousTransportStatistics: nil, previousStorageStatistics: nil, + previousAudioDrops: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -205,6 +214,25 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } entries[index].previousStorageStatistics = storage } + if let audio = entries[index].audioMetrics?() { + let (sum, overflow) = audio.droppedPlaybackPeriods.addingReportingOverflow( + audio.droppedCapturePeriods + ) + let drops = overflow ? UInt64.max : sum + metrics.append(.measured(.audioDrops, value: drops)) + let previousDrops = entries[index].previousAudioDrops ?? 0 + let newDrops = Self.monotonicDelta(current: drops, previous: previousDrops) + if newDrops > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .audioDrop, + occurrences: newDrops, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + entries[index].previousAudioDrops = drops + } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) }) @@ -525,7 +553,18 @@ enum DesktopMode { } for (slot, backend) in backends.enumerated() { let transport = Self.attachBackend(backend, to: machine, slot: slot) - deviceTelemetry.register(slot: slot, backend: backend, transport: transport) + let audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? + if backend === sound { + audioMetrics = { [weak audio] in audio?.runtimeMetrics } + } else { + audioMetrics = nil + } + deviceTelemetry.register( + slot: slot, + backend: backend, + transport: transport, + audioMetrics: audioMetrics + ) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in guard let gpu, let transport else { return } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index e0331cb6..b4cce107 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -111,4 +111,45 @@ import Testing #expect(stable.devices.first?.health == .healthy) #expect(stable.events.map(\.kind) == [.storageFlushSlow]) } + + @Test func publishesMeasuredAudioDropsAndClassifiedEvents() throws { + let audio = DoryMacAudioBackend(log: { _ in }) + let parameters = VirtioSoundPCMParameters( + bufferBytes: 8, + periodBytes: 4, + sampleRate: 48_000, + channels: 2 + ) + #expect(audio.configure(streamID: 0, direction: .output, parameters: parameters)) + #expect(audio.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + #expect(audio.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + #expect(!audio.enqueuePlayback(Data(count: 4), parameters: parameters) { _, _ in }) + + let backend = VirtioSound(host: audio) + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let registry = RawDeviceTelemetryRegistry(machineID: "raw-audio", operationID: UUID()) + registry.register( + slot: 4, + backend: backend, + transport: transport, + audioMetrics: { [weak audio] in audio?.runtimeMetrics } + ) + + let snapshot = registry.snapshot() + let device = try #require(snapshot.devices.first) + #expect(device.id == "virtio-audio-4") + #expect(device.health == .degraded) + #expect(device.metrics.first { $0.kind == .audioDrops }?.value == 1) + #expect(snapshot.events.map(\.kind) == [.audioDrop]) + #expect(snapshot.events.first?.occurrences == 1) + + let stable = registry.snapshot() + #expect(stable.devices.first?.health == .healthy) + #expect(stable.events.map(\.kind) == [.audioDrop]) + } } diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 08dd467c..247494b4 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -742,10 +742,11 @@ events in the workspace flight recorder, and exposes the same bounded evidence t client, and `dorydctl`. Raw-HV derives reset and sustained queue-stall events from monotonic VirtIO transport counters and retains a bounded event history. Its block backend also counts serialized guest flushes, measures the maximum host `fsync` latency, and emits a classified slow-flush event -for every flush taking at least 250 ms. Virtualization.framework counters that do not have a stable -public authority remain explicitly unavailable rather than being reported as zero. Backend-specific -producers for GPU loss/fences, display/audio drops, network reconnects, and share invalidation remain -release gates for those individual capability claims. +for every flush taking at least 250 ms. Its Core Audio bridge reports exact playback and capture +period drops and emits classified audio-drop events. Virtualization.framework counters that do not +have a stable public authority remain explicitly unavailable rather than being reported as zero. +Backend-specific producers for GPU loss/fences, display drops, network reconnects, and share +invalidation remain release gates for those individual capability claims. ## Qualification and release gates From 7706a8044749fb08b75c84d1dde1a1785754646a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:16:09 +0000 Subject: [PATCH 233/338] feat(vm): report share invalidation health --- .../Sources/DoryHV/VirtioFS.swift | 70 ++++++++++++++++++- .../Sources/dory-hv/DesktopMode.swift | 38 ++++++++-- .../DesktopDeviceTelemetryTests.swift | 43 ++++++++++++ .../Tests/DoryHVTests/VirtioFSTests.swift | 15 ++++ docs/virtual-workspace-platform.md | 10 +-- 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift index 032a47cc..09f59a0d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift @@ -35,6 +35,22 @@ public enum VirtioFSCacheActivationResult: Equatable, Sendable { case ineligible(VirtioFSCacheActivationEligibility) } +public struct VirtioFSStatistics: Equatable, Sendable { + public var invalidations: UInt64 + public var invalidationFailures: UInt64 + public var invalidationFailureLatched: Bool + + public init( + invalidations: UInt64, + invalidationFailures: UInt64, + invalidationFailureLatched: Bool + ) { + self.invalidations = invalidations + self.invalidationFailures = invalidationFailures + self.invalidationFailureLatched = invalidationFailureLatched + } +} + public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { public static let tagByteCount = 36 public static let notificationFeature: UInt64 = 1 << 0 @@ -141,6 +157,10 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid private var _responseFenceTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? private let requestGateDrainTestHookLock = NSLock() private var _requestGateDrainTestHook: (@Sendable (RequestGateDrainTestEvent) -> Void)? + private let telemetryLock = NSLock() + private var invalidationCount: UInt64 = 0 + private var invalidationFailureCount: UInt64 = 0 + private var hasLatchedInvalidationFailure = false public convenience init( tag: String, @@ -359,7 +379,20 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func submitInvalidations( _ invalidations: [VirtioFSInvalidation] ) async throws -> VirtioFSNotificationBarrier { - try await submitInvalidations(invalidations, retainRequestGateForCaller: false) + guard !invalidations.isEmpty else { + return VirtioFSNotificationBarrier(notificationCount: 0) + } + do { + return try await submitInvalidations( + invalidations, + retainRequestGateForCaller: false + ) + } catch { + recordInvalidationFailure(latched: requestGateLock.withLock { + requestGateFailureLatched + }) + throw error + } } private func submitInvalidations( @@ -453,7 +486,9 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid ) } apply(effects, transport: transport) - return try submission.get() + let barrier = try submission.get() + recordInvalidations(frames.count) + return barrier } public func submitInvalidation( @@ -521,10 +556,41 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid releaseCallerRetainedRequestGate(barrier, succeeded: false) } latchRequestGateFailure(barrier: nil) + recordInvalidationFailure(latched: true) throw error } } + public var statistics: VirtioFSStatistics { + telemetryLock.withLock { + VirtioFSStatistics( + invalidations: invalidationCount, + invalidationFailures: invalidationFailureCount, + invalidationFailureLatched: hasLatchedInvalidationFailure + ) + } + } + + private func recordInvalidations(_ count: Int) { + guard count > 0 else { return } + let increment = UInt64(count) + telemetryLock.withLock { + invalidationCount = Self.saturatingAdd(invalidationCount, increment) + } + } + + private func recordInvalidationFailure(latched: Bool) { + telemetryLock.withLock { + invalidationFailureCount = Self.saturatingAdd(invalidationFailureCount, 1) + hasLatchedInvalidationFailure = hasLatchedInvalidationFailure || latched + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { guard queue >= 0, queue < queueCount else { return } // Queue notification MMIO is intentionally delivered without the transport lock for this diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 07296b96..8ab2e658 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -14,11 +14,13 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var transport: VirtioMMIOTransport var storage: VirtioBlk? var network: VirtioNet? + var sharedDirectory: VirtioFS? var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? var previousStorageStatistics: VirtioBlkStatistics? var previousAudioDrops: UInt64? + var previousShareInvalidationFailures: UInt64? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -64,10 +66,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { unavailable = audioMetrics == nil ? [(.audioDrops, .count)] : [] case is VirtioFS: kind = .sharedDirectory - unavailable = [ - (.shareInvalidations, .count), - (.shareInvalidationFailures, .count), - ] + unavailable = [] case is VirtioNet, is VirtioDisconnectedNet: kind = .network unavailable = backend is VirtioNet ? [] : [ @@ -102,11 +101,13 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { transport: transport, storage: backend as? VirtioBlk, network: backend as? VirtioNet, + sharedDirectory: backend as? VirtioFS, audioMetrics: audioMetrics, unavailableMetrics: unavailable, previousTransportStatistics: nil, previousStorageStatistics: nil, previousAudioDrops: nil, + previousShareInvalidationFailures: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -233,6 +234,35 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } entries[index].previousAudioDrops = drops } + if let share = entries[index].sharedDirectory?.statistics { + metrics.append(contentsOf: [ + .measured(.shareInvalidations, value: share.invalidations), + .measured( + .shareInvalidationFailures, + value: share.invalidationFailures + ), + ]) + let previousFailures = + entries[index].previousShareInvalidationFailures ?? 0 + let newFailures = Self.monotonicDelta( + current: share.invalidationFailures, + previous: previousFailures + ) + if newFailures > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .shareInvalidationFailure, + occurrences: newFailures, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + if share.invalidationFailureLatched { + health = .failed + } + entries[index].previousShareInvalidationFailures = + share.invalidationFailures + } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) }) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index b4cce107..fee5783a 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -152,4 +152,47 @@ import Testing #expect(stable.devices.first?.health == .healthy) #expect(stable.events.map(\.kind) == [.audioDrop]) } + + @Test func publishesMeasuredShareInvalidationsAndPermanentFailureHealth() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-share-telemetry-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let backend = try VirtioFS( + tag: "telemetry", + hostFS: HostFS(rootPath: root.path), + requestQueueCount: 1 + ) + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let registry = RawDeviceTelemetryRegistry(machineID: "raw-share", operationID: UUID()) + registry.register(slot: 6, backend: backend, transport: transport) + + do { + try await backend.invalidate([.inode(nodeID: HostFS.rootNodeID)]) + Issue.record("unnegotiated share unexpectedly completed an invalidation") + } catch let error as VirtioFSNotificationError { + #expect(error == .featureNotNegotiated) + } + + let snapshot = registry.snapshot() + let device = try #require(snapshot.devices.first) + #expect(device.id == "virtio-shared-directory-6") + #expect(device.health == .failed) + #expect(device.metrics.first { $0.kind == .shareInvalidations }?.value == 0) + #expect(device.metrics.first { + $0.kind == .shareInvalidationFailures + }?.value == 1) + #expect(snapshot.events.map(\.kind) == [.shareInvalidationFailure]) + #expect(snapshot.events.first?.occurrences == 1) + + let persistent = registry.snapshot() + #expect(persistent.devices.first?.health == .failed) + #expect(persistent.events.map(\.kind) == [.shareInvalidationFailure]) + } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift index c9ec24a8..0e75215c 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift @@ -101,6 +101,11 @@ struct VirtioFSTests { } catch let error as VirtioFSNotificationError { #expect(error == .featureNotNegotiated) } + #expect(harness.fs.statistics == VirtioFSStatistics( + invalidations: 0, + invalidationFailures: 1, + invalidationFailureLatched: false + )) } @Test func highLevelInvalidationFailureLatchesRequestPublicationUntilBackendReplacement() async throws { @@ -114,6 +119,11 @@ struct VirtioFSTests { } catch let error as VirtioFSNotificationError { #expect(error == .featureNotNegotiated) } + #expect(harness.fs.statistics == VirtioFSStatistics( + invalidations: 0, + invalidationFailures: 1, + invalidationFailureLatched: true + )) #expect(harness.fs.requestPublicationGateClosed) let blocked = try harness.enqueueFuseRequest( @@ -486,6 +496,11 @@ struct VirtioFSTests { #expect(barrier.isCompleted) try await barrier.wait() + #expect(harness.fs.statistics == VirtioFSStatistics( + invalidations: 1, + invalidationFailures: 0, + invalidationFailureLatched: false + )) } @Test func barriersPreserveSubmissionOrderAcrossOutOfOrderBufferReturns() async throws { diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 247494b4..db2f27b2 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -743,10 +743,12 @@ client, and `dorydctl`. Raw-HV derives reset and sustained queue-stall events fr transport counters and retains a bounded event history. Its block backend also counts serialized guest flushes, measures the maximum host `fsync` latency, and emits a classified slow-flush event for every flush taking at least 250 ms. Its Core Audio bridge reports exact playback and capture -period drops and emits classified audio-drop events. Virtualization.framework counters that do not -have a stable public authority remain explicitly unavailable rather than being reported as zero. -Backend-specific producers for GPU loss/fences, display drops, network reconnects, and share -invalidation remain release gates for those individual capability claims. +period drops and emits classified audio-drop events. VirtioFS counts admitted reverse invalidations +and failed submission or acknowledgement transactions; because an uncertain invalidation latches +request publication closed for that backend, its telemetry remains failed until backend replacement. +Virtualization.framework counters that do not have a stable public authority remain explicitly +unavailable rather than being reported as zero. Backend-specific producers for GPU loss/fences, +display drops, and network reconnects remain release gates for those individual capability claims. ## Qualification and release gates From ea2a537711aaf8e905896242369fe79248ea83fa Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:19:32 +0000 Subject: [PATCH 234/338] feat(vm): measure display presentation health --- .../Sources/dory-hv/DesktopMetalDisplay.swift | 60 ++++++++++++++++--- .../Sources/dory-hv/DesktopMode.swift | 45 +++++++++++--- .../DesktopDeviceTelemetryTests.swift | 47 +++++++++++++++ docs/virtual-workspace-platform.md | 6 +- 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index fdc22b63..3e50c2aa 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -19,10 +19,17 @@ enum DesktopMetalDisplayError: Error, CustomStringConvertible { /// Coalesces producer-thread flushes to the newest complete frame and performs one main-thread /// upload. This keeps a busy compositor from building an unbounded queue of stale 16 MiB frames. +struct DesktopFrameMailboxMetrics: Equatable, Sendable { + var presentedFrames: UInt64 + var droppedFrames: UInt64 +} + final class DesktopFrameMailbox: @unchecked Sendable { private let lock = NSLock() private var pending = [VirtioGPUScanoutFrame]() private var deliveryScheduled = false + private var presentedFrameCount: UInt64 = 0 + private var droppedFrameCount: UInt64 = 0 nonisolated(unsafe) weak var view: DesktopMetalView? func submit(_ frame: VirtioGPUScanoutFrame) { @@ -32,7 +39,14 @@ final class DesktopFrameMailbox: @unchecked Sendable { pending = [frame] } else { pending.append(frame) - if pending.count > 256 { pending.removeFirst(pending.count - 256) } + if pending.count > 256 { + let overflow = pending.count - 256 + pending.removeFirst(overflow) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + UInt64(overflow) + ) + } } let shouldSchedule = !deliveryScheduled deliveryScheduled = true @@ -51,7 +65,12 @@ final class DesktopFrameMailbox: @unchecked Sendable { /// next partial update appear as a black or torn frame. func release(resourceID: UInt32) { lock.lock() + let previousCount = pending.count pending.removeAll { $0.resourceID == resourceID } + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + UInt64(previousCount - pending.count) + ) lock.unlock() DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { @@ -61,13 +80,38 @@ final class DesktopFrameMailbox: @unchecked Sendable { } @MainActor - private func deliver() { + func deliver() { lock.lock() let frames = pending pending.removeAll(keepingCapacity: true) deliveryScheduled = false lock.unlock() - view?.present(frames) + guard !frames.isEmpty else { return } + let presented = view?.present(frames) ?? 0 + lock.withLock { + presentedFrameCount = Self.saturatingAdd( + presentedFrameCount, + UInt64(presented) + ) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + UInt64(frames.count - presented) + ) + } + } + + var metrics: DesktopFrameMailboxMetrics { + lock.withLock { + DesktopFrameMailboxMetrics( + presentedFrames: presentedFrameCount, + droppedFrames: droppedFrameCount + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum } } @@ -156,12 +200,14 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.updateTrackingAreas() } - func present(_ frames: [VirtioGPUScanoutFrame]) { - var updated = false + @discardableResult + func present(_ frames: [VirtioGPUScanoutFrame]) -> Int { + var updated = 0 for frame in frames { - updated = presentUpdate(frame) || updated + if presentUpdate(frame) { updated += 1 } } - if updated { needsDisplay = true } + if updated > 0 { needsDisplay = true } + return updated } func release(resourceID: UInt32) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 8ab2e658..7112d366 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -16,11 +16,13 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var network: VirtioNet? var sharedDirectory: VirtioFS? var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? + var displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? var previousStorageStatistics: VirtioBlkStatistics? var previousAudioDrops: UInt64? var previousShareInvalidationFailures: UInt64? + var previousDisplayDrops: UInt64? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -45,22 +47,24 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { slot: Int, backend: any VirtioDeviceBackend, transport: VirtioMMIOTransport, - audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil + audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil, + displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? = nil ) { let kind: DoryDeviceTelemetryKind - let unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + var unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] switch backend { case is VirtioBlk: kind = .storage unavailable = [] case is VirtioGPU: kind = .graphics - unavailable = [ - (.displayFrames, .count), - (.displayDrops, .count), - (.graphicsFences, .count), - (.graphicsDeviceLosses, .count), - ] + unavailable = [(.graphicsFences, .count), (.graphicsDeviceLosses, .count)] + if displayMetrics == nil { + unavailable.append(contentsOf: [ + (.displayFrames, .count), + (.displayDrops, .count), + ]) + } case is VirtioSound: kind = .audio unavailable = audioMetrics == nil ? [(.audioDrops, .count)] : [] @@ -103,11 +107,13 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { network: backend as? VirtioNet, sharedDirectory: backend as? VirtioFS, audioMetrics: audioMetrics, + displayMetrics: displayMetrics, unavailableMetrics: unavailable, previousTransportStatistics: nil, previousStorageStatistics: nil, previousAudioDrops: nil, previousShareInvalidationFailures: nil, + previousDisplayDrops: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -263,6 +269,20 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { entries[index].previousShareInvalidationFailures = share.invalidationFailures } + if let display = entries[index].displayMetrics?() { + metrics.append(contentsOf: [ + .measured(.displayFrames, value: display.presentedFrames), + .measured(.displayDrops, value: display.droppedFrames), + ]) + let previousDrops = entries[index].previousDisplayDrops ?? 0 + if Self.monotonicDelta( + current: display.droppedFrames, + previous: previousDrops + ) > 0 { + health = .degraded + } + entries[index].previousDisplayDrops = display.droppedFrames + } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) }) @@ -589,11 +609,18 @@ enum DesktopMode { } else { audioMetrics = nil } + let displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? + if backend === gpu { + displayMetrics = { [weak mailbox] in mailbox?.metrics } + } else { + displayMetrics = nil + } deviceTelemetry.register( slot: slot, backend: backend, transport: transport, - audioMetrics: audioMetrics + audioMetrics: audioMetrics, + displayMetrics: displayMetrics ) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index fee5783a..96f36494 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -195,4 +195,51 @@ import Testing #expect(persistent.devices.first?.health == .failed) #expect(persistent.events.map(\.kind) == [.shareInvalidationFailure]) } + + @MainActor + @Test func publishesMeasuredDisplayFramesAndRealPresentationDrops() throws { + let mailbox = DesktopFrameMailbox() + let frame = VirtioGPUScanoutFrame( + scanoutID: 0, + resourceID: 1, + format: 1, + width: 1, + height: 1, + stride: 4, + dirtyRect: VirtioGPURect(x: 0, y: 0, width: 1, height: 1), + bytes: Data(repeating: 0, count: 4) + ) + mailbox.submit(frame) + mailbox.submit(frame) + mailbox.deliver() + #expect(mailbox.metrics == DesktopFrameMailboxMetrics( + presentedFrames: 0, + droppedFrames: 1 + )) + + let backend = VirtioGPU(hostMemoryBase: GuestLayout.daxWindowBase, scanoutCount: 1) + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let registry = RawDeviceTelemetryRegistry(machineID: "raw-display", operationID: UUID()) + registry.register( + slot: 1, + backend: backend, + transport: transport, + displayMetrics: { [weak mailbox] in mailbox?.metrics } + ) + + let snapshot = registry.snapshot() + let device = try #require(snapshot.devices.first) + #expect(device.id == "virtio-graphics-1") + #expect(device.health == .degraded) + #expect(device.metrics.first { $0.kind == .displayFrames }?.value == 0) + #expect(device.metrics.first { $0.kind == .displayDrops }?.value == 1) + + let stable = registry.snapshot() + #expect(stable.devices.first?.health == .healthy) + } } diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index db2f27b2..855fdf6e 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -746,9 +746,13 @@ for every flush taking at least 250 ms. Its Core Audio bridge reports exact play period drops and emits classified audio-drop events. VirtioFS counts admitted reverse invalidations and failed submission or acknowledgement transactions; because an uncertain invalidation latches request publication closed for that backend, its telemetry remains failed until backend replacement. +The raw-HV display mailbox reports only updates accepted by the Metal presentation layer as frames; +invalid updates, bounded partial-update overflow, released pending resources, and a missing display +surface are counted as drops, while a newer complete frame superseding older damage is intentional +coalescing rather than loss. Virtualization.framework counters that do not have a stable public authority remain explicitly unavailable rather than being reported as zero. Backend-specific producers for GPU loss/fences, -display drops, and network reconnects remain release gates for those individual capability claims. +and network reconnects remain release gates for those individual capability claims. ## Qualification and release gates From 8af1a13a5e566aea99f765789b4e76b451644cf2 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:24:05 +0000 Subject: [PATCH 235/338] feat(vm): classify gpu fence health --- .../Sources/DoryHV/VirtioGPU.swift | 80 ++++++++++++++++++- .../Sources/dory-hv/DesktopMode.swift | 52 +++++++++++- .../DesktopDeviceTelemetryTests.swift | 37 +++++++++ .../Tests/DoryHVTests/DeviceLogicTests.swift | 24 +++++- docs/virtual-workspace-platform.md | 8 +- 5 files changed, 193 insertions(+), 8 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index db41987f..8866cdc0 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -119,6 +119,25 @@ public struct VirtioGPUScanoutFrame: Sendable, Equatable { } } +public struct VirtioGPUStatistics: Equatable, Sendable { + public var fences: UInt64 + public var fenceRegistrationFailures: UInt64 + public var fenceTimeouts: UInt64 + public var hasTimedOutPendingFence: Bool + + public init( + fences: UInt64, + fenceRegistrationFailures: UInt64, + fenceTimeouts: UInt64, + hasTimedOutPendingFence: Bool + ) { + self.fences = fences + self.fenceRegistrationFailures = fenceRegistrationFailures + self.fenceTimeouts = fenceTimeouts + self.hasTimedOutPendingFence = hasTimedOutPendingFence + } +} + /// A copied guest cursor plane ready for the host window. Cursor bytes are never exposed through /// guest-owned pointers: the device snapshots the complete 32-bit BGRA resource at the /// UPDATE_CURSOR command boundary and carries the guest hotspot with it. @@ -262,7 +281,7 @@ private extension UInt64 { /// Bootstrap mode keeps the Linux driver bring-up surface deliberately inert. Venus mode advertises /// the Linux UAPI feature bits only when a host renderer is supplied, then forwards blob/context /// commands to that renderer. -public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { +public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider, @unchecked Sendable { public let deviceID: UInt32 = 16 public let queueCount = 2 public let deviceFeatures: UInt64 @@ -342,11 +361,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi var fenceID: UInt64 var response: [UInt8] var chain: VirtqueueChain + var createdAtMonotonicNanoseconds: UInt64 + var timeoutReported: Bool } private let fenceLock = NSLock() private var pendingFences: [FenceKey: [PendingFence]] = [:] private weak var lastTransport: VirtioMMIOTransport? + private let fenceTimeoutNanoseconds: UInt64 + private var fenceCount: UInt64 = 0 + private var fenceRegistrationFailureCount: UInt64 = 0 + private var fenceTimeoutCount: UInt64 = 0 private enum HeaderFlag { static let fence: UInt32 = 1 << 0 @@ -422,6 +447,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi renderer: VirtioGPURenderer? = nil, hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1", + fenceTimeoutNanoseconds: UInt64 = 10_000_000_000, onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil, onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil, onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil @@ -430,6 +456,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi self.renderer = renderer self.capsets = renderer?.capsets ?? [] self.traceResourceLifecycle = traceResourceLifecycle + self.fenceTimeoutNanoseconds = fenceTimeoutNanoseconds self.deviceFeatures = renderer == nil ? 0 : Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit @@ -546,7 +573,13 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi // callback from createFence itself or immediately on another thread. Registering after // createFence loses that edge forever and stalls the guest compositor on its first frame. fenceLock.lock() - pendingFences[key, default: []].append(PendingFence(fenceID: fenceID, response: response, chain: chain)) + pendingFences[key, default: []].append(PendingFence( + fenceID: fenceID, + response: response, + chain: chain, + createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + timeoutReported: false + )) fenceLock.unlock() do { try renderer.createFence( @@ -561,12 +594,55 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi waiting.removeAll { $0.fenceID == fenceID } pendingFences[key] = waiting.isEmpty ? nil : waiting } + fenceRegistrationFailureCount = Self.saturatingAdd( + fenceRegistrationFailureCount, + 1 + ) fenceLock.unlock() return false } + fenceLock.withLock { + fenceCount = Self.saturatingAdd(fenceCount, 1) + } return true } + public var statistics: VirtioGPUStatistics { + fenceLock.withLock { + let now = DispatchTime.now().uptimeNanoseconds + var newlyTimedOut: UInt64 = 0 + var hasTimedOutPendingFence = false + for key in Array(pendingFences.keys) { + guard var waiting = pendingFences[key] else { continue } + for index in waiting.indices { + if !waiting[index].timeoutReported { + let started = waiting[index].createdAtMonotonicNanoseconds + let age = now >= started ? now - started : 0 + if age >= fenceTimeoutNanoseconds { + waiting[index].timeoutReported = true + newlyTimedOut = Self.saturatingAdd(newlyTimedOut, 1) + } + } + hasTimedOutPendingFence = + hasTimedOutPendingFence || waiting[index].timeoutReported + } + pendingFences[key] = waiting + } + fenceTimeoutCount = Self.saturatingAdd(fenceTimeoutCount, newlyTimedOut) + return VirtioGPUStatistics( + fences: fenceCount, + fenceRegistrationFailures: fenceRegistrationFailureCount, + fenceTimeouts: fenceTimeoutCount, + hasTimedOutPendingFence: hasTimedOutPendingFence + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + /// Renderer-thread entry: completes every pending descriptor on the signaled timeline whose /// fence id is covered (fences signal in creation order within a ring). private func fenceSignaled(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 7112d366..5d0dc64e 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -17,12 +17,15 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var sharedDirectory: VirtioFS? var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? var displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? + var graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? var previousStorageStatistics: VirtioBlkStatistics? var previousAudioDrops: UInt64? var previousShareInvalidationFailures: UInt64? var previousDisplayDrops: UInt64? + var previousGraphicsFenceRegistrationFailures: UInt64? + var previousGraphicsFenceTimeouts: UInt64? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -48,8 +51,17 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { backend: any VirtioDeviceBackend, transport: VirtioMMIOTransport, audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil, - displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? = nil + displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? = nil, + graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? = nil ) { + let effectiveGraphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? + if let graphicsMetrics { + effectiveGraphicsMetrics = graphicsMetrics + } else if let graphics = backend as? VirtioGPU { + effectiveGraphicsMetrics = { [weak graphics] in graphics?.statistics } + } else { + effectiveGraphicsMetrics = nil + } let kind: DoryDeviceTelemetryKind var unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] switch backend { @@ -58,7 +70,10 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { unavailable = [] case is VirtioGPU: kind = .graphics - unavailable = [(.graphicsFences, .count), (.graphicsDeviceLosses, .count)] + unavailable = [(.graphicsDeviceLosses, .count)] + if effectiveGraphicsMetrics == nil { + unavailable.append((.graphicsFences, .count)) + } if displayMetrics == nil { unavailable.append(contentsOf: [ (.displayFrames, .count), @@ -108,12 +123,15 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { sharedDirectory: backend as? VirtioFS, audioMetrics: audioMetrics, displayMetrics: displayMetrics, + graphicsMetrics: effectiveGraphicsMetrics, unavailableMetrics: unavailable, previousTransportStatistics: nil, previousStorageStatistics: nil, previousAudioDrops: nil, previousShareInvalidationFailures: nil, previousDisplayDrops: nil, + previousGraphicsFenceRegistrationFailures: nil, + previousGraphicsFenceTimeouts: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -283,6 +301,36 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } entries[index].previousDisplayDrops = display.droppedFrames } + if let graphics = entries[index].graphicsMetrics?() { + metrics.append(.measured(.graphicsFences, value: graphics.fences)) + let previousRegistrationFailures = + entries[index].previousGraphicsFenceRegistrationFailures ?? 0 + if Self.monotonicDelta( + current: graphics.fenceRegistrationFailures, + previous: previousRegistrationFailures + ) > 0 { + health = .degraded + } + let previousTimeouts = entries[index].previousGraphicsFenceTimeouts ?? 0 + let newTimeouts = Self.monotonicDelta( + current: graphics.fenceTimeouts, + previous: previousTimeouts + ) + if newTimeouts > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .graphicsFenceTimeout, + occurrences: newTimeouts, + monotonicNanoseconds: monotonicNanoseconds + ) + } + if graphics.hasTimedOutPendingFence { + health = .failed + } + entries[index].previousGraphicsFenceRegistrationFailures = + graphics.fenceRegistrationFailures + entries[index].previousGraphicsFenceTimeouts = graphics.fenceTimeouts + } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) }) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index 96f36494..4bf6302a 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -242,4 +242,41 @@ import Testing let stable = registry.snapshot() #expect(stable.devices.first?.health == .healthy) } + + @Test func publishesFenceCountsAndClassifiesPendingTimeouts() throws { + let backend = VirtioGPU(hostMemoryBase: GuestLayout.daxWindowBase, scanoutCount: 1) + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: backend, + memory: memory + ) {} + let registry = RawDeviceTelemetryRegistry(machineID: "raw-gpu", operationID: UUID()) + let statistics = VirtioGPUStatistics( + fences: 9, + fenceRegistrationFailures: 1, + fenceTimeouts: 2, + hasTimedOutPendingFence: true + ) + registry.register( + slot: 1, + backend: backend, + transport: transport, + graphicsMetrics: { statistics } + ) + + let snapshot = registry.snapshot() + let device = try #require(snapshot.devices.first) + #expect(device.health == .failed) + #expect(device.metrics.first { $0.kind == .graphicsFences }?.value == 9) + #expect(device.metrics.first { + $0.kind == .graphicsDeviceLosses + }?.availability == .unavailable) + #expect(snapshot.events.map(\.kind) == [.graphicsFenceTimeout]) + #expect(snapshot.events.first?.occurrences == 2) + + let stable = registry.snapshot() + #expect(stable.devices.first?.health == .failed) + #expect(stable.events.map(\.kind) == [.graphicsFenceTimeout]) + } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 7d05a5fa..48fe3242 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -1620,7 +1620,11 @@ import Testing @Test func fencedCommandDefersCompletionUntilRendererSignals() throws { let renderer = FakeVirtioGPURenderer(capsets: [VirtioGPUCapset(id: 4, maxVersion: 0, data: [1])]) - let gpu = VirtioGPU(hostMemoryBase: 0x1_0000_0000, renderer: renderer) + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + renderer: renderer, + fenceTimeoutNanoseconds: 0 + ) let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) let transport = VirtioMMIOTransport(baseAddress: GuestLayout.virtioBase, backend: gpu, memory: memory) {} transport.queues[0].configure(size: 8, descriptorTable: descTable, availRing: availRing, usedRing: usedRing) @@ -1649,6 +1653,12 @@ import Testing #expect(renderer.createdFences.first?.fenceID == 7) #expect(renderer.createdFences.first?.contextFence == false) #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0) + #expect(gpu.statistics == VirtioGPUStatistics( + fences: 1, + fenceRegistrationFailures: 0, + fenceTimeouts: 1, + hasTimedOutPendingFence: true + )) // A ctx0 signal completes it: response carries OK + FLAG_FENCE + the fence id. renderer.signalFence(contextID: 0, ringIndex: 0, fenceID: 7) @@ -1656,6 +1666,12 @@ import Testing #expect(try memory.read(UInt32.self, at: responseBuffer) == 0x1100) #expect(try memory.read(UInt32.self, at: responseBuffer + 4) == 1) #expect(try memory.read(UInt64.self, at: responseBuffer + 8) == 7) + #expect(gpu.statistics == VirtioGPUStatistics( + fences: 1, + fenceRegistrationFailures: 0, + fenceTimeouts: 1, + hasTimedOutPendingFence: false + )) } @Test func contextFenceCompletesOnlyItsRing() throws { @@ -1726,6 +1742,12 @@ import Testing #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1) #expect(try memory.read(UInt32.self, at: responseBuffer) == 0x1100) #expect(try memory.read(UInt64.self, at: responseBuffer + 8) == 13) + #expect(gpu.statistics == VirtioGPUStatistics( + fences: 0, + fenceRegistrationFailures: 1, + fenceTimeouts: 0, + hasTimedOutPendingFence: false + )) } private func writeDescriptor(_ memory: GuestMemory, index: UInt64, addr: UInt64, len: UInt32, flags: UInt16, next: UInt16) throws { diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index 855fdf6e..c0c66b34 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -749,10 +749,12 @@ request publication closed for that backend, its telemetry remains failed until The raw-HV display mailbox reports only updates accepted by the Metal presentation layer as frames; invalid updates, bounded partial-update overflow, released pending resources, and a missing display surface are counted as drops, while a newer complete frame superseding older damage is intentional -coalescing rather than loss. +coalescing rather than loss. VirtioGPU counts successful renderer fence registrations, reports a +registration failure as degraded health, and classifies a fence still pending after 10 seconds as a +timeout; health remains failed until that exact pending fence is signaled. Virtualization.framework counters that do not have a stable public authority remain explicitly -unavailable rather than being reported as zero. Backend-specific producers for GPU loss/fences, -and network reconnects remain release gates for those individual capability claims. +unavailable rather than being reported as zero. Renderer device-loss classification and network +reconnect support remain release gates for those individual capability claims. ## Qualification and release gates From 754405a5f587a95834f72c25efa828da730963d7 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:38:15 +0000 Subject: [PATCH 236/338] feat(vm): report renderer device loss --- .../Sources/DoryHV/VirglRenderer.swift | 24 +++++++++ .../Sources/DoryHV/VirtioGPU.swift | 40 ++++++++++++++- .../Sources/dory-hv/DesktopMode.swift | 36 ++++++++++++-- .../DesktopDeviceTelemetryTests.swift | 17 +++++-- .../Tests/DoryHVTests/DeviceLogicTests.swift | 49 ++++++++++++++++++- docs/virtual-workspace-platform.md | 7 ++- 6 files changed, 161 insertions(+), 12 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift index f958e0e1..e1cc8d19 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift @@ -44,6 +44,7 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { private static let fenceLock = NSLock() nonisolated(unsafe) private static weak var activeRenderer: VirglRenderer? nonisolated(unsafe) private var fenceSink: ((UInt32, UInt32, UInt64) -> Void)? + nonisolated(unsafe) private var runtimeFailureSink: ((VirtioGPURendererRuntimeFailure) -> Void)? nonisolated(unsafe) private var pendingFenceSignals: [(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64)] = [] public var onFenceSignaled: ((UInt32, UInt32, UInt64) -> Void)? { @@ -51,12 +52,29 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { set { Self.fenceLock.lock(); fenceSink = newValue; Self.fenceLock.unlock() } } + public var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? { + get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return runtimeFailureSink } + set { Self.fenceLock.lock(); runtimeFailureSink = newValue; Self.fenceLock.unlock() } + } + fileprivate static func signalFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { fenceLock.lock() activeRenderer?.pendingFenceSignals.append((contextID, ringIndex, fenceID)) fenceLock.unlock() } + fileprivate static func signalRuntimeFailure(_ failure: VirtioGPURendererRuntimeFailure) { + fenceLock.lock() + let sink = activeRenderer?.runtimeFailureSink + fenceLock.unlock() + sink?(failure) + } + + static func runtimeFailure(logMessage: String) -> VirtioGPURendererRuntimeFailure? { + guard logMessage.contains("VK_ERROR_DEVICE_LOST") else { return nil } + return .deviceLost("renderer reported VK_ERROR_DEVICE_LOST") + } + public static func discover( environment: [String: String] = ProcessInfo.processInfo.environment ) throws -> VirglRenderer { @@ -785,6 +803,12 @@ private typealias GetEGLDisplayCallback = @convention(c) (UnsafeMutableRawPointe private let doryVirglLog: @convention(c) (Int32, UnsafePointer?, UnsafeMutableRawPointer?) -> Void = { level, message, _ in let text = message.map { String(cString: $0) } ?? "" FileHandle.standardError.write(Data("virgl[\(level)]: \(text)\n".utf8)) + // VK_ERROR_DEVICE_LOST is the Vulkan API's explicit host logical-device-loss authority. + // Do not infer loss from generic renderer errors: those can be caused by malformed guest + // command streams and must remain ordinary command failures. + if let failure = VirglRenderer.runtimeFailure(logMessage: text) { + VirglRenderer.signalRuntimeFailure(failure) + } } // ctx0 fences ride the global timeline: write_fence has no context/ring, so they complete under diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 8866cdc0..10b0082b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -124,20 +124,33 @@ public struct VirtioGPUStatistics: Equatable, Sendable { public var fenceRegistrationFailures: UInt64 public var fenceTimeouts: UInt64 public var hasTimedOutPendingFence: Bool + public var rendererDeviceLosses: UInt64 + public var hasLostRendererDevice: Bool public init( fences: UInt64, fenceRegistrationFailures: UInt64, fenceTimeouts: UInt64, - hasTimedOutPendingFence: Bool + hasTimedOutPendingFence: Bool, + rendererDeviceLosses: UInt64 = 0, + hasLostRendererDevice: Bool = false ) { self.fences = fences self.fenceRegistrationFailures = fenceRegistrationFailures self.fenceTimeouts = fenceTimeouts self.hasTimedOutPendingFence = hasTimedOutPendingFence + self.rendererDeviceLosses = rendererDeviceLosses + self.hasLostRendererDevice = hasLostRendererDevice } } +/// A renderer failure whose meaning is host-owned and therefore safe to publish as device-loss +/// telemetry. Ordinary renderer command errors are deliberately excluded: malformed guest 3D +/// commands must not be relabeled as a host GPU failure. +public enum VirtioGPURendererRuntimeFailure: Error, Equatable, Sendable { + case deviceLost(String) +} + /// A copied guest cursor plane ready for the host window. Cursor bytes are never exposed through /// guest-owned pointers: the device snapshots the complete 32-bit BGRA resource at the /// UPDATE_CURSOR command boundary and carries the guest hotspot with it. @@ -177,6 +190,7 @@ public struct VirtioGPUCursorUpdate: Sendable, Equatable { public protocol VirtioGPURenderer: AnyObject { var capsets: [VirtioGPUCapset] { get } + var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? { get set } func createContext(id: UInt32, flags: UInt32, name: String) throws func destroyContext(id: UInt32) throws func attachResource(contextID: UInt32, resourceID: UInt32) throws @@ -372,6 +386,8 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private var fenceCount: UInt64 = 0 private var fenceRegistrationFailureCount: UInt64 = 0 private var fenceTimeoutCount: UInt64 = 0 + private var rendererDeviceLossCount: UInt64 = 0 + private var rendererDeviceLossLatched = false private enum HeaderFlag { static let fence: UInt32 = 1 << 0 @@ -473,6 +489,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) } + renderer?.onRuntimeFailure = { [weak self] failure in + self?.recordRendererFailure(failure) + } } public var configSpace: [UInt8] { @@ -598,6 +617,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi fenceRegistrationFailureCount, 1 ) + recordRendererFailureWhileLocked(error) fenceLock.unlock() return false } @@ -633,7 +653,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi fences: fenceCount, fenceRegistrationFailures: fenceRegistrationFailureCount, fenceTimeouts: fenceTimeoutCount, - hasTimedOutPendingFence: hasTimedOutPendingFence + hasTimedOutPendingFence: hasTimedOutPendingFence, + rendererDeviceLosses: rendererDeviceLossCount, + hasLostRendererDevice: rendererDeviceLossLatched ) } } @@ -1252,6 +1274,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi do { return try body(renderer) } catch { + recordRendererFailure(error) logCommandFailure(request: request, error: error) return responseHeader(type: Response.errorInvalidParameter, request: request) } @@ -1273,11 +1296,24 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi do { return try body() } catch { + recordRendererFailure(error) logCommandFailure(request: request, error: error) return responseHeader(type: Response.errorInvalidParameter, request: request) } } + private func recordRendererFailure(_ error: Error) { + fenceLock.withLock { recordRendererFailureWhileLocked(error) } + } + + private func recordRendererFailureWhileLocked(_ error: Error) { + guard let failure = error as? VirtioGPURendererRuntimeFailure, + case .deviceLost = failure, + !rendererDeviceLossLatched else { return } + rendererDeviceLossLatched = true + rendererDeviceLossCount = Self.saturatingAdd(rendererDeviceLossCount, 1) + } + /// Linux may retry a failed renderer command many times per second. Preserve the status and /// identifying fields needed for diagnosis without flooding the VM's serial log. private func logCommandFailure(request: [UInt8], error: Error) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 5d0dc64e..bcc8c610 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -26,6 +26,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var previousDisplayDrops: UInt64? var previousGraphicsFenceRegistrationFailures: UInt64? var previousGraphicsFenceTimeouts: UInt64? + var previousGraphicsDeviceLosses: UInt64? var consecutiveUncompletedNotificationSamples: UInt8 var queueStallReported: Bool } @@ -70,9 +71,12 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { unavailable = [] case is VirtioGPU: kind = .graphics - unavailable = [(.graphicsDeviceLosses, .count)] + unavailable = [] if effectiveGraphicsMetrics == nil { - unavailable.append((.graphicsFences, .count)) + unavailable.append(contentsOf: [ + (.graphicsFences, .count), + (.graphicsDeviceLosses, .count), + ]) } if displayMetrics == nil { unavailable.append(contentsOf: [ @@ -132,6 +136,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { previousDisplayDrops: nil, previousGraphicsFenceRegistrationFailures: nil, previousGraphicsFenceTimeouts: nil, + previousGraphicsDeviceLosses: nil, consecutiveUncompletedNotificationSamples: 0, queueStallReported: false ) @@ -302,7 +307,13 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { entries[index].previousDisplayDrops = display.droppedFrames } if let graphics = entries[index].graphicsMetrics?() { - metrics.append(.measured(.graphicsFences, value: graphics.fences)) + metrics.append(contentsOf: [ + .measured(.graphicsFences, value: graphics.fences), + .measured( + .graphicsDeviceLosses, + value: graphics.rendererDeviceLosses + ), + ]) let previousRegistrationFailures = entries[index].previousGraphicsFenceRegistrationFailures ?? 0 if Self.monotonicDelta( @@ -327,9 +338,28 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { if graphics.hasTimedOutPendingFence { health = .failed } + let previousDeviceLosses = + entries[index].previousGraphicsDeviceLosses ?? 0 + let newDeviceLosses = Self.monotonicDelta( + current: graphics.rendererDeviceLosses, + previous: previousDeviceLosses + ) + if newDeviceLosses > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .graphicsDeviceLoss, + occurrences: newDeviceLosses, + monotonicNanoseconds: monotonicNanoseconds + ) + } + if graphics.hasLostRendererDevice { + health = .failed + } entries[index].previousGraphicsFenceRegistrationFailures = graphics.fenceRegistrationFailures entries[index].previousGraphicsFenceTimeouts = graphics.fenceTimeouts + entries[index].previousGraphicsDeviceLosses = + graphics.rendererDeviceLosses } metrics.append(contentsOf: entries[index].unavailableMetrics.map { .unavailable($0.0, unit: $0.1, reason: unavailableReason) diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift index 4bf6302a..2d036e2d 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDeviceTelemetryTests.swift @@ -256,7 +256,9 @@ import Testing fences: 9, fenceRegistrationFailures: 1, fenceTimeouts: 2, - hasTimedOutPendingFence: true + hasTimedOutPendingFence: true, + rendererDeviceLosses: 1, + hasLostRendererDevice: true ) registry.register( slot: 1, @@ -271,12 +273,19 @@ import Testing #expect(device.metrics.first { $0.kind == .graphicsFences }?.value == 9) #expect(device.metrics.first { $0.kind == .graphicsDeviceLosses - }?.availability == .unavailable) - #expect(snapshot.events.map(\.kind) == [.graphicsFenceTimeout]) + }?.value == 1) + #expect(snapshot.events.map(\.kind) == [ + .graphicsFenceTimeout, + .graphicsDeviceLoss, + ]) #expect(snapshot.events.first?.occurrences == 2) + #expect(snapshot.events.last?.occurrences == 1) let stable = registry.snapshot() #expect(stable.devices.first?.health == .failed) - #expect(stable.events.map(\.kind) == [.graphicsFenceTimeout]) + #expect(stable.events.map(\.kind) == [ + .graphicsFenceTimeout, + .graphicsDeviceLoss, + ]) } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 48fe3242..34602100 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -834,6 +834,18 @@ import Testing private let requestBuffer: UInt64 = 0x8000_4000 private let responseBuffer: UInt64 = 0x8000_5000 + @Test func virglLogClassifiesOnlyExplicitVulkanDeviceLoss() { + #expect(VirglRenderer.runtimeFailure( + logMessage: "queue submit failed: VK_ERROR_DEVICE_LOST" + ) == .deviceLost("renderer reported VK_ERROR_DEVICE_LOST")) + #expect(VirglRenderer.runtimeFailure( + logMessage: "guest command rejected with invalid resource" + ) == nil) + #expect(VirglRenderer.runtimeFailure( + logMessage: "generic renderer device lost text" + ) == nil) + } + @Test func singleCapsetBecomesImplicitRendererDefault() { let venus = VirtioGPUCapset(id: 4, maxVersion: 0, data: [1]) #expect(VirtioGPU.rendererContextFlags(requested: 0, capsets: [venus]) == 4) @@ -1618,6 +1630,31 @@ import Testing #expect(renderer.unmapBlobCount == 1) } + @Test func explicitHostRendererLossIsLatchedWithoutCountingGuestCommandErrors() throws { + let renderer = FakeVirtioGPURenderer(capsets: [ + VirtioGPUCapset(id: 4, maxVersion: 0, data: [1]), + ]) + let gpu = VirtioGPU(hostMemoryBase: 0x1_0000_0000, renderer: renderer) + var request = gpuRequest(type: 0x0207, fenceID: 0, contextID: 7, ringIndex: 0) + request.appendLE(UInt32(0)) + request.appendLE(UInt32(0)) + + renderer.failSubmit = true + #expect(leUInt32(try gpuResponse(gpu: gpu, request: request), at: 0) == 0x1202) + #expect(gpu.statistics.rendererDeviceLosses == 0) + #expect(!gpu.statistics.hasLostRendererDevice) + + renderer.signalRuntimeFailure(.deviceLost("VK_ERROR_DEVICE_LOST")) + #expect(gpu.statistics.rendererDeviceLosses == 1) + #expect(gpu.statistics.hasLostRendererDevice) + + // A lost renderer can reject many subsequent guest commands or repeat its diagnostic. + // The metric records the single loss transition rather than fabricating extra devices. + renderer.signalRuntimeFailure(.deviceLost("VK_ERROR_DEVICE_LOST")) + #expect(gpu.statistics.rendererDeviceLosses == 1) + #expect(gpu.statistics.hasLostRendererDevice) + } + @Test func fencedCommandDefersCompletionUntilRendererSignals() throws { let renderer = FakeVirtioGPURenderer(capsets: [VirtioGPUCapset(id: 4, maxVersion: 0, data: [1])]) let gpu = VirtioGPU( @@ -1865,6 +1902,8 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { private var transferReadbackHeight: UInt32 = 0 private var transferReadbackPixels = [UInt8]() var transferFromHostCalls = [TransferFromHostCall]() + var failSubmit = false + var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? init(capsets: [VirtioGPUCapset], blobBytes: [UInt8]? = nil) { self.capsets = capsets @@ -1889,7 +1928,15 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { func destroyContext(id: UInt32) throws {} func attachResource(contextID: UInt32, resourceID: UInt32) throws {} func detachResource(contextID: UInt32, resourceID: UInt32) throws {} - func submit3D(contextID: UInt32, command: [UInt8]) throws {} + func submit3D(contextID: UInt32, command: [UInt8]) throws { + if failSubmit { + throw VMError.invalidConfiguration("guest command rejected") + } + } + + func signalRuntimeFailure(_ failure: VirtioGPURendererRuntimeFailure) { + onRuntimeFailure?(failure) + } func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws { createdResources.append(resource) } diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md index c0c66b34..2de355c6 100644 --- a/docs/virtual-workspace-platform.md +++ b/docs/virtual-workspace-platform.md @@ -752,9 +752,12 @@ surface are counted as drops, while a newer complete frame superseding older dam coalescing rather than loss. VirtioGPU counts successful renderer fence registrations, reports a registration failure as degraded health, and classifies a fence still pending after 10 seconds as a timeout; health remains failed until that exact pending fence is signaled. +An explicit host renderer loss is counted once and latches raw-HV graphics health failed; the +current VirGL bridge issues that authority only for Vulkan's explicit `VK_ERROR_DEVICE_LOST`. +Ordinary guest command validation failures remain excluded from device-loss telemetry. Virtualization.framework counters that do not have a stable public authority remain explicitly -unavailable rather than being reported as zero. Renderer device-loss classification and network -reconnect support remain release gates for those individual capability claims. +unavailable rather than being reported as zero. Network reconnect support remains a release gate +for that individual capability claim. ## Qualification and release gates From db26ad4ba31e0ade505f3226c619defdeee9884a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 01:58:22 +0000 Subject: [PATCH 237/338] feat(vm): add guest usb vhci control --- .../Sources/DoryHV/AgentChannel.swift | 73 ++- .../DoryHV/Usb/UsbControlHandler.swift | 22 +- .../Sources/dory-hv/EngineMode.swift | 24 +- .../DoryHVTests/AgentProtocolTests.swift | 95 +++- .../Tests/DoryHVTests/UsbipBridgeTests.swift | 25 + .../Sources/DoryCore/DoryCore.swift | 22 + dory-core/README.md | 7 +- dory-core/agent/src/dispatch.rs | 3 + dory-core/agent/src/handler.rs | 49 ++ dory-core/agent/src/lib.rs | 1 + dory-core/agent/src/usb_vhci.rs | 439 ++++++++++++++++++ dory-core/ffi/src/agent_forward.rs | 44 ++ dory-core/pb/proto/agent.proto | 31 ++ dory-core/remote/src/agent_client.rs | 62 ++- 14 files changed, 876 insertions(+), 21 deletions(-) create mode 100644 dory-core/agent/src/usb_vhci.rs diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift index d4defb3d..9035d1b4 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift @@ -12,15 +12,30 @@ protocol AgentControlRPC: AnyObject, Sendable { func info() throws -> DoryAgentInfo func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws + func usbVhciDetach(busID: String, port: UInt32) throws func close() } extension DoryAgentControlHandle: AgentControlRPC {} +extension AgentControlRPC { + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws { + throw AgentProtocolError.capabilityUnavailable("usb-vhci", 1) + } + + func usbVhciDetach(busID: String, port: UInt32) throws { + throw AgentProtocolError.capabilityUnavailable("usb-vhci", 1) + } +} + public enum AgentProtocolError: Error, Equatable, Sendable { case connectionAlreadyConsumed case invalidGuestPort(UInt32) + case invalidVhciPort(Int) case socketPair(Int32) + case capabilityUnavailable(String, UInt32) + case invalidCapabilityInventory } /// A typed control channel over one connected guest vsock stream. @@ -65,11 +80,17 @@ public final class AgentChannel: @unchecked Sendable { public func info() async throws -> AgentInfo { let raw = try await perform { try $0.info() } + guard raw.capabilitiesAreCanonical else { + throw AgentProtocolError.invalidCapabilityInventory + } return AgentInfo( protocolVersion: raw.protocolVersion, kernel: raw.kernel, agentBuild: raw.agentBuild, - uptimeSeconds: raw.uptimeSeconds + uptimeSeconds: raw.uptimeSeconds, + capabilities: raw.capabilities.map { + AgentCapability(id: $0.id, version: $0.version) + } ) } @@ -93,6 +114,35 @@ public final class AgentChannel: @unchecked Sendable { ) } + public func requireCapability(_ id: String, version: UInt32) async throws { + let info = try await info() + guard info.capabilities.contains(where: { $0.id == id && $0.version >= version }) else { + throw AgentProtocolError.capabilityUnavailable(id, version) + } + } + + public func usbVhciAttach(_ request: UsbAgentAttachRequest) async throws { + guard let port = UInt32(exactly: request.port) else { + throw AgentProtocolError.invalidVhciPort(request.port) + } + try await perform { + try $0.usbVhciAttach( + busID: request.busid, + port: port, + vsockPort: request.vsock_port, + deviceID: request.device_id, + speed: request.speed + ) + } + } + + public func usbVhciDetach(_ request: UsbAgentDetachRequest) async throws { + guard let port = UInt32(exactly: request.port) else { + throw AgentProtocolError.invalidVhciPort(request.port) + } + try await perform { try $0.usbVhciDetach(busID: request.busid, port: port) } + } + private func perform( _ operation: @escaping @Sendable (any AgentControlRPC) throws -> Result ) async throws -> Result { @@ -178,12 +228,20 @@ public struct AgentInfo: Codable, Equatable, Sendable { public var kernel: String public var agentBuild: String public var uptimeSeconds: UInt64 + public var capabilities: [AgentCapability] - public init(protocolVersion: UInt32, kernel: String, agentBuild: String, uptimeSeconds: UInt64) { + public init( + protocolVersion: UInt32, + kernel: String, + agentBuild: String, + uptimeSeconds: UInt64, + capabilities: [AgentCapability] = [] + ) { self.protocolVersion = protocolVersion self.kernel = kernel self.agentBuild = agentBuild self.uptimeSeconds = uptimeSeconds + self.capabilities = capabilities } enum CodingKeys: String, CodingKey { @@ -191,6 +249,17 @@ public struct AgentInfo: Codable, Equatable, Sendable { case kernel case agentBuild = "agent_build" case uptimeSeconds = "uptime_seconds" + case capabilities + } +} + +public struct AgentCapability: Codable, Equatable, Sendable { + public var id: String + public var version: UInt32 + + public init(id: String, version: UInt32) { + self.id = id + self.version = version } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift index 09688a2c..90686371 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift @@ -33,7 +33,7 @@ public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible case .notAttached(let busID): return "USB device is not attached: \(busID)" case .guestAgentRPCUnavailable: - return "USB attach/detach is unavailable: dory-agent control protocol has no USB RPC" + return "USB attach/detach is unavailable: the guest does not expose usb-vhci@1" } } } @@ -44,7 +44,7 @@ public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible /// fails) is unit-testable without real hardware, a socket, or a running guest. public final class UsbControlHandler: @unchecked Sendable { private let manager: UsbipManager - private let ensureSupported: () throws -> Void + private let ensureSupported: () async throws -> Void private let openDevice: (String, HostUsbOpenMode) throws -> any UsbipExportedDevice private let notifyAttach: (UsbAgentAttachRequest) async throws -> Void private let notifyDetach: (UsbAgentDetachRequest) async throws -> Void @@ -55,7 +55,7 @@ public final class UsbControlHandler: @unchecked Sendable { public init( manager: UsbipManager, - ensureSupported: @escaping () throws -> Void = {}, + ensureSupported: @escaping () async throws -> Void = {}, openDevice: @escaping (String, HostUsbOpenMode) throws -> any UsbipExportedDevice, notifyAttach: @escaping (UsbAgentAttachRequest) async throws -> Void, notifyDetach: @escaping (UsbAgentDetachRequest) async throws -> Void @@ -70,13 +70,19 @@ public final class UsbControlHandler: @unchecked Sendable { public func attach(busID: String, mode: HostUsbOpenMode = .userAuthorized) async throws -> UsbAttachOutcome { // Capability is checked before opening or claiming the host device. A missing guest RPC must // fail closed; briefly seizing hardware and rolling back is still an observable disruption. - try ensureSupported() - try lock.withLock { + try await ensureSupported() + let port = try lock.withLock { () -> Int in guard portByBusID[busID] == nil else { throw UsbControlError.alreadyAttached(busID) } + return allocatePortLocked(for: busID) + } + let device: any UsbipExportedDevice + do { + device = try openDevice(busID, mode) + } catch { + lock.withLock { releasePortLocked(busID) } + throw error } - let device = try openDevice(busID, mode) manager.register(device) - let port = lock.withLock { allocatePortLocked(for: busID) } let descriptor = device.descriptor let request = UsbAgentAttachRequest( busid: busID, @@ -97,7 +103,7 @@ public final class UsbControlHandler: @unchecked Sendable { } public func detach(busID: String) async throws { - try ensureSupported() + try await ensureSupported() let port = try lock.withLock { () -> Int in guard let port = portByBusID[busID] else { throw UsbControlError.notAttached(busID) } return port diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift index 9872bcbf..ab0d4e11 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift @@ -768,17 +768,29 @@ enum EngineMode { defer { gvproxyDatapathTask.cancel() } note("engine starting: \(configuration.memoryMB)MiB ceiling, \(configuration.cpus) cpus, socket \(configuration.engineSocket)") - // The host usbip bridge exists, but attach/detach is deliberately unavailable until the - // authoritative protobuf agent protocol has a real guest vhci RPC. The capability gate runs - // before HostUsbDeviceFactory.open, so commands fail closed without claiming host hardware. + // USB/IP device data stays on its dedicated vsock connection. Attach/detach authority runs + // over a fresh shared agent-protocol channel for every operation, so an agent restart does + // not strand a long-lived control client and capability is revalidated before hardware is + // claimed and again immediately before the guest vhci mutation. let usbipManager = UsbipManager() usbipManager.attachListener(to: vsock) let usbControlHandler = UsbControlHandler( manager: usbipManager, - ensureSupported: { throw UsbControlError.guestAgentRPCUnavailable }, + ensureSupported: { + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + }, openDevice: { busID, mode in try HostUsbDeviceFactory.open(busID: busID, mode: mode) }, - notifyAttach: { _ in throw UsbControlError.guestAgentRPCUnavailable }, - notifyDetach: { _ in throw UsbControlError.guestAgentRPCUnavailable } + notifyAttach: { request in + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciAttach(request) + }, + notifyDetach: { request in + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciDetach(request) + } ) let usbControlServer = UsbControlServer(path: configuration.stateDirectory + "/usb-control.sock", handler: usbControlHandler) do { try usbControlServer.start() } catch { note("usb control server unavailable: \(error)") } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift index 5710ab47..71e9a74e 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift @@ -10,7 +10,8 @@ import Testing protocolVersion: DoryCore.protocolVersion(), kernel: "Linux 6.12.30-dory", agentBuild: "dory-agent/0.1.0", - uptimeSeconds: 42 + uptimeSeconds: 42, + capabilities: [DoryAgentCapability(id: "usb-vhci", version: 1)] ) client.clockSyncResult = true let channel = AgentChannel(client: client) @@ -22,7 +23,8 @@ import Testing protocolVersion: 1, kernel: "Linux 6.12.30-dory", agentBuild: "dory-agent/0.1.0", - uptimeSeconds: 42 + uptimeSeconds: 42, + capabilities: [AgentCapability(id: "usb-vhci", version: 1)] )) #expect(clock.synced) #expect(client.clockSyncInputs == [1_725_000_000_123_456_789]) @@ -60,6 +62,66 @@ import Testing } } + @Test func channelRequiresAndForwardsExactUsbVhciAuthority() async throws { + let client = StubAgentControlRPC() + client.infoResult = DoryAgentInfo( + protocolVersion: 1, + kernel: "Linux test", + agentBuild: "dory-agent/test", + uptimeSeconds: 1, + capabilities: [DoryAgentCapability(id: "usb-vhci", version: 1)] + ) + let channel = AgentChannel(client: client) + let attach = UsbAgentAttachRequest( + busid: "3-2", + port: 4, + vsock_port: VsockPorts.usbip, + device_id: (3 << 16) | 2, + speed: 3 + ) + + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciAttach(attach) + try await channel.usbVhciDetach(UsbAgentDetachRequest(busid: "3-2", port: 4)) + + #expect(client.usbAttachCalls == [attach]) + #expect(client.usbDetachCalls == [UsbAgentDetachRequest(busid: "3-2", port: 4)]) + } + + @Test func channelRejectsMissingUsbCapabilityAndInvalidPort() async throws { + let channel = AgentChannel(client: StubAgentControlRPC()) + await #expect(throws: AgentProtocolError.capabilityUnavailable("usb-vhci", 1)) { + try await channel.requireCapability("usb-vhci", version: 1) + } + await #expect(throws: AgentProtocolError.invalidVhciPort(-1)) { + try await channel.usbVhciAttach(UsbAgentAttachRequest( + busid: "3-2", + port: -1, + vsock_port: VsockPorts.usbip, + device_id: (3 << 16) | 2, + speed: 3 + )) + } + } + + @Test func channelRejectsNoncanonicalCapabilityInventory() async { + let client = StubAgentControlRPC() + client.infoResult = DoryAgentInfo( + protocolVersion: 1, + kernel: "Linux test", + agentBuild: "dory-agent/test", + uptimeSeconds: 1, + capabilities: [ + DoryAgentCapability(id: "usb-vhci", version: 1), + DoryAgentCapability(id: "clock-sync", version: 1), + ] + ) + + await #expect(throws: AgentProtocolError.invalidCapabilityInventory) { + _ = try await AgentChannel(client: client).info() + } + } + @Test func infoJSONUsesCurrentProtobufSurfaceNames() throws { let info = AgentInfo( protocolVersion: 1, @@ -127,11 +189,21 @@ final class StubAgentControlRPC: AgentControlRPC, @unchecked Sendable { var clockSyncResult = false var portsResult = DoryPortsSnapshot(ports: [], added: [], removed: []) private var storedClockSyncInputs: [Int64] = [] + private var storedUsbAttachCalls: [UsbAgentAttachRequest] = [] + private var storedUsbDetachCalls: [UsbAgentDetachRequest] = [] var clockSyncInputs: [Int64] { lock.withLock { storedClockSyncInputs } } + var usbAttachCalls: [UsbAgentAttachRequest] { + lock.withLock { storedUsbAttachCalls } + } + + var usbDetachCalls: [UsbAgentDetachRequest] { + lock.withLock { storedUsbDetachCalls } + } + func info() throws -> DoryAgentInfo { infoResult } func clockSync(hostEpochNs: Int64) throws -> Bool { @@ -140,6 +212,25 @@ final class StubAgentControlRPC: AgentControlRPC, @unchecked Sendable { } func portsWatch() throws -> DoryPortsSnapshot { portsResult } + + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws { + lock.withLock { + storedUsbAttachCalls.append(UsbAgentAttachRequest( + busid: busID, + port: Int(port), + vsock_port: vsockPort, + device_id: deviceID, + speed: speed + )) + } + } + + func usbVhciDetach(busID: String, port: UInt32) throws { + lock.withLock { + storedUsbDetachCalls.append(UsbAgentDetachRequest(busid: busID, port: Int(port))) + } + } + func close() {} } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift index 724054ed..99d0a48f 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift @@ -135,6 +135,31 @@ struct UsbControlHandlerTests { await #expect(throws: UsbControlError.self) { _ = try await handler.attach(busID: "3-2") } } + @Test func concurrentDuplicateAttachClaimsHostDeviceExactlyOnce() async { + let manager = UsbipManager() + let handler = UsbControlHandler( + manager: manager, + openDevice: { busID, _ in + StubExportedDevice(descriptor: fixtureDescriptor(busID: busID)) + }, + notifyAttach: { _ in await Task.yield() }, + notifyDetach: { _ in } + ) + + let successes = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for _ in 0..<2 { + group.addTask { (try? await handler.attach(busID: "3-2")) != nil } + } + var count = 0 + for await succeeded in group where succeeded { count += 1 } + return count + } + + #expect(successes == 1) + #expect(handler.attachedBusIDs == ["3-2"]) + #expect(manager.claimedBusIDs == ["3-2"]) + } + @Test func attachRollsBackWhenGuestNotifyFails() async throws { let manager = UsbipManager() let (handler, _) = makeHandler(manager: manager, attachFails: true) diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index 8aa0579a..d8b74e5f 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -734,6 +734,28 @@ public final class DoryAgentControlHandle: @unchecked Sendable { } } + public func usbVhciAttach( + busID: String, + port: UInt32, + vsockPort: UInt32, + deviceID: UInt32, + speed: UInt32 + ) throws { + try withControl { + try $0.usbVhciAttach( + busId: busID, + port: port, + vsockPort: vsockPort, + deviceId: deviceID, + speed: speed + ) + } + } + + public func usbVhciDetach(busID: String, port: UInt32) throws { + try withControl { try $0.usbVhciDetach(busId: busID, port: port) } + } + public func exec( argv: [String], cwd: String = "", diff --git a/dory-core/README.md b/dory-core/README.md index a78b1893..74982e06 100644 --- a/dory-core/README.md +++ b/dory-core/README.md @@ -83,7 +83,10 @@ cutover; the Rust path stayed healthy throughout. - **Remote workspace product UX** — the protocol, SSH transport, pinned host-key policy, Keychain key lookup, and host-authoritative push exist. Persistent reconnect, offline/conflict UX, and a full remote-workspace threat model remain preview work, not gaps in the local Docker dataplane. -- **USB passthrough** — host discovery and bridge scaffolding exist. The guest vhci attach/detach RPC - is intentionally unavailable, so public app and CLI surfaces expose discovery only. +- **USB passthrough** — the Rust agent now exposes a capability-gated `usb-vhci@1` attach/detach RPC, + validates the exact USB/IP import descriptor, and hands the dedicated host vsock stream to Linux + `vhci_hcd`; the raw-HV engine consumes that path with pre-claim and pre-mutation capability checks. + Public app/CLI attachment and automatic replay remain disabled until the machine-scoped control + routing and physical-device qualification slice lands. - **GPU** — Venus/Vulkan remains an opt-in preview on the arm64 raw-HV tier. It is not a stable 0.4 cross-host GPU API. diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs index c31d3bed..3640273c 100644 --- a/dory-core/agent/src/dispatch.rs +++ b/dory-core/agent/src/dispatch.rs @@ -33,6 +33,9 @@ pub fn agent_capabilities() -> Vec { if crate::snapshot_quiesce::available() { capabilities.push(("snapshot-quiesce", 2)); } + if crate::usb_vhci::available() { + capabilities.push(("usb-vhci", 1)); + } capabilities.sort_unstable_by_key(|(id, _)| *id); capabilities .into_iter() diff --git a/dory-core/agent/src/handler.rs b/dory-core/agent/src/handler.rs index 9d007763..d0c2eff1 100644 --- a/dory-core/agent/src/handler.rs +++ b/dory-core/agent/src/handler.rs @@ -12,6 +12,7 @@ use crate::dispatch::{err, handle_method}; use crate::exec::{self, ExecError}; use crate::snapshot_quiesce; use crate::sync_apply::{self, SyncError}; +use crate::usb_vhci::{self, UsbVhciError}; pub async fn handle(req_bytes: &[u8]) -> Vec { let req = match AgentRequest::decode(req_bytes) { @@ -33,11 +34,22 @@ pub async fn handle(req_bytes: &[u8]) -> Vec { Some(Method::SnapshotQuiesce(r)) => AgentResponse { result: Some(Res::SnapshotQuiesce(snapshot_quiesce::run(r).await)), }, + Some(Method::UsbVhciAttach(r)) => wrap_usb(usb_vhci::attach(r).await, Res::UsbVhciAttach), + Some(Method::UsbVhciDetach(r)) => wrap_usb(usb_vhci::detach(r).await, Res::UsbVhciDetach), other => handle_method(other), }; response.encode_to_vec() } +fn wrap_usb(result: Result, ok: impl FnOnce(T) -> Res) -> AgentResponse { + match result { + Ok(value) => agent::AgentResponse { + result: Some(ok(value)), + }, + Err(error) => err(error.code(), &error.to_string()), + } +} + /// Turn a sync handler result into an `AgentResponse`: the ok variant, or a coded RpcError. fn wrap(result: Result, ok: impl FnOnce(T) -> Res) -> AgentResponse { match result { @@ -56,3 +68,40 @@ fn wrap_exec(result: Result) -> AgentResponse { Err(e) => err(e.code(), &e.to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn usb_vhci_requests_are_routed_and_rejected_before_platform_io() { + let request = AgentRequest { + method: Some(Method::UsbVhciAttach(agent::UsbVhciAttachRequest { + bus_id: "../device".into(), + port: 0, + vsock_port: dory_proto::channels::PORT_USBIP, + device_id: (3 << 16) | 2, + speed: 3, + })), + }; + let response = AgentResponse::decode(handle(&request.encode_to_vec()).await.as_slice()) + .expect("well-formed response"); + match response.result { + Some(Res::Error(error)) => assert_eq!(error.code, 422), + other => panic!("expected validation error, got {other:?}"), + } + + let request = AgentRequest { + method: Some(Method::UsbVhciDetach(agent::UsbVhciDetachRequest { + bus_id: "3-2".into(), + port: 128, + })), + }; + let response = AgentResponse::decode(handle(&request.encode_to_vec()).await.as_slice()) + .expect("well-formed response"); + match response.result { + Some(Res::Error(error)) => assert_eq!(error.code, 422), + other => panic!("expected validation error, got {other:?}"), + } + } +} diff --git a/dory-core/agent/src/lib.rs b/dory-core/agent/src/lib.rs index 1f20148c..8b2f808c 100644 --- a/dory-core/agent/src/lib.rs +++ b/dory-core/agent/src/lib.rs @@ -19,6 +19,7 @@ pub mod reaper; pub mod snapshot_quiesce; pub mod sync_apply; pub mod telemetry; +pub mod usb_vhci; #[cfg(target_os = "linux")] pub mod terminal; diff --git a/dory-core/agent/src/usb_vhci.rs b/dory-core/agent/src/usb_vhci.rs new file mode 100644 index 00000000..266beb36 --- /dev/null +++ b/dory-core/agent/src/usb_vhci.rs @@ -0,0 +1,439 @@ +//! Guest-side USB/IP import and Linux vhci attachment. +//! +//! The host owns device selection and exposes the claimed device on the fixed USB/IP vsock port. +//! The guest agent still fails closed: it validates the request, performs the standard USB/IP +//! import handshake, verifies that the returned descriptor is the requested device, and only then +//! hands the connected socket fd to `vhci_hcd`. + +use dory_pb::agent; +use std::time::Duration; +use thiserror::Error; + +const USBIP_BUS_ID_BYTES: usize = 32; +const MAX_VHCI_PORT: u32 = 127; +const VHCI_OPERATION_TIMEOUT: Duration = Duration::from_secs(10); + +#[cfg(any(target_os = "linux", test))] +const USBIP_VERSION: u16 = 0x0111; +#[cfg(any(target_os = "linux", test))] +const USBIP_REQ_IMPORT: u16 = 0x8003; +#[cfg(any(target_os = "linux", test))] +const USBIP_REP_IMPORT: u16 = 0x0003; +#[cfg(any(target_os = "linux", test))] +const USBIP_OPERATION_BYTES: usize = 8; +#[cfg(any(target_os = "linux", test))] +const USBIP_DEVICE_BYTES: usize = 312; +#[cfg(any(target_os = "linux", test))] +const USBIP_IMPORT_REQUEST_BYTES: usize = USBIP_OPERATION_BYTES + USBIP_BUS_ID_BYTES; +#[cfg(any(target_os = "linux", test))] +const USBIP_IMPORT_REPLY_BYTES: usize = USBIP_OPERATION_BYTES + USBIP_DEVICE_BYTES; + +#[derive(Debug, Error)] +pub enum UsbVhciError { + #[error("usb vhci is unavailable in this guest")] + Unavailable, + #[error("invalid USB bus id")] + InvalidBusId, + #[error("invalid vhci port {0}")] + InvalidPort(u32), + #[error("USB/IP must use the fixed vsock port {expected}, got {actual}")] + InvalidVsockPort { expected: u32, actual: u32 }, + #[error("invalid USB device id")] + InvalidDeviceId, + #[error("invalid USB speed {0}")] + InvalidSpeed(u32), + #[error("USB/IP I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("USB/IP import reply is malformed")] + MalformedImportReply, + #[error("USB/IP host rejected device import with status {0}")] + ImportRejected(u32), + #[error("USB/IP host returned a different device")] + ImportAuthorityMismatch, + #[error("USB vhci worker failed: {0}")] + Worker(String), + #[error("USB vhci operation timed out")] + Timeout, +} + +impl UsbVhciError { + pub fn code(&self) -> i32 { + match self { + Self::Unavailable => 503, + Self::InvalidBusId + | Self::InvalidPort(_) + | Self::InvalidVsockPort { .. } + | Self::InvalidDeviceId + | Self::InvalidSpeed(_) => 422, + Self::ImportRejected(_) => 409, + Self::Timeout => 504, + Self::Io(_) + | Self::MalformedImportReply + | Self::ImportAuthorityMismatch + | Self::Worker(_) => 502, + } + } +} + +pub fn available() -> bool { + platform::available() +} + +pub async fn attach( + request: agent::UsbVhciAttachRequest, +) -> Result { + validate_attach(&request)?; + tokio::time::timeout(VHCI_OPERATION_TIMEOUT, platform::attach(&request)) + .await + .map_err(|_| UsbVhciError::Timeout)??; + Ok(agent::UsbVhciAttachResponse { + attached: true, + bus_id: request.bus_id, + port: request.port, + device_id: request.device_id, + }) +} + +pub async fn detach( + request: agent::UsbVhciDetachRequest, +) -> Result { + validate_bus_id(&request.bus_id)?; + validate_port(request.port)?; + tokio::time::timeout(VHCI_OPERATION_TIMEOUT, platform::detach(request.port)) + .await + .map_err(|_| UsbVhciError::Timeout)??; + Ok(agent::UsbVhciDetachResponse { + detached: true, + bus_id: request.bus_id, + port: request.port, + }) +} + +fn validate_attach(request: &agent::UsbVhciAttachRequest) -> Result<(), UsbVhciError> { + validate_bus_id(&request.bus_id)?; + validate_port(request.port)?; + if request.vsock_port != dory_proto::channels::PORT_USBIP { + return Err(UsbVhciError::InvalidVsockPort { + expected: dory_proto::channels::PORT_USBIP, + actual: request.vsock_port, + }); + } + if request.device_id == 0 || request.device_id & 0xffff == 0 { + return Err(UsbVhciError::InvalidDeviceId); + } + // Linux USB_SPEED_* values accepted by vhci_hcd: low, full, high, wireless, super, + // super-plus. Unknown speed cannot be attached truthfully. + if !(1..=6).contains(&request.speed) { + return Err(UsbVhciError::InvalidSpeed(request.speed)); + } + Ok(()) +} + +fn validate_bus_id(bus_id: &str) -> Result<(), UsbVhciError> { + let bytes = bus_id.as_bytes(); + if bytes.is_empty() + || bytes.len() >= USBIP_BUS_ID_BYTES + || !bytes + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b'-' || *byte == b'.') + || !bytes[0].is_ascii_digit() + || !bytes[bytes.len() - 1].is_ascii_digit() + { + return Err(UsbVhciError::InvalidBusId); + } + Ok(()) +} + +fn validate_port(port: u32) -> Result<(), UsbVhciError> { + if port > MAX_VHCI_PORT { + return Err(UsbVhciError::InvalidPort(port)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", test))] +fn import_request(bus_id: &str) -> [u8; USBIP_IMPORT_REQUEST_BYTES] { + let mut bytes = [0_u8; USBIP_IMPORT_REQUEST_BYTES]; + bytes[0..2].copy_from_slice(&USBIP_VERSION.to_be_bytes()); + bytes[2..4].copy_from_slice(&USBIP_REQ_IMPORT.to_be_bytes()); + bytes[8..8 + bus_id.len()].copy_from_slice(bus_id.as_bytes()); + bytes +} + +#[cfg(any(target_os = "linux", test))] +fn validate_import_reply( + bytes: &[u8], + request: &agent::UsbVhciAttachRequest, +) -> Result<(), UsbVhciError> { + if bytes.len() != USBIP_IMPORT_REPLY_BYTES { + return Err(UsbVhciError::MalformedImportReply); + } + validate_import_header(&bytes[..USBIP_OPERATION_BYTES])?; + let status = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + if status != 0 { + return Err(UsbVhciError::ImportRejected(status)); + } + let descriptor = &bytes[USBIP_OPERATION_BYTES..]; + let bus_id = c_string(&descriptor[256..288])?; + let bus_number = u32::from_be_bytes(descriptor[288..292].try_into().unwrap()); + let device_number = u32::from_be_bytes(descriptor[292..296].try_into().unwrap()); + let speed = u32::from_be_bytes(descriptor[296..300].try_into().unwrap()); + if bus_number > u16::MAX.into() || device_number == 0 || device_number > u16::MAX.into() { + return Err(UsbVhciError::ImportAuthorityMismatch); + } + let device_id = (bus_number << 16) | device_number; + if bus_id != request.bus_id || device_id != request.device_id || speed != request.speed { + return Err(UsbVhciError::ImportAuthorityMismatch); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", test))] +fn validate_import_header(bytes: &[u8]) -> Result<(), UsbVhciError> { + if bytes.len() != USBIP_OPERATION_BYTES + || u16::from_be_bytes([bytes[0], bytes[1]]) != USBIP_VERSION + || u16::from_be_bytes([bytes[2], bytes[3]]) != USBIP_REP_IMPORT + { + return Err(UsbVhciError::MalformedImportReply); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", test))] +fn c_string(bytes: &[u8]) -> Result<&str, UsbVhciError> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + std::str::from_utf8(&bytes[..end]).map_err(|_| UsbVhciError::MalformedImportReply) +} + +#[cfg(target_os = "linux")] +mod platform { + use super::*; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::path::{Path, PathBuf}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_vsock::{VsockAddr, VsockStream, VMADDR_CID_HOST}; + + const VHCI_PLATFORM_ROOT: &str = "/sys/devices/platform"; + + pub fn available() -> bool { + control_files() + .and_then(|(attach, detach)| { + std::fs::OpenOptions::new().write(true).open(attach)?; + std::fs::OpenOptions::new().write(true).open(detach)?; + Ok(()) + }) + .is_ok() + } + + pub async fn attach(request: &agent::UsbVhciAttachRequest) -> Result<(), UsbVhciError> { + let mut stream = VsockStream::connect(VsockAddr::new( + VMADDR_CID_HOST, + dory_proto::channels::PORT_USBIP, + )) + .await?; + AsyncWriteExt::write_all(&mut stream, &import_request(&request.bus_id)).await?; + + let mut header = [0_u8; USBIP_OPERATION_BYTES]; + stream.read_exact(&mut header).await?; + validate_import_header(&header)?; + let status = u32::from_be_bytes(header[4..8].try_into().unwrap()); + if status != 0 { + return Err(UsbVhciError::ImportRejected(status)); + } + let mut reply = [0_u8; USBIP_IMPORT_REPLY_BYTES]; + reply[..USBIP_OPERATION_BYTES].copy_from_slice(&header); + stream + .read_exact(&mut reply[USBIP_OPERATION_BYTES..]) + .await?; + validate_import_reply(&reply, request)?; + + let socket_fd = stream.as_raw_fd(); + let command = format!( + "{} {} {} {}", + request.port, socket_fd, request.device_id, request.speed + ); + tokio::task::spawn_blocking(move || write_control("attach", &command)) + .await + .map_err(|error| UsbVhciError::Worker(error.to_string()))??; + // vhci_hcd takes its own reference to the socket when the sysfs write succeeds. + drop(stream); + Ok(()) + } + + pub async fn detach(port: u32) -> Result<(), UsbVhciError> { + tokio::task::spawn_blocking(move || write_control("detach", &port.to_string())) + .await + .map_err(|error| UsbVhciError::Worker(error.to_string()))??; + Ok(()) + } + + fn write_control(name: &str, value: &str) -> Result<(), UsbVhciError> { + let (attach, detach) = control_files()?; + let path = match name { + "attach" => attach, + "detach" => detach, + _ => return Err(UsbVhciError::Unavailable), + }; + let mut file = std::fs::OpenOptions::new().write(true).open(path)?; + file.write_all(value.as_bytes())?; + Ok(()) + } + + fn control_files() -> std::io::Result<(PathBuf, PathBuf)> { + let root = Path::new(VHCI_PLATFORM_ROOT); + let mut controllers = std::fs::read_dir(root)? + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name == "vhci_hcd" || name.starts_with("vhci_hcd.") + }) + .map(|entry| entry.path()) + .collect::>(); + controllers.sort(); + for controller in controllers { + let attach = controller.join("attach"); + let detach = controller.join("detach"); + if attach.exists() && detach.exists() { + return Ok((attach, detach)); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "vhci_hcd sysfs controls are unavailable", + )) + } +} + +#[cfg(not(target_os = "linux"))] +mod platform { + use super::*; + + pub fn available() -> bool { + false + } + + pub async fn attach(_request: &agent::UsbVhciAttachRequest) -> Result<(), UsbVhciError> { + Err(UsbVhciError::Unavailable) + } + + pub async fn detach(_port: u32) -> Result<(), UsbVhciError> { + Err(UsbVhciError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> agent::UsbVhciAttachRequest { + agent::UsbVhciAttachRequest { + bus_id: "3-2.1".into(), + port: 4, + vsock_port: dory_proto::channels::PORT_USBIP, + device_id: (3 << 16) | 2, + speed: 3, + } + } + + fn success_reply(request: &agent::UsbVhciAttachRequest) -> Vec { + let mut bytes = vec![0_u8; USBIP_IMPORT_REPLY_BYTES]; + bytes[0..2].copy_from_slice(&USBIP_VERSION.to_be_bytes()); + bytes[2..4].copy_from_slice(&USBIP_REP_IMPORT.to_be_bytes()); + let descriptor = &mut bytes[USBIP_OPERATION_BYTES..]; + descriptor[256..256 + request.bus_id.len()].copy_from_slice(request.bus_id.as_bytes()); + descriptor[288..292].copy_from_slice(&(request.device_id >> 16).to_be_bytes()); + descriptor[292..296].copy_from_slice(&(request.device_id & 0xffff).to_be_bytes()); + descriptor[296..300].copy_from_slice(&request.speed.to_be_bytes()); + bytes + } + + #[test] + fn import_request_matches_usbip_wire_contract() { + let bytes = import_request("3-2.1"); + assert_eq!(bytes.len(), 40); + assert_eq!(&bytes[0..2], &USBIP_VERSION.to_be_bytes()); + assert_eq!(&bytes[2..4], &USBIP_REQ_IMPORT.to_be_bytes()); + assert_eq!(&bytes[8..13], b"3-2.1"); + assert!(bytes[13..].iter().all(|byte| *byte == 0)); + } + + #[test] + fn validation_rejects_untrusted_attach_coordinates() { + let valid = request(); + assert!(validate_attach(&valid).is_ok()); + + for bus_id in ["", "-1", "1-", "1/a", "é-1"] { + let mut candidate = valid.clone(); + candidate.bus_id = bus_id.into(); + assert!(matches!( + validate_attach(&candidate), + Err(UsbVhciError::InvalidBusId) + )); + } + + let mut candidate = valid.clone(); + candidate.port = MAX_VHCI_PORT + 1; + assert!(matches!( + validate_attach(&candidate), + Err(UsbVhciError::InvalidPort(_)) + )); + + let mut candidate = valid.clone(); + candidate.vsock_port += 1; + assert!(matches!( + validate_attach(&candidate), + Err(UsbVhciError::InvalidVsockPort { .. }) + )); + + let mut candidate = valid.clone(); + candidate.speed = 0; + assert!(matches!( + validate_attach(&candidate), + Err(UsbVhciError::InvalidSpeed(0)) + )); + } + + #[test] + fn import_reply_is_bound_to_exact_host_device() { + let request = request(); + let reply = success_reply(&request); + assert!(validate_import_reply(&reply, &request).is_ok()); + + for mutate in ["bus", "device", "speed"] { + let mut changed = reply.clone(); + match mutate { + "bus" => changed[USBIP_OPERATION_BYTES + 256] = b'9', + "device" => changed[USBIP_OPERATION_BYTES + 295] ^= 1, + "speed" => changed[USBIP_OPERATION_BYTES + 299] ^= 1, + _ => unreachable!(), + } + assert!(matches!( + validate_import_reply(&changed, &request), + Err(UsbVhciError::ImportAuthorityMismatch) + )); + } + } + + #[test] + fn import_reply_rejects_protocol_and_host_failure() { + let request = request(); + let mut reply = success_reply(&request); + reply[2..4].copy_from_slice(&0x9999_u16.to_be_bytes()); + assert!(matches!( + validate_import_reply(&reply, &request), + Err(UsbVhciError::MalformedImportReply) + )); + + let mut reply = success_reply(&request); + reply[4..8].copy_from_slice(&7_u32.to_be_bytes()); + assert!(matches!( + validate_import_reply(&reply, &request), + Err(UsbVhciError::ImportRejected(7)) + )); + } +} diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs index 006c407b..9e5c33ef 100644 --- a/dory-core/ffi/src/agent_forward.rs +++ b/dory-core/ffi/src/agent_forward.rs @@ -320,6 +320,50 @@ impl AgentControl { Ok(receipt.operation_id) } + pub fn usb_vhci_attach( + &self, + bus_id: String, + port: u32, + vsock_port: u32, + device_id: u32, + speed: u32, + ) -> Result<(), RemoteFfiError> { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let response = runtime.block_on(self.client.usb_vhci_attach( + dory_pb::agent::UsbVhciAttachRequest { + bus_id: bus_id.clone(), + port, + vsock_port, + device_id, + speed, + }, + ))?; + if !response.attached + || response.bus_id != bus_id + || response.port != port + || response.device_id != device_id + { + return Err(failed("guest returned a mismatched USB attach receipt")); + } + Ok(()) + } + + pub fn usb_vhci_detach(&self, bus_id: String, port: u32) -> Result<(), RemoteFfiError> { + let guard = self.runtime.lock().unwrap(); + let runtime = guard.as_ref().ok_or_else(shutdown_error)?; + let response = runtime.block_on(self.client.usb_vhci_detach( + dory_pb::agent::UsbVhciDetachRequest { + bus_id: bus_id.clone(), + port, + }, + ))?; + if !response.detached || response.bus_id != bus_id || response.port != port { + return Err(failed("guest returned a mismatched USB detach receipt")); + } + Ok(()) + } + pub fn exec( &self, argv: Vec, diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto index dfee7b91..3bca0914 100644 --- a/dory-core/pb/proto/agent.proto +++ b/dory-core/pb/proto/agent.proto @@ -99,6 +99,33 @@ message LifecycleReceiptResponse { string operation_id = 3; } +// Attach one host-exported USB/IP device to the guest's vhci controller. The host has already +// claimed the device and exposed it on the fixed USB/IP vsock channel; the guest agent performs +// the USB/IP import handshake and passes that exact connected socket to vhci_hcd. +message UsbVhciAttachRequest { + string bus_id = 1; + uint32 port = 2; + uint32 vsock_port = 3; + uint32 device_id = 4; + uint32 speed = 5; +} +message UsbVhciAttachResponse { + bool attached = 1; + string bus_id = 2; + uint32 port = 3; + uint32 device_id = 4; +} + +message UsbVhciDetachRequest { + string bus_id = 1; + uint32 port = 2; +} +message UsbVhciDetachResponse { + bool detached = 1; + string bus_id = 2; + uint32 port = 3; +} + // Run a bounded non-interactive command in the guest. This is the primitive doryd uses for // VM-machine provisioning and smoke checks; streaming terminals/debug shells can layer on a // separate channel later. @@ -242,6 +269,8 @@ message AgentRequest { SyncReadTreeRequest sync_read_tree = 12; SyncGetChunkRequest sync_get_chunk = 13; LifecycleReceiptRequest lifecycle_receipt = 14; + UsbVhciAttachRequest usb_vhci_attach = 15; + UsbVhciDetachRequest usb_vhci_detach = 16; } } @@ -262,5 +291,7 @@ message AgentResponse { SyncReadTreeResponse sync_read_tree = 13; SyncGetChunkResponse sync_get_chunk = 14; LifecycleReceiptResponse lifecycle_receipt = 15; + UsbVhciAttachResponse usb_vhci_attach = 16; + UsbVhciDetachResponse usb_vhci_detach = 17; } } diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs index 6e46d843..78940a3d 100644 --- a/dory-core/remote/src/agent_client.rs +++ b/dory-core/remote/src/agent_client.rs @@ -16,7 +16,8 @@ use dory_pb::agent::{ SyncDeleteRequest, SyncDeleteResponse, SyncFileStatusRequest, SyncFileStatusResponse, SyncGetChunkRequest, SyncGetChunkResponse, SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse, SyncReadTreeRequest, SyncReadTreeResponse, - TelemetryRequest, TelemetryResponse, + TelemetryRequest, TelemetryResponse, UsbVhciAttachRequest, UsbVhciAttachResponse, + UsbVhciDetachRequest, UsbVhciDetachResponse, }; use dory_proto::handshake::{handshake, Hello}; use dory_proto::mux::Mux; @@ -162,6 +163,26 @@ impl AgentClient { } } + pub async fn usb_vhci_attach( + &self, + request: UsbVhciAttachRequest, + ) -> Result { + match self.call(Method::UsbVhciAttach(request)).await? { + Res::UsbVhciAttach(response) => Ok(response), + _ => Err(RemoteError::UnexpectedVariant), + } + } + + pub async fn usb_vhci_detach( + &self, + request: UsbVhciDetachRequest, + ) -> Result { + match self.call(Method::UsbVhciDetach(request)).await? { + Res::UsbVhciDetach(response) => Ok(response), + _ => Err(RemoteError::UnexpectedVariant), + } + } + pub async fn exec( &self, argv: Vec, @@ -363,6 +384,21 @@ mod tests { operation_id: request.operation_id, }) } + Some(Method::UsbVhciAttach(request)) => { + Res::UsbVhciAttach(agent::UsbVhciAttachResponse { + attached: true, + bus_id: request.bus_id, + port: request.port, + device_id: request.device_id, + }) + } + Some(Method::UsbVhciDetach(request)) => { + Res::UsbVhciDetach(agent::UsbVhciDetachResponse { + detached: true, + bus_id: request.bus_id, + port: request.port, + }) + } Some(Method::SyncReadTree(_)) => Res::SyncReadTree(agent::SyncReadTreeResponse { files: vec![agent::SyncFileEntry { path: "report.txt".into(), @@ -444,6 +480,30 @@ mod tests { ); assert_eq!(receipt.operation_id, operation_id); + let attached = client + .usb_vhci_attach(UsbVhciAttachRequest { + bus_id: "3-2".into(), + port: 1, + vsock_port: dory_proto::channels::PORT_USBIP, + device_id: (3 << 16) | 2, + speed: 3, + }) + .await + .unwrap(); + assert!(attached.attached); + assert_eq!(attached.bus_id, "3-2"); + assert_eq!(attached.device_id, (3 << 16) | 2); + + let detached = client + .usb_vhci_detach(UsbVhciDetachRequest { + bus_id: "3-2".into(), + port: 1, + }) + .await + .unwrap(); + assert!(detached.detached); + assert_eq!(detached.bus_id, "3-2"); + let tree = client .sync_read_tree(SyncReadTreeRequest { root: "/home/dory/Downloads".into(), From 7c7c8c17d56ee4d7b8d86edef3636a87f5dcf3dc Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 02:04:08 +0000 Subject: [PATCH 238/338] feat(vm): scope usb control per machine --- .../Sources/dory-hv/DesktopMode.swift | 42 ++++++++++++++++++- .../Sources/dory-hv/main.swift | 4 ++ .../Sources/DorydKit/MachineManager.swift | 5 +++ .../DorydKitTests/MachineManagerTests.swift | 9 ++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index bcc8c610..e031907d 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -425,6 +425,7 @@ enum DesktopMode { var agentSocketPath: String var shellSocketPath: String var controlSocketPath: String + var usbControlSocketPath: String var sshAgentSocketPath: String? var memoryMB: UInt64 var cpuCount: Int @@ -518,6 +519,8 @@ enum DesktopMode { private let agentBridge: GuestVsockSocketBridge private let shellBridge: GuestVsockSocketBridge private let sshAgentBridge: HostSSHAgentBridge? + private let usbipManager: UsbipManager + private let usbControlServer: UsbControlServer private let clipboard: DoryDesktopClipboardCoordinator? private let firstFrame: FirstFrameGate private let deviceTelemetry: RawDeviceTelemetryRegistry @@ -630,7 +633,35 @@ enum DesktopMode { cursorMailbox.submit(update) } ) - self.vsock = VirtioVsock(guestCID: 3) + let vsock = VirtioVsock(guestCID: 3) + self.vsock = vsock + let usbipManager = UsbipManager() + usbipManager.attachListener(to: vsock) + self.usbipManager = usbipManager + let usbControlHandler = UsbControlHandler( + manager: usbipManager, + ensureSupported: { + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + }, + openDevice: { busID, mode in + try HostUsbDeviceFactory.open(busID: busID, mode: mode) + }, + notifyAttach: { request in + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciAttach(request) + }, + notifyDetach: { request in + let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciDetach(request) + } + ) + self.usbControlServer = UsbControlServer( + path: configuration.usbControlSocketPath, + handler: usbControlHandler + ) self.audio = DoryMacAudioBackend(log: Self.log) let sound = VirtioSound(host: audio, log: Self.log) let balloon = VirtioBalloon(memory: machine.memory) { message in @@ -791,7 +822,13 @@ enum DesktopMode { } func run() throws { - try lifecycleReceiptServer.start() + try usbControlServer.start() + do { + try lifecycleReceiptServer.start() + } catch { + usbControlServer.stop() + throw error + } application.setActivationPolicy(.regular) application.delegate = self installApplicationMenu() @@ -981,6 +1018,7 @@ enum DesktopMode { private func cleanup() { lifecycleReceiptServer.stop() + usbControlServer.stop() clipboard?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index 3a0c193c..f1aef0ce 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -433,6 +433,7 @@ case "desktop": var agentSocket: String? var shellSocket: String? var controlSocket: String? + var usbControlSocket: String? var sshAgentSocket: String? var memoryMB: UInt64 = 6_144 var cpus = 6 @@ -496,6 +497,7 @@ case "desktop": fail("raw-HV desktop requires --boot-mode linux-kernel or efi-installed") } case "--control-sock": controlSocket = iterator.next() + case "--usb-control-sock": usbControlSocket = iterator.next() case "--dockerd-sock": _ = iterator.next() // accepted for dory-vmm command-line compatibility default: @@ -513,6 +515,7 @@ case "desktop": guard let agentSocket else { fail("desktop requires --agent-sock") } guard let shellSocket else { fail("desktop requires --shell-sock") } guard let controlSocket else { fail("desktop requires --control-sock") } + guard let usbControlSocket else { fail("desktop requires --usb-control-sock") } do { try DesktopMode.run(.init( machineID: machineID, @@ -528,6 +531,7 @@ case "desktop": agentSocketPath: agentSocket, shellSocketPath: shellSocket, controlSocketPath: controlSocket, + usbControlSocketPath: usbControlSocket, sshAgentSocketPath: sshAgentSocket, memoryMB: memoryMB, cpuCount: cpus, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index f7c25c6b..b1460839 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -6085,6 +6085,11 @@ public final class MachineManager: @unchecked Sendable { "--cpus", String(machine.cpuCount), "--display-mode", machine.displayMode.rawValue, ] + if acceleratedDesktop { + arguments.append(contentsOf: [ + "--usb-control-sock", "\(machineRuntimeDirectory(id: machine.id))/u.sock", + ]) + } if let bootDescriptor { arguments.append(contentsOf: [ "--initrd", machineInstalledLinuxInitrdPath(id: machine.id), diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 48c4dbce..19ed07c3 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3984,6 +3984,7 @@ final class MachineManagerTests: XCTestCase { XCTAssertTrue(path.hasPrefix(runtime + "/"), "\(flag) should use transient runtime storage") XCTAssertLessThan(path.utf8.count, 104) } + XCTAssertFalse(arguments.contains("--usb-control-sock")) _ = try manager.stop(id: "dev") } @@ -4036,6 +4037,13 @@ final class MachineManagerTests: XCTestCase { XCTAssertEqual(Array(desktopArguments.prefix(3)), ["desktop", "--gvproxy", "/tmp/gvproxy"]) let desktopDisplayIndex = try XCTUnwrap(desktopArguments.firstIndex(of: "--display-mode")) XCTAssertEqual(desktopArguments[desktopDisplayIndex + 1], "desktop") + let usbControlIndex = try XCTUnwrap( + desktopArguments.firstIndex(of: "--usb-control-sock") + ) + let usbControlPath = desktopArguments[usbControlIndex + 1] + XCTAssertTrue(usbControlPath.hasPrefix(base + "/machines/")) + XCTAssertTrue(usbControlPath.hasSuffix("/u.sock")) + XCTAssertLessThan(usbControlPath.utf8.count, 104) _ = try manager.stop(id: "desktop") _ = try manager.create(DoryMachineConfiguration( @@ -4052,6 +4060,7 @@ final class MachineManagerTests: XCTestCase { let compatibleArguments = try String(contentsOfFile: fallbackCapture, encoding: .utf8) .split(separator: "\n").map(String.init) XCTAssertEqual(compatibleArguments.first, "fallback") + XCTAssertFalse(compatibleArguments.contains("--usb-control-sock")) let compatibleDisplayIndex = try XCTUnwrap(compatibleArguments.firstIndex(of: "--display-mode")) XCTAssertEqual(compatibleArguments[compatibleDisplayIndex + 1], "desktop") _ = try manager.stop(id: "compatible") From 4e06dee55add4380db1e26c4e8951136b182ae1f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 02:20:23 +0000 Subject: [PATCH 239/338] feat(vm): route usb control through daemon --- COMPATIBILITY.md | 5 +- Dory/Net/UsbAttachmentStore.swift | 5 +- DoryTests/UsbAttachmentStoreTests.swift | 2 +- .../Sources/DoryHV/Usb/UsbControlServer.swift | 70 ++++- .../Tests/DoryHVTests/UsbipBridgeTests.swift | 45 +++ .../DorydKit/DoryMachineUSBControl.swift | 279 ++++++++++++++++++ .../Sources/DorydKit/MachineManager.swift | 126 ++++++++ .../DoryMachineUSBControlTests.swift | 150 ++++++++++ .../MachineManagerUSBControlTests.swift | 175 +++++++++++ dory-core/README.md | 5 +- 10 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineUSBControlTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 152189ea..67ade990 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -139,8 +139,9 @@ grants; `full` is an explicit unrestricted choice. See the ## Unavailable -- USB attach, detach, and remembered replay are unavailable. Host discovery is supported, but the - engine fails closed until a complete guest USB/IP RPC and physical qualification exist. +- USB attach, detach, and remembered replay are unavailable. Host discovery, the guest USB/IP RPC, + and a private raw-HV machine route exist, but the product fails closed until removable-device + plan authorization and physical qualification exist. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift index 67b061e2..31b51439 100644 --- a/Dory/Net/UsbAttachmentStore.swift +++ b/Dory/Net/UsbAttachmentStore.swift @@ -3,8 +3,9 @@ import Foundation enum UsbPassthroughAvailability: Sendable { static let attachSupported = false static let unavailableReason = - "USB passthrough is not available in the current Dory engine. Host discovery works, but " + - "attach, detach, and automatic replay remain disabled until the guest USB/IP RPC ships." + "USB passthrough is not yet available in the public app. Host discovery and the private " + + "machine route work, but attach, detach, and replay remain disabled until removable-device " + + "plan authorization and physical qualification ship." } struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { diff --git a/DoryTests/UsbAttachmentStoreTests.swift b/DoryTests/UsbAttachmentStoreTests.swift index 0985259c..a07c2ff7 100644 --- a/DoryTests/UsbAttachmentStoreTests.swift +++ b/DoryTests/UsbAttachmentStoreTests.swift @@ -5,7 +5,7 @@ import Testing struct UsbAttachmentStoreTests { @Test func passthroughIsTruthfullyDisabledUntilGuestRPCShips() { #expect(!UsbPassthroughAvailability.attachSupported) - #expect(UsbPassthroughAvailability.unavailableReason.contains("guest USB/IP RPC")) + #expect(UsbPassthroughAvailability.unavailableReason.contains("removable-device")) } @Test func remembersAttachmentsSortedByMachineAndBusID() throws { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift index 4891ae65..0ea8f951 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift @@ -67,7 +67,9 @@ public final class UsbControlServer: @unchecked Sendable { private let path: String private let handler: UsbControlHandler private let queue = DispatchQueue(label: "dory.usb.control") + private let lock = NSLock() private var listenFD: Int32 = -1 + private var boundIdentity: (device: dev_t, inode: ino_t)? public init(path: String, handler: UsbControlHandler) { self.path = path @@ -75,27 +77,75 @@ public final class UsbControlServer: @unchecked Sendable { } public func start() throws { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } - unlink(path) + lock.lock() + defer { lock.unlock() } + guard listenFD < 0 else { + return + } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) + let capacity = withUnsafeBytes(of: &address.sun_path) { $0.count } + guard path.first == "/", path.utf8.count < capacity else { + throw UsbControlServerError.socket("invalid unix socket path") + } + var stale = stat() + if lstat(path, &stale) == 0 { + guard stale.st_mode & S_IFMT == S_IFSOCK, + stale.st_uid == geteuid(), + unlink(path) == 0 else { + throw UsbControlServerError.socket("refusing to replace untrusted path \(path)") + } + } else if errno != ENOENT { + throw UsbControlServerError.socket("lstat \(path): errno \(errno)") + } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } Self.copyPath(path, into: &address) - let bound = withUnsafePointer(to: &address) { pointer in + let bindResult = withUnsafePointer(to: &address) { pointer in pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } - guard bound == 0 else { close(fd); throw UsbControlServerError.socket("bind \(path): errno \(errno)") } - guard listen(fd, 8) == 0 else { close(fd); throw UsbControlServerError.socket("listen: errno \(errno)") } + guard bindResult == 0 else { close(fd); throw UsbControlServerError.socket("bind \(path): errno \(errno)") } + guard chmod(path, 0o600) == 0 else { + close(fd) + unlink(path) + throw UsbControlServerError.socket("chmod \(path): errno \(errno)") + } + guard listen(fd, 8) == 0 else { + close(fd) + unlink(path) + throw UsbControlServerError.socket("listen: errno \(errno)") + } + var bound = stat() + guard lstat(path, &bound) == 0, + bound.st_mode & S_IFMT == S_IFSOCK, + bound.st_uid == geteuid() else { + close(fd) + unlink(path) + throw UsbControlServerError.socket("could not bind trusted socket \(path)") + } listenFD = fd - queue.async { [weak self] in self?.acceptLoop() } + boundIdentity = (bound.st_dev, bound.st_ino) + queue.async { [weak self] in self?.acceptLoop(listenFD: fd) } } public func stop() { - if listenFD >= 0 { close(listenFD); listenFD = -1 } - unlink(path) + lock.lock() + let descriptor = listenFD + listenFD = -1 + let identity = boundIdentity + boundIdentity = nil + lock.unlock() + if descriptor >= 0 { close(descriptor) } + guard let identity else { return } + var current = stat() + if lstat(path, ¤t) == 0, + current.st_dev == identity.device, + current.st_ino == identity.inode { + unlink(path) + } } - private func acceptLoop() { + private func acceptLoop(listenFD: Int32) { while true { let client = accept(listenFD, nil, nil) guard client >= 0 else { return } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift index 99d0a48f..9e1076a6 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Testing @testable import DoryHV @@ -240,6 +241,50 @@ struct UsbControlHandlerTests { } } +@Suite(.serialized) +struct UsbControlServerTests { + @Test func createsOwnerPrivateSocketAndRemovesOnlyItsOwnNode() throws { + let root = "/tmp/dory-usb-server-\(getpid())-\(UInt32.random(in: 0.. UsbControlHandler { + UsbControlHandler( + manager: UsbipManager(), + openDevice: { busID, _ in + StubExportedDevice(descriptor: fixtureDescriptor(busID: busID)) + }, + notifyAttach: { _ in }, + notifyDetach: { _ in } + ) + } +} + private final class StubExportedDevice: UsbipExportedDevice, @unchecked Sendable { let descriptor: UsbipDeviceDescriptor let submitPayload: [UInt8] diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift b/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift new file mode 100644 index 00000000..40f9c53e --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift @@ -0,0 +1,279 @@ +import Darwin +import Foundation + +public enum DoryMachineUSBOpenMode: String, Codable, CaseIterable, Sendable { + case userAuthorized + case seize + case capture +} + +public struct DoryMachineUSBAttachment: Equatable, Sendable { + public var machineID: String + public var busID: String + public var port: Int + public var vsockPort: UInt32 + public var deviceID: UInt32 + public var speed: UInt32 + + public init( + machineID: String, + busID: String, + port: Int, + vsockPort: UInt32, + deviceID: UInt32, + speed: UInt32 + ) { + self.machineID = machineID + self.busID = busID + self.port = port + self.vsockPort = vsockPort + self.deviceID = deviceID + self.speed = speed + } +} + +public protocol DoryMachineUSBControlling: Sendable { + func attach( + machineID: String, + socketPath: String, + busID: String, + mode: DoryMachineUSBOpenMode + ) throws -> DoryMachineUSBAttachment + func detach(socketPath: String, busID: String) throws +} + +public enum DoryMachineUSBControlError: Error, Equatable, Sendable, CustomStringConvertible { + case invalidBusID + case invalidSocketPath + case untrustedSocket + case syscall(String, Int32) + case malformedResponse + case rejected(String) + + public var description: String { + switch self { + case .invalidBusID: + return "USB bus identifier is invalid" + case .invalidSocketPath: + return "USB control socket path is invalid" + case .untrustedSocket: + return "USB control socket is not owned by the current user" + case let .syscall(name, code): + return "USB control \(name) failed: \(String(cString: strerror(code)))" + case .malformedResponse: + return "USB control returned a malformed response" + case let .rejected(message): + return "USB control rejected the request: \(message)" + } + } +} + +/// Daemon-side client for the private per-machine raw-HV USB socket. The helper owns host-device +/// claims; doryd only selects the already-authorized live machine and forwards one bounded request. +public struct UnixDoryMachineUSBController: DoryMachineUSBControlling, Sendable { + private static let usbipVsockPort: UInt32 = 1025 + private static let maximumMessageBytes = 8 * 1024 + private static let operationTimeoutSeconds = 15 + + public init() {} + + public func attach( + machineID: String, + socketPath: String, + busID: String, + mode: DoryMachineUSBOpenMode + ) throws -> DoryMachineUSBAttachment { + let busID = try Self.validatedBusID(busID) + let response = try send( + Request(command: "attach", busID: busID, mode: mode.rawValue), + socketPath: socketPath + ) + guard response.ok, + response.error == nil, + let port = response.port, + (0...65_535).contains(port), + response.vsockPort == Self.usbipVsockPort, + let deviceID = response.deviceID, + deviceID != 0, + let speed = response.speed, + speed > 0 else { + if !response.ok, let error = response.error, !error.isEmpty { + throw DoryMachineUSBControlError.rejected(error) + } + throw DoryMachineUSBControlError.malformedResponse + } + return DoryMachineUSBAttachment( + machineID: machineID, + busID: busID, + port: port, + vsockPort: Self.usbipVsockPort, + deviceID: deviceID, + speed: speed + ) + } + + public func detach(socketPath: String, busID: String) throws { + let busID = try Self.validatedBusID(busID) + let response = try send( + Request(command: "detach", busID: busID, mode: nil), + socketPath: socketPath + ) + guard response.ok, + response.error == nil, + response.port == nil, + response.vsockPort == nil, + response.deviceID == nil, + response.speed == nil else { + if !response.ok, let error = response.error, !error.isEmpty { + throw DoryMachineUSBControlError.rejected(error) + } + throw DoryMachineUSBControlError.malformedResponse + } + } + + private func send(_ request: Request, socketPath: String) throws -> Response { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let capacity = withUnsafeBytes(of: &address.sun_path) { $0.count } + guard socketPath.first == "/", + !socketPath.contains("\0"), + socketPath.utf8.count < capacity else { + throw DoryMachineUSBControlError.invalidSocketPath + } + + var before = stat() + guard lstat(socketPath, &before) == 0 else { + throw DoryMachineUSBControlError.syscall("lstat", errno) + } + guard before.st_mode & S_IFMT == S_IFSOCK, + before.st_uid == geteuid() else { + throw DoryMachineUSBControlError.untrustedSocket + } + + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw DoryMachineUSBControlError.syscall("socket", errno) + } + defer { close(descriptor) } + var timeout = timeval(tv_sec: Self.operationTimeoutSeconds, tv_usec: 0) + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_RCVTIMEO, + &timeout, + socklen_t(MemoryLayout.size) + ) == 0, + setsockopt( + descriptor, + SOL_SOCKET, + SO_SNDTIMEO, + &timeout, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw DoryMachineUSBControlError.syscall("setsockopt", errno) + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.initializeMemory(as: UInt8.self, repeating: 0) + destination.copyBytes(from: socketPath.utf8) + } + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard connected == 0 else { + throw DoryMachineUSBControlError.syscall("connect", errno) + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(descriptor, &peerUID, &peerGID) == 0, + peerUID == geteuid() else { + throw DoryMachineUSBControlError.untrustedSocket + } + var after = stat() + guard lstat(socketPath, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + after.st_uid == geteuid(), + after.st_mode & S_IFMT == S_IFSOCK else { + throw DoryMachineUSBControlError.untrustedSocket + } + + var payload = try JSONEncoder().encode(request) + payload.append(0x0a) + try Self.writeAll(payload, descriptor: descriptor) + var response = Data() + var byte: UInt8 = 0 + while response.count < Self.maximumMessageBytes { + let count = Darwin.read(descriptor, &byte, 1) + guard count == 1 else { + throw DoryMachineUSBControlError.syscall("read", count < 0 ? errno : ECONNRESET) + } + if byte == 0x0a { + return try JSONDecoder().decode(Response.self, from: response) + } + response.append(byte) + } + throw DoryMachineUSBControlError.malformedResponse + } + + private static func validatedBusID(_ value: String) throws -> String { + guard !value.isEmpty, + value.utf8.count <= 128, + value.unicodeScalars.allSatisfy({ scalar in + scalar.isASCII && ( + CharacterSet.alphanumerics.contains(scalar) + || "_.-:/".unicodeScalars.contains(scalar) + ) + }) else { + throw DoryMachineUSBControlError.invalidBusID + } + return value + } + + private static func writeAll(_ data: Data, descriptor: Int32) throws { + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, + bytes.baseAddress?.advanced(by: offset), + bytes.count - offset + ) + if count < 0, errno == EINTR { continue } + guard count > 0 else { + throw DoryMachineUSBControlError.syscall( + "write", + count < 0 ? errno : EPIPE + ) + } + offset += count + } + } + } + + private struct Request: Encodable { + var command: String + var busID: String + var mode: String? + + enum CodingKeys: String, CodingKey { + case command = "cmd" + case busID = "busid" + case mode + } + } + + private struct Response: Decodable { + var ok: Bool + var port: Int? + var vsockPort: UInt32? + var deviceID: UInt32? + var speed: UInt32? + var error: String? + } +} diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index b1460839..940c8b9f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -830,6 +830,8 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert case balloonApplyFailed(String, String) case deviceTelemetryUnavailable(String) case deviceTelemetryRejected(String, String) + case usbUnavailable(String) + case usbControlFailed(String, String) case invalidAddress(String) case invalidShare(String) case invalidEnvironment(String) @@ -861,6 +863,10 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert return "machine device telemetry is unavailable: \(id)" case let .deviceTelemetryRejected(id, message): return "machine device telemetry was rejected for \(id): \(message)" + case let .usbUnavailable(id): + return "machine USB passthrough is unavailable for \(id)" + case let .usbControlFailed(id, message): + return "machine USB control failed for \(id): \(message)" case let .invalidAddress(address): return "invalid machine address: \(address)" case let .invalidShare(share): @@ -974,6 +980,7 @@ public final class MachineManager: @unchecked Sendable { private let agentConnector: AgentConnector private let balloonController: any MachineBalloonControlling private let deviceTelemetryController: any MachineDeviceTelemetryControlling + private let usbController: any DoryMachineUSBControlling private let vzLifecycleController: any MachineVZLifecycleControlling private let savedStateStore: DoryMachineSavedStateStore private let processStarter: ProcessStarter @@ -1025,6 +1032,7 @@ public final class MachineManager: @unchecked Sendable { balloonController: any MachineBalloonControlling = UnixMachineBalloonController(), deviceTelemetryController: any MachineDeviceTelemetryControlling = UnixMachineDeviceTelemetryController(), + usbController: any DoryMachineUSBControlling = UnixDoryMachineUSBController(), vzLifecycleController: any MachineVZLifecycleControlling = UnixMachineVZLifecycleController(), agentConnector: @escaping AgentConnector = { socketPath in try LocalAgentControl.connect(socketPath: socketPath) @@ -1035,6 +1043,7 @@ public final class MachineManager: @unchecked Sendable { self.launchPolicy = launchPolicy self.balloonController = balloonController self.deviceTelemetryController = deviceTelemetryController + self.usbController = usbController self.vzLifecycleController = vzLifecycleController self.agentConnector = agentConnector self.processStarter = processStarter @@ -6790,6 +6799,123 @@ public final class MachineManager: @unchecked Sendable { return snapshot } + /// Routes a hotplug request only to the exact live raw-HV helper selected for this machine. + /// Resolved-plan workspaces remain fail-closed until removable USB is represented in the + /// durable device graph; this compatibility route cannot mutate an exact resolved launch. + public func attachUSBDevice( + id: String, + busID: String, + mode: DoryMachineUSBOpenMode = .userAuthorized + ) throws -> DoryMachineUSBAttachment { + operationLock.lock() + defer { operationLock.unlock() } + + let launchID: UUID + let socketPath: String + lock.lock() + guard let entry = machines[id], + entry.state == .running, + entry.process?.isRunning == true, + entry.activeBackend == .doryHypervisor, + entry.runtimeIdentity.mode == .legacyCompatibility, + let currentLaunchID = entry.launchID, + entry.handoff?.ready.supportsAgentCapability( + "usb-vhci", + minimumVersion: 1 + ) == true else { + lock.unlock() + throw MachineManagerError.usbUnavailable(id) + } + launchID = currentLaunchID + socketPath = "\(machineRuntimeDirectory(id: id))/u.sock" + lock.unlock() + + let attachment: DoryMachineUSBAttachment + do { + attachment = try usbController.attach( + machineID: id, + socketPath: socketPath, + busID: busID, + mode: mode + ) + } catch { + throw MachineManagerError.usbControlFailed(id, "\(error)") + } + guard attachment.machineID == id, + attachment.busID == busID else { + throw MachineManagerError.usbControlFailed( + id, + "helper response does not match the requested machine and device" + ) + } + + lock.lock() + defer { lock.unlock() } + guard let entry = machines[id], + entry.state == .running, + entry.process?.isRunning == true, + entry.activeBackend == .doryHypervisor, + entry.launchID == launchID, + entry.runtimeIdentity.mode == .legacyCompatibility, + entry.handoff?.ready.supportsAgentCapability( + "usb-vhci", + minimumVersion: 1 + ) == true else { + throw MachineManagerError.usbControlFailed( + id, + "live launch changed while attaching the USB device" + ) + } + return attachment + } + + /// Detach uses the same launch fence as attach so a request cannot be redirected across a + /// stop/restart boundary or to the compatible Virtualization.framework backend. + public func detachUSBDevice(id: String, busID: String) throws { + operationLock.lock() + defer { operationLock.unlock() } + + let launchID: UUID + let socketPath: String + lock.lock() + guard let entry = machines[id], + entry.state == .running, + entry.process?.isRunning == true, + entry.activeBackend == .doryHypervisor, + entry.runtimeIdentity.mode == .legacyCompatibility, + let currentLaunchID = entry.launchID, + entry.handoff?.ready.supportsAgentCapability( + "usb-vhci", + minimumVersion: 1 + ) == true else { + lock.unlock() + throw MachineManagerError.usbUnavailable(id) + } + launchID = currentLaunchID + socketPath = "\(machineRuntimeDirectory(id: id))/u.sock" + lock.unlock() + + do { + try usbController.detach(socketPath: socketPath, busID: busID) + } catch { + throw MachineManagerError.usbControlFailed(id, "\(error)") + } + + lock.lock() + defer { lock.unlock() } + guard let entry = machines[id], + entry.state == .running, + entry.process?.isRunning == true, + entry.activeBackend == .doryHypervisor, + entry.launchID == launchID, + entry.runtimeIdentity.mode == .legacyCompatibility else { + throw MachineManagerError.usbControlFailed( + id, + "live launch changed while detaching the USB device" + ) + } + } + public func memorySnapshots() -> [GuestMemorySnapshot] { list().compactMap { status in if status.state == .paused { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineUSBControlTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineUSBControlTests.swift new file mode 100644 index 00000000..ed485e5a --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineUSBControlTests.swift @@ -0,0 +1,150 @@ +import Darwin +import Foundation +import Testing +@testable import DorydKit + +@Suite(.serialized) +struct DoryMachineUSBControlTests { + @Test func attachUsesExactPrivateSocketWireContract() throws { + let server = try OneShotUSBServer( + response: """ + {"ok":true,"port":7,"vsockPort":1025,"deviceID":196610,"speed":3} + """ + ) + defer { server.stop() } + + let attachment = try UnixDoryMachineUSBController().attach( + machineID: "desktop", + socketPath: server.path, + busID: "3-2", + mode: .capture + ) + + #expect(attachment == DoryMachineUSBAttachment( + machineID: "desktop", + busID: "3-2", + port: 7, + vsockPort: 1025, + deviceID: 196_610, + speed: 3 + )) + #expect(server.request?["cmd"] as? String == "attach") + #expect(server.request?["busid"] as? String == "3-2") + #expect(server.request?["mode"] as? String == "capture") + } + + @Test func detachRejectsAttachMetadataInSuccessResponse() throws { + let server = try OneShotUSBServer( + response: """ + {"ok":true,"port":7} + """ + ) + defer { server.stop() } + + #expect(throws: DoryMachineUSBControlError.malformedResponse) { + try UnixDoryMachineUSBController().detach( + socketPath: server.path, + busID: "3-2" + ) + } + } + + @Test func regularFileCannotSubstituteForControlSocket() throws { + let root = "/tmp/dory-usb-client-file-\(getpid())-\(UInt32.random(in: 0..= 0 else { throw POSIXError(.ENOTSOCK) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.initializeMemory(as: UInt8.self, repeating: 0) + destination.copyBytes(from: path.utf8) + } + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bound == 0, chmod(path, 0o600) == 0, listen(descriptor, 1) == 0 else { + stop() + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let listenDescriptor = descriptor + queue.async { [weak self] in + self?.serve(listenDescriptor: listenDescriptor) + } + } + + func stop() { + let active = descriptor + descriptor = -1 + if active >= 0 { close(active) } + unlink(path) + try? FileManager.default.removeItem(atPath: root) + } + + private func serve(listenDescriptor: Int32) { + let client = accept(listenDescriptor, nil, nil) + guard client >= 0 else { return } + defer { close(client) } + var data = Data() + var byte: UInt8 = 0 + while data.count < 8 * 1024, Darwin.read(client, &byte, 1) == 1 { + if byte == 0x0a { break } + data.append(byte) + } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + lock.lock() + storedRequest = object + lock.unlock() + } + _ = response.withUnsafeBytes { bytes in + Darwin.write(client, bytes.baseAddress, bytes.count) + } + } + + deinit { + stop() + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift new file mode 100644 index 00000000..1eeca95c --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift @@ -0,0 +1,175 @@ +import DoryCore +import Foundation +import XCTest +@testable import DorydKit + +final class MachineManagerUSBControlTests: XCTestCase { + func testRoutesAttachAndDetachOnlyToThePinnedRawHVLaunch() throws { + let fixture = try Fixture(capabilities: [ + DoryAgentCapability(id: "usb-vhci", version: 1), + ]) + defer { fixture.cleanup() } + + let attachment = try fixture.manager.attachUSBDevice( + id: "desktop", + busID: "3-2", + mode: .capture + ) + XCTAssertEqual(attachment.machineID, "desktop") + XCTAssertEqual(attachment.busID, "3-2") + XCTAssertEqual(attachment.port, 4) + + try fixture.manager.detachUSBDevice(id: "desktop", busID: "3-2") + + let calls = fixture.controller.calls + XCTAssertEqual(calls.count, 2) + XCTAssertEqual(calls[0].operation, "attach") + XCTAssertEqual(calls[0].machineID, "desktop") + XCTAssertEqual(calls[0].busID, "3-2") + XCTAssertEqual(calls[0].mode, .capture) + XCTAssertTrue(calls[0].socketPath.hasPrefix(fixture.runtimeDirectory + "/")) + XCTAssertTrue(calls[0].socketPath.hasSuffix("/u.sock")) + XCTAssertLessThan(calls[0].socketPath.utf8.count, 104) + XCTAssertEqual(calls[1].operation, "detach") + XCTAssertEqual(calls[1].socketPath, calls[0].socketPath) + } + + func testRejectsMissingGuestCapabilityBeforeContactingUSBController() throws { + let fixture = try Fixture(capabilities: [ + DoryAgentCapability(id: "exec", version: 1), + ]) + defer { fixture.cleanup() } + + XCTAssertThrowsError( + try fixture.manager.attachUSBDevice(id: "desktop", busID: "3-2") + ) { error in + XCTAssertEqual(error as? MachineManagerError, .usbUnavailable("desktop")) + } + XCTAssertThrowsError( + try fixture.manager.detachUSBDevice(id: "desktop", busID: "3-2") + ) { error in + XCTAssertEqual(error as? MachineManagerError, .usbUnavailable("desktop")) + } + XCTAssertTrue(fixture.controller.calls.isEmpty) + } + + private final class Fixture { + let root: String + let runtimeDirectory: String + let manager: MachineManager + let controller = RecordingUSBController() + + init(capabilities: [DoryAgentCapability]) throws { + root = "/tmp/dory-machine-usb-\(getpid())-\(UInt32.random(in: 0.. DoryMachineUSBAttachment { + lock.lock() + storedCalls.append(Call( + operation: "attach", + machineID: machineID, + socketPath: socketPath, + busID: busID, + mode: mode + )) + lock.unlock() + return DoryMachineUSBAttachment( + machineID: machineID, + busID: busID, + port: 4, + vsockPort: 1025, + deviceID: 0x0003_0002, + speed: 3 + ) + } + + func detach(socketPath: String, busID: String) throws { + lock.lock() + storedCalls.append(Call( + operation: "detach", + machineID: nil, + socketPath: socketPath, + busID: busID, + mode: nil + )) + lock.unlock() + } +} diff --git a/dory-core/README.md b/dory-core/README.md index 74982e06..1a0d316c 100644 --- a/dory-core/README.md +++ b/dory-core/README.md @@ -86,7 +86,8 @@ cutover; the Rust path stayed healthy throughout. - **USB passthrough** — the Rust agent now exposes a capability-gated `usb-vhci@1` attach/detach RPC, validates the exact USB/IP import descriptor, and hands the dedicated host vsock stream to Linux `vhci_hcd`; the raw-HV engine consumes that path with pre-claim and pre-mutation capability checks. - Public app/CLI attachment and automatic replay remain disabled until the machine-scoped control - routing and physical-device qualification slice lands. + Raw-HV desktop machines now receive a private, launch-pinned control route. Public app/CLI + attachment and automatic replay remain disabled until removable-device plan authorization and + physical-device qualification land. - **GPU** — Venus/Vulkan remains an opt-in preview on the arm64 raw-HV tier. It is not a stable 0.4 cross-host GPU API. From 95ca4aec318c0469af543897d40b7961c0c2b732 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 02:28:15 +0000 Subject: [PATCH 240/338] feat(vm): authorize removable usb in resolved plans --- COMPATIBILITY.md | 7 +- Dory/Net/UsbAttachmentStore.swift | 6 +- DoryTests/UsbAttachmentStoreTests.swift | 2 +- .../Sources/dory-hv/DesktopMode.swift | 17 ++-- .../Sources/dory-hv/main.swift | 7 +- .../DoryVirtualMachineCapabilities.swift | 80 ++++++++++++++++++- .../DoryVirtualMachineDefinition.swift | 3 + ...yDaemonVirtualMachineRuntimePlanning.swift | 3 +- .../Sources/DorydKit/MachineManager.swift | 39 +++++++-- .../DoryVirtualMachineCapabilitiesTests.swift | 40 ++++++++++ .../DoryVirtualMachineDefinitionTests.swift | 13 +++ ...onVirtualMachineRuntimePlanningTests.swift | 4 + ...eManagerResolvedPlanIntegrationTests.swift | 4 +- dory-core/README.md | 7 +- 14 files changed, 202 insertions(+), 30 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 67ade990..74856932 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -139,9 +139,10 @@ grants; `full` is an explicit unrestricted choice. See the ## Unavailable -- USB attach, detach, and remembered replay are unavailable. Host discovery, the guest USB/IP RPC, - and a private raw-HV machine route exist, but the product fails closed until removable-device - plan authorization and physical qualification exist. +- USB attach, detach, and remembered replay are unavailable in public app/CLI surfaces. Host + discovery, the guest USB/IP RPC, private raw-HV routing, and exact resolved-plan authorization + exist, but the product fails closed until signed physical-device qualification and public consent + transport exist. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift index 31b51439..e6375d4d 100644 --- a/Dory/Net/UsbAttachmentStore.swift +++ b/Dory/Net/UsbAttachmentStore.swift @@ -3,9 +3,9 @@ import Foundation enum UsbPassthroughAvailability: Sendable { static let attachSupported = false static let unavailableReason = - "USB passthrough is not yet available in the public app. Host discovery and the private " + - "machine route work, but attach, detach, and replay remain disabled until removable-device " + - "plan authorization and physical qualification ship." + "USB passthrough is not yet available in the public app. Host discovery, private routing, " + + "and resolved-plan authorization work, but attach, detach, and replay remain disabled " + + "until signed physical-device qualification and public consent transport ship." } struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { diff --git a/DoryTests/UsbAttachmentStoreTests.swift b/DoryTests/UsbAttachmentStoreTests.swift index a07c2ff7..f39fb160 100644 --- a/DoryTests/UsbAttachmentStoreTests.swift +++ b/DoryTests/UsbAttachmentStoreTests.swift @@ -5,7 +5,7 @@ import Testing struct UsbAttachmentStoreTests { @Test func passthroughIsTruthfullyDisabledUntilGuestRPCShips() { #expect(!UsbPassthroughAvailability.attachSupported) - #expect(UsbPassthroughAvailability.unavailableReason.contains("removable-device")) + #expect(UsbPassthroughAvailability.unavailableReason.contains("physical-device")) } @Test func remembersAttachmentsSortedByMachineAndBusID() throws { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index e031907d..a8cedd24 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -425,7 +425,7 @@ enum DesktopMode { var agentSocketPath: String var shellSocketPath: String var controlSocketPath: String - var usbControlSocketPath: String + var usbControlSocketPath: String? var sshAgentSocketPath: String? var memoryMB: UInt64 var cpuCount: Int @@ -520,7 +520,7 @@ enum DesktopMode { private let shellBridge: GuestVsockSocketBridge private let sshAgentBridge: HostSSHAgentBridge? private let usbipManager: UsbipManager - private let usbControlServer: UsbControlServer + private let usbControlServer: UsbControlServer? private let clipboard: DoryDesktopClipboardCoordinator? private let firstFrame: FirstFrameGate private let deviceTelemetry: RawDeviceTelemetryRegistry @@ -658,10 +658,9 @@ enum DesktopMode { try await channel.usbVhciDetach(request) } ) - self.usbControlServer = UsbControlServer( - path: configuration.usbControlSocketPath, - handler: usbControlHandler - ) + self.usbControlServer = configuration.usbControlSocketPath.map { + UsbControlServer(path: $0, handler: usbControlHandler) + } self.audio = DoryMacAudioBackend(log: Self.log) let sound = VirtioSound(host: audio, log: Self.log) let balloon = VirtioBalloon(memory: machine.memory) { message in @@ -822,11 +821,11 @@ enum DesktopMode { } func run() throws { - try usbControlServer.start() + try usbControlServer?.start() do { try lifecycleReceiptServer.start() } catch { - usbControlServer.stop() + usbControlServer?.stop() throw error } application.setActivationPolicy(.regular) @@ -1018,7 +1017,7 @@ enum DesktopMode { private func cleanup() { lifecycleReceiptServer.stop() - usbControlServer.stop() + usbControlServer?.stop() clipboard?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index f1aef0ce..48e8ceb4 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -515,7 +515,12 @@ case "desktop": guard let agentSocket else { fail("desktop requires --agent-sock") } guard let shellSocket else { fail("desktop requires --shell-sock") } guard let controlSocket else { fail("desktop requires --control-sock") } - guard let usbControlSocket else { fail("desktop requires --usb-control-sock") } + if resolvedDevices?.removableUSBHotplug == true, usbControlSocket == nil { + fail("desktop resolved removable USB hotplug requires --usb-control-sock") + } + if resolvedDevices?.removableUSBHotplug == false, usbControlSocket != nil { + fail("desktop --usb-control-sock is not authorized by the resolved device contract") + } do { try DesktopMode.run(.init( machineID: machineID, diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index e1e766de..cf38db69 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -245,6 +245,9 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa public var clockSynchronization: Bool public var dynamicDisplay: Bool public var gracefulShutdown: Bool + /// Exact permission for user-approved removable USB hotplug. Host bus identifiers are + /// deliberately excluded because they are ephemeral runtime selections. + public var removableUSBHotplug: Bool public init( networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, @@ -258,7 +261,8 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa clipboard: Bool = false, clockSynchronization: Bool = false, dynamicDisplay: Bool = false, - gracefulShutdown: Bool = false + gracefulShutdown: Bool = false, + removableUSBHotplug: Bool = false ) { self.networkAttachment = networkAttachment self.networkInterface = networkInterface @@ -272,6 +276,71 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa self.clockSynchronization = clockSynchronization self.dynamicDisplay = dynamicDisplay self.gracefulShutdown = gracefulShutdown + self.removableUSBHotplug = removableUSBHotplug + } + + private enum CodingKeys: String, CodingKey { + case networkAttachment + case networkInterface + case display + case audioInput + case audioOutput + case keyboard + case pointer + case directorySharing + case clipboard + case clockSynchronization + case dynamicDisplay + case gracefulShutdown + case removableUSBHotplug + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + networkAttachment = try container.decode( + DoryVirtualMachineNetworkAttachmentMode.self, + forKey: .networkAttachment + ) + networkInterface = try container.decodeIfPresent( + DoryVirtualMachineNetworkInterfaceCapabilityRequest.self, + forKey: .networkInterface + ) + display = try container.decodeIfPresent( + DoryVirtualMachineDisplayCapabilityRequest.self, + forKey: .display + ) + audioInput = try container.decode(Bool.self, forKey: .audioInput) + audioOutput = try container.decode(Bool.self, forKey: .audioOutput) + keyboard = try container.decode(Bool.self, forKey: .keyboard) + pointer = try container.decode(Bool.self, forKey: .pointer) + directorySharing = try container.decode(Bool.self, forKey: .directorySharing) + clipboard = try container.decode(Bool.self, forKey: .clipboard) + clockSynchronization = try container.decode(Bool.self, forKey: .clockSynchronization) + dynamicDisplay = try container.decode(Bool.self, forKey: .dynamicDisplay) + gracefulShutdown = try container.decode(Bool.self, forKey: .gracefulShutdown) + removableUSBHotplug = try container.decodeIfPresent( + Bool.self, + forKey: .removableUSBHotplug + ) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(networkAttachment, forKey: .networkAttachment) + try container.encodeIfPresent(networkInterface, forKey: .networkInterface) + try container.encodeIfPresent(display, forKey: .display) + try container.encode(audioInput, forKey: .audioInput) + try container.encode(audioOutput, forKey: .audioOutput) + try container.encode(keyboard, forKey: .keyboard) + try container.encode(pointer, forKey: .pointer) + try container.encode(directorySharing, forKey: .directorySharing) + try container.encode(clipboard, forKey: .clipboard) + try container.encode(clockSynchronization, forKey: .clockSynchronization) + try container.encode(dynamicDisplay, forKey: .dynamicDisplay) + try container.encode(gracefulShutdown, forKey: .gracefulShutdown) + if removableUSBHotplug { + try container.encode(true, forKey: .removableUSBHotplug) + } } /// Compares the device ABI covered by a signed runtime qualification. @@ -377,6 +446,7 @@ public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, H case clockSynchronizationUnsupported = "clock-synchronization-unsupported" case dynamicDisplayUnsupported = "dynamic-display-unsupported" case gracefulShutdownUnsupported = "graceful-shutdown-unsupported" + case removableUSBHotplugUnsupported = "removable-usb-hotplug-unsupported" case runtimeQualificationUnavailable = "runtime-qualification-unavailable" case runtimeQualificationEvidenceInvalid = "runtime-qualification-evidence-invalid" case runtimeQualificationRequestMismatch = "runtime-qualification-request-mismatch" @@ -1614,6 +1684,14 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } } + if devices.removableUSBHotplug, + !(request.guest.family == .linux && request.backend == .doryHypervisor) { + return unavailable( + tier: tier, + code: .removableUSBHotplugUnsupported, + message: "The selected guest/backend contract does not implement removable USB hotplug." + ) + } return nil } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 93039d93..ec53dca3 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -297,6 +297,9 @@ public enum DoryVMGuestIntegration: String, Codable, Sendable, CaseIterable { case clockSynchronization = "clock-synchronization" case dynamicDisplay = "dynamic-display" case gracefulShutdown = "graceful-shutdown" + /// Authorizes user-approved USB hotplug through the guest's versioned USB/IP integration. + /// Host device identities remain runtime-only and are never persisted in desired state. + case removableUSBHotplug = "removable-usb-hotplug" } /// Minimal metadata needed for optimistic persistence and ordering. diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index fa8950df..210a94cb 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -539,7 +539,8 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda clipboard: definition.integrations.contains(.clipboard), clockSynchronization: definition.integrations.contains(.clockSynchronization), dynamicDisplay: definition.integrations.contains(.dynamicDisplay), - gracefulShutdown: definition.integrations.contains(.gracefulShutdown) + gracefulShutdown: definition.integrations.contains(.gracefulShutdown), + removableUSBHotplug: definition.integrations.contains(.removableUSBHotplug) ) } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 940c8b9f..4c0708a4 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -6094,7 +6094,9 @@ public final class MachineManager: @unchecked Sendable { "--cpus", String(machine.cpuCount), "--display-mode", machine.displayMode.rawValue, ] - if acceleratedDesktop { + let removableUSBHotplugEnabled = resolvedLaunchBinding? + .devices.removableUSBHotplug ?? true + if acceleratedDesktop, removableUSBHotplugEnabled { arguments.append(contentsOf: [ "--usb-control-sock", "\(machineRuntimeDirectory(id: machine.id))/u.sock", ]) @@ -6800,8 +6802,8 @@ public final class MachineManager: @unchecked Sendable { } /// Routes a hotplug request only to the exact live raw-HV helper selected for this machine. - /// Resolved-plan workspaces remain fail-closed until removable USB is represented in the - /// durable device graph; this compatibility route cannot mutate an exact resolved launch. + /// Resolved-plan workspaces require the exact removable-USB device bit and initially permit + /// only the normal user-authorization mode; this route cannot widen an exact resolved launch. public func attachUSBDevice( id: String, busID: String, @@ -6817,7 +6819,7 @@ public final class MachineManager: @unchecked Sendable { entry.state == .running, entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, - entry.runtimeIdentity.mode == .legacyCompatibility, + usbHotplugIsAuthorized(entry.runtimeIdentity, mode: mode), let currentLaunchID = entry.launchID, entry.handoff?.ready.supportsAgentCapability( "usb-vhci", @@ -6856,7 +6858,7 @@ public final class MachineManager: @unchecked Sendable { entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, entry.launchID == launchID, - entry.runtimeIdentity.mode == .legacyCompatibility, + usbHotplugIsAuthorized(entry.runtimeIdentity, mode: mode), entry.handoff?.ready.supportsAgentCapability( "usb-vhci", minimumVersion: 1 @@ -6882,7 +6884,7 @@ public final class MachineManager: @unchecked Sendable { entry.state == .running, entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, - entry.runtimeIdentity.mode == .legacyCompatibility, + usbHotplugIsAuthorized(entry.runtimeIdentity, mode: nil), let currentLaunchID = entry.launchID, entry.handoff?.ready.supportsAgentCapability( "usb-vhci", @@ -6908,7 +6910,11 @@ public final class MachineManager: @unchecked Sendable { entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, entry.launchID == launchID, - entry.runtimeIdentity.mode == .legacyCompatibility else { + usbHotplugIsAuthorized(entry.runtimeIdentity, mode: nil), + entry.handoff?.ready.supportsAgentCapability( + "usb-vhci", + minimumVersion: 1 + ) == true else { throw MachineManagerError.usbControlFailed( id, "live launch changed while detaching the USB device" @@ -6916,6 +6922,25 @@ public final class MachineManager: @unchecked Sendable { } } + private func usbHotplugIsAuthorized( + _ identity: DoryMachineRuntimeIdentity, + mode: DoryMachineUSBOpenMode? + ) -> Bool { + switch identity.mode { + case .legacyCompatibility: + return true + case .resolvedPlan: + guard identity.resolvedPlan?.devices.removableUSBHotplug == true else { + return false + } + // The first signed contract authorizes only the normal user-consent flow. Host-device + // seizure and capture need their own durable policy before resolved launches may use it. + return mode == nil || mode == .userAuthorized + case .requiresReplanning: + return false + } + } + public func memorySnapshots() -> [GuestMemorySnapshot] { list().compactMap { status in if status.state == .paused { diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 8ce154b8..32984ce3 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -66,6 +66,7 @@ struct VirtualMachineCapabilitiesTests { var object = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any] ) + #expect(object["removableUSBHotplug"] == nil) object.removeValue(forKey: "networkInterface") let historical = try JSONSerialization.data(withJSONObject: object) @@ -74,6 +75,45 @@ struct VirtualMachineCapabilitiesTests { from: historical ) #expect(decoded.networkInterface == nil) + #expect(!decoded.removableUSBHotplug) + + object.removeValue(forKey: "keyboard") + let truncated = try JSONSerialization.data(withJSONObject: object) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode( + DoryVirtualMachineDeviceCapabilityRequest.self, + from: truncated + ) + } + } + + @Test("removable USB hotplug is exact and raw-HV-only") + func removableUSBHotplugCapability() { + let devices = DoryVirtualMachineDeviceCapabilityRequest(removableUSBHotplug: true) + let rawHV = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + let virtualizationFramework = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + + #expect(rawHV.availability.isUsable) + #expect(rawHV.resolvedDevices?.removableUSBHotplug == true) + #expect(virtualizationFramework.availability.reason?.code + == .removableUSBHotplugUnsupported) + #expect(!devices.matchesRuntimeQualificationContract(.minimumBootable)) } private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification( auditEvidence: DorySignedArtifactQualificationEvidence( diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 0bba3600..7f873678 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -73,6 +73,19 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.isValid) } + @Test("removable USB hotplug is explicit durable guest integration intent") + func removableUSBHotplugRoundTrip() throws { + var original = linuxDefinition() + original.integrations.append(.removableUSBHotplug) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: data) + + #expect(decoded.integrations.contains(.removableUSBHotplug)) + #expect(decoded == original) + #expect(decoded.isValid) + } + @Test("sandbox lifecycle and credential grants are closed bounded intent") func sandboxPolicyValidation() throws { var definition = linuxDefinition() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 9d90f6c7..bd056ab4 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -28,6 +28,10 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(devices.keyboard == definition.input.keyboardEnabled) #expect(devices.pointer == definition.input.pointerEnabled) + definition.integrations.append(.removableUSBHotplug) + #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) + .removableUSBHotplug) + definition.display = .disabled #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition).display == nil) } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 209ecb2d..9ac2413a 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -229,7 +229,8 @@ struct MachineManagerResolvedPlanIntegrationTests { display: DoryVirtualMachineDisplayCapabilityRequest( widthPixels: 1_920, heightPixels: 1_080 - ) + ), + removableUSBHotplug: true ) let resolver = ClosureLaunchResolver { request in let resolution = try exactResolution( @@ -264,6 +265,7 @@ struct MachineManagerResolvedPlanIntegrationTests { } #expect(try value(after: "--resolved-graphics") == "host-accelerated-display") #expect(try value(after: "--operation-id") == started.activeOperationID) + #expect(arguments.contains("--usb-control-sock")) let deviceData = Data(try value(after: "--resolved-devices").utf8) #expect(try JSONDecoder().decode( DoryVirtualMachineDeviceCapabilityRequest.self, diff --git a/dory-core/README.md b/dory-core/README.md index 1a0d316c..d051b8bf 100644 --- a/dory-core/README.md +++ b/dory-core/README.md @@ -86,8 +86,9 @@ cutover; the Rust path stayed healthy throughout. - **USB passthrough** — the Rust agent now exposes a capability-gated `usb-vhci@1` attach/detach RPC, validates the exact USB/IP import descriptor, and hands the dedicated host vsock stream to Linux `vhci_hcd`; the raw-HV engine consumes that path with pre-claim and pre-mutation capability checks. - Raw-HV desktop machines now receive a private, launch-pinned control route. Public app/CLI - attachment and automatic replay remain disabled until removable-device plan authorization and - physical-device qualification land. + Raw-HV desktop machines now receive a private, launch-pinned control route, and signed resolved + plans carry an exact removable-USB authorization bit through helper construction. Public app/CLI + attachment and automatic replay remain disabled until physical-device qualification and public + consent transport land. - **GPU** — Venus/Vulkan remains an opt-in preview on the arm64 raw-HV tier. It is not a stable 0.4 cross-host GPU API. From 58ccaed4cb5cfa2c5200674c04bfbca33cda4ba0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 02:43:38 +0000 Subject: [PATCH 241/338] feat(vm): expose resolved usb control --- COMPATIBILITY.md | 9 +- Dory/Net/UsbAttachmentStore.swift | 6 +- Dory/Runtime/Doryd/DorydClient.swift | 2 + .../DorydKit/DoryMachineUSBControl.swift | 11 ++ .../Sources/DorydKit/DorydControl.swift | 2 + .../Sources/DorydKit/DorydService.swift | 38 ++++++ .../Sources/DorydKit/MachineManager.swift | 71 ++++++++++- dory-core-swift/Sources/dorydctl/main.swift | 32 ++++- .../DorydKitTests/DorydServiceTests.swift | 36 ++++++ ...eManagerResolvedPlanIntegrationTests.swift | 117 +++++++++++++++++- .../MachineManagerUSBControlTests.swift | 19 +++ dory-core/README.md | 6 +- 12 files changed, 328 insertions(+), 21 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 74856932..cd661e19 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -139,10 +139,11 @@ grants; `full` is an explicit unrestricted choice. See the ## Unavailable -- USB attach, detach, and remembered replay are unavailable in public app/CLI surfaces. Host - discovery, the guest USB/IP RPC, private raw-HV routing, and exact resolved-plan authorization - exist, but the product fails closed until signed physical-device qualification and public consent - transport exist. +- USB attach, detach, and remembered replay are unavailable in the public app. The resolved-only + `dorydctl machine usb-attach|usb-detach` transport, host discovery, guest USB/IP RPC, private + raw-HV routing, and exact plan authorization exist, but current production catalogs carry no + signed physical-device qualification, so attachment still fails closed. App consent UI and + remembered replay remain future work. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift index e6375d4d..f56b914e 100644 --- a/Dory/Net/UsbAttachmentStore.swift +++ b/Dory/Net/UsbAttachmentStore.swift @@ -3,9 +3,9 @@ import Foundation enum UsbPassthroughAvailability: Sendable { static let attachSupported = false static let unavailableReason = - "USB passthrough is not yet available in the public app. Host discovery, private routing, " + - "and resolved-plan authorization work, but attach, detach, and replay remain disabled " + - "until signed physical-device qualification and public consent transport ship." + "USB passthrough is not yet available in the public app. Host discovery, resolved-only " + + "daemon transport, private routing, and plan authorization work, but current production " + + "catalogs have no signed physical-device qualification. App consent and replay remain disabled." } struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index de97c474..1d97f587 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -34,6 +34,8 @@ nonisolated protocol DorydControlXPC { func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineUSBAttach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineUSBDetach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift b/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift index 40f9c53e..8cd6c4c0 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineUSBControl.swift @@ -30,6 +30,17 @@ public struct DoryMachineUSBAttachment: Equatable, Sendable { self.deviceID = deviceID self.speed = speed } + + var xpcDictionary: NSDictionary { + [ + "machineID": machineID, + "busID": busID, + "port": port, + "vsockPort": vsockPort, + "deviceID": deviceID, + "speed": speed, + ] + } } public protocol DoryMachineUSBControlling: Sendable { diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 621f40f4..58298d83 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -34,6 +34,8 @@ import Foundation func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineUSBAttach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineUSBDetach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 4e740f1d..2df28372 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -582,6 +582,44 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineUSBAttach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let attachment = try machineManager.attachResolvedUSBDevice( + id: machineID, + busID: busID, + mode: .userAuthorized + ) + reply(true, attachment.xpcDictionary, "") + } catch { + reply(false, [:], "\(error)") + } + } + + public func machineUSBDetach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + try machineManager.detachResolvedUSBDevice(id: machineID, busID: busID) + reply(true, ["machineID": machineID, "busID": busID], "") + } catch { + reply(false, [:], "\(error)") + } + } + public func machineExec( _ machineID: String, request: NSDictionary, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 4c0708a4..4c8437e7 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -6808,6 +6808,35 @@ public final class MachineManager: @unchecked Sendable { id: String, busID: String, mode: DoryMachineUSBOpenMode = .userAuthorized + ) throws -> DoryMachineUSBAttachment { + try attachUSBDevice( + id: id, + busID: busID, + mode: mode, + requiresResolvedPlan: false + ) + } + + /// Public control surfaces use this entry point so legacy compatibility cannot authorize a + /// newly exposed host-device operation. + public func attachResolvedUSBDevice( + id: String, + busID: String, + mode: DoryMachineUSBOpenMode = .userAuthorized + ) throws -> DoryMachineUSBAttachment { + try attachUSBDevice( + id: id, + busID: busID, + mode: mode, + requiresResolvedPlan: true + ) + } + + private func attachUSBDevice( + id: String, + busID: String, + mode: DoryMachineUSBOpenMode, + requiresResolvedPlan: Bool ) throws -> DoryMachineUSBAttachment { operationLock.lock() defer { operationLock.unlock() } @@ -6819,7 +6848,11 @@ public final class MachineManager: @unchecked Sendable { entry.state == .running, entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, - usbHotplugIsAuthorized(entry.runtimeIdentity, mode: mode), + usbHotplugIsAuthorized( + entry.runtimeIdentity, + mode: mode, + requiresResolvedPlan: requiresResolvedPlan + ), let currentLaunchID = entry.launchID, entry.handoff?.ready.supportsAgentCapability( "usb-vhci", @@ -6858,7 +6891,11 @@ public final class MachineManager: @unchecked Sendable { entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, entry.launchID == launchID, - usbHotplugIsAuthorized(entry.runtimeIdentity, mode: mode), + usbHotplugIsAuthorized( + entry.runtimeIdentity, + mode: mode, + requiresResolvedPlan: requiresResolvedPlan + ), entry.handoff?.ready.supportsAgentCapability( "usb-vhci", minimumVersion: 1 @@ -6874,6 +6911,19 @@ public final class MachineManager: @unchecked Sendable { /// Detach uses the same launch fence as attach so a request cannot be redirected across a /// stop/restart boundary or to the compatible Virtualization.framework backend. public func detachUSBDevice(id: String, busID: String) throws { + try detachUSBDevice(id: id, busID: busID, requiresResolvedPlan: false) + } + + /// Resolved-only counterpart used by XPC and CLI control surfaces. + public func detachResolvedUSBDevice(id: String, busID: String) throws { + try detachUSBDevice(id: id, busID: busID, requiresResolvedPlan: true) + } + + private func detachUSBDevice( + id: String, + busID: String, + requiresResolvedPlan: Bool + ) throws { operationLock.lock() defer { operationLock.unlock() } @@ -6884,7 +6934,11 @@ public final class MachineManager: @unchecked Sendable { entry.state == .running, entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, - usbHotplugIsAuthorized(entry.runtimeIdentity, mode: nil), + usbHotplugIsAuthorized( + entry.runtimeIdentity, + mode: nil, + requiresResolvedPlan: requiresResolvedPlan + ), let currentLaunchID = entry.launchID, entry.handoff?.ready.supportsAgentCapability( "usb-vhci", @@ -6910,7 +6964,11 @@ public final class MachineManager: @unchecked Sendable { entry.process?.isRunning == true, entry.activeBackend == .doryHypervisor, entry.launchID == launchID, - usbHotplugIsAuthorized(entry.runtimeIdentity, mode: nil), + usbHotplugIsAuthorized( + entry.runtimeIdentity, + mode: nil, + requiresResolvedPlan: requiresResolvedPlan + ), entry.handoff?.ready.supportsAgentCapability( "usb-vhci", minimumVersion: 1 @@ -6924,11 +6982,12 @@ public final class MachineManager: @unchecked Sendable { private func usbHotplugIsAuthorized( _ identity: DoryMachineRuntimeIdentity, - mode: DoryMachineUSBOpenMode? + mode: DoryMachineUSBOpenMode?, + requiresResolvedPlan: Bool ) -> Bool { switch identity.mode { case .legacyCompatibility: - return true + return !requiresResolvedPlan case .resolvedPlan: guard identity.resolvedPlan?.devices.removableUSBHotplug == true else { return false diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index c8b3458d..6427f827 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -197,6 +197,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME + dorydctl [global] machine usb-attach NAME BUS_ID + dorydctl [global] machine usb-detach NAME BUS_ID dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...] dorydctl [global] machine shell NAME dorydctl [global] machine provision NAME --recipe RECIPE @@ -1149,7 +1151,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|device-telemetry|flight-recorder|console|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup") + let subcommand = try cursor.take("usage: dorydctl machine list|status|stats|device-telemetry|flight-recorder|console|create|update|desktop-update|start|stop|pause|suspend|resume|restart|delete|usb-attach|usb-detach|exec|shell|provision|snapshots|snapshot|backup") switch subcommand { case "list": let rows: NSArray = try client.call { proxy, finish in @@ -1397,6 +1399,34 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { $0.machineDelete(name, reply: $1) }) + case "usb-attach": + let name = try cursor.take( + "usage: dorydctl machine usb-attach NAME BUS_ID" + ) + let busID = try cursor.take( + "usage: dorydctl machine usb-attach NAME BUS_ID" + ) + guard cursor.values.isEmpty else { + throw DorydCtlError.usage( + "unexpected machine usb-attach argument: \(cursor.values[0])" + ) + } + let attachment = try client.statusCommand { proxy, reply in + proxy.machineUSBAttach(name, busID: busID, reply: reply) + } + try emitJSON(attachment) + case "usb-detach": + let name = try cursor.take("usage: dorydctl machine usb-detach NAME BUS_ID") + let busID = try cursor.take("usage: dorydctl machine usb-detach NAME BUS_ID") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage( + "unexpected machine usb-detach argument: \(cursor.values[0])" + ) + } + let detached = try client.statusCommand { proxy, reply in + proxy.machineUSBDetach(name, busID: busID, reply: reply) + } + try emitJSON(detached) case "exec": try runMachineExec(cursor: &cursor, client: client) case "shell": diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 1deecc4c..d1a412d9 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -5,6 +5,42 @@ import DoryOperations import XCTest final class DorydServiceTests: XCTestCase { + func testMachineUSBXPCIsResolvedOnly() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("doryd-usb-xpc-\(UUID().uuidString)").path + let manager = MachineManager(configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: base, + passMachineArguments: false, + requiresReadyHandoff: false + )) + defer { try? FileManager.default.removeItem(atPath: base) } + let service = DorydService( + socketPath: "/tmp/doryd-test.sock", + machineManager: manager + ) + let listener = makeAnonymousListener(service: service) + listener.resume() + defer { listener.invalidate() } + let connection = NSXPCConnection(listenerEndpoint: listener.endpoint) + connection.remoteObjectInterface = NSXPCInterface(with: DorydControl.self) + connection.resume() + defer { connection.invalidate() } + let proxy = try XCTUnwrap(connection.remoteObjectProxy as? DorydControl) + + let unavailable = expectation(description: "resolved USB authority required") + proxy.machineUSBAttach( + "missing", + busID: "3-2" + ) { ok, body, message in + XCTAssertFalse(ok) + XCTAssertEqual(body.count, 0) + XCTAssertTrue(message.contains("machine USB passthrough is unavailable")) + unavailable.fulfill() + } + wait(for: [unavailable], timeout: 5) + } + func testPublishedPortRepairDetailUsesValidatedGvproxyReceiptCounts() { let startedAt = Date() let receipt = PublishedPortReconcileReceipt( diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 9ac2413a..9f4150fa 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import DoryCore import DoryOperations import Foundation import Testing @@ -277,6 +278,77 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("resolved USB control requires exact plan and guest capability authority") + func resolvedUSBControlUsesExactAuthorization() throws { + let usb = ResolvedPlanRecordingUSBController() + try withHarness( + "resolved-usb-control", + requiresReadyHandoff: true, + useShortStatePath: true, + usbController: usb + ) { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor) + let registry = try rawRegistry(operations: operations) + let devices = DoryVirtualMachineDeviceCapabilityRequest( + removableUSBHotplug: true + ) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution(request: request, devices: devices) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + let starting = try manager.start(id: "dev") + try sendVmmHandoff( + path: try #require(starting.handoffSocketPath), + ready: VmmReadyMessage( + machineID: "dev", + operationID: starting.activeOperationID, + agentBuild: "dory-agent/resolved-usb-test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [DoryAgentCapability(id: "usb-vhci", version: 1)], + agentSocketPath: "/run/dory-agent.sock", + controlSocketPath: "/run/dory-control.sock" + ), + fileDescriptors: [] + ) + for _ in 0..<200 { + if manager.status(id: "dev")?.state == .running { break } + Thread.sleep(forTimeInterval: 0.01) + } + let readyStatus = try #require(manager.status(id: "dev")) + #expect( + readyStatus.state == .running, + "resolved USB readiness failed: \(readyStatus.lastError ?? "unknown")" + ) + #expect(starter.count == 1) + + let attachment = try manager.attachResolvedUSBDevice( + id: "dev", + busID: "3-2" + ) + #expect(attachment.busID == "3-2") + #expect(usb.callCount == 1) + #expect(throws: MachineManagerError.self) { + _ = try manager.attachResolvedUSBDevice( + id: "dev", + busID: "3-3", + mode: .capture + ) + } + #expect(usb.callCount == 1) + try manager.detachResolvedUSBDevice(id: "dev", busID: "3-2") + #expect(usb.callCount == 2) + } + } + @Test("resolved installed-Linux launch rematerializes verified boot outputs after authorization") func resolvedInstalledLinuxRevalidatesMaterializedOutputs() throws { let root = try makeState("resolved-installed-linux") @@ -1492,11 +1564,16 @@ struct MachineManagerResolvedPlanIntegrationTests { launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan, acceleratedExecutablePath: String? = "/bin/sleep", passMachineArguments: Bool = false, + requiresReadyHandoff: Bool = false, + useShortStatePath: Bool = false, + usbController: any DoryMachineUSBControlling = UnixDoryMachineUSBController(), _ body: (MachineManager, CountingProcessStarter, String) throws -> Void ) throws { - let state = FileManager.default.temporaryDirectory - .appendingPathComponent("dory-resolved-start-\(label)-\(UUID().uuidString)") - .path + let state = useShortStatePath + ? "/tmp/dory-r-\(UUID().uuidString)" + : FileManager.default.temporaryDirectory + .appendingPathComponent("dory-resolved-start-\(label)-\(UUID().uuidString)") + .path let starter = CountingProcessStarter() let manager = MachineManager( configuration: MachineManagerConfiguration( @@ -1506,9 +1583,10 @@ struct MachineManagerResolvedPlanIntegrationTests { baseArguments: ["30"], acceleratedDesktopBaseArguments: ["30"], passMachineArguments: passMachineArguments, - requiresReadyHandoff: false + requiresReadyHandoff: requiresReadyHandoff ), launchPolicy: launchPolicy, + usbController: usbController, processStarter: { process in try starter.start(process) } ) defer { @@ -1795,6 +1873,37 @@ private final class CountingProcessStarter: @unchecked Sendable { } } +private final class ResolvedPlanRecordingUSBController: + DoryMachineUSBControlling, + @unchecked Sendable +{ + private let lock = NSLock() + private var calls = 0 + + var callCount: Int { lock.withLock { calls } } + + func attach( + machineID: String, + socketPath: String, + busID: String, + mode: DoryMachineUSBOpenMode + ) throws -> DoryMachineUSBAttachment { + lock.withLock { calls += 1 } + return DoryMachineUSBAttachment( + machineID: machineID, + busID: busID, + port: 4, + vsockPort: 1025, + deviceID: 0x0003_0002, + speed: 3 + ) + } + + func detach(socketPath: String, busID: String) throws { + lock.withLock { calls += 1 } + } +} + private final class MutablePlanStore: DoryResolvedMachinePlanStoring, @unchecked Sendable { private let lock = NSLock() private var plan: DoryResolvedMachinePlan? diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift index 1eeca95c..95c4bde7 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerUSBControlTests.swift @@ -53,6 +53,25 @@ final class MachineManagerUSBControlTests: XCTestCase { XCTAssertTrue(fixture.controller.calls.isEmpty) } + func testResolvedOnlyPublicRouteRejectsLegacyCompatibility() throws { + let fixture = try Fixture(capabilities: [ + DoryAgentCapability(id: "usb-vhci", version: 1), + ]) + defer { fixture.cleanup() } + + XCTAssertThrowsError( + try fixture.manager.attachResolvedUSBDevice(id: "desktop", busID: "3-2") + ) { error in + XCTAssertEqual(error as? MachineManagerError, .usbUnavailable("desktop")) + } + XCTAssertThrowsError( + try fixture.manager.detachResolvedUSBDevice(id: "desktop", busID: "3-2") + ) { error in + XCTAssertEqual(error as? MachineManagerError, .usbUnavailable("desktop")) + } + XCTAssertTrue(fixture.controller.calls.isEmpty) + } + private final class Fixture { let root: String let runtimeDirectory: String diff --git a/dory-core/README.md b/dory-core/README.md index d051b8bf..02d8d69b 100644 --- a/dory-core/README.md +++ b/dory-core/README.md @@ -87,8 +87,8 @@ cutover; the Rust path stayed healthy throughout. validates the exact USB/IP import descriptor, and hands the dedicated host vsock stream to Linux `vhci_hcd`; the raw-HV engine consumes that path with pre-claim and pre-mutation capability checks. Raw-HV desktop machines now receive a private, launch-pinned control route, and signed resolved - plans carry an exact removable-USB authorization bit through helper construction. Public app/CLI - attachment and automatic replay remain disabled until physical-device qualification and public - consent transport land. + plans carry an exact removable-USB authorization bit through helper construction. A resolved-only + `dorydctl` attach/detach transport is wired, but current catalogs carry no signed physical-device + qualification; the public app consent UI and automatic replay remain disabled. - **GPU** — Venus/Vulkan remains an opt-in preview on the arm64 raw-HV tier. It is not a stable 0.4 cross-host GPU API. From 71b4c168e36654c9ae16d51c419f3ca1319839d6 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 02:55:47 +0000 Subject: [PATCH 242/338] feat(app): add resolved usb consent controls --- COMPATIBILITY.md | 9 +- Dory/Features/Settings/UsbDevicesView.swift | 146 ++++++++---------- Dory/Models/AppStore.swift | 2 +- Dory/Net/UsbAttachmentStore.swift | 32 +++- Dory/Runtime/Doryd/DorydClient.swift | 95 ++++++++++++ DoryTests/DorydClientTests.swift | 114 ++++++++++++++ DoryTests/UsbAttachmentStoreTests.swift | 10 +- README.md | 6 +- .../Sources/DorydKit/DorydService.swift | 1 + dory-core/README.md | 6 +- 10 files changed, 324 insertions(+), 97 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index cd661e19..20cb7683 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -139,11 +139,10 @@ grants; `full` is an explicit unrestricted choice. See the ## Unavailable -- USB attach, detach, and remembered replay are unavailable in the public app. The resolved-only - `dorydctl machine usb-attach|usb-detach` transport, host discovery, guest USB/IP RPC, private - raw-HV routing, and exact plan authorization exist, but current production catalogs carry no - signed physical-device qualification, so attachment still fails closed. App consent UI and - remembered replay remain future work. +- USB attach/detach controls exist in the public app and `dorydctl`, but enable only for a running + raw-HV machine whose exact signed resolved plan authorizes removable USB. Current production + catalogs carry no such physical-device qualification, so attachment still fails closed in release + builds. Remembered replay remains unavailable. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are diff --git a/Dory/Features/Settings/UsbDevicesView.swift b/Dory/Features/Settings/UsbDevicesView.swift index 4162673b..9e5a14b7 100644 --- a/Dory/Features/Settings/UsbDevicesView.swift +++ b/Dory/Features/Settings/UsbDevicesView.swift @@ -5,8 +5,7 @@ struct UsbDevicesView: View { @State private var devicesOutput = "" @State private var machine = UserDefaults.standard.string(forKey: "dev.dory.usb.lastMachine") ?? "default" @State private var busid = "" - @State private var port = "" - @State private var rememberAttachment = true + @State private var machines: [DorydMachineStatus] = [] @State private var remembered: [UsbAttachment] = UsbAttachmentStore().attachments() @State private var busy = false @State private var status = "" @@ -45,42 +44,38 @@ struct UsbDevicesView: View { groupLabel("ATTACHMENT") VStack(alignment: .leading, spacing: 12) { Label { - Text(UsbPassthroughAvailability.unavailableReason) + Text(availabilityMessage) .font(.system(size: 12)) .foregroundStyle(p.text2) } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + Image(systemName: attachmentIsAvailable + ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(attachmentIsAvailable ? .green : .orange) } - .accessibilityIdentifier("usb-passthrough-unavailable") + .accessibilityIdentifier(attachmentIsAvailable + ? "usb-passthrough-available" : "usb-passthrough-unavailable") + + Text("Attach grants the selected guest temporary access to this host device. The public app requests user authorization only; it never silently seizes or captures devices.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) HStack(spacing: 10) { - TextField("machine", text: $machine) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) + Picker("Machine", selection: $machine) { + if machines.isEmpty { + Text("No local machines").tag(machine) + } else { + ForEach(machines, id: \.id) { candidate in + Text("\(candidate.id) · \(candidate.state)").tag(candidate.id) + } + } + } + .labelsHidden() + .frame(minWidth: 190) .accessibilityIdentifier("usb-machine") - TextField("bus id or vid:pid", text: $busid) + TextField("bus id", text: $busid) .textFieldStyle(.roundedBorder) .font(.system(size: 12, design: .monospaced)) .accessibilityIdentifier("usb-busid") - TextField("port", text: $port) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) - .frame(width: 76) - .accessibilityIdentifier("usb-port") - } - .disabled(!UsbPassthroughAvailability.attachSupported) - - Toggle("Remember for this machine", isOn: $rememberAttachment) - .font(.system(size: 12.5)) - .toggleStyle(.checkbox) - .disabled(!UsbPassthroughAvailability.attachSupported) - - if UsbPassthroughAvailability.attachSupported, - rememberAttachment && validRememberPort() == nil { - Text("Enter a valid port (1-65535) to remember this attachment for automatic replay.") - .font(.system(size: 11)) - .foregroundStyle(p.text3) } HStack(spacing: 10) { @@ -90,7 +85,7 @@ struct UsbDevicesView: View { } .buttonStyle(.borderedProminent) .disabled( - !UsbPassthroughAvailability.attachSupported || + !attachmentIsAvailable || busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) @@ -100,7 +95,7 @@ struct UsbDevicesView: View { } .buttonStyle(.bordered) .disabled( - !UsbPassthroughAvailability.attachSupported || + !attachmentIsAvailable || busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) } @@ -115,7 +110,7 @@ struct UsbDevicesView: View { if !remembered.isEmpty { Divider() VStack(alignment: .leading, spacing: 8) { - Text("Saved preview entries (automatic replay is disabled)") + Text("Legacy remembered entries (automatic replay is disabled)") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(p.text3) ForEach(remembered) { attachment in @@ -154,66 +149,61 @@ struct UsbDevicesView: View { @MainActor private func refresh() async { busy = true defer { busy = false } - let result = await Self.runDory(["usb", "ls"]) + async let scan = Self.runDory(["usb", "ls"]) + var machineStatusError: String? + do { + machines = try await DorydClient().machineList().sorted { $0.id < $1.id } + if !machines.contains(where: { $0.id == machine }) { + machine = machines.first(where: { + UsbPassthroughAvailability.attachSupported(for: $0) + })?.id ?? machines.first?.id ?? machine + } + } catch { + machines = [] + machineStatusError = "Machine status failed: \(error)" + } + let result = await scan devicesOutput = result.output - status = result.succeeded ? "USB devices refreshed." : "USB scan failed: \(result.output)" + status = machineStatusError + ?? (result.succeeded ? "USB devices refreshed." : "USB scan failed: \(result.output)") } @MainActor private func attach() async { - guard UsbPassthroughAvailability.attachSupported else { - status = UsbPassthroughAvailability.unavailableReason + guard attachmentIsAvailable else { + status = availabilityMessage return } busy = true defer { busy = false } - let args = usbCommand("attach") - let result = await Self.runDory(args) - if result.succeeded { - rememberIfNeeded() - status = "Attached \(cleanBusID())." - } else { - status = "Attach failed: \(result.output)" + do { + let attachment = try await DorydClient().machineUSBAttach( + cleanMachine(), + busID: cleanBusID() + ) + UserDefaults.standard.set(cleanMachine(), forKey: "dev.dory.usb.lastMachine") + status = "Attached \(attachment.busID) on guest port \(attachment.port)." + } catch { + status = "Attach failed: \(error)" } } @MainActor private func detach() async { - guard UsbPassthroughAvailability.attachSupported else { - status = UsbPassthroughAvailability.unavailableReason + guard attachmentIsAvailable else { + status = availabilityMessage return } busy = true defer { busy = false } - let args = usbCommand("detach") - let result = await Self.runDory(args) - if result.succeeded { + do { + try await DorydClient().machineUSBDetach(cleanMachine(), busID: cleanBusID()) try? UsbAttachmentStore().forget(machine: cleanMachine(), busID: cleanBusID()) reloadRemembered() status = "Detached \(cleanBusID())." - } else { - status = "Detach failed: \(result.output)" - } - } - - @MainActor private func rememberIfNeeded() { - guard rememberAttachment else { return } - guard let port = validRememberPort() else { - status = "Attached \(cleanBusID()), but not remembered: enter a valid port (1-65535) to replay it automatically." - return - } - do { - UserDefaults.standard.set(cleanMachine(), forKey: "dev.dory.usb.lastMachine") - _ = try UsbAttachmentStore().remember(machine: cleanMachine(), busID: cleanBusID(), port: port) - reloadRemembered() } catch { - status = "Attached, but could not remember it: \(error)" + status = "Detach failed: \(error)" } } - private func validRememberPort() -> Int? { - guard let port = Int(cleanPort()), (1...65_535).contains(port) else { return nil } - return port - } - @MainActor private func forget(_ attachment: UsbAttachment) { try? UsbAttachmentStore().forget(machine: attachment.machine, busID: attachment.busID) reloadRemembered() @@ -225,17 +215,17 @@ struct UsbDevicesView: View { private func cleanMachine() -> String { machine.trimmingCharacters(in: .whitespacesAndNewlines) } private func cleanBusID() -> String { busid.trimmingCharacters(in: .whitespacesAndNewlines) } - private func cleanPort() -> String { port.trimmingCharacters(in: .whitespacesAndNewlines) } - private func usbCommand(_ action: String) -> [String] { - var args = ["usb", action, cleanBusID()] - if !cleanPort().isEmpty { - args += ["--port", cleanPort()] - } - if !cleanMachine().isEmpty { - args += ["--machine", cleanMachine()] - } - return args + private var selectedMachine: DorydMachineStatus? { + machines.first { $0.id == cleanMachine() } + } + + private var attachmentIsAvailable: Bool { + UsbPassthroughAvailability.attachSupported(for: selectedMachine) + } + + private var availabilityMessage: String { + UsbPassthroughAvailability.unavailableReason(for: selectedMachine) } nonisolated static func runDory(_ arguments: [String]) async -> CommandResult { diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index ca44e82e..edeaec69 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -2646,7 +2646,7 @@ final class AppStore { } private func replayRememberedUSB(machine: String) { - guard UsbPassthroughAvailability.attachSupported else { return } + guard UsbPassthroughAvailability.automaticReplaySupported else { return } guard !usbReplayedMachines.contains(machine) else { return } let commands = usbAttachments.reattachCommands(for: machine) usbReplayedMachines.insert(machine) diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift index f56b914e..d306efc8 100644 --- a/Dory/Net/UsbAttachmentStore.swift +++ b/Dory/Net/UsbAttachmentStore.swift @@ -1,11 +1,33 @@ import Foundation enum UsbPassthroughAvailability: Sendable { - static let attachSupported = false - static let unavailableReason = - "USB passthrough is not yet available in the public app. Host discovery, resolved-only " + - "daemon transport, private routing, and plan authorization work, but current production " + - "catalogs have no signed physical-device qualification. App consent and replay remain disabled." + static let automaticReplaySupported = false + + static func attachSupported(for status: DorydMachineStatus?) -> Bool { + guard let status else { return false } + return status.state == "running" + && status.runtimeIdentity.backend == "dory-hypervisor" + && status.runtimeIdentity.authorizesRemovableUSBHotplug + } + + static func unavailableReason(for status: DorydMachineStatus?) -> String { + guard let status else { + return "Select a running machine with signed removable-USB authorization." + } + guard status.state == "running" else { + return "Start this machine before attaching a host USB device." + } + guard status.runtimeIdentity.mode == "resolved-plan" else { + return "This machine has no launch-authorizing resolved plan. USB attachment fails closed." + } + guard status.runtimeIdentity.backend == "dory-hypervisor" else { + return "USB attachment requires the resolved raw-hypervisor backend." + } + guard status.runtimeIdentity.authorizesRemovableUSBHotplug else { + return "This machine's signed plan does not authorize removable USB hotplug." + } + return "USB attachment is available for this machine." + } } struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 1d97f587..ca707fd8 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -498,6 +498,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha var backendRuntimeBuildIdentifier: String? = nil var supportTier: String? = nil var graphics: String? = nil + var removableUSBHotplug: Bool? = nil var selectionDisposition: String? = nil var fallbackAuthorizationIdentity: String? = nil var experimentalAuthorizationIdentity: String? = nil @@ -541,6 +542,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha && backendRuntimeBuildIdentifier == nil && supportTier == nil && graphics == nil + && removableUSBHotplug == nil && selectionDisposition == nil && fallbackAuthorizationIdentity == nil && experimentalAuthorizationIdentity == nil @@ -604,6 +606,19 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha return false } } + + var authorizesRemovableUSBHotplug: Bool { + mode == "resolved-plan" && removableUSBHotplug == true && isValid + } +} + +nonisolated struct DorydMachineUSBAttachment: Sendable, Equatable { + var machineID: String + var busID: String + var port: Int + var vsockPort: UInt32 + var deviceID: UInt32 + var speed: UInt32 } nonisolated private extension String { @@ -2114,6 +2129,35 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineUSBAttach( + _ machineID: String, + busID: String + ) async throws -> DorydMachineUSBAttachment { + try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machineUSBAttach(machineID, busID: busID, reply: reply) + } decode: { + Self.machineUSBAttachment( + from: $0, + expectedMachineID: machineID, + expectedBusID: busID + ) + } + } + + func machineUSBDetach(_ machineID: String, busID: String) async throws { + _ = try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machineUSBDetach(machineID, busID: busID, reply: reply) + } decode: { response -> Bool? in + guard let keys = response.allKeys as? [String], + Set(keys) == ["machineID", "busID"], + response["machineID"] as? String == machineID, + response["busID"] as? String == busID else { + return nil + } + return true + } + } + func machineProvision(_ machineID: String, recipe: String) async throws -> DorydMachineProvisionResult { try await withTimeout(atLeast: Self.machineProvisionControlTimeout).statusCommand { proxy, reply in proxy.machineProvision(machineID, request: ["recipe": recipe] as NSDictionary, reply: reply) @@ -2754,6 +2798,57 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineUSBAttachment( + from dictionary: NSDictionary, + expectedMachineID: String, + expectedBusID: String + ) -> DorydMachineUSBAttachment? { + let expectedKeys: Set = [ + "machineID", "busID", "port", "vsockPort", "deviceID", "speed", + ] + guard let keys = dictionary.allKeys as? [String], + Set(keys) == expectedKeys, + keys.count == expectedKeys.count, + dictionary["machineID"] as? String == expectedMachineID, + dictionary["busID"] as? String == expectedBusID, + let port = exactUnsignedInteger(dictionary["port"], maximum: 65_535), + let vsockPort = exactUnsignedInteger( + dictionary["vsockPort"], maximum: UInt64(UInt32.max) + ), vsockPort == 1_025, + let deviceID = exactUnsignedInteger( + dictionary["deviceID"], maximum: UInt64(UInt32.max) + ), deviceID > 0, + let speed = exactUnsignedInteger( + dictionary["speed"], maximum: UInt64(UInt32.max) + ), speed > 0 else { + return nil + } + return DorydMachineUSBAttachment( + machineID: expectedMachineID, + busID: expectedBusID, + port: Int(port), + vsockPort: UInt32(vsockPort), + deviceID: UInt32(deviceID), + speed: UInt32(speed) + ) + } + + nonisolated private static func exactUnsignedInteger( + _ raw: Any?, + maximum: UInt64 + ) -> UInt64? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, + number.doubleValue >= 0, + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.doubleValue <= Double(maximum) else { + return nil + } + let value = number.uint64Value + return value <= maximum && Double(value) == number.doubleValue ? value : nil + } + private struct ParsedMachineFailure { var value: DorydMachineFailure? } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 9fa8ecee..bd80b397 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -5,6 +5,73 @@ import Testing @Suite(.serialized) struct DorydClientTests { + @MainActor + @Test func machineUSBControlRequiresExactResolvedResponse() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let attachment = try await client.machineUSBAttach("dev", busID: "3-2") + #expect(attachment == DorydMachineUSBAttachment( + machineID: "dev", + busID: "3-2", + port: 4, + vsockPort: 1_025, + deviceID: 0x0003_0002, + speed: 3 + )) + try await client.machineUSBDetach("dev", busID: "3-2") + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": 4, + "vsockPort": 1_025, + "deviceID": 0x0003_0002, + "speed": 3, + "unexpected": true, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": true, + "vsockPort": 1_025, + "deviceID": 0x0003_0002, + "speed": 3, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": 4, + "vsockPort": 1_026, + "deviceID": 0x0003_0002, + "speed": 3, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBDetachResponse([ + "machineID": "dev", + "busID": "different", + ]) + await #expect(throws: (any Error).self) { + try await client.machineUSBDetach("dev", busID: "3-2") + } + } + @MainActor @Test func desktopUpdateRejectsPresentMalformedOperationIdentity() async throws { let listener = NSXPCListener.anonymous() @@ -1579,6 +1646,8 @@ struct DorydClientTests { let status = try #require( (try await DorydClient(endpoint: listener.endpoint).machineList()).first ) + #expect(status.runtimeIdentity.authorizesRemovableUSBHotplug) + #expect(UsbPassthroughAvailability.attachSupported(for: status)) let machine = AppStore.machine(fromDoryd: status) #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") #expect(machine.agentProtocolVersion == 1) @@ -2104,6 +2173,7 @@ struct DorydClientTests { "backendRuntimeBuildIdentifier": "runtime-1", "supportTier": "supported", "graphics": "hardware-accelerated-3d", + "removableUSBHotplug": true, "selectionDisposition": "primary", "runtimeQualification": [ "qualificationIdentity": "runtime-qualification-1", @@ -4072,6 +4142,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineEventBatchOverride: NSDictionary? private var _machineFlightRecorderBatchOverride: NSDictionary? private var _machineDeviceTelemetryResponseOverride: NSDictionary? + private var _machineUSBAttachResponseOverride: NSDictionary? + private var _machineUSBDetachResponseOverride: NSDictionary? private var _machineSerialConsoleBatchOverride: NSDictionary? private var _latestMachineSerialConsoleCursor: NSDictionary? private var _latestMachineSerialConsoleInput: Data? @@ -4111,6 +4183,16 @@ private final class FakeDorydService: NSObject, DorydControlXPC { _machineTransferResponseOverride = response } + func setMachineUSBAttachResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineUSBAttachResponseOverride = response + } + + func setMachineUSBDetachResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineUSBDetachResponseOverride = response + } + func setMachineTransferOperationResponse(_ response: NSDictionary?) { lock.lock(); defer { lock.unlock() } _machineTransferOperationResponseOverride = response @@ -4930,6 +5012,38 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineUSBAttach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineUSBAttachResponseOverride ?? [ + "machineID": machineID, + "busID": busID, + "port": 4, + "vsockPort": UInt32(1_025), + "deviceID": UInt32(0x0003_0002), + "speed": UInt32(3), + ] + lock.unlock() + reply(true, row, "") + } + + func machineUSBDetach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineUSBDetachResponseOverride ?? [ + "machineID": machineID, + "busID": busID, + ] + lock.unlock() + reply(true, row, "") + } + func machineSerialConsoleRead( _ machineID: String, cursor: NSDictionary, diff --git a/DoryTests/UsbAttachmentStoreTests.swift b/DoryTests/UsbAttachmentStoreTests.swift index f39fb160..194604b2 100644 --- a/DoryTests/UsbAttachmentStoreTests.swift +++ b/DoryTests/UsbAttachmentStoreTests.swift @@ -3,9 +3,13 @@ import Testing @testable import Dory struct UsbAttachmentStoreTests { - @Test func passthroughIsTruthfullyDisabledUntilGuestRPCShips() { - #expect(!UsbPassthroughAvailability.attachSupported) - #expect(UsbPassthroughAvailability.unavailableReason.contains("physical-device")) + @Test func automaticReplayRemainsDisabledWithoutASelectedAuthorizedMachine() { + #expect(!UsbPassthroughAvailability.automaticReplaySupported) + #expect(!UsbPassthroughAvailability.attachSupported(for: nil)) + #expect( + UsbPassthroughAvailability.unavailableReason(for: nil) + .contains("signed removable-USB authorization") + ) } @Test func remembersAttachmentsSortedByMachineAndBusID() throws { diff --git a/README.md b/README.md index 845f8bcc..ba3791d5 100644 --- a/README.md +++ b/README.md @@ -711,8 +711,10 @@ docker run --rm \ - **Supported — Headless Linux:** Alpine-based arm64 guests with an initial root `/bin/sh` login. - **Supported — Desktop Venus/Vulkan:** managed arm64 desktops automatically use the isolated, capability-probed Venus path for Vulkan apps, with ordered fallback to classic VirGL and software. -- **Supported discovery / unavailable passthrough — USB:** host discovery is available. Attach, detach, and remembered replay are disabled - until the engine has a complete guest USB/IP RPC and verified guest-kernel support. +- **Qualified-only USB passthrough:** host discovery and explicit app/CLI attach/detach controls are + wired. They enable only for a running raw-HV machine whose exact signed resolved plan authorizes + removable USB; current production catalogs do not yet carry that qualification. Remembered replay + remains disabled. - **Unavailable — audio passthrough:** not part of the current release. - **Supported — agent sandboxes:** grants and residual risks are documented in the [agent guide](https://augani.github.io/dory/docs/agents.md). - Specialized Docker extensions may depend on another product's private paths. Use `dory compat` diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 2df28372..b6ff8866 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -2821,6 +2821,7 @@ private extension DoryMachineRuntimeIdentity { dictionary["backendRuntimeBuildIdentifier"] = plan.backendRuntimeBuildIdentifier dictionary["supportTier"] = plan.supportTier.rawValue dictionary["graphics"] = plan.graphics.rawValue + dictionary["removableUSBHotplug"] = plan.devices.removableUSBHotplug if let selectionDisposition = plan.selectionEvidence?.disposition { dictionary["selectionDisposition"] = selectionDisposition.rawValue } diff --git a/dory-core/README.md b/dory-core/README.md index 02d8d69b..1ec830a9 100644 --- a/dory-core/README.md +++ b/dory-core/README.md @@ -87,8 +87,8 @@ cutover; the Rust path stayed healthy throughout. validates the exact USB/IP import descriptor, and hands the dedicated host vsock stream to Linux `vhci_hcd`; the raw-HV engine consumes that path with pre-claim and pre-mutation capability checks. Raw-HV desktop machines now receive a private, launch-pinned control route, and signed resolved - plans carry an exact removable-USB authorization bit through helper construction. A resolved-only - `dorydctl` attach/detach transport is wired, but current catalogs carry no signed physical-device - qualification; the public app consent UI and automatic replay remain disabled. + plans carry an exact removable-USB authorization bit through helper construction. Resolved-only + app and `dorydctl` attach/detach controls are wired and fail closed unless that bit is present; + current catalogs carry no signed physical-device qualification. Automatic replay remains disabled. - **GPU** — Venus/Vulkan remains an opt-in preview on the arm64 raw-HV tier. It is not a stable 0.4 cross-host GPU API. From 0937d17a891002f9cd66ded53b16eb671d8dc938 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 03:06:49 +0000 Subject: [PATCH 243/338] feat(app): discover host usb through daemon --- Dory/Features/Settings/UsbDevicesView.swift | 114 +++++---- Dory/Models/AppStore.swift | 17 -- Dory/Runtime/Doryd/DorydClient.swift | 104 ++++++++ DoryTests/DorydClientTests.swift | 50 ++++ .../DorydKit/DoryHostUSBDiscovery.swift | 231 ++++++++++++++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 13 + .../DoryHostUSBDiscoveryTests.swift | 62 +++++ .../DorydKitTests/DorydServiceTests.swift | 48 ++++ 9 files changed, 573 insertions(+), 67 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryHostUSBDiscovery.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryHostUSBDiscoveryTests.swift diff --git a/Dory/Features/Settings/UsbDevicesView.swift b/Dory/Features/Settings/UsbDevicesView.swift index 9e5a14b7..47894a83 100644 --- a/Dory/Features/Settings/UsbDevicesView.swift +++ b/Dory/Features/Settings/UsbDevicesView.swift @@ -2,7 +2,7 @@ import SwiftUI struct UsbDevicesView: View { @Environment(\.palette) private var p - @State private var devicesOutput = "" + @State private var hostDevices: [DorydHostUSBDevice] = [] @State private var machine = UserDefaults.standard.string(forKey: "dev.dory.usb.lastMachine") ?? "default" @State private var busid = "" @State private var machines: [DorydMachineStatus] = [] @@ -26,12 +26,20 @@ struct UsbDevicesView: View { } ScrollView { - Text(devicesOutput.isEmpty ? "No USB scan has run yet." : devicesOutput) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(devicesOutput.isEmpty ? p.text3 : p.text2) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + if hostDevices.isEmpty { + Text("No attachable host USB devices found.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(hostDevices) { device in + hostDeviceButton(device) + if device.id != hostDevices.last?.id { Divider() } + } + } + } } .frame(minHeight: 180, maxHeight: 280) .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) @@ -137,7 +145,7 @@ struct UsbDevicesView: View { .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) } .task { - if devicesOutput.isEmpty { await refresh() } + if hostDevices.isEmpty { await refresh() } } } @@ -146,13 +154,47 @@ struct UsbDevicesView: View { .padding(.bottom, -10) } + private func hostDeviceButton(_ device: DorydHostUSBDevice) -> some View { + let subtitle = String( + format: "%@ · %04x:%04x", + device.busID, + device.vendorID, + device.productID + ) + return Button { + busid = device.busID + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(device.displayName) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(p.text) + Text(subtitle) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(p.text3) + } + Spacer(minLength: 0) + if busid == device.busID { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 9) + } + .buttonStyle(.plain) + .accessibilityIdentifier("usb-device-\(device.busID)") + } + @MainActor private func refresh() async { busy = true defer { busy = false } - async let scan = Self.runDory(["usb", "ls"]) - var machineStatusError: String? + async let deviceScan = DorydClient().hostUSBDevices() + async let machineScan = DorydClient().machineList() + var errors: [String] = [] do { - machines = try await DorydClient().machineList().sorted { $0.id < $1.id } + machines = try await machineScan.sorted { $0.id < $1.id } if !machines.contains(where: { $0.id == machine }) { machine = machines.first(where: { UsbPassthroughAvailability.attachSupported(for: $0) @@ -160,12 +202,18 @@ struct UsbDevicesView: View { } } catch { machines = [] - machineStatusError = "Machine status failed: \(error)" + errors.append("Machine status failed: \(error)") } - let result = await scan - devicesOutput = result.output - status = machineStatusError - ?? (result.succeeded ? "USB devices refreshed." : "USB scan failed: \(result.output)") + do { + hostDevices = try await deviceScan + if busid.isEmpty, let first = hostDevices.first { + busid = first.busID + } + } catch { + hostDevices = [] + errors.append("USB scan failed: \(error)") + } + status = errors.isEmpty ? "USB devices refreshed." : errors.joined(separator: " ") } @MainActor private func attach() async { @@ -228,38 +276,4 @@ struct UsbDevicesView: View { UsbPassthroughAvailability.unavailableReason(for: selectedMachine) } - nonisolated static func runDory(_ arguments: [String]) async -> CommandResult { - await Task.detached(priority: .userInitiated) { - let process = Process() - process.executableURL = doryCLIURL() - process.arguments = arguments - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return CommandResult(succeeded: process.terminationStatus == 0, output: output) - } catch { - return CommandResult(succeeded: false, output: error.localizedDescription) - } - }.value - } - - nonisolated private static func doryCLIURL() -> URL { - if let override = ProcessInfo.processInfo.environment["DORY_CLI"], !override.isEmpty { - return URL(fileURLWithPath: override) - } - if FileManager.default.isExecutableFile(atPath: "/usr/local/bin/dory") { - return URL(fileURLWithPath: "/usr/local/bin/dory") - } - return URL(fileURLWithPath: "/opt/homebrew/bin/dory") - } - - struct CommandResult: Sendable, Equatable { - let succeeded: Bool - let output: String - } } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index edeaec69..387a5e30 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -2204,8 +2204,6 @@ final class AppStore { enabled: openLoginsOnMac, open: { url in DispatchQueue.main.async { NSWorkspace.shared.open(url) } } ) - @ObservationIgnored private let usbAttachments = UsbAttachmentStore() - @ObservationIgnored private var usbReplayedMachines: Set = [] private let domainTable = DomainTable() @ObservationIgnored private var dns = DoryDNS() @ObservationIgnored private let reverseProxy: DoryReverseProxy @@ -2636,26 +2634,11 @@ final class AppStore { func registerMachineBridge(_ name: String) { try? FileManager.default.createDirectory(atPath: MachineService.bridgeHostDir(for: name), withIntermediateDirectories: true) hostBridge.startWatching(machine: name) - replayRememberedUSB(machine: name) } func unregisterMachineBridge(_ name: String) { hostBridge.stopWatching(machine: name) portForwarder.teardownLoopback(forMachine: name) - usbReplayedMachines.remove(name) - } - - private func replayRememberedUSB(machine: String) { - guard UsbPassthroughAvailability.automaticReplaySupported else { return } - guard !usbReplayedMachines.contains(machine) else { return } - let commands = usbAttachments.reattachCommands(for: machine) - usbReplayedMachines.insert(machine) - guard !commands.isEmpty else { return } - Task.detached(priority: .utility) { - for arguments in commands { - _ = await UsbDevicesView.runDory(arguments) - } - } } /// `.dory.local` → the published host port that reaches the container. Containers without a diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index ca707fd8..4029f12a 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -34,6 +34,7 @@ nonisolated protocol DorydControlXPC { func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) func machineUSBAttach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUSBDetach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -621,6 +622,24 @@ nonisolated struct DorydMachineUSBAttachment: Sendable, Equatable { var speed: UInt32 } +nonisolated struct DorydHostUSBDevice: Sendable, Equatable, Identifiable { + var busID: String + var vendorID: UInt16 + var productID: UInt16 + var vendorName: String + var productName: String + var deviceClass: UInt8 + var speed: UInt32 + + var id: String { busID } + + var displayName: String { + if !productName.isEmpty { return productName } + if !vendorName.isEmpty { return vendorName } + return String(format: "%04x:%04x", vendorID, productID) + } +} + nonisolated private extension String { var isLowercaseSHA256: Bool { utf8.count == 64 && utf8.allSatisfy { byte in @@ -2144,6 +2163,24 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func hostUSBDevices() async throws -> [DorydHostUSBDevice] { + try await call { proxy, finish in + proxy.hostUSBDevices { ok, rows, message in + guard ok else { + finish(.failure(DorydClientError.daemon(message))) + return + } + guard let devices = Self.hostUSBDevices(from: rows) else { + finish(.failure(DorydClientError.daemon( + message.isEmpty ? "invalid host USB device list" : message + ))) + return + } + finish(.success(devices)) + } + } + } + func machineUSBDetach(_ machineID: String, busID: String) async throws { _ = try await withTimeout(atLeast: 30).statusCommand { proxy, reply in proxy.machineUSBDetach(machineID, busID: busID, reply: reply) @@ -2833,6 +2870,73 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func hostUSBDevices( + from rows: NSArray + ) -> [DorydHostUSBDevice]? { + guard rows.count <= 256 else { return nil } + var devices: [DorydHostUSBDevice] = [] + var busIDs = Set() + for raw in rows { + guard let dictionary = raw as? NSDictionary, + let device = hostUSBDevice(from: dictionary), + busIDs.insert(device.busID).inserted else { + return nil + } + devices.append(device) + } + guard devices == devices.sorted(by: { $0.busID < $1.busID }) else { return nil } + return devices + } + + nonisolated private static func hostUSBDevice( + from dictionary: NSDictionary + ) -> DorydHostUSBDevice? { + let expectedKeys: Set = [ + "busID", "vendorID", "productID", "vendorName", "productName", "deviceClass", "speed", + ] + guard let keys = dictionary.allKeys as? [String], + Set(keys) == expectedKeys, + keys.count == expectedKeys.count, + let busID = dictionary["busID"] as? String, + isValidUSBBusID(busID), + let vendorID = exactUnsignedInteger(dictionary["vendorID"], maximum: UInt64(UInt16.max)), + let productID = exactUnsignedInteger(dictionary["productID"], maximum: UInt64(UInt16.max)), + let vendorName = dictionary["vendorName"] as? String, + isValidUSBDisplayName(vendorName), + let productName = dictionary["productName"] as? String, + isValidUSBDisplayName(productName), + let deviceClass = exactUnsignedInteger(dictionary["deviceClass"], maximum: UInt64(UInt8.max)), + let speed = exactUnsignedInteger(dictionary["speed"], maximum: UInt64(UInt32.max)) else { + return nil + } + return DorydHostUSBDevice( + busID: busID, + vendorID: UInt16(vendorID), + productID: UInt16(productID), + vendorName: vendorName, + productName: productName, + deviceClass: UInt8(deviceClass), + speed: UInt32(speed) + ) + } + + nonisolated private static func isValidUSBBusID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1..<32).contains(bytes.count), + bytes.first.map({ (48...57).contains($0) }) == true, + bytes.last.map({ (48...57).contains($0) }) == true else { + return false + } + return bytes.allSatisfy { (48...57).contains($0) || $0 == 45 || $0 == 46 } + } + + nonisolated private static func isValidUSBDisplayName(_ value: String) -> Bool { + value.utf8.count <= 128 + && value.unicodeScalars.allSatisfy { + !CharacterSet.controlCharacters.contains($0) + } + } + nonisolated private static func exactUnsignedInteger( _ raw: Any?, maximum: UInt64 diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index bd80b397..16f665ba 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -15,6 +15,33 @@ struct DorydClientTests { defer { listener.invalidate() } let client = DorydClient(endpoint: listener.endpoint) + let hostDevices = try await client.hostUSBDevices() + #expect(hostDevices == [DorydHostUSBDevice( + busID: "3-2", + vendorID: 0x05ac, + productID: 0x12a8, + vendorName: "Example Vendor", + productName: "Example Device", + deviceClass: 3, + speed: 4 + )]) + + service.setHostUSBDevicesResponse([ + [ + "busID": "3-2", + "vendorID": 0x05ac, + "productID": 0x12a8, + "vendorName": "Example Vendor", + "productName": "Example Device", + "deviceClass": 3, + "speed": 4, + "serialNumber": "must-not-cross-xpc", + ], + ]) + await #expect(throws: (any Error).self) { + _ = try await client.hostUSBDevices() + } + let attachment = try await client.machineUSBAttach("dev", busID: "3-2") #expect(attachment == DorydMachineUSBAttachment( machineID: "dev", @@ -4144,6 +4171,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { private var _machineDeviceTelemetryResponseOverride: NSDictionary? private var _machineUSBAttachResponseOverride: NSDictionary? private var _machineUSBDetachResponseOverride: NSDictionary? + private var _hostUSBDevicesResponseOverride: NSArray? private var _machineSerialConsoleBatchOverride: NSDictionary? private var _latestMachineSerialConsoleCursor: NSDictionary? private var _latestMachineSerialConsoleInput: Data? @@ -4193,6 +4221,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { _machineUSBDetachResponseOverride = response } + func setHostUSBDevicesResponse(_ response: NSArray?) { + lock.lock(); defer { lock.unlock() } + _hostUSBDevicesResponseOverride = response + } + func setMachineTransferOperationResponse(_ response: NSDictionary?) { lock.lock(); defer { lock.unlock() } _machineTransferOperationResponseOverride = response @@ -5030,6 +5063,23 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) { + lock.lock() + let rows = _hostUSBDevicesResponseOverride ?? [ + [ + "busID": "3-2", + "vendorID": 0x05ac, + "productID": 0x12a8, + "vendorName": "Example Vendor", + "productName": "Example Device", + "deviceClass": 3, + "speed": 4, + ], + ] + lock.unlock() + reply(true, rows, "") + } + func machineUSBDetach( _ machineID: String, busID: String, diff --git a/dory-core-swift/Sources/DorydKit/DoryHostUSBDiscovery.swift b/dory-core-swift/Sources/DorydKit/DoryHostUSBDiscovery.swift new file mode 100644 index 00000000..b1c2e4d4 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryHostUSBDiscovery.swift @@ -0,0 +1,231 @@ +import CoreFoundation +import Foundation +import IOKit + +/// A bounded, non-secret projection of one attachable host USB device. Serial numbers and registry +/// paths are intentionally excluded from the public control plane. +public struct DoryHostUSBDevice: Equatable, Sendable { + public var busID: String + public var vendorID: UInt16 + public var productID: UInt16 + public var vendorName: String + public var productName: String + public var deviceClass: UInt8 + public var speed: UInt32 + + public init( + busID: String, + vendorID: UInt16, + productID: UInt16, + vendorName: String = "", + productName: String = "", + deviceClass: UInt8 = 0, + speed: UInt32 = 0 + ) { + self.busID = busID + self.vendorID = vendorID + self.productID = productID + self.vendorName = vendorName + self.productName = productName + self.deviceClass = deviceClass + self.speed = speed + } + + var isValid: Bool { + Self.isValidBusID(busID) + && Self.isValidDisplayName(vendorName) + && Self.isValidDisplayName(productName) + } + + var xpcDictionary: NSDictionary { + [ + "busID": busID, + "vendorID": vendorID, + "productID": productID, + "vendorName": vendorName, + "productName": productName, + "deviceClass": deviceClass, + "speed": speed, + ] + } + + fileprivate static func isValidBusID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1..<32).contains(bytes.count), + bytes.first.map({ (48...57).contains($0) }) == true, + bytes.last.map({ (48...57).contains($0) }) == true else { + return false + } + return bytes.allSatisfy { (48...57).contains($0) || $0 == 45 || $0 == 46 } + } + + fileprivate static func isValidDisplayName(_ value: String) -> Bool { + value.utf8.count <= 128 + && value.unicodeScalars.allSatisfy { + !CharacterSet.controlCharacters.contains($0) + } + } +} + +public protocol DoryHostUSBDiscovering: Sendable { + func devices() throws -> [DoryHostUSBDevice] +} + +public enum DoryHostUSBDiscoveryError: Error, Equatable, Sendable, CustomStringConvertible { + case matchingFailed(kern_return_t) + case invalidDeviceProjection + case duplicateBusID(String) + case tooManyDevices + + public var description: String { + switch self { + case let .matchingFailed(code): + return "host USB discovery failed with IOKit status \(code)" + case .invalidDeviceProjection: + return "host USB discovery returned an invalid device projection" + case let .duplicateBusID(busID): + return "host USB discovery returned duplicate bus identifier \(busID)" + case .tooManyDevices: + return "host USB discovery exceeded its bounded device limit" + } + } +} + +enum DoryHostUSBProjection { + static let maximumDeviceCount = 256 + + static func validated(_ devices: [DoryHostUSBDevice]) throws -> [DoryHostUSBDevice] { + guard devices.count <= maximumDeviceCount else { + throw DoryHostUSBDiscoveryError.tooManyDevices + } + guard devices.allSatisfy(\.isValid) else { + throw DoryHostUSBDiscoveryError.invalidDeviceProjection + } + let sorted = devices.sorted { $0.busID < $1.busID } + for pair in zip(sorted, sorted.dropFirst()) where pair.0.busID == pair.1.busID { + throw DoryHostUSBDiscoveryError.duplicateBusID(pair.0.busID) + } + return sorted + } +} + +/// Read-only IOKit discovery. Attachment still reopens and validates the selected device inside the +/// launch-pinned raw-HV helper, so this advisory snapshot never grants device authority. +public struct IOKitDoryHostUSBDiscovery: DoryHostUSBDiscovering, Sendable { + public init() {} + + public func devices() throws -> [DoryHostUSBDevice] { + var iterator: io_iterator_t = 0 + let status = IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching("IOUSBHostDevice"), + &iterator + ) + guard status == KERN_SUCCESS else { + throw DoryHostUSBDiscoveryError.matchingFailed(status) + } + defer { IOObjectRelease(iterator) } + + var devices: [DoryHostUSBDevice] = [] + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties( + service, + &properties, + kCFAllocatorDefault, + 0 + ) == KERN_SUCCESS, + let dictionary = properties?.takeRetainedValue() as? [String: Any], + let device = Self.device(from: dictionary, service: service) else { + continue + } + devices.append(device) + } + + return try DoryHostUSBProjection.validated(devices) + } + + static func device( + from properties: [String: Any], + service: io_registry_entry_t = 0 + ) -> DoryHostUSBDevice? { + guard let vendorID = uint16(properties, keys: ["idVendor", "USB Vendor ID"]), + let productID = uint16(properties, keys: ["idProduct", "USB Product ID"]), + let deviceNumber = uint32( + properties, + keys: ["USB Address", "bDeviceAddress", "Device Address"] + ) ?? (service == 0 ? nil : UInt32(service & 0xffff)), + deviceNumber > 0 else { + return nil + } + let locationID = uint32( + properties, + keys: ["locationID", "LocationID", "USB LocationID"] + ) + let busNumber = locationID.map { max(1, ($0 >> 24) & 0xff) } ?? 0 + let busID = properties["DoryBusID"] as? String ?? "\(busNumber)-\(deviceNumber)" + let candidate = DoryHostUSBDevice( + busID: busID, + vendorID: vendorID, + productID: productID, + vendorName: boundedName( + properties, + keys: ["USB Vendor Name", "kUSBVendorString", "iManufacturer"] + ), + productName: boundedName( + properties, + keys: ["USB Product Name", "kUSBProductString", "iProduct"] + ), + deviceClass: uint8( + properties, + keys: ["bDeviceClass", "USB Device Class"] + ) ?? 0, + speed: uint32(properties, keys: ["Device Speed", "speed", "USB Speed"]) ?? 0 + ) + return candidate.isValid ? candidate : nil + } + + private static func boundedName(_ properties: [String: Any], keys: [String]) -> String { + for key in keys { + guard let value = properties[key] as? String, !value.isEmpty else { continue } + if DoryHostUSBDevice.isValidDisplayName(value) { return value } + } + return "" + } + + private static func uint8(_ properties: [String: Any], keys: [String]) -> UInt8? { + uint32(properties, keys: keys).flatMap(UInt8.init(exactly:)) + } + + private static func uint16(_ properties: [String: Any], keys: [String]) -> UInt16? { + uint32(properties, keys: keys).flatMap(UInt16.init(exactly:)) + } + + private static func uint32(_ properties: [String: Any], keys: [String]) -> UInt32? { + for key in keys { + guard let raw = properties[key] else { continue } + if let value = raw as? UInt32 { return value } + if let value = raw as? UInt16 { return UInt32(value) } + if let value = raw as? UInt8 { return UInt32(value) } + if let value = raw as? Int, value >= 0 { return UInt32(exactly: value) } + if let value = raw as? NSNumber, + CFGetTypeID(value) != CFBooleanGetTypeID(), + value.doubleValue.isFinite, + value.doubleValue >= 0, + value.doubleValue.rounded(.towardZero) == value.doubleValue, + value.doubleValue <= Double(UInt32.max) { + return UInt32(value.uint64Value) + } + if let value = raw as? String { + if value.lowercased().hasPrefix("0x") { + return UInt32(value.dropFirst(2), radix: 16) + } + return UInt32(value) + } + } + return nil + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 58298d83..298a42f1 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -34,6 +34,7 @@ import Foundation func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) func machineUSBAttach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUSBDetach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index b6ff8866..4bb2a7d5 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -13,6 +13,7 @@ public final class DorydService: NSObject, DorydControl { private let machineImportEnvironment: DoryMachineImportEnvironment private let machineEventStore: DoryMachineEventStore? private let machineBackupScheduler: MachineBackupScheduler? + private let hostUSBDiscovery: any DoryHostUSBDiscovering private let remoteManager: RemoteMachineManager? private let networkingController: NetworkingController? private let networkRouteRepair: (@Sendable () -> Int)? @@ -35,6 +36,7 @@ public final class DorydService: NSObject, DorydControl { (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil, machineImportEnvironment: DoryMachineImportEnvironment = .unverified, machineBackupScheduler: MachineBackupScheduler? = nil, + hostUSBDiscovery: any DoryHostUSBDiscovering = IOKitDoryHostUSBDiscovery(), remoteManager: RemoteMachineManager? = nil, networkingController: NetworkingController? = nil, networkRouteRepair: (@Sendable () -> Int)? = nil, @@ -56,6 +58,7 @@ public final class DorydService: NSObject, DorydControl { DoryMachineEventStore(root: $0.managedStateDirectory) } self.machineBackupScheduler = machineBackupScheduler + self.hostUSBDiscovery = hostUSBDiscovery self.remoteManager = remoteManager self.networkingController = networkingController self.networkRouteRepair = networkRouteRepair @@ -582,6 +585,16 @@ public final class DorydService: NSObject, DorydControl { } } + public func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) { + do { + let devices = try DoryHostUSBProjection.validated(hostUSBDiscovery.devices()) + let rows = devices.map(\.xpcDictionary) as NSArray + reply(true, rows, "") + } catch { + reply(false, [], "\(error)") + } + } + public func machineUSBAttach( _ machineID: String, busID: String, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryHostUSBDiscoveryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryHostUSBDiscoveryTests.swift new file mode 100644 index 00000000..ab27cfc8 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryHostUSBDiscoveryTests.swift @@ -0,0 +1,62 @@ +@testable import DorydKit +import Testing + +struct DoryHostUSBDiscoveryTests { + @Test func propertyProjectionMatchesTheRawHVBusIdentityWithoutSerialData() throws { + let device = try #require(IOKitDoryHostUSBDiscovery.device(from: [ + "idVendor": 0x05ac, + "idProduct": 0x12a8, + "locationID": 0x0300_0000, + "USB Address": 2, + "USB Vendor Name": "Example Vendor", + "USB Product Name": "Example Device", + "USB Serial Number": "private-serial", + "bDeviceClass": 3, + "Device Speed": 4, + ])) + + #expect(device == DoryHostUSBDevice( + busID: "3-2", + vendorID: 0x05ac, + productID: 0x12a8, + vendorName: "Example Vendor", + productName: "Example Device", + deviceClass: 3, + speed: 4 + )) + #expect(!device.xpcDictionary.allValues.contains { ($0 as? String) == "private-serial" }) + } + + @Test func propertyProjectionRejectsAmbiguousOrMalformedIdentity() { + #expect(IOKitDoryHostUSBDiscovery.device(from: [ + "idVendor": true, + "idProduct": 1, + "USB Address": 2, + ]) == nil) + #expect(IOKitDoryHostUSBDiscovery.device(from: [ + "idVendor": 1, + "idProduct": 2, + "USB Address": 0, + ]) == nil) + #expect(IOKitDoryHostUSBDiscovery.device(from: [ + "idVendor": 1, + "idProduct": 2, + "USB Address": 3, + "DoryBusID": "../device", + ]) == nil) + } + + @Test func publicProjectionIsSortedBoundedAndRejectsDuplicates() throws { + let first = DoryHostUSBDevice(busID: "1-2", vendorID: 1, productID: 2) + let second = DoryHostUSBDevice(busID: "1-1", vendorID: 1, productID: 3) + #expect(try DoryHostUSBProjection.validated([first, second]).map(\.busID) == ["1-1", "1-2"]) + #expect(throws: DoryHostUSBDiscoveryError.duplicateBusID("1-2")) { + try DoryHostUSBProjection.validated([first, first]) + } + #expect(throws: DoryHostUSBDiscoveryError.invalidDeviceProjection) { + try DoryHostUSBProjection.validated([ + DoryHostUSBDevice(busID: "../device", vendorID: 1, productID: 2), + ]) + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index d1a412d9..7e84f1af 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -5,6 +5,46 @@ import DoryOperations import XCTest final class DorydServiceTests: XCTestCase { + func testHostUSBDiscoveryPublishesOnlyTheBoundedTypedProjection() throws { + let service = DorydService( + socketPath: "/tmp/doryd-test.sock", + hostUSBDiscovery: StaticHostUSBDiscovery(devices: [ + DoryHostUSBDevice( + busID: "3-2", + vendorID: 0x05ac, + productID: 0x12a8, + vendorName: "Example Vendor", + productName: "Example Device", + deviceClass: 3, + speed: 4 + ), + ]) + ) + let listener = makeAnonymousListener(service: service) + listener.resume() + defer { listener.invalidate() } + let connection = NSXPCConnection(listenerEndpoint: listener.endpoint) + connection.remoteObjectInterface = NSXPCInterface(with: DorydControl.self) + connection.resume() + defer { connection.invalidate() } + let proxy = try XCTUnwrap(connection.remoteObjectProxy as? DorydControl) + + let discovered = expectation(description: "typed host USB projection") + proxy.hostUSBDevices { ok, rows, message in + XCTAssertTrue(ok) + XCTAssertEqual(message, "") + XCTAssertEqual(rows.count, 1) + let row = rows.firstObject as? NSDictionary + XCTAssertEqual( + Set(row?.allKeys.compactMap { $0 as? String } ?? []), + ["busID", "vendorID", "productID", "vendorName", "productName", "deviceClass", "speed"] + ) + XCTAssertEqual(row?["busID"] as? String, "3-2") + discovered.fulfill() + } + wait(for: [discovered], timeout: 5) + } + func testMachineUSBXPCIsResolvedOnly() throws { let base = FileManager.default.temporaryDirectory .appendingPathComponent("doryd-usb-xpc-\(UUID().uuidString)").path @@ -3087,6 +3127,14 @@ final class DorydServiceTests: XCTestCase { } } +private struct StaticHostUSBDiscovery: DoryHostUSBDiscovering { + var values: [DoryHostUSBDevice] + + init(devices: [DoryHostUSBDevice]) { values = devices } + + func devices() throws -> [DoryHostUSBDevice] { values } +} + private func waitForServiceMachineState( _ manager: MachineManager, id: String, From 44567bd9435d6fb311f77d246881852c2a763bb0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 03:20:37 +0000 Subject: [PATCH 244/338] feat(vm): add typed audio policy --- Dory/Runtime/Doryd/DorydClient.swift | 64 ++++++- DoryTests/DorydClientTests.swift | 41 +++++ .../DoryMachineTypedWriteAuthority.swift | 164 +++++++++++++++++- dory-core-swift/Sources/dorydctl/main.swift | 4 +- .../DoryMachineTypedWriteAuthorityTests.swift | 67 ++++++- 5 files changed, 330 insertions(+), 10 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 4029f12a..aaa463ae 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -123,19 +123,22 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { var runtimePreference: DoryDesktopVMMPreference? = nil var graphicsPreference: DoryDesktopGraphicsPreference? = nil var networkMode: DoryVMNetworkMode? = nil + var audioConfiguration: DoryVMAudioConfiguration? = nil init( guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, clipboardPolicy: DoryVMClipboardPolicy? = nil, runtimePreference: DoryDesktopVMMPreference? = nil, graphicsPreference: DoryDesktopGraphicsPreference? = nil, - networkMode: DoryVMNetworkMode? = nil + networkMode: DoryVMNetworkMode? = nil, + audioConfiguration: DoryVMAudioConfiguration? = nil ) { self.guestIdentityIntent = guestIdentityIntent self.clipboardPolicy = clipboardPolicy self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference self.networkMode = networkMode + self.audioConfiguration = audioConfiguration } init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) { @@ -189,10 +192,15 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { graphicsPreference = (try? DoryDesktopGraphicsPreference( environment: legacyEnvironment )) ?? .automatic + audioConfiguration = DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + ) } else { clipboardPolicy = nil runtimePreference = nil graphicsPreference = nil + audioConfiguration = nil } } @@ -202,6 +210,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { && runtimePreference == nil && graphicsPreference == nil && networkMode == nil + && audioConfiguration == nil } var xpcDictionary: NSDictionary { @@ -244,6 +253,12 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { if let networkMode { result["networkMode"] = networkMode.rawValue } + if let audioConfiguration { + result["audio"] = [ + "inputEnabled": audioConfiguration.inputEnabled, + "outputEnabled": audioConfiguration.outputEnabled, + ] as NSDictionary + } return result as NSDictionary } @@ -260,6 +275,8 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) hasher.combine(networkMode?.rawValue) + hasher.combine(audioConfiguration?.inputEnabled) + hasher.combine(audioConfiguration?.outputEnabled) } } @@ -341,6 +358,11 @@ nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { key: "networkMode", into: &result ) + Self.encodeAudio( + baseline.audioConfiguration, + desired.audioConfiguration, + into: &result + ) return result as NSDictionary } @@ -380,6 +402,26 @@ nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { guard baseline != desired else { return } dictionary[key] = desired?.rawValue ?? NSNull() } + + private static func encodeAudio( + _ baseline: DoryVMAudioConfiguration?, + _ desired: DoryVMAudioConfiguration?, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + guard let desired else { + dictionary["audio"] = NSNull() + return + } + var audio: [String: Any] = [:] + if baseline?.inputEnabled != desired.inputEnabled { + audio["inputEnabled"] = desired.inputEnabled + } + if baseline?.outputEnabled != desired.outputEnabled { + audio["outputEnabled"] = desired.outputEnabled + } + dictionary["audio"] = audio as NSDictionary + } } nonisolated struct DorydMachineConfiguration: Sendable, Equatable { @@ -3332,7 +3374,7 @@ nonisolated final class DorydClient: @unchecked Sendable { let keys = value.allKeys as? [String], Set(keys).isSubset(of: [ "guestIdentityIntent", "clipboardPolicy", - "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", + "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", "audio", ]), keys.count == Set(keys).count else { return nil } @@ -3439,12 +3481,28 @@ nonisolated final class DorydClient: @unchecked Sendable { let parsed = DoryVMNetworkMode(rawValue: raw) else { return nil } networkMode = parsed } else { networkMode = nil } + let audioConfiguration: DoryVMAudioConfiguration? + if let encoded = value["audio"] { + guard let raw = encoded as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys) == ["inputEnabled", "outputEnabled"], + rawKeys.count == 2, + let input = raw["inputEnabled"] as? NSNumber, + CFGetTypeID(input) == CFBooleanGetTypeID(), + let output = raw["outputEnabled"] as? NSNumber, + CFGetTypeID(output) == CFBooleanGetTypeID() else { return nil } + audioConfiguration = DoryVMAudioConfiguration( + inputEnabled: input.boolValue, + outputEnabled: output.boolValue + ) + } else { audioConfiguration = nil } return ParsedMachineTypedSettings(value: DorydMachineTypedSettings( guestIdentityIntent: identity, clipboardPolicy: clipboard, runtimePreference: runtime, graphicsPreference: graphics, - networkMode: networkMode + networkMode: networkMode, + audioConfiguration: audioConfiguration )) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 16f665ba..6db18b9b 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -485,6 +485,22 @@ struct DorydClientTests { #expect(networkWire.count == 1) #expect(networkWire["networkMode"] as? String == "disconnected") + #expect(defaults.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + )) + var microphoneDisabled = defaults + microphoneDisabled.audioConfiguration?.inputEnabled = false + let audioWire = DorydMachineTypedSettingsPatch( + baseline: defaults, + desired: microphoneDisabled + ).xpcDictionary + #expect(audioWire.count == 1) + let audio = try #require(audioWire["audio"] as? NSDictionary) + #expect(audio.count == 1) + #expect(audio["inputEnabled"] as? Bool == false) + #expect(audio["outputEnabled"] == nil) + for (legacy, expected) in [("1", DoryDesktopGraphicsPreference.virgl), ("0", DoryDesktopGraphicsPreference.virglVenus)] { let settings = DorydMachineTypedSettings( @@ -659,6 +675,10 @@ struct DorydClientTests { "desktopRuntimePreference": "accelerated", "desktopGraphicsPreference": "virgl-venus", "networkMode": "disconnected", + "audio": [ + "inputEnabled": false, + "outputEnabled": true, + ] as NSDictionary, ]) let delegate = FakeDorydListenerDelegate(service: service) listener.delegate = delegate @@ -674,11 +694,32 @@ struct DorydClientTests { #expect(status.typedSettings?.runtimePreference == .accelerated) #expect(status.typedSettings?.graphicsPreference == .virglVenus) #expect(status.typedSettings?.networkMode == .disconnected) + #expect(status.typedSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: false, + outputEnabled: true + )) service.setMachineTypedSettings("dev", ["unknown": "claim"]) await #expect(throws: (any Error).self) { _ = try await client.machineList() } + + for malformed: NSDictionary in [ + ["audio": ["inputEnabled": 0, "outputEnabled": true]], + ["audio": ["inputEnabled": true]], + [ + "audio": [ + "inputEnabled": true, + "outputEnabled": true, + "route": "private-host-device", + ], + ], + ] { + service.setMachineTypedSettings("dev", malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } } @Test func machineListRequiresExactSavedStateEvidenceForSuspendedRows() async throws { diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index 0093032c..6462f06d 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -1,4 +1,5 @@ import DoryOperations +import CoreFoundation import Foundation public enum DoryMachineTypedWriteAuthorityError: Error, Sendable, Equatable, CustomStringConvertible { @@ -38,6 +39,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha public var runtimePreference: DoryDesktopVMMPreference? public var graphicsPreference: DoryDesktopGraphicsPreference? public var networkMode: DoryVMNetworkMode + public var audioConfiguration: DoryVMAudioConfiguration? private enum CodingKeys: String, CodingKey { case guestIdentityIntent @@ -45,6 +47,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha case runtimePreference case graphicsPreference case networkMode + case audioConfiguration } public init(from decoder: Decoder) throws { @@ -69,6 +72,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha DoryVMNetworkMode.self, forKey: .networkMode ) ?? .sharedNAT + audioConfiguration = try container.decodeIfPresent( + DoryVMAudioConfiguration.self, + forKey: .audioConfiguration + ) } public func encode(to encoder: Encoder) throws { @@ -78,6 +85,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha try container.encodeIfPresent(runtimePreference, forKey: .runtimePreference) try container.encodeIfPresent(graphicsPreference, forKey: .graphicsPreference) try container.encode(networkMode, forKey: .networkMode) + try container.encodeIfPresent(audioConfiguration, forKey: .audioConfiguration) } public init(definition: DoryVirtualMachineDefinition) throws { @@ -87,8 +95,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha clipboardPolicy = nil runtimePreference = nil graphicsPreference = nil + audioConfiguration = nil return } + audioConfiguration = definition.audio clipboardPolicy = definition.clipboardPolicy switch (definition.backendPreference.mode, definition.backendPreference.backend) { case (.automatic, nil): @@ -180,10 +190,18 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha graphicsPreference = (try? DoryDesktopGraphicsPreference( environment: legacyEnvironment )) ?? .automatic + // The compatibility desktop runtime has always attached its combined input/output + // audio device. This is an observation of that fixed legacy behavior, not a new + // persisted environment setting. + audioConfiguration = DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + ) } else { clipboardPolicy = nil runtimePreference = nil graphicsPreference = nil + audioConfiguration = nil } } @@ -202,7 +220,9 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha clipboardPolicy: update(clipboardPolicy), runtimePreference: update(runtimePreference), graphicsPreference: update(graphicsPreference), - networkMode: .set(networkMode) + networkMode: .set(networkMode), + audioInputEnabled: update(audioConfiguration?.inputEnabled), + audioOutputEnabled: update(audioConfiguration?.outputEnabled) ).xpcDictionary } @@ -231,7 +251,9 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha clipboardPolicy: update(clipboardPolicy), runtimePreference: update(runtimePreference), graphicsPreference: update(graphicsPreference), - networkMode: .set(networkMode) + networkMode: .set(networkMode), + audioInputEnabled: update(audioConfiguration?.inputEnabled), + audioOutputEnabled: update(audioConfiguration?.outputEnabled) ) } @@ -248,6 +270,8 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) hasher.combine(networkMode.rawValue) + hasher.combine(audioConfiguration?.inputEnabled) + hasher.combine(audioConfiguration?.outputEnabled) } private func update( @@ -279,6 +303,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { public var runtimePreference: DoryMachineTypedSettingUpdate public var graphicsPreference: DoryMachineTypedSettingUpdate public var networkMode: DoryMachineTypedSettingUpdate + public var audioInputEnabled: DoryMachineTypedSettingUpdate + public var audioOutputEnabled: DoryMachineTypedSettingUpdate public init( guestUsername: DoryMachineTypedSettingUpdate = .unchanged, @@ -290,7 +316,9 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { clipboardPolicy: DoryMachineTypedSettingUpdate = .unchanged, runtimePreference: DoryMachineTypedSettingUpdate = .unchanged, graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged, - networkMode: DoryMachineTypedSettingUpdate = .unchanged + networkMode: DoryMachineTypedSettingUpdate = .unchanged, + audioInputEnabled: DoryMachineTypedSettingUpdate = .unchanged, + audioOutputEnabled: DoryMachineTypedSettingUpdate = .unchanged ) { self.guestUsername = guestUsername self.guestNumericUserID = guestNumericUserID @@ -302,6 +330,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference self.networkMode = networkMode + self.audioInputEnabled = audioInputEnabled + self.audioOutputEnabled = audioOutputEnabled } public var isEmpty: Bool { @@ -315,6 +345,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { && !runtimePreference.isChanged && !graphicsPreference.isChanged && !networkMode.isChanged + && !audioInputEnabled.isChanged + && !audioOutputEnabled.isChanged } /// Consume the typed persistent-machine options shared by dorydctl create and update. Other @@ -384,6 +416,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.networkMode = .set(mode) } + if let raw = try takeOption("--audio-input", from: &arguments) { + patch.audioInputEnabled = .set(try cliBoolean(raw, option: "--audio-input")) + } + if let raw = try takeOption("--audio-output", from: &arguments) { + patch.audioOutputEnabled = .set(try cliBoolean(raw, option: "--audio-output")) + } let clearsAccount = takeFlag("--clear-guest-account", from: &arguments) let clearsDesktop = takeFlag("--clear-desktop-identity", from: &arguments) @@ -391,8 +429,9 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { let clearsRuntime = takeFlag("--clear-runtime", from: &arguments) let clearsGraphics = takeFlag("--clear-graphics", from: &arguments) let clearsNetwork = takeFlag("--clear-network", from: &arguments) + let clearsAudio = takeFlag("--clear-audio", from: &arguments) guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard - && !clearsRuntime && !clearsGraphics && !clearsNetwork) else { + && !clearsRuntime && !clearsGraphics && !clearsNetwork && !clearsAudio) else { throw DoryMachineTypedWriteAuthorityError.invalidField("clear options") } if clearsAccount { @@ -442,6 +481,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.networkMode = .clear } + if clearsAudio { + guard !patch.audioInputEnabled.isChanged, + !patch.audioOutputEnabled.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-audio") + } + patch.audioInputEnabled = .clear + patch.audioOutputEnabled = .clear + } return patch } @@ -479,6 +526,9 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { allowsClears: allowsClears, type: DoryVMNetworkMode.self ) + if let rawAudio = dictionary["audio"] { + try decodeAudio(rawAudio, allowsClears: allowsClears) + } } /// Canonical XPC representation used by dorydctl and future typed clients. @@ -525,6 +575,17 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { into: &result ) Self.encodeEnum(networkMode, key: "networkMode", into: &result) + if audioInputEnabled.isChanged || audioOutputEnabled.isChanged { + if case .clear = audioInputEnabled, + case .clear = audioOutputEnabled { + result["audio"] = NSNull() + } else { + var audio: [String: Any] = [:] + Self.encode(audioInputEnabled, key: "inputEnabled", into: &audio) + Self.encode(audioOutputEnabled, key: "outputEnabled", into: &audio) + result["audio"] = audio as NSDictionary + } + } return result as NSDictionary } @@ -595,6 +656,9 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { "networkMode" ) } + guard !audioInputEnabled.isChanged, !audioOutputEnabled.isChanged else { + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime("audio") + } return environment } @@ -675,6 +739,23 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { case let .set(mode): definition.networkMode = mode } + if audioInputEnabled.isChanged || audioOutputEnabled.isChanged { + var audio = definition.audio + let defaults = displayMode == .desktop + ? DoryVMAudioConfiguration(inputEnabled: true, outputEnabled: true) + : DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false) + Self.apply( + audioInputEnabled, + to: &audio.inputEnabled, + clearValue: defaults.inputEnabled + ) + Self.apply( + audioOutputEnabled, + to: &audio.outputEnabled, + clearValue: defaults.outputEnabled + ) + definition.audio = audio + } let issues = definition.validate() guard issues.isEmpty else { throw DoryMachineTypedWriteAuthorityError.invalidField( @@ -814,6 +895,34 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return .set(DoryVMClipboardPolicy(text: text, image: image, files: files)) } + private mutating func decodeAudio(_ raw: Any, allowsClears: Bool) throws { + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField("audio") + } + audioInputEnabled = .clear + audioOutputEnabled = .clear + return + } + guard let dictionary = Self.dictionary(raw), + Self.hasOnlyKeys(dictionary, allowed: ["inputEnabled", "outputEnabled"]), + dictionary.count > 0 else { + throw DoryMachineTypedWriteAuthorityError.invalidField("audio") + } + audioInputEnabled = try Self.decodeBool( + dictionary, + key: "inputEnabled", + field: "audio.inputEnabled", + allowsClears: allowsClears + ) + audioOutputEnabled = try Self.decodeBool( + dictionary, + key: "outputEnabled", + field: "audio.outputEnabled", + allowsClears: allowsClears + ) + } + private func validate(displayMode: DoryMachineDisplayMode) throws { if case let .set(value) = guestUsername, !DoryVMGuestAccountIntent.isValidUsername(value) { @@ -874,6 +983,10 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { "desktopRuntimePreference" ) } + if (audioInputEnabled.isChanged || audioOutputEnabled.isChanged), + displayMode != .desktop { + throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay("audio") + } } private static func dictionary(_ raw: Any) -> NSDictionary? { @@ -896,6 +1009,21 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } } + private static func apply( + _ update: DoryMachineTypedSettingUpdate, + to value: inout Value, + clearValue: Value + ) { + switch update { + case .unchanged: + break + case let .set(replacement): + value = replacement + case .clear: + value = clearValue + } + } + private static func hasOnlyKeys(_ dictionary: NSDictionary, allowed: Set) -> Bool { dictionary.allKeys.allSatisfy { key in guard let key = key as? String else { return false } @@ -950,6 +1078,26 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return .set(value) } + private static func decodeBool( + _ dictionary: NSDictionary, + key: String, + field: String, + allowsClears: Bool + ) throws -> DoryMachineTypedSettingUpdate { + guard let raw = dictionary[key] else { return .unchanged } + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .clear + } + guard let number = raw as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + return .set(number.boolValue) + } + private static func direction(_ raw: Any?) -> DoryVMClipboardDirection? { guard let raw = raw as? String else { return nil } return DoryVMClipboardDirection(rawValue: raw) @@ -999,6 +1147,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return value } + private static func cliBoolean(_ raw: String, option: String) throws -> Bool { + switch raw { + case "on": true + case "off": false + default: throw DoryMachineTypedWriteAuthorityError.invalidField(option) + } + } + private static func takeFlag(_ name: String, from arguments: inout [String]) -> Bool { guard let index = arguments.firstIndex(of: name) else { return false } arguments.remove(at: index) diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 6427f827..0d906ae6 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -194,8 +194,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine device-telemetry NAME dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] dorydctl [global] machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT] - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network] [--attach-installer | --eject-installer] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--audio-input on|off] [--audio-output on|off] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network | --clear-audio] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME dorydctl [global] machine usb-attach NAME BUS_ID dorydctl [global] machine usb-detach NAME BUS_ID diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 9dcecb72..73b09c7c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -17,7 +17,9 @@ struct DoryMachineTypedWriteAuthorityTests { clipboardPolicy: .set(.legacyDesktop(.hostToGuest)), runtimePreference: .set(.accelerated), graphicsPreference: .set(.virglVenus), - networkMode: .set(.disconnected) + networkMode: .set(.disconnected), + audioInputEnabled: .set(false), + audioOutputEnabled: .set(true) ) let wire = source.xpcDictionary @@ -95,6 +97,10 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(snapshot.runtimePreference == .accelerated) #expect(snapshot.graphicsPreference == .virglVenus) #expect(snapshot.networkMode == .sharedNAT) + #expect(snapshot.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + )) #expect(snapshot.xpcDictionary.description.contains("must-never-cross-xpc") == false) let invalid = DoryMachineTypedSettingsSnapshot( @@ -113,11 +119,13 @@ struct DoryMachineTypedWriteAuthorityTests { as? [String: Any] ) historical.removeValue(forKey: "networkMode") + historical.removeValue(forKey: "audioConfiguration") let decoded = try JSONDecoder().decode( DoryMachineTypedSettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: historical) ) #expect(decoded.networkMode == .sharedNAT) + #expect(decoded.audioConfiguration == nil) } @Test("update clear is explicit and field scoped") @@ -187,6 +195,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(snapshot.runtimePreference == nil) #expect(snapshot.graphicsPreference == nil) + #expect(snapshot.audioConfiguration == nil) #expect(restored.backendPreference == migration.definition.backendPreference) #expect(restored.graphics == migration.definition.graphics) #expect(restored.clipboardPolicy == migration.definition.clipboardPolicy) @@ -292,6 +301,17 @@ struct DoryMachineTypedWriteAuthorityTests { to: migrated.definition, displayMode: .desktop ).networkMode == .disconnected) + + let audio = DoryMachineTypedSettingsPatch(audioInputEnabled: .set(false)) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "audio" + )) { + try audio.applying(to: [:], displayMode: .desktop) + } + #expect(try audio.applying( + to: migrated.definition, + displayMode: .desktop + ).audio == DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true)) } @Test("clipboard wire shape is complete and exact") @@ -326,6 +346,44 @@ struct DoryMachineTypedWriteAuthorityTests { } } + @Test("audio wire shape is strict, leaf-scoped, and resets explicitly") + func exactAudioWireShape() throws { + let patch = try DoryMachineTypedSettingsPatch( + xpcDictionary: ["audio": ["inputEnabled": false]], + allowsClears: true + ) + #expect(patch.audioInputEnabled == .set(false)) + #expect(patch.audioOutputEnabled == .unchanged) + #expect((patch.xpcDictionary["audio"] as? NSDictionary)?["inputEnabled"] as? Bool == false) + + for raw: Any in [ + ["inputEnabled": 0], + ["outputEnabled": "true"], + ["inputEnabled": true, "secret": "opaque"], + [:], + ] { + #expect(throws: (any Error).self) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: ["audio": raw], + allowsClears: true + ) + } + } + #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("audio")) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: ["audio": NSNull()], + allowsClears: false + ) + } + + let reset = try DoryMachineTypedSettingsPatch( + xpcDictionary: ["audio": NSNull()], + allowsClears: true + ) + #expect(reset.audioInputEnabled == .clear) + #expect(reset.audioOutputEnabled == .clear) + } + @Test("CLI consumes typed persistence options and leaves raw env unconsumed") func cliTypedOptions() throws { var arguments = [ @@ -340,6 +398,8 @@ struct DoryMachineTypedWriteAuthorityTests { "--runtime", "compatible", "--graphics", "software", "--network", "disconnected", + "--audio-input", "off", + "--audio-output", "on", "--env", "TOKEN=secret", ] @@ -355,6 +415,8 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.runtimePreference == .set(.compatible)) #expect(patch.graphicsPreference == .set(.software)) #expect(patch.networkMode == .set(.disconnected)) + #expect(patch.audioInputEnabled == .set(false)) + #expect(patch.audioOutputEnabled == .set(true)) #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"]) #expect(patch.xpcDictionary["env"] == nil) } @@ -380,6 +442,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--clear-runtime", "--clear-graphics", "--clear-network", + "--clear-audio", ] let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( &clearing, @@ -394,6 +457,8 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.runtimePreference == .clear) #expect(patch.graphicsPreference == .clear) #expect(patch.networkMode == .clear) + #expect(patch.audioInputEnabled == .clear) + #expect(patch.audioOutputEnabled == .clear) var createClear = ["--clear-clipboard"] #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("clear options")) { From 7b99dea7681da3c9b48d1d3426618bb0530232c5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 03:26:54 +0000 Subject: [PATCH 245/338] feat(app): add virtual machine audio controls --- Dory/Features/Machines/MachinesView.swift | 61 ++++++++++++++++++++++ Dory/Features/Sheets/NewMachineSheet.swift | 47 +++++++++++++++-- DoryTests/NewMachineSettingsTests.swift | 38 ++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 6eb2d23f..83b34886 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1275,6 +1275,22 @@ private struct MachineIntegrationHealthSheet: View { } } +nonisolated enum MachineAudioSettingsPolicy { + static func editedConfiguration( + existing: DoryVMAudioConfiguration?, + inputEnabled: Bool, + outputEnabled: Bool + ) -> DoryVMAudioConfiguration? { + // An older daemon may omit the audio claim entirely. Preserve that absence for an + // unrelated edit, but let an explicit toggle opt into the typed policy. + guard existing != nil || !inputEnabled || !outputEnabled else { return nil } + return DoryVMAudioConfiguration( + inputEnabled: inputEnabled, + outputEnabled: outputEnabled + ) + } +} + private struct MachineEditSheet: View { @Environment(AppStore.self) private var store @Environment(\.palette) private var p @@ -1289,6 +1305,9 @@ private struct MachineEditSheet: View { @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var audioInputEnabled = true + @State private var audioOutputEnabled = true + @State private var originalAudioConfiguration: DoryVMAudioConfiguration? @State private var typedSettings = DorydMachineTypedSettings() private struct MountRow: Identifiable, Hashable { @@ -1310,6 +1329,7 @@ private struct MachineEditSheet: View { warning machineTypeBlock networkBlock + audioBlock runtimeBlock clipboardBlock resourceRow @@ -1349,6 +1369,9 @@ private struct MachineEditSheet: View { runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic networkMode = typedSettings.networkMode ?? .sharedNAT + originalAudioConfiguration = typedSettings.audioConfiguration + audioInputEnabled = typedSettings.audioConfiguration?.inputEnabled ?? true + audioOutputEnabled = typedSettings.audioConfiguration?.outputEnabled ?? true mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly, shareTag: $0.shareTag) } @@ -1463,6 +1486,33 @@ private struct MachineEditSheet: View { } } + @ViewBuilder private var audioBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("AUDIO") + HStack(spacing: 24) { + Toggle("Microphone", isOn: $audioInputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("edit-machine-audio-input") + Toggle("Speakers", isOn: $audioOutputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("edit-machine-audio-output") + Spacer(minLength: 0) + } + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + .disabled(!audioPolicyEditable) + Text(audioPolicyEditable + ? "Only enabled audio directions are attached. Changing this policy requires a backend qualified for the exact device combination." + : "This compatibility machine keeps its historical combined audio device. Replan it into the resolved runtime before changing audio policy.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + @ViewBuilder private var clipboardBlock: some View { if displayMode == .desktop, machine.bootMode != .efi { VStack(alignment: .leading, spacing: 8) { @@ -1653,6 +1703,13 @@ private struct MachineEditSheet: View { ) } typedSettings.networkMode = networkMode + if displayMode == .desktop { + typedSettings.audioConfiguration = MachineAudioSettingsPolicy.editedConfiguration( + existing: originalAudioConfiguration, + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ) + } if displayMode == .desktop, machine.bootMode != .efi { let previousUsername = typedSettings.guestIdentityIntent.account?.username ?? "dory" if previousUsername != normalizedGuestUsername { @@ -1706,6 +1763,10 @@ private struct MachineEditSheet: View { guestUsername.trimmingCharacters(in: .whitespacesAndNewlines) } + private var audioPolicyEditable: Bool { + machine.runtimeIdentity.mode != "legacy-compatibility" + } + private var guestUsernameInvalid: Bool { guard displayMode == .desktop, machine.bootMode != .efi else { return false } return normalizedGuestUsername.range( diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index f9fddeb8..7ce37d6e 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -18,6 +18,8 @@ struct NewMachineSheet: View { @State private var installerISOCheck: InstallerISOCheck = .none @State private var diskSizeGB = 64 @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var audioInputEnabled = true + @State private var audioOutputEnabled = true private enum InstallerISOCheck: Equatable { case none @@ -90,6 +92,7 @@ struct NewMachineSheet: View { devEnvironmentSection identitySection networkBlock + audioBlock optionsRow advancedSection } @@ -535,6 +538,30 @@ struct NewMachineSheet: View { } } + @ViewBuilder private var audioBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("AUDIO") + HStack(spacing: 24) { + Toggle("Microphone", isOn: $audioInputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-audio-input") + Toggle("Speakers", isOn: $audioOutputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-audio-output") + Spacer(minLength: 0) + } + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + Text("Only enabled audio directions are attached to the Linux desktop. The selected backend must qualify the exact combination before launch.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + private var advancedSection: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -892,7 +919,9 @@ struct NewMachineSheet: View { desktopDistro: DesktopMachineDistro = .debian, guestUsername: String = "dory", guestUID: uid_t = getuid(), - networkMode: DoryVMNetworkMode = .sharedNAT + networkMode: DoryVMNetworkMode = .sharedNAT, + audioInputEnabled: Bool = true, + audioOutputEnabled: Bool = true ) -> MachineSettings { let typedSettings: DorydMachineTypedSettings if displayMode == .desktop { @@ -912,7 +941,11 @@ struct NewMachineSheet: View { clipboardPolicy: .legacyDesktop(.bidirectional), runtimePreference: .automatic, graphicsPreference: .automatic, - networkMode: networkMode + networkMode: networkMode, + audioConfiguration: DoryVMAudioConfiguration( + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ) ) } else { typedSettings = DorydMachineTypedSettings(networkMode: networkMode) @@ -960,7 +993,9 @@ struct NewMachineSheet: View { displayMode: displayMode, desktopDistro: desktopDistro, guestUsername: normalizedGuestUsername, - networkMode: networkMode + networkMode: networkMode, + audioInputEnabled: audioInputEnabled, + audioOutputEnabled: audioOutputEnabled ) if customISOInstall { settings.bootMode = .efi @@ -970,7 +1005,11 @@ struct NewMachineSheet: View { // environment marker or carry managed-desktop provisioning intent. settings.env = [:] settings.virtualMachineSettings = DorydMachineTypedSettings( - networkMode: networkMode + networkMode: networkMode, + audioConfiguration: DoryVMAudioConfiguration( + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ) ) } return settings diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index ea362b8b..a2f6cba7 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -50,6 +50,10 @@ struct NewMachineSettingsTests { #expect(s.virtualMachineSettings?.runtimePreference == .automatic) #expect(s.virtualMachineSettings?.graphicsPreference == .automatic) #expect(s.virtualMachineSettings?.networkMode == .sharedNAT) + #expect(s.virtualMachineSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + )) #expect(s.ports.isEmpty) } @@ -106,5 +110,39 @@ struct NewMachineSettingsTests { #expect(settings.virtualMachineSettings?.runtimePreference == nil) #expect(settings.virtualMachineSettings?.graphicsPreference == nil) #expect(settings.virtualMachineSettings?.networkMode == .disconnected) + #expect(settings.virtualMachineSettings?.audioConfiguration == nil) + } + + @Test func desktopAudioDirectionsAreCollectedIndependently() { + let settings = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [], + audioInputEnabled: false, + audioOutputEnabled: true + ) + + #expect(settings.virtualMachineSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: false, + outputEnabled: true + )) + } + + @Test func editingPreservesAnOlderDaemonsAbsentAudioClaimUntilTheUserChangesIt() { + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: nil, + inputEnabled: true, + outputEnabled: true + ) == nil) + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: nil, + inputEnabled: false, + outputEnabled: true + ) == DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true)) + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true), + inputEnabled: true, + outputEnabled: true + ) == DoryVMAudioConfiguration(inputEnabled: true, outputEnabled: true)) } } From 028a883d7af4a2d976ae7cfafc7b8b3868815986 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 03:49:06 +0000 Subject: [PATCH 246/338] feat(vm): bind directional clipboard policy --- .../Sources/dory-hv/DesktopMode.swift | 54 ++++++++++-- .../DesktopClipboardPlanTests.swift | 61 +++++++++++++ .../DoryDesktopClipboardPolicy.swift | 5 ++ .../DoryVirtualMachineCapabilities.swift | 29 +++++++ .../DoryVirtualMachineGuestIntent.swift | 4 +- .../DoryDesktopClipboardCoordinator.swift | 68 +++++++++++++-- .../Sources/DoryVMMKit/DoryVMM.swift | 22 ++++- .../DoryVMMDesktopApplication.swift | 13 ++- ...yDaemonVirtualMachineRuntimePlanning.swift | 3 +- .../DoryDesktopClipboardPolicyTests.swift | 2 + .../DoryVirtualMachineCapabilitiesTests.swift | 44 ++++++++++ ...onVirtualMachineProductionTrustTests.swift | 1 + ...onVirtualMachineRuntimePlanningTests.swift | 2 + .../Tests/DorydKitTests/DoryVMMKitTests.swift | 86 ++++++++++++++++++- 14 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index a8cedd24..500f7665 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -489,6 +489,46 @@ enum DesktopMode { } } + struct ClipboardPlan: Equatable { + var policy: DoryVMClipboardPolicy? + + init( + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + environment: [String: String], + genericGuest: Bool + ) throws { + guard let resolvedDevices else { + policy = genericGuest ? nil + : DoryDesktopClipboardPolicy( + environment: environment + ).virtualMachinePolicy + return + } + guard resolvedDevices.clipboard else { + guard resolvedDevices.clipboardPolicy?.isEnabled != true else { + throw VMError.bootFailure( + "resolved clipboard device and directional policy disagree" + ) + } + policy = nil + return + } + let selected = resolvedDevices.clipboardPolicy + ?? DoryDesktopClipboardPolicy(environment: environment).virtualMachinePolicy + guard selected.isEnabled else { + throw VMError.bootFailure( + "resolved clipboard device and directional policy disagree" + ) + } + guard selected.files == .off else { + throw VMError.bootFailure( + "resolved clipboard file transfer is not implemented" + ) + } + policy = selected + } + } + private struct ResolvedGraphics { var backend: DoryDesktopGraphicsBackend var renderer: VirglRenderer? @@ -582,6 +622,11 @@ enum DesktopMode { ) } } + let clipboardPlan = try ClipboardPlan( + resolvedDevices: configuration.resolvedDevices, + environment: configuration.environment, + genericGuest: configuration.genericGuest + ) self.machine = try Machine(configuration: MachineConfiguration( kernelPath: configuration.kernelPath, initrdPath: configuration.initrdPath, @@ -768,16 +813,13 @@ enum DesktopMode { } else { self.sshAgentBridge = nil } - if configuration.resolvedDevices?.clipboard == false - || (configuration.resolvedDevices == nil && configuration.genericGuest) { - self.clipboard = nil - } else { + if let clipboardPolicy = clipboardPlan.policy { let clipboardControl = DorydKit.AgentControl(configuration: .init( directSocketPath: configuration.agentSocketPath )) let clipboardInput = self.input self.clipboard = DoryDesktopClipboardCoordinator( - policy: DoryDesktopClipboardPolicy(environment: configuration.environment), + policy: clipboardPolicy, execute: { argv, stdin, timeoutMs, outputLimitBytes in try clipboardControl.execWithInput( argv: argv, @@ -798,6 +840,8 @@ enum DesktopMode { }, log: Self.log ) + } else { + self.clipboard = nil } self.window = NSWindow( diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift new file mode 100644 index 00000000..9512f52b --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift @@ -0,0 +1,61 @@ +import DoryHV +import DoryOperations +import Testing +@testable import dory_hv + +@Suite struct DesktopClipboardPlanTests { + @Test func resolvedPolicyOverridesConflictingLegacyEnvironment() throws { + let exact = DoryVMClipboardPolicy( + text: .hostToGuest, + image: .guestToHost, + files: .off + ) + let plan = try DesktopMode.ClipboardPlan( + resolvedDevices: .init(clipboard: true, clipboardPolicy: exact), + environment: [DoryDesktopClipboardPolicy.environmentKey: "bidirectional"], + genericGuest: false + ) + + #expect(plan.policy == exact) + } + + @Test func disabledResolvedClipboardCannotCarryEnabledPolicy() { + #expect(throws: VMError.self) { + _ = try DesktopMode.ClipboardPlan( + resolvedDevices: .init( + clipboard: false, + clipboardPolicy: .legacyDesktop(.hostToGuest) + ), + environment: [:], + genericGuest: false + ) + } + } + + @Test func unsupportedFileTransferFailsClosed() { + #expect(throws: VMError.self) { + _ = try DesktopMode.ClipboardPlan( + resolvedDevices: .init( + clipboard: true, + clipboardPolicy: .init( + text: .bidirectional, + image: .bidirectional, + files: .hostToGuest + ) + ), + environment: [:], + genericGuest: false + ) + } + } + + @Test func historicalLaunchRetainsLegacyPolicy() throws { + let plan = try DesktopMode.ClipboardPlan( + resolvedDevices: nil, + environment: [DoryDesktopClipboardPolicy.environmentKey: "guest-to-host"], + genericGuest: false + ) + + #expect(plan.policy == .legacyDesktop(.guestToHost)) + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift index 698c1235..bae7b4dd 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift @@ -30,4 +30,9 @@ public enum DoryDesktopClipboardPolicy: String, CaseIterable, Sendable { case .bidirectional: "Bidirectional" } } + + public var virtualMachinePolicy: DoryVMClipboardPolicy { + let direction = DoryVMClipboardDirection(rawValue: rawValue) ?? .off + return .legacyDesktop(direction) + } } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index cf38db69..d24eb7a0 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -242,6 +242,9 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa public var pointer: Bool public var directorySharing: Bool public var clipboard: Bool + /// Exact per-content clipboard boundary selected by native planning. `nil` is retained only + /// for historical capability records that predate directional clipboard binding. + public var clipboardPolicy: DoryVMClipboardPolicy? public var clockSynchronization: Bool public var dynamicDisplay: Bool public var gracefulShutdown: Bool @@ -259,6 +262,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa pointer: Bool = false, directorySharing: Bool = false, clipboard: Bool = false, + clipboardPolicy: DoryVMClipboardPolicy? = nil, clockSynchronization: Bool = false, dynamicDisplay: Bool = false, gracefulShutdown: Bool = false, @@ -273,6 +277,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa self.pointer = pointer self.directorySharing = directorySharing self.clipboard = clipboard + self.clipboardPolicy = clipboardPolicy self.clockSynchronization = clockSynchronization self.dynamicDisplay = dynamicDisplay self.gracefulShutdown = gracefulShutdown @@ -289,6 +294,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa case pointer case directorySharing case clipboard + case clipboardPolicy case clockSynchronization case dynamicDisplay case gracefulShutdown @@ -315,6 +321,10 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa pointer = try container.decode(Bool.self, forKey: .pointer) directorySharing = try container.decode(Bool.self, forKey: .directorySharing) clipboard = try container.decode(Bool.self, forKey: .clipboard) + clipboardPolicy = try container.decodeIfPresent( + DoryVMClipboardPolicy.self, + forKey: .clipboardPolicy + ) clockSynchronization = try container.decode(Bool.self, forKey: .clockSynchronization) dynamicDisplay = try container.decode(Bool.self, forKey: .dynamicDisplay) gracefulShutdown = try container.decode(Bool.self, forKey: .gracefulShutdown) @@ -335,6 +345,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa try container.encode(pointer, forKey: .pointer) try container.encode(directorySharing, forKey: .directorySharing) try container.encode(clipboard, forKey: .clipboard) + try container.encodeIfPresent(clipboardPolicy, forKey: .clipboardPolicy) try container.encode(clockSynchronization, forKey: .clockSynchronization) try container.encode(dynamicDisplay, forKey: .dynamicDisplay) try container.encode(gracefulShutdown, forKey: .gracefulShutdown) @@ -443,6 +454,8 @@ public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, H case pointerInputUnsupported = "pointer-input-unsupported" case directorySharingUnsupported = "directory-sharing-unsupported" case clipboardIntegrationUnsupported = "clipboard-integration-unsupported" + case clipboardPolicyInvalid = "clipboard-policy-invalid" + case clipboardFileTransferUnsupported = "clipboard-file-transfer-unsupported" case clockSynchronizationUnsupported = "clock-synchronization-unsupported" case dynamicDisplayUnsupported = "dynamic-display-unsupported" case gracefulShutdownUnsupported = "graceful-shutdown-unsupported" @@ -1655,6 +1668,22 @@ public enum DoryAppleSiliconCapabilityEvaluator { message: "The selected guest/backend contract does not implement clipboard integration." ) } + if let clipboardPolicy = devices.clipboardPolicy { + guard devices.clipboard == clipboardPolicy.isEnabled else { + return unavailable( + tier: tier, + code: .clipboardPolicyInvalid, + message: "The resolved clipboard device and directional policy disagree." + ) + } + guard clipboardPolicy.files == .off else { + return unavailable( + tier: tier, + code: .clipboardFileTransferUnsupported, + message: "The selected guest/backend contract does not implement clipboard file transfer." + ) + } + } if devices.clockSynchronization { guard request.guest.family == .linux, request.backend == .doryHypervisor diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift index c1a3fdd1..a8d12863 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift @@ -1,7 +1,7 @@ import Foundation /// Direction in which one clipboard content class may cross the host/guest boundary. -public enum DoryVMClipboardDirection: String, Codable, Sendable, Equatable, CaseIterable { +public enum DoryVMClipboardDirection: String, Codable, Sendable, Equatable, Hashable, CaseIterable { case off case hostToGuest = "host-to-guest" case guestToHost = "guest-to-host" @@ -18,7 +18,7 @@ public enum DoryVMClipboardDirection: String, Codable, Sendable, Equatable, Case /// Clipboard intent is persisted per content class. Runtime negotiation may select only a /// capability that preserves these directions; it must not silently widen them. -public struct DoryVMClipboardPolicy: Codable, Sendable, Equatable { +public struct DoryVMClipboardPolicy: Codable, Sendable, Equatable, Hashable { public var text: DoryVMClipboardDirection public var image: DoryVMClipboardDirection public var files: DoryVMClipboardDirection diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift index d19662da..bd28208b 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift @@ -29,7 +29,7 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { ) throws -> DoryExecResult public typealias ShortcutSender = @MainActor @Sendable (_ linuxKeyCode: UInt16) -> Void - private let policy: DoryDesktopClipboardPolicy + private let policy: DoryVMClipboardPolicy private let execute: Executor private let sendShortcut: ShortcutSender private let pasteboard: NSPasteboard @@ -46,6 +46,23 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { execute: @escaping Executor, sendShortcut: @escaping ShortcutSender, log: @escaping @Sendable (String) -> Void + ) { + self.init( + policy: policy.virtualMachinePolicy, + execute: execute, + sendShortcut: sendShortcut, + pasteboard: .general, + startupRetryDelay: 1, + startupRetryLimit: 60, + log: log + ) + } + + public convenience init( + policy: DoryVMClipboardPolicy, + execute: @escaping Executor, + sendShortcut: @escaping ShortcutSender, + log: @escaping @Sendable (String) -> Void ) { self.init( policy: policy, @@ -58,7 +75,7 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { ) } - init( + convenience init( policy: DoryDesktopClipboardPolicy, execute: @escaping Executor, sendShortcut: @escaping ShortcutSender, @@ -66,6 +83,26 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { startupRetryDelay: TimeInterval, startupRetryLimit: Int, log: @escaping @Sendable (String) -> Void + ) { + self.init( + policy: policy.virtualMachinePolicy, + execute: execute, + sendShortcut: sendShortcut, + pasteboard: pasteboard, + startupRetryDelay: startupRetryDelay, + startupRetryLimit: startupRetryLimit, + log: log + ) + } + + init( + policy: DoryVMClipboardPolicy, + execute: @escaping Executor, + sendShortcut: @escaping ShortcutSender, + pasteboard: NSPasteboard, + startupRetryDelay: TimeInterval, + startupRetryLimit: Int, + log: @escaping @Sendable (String) -> Void ) { self.policy = policy self.execute = execute @@ -158,8 +195,9 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { scheduleGuestReadIfAllowed() return true case "v": - guard policy.allowsHostToGuest, guestReady, - let payload = Self.readHostClipboard(from: pasteboard) else { + guard guestReady, + let payload = Self.readHostClipboard(from: pasteboard), + allowsHostToGuest(payload) else { sendShortcut(47) return true } @@ -184,7 +222,9 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { @MainActor private func scheduleGuestReadIfAllowed() { - guard guestReady, policy.allowsGuestToHost else { return } + guard guestReady, policy.text.allowsGuestToHost || policy.image.allowsGuestToHost else { + return + } queue.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.readGuestClipboardAndPublishToHost() } @@ -195,10 +235,11 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { force: Bool, startupRetriesRemaining: Int = 0 ) { - guard guestReady, policy.allowsHostToGuest else { return } + guard guestReady else { return } let changeCount = pasteboard.changeCount guard force || changeCount != lastPushedHostChangeCount, - let payload = Self.readHostClipboard(from: pasteboard) else { return } + let payload = Self.readHostClipboard(from: pasteboard), + allowsHostToGuest(payload) else { return } queue.async { [weak self] in guard let self else { return } let didWrite = self.writeGuestClipboard(payload) @@ -228,7 +269,9 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { @MainActor private func pullGuestClipboard() { - guard guestReady, policy.allowsGuestToHost else { return } + guard guestReady, policy.text.allowsGuestToHost || policy.image.allowsGuestToHost else { + return + } queue.async { [weak self] in self?.readGuestClipboardAndPublishToHost() } } @@ -264,6 +307,7 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { private func readGuestClipboard() -> DoryDesktopClipboardPayload? { for mimeType in ["image/png", "text/plain;charset=utf-8", "text/plain"] { + guard direction(for: mimeType).allowsGuestToHost else { continue } do { let result = try execute( ["/usr/lib/dory/clipboard", "get", mimeType], @@ -284,6 +328,14 @@ public final class DoryDesktopClipboardCoordinator: @unchecked Sendable { return nil } + private func allowsHostToGuest(_ payload: DoryDesktopClipboardPayload) -> Bool { + direction(for: payload.mimeType).allowsHostToGuest + } + + private func direction(for mimeType: String) -> DoryVMClipboardDirection { + mimeType == "image/png" ? policy.image : policy.text + } + @MainActor private static func readHostClipboard( from pasteboard: NSPasteboard diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 57476288..6fc67733 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -427,6 +427,18 @@ public enum DoryVZConfigurationBuilder { "resolved directory-sharing contract does not match the launch shares" ) } + if let clipboardPolicy = devices.clipboardPolicy { + guard devices.clipboard == clipboardPolicy.isEnabled else { + throw DoryVZMachineError.validation( + "resolved clipboard device and directional policy disagree" + ) + } + guard clipboardPolicy.files == .off else { + throw DoryVZMachineError.validation( + "resolved clipboard file transfer is not implemented" + ) + } + } if spec.displayMode != .desktop, devices.display != nil || devices.audioInput || devices.audioOutput || devices.keyboard || devices.pointer || devices.clipboard { @@ -527,15 +539,19 @@ public enum DoryVZConfigurationBuilder { configuration.audioDevices = audioDevices if devices?.clipboard != false { + let clipboardPolicy = devices?.clipboardPolicy + ?? DoryDesktopClipboardPolicy( + environment: spec.environment + ).virtualMachinePolicy let console = VZVirtioConsoleDeviceConfiguration() let spiceAgent = VZVirtioConsolePortConfiguration() spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName let spiceAttachment = VZSpiceAgentPortAttachment() // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient // bidirectional default; directional policies use Dory's agent-backed bridge instead. - spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy( - environment: spec.environment - ) == .bidirectional + spiceAttachment.sharesClipboard = clipboardPolicy.text == .bidirectional + && clipboardPolicy.image == .bidirectional + && clipboardPolicy.files == .off spiceAgent.attachment = spiceAttachment console.ports[0] = spiceAgent configuration.consoleDevices = [console] diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index 5afcc118..d82817a0 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -41,14 +41,19 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow machineView.capturesSystemKeys = true self.machineView = machineView - let requestedPolicy = DoryDesktopClipboardPolicy(environment: environment) + let requestedPolicy = resolvedDevices?.clipboardPolicy + ?? DoryDesktopClipboardPolicy(environment: environment).virtualMachinePolicy // Bidirectional sharing is already handled by Apple's efficient SPICE transport. The // shared coordinator still translates Mac shortcuts, while directional modes use its // agent-backed data path to enforce the selected boundary. - let coordinatorPolicy: DoryDesktopClipboardPolicy = requestedPolicy == .bidirectional - ? .off + let usesNativeBidirectionalClipboard = requestedPolicy.text == .bidirectional + && requestedPolicy.image == .bidirectional + && requestedPolicy.files == .off + let coordinatorPolicy: DoryVMClipboardPolicy = usesNativeBidirectionalClipboard + ? .disabled : requestedPolicy - clipboard = resolvedDevices?.clipboard == false ? nil : DoryDesktopClipboardCoordinator( + clipboard = resolvedDevices?.clipboard == false || !requestedPolicy.isEnabled + ? nil : DoryDesktopClipboardCoordinator( policy: coordinatorPolicy, execute: { argv, stdin, timeoutMs, outputLimitBytes in try runtime.executeDesktopIntegration( diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 210a94cb..7fc811ad 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -536,7 +536,8 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda keyboard: definition.input.keyboardEnabled, pointer: definition.input.pointerEnabled, directorySharing: !definition.shares.isEmpty, - clipboard: definition.integrations.contains(.clipboard), + clipboard: definition.clipboardPolicy.isEnabled, + clipboardPolicy: definition.clipboardPolicy, clockSynchronization: definition.integrations.contains(.clockSynchronization), dynamicDisplay: definition.integrations.contains(.dynamicDisplay), gracefulShutdown: definition.integrations.contains(.gracefulShutdown), diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift index 0f937f93..eee814c3 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift @@ -22,5 +22,7 @@ struct DoryDesktopClipboardPolicyTests { #expect(DoryDesktopClipboardPolicy(environment: [ DoryDesktopClipboardPolicy.environmentKey: "invalid" ]) == .off) + #expect(DoryDesktopClipboardPolicy.hostToGuest.virtualMachinePolicy + == .legacyDesktop(.hostToGuest)) } } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 32984ce3..eee96755 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -75,6 +75,7 @@ struct VirtualMachineCapabilitiesTests { from: historical ) #expect(decoded.networkInterface == nil) + #expect(decoded.clipboardPolicy == nil) #expect(!decoded.removableUSBHotplug) object.removeValue(forKey: "keyboard") @@ -988,6 +989,44 @@ struct VirtualMachineCapabilitiesTests { mediaArtifactSHA256: Self.guestArtifactSHA256, devices: DoryVirtualMachineDeviceCapabilityRequest(audioOutput: true) ) + let directionalClipboard = DoryVirtualMachineDeviceCapabilityRequest( + clipboard: true, + clipboardPolicy: .legacyDesktop(.hostToGuest) + ) + let qualifiedDirectionalClipboard = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: directionalClipboard + ) + let mismatchedClipboard = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: DoryVirtualMachineDeviceCapabilityRequest( + clipboard: false, + clipboardPolicy: .legacyDesktop(.hostToGuest) + ) + ) + let clipboardFileTransfer = evaluate( + family: .linux, + media: .virtualDisk, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + devices: DoryVirtualMachineDeviceCapabilityRequest( + clipboard: true, + clipboardPolicy: DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .hostToGuest + ) + ) + ) #expect(resolved.resolvedDevices == .minimumBootable) #expect(disconnected.availability.isUsable) @@ -1003,6 +1042,11 @@ struct VirtualMachineCapabilitiesTests { #expect(qualifiedRawHostOnly.availability.isUsable) #expect(qualifiedRawHostOnly.resolvedDevices == qualifiedRawHostOnlyDevices) #expect(unsupportedAudioShape.availability.reason?.code == .audioOutputUnsupported) + #expect(qualifiedDirectionalClipboard.availability.isUsable) + #expect(qualifiedDirectionalClipboard.resolvedDevices == directionalClipboard) + #expect(mismatchedClipboard.availability.reason?.code == .clipboardPolicyInvalid) + #expect(clipboardFileTransfer.availability.reason?.code + == .clipboardFileTransferUnsupported) } @Test("version-one descriptor JSON remains readable with conservative device defaults") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 22dfe48d..1961213e 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -1150,6 +1150,7 @@ private final class ProductionTrustFixture: @unchecked Sendable { let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable let headlessDevices = DoryVirtualMachineDeviceCapabilityRequest( networkInterface: .init(macAddress: "02:00:00:00:00:01"), + clipboardPolicy: .disabled, clockSynchronization: true, gracefulShutdown: true ) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index bd056ab4..bfaadcfe 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -27,6 +27,8 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(devices.audioOutput == definition.audio.outputEnabled) #expect(devices.keyboard == definition.input.keyboardEnabled) #expect(devices.pointer == definition.input.pointerEnabled) + #expect(devices.clipboard == definition.clipboardPolicy.isEnabled) + #expect(devices.clipboardPolicy == definition.clipboardPolicy) definition.integrations.append(.removableUSBHotplug) #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index bca698d5..ac5050a9 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -103,6 +103,63 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(pasteboard.string(forType: .string), "guest clipboard after copy") } + @MainActor + func testClipboardEnforcesPerContentDirectionsWithoutImageFallback() async throws { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("original host value", forType: .string) + let recorder = ClipboardWriteRecorder() + let coordinator = DoryDesktopClipboardCoordinator( + policy: DoryVMClipboardPolicy( + text: .guestToHost, + image: .off, + files: .off + ), + execute: { argv, _, _, _ in + if argv == ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"] { + recorder.recordCapabilityProbe() + return Self.execResult(exitCode: 0) + } + XCTAssertEqual(argv, [ + "/usr/lib/dory/clipboard", "get", "text/plain;charset=utf-8", + ]) + return DoryExecResult( + exitCode: 0, + stdout: Data("text-only guest clipboard".utf8), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + }, + sendShortcut: { _ in }, + pasteboard: pasteboard, + startupRetryDelay: 0.01, + startupRetryLimit: 1, + log: { _ in } + ) + + coordinator.start() + coordinator.markGuestReady() + defer { coordinator.stop() } + + let readyDeadline = ContinuousClock.now + .seconds(2) + while recorder.capabilityProbeCount == 0, ContinuousClock.now < readyDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + NotificationCenter.default.post( + name: NSApplication.didResignActiveNotification, + object: NSApplication.shared + ) + let pullDeadline = ContinuousClock.now + .seconds(2) + while pasteboard.string(forType: .string) != "text-only guest clipboard", + ContinuousClock.now < pullDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(pasteboard.string(forType: .string), "text-only guest clipboard") + } + private static func execResult(exitCode: Int32) -> DoryExecResult { DoryExecResult( exitCode: exitCode, @@ -829,7 +886,8 @@ final class DoryVMMKitTests: XCTestCase { keyboard: true, pointer: false, directorySharing: false, - clipboard: false, + clipboard: true, + clipboardPolicy: .legacyDesktop(.hostToGuest), clockSynchronization: false, dynamicDisplay: true, gracefulShutdown: false @@ -859,9 +917,33 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(configuration.keyboards.count, 1) XCTAssertTrue(configuration.pointingDevices.isEmpty) XCTAssertEqual(configuration.audioDevices.count, 1) - XCTAssertTrue(configuration.consoleDevices.isEmpty) + let resolvedConsole = try XCTUnwrap( + configuration.consoleDevices.first as? VZVirtioConsoleDeviceConfiguration + ) + let resolvedPort = try XCTUnwrap(resolvedConsole.ports[0]) + let resolvedClipboard = try XCTUnwrap( + resolvedPort.attachment as? VZSpiceAgentPortAttachment + ) + XCTAssertFalse(resolvedClipboard.sharesClipboard) XCTAssertTrue(configuration.directorySharingDevices.isEmpty) + var invalidClipboardDevices = devices + invalidClipboardDevices.clipboard = false + XCTAssertThrowsError(try DoryVZConfigurationBuilder.makeConfiguration( + spec: DoryVZMachineSpec( + machineID: "resolved-desktop", + stateDirectory: base, + kernelPath: kernel, + rootfsPath: rootfs, + memoryMB: 4096, + cpuCount: 4, + displayMode: .desktop, + resolvedGraphics: .hostAcceleratedDisplay, + resolvedDevices: invalidClipboardDevices + ), + serialOutput: nil + )) + XCTAssertThrowsError(try DoryVZConfigurationBuilder.makeConfiguration( spec: DoryVZMachineSpec( machineID: "resolved-desktop", From a05822edff89df61308f7b649e6576762941bfea Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 04:19:38 +0000 Subject: [PATCH 247/338] feat(vm): make snapshot clones copy-on-write --- Dory/Models/AppStore.swift | 1 + Dory/Models/Models.swift | 10 ++ Dory/Runtime/Doryd/DorydClient.swift | 54 ++++++ DoryTests/DorydClientTests.swift | 52 ++++++ .../DorydKit/DoryMachineCloneReceipt.swift | 59 +++++++ .../DoryMachineConfigurationMigration.swift | 3 +- .../Sources/DorydKit/DorydService.swift | 11 ++ .../Sources/DorydKit/MachineManager.swift | 164 ++++++++++++++++-- .../DoryMachineCloneReceiptTests.swift | 46 +++++ .../DorydKitTests/DorydServiceTests.swift | 54 ++++++ .../DorydKitTests/MachineManagerTests.swift | 61 +++++++ 11 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineCloneReceipt.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineCloneReceiptTests.swift diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 387a5e30..32fb6bd4 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5264,6 +5264,7 @@ final class AppStore { bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, runtimeIdentity: status.runtimeIdentity, + cloneReceipt: status.cloneReceipt, agentBuild: status.agentBuild, agentProtocolVersion: status.agentProtocolVersion, agentCapabilities: status.agentCapabilities, diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 546c2689..99dd776d 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -310,6 +310,7 @@ struct Machine: Identifiable, Hashable, Sendable { var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var cloneReceipt: DorydMachineCloneReceipt? = nil var agentBuild: String? = nil var agentProtocolVersion: UInt32? = nil var agentCapabilities: [DorydAgentCapability] = [] @@ -359,6 +360,15 @@ struct Machine: Identifiable, Hashable, Sendable { )) } } + if let cloneReceipt { + evidence.append(MachineRuntimeEvidence( + id: "clone-storage", + label: "Copy-on-write clone", + systemImage: "square.on.square", + tone: .standard, + detail: "APFS-managed from \(cloneReceipt.sourceMachineID)/\(cloneReceipt.sourceSnapshotID)" + )) + } switch runtimeIdentity.mode { case "resolved-plan": evidence.append(MachineRuntimeEvidence( diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index aaa463ae..ae05bbd9 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -1037,9 +1037,19 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var typedSettings: DorydMachineTypedSettings? = nil var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil + var cloneReceipt: DorydMachineCloneReceipt? = nil var savedState: DorydMachineSavedStateSummary? = nil } +nonisolated struct DorydMachineCloneReceipt: Sendable, Equatable, Hashable { + var sourceMachineID: String + var sourceSnapshotID: String + var sourceRootfsSHA256: String + var sourceRootfsByteCount: UInt64 + var storageMode: String + var createdAtUnixMilliseconds: Int64 +} + nonisolated struct DorydMachineSavedStateSummary: Sendable, Equatable { var stateFileSHA256: String var stateFileByteCount: UInt64 @@ -2797,6 +2807,9 @@ nonisolated final class DorydClient: @unchecked Sendable { ) else { return nil } + guard let cloneReceipt = machineCloneReceipt(from: dictionary["cloneReceipt"]) else { + return nil + } guard let agentHandshake = machineAgentHandshake(from: dictionary) else { return nil } @@ -2873,10 +2886,51 @@ nonisolated final class DorydClient: @unchecked Sendable { typedSettings: typedSettings.value, runtimeIdentity: runtimeIdentity, installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, + cloneReceipt: cloneReceipt.value, savedState: savedState.value ) } + private struct ParsedMachineCloneReceipt { + var value: DorydMachineCloneReceipt? + } + + nonisolated private static func machineCloneReceipt( + from encoded: Any? + ) -> ParsedMachineCloneReceipt? { + guard let encoded else { return ParsedMachineCloneReceipt(value: nil) } + guard let value = encoded as? NSDictionary, + let keys = value.allKeys as? [String], + keys.count == 7, + Set(keys) == [ + "schemaVersion", "sourceMachineID", "sourceSnapshotID", + "sourceRootfsSHA256", "sourceRootfsByteCount", "storageMode", + "createdAtUnixMilliseconds", + ], + strictUInt64(value["schemaVersion"]) == 1, + let sourceMachineID = value["sourceMachineID"] as? String, + sourceMachineID.isSafeMachineIdentifier, + let sourceSnapshotID = value["sourceSnapshotID"] as? String, + sourceSnapshotID.isSafeMachineIdentifier, + let sourceRootfsSHA256 = value["sourceRootfsSHA256"] as? String, + sourceRootfsSHA256.isLowercaseSHA256, + let sourceRootfsByteCount = strictUInt64(value["sourceRootfsByteCount"]), + sourceRootfsByteCount > 0, + value["storageMode"] as? String == "apfs-copy-on-write", + let createdAtUnixMilliseconds = int64(value["createdAtUnixMilliseconds"]), + createdAtUnixMilliseconds > 0 else { + return nil + } + return ParsedMachineCloneReceipt(value: DorydMachineCloneReceipt( + sourceMachineID: sourceMachineID, + sourceSnapshotID: sourceSnapshotID, + sourceRootfsSHA256: sourceRootfsSHA256, + sourceRootfsByteCount: sourceRootfsByteCount, + storageMode: "apfs-copy-on-write", + createdAtUnixMilliseconds: createdAtUnixMilliseconds + )) + } + nonisolated private static func machineUSBAttachment( from dictionary: NSDictionary, expectedMachineID: String, diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 6db18b9b..1226dd18 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -2169,6 +2169,44 @@ struct DorydClientTests { } } + @Test func machineCloneReceiptRequiresExactShape() async throws { + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "sourceMachineID": "source", + "sourceSnapshotID": "base", + "sourceRootfsSHA256": String(repeating: "a", count: 64), + "sourceRootfsByteCount": UInt64(4_096), + "storageMode": "apfs-copy-on-write", + "createdAtUnixMilliseconds": Int64(1_787_300_000_000), + ] + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineCloneReceipt("dev", valid) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let status = try #require(try await client.machineList().first) + #expect(status.cloneReceipt?.sourceMachineID == "source") + #expect(status.cloneReceipt?.sourceSnapshotID == "base") + #expect(status.cloneReceipt?.storageMode == "apfs-copy-on-write") + + let malformed = NSMutableDictionary(dictionary: valid) + malformed["unknown"] = true + service.setMachineCloneReceipt("dev", malformed) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + + let wrongType = NSMutableDictionary(dictionary: valid) + wrongType["sourceRootfsByteCount"] = true + service.setMachineCloneReceipt("dev", wrongType) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + } + @Test func desktopUpdateSkipRequiresExactVerifiedActiveComponentProvenance() { let receipt = DorydInstalledDesktopPayloadReceipt( schemaVersion: 1, @@ -4494,6 +4532,20 @@ private final class FakeDorydService: NSObject, DorydControlXPC { machines[machineID] = current.copy() as? NSDictionary } + func setMachineCloneReceipt(_ machineID: String, _ receipt: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let receipt { + current["cloneReceipt"] = receipt + } else { + current.removeObject(forKey: "cloneReceipt") + } + machines[machineID] = current.copy() as? NSDictionary + } + func setMachineFailure( _ machineID: String, _ failure: Any?, diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineCloneReceipt.swift b/dory-core-swift/Sources/DorydKit/DoryMachineCloneReceipt.swift new file mode 100644 index 00000000..53bf8365 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineCloneReceipt.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum DoryMachineCloneStorageMode: String, Codable, Sendable, Equatable, Hashable { + /// APFS owns shared-extent reference accounting. The clone has no path or lifetime dependency + /// on its source snapshot: deleting either file releases only that file's extent references. + case apfsCopyOnWrite = "apfs-copy-on-write" +} + +/// Durable, path-free evidence for a machine created from an immutable snapshot. +/// +/// The receipt is minted only after `fclonefileat` succeeds and the cloned root disk hashes to the +/// snapshot's immutable artifact evidence. It is not an authorization to read the source and does +/// not keep the source snapshot alive; APFS provides the reference accounting and deletion safety. +public struct DoryMachineCloneReceipt: Codable, Sendable, Equatable, Hashable { + public static let currentSchemaVersion: UInt16 = 1 + + public var schemaVersion: UInt16 + public var sourceMachineID: String + public var sourceSnapshotID: String + public var sourceRootfsSHA256: String + public var sourceRootfsByteCount: UInt64 + public var storageMode: DoryMachineCloneStorageMode + public var createdAtUnixMilliseconds: Int64 + + public init( + schemaVersion: UInt16 = Self.currentSchemaVersion, + sourceMachineID: String, + sourceSnapshotID: String, + sourceRootfsSHA256: String, + sourceRootfsByteCount: UInt64, + storageMode: DoryMachineCloneStorageMode = .apfsCopyOnWrite, + createdAtUnixMilliseconds: Int64 + ) { + self.schemaVersion = schemaVersion + self.sourceMachineID = sourceMachineID + self.sourceSnapshotID = sourceSnapshotID + self.sourceRootfsSHA256 = sourceRootfsSHA256 + self.sourceRootfsByteCount = sourceRootfsByteCount + self.storageMode = storageMode + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + } + + public var isValid: Bool { + schemaVersion == Self.currentSchemaVersion + && Self.isValidIdentifier(sourceMachineID) + && Self.isValidIdentifier(sourceSnapshotID) + && sourceRootfsSHA256.count == 64 + && sourceRootfsSHA256.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66) + } + && sourceRootfsByteCount > 0 + && createdAtUnixMilliseconds > 0 + } + + private static func isValidIdentifier(_ value: String) -> Bool { + value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil + && !value.hasPrefix(".") + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index a7ff09b1..922da86f 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -292,7 +292,8 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { shares: shares, environment: environment, installedDesktopPayloadReceipt: - authoritativeLegacyConfiguration.installedDesktopPayloadReceipt + authoritativeLegacyConfiguration.installedDesktopPayloadReceipt, + cloneReceipt: authoritativeLegacyConfiguration.cloneReceipt ) } diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 4bb2a7d5..57136b69 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -2522,6 +2522,17 @@ private extension DoryMachineStatus { dictionary["installedDesktopPayloadReceipt"] = installedDesktopPayloadReceipt.xpcDictionary } + if let cloneReceipt, cloneReceipt.isValid { + dictionary["cloneReceipt"] = [ + "schemaVersion": cloneReceipt.schemaVersion, + "sourceMachineID": cloneReceipt.sourceMachineID, + "sourceSnapshotID": cloneReceipt.sourceSnapshotID, + "sourceRootfsSHA256": cloneReceipt.sourceRootfsSHA256, + "sourceRootfsByteCount": cloneReceipt.sourceRootfsByteCount, + "storageMode": cloneReceipt.storageMode.rawValue, + "createdAtUnixMilliseconds": cloneReceipt.createdAtUnixMilliseconds, + ] as NSDictionary + } if let savedState { dictionary["savedState"] = [ "schemaVersion": savedState.schemaVersion, diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 4c8437e7..aa8c9c83 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -242,6 +242,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { public var shares: [DoryMachineShareConfiguration] public var environment: [String: String] public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? + public var cloneReceipt: DoryMachineCloneReceipt? public init( id: String, @@ -256,7 +257,8 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { displayMode: DoryMachineDisplayMode = .headless, shares: [DoryMachineShareConfiguration] = [], environment: [String: String] = [:], - installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil + installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil, + cloneReceipt: DoryMachineCloneReceipt? = nil ) { self.id = id self.kernelPath = kernelPath @@ -271,6 +273,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { self.shares = shares self.environment = environment self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt + self.cloneReceipt = cloneReceipt } private enum CodingKeys: String, CodingKey { @@ -287,6 +290,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { case shares case environment case installedDesktopPayloadReceipt + case cloneReceipt } public init(from decoder: Decoder) throws { @@ -307,6 +311,10 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable { installedDesktopPayloadReceipt: try container.decodeIfPresent( DoryInstalledDesktopPayloadReceipt.self, forKey: .installedDesktopPayloadReceipt + ), + cloneReceipt: try container.decodeIfPresent( + DoryMachineCloneReceipt.self, + forKey: .cloneReceipt ) ) } @@ -364,6 +372,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var diagnosticOverrides: [DoryMachineDiagnosticOverride] public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? + public var cloneReceipt: DoryMachineCloneReceipt? public var savedState: DoryMachineSavedStateStatus? public init( @@ -404,6 +413,7 @@ public struct DoryMachineStatus: Sendable, Equatable { DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion ), installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil, + cloneReceipt: DoryMachineCloneReceipt? = nil, savedState: DoryMachineSavedStateStatus? = nil ) { self.id = id @@ -440,6 +450,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.diagnosticOverrides = diagnosticOverrides self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt + self.cloneReceipt = cloneReceipt self.savedState = savedState } @@ -936,6 +947,13 @@ public enum DoryMachineLaunchPolicy: String, Sendable, Equatable { case perWorkspaceAuthority } +private struct DoryMachineCloneCreationAuthority { + var sourceMachineID: String + var sourceSnapshotID: String + var rootfsSHA256: String + var rootfsByteCount: UInt64 +} + public final class MachineManager: @unchecked Sendable { public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void @@ -1288,13 +1306,30 @@ public final class MachineManager: @unchecked Sendable { _ machine: DoryMachineConfiguration, typedSettings: DoryMachineTypedSettingsPatch? = nil, sandboxPolicy: DoryVMSandboxPolicy? = nil + ) throws -> DoryMachineStatus { + try createMachine( + machine, + typedSettings: typedSettings, + sandboxPolicy: sandboxPolicy, + cloneAuthority: nil + ) + } + + private func createMachine( + _ requestedMachine: DoryMachineConfiguration, + typedSettings: DoryMachineTypedSettingsPatch?, + sandboxPolicy: DoryVMSandboxPolicy?, + cloneAuthority: DoryMachineCloneCreationAuthority? ) throws -> DoryMachineStatus { operationLock.lock() defer { operationLock.unlock() } + var machine = requestedMachine + // Public create callers cannot manufacture clone evidence. It is minted only by the + // snapshot-clone path after an exact APFS clone and destination digest verification. + machine.cloneReceipt = nil guard Self.isValidID(machine.id) else { throw MachineManagerError.invalidID(machine.id) } - var machine = machine machine.address = try Self.normalizedAddress(machine.address) if launchPolicy == .perWorkspaceAuthority { guard machine.environment.isEmpty else { @@ -1419,7 +1454,23 @@ public final class MachineManager: @unchecked Sendable { ) try Self.syncDirectory(path: statePath) - let preparedMachine = try prepareMachineArtifacts(machine) + var preparedMachine = try prepareMachineArtifacts( + machine, + cloneAuthority: cloneAuthority + ) + if let cloneAuthority { + let receipt = DoryMachineCloneReceipt( + sourceMachineID: cloneAuthority.sourceMachineID, + sourceSnapshotID: cloneAuthority.sourceSnapshotID, + sourceRootfsSHA256: cloneAuthority.rootfsSHA256, + sourceRootfsByteCount: cloneAuthority.rootfsByteCount, + createdAtUnixMilliseconds: Int64(Date().timeIntervalSince1970 * 1_000) + ) + guard receipt.isValid else { + throw MachineManagerError.persistence("could not mint clone receipt") + } + preparedMachine.cloneReceipt = receipt + } try validateManagedMachineArtifacts(preparedMachine) let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine() let authoritativeLegacyData = try DoryMachineConfigurationMigrationBridge @@ -1504,7 +1555,10 @@ public final class MachineManager: @unchecked Sendable { diagnosticOverrides: DoryMachineDiagnosticOverride.configured( in: preparedMachine.environment ), - runtimeIdentity: initialRuntimeIdentity + runtimeIdentity: initialRuntimeIdentity, + installedDesktopPayloadReceipt: + preparedMachine.effectiveInstalledDesktopPayloadReceipt, + cloneReceipt: preparedMachine.cloneReceipt ) } @@ -4925,6 +4979,19 @@ public final class MachineManager: @unchecked Sendable { defer { operationLock.unlock() } let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID) try validateSnapshotRuntimeCompatibility(snapshot, machine: nil, cloning: true) + let rootfsEvidence = try snapshot.artifactEvidence?.rootfs + ?? Self.snapshotArtifact(path: snapshot.rootfsPath) + guard rootfsEvidence.byteCount > 0 else { + throw MachineManagerError.persistence( + "snapshot root disk has no immutable clone authority" + ) + } + let cloneAuthority = DoryMachineCloneCreationAuthority( + sourceMachineID: machineID, + sourceSnapshotID: snapshotID, + rootfsSHA256: rootfsEvidence.sha256, + rootfsByteCount: rootfsEvidence.byteCount + ) let machine = DoryMachineConfiguration( id: newID, kernelPath: snapshot.kernelPath, @@ -4939,10 +5006,11 @@ public final class MachineManager: @unchecked Sendable { installedDesktopPayloadReceipt: Self.configurationReceipt(restoring: snapshot) ) - let created = try create( + let created = try createMachine( machine, typedSettings: snapshot.typedSettings?.replacementPatch, - sandboxPolicy: snapshot.sandboxPolicy + sandboxPolicy: snapshot.sandboxPolicy, + cloneAuthority: cloneAuthority ) do { if snapshot.bootMode == .efi { @@ -5642,6 +5710,7 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt, + cloneReceipt: entry.configuration.cloneReceipt, savedState: entry.savedStateStatus ) } @@ -5686,6 +5755,7 @@ public final class MachineManager: @unchecked Sendable { runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt, + cloneReceipt: entry.configuration.cloneReceipt, savedState: entry.savedStateStatus ) } @@ -6523,7 +6593,8 @@ public final class MachineManager: @unchecked Sendable { shares: snapshot.shares, environment: snapshot.environment, installedDesktopPayloadReceipt: - Self.configurationReceipt(restoring: snapshot) + Self.configurationReceipt(restoring: snapshot), + cloneReceipt: current.cloneReceipt ) let actualSourceSHA256 = Self.sha256( data: try DoryMachineConfigurationMigrationBridge.encodeLegacy(source) @@ -8224,7 +8295,10 @@ public final class MachineManager: @unchecked Sendable { lock.unlock() } - private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration { + private func prepareMachineArtifacts( + _ machine: DoryMachineConfiguration, + cloneAuthority: DoryMachineCloneCreationAuthority? = nil + ) throws -> DoryMachineConfiguration { let rootfsDestination = machineRootfsPath(id: machine.id) let kernelDestination = machineKernelPath(id: machine.id) let installerDestination = machineInstallerISOPath(id: machine.id) @@ -8236,12 +8310,29 @@ public final class MachineManager: @unchecked Sendable { var copy = machine switch machine.bootMode { case .linuxKernel: - try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination) + try Self.cloneOrCopyFile( + source: machine.rootfsPath, + destination: rootfsDestination, + requiresCopyOnWrite: cloneAuthority != nil, + expectedSHA256: cloneAuthority?.rootfsSHA256, + expectedByteCount: cloneAuthority?.rootfsByteCount + ) try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination) case .efi: if Self.isRegularNonemptyFile(path: machine.rootfsPath) { - try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination) + try Self.cloneOrCopyFile( + source: machine.rootfsPath, + destination: rootfsDestination, + requiresCopyOnWrite: cloneAuthority != nil, + expectedSHA256: cloneAuthority?.rootfsSHA256, + expectedByteCount: cloneAuthority?.rootfsByteCount + ) } else if let diskSizeBytes = machine.diskSizeBytes { + guard cloneAuthority == nil else { + throw MachineManagerError.persistence( + "a linked clone requires an existing snapshot root disk" + ) + } try Self.createPrivateSparseFile(path: rootfsDestination, sizeBytes: diskSizeBytes) } else { throw MachineManagerError.persistence("EFI machine is missing its disk source or size") @@ -9686,6 +9777,9 @@ public final class MachineManager: @unchecked Sendable { "installed desktop payload receipt is invalid" ) } + guard machine.cloneReceipt?.isValid ?? true else { + throw MachineManagerError.persistence("machine clone receipt is invalid") + } } /// A local legacy snapshot retains the original raw receipt fields byte-for-byte. Imported @@ -9727,8 +9821,15 @@ public final class MachineManager: @unchecked Sendable { private static func cloneOrCopyFile( source: String, destination: String, - replaceExisting: Bool = false + replaceExisting: Bool = false, + requiresCopyOnWrite: Bool = false, + expectedSHA256: String? = nil, + expectedByteCount: UInt64? = nil ) throws { + guard (expectedSHA256 == nil) == (expectedByteCount == nil), + !requiresCopyOnWrite || expectedSHA256 != nil else { + throw MachineManagerError.persistence("invalid clone artifact authority") + } let destinationURL = URL(fileURLWithPath: destination) let parent = destinationURL.deletingLastPathComponent() let parentDescriptor = open(parent.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) @@ -9761,7 +9862,14 @@ public final class MachineManager: @unchecked Sendable { } do { if fclonefileat(sourceDescriptor, parentDescriptor, temporaryName, 0) != 0 { + let cloneError = errno _ = unlinkat(parentDescriptor, temporaryName, 0) + guard !requiresCopyOnWrite else { + throw MachineManagerError.persistence( + "APFS copy-on-write cloning is unavailable: " + + String(cString: strerror(cloneError)) + ) + } let destinationDescriptor = openat( parentDescriptor, temporaryName, @@ -9789,13 +9897,34 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("could not reopen cloned artifact") } defer { close(clonedDescriptor) } + var clonedInfo = stat() guard fchmod(clonedDescriptor, mode_t(0o600)) == 0, isPrivateRegularFile(descriptor: clonedDescriptor), + fstat(clonedDescriptor, &clonedInfo) == 0, fsync(clonedDescriptor) == 0 else { throw MachineManagerError.persistence( "could not synchronize cloned artifact: \(String(cString: strerror(errno)))" ) } + if let expectedSHA256, let expectedByteCount { + let actualSHA256 = try sha256(descriptor: clonedDescriptor) + var verifiedInfo = stat() + guard clonedInfo.st_size >= 0, + UInt64(clonedInfo.st_size) == expectedByteCount, + fstat(clonedDescriptor, &verifiedInfo) == 0, + clonedInfo.st_dev == verifiedInfo.st_dev, + clonedInfo.st_ino == verifiedInfo.st_ino, + clonedInfo.st_size == verifiedInfo.st_size, + clonedInfo.st_mtimespec.tv_sec == verifiedInfo.st_mtimespec.tv_sec, + clonedInfo.st_mtimespec.tv_nsec == verifiedInfo.st_mtimespec.tv_nsec, + clonedInfo.st_ctimespec.tv_sec == verifiedInfo.st_ctimespec.tv_sec, + clonedInfo.st_ctimespec.tv_nsec == verifiedInfo.st_ctimespec.tv_nsec, + actualSHA256 == expectedSHA256 else { + throw MachineManagerError.persistence( + "copy-on-write clone does not match immutable snapshot evidence" + ) + } + } } if replaceExisting { guard renameat(parentDescriptor, temporaryName, parentDescriptor, destinationName) == 0 else { @@ -9822,6 +9951,19 @@ public final class MachineManager: @unchecked Sendable { } } + private static func sha256(descriptor: Int32) throws -> String { + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + try handle.seek(toOffset: 0) + defer { try? handle.seek(toOffset: 0) } + var hasher = SHA256() + while true { + let chunk = try handle.read(upToCount: 4 * 1_024 * 1_024) ?? Data() + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + private static func createPrivateSparseFile(path: String, sizeBytes: UInt64) throws { guard sizeBytes <= UInt64(Int64.max) else { throw MachineManagerError.persistence("managed disk size is too large") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineCloneReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineCloneReceiptTests.swift new file mode 100644 index 00000000..acc2b081 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineCloneReceiptTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import DorydKit + +@Suite("Machine clone receipt") +struct DoryMachineCloneReceiptTests { + @Test("receipt round trips exact APFS clone evidence") + func roundTrip() throws { + let receipt = DoryMachineCloneReceipt( + sourceMachineID: "desktop", + sourceSnapshotID: "before-upgrade", + sourceRootfsSHA256: String(repeating: "a", count: 64), + sourceRootfsByteCount: 8 * 1_024 * 1_024 * 1_024, + createdAtUnixMilliseconds: 1_787_300_000_000 + ) + #expect(receipt.isValid) + let data = try JSONEncoder().encode(receipt) + #expect(try JSONDecoder().decode(DoryMachineCloneReceipt.self, from: data) == receipt) + } + + @Test("receipt rejects unbounded or fabricated authority") + func invalidShapes() { + let valid = DoryMachineCloneReceipt( + sourceMachineID: "desktop", + sourceSnapshotID: "base", + sourceRootfsSHA256: String(repeating: "b", count: 64), + sourceRootfsByteCount: 4_096, + createdAtUnixMilliseconds: 1 + ) + var receipt = valid + receipt.schemaVersion = 2 + #expect(!receipt.isValid) + receipt = valid + receipt.sourceMachineID = "../desktop" + #expect(!receipt.isValid) + receipt = valid + receipt.sourceRootfsSHA256 = String(repeating: "A", count: 64) + #expect(!receipt.isValid) + receipt = valid + receipt.sourceRootfsByteCount = 0 + #expect(!receipt.isValid) + receipt = valid + receipt.createdAtUnixMilliseconds = 0 + #expect(!receipt.isValid) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift index 7e84f1af..3ae06427 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift @@ -1992,6 +1992,60 @@ final class DorydServiceTests: XCTestCase { wait(for: [snapshot], timeout: 5) } + func testMachineStatusExposesPathFreeCopyOnWriteCloneReceipt() throws { + let base = "/tmp/doryd-service-clone-receipt-\(getpid())-\(UInt32.random(in: 0.. Date: Sat, 22 Aug 2026 04:40:50 +0000 Subject: [PATCH 248/338] fix(vm): bound capability comparison stack usage --- .../DoryVirtualMachineCapabilities.swift | 124 ++++++++++++++++++ .../DoryVirtualMachineCapabilitiesTests.swift | 2 + 2 files changed, 126 insertions(+) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index d24eb7a0..1ee1b905 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -803,6 +803,54 @@ public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, self.virtualHardwareABIVersion = virtualHardwareABIVersion } + public static func == ( + lhs: DoryVirtualMachineCapabilityRequest, + rhs: DoryVirtualMachineCapabilityRequest + ) -> Bool { + guard equalGuest(lhs, rhs) else { return false } + guard equalBootMedia(lhs, rhs) else { return false } + guard equalBackend(lhs, rhs) else { return false } + guard equalGraphics(lhs, rhs) else { return false } + guard equalDevices(lhs, rhs) else { return false } + return equalVirtualHardwareABI(lhs, rhs) + } + + // Keep each comparison in a separate frame. In unoptimized builds Swift otherwise retains + // temporaries for every nested enum/collection in this value at once; the resulting frame can + // exhaust the bounded stack used by Swift Testing's cooperative tasks. + @inline(never) + private static func equalGuest(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.guest == rhs.guest + } + + @inline(never) + private static func equalBootMedia(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.bootMedia == rhs.bootMedia + } + + @inline(never) + private static func equalBackend(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.backend == rhs.backend + } + + @inline(never) + private static func equalGraphics(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.graphics == rhs.graphics + } + + @inline(never) + private static func equalDevices(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.devices == rhs.devices + } + + @inline(never) + private static func equalVirtualHardwareABI( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.virtualHardwareABIVersion == rhs.virtualHardwareABIVersion + } + private enum CodingKeys: String, CodingKey { case guest case bootMedia @@ -877,6 +925,82 @@ public struct DoryVirtualMachineCapabilityDescriptor: Codable, Sendable, Equatab self.mutableBootMediaProvenanceEvidence = mutableBootMediaProvenanceEvidence self.runtimeQualificationEvidence = runtimeQualificationEvidence } + + public static func == ( + lhs: DoryVirtualMachineCapabilityDescriptor, + rhs: DoryVirtualMachineCapabilityDescriptor + ) -> Bool { + guard equalSchemaVersion(lhs, rhs) else { return false } + guard equalEvaluatorVersion(lhs, rhs) else { return false } + guard equalRequest(lhs, rhs) else { return false } + guard equalAvailability(lhs, rhs) else { return false } + guard equalResolvedDevices(lhs, rhs) else { return false } + guard equalGraphicsQualificationEvidence(lhs, rhs) else { return false } + guard equalBootMediaInspectionEvidence(lhs, rhs) else { return false } + guard equalMutableBootMediaProvenanceEvidence(lhs, rhs) else { return false } + return equalRuntimeQualificationEvidence(lhs, rhs) + } + + @inline(never) + private static func equalSchemaVersion(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.schemaVersion == rhs.schemaVersion + } + + @inline(never) + private static func equalEvaluatorVersion( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.evaluatorVersion == rhs.evaluatorVersion + } + + @inline(never) + private static func equalRequest(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.request == rhs.request + } + + @inline(never) + private static func equalAvailability(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.availability == rhs.availability + } + + @inline(never) + private static func equalResolvedDevices(_ lhs: borrowing Self, _ rhs: borrowing Self) -> Bool { + lhs.resolvedDevices == rhs.resolvedDevices + } + + @inline(never) + private static func equalGraphicsQualificationEvidence( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.graphicsQualificationEvidence == rhs.graphicsQualificationEvidence + } + + @inline(never) + private static func equalBootMediaInspectionEvidence( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.bootMediaInspectionEvidence == rhs.bootMediaInspectionEvidence + } + + @inline(never) + private static func equalMutableBootMediaProvenanceEvidence( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.mutableBootMediaProvenanceEvidence == rhs.mutableBootMediaProvenanceEvidence + } + + @inline(never) + private static func equalRuntimeQualificationEvidence( + _ lhs: borrowing Self, + _ rhs: borrowing Self + ) -> Bool { + lhs.runtimeQualificationEvidence == rhs.runtimeQualificationEvidence + } + } /// Host observations used by the Apple Silicon policy. No global process or framework probing is diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index eee96755..7c94f131 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -620,6 +620,8 @@ struct VirtualMachineCapabilitiesTests { from: encoded ) + #expect(decoded == descriptor) + #expect(Set([decoded]).contains(descriptor)) #expect(decoded.graphicsQualificationEvidence == Self.qualifiedLinuxGraphics.auditEvidence) #expect(decoded.graphicsQualificationEvidence?.artifactSHA256 From 66d304a813970a2176dd4905db45ac280b6e6039 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 04:50:00 +0000 Subject: [PATCH 249/338] feat(vm): add explicit port-forward intent --- .../DoryVirtualMachineDefinition.swift | 97 ++++++++++++++++++- .../DoryMachineConfigurationMigration.swift | 3 + .../DoryVirtualMachineDefinitionTests.swift | 81 +++++++++++++++- ...ryMachineConfigurationMigrationTests.swift | 13 +++ 4 files changed, 187 insertions(+), 7 deletions(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index ec53dca3..c16038d5 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -182,6 +182,45 @@ public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable { case isolated } +public enum DoryVMPortForwardTransport: String, Codable, Sendable, CaseIterable, Hashable { + case tcp + case udp +} + +/// Host visibility is closed intent rather than a caller-supplied bind address. Loopback is the +/// safe default; LAN exposure remains an explicit choice that policy and runtime authorization can +/// reject without rewriting it to a broader address. +public enum DoryVMPortForwardExposure: String, Codable, Sendable, CaseIterable, Hashable { + case loopback + case lan +} + +/// One stable inbound mapping for a userspace-networked guest. Runtime endpoints and gvproxy +/// process details are deliberately absent from desired state. +public struct DoryVMPortForward: Codable, Sendable, Equatable, Hashable { + public static let maximumCount = 64 + + public var id: String + public var transport: DoryVMPortForwardTransport + public var hostPort: UInt16 + public var guestPort: UInt16 + public var exposure: DoryVMPortForwardExposure + + public init( + id: String, + transport: DoryVMPortForwardTransport = .tcp, + hostPort: UInt16, + guestPort: UInt16, + exposure: DoryVMPortForwardExposure = .loopback + ) { + self.id = id + self.transport = transport + self.hostPort = hostPort + self.guestPort = guestPort + self.exposure = exposure + } +} + public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { public var enabled: Bool public var widthPixels: UInt32 @@ -367,6 +406,13 @@ public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseItera case systemDiskCapacityMismatch = "system-disk-capacity-mismatch" case bootDiskArtifactMismatch = "boot-disk-artifact-mismatch" case invalidDisplayConfiguration = "invalid-display-configuration" + case tooManyPortForwards = "too-many-port-forwards" + case invalidPortForwardIdentifier = "invalid-port-forward-identifier" + case duplicatePortForwardIdentifier = "duplicate-port-forward-identifier" + case invalidPortForwardPort = "invalid-port-forward-port" + case duplicatePortForwardBinding = "duplicate-port-forward-binding" + case portForwardNetworkModeUnsupported = "port-forward-network-mode-unsupported" + case lanPortForwardRequiresSharedNAT = "lan-port-forward-requires-shared-nat" case emptyShareIdentifier = "empty-share-identifier" case duplicateShareIdentifier = "duplicate-share-identifier" case duplicateGuestMountPath = "duplicate-guest-mount-path" @@ -400,7 +446,7 @@ public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable { /// passwords, tokens, host filesystem paths, or volatile process state. public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public static let oldestSupportedSchemaVersion: UInt16 = 1 - public static let currentSchemaVersion: UInt16 = 3 + public static let currentSchemaVersion: UInt16 = 4 public static let currentVirtualHardwareABIVersion: UInt16 = 1 public var schemaVersion: UInt16 @@ -414,6 +460,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public var resources: DoryVMResourceRequest public var storage: [DoryVMStorageAttachment] public var networkMode: DoryVMNetworkMode + public var portForwards: [DoryVMPortForward] public var display: DoryVMDisplayConfiguration public var audio: DoryVMAudioConfiguration public var input: DoryVMInputConfiguration @@ -436,6 +483,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { resources: DoryVMResourceRequest, storage: [DoryVMStorageAttachment], networkMode: DoryVMNetworkMode = .sharedNAT, + portForwards: [DoryVMPortForward] = [], display: DoryVMDisplayConfiguration = DoryVMDisplayConfiguration(), audio: DoryVMAudioConfiguration = DoryVMAudioConfiguration(), input: DoryVMInputConfiguration = DoryVMInputConfiguration(), @@ -457,6 +505,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { self.resources = resources self.storage = storage self.networkMode = networkMode + self.portForwards = portForwards self.display = display self.audio = audio self.input = input @@ -483,6 +532,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { case resources case storage case networkMode + case portForwards case display case audio case input @@ -587,6 +637,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { ) } networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode) + portForwards = [] display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) @@ -625,7 +676,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { ) } // Schema 2 is structurally migrated by the attachment decoder above. Its missing storage - // source becomes `.userProvided`. Sandbox policy is an optional schema-3 extension. + // source becomes `.userProvided`. Sandbox policy is an optional schema-3 extension and + // explicit port forwards are an additive schema-4 extension. schemaVersion = Self.currentSchemaVersion virtualHardwareABIVersion = try container.decode( UInt16.self, @@ -640,6 +692,10 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources) storage = try container.decode([DoryVMStorageAttachment].self, forKey: .storage) networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode) + portForwards = try container.decodeIfPresent( + [DoryVMPortForward].self, + forKey: .portForwards + ) ?? [] display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) @@ -674,6 +730,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { try container.encode(resources, forKey: .resources) try container.encode(storage, forKey: .storage) try container.encode(networkMode, forKey: .networkMode) + try container.encode(portForwards, forKey: .portForwards) try container.encode(display, forKey: .display) try container.encode(audio, forKey: .audio) try container.encode(input, forKey: .input) @@ -730,6 +787,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { validateGraphics(into: &issues) validateResources(into: &issues) validateStorage(into: &issues) + validatePortForwards(into: &issues) validateDisplay(into: &issues) validateShares(into: &issues) validateIntegrations(into: &issues) @@ -896,6 +954,41 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } } + private func validatePortForwards(into issues: inout [DoryVMDefinitionValidationIssue]) { + if portForwards.count > DoryVMPortForward.maximumCount { + issues.append(issue(.tooManyPortForwards, "portForwards")) + } + if !portForwards.isEmpty, + networkMode != .sharedNAT, + networkMode != .isolated { + issues.append(issue(.portForwardNetworkModeUnsupported, "portForwards")) + } + + var identifiers: Set = [] + var hostBindings: Set = [] + for (index, forward) in portForwards.enumerated() { + let field = "portForwards[\(index)]" + if !Self.isSafeMachineIdentifier(forward.id) { + issues.append(issue(.invalidPortForwardIdentifier, "\(field).id")) + } else if !identifiers.insert(forward.id).inserted { + issues.append(issue(.duplicatePortForwardIdentifier, "\(field).id")) + } + if forward.hostPort == 0 { + issues.append(issue(.invalidPortForwardPort, "\(field).hostPort")) + } + if forward.guestPort == 0 { + issues.append(issue(.invalidPortForwardPort, "\(field).guestPort")) + } + let binding = "\(forward.transport.rawValue):\(forward.hostPort)" + if !hostBindings.insert(binding).inserted { + issues.append(issue(.duplicatePortForwardBinding, "\(field).hostPort")) + } + if forward.exposure == .lan, networkMode != .sharedNAT { + issues.append(issue(.lanPortForwardRequiresSharedNAT, "\(field).exposure")) + } + } + } + private func validateDisplay(into issues: inout [DoryVMDefinitionValidationIssue]) { let dimensionsArePositive = display.widthPixels > 0 && display.heightPixels > 0 diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift index 922da86f..0736645e 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift @@ -200,6 +200,9 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable { guard definition.networkMode == baselineDefinition.networkMode else { throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("networkMode") } + guard definition.portForwards == baselineDefinition.portForwards else { + throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("portForwards") + } guard definition.display == baselineDefinition.display else { throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("display") } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 7f873678..a095572b 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -7,7 +7,7 @@ struct DoryVirtualMachineDefinitionTests { private let gibibyte: UInt64 = 1_073_741_824 private let nowMilliseconds: Int64 = 1_787_200_000_000 - @Test("schema 3 round trips with stable resolver and timestamp representations") + @Test("schema 4 round trips with stable resolver and timestamp representations") func currentRoundTrip() throws { let original = linuxDefinition() #expect(original.isValid) @@ -19,7 +19,7 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded == original) let json = try #require(String(data: data, encoding: .utf8)) - #expect(json.contains("\"schemaVersion\":3")) + #expect(json.contains("\"schemaVersion\":4")) #expect(json.contains("\"virtualHardwareABIVersion\":1")) #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000")) #expect(json.contains("\"namespace\":\"boot\"")) @@ -27,6 +27,7 @@ struct DoryVirtualMachineDefinitionTests { #expect(json.contains("\"guestIdentityIntent\"")) #expect(json.contains("\"backingScaleFactor\":2")) #expect(json.contains("\"guestUIScaleFactor\":2")) + #expect(json.contains("\"portForwards\":[]")) #expect(!json.contains("\"bootMedia\"")) #expect(!json.contains("artifactID")) #expect(!json.contains("hostLocationID")) @@ -41,6 +42,7 @@ struct DoryVirtualMachineDefinitionTests { ) object.removeValue(forKey: "guestIdentityIntent") object.removeValue(forKey: "clipboardPolicy") + object.removeValue(forKey: "portForwards") object["schemaVersion"] = 2 var storage = try #require(object["storage"] as? [[String: Any]]) for index in storage.indices { storage[index].removeValue(forKey: "source") } @@ -53,7 +55,8 @@ struct DoryVirtualMachineDefinitionTests { ) #expect(decoded.guestIdentityIntent == .unspecified) #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional)) - #expect(decoded.schemaVersion == 3) + #expect(decoded.schemaVersion == 4) + #expect(decoded.portForwards.isEmpty) #expect(decoded.storage.allSatisfy { $0.source == .userProvided }) #expect(decoded.display.backingScaleFactor == 2) #expect(decoded.display.guestUIScaleFactor == 2) @@ -73,6 +76,73 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.isValid) } + @Test("port forwards are bounded explicit network intent") + func portForwardValidation() throws { + var definition = linuxDefinition() + definition.portForwards = [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + DoryVMPortForward( + id: "dns", + transport: .udp, + hostPort: 5_353, + guestPort: 53, + exposure: .lan + ), + ] + #expect(definition.isValid) + + let data = try JSONEncoder().encode(definition) + let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: data) + #expect(decoded == definition) + #expect(decoded.portForwards[0].exposure == .loopback) + + definition.portForwards[1].id = "web" + #expect(has( + .duplicatePortForwardIdentifier, + "portForwards[1].id", + in: definition.validate() + )) + definition.portForwards[1].id = "dns" + definition.portForwards[1].transport = .tcp + definition.portForwards[1].hostPort = 8_080 + #expect(has( + .duplicatePortForwardBinding, + "portForwards[1].hostPort", + in: definition.validate() + )) + definition.portForwards[1].hostPort = 5_353 + definition.portForwards[0].guestPort = 0 + #expect(has( + .invalidPortForwardPort, + "portForwards[0].guestPort", + in: definition.validate() + )) + definition.portForwards[0].guestPort = 80 + definition.networkMode = .isolated + #expect(has( + .lanPortForwardRequiresSharedNAT, + "portForwards[1].exposure", + in: definition.validate() + )) + definition.portForwards[1].exposure = .loopback + #expect(definition.isValid) + definition.networkMode = .disconnected + #expect(has( + .portForwardNetworkModeUnsupported, + "portForwards", + in: definition.validate() + )) + definition.networkMode = .sharedNAT + definition.portForwards = (0...DoryVMPortForward.maximumCount).map { + DoryVMPortForward( + id: "port-\($0)", + hostPort: UInt16(10_000 + $0), + guestPort: UInt16(20_000 + $0) + ) + } + #expect(has(.tooManyPortForwards, "portForwards", in: definition.validate())) + } + @Test("removable USB hotplug is explicit durable guest integration intent") func removableUSBHotplugRoundTrip() throws { var original = linuxDefinition() @@ -259,7 +329,8 @@ struct DoryVirtualMachineDefinitionTests { """#.utf8) let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden) - #expect(migrated.schemaVersion == 3) + #expect(migrated.schemaVersion == 4) + #expect(migrated.portForwards.isEmpty) #expect(migrated.virtualHardwareABIVersion == 1) #expect(migrated.workload == .desktop) #expect(migrated.boot.phase == .install) @@ -274,7 +345,7 @@ struct DoryVirtualMachineDefinitionTests { let upgraded = try JSONEncoder().encode(migrated) let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8)) - #expect(upgradedJSON.contains("\"schemaVersion\":3")) + #expect(upgradedJSON.contains("\"schemaVersion\":4")) #expect(upgradedJSON.contains("createdAtUnixMilliseconds")) #expect(!upgradedJSON.contains("\"bootMedia\"")) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift index 80dca5cd..844a2728 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift @@ -80,6 +80,7 @@ struct DoryMachineConfigurationMigrationTests { desktopEnvironment: "GNOME" )) #expect(migrated.definition.clipboardPolicy == .legacyDesktop(.hostToGuest)) + #expect(migrated.definition.portForwards.isEmpty) #expect(migrated.hostPath(for: migrated.definition.shares[0].hostLocation) == "/Users/developer/Source") #expect(migrated.artifactPath(for: migrated.definition.storage[0].artifact) @@ -449,6 +450,18 @@ struct DoryMachineConfigurationMigrationTests { try migrated.legacyConfiguration() } + migrated = try migrate(direct, capacity: 64 * gibibyte) + migrated.definition.portForwards = [DoryVMPortForward( + id: "ssh", + hostPort: 2_222, + guestPort: 22 + )] + #expect(throws: DoryMachineConfigurationMigrationError.unsupportedDefinitionChange( + "portForwards" + )) { + try migrated.legacyConfiguration() + } + migrated = try migrate(direct, capacity: 64 * gibibyte) migrated.definition.storage[0].artifact = DoryVMResolverReference( namespace: "artifact", From 9396d2883dd9f11aa20331b84343d0ce2b871012 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 04:57:58 +0000 Subject: [PATCH 250/338] feat(vm): bind port forwards into resolved launches --- ...emonVirtualMachineLaunchPlanResolver.swift | 6 +- ...achinePlanningTransactionCoordinator.swift | 4 +- ...yDaemonVirtualMachineRuntimePlanning.swift | 7 +- .../DorydKit/DoryResolvedMachinePlan.swift | 70 ++++++++++++++++++- .../LinuxMachineBackendAdapters.swift | 56 ++++++++++++++- .../Sources/DorydKit/MachineBackend.swift | 15 +++- .../Sources/DorydKit/MachineManager.swift | 12 ++++ ...onVirtualMachineRuntimePlanningTests.swift | 7 ++ .../DoryResolvedMachinePlanTests.swift | 44 ++++++++++++ .../DorydKitTests/MachineBackendTests.swift | 8 ++- ...eManagerResolvedPlanIntegrationTests.swift | 14 +++- 11 files changed, 226 insertions(+), 17 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift index af50ff08..00c4c422 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift @@ -421,7 +421,8 @@ public final class DoryDaemonVirtualMachineLaunchPlanResolver: ) let mapped = registry.plan(MachineBackendPlanRequest( machine: request.machine, - capabilityPlan: capabilityPlan + capabilityPlan: capabilityPlan, + portForwards: plan.portForwards )) guard let backendPlan = mapped.plan, mapped.failure == nil else { throw failure(.backendPlanRejected, "The exact backend adapter rejected the launch plan.") @@ -430,7 +431,8 @@ public final class DoryDaemonVirtualMachineLaunchPlanResolver: backendPlan.backend.implementationIdentifier == plan.backendImplementationIdentifier, backendPlan.capability == fresh.capability, - backendPlan.machine.id == plan.machineID else { + backendPlan.machine.id == plan.machineID, + backendPlan.portForwards == plan.portForwards else { throw failure(.backendPlanIdentityMismatch, "The adapter plan differs from the durable selection.") } return DoryDaemonVirtualMachineLaunchPlanResolution( diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index 1a5e5e2b..1421455b 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -1000,12 +1000,14 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: } let backendResult = registry.plan(MachineBackendPlanRequest( machine: request.planning.machine, - capabilityPlan: plannerResult + capabilityPlan: plannerResult, + portForwards: plan.portForwards )) guard let backendPlan = backendResult.plan, backendResult.failure == nil, backendPlan.machine == request.planning.machine, backendPlan.capability == selected, + backendPlan.portForwards == plan.portForwards, backendPlan.backend.identity == plan.backend, backendPlan.backend.implementationIdentifier == plan.backendImplementationIdentifier else { diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 7fc811ad..1061b8a2 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -428,12 +428,14 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda } let backendResult = registry.plan(MachineBackendPlanRequest( machine: input.machine, - capabilityPlan: plannerResult + capabilityPlan: plannerResult, + portForwards: definition.portForwards )) guard let backendPlan = backendResult.plan, backendResult.failure == nil, backendPlan.backend == backend.descriptor, backendPlan.machine == input.machine, - backendPlan.capability == selected else { + backendPlan.capability == selected, + backendPlan.portForwards == definition.portForwards else { let detail = backendResult.failure.map { " (\($0.code.rawValue): \($0.message))" } ?? "" @@ -472,6 +474,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier, resolverReference: snapshot.media.reference, launchArtifacts: snapshot.launchArtifacts, + portForwards: definition.portForwards, components: runtime.components.sorted { $0.componentIdentifier < $1.componentIdentifier }, diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 0e2c2217..9619dbce 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -457,6 +457,7 @@ public enum DoryResolvedMachinePlanValidationCode: String, Codable, Sendable, Ha case invalidMediaBinding = "invalid-media-binding" case invalidMediaEvidence = "invalid-media-evidence" case invalidLaunchArtifactEvidence = "invalid-launch-artifact-evidence" + case invalidPortForwardContract = "invalid-port-forward-contract" case unsupportedRuntimeCombination = "unsupported-runtime-combination" case duplicateComponent = "duplicate-component" case unorderedComponents = "unordered-components" @@ -491,7 +492,7 @@ public struct DoryResolvedMachinePlanValidationIssue: Codable, Sendable, Equatab /// decision or non-secret audit reference and is replaced whenever any bound evidence changes. public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { public static let oldestSupportedSchemaVersion: UInt16 = 1 - public static let currentSchemaVersion: UInt16 = 3 + public static let currentSchemaVersion: UInt16 = 4 public var schemaVersion: UInt16 public var sourceSchemaVersion: UInt16 @@ -512,6 +513,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { public var components: [DoryResolvedBackendComponentEvidence] public var devices: DoryVirtualMachineDeviceCapabilityRequest public var graphics: DoryGraphicsAccelerationLevel + public var portForwards: [DoryVMPortForward] public var supportTier: DoryCapabilitySupportTier public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence? public var qualificationEvidence: DoryResolvedMachineQualificationEvidence @@ -536,6 +538,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { components: [DoryResolvedBackendComponentEvidence], devices: DoryVirtualMachineDeviceCapabilityRequest, graphics: DoryGraphicsAccelerationLevel, + portForwards: [DoryVMPortForward] = [], supportTier: DoryCapabilitySupportTier, selectionEvidence: DoryResolvedMachineBackendSelectionEvidence, qualificationEvidence: DoryResolvedMachineQualificationEvidence, @@ -562,6 +565,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { self.components = components self.devices = devices self.graphics = graphics + self.portForwards = portForwards self.supportTier = supportTier self.selectionEvidence = selectionEvidence self.qualificationEvidence = qualificationEvidence @@ -583,6 +587,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { backendRuntimeBuildIdentifier: String, resolverReference: DoryVMResolverReference?, launchArtifacts: [DoryResolvedMachineLaunchArtifact], + portForwards: [DoryVMPortForward] = [], components: [DoryResolvedBackendComponentEvidence], resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence, hostQualification: DoryResolvedHostQualificationEvidence, @@ -634,6 +639,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { components: components, devices: selectedCapability.request.devices, graphics: selectedCapability.request.graphics, + portForwards: portForwards, supportTier: selectedCapability.availability.supportTier, selectionEvidence: selectionEvidence, qualificationEvidence: DoryResolvedMachineQualificationEvidence( @@ -671,6 +677,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { case componentDigests case devices case graphics + case portForwards case supportTier case selectionEvidence case qualificationEvidence @@ -731,13 +738,14 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { forKey: .devices ) ?? .minimumBootable graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics) + portForwards = [] supportTier = .experimental selectionEvidence = nil qualificationEvidence = DoryResolvedMachineQualificationEvidence() resourceAdmission = nil hostQualification = nil experimentalAuthorization = nil - case 2, Self.currentSchemaVersion: + case 2, 3, Self.currentSchemaVersion: schemaVersion = Self.currentSchemaVersion sourceSchemaVersion = persistedSchema migrationDisposition = persistedSchema == Self.currentSchemaVersion @@ -786,6 +794,9 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { forKey: .devices ) graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics) + portForwards = persistedSchema == Self.currentSchemaVersion + ? try container.decode([DoryVMPortForward].self, forKey: .portForwards) + : [] supportTier = try container.decode(DoryCapabilitySupportTier.self, forKey: .supportTier) selectionEvidence = try container.decodeIfPresent( DoryResolvedMachineBackendSelectionEvidence.self, @@ -837,6 +848,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { try container.encode(components, forKey: .components) try container.encode(devices, forKey: .devices) try container.encode(graphics, forKey: .graphics) + try container.encode(portForwards, forKey: .portForwards) try container.encode(supportTier, forKey: .supportTier) try container.encodeIfPresent(selectionEvidence, forKey: .selectionEvidence) try container.encode(qualificationEvidence, forKey: .qualificationEvidence) @@ -880,6 +892,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { validateBootMedia(into: &issues) validateLaunchArtifacts(into: &issues) + validatePortForwards(into: &issues) validateComponents(into: &issues) validateQualifications(into: &issues) validateResourceAdmission(into: &issues) @@ -889,6 +902,48 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { return issues } + private func validatePortForwards( + into issues: inout [DoryResolvedMachinePlanValidationIssue] + ) { + func reject(_ field: String) { + issues.append(DoryResolvedMachinePlanValidationIssue( + code: .invalidPortForwardContract, + field: field + )) + } + if portForwards.count > DoryVMPortForward.maximumCount { + reject("portForwards") + } + if !portForwards.isEmpty, + devices.networkAttachment != .sharedNAT, + devices.networkAttachment != .isolated { + reject("portForwards") + } + var identifiers: Set = [] + var bindings: Set = [] + for (index, forward) in portForwards.enumerated() { + let bytes = Array(forward.id.utf8) + let identifierIsValid = (1...63).contains(bytes.count) + && bytes.first.map(Self.isAlphaNumeric) == true + && bytes.dropFirst().allSatisfy { + Self.isAlphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + if !identifierIsValid || !identifiers.insert(forward.id).inserted { + reject("portForwards[\(index)].id") + } + if forward.hostPort == 0 || forward.guestPort == 0 { + reject("portForwards[\(index)].port") + } + let binding = "\(forward.transport.rawValue):\(forward.hostPort)" + if !bindings.insert(binding).inserted { + reject("portForwards[\(index)].hostPort") + } + if forward.exposure == .lan, devices.networkAttachment != .sharedNAT { + reject("portForwards[\(index)].exposure") + } + } + } + private func validateBootMedia( into issues: inout [DoryResolvedMachinePlanValidationIssue] ) { @@ -1433,6 +1488,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, public var components: [DoryResolvedBackendComponentEvidence] public var devices: DoryVirtualMachineDeviceCapabilityRequest public var graphics: DoryGraphicsAccelerationLevel + public var portForwards: [DoryVMPortForward] public var supportTier: DoryCapabilitySupportTier public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence? public var qualificationEvidence: DoryResolvedMachineQualificationEvidence @@ -1451,6 +1507,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, components: [DoryResolvedBackendComponentEvidence], devices: DoryVirtualMachineDeviceCapabilityRequest, graphics: DoryGraphicsAccelerationLevel, + portForwards: [DoryVMPortForward] = [], supportTier: DoryCapabilitySupportTier, selectionEvidence: DoryResolvedMachineBackendSelectionEvidence?, qualificationEvidence: DoryResolvedMachineQualificationEvidence, @@ -1468,6 +1525,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, self.components = components self.devices = devices self.graphics = graphics + self.portForwards = portForwards self.supportTier = supportTier self.selectionEvidence = selectionEvidence self.qualificationEvidence = qualificationEvidence @@ -1487,6 +1545,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, components = plan.components devices = plan.devices graphics = plan.graphics + portForwards = plan.portForwards supportTier = plan.supportTier selectionEvidence = plan.selectionEvidence qualificationEvidence = plan.qualificationEvidence @@ -1535,6 +1594,7 @@ public enum DoryResolvedMachinePlanRevalidationCode: String, Codable, Sendable, case componentEvidenceMismatch = "component-evidence-mismatch" case deviceContractMismatch = "device-contract-mismatch" case graphicsMismatch = "graphics-mismatch" + case portForwardContractMismatch = "port-forward-contract-mismatch" case supportTierMismatch = "support-tier-mismatch" case selectionEvidenceMismatch = "selection-evidence-mismatch" case qualificationEvidenceMismatch = "qualification-evidence-mismatch" @@ -1646,6 +1706,12 @@ public enum DoryResolvedMachinePlanStartValidator { compare(runtime.components, plan.components, code: .componentEvidenceMismatch, field: "components") compare(runtime.devices, plan.devices, code: .deviceContractMismatch, field: "devices") compare(runtime.graphics, plan.graphics, code: .graphicsMismatch, field: "graphics") + compare( + runtime.portForwards, + plan.portForwards, + code: .portForwardContractMismatch, + field: "portForwards" + ) compare( runtime.supportTier, plan.supportTier, diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index ec2df94e..c656af4c 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -180,17 +180,65 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { message: validationFailure ) } + if let validationFailure = validatePortForwards( + request.portForwards, + networkAttachment: capability.request.devices.networkAttachment + ) { + return failedPlan( + probe: currentProbe, + code: .machineConfigurationIncompatible, + message: validationFailure + ) + } return MachineBackendPlanResult( plan: MachineBackendPlan( backend: descriptor, machine: request.machine, - capability: capability + capability: capability, + portForwards: request.portForwards ), probe: currentProbe, failure: nil ) } + private func validatePortForwards( + _ forwards: [DoryVMPortForward], + networkAttachment: DoryVirtualMachineNetworkAttachmentMode + ) -> String? { + guard forwards.count <= DoryVMPortForward.maximumCount else { + return "The resolved port-forward count exceeds the backend limit." + } + if !forwards.isEmpty, + networkAttachment != .sharedNAT, + networkAttachment != .isolated { + return "The resolved network attachment cannot publish host ports." + } + var identifiers: Set = [] + var bindings: Set = [] + for forward in forwards { + let bytes = Array(forward.id.utf8) + let firstIsValid = bytes.first.map(Self.isASCIIAlphaNumeric) == true + let restIsValid = bytes.dropFirst().allSatisfy { + Self.isASCIIAlphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + guard (1...63).contains(bytes.count), firstIsValid, restIsValid, + identifiers.insert(forward.id).inserted, + forward.hostPort > 0, forward.guestPort > 0, + bindings.insert("\(forward.transport.rawValue):\(forward.hostPort)").inserted, + forward.exposure != .lan || networkAttachment == .sharedNAT else { + return "The resolved port-forward contract is invalid or conflicting." + } + } + return nil + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + func start(_ request: MachineBackendStartRequest) -> MachineBackendOperationResult { let plan = request.plan guard request.hasValidOperationIdentity else { @@ -214,7 +262,8 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { selectedDescriptor: plan.capability, evaluatedDescriptors: [plan.capability], failure: nil - ) + ), + portForwards: plan.portForwards )) guard revalidated.plan == plan else { return failedOperation( @@ -241,7 +290,8 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { componentIdentifier: componentIdentifier, executablePath: executablePath, graphics: plan.capability.request.graphics, - devices: plan.capability.request.devices + devices: plan.capability.request.devices, + portForwards: plan.portForwards )) } diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift index da8fa386..a57df6fd 100644 --- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift +++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift @@ -117,13 +117,16 @@ public struct MachineBackendProbeResult: Codable, Sendable, Equatable, Hashable public struct MachineBackendPlanRequest: Codable, Sendable, Equatable, Hashable { public var machine: DoryMachineConfiguration public var capabilityPlan: DoryVirtualMachineBackendPlanResult + public var portForwards: [DoryVMPortForward] public init( machine: DoryMachineConfiguration, - capabilityPlan: DoryVirtualMachineBackendPlanResult + capabilityPlan: DoryVirtualMachineBackendPlanResult, + portForwards: [DoryVMPortForward] = [] ) { self.machine = machine self.capabilityPlan = capabilityPlan + self.portForwards = portForwards } } @@ -134,17 +137,20 @@ public struct MachineBackendPlan: Codable, Sendable, Equatable, Hashable { public var backend: MachineBackendDescriptor public var machine: DoryMachineConfiguration public var capability: DoryVirtualMachineCapabilityDescriptor + public var portForwards: [DoryVMPortForward] public init( schemaVersion: UInt16 = Self.currentSchemaVersion, backend: MachineBackendDescriptor, machine: DoryMachineConfiguration, - capability: DoryVirtualMachineCapabilityDescriptor + capability: DoryVirtualMachineCapabilityDescriptor, + portForwards: [DoryVMPortForward] = [] ) { self.schemaVersion = schemaVersion self.backend = backend self.machine = machine self.capability = capability + self.portForwards = portForwards } } @@ -231,6 +237,7 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { /// carried into process construction; a launcher may not reinterpret them as "automatic". public var graphics: DoryGraphicsAccelerationLevel public var devices: DoryVirtualMachineDeviceCapabilityRequest + public var portForwards: [DoryVMPortForward] public init( machineID: String, @@ -239,7 +246,8 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { componentIdentifier: String, executablePath: String, graphics: DoryGraphicsAccelerationLevel, - devices: DoryVirtualMachineDeviceCapabilityRequest + devices: DoryVirtualMachineDeviceCapabilityRequest, + portForwards: [DoryVMPortForward] = [] ) { self.machineID = machineID self.operationID = operationID @@ -248,6 +256,7 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable { self.executablePath = executablePath self.graphics = graphics self.devices = devices + self.portForwards = portForwards } } diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index aa8c9c83..07600a11 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -2024,6 +2024,7 @@ public final class MachineManager: @unchecked Sendable { runtimeComponents: resolved.resolvedPlan.components, graphics: resolved.resolvedPlan.graphics, devices: resolved.resolvedPlan.devices, + portForwards: resolved.resolvedPlan.portForwards, operationID: operationID, planRevision: resolved.resolvedPlan.planRevision, planSHA256: resolved.resolvedPlanSHA256, @@ -2138,6 +2139,7 @@ public final class MachineManager: @unchecked Sendable { binding.backend.identity == expectedBackend, authorization.graphics == binding.graphics, authorization.devices == binding.devices, + authorization.portForwards == binding.portForwards, !binding.executablePath.isEmpty else { pendingResolvedStart = nil throw MachineManagerError.persistence( @@ -2225,6 +2227,8 @@ public final class MachineManager: @unchecked Sendable { capability.request.bootMedia == plan.bootMedia.media, capability.request.devices == plan.devices, capability.request.graphics == plan.graphics, + plan.portForwards == definition.portForwards, + resolved.backendPlan.portForwards == plan.portForwards, capability.request.virtualHardwareABIVersion == plan.virtualHardwareABIVersion, capability.availability.supportTier == plan.supportTier, capability.availability.isUsable, @@ -6204,6 +6208,13 @@ public final class MachineManager: @unchecked Sendable { "--resolved-graphics", resolvedLaunchBinding.graphics.rawValue, "--resolved-devices", deviceContract, ]) + let portForwardContract = String( + decoding: try encoder.encode(resolvedLaunchBinding.portForwards), + as: UTF8.self + ) + arguments.append(contentsOf: [ + "--resolved-port-forwards", portForwardContract, + ]) } for share in machine.shares { arguments.append(contentsOf: ["--share", share.argumentValue]) @@ -12877,6 +12888,7 @@ private struct PendingResolvedMachineStart { var runtimeComponents: [DoryResolvedBackendComponentEvidence] var graphics: DoryGraphicsAccelerationLevel var devices: DoryVirtualMachineDeviceCapabilityRequest + var portForwards: [DoryVMPortForward] var operationID: UUID var planRevision: UInt64 var planSHA256: String diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index bfaadcfe..c6bfc721 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -50,6 +50,8 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(result.resolvedPlan.launchArtifacts.count == 2) #expect(result.resolvedPlan.launchArtifacts.flatMap(\.usages).map(\.kind) == [.storage, .boot]) + #expect(result.resolvedPlan.portForwards == fixture.definition.portForwards) + #expect(result.backendPlan.portForwards == fixture.definition.portForwards) #expect(result.backendPlan.backend.identity == .doryHypervisor) #expect(try fixture.store.read(id: fixture.definition.identity.id) == result.resolvedPlan) } @@ -284,6 +286,11 @@ private final class Fixture { artifact: diskArtifact, capacityBytes: resources.diskBytes )], + portForwards: [DoryVMPortForward( + id: "ssh", + hostPort: 2_222, + guestPort: 22 + )], audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), lifecycle: DoryVMLifecycleMetadata( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift index b0923528..9f4198ce 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift @@ -61,6 +61,26 @@ struct DoryResolvedMachinePlanTests { #expect(migrated.validate().contains { $0.code == .invalidLaunchArtifactEvidence }) } + @Test("schema v3 without port-forward authority requires replanning") + func schemaV3PortForwardMigration() throws { + let encoded = try JSONEncoder().encode(supportedPlan()) + var object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + object["schemaVersion"] = 3 + object["sourceSchemaVersion"] = 3 + object.removeValue(forKey: "portForwards") + + let migrated = try JSONDecoder().decode( + DoryResolvedMachinePlan.self, + from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + #expect(migrated.sourceSchemaVersion == 3) + #expect(migrated.migrationDisposition == .requiresReplanning) + #expect(migrated.portForwards.isEmpty) + #expect(migrated.validate().contains { $0.code == .legacyPlanRequiresReplanning }) + } + @Test("exact fresh evidence authorizes start") func exactStartEvidence() { let plan = supportedPlan() @@ -120,6 +140,25 @@ struct DoryResolvedMachinePlanTests { #expect(result.issues.contains { $0.code == .deviceContractMismatch }) } + @Test("port forwards are exact start evidence and fail closed structurally") + func portForwardEvidenceMismatch() { + let plan = supportedPlan() + #expect(plan.validate().isEmpty) + var input = exactInput(for: plan) + input.runtimeEvidence.portForwards[0].guestPort = 2_222 + let mismatch = DoryResolvedMachinePlanStartValidator.revalidate(plan, against: input) + #expect(!mismatch.mayStart) + #expect(mismatch.issues.contains { $0.code == .portForwardContractMismatch }) + + var invalid = plan + invalid.portForwards.append(DoryVMPortForward( + id: "duplicate-binding", + hostPort: 2_222, + guestPort: 220 + )) + #expect(invalid.validate().contains { $0.code == .invalidPortForwardContract }) + } + @Test("immutable media and qualification evidence mismatches are exact") func immutableEvidenceMismatch() { let plan = supportedPlan() @@ -764,6 +803,11 @@ private func supportedPlan() -> DoryResolvedMachinePlan { ], devices: devices, graphics: .hostAcceleratedDisplay, + portForwards: [DoryVMPortForward( + id: "ssh", + hostPort: 2_222, + guestPort: 22 + )], supportTier: .supported, selectionEvidence: primarySelectionEvidence( guest: DoryGuestPlatform(family: .linux, architecture: .arm64), diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift index 766f2a0d..3c63f985 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift @@ -87,7 +87,12 @@ final class MachineBackendTests: XCTestCase { capabilityPlan: capabilityPlan( backend: .doryHypervisor, media: .linuxKernel - ) + ), + portForwards: [DoryVMPortForward( + id: "ssh", + hostPort: 2_222, + guestPort: 22 + )] ) let result = registry.plan(request) @@ -106,6 +111,7 @@ final class MachineBackendTests: XCTestCase { XCTAssertEqual(recorder.launchBindings.first?.operationID, operationID) XCTAssertEqual(recorder.launchBindings.first?.graphics, .hostAcceleratedDisplay) XCTAssertEqual(recorder.launchBindings.first?.devices, .minimumBootable) + XCTAssertEqual(recorder.launchBindings.first?.portForwards, request.portForwards) let stopOperationID = UUID(uuidString: "12345678-9abc-4def-8012-3456789abcde")! let stopped = registry.stop(MachineBackendRuntimeRequest( diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 9f4150fa..4e1c4121 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -157,9 +157,9 @@ struct MachineManagerResolvedPlanIntegrationTests { } } - @Test("adapter cannot substitute resolved graphics or devices") + @Test("adapter cannot substitute resolved graphics devices or port forwards") func adapterCannotSubstituteLaunchContract() throws { - enum Mutation: CaseIterable, Sendable { case graphics, devices } + enum Mutation: CaseIterable, Sendable { case graphics, devices, portForwards } for mutation in Mutation.allCases { try withHarness("binding-substitution-\(mutation)") { manager, starter, _ in @@ -175,6 +175,12 @@ struct MachineManagerResolvedPlanIntegrationTests { changed.graphics = .software case .devices: changed.devices.keyboard.toggle() + case .portForwards: + changed.portForwards = [DoryVMPortForward( + id: "substituted", + hostPort: 8_080, + guestPort: 80 + )] } return try managerOperations.authorizedStart(changed) }, @@ -1669,6 +1675,7 @@ struct MachineManagerResolvedPlanIntegrationTests { )], devices: devices, graphics: .hostAcceleratedDisplay, + portForwards: request.definition.portForwards, supportTier: .supported, selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( disposition: .primary, @@ -1755,7 +1762,8 @@ struct MachineManagerResolvedPlanIntegrationTests { backendPlan: MachineBackendPlan( backend: RawHVLinuxMachineBackend.backendDescriptor, machine: request.machine, - capability: capability + capability: capability, + portForwards: request.definition.portForwards ), preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization( revalidate: preSpawnRevalidation From 4bc0e64c04aa8b8a84758c83935254bb99cdeef6 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 05:07:44 +0000 Subject: [PATCH 251/338] feat(vm): install resolved port forwards in helpers --- .../Sources/dory-hv/DesktopMode.swift | 82 ++++++++++++++++++- .../Sources/dory-hv/main.swift | 14 +++- .../PublishedPortForwardPlanTests.swift | 39 +++++++++ .../DoryCore/PublishedPortForwardPlan.swift | 44 ++++++++++ .../DoryVirtualMachineDefinition.swift | 2 +- .../Sources/DoryVMMKit/DoryVMM.swift | 44 +++++++++- .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 54 +++++++++++- .../DorydKit/DoryResolvedMachinePlan.swift | 2 +- .../LinuxMachineBackendAdapters.swift | 2 +- .../DoryVirtualMachineDefinitionTests.swift | 7 ++ .../DoryResolvedMachinePlanTests.swift | 4 + .../Tests/DorydKitTests/DoryVMMKitTests.swift | 11 +++ ...eManagerResolvedPlanIntegrationTests.swift | 10 ++- 13 files changed, 306 insertions(+), 9 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 500f7665..e39ebbdc 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -433,6 +433,7 @@ enum DesktopMode { var environment: [String: String] var resolvedGraphics: DoryGraphicsAccelerationLevel? var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + var resolvedPortForwards: [DoryVMPortForward]? } enum NetworkPlan: Equatable { @@ -720,7 +721,8 @@ enum DesktopMode { networkInterface: networkInterface, gvproxyPath: configuration.gvproxyPath, runtimeDirectory: runtimeDirectory, - token: token + token: token, + resolvedPortForwards: configuration.resolvedPortForwards ) self.gvproxy = networkRuntime.process self.networkSocketPaths = networkRuntime.socketPaths @@ -1210,8 +1212,28 @@ enum DesktopMode { networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest?, gvproxyPath: String, runtimeDirectory: String, - token: String + token: String, + resolvedPortForwards: [DoryVMPortForward]? ) throws -> NetworkRuntime { + guard resolvedPortForwards == nil || networkInterface != nil else { + throw VMError.bootFailure( + "resolved port forwards require an exact resolved network device contract" + ) + } + let portIntents = resolvedPortForwards ?? [] + guard let portForwards = PublishedPortForwardPlan.resolvedForwards( + portIntents, + guestIP: "192.168.127.2" + ) else { + throw VMError.bootFailure("resolved port-forward contract is invalid") + } + if !portIntents.isEmpty, plan == .disconnected { + throw VMError.bootFailure("disconnected networking cannot publish host ports") + } + if plan != .sharedNAT, + portIntents.contains(where: { $0.exposure == .lan }) { + throw VMError.bootFailure("LAN port exposure requires shared NAT") + } if plan == .disconnected { let mac = networkInterface?.macAddressOctets ?? VirtioNet.guestMAC return NetworkRuntime( @@ -1262,6 +1284,7 @@ enum DesktopMode { do { try process.run() try waitForSocket(path: gvproxySocket, process: process) + try publishResolvedPortForwards(portForwards, apiSocket: apiSocket) return NetworkRuntime( process: process, socketPaths: socketPaths, @@ -1279,6 +1302,61 @@ enum DesktopMode { } } + private static func publishResolvedPortForwards( + _ forwards: Set, + apiSocket: String + ) throws { + for forward in forwards.sorted(by: portForwardOrder) { + let bodyData = try JSONSerialization.data(withJSONObject: [ + "local": forward.localEndpoint, + "remote": forward.remoteEndpoint, + "protocol": forward.protocol.rawValue, + ]) + guard let body = String(data: bodyData, encoding: .utf8) else { + throw VMError.bootFailure("could not encode resolved gvproxy forward") + } + var published = false + for _ in 0..<100 { + let curl = Process() + curl.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + curl.arguments = [ + "--fail", "--silent", "--show-error", + "--connect-timeout", "1", "--max-time", "1", + "--unix-socket", apiSocket, + "--request", "POST", + "--data-binary", body, + "http://gvproxy/services/forwarder/expose", + ] + curl.standardOutput = FileHandle.nullDevice + curl.standardError = FileHandle.nullDevice + if (try? curl.run()) != nil { + curl.waitUntilExit() + if curl.terminationStatus == 0 { + published = true + break + } + } + usleep(20_000) + } + guard published else { + throw VMError.bootFailure( + "gvproxy could not publish \(forward.localEndpoint)/\(forward.protocol.rawValue)" + ) + } + } + } + + private static func portForwardOrder( + _ lhs: PublishedPortForward, + _ rhs: PublishedPortForward + ) -> Bool { + if lhs.protocol != rhs.protocol { + return lhs.protocol.rawValue < rhs.protocol.rawValue + } + if lhs.localHost != rhs.localHost { return lhs.localHost < rhs.localHost } + return lhs.localPort < rhs.localPort + } + @discardableResult private static func attachBackend( _ backend: VirtioDeviceBackend, diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index 48e8ceb4..d82156cc 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -441,6 +441,7 @@ case "desktop": var environment = [String: String]() var resolvedGraphics: DoryGraphicsAccelerationLevel? var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + var resolvedPortForwards: [DoryVMPortForward]? var iterator = arguments.dropFirst().makeIterator() while let argument = iterator.next() { switch argument { @@ -490,6 +491,16 @@ case "desktop": fail("desktop --resolved-devices requires a valid device contract") } resolvedDevices = devices + case "--resolved-port-forwards": + guard let value = iterator.next(), + let data = value.data(using: .utf8), + let forwards = try? JSONDecoder().decode( + [DoryVMPortForward].self, + from: data + ) else { + fail("desktop --resolved-port-forwards requires a valid port-forward contract") + } + resolvedPortForwards = forwards case "--display-mode": guard iterator.next() == "desktop" else { fail("raw-HV desktop requires --display-mode desktop") } case "--boot-mode": @@ -543,7 +554,8 @@ case "desktop": shares: shares, environment: environment, resolvedGraphics: resolvedGraphics, - resolvedDevices: resolvedDevices + resolvedDevices: resolvedDevices, + resolvedPortForwards: resolvedPortForwards )) } catch { fail("desktop failed: \(error)") diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/PublishedPortForwardPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/PublishedPortForwardPlanTests.swift index 185d24f3..fdb077f2 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/PublishedPortForwardPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/PublishedPortForwardPlanTests.swift @@ -1,6 +1,7 @@ import Testing @testable import DoryHV import DoryCore +import DoryOperations @Suite struct PublishedPortForwardPlanTests { @Test func parsesDockerTransportTypes() { @@ -12,6 +13,44 @@ import DoryCore #expect(PublishedPortBinding(dockerType: "tcp", publicPort: 0) == nil) } + @Test func resolvedWorkspaceForwardsPreserveExactPortsAndExposure() throws { + let forwards = try #require(PublishedPortForwardPlan.resolvedForwards([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + DoryVMPortForward( + id: "dns", + transport: .udp, + hostPort: 5_353, + guestPort: 53, + exposure: .lan + ), + ], guestIP: "192.168.127.2")) + #expect(forwards == [ + PublishedPortForward( + protocol: .tcp, + publishedPort: 8_080, + localHost: "127.0.0.1", + localPort: 8_080, + guestHost: "192.168.127.2", + guestPort: 80 + ), + PublishedPortForward( + protocol: .udp, + publishedPort: 5_353, + localHost: "0.0.0.0", + localPort: 5_353, + guestHost: "192.168.127.2", + guestPort: 53 + ), + ]) + #expect(PublishedPortForwardPlan.resolvedForwards([ + DoryVMPortForward(id: "privileged", hostPort: 443, guestPort: 443), + ], guestIP: "192.168.127.2") == nil) + #expect(PublishedPortForwardPlan.resolvedForwards([ + DoryVMPortForward(id: "one", hostPort: 8_080, guestPort: 80), + DoryVMPortForward(id: "two", hostPort: 8_080, guestPort: 81), + ], guestIP: "192.168.127.2") == nil) + } + @Test func loopbackPlanIncludesIPv4AndIPv6ForTCPAndUDP() { let forwards = PublishedPortForwardPlan.forwards( for: [ diff --git a/dory-core-swift/Sources/DoryCore/PublishedPortForwardPlan.swift b/dory-core-swift/Sources/DoryCore/PublishedPortForwardPlan.swift index 48914e2d..e49dba53 100644 --- a/dory-core-swift/Sources/DoryCore/PublishedPortForwardPlan.swift +++ b/dory-core-swift/Sources/DoryCore/PublishedPortForwardPlan.swift @@ -1,4 +1,5 @@ import Darwin +import DoryOperations import Foundation public enum PublishedPortForwardProtocol: String, Sendable, Hashable, Codable { @@ -145,6 +146,43 @@ public enum PublishedPortForwardPlan { }) } + /// Maps a daemon-validated workspace contract to exact gvproxy endpoints. This performs the + /// same closed structural checks again at the helper boundary because command-line input is + /// untrusted. Privileged host ports are intentionally unavailable until a dedicated + /// authorization path exists; Dory never remaps an explicitly requested host port. + public static func resolvedForwards( + _ intents: [DoryVMPortForward], + guestIP: String + ) -> Set? { + guard intents.count <= DoryVMPortForward.maximumCount else { return nil } + var identifiers: Set = [] + var bindings: Set = [] + var result: Set = [] + for intent in intents { + let bytes = Array(intent.id.utf8) + guard (1...63).contains(bytes.count), + bytes.first.map(isASCIIAlphaNumeric) == true, + bytes.dropFirst().allSatisfy({ + isASCIIAlphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + }), + identifiers.insert(intent.id).inserted, + intent.hostPort >= 1_024, + intent.guestPort > 0, + bindings.insert("\(intent.transport.rawValue):\(intent.hostPort)").inserted + else { return nil } + let transport: PublishedPortForwardProtocol = intent.transport == .tcp ? .tcp : .udp + result.insert(PublishedPortForward( + protocol: transport, + publishedPort: Int(intent.hostPort), + localHost: intent.exposure == .loopback ? "127.0.0.1" : "0.0.0.0", + localPort: Int(intent.hostPort), + guestHost: guestIP, + guestPort: Int(intent.guestPort) + )) + } + return result + } + public static func forward( for binding: PublishedPortBinding, localHost: String, @@ -205,4 +243,10 @@ public enum PublishedPortForwardPlan { var address = in_addr() return unbracketed.withCString { inet_pton(AF_INET, $0, &address) } == 1 } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } } diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index c16038d5..166a3267 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -973,7 +973,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } else if !identifiers.insert(forward.id).inserted { issues.append(issue(.duplicatePortForwardIdentifier, "\(field).id")) } - if forward.hostPort == 0 { + if forward.hostPort < 1_024 { issues.append(issue(.invalidPortForwardPort, "\(field).hostPort")) } if forward.guestPort == 0 { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 6fc67733..e58e7a9e 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -54,6 +54,7 @@ public struct DoryVMMArguments: Sendable, Equatable { public var environment: [String: String] = [:] public var resolvedGraphics: DoryGraphicsAccelerationLevel? public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + public var resolvedPortForwards: [DoryVMPortForward]? public init() {} @@ -81,6 +82,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case invalidEnvironment(String) case invalidResolvedGraphics(String) case invalidResolvedDevices(String) + case invalidResolvedPortForwards(String) case invalidOperationID(String) public var description: String { @@ -115,6 +117,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid --resolved-graphics value: \(value)" case let .invalidResolvedDevices(value): return "invalid --resolved-devices value: \(value)" + case let .invalidResolvedPortForwards(value): + return "invalid --resolved-port-forwards value: \(value)" case let .invalidOperationID(value): return "invalid --operation-id value: \(value)" } @@ -220,6 +224,16 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { } catch { throw DoryVMMArgumentError.invalidResolvedDevices(rawValue) } + case "--resolved-port-forwards": + let rawValue = try value(after: argument, from: raw, index: &index) + do { + parsed.resolvedPortForwards = try JSONDecoder().decode( + [DoryVMPortForward].self, + from: Data(rawValue.utf8) + ) + } catch { + throw DoryVMMArgumentError.invalidResolvedPortForwards(rawValue) + } case "--exit-after-handoff": parsed.exitAfterHandoff = true case "--handoff-only": @@ -1030,6 +1044,7 @@ public enum DoryVMMMain { environment: arguments.environment, resolvedGraphics: arguments.resolvedGraphics, resolvedDevices: arguments.resolvedDevices, + resolvedPortForwards: arguments.resolvedPortForwards, gvproxyPath: arguments.gvproxyPath, sshAgentSocketPath: arguments.sshAgentSocketPath, publishHost: arguments.publishHost, @@ -1128,6 +1143,7 @@ public enum DoryVMMMain { environment: [String: String], resolvedGraphics: DoryGraphicsAccelerationLevel?, resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + resolvedPortForwards: [DoryVMPortForward]? = nil, gvproxyPath: String?, sshAgentSocketPath: String?, publishHost: String, @@ -1168,6 +1184,31 @@ public enum DoryVMMMain { dataDrive = nil } let requestedNetwork = resolvedDevices?.networkAttachment ?? .sharedNAT + let portForwards = resolvedPortForwards ?? [] + guard resolvedPortForwards == nil || resolvedDevices?.networkInterface != nil else { + throw DoryVZMachineError.validation( + "resolved port forwards require an exact resolved network device contract" + ) + } + guard let exactPortForwards = PublishedPortForwardPlan.resolvedForwards( + portForwards, + guestIP: "192.168.127.2" + ) else { + throw DoryVZMachineError.validation("resolved port-forward contract is invalid") + } + if !portForwards.isEmpty, + requestedNetwork != .sharedNAT, + requestedNetwork != .isolated { + throw DoryVZMachineError.validation( + "resolved network attachment cannot publish host ports" + ) + } + if requestedNetwork != .sharedNAT, + portForwards.contains(where: { $0.exposure == .lan }) { + throw DoryVZMachineError.validation( + "LAN port exposure requires shared NAT" + ) + } // An exact NIC contract uses the same gvproxy datapath on every supported attachment so // the backend can enforce both its MAC lease and MTU rather than delegating them to VZ NAT. let usesGVProxy = machineID == "docker" @@ -1189,7 +1230,8 @@ public enum DoryVMMMain { stateDirectory: stateDirectory, networkAttachment: requestedNetwork, networkInterface: resolvedDevices?.networkInterface, - sourcePreservingLAN: requestedNetwork == .sharedNAT && publishHost == "0.0.0.0" + sourcePreservingLAN: requestedNetwork == .sharedNAT && publishHost == "0.0.0.0", + resolvedPortForwards: exactPortForwards ) } else { gvproxyNetwork = nil diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift index 27501daf..e349ab86 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift @@ -67,7 +67,8 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { stateDirectory: String, networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? = nil, - sourcePreservingLAN: Bool = false + sourcePreservingLAN: Bool = false, + resolvedPortForwards: Set = [] ) throws { guard FileManager.default.isExecutableFile(atPath: gvproxyPath) else { throw DoryVZMachineError.missingFile(gvproxyPath) @@ -159,6 +160,9 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { guestPort: 2377, apiSocketPath: apiSocketPath ) + for forward in resolvedPortForwards.sorted(by: Self.portForwardOrder) { + try Self.publishIPForward(forward, apiSocketPath: apiSocketPath) + } } catch { if child.isRunning { child.terminate() @@ -273,6 +277,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { curl.executableURL = URL(fileURLWithPath: "/usr/bin/curl") curl.arguments = [ "--fail", "--silent", "--show-error", + "--connect-timeout", "1", "--max-time", "1", "--unix-socket", apiSocketPath, "--request", "POST", "--data-binary", body, @@ -292,6 +297,53 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { throw DoryVZMachineError.validation("gvproxy did not publish the guest shutdown channel") } + private static func publishIPForward( + _ forward: PublishedPortForward, + apiSocketPath: String + ) throws { + let bodyData = try JSONSerialization.data(withJSONObject: [ + "local": forward.localEndpoint, + "remote": forward.remoteEndpoint, + "protocol": forward.protocol.rawValue, + ]) + guard let body = String(data: bodyData, encoding: .utf8) else { + throw DoryVZMachineError.validation("could not encode resolved gvproxy forward") + } + for _ in 0..<100 { + let curl = Process() + curl.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + curl.arguments = [ + "--fail", "--silent", "--show-error", + "--connect-timeout", "1", "--max-time", "1", + "--unix-socket", apiSocketPath, + "--request", "POST", + "--data-binary", body, + "http://gvproxy/services/forwarder/expose", + ] + curl.standardOutput = FileHandle.nullDevice + curl.standardError = FileHandle.nullDevice + if (try? curl.run()) != nil { + curl.waitUntilExit() + if curl.terminationStatus == 0 { return } + } + usleep(20_000) + } + throw DoryVZMachineError.validation( + "gvproxy could not publish \(forward.localEndpoint)/\(forward.protocol.rawValue)" + ) + } + + private static func portForwardOrder( + _ lhs: PublishedPortForward, + _ rhs: PublishedPortForward + ) -> Bool { + if lhs.protocol != rhs.protocol { + return lhs.protocol.rawValue < rhs.protocol.rawValue + } + if lhs.localHost != rhs.localHost { return lhs.localHost < rhs.localHost } + return lhs.localPort < rhs.localPort + } + private static func validateUnixPath(_ path: String) throws { guard !path.utf8.contains(0), path.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { throw DoryVZMachineError.validation("Unix socket path is too long: \(path)") diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift index 9619dbce..6bd40816 100644 --- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift +++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift @@ -931,7 +931,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable { if !identifierIsValid || !identifiers.insert(forward.id).inserted { reject("portForwards[\(index)].id") } - if forward.hostPort == 0 || forward.guestPort == 0 { + if forward.hostPort < 1_024 || forward.guestPort == 0 { reject("portForwards[\(index)].port") } let binding = "\(forward.transport.rawValue):\(forward.hostPort)" diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift index c656af4c..6ceacd7b 100644 --- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift +++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift @@ -224,7 +224,7 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable { } guard (1...63).contains(bytes.count), firstIsValid, restIsValid, identifiers.insert(forward.id).inserted, - forward.hostPort > 0, forward.guestPort > 0, + forward.hostPort >= 1_024, forward.guestPort > 0, bindings.insert("\(forward.transport.rawValue):\(forward.hostPort)").inserted, forward.exposure != .lan || networkAttachment == .sharedNAT else { return "The resolved port-forward contract is invalid or conflicting." diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index a095572b..5e81ec83 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -118,6 +118,13 @@ struct DoryVirtualMachineDefinitionTests { in: definition.validate() )) definition.portForwards[0].guestPort = 80 + definition.portForwards[0].hostPort = 443 + #expect(has( + .invalidPortForwardPort, + "portForwards[0].hostPort", + in: definition.validate() + )) + definition.portForwards[0].hostPort = 8_080 definition.networkMode = .isolated #expect(has( .lanPortForwardRequiresSharedNAT, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift index 9f4198ce..31bf0403 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift @@ -157,6 +157,10 @@ struct DoryResolvedMachinePlanTests { guestPort: 220 )) #expect(invalid.validate().contains { $0.code == .invalidPortForwardContract }) + + invalid = plan + invalid.portForwards[0].hostPort = 443 + #expect(invalid.validate().contains { $0.code == .invalidPortForwardContract }) } @Test("immutable media and qualification evidence mismatches are exact") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index ac5050a9..210348e3 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -329,19 +329,30 @@ final class DoryVMMKitTests: XCTestCase { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let encodedDevices = String(decoding: try encoder.encode(devices), as: UTF8.self) + let forwards = [DoryVMPortForward( + id: "web", + hostPort: 8_080, + guestPort: 80 + )] + let encodedForwards = String(decoding: try encoder.encode(forwards), as: UTF8.self) let arguments = try parseDoryVMMArguments([ "--resolved-graphics", "host-accelerated-display", "--resolved-devices", encodedDevices, + "--resolved-port-forwards", encodedForwards, ]) XCTAssertEqual(arguments.resolvedGraphics, .hostAcceleratedDisplay) XCTAssertEqual(arguments.resolvedDevices, devices) + XCTAssertEqual(arguments.resolvedPortForwards, forwards) XCTAssertThrowsError(try parseDoryVMMArguments([ "--resolved-graphics", "auto", ])) XCTAssertThrowsError(try parseDoryVMMArguments([ "--resolved-devices", "{}", ])) + XCTAssertThrowsError(try parseDoryVMMArguments([ + "--resolved-port-forwards", "{}", + ])) } func testRejectsInvalidDisplayModeArgument() throws { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 4e1c4121..c6ac2925 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -262,7 +262,10 @@ struct MachineManagerResolvedPlanIntegrationTests { while Date() < deadline { if let contents = try? String(contentsOfFile: capture, encoding: .utf8) { arguments = contents.split(separator: "\n").map(String.init) - if arguments.contains("--resolved-devices") { break } + if arguments.contains("--resolved-devices"), + arguments.contains("--resolved-port-forwards") { + break + } } Thread.sleep(forTimeInterval: 0.01) } @@ -278,6 +281,11 @@ struct MachineManagerResolvedPlanIntegrationTests { DoryVirtualMachineDeviceCapabilityRequest.self, from: deviceData ) == devices) + let portForwardData = Data(try value(after: "--resolved-port-forwards").utf8) + #expect(try JSONDecoder().decode( + [DoryVMPortForward].self, + from: portForwardData + ).isEmpty) #expect(arguments.contains("DORY_DESKTOP_GRAPHICS=virgl")) #expect(arguments.contains("DORY_DESKTOP_VMM=accelerated")) #expect(!arguments.contains("DORY_DESKTOP_GRAPHICS=auto")) From 9504f9faf248082914f3fda1da15b04faf00e7c7 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 05:17:55 +0000 Subject: [PATCH 252/338] feat(vm): reconcile resolved port forwards --- .../Sources/dory-hv/DesktopMode.swift | 23 +- .../ResolvedPortForwardReconciler.swift | 308 ++++++++++++++++++ .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 13 + .../ResolvedPortForwardReconcilerTests.swift | 125 +++++++ 4 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift create mode 100644 dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index e39ebbdc..ffb0ead5 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -557,6 +557,7 @@ enum DesktopMode { private let audio: DoryMacAudioBackend private let gvproxy: Process? private let networkSocketPaths: [String] + private let resolvedPortForwardReconciler: ResolvedPortForwardReconciler? private let agentBridge: GuestVsockSocketBridge private let shellBridge: GuestVsockSocketBridge private let sshAgentBridge: HostSSHAgentBridge? @@ -726,6 +727,7 @@ enum DesktopMode { ) self.gvproxy = networkRuntime.process self.networkSocketPaths = networkRuntime.socketPaths + self.resolvedPortForwardReconciler = networkRuntime.portForwardReconciler do { var backends: [VirtioDeviceBackend] = [ try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), @@ -1065,6 +1067,7 @@ enum DesktopMode { lifecycleReceiptServer.stop() usbControlServer?.stop() clipboard?.stop() + resolvedPortForwardReconciler?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() if let gvproxy { @@ -1205,6 +1208,7 @@ enum DesktopMode { let process: Process? let socketPaths: [String] let backend: (any VirtioDeviceBackend)? + let portForwardReconciler: ResolvedPortForwardReconciler? } private static func prepareNetwork( @@ -1242,11 +1246,17 @@ enum DesktopMode { backend: VirtioDisconnectedNet( macAddress: mac, maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit - ) + ), + portForwardReconciler: nil ) } guard plan.startsGVProxy, plan.attachesNetworkDevice else { - return NetworkRuntime(process: nil, socketPaths: [], backend: nil) + return NetworkRuntime( + process: nil, + socketPaths: [], + backend: nil, + portForwardReconciler: nil + ) } let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" @@ -1285,6 +1295,12 @@ enum DesktopMode { try process.run() try waitForSocket(path: gvproxySocket, process: process) try publishResolvedPortForwards(portForwards, apiSocket: apiSocket) + let reconciler = portForwards.isEmpty ? nil : ResolvedPortForwardReconciler( + desired: portForwards, + apiSocketPath: apiSocket, + log: Self.log + ) + reconciler?.start() return NetworkRuntime( process: process, socketPaths: socketPaths, @@ -1293,7 +1309,8 @@ enum DesktopMode { remotePath: gvproxySocket, macAddress: networkInterface?.macAddressOctets ?? VirtioNet.guestMAC, maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit - ) + ), + portForwardReconciler: reconciler ) } catch { ChildProcessTerminator.terminateAndReap(process) diff --git a/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift b/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift new file mode 100644 index 00000000..02fce63d --- /dev/null +++ b/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift @@ -0,0 +1,308 @@ +import Darwin +import Foundation + +/// A fail-closed view of gvproxy's TCP/UDP registry. Unix infrastructure forwards are ignored; +/// malformed IP-forward rows reject the entire observation so reconciliation never acts on a +/// partial registry. +public struct ResolvedPortForwardRegistry: Sendable { + private struct Entry: Sendable, Hashable { + var `protocol`: PublishedPortForwardProtocol + var local: Endpoint + var remote: Endpoint + + func matches(_ forward: PublishedPortForward) -> Bool { + `protocol` == forward.protocol + && local.matches(host: forward.localHost, port: forward.localPort) + && remote.matches(host: forward.guestHost, port: forward.guestPort) + } + + func occupiesLocalEndpoint(of forward: PublishedPortForward) -> Bool { + `protocol` == forward.protocol + && local.matches(host: forward.localHost, port: forward.localPort) + } + } + + private struct Endpoint: Sendable, Hashable { + var host: String + var port: Int + + init?(_ rawValue: String) { + let value: Substring + if let scheme = rawValue.range(of: "://") { + value = rawValue[scheme.upperBound...] + } else { + value = rawValue[...] + } + let host: Substring + let portText: Substring + if value.first == "[" { + guard let closingBracket = value.firstIndex(of: "]"), + value.index(after: closingBracket) < value.endIndex, + value[value.index(after: closingBracket)] == ":" else { + return nil + } + host = value[...closingBracket] + portText = value[value.index(closingBracket, offsetBy: 2)...] + } else { + guard let separator = value.lastIndex(of: ":") else { return nil } + host = value[.. Bool { + port == candidatePort && normalized(host) == normalized(candidate) + } + + private func normalized(_ value: String) -> String { + value.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + } + + private var isIPAddress: Bool { + let value = normalized(host) + var ipv4 = in_addr() + if value.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { return true } + var ipv6 = in6_addr() + return value.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 + } + } + + private struct DecodedEntry: Decodable { + var local: String + var remote: String + var `protocol`: String + } + + private var entries: Set + + public static func decode(_ data: Data) -> ResolvedPortForwardRegistry? { + guard let decoded = try? JSONDecoder().decode([DecodedEntry].self, from: data) else { + return nil + } + var entries: Set = [] + for entry in decoded { + guard let transport = PublishedPortForwardProtocol( + rawValue: entry.protocol.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + ) else { + // gvproxy stores Dory's Unix shutdown channel in the same registry. + continue + } + guard let local = Endpoint(entry.local), let remote = Endpoint(entry.remote) else { + return nil + } + entries.insert(Entry(protocol: transport, local: local, remote: remote)) + } + return ResolvedPortForwardRegistry(entries: entries) + } + + public func contains(_ forward: PublishedPortForward) -> Bool { + entries.contains { $0.matches(forward) } + } + + public func conflicts(with forward: PublishedPortForward) -> Bool { + entries.contains { + $0.occupiesLocalEndpoint(of: forward) && !$0.matches(forward) + } + } +} + +public struct ResolvedPortForwardReconciliation: Sendable, Equatable { + public var toUnexpose: Set + public var toExpose: Set + public var missing: Set + + public init( + desired: Set, + registry: ResolvedPortForwardRegistry + ) { + missing = Set(desired.filter { !registry.contains($0) }) + toUnexpose = Set(desired.filter { registry.conflicts(with: $0) }) + toExpose = missing.union(toUnexpose) + } +} + +/// Periodically proves and repairs only the exact forwards pinned by the resolved launch. It never +/// adopts or removes unrelated registry entries. A conflicting local key is released only because +/// the same resolved contract owns that protocol/address/port tuple. +public final class ResolvedPortForwardReconciler: @unchecked Sendable { + public typealias RegistryProvider = @Sendable () -> ResolvedPortForwardRegistry? + public typealias Mutation = @Sendable (PublishedPortForward) -> Bool + + private let desired: Set + private let registryProvider: RegistryProvider + private let exposeProvider: Mutation + private let unexposeProvider: Mutation + private let log: @Sendable (String) -> Void + private let queue = DispatchQueue(label: "dev.dory.resolved-port-forward-reconciler") + private let queueKey = DispatchSpecificKey() + private let timer: any DispatchSourceTimer + private let lock = NSLock() + private var activated = false + private var cancelled = false + private var lastHealthy = true + + public convenience init( + desired: Set, + apiSocketPath: String, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.init( + desired: desired, + registryProvider: { + guard let data = Self.curlData( + unixSocketPath: apiSocketPath, + URL: "http://gvproxy/services/forwarder/all" + ) else { return nil } + return ResolvedPortForwardRegistry.decode(data) + }, + exposeProvider: { forward in + Self.post( + forward, + operation: "expose", + apiSocketPath: apiSocketPath + ) + }, + unexposeProvider: { forward in + Self.post( + forward, + operation: "unexpose", + apiSocketPath: apiSocketPath + ) + }, + log: log + ) + } + + public init( + desired: Set, + registryProvider: @escaping RegistryProvider, + exposeProvider: @escaping Mutation, + unexposeProvider: @escaping Mutation, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.desired = desired + self.registryProvider = registryProvider + self.exposeProvider = exposeProvider + self.unexposeProvider = unexposeProvider + self.log = log + queue.setSpecific(key: queueKey, value: 1) + timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 2, repeating: 2) + timer.setEventHandler { [weak self] in + self?.reconcileAndReport() + } + } + + public func start() { + lock.lock() + defer { lock.unlock() } + guard !activated, !cancelled, !desired.isEmpty else { return } + activated = true + timer.resume() + } + + public func stop() { + lock.lock() + guard !cancelled else { + lock.unlock() + return + } + timer.setEventHandler {} + if !activated { + activated = true + timer.resume() + } + cancelled = true + timer.cancel() + lock.unlock() + if DispatchQueue.getSpecific(key: queueKey) == nil { queue.sync {} } + } + + deinit { + stop() + } + + @discardableResult + public func reconcileNow() -> Bool { + queue.sync { reconcile() } + } + + private func reconcileAndReport() { + let healthy = reconcile() + if healthy != lastHealthy { + lastHealthy = healthy + log(healthy + ? "resolved port forwards recovered" + : "resolved port-forward reconciliation is waiting to recover") + } + } + + private func reconcile() -> Bool { + guard !desired.isEmpty else { return true } + guard let registry = registryProvider() else { return false } + let plan = ResolvedPortForwardReconciliation(desired: desired, registry: registry) + for forward in plan.toUnexpose where !unexposeProvider(forward) { + return false + } + for forward in plan.toExpose where !exposeProvider(forward) { + return false + } + guard let verified = registryProvider() else { return false } + return desired.allSatisfy { verified.contains($0) && !verified.conflicts(with: $0) } + } + + private static func curlData(unixSocketPath: String, URL: String) -> Data? { + let process = Process() + let output = Pipe() + process.executableURL = Foundation.URL(fileURLWithPath: "/usr/bin/curl") + process.arguments = [ + "--fail", "--silent", "--connect-timeout", "1", "--max-time", "2", + "--unix-socket", unixSocketPath, + URL, + ] + process.standardOutput = output + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return process.terminationStatus == 0 ? data : nil + } + + private static func post( + _ forward: PublishedPortForward, + operation: String, + apiSocketPath: String + ) -> Bool { + var body = [ + "local": forward.localEndpoint, + "protocol": forward.protocol.rawValue, + ] + if operation == "expose" { body["remote"] = forward.remoteEndpoint } + guard let data = try? JSONSerialization.data(withJSONObject: body), + let bodyString = String(data: data, encoding: .utf8) else { + return false + } + let process = Process() + process.executableURL = Foundation.URL(fileURLWithPath: "/usr/bin/curl") + process.arguments = [ + "--fail", "--silent", "--connect-timeout", "1", "--max-time", "2", + "--unix-socket", apiSocketPath, + "--request", "POST", + "--data-binary", bodyString, + "http://gvproxy/services/forwarder/\(operation)", + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return false } + process.waitUntilExit() + return process.terminationStatus == 0 + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift index e349ab86..b5642785 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift @@ -61,6 +61,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { private let configurationPath: String private let lock = NSLock() private var stopped = false + private var resolvedPortForwardReconciler: ResolvedPortForwardReconciler? init( gvproxyPath: String, @@ -163,6 +164,17 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { for forward in resolvedPortForwards.sorted(by: Self.portForwardOrder) { try Self.publishIPForward(forward, apiSocketPath: apiSocketPath) } + if !resolvedPortForwards.isEmpty { + let reconciler = ResolvedPortForwardReconciler( + desired: resolvedPortForwards, + apiSocketPath: apiSocketPath, + log: { message in + FileHandle.standardError.write(Data("dory-vmm: \(message)\n".utf8)) + } + ) + resolvedPortForwardReconciler = reconciler + reconciler.start() + } } catch { if child.isRunning { child.terminate() @@ -186,6 +198,7 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { stopped = true lock.unlock() + resolvedPortForwardReconciler?.stop() try? fileHandle.close() if process.isRunning { process.terminate() diff --git a/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift b/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift new file mode 100644 index 00000000..38703447 --- /dev/null +++ b/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift @@ -0,0 +1,125 @@ +import Foundation +import XCTest +@testable import DoryCore + +final class ResolvedPortForwardReconcilerTests: XCTestCase { + func testRegistrySeparatesExactMissingAndConflictingForwards() throws { + let exact = forward(hostPort: 8_080, guestPort: 80) + let wanted = forward(hostPort: 8_443, guestPort: 443) + let registry = try XCTUnwrap(ResolvedPortForwardRegistry.decode(Data(#""" + [ + {"local":"127.0.0.1:8080","remote":"192.168.127.2:80","protocol":"tcp"}, + {"local":"127.0.0.1:8443","remote":"192.168.127.2:443","protocol":"tcp"}, + {"local":"127.0.0.1:8443","remote":"192.168.127.2:444","protocol":"tcp"}, + {"local":"/tmp/shutdown.sock","remote":"tcp://192.168.127.2:2377","protocol":"unix"} + ] + """#.utf8))) + + XCTAssertTrue(registry.contains(exact)) + XCTAssertTrue(registry.conflicts(with: wanted)) + let plan = ResolvedPortForwardReconciliation( + desired: [exact, wanted], + registry: registry + ) + XCTAssertTrue(plan.missing.isEmpty) + XCTAssertEqual(plan.toUnexpose, [wanted]) + XCTAssertEqual(plan.toExpose, [wanted]) + } + + func testMalformedTCPRowFailsTheWholeObservation() { + XCTAssertNil(ResolvedPortForwardRegistry.decode(Data(#""" + [ + {"local":"not-an-endpoint","remote":"192.168.127.2:80","protocol":"tcp"} + ] + """#.utf8))) + XCTAssertNil(ResolvedPortForwardRegistry.decode(Data("not-json".utf8))) + } + + func testReconcilerRepairsAndThenProvesTheExactRegistry() { + let desired = forward(hostPort: 8_080, guestPort: 80) + let state = RegistryState(entries: [ + forward(hostPort: 8_080, guestPort: 81), + ]) + let reconciler = ResolvedPortForwardReconciler( + desired: [desired], + registryProvider: { state.registry() }, + exposeProvider: { state.expose($0) }, + unexposeProvider: { state.unexpose($0) } + ) + + XCTAssertTrue(reconciler.reconcileNow()) + XCTAssertEqual(state.entries, [desired]) + XCTAssertEqual(state.unexposed, [desired]) + XCTAssertEqual(state.exposed, [desired]) + } + + func testUnavailableRegistryNeverMutatesHostListeners() { + let state = RegistryState(entries: []) + let reconciler = ResolvedPortForwardReconciler( + desired: [forward(hostPort: 8_080, guestPort: 80)], + registryProvider: { nil }, + exposeProvider: { state.expose($0) }, + unexposeProvider: { state.unexpose($0) } + ) + + XCTAssertFalse(reconciler.reconcileNow()) + XCTAssertTrue(state.exposed.isEmpty) + XCTAssertTrue(state.unexposed.isEmpty) + } + + private func forward(hostPort: Int, guestPort: Int) -> PublishedPortForward { + PublishedPortForward( + protocol: .tcp, + publishedPort: hostPort, + localHost: "127.0.0.1", + localPort: hostPort, + guestHost: "192.168.127.2", + guestPort: guestPort + ) + } +} + +private final class RegistryState: @unchecked Sendable { + private let lock = NSLock() + private(set) var entries: Set + private(set) var exposed: [PublishedPortForward] = [] + private(set) var unexposed: [PublishedPortForward] = [] + + init(entries: Set) { + self.entries = entries + } + + func registry() -> ResolvedPortForwardRegistry? { + lock.lock() + defer { lock.unlock() } + let rows = entries.map { forward in + [ + "local": forward.localEndpoint, + "remote": forward.remoteEndpoint, + "protocol": forward.protocol.rawValue, + ] + } + guard let data = try? JSONSerialization.data(withJSONObject: rows) else { return nil } + return ResolvedPortForwardRegistry.decode(data) + } + + func expose(_ forward: PublishedPortForward) -> Bool { + lock.lock() + defer { lock.unlock() } + exposed.append(forward) + entries.insert(forward) + return true + } + + func unexpose(_ forward: PublishedPortForward) -> Bool { + lock.lock() + defer { lock.unlock() } + unexposed.append(forward) + entries = Set(entries.filter { + $0.protocol != forward.protocol + || $0.localHost != forward.localHost + || $0.localPort != forward.localPort + }) + return true + } +} From be4a639d74ab5db4ba8ae67680a14c84d5cd770a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 05:38:45 +0000 Subject: [PATCH 253/338] feat(vm): expose resolved port forwarding controls --- .../Machines/MachinePortForwardEditor.swift | 164 ++++++++++++++ Dory/Features/Machines/MachinesView.swift | 18 +- Dory/Features/Sheets/NewMachineSheet.swift | 27 ++- Dory/Models/AppStore.swift | 2 +- Dory/Runtime/Doryd/DorydClient.swift | 91 +++++++- DoryTests/DorydClientTests.swift | 47 +++- DoryTests/NewMachineSettingsTests.swift | 41 +++- .../DoryMachineTypedWriteAuthority.swift | 202 +++++++++++++++++- .../Sources/DorydKit/MachineManager.swift | 1 + dory-core-swift/Sources/dorydctl/main.swift | 4 +- .../DoryMachineTypedWriteAuthorityTests.swift | 92 ++++++++ ...eManagerResolvedPlanIntegrationTests.swift | 11 +- 12 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 Dory/Features/Machines/MachinePortForwardEditor.swift diff --git a/Dory/Features/Machines/MachinePortForwardEditor.swift b/Dory/Features/Machines/MachinePortForwardEditor.swift new file mode 100644 index 00000000..5eb8f9c0 --- /dev/null +++ b/Dory/Features/Machines/MachinePortForwardEditor.swift @@ -0,0 +1,164 @@ +import DoryOperations +import SwiftUI + +struct MachinePortForwardDraft: Identifiable, Hashable { + let id: UUID + var name: String + var transport: DoryVMPortForwardTransport + var hostPort: String + var guestPort: String + var exposure: DoryVMPortForwardExposure + + init( + id: UUID = UUID(), + name: String = "", + transport: DoryVMPortForwardTransport = .tcp, + hostPort: String = "", + guestPort: String = "", + exposure: DoryVMPortForwardExposure = .loopback + ) { + self.id = id + self.name = name + self.transport = transport + self.hostPort = hostPort + self.guestPort = guestPort + self.exposure = exposure + } + + init(_ forward: DoryVMPortForward) { + self.init( + name: forward.id, + transport: forward.transport, + hostPort: String(forward.hostPort), + guestPort: String(forward.guestPort), + exposure: forward.exposure + ) + } + + var resolved: DoryVMPortForward? { + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard Self.isSafeIdentifier(normalizedName), + let host = UInt16(hostPort), host >= 1_024, + let guest = UInt16(guestPort), guest > 0 else { + return nil + } + return DoryVMPortForward( + id: normalizedName, + transport: transport, + hostPort: host, + guestPort: guest, + exposure: exposure + ) + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + func alphaNumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + guard (1...63).contains(bytes.count), alphaNumeric(bytes[0]) else { return false } + return bytes.dropFirst().allSatisfy { + alphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + } + + static func resolved( + _ rows: [Self], + networkMode: DoryVMNetworkMode + ) -> [DoryVMPortForward]? { + guard rows.count <= DoryVMPortForward.maximumCount else { return nil } + let forwards = rows.compactMap(\.resolved) + guard forwards.count == rows.count, + rows.isEmpty || networkMode == .sharedNAT || networkMode == .isolated else { + return nil + } + var identifiers: Set = [] + var bindings: Set = [] + for forward in forwards { + guard identifiers.insert(forward.id).inserted, + bindings.insert("\(forward.transport.rawValue):\(forward.hostPort)").inserted, + forward.exposure != .lan || networkMode == .sharedNAT else { + return nil + } + } + return forwards + } +} + +struct MachinePortForwardEditor: View { + @Environment(\.palette) private var p + @Binding var rows: [MachinePortForwardDraft] + let networkMode: DoryVMNetworkMode + let accessibilityPrefix: String + + private var isValid: Bool { + MachinePortForwardDraft.resolved(rows, networkMode: networkMode) != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("PORT FORWARDS") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(p.text3) + .tracking(0.5) + Spacer(minLength: 0) + Button { + guard rows.count < DoryVMPortForward.maximumCount else { return } + rows.append(MachinePortForwardDraft()) + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(p.accent) + .frame(width: 22, height: 22) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("\(accessibilityPrefix)-add-forward") + } + + ForEach($rows) { $row in + HStack(spacing: 7) { + TextField("name", text: $row.name) + .frame(width: 86) + Picker("Transport", selection: $row.transport) { + Text("TCP").tag(DoryVMPortForwardTransport.tcp) + Text("UDP").tag(DoryVMPortForwardTransport.udp) + } + .labelsHidden() + .frame(width: 68) + TextField("host", text: $row.hostPort) + .frame(width: 58) + Image(systemName: "arrow.right") + .font(.system(size: 9)) + .foregroundStyle(p.text3) + TextField("guest", text: $row.guestPort) + .frame(width: 58) + Picker("Exposure", selection: $row.exposure) { + Text("This Mac").tag(DoryVMPortForwardExposure.loopback) + Text("LAN").tag(DoryVMPortForwardExposure.lan) + } + .labelsHidden() + .frame(width: 92) + Button { + rows.removeAll { $0.id == row.id } + } label: { + Image(systemName: "minus.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(p.text3) + } + .buttonStyle(.plain) + } + .textFieldStyle(.roundedBorder) + } + + Text(rows.isEmpty + ? "Expose selected guest services only when you add them." + : isValid + ? "Host ports 1024–65535 are installed exactly before the VM is ready." + : "Use unique names and transport/host-port pairs. LAN requires Shared NAT; host ports start at 1024.") + .font(.system(size: 11)) + .foregroundStyle(isValid ? p.text3 : p.red) + } + } +} diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 83b34886..ed3e07c0 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1305,6 +1305,7 @@ private struct MachineEditSheet: View { @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var portForwardRows: [MachinePortForwardDraft] = [] @State private var audioInputEnabled = true @State private var audioOutputEnabled = true @State private var originalAudioConfiguration: DoryVMAudioConfiguration? @@ -1329,6 +1330,11 @@ private struct MachineEditSheet: View { warning machineTypeBlock networkBlock + MachinePortForwardEditor( + rows: $portForwardRows, + networkMode: networkMode, + accessibilityPrefix: "edit-machine" + ) audioBlock runtimeBlock clipboardBlock @@ -1369,6 +1375,7 @@ private struct MachineEditSheet: View { runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic networkMode = typedSettings.networkMode ?? .sharedNAT + portForwardRows = typedSettings.portForwards.map(MachinePortForwardDraft.init) originalAudioConfiguration = typedSettings.audioConfiguration audioInputEnabled = typedSettings.audioConfiguration?.inputEnabled ?? true audioOutputEnabled = typedSettings.audioConfiguration?.outputEnabled ?? true @@ -1626,7 +1633,11 @@ private struct MachineEditSheet: View { .background(p.accent.opacity(store.isMachineBusy(machine.name) ? 0.5 : 1), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .disabled(store.isMachineBusy(machine.name) || guestUsernameInvalid) + .disabled( + store.isMachineBusy(machine.name) + || guestUsernameInvalid + || resolvedPortForwards == nil + ) } .padding(.horizontal, 18).padding(.vertical, 13) } @@ -1703,6 +1714,7 @@ private struct MachineEditSheet: View { ) } typedSettings.networkMode = networkMode + typedSettings.portForwards = resolvedPortForwards ?? [] if displayMode == .desktop { typedSettings.audioConfiguration = MachineAudioSettingsPolicy.editedConfiguration( existing: originalAudioConfiguration, @@ -1763,6 +1775,10 @@ private struct MachineEditSheet: View { guestUsername.trimmingCharacters(in: .whitespacesAndNewlines) } + private var resolvedPortForwards: [DoryVMPortForward]? { + MachinePortForwardDraft.resolved(portForwardRows, networkMode: networkMode) + } + private var audioPolicyEditable: Bool { machine.runtimeIdentity.mode != "legacy-compatibility" } diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 7ce37d6e..d34def58 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -18,6 +18,7 @@ struct NewMachineSheet: View { @State private var installerISOCheck: InstallerISOCheck = .none @State private var diskSizeGB = 64 @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var portForwardRows: [MachinePortForwardDraft] = [] @State private var audioInputEnabled = true @State private var audioOutputEnabled = true @@ -92,6 +93,11 @@ struct NewMachineSheet: View { devEnvironmentSection identitySection networkBlock + MachinePortForwardEditor( + rows: $portForwardRows, + networkMode: networkMode, + accessibilityPrefix: "new-machine" + ) audioBlock optionsRow advancedSection @@ -201,6 +207,13 @@ struct NewMachineSheet: View { cpus = useCase.cpus memoryGB = useCase.memoryGB activeUseCaseID = useCase.id + portForwardRows = useCase.recipe?.ports.map { + MachinePortForwardDraft( + name: "port-\($0)", + hostPort: String($0), + guestPort: String($0) + ) + } ?? [] stage = .form } @@ -808,6 +821,7 @@ struct NewMachineSheet: View { || store.machineBusy || !engineReady || mountsOutsideHome + || resolvedPortForwards == nil } private var mountsOutsideHome: Bool { @@ -920,6 +934,7 @@ struct NewMachineSheet: View { guestUsername: String = "dory", guestUID: uid_t = getuid(), networkMode: DoryVMNetworkMode = .sharedNAT, + portForwards: [DoryVMPortForward] = [], audioInputEnabled: Bool = true, audioOutputEnabled: Bool = true ) -> MachineSettings { @@ -942,13 +957,17 @@ struct NewMachineSheet: View { runtimePreference: .automatic, graphicsPreference: .automatic, networkMode: networkMode, + portForwards: portForwards, audioConfiguration: DoryVMAudioConfiguration( inputEnabled: audioInputEnabled, outputEnabled: audioOutputEnabled ) ) } else { - typedSettings = DorydMachineTypedSettings(networkMode: networkMode) + typedSettings = DorydMachineTypedSettings( + networkMode: networkMode, + portForwards: portForwards + ) } return MachineSettings( cpus: cpus, @@ -994,6 +1013,7 @@ struct NewMachineSheet: View { desktopDistro: desktopDistro, guestUsername: normalizedGuestUsername, networkMode: networkMode, + portForwards: resolvedPortForwards ?? [], audioInputEnabled: audioInputEnabled, audioOutputEnabled: audioOutputEnabled ) @@ -1006,6 +1026,7 @@ struct NewMachineSheet: View { settings.env = [:] settings.virtualMachineSettings = DorydMachineTypedSettings( networkMode: networkMode, + portForwards: resolvedPortForwards ?? [], audioConfiguration: DoryVMAudioConfiguration( inputEnabled: audioInputEnabled, outputEnabled: audioOutputEnabled @@ -1015,6 +1036,10 @@ struct NewMachineSheet: View { return settings } + private var resolvedPortForwards: [DoryVMPortForward]? { + MachinePortForwardDraft.resolved(portForwardRows, networkMode: networkMode) + } + static func defaultName() -> String { "dory-\(AppStore.generatedMachineToken())" } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 32fb6bd4..7b508139 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -6677,7 +6677,7 @@ final class AppStore { mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [], env: current?.environment ?? [:], virtualMachineSettings: current.map { - DorydMachineTypedSettings( + $0.typedSettings ?? DorydMachineTypedSettings( legacyEnvironment: $0.environment, displayMode: $0.displayMode ) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index ae05bbd9..a069c94d 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -123,6 +123,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { var runtimePreference: DoryDesktopVMMPreference? = nil var graphicsPreference: DoryDesktopGraphicsPreference? = nil var networkMode: DoryVMNetworkMode? = nil + var portForwards: [DoryVMPortForward] = [] var audioConfiguration: DoryVMAudioConfiguration? = nil init( @@ -131,6 +132,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { runtimePreference: DoryDesktopVMMPreference? = nil, graphicsPreference: DoryDesktopGraphicsPreference? = nil, networkMode: DoryVMNetworkMode? = nil, + portForwards: [DoryVMPortForward] = [], audioConfiguration: DoryVMAudioConfiguration? = nil ) { self.guestIdentityIntent = guestIdentityIntent @@ -138,6 +140,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference self.networkMode = networkMode + self.portForwards = portForwards self.audioConfiguration = audioConfiguration } @@ -179,6 +182,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { desktop: desktop ) networkMode = .sharedNAT + portForwards = [] if displayMode == .desktop { let effectiveClipboard = DoryDesktopClipboardPolicy( environment: legacyEnvironment @@ -210,6 +214,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { && runtimePreference == nil && graphicsPreference == nil && networkMode == nil + && portForwards.isEmpty && audioConfiguration == nil } @@ -253,6 +258,9 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { if let networkMode { result["networkMode"] = networkMode.rawValue } + if !portForwards.isEmpty { + result["portForwards"] = Self.xpcPortForwards(portForwards) + } if let audioConfiguration { result["audio"] = [ "inputEnabled": audioConfiguration.inputEnabled, @@ -275,9 +283,22 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) hasher.combine(networkMode?.rawValue) + hasher.combine(portForwards) hasher.combine(audioConfiguration?.inputEnabled) hasher.combine(audioConfiguration?.outputEnabled) } + + fileprivate static func xpcPortForwards(_ forwards: [DoryVMPortForward]) -> NSArray { + forwards.map { forward in + [ + "id": forward.id, + "transport": forward.transport.rawValue, + "hostPort": NSNumber(value: forward.hostPort), + "guestPort": NSNumber(value: forward.guestPort), + "exposure": forward.exposure.rawValue, + ] as NSDictionary + } as NSArray + } } /// Leaf-level update authority. Comparing a safely migrated baseline to the desired typed state @@ -358,6 +379,11 @@ nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { key: "networkMode", into: &result ) + if baseline.portForwards != desired.portForwards { + result["portForwards"] = DorydMachineTypedSettings.xpcPortForwards( + desired.portForwards + ) + } Self.encodeAudio( baseline.audioConfiguration, desired.audioConfiguration, @@ -3428,7 +3454,8 @@ nonisolated final class DorydClient: @unchecked Sendable { let keys = value.allKeys as? [String], Set(keys).isSubset(of: [ "guestIdentityIntent", "clipboardPolicy", - "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", "audio", + "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", + "portForwards", "audio", ]), keys.count == Set(keys).count else { return nil } @@ -3535,6 +3562,46 @@ nonisolated final class DorydClient: @unchecked Sendable { let parsed = DoryVMNetworkMode(rawValue: raw) else { return nil } networkMode = parsed } else { networkMode = nil } + let portForwards: [DoryVMPortForward] + if let encoded = value["portForwards"] { + guard let rows = encoded as? NSArray, + rows.count <= DoryVMPortForward.maximumCount else { return nil } + var parsed: [DoryVMPortForward] = [] + var identifiers: Set = [] + var bindings: Set = [] + for rawRow in rows { + guard let row = rawRow as? NSDictionary, + let rowKeys = row.allKeys as? [String], + Set(rowKeys) == ["id", "transport", "hostPort", "guestPort", "exposure"], + rowKeys.count == 5, + let id = row["id"] as? String, + isSafePortForwardIdentifier(id), + identifiers.insert(id).inserted, + let transportRaw = row["transport"] as? String, + let transport = DoryVMPortForwardTransport(rawValue: transportRaw), + let hostPort = machinePort(row["hostPort"]), hostPort >= 1_024, + let guestPort = machinePort(row["guestPort"]), guestPort > 0, + let exposureRaw = row["exposure"] as? String, + let exposure = DoryVMPortForwardExposure(rawValue: exposureRaw), + bindings.insert("\(transport.rawValue):\(hostPort)").inserted else { + return nil + } + parsed.append(DoryVMPortForward( + id: id, + transport: transport, + hostPort: hostPort, + guestPort: guestPort, + exposure: exposure + )) + } + portForwards = parsed + } else { portForwards = [] } + if !portForwards.isEmpty { + guard networkMode == .sharedNAT || networkMode == .isolated, + !portForwards.contains(where: { + $0.exposure == .lan && networkMode != .sharedNAT + }) else { return nil } + } let audioConfiguration: DoryVMAudioConfiguration? if let encoded = value["audio"] { guard let raw = encoded as? NSDictionary, @@ -3556,10 +3623,32 @@ nonisolated final class DorydClient: @unchecked Sendable { runtimePreference: runtime, graphicsPreference: graphics, networkMode: networkMode, + portForwards: portForwards, audioConfiguration: audioConfiguration )) } + nonisolated private static func machinePort(_ raw: Any?) -> UInt16? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.rounded(.towardZero) == number.doubleValue, + (1...Double(UInt16.max)).contains(number.doubleValue) else { + return nil + } + return UInt16(number.uint64Value) + } + + nonisolated private static func isSafePortForwardIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + func alphaNumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + guard (1...63).contains(bytes.count), alphaNumeric(bytes[0]) else { return false } + return bytes.dropFirst().allSatisfy { + alphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + } + private struct ParsedInstalledDesktopPayloadReceipt { var value: DorydInstalledDesktopPayloadReceipt? } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 1226dd18..7f998657 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -475,6 +475,7 @@ struct DorydClientTests { #expect(defaults.runtimePreference == .automatic) #expect(defaults.graphicsPreference == .automatic) #expect(defaults.networkMode == .sharedNAT) + #expect(defaults.portForwards.isEmpty) var disconnected = defaults disconnected.networkMode = .disconnected @@ -485,6 +486,18 @@ struct DorydClientTests { #expect(networkWire.count == 1) #expect(networkWire["networkMode"] as? String == "disconnected") + var forwarded = defaults + forwarded.portForwards = [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ] + let forwardWire = DorydMachineTypedSettingsPatch( + baseline: defaults, + desired: forwarded + ).xpcDictionary + let forwards = try #require(forwardWire["portForwards"] as? NSArray) + #expect(forwards.count == 1) + #expect((forwards[0] as? NSDictionary)?["id"] as? String == "web") + #expect(defaults.audioConfiguration == DoryVMAudioConfiguration( inputEnabled: true, outputEnabled: true @@ -674,7 +687,14 @@ struct DorydClientTests { ] as NSDictionary, "desktopRuntimePreference": "accelerated", "desktopGraphicsPreference": "virgl-venus", - "networkMode": "disconnected", + "networkMode": "shared-nat", + "portForwards": [[ + "id": "web", + "transport": "tcp", + "hostPort": 8_080, + "guestPort": 80, + "exposure": "loopback", + ] as NSDictionary] as NSArray, "audio": [ "inputEnabled": false, "outputEnabled": true, @@ -693,7 +713,10 @@ struct DorydClientTests { == "ubuntu") #expect(status.typedSettings?.runtimePreference == .accelerated) #expect(status.typedSettings?.graphicsPreference == .virglVenus) - #expect(status.typedSettings?.networkMode == .disconnected) + #expect(status.typedSettings?.networkMode == .sharedNAT) + #expect(status.typedSettings?.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) #expect(status.typedSettings?.audioConfiguration == DoryVMAudioConfiguration( inputEnabled: false, outputEnabled: true @@ -707,6 +730,17 @@ struct DorydClientTests { for malformed: NSDictionary in [ ["audio": ["inputEnabled": 0, "outputEnabled": true]], ["audio": ["inputEnabled": true]], + ["portForwards": [[ + "id": "web", "transport": "tcp", "hostPort": 443, + "guestPort": 80, "exposure": "loopback", + ]]], + [ + "networkMode": "disconnected", + "portForwards": [[ + "id": "web", "transport": "tcp", "hostPort": 8080, + "guestPort": 80, "exposure": "loopback", + ]], + ], [ "audio": [ "inputEnabled": true, @@ -1366,7 +1400,10 @@ struct DorydClientTests { clipboardPolicy: .legacyDesktop(.bidirectional), runtimePreference: .accelerated, graphicsPreference: .virglVenus, - networkMode: .disconnected + networkMode: .sharedNAT, + portForwards: [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ] ) )) let startOperationID = UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd")! @@ -1525,6 +1562,10 @@ struct DorydClientTests { service.latestMachineCreateConfig?["shares"] as? [NSDictionary] ) #expect(createShares.first?["authorizationBookmark"] as? Data == shareBookmark) + let createForwards = try #require( + service.latestMachineCreateConfig?["portForwards"] as? NSArray + ) + #expect((createForwards.firstObject as? NSDictionary)?["hostPort"] as? Int == 8_080) #expect(createdMachine.state == "created") #expect(createdMachine.displayMode == .desktop) #expect(startedMachine.pid == 1234) diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index a2f6cba7..1e3be266 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -4,6 +4,39 @@ import Testing @testable import Dory struct NewMachineSettingsTests { + @Test func portForwardDraftsRequireExactConflictFreeBindings() throws { + let rows = [ + MachinePortForwardDraft( + name: "web", + hostPort: "8080", + guestPort: "80" + ), + MachinePortForwardDraft( + name: "dns", + transport: .udp, + hostPort: "5353", + guestPort: "53", + exposure: .lan + ), + ] + let resolved = try #require( + MachinePortForwardDraft.resolved(rows, networkMode: .sharedNAT) + ) + #expect(resolved.map(\.id) == ["web", "dns"]) + #expect(resolved[1].transport == .udp) + #expect(MachinePortForwardDraft.resolved(rows, networkMode: .isolated) == nil) + + var duplicate = rows + duplicate[1].transport = .tcp + duplicate[1].hostPort = "8080" + duplicate[1].exposure = .loopback + #expect(MachinePortForwardDraft.resolved(duplicate, networkMode: .sharedNAT) == nil) + + var privileged = rows + privileged[0].hostPort = "443" + #expect(MachinePortForwardDraft.resolved(privileged, networkMode: .sharedNAT) == nil) + } + @Test func desktopDefaultsScaleForBrowserWorkloadsWithoutConsumingTheHost() { let eightGB = NewMachineSheet.recommendedDesktopResources( activeProcessorCount: 8, @@ -35,7 +68,10 @@ struct NewMachineSettingsTests { @Test func collectsResourcesRegardlessOfDisclosure() { let s = NewMachineSheet.buildSettings(cpus: 4, memoryGB: 8, mounts: [MountPair(host: "/Users/u/p", guest: "/Users/u/p")], - address: "192.168.215.40") + address: "192.168.215.40", + portForwards: [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) #expect(s.cpus == 4) #expect(s.memoryMB == 8 * 1024) #expect(s.mounts.count == 1) @@ -50,6 +86,9 @@ struct NewMachineSettingsTests { #expect(s.virtualMachineSettings?.runtimePreference == .automatic) #expect(s.virtualMachineSettings?.graphicsPreference == .automatic) #expect(s.virtualMachineSettings?.networkMode == .sharedNAT) + #expect(s.virtualMachineSettings?.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) #expect(s.virtualMachineSettings?.audioConfiguration == DoryVMAudioConfiguration( inputEnabled: true, outputEnabled: true diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index 6462f06d..b7e0ce72 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -39,6 +39,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha public var runtimePreference: DoryDesktopVMMPreference? public var graphicsPreference: DoryDesktopGraphicsPreference? public var networkMode: DoryVMNetworkMode + public var portForwards: [DoryVMPortForward] public var audioConfiguration: DoryVMAudioConfiguration? private enum CodingKeys: String, CodingKey { @@ -47,6 +48,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha case runtimePreference case graphicsPreference case networkMode + case portForwards case audioConfiguration } @@ -72,6 +74,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha DoryVMNetworkMode.self, forKey: .networkMode ) ?? .sharedNAT + portForwards = try container.decodeIfPresent( + [DoryVMPortForward].self, + forKey: .portForwards + ) ?? [] audioConfiguration = try container.decodeIfPresent( DoryVMAudioConfiguration.self, forKey: .audioConfiguration @@ -85,12 +91,14 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha try container.encodeIfPresent(runtimePreference, forKey: .runtimePreference) try container.encodeIfPresent(graphicsPreference, forKey: .graphicsPreference) try container.encode(networkMode, forKey: .networkMode) + try container.encode(portForwards, forKey: .portForwards) try container.encodeIfPresent(audioConfiguration, forKey: .audioConfiguration) } public init(definition: DoryVirtualMachineDefinition) throws { guestIdentityIntent = definition.guestIdentityIntent networkMode = definition.networkMode + portForwards = definition.portForwards guard definition.display.enabled else { clipboardPolicy = nil runtimePreference = nil @@ -180,6 +188,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha desktop: desktop ) networkMode = .sharedNAT + portForwards = [] if displayMode == .desktop { let clipboard = DoryDesktopClipboardPolicy(environment: legacyEnvironment) clipboardPolicy = DoryVMClipboardDirection(rawValue: clipboard.rawValue) @@ -221,6 +230,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha runtimePreference: update(runtimePreference), graphicsPreference: update(graphicsPreference), networkMode: .set(networkMode), + portForwards: .set(portForwards), audioInputEnabled: update(audioConfiguration?.inputEnabled), audioOutputEnabled: update(audioConfiguration?.outputEnabled) ).xpcDictionary @@ -252,6 +262,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha runtimePreference: update(runtimePreference), graphicsPreference: update(graphicsPreference), networkMode: .set(networkMode), + portForwards: .set(portForwards), audioInputEnabled: update(audioConfiguration?.inputEnabled), audioOutputEnabled: update(audioConfiguration?.outputEnabled) ) @@ -270,6 +281,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha hasher.combine(runtimePreference?.rawValue) hasher.combine(graphicsPreference?.rawValue) hasher.combine(networkMode.rawValue) + hasher.combine(portForwards) hasher.combine(audioConfiguration?.inputEnabled) hasher.combine(audioConfiguration?.outputEnabled) } @@ -303,6 +315,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { public var runtimePreference: DoryMachineTypedSettingUpdate public var graphicsPreference: DoryMachineTypedSettingUpdate public var networkMode: DoryMachineTypedSettingUpdate + public var portForwards: DoryMachineTypedSettingUpdate<[DoryVMPortForward]> public var audioInputEnabled: DoryMachineTypedSettingUpdate public var audioOutputEnabled: DoryMachineTypedSettingUpdate @@ -317,6 +330,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { runtimePreference: DoryMachineTypedSettingUpdate = .unchanged, graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged, networkMode: DoryMachineTypedSettingUpdate = .unchanged, + portForwards: DoryMachineTypedSettingUpdate<[DoryVMPortForward]> = .unchanged, audioInputEnabled: DoryMachineTypedSettingUpdate = .unchanged, audioOutputEnabled: DoryMachineTypedSettingUpdate = .unchanged ) { @@ -330,6 +344,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { self.runtimePreference = runtimePreference self.graphicsPreference = graphicsPreference self.networkMode = networkMode + self.portForwards = portForwards self.audioInputEnabled = audioInputEnabled self.audioOutputEnabled = audioOutputEnabled } @@ -345,6 +360,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { && !runtimePreference.isChanged && !graphicsPreference.isChanged && !networkMode.isChanged + && !portForwards.isChanged && !audioInputEnabled.isChanged && !audioOutputEnabled.isChanged } @@ -416,6 +432,10 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.networkMode = .set(mode) } + let rawForwards = try takeOptions("--forward", from: &arguments) + if !rawForwards.isEmpty { + patch.portForwards = .set(try rawForwards.map(parseCLIForward)) + } if let raw = try takeOption("--audio-input", from: &arguments) { patch.audioInputEnabled = .set(try cliBoolean(raw, option: "--audio-input")) } @@ -429,9 +449,11 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { let clearsRuntime = takeFlag("--clear-runtime", from: &arguments) let clearsGraphics = takeFlag("--clear-graphics", from: &arguments) let clearsNetwork = takeFlag("--clear-network", from: &arguments) + let clearsPortForwards = takeFlag("--clear-forwards", from: &arguments) let clearsAudio = takeFlag("--clear-audio", from: &arguments) guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard - && !clearsRuntime && !clearsGraphics && !clearsNetwork && !clearsAudio) else { + && !clearsRuntime && !clearsGraphics && !clearsNetwork + && !clearsPortForwards && !clearsAudio) else { throw DoryMachineTypedWriteAuthorityError.invalidField("clear options") } if clearsAccount { @@ -481,6 +503,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } patch.networkMode = .clear } + if clearsPortForwards { + guard !patch.portForwards.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-forwards") + } + patch.portForwards = .clear + } if clearsAudio { guard !patch.audioInputEnabled.isChanged, !patch.audioOutputEnabled.isChanged else { @@ -526,6 +554,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { allowsClears: allowsClears, type: DoryVMNetworkMode.self ) + if let rawPortForwards = dictionary["portForwards"] { + portForwards = try Self.decodePortForwards( + rawPortForwards, + allowsClears: allowsClears + ) + } if let rawAudio = dictionary["audio"] { try decodeAudio(rawAudio, allowsClears: allowsClears) } @@ -575,6 +609,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { into: &result ) Self.encodeEnum(networkMode, key: "networkMode", into: &result) + switch portForwards { + case .unchanged: + break + case .clear: + result["portForwards"] = NSNull() + case let .set(forwards): + result["portForwards"] = Self.xpcPortForwards(forwards) + } if audioInputEnabled.isChanged || audioOutputEnabled.isChanged { if case .clear = audioInputEnabled, case .clear = audioOutputEnabled { @@ -656,6 +698,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { "networkMode" ) } + switch portForwards { + case .unchanged, .clear, .set([]): + break + case .set: + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "portForwards" + ) + } guard !audioInputEnabled.isChanged, !audioOutputEnabled.isChanged else { throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime("audio") } @@ -739,6 +789,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { case let .set(mode): definition.networkMode = mode } + switch portForwards { + case .unchanged: + break + case .clear: + definition.portForwards = [] + case let .set(forwards): + definition.portForwards = forwards + } if audioInputEnabled.isChanged || audioOutputEnabled.isChanged { var audio = definition.audio let defaults = displayMode == .desktop @@ -923,6 +981,70 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { ) } + private static func decodePortForwards( + _ raw: Any, + allowsClears: Bool + ) throws -> DoryMachineTypedSettingUpdate<[DoryVMPortForward]> { + if raw is NSNull { + guard allowsClears else { + throw DoryMachineTypedWriteAuthorityError.invalidField("portForwards") + } + return .clear + } + let values: [Any] + if let array = raw as? [Any] { + values = array + } else if let array = raw as? NSArray { + values = array.map { $0 } + } else { + throw DoryMachineTypedWriteAuthorityError.invalidField("portForwards") + } + guard values.count <= DoryVMPortForward.maximumCount else { + throw DoryMachineTypedWriteAuthorityError.invalidField("portForwards") + } + var forwards: [DoryVMPortForward] = [] + for (index, rawValue) in values.enumerated() { + let field = "portForwards[\(index)]" + guard let value = dictionary(rawValue), + hasOnlyKeys( + value, + allowed: ["id", "transport", "hostPort", "guestPort", "exposure"] + ), + value.count == 5, + let id = value["id"] as? String, + isSafeForwardIdentifier(id), + let transportRaw = value["transport"] as? String, + let transport = DoryVMPortForwardTransport(rawValue: transportRaw), + let hostPort = port(value["hostPort"]), hostPort >= 1_024, + let guestPort = port(value["guestPort"]), guestPort > 0, + let exposureRaw = value["exposure"] as? String, + let exposure = DoryVMPortForwardExposure(rawValue: exposureRaw) else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + forwards.append(DoryVMPortForward( + id: id, + transport: transport, + hostPort: hostPort, + guestPort: guestPort, + exposure: exposure + )) + } + try validatePortForwards(forwards) + return .set(forwards) + } + + private static func xpcPortForwards(_ forwards: [DoryVMPortForward]) -> NSArray { + forwards.map { forward in + [ + "id": forward.id, + "transport": forward.transport.rawValue, + "hostPort": NSNumber(value: forward.hostPort), + "guestPort": NSNumber(value: forward.guestPort), + "exposure": forward.exposure.rawValue, + ] as NSDictionary + } as NSArray + } + private func validate(displayMode: DoryMachineDisplayMode) throws { if case let .set(value) = guestUsername, !DoryVMGuestAccountIntent.isValidUsername(value) { @@ -987,6 +1109,54 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { displayMode != .desktop { throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay("audio") } + if case let .set(forwards) = portForwards { + try Self.validatePortForwards(forwards) + } + } + + private static func validatePortForwards(_ forwards: [DoryVMPortForward]) throws { + guard forwards.count <= DoryVMPortForward.maximumCount else { + throw DoryMachineTypedWriteAuthorityError.invalidField("portForwards") + } + var identifiers: Set = [] + var bindings: Set = [] + for (index, forward) in forwards.enumerated() { + let field = "portForwards[\(index)]" + guard isSafeForwardIdentifier(forward.id), forward.hostPort >= 1_024, + forward.guestPort > 0 else { + throw DoryMachineTypedWriteAuthorityError.invalidField(field) + } + guard identifiers.insert(forward.id).inserted else { + throw DoryMachineTypedWriteAuthorityError.invalidField("\(field).id") + } + let binding = "\(forward.transport.rawValue):\(forward.hostPort)" + guard bindings.insert(binding).inserted else { + throw DoryMachineTypedWriteAuthorityError.invalidField("\(field).hostPort") + } + } + } + + private static func isSafeForwardIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...63).contains(bytes.count), isASCIIAlphaNumeric(bytes[0]) else { + return false + } + return bytes.dropFirst().allSatisfy { + isASCIIAlphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + + private static func port(_ raw: Any?) -> UInt16? { + guard !(raw is Bool), let number = raw as? NSNumber, + number.doubleValue.rounded(.towardZero) == number.doubleValue, + (1...Double(UInt16.max)).contains(number.doubleValue) else { + return nil + } + return UInt16(number.uint64Value) } private static func dictionary(_ raw: Any) -> NSDictionary? { @@ -1147,6 +1317,36 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { return value } + private static func takeOptions( + _ name: String, + from arguments: inout [String] + ) throws -> [String] { + var values: [String] = [] + while let value = try takeOption(name, from: &arguments) { + values.append(value) + } + return values + } + + private static func parseCLIForward(_ raw: String) throws -> DoryVMPortForward { + let components = raw.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard components.count == 5, + isSafeForwardIdentifier(components[0]), + let transport = DoryVMPortForwardTransport(rawValue: components[1]), + let hostPort = UInt16(components[2]), hostPort >= 1_024, + let guestPort = UInt16(components[3]), guestPort > 0, + let exposure = DoryVMPortForwardExposure(rawValue: components[4]) else { + throw DoryMachineTypedWriteAuthorityError.invalidField("--forward") + } + return DoryVMPortForward( + id: components[0], + transport: transport, + hostPort: hostPort, + guestPort: guestPort, + exposure: exposure + ) + } + private static func cliBoolean(_ raw: String, option: String) throws -> Bool { switch raw { case "on": true diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 07600a11..ac11daab 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -8879,6 +8879,7 @@ public final class MachineManager: @unchecked Sendable { expected.clipboardPolicy = definition.clipboardPolicy expected.sandboxPolicy = definition.sandboxPolicy expected.networkMode = definition.networkMode + expected.portForwards = definition.portForwards return expected == definition && definition.validate().isEmpty } diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 0d906ae6..446d3910 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -194,8 +194,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine device-telemetry NAME dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] dorydctl [global] machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT] - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--audio-input on|off] [--audio-output on|off] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network | --clear-audio] [--attach-installer | --eject-installer] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--forward ID:tcp|udp:HOST_PORT:GUEST_PORT:loopback|lan ...] [--audio-input on|off] [--audio-output on|off] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network | --clear-forwards | --clear-audio] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME dorydctl [global] machine usb-attach NAME BUS_ID dorydctl [global] machine usb-detach NAME BUS_ID diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 73b09c7c..7e20b025 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -18,6 +18,16 @@ struct DoryMachineTypedWriteAuthorityTests { runtimePreference: .set(.accelerated), graphicsPreference: .set(.virglVenus), networkMode: .set(.disconnected), + portForwards: .set([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + DoryVMPortForward( + id: "dns", + transport: .udp, + hostPort: 5_353, + guestPort: 53, + exposure: .lan + ), + ]), audioInputEnabled: .set(false), audioOutputEnabled: .set(true) ) @@ -97,6 +107,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(snapshot.runtimePreference == .accelerated) #expect(snapshot.graphicsPreference == .virglVenus) #expect(snapshot.networkMode == .sharedNAT) + #expect(snapshot.portForwards.isEmpty) #expect(snapshot.audioConfiguration == DoryVMAudioConfiguration( inputEnabled: true, outputEnabled: true @@ -120,12 +131,14 @@ struct DoryMachineTypedWriteAuthorityTests { ) historical.removeValue(forKey: "networkMode") historical.removeValue(forKey: "audioConfiguration") + historical.removeValue(forKey: "portForwards") let decoded = try JSONDecoder().decode( DoryMachineTypedSettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: historical) ) #expect(decoded.networkMode == .sharedNAT) #expect(decoded.audioConfiguration == nil) + #expect(decoded.portForwards.isEmpty) } @Test("update clear is explicit and field scoped") @@ -312,6 +325,71 @@ struct DoryMachineTypedWriteAuthorityTests { to: migrated.definition, displayMode: .desktop ).audio == DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true)) + + let forwards = DoryMachineTypedSettingsPatch(portForwards: .set([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ])) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "portForwards" + )) { + try forwards.applying(to: [:], displayMode: .desktop) + } + #expect(try forwards.applying( + to: migrated.definition, + displayMode: .desktop + ).portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) + } + + @Test("port-forward wire shape is exact, bounded, and conflict free") + func exactPortForwardWireShape() throws { + let source = DoryMachineTypedSettingsPatch(portForwards: .set([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + DoryVMPortForward( + id: "dns", + transport: .udp, + hostPort: 5_353, + guestPort: 53, + exposure: .lan + ), + ])) + let wire = source.xpcDictionary + #expect(try DoryMachineTypedSettingsPatch( + xpcDictionary: wire, + allowsClears: false + ) == source) + + let malformed: [Any] = [ + [["id": "web", "transport": "tcp", "hostPort": 8080, "guestPort": 80]], + [["id": "web", "transport": "sctp", "hostPort": 8080, "guestPort": 80, + "exposure": "loopback"]], + [["id": "web", "transport": "tcp", "hostPort": true, "guestPort": 80, + "exposure": "loopback"]], + [["id": "web", "transport": "tcp", "hostPort": 443, "guestPort": 80, + "exposure": "loopback"]], + [["id": "../web", "transport": "tcp", "hostPort": 8080, "guestPort": 80, + "exposure": "loopback"]], + ] + for value in malformed { + #expect(throws: (any Error).self) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: ["portForwards": value], + allowsClears: false + ) + } + } + #expect(throws: (any Error).self) { + try DoryMachineTypedSettingsPatch( + xpcDictionary: ["portForwards": [ + ["id": "one", "transport": "tcp", "hostPort": 8080, + "guestPort": 80, "exposure": "loopback"], + ["id": "two", "transport": "tcp", "hostPort": 8080, + "guestPort": 81, "exposure": "loopback"], + ]], + allowsClears: false + ) + } } @Test("clipboard wire shape is complete and exact") @@ -398,6 +476,8 @@ struct DoryMachineTypedWriteAuthorityTests { "--runtime", "compatible", "--graphics", "software", "--network", "disconnected", + "--forward", "web:tcp:8080:80:loopback", + "--forward", "dns:udp:5353:53:lan", "--audio-input", "off", "--audio-output", "on", "--env", "TOKEN=secret", @@ -415,6 +495,16 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.runtimePreference == .set(.compatible)) #expect(patch.graphicsPreference == .set(.software)) #expect(patch.networkMode == .set(.disconnected)) + #expect(patch.portForwards == .set([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + DoryVMPortForward( + id: "dns", + transport: .udp, + hostPort: 5_353, + guestPort: 53, + exposure: .lan + ), + ])) #expect(patch.audioInputEnabled == .set(false)) #expect(patch.audioOutputEnabled == .set(true)) #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"]) @@ -442,6 +532,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--clear-runtime", "--clear-graphics", "--clear-network", + "--clear-forwards", "--clear-audio", ] let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( @@ -457,6 +548,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.runtimePreference == .clear) #expect(patch.graphicsPreference == .clear) #expect(patch.networkMode == .clear) + #expect(patch.portForwards == .clear) #expect(patch.audioInputEnabled == .clear) #expect(patch.audioOutputEnabled == .clear) diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index c6ac2925..e65bb494 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -902,12 +902,18 @@ struct MachineManagerResolvedPlanIntegrationTests { desktopDisplayName: .set("Ubuntu"), clipboardPolicy: .set(.legacyDesktop(.bidirectional)), runtimePreference: .set(.accelerated), - graphicsPreference: .set(.virgl) + graphicsPreference: .set(.virgl), + portForwards: .set([ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) ) ) #expect(created.environment.isEmpty) #expect(created.typedSettings?.guestIdentityIntent.account?.username == "developer") #expect(created.typedSettings?.runtimePreference == .accelerated) + #expect(created.typedSettings?.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) let machineData = try Data(contentsOf: URL( fileURLWithPath: state + "/typed/machine.json" @@ -926,6 +932,9 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(createdRecord.definition.boot.devices.first?.kind == .linuxKernel) #expect(created.typedSettings?.graphicsPreference == .virgl) #expect(createdRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay]) + #expect(createdRecord.definition.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline") #expect(snapshot.typedSettings == created.typedSettings) From 9c08c329b5f938d231e0e8402324b1914ef0211b Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 05:51:11 +0000 Subject: [PATCH 254/338] feat(vm): admit host port bindings durably --- ...achinePlanningTransactionCoordinator.swift | 4 + ...irtualMachineResourceAdmissionLedger.swift | 203 +++++++++++- ...ePlanningTransactionCoordinatorTests.swift | 21 ++ ...lMachineResourceAdmissionLedgerTests.swift | 289 +++++++++++++++++- 4 files changed, 513 insertions(+), 4 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index 1421455b..b6bc29ee 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -846,6 +846,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: lease.binding == binding, Self.sameStableAdmissionHost(lease.hostFacts, hostResources), lease.resources == request.planning.definition.resources, + lease.portForwards == request.planning.definition.portForwards, lease.workload == request.planning.definition.workload, lease.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder) else { throw failure(.recoveryRequired, "The journaled resource lease cannot be adopted exactly.") @@ -859,6 +860,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: workload: request.planning.definition.workload, requirements: request.resourceRequirements, resources: request.planning.definition.resources, + portForwards: request.planning.definition.portForwards, startingLeaseDurationMilliseconds: request.startingLeaseDurationMilliseconds ) @@ -912,6 +914,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: $0.binding == binding && $0.state == .starting && Self.sameStableAdmissionHost($0.hostFacts, hostResources) && $0.resources == request.planning.definition.resources + && $0.portForwards == request.planning.definition.portForwards && $0.workload == request.planning.definition.workload && $0.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder) } @@ -926,6 +929,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: workload: request.planning.definition.workload, requirements: request.resourceRequirements, resources: request.planning.definition.resources, + portForwards: request.planning.definition.portForwards, startingLeaseDurationMilliseconds: request.startingLeaseDurationMilliseconds ) diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift index c0370b14..0afb0d2f 100644 --- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -40,7 +40,7 @@ public struct DoryVirtualMachineResourceAdmissionPlanBinding: } public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equatable { - public static let schemaVersion: UInt16 = 1 + public static let schemaVersion: UInt16 = 2 public var schemaVersion: UInt16 public var leaseID: String @@ -52,6 +52,7 @@ public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equat public var workload: DoryVMWorkloadProfile public var requirements: [DoryVMResourceRequirement] public var resources: DoryVMResourceRequest + public var portForwards: [DoryVMPortForward] public var state: DoryVirtualMachineResourceLeaseState public var startingExpiresAtUnixMilliseconds: Int64? public var createdAtUnixMilliseconds: Int64 @@ -65,6 +66,7 @@ public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equat workload: DoryVMWorkloadProfile, requirements: [DoryVMResourceRequirement], resources: DoryVMResourceRequest, + portForwards: [DoryVMPortForward], startingExpiresAtUnixMilliseconds: Int64, createdAtUnixMilliseconds: Int64, evidence: DoryResolvedMachineResourceAdmissionEvidence @@ -79,6 +81,7 @@ public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equat self.workload = workload self.requirements = requirements self.resources = resources + self.portForwards = portForwards state = .starting self.startingExpiresAtUnixMilliseconds = startingExpiresAtUnixMilliseconds self.createdAtUnixMilliseconds = createdAtUnixMilliseconds @@ -86,6 +89,112 @@ public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equat self.evidence = evidence } + private enum CodingKeys: String, CodingKey { + case schemaVersion + case leaseID + case leaseRevision + case binding + case boundPlanSHA256 + case hostFacts + case hostFactsSHA256 + case workload + case requirements + case resources + case portForwards + case state + case startingExpiresAtUnixMilliseconds + case createdAtUnixMilliseconds + case updatedAtUnixMilliseconds + case evidence + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion) + guard schemaVersion == 1 || schemaVersion == Self.schemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Unsupported resource admission lease schema." + ) + } + leaseID = try container.decode(String.self, forKey: .leaseID) + leaseRevision = try container.decode(UInt64.self, forKey: .leaseRevision) + binding = try container.decode( + DoryVirtualMachineResourceAdmissionPlanBinding.self, + forKey: .binding + ) + boundPlanSHA256 = try container.decodeIfPresent(String.self, forKey: .boundPlanSHA256) + hostFacts = try container.decode(DoryVMHostResources.self, forKey: .hostFacts) + hostFactsSHA256 = try container.decode(String.self, forKey: .hostFactsSHA256) + workload = try container.decode(DoryVMWorkloadProfile.self, forKey: .workload) + requirements = try container.decode( + [DoryVMResourceRequirement].self, + forKey: .requirements + ) + resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources) + if schemaVersion == 1 { + guard !container.contains(.portForwards) else { + throw DecodingError.dataCorruptedError( + forKey: .portForwards, + in: container, + debugDescription: "Schema-1 leases cannot contain port-forward authority." + ) + } + portForwards = [] + } else { + portForwards = try container.decode( + [DoryVMPortForward].self, + forKey: .portForwards + ) + } + state = try container.decode( + DoryVirtualMachineResourceLeaseState.self, + forKey: .state + ) + startingExpiresAtUnixMilliseconds = try container.decodeIfPresent( + Int64.self, + forKey: .startingExpiresAtUnixMilliseconds + ) + createdAtUnixMilliseconds = try container.decode( + Int64.self, + forKey: .createdAtUnixMilliseconds + ) + updatedAtUnixMilliseconds = try container.decode( + Int64.self, + forKey: .updatedAtUnixMilliseconds + ) + evidence = try container.decode( + DoryResolvedMachineResourceAdmissionEvidence.self, + forKey: .evidence + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(leaseID, forKey: .leaseID) + try container.encode(leaseRevision, forKey: .leaseRevision) + try container.encode(binding, forKey: .binding) + try container.encodeIfPresent(boundPlanSHA256, forKey: .boundPlanSHA256) + try container.encode(hostFacts, forKey: .hostFacts) + try container.encode(hostFactsSHA256, forKey: .hostFactsSHA256) + try container.encode(workload, forKey: .workload) + try container.encode(requirements, forKey: .requirements) + try container.encode(resources, forKey: .resources) + if schemaVersion >= 2 { + try container.encode(portForwards, forKey: .portForwards) + } + try container.encode(state, forKey: .state) + try container.encodeIfPresent( + startingExpiresAtUnixMilliseconds, + forKey: .startingExpiresAtUnixMilliseconds + ) + try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds) + try container.encode(updatedAtUnixMilliseconds, forKey: .updatedAtUnixMilliseconds) + try container.encode(evidence, forKey: .evidence) + } + fileprivate static func digest(_ value: T) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -125,6 +234,7 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: { case invalidBinding case invalidHostFacts + case invalidPortForwardContract case invalidLeaseDuration case capacityUnavailable([DoryVMResourceValidationIssue]) case machineAlreadyReserved(String) @@ -133,6 +243,11 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: case staleLeaseRevision(expected: UInt64, actual: UInt64) case invalidLeaseState(DoryVirtualMachineResourceLeaseState) case storageReservationCannotShrink(existing: UInt64, requested: UInt64) + case portBindingUnavailable( + transport: DoryVMPortForwardTransport, + hostPort: UInt16, + machineID: String + ) case planAlreadyBound case planMismatch case hostFactsMismatch @@ -144,6 +259,7 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: switch self { case .invalidBinding: "resource admission plan binding is invalid" case .invalidHostFacts: "host resource facts are invalid" + case .invalidPortForwardContract: "port-forward admission contract is invalid" case .invalidLeaseDuration: "starting lease duration is invalid" case .capacityUnavailable: "host capacity cannot admit the requested virtual machine" case let .machineAlreadyReserved(machineID): @@ -157,6 +273,8 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: "resource lease has invalid state: \(state.rawValue)" case let .storageReservationCannotShrink(existing, requested): "storage reservation cannot shrink from \(existing) to \(requested) bytes" + case let .portBindingUnavailable(transport, hostPort, machineID): + "host \(transport.rawValue) port \(hostPort) is reserved by machine \(machineID)" case .planAlreadyBound: "resource admission lease is already bound to a plan" case .planMismatch: "resolved plan does not match the admitted resource lease" case .hostFactsMismatch: "current host facts do not match the admitted snapshot" @@ -202,6 +320,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl workload: DoryVMWorkloadProfile, requirements: [DoryVMResourceRequirement] = [], resources: DoryVMResourceRequest, + portForwards: [DoryVMPortForward] = [], startingLeaseDurationMilliseconds: Int64 = 120_000, expectedLedgerRevision: UInt64? = nil ) throws -> DoryVirtualMachineResourceAdmissionLease { @@ -212,6 +331,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl try validateExpectedLedgerRevision(expectedLedgerRevision, actual: record.ledgerRevision) try Self.validate(binding) try Self.validate(hostFacts) + try Self.validate(portForwards) guard startingLeaseDurationMilliseconds > 0, startingLeaseDurationMilliseconds <= Self.maximumStartingLeaseDurationMilliseconds, @@ -245,6 +365,16 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl let leasesBeingRetained = record.leases.enumerated().compactMap { index, lease in index == stoppedLeaseIndex ? nil : lease } + do { + try Self.validatePortAvailability( + portForwards, + for: binding.machineID, + among: leasesBeingRetained + ) + } catch { + if recovered { try persistRecoveredRecord(&record) } + throw error + } let commitments = try Self.commitments(in: leasesBeingRetained) let assessedHost = try Self.composing( hostFacts, @@ -282,6 +412,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl workload: workload, requirements: sortedRequirements, resources: resources, + portForwards: portForwards, startingExpiresAtUnixMilliseconds: timestamp + startingLeaseDurationMilliseconds, createdAtUnixMilliseconds: timestamp, @@ -833,6 +964,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl plan.definitionRevision == lease.binding.definitionRevision, plan.definitionSHA256?.lowercased() == lease.binding.definitionSHA256.lowercased(), plan.planRevision == lease.binding.plannedPlanRevision, + plan.portForwards == lease.portForwards, plan.resourceAdmission == lease.evidence else { throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch } @@ -868,6 +1000,55 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } } + private static func validate(_ portForwards: [DoryVMPortForward]) throws { + guard portForwards.count <= DoryVMPortForward.maximumCount else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidPortForwardContract + } + var identifiers: Set = [] + var bindings: Set = [] + for forward in portForwards { + let identifier = Array(forward.id.utf8) + guard (1...63).contains(identifier.count), + identifier.first.map(isAlphaNumeric) == true, + identifier.dropFirst().allSatisfy({ + isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 + }), + identifiers.insert(forward.id).inserted, + forward.hostPort >= 1_024, + forward.guestPort > 0, + bindings.insert(Self.portBindingKey(forward)).inserted else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidPortForwardContract + } + } + } + + private static func validatePortAvailability( + _ requested: [DoryVMPortForward], + for machineID: String, + among leases: [DoryVirtualMachineResourceAdmissionLease] + ) throws { + let active = leases.filter { + $0.state == .starting || $0.state == .running || $0.state == .recoveryRequired + } + for forward in requested { + let key = portBindingKey(forward) + if let owner = active.first(where: { lease in + lease.binding.machineID != machineID + && lease.portForwards.contains(where: { portBindingKey($0) == key }) + }) { + throw DoryVirtualMachineResourceAdmissionLedgerError.portBindingUnavailable( + transport: forward.transport, + hostPort: forward.hostPort, + machineID: owner.binding.machineID + ) + } + } + } + + private static func portBindingKey(_ forward: DoryVMPortForward) -> String { + "\(forward.transport.rawValue):\(forward.hostPort)" + } + private static func validate(_ record: LedgerRecord) throws { guard record.schemaVersion == LedgerRecord.schemaVersion, record.ledgerRevision > 0, @@ -882,7 +1063,9 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } catch { throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord } - guard lease.schemaVersion == DoryVirtualMachineResourceAdmissionLease.schemaVersion, + guard (lease.schemaVersion == 1 + || lease.schemaVersion + == DoryVirtualMachineResourceAdmissionLease.schemaVersion), isSafeIdentifier(lease.leaseID), lease.leaseRevision > 0, lease.createdAtUnixMilliseconds > 0, @@ -912,6 +1095,14 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl lease.boundPlanSHA256.map(DoryResolvedMachinePlan.isSHA256) ?? true else { throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord } + do { + try validate(lease.portForwards) + guard lease.schemaVersion >= 2 || lease.portForwards.isEmpty else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + } catch { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } let reserve = DoryVMResourcePolicy.recommend( host: lease.hostFacts, workload: lease.workload, @@ -944,6 +1135,14 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } } } + var activeBindings: Set = [] + for lease in record.leases where lease.state != .stopped { + for forward in lease.portForwards { + guard activeBindings.insert(portBindingKey(forward)).inserted else { + throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord + } + } + } } private static func admissionReportSHA256( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index 418a325f..5d3f021d 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -40,6 +40,27 @@ struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests { #expect(lease.boundPlanSHA256 == first.planning.resolvedPlanSHA256) } + @Test("transaction admits the exact desired port bindings into the plan") + func exactPortForwardAdmission() throws { + let fixture = try TransactionFixture() + var definition = fixture.definition + definition.portForwards = [DoryVMPortForward( + id: "ssh", + transport: .tcp, + hostPort: 22_220, + guestPort: 22, + exposure: .loopback + )] + let result = try fixture.coordinator().resolveReserveAndPublish( + fixture.request(definition: definition) + ) + + #expect(result.lease.portForwards == definition.portForwards) + #expect(result.planning.resolvedPlan.portForwards == definition.portForwards) + #expect(try fixture.workspaces.read(id: definition.identity.id).portForwards + == definition.portForwards) + } + @Test("crash after workspace publication resumes exact candidate bytes") func workspacePublicationRecovery() throws { let fixture = try TransactionFixture() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift index 1e60270c..fd05bb7c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Darwin import DoryOperations @testable import DorydKit @@ -106,6 +107,266 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { } } + func testPortBindingsAreAtomicAcrossProcessesAndReleasedOnlyWhenStopped() throws { + try withFixture("port-binding-lifecycle") { fixture in + let tcp = fixture.forward("ssh", transport: .tcp, hostPort: 22_220) + let udp = fixture.forward("dns", transport: .udp, hostPort: 22_220) + let resources = fixture.lightweightResources + let first = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .server, + resources: resources, + portForwards: [tcp] + ) + XCTAssertEqual(first.portForwards, [tcp]) + + let secondLedger = DoryVirtualMachineResourceAdmissionLedger( + root: fixture.ledger.root + ) + XCTAssertThrowsError(try secondLedger.reserveStarting( + binding: fixture.binding("machine-b"), + hostFacts: fixture.host, + workload: .server, + resources: resources, + portForwards: [fixture.forward( + "web", + transport: .tcp, + hostPort: 22_220, + exposure: .lan + )] + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .portBindingUnavailable( + transport: .tcp, + hostPort: 22_220, + machineID: "machine-a" + ) + ) + } + + let udpLease = try secondLedger.reserveStarting( + binding: fixture.binding("machine-b"), + hostFacts: fixture.host, + workload: .server, + resources: resources, + portForwards: [udp] + ) + XCTAssertEqual(udpLease.portForwards, [udp]) + + let plan = fixture.plan( + binding: first.binding, + evidence: first.evidence, + portForwards: [tcp] + ) + let bound = try fixture.ledger.bind( + leaseID: first.leaseID, + to: plan, + expectedLeaseRevision: first.leaseRevision + ) + let running = try fixture.ledger.markRunning( + leaseID: bound.leaseID, + plan: plan, + hostFacts: fixture.host, + expectedLeaseRevision: bound.leaseRevision + ) + _ = try fixture.ledger.markStopped( + leaseID: running.leaseID, + expectedLeaseRevision: running.leaseRevision + ) + + let replacement = try secondLedger.reserveStarting( + binding: fixture.binding("machine-c"), + hostFacts: fixture.host, + workload: .server, + resources: resources, + portForwards: [tcp] + ) + XCTAssertEqual(replacement.portForwards, [tcp]) + + let beforeRestart = try fixture.ledger.snapshot() + XCTAssertThrowsError(try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .server, + resources: resources, + portForwards: [tcp] + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .portBindingUnavailable( + transport: .tcp, + hostPort: 22_220, + machineID: "machine-c" + ) + ) + } + XCTAssertEqual(try fixture.ledger.snapshot(), beforeRestart) + } + } + + func testTwoLedgerInstancesAtomicallyReserveOnlyOnePortOwner() async throws { + let fixture = try ResourceLedgerFixture("port-binding-race") + defer { fixture.cleanup() } + let ledgers = [ + fixture.ledger, + DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root), + ] + let bindings = [fixture.binding("machine-a"), fixture.binding("machine-b")] + let host = fixture.host + let resources = fixture.lightweightResources + let forward = fixture.forward("service", transport: .tcp, hostPort: 22_223) + let results = await withTaskGroup( + of: Result.self, + returning: [Result].self + ) { group in + for (ledger, binding) in zip(ledgers, bindings) { + group.addTask { + do { + return .success(try ledger.reserveStarting( + binding: binding, + hostFacts: host, + workload: .server, + resources: resources, + portForwards: [forward] + )) + } catch let error as DoryVirtualMachineResourceAdmissionLedgerError { + return .failure(error) + } catch { + return .failure(.filesystem("unexpected test error")) + } + } + } + var values: [Result] = [] + for await value in group { values.append(value) } + return values + } + + XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1) + let failure = try XCTUnwrap(results.compactMap { result + -> DoryVirtualMachineResourceAdmissionLedgerError? in + guard case let .failure(error) = result else { return nil } + return error + }.first) + guard case let .portBindingUnavailable(transport, hostPort, machineID) = failure else { + return XCTFail("expected port-binding rejection, got \(failure)") + } + XCTAssertEqual(transport, .tcp) + XCTAssertEqual(hostPort, 22_223) + XCTAssertTrue(["machine-a", "machine-b"].contains(machineID)) + XCTAssertEqual(try fixture.ledger.snapshot().leases.count, 1) + } + + func testPlanMustMatchExactAdmittedPortForwards() throws { + try withFixture("port-binding-plan") { fixture in + let forward = fixture.forward("ssh", transport: .tcp, hostPort: 22_221) + let reserved = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .server, + resources: fixture.lightweightResources, + portForwards: [forward] + ) + var changed = forward + changed.guestPort += 1 + let changedPlan = fixture.plan( + binding: reserved.binding, + evidence: reserved.evidence, + portForwards: [changed] + ) + XCTAssertThrowsError(try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: changedPlan, + expectedLeaseRevision: reserved.leaseRevision + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .planMismatch + ) + } + + let exactPlan = fixture.plan( + binding: reserved.binding, + evidence: reserved.evidence, + portForwards: [forward] + ) + XCTAssertNoThrow(try fixture.ledger.bind( + leaseID: reserved.leaseID, + to: exactPlan, + expectedLeaseRevision: reserved.leaseRevision + )) + } + } + + func testInvalidPortForwardContractNeverCreatesALease() throws { + try withFixture("invalid-port-binding") { fixture in + XCTAssertThrowsError(try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .server, + resources: fixture.lightweightResources, + portForwards: [fixture.forward( + "ssh", + transport: .tcp, + hostPort: 22 + )] + )) { error in + XCTAssertEqual( + error as? DoryVirtualMachineResourceAdmissionLedgerError, + .invalidPortForwardContract + ) + } + XCTAssertTrue(try fixture.ledger.snapshot().leases.isEmpty) + } + } + + func testSchemaOneLeaseRemainsReadableWithNoPortClaims() throws { + try withFixture("schema-one-port-migration") { fixture in + _ = try fixture.ledger.reserveStarting( + binding: fixture.binding("machine-a"), + hostFacts: fixture.host, + workload: .desktop, + resources: fixture.resources + ) + let path = fixture.ledger.root + "/resource-admissions.json" + let original = try Data(contentsOf: URL(fileURLWithPath: path)) + var envelope = try XCTUnwrap( + JSONSerialization.jsonObject(with: original) as? [String: Any] + ) + var record = try XCTUnwrap(envelope["record"] as? [String: Any]) + var leases = try XCTUnwrap(record["leases"] as? [[String: Any]]) + XCTAssertEqual(leases.count, 1) + leases[0]["schemaVersion"] = 1 + leases[0].removeValue(forKey: "portForwards") + record["leases"] = leases + let recordData = try JSONSerialization.data( + withJSONObject: record, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + envelope["record"] = record + envelope["recordSHA256"] = SHA256.hash(data: recordData).map { + String(format: "%02x", $0) + }.joined() + let downgraded = try JSONSerialization.data( + withJSONObject: envelope, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + Data("\n".utf8) + try downgraded.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + + let lease = try XCTUnwrap(fixture.ledger.snapshot().leases.first) + XCTAssertEqual(lease.schemaVersion, 1) + XCTAssertEqual(lease.portForwards, []) + } + } + func testRunningLeaseCanBeReopenedOnlyForTheSameRetainedPlan() throws { try withFixture("retained-running-restart") { fixture in let reserved = try fixture.ledger.reserveStarting( @@ -759,6 +1020,11 @@ private final class ResourceLedgerFixture { memoryBytes: 4 * gibibyte, diskBytes: 32 * gibibyte ) + let lightweightResources = DoryVMResourceRequest( + virtualCPUCount: 2, + memoryBytes: 2 * gibibyte, + diskBytes: 32 * gibibyte + ) init(_ name: String, clock: ResourceLedgerClock? = nil) throws { root = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -788,7 +1054,8 @@ private final class ResourceLedgerFixture { func plan( binding: DoryVirtualMachineResourceAdmissionPlanBinding, - evidence: DoryResolvedMachineResourceAdmissionEvidence + evidence: DoryResolvedMachineResourceAdmissionEvidence, + portForwards: [DoryVMPortForward] = [] ) -> DoryResolvedMachinePlan { let provenance = DoryMutableBootMediaProvenanceReference( repositoryIdentity: "machine-store", @@ -800,7 +1067,8 @@ private final class ResourceLedgerFixture { source: .userProvided, mutableProvenance: provenance ) - let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + var devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable + if !portForwards.isEmpty { devices.networkAttachment = .sharedNAT } let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) return DoryResolvedMachinePlan( machineID: binding.machineID, @@ -849,6 +1117,7 @@ private final class ResourceLedgerFixture { )], devices: devices, graphics: .software, + portForwards: portForwards, supportTier: .supported, selectionEvidence: DoryResolvedMachineBackendSelectionEvidence( disposition: .primary, @@ -895,6 +1164,22 @@ private final class ResourceLedgerFixture { ) } + func forward( + _ id: String, + transport: DoryVMPortForwardTransport, + hostPort: UInt16, + guestPort: UInt16 = 22, + exposure: DoryVMPortForwardExposure = .loopback + ) -> DoryVMPortForward { + DoryVMPortForward( + id: id, + transport: transport, + hostPort: hostPort, + guestPort: guestPort, + exposure: exposure + ) + } + func cleanup() { try? FileManager.default.removeItem(atPath: root) } } From 0254975d61d8649e86777bcd16f3ed0ddb33fe26 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 05:54:30 +0000 Subject: [PATCH 255/338] feat(vm): report host port admission conflicts --- ...achinePlanningTransactionCoordinator.swift | 30 +++++++++++++-- ...ePlanningTransactionCoordinatorTests.swift | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift index b6bc29ee..6e5bcb64 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift @@ -276,6 +276,7 @@ public enum DoryDaemonVirtualMachinePlanningTransactionFailureCode: case recoveryRequired = "recovery-required" case trustUnavailable = "trust-unavailable" case resourceReservationRejected = "resource-reservation-rejected" + case portBindingUnavailable = "port-binding-unavailable" case planConstructionRejected = "plan-construction-rejected" case planBindingRejected = "plan-binding-rejected" case publicationAuthorizationRejected = "publication-authorization-rejected" @@ -286,7 +287,7 @@ public enum DoryDaemonVirtualMachinePlanningTransactionFailureCode: } public struct DoryDaemonVirtualMachinePlanningTransactionFailure: - Error, Sendable, Equatable + Error, Sendable, Equatable, CustomStringConvertible { public var code: DoryDaemonVirtualMachinePlanningTransactionFailureCode public var message: String @@ -298,6 +299,8 @@ public struct DoryDaemonVirtualMachinePlanningTransactionFailure: self.code = code self.message = message } + + public var description: String { "\(code.rawValue): \(message)" } } public struct DoryDaemonVirtualMachinePlanningTransactionResult: Sendable { @@ -865,7 +868,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: request.startingLeaseDurationMilliseconds ) } catch { - throw failure(.resourceReservationRejected, "Stopped planning lease cannot be reactivated.") + throw resourceReservationFailure( + error, + fallback: "Stopped planning lease cannot be reactivated." + ) } guard reactivated.leaseID == leaseID, reactivated.boundPlanSHA256 == nil else { @@ -934,7 +940,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: request.startingLeaseDurationMilliseconds ) } catch { - throw failure(.resourceReservationRejected, "Resources could not be reserved atomically.") + throw resourceReservationFailure( + error, + fallback: "Resources could not be reserved atomically." + ) } try faultInjector?(.resourceReservationCommitted) } else { @@ -1673,4 +1682,19 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator: ) -> DoryDaemonVirtualMachinePlanningTransactionFailure { DoryDaemonVirtualMachinePlanningTransactionFailure(code: code, message: message) } + + private func resourceReservationFailure( + _ error: Error, + fallback: String + ) -> DoryDaemonVirtualMachinePlanningTransactionFailure { + guard case let DoryVirtualMachineResourceAdmissionLedgerError + .portBindingUnavailable(transport, hostPort, machineID) = error else { + return failure(.resourceReservationRejected, fallback) + } + return failure( + .portBindingUnavailable, + "Host \(transport.rawValue.uppercased()) port \(hostPort) is already reserved by " + + "machine \(machineID). Stop that machine or choose another host port." + ) + } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift index 5d3f021d..c913cf90 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift @@ -61,6 +61,44 @@ struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests { == definition.portForwards) } + @Test("port collision returns a stable actionable transaction failure") + func portCollisionFailureIsActionable() throws { + let fixture = try TransactionFixture() + let forward = DoryVMPortForward( + id: "ssh", + transport: .tcp, + hostPort: 22_220, + guestPort: 22, + exposure: .loopback + ) + _ = try fixture.ledger.reserveStarting( + binding: DoryVirtualMachineResourceAdmissionPlanBinding( + machineID: "port-owner", + definitionRevision: 1, + definitionSHA256: transactionDigest("7"), + plannedPlanRevision: 1 + ), + hostFacts: fixture.resources, + workload: .server, + resources: fixture.definition.resources, + portForwards: [forward] + ) + var definition = fixture.definition + definition.portForwards = [forward] + + do { + _ = try fixture.coordinator().resolveReserveAndPublish( + fixture.request(definition: definition) + ) + Issue.record("Expected port collision") + } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure { + #expect(failure.code == .portBindingUnavailable) + #expect(failure.message.contains("TCP port 22220")) + #expect(failure.message.contains("port-owner")) + #expect(failure.description.hasPrefix("port-binding-unavailable:")) + } + } + @Test("crash after workspace publication resumes exact candidate bytes") func workspacePublicationRecovery() throws { let fixture = try TransactionFixture() From 901918aa7ff9e4aa304841d94f4ae48e26dd3413 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:01:22 +0000 Subject: [PATCH 256/338] feat(vm): report resolved port health --- Dory/Runtime/Doryd/DorydClient.swift | 5 +- DoryTests/DorydClientTests.swift | 24 ++++- .../Sources/dory-hv/DesktopMode.swift | 39 +++++++- .../ResolvedPortForwardReconciler.swift | 88 ++++++++++++++++--- .../Sources/DoryVMMKit/DoryVMM.swift | 44 +++++++--- .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 9 ++ .../DorydKit/DoryDeviceTelemetry.swift | 3 + .../ResolvedPortForwardReconcilerTests.swift | 49 +++++++++++ .../DoryDeviceTelemetryTests.swift | 4 + 9 files changed, 238 insertions(+), 27 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index a069c94d..c51cd664 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -3942,7 +3942,8 @@ nonisolated final class DorydClient: @unchecked Sendable { let rawHealth = dictionary["health"] as? String, let health = DorydDeviceTelemetryHealth(rawValue: rawHealth), let rawMetrics = dictionary["metrics"] as? NSArray, - rawMetrics.count > 0, rawMetrics.count <= 23 else { + rawMetrics.count > 0, + rawMetrics.count <= deviceTelemetryMetricKinds.count else { return nil } let metrics = rawMetrics.compactMap { raw -> DorydDeviceTelemetryMetric? in @@ -4555,6 +4556,8 @@ nonisolated final class DorydClient: @unchecked Sendable { "configuration-interrupts", "device-resets", "transmitted-frames", "transmitted-bytes", "transmit-drops", "received-frames", "received-bytes", "receive-deferred", "receive-drops", "receive-truncations", "reconnects", + "configured-port-forwards", "active-port-forwards", + "port-forward-reconciliation-failures", "display-frames", "display-drops", "audio-drops", "storage-flushes", "maximum-storage-flush-latency-nanoseconds", "graphics-fences", "graphics-device-losses", "share-invalidations", "share-invalidation-failures", diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 7f998657..b7c51c34 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1159,7 +1159,27 @@ struct DorydClientTests { "id": "virtio-network-7", "kind": "network", "health": "degraded", - "metrics": [metric], + "metrics": [ + metric, + [ + "kind": "configured-port-forwards", + "unit": "count", + "availability": "measured", + "value": UInt64(2), + ] as NSDictionary, + [ + "kind": "active-port-forwards", + "unit": "count", + "availability": "measured", + "value": UInt64(1), + ] as NSDictionary, + [ + "kind": "port-forward-reconciliation-failures", + "unit": "count", + "availability": "measured", + "value": UInt64(3), + ] as NSDictionary, + ], ] let event: NSDictionary = [ "sequence": UInt64(1), @@ -1184,6 +1204,8 @@ struct DorydClientTests { #expect(snapshot.operationID == operationID) #expect(snapshot.backend == .appleVirtualizationFramework) #expect(snapshot.devices.first?.metrics.first?.value == 2) + #expect(snapshot.devices.first?.metrics.last?.kind == "port-forward-reconciliation-failures") + #expect(snapshot.devices.first?.metrics.last?.value == 3) #expect(snapshot.events.first?.kind == "queue-stall") #expect(snapshot.events.first?.occurrences == 2) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index ffb0ead5..85d45e9f 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -38,6 +38,8 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { private var eventSequence: UInt64 = 0 private var eventHistory = [DoryDeviceTelemetryEvent]() private var entries = [Entry]() + private var resolvedPortForwardHealthProvider: + (@Sendable () -> ResolvedPortForwardHealthSnapshot?)? private static let queueStallSampleThreshold: UInt8 = 3 private static let maximumEventHistory = 256 @@ -47,6 +49,12 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { self.operationID = DoryOperationIdentity.canonical(operationID) } + func registerResolvedPortForwardHealth( + _ provider: @escaping @Sendable () -> ResolvedPortForwardHealthSnapshot? + ) { + lock.withLock { resolvedPortForwardHealthProvider = provider } + } + func register( slot: Int, backend: any VirtioDeviceBackend, @@ -150,7 +158,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { let monotonicNanoseconds = DispatchTime.now().uptimeNanoseconds let unavailableReason = "raw-HV backend does not expose this counter yet" var devices = [DoryDeviceTelemetryDevice]() - devices.reserveCapacity(entries.count) + devices.reserveCapacity(entries.count + 1) for index in entries.indices { let transport = entries[index].transport.statistics @@ -372,6 +380,25 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { )) } + if let health = resolvedPortForwardHealthProvider?(), health.isValid { + devices.append(DoryDeviceTelemetryDevice( + id: "resolved-port-forwards", + kind: .network, + health: health.healthy ? .healthy : .degraded, + metrics: [ + .measured( + .configuredPortForwards, + value: health.configuredForwards + ), + .measured(.activePortForwards, value: health.activeForwards), + .measured( + .portForwardReconciliationFailures, + value: health.failedReconciliations + ), + ] + )) + } + return DoryDeviceTelemetrySnapshot( machineID: machineID, operationID: operationID, @@ -728,6 +755,11 @@ enum DesktopMode { self.gvproxy = networkRuntime.process self.networkSocketPaths = networkRuntime.socketPaths self.resolvedPortForwardReconciler = networkRuntime.portForwardReconciler + if let reconciler = networkRuntime.portForwardReconciler { + deviceTelemetry.registerResolvedPortForwardHealth { [weak reconciler] in + reconciler?.healthSnapshot() + } + } do { var backends: [VirtioDeviceBackend] = [ try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), @@ -1300,6 +1332,11 @@ enum DesktopMode { apiSocketPath: apiSocket, log: Self.log ) + if let reconciler, !reconciler.reconcileNow() { + throw VMError.bootFailure( + "could not verify the resolved gvproxy port-forward registry" + ) + } reconciler?.start() return NetworkRuntime( process: process, diff --git a/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift b/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift index 02fce63d..bc6466ea 100644 --- a/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift +++ b/dory-core-swift/Sources/DoryCore/ResolvedPortForwardReconciler.swift @@ -129,6 +129,33 @@ public struct ResolvedPortForwardReconciliation: Sendable, Equatable { } } +/// A backend-neutral, point-in-time view of the exact host listeners owned by a resolved launch. +/// Failure counts are monotonic for the lifetime of the helper so daemon telemetry can distinguish +/// an initial repair from a repeatedly unhealthy listener after sleep or a network transition. +public struct ResolvedPortForwardHealthSnapshot: Sendable, Equatable { + public var configuredForwards: UInt64 + public var activeForwards: UInt64 + public var failedReconciliations: UInt64 + public var healthy: Bool + + public init( + configuredForwards: UInt64, + activeForwards: UInt64, + failedReconciliations: UInt64, + healthy: Bool + ) { + self.configuredForwards = configuredForwards + self.activeForwards = activeForwards + self.failedReconciliations = failedReconciliations + self.healthy = healthy + } + + public var isValid: Bool { + activeForwards <= configuredForwards + && (healthy == (activeForwards == configuredForwards)) + } +} + /// Periodically proves and repairs only the exact forwards pinned by the resolved launch. It never /// adopts or removes unrelated registry entries. A conflicting local key is released only because /// the same resolved contract owns that protocol/address/port tuple. @@ -147,7 +174,9 @@ public final class ResolvedPortForwardReconciler: @unchecked Sendable { private let lock = NSLock() private var activated = false private var cancelled = false - private var lastHealthy = true + private var lastHealthy: Bool + private var activeForwards: UInt64 + private var failedReconciliations: UInt64 = 0 public convenience init( desired: Set, @@ -189,6 +218,8 @@ public final class ResolvedPortForwardReconciler: @unchecked Sendable { log: @escaping @Sendable (String) -> Void = { _ in } ) { self.desired = desired + self.lastHealthy = desired.isEmpty + self.activeForwards = 0 self.registryProvider = registryProvider self.exposeProvider = exposeProvider self.unexposeProvider = unexposeProvider @@ -232,31 +263,60 @@ public final class ResolvedPortForwardReconciler: @unchecked Sendable { @discardableResult public func reconcileNow() -> Bool { - queue.sync { reconcile() } + queue.sync { reconcileAndReport() } } - private func reconcileAndReport() { - let healthy = reconcile() - if healthy != lastHealthy { - lastHealthy = healthy - log(healthy + public func healthSnapshot() -> ResolvedPortForwardHealthSnapshot { + lock.lock() + defer { lock.unlock() } + return ResolvedPortForwardHealthSnapshot( + configuredForwards: UInt64(desired.count), + activeForwards: activeForwards, + failedReconciliations: failedReconciliations, + healthy: lastHealthy + ) + } + + @discardableResult + private func reconcileAndReport() -> Bool { + let result = reconcile() + lock.lock() + let healthChanged = result.healthy != lastHealthy + lastHealthy = result.healthy + activeForwards = UInt64(result.activeCount) + if !result.healthy, failedReconciliations < UInt64.max { + failedReconciliations += 1 + } + lock.unlock() + if healthChanged { + log(result.healthy ? "resolved port forwards recovered" : "resolved port-forward reconciliation is waiting to recover") } + return result.healthy } - private func reconcile() -> Bool { - guard !desired.isEmpty else { return true } - guard let registry = registryProvider() else { return false } + private func reconcile() -> (healthy: Bool, activeCount: Int) { + guard !desired.isEmpty else { return (true, 0) } + guard let registry = registryProvider() else { return (false, 0) } let plan = ResolvedPortForwardReconciliation(desired: desired, registry: registry) for forward in plan.toUnexpose where !unexposeProvider(forward) { - return false + return (false, exactActiveCount(in: registry)) } for forward in plan.toExpose where !exposeProvider(forward) { - return false + return (false, exactActiveCount(in: registry)) + } + guard let verified = registryProvider() else { return (false, 0) } + let activeCount = exactActiveCount(in: verified) + return (activeCount == desired.count, activeCount) + } + + private func exactActiveCount(in registry: ResolvedPortForwardRegistry) -> Int { + desired.reduce(into: 0) { count, forward in + if registry.contains(forward), !registry.conflicts(with: forward) { + count += 1 + } } - guard let verified = registryProvider() else { return false } - return desired.allSatisfy { verified.contains($0) && !verified.conflicts(with: $0) } } private static func curlData(unixSocketPath: String, URL: String) -> Data? { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index e58e7a9e..86d56f6a 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1322,7 +1322,10 @@ public enum DoryVMMMain { machineID: machineID, operationID: operationID, localSocketPath: controlSocketPath, - stateDirectory: stateDirectory + stateDirectory: stateDirectory, + resolvedPortForwardHealthProvider: { + gvproxyNetwork?.resolvedPortForwardHealth + } ) let dockerdProxy = try DoryVZPortUnixProxy( machine: machine, @@ -2262,6 +2265,8 @@ private final class DoryVMMControlServer: @unchecked Sendable { private let operationID: String private let localSocketPath: String private let stateDirectory: String + private let resolvedPortForwardHealthProvider: + @Sendable () -> ResolvedPortForwardHealthSnapshot? private let queue: DispatchQueue private let lock = NSLock() private var listenerFD: Int32 = -1 @@ -2273,13 +2278,16 @@ private final class DoryVMMControlServer: @unchecked Sendable { machineID: String, operationID: UUID, localSocketPath: String, - stateDirectory: String + stateDirectory: String, + resolvedPortForwardHealthProvider: + @escaping @Sendable () -> ResolvedPortForwardHealthSnapshot? = { nil } ) throws { self.machine = machine self.machineID = machineID self.operationID = DoryOperationIdentity.canonical(operationID) self.localSocketPath = localSocketPath self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path + self.resolvedPortForwardHealthProvider = resolvedPortForwardHealthProvider self.queue = DispatchQueue(label: "dev.dory.dory-vmm.control") } @@ -2491,6 +2499,29 @@ private final class DoryVMMControlServer: @unchecked Sendable { reason: unavailableReason ) } + var devices = [ + DoryDeviceTelemetryDevice( + id: "virtualization-framework", + kind: .platform, + health: .unavailable, + metrics: metrics + ), + ] + if let health = resolvedPortForwardHealthProvider(), health.isValid { + devices.append(DoryDeviceTelemetryDevice( + id: "resolved-port-forwards", + kind: .network, + health: health.healthy ? .healthy : .degraded, + metrics: [ + .measured(.configuredPortForwards, value: health.configuredForwards), + .measured(.activePortForwards, value: health.activeForwards), + .measured( + .portForwardReconciliationFailures, + value: health.failedReconciliations + ), + ] + )) + } return DoryDeviceTelemetrySnapshot( machineID: machineID, operationID: operationID, @@ -2498,14 +2529,7 @@ private final class DoryVMMControlServer: @unchecked Sendable { sampleSequence: sequence, sampledAtUnixMilliseconds: UInt64(Date().timeIntervalSince1970 * 1_000), monotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, - devices: [ - DoryDeviceTelemetryDevice( - id: "virtualization-framework", - kind: .platform, - health: .unavailable, - metrics: metrics - ), - ] + devices: devices ) } diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift index b5642785..00f32555 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMGVProxyNetwork.swift @@ -173,6 +173,11 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { } ) resolvedPortForwardReconciler = reconciler + guard reconciler.reconcileNow() else { + throw DoryVZMachineError.validation( + "could not verify the resolved gvproxy port-forward registry" + ) + } reconciler.start() } } catch { @@ -189,6 +194,10 @@ final class DoryVMMGVProxyNetwork: @unchecked Sendable { stop() } + var resolvedPortForwardHealth: ResolvedPortForwardHealthSnapshot? { + resolvedPortForwardReconciler?.healthSnapshot() + } + func stop() { lock.lock() guard !stopped else { diff --git a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift index 1d92818b..a86f7c75 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift @@ -38,6 +38,9 @@ public enum DoryDeviceTelemetryMetricKind: String, Codable, Sendable, CaseIterab case receiveDrops = "receive-drops" case receiveTruncations = "receive-truncations" case reconnects + case configuredPortForwards = "configured-port-forwards" + case activePortForwards = "active-port-forwards" + case portForwardReconciliationFailures = "port-forward-reconciliation-failures" case displayFrames = "display-frames" case displayDrops = "display-drops" case audioDrops = "audio-drops" diff --git a/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift b/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift index 38703447..59a006bb 100644 --- a/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/ResolvedPortForwardReconcilerTests.swift @@ -51,6 +51,12 @@ final class ResolvedPortForwardReconcilerTests: XCTestCase { XCTAssertEqual(state.entries, [desired]) XCTAssertEqual(state.unexposed, [desired]) XCTAssertEqual(state.exposed, [desired]) + XCTAssertEqual(reconciler.healthSnapshot(), ResolvedPortForwardHealthSnapshot( + configuredForwards: 1, + activeForwards: 1, + failedReconciliations: 0, + healthy: true + )) } func testUnavailableRegistryNeverMutatesHostListeners() { @@ -65,6 +71,35 @@ final class ResolvedPortForwardReconcilerTests: XCTestCase { XCTAssertFalse(reconciler.reconcileNow()) XCTAssertTrue(state.exposed.isEmpty) XCTAssertTrue(state.unexposed.isEmpty) + XCTAssertEqual(reconciler.healthSnapshot(), ResolvedPortForwardHealthSnapshot( + configuredForwards: 1, + activeForwards: 0, + failedReconciliations: 1, + healthy: false + )) + } + + func testHealthSnapshotTracksFailureThenRecoveryWithoutFalseActiveListeners() { + let desired = forward(hostPort: 8_080, guestPort: 80) + let state = RegistryState(entries: []) + let availability = LockedAvailability(false) + let reconciler = ResolvedPortForwardReconciler( + desired: [desired], + registryProvider: { availability.value ? state.registry() : nil }, + exposeProvider: { state.expose($0) }, + unexposeProvider: { state.unexpose($0) } + ) + + XCTAssertFalse(reconciler.reconcileNow()) + XCTAssertFalse(reconciler.healthSnapshot().healthy) + availability.value = true + XCTAssertTrue(reconciler.reconcileNow()) + XCTAssertEqual(reconciler.healthSnapshot(), ResolvedPortForwardHealthSnapshot( + configuredForwards: 1, + activeForwards: 1, + failedReconciliations: 1, + healthy: true + )) } private func forward(hostPort: Int, guestPort: Int) -> PublishedPortForward { @@ -79,6 +114,20 @@ final class ResolvedPortForwardReconcilerTests: XCTestCase { } } +private final class LockedAvailability: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Bool + + init(_ value: Bool) { + storedValue = value + } + + var value: Bool { + get { lock.withLock { storedValue } } + set { lock.withLock { storedValue = newValue } } + } +} + private final class RegistryState: @unchecked Sendable { private let lock = NSLock() private(set) var entries: Set diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift index 0526638b..9de679c8 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift @@ -61,6 +61,10 @@ final class DoryDeviceTelemetryTests: XCTestCase { ).unit, .nanoseconds ) + XCTAssertEqual( + DoryDeviceTelemetryMetric.measured(.configuredPortForwards, value: 2).unit, + .count + ) var unknownEventDevice = snapshot unknownEventDevice.events[0].deviceID = "not-present" From 86a4e3e8eac5671920b79104e0f45295c9ab4631 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:04:24 +0000 Subject: [PATCH 257/338] feat(vm): record port forward recovery events --- Dory/Runtime/Doryd/DorydClient.swift | 3 +- DoryTests/DorydClientTests.swift | 9 ++++ .../Sources/dory-hv/DesktopMode.swift | 19 +++++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 42 +++++++++++++++++-- .../DorydKit/DoryDeviceTelemetry.swift | 2 + .../DoryDeviceTelemetryTests.swift | 6 +++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index c51cd664..dc85d47d 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -4564,7 +4564,8 @@ nonisolated final class DorydClient: @unchecked Sendable { ] nonisolated private static let deviceTelemetryEventKinds: Set = [ "queue-stall", "reset", "graphics-fence-timeout", "graphics-device-loss", - "network-reconnect", "audio-drop", "storage-flush-slow", + "network-reconnect", "port-forward-unavailable", "port-forward-recovered", + "audio-drop", "storage-flush-slow", "share-invalidation-failure", ] diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index b7c51c34..9adea9ea 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1209,6 +1209,15 @@ struct DorydClientTests { #expect(snapshot.events.first?.kind == "queue-stall") #expect(snapshot.events.first?.occurrences == 2) + let recoveryEvent = event.mutableCopy() as! NSMutableDictionary + recoveryEvent["sequence"] = UInt64(2) + recoveryEvent["kind"] = "port-forward-recovered" + let validWithRecovery = valid.mutableCopy() as! NSMutableDictionary + validWithRecovery["events"] = [event, recoveryEvent] + service.setMachineDeviceTelemetryResponse(validWithRecovery) + let recovered = try await client.machineDeviceTelemetry("dev") + #expect(recovered.events.last?.kind == "port-forward-recovered") + let unavailableWithValue: NSDictionary = [ "kind": "receive-drops", "unit": "count", diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 85d45e9f..f13d5b70 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -40,6 +40,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { private var entries = [Entry]() private var resolvedPortForwardHealthProvider: (@Sendable () -> ResolvedPortForwardHealthSnapshot?)? + private var previousResolvedPortForwardHealth: Bool? private static let queueStallSampleThreshold: UInt8 = 3 private static let maximumEventHistory = 256 @@ -381,6 +382,24 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } if let health = resolvedPortForwardHealthProvider?(), health.isValid { + if let previous = previousResolvedPortForwardHealth, + previous != health.healthy { + appendEvent( + deviceID: "resolved-port-forwards", + kind: health.healthy + ? .portForwardRecovered : .portForwardUnavailable, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + } else if previousResolvedPortForwardHealth == nil, !health.healthy { + appendEvent( + deviceID: "resolved-port-forwards", + kind: .portForwardUnavailable, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + } + previousResolvedPortForwardHealth = health.healthy devices.append(DoryDeviceTelemetryDevice( id: "resolved-port-forwards", kind: .network, diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 86d56f6a..7447e41f 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -2272,6 +2272,11 @@ private final class DoryVMMControlServer: @unchecked Sendable { private var listenerFD: Int32 = -1 private var running = false private var telemetrySampleSequence: UInt64 = 0 + private var telemetryEventSequence: UInt64 = 0 + private var telemetryEventHistory = [DoryDeviceTelemetryEvent]() + private var previousResolvedPortForwardHealth: Bool? + + private static let maximumTelemetryEventHistory = 256 init( machine: DoryVZMachine, @@ -2478,9 +2483,39 @@ private final class DoryVMMControlServer: @unchecked Sendable { } private func deviceTelemetrySnapshot() -> DoryDeviceTelemetrySnapshot { + let monotonicNanoseconds = DispatchTime.now().uptimeNanoseconds + let portForwardHealth = resolvedPortForwardHealthProvider() lock.lock() telemetrySampleSequence &+= 1 let sequence = telemetrySampleSequence + if let health = portForwardHealth, health.isValid { + let transition: DoryDeviceTelemetryEventKind? + if let previous = previousResolvedPortForwardHealth, + previous != health.healthy { + transition = health.healthy + ? .portForwardRecovered : .portForwardUnavailable + } else if previousResolvedPortForwardHealth == nil, !health.healthy { + transition = .portForwardUnavailable + } else { + transition = nil + } + previousResolvedPortForwardHealth = health.healthy + if let transition, telemetryEventSequence < UInt64.max { + telemetryEventSequence += 1 + telemetryEventHistory.append(DoryDeviceTelemetryEvent( + sequence: telemetryEventSequence, + monotonicNanoseconds: monotonicNanoseconds, + deviceID: "resolved-port-forwards", + kind: transition + )) + if telemetryEventHistory.count > Self.maximumTelemetryEventHistory { + telemetryEventHistory.removeFirst( + telemetryEventHistory.count - Self.maximumTelemetryEventHistory + ) + } + } + } + let events = telemetryEventHistory lock.unlock() let unavailableReason = "Virtualization.framework does not expose stable per-device counters" let metrics = DoryDeviceTelemetryMetricKind.allCases.map { kind in @@ -2507,7 +2542,7 @@ private final class DoryVMMControlServer: @unchecked Sendable { metrics: metrics ), ] - if let health = resolvedPortForwardHealthProvider(), health.isValid { + if let health = portForwardHealth, health.isValid { devices.append(DoryDeviceTelemetryDevice( id: "resolved-port-forwards", kind: .network, @@ -2528,8 +2563,9 @@ private final class DoryVMMControlServer: @unchecked Sendable { backend: .appleVirtualizationFramework, sampleSequence: sequence, sampledAtUnixMilliseconds: UInt64(Date().timeIntervalSince1970 * 1_000), - monotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, - devices: devices + monotonicNanoseconds: monotonicNanoseconds, + devices: devices, + events: events ) } diff --git a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift index a86f7c75..3dba89a3 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDeviceTelemetry.swift @@ -180,6 +180,8 @@ public enum DoryDeviceTelemetryEventKind: String, Codable, Sendable, CaseIterabl case graphicsFenceTimeout = "graphics-fence-timeout" case graphicsDeviceLoss = "graphics-device-loss" case networkReconnect = "network-reconnect" + case portForwardUnavailable = "port-forward-unavailable" + case portForwardRecovered = "port-forward-recovered" case audioDrop = "audio-drop" case storageFlushSlow = "storage-flush-slow" case shareInvalidationFailure = "share-invalidation-failure" diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift index 9de679c8..ff6110a7 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDeviceTelemetryTests.swift @@ -33,6 +33,12 @@ final class DoryDeviceTelemetryTests: XCTestCase { deviceID: device.id, kind: .networkReconnect ), + DoryDeviceTelemetryEvent( + sequence: 2, + monotonicNanoseconds: 2, + deviceID: device.id, + kind: .portForwardUnavailable + ), ] ) From d963a23d394765548e73099f078c2f68abb93c71 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:08:24 +0000 Subject: [PATCH 258/338] feat(vm): diagnose port forward recovery --- .../Sources/DorydKit/HealthReporter.swift | 120 +++++++++++++++++- .../DorydKitTests/HealthReporterTests.swift | 92 ++++++++++++++ 2 files changed, 207 insertions(+), 5 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift index 70d95a10..9d134c54 100644 --- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift +++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift @@ -1962,13 +1962,123 @@ public final class HealthReporter: @unchecked Sendable { data: data ) } - return [summary] + statuses.flatMap { - [ - Self.machineEvidenceCheck($0), - Self.machineFlightRecorderCheck($0), - Self.machineToolsCheck($0), + return [summary] + statuses.flatMap { status in + var checks = [ + Self.machineEvidenceCheck(status), + Self.machineFlightRecorderCheck(status), + Self.machineToolsCheck(status), ] + if let portForwardCheck = Self.machinePortForwardCheck( + machineID: status.id, + state: status.state, + configuredForwards: status.typedSettings?.portForwards.count ?? 0, + telemetry: { try machineManager.deviceTelemetry(id: status.id) } + ) { + checks.append(portForwardCheck) + } + return checks + } + } + + static func machinePortForwardCheck( + machineID: String, + state: DoryMachineState, + configuredForwards: Int, + telemetry: () throws -> DoryDeviceTelemetrySnapshot + ) -> HealthCheck? { + guard configuredForwards > 0 else { return nil } + let id = "machine.local.\(machineID).port-forwards" + guard state == .running || state == .paused else { + return HealthCheck( + id: id, + status: .skip, + code: "machine.port_forwards.inactive", + title: "Workspace port forwards inactive", + detail: "\(machineID) has \(configuredForwards) configured host listener(s)", + data: ["configured": String(configuredForwards)] + ) } + let snapshot: DoryDeviceTelemetrySnapshot + do { + snapshot = try telemetry() + } catch { + return HealthCheck( + id: id, + status: .warn, + code: "machine.port_forwards.telemetry_unavailable", + title: "Port-forward health unavailable", + detail: "\(machineID) did not return its resolved listener health", + action: "Retry after the workspace is ready; restart it if telemetry remains unavailable.", + data: ["configured": String(configuredForwards)] + ) + } + guard snapshot.isValid, + let device = snapshot.devices.first(where: { + $0.id == "resolved-port-forwards" && $0.kind == .network + }), + let helperConfigured = Self.measuredValue( + .configuredPortForwards, + in: device + ), + let active = Self.measuredValue(.activePortForwards, in: device), + let failures = Self.measuredValue( + .portForwardReconciliationFailures, + in: device + ) else { + return HealthCheck( + id: id, + status: .warn, + code: "machine.port_forwards.telemetry_unavailable", + title: "Port-forward health unavailable", + detail: "\(machineID) returned no exact listener-health device", + action: "Restart the workspace with the current qualified helper components.", + data: ["configured": String(configuredForwards)] + ) + } + let data = [ + "configured": String(helperConfigured), + "active": String(active), + "failed_reconciliations": String(failures), + ] + guard helperConfigured == UInt64(configuredForwards) else { + return HealthCheck( + id: id, + status: .fail, + code: "machine.port_forwards.contract_mismatch", + title: "Port-forward launch contract mismatch", + detail: "\(machineID) helper owns \(helperConfigured) of \(configuredForwards) configured listener contracts", + action: "Stop and replan the workspace before exposing host ports.", + data: data + ) + } + guard device.health == .healthy, active == helperConfigured else { + return HealthCheck( + id: id, + status: .warn, + code: "machine.port_forwards.recovering", + title: "Workspace port forwards are recovering", + detail: "\(machineID) has \(active)/\(helperConfigured) resolved host listener(s) active", + action: "Check for a host-port conflict if automatic recovery does not complete.", + data: data + ) + } + return HealthCheck( + id: id, + status: .pass, + code: "machine.port_forwards.ready", + title: "Workspace port forwards ready", + detail: "\(machineID) has \(active)/\(helperConfigured) resolved host listener(s) active", + data: data + ) + } + + private static func measuredValue( + _ kind: DoryDeviceTelemetryMetricKind, + in device: DoryDeviceTelemetryDevice + ) -> UInt64? { + device.metrics.first { + $0.kind == kind && $0.availability == .measured + }?.value } static func machineFlightRecorderCheck(_ status: DoryMachineStatus) -> HealthCheck { diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift index f729beec..c32012cd 100644 --- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift @@ -6,6 +6,67 @@ import Foundation import XCTest final class HealthReporterTests: XCTestCase { + func testMachinePortForwardHealthReportsReadyRecoveringAndContractMismatch() throws { + let ready = HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .running, + configuredForwards: 2, + telemetry: { portForwardTelemetry(configured: 2, active: 2, failures: 1) } + ) + XCTAssertEqual(ready?.status, .pass) + XCTAssertEqual(ready?.code, "machine.port_forwards.ready") + XCTAssertEqual(ready?.data["active"], "2") + XCTAssertEqual(ready?.data["failed_reconciliations"], "1") + + let recovering = HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .paused, + configuredForwards: 2, + telemetry: { + portForwardTelemetry(configured: 2, active: 1, failures: 3) + } + ) + XCTAssertEqual(recovering?.status, .warn) + XCTAssertEqual(recovering?.code, "machine.port_forwards.recovering") + XCTAssertEqual(recovering?.data["active"], "1") + + let mismatch = HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .running, + configuredForwards: 2, + telemetry: { portForwardTelemetry(configured: 1, active: 1, failures: 0) } + ) + XCTAssertEqual(mismatch?.status, .fail) + XCTAssertEqual(mismatch?.code, "machine.port_forwards.contract_mismatch") + } + + func testMachinePortForwardHealthSkipsStoppedAndFailsClosedOnMissingTelemetry() { + let stopped = HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .stopped, + configuredForwards: 1, + telemetry: { throw HealthTestError.unavailable } + ) + XCTAssertEqual(stopped?.status, .skip) + XCTAssertEqual(stopped?.code, "machine.port_forwards.inactive") + + let missing = HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .running, + configuredForwards: 1, + telemetry: { throw HealthTestError.unavailable } + ) + XCTAssertEqual(missing?.status, .warn) + XCTAssertEqual(missing?.code, "machine.port_forwards.telemetry_unavailable") + + XCTAssertNil(HealthReporter.machinePortForwardCheck( + machineID: "dev", + state: .running, + configuredForwards: 0, + telemetry: { throw HealthTestError.unavailable } + )) + } + func testMachineFlightRecorderHealthPublishesOnlyAvailabilityAndCursor() { let ready = HealthReporter.machineFlightRecorderCheck(DoryMachineStatus( id: "dev", @@ -839,6 +900,37 @@ final class HealthReporterTests: XCTestCase { } } +private enum HealthTestError: Error { + case unavailable +} + +private func portForwardTelemetry( + configured: UInt64, + active: UInt64, + failures: UInt64 +) -> DoryDeviceTelemetrySnapshot { + DoryDeviceTelemetrySnapshot( + machineID: "dev", + operationID: "12345678-1234-4234-8234-123456789abc", + backend: .doryHypervisor, + sampleSequence: 1, + sampledAtUnixMilliseconds: 1, + monotonicNanoseconds: 1, + devices: [ + DoryDeviceTelemetryDevice( + id: "resolved-port-forwards", + kind: .network, + health: active == configured ? .healthy : .degraded, + metrics: [ + .measured(.configuredPortForwards, value: configured), + .measured(.activePortForwards, value: active), + .measured(.portForwardReconciliationFailures, value: failures), + ] + ), + ] + ) +} + private func healthResolvedPlan() -> DoryResolvedMachinePlan { let artifact = healthDigest("a") let guest = DoryGuestPlatform(family: .linux, architecture: .arm64) From b54743c104e5d9393c7a27af08a7d8d6f409fd53 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:20:53 +0000 Subject: [PATCH 259/338] feat(vm): replace VZ shares at runtime --- .../Sources/DoryVMMKit/DoryVMM.swift | 126 ++++++- .../Sources/DorydKit/MachineManager.swift | 110 +++++++ .../Sources/DorydKit/VmmControl.swift | 74 ++++- .../MachineDirectoryShareRuntimeTests.swift | 308 ++++++++++++++++++ 4 files changed, 612 insertions(+), 6 deletions(-) create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineDirectoryShareRuntimeTests.swift diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 7447e41f..381c9010 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1877,6 +1877,8 @@ private final class DoryVZMachineStopObserver: NSObject, VZVirtualMachineDelegat } public final class DoryVZMachine: @unchecked Sendable { + private static let reservedBootConfigurationShareTag = "dorycfg" + private let queue: DispatchQueue private let configuration: VZVirtualMachineConfiguration private let virtualMachine: VZVirtualMachine @@ -2117,6 +2119,81 @@ public final class DoryVZMachine: @unchecked Sendable { return try box.wait() } + /// Replaces the backing directories of the existing user-visible VirtioFS devices without + /// changing their guest-visible tag/device identity. Virtualization.framework exposes this + /// mutation directly on VZVirtioFileSystemDevice; add/remove/retag is intentionally rejected + /// because the live device graph itself is immutable. + public func replaceDirectoryShares( + _ replacements: [VmmDirectoryShareReplacement] + ) throws { + guard replacements.count <= VmmDirectoryShareReplacement.maximumCount, + replacements.allSatisfy(\.isValid), + Set(replacements.map(\.tag)).count == replacements.count, + !replacements.contains(where: { + $0.tag == Self.reservedBootConfigurationShareTag + }) else { + throw DoryVZMachineError.validation( + "invalid runtime directory-share replacement" + ) + } + + try queue.sync { [self] in + guard virtualMachine.state == .running || virtualMachine.state == .paused else { + throw DoryVZMachineError.validation( + "virtual machine is not running or paused" + ) + } + let devices = virtualMachine.directorySharingDevices.compactMap { + $0 as? VZVirtioFileSystemDevice + } + guard devices.count == virtualMachine.directorySharingDevices.count else { + throw DoryVZMachineError.validation( + "virtual machine has an unsupported directory-sharing device" + ) + } + let userDevices = devices.filter { + $0.tag != Self.reservedBootConfigurationShareTag + } + let deviceTags = Set(userDevices.map(\.tag)) + let replacementTags = Set(replacements.map(\.tag)) + guard deviceTags == replacementTags, + userDevices.count == replacements.count else { + throw DoryVZMachineError.validation( + "runtime directory-share device set changed" + ) + } + + var prepared: [String: VZSingleDirectoryShare] = [:] + for replacement in replacements { + var info = stat() + guard lstat(replacement.hostPath, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR else { + throw DoryVZMachineError.missingFile(replacement.hostPath) + } + do { + try VZVirtioFileSystemDeviceConfiguration.validateTag(replacement.tag) + } catch { + throw DoryVZMachineError.validation("\(error)") + } + let directory = VZSharedDirectory( + url: URL( + fileURLWithPath: replacement.hostPath, + isDirectory: true + ), + readOnly: replacement.readOnly + ) + prepared[replacement.tag] = VZSingleDirectoryShare(directory: directory) + } + + // All validation and VZ share construction is complete before the first mutation. + // These property assignments are synchronous and non-throwing for VZ directory + // shares, so another operation on the VM queue cannot observe a partial set. + for device in userDevices { + device.share = prepared[device.tag] + } + } + } + private func firstSocketDeviceOnQueue() throws -> VZVirtioSocketDevice { guard let device = virtualMachine.socketDevices.first as? VZVirtioSocketDevice else { throw DoryVZMachineError.missingSocketDevice @@ -2412,7 +2489,8 @@ private final class DoryVMMControlServer: @unchecked Sendable { guard request.targetMB == nil, request.statePath == nil, request.lifecycleAction == nil, - request.operationID == nil else { + request.operationID == nil, + request.directoryShares == nil else { return HandledControlResponse(response: VmmControlResponse( ok: false, message: "invalid VMM device telemetry request" @@ -2425,7 +2503,11 @@ private final class DoryVMMControlServer: @unchecked Sendable { ) ) case "setBalloonTarget": - guard let targetMB = request.targetMB, targetMB > 0 else { + guard let targetMB = request.targetMB, targetMB > 0, + request.statePath == nil, + request.lifecycleAction == nil, + request.operationID == nil, + request.directoryShares == nil else { return HandledControlResponse( response: VmmControlResponse(ok: false, message: "missing positive targetMB") ) @@ -2434,7 +2516,28 @@ private final class DoryVMMControlServer: @unchecked Sendable { return HandledControlResponse( response: VmmControlResponse(ok: true, targetMB: appliedMB) ) + case "replaceDirectoryShares": + guard request.targetMB == nil, + request.statePath == nil, + request.lifecycleAction == nil, + request.operationID == nil, + let replacements = request.directoryShares else { + return HandledControlResponse(response: VmmControlResponse( + ok: false, + message: "invalid runtime directory-share request" + )) + } + try machine.replaceDirectoryShares(replacements) + return HandledControlResponse(response: VmmControlResponse(ok: true)) case "pauseMachine": + guard request.targetMB == nil, + request.statePath == nil, + request.directoryShares == nil else { + return HandledControlResponse(response: VmmControlResponse( + ok: false, + message: "invalid pause request" + )) + } let receipt = try lifecycleReceipt( request, expectedAction: .preparePause @@ -2442,11 +2545,22 @@ private final class DoryVMMControlServer: @unchecked Sendable { try machine.pause() return HandledControlResponse(response: receipt) case "resumeMachine": + guard request.targetMB == nil, + request.statePath == nil, + request.directoryShares == nil else { + return HandledControlResponse(response: VmmControlResponse( + ok: false, + message: "invalid resume request" + )) + } let receipt = try lifecycleReceipt(request, expectedAction: .resumed) try machine.resume() return HandledControlResponse(response: receipt) case "acknowledgeLifecycle": - guard let action = request.lifecycleAction else { + guard request.targetMB == nil, + request.statePath == nil, + request.directoryShares == nil, + let action = request.lifecycleAction else { return HandledControlResponse(response: VmmControlResponse( ok: false, message: "missing lifecycle action" @@ -2457,7 +2571,11 @@ private final class DoryVMMControlServer: @unchecked Sendable { expectedAction: action )) case "saveMachineState": - guard let path = request.statePath, + guard request.targetMB == nil, + request.lifecycleAction == nil, + request.operationID == nil, + request.directoryShares == nil, + let path = request.statePath, let accepted = acceptedSavedStatePath(path) else { return HandledControlResponse( response: VmmControlResponse( diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index ac11daab..8922688f 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -998,6 +998,7 @@ public final class MachineManager: @unchecked Sendable { private let agentConnector: AgentConnector private let balloonController: any MachineBalloonControlling private let deviceTelemetryController: any MachineDeviceTelemetryControlling + private let directoryShareController: any MachineDirectoryShareControlling private let usbController: any DoryMachineUSBControlling private let vzLifecycleController: any MachineVZLifecycleControlling private let savedStateStore: DoryMachineSavedStateStore @@ -1050,6 +1051,8 @@ public final class MachineManager: @unchecked Sendable { balloonController: any MachineBalloonControlling = UnixMachineBalloonController(), deviceTelemetryController: any MachineDeviceTelemetryControlling = UnixMachineDeviceTelemetryController(), + directoryShareController: any MachineDirectoryShareControlling = + UnixMachineDirectoryShareController(), usbController: any DoryMachineUSBControlling = UnixDoryMachineUSBController(), vzLifecycleController: any MachineVZLifecycleControlling = UnixMachineVZLifecycleController(), agentConnector: @escaping AgentConnector = { socketPath in @@ -1061,6 +1064,7 @@ public final class MachineManager: @unchecked Sendable { self.launchPolicy = launchPolicy self.balloonController = balloonController self.deviceTelemetryController = deviceTelemetryController + self.directoryShareController = directoryShareController self.usbController = usbController self.vzLifecycleController = vzLifecycleController self.agentConnector = agentConnector @@ -4292,6 +4296,54 @@ public final class MachineManager: @unchecked Sendable { } return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped) } + let hasOnlyLiveShareRestartChanges = updated.shares != current.shares + && updated.memoryMB == current.memoryMB + && updated.cpuCount == current.cpuCount + && updated.environment == current.environment + && updated.installerISOPath == current.installerISOPath + if wasRunning, + hasOnlyLiveShareRestartChanges, + let liveContext = liveVZDirectoryShareReplacementContext( + id: id, + current: current, + updated: updated + ) { + let authorities = try Self.captureShareRuntimeAuthorities(updated.shares) + // Desired state is committed first. If the helper rejects the mutation or its live + // launch changes, the normal stop/start transaction below applies that same durable + // state and preserves the existing rollback semantics. + try persist(updated) + try publishConfiguration(updated) + do { + let liveShares = try Self.revalidateShareRuntimeAuthorities( + authorities, + expectedShares: updated.shares + ).map { + VmmDirectoryShareReplacement( + tag: $0.tag, + hostPath: $0.hostPath, + readOnly: $0.readOnly + ) + } + try directoryShareController.replaceDirectoryShares( + socketPath: liveContext.socketPath, + shares: liveShares + ) + guard liveVZDirectoryShareReplacementStillApplies( + id: id, + context: liveContext, + configuration: updated + ) else { + throw MachineManagerError.persistence( + "live VZ launch changed during directory-share replacement" + ) + } + return status(id: id) ?? DoryMachineStatus(id: id, state: .running) + } catch { + // A lost response is ambiguous: the helper may have applied the replacement. + // Fall through to a full stop/start instead of attempting a path-based rollback. + } + } if wasRunning { _ = try stopImplementation(id: id, journalLifecycle: true) } @@ -4349,6 +4401,64 @@ public final class MachineManager: @unchecked Sendable { } } + private struct LiveVZDirectoryShareReplacementContext { + var launchID: UUID + var socketPath: String + } + + private func liveVZDirectoryShareReplacementContext( + id: String, + current: DoryMachineConfiguration, + updated: DoryMachineConfiguration + ) -> LiveVZDirectoryShareReplacementContext? { + // Resolved workspaces must first publish a replacement exact plan. The current planning + // transaction is intentionally stopped-only, so this fast path is compatibility-only + // until running-plan replacement has its own durable admission transaction. + guard launchPolicy == .legacyCompatibility, + current.shares.count == updated.shares.count else { + return nil + } + let currentMounts = Dictionary( + uniqueKeysWithValues: current.shares.map { ($0.tag, $0.guestPath) } + ) + let updatedMounts = Dictionary( + uniqueKeysWithValues: updated.shares.map { ($0.tag, $0.guestPath) } + ) + guard currentMounts == updatedMounts else { return nil } + + lock.lock() + defer { lock.unlock() } + guard let entry = machines[id], + entry.configuration == current, + entry.state == .running, + entry.process?.isRunning == true, + entry.activeBackend == .appleVirtualizationFramework, + let launchID = entry.launchID, + let socketPath = entry.handoff?.ready.controlSocketPath else { + return nil + } + return LiveVZDirectoryShareReplacementContext( + launchID: launchID, + socketPath: socketPath + ) + } + + private func liveVZDirectoryShareReplacementStillApplies( + id: String, + context: LiveVZDirectoryShareReplacementContext, + configuration: DoryMachineConfiguration + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard let entry = machines[id] else { return false } + return entry.configuration == configuration + && entry.state == .running + && entry.process?.isRunning == true + && entry.activeBackend == .appleVirtualizationFramework + && entry.launchID == context.launchID + && entry.handoff?.ready.controlSocketPath == context.socketPath + } + public func snapshot( id: String, note: String = "", diff --git a/dory-core-swift/Sources/DorydKit/VmmControl.swift b/dory-core-swift/Sources/DorydKit/VmmControl.swift index cb17f32d..32c03afd 100644 --- a/dory-core-swift/Sources/DorydKit/VmmControl.swift +++ b/dory-core-swift/Sources/DorydKit/VmmControl.swift @@ -2,25 +2,56 @@ import Darwin import DoryCore import Foundation +/// The minimal daemon-to-helper authority needed to replace the backing directory of an +/// existing VirtioFS device. Persistent bookmarks and guest paths never cross this runtime +/// control boundary: the device tag is already the guest-visible mount identity. +public struct VmmDirectoryShareReplacement: Sendable, Equatable, Codable { + public static let maximumCount = 64 + + public var tag: String + public var hostPath: String + public var readOnly: Bool + + public init(tag: String, hostPath: String, readOnly: Bool) { + self.tag = tag + self.hostPath = hostPath + self.readOnly = readOnly + } + + public var isValid: Bool { + !tag.isEmpty + && tag.utf8.count < 36 + && tag.allSatisfy { + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." + } + && hostPath.hasPrefix("/") + && !hostPath.contains("\0") + && hostPath.utf8.count < Int(PATH_MAX) + } +} + public struct VmmControlRequest: Sendable, Equatable, Codable { public var command: String public var targetMB: UInt64? public var statePath: String? public var lifecycleAction: DoryLifecycleReceiptAction? public var operationID: String? + public var directoryShares: [VmmDirectoryShareReplacement]? public init( command: String, targetMB: UInt64? = nil, statePath: String? = nil, lifecycleAction: DoryLifecycleReceiptAction? = nil, - operationID: String? = nil + operationID: String? = nil, + directoryShares: [VmmDirectoryShareReplacement]? = nil ) { self.command = command self.targetMB = targetMB self.statePath = statePath self.lifecycleAction = lifecycleAction self.operationID = operationID + self.directoryShares = directoryShares } public static func setBalloonTarget(_ targetMB: UInt64) -> VmmControlRequest { @@ -57,6 +88,12 @@ public struct VmmControlRequest: Sendable, Equatable, Codable { public static func saveMachineState(to statePath: String) -> VmmControlRequest { VmmControlRequest(command: "saveMachineState", statePath: statePath) } + + public static func replaceDirectoryShares( + _ shares: [VmmDirectoryShareReplacement] + ) -> VmmControlRequest { + VmmControlRequest(command: "replaceDirectoryShares", directoryShares: shares) + } } public struct VmmControlResponse: Sendable, Equatable, Codable { @@ -136,6 +173,35 @@ public protocol MachineBalloonControlling: Sendable { func setBalloonTarget(socketPath: String, targetMB: UInt64) throws } +public protocol MachineDirectoryShareControlling: Sendable { + func replaceDirectoryShares( + socketPath: String, + shares: [VmmDirectoryShareReplacement] + ) throws +} + +public struct UnixMachineDirectoryShareController: MachineDirectoryShareControlling { + public init() {} + + public func replaceDirectoryShares( + socketPath: String, + shares: [VmmDirectoryShareReplacement] + ) throws { + guard shares.count <= VmmDirectoryShareReplacement.maximumCount, + shares.allSatisfy(\.isValid), + Set(shares.map(\.tag)).count == shares.count else { + throw VmmControlError.rejected("invalid runtime directory-share replacement") + } + let response = try VmmControlClient.send( + socketPath: socketPath, + request: .replaceDirectoryShares(shares) + ) + guard response.ok else { + throw VmmControlError.rejected(response.message) + } + } +} + /// Daemon-side client for lifecycle operations that must execute inside the VZ helper process. /// Raw-HV helpers intentionally do not expose this socket and therefore cannot claim saved-state /// support. Paths are daemon-owned private workspace paths, never client input. @@ -472,7 +538,8 @@ public final class VmmLifecycleReceiptServer: @unchecked Sendable { guard request.targetMB == nil, request.statePath == nil, request.lifecycleAction == nil, - request.operationID == nil else { + request.operationID == nil, + request.directoryShares == nil else { throw VmmControlError.rejected("invalid helper device telemetry request") } guard let deviceTelemetryProvider else { @@ -491,6 +558,9 @@ public final class VmmLifecycleReceiptServer: @unchecked Sendable { guard request.command == "acknowledgeLifecycle", let action = request.lifecycleAction, let operationID = request.operationID, + request.targetMB == nil, + request.statePath == nil, + request.directoryShares == nil, DoryOperationIdentity.parseCanonical(operationID) != nil, operationID != "00000000-0000-0000-0000-000000000000" else { throw VmmControlError.rejected("invalid helper lifecycle receipt request") diff --git a/dory-core-swift/Tests/DorydKitTests/MachineDirectoryShareRuntimeTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineDirectoryShareRuntimeTests.swift new file mode 100644 index 00000000..1ed72c5d --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/MachineDirectoryShareRuntimeTests.swift @@ -0,0 +1,308 @@ +import DoryCore +@testable import DorydKit +import Darwin +import Foundation +import XCTest + +final class MachineDirectoryShareRuntimeTests: XCTestCase { + func testRunningVZMachineReplacesExistingShareWithoutRestart() throws { + let root = "/tmp/dory-live-share-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let firstRoot = root + "/first" + let secondRoot = root + "/second" + try FileManager.default.createDirectory( + atPath: firstRoot, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + atPath: secondRoot, + withIntermediateDirectories: true + ) + let shares = RecordingDirectoryShareController() + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: root + "/state", + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: true + ), + directoryShareController: shares, + vzLifecycleController: NoopVZLifecycleController() + ) + defer { + try? manager.delete(id: "dev") + try? FileManager.default.removeItem(atPath: root) + } + + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: firstRoot, + guestPath: "/workspace/src" + )] + )) + let starting = try manager.start(id: "dev") + try VmmHandoffClient.send( + path: try XCTUnwrap(starting.handoffSocketPath), + ready: VmmReadyMessage( + machineID: "dev", + operationID: try XCTUnwrap(starting.activeOperationID), + agentBuild: "dory-agent/live-share-test", + agentProtocolVersion: DoryCore.protocolVersion(), + controlSocketPath: "/run/dory-live-share-control.sock" + ) + ) + let running = try waitForState(manager, id: "dev", state: .running) + let originalPID = try XCTUnwrap(running.pid) + + let updated = try manager.update( + id: "dev", + address: "192.168.215.44", + updatesAddress: true, + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: secondRoot, + guestPath: "/workspace/src", + readOnly: true + )], + updatesShares: true + ) + + XCTAssertEqual(updated.state, .running) + XCTAssertEqual(updated.pid, originalPID) + XCTAssertEqual(updated.address, "192.168.215.44") + XCTAssertEqual(shares.calls, [RecordingDirectoryShareController.Call( + socketPath: "/run/dory-live-share-control.sock", + shares: [VmmDirectoryShareReplacement( + tag: "src", + hostPath: "/private" + secondRoot, + readOnly: true + )] + )]) + let stored = try JSONDecoder().decode( + DoryMachineConfiguration.self, + from: Data(contentsOf: URL( + fileURLWithPath: root + "/state/dev/machine.json" + )) + ) + XCTAssertEqual(stored.shares, updated.shares) + } + + func testRawHelperRejectsRuntimeShareReplacement() throws { + let root = "/tmp/dory-raw-share-control-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let socketPath = root + "/control.sock" + let server = VmmLifecycleReceiptServer(socketPath: socketPath) + defer { + server.stop() + try? FileManager.default.removeItem(atPath: root) + } + try server.start() + + let response = try VmmControlClient.send( + socketPath: socketPath, + request: .replaceDirectoryShares([ + VmmDirectoryShareReplacement( + tag: "src", + hostPath: "/private/tmp/source", + readOnly: false + ), + ]) + ) + XCTAssertFalse(response.ok) + } + + func testRejectedLiveReplacementFallsBackToDurableRestart() throws { + let root = "/tmp/dory-live-share-fallback-\(getpid())-\(UInt32.random(in: 0...UInt32.max))" + let firstRoot = root + "/first" + let secondRoot = root + "/second" + try FileManager.default.createDirectory( + atPath: firstRoot, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + atPath: secondRoot, + withIntermediateDirectories: true + ) + let shares = RecordingDirectoryShareController(rejects: true) + let manager = MachineManager( + configuration: MachineManagerConfiguration( + vmmExecutablePath: "/bin/sleep", + stateDirectory: root + "/state", + baseArguments: ["30"], + passMachineArguments: false, + requiresReadyHandoff: true + ), + directoryShareController: shares, + vzLifecycleController: NoopVZLifecycleController() + ) + defer { + try? manager.delete(id: "dev") + try? FileManager.default.removeItem(atPath: root) + } + + _ = try manager.create(DoryMachineConfiguration( + id: "dev", + kernelPath: doryTestKernelPath, + rootfsPath: doryTestRootfsPath, + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: firstRoot, + guestPath: "/workspace/src" + )] + )) + let starting = try manager.start(id: "dev") + try sendReadyHandoff(starting) + let originalPID = try XCTUnwrap( + waitForState(manager, id: "dev", state: .running).pid + ) + + let result = LockedMachineStatusResult() + DispatchQueue.global(qos: .userInitiated).async { + result.store(Result { + try manager.update( + id: "dev", + shares: [DoryMachineShareConfiguration( + tag: "src", + hostPath: secondRoot, + guestPath: "/workspace/src" + )], + updatesShares: true + ) + }) + } + + let deadline = Date().addingTimeInterval(5) + var handedOffPID: Int32? + while Date() < deadline, result.value == nil { + if let status = manager.status(id: "dev"), + status.state == .starting, + let pid = status.pid, + pid != originalPID, + handedOffPID != pid { + try sendReadyHandoff(status) + handedOffPID = pid + } + Thread.sleep(forTimeInterval: 0.02) + } + + let updated = try XCTUnwrap(result.value, "update restart timed out").get() + XCTAssertEqual(updated.state, .running) + XCTAssertNotEqual(updated.pid, originalPID) + XCTAssertEqual(updated.shares.first?.hostPath, secondRoot) + XCTAssertEqual(shares.calls.count, 1) + } + + func testDirectoryShareReplacementRejectsDuplicateTagsBeforeConnecting() { + let duplicate = VmmDirectoryShareReplacement( + tag: "src", + hostPath: "/private/tmp/source", + readOnly: false + ) + XCTAssertThrowsError( + try UnixMachineDirectoryShareController().replaceDirectoryShares( + socketPath: "/does/not/exist", + shares: [duplicate, duplicate] + ) + ) { error in + XCTAssertEqual( + String(describing: error), + "invalid runtime directory-share replacement" + ) + } + } +} + +private final class RecordingDirectoryShareController: + MachineDirectoryShareControlling, @unchecked Sendable +{ + struct Call: Equatable { + var socketPath: String + var shares: [VmmDirectoryShareReplacement] + } + + private let lock = NSLock() + private let rejects: Bool + private var recorded: [Call] = [] + + init(rejects: Bool = false) { + self.rejects = rejects + } + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func replaceDirectoryShares( + socketPath: String, + shares: [VmmDirectoryShareReplacement] + ) throws { + lock.lock() + recorded.append(Call(socketPath: socketPath, shares: shares)) + lock.unlock() + if rejects { + throw VmmControlError.rejected("injected live-share rejection") + } + } +} + +private struct NoopVZLifecycleController: MachineVZLifecycleControlling { + func pause(socketPath: String) throws {} + func resume(socketPath: String) throws {} + func acknowledgeLifecycle( + socketPath: String, + action: DoryLifecycleReceiptAction, + operationID: UUID + ) throws {} + func saveMachineState(socketPath: String, statePath: String) throws {} +} + +private func waitForState( + _ manager: MachineManager, + id: String, + state: DoryMachineState, + timeout: TimeInterval = 3 +) throws -> DoryMachineStatus { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let status = manager.status(id: id), status.state == state { + return status + } + Thread.sleep(forTimeInterval: 0.02) + } + return try XCTUnwrap(manager.status(id: id)) +} + +private func sendReadyHandoff(_ status: DoryMachineStatus) throws { + try VmmHandoffClient.send( + path: try XCTUnwrap(status.handoffSocketPath), + ready: VmmReadyMessage( + machineID: status.id, + operationID: try XCTUnwrap(status.activeOperationID), + agentBuild: "dory-agent/live-share-test", + agentProtocolVersion: DoryCore.protocolVersion(), + controlSocketPath: "/run/dory-live-share-control.sock" + ) + ) +} + +private final class LockedMachineStatusResult: @unchecked Sendable { + private let lock = NSLock() + private var stored: Result? + + var value: Result? { + lock.lock() + defer { lock.unlock() } + return stored + } + + func store(_ value: Result) { + lock.lock() + stored = value + lock.unlock() + } +} From 86430934c9c0d8e35fb69bd0defe0bcc141faf54 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:27:46 +0000 Subject: [PATCH 260/338] feat(network): react to VPN route changes --- dory-core-swift/Package.swift | 1 + .../DorydKit/CorporateConnectivity.swift | 44 ++++++ ...orateConnectivitySystemChangeMonitor.swift | 142 +++++++++++++++++ .../CorporateConnectivityTests.swift | 146 ++++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 dory-core-swift/Sources/DorydKit/CorporateConnectivitySystemChangeMonitor.swift diff --git a/dory-core-swift/Package.swift b/dory-core-swift/Package.swift index 6554d82e..fba709da 100644 --- a/dory-core-swift/Package.swift +++ b/dory-core-swift/Package.swift @@ -36,6 +36,7 @@ let package = Package( .linkedFramework("IOKit"), .linkedFramework("Network"), .linkedFramework("Security"), + .linkedFramework("SystemConfiguration"), ] ), .target( diff --git a/dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift b/dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift index d45271a2..352476dd 100644 --- a/dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift +++ b/dory-core-swift/Sources/DorydKit/CorporateConnectivity.swift @@ -1183,10 +1183,13 @@ public final class CorporateConnectivityReconciler: WakeNetworkReconciling, @unc private let prober: CorporateConnectivityProber private let guestApply: CorporateGuestApply? private let incidentWriter: IncidentWriter? + private let systemChangeMonitor: any CorporateConnectivitySystemChangeMonitoring private let interval: TimeInterval private let queue = DispatchQueue(label: "dev.dory.corporate-connectivity") private let lock = NSLock() private var timer: DispatchSourceTimer? + private var pendingSystemChange: DispatchWorkItem? + private var monitorGeneration: UInt64 = 0 private var lastFingerprint: String? private var lastProfileDigest: String? private var lastStatus: CorporateConnectivityStatus? @@ -1197,6 +1200,8 @@ public final class CorporateConnectivityReconciler: WakeNetworkReconciling, @unc prober: CorporateConnectivityProber = CorporateConnectivityProber(), guestApply: CorporateGuestApply? = nil, incidentWriter: IncidentWriter? = nil, + systemChangeMonitor: any CorporateConnectivitySystemChangeMonitoring = + SystemCorporateConnectivityChangeMonitor(), interval: TimeInterval = 10 ) { self.store = CorporateConnectivityStore(home: home) @@ -1204,6 +1209,7 @@ public final class CorporateConnectivityReconciler: WakeNetworkReconciling, @unc self.prober = prober self.guestApply = guestApply self.incidentWriter = incidentWriter + self.systemChangeMonitor = systemChangeMonitor self.interval = max(2, interval) } @@ -1214,15 +1220,32 @@ public final class CorporateConnectivityReconciler: WakeNetworkReconciling, @unc source.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) source.setEventHandler { [weak self] in self?.reconcileIfSystemChanged() } timer = source + monitorGeneration &+= 1 + let generation = monitorGeneration lock.unlock() source.resume() + do { + try systemChangeMonitor.start { [weak self] in + self?.scheduleSystemChangeReconciliation(generation: generation) + } + } catch { + incidentWriter?.record( + type: "network.corporate_monitor_failed", + detail: "dynamic store: \(error)" + ) + } } public func stop() { lock.lock() let current = timer timer = nil + monitorGeneration &+= 1 + let pending = pendingSystemChange + pendingSystemChange = nil lock.unlock() + systemChangeMonitor.stop() + pending?.cancel() current?.cancel() } @@ -1485,6 +1508,27 @@ public final class CorporateConnectivityReconciler: WakeNetworkReconciling, @unc } } + private func scheduleSystemChangeReconciliation(generation: UInt64) { + queue.async { [weak self] in + guard let self else { return } + self.lock.lock() + guard self.timer != nil, self.monitorGeneration == generation else { + self.lock.unlock() + return + } + self.pendingSystemChange?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.reconcileIfSystemChanged() + } + self.pendingSystemChange = work + self.lock.unlock() + // Dynamic-store changes arrive in short IPv4/IPv6/DNS bursts. Coalescing them avoids + // repeatedly restarting the managed guest while still recovering far sooner than + // the fallback polling interval. + self.queue.asyncAfter(deadline: .now() + 0.25, execute: work) + } + } + private func remember(_ status: CorporateConnectivityStatus) { lock.lock() lastFingerprint = status.system.fingerprint diff --git a/dory-core-swift/Sources/DorydKit/CorporateConnectivitySystemChangeMonitor.swift b/dory-core-swift/Sources/DorydKit/CorporateConnectivitySystemChangeMonitor.swift new file mode 100644 index 00000000..fdb64e73 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/CorporateConnectivitySystemChangeMonitor.swift @@ -0,0 +1,142 @@ +import Foundation +import SystemConfiguration + +private final class CorporateConnectivityChangeCallbackBox: @unchecked Sendable { + weak var owner: SystemCorporateConnectivityChangeMonitor? + + init(owner: SystemCorporateConnectivityChangeMonitor) { + self.owner = owner + } +} + +public protocol CorporateConnectivitySystemChangeMonitoring: Sendable { + func start(onChange: @escaping @Sendable () -> Void) throws + func stop() +} + +/// Event-driven observation of the macOS dynamic-store keys that change when Wi-Fi, a packet +/// tunnel, split DNS, PAC, or the default route changes. The reconciler keeps its timer as a +/// fallback; this monitor removes the normal polling delay from VPN recovery. +public final class SystemCorporateConnectivityChangeMonitor: + CorporateConnectivitySystemChangeMonitoring, @unchecked Sendable +{ + private let callbackQueue: DispatchQueue + private let lock = NSLock() + private var store: SCDynamicStore? + private var onChange: (@Sendable () -> Void)? + private var activationID: UInt64 = 0 + + public init( + callbackQueue: DispatchQueue = DispatchQueue( + label: "dev.dory.corporate-connectivity.dynamic-store" + ) + ) { + self.callbackQueue = callbackQueue + } + + public func start(onChange: @escaping @Sendable () -> Void) throws { + lock.lock() + guard store == nil else { + lock.unlock() + return + } + activationID &+= 1 + let requestedActivationID = activationID + self.onChange = onChange + lock.unlock() + + let callbackBox = CorporateConnectivityChangeCallbackBox(owner: self) + var context = SCDynamicStoreContext( + version: 0, + info: Unmanaged.passUnretained(callbackBox).toOpaque(), + retain: { info in + _ = Unmanaged + .fromOpaque(info).retain() + return info + }, + release: { info in + Unmanaged + .fromOpaque(info).release() + }, + copyDescription: nil + ) + guard let created = SCDynamicStoreCreate( + nil, + "dev.dory.corporate-connectivity" as CFString, + { _, _, info in + guard let info else { return } + Unmanaged + .fromOpaque(info).takeUnretainedValue().owner?.notify() + }, + &context + ) else { + clearCallback(activationID: requestedActivationID) + throw CorporateConnectivityError.unavailable( + "could not create the macOS network change monitor" + ) + } + let keys = [ + "State:/Network/Global/IPv4", + "State:/Network/Global/IPv6", + "State:/Network/Global/DNS", + "State:/Network/Global/Proxies", + ] as CFArray + let patterns = [ + "State:/Network/Service/.*/DNS", + "State:/Network/Service/.*/IPv4", + "State:/Network/Service/.*/IPv6", + "State:/Network/Interface/.*/IPv4", + "State:/Network/Interface/.*/IPv6", + ] as CFArray + guard SCDynamicStoreSetNotificationKeys(created, keys, patterns), + SCDynamicStoreSetDispatchQueue(created, callbackQueue) else { + SCDynamicStoreSetDispatchQueue(created, nil) + clearCallback(activationID: requestedActivationID) + throw CorporateConnectivityError.unavailable( + "could not subscribe to macOS network changes: " + + String(cString: SCErrorString(SCError())) + ) + } + + lock.lock() + if store == nil, + activationID == requestedActivationID, + self.onChange != nil { + store = created + lock.unlock() + } else { + lock.unlock() + SCDynamicStoreSetDispatchQueue(created, nil) + } + } + + public func stop() { + lock.lock() + let current = store + store = nil + onChange = nil + activationID &+= 1 + lock.unlock() + if let current { + SCDynamicStoreSetDispatchQueue(current, nil) + } + } + + private func notify() { + lock.lock() + let callback = onChange + let active = store != nil + lock.unlock() + if active { callback?() } + } + + private func clearCallback(activationID expected: UInt64) { + lock.lock() + if activationID == expected { + onChange = nil + } + lock.unlock() + } + + deinit { stop() } +} diff --git a/dory-core-swift/Tests/DorydKitTests/CorporateConnectivityTests.swift b/dory-core-swift/Tests/DorydKitTests/CorporateConnectivityTests.swift index 321d022e..910a6d06 100644 --- a/dory-core-swift/Tests/DorydKitTests/CorporateConnectivityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/CorporateConnectivityTests.swift @@ -302,6 +302,99 @@ final class CorporateConnectivityTests: XCTestCase { XCTAssertEqual(restored?["httpProxy"] as? String, "http://before.test:3128") } + func testDynamicStoreChangeTriggersImmediateVPNReconciliation() throws { + let home = try temporaryHome("dynamic-vpn") + defer { try? FileManager.default.removeItem(atPath: home) } + let runner = MutableCorporateCommandRunner(outputs: systemCommandOutputs( + interfaces: "lo0 en0\n", + dns: "", + route: "gateway: 10.0.0.1\ninterface: en0\n" + )) + let monitor = RecordingCorporateSystemChangeMonitor() + let reconciler = CorporateConnectivityReconciler( + home: home, + inspector: CorporateConnectivitySystemInspector(runner: runner), + prober: CorporateConnectivityProber(runner: runner), + systemChangeMonitor: monitor, + interval: 30 + ) + defer { reconciler.stop() } + + let applied = reconciler.apply(validProfile(), runProbes: false) + XCTAssertTrue(applied.valid, applied.validationErrors.joined(separator: "; ")) + XCTAssertTrue(applied.system.tunnelInterfaces.isEmpty) + reconciler.start() + + runner.replace(outputs: systemCommandOutputs( + interfaces: "lo0 en0 utun7\n", + dns: """ + resolver #1 + nameserver[0] : 10.20.0.53 + if_index : 18 (utun7) + flags : Scoped + order : 100 + """, + route: "gateway: 10.20.0.1\ninterface: utun7\n" + )) + monitor.fire() + + let deadline = Date().addingTimeInterval(1.5) + var recovered: CorporateConnectivityStatus? + while Date() < deadline { + if let status = reconciler.cachedStatus(), + status.system.tunnelInterfaces == ["utun7"] { + recovered = status + break + } + Thread.sleep(forTimeInterval: 0.02) + } + XCTAssertEqual(recovered?.system.defaultInterface, "utun7") + XCTAssertEqual(recovered?.system.dnsResolvers.first?.interface, "utun7") + XCTAssertTrue(recovered?.applied == true) + } + + func testSystemChangeMonitorStartAndStopAreIdempotent() throws { + let monitor = SystemCorporateConnectivityChangeMonitor() + try monitor.start(onChange: {}) + try monitor.start(onChange: {}) + monitor.stop() + monitor.stop() + } + + private func systemCommandOutputs( + interfaces: String, + dns: String, + route: String + ) -> [String: HealthCommandOutput] { + [ + "/usr/sbin/scutil --proxy": HealthCommandOutput( + exitCode: 0, + stdout: " {\n}", + stderr: "" + ), + "/usr/sbin/scutil --dns": HealthCommandOutput( + exitCode: 0, + stdout: dns, + stderr: "" + ), + "/sbin/route -n get default": HealthCommandOutput( + exitCode: 0, + stdout: route, + stderr: "" + ), + "/sbin/ifconfig -l": HealthCommandOutput( + exitCode: 0, + stdout: interfaces, + stderr: "" + ), + "/usr/sbin/netstat -rn -f inet": HealthCommandOutput( + exitCode: 0, + stdout: "", + stderr: "" + ), + ] + } + private func validProfile() -> CorporateConnectivityProfile { CorporateConnectivityProfile( enabled: true, @@ -389,3 +482,56 @@ private final class FixtureCommandRunner: HealthCommandRunning, @unchecked Senda ?? HealthCommandOutput(exitCode: 127, stdout: "", stderr: "missing fixture") } } + +private final class MutableCorporateCommandRunner: HealthCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var outputs: [String: HealthCommandOutput] + + init(outputs: [String: HealthCommandOutput]) { + self.outputs = outputs + } + + func replace(outputs: [String: HealthCommandOutput]) { + lock.lock() + self.outputs = outputs + lock.unlock() + } + + func run( + executablePath: String, + arguments: [String], + environment _: [String: String], + timeout _: TimeInterval + ) -> HealthCommandOutput { + lock.lock() + defer { lock.unlock() } + return outputs[([executablePath] + arguments).joined(separator: " ")] + ?? HealthCommandOutput(exitCode: 127, stdout: "", stderr: "missing fixture") + } +} + +private final class RecordingCorporateSystemChangeMonitor: + CorporateConnectivitySystemChangeMonitoring, @unchecked Sendable +{ + private let lock = NSLock() + private var callback: (@Sendable () -> Void)? + + func start(onChange: @escaping @Sendable () -> Void) throws { + lock.lock() + callback = onChange + lock.unlock() + } + + func stop() { + lock.lock() + callback = nil + lock.unlock() + } + + func fire() { + lock.lock() + let current = callback + lock.unlock() + current?() + } +} From 5c0140735daaab7b1e5c6dbdfb4c4cfc9eebc318 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 06:35:46 +0000 Subject: [PATCH 261/338] feat(vm): preflight snapshot export storage --- .../Sources/DorydKit/MachineManager.swift | 123 +++++++++++++++++- .../DorydKitTests/MachineManagerTests.swift | 49 +++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 8922688f..d82abb07 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1008,6 +1008,7 @@ public final class MachineManager: @unchecked Sendable { private let failureStore: DoryMachineFailureStore private let flightRecorderStore: DoryMachineFlightRecorderStore private let launchPolicy: DoryMachineLaunchPolicy + private var storageCapacityProvider: @Sendable (String) throws -> UInt64 private let lifecycleJournalStore: DoryOperationJournalStore? private let lifecycleJournalInitializationError: String? private let operationLock = NSRecursiveLock() @@ -1069,6 +1070,9 @@ public final class MachineManager: @unchecked Sendable { self.vzLifecycleController = vzLifecycleController self.agentConnector = agentConnector self.processStarter = processStarter + self.storageCapacityProvider = { path in + try MachineManager.availableStorageCapacity(forDestinationPath: path) + } self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory) self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore( root: configuration.stateDirectory @@ -5439,7 +5443,11 @@ public final class MachineManager: @unchecked Sendable { ) } do { - try MachineSnapshotBundle.write(snapshot: snapshot, toPath: path) + try MachineSnapshotBundle.write( + snapshot: snapshot, + toPath: path, + availableCapacity: storageCapacityProvider + ) } catch let error as MachineManagerError { throw error } catch { @@ -11694,8 +11702,47 @@ public final class MachineManager: @unchecked Sendable { shareAuthorityPreSpawnTestHook = hook operationLock.unlock() } + + func installStorageCapacityProviderForTesting( + _ provider: @escaping @Sendable (String) throws -> UInt64 + ) { + operationLock.lock() + storageCapacityProvider = provider + operationLock.unlock() + } #endif + private static func availableStorageCapacity(forDestinationPath path: String) throws -> UInt64 { + var candidate = URL(fileURLWithPath: path) + .standardizedFileURL + .deletingLastPathComponent() + let fileManager = FileManager.default + while !fileManager.fileExists(atPath: candidate.path) { + let parent = candidate.deletingLastPathComponent() + guard parent.path != candidate.path else { + throw MachineManagerError.persistence( + "could not locate the machine export destination filesystem" + ) + } + candidate = parent + } + var filesystem = statfs() + guard statfs(candidate.path, &filesystem) == 0 else { + throw MachineManagerError.persistence( + "could not inspect machine export storage: \(String(cString: strerror(errno)))" + ) + } + let (available, overflow) = UInt64(filesystem.f_bavail).multipliedReportingOverflow( + by: UInt64(filesystem.f_bsize) + ) + guard !overflow else { + throw MachineManagerError.persistence( + "machine export storage capacity exceeds supported accounting" + ) + } + return available + } + private static func recoverInterruptedLifecycleOperations( store: DoryOperationJournalStore?, configuration: MachineManagerConfiguration @@ -12241,8 +12288,13 @@ private enum MachineSnapshotBundle { private static let digestByteCount = 32 private static let maximumMetadataLength: UInt64 = 16 * 1024 * 1024 private static let copyChunkSize = 4 * 1024 * 1024 + private static let capacitySafetyBytes: UInt64 = 256 * 1024 * 1024 - static func write(snapshot: DoryMachineSnapshot, toPath path: String) throws { + static func write( + snapshot: DoryMachineSnapshot, + toPath path: String, + availableCapacity: @Sendable (String) throws -> UInt64 + ) throws { try MachineManager.validateSnapshotRuntimeIdentity(snapshot) try MachineManager.validateSnapshotArtifactEvidence(snapshot) let rootfs = try openRegularFileForReading(path: snapshot.rootfsPath, requirePrivateOwnership: true) @@ -12304,6 +12356,27 @@ private enum MachineSnapshotBundle { throw MachineManagerError.persistence("invalid dory machine bundle metadata") } let metadataDigest = Data(SHA256.hash(data: metadata)) + let bundleByteCount = try requiredBundleByteCount( + metadataLength: UInt64(metadata.count), + rootfsLength: rootfsLength, + kernelLength: kernelLength, + machineIdentifierLength: machineIdentifierLength, + nvramLength: nvramLength + ) + let safetyBytes = max(capacitySafetyBytes, bundleByteCount / 10) + let requiredCapacity = try checkedAdd( + bundleByteCount, + safetyBytes, + failure: "machine export capacity exceeds supported accounting" + ) + let available = try availableCapacity(path) + guard available >= requiredCapacity else { + throw MachineManagerError.persistence( + "insufficient host storage for machine export " + + "(need \(requiredCapacity) bytes including safety reserve, " + + "have \(available))" + ) + } let outputURL = URL(fileURLWithPath: path) let parent = outputURL.deletingLastPathComponent() @@ -12410,6 +12483,52 @@ private enum MachineSnapshotBundle { } } + private static func requiredBundleByteCount( + metadataLength: UInt64, + rootfsLength: UInt64, + kernelLength: UInt64, + machineIdentifierLength: UInt64?, + nvramLength: UInt64? + ) throws -> UInt64 { + let includesFirmware = machineIdentifierLength != nil || nvramLength != nil + guard (machineIdentifierLength == nil) == (nvramLength == nil) else { + throw MachineManagerError.persistence( + "machine export firmware capacity evidence is incomplete" + ) + } + let artifactCount: UInt64 = includesFirmware ? 5 : 3 + var total = UInt64(includesFirmware ? v4Magic.count : v3Magic.count) + total = try checkedAdd( + total, + artifactCount * UInt64(lengthByteCount + digestByteCount), + failure: "machine export capacity exceeds supported accounting" + ) + for length in [ + metadataLength, + rootfsLength, + kernelLength, + machineIdentifierLength ?? 0, + nvramLength ?? 0, + ] { + total = try checkedAdd( + total, + length, + failure: "machine export capacity exceeds supported accounting" + ) + } + return total + } + + private static func checkedAdd( + _ lhs: UInt64, + _ rhs: UInt64, + failure: String + ) throws -> UInt64 { + let (value, overflow) = lhs.addingReportingOverflow(rhs) + guard !overflow else { throw MachineManagerError.persistence(failure) } + return value + } + static func verifyDescriptor( fromPath path: String ) throws -> (snapshot: DoryMachineSnapshot, contentID: Data) { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 7fb06965..719c1b91 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3513,6 +3513,55 @@ final class MachineManagerTests: XCTestCase { XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: exportDirectory), ["dev.dorymachine"]) } + func testSnapshotExportRejectsLowStorageBeforeCreatingTemporaryBundle() throws { + let base = "/tmp/dory-machine-export-low-storage-\(getpid())-\(UInt32.random(in: 0.. Date: Sat, 22 Aug 2026 06:38:09 +0000 Subject: [PATCH 262/338] feat(vm): preflight snapshot import storage --- .../Sources/DorydKit/MachineManager.swift | 68 +++++++++++++++---- .../DorydKitTests/MachineManagerTests.swift | 47 +++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d82abb07..a6070f30 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -5600,6 +5600,12 @@ public final class MachineManager: @unchecked Sendable { snapshot.address = nil snapshot.shares = [] snapshot.environment = [:] + try MachineSnapshotBundle.requireArtifactCapacity( + artifactByteCount: bundle.artifactByteCount, + destinationPath: snapshotDirectory(machineID: snapshot.machineID), + operation: "machine import", + availableCapacity: storageCapacityProvider + ) importedMachineID = snapshot.machineID try ensurePrivateSnapshotDirectory(machineID: snapshot.machineID) snapshot.id = try availableImportedSnapshotID( @@ -12363,20 +12369,12 @@ private enum MachineSnapshotBundle { machineIdentifierLength: machineIdentifierLength, nvramLength: nvramLength ) - let safetyBytes = max(capacitySafetyBytes, bundleByteCount / 10) - let requiredCapacity = try checkedAdd( - bundleByteCount, - safetyBytes, - failure: "machine export capacity exceeds supported accounting" + try requireArtifactCapacity( + artifactByteCount: bundleByteCount, + destinationPath: path, + operation: "machine export", + availableCapacity: availableCapacity ) - let available = try availableCapacity(path) - guard available >= requiredCapacity else { - throw MachineManagerError.persistence( - "insufficient host storage for machine export " - + "(need \(requiredCapacity) bytes including safety reserve, " - + "have \(available))" - ) - } let outputURL = URL(fileURLWithPath: path) let parent = outputURL.deletingLastPathComponent() @@ -12529,9 +12527,31 @@ private enum MachineSnapshotBundle { return value } + static func requireArtifactCapacity( + artifactByteCount: UInt64, + destinationPath: String, + operation: String, + availableCapacity: @Sendable (String) throws -> UInt64 + ) throws { + let safetyBytes = max(capacitySafetyBytes, artifactByteCount / 10) + let requiredCapacity = try checkedAdd( + artifactByteCount, + safetyBytes, + failure: "\(operation) capacity exceeds supported accounting" + ) + let available = try availableCapacity(destinationPath) + guard available >= requiredCapacity else { + throw MachineManagerError.persistence( + "insufficient host storage for \(operation) " + + "(need \(requiredCapacity) bytes including safety reserve, " + + "have \(available))" + ) + } + } + static func verifyDescriptor( fromPath path: String - ) throws -> (snapshot: DoryMachineSnapshot, contentID: Data) { + ) throws -> (snapshot: DoryMachineSnapshot, contentID: Data, artifactByteCount: UInt64) { let input = try openRegularFileForReading(path: path) defer { try? input.close() } let before = try fileSnapshot(input) @@ -12562,7 +12582,25 @@ private enum MachineSnapshotBundle { "machine bundle changed during import assessment" ) } - return (header.snapshot, header.contentID) + var artifactByteCount = try checkedAdd( + header.rootfsLength, + header.kernelLength, + failure: "machine import capacity exceeds supported accounting" + ) + if let machineIdentifierLength = header.machineIdentifierLength, + let nvramLength = header.nvramLength { + artifactByteCount = try checkedAdd( + artifactByteCount, + machineIdentifierLength, + failure: "machine import capacity exceeds supported accounting" + ) + artifactByteCount = try checkedAdd( + artifactByteCount, + nvramLength, + failure: "machine import capacity exceeds supported accounting" + ) + } + return (header.snapshot, header.contentID, artifactByteCount) } static func extractArtifacts( diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 719c1b91..2e1e5810 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -1868,6 +1868,53 @@ final class MachineManagerTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: "\(base)/machines/dev")) } + func testImportSnapshotRejectsLowStorageBeforeCreatingManagedNamespace() throws { + let base = "/tmp/dory-machine-import-low-storage-\(getpid())-\(UInt32.random(in: 0.. Date: Sat, 22 Aug 2026 06:42:38 +0000 Subject: [PATCH 263/338] feat(vm): admit snapshot fallback storage --- .../Sources/DorydKit/MachineManager.swift | 55 ++++++++++++++++--- .../DorydKitTests/MachineManagerTests.swift | 41 ++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index a6070f30..d3efcee0 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -1041,6 +1041,7 @@ public final class MachineManager: @unchecked Sendable { private var activeDirectWorkspaceMutationLocks: [String: MachineManagerDirectMutationRetention] = [:] private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)? + private var forceSnapshotCopyFallback = false #if DEBUG private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)? private var shareAuthorityPreSpawnTestHook: (@Sendable () throws -> Void)? @@ -4606,23 +4607,32 @@ public final class MachineManager: @unchecked Sendable { var publishedNVRAM = false do { try advanceLifecycleToPublishing(lifecycle) - try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsPath) + try cloneOrCopySnapshotArtifact( + source: machine.rootfsPath, + destination: rootfsPath + ) publishedRootfs = true #if DEBUG try injectLifecycleFault(.snapshotAfterRootfs) #endif - try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelPath) + try cloneOrCopySnapshotArtifact( + source: machine.kernelPath, + destination: kernelPath + ) publishedKernel = true if machine.bootMode == .efi { guard let liveMachineIdentifierPath, let liveNVRAMPath else { throw MachineManagerError.persistence("EFI firmware state is unavailable") } - try Self.cloneOrCopyFile( + try cloneOrCopySnapshotArtifact( source: liveMachineIdentifierPath, destination: machineIdentifierPath ) publishedMachineIdentifier = true - try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath) + try cloneOrCopySnapshotArtifact( + source: liveNVRAMPath, + destination: nvramPath + ) publishedNVRAM = true } // Validate the copies against the pre-publish authority before publishing metadata. @@ -4686,6 +4696,16 @@ public final class MachineManager: @unchecked Sendable { return snapshot } + private func cloneOrCopySnapshotArtifact(source: String, destination: String) throws { + try Self.cloneOrCopyFile( + source: source, + destination: destination, + copyCapacityProvider: storageCapacityProvider, + capacityOperation: "machine snapshot", + forceCopyFallback: forceSnapshotCopyFallback + ) + } + private func freezeGuestForSnapshotIfSupported( id: String, resolvedPlan: DoryResolvedMachinePlan? @@ -9960,9 +9980,13 @@ public final class MachineManager: @unchecked Sendable { replaceExisting: Bool = false, requiresCopyOnWrite: Bool = false, expectedSHA256: String? = nil, - expectedByteCount: UInt64? = nil + expectedByteCount: UInt64? = nil, + copyCapacityProvider: (@Sendable (String) throws -> UInt64)? = nil, + capacityOperation: String? = nil, + forceCopyFallback: Bool = false ) throws { guard (expectedSHA256 == nil) == (expectedByteCount == nil), + (copyCapacityProvider == nil) == (capacityOperation == nil), !requiresCopyOnWrite || expectedSHA256 != nil else { throw MachineManagerError.persistence("invalid clone artifact authority") } @@ -9997,8 +10021,11 @@ public final class MachineManager: @unchecked Sendable { throw MachineManagerError.persistence("artifact source is not a nonempty regular file") } do { - if fclonefileat(sourceDescriptor, parentDescriptor, temporaryName, 0) != 0 { - let cloneError = errno + let cloneResult = forceCopyFallback + ? -1 + : fclonefileat(sourceDescriptor, parentDescriptor, temporaryName, 0) + if cloneResult != 0 { + let cloneError = forceCopyFallback ? ENOTSUP : errno _ = unlinkat(parentDescriptor, temporaryName, 0) guard !requiresCopyOnWrite else { throw MachineManagerError.persistence( @@ -10006,6 +10033,14 @@ public final class MachineManager: @unchecked Sendable { + String(cString: strerror(cloneError)) ) } + if let copyCapacityProvider, let capacityOperation { + try MachineSnapshotBundle.requireArtifactCapacity( + artifactByteCount: UInt64(sourceInfo.st_size), + destinationPath: destination, + operation: capacityOperation, + availableCapacity: copyCapacityProvider + ) + } let destinationDescriptor = openat( parentDescriptor, temporaryName, @@ -11716,6 +11751,12 @@ public final class MachineManager: @unchecked Sendable { storageCapacityProvider = provider operationLock.unlock() } + + func forceSnapshotCopyFallbackForTesting() { + operationLock.lock() + forceSnapshotCopyFallback = true + operationLock.unlock() + } #endif private static func availableStorageCapacity(forDestinationPath path: String) throws -> UInt64 { diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index 2e1e5810..ebea8a89 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -3642,6 +3642,47 @@ final class MachineManagerTests: XCTestCase { XCTAssertEqual(reloaded.list().map(\.id), ["dev"]) } + func testSnapshotStorageAdmissionOnlyChargesFallbackCopies() throws { + let base = "/tmp/dory-machine-snapshot-storage-\(getpid())-\(UInt32.random(in: 0.. Date: Sat, 22 Aug 2026 06:51:34 +0000 Subject: [PATCH 264/338] feat(vm): qualify Intel Linux translation --- .../DoryVirtualMachineCapabilities.swift | 37 +++++++++++++- .../DoryVirtualMachineDefinition.swift | 3 ++ ...yDaemonVirtualMachineRuntimePlanning.swift | 2 + .../DoryVirtualMachineCapabilitiesTests.swift | 48 +++++++++++++++++++ .../DoryVirtualMachineDefinitionTests.swift | 16 +++++++ ...onVirtualMachineRuntimePlanningTests.swift | 3 ++ 6 files changed, 108 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 1ee1b905..a3febfee 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -248,6 +248,8 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa public var clockSynchronization: Bool public var dynamicDisplay: Bool public var gracefulShutdown: Bool + /// Exact authorization to expose the host's installed Intel Linux translation runtime. + public var intelApplicationTranslation: Bool /// Exact permission for user-approved removable USB hotplug. Host bus identifiers are /// deliberately excluded because they are ephemeral runtime selections. public var removableUSBHotplug: Bool @@ -266,6 +268,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa clockSynchronization: Bool = false, dynamicDisplay: Bool = false, gracefulShutdown: Bool = false, + intelApplicationTranslation: Bool = false, removableUSBHotplug: Bool = false ) { self.networkAttachment = networkAttachment @@ -281,6 +284,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa self.clockSynchronization = clockSynchronization self.dynamicDisplay = dynamicDisplay self.gracefulShutdown = gracefulShutdown + self.intelApplicationTranslation = intelApplicationTranslation self.removableUSBHotplug = removableUSBHotplug } @@ -298,6 +302,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa case clockSynchronization case dynamicDisplay case gracefulShutdown + case intelApplicationTranslation case removableUSBHotplug } @@ -328,6 +333,10 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa clockSynchronization = try container.decode(Bool.self, forKey: .clockSynchronization) dynamicDisplay = try container.decode(Bool.self, forKey: .dynamicDisplay) gracefulShutdown = try container.decode(Bool.self, forKey: .gracefulShutdown) + intelApplicationTranslation = try container.decodeIfPresent( + Bool.self, + forKey: .intelApplicationTranslation + ) ?? false removableUSBHotplug = try container.decodeIfPresent( Bool.self, forKey: .removableUSBHotplug @@ -349,6 +358,9 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa try container.encode(clockSynchronization, forKey: .clockSynchronization) try container.encode(dynamicDisplay, forKey: .dynamicDisplay) try container.encode(gracefulShutdown, forKey: .gracefulShutdown) + if intelApplicationTranslation { + try container.encode(true, forKey: .intelApplicationTranslation) + } if removableUSBHotplug { try container.encode(true, forKey: .removableUSBHotplug) } @@ -459,6 +471,7 @@ public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, H case clockSynchronizationUnsupported = "clock-synchronization-unsupported" case dynamicDisplayUnsupported = "dynamic-display-unsupported" case gracefulShutdownUnsupported = "graceful-shutdown-unsupported" + case intelApplicationTranslationUnavailable = "intel-application-translation-unavailable" case removableUSBHotplugUnsupported = "removable-usb-hotplug-unsupported" case runtimeQualificationUnavailable = "runtime-qualification-unavailable" case runtimeQualificationEvidenceInvalid = "runtime-qualification-evidence-invalid" @@ -1022,6 +1035,9 @@ public struct DoryAppleSiliconHostFacts: Codable, Sendable, Equatable, Hashable public var doryMacOSBackendQualified: Bool public var metalAvailable: Bool public var doryAcceleratedRendererAvailable: Bool + /// `nil` means the host observation predates explicit Rosetta-for-Linux probing and is + /// therefore not sufficient to authorize application translation. + public var linuxIntelApplicationTranslationAvailable: Bool? public var runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext? public init( @@ -1041,6 +1057,7 @@ public struct DoryAppleSiliconHostFacts: Codable, Sendable, Equatable, Hashable doryMacOSBackendQualified: Bool, metalAvailable: Bool, doryAcceleratedRendererAvailable: Bool, + linuxIntelApplicationTranslationAvailable: Bool? = nil, runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext? = nil ) { self.macOSMajorVersion = macOSMajorVersion @@ -1059,6 +1076,8 @@ public struct DoryAppleSiliconHostFacts: Codable, Sendable, Equatable, Hashable self.doryMacOSBackendQualified = doryMacOSBackendQualified self.metalAvailable = metalAvailable self.doryAcceleratedRendererAvailable = doryAcceleratedRendererAvailable + self.linuxIntelApplicationTranslationAvailable = + linuxIntelApplicationTranslationAvailable self.runtimeQualificationContext = runtimeQualificationContext } } @@ -1200,7 +1219,11 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) { return unavailable } - if let unavailable = deviceCapabilityFailure(for: request, tier: supportTier) { + if let unavailable = deviceCapabilityFailure( + for: request, + host: host, + tier: supportTier + ) { return unavailable } @@ -1687,6 +1710,7 @@ public enum DoryAppleSiliconCapabilityEvaluator { private static func deviceCapabilityFailure( for request: DoryVirtualMachineCapabilityRequest, + host: DoryAppleSiliconHostFacts, tier: DoryCapabilitySupportTier ) -> DoryCapabilityAvailability? { let devices = request.devices @@ -1837,6 +1861,17 @@ public enum DoryAppleSiliconCapabilityEvaluator { ) } } + if devices.intelApplicationTranslation { + guard request.guest.family == .linux, + request.backend == .appleVirtualizationFramework, + host.linuxIntelApplicationTranslationAvailable == true else { + return unavailable( + tier: tier, + code: .intelApplicationTranslationUnavailable, + message: "Intel Linux application translation is not installed or is unavailable for the selected guest/backend contract." + ) + } + } if devices.removableUSBHotplug, !(request.guest.family == .linux && request.backend == .doryHypervisor) { return unavailable( diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index 166a3267..f545e0ef 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -336,6 +336,9 @@ public enum DoryVMGuestIntegration: String, Codable, Sendable, CaseIterable { case clockSynchronization = "clock-synchronization" case dynamicDisplay = "dynamic-display" case gracefulShutdown = "graceful-shutdown" + /// Exposes Apple's Rosetta runtime to an ARM64 Linux guest so it can execute Intel Linux + /// applications. Installation remains an explicit host-side user action. + case intelApplicationTranslation = "intel-application-translation" /// Authorizes user-approved USB hotplug through the guest's versioned USB/IP integration. /// Host device identities remain runtime-only and are never persisted in desired state. case removableUSBHotplug = "removable-usb-hotplug" diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 1061b8a2..4559c8cd 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -544,6 +544,8 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda clockSynchronization: definition.integrations.contains(.clockSynchronization), dynamicDisplay: definition.integrations.contains(.dynamicDisplay), gracefulShutdown: definition.integrations.contains(.gracefulShutdown), + intelApplicationTranslation: + definition.integrations.contains(.intelApplicationTranslation), removableUSBHotplug: definition.integrations.contains(.removableUSBHotplug) ) } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 7c94f131..f2c505c9 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -67,6 +67,7 @@ struct VirtualMachineCapabilitiesTests { JSONSerialization.jsonObject(with: data) as? [String: Any] ) #expect(object["removableUSBHotplug"] == nil) + #expect(object["intelApplicationTranslation"] == nil) object.removeValue(forKey: "networkInterface") let historical = try JSONSerialization.data(withJSONObject: object) @@ -77,6 +78,7 @@ struct VirtualMachineCapabilitiesTests { #expect(decoded.networkInterface == nil) #expect(decoded.clipboardPolicy == nil) #expect(!decoded.removableUSBHotplug) + #expect(!decoded.intelApplicationTranslation) object.removeValue(forKey: "keyboard") let truncated = try JSONSerialization.data(withJSONObject: object) @@ -88,6 +90,52 @@ struct VirtualMachineCapabilitiesTests { } } + @Test("Intel application translation is exact, installed, Linux, and VZ-only") + func intelApplicationTranslationCapability() { + let devices = DoryVirtualMachineDeviceCapabilityRequest( + intelApplicationTranslation: true + ) + var installedHost = Self.provisionedHost + installedHost.linuxIntelApplicationTranslationAvailable = true + + let virtualizationFramework = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .hostAcceleratedDisplay, + host: installedHost, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + let missingHostRuntime = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .hostAcceleratedDisplay, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + let rawHV = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .hostAcceleratedDisplay, + host: installedHost, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + + #expect(virtualizationFramework.availability.isUsable) + #expect(virtualizationFramework.resolvedDevices?.intelApplicationTranslation == true) + #expect(missingHostRuntime.availability.reason?.code + == .intelApplicationTranslationUnavailable) + #expect(rawHV.availability.reason?.code == .intelApplicationTranslationUnavailable) + #expect(!devices.matchesRuntimeQualificationContract(.minimumBootable)) + } + @Test("removable USB hotplug is exact and raw-HV-only") func removableUSBHotplugCapability() { let devices = DoryVirtualMachineDeviceCapabilityRequest(removableUSBHotplug: true) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 5e81ec83..102c351e 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -163,6 +163,22 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded.isValid) } + @Test("Intel application translation integration round-trips") + func intelApplicationTranslationRoundTrip() throws { + var original = linuxDefinition() + original.integrations.append(.intelApplicationTranslation) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + DoryVirtualMachineDefinition.self, + from: data + ) + + #expect(decoded.integrations.contains(.intelApplicationTranslation)) + #expect(decoded == original) + #expect(decoded.isValid) + } + @Test("sandbox lifecycle and credential grants are closed bounded intent") func sandboxPolicyValidation() throws { var definition = linuxDefinition() diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index c6bfc721..4abacb39 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -33,6 +33,9 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { definition.integrations.append(.removableUSBHotplug) #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) .removableUSBHotplug) + definition.integrations.append(.intelApplicationTranslation) + #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) + .intelApplicationTranslation) definition.display = .disabled #expect(DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition).display == nil) From ea2cbea3e058eac1eea90521fd79ca9d2dd5ee38 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:01:29 +0000 Subject: [PATCH 265/338] feat(vm): attach Intel translation runtime --- dory-core-swift/Package.swift | 1 + .../Sources/DoryVMMKit/DoryVMM.swift | 53 +++++++++++- ...yDaemonVirtualMachineProductionTrust.swift | 8 ++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 82 +++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Package.swift b/dory-core-swift/Package.swift index fba709da..d87c34cc 100644 --- a/dory-core-swift/Package.swift +++ b/dory-core-swift/Package.swift @@ -37,6 +37,7 @@ let package = Package( .linkedFramework("Network"), .linkedFramework("Security"), .linkedFramework("SystemConfiguration"), + .linkedFramework("Virtualization"), ] ), .target( diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 381c9010..7591849f 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -372,12 +372,31 @@ public enum DoryVZMachineError: Error, Sendable, CustomStringConvertible { public enum DoryVZConfigurationBuilder { private static let bootConfigTag = "dorycfg" private static let bootConfigGuestPath = "/mnt/dory-config" + private static let intelApplicationTranslationTag = "rosetta" public static func makeConfiguration( spec: DoryVZMachineSpec, serialOutput: FileHandle?, serialInput: FileHandle? = nil, networkAttachment: VZNetworkDeviceAttachment? = nil + ) throws -> VZVirtualMachineConfiguration { + try makeConfigurationForTesting( + spec: spec, + serialOutput: serialOutput, + serialInput: serialInput, + networkAttachment: networkAttachment, + intelApplicationTranslationShareProvider: nil + ) + } + + /// Internal-only injection point for deterministic framework-independent tests. Production + /// callers cannot substitute the resolved Rosetta share. + static func makeConfigurationForTesting( + spec: DoryVZMachineSpec, + serialOutput: FileHandle?, + serialInput: FileHandle?, + networkAttachment: VZNetworkDeviceAttachment?, + intelApplicationTranslationShareProvider: (() throws -> VZDirectoryShare)? ) throws -> VZVirtualMachineConfiguration { let fileManager = FileManager.default let (memorySize, memoryOverflow) = spec.memoryMB.multipliedReportingOverflow(by: 1024 * 1024) @@ -607,7 +626,7 @@ public enum DoryVZConfigurationBuilder { let attachedDirectoryShares = spec.resolvedDevices?.directorySharing == false ? directoryShares.filter { $0.tag == bootConfigTag } : directoryShares - configuration.directorySharingDevices = try attachedDirectoryShares.map { share in + var fileSystemDevices = try attachedDirectoryShares.map { share in try share.validate() var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: share.hostPath, isDirectory: &isDirectory), @@ -628,6 +647,29 @@ public enum DoryVZConfigurationBuilder { device.share = shareConfig return device } + if spec.resolvedDevices?.intelApplicationTranslation == true { + guard !directoryShares.contains(where: { + $0.tag == intelApplicationTranslationTag + }) else { + throw DoryVZMachineError.validation( + "machine share tag '\(intelApplicationTranslationTag)' is reserved" + ) + } + let provider = intelApplicationTranslationShareProvider + ?? installedIntelApplicationTranslationShare + let device = VZVirtioFileSystemDeviceConfiguration( + tag: intelApplicationTranslationTag + ) + do { + device.share = try provider() + } catch { + throw DoryVZMachineError.validation( + "Intel Linux application translation is unavailable: \(error)" + ) + } + fileSystemDevices.append(device) + } + configuration.directorySharingDevices = fileSystemDevices do { let rootURL = URL(fileURLWithPath: spec.rootfsPath) @@ -701,6 +743,15 @@ public enum DoryVZConfigurationBuilder { return configuration } + private static func installedIntelApplicationTranslationShare() throws -> VZDirectoryShare { + guard VZLinuxRosettaDirectoryShare.availability == .installed else { + throw DoryVZMachineError.validation( + "Rosetta support for Linux is not installed on this host" + ) + } + return try VZLinuxRosettaDirectoryShare() + } + static func stableNetworkMACAddress(machineID: String) -> String { // FNV-1a provides a deterministic identity without introducing a cryptographic trust // claim. Set the locally administered bit and clear multicast. diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift index b9e7cd4f..c433626b 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift @@ -3,6 +3,7 @@ import Darwin import DoryOperations import Foundation import Security +@preconcurrency import Virtualization public enum DoryDaemonVirtualMachineProductionTrustReadinessCode: String, Sendable, Equatable @@ -408,6 +409,7 @@ struct DoryDaemonProductionHostObservation: Sendable, Equatable { var virtualizationFrameworkAvailable: Bool var hypervisorFrameworkAvailable: Bool var metalAvailable: Bool + var linuxIntelApplicationTranslationAvailable: Bool = false var resources: DoryVMHostResources } @@ -956,6 +958,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: metalAvailable: host.metalAvailable, doryAcceleratedRendererAvailable: host.metalAvailable && !rawBuild.isEmpty, + linuxIntelApplicationTranslationAvailable: + host.linuxIntelApplicationTranslationAvailable, runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( virtualHardwareABIVersion: 1, @@ -1000,6 +1004,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory: metalAvailable: host.metalAvailable, doryAcceleratedRendererAvailable: host.metalAvailable && !rawBuild.isEmpty, + linuxIntelApplicationTranslationAvailable: + host.linuxIntelApplicationTranslationAvailable, runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext( virtualHardwareABIVersion: 1, @@ -1545,6 +1551,8 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable { metalAvailable: frameworkAvailable( "/System/Library/Frameworks/Metal.framework/Metal" ), + linuxIntelApplicationTranslationAvailable: + VZLinuxRosettaDirectoryShare.availability == .installed, resources: DoryVMHostResources( logicalCPUCount: UInt64(process.activeProcessorCount), physicalMemoryBytes: process.physicalMemory, diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 210348e3..e14c1018 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -971,6 +971,88 @@ final class DoryVMMKitTests: XCTestCase { )) } + func testResolvedVZContractAttachesIntelApplicationTranslationShare() throws { + let base = "/tmp/dory-vmm-rosetta-\(getpid())-\(UInt32.random(in: 0.. Date: Sat, 22 Aug 2026 07:01:58 +0000 Subject: [PATCH 266/338] feat(guest): configure Intel application translation --- guest/desktop/apply-update.sh | 7 ++-- guest/desktop/build.sh | 5 ++- .../system/dory-intel-translation.service | 13 ++++++ .../usr/lib/dory/configure-intel-translation | 40 +++++++++++++++++++ guest/desktop/verify-build.sh | 14 +++++++ 5 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/dory-intel-translation.service create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/configure-intel-translation diff --git a/guest/desktop/apply-update.sh b/guest/desktop/apply-update.sh index 6146e75b..720775f4 100755 --- a/guest/desktop/apply-update.sh +++ b/guest/desktop/apply-update.sh @@ -50,7 +50,7 @@ require_package() { # must not turn into an unreviewed, network-dependent distribution upgrade. Validate the small # runtime surface the integration layer needs, then update only Dory-owned files below. Ubuntu, # Debian, and Kali remain responsible for their normal package updates inside the guest. -for package in dconf-cli network-manager openssh-server pipewire spice-vdagent x11-utils zstd; do +for package in binfmt-support dconf-cli network-manager openssh-server pipewire spice-vdagent x11-utils zstd; do require_package "$package" done case "$EXPECTED_DISTRO" in @@ -78,7 +78,8 @@ install -m0755 "$ROOT/dory-agent" /usr/bin/dory-agent chmod 0755 /usr/lib/dory/clipboard /usr/lib/dory/configure-machine /usr/lib/dory/first-boot \ /usr/lib/dory/start-agent /usr/lib/dory/wait-host-configuration \ /usr/lib/dory/configure-display /usr/lib/dory/configure-zram \ - /usr/lib/dory/configure-graphics-backend /usr/lib/dory/dory-vulkan-probe + /usr/lib/dory/configure-graphics-backend /usr/lib/dory/configure-intel-translation \ + /usr/lib/dory/dory-vulkan-probe chmod 0600 /etc/NetworkManager/system-connections/dory-wired.nmconnection chmod 0644 /etc/polkit-1/rules.d/49-dory-passwordless-admin.rules if [ -x /usr/bin/dconf ]; then @@ -102,7 +103,7 @@ case "$EXPECTED_DISTRO" in esac systemctl enable NetworkManager.service NetworkManager-wait-online.service ssh.service \ dory-first-boot.service dory-boot.service dory-desktop-ready.service dory-zram.service \ - dory-graphics-backend.service + dory-graphics-backend.service dory-intel-translation.service systemctl set-default graphical.target systemctl daemon-reload diff --git a/guest/desktop/build.sh b/guest/desktop/build.sh index 3a0d6427..7059045b 100755 --- a/guest/desktop/build.sh +++ b/guest/desktop/build.sh @@ -92,7 +92,7 @@ rustup target add "$TARGET" >/dev/null AGENT="$ROOT/dory-core/target/$TARGET/release/dory-agent" [ -x "$AGENT" ] || { echo "dory-agent was not produced for $TARGET" >&2; exit 1; } -COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,dconf-cli,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,x11-utils,xterm,libgl1-mesa-dri,mesa-vulkan-drivers,mesa-utils,vulkan-tools,spice-vdagent,wl-clipboard,xclip,pipewire-audio,wireplumber,polkitd,pkexec,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends" +COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,dconf-cli,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,x11-utils,xterm,libgl1-mesa-dri,mesa-vulkan-drivers,mesa-utils,vulkan-tools,spice-vdagent,wl-clipboard,xclip,pipewire-audio,wireplumber,polkitd,pkexec,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends,binfmt-support" XFCE_PACKAGES="xfce4,xfce4-terminal,xfce4-notifyd,xfce4-power-manager,lightdm,lightdm-gtk-greeter,mate-polkit,mousepad,ristretto,file-roller" case "$DISTRO" in debian) PACKAGES="$COMMON_PACKAGES,$XFCE_PACKAGES,network-manager-gnome,desktop-base,firefox-esr,evince,galculator" ;; @@ -155,6 +155,7 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \ /rootfs/usr/lib/dory/start-agent /rootfs/usr/lib/dory/wait-host-configuration \ /rootfs/usr/lib/dory/configure-display /rootfs/usr/lib/dory/configure-zram \ /rootfs/usr/lib/dory/configure-graphics-backend \ + /rootfs/usr/lib/dory/configure-intel-translation \ /rootfs/usr/lib/dory/dory-vulkan-probe if [ "$DORY_DESKTOP_DISTRO" = ubuntu ]; then @@ -258,7 +259,7 @@ EOF esac chroot /rootfs systemctl enable ssh.service dory-first-boot.service \ dory-boot.service dory-desktop-ready.service dory-zram.service \ - dory-graphics-backend.service + dory-graphics-backend.service dory-intel-translation.service chroot /rootfs systemctl set-default graphical.target rm -f /rootfs/etc/machine-id /rootfs/var/lib/dbus/machine-id /rootfs/etc/ssh/ssh_host_* diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-intel-translation.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-intel-translation.service new file mode 100644 index 00000000..c4489965 --- /dev/null +++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-intel-translation.service @@ -0,0 +1,13 @@ +[Unit] +Description=Configure Dory Intel Linux application translation +After=local-fs.target systemd-binfmt.service binfmt-support.service +Before=display-manager.service dory-boot.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/dory/configure-intel-translation +RemainAfterExit=yes +TimeoutStartSec=15 + +[Install] +WantedBy=graphical.target diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-intel-translation b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-intel-translation new file mode 100644 index 00000000..23dac44c --- /dev/null +++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-intel-translation @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail + +mountpoint=/run/dory/rosetta +status=/run/dory/intel-application-translation-status +runtime=$mountpoint/rosetta +magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00' +mask='\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' + +install -d -m0755 /run/dory "$mountpoint" + +# This unit is present in every managed image, while the host exposes the reserved share only +# when an exact resolved plan authorizes translation. An absent tag is therefore a clean no-op. +if ! mountpoint -q "$mountpoint"; then + if ! mount -t virtiofs -o ro rosetta "$mountpoint" 2>/dev/null; then + printf 'unavailable\n' > "$status" + chmod 0644 "$status" + exit 0 + fi +fi + +if [ ! -x "$runtime" ]; then + printf 'invalid-runtime\n' > "$status" + chmod 0644 "$status" + echo "Dory: the resolved Rosetta share does not contain an executable runtime" >&2 + exit 1 +fi + +# Re-register on each boot after the ephemeral VirtioFS mount exists. update-binfmts keeps a +# durable description, but systemd-binfmt may run before this host-provided share is mountable. +if /usr/sbin/update-binfmts --display rosetta >/dev/null 2>&1; then + /usr/sbin/update-binfmts --remove rosetta "$runtime" >/dev/null 2>&1 || true +fi +/usr/sbin/update-binfmts --install rosetta "$runtime" \ + --magic "$magic" \ + --mask "$mask" \ + --credentials yes --preserve yes --fix-binary yes + +printf 'ready\n' > "$status" +chmod 0644 "$status" diff --git a/guest/desktop/verify-build.sh b/guest/desktop/verify-build.sh index fe905ec8..ef5fccf9 100755 --- a/guest/desktop/verify-build.sh +++ b/guest/desktop/verify-build.sh @@ -366,6 +366,8 @@ grep -q $'^x11-utils\t' "$PACKAGES" || fail "X11 window qualification tools are grep -q $'^wl-clipboard\t' "$PACKAGES" || fail "Wayland clipboard package provenance is missing" grep -q $'^xclip\t' "$PACKAGES" || fail "X11 clipboard package provenance is missing" grep -q $'^pipewire-audio\t' "$PACKAGES" || fail "PipeWire package provenance is missing" +grep -q $'^binfmt-support\t' "$PACKAGES" \ + || fail "Intel application translation registration support is missing" for package in mesa-vulkan-drivers vulkan-tools; do # dpkg's ${binary:Package} field appends an architecture qualifier for # Multi-Arch packages (for example, mesa-vulkan-drivers:arm64). @@ -373,6 +375,18 @@ for package in mesa-vulkan-drivers vulkan-tools; do || fail "$package provenance is missing" done +INTEL_TRANSLATION_CONFIGURATION="$($DEBUGFS -R \ + 'cat /usr/lib/dory/configure-intel-translation' "$IMAGE" 2>/dev/null)" +grep -Fq 'mount -t virtiofs -o ro rosetta "$mountpoint"' \ + <<<"$INTEL_TRANSLATION_CONFIGURATION" \ + || fail "Intel application translation does not mount the resolved Rosetta share" +grep -Fq '\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00' \ + <<<"$INTEL_TRANSLATION_CONFIGURATION" \ + || fail "Intel application translation has the wrong x86_64 ELF signature" +grep -Fq -- '--credentials yes --preserve yes --fix-binary yes' \ + <<<"$INTEL_TRANSLATION_CONFIGURATION" \ + || fail "Intel application translation registration is incomplete" + GRAPHICS_CONFIGURATION="$($DEBUGFS -R 'cat /usr/lib/dory/configure-graphics-backend' "$IMAGE" 2>/dev/null)" grep -Fq "dory.graphics=virgl-venus" <<<"$GRAPHICS_CONFIGURATION" \ || fail "graphics backend configuration does not recognize VirGL plus Venus" From 6b06aefcaadc30d75910f4109e585c52a5c1f550 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:09:10 +0000 Subject: [PATCH 267/338] feat(vm): expose Intel translation setting --- Dory/Runtime/Doryd/DorydClient.swift | 28 ++++++- DoryTests/DorydClientTests.swift | 13 +++ .../DoryMachineTypedWriteAuthority.swift | 84 ++++++++++++++++++- .../DoryMachineTypedWriteAuthorityTests.swift | 38 ++++++++- 4 files changed, 155 insertions(+), 8 deletions(-) diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index dc85d47d..a5b1d51d 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -125,6 +125,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { var networkMode: DoryVMNetworkMode? = nil var portForwards: [DoryVMPortForward] = [] var audioConfiguration: DoryVMAudioConfiguration? = nil + var intelApplicationTranslationEnabled: Bool? = nil init( guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, @@ -133,7 +134,8 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { graphicsPreference: DoryDesktopGraphicsPreference? = nil, networkMode: DoryVMNetworkMode? = nil, portForwards: [DoryVMPortForward] = [], - audioConfiguration: DoryVMAudioConfiguration? = nil + audioConfiguration: DoryVMAudioConfiguration? = nil, + intelApplicationTranslationEnabled: Bool? = nil ) { self.guestIdentityIntent = guestIdentityIntent self.clipboardPolicy = clipboardPolicy @@ -142,6 +144,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { self.networkMode = networkMode self.portForwards = portForwards self.audioConfiguration = audioConfiguration + self.intelApplicationTranslationEnabled = intelApplicationTranslationEnabled } init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) { @@ -183,6 +186,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { ) networkMode = .sharedNAT portForwards = [] + intelApplicationTranslationEnabled = nil if displayMode == .desktop { let effectiveClipboard = DoryDesktopClipboardPolicy( environment: legacyEnvironment @@ -216,6 +220,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { && networkMode == nil && portForwards.isEmpty && audioConfiguration == nil + && intelApplicationTranslationEnabled == nil } var xpcDictionary: NSDictionary { @@ -267,6 +272,9 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { "outputEnabled": audioConfiguration.outputEnabled, ] as NSDictionary } + if let intelApplicationTranslationEnabled { + result["intelApplicationTranslationEnabled"] = intelApplicationTranslationEnabled + } return result as NSDictionary } @@ -286,6 +294,7 @@ nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { hasher.combine(portForwards) hasher.combine(audioConfiguration?.inputEnabled) hasher.combine(audioConfiguration?.outputEnabled) + hasher.combine(intelApplicationTranslationEnabled) } fileprivate static func xpcPortForwards(_ forwards: [DoryVMPortForward]) -> NSArray { @@ -389,6 +398,12 @@ nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { desired.audioConfiguration, into: &result ) + Self.encode( + baseline.intelApplicationTranslationEnabled, + desired.intelApplicationTranslationEnabled, + key: "intelApplicationTranslationEnabled", + into: &result + ) return result as NSDictionary } @@ -3455,7 +3470,7 @@ nonisolated final class DorydClient: @unchecked Sendable { Set(keys).isSubset(of: [ "guestIdentityIntent", "clipboardPolicy", "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", - "portForwards", "audio", + "portForwards", "audio", "intelApplicationTranslationEnabled", ]), keys.count == Set(keys).count else { return nil } @@ -3617,6 +3632,12 @@ nonisolated final class DorydClient: @unchecked Sendable { outputEnabled: output.boolValue ) } else { audioConfiguration = nil } + let intelApplicationTranslationEnabled: Bool? + if let encoded = value["intelApplicationTranslationEnabled"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } + intelApplicationTranslationEnabled = number.boolValue + } else { intelApplicationTranslationEnabled = nil } return ParsedMachineTypedSettings(value: DorydMachineTypedSettings( guestIdentityIntent: identity, clipboardPolicy: clipboard, @@ -3624,7 +3645,8 @@ nonisolated final class DorydClient: @unchecked Sendable { graphicsPreference: graphics, networkMode: networkMode, portForwards: portForwards, - audioConfiguration: audioConfiguration + audioConfiguration: audioConfiguration, + intelApplicationTranslationEnabled: intelApplicationTranslationEnabled )) } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 9adea9ea..eaa38c8f 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -476,6 +476,7 @@ struct DorydClientTests { #expect(defaults.graphicsPreference == .automatic) #expect(defaults.networkMode == .sharedNAT) #expect(defaults.portForwards.isEmpty) + #expect(defaults.intelApplicationTranslationEnabled == nil) var disconnected = defaults disconnected.networkMode = .disconnected @@ -514,6 +515,15 @@ struct DorydClientTests { #expect(audio["inputEnabled"] as? Bool == false) #expect(audio["outputEnabled"] == nil) + var translationEnabled = defaults + translationEnabled.intelApplicationTranslationEnabled = true + let translationWire = DorydMachineTypedSettingsPatch( + baseline: defaults, + desired: translationEnabled + ).xpcDictionary + #expect(translationWire.count == 1) + #expect(translationWire["intelApplicationTranslationEnabled"] as? Bool == true) + for (legacy, expected) in [("1", DoryDesktopGraphicsPreference.virgl), ("0", DoryDesktopGraphicsPreference.virglVenus)] { let settings = DorydMachineTypedSettings( @@ -699,6 +709,7 @@ struct DorydClientTests { "inputEnabled": false, "outputEnabled": true, ] as NSDictionary, + "intelApplicationTranslationEnabled": true, ]) let delegate = FakeDorydListenerDelegate(service: service) listener.delegate = delegate @@ -721,6 +732,7 @@ struct DorydClientTests { inputEnabled: false, outputEnabled: true )) + #expect(status.typedSettings?.intelApplicationTranslationEnabled == true) service.setMachineTypedSettings("dev", ["unknown": "claim"]) await #expect(throws: (any Error).self) { @@ -729,6 +741,7 @@ struct DorydClientTests { for malformed: NSDictionary in [ ["audio": ["inputEnabled": 0, "outputEnabled": true]], + ["intelApplicationTranslationEnabled": 1], ["audio": ["inputEnabled": true]], ["portForwards": [[ "id": "web", "transport": "tcp", "hostPort": 443, diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index b7e0ce72..37e22054 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -41,6 +41,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha public var networkMode: DoryVMNetworkMode public var portForwards: [DoryVMPortForward] public var audioConfiguration: DoryVMAudioConfiguration? + public var intelApplicationTranslationEnabled: Bool? private enum CodingKeys: String, CodingKey { case guestIdentityIntent @@ -50,6 +51,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha case networkMode case portForwards case audioConfiguration + case intelApplicationTranslationEnabled } public init(from decoder: Decoder) throws { @@ -82,6 +84,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha DoryVMAudioConfiguration.self, forKey: .audioConfiguration ) + intelApplicationTranslationEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .intelApplicationTranslationEnabled + ) } public func encode(to encoder: Encoder) throws { @@ -93,12 +99,19 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha try container.encode(networkMode, forKey: .networkMode) try container.encode(portForwards, forKey: .portForwards) try container.encodeIfPresent(audioConfiguration, forKey: .audioConfiguration) + try container.encodeIfPresent( + intelApplicationTranslationEnabled, + forKey: .intelApplicationTranslationEnabled + ) } public init(definition: DoryVirtualMachineDefinition) throws { guestIdentityIntent = definition.guestIdentityIntent networkMode = definition.networkMode portForwards = definition.portForwards + intelApplicationTranslationEnabled = definition.integrations.contains( + .intelApplicationTranslation + ) guard definition.display.enabled else { clipboardPolicy = nil runtimePreference = nil @@ -189,6 +202,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha ) networkMode = .sharedNAT portForwards = [] + intelApplicationTranslationEnabled = nil if displayMode == .desktop { let clipboard = DoryDesktopClipboardPolicy(environment: legacyEnvironment) clipboardPolicy = DoryVMClipboardDirection(rawValue: clipboard.rawValue) @@ -232,7 +246,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha networkMode: .set(networkMode), portForwards: .set(portForwards), audioInputEnabled: update(audioConfiguration?.inputEnabled), - audioOutputEnabled: update(audioConfiguration?.outputEnabled) + audioOutputEnabled: update(audioConfiguration?.outputEnabled), + intelApplicationTranslationEnabled: update( + intelApplicationTranslationEnabled + ) ).xpcDictionary } @@ -264,7 +281,10 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha networkMode: .set(networkMode), portForwards: .set(portForwards), audioInputEnabled: update(audioConfiguration?.inputEnabled), - audioOutputEnabled: update(audioConfiguration?.outputEnabled) + audioOutputEnabled: update(audioConfiguration?.outputEnabled), + intelApplicationTranslationEnabled: update( + intelApplicationTranslationEnabled + ) ) } @@ -284,6 +304,7 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha hasher.combine(portForwards) hasher.combine(audioConfiguration?.inputEnabled) hasher.combine(audioConfiguration?.outputEnabled) + hasher.combine(intelApplicationTranslationEnabled) } private func update( @@ -318,6 +339,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { public var portForwards: DoryMachineTypedSettingUpdate<[DoryVMPortForward]> public var audioInputEnabled: DoryMachineTypedSettingUpdate public var audioOutputEnabled: DoryMachineTypedSettingUpdate + public var intelApplicationTranslationEnabled: DoryMachineTypedSettingUpdate public init( guestUsername: DoryMachineTypedSettingUpdate = .unchanged, @@ -332,7 +354,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { networkMode: DoryMachineTypedSettingUpdate = .unchanged, portForwards: DoryMachineTypedSettingUpdate<[DoryVMPortForward]> = .unchanged, audioInputEnabled: DoryMachineTypedSettingUpdate = .unchanged, - audioOutputEnabled: DoryMachineTypedSettingUpdate = .unchanged + audioOutputEnabled: DoryMachineTypedSettingUpdate = .unchanged, + intelApplicationTranslationEnabled: DoryMachineTypedSettingUpdate = .unchanged ) { self.guestUsername = guestUsername self.guestNumericUserID = guestNumericUserID @@ -347,6 +370,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { self.portForwards = portForwards self.audioInputEnabled = audioInputEnabled self.audioOutputEnabled = audioOutputEnabled + self.intelApplicationTranslationEnabled = intelApplicationTranslationEnabled } public var isEmpty: Bool { @@ -363,6 +387,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { && !portForwards.isChanged && !audioInputEnabled.isChanged && !audioOutputEnabled.isChanged + && !intelApplicationTranslationEnabled.isChanged } /// Consume the typed persistent-machine options shared by dorydctl create and update. Other @@ -442,6 +467,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { if let raw = try takeOption("--audio-output", from: &arguments) { patch.audioOutputEnabled = .set(try cliBoolean(raw, option: "--audio-output")) } + if let raw = try takeOption("--intel-application-translation", from: &arguments) { + patch.intelApplicationTranslationEnabled = .set(try cliBoolean( + raw, + option: "--intel-application-translation" + )) + } let clearsAccount = takeFlag("--clear-guest-account", from: &arguments) let clearsDesktop = takeFlag("--clear-desktop-identity", from: &arguments) @@ -451,9 +482,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { let clearsNetwork = takeFlag("--clear-network", from: &arguments) let clearsPortForwards = takeFlag("--clear-forwards", from: &arguments) let clearsAudio = takeFlag("--clear-audio", from: &arguments) + let clearsIntelApplicationTranslation = takeFlag( + "--clear-intel-application-translation", + from: &arguments + ) guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard && !clearsRuntime && !clearsGraphics && !clearsNetwork - && !clearsPortForwards && !clearsAudio) else { + && !clearsPortForwards && !clearsAudio + && !clearsIntelApplicationTranslation) else { throw DoryMachineTypedWriteAuthorityError.invalidField("clear options") } if clearsAccount { @@ -517,6 +553,14 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { patch.audioInputEnabled = .clear patch.audioOutputEnabled = .clear } + if clearsIntelApplicationTranslation { + guard !patch.intelApplicationTranslationEnabled.isChanged else { + throw DoryMachineTypedWriteAuthorityError.invalidField( + "--clear-intel-application-translation" + ) + } + patch.intelApplicationTranslationEnabled = .clear + } return patch } @@ -563,6 +607,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { if let rawAudio = dictionary["audio"] { try decodeAudio(rawAudio, allowsClears: allowsClears) } + intelApplicationTranslationEnabled = try Self.decodeBool( + dictionary, + key: "intelApplicationTranslationEnabled", + field: "intelApplicationTranslationEnabled", + allowsClears: allowsClears + ) } /// Canonical XPC representation used by dorydctl and future typed clients. @@ -628,6 +678,11 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { result["audio"] = audio as NSDictionary } } + Self.encode( + intelApplicationTranslationEnabled, + key: "intelApplicationTranslationEnabled", + into: &result + ) return result as NSDictionary } @@ -709,6 +764,11 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { guard !audioInputEnabled.isChanged, !audioOutputEnabled.isChanged else { throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime("audio") } + guard !intelApplicationTranslationEnabled.isChanged else { + throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "intelApplicationTranslationEnabled" + ) + } return environment } @@ -814,6 +874,16 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { ) definition.audio = audio } + switch intelApplicationTranslationEnabled { + case .unchanged: + break + case .clear, .set(false): + definition.integrations.removeAll { $0 == .intelApplicationTranslation } + case .set(true): + if !definition.integrations.contains(.intelApplicationTranslation) { + definition.integrations.append(.intelApplicationTranslation) + } + } let issues = definition.validate() guard issues.isEmpty else { throw DoryMachineTypedWriteAuthorityError.invalidField( @@ -1109,6 +1179,12 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { displayMode != .desktop { throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay("audio") } + if case .set(true) = intelApplicationTranslationEnabled, + displayMode != .desktop { + throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "intelApplicationTranslationEnabled" + ) + } if case let .set(forwards) = portForwards { try Self.validatePortForwards(forwards) } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 7e20b025..9b267129 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -29,7 +29,8 @@ struct DoryMachineTypedWriteAuthorityTests { ), ]), audioInputEnabled: .set(false), - audioOutputEnabled: .set(true) + audioOutputEnabled: .set(true), + intelApplicationTranslationEnabled: .set(true) ) let wire = source.xpcDictionary @@ -112,6 +113,7 @@ struct DoryMachineTypedWriteAuthorityTests { inputEnabled: true, outputEnabled: true )) + #expect(snapshot.intelApplicationTranslationEnabled == nil) #expect(snapshot.xpcDictionary.description.contains("must-never-cross-xpc") == false) let invalid = DoryMachineTypedSettingsSnapshot( @@ -132,6 +134,7 @@ struct DoryMachineTypedWriteAuthorityTests { historical.removeValue(forKey: "networkMode") historical.removeValue(forKey: "audioConfiguration") historical.removeValue(forKey: "portForwards") + historical.removeValue(forKey: "intelApplicationTranslationEnabled") let decoded = try JSONDecoder().decode( DoryMachineTypedSettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: historical) @@ -139,6 +142,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(decoded.networkMode == .sharedNAT) #expect(decoded.audioConfiguration == nil) #expect(decoded.portForwards.isEmpty) + #expect(decoded.intelApplicationTranslationEnabled == nil) } @Test("update clear is explicit and field scoped") @@ -326,6 +330,34 @@ struct DoryMachineTypedWriteAuthorityTests { displayMode: .desktop ).audio == DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true)) + let translation = DoryMachineTypedSettingsPatch( + intelApplicationTranslationEnabled: .set(true) + ) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( + "intelApplicationTranslationEnabled" + )) { + try translation.applying(to: [:], displayMode: .desktop) + } + let translated = try translation.applying( + to: migrated.definition, + displayMode: .desktop + ) + #expect(translated.integrations.contains(.intelApplicationTranslation)) + #expect(try DoryMachineTypedSettingsSnapshot( + definition: translated + ).intelApplicationTranslationEnabled == true) + #expect(try DoryMachineTypedSettingsPatch( + intelApplicationTranslationEnabled: .set(false) + ).applying( + to: translated, + displayMode: .desktop + ).integrations.contains(.intelApplicationTranslation) == false) + #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay( + "intelApplicationTranslationEnabled" + )) { + try translation.applying(to: migrated.definition, displayMode: .headless) + } + let forwards = DoryMachineTypedSettingsPatch(portForwards: .set([ DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), ])) @@ -480,6 +512,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--forward", "dns:udp:5353:53:lan", "--audio-input", "off", "--audio-output", "on", + "--intel-application-translation", "on", "--env", "TOKEN=secret", ] @@ -507,6 +540,7 @@ struct DoryMachineTypedWriteAuthorityTests { ])) #expect(patch.audioInputEnabled == .set(false)) #expect(patch.audioOutputEnabled == .set(true)) + #expect(patch.intelApplicationTranslationEnabled == .set(true)) #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"]) #expect(patch.xpcDictionary["env"] == nil) } @@ -534,6 +568,7 @@ struct DoryMachineTypedWriteAuthorityTests { "--clear-network", "--clear-forwards", "--clear-audio", + "--clear-intel-application-translation", ] let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments( &clearing, @@ -551,6 +586,7 @@ struct DoryMachineTypedWriteAuthorityTests { #expect(patch.portForwards == .clear) #expect(patch.audioInputEnabled == .clear) #expect(patch.audioOutputEnabled == .clear) + #expect(patch.intelApplicationTranslationEnabled == .clear) var createClear = ["--clear-clipboard"] #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("clear options")) { From 7b3596d6af9892654e892e9ab959c3da5e47fcb9 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:14:17 +0000 Subject: [PATCH 268/338] feat(vm): configure Intel Linux applications --- Dory/Features/Machines/MachinesView.swift | 29 ++++ Dory/Features/Sheets/NewMachineSheet.swift | 143 +++++++++++++++++++- DoryTests/NewMachineSettingsTests.swift | 21 +++ dory-core-swift/Sources/dorydctl/main.swift | 4 +- 4 files changed, 192 insertions(+), 5 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index ed3e07c0..af4a14d9 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1309,6 +1309,7 @@ private struct MachineEditSheet: View { @State private var audioInputEnabled = true @State private var audioOutputEnabled = true @State private var originalAudioConfiguration: DoryVMAudioConfiguration? + @State private var intelApplicationTranslationEnabled = false @State private var typedSettings = DorydMachineTypedSettings() private struct MountRow: Identifiable, Hashable { @@ -1337,6 +1338,7 @@ private struct MachineEditSheet: View { ) audioBlock runtimeBlock + intelApplicationTranslationBlock clipboardBlock resourceRow addressBlock @@ -1379,6 +1381,8 @@ private struct MachineEditSheet: View { originalAudioConfiguration = typedSettings.audioConfiguration audioInputEnabled = typedSettings.audioConfiguration?.inputEnabled ?? true audioOutputEnabled = typedSettings.audioConfiguration?.outputEnabled ?? true + intelApplicationTranslationEnabled = typedSettings + .intelApplicationTranslationEnabled ?? false mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly, shareTag: $0.shareTag) } @@ -1569,6 +1573,17 @@ private struct MachineEditSheet: View { } } + @ViewBuilder private var intelApplicationTranslationBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + MachineIntelApplicationTranslationControl( + isEnabled: $intelApplicationTranslationEnabled, + editable: intelApplicationTranslationPolicyEditable, + runtimeCompatible: runtimePreference != .accelerated, + accessibilityPrefix: "edit-machine" + ) + } + } + private var addressBlock: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("DNS TARGET OVERRIDE") @@ -1637,6 +1652,7 @@ private struct MachineEditSheet: View { store.isMachineBusy(machine.name) || guestUsernameInvalid || resolvedPortForwards == nil + || intelApplicationTranslationRuntimeConflict ) } .padding(.horizontal, 18).padding(.vertical, 13) @@ -1755,6 +1771,11 @@ private struct MachineEditSheet: View { if typedSettings.graphicsPreference != nil || graphicsPreference != .automatic { typedSettings.graphicsPreference = graphicsPreference } + if typedSettings.intelApplicationTranslationEnabled != nil + || intelApplicationTranslationEnabled { + typedSettings.intelApplicationTranslationEnabled = + intelApplicationTranslationEnabled + } } let settings = MachineSettings( cpus: cpus, @@ -1783,6 +1804,14 @@ private struct MachineEditSheet: View { machine.runtimeIdentity.mode != "legacy-compatibility" } + private var intelApplicationTranslationPolicyEditable: Bool { + machine.runtimeIdentity.mode != "legacy-compatibility" + } + + private var intelApplicationTranslationRuntimeConflict: Bool { + intelApplicationTranslationEnabled && runtimePreference == .accelerated + } + private var guestUsernameInvalid: Bool { guard displayMode == .desktop, machine.bootMode != .efi else { return false } return normalizedGuestUsername.range( diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index d34def58..dd654dc6 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -2,6 +2,7 @@ import Darwin import DoryOperations import SwiftUI import UniformTypeIdentifiers +import Virtualization struct NewMachineSheet: View { @Environment(AppStore.self) private var store @@ -21,6 +22,7 @@ struct NewMachineSheet: View { @State private var portForwardRows: [MachinePortForwardDraft] = [] @State private var audioInputEnabled = true @State private var audioOutputEnabled = true + @State private var intelApplicationTranslationEnabled = false private enum InstallerISOCheck: Equatable { case none @@ -99,6 +101,7 @@ struct NewMachineSheet: View { accessibilityPrefix: "new-machine" ) audioBlock + intelApplicationTranslationBlock optionsRow advancedSection } @@ -575,6 +578,17 @@ struct NewMachineSheet: View { } } + @ViewBuilder private var intelApplicationTranslationBlock: some View { + if displayMode == .desktop, !customISOInstall { + MachineIntelApplicationTranslationControl( + isEnabled: $intelApplicationTranslationEnabled, + editable: true, + runtimeCompatible: true, + accessibilityPrefix: "new-machine" + ) + } + } + private var advancedSection: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -936,7 +950,8 @@ struct NewMachineSheet: View { networkMode: DoryVMNetworkMode = .sharedNAT, portForwards: [DoryVMPortForward] = [], audioInputEnabled: Bool = true, - audioOutputEnabled: Bool = true + audioOutputEnabled: Bool = true, + intelApplicationTranslationEnabled: Bool = false ) -> MachineSettings { let typedSettings: DorydMachineTypedSettings if displayMode == .desktop { @@ -961,7 +976,8 @@ struct NewMachineSheet: View { audioConfiguration: DoryVMAudioConfiguration( inputEnabled: audioInputEnabled, outputEnabled: audioOutputEnabled - ) + ), + intelApplicationTranslationEnabled: intelApplicationTranslationEnabled ) } else { typedSettings = DorydMachineTypedSettings( @@ -1015,7 +1031,8 @@ struct NewMachineSheet: View { networkMode: networkMode, portForwards: resolvedPortForwards ?? [], audioInputEnabled: audioInputEnabled, - audioOutputEnabled: audioOutputEnabled + audioOutputEnabled: audioOutputEnabled, + intelApplicationTranslationEnabled: intelApplicationTranslationEnabled ) if customISOInstall { settings.bootMode = .efi @@ -1084,3 +1101,123 @@ struct NewMachineSheet: View { return trimmed.isEmpty ? nil : trimmed } } + +enum IntelApplicationTranslationHostAvailability: Sendable, Equatable { + case installed + case notInstalled + case unsupported +} + +@MainActor +enum IntelApplicationTranslationHost { + static var availability: IntelApplicationTranslationHostAvailability { + switch VZLinuxRosettaDirectoryShare.availability { + case .installed: + .installed + case .notInstalled: + .notInstalled + case .notSupported: + .unsupported + @unknown default: + .unsupported + } + } + + static func install() async throws { + try await VZLinuxRosettaDirectoryShare.installRosetta() + } +} + +struct MachineIntelApplicationTranslationControl: View { + @Environment(\.palette) private var p + @Binding var isEnabled: Bool + let editable: Bool + let runtimeCompatible: Bool + let accessibilityPrefix: String + + @State private var availability = IntelApplicationTranslationHost.availability + @State private var installing = false + @State private var installationError: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("INTEL APPLICATIONS") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(p.text3) + .tracking(0.5) + HStack(spacing: 12) { + Toggle("Run Intel Linux applications", isOn: $isEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + .disabled(!editable || (availability != .installed && !isEnabled)) + .accessibilityIdentifier( + "\(accessibilityPrefix)-intel-application-translation" + ) + Spacer(minLength: 0) + if availability == .notInstalled { + Button { + Task { await installRosetta() } + } label: { + HStack(spacing: 6) { + if installing { ProgressView().controlSize(.mini) } + Text(installing ? "Installing…" : "Install Rosetta") + .font(.system(size: 11.5, weight: .semibold)) + } + } + .buttonStyle(.bordered) + .disabled(installing || !editable) + .accessibilityIdentifier( + "\(accessibilityPrefix)-install-intel-application-translation" + ) + } + } + Text(statusMessage) + .font(.system(size: 11)) + .foregroundStyle(statusColor) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var statusMessage: String { + if let installationError { + return "Rosetta could not be installed: \(installationError)" + } + guard runtimeCompatible else { + return "Intel application translation requires Automatic or Compatibility runtime selection." + } + guard editable else { + return "Replan this compatibility machine into the resolved runtime before changing Intel application translation." + } + switch availability { + case .installed: + return "Uses Apple's Rosetta runtime inside this ARM64 Linux desktop. The runtime is attached only when the resolved plan qualifies it." + case .notInstalled: + return "Rosetta is not installed on this Mac. Installation is an explicit Apple system action." + case .unsupported: + return "This Mac does not support Rosetta for Linux virtual machines." + } + } + + private var statusColor: Color { + installationError == nil && availability == .installed && runtimeCompatible + ? p.text3 : p.amber + } + + private func installRosetta() async { + installationError = nil + installing = true + defer { installing = false } + do { + try await IntelApplicationTranslationHost.install() + availability = IntelApplicationTranslationHost.availability + if availability != .installed { + installationError = "macOS did not report the runtime as installed." + } + } catch { + installationError = error.localizedDescription + availability = IntelApplicationTranslationHost.availability + } + } +} diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 1e3be266..9da19264 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -93,6 +93,7 @@ struct NewMachineSettingsTests { inputEnabled: true, outputEnabled: true )) + #expect(s.virtualMachineSettings?.intelApplicationTranslationEnabled == false) #expect(s.ports.isEmpty) } @@ -167,6 +168,26 @@ struct NewMachineSettingsTests { )) } + @Test func desktopIntelApplicationTranslationIsExplicitTypedIntent() { + let enabled = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [], + intelApplicationTranslationEnabled: true + ) + #expect(enabled.env.isEmpty) + #expect(enabled.virtualMachineSettings?.intelApplicationTranslationEnabled == true) + + let headless = NewMachineSheet.buildSettings( + cpus: 2, + memoryGB: 2, + mounts: [], + displayMode: .headless, + intelApplicationTranslationEnabled: true + ) + #expect(headless.virtualMachineSettings?.intelApplicationTranslationEnabled == nil) + } + @Test func editingPreservesAnOlderDaemonsAbsentAudioClaimUntilTheUserChangesIt() { #expect(MachineAudioSettingsPolicy.editedConfiguration( existing: nil, diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 446d3910..0cfaf141 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -194,8 +194,8 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine device-telemetry NAME dorydctl [global] machine flight-recorder NAME [--after SEQUENCE] dorydctl [global] machine console NAME [--generation SHA256 --after OFFSET] [--limit BYTES] [--input TEXT] - dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--forward ID:tcp|udp:HOST_PORT:GUEST_PORT:loopback|lan ...] [--audio-input on|off] [--audio-output on|off] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] - dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network | --clear-forwards | --clear-audio] [--attach-installer | --eject-installer] + dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software] [--network shared-nat|host-only|disconnected|bridged] [--forward ID:tcp|udp:HOST_PORT:GUEST_PORT:loopback|lan ...] [--audio-input on|off] [--audio-output on|off] [--intel-application-translation on|off] [--sandbox [--sandbox-expires-at UNIX_SECONDS] [--sandbox-ssh-agent denied|granted] [--sandbox-profile standard|agent-ready] [--sandbox-tool TOOL ...] [--sandbox-baseline ID]] + dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics | --clear-network | --clear-forwards | --clear-audio | --clear-intel-application-translation] [--attach-installer | --eject-installer] dorydctl [global] machine start|stop|pause|suspend|resume|restart|delete NAME dorydctl [global] machine usb-attach NAME BUS_ID dorydctl [global] machine usb-detach NAME BUS_ID From e310cc5adf6ffb68dbcd81cc1cf9ecbced9020ea Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:20:48 +0000 Subject: [PATCH 269/338] fix(vm): require exact resolved launch arguments --- ...onVirtualMachineProductionActivation.swift | 6 +++++ ...onVirtualMachineProductionTrustTests.swift | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift index c9e055e9..4f69a812 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift @@ -115,6 +115,12 @@ extension DoryDaemonVirtualMachineProductionTrustFactory { "Production planning requires one canonical state authority." ) } + guard machineConfiguration.passMachineArguments else { + return unavailableActivation( + .installationRejected, + "Production resolved launches require exact machine argument binding." + ) + } let material: DoryDaemonVirtualMachineVerifiedTrustMaterial switch verifiedTrustMaterial( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 1961213e..3754a85c 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -367,6 +367,31 @@ struct DoryDaemonVirtualMachineProductionTrustTests { #expect(trustFloor.activationCount == 1) } + @Test("activation rejects helpers that omit the resolved launch contract") + func activationRequiresExactMachineArguments() throws { + let trustFloor = ProductionTrustFloorActivationState() + let fixture = try ProductionTrustFixture( + trustFloorActivationState: trustFloor + ) + defer { fixture.cleanup() } + var configuration = fixture.machineConfiguration + configuration.passMachineArguments = false + + guard case let .unavailable(failure) = fixture.factory.activate( + store: fixture.store, + machineConfiguration: configuration, + appVersion: fixture.appVersion, + publicKey: fixture.publicKey, + expectedArchitecture: "arm64" + ) else { + Issue.record("Expected exact launch-argument binding to be mandatory") + return + } + #expect(failure.code == .installationRejected) + #expect(failure.trustFailure == nil) + #expect(trustFloor.activationCount == 0) + } + @Test("activated production graph publishes a headless create plan through XPC authority") func activatedGraphPlansHeadlessCreate() throws { let fixture = try ProductionTrustFixture() From 80f214b19d81781f980c5293189e060799b4ddea Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:24:08 +0000 Subject: [PATCH 270/338] fix(vm): honor resolved clock sync policy --- .../Sources/DorydKit/MachineManager.swift | 16 ++- ...eManagerResolvedPlanIntegrationTests.swift | 128 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index d3efcee0..06261e8c 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -13245,7 +13245,9 @@ private struct MachineEntry { extension MachineManager: WakeClockSyncing { public func syncAgentClock(now: Date) -> AgentClockSyncResult { let runningAgents = list().compactMap { status -> (id: String, socketPath: String, supported: Bool)? in - guard status.state == .running, let socketPath = status.agentSocketPath else { + guard status.state == .running, + status.authorizesAgentClockSynchronization, + let socketPath = status.agentSocketPath else { return nil } return (status.id, socketPath, status.supportsAgentCapability("clock-sync")) @@ -13285,6 +13287,18 @@ extension MachineManager: WakeClockSyncing { } private extension DoryMachineStatus { + var authorizesAgentClockSynchronization: Bool { + switch runtimeIdentity.mode { + case .legacyCompatibility: + // Compatibility machines retain the pre-contract wake behavior. + return true + case .requiresReplanning: + return false + case .resolvedPlan: + return runtimeIdentity.resolvedPlan?.devices.clockSynchronization == true + } + } + func supportsAgentCapability(_ id: String, minimumVersion: UInt32 = 1) -> Bool { guard agentBuild?.isEmpty == false, agentProtocolVersion == DoryCore.protocolVersion(), diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index e65bb494..2ae2b1b0 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -363,6 +363,72 @@ struct MachineManagerResolvedPlanIntegrationTests { } } + @Test("resolved wake clock synchronization follows the exact device contract") + func resolvedClockSyncUsesExactAuthorization() throws { + for authorized in [false, true] { + let clock = ResolvedClockSyncRecorder() + try withHarness( + "resolved-clock-sync-\(authorized)", + requiresReadyHandoff: true, + useShortStatePath: true, + agentConnector: clock.connect(socketPath:) + ) { manager, starter, _ in + let plans = MutablePlanStore() + let operations = manager.resolvedLaunchCompatibilityOperations( + for: .doryHypervisor + ) + let registry = try rawRegistry(operations: operations) + let devices = DoryVirtualMachineDeviceCapabilityRequest( + clockSynchronization: authorized + ) + let resolver = ClosureLaunchResolver { request in + let resolution = try exactResolution( + request: request, + devices: devices + ) + plans.set(resolution.resolvedPlan) + return resolution + } + try manager.installResolvedLaunchInfrastructure( + registry: registry, + resolver: resolver, + plans: plans, + expectedPlanRevision: { _ in 1 } + ) + + let starting = try manager.start(id: "dev") + try sendVmmHandoff( + path: try #require(starting.handoffSocketPath), + ready: VmmReadyMessage( + machineID: "dev", + operationID: starting.activeOperationID, + agentBuild: "dory-agent/resolved-clock-test", + agentProtocolVersion: DoryCore.protocolVersion(), + agentCapabilities: [ + DoryAgentCapability(id: "clock-sync", version: 1), + ], + agentSocketPath: "/run/dory-agent.sock", + controlSocketPath: "/run/dory-control.sock" + ), + fileDescriptors: [] + ) + for _ in 0..<200 { + if manager.status(id: "dev")?.state == .running { break } + Thread.sleep(forTimeInterval: 0.01) + } + #expect(manager.status(id: "dev")?.state == .running) + #expect(starter.count == 1) + + let result = manager.syncAgentClock( + now: Date(timeIntervalSince1970: 1_234.5) + ) + #expect(result.attempted == authorized) + #expect(result.synced == authorized) + #expect(clock.syncs == (authorized ? [1_234_500_000_000] : [])) + } + } + } + @Test("resolved installed-Linux launch rematerializes verified boot outputs after authorization") func resolvedInstalledLinuxRevalidatesMaterializedOutputs() throws { let root = try makeState("resolved-installed-linux") @@ -1590,6 +1656,9 @@ struct MachineManagerResolvedPlanIntegrationTests { requiresReadyHandoff: Bool = false, useShortStatePath: Bool = false, usbController: any DoryMachineUSBControlling = UnixDoryMachineUSBController(), + agentConnector: @escaping MachineManager.AgentConnector = { socketPath in + try LocalAgentControl.connect(socketPath: socketPath) + }, _ body: (MachineManager, CountingProcessStarter, String) throws -> Void ) throws { let state = useShortStatePath @@ -1610,6 +1679,7 @@ struct MachineManagerResolvedPlanIntegrationTests { ), launchPolicy: launchPolicy, usbController: usbController, + agentConnector: agentConnector, processStarter: { process in try starter.start(process) } ) defer { @@ -1898,6 +1968,64 @@ private final class CountingProcessStarter: @unchecked Sendable { } } +private final class ResolvedClockSyncRecorder: AgentControlClient, @unchecked Sendable { + private let lock = NSLock() + private var recordedSyncs: [Int64] = [] + + var syncs: [Int64] { lock.withLock { recordedSyncs } } + + func connect(socketPath: String) throws -> any AgentControlClient { + #expect(socketPath == "/run/dory-agent.sock") + return self + } + + func info() throws -> DoryAgentInfo { + DoryAgentInfo( + protocolVersion: DoryCore.protocolVersion(), + kernel: "Linux test", + agentBuild: "dory-agent/resolved-clock-test", + uptimeSeconds: 1 + ) + } + + func clockSync(hostEpochNs: Int64) throws -> Bool { + lock.withLock { recordedSyncs.append(hostEpochNs) } + return true + } + + func portsWatch() throws -> DoryPortsSnapshot { + DoryPortsSnapshot(ports: [], added: [], removed: []) + } + + func telemetry() throws -> DoryTelemetry { + DoryTelemetry( + memTotalKB: 1, + memAvailableKB: 1, + psiSomeAvg10: 0, + psiFullAvg10: 0 + ) + } + + func exec( + argv: [String], + cwd: String, + env: [DoryExecEnvironment], + timeoutMs: UInt64, + outputLimitBytes: UInt64 + ) throws -> DoryExecResult { + DoryExecResult( + exitCode: 0, + stdout: Data(), + stderr: Data(), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false + ) + } + + func close() {} +} + private final class ResolvedPlanRecordingUSBController: DoryMachineUSBControlling, @unchecked Sendable From e6cb4329c7207d457ee098b562e05440bc6ce0bb Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:27:52 +0000 Subject: [PATCH 271/338] fix(vm): honor resolved shutdown policy --- .../Sources/dory-hv/DesktopMode.swift | 14 ++++++++ .../DesktopShutdownPlanTests.swift | 18 +++++++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 19 +++++++++-- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 32 +++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopShutdownPlanTests.swift diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index f13d5b70..cdb89397 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -576,6 +576,16 @@ enum DesktopMode { } } + enum ShutdownPlan: Equatable { + case guestAssisted + case immediate + + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) { + self = resolvedDevices?.gracefulShutdown == false + ? .immediate : .guestAssisted + } + } + private struct ResolvedGraphics { var backend: DoryDesktopGraphicsBackend var renderer: VirglRenderer? @@ -1067,6 +1077,10 @@ enum DesktopMode { stopping = true window.orderOut(nil) let machine = self.machine + if ShutdownPlan(resolvedDevices: configuration.resolvedDevices) == .immediate { + machine.requestStop(.powerOff) + return + } if configuration.genericGuest { // Linux maps KEY_POWER to logind's normal power-button action. This provides a // clean integration-free shutdown path until Dory guest tools are installed. diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopShutdownPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopShutdownPlanTests.swift new file mode 100644 index 00000000..d733bf11 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopShutdownPlanTests.swift @@ -0,0 +1,18 @@ +import DoryOperations +import Testing +@testable import dory_hv + +@Suite struct DesktopShutdownPlanTests { + @Test func legacyAndAuthorizedContractsUseGuestAssistedShutdown() { + #expect(DesktopMode.ShutdownPlan(resolvedDevices: nil) == .guestAssisted) + #expect(DesktopMode.ShutdownPlan(resolvedDevices: .init( + gracefulShutdown: true + )) == .guestAssisted) + } + + @Test func resolvedOptOutUsesImmediateHostShutdown() { + #expect(DesktopMode.ShutdownPlan(resolvedDevices: .init( + gracefulShutdown: false + )) == .immediate) + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 7591849f..0bb6dd75 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -1070,7 +1070,11 @@ public enum DoryVMMMain { guard let rootfsPath = arguments.rootfsPath else { throw DoryVMMArgumentError.missingRootfs } - let coordinator = DoryVMMShutdownCoordinator() + let gracefulShutdownAuthorized = arguments.resolvedDevices? + .gracefulShutdown ?? true + let coordinator = DoryVMMShutdownCoordinator( + guestShutdownAuthorized: gracefulShutdownAuthorized + ) coordinator.installSignalHandlers() shutdownCoordinator = coordinator runtime = try runVirtualMachine( @@ -1642,6 +1646,7 @@ final class DoryVMMShutdownCoordinator: @unchecked Sendable { private let lock = NSLock() private let worker = DispatchQueue(label: "dev.dory.dory-vmm.shutdown", qos: .userInitiated) private let watchdogSeconds: TimeInterval + private let guestShutdownAuthorized: Bool private let scheduleWatchdog: WatchdogScheduler private let forceExit: ForceExit private var target: (any DoryVMMGuestShutdownHandling)? @@ -1652,12 +1657,14 @@ final class DoryVMMShutdownCoordinator: @unchecked Sendable { init( watchdogSeconds: TimeInterval = DoryEngineShutdownTiming.helperWatchdogSeconds, + guestShutdownAuthorized: Bool = true, scheduleWatchdog: @escaping WatchdogScheduler = { delay, action in DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: action) }, forceExit: @escaping ForceExit = { code in exit(code) } ) { self.watchdogSeconds = watchdogSeconds + self.guestShutdownAuthorized = guestShutdownAuthorized self.scheduleWatchdog = scheduleWatchdog self.forceExit = forceExit } @@ -1717,13 +1724,21 @@ final class DoryVMMShutdownCoordinator: @unchecked Sendable { if target != nil { begun = true } lock.unlock() - FileHandle.standardError.write(Data("dory-vmm: graceful shutdown requested (\(reason))\n".utf8)) + let detail = guestShutdownAuthorized + ? "graceful shutdown requested" + : "immediate host shutdown requested" + FileHandle.standardError.write(Data("dory-vmm: \(detail) (\(reason))\n".utf8)) if let target { beginShutdown(target) } } private func beginShutdown(_ target: any DoryVMMGuestShutdownHandling) { + guard guestShutdownAuthorized else { + target.forceCleanup() + forceExit(0) + return + } scheduleWatchdog(watchdogSeconds) { [weak self, weak target] in guard let self, let target, !target.isStopped else { return } FileHandle.standardError.write(Data( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index e14c1018..5a1d4992 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -1348,6 +1348,26 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertEqual(forcedExit.codes, [1]) } + + func testResolvedShutdownOptOutNeverRequestsGuestShutdown() { + let watchdog = ShutdownWatchdogRecorder() + let forcedExit = ForcedExitRecorder() + let coordinator = DoryVMMShutdownCoordinator( + watchdogSeconds: 25, + guestShutdownAuthorized: false, + scheduleWatchdog: { watchdog.schedule(delay: $0, action: $1) }, + forceExit: { forcedExit.record($0) } + ) + let target = FakeVMMShutdownTarget() + + coordinator.attach(target) + coordinator.request(reason: "SIGTERM") + + XCTAssertEqual(target.requestCount, 0) + XCTAssertEqual(target.cleanupCount, 1) + XCTAssertEqual(watchdog.delays, []) + XCTAssertEqual(forcedExit.codes, [0]) + } } private final class ClipboardWriteRecorder: @unchecked Sendable { @@ -1394,6 +1414,7 @@ private final class FakeVMMShutdownTarget: DoryVMMGuestShutdownHandling, @unchec private let requested = DispatchSemaphore(value: 0) private var stopped = false private var requests = 0 + private var cleanups = 0 var isStopped: Bool { lock.lock(); defer { lock.unlock() } @@ -1405,6 +1426,11 @@ private final class FakeVMMShutdownTarget: DoryVMMGuestShutdownHandling, @unchec return requests } + var cleanupCount: Int { + lock.lock(); defer { lock.unlock() } + return cleanups + } + func requestGuestShutdown() throws { lock.lock() requests += 1 @@ -1421,6 +1447,12 @@ private final class FakeVMMShutdownTarget: DoryVMMGuestShutdownHandling, @unchec stopped = true lock.unlock() } + + func forceCleanup() { + lock.lock() + cleanups += 1 + lock.unlock() + } } private final class ShutdownWatchdogRecorder: @unchecked Sendable { From 3cfbb85b1ea070d70b72b7dfbb71709e319b2461 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 07:57:55 +0000 Subject: [PATCH 272/338] feat(vm): enforce directional file transfers --- Dory/Features/Machines/MachinesView.swift | 40 +++++++++++++--- Dory/Features/Sheets/NewMachineSheet.swift | 6 ++- Dory/Models/AppStore.swift | 6 +++ Dory/Models/Models.swift | 1 + Dory/Runtime/Doryd/DorydClient.swift | 1 + DoryTests/DorydClientTests.swift | 10 ++++ DoryTests/NewMachineSettingsTests.swift | 6 ++- .../Sources/dory-hv/DesktopMode.swift | 7 +-- .../DesktopClipboardPlanTests.swift | 28 +++++------ .../DoryVirtualMachineCapabilities.swift | 11 ++--- .../Sources/DoryVMMKit/DoryVMM.swift | 8 +--- .../DoryVMMDesktopApplication.swift | 1 - .../DorydKit/DoryMachineFileTransfer.swift | 48 +++++++++++++++++++ .../DoryMachineTypedWriteAuthority.swift | 10 ++-- .../Sources/DorydKit/MachineManager.swift | 28 +++++++++++ .../DoryVirtualMachineCapabilitiesTests.swift | 4 +- .../DoryMachineTypedWriteAuthorityTests.swift | 5 ++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 6 ++- .../MachineManagerFileTransferTests.swift | 35 ++++++++++++++ ...eManagerResolvedPlanIntegrationTests.swift | 7 ++- 20 files changed, 219 insertions(+), 49 deletions(-) diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index af4a14d9..9065fe1f 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1302,6 +1302,10 @@ private struct MachineEditSheet: View { @State private var displayMode: MachineDisplayMode = .headless @State private var guestUsername = "dory" @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional + @State private var fileTransferPolicy = DoryVMClipboardDirection.bidirectional + @State private var initialClipboardPicker = DoryDesktopClipboardPolicy.bidirectional + @State private var initialFileTransferPolicy = DoryVMClipboardDirection.bidirectional + @State private var originalClipboardPolicy: DoryVMClipboardPolicy? @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic @State private var networkMode = DoryVMNetworkMode.sharedNAT @@ -1370,10 +1374,14 @@ private struct MachineEditSheet: View { guestUsername = settings.env[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] ?? typedSettings.guestIdentityIntent.account?.username ?? "dory" + originalClipboardPolicy = typedSettings.clipboardPolicy clipboardPolicy = typedSettings.clipboardPolicy.flatMap { - guard $0.text == $0.image, $0.files == .off else { return nil } + guard $0.text == $0.image else { return nil } return DoryDesktopClipboardPolicy(rawValue: $0.text.rawValue) } ?? .bidirectional + fileTransferPolicy = typedSettings.clipboardPolicy?.files ?? .bidirectional + initialClipboardPicker = clipboardPolicy + initialFileTransferPolicy = fileTransferPolicy runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic networkMode = typedSettings.networkMode ?? .sharedNAT @@ -1538,6 +1546,16 @@ private struct MachineEditSheet: View { .accessibilityIdentifier("edit-machine-clipboard-policy") Text("Control whether text and images can move between this Linux desktop and your Mac.") .font(.system(size: 11)).foregroundStyle(p.text3) + Picker("File transfer", selection: $fileTransferPolicy) { + Text("Off").tag(DoryVMClipboardDirection.off) + Text("To Linux").tag(DoryVMClipboardDirection.hostToGuest) + Text("To Mac").tag(DoryVMClipboardDirection.guestToHost) + Text("Both").tag(DoryVMClipboardDirection.bidirectional) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-file-transfer-policy") + Text("File and folder drag/drop uses Dory Tools and follows this direction independently of text and images.") + .font(.system(size: 11)).foregroundStyle(p.text3) } } } @@ -1759,11 +1777,21 @@ private struct MachineEditSheet: View { username: normalizedGuestUsername, numericUserID: typedSettings.guestIdentityIntent.account?.numericUserID ) - if typedSettings.clipboardPolicy != nil || clipboardPolicy != .bidirectional { - typedSettings.clipboardPolicy = .legacyDesktop( - DoryVMClipboardDirection(rawValue: clipboardPolicy.rawValue) - ?? .bidirectional - ) + if originalClipboardPolicy != nil + || clipboardPolicy != initialClipboardPicker + || fileTransferPolicy != initialFileTransferPolicy { + var exactPolicy = originalClipboardPolicy ?? .disabled + if clipboardPolicy != initialClipboardPicker { + let direction = DoryVMClipboardDirection( + rawValue: clipboardPolicy.rawValue + ) ?? .bidirectional + exactPolicy.text = direction + exactPolicy.image = direction + } + if fileTransferPolicy != initialFileTransferPolicy { + exactPolicy.files = fileTransferPolicy + } + typedSettings.clipboardPolicy = exactPolicy } if typedSettings.runtimePreference != nil || runtimePreference != .automatic { typedSettings.runtimePreference = runtimePreference diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index dd654dc6..bc1203be 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -968,7 +968,11 @@ struct NewMachineSheet: View { desktopEnvironment: desktopDistro.desktopName ) ), - clipboardPolicy: .legacyDesktop(.bidirectional), + clipboardPolicy: DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .bidirectional + ), runtimePreference: .automatic, graphicsPreference: .automatic, networkMode: networkMode, diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 7b508139..860301bc 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5239,6 +5239,9 @@ final class AppStore { ) let guestUsername = typedSettings.guestIdentityIntent.account?.username ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root")) + let fileTransferPolicy = status.runtimeIdentity.mode == "legacy-compatibility" + ? DoryVMClipboardDirection.bidirectional + : typedSettings.clipboardPolicy?.files ?? .off return Machine( name: status.id, distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"), @@ -5269,6 +5272,7 @@ final class AppStore { agentProtocolVersion: status.agentProtocolVersion, agentCapabilities: status.agentCapabilities, integrationHealth: status.integrationHealth, + fileTransferPolicy: fileTransferPolicy, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } @@ -5390,6 +5394,7 @@ final class AppStore { func canTransferFiles(to machine: Machine) -> Bool { guard runtimeOwnedByDoryd, machine.status == .running, + machine.fileTransferPolicy.allowsHostToGuest, machine.agentProtocolVersion == 1 else { return false } @@ -5409,6 +5414,7 @@ final class AppStore { func canExportGuestFiles(from machine: Machine) -> Bool { runtimeOwnedByDoryd && machine.status == .running + && machine.fileTransferPolicy.allowsGuestToHost && machine.agentProtocolVersion == 1 && machine.username != "root" && machine.agentCapabilities.contains { diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 99dd776d..ae1f8851 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -315,6 +315,7 @@ struct Machine: Identifiable, Hashable, Sendable { var agentProtocolVersion: UInt32? = nil var agentCapabilities: [DorydAgentCapability] = [] var integrationHealth: DoryGuestIntegrationHealth? = nil + var fileTransferPolicy: DoryVMClipboardDirection = .bidirectional var mounts: [MountPair] = [] var id: String { name } diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index a5b1d51d..08840c7a 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -1502,6 +1502,7 @@ nonisolated enum DorydMachineFileTransferPhase: String, Sendable, Equatable { nonisolated enum DorydMachineFileTransferFailureCode: String, Sendable, Equatable { case guestUnavailable = "guest-unavailable" + case directionNotAuthorized = "direction-not-authorized" case guestPreparationFailed = "guest-preparation-failed" case transferFailed = "transfer-failed" case guestFinalizationFailed = "guest-finalization-failed" diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index eaa38c8f..65222454 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -2817,12 +2817,22 @@ struct DorydClientTests { } #expect(!store.canTransferFiles(to: transferUnavailable)) + var receiveOnly = machine + receiveOnly.fileTransferPolicy = .guestToHost + #expect(!store.canTransferFiles(to: receiveOnly)) + #expect(store.canExportGuestFiles(from: receiveOnly)) + var exportUnavailable = machine exportUnavailable.agentCapabilities = exportUnavailable.agentCapabilities.filter { $0.id != "sync-pull" } #expect(!store.canExportGuestFiles(from: exportUnavailable)) + var sendOnly = machine + sendOnly.fileTransferPolicy = .hostToGuest + #expect(store.canTransferFiles(to: sendOnly)) + #expect(!store.canExportGuestFiles(from: sendOnly)) + let currentSettings = await store.machineSettings(machine.name) #expect(currentSettings.cpus == 2) #expect(currentSettings.memoryMB == 2048) diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 9da19264..93082ae0 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -82,7 +82,11 @@ struct NewMachineSettingsTests { #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == UInt32(getuid())) #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "debian") #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "13") - #expect(s.virtualMachineSettings?.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(s.virtualMachineSettings?.clipboardPolicy == DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .bidirectional + )) #expect(s.virtualMachineSettings?.runtimePreference == .automatic) #expect(s.virtualMachineSettings?.graphicsPreference == .automatic) #expect(s.virtualMachineSettings?.networkMode == .sharedNAT) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index cdb89397..9f919c6a 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -567,11 +567,8 @@ enum DesktopMode { "resolved clipboard device and directional policy disagree" ) } - guard selected.files == .off else { - throw VMError.bootFailure( - "resolved clipboard file transfer is not implemented" - ) - } + // Files use the daemon-owned Dory Tools sync channel. Keep their direction in the + // exact policy while this display-local coordinator handles only text and images. policy = selected } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift index 9512f52b..669dc5b4 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopClipboardPlanTests.swift @@ -32,21 +32,19 @@ import Testing } } - @Test func unsupportedFileTransferFailsClosed() { - #expect(throws: VMError.self) { - _ = try DesktopMode.ClipboardPlan( - resolvedDevices: .init( - clipboard: true, - clipboardPolicy: .init( - text: .bidirectional, - image: .bidirectional, - files: .hostToGuest - ) - ), - environment: [:], - genericGuest: false - ) - } + @Test func fileTransferDirectionRemainsBoundForDaemonEnforcement() throws { + let exact = DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .hostToGuest + ) + let plan = try DesktopMode.ClipboardPlan( + resolvedDevices: .init(clipboard: true, clipboardPolicy: exact), + environment: [:], + genericGuest: false + ) + + #expect(plan.policy == exact) } @Test func historicalLaunchRetainsLegacyPolicy() throws { diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index a3febfee..3d301c8c 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -1824,13 +1824,10 @@ public enum DoryAppleSiliconCapabilityEvaluator { message: "The resolved clipboard device and directional policy disagree." ) } - guard clipboardPolicy.files == .off else { - return unavailable( - tier: tier, - code: .clipboardFileTransferUnsupported, - message: "The selected guest/backend contract does not implement clipboard file transfer." - ) - } + // File transfer is implemented by the authenticated Dory Tools sync channel rather + // than the display helper's clipboard transport. The exact directional policy still + // belongs to this resolved device contract and is enforced by the daemon at each + // push/pull operation boundary. } if devices.clockSynchronization { guard request.guest.family == .linux, diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 0bb6dd75..aeb3a418 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -466,11 +466,8 @@ public enum DoryVZConfigurationBuilder { "resolved clipboard device and directional policy disagree" ) } - guard clipboardPolicy.files == .off else { - throw DoryVZMachineError.validation( - "resolved clipboard file transfer is not implemented" - ) - } + // File directions are enforced by doryd's authenticated Dory Tools transfer + // boundary. This VZ-local clipboard device represents text and images only. } if spec.displayMode != .desktop, devices.display != nil || devices.audioInput || devices.audioOutput || devices.keyboard @@ -584,7 +581,6 @@ public enum DoryVZConfigurationBuilder { // bidirectional default; directional policies use Dory's agent-backed bridge instead. spiceAttachment.sharesClipboard = clipboardPolicy.text == .bidirectional && clipboardPolicy.image == .bidirectional - && clipboardPolicy.files == .off spiceAgent.attachment = spiceAttachment console.ports[0] = spiceAgent configuration.consoleDevices = [console] diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index d82817a0..d4fe1708 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -48,7 +48,6 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow // agent-backed data path to enforce the selected boundary. let usesNativeBidirectionalClipboard = requestedPolicy.text == .bidirectional && requestedPolicy.image == .bidirectional - && requestedPolicy.files == .off let coordinatorPolicy: DoryVMClipboardPolicy = usesNativeBidirectionalClipboard ? .disabled : requestedPolicy diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift index 6d8c8874..bc517930 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineFileTransfer.swift @@ -1,4 +1,5 @@ import DoryCore +import DoryOperations import Foundation /// Safe evidence returned after doryd has copied one private staging tree into a managed guest. @@ -43,6 +44,7 @@ public enum DoryMachineFileTransferPhase: String, Sendable, Equatable, Hashable public enum DoryMachineFileTransferFailureCode: String, Sendable, Equatable, Hashable { case guestUnavailable = "guest-unavailable" + case directionNotAuthorized = "direction-not-authorized" case guestPreparationFailed = "guest-preparation-failed" case transferFailed = "transfer-failed" case guestFinalizationFailed = "guest-finalization-failed" @@ -119,6 +121,7 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri case transferAlreadyInProgress(String) case unknownTransfer(String, String) case exportNotComplete(String, String) + case directionNotAuthorized(String) case guestAccountUnavailable(String) case guestPreparationFailed(String) case transferFailed(String) @@ -136,6 +139,8 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri "unknown file transfer \(operationID) for machine: \(machineID)" case let .exportNotComplete(machineID, operationID): "guest file export \(operationID) is not complete for machine: \(machineID)" + case let .directionNotAuthorized(machineID): + "resolved file transfer direction is not authorized for machine: \(machineID)" case let .guestAccountUnavailable(machineID): "managed guest account is unavailable for machine: \(machineID)" case let .guestPreparationFailed(machineID): @@ -148,6 +153,49 @@ public enum DoryMachineFileTransferError: Error, Sendable, Equatable, CustomStri } } +enum DoryMachineFileTransferFlow: Sendable { + case hostToGuest + case guestToHost +} + +enum DoryMachineFileTransferAuthorization { + /// Compatibility launches retain their historical transfer behavior. A resolved workspace + /// must carry the exact file direction in its validated immutable plan; replanning state is + /// never allowed to borrow stale integration authority. + static func allows( + runtimeIdentity: DoryMachineRuntimeIdentity, + flow: DoryMachineFileTransferFlow + ) -> Bool { + switch runtimeIdentity.mode { + case .legacyCompatibility: + return runtimeIdentity.validate().isEmpty + case .requiresReplanning: + return false + case .resolvedPlan: + guard runtimeIdentity.validate().isEmpty, + let devices = runtimeIdentity.resolvedPlan?.devices else { + return false + } + return allows(resolvedDevices: devices, flow: flow) + } + } + + static func allows( + resolvedDevices devices: DoryVirtualMachineDeviceCapabilityRequest, + flow: DoryMachineFileTransferFlow + ) -> Bool { + guard devices.clipboard, let policy = devices.clipboardPolicy else { + return false + } + switch flow { + case .hostToGuest: + return policy.files.allowsHostToGuest + case .guestToHost: + return policy.files.allowsGuestToHost + } + } +} + /// A verified guest tree staged under a daemon-owned private root. The staging path is an opaque, /// short-lived handoff capability and is returned only for the exact completed operation. public struct DoryMachineGuestFileExportResult: Sendable, Equatable { diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift index 37e22054..981366e1 100644 --- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift +++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift @@ -776,7 +776,7 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { to source: DoryVirtualMachineDefinition, displayMode: DoryMachineDisplayMode ) throws -> DoryVirtualMachineDefinition { - try validate(displayMode: displayMode) + try validate(displayMode: displayMode, requiresLegacyRepresentation: false) var definition = source var account = definition.guestIdentityIntent.account ?? DoryVMGuestAccountIntent() Self.apply(guestUsername, to: &account.username) @@ -1115,7 +1115,10 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { } as NSArray } - private func validate(displayMode: DoryMachineDisplayMode) throws { + private func validate( + displayMode: DoryMachineDisplayMode, + requiresLegacyRepresentation: Bool = true + ) throws { if case let .set(value) = guestUsername, !DoryVMGuestAccountIntent.isValidUsername(value) { throw DoryMachineTypedWriteAuthorityError.invalidField( @@ -1159,7 +1162,8 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable { ) } if case let .set(policy) = clipboardPolicy { - guard policy.text == policy.image, policy.files == .off else { + guard !requiresLegacyRepresentation + || (policy.text == policy.image && policy.files == .off) else { throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( "clipboardPolicy" ) diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 06261e8c..6b034b2d 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -7332,6 +7332,7 @@ public final class MachineManager: @unchecked Sendable { id: String, privateStagingRoot: String ) throws -> DoryMachineFileTransferResult { + try requireFileTransferAuthorization(id: id, flow: .hostToGuest) let transferID = Self.makeMachineFileTransferID() fileTransferLock.lock() pruneFileTransferOperationsLocked(now: Date()) @@ -7367,6 +7368,7 @@ public final class MachineManager: @unchecked Sendable { id: String, privateStagingRoot: String ) throws -> DoryMachineFileTransferOperationStatus { + try requireFileTransferAuthorization(id: id, flow: .hostToGuest) guard let machineStatus = status(id: id) else { throw MachineManagerError.unknownMachine(id) } @@ -7507,6 +7509,7 @@ public final class MachineManager: @unchecked Sendable { id: String, guestSource: String ) throws -> DoryMachineGuestFileExportOperationStatus { + try requireFileTransferAuthorization(id: id, flow: .guestToHost) guard let machineStatus = status(id: id) else { throw MachineManagerError.unknownMachine(id) } @@ -7682,6 +7685,7 @@ public final class MachineManager: @unchecked Sendable { exportID: String, operation: MachineGuestFileExportOperation ) throws -> DoryMachineGuestFileExportResult { + try requireFileTransferAuthorization(id: id, flow: .guestToHost) try Self.requireTransferNotCancelled(operation) operation.setTransferring() let limits = DoryPullLimits( @@ -7723,6 +7727,7 @@ public final class MachineManager: @unchecked Sendable { transferID: String, operation: MachineFileTransferOperation? ) throws -> DoryMachineFileTransferResult { + try requireFileTransferAuthorization(id: id, flow: .hostToGuest) guard DoryMachineFileTransferStager.isManagedStagingRoot(privateStagingRoot) else { throw DoryMachineFileTransferError.invalidPrivateStagingRoot } @@ -7860,6 +7865,19 @@ public final class MachineManager: @unchecked Sendable { UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() } + private func requireFileTransferAuthorization( + id: String, + flow: DoryMachineFileTransferFlow + ) throws { + let identity = try currentRuntimeIdentity(id: id) + guard DoryMachineFileTransferAuthorization.allows( + runtimeIdentity: identity, + flow: flow + ) else { + throw DoryMachineFileTransferError.directionNotAuthorized(id) + } + } + private static func requireTransferNotCancelled( _ operation: MachineFileTransferOperation? ) throws { @@ -7905,6 +7923,11 @@ public final class MachineManager: @unchecked Sendable { ) -> DoryMachineFileTransferFailure { let transferError = error as? DoryMachineFileTransferError switch transferError { + case .directionNotAuthorized: + return .init( + code: .directionNotAuthorized, + message: "The resolved file-transfer direction is not authorized." + ) case .guestAccountUnavailable: return .init(code: .guestUnavailable, message: "Guest account is unavailable.") case .guestPreparationFailed: @@ -9036,6 +9059,11 @@ public final class MachineManager: @unchecked Sendable { ) -> DoryVirtualMachineDefinition { var projected = definition projected.networkMode = compatibility.networkMode + // Legacy machine arguments can encode only one text/image clipboard direction and no + // file policy. Resolved helpers receive the exact policy separately through + // `--resolved-devices`, while this transient projection remains representable without + // persisting or widening the native workspace authority. + projected.clipboardPolicy = compatibility.clipboardPolicy return projected } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index f2c505c9..3df2f2a5 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -1095,8 +1095,8 @@ struct VirtualMachineCapabilitiesTests { #expect(qualifiedDirectionalClipboard.availability.isUsable) #expect(qualifiedDirectionalClipboard.resolvedDevices == directionalClipboard) #expect(mismatchedClipboard.availability.reason?.code == .clipboardPolicyInvalid) - #expect(clipboardFileTransfer.availability.reason?.code - == .clipboardFileTransferUnsupported) + #expect(clipboardFileTransfer.availability.isUsable) + #expect(clipboardFileTransfer.resolvedDevices?.clipboardPolicy?.files == .hostToGuest) } @Test("version-one descriptor JSON remains readable with conservative device defaults") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift index 9b267129..55a78efd 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift @@ -318,6 +318,11 @@ struct DoryMachineTypedWriteAuthorityTests { to: migrated.definition, displayMode: .desktop ).networkMode == .disconnected) + let exactClipboard = try files.applying( + to: migrated.definition, + displayMode: .desktop + ) + #expect(exactClipboard.clipboardPolicy.files == .hostToGuest) let audio = DoryMachineTypedSettingsPatch(audioInputEnabled: .set(false)) #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 5a1d4992..f6e9b0dc 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -898,7 +898,11 @@ final class DoryVMMKitTests: XCTestCase { pointer: false, directorySharing: false, clipboard: true, - clipboardPolicy: .legacyDesktop(.hostToGuest), + clipboardPolicy: DoryVMClipboardPolicy( + text: .hostToGuest, + image: .hostToGuest, + files: .hostToGuest + ), clockSynchronization: false, dynamicDisplay: true, gracefulShutdown: false diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift index 9855a832..b94c9bbb 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerFileTransferTests.swift @@ -6,6 +6,41 @@ import Foundation import XCTest final class MachineManagerFileTransferTests: XCTestCase { + func testResolvedTransferAuthorizationIsDirectionalAndLegacyCompatible() { + XCTAssertTrue(DoryMachineFileTransferAuthorization.allows( + runtimeIdentity: .legacyCompatibility(), + flow: .hostToGuest + )) + XCTAssertFalse(DoryMachineFileTransferAuthorization.allows( + runtimeIdentity: .requiresReplanning(reason: .definitionChanged), + flow: .guestToHost + )) + + let hostOnly = DoryVirtualMachineDeviceCapabilityRequest( + clipboard: true, + clipboardPolicy: DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .hostToGuest + ) + ) + XCTAssertTrue(DoryMachineFileTransferAuthorization.allows( + resolvedDevices: hostOnly, + flow: .hostToGuest + )) + XCTAssertFalse(DoryMachineFileTransferAuthorization.allows( + resolvedDevices: hostOnly, + flow: .guestToHost + )) + + var disabledDevice = hostOnly + disabledDevice.clipboard = false + XCTAssertFalse(DoryMachineFileTransferAuthorization.allows( + resolvedDevices: disabledDevice, + flow: .hostToGuest + )) + } + func testTransferUsesUniqueDerivedDestinationAndGuestOwnership() throws { let fixture = try makeRunningFixture(tag: "success") defer { fixture.cleanup() } diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift index 2ae2b1b0..99ea0723 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift @@ -966,7 +966,11 @@ struct MachineManagerResolvedPlanIntegrationTests { guestNumericUserID: .set(1_000), desktopDistributionIdentifier: .set("ubuntu"), desktopDisplayName: .set("Ubuntu"), - clipboardPolicy: .set(.legacyDesktop(.bidirectional)), + clipboardPolicy: .set(DoryVMClipboardPolicy( + text: .bidirectional, + image: .bidirectional, + files: .bidirectional + )), runtimePreference: .set(.accelerated), graphicsPreference: .set(.virgl), portForwards: .set([ @@ -977,6 +981,7 @@ struct MachineManagerResolvedPlanIntegrationTests { #expect(created.environment.isEmpty) #expect(created.typedSettings?.guestIdentityIntent.account?.username == "developer") #expect(created.typedSettings?.runtimePreference == .accelerated) + #expect(created.typedSettings?.clipboardPolicy?.files == .bidirectional) #expect(created.typedSettings?.portForwards == [ DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), ]) From 49593e24f1d7571d7d29b0ec6157d5ee50df865f Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 08:34:15 +0000 Subject: [PATCH 273/338] fix(vm): bound large artifact verification memory --- .../DoryVirtualMachineArtifactAuthority.swift | 7 ++- ...onVirtualMachineProductionTrustTests.swift | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift index ced78550..ab28bc67 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift @@ -452,7 +452,12 @@ public final class DoryVirtualMachineArtifactAuthority: @unchecked Sendable { throw DoryVirtualMachineArtifactAuthorityError.artifactChanged } if count == 0 { break } - hasher.update(data: Data(buffer.prefix(count))) + buffer.withUnsafeBytes { bytes in + hasher.update(bufferPointer: UnsafeRawBufferPointer( + start: bytes.baseAddress, + count: count + )) + } if !reportedProgress { reportedProgress = true try progressInjector?(.firstChunkHashed(path: path)) diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift index 3754a85c..213ce985 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift @@ -394,6 +394,7 @@ struct DoryDaemonVirtualMachineProductionTrustTests { @Test("activated production graph publishes a headless create plan through XPC authority") func activatedGraphPlansHeadlessCreate() throws { + try withProductionIntegrationTestStack { let fixture = try ProductionTrustFixture() defer { fixture.cleanup() } guard case let .activated(context) = fixture.factory.activate( @@ -481,6 +482,7 @@ struct DoryDaemonVirtualMachineProductionTrustTests { #expect(try context.planning.resourceLedger.snapshot().leases.contains { $0.binding.machineID == "qualified-headless" } == false) + } } @Test("trust-floor failure exposes no manager and a fresh activation can retry") @@ -668,6 +670,51 @@ struct DoryDaemonVirtualMachineProductionTrustTests { private enum ProductionTrustFixtureError: Error { case runtimeRejected + case integrationTestDidNotComplete +} + +private final class ProductionIntegrationTestCompletion: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var result: Result? + + func finish(_ result: Result) { + lock.lock() + self.result = result + lock.unlock() + semaphore.signal() + } + + func wait() throws { + semaphore.wait() + lock.lock() + let result = self.result + lock.unlock() + guard let result else { + throw ProductionTrustFixtureError.integrationTestDidNotComplete + } + try result.get() + } +} + +/// Swift Testing runs synchronous cases on a bounded cooperative stack. This end-to-end case +/// deliberately nests the complete production planning and lifecycle transaction, whose Debug +/// frames exceed that test-only stack even though normal daemon threads and release builds do not. +private func withProductionIntegrationTestStack( + _ operation: @escaping @Sendable () throws -> Void +) throws { + let completion = ProductionIntegrationTestCompletion() + let thread = Thread { + do { + try operation() + completion.finish(.success(())) + } catch { + completion.finish(.failure(error)) + } + } + thread.stackSize = 8 * 1_024 * 1_024 + thread.start() + try completion.wait() } private final class LockedPlanningCreateReply: @unchecked Sendable { From c4ce27a5bb90766dd8af409547700f91372fd4d5 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 08:36:12 +0000 Subject: [PATCH 274/338] fix(vm): serialize in-process resource admission --- ...irtualMachineResourceAdmissionLedger.swift | 30 ++++++++++++++++--- ...lMachineResourceAdmissionLedgerTests.swift | 23 ++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift index 0afb0d2f..8e926073 100644 --- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift +++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift @@ -285,6 +285,22 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError: } } +private final class DoryVirtualMachineResourceAdmissionProcessLockRegistry: + @unchecked Sendable +{ + private let registryLock = NSLock() + private var locks: [String: NSLock] = [:] + + func lock(for root: String) -> NSLock { + registryLock.lock() + defer { registryLock.unlock() } + if let existing = locks[root] { return existing } + let created = NSLock() + locks[root] = created + return created + } +} + /// Durable admission authority for VM starts. Callers provide host facts excluding leases owned by /// this ledger; the ledger composes those facts with all starting/running CPU and RAM commitments /// and every durable disk reservation under one cross-process lock. @@ -297,18 +313,24 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl private static let temporaryPrefix = ".resource-admissions." private static let maximumRecordBytes = 4 * 1_024 * 1_024 private static let maximumStartingLeaseDurationMilliseconds: Int64 = 86_400_000 + private static let processLockRegistry = + DoryVirtualMachineResourceAdmissionProcessLockRegistry() public let root: String - private let instanceLock = NSLock() + private let processLock: NSLock private let now: @Sendable () -> Int64 public init(root: String) { - self.root = URL(fileURLWithPath: root).standardizedFileURL.path + let standardizedRoot = URL(fileURLWithPath: root).standardizedFileURL.path + self.root = standardizedRoot + processLock = Self.processLockRegistry.lock(for: standardizedRoot) now = { Int64(Date().timeIntervalSince1970 * 1_000) } } init(root: String, now: @escaping @Sendable () -> Int64) { - self.root = URL(fileURLWithPath: root).standardizedFileURL.path + let standardizedRoot = URL(fileURLWithPath: root).standardizedFileURL.path + self.root = standardizedRoot + processLock = Self.processLockRegistry.lock(for: standardizedRoot) self.now = now } @@ -754,7 +776,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl } private func withExclusiveAccess(_ body: () throws -> T) throws -> T { - try instanceLock.withLock { + try processLock.withLock { try Self.ensurePrivateDirectory(root) let path = root + "/" + Self.lockFilename let descriptor = path.withCString { diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift index fd05bb7c..bddd8583 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift @@ -14,6 +14,7 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase { let host = fixture.host let resources = fixture.resources let bindings = [fixture.binding("machine-a"), fixture.binding("machine-b")] + let startGate = ResourceLedgerConcurrentStartGate(participants: 2) let results = await withTaskGroup( of: Result] = [] + + init(participants: Int) { + self.participants = participants + } + + func wait() async { + if waiters.count + 1 == participants { + let pending = waiters + waiters.removeAll() + for waiter in pending { waiter.resume() } + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + private final class ResourceLedgerFixture { static let gibibyte: UInt64 = 1_073_741_824 From 79e15d09217cb78df0039276a3e48ad69354a91a Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 08:53:24 +0000 Subject: [PATCH 275/338] test(linux): gate real incompatible installer media --- .../DoryInstallerISOTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift index cbd1c13c..06c69fd5 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift @@ -232,4 +232,45 @@ final class DoryInstallerISOTests: XCTestCase { } XCTAssertFalse(FileManager.default.fileExists(atPath: stagingDirectory.path)) } + + func testRejectsOptInRealX86InstallerBeforeStaging() throws { + let environment = ProcessInfo.processInfo.environment + guard let sourcePath = environment["DORY_TEST_X86_64_INSTALLER_ISO"], + !sourcePath.isEmpty else { + throw XCTSkip( + "set DORY_TEST_X86_64_INSTALLER_ISO for the real-media architecture gate" + ) + } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-real-iso-rejection-\(UUID().uuidString)") + let stagingDirectory = base.appendingPathComponent("staging", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let identity = try DoryInstallerISOInspector.mediaIdentity(atPath: sourcePath) + XCTAssertEqual(identity.architecture, .x86_64) + if let expectedSHA256 = environment["DORY_TEST_INSTALLER_ISO_SHA256"], + !expectedSHA256.isEmpty { + XCTAssertEqual(identity.sha256, expectedSHA256.lowercased()) + } + + XCTAssertThrowsError(try DoryInstallerISOStager.stage( + atPath: sourcePath, + stagingDirectory: stagingDirectory, + hostArchitecture: "arm64" + )) { error in + guard case let .incompatible(message) = error as? DoryInstallerISOStagingError else { + return XCTFail("expected an incompatible-media rejection, got \(error)") + } + XCTAssertEqual( + message, + "This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO." + ) + } + XCTAssertFalse( + FileManager.default.fileExists(atPath: stagingDirectory.path), + "the rejected ISO must not be copied or cloned into managed staging" + ) + } } From b176188b17b92b60e040872af4a0aa8ab7ca9045 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 09:28:02 +0000 Subject: [PATCH 276/338] feat(linux): add exact multi-display runtime --- .../Sources/DoryHV/VirtioGPU.swift | 68 +++-- .../Sources/dory-hv/DesktopMetalDisplay.swift | 65 ++++- .../Sources/dory-hv/DesktopMode.swift | 236 +++++++++++++----- .../DoryHVTests/DesktopDisplayPlanTests.swift | 87 +++++++ .../Tests/DoryHVTests/DeviceLogicTests.swift | 58 +++++ .../DoryVirtualMachineCapabilities.swift | 78 +++++- .../DoryVirtualMachineDefinition.swift | 109 ++++++-- .../Sources/DoryVMMKit/DoryVMM.swift | 7 +- ...yDaemonVirtualMachineRuntimePlanning.swift | 15 +- .../DoryVirtualMachineCapabilitiesTests.swift | 71 +++++- .../DoryVirtualMachineDefinitionTests.swift | 54 +++- ...onVirtualMachineRuntimePlanningTests.swift | 39 ++- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 21 ++ 13 files changed, 775 insertions(+), 133 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 10b0082b..a26fff58 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -84,6 +84,18 @@ public struct VirtioGPURect: Sendable, Equatable { } } +/// The preferred mode for one stable virtio-gpu scanout. Array order is the guest-visible +/// scanout identifier, so callers must preserve it for the lifetime of the device. +public struct VirtioGPUScanoutSize: Sendable, Equatable { + public var width: UInt32 + public var height: UInt32 + + public init(width: UInt32, height: UInt32) { + self.width = min(16_384, max(1, width)) + self.height = min(16_384, max(1, height)) + } +} + /// One copied scanout update ready for a host display surface. `width` and `height` describe the /// complete scanout, while `bytes` contains only `dirtyRect` rows at `stride` bytes per row. The /// device never exposes guest pointers to the UI layer, and small browser repaints therefore avoid @@ -303,8 +315,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private let scanoutCount: UInt32 private let displayLock = NSLock() - private var scanoutWidth: UInt32 - private var scanoutHeight: UInt32 + private var scanoutSizes: [VirtioGPUScanoutSize] private var pendingDisplayEvents: UInt32 = 0 private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? private let onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? @@ -460,6 +471,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi scanoutCount: UInt32 = 0, scanoutWidth: UInt32 = 1_280, scanoutHeight: UInt32 = 800, + scanoutSizes: [VirtioGPUScanoutSize]? = nil, renderer: VirtioGPURenderer? = nil, hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1", @@ -468,7 +480,19 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil, onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil ) { - let boundedScanoutCount = min(scanoutCount, 16) + let boundedScanoutSizes: [VirtioGPUScanoutSize] + if let scanoutSizes { + boundedScanoutSizes = Array(scanoutSizes.prefix(16)) + } else { + boundedScanoutSizes = Array( + repeating: VirtioGPUScanoutSize( + width: scanoutWidth, + height: scanoutHeight + ), + count: Int(min(scanoutCount, 16)) + ) + } + let boundedScanoutCount = UInt32(boundedScanoutSizes.count) self.renderer = renderer self.capsets = renderer?.capsets ?? [] self.traceResourceLifecycle = traceResourceLifecycle @@ -480,8 +504,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize) ] self.scanoutCount = boundedScanoutCount - self.scanoutWidth = max(1, scanoutWidth) - self.scanoutHeight = max(1, scanoutHeight) + self.scanoutSizes = boundedScanoutSizes self.hostVisibleMemory = hostVisibleMemory self.onScanoutFrame = onScanoutFrame self.onScanoutResourceReleased = onScanoutResourceReleased @@ -512,23 +535,37 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed /// framebuffer on the host. public func updateScanoutSize( + scanoutID: UInt32, width: UInt32, height: UInt32, transport: VirtioMMIOTransport ) { - let boundedWidth = min(16_384, max(1, width)) - let boundedHeight = min(16_384, max(1, height)) + let updated = VirtioGPUScanoutSize(width: width, height: height) displayLock.lock() - let changed = scanoutWidth != boundedWidth || scanoutHeight != boundedHeight + let index = Int(scanoutID) + let changed = scanoutSizes.indices.contains(index) && scanoutSizes[index] != updated if changed { - scanoutWidth = boundedWidth - scanoutHeight = boundedHeight + scanoutSizes[index] = updated pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY } displayLock.unlock() if changed { transport.notifyConfigChange() } } + /// Source-compatible primary-scanout resize bridge. + public func updateScanoutSize( + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + updateScanoutSize( + scanoutID: 0, + width: width, + height: height, + transport: transport + ) + } + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return } var cleared: UInt32 = 0 @@ -709,17 +746,16 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi switch command { case Command.getDisplayInfo: displayLock.lock() - let width = scanoutWidth - let height = scanoutHeight - let count = scanoutCount + let sizes = scanoutSizes displayLock.unlock() var response = responseHeader(type: Response.okDisplayInfo, request: request) for index in 0..<16 { + let size = sizes.indices.contains(index) ? sizes[index] : nil response.appendLE(UInt32(0)) response.appendLE(UInt32(0)) - response.appendLE(index < count ? width : 0) - response.appendLE(index < count ? height : 0) - response.appendLE(index < count ? UInt32(1) : 0) + response.appendLE(size?.width ?? 0) + response.appendLE(size?.height ?? 0) + response.appendLE(size == nil ? UInt32(0) : UInt32(1)) response.appendLE(UInt32(0)) } return response diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index 3e50c2aa..36221a92 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -17,6 +17,54 @@ enum DesktopMetalDisplayError: Error, CustomStringConvertible { } } +/// Maps one window-local absolute pointer into the guest's deterministic horizontal scanout +/// layout. Virtio-input exposes one tablet for the whole desktop rather than one per connector. +final class DesktopPointerTopology: @unchecked Sendable { + private let lock = NSLock() + private var sizes: [VirtioGPUScanoutSize] + + init(sizes: [VirtioGPUScanoutSize]) { + self.sizes = sizes + } + + func update(scanoutID: UInt32, width: UInt32, height: UInt32) { + lock.withLock { + let index = Int(scanoutID) + guard sizes.indices.contains(index) else { return } + sizes[index] = VirtioGPUScanoutSize(width: width, height: height) + } + } + + func normalizedPoint( + scanoutID: UInt32, + localX: CGFloat, + localY: CGFloat + ) -> CGPoint { + lock.withLock { + let index = Int(scanoutID) + guard sizes.indices.contains(index), !sizes.isEmpty else { + return CGPoint( + x: min(1, max(0, localX)), + y: min(1, max(0, localY)) + ) + } + let totalWidth = sizes.reduce(UInt64(0)) { $0 + UInt64($1.width) } + let totalHeight = sizes.map(\.height).max() ?? 1 + let originX = sizes[.. [DisplayPlan] { + let displays = resolvedDevices?.displays ?? [DoryVMMDisplayDefaults.capability] + guard !displays.isEmpty, displays.count <= 16 else { + throw VMError.bootFailure( + "raw-HV desktop launch requires between one and sixteen displays" + ) + } + guard Set(displays.map(\.id)).count == displays.count else { + throw VMError.bootFailure( + "resolved display identifiers must be unique" + ) + } + guard Set(displays.map(\.guestUIScaleFactor)).count == 1 else { + throw VMError.bootFailure( + "raw-HV guest tools require one UI scale across all displays" + ) + } + return try displays.enumerated().map { index, display in + try DisplayPlan(display: display, scanoutID: UInt32(index)) + } + } + var windowSize: NSSize { NSSize( width: max(1, CGFloat(widthPixels) / CGFloat(backingScaleFactor)), @@ -603,9 +638,9 @@ enum DesktopMode { private let machine: Machine private let graphicsBackend: DoryDesktopGraphicsBackend private let input: VirtioInput - private let mailbox: DesktopFrameMailbox - private let display: DesktopMetalView - private let window: NSWindow + private let mailboxes: [DesktopFrameMailbox] + private let displays: [DesktopMetalView] + private let windows: [NSWindow] private let vsock: VirtioVsock private let audio: DoryMacAudioBackend private let gvproxy: Process? @@ -659,7 +694,9 @@ enum DesktopMode { "resolved network interface identity or MTU is invalid" ) } - let displayPlan = try DisplayPlan(resolvedDevices: configuration.resolvedDevices) + let displayPlans = try DisplayPlan.resolve( + resolvedDevices: configuration.resolvedDevices + ) if let devices = configuration.resolvedDevices { guard devices.keyboard == devices.pointer else { throw VMError.bootFailure( @@ -698,17 +735,32 @@ enum DesktopMode { Self.attachPlatformDevices(to: machine, serialLog: serialLog) self.input = VirtioInput() - self.mailbox = DesktopFrameMailbox() - let cursorMailbox = DesktopCursorMailbox() - let firstFrame = FirstFrameGate() + var mailboxes = [DesktopFrameMailbox]() + var cursorMailboxes = [DesktopCursorMailbox]() + var displays = [DesktopMetalView]() + let pointerTopology = DesktopPointerTopology(sizes: displayPlans.map { + VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) + }) + for plan in displayPlans { + let mailbox = DesktopFrameMailbox() + let cursorMailbox = DesktopCursorMailbox() + let display = try DesktopMetalView( + frame: NSRect(origin: .zero, size: plan.windowSize), + input: input, + guestBackingScaleFactor: CGFloat(plan.backingScaleFactor), + scanoutID: plan.scanoutID, + pointerTopology: pointerTopology + ) + mailbox.view = display + cursorMailbox.view = display + mailboxes.append(mailbox) + cursorMailboxes.append(cursorMailbox) + displays.append(display) + } + self.mailboxes = mailboxes + self.displays = displays + let firstFrame = FirstFrameGate(requiredScanoutCount: displayPlans.count) self.firstFrame = firstFrame - self.display = try DesktopMetalView( - frame: NSRect(origin: .zero, size: displayPlan.windowSize), - input: input, - guestBackingScaleFactor: CGFloat(displayPlan.backingScaleFactor) - ) - mailbox.view = display - cursorMailbox.view = display let renderer = resolvedGraphics.renderer let hostVisibleMemory = try renderer.map { _ in @@ -716,21 +768,29 @@ enum DesktopMode { } let gpu = VirtioGPU( hostMemoryBase: GuestLayout.daxWindowBase, - scanoutCount: 1, - scanoutWidth: displayPlan.widthPixels, - scanoutHeight: displayPlan.heightPixels, + scanoutSizes: displayPlans.map { + VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) + }, renderer: renderer, hostVisibleMemory: hostVisibleMemory, traceResourceLifecycle: configuration.environment["DORY_GPU_TRACE_RESOURCES"] == "1", - onScanoutFrame: { [mailbox, firstFrame] frame in - mailbox.submit(frame) - firstFrame.signal() + onScanoutFrame: { [mailboxes, firstFrame] frame in + guard mailboxes.indices.contains(Int(frame.scanoutID)) else { return } + mailboxes[Int(frame.scanoutID)].submit(frame) + firstFrame.signal(scanoutID: frame.scanoutID) }, - onScanoutResourceReleased: { [mailbox] resourceID in - mailbox.release(resourceID: resourceID) + onScanoutResourceReleased: { [mailboxes] resourceID in + for mailbox in mailboxes { + mailbox.release(resourceID: resourceID) + } }, - onCursorUpdate: { [cursorMailbox] update in - cursorMailbox.submit(update) + onCursorUpdate: { [cursorMailboxes] update in + guard let update else { + for mailbox in cursorMailboxes { mailbox.submit(nil) } + return + } + guard cursorMailboxes.indices.contains(Int(update.scanoutID)) else { return } + cursorMailboxes[Int(update.scanoutID)].submit(update) } ) let vsock = VirtioVsock(guestCID: 3) @@ -826,7 +886,23 @@ enum DesktopMode { } let displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? if backend === gpu { - displayMetrics = { [weak mailbox] in mailbox?.metrics } + displayMetrics = { [mailboxes] in + mailboxes.map(\.metrics).reduce( + DesktopFrameMailboxMetrics( + presentedFrames: 0, + droppedFrames: 0 + ) + ) { partial, next in + DesktopFrameMailboxMetrics( + presentedFrames: partial.presentedFrames.addingClamped( + next.presentedFrames + ), + droppedFrames: partial.droppedFrames.addingClamped( + next.droppedFrames + ) + ) + } + } } else { displayMetrics = nil } @@ -838,9 +914,23 @@ enum DesktopMode { displayMetrics: displayMetrics ) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { - display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in - guard let gpu, let transport else { return } - gpu.updateScanoutSize(width: width, height: height, transport: transport) + for (index, display) in displays.enumerated() { + let scanoutID = UInt32(index) + display.onDrawableSizeChange = { + [weak gpu, weak transport] width, height in + guard let gpu, let transport else { return } + pointerTopology.update( + scanoutID: scanoutID, + width: width, + height: height + ) + gpu.updateScanoutSize( + scanoutID: scanoutID, + width: width, + height: height, + transport: transport + ) + } } } } @@ -906,22 +996,37 @@ enum DesktopMode { self.clipboard = nil } - self.window = NSWindow( - contentRect: NSRect(origin: .zero, size: displayPlan.windowSize), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.title = "\(configuration.machineID) — Dory Linux" - window.contentView = display - window.minSize = NSSize(width: 640, height: 400) - window.collectionBehavior.insert(.fullScreenPrimary) - window.tabbingMode = .disallowed - window.center() + var windows = [NSWindow]() + for (index, plan) in displayPlans.enumerated() { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: plan.windowSize), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = displayPlans.count == 1 + ? "\(configuration.machineID) — Dory Linux" + : "\(configuration.machineID) — Dory Linux — Display \(index + 1)" + window.contentView = displays[index] + window.minSize = NSSize(width: 640, height: 400) + window.collectionBehavior.insert(.fullScreenPrimary) + window.tabbingMode = .disallowed + window.center() + if index > 0, let first = windows.first { + window.setFrameOrigin(NSPoint( + x: first.frame.minX + CGFloat(index * 36), + y: first.frame.minY - CGFloat(index * 36) + )) + } + windows.append(window) + } + self.windows = windows super.init() - window.delegate = self - display.onMacShortcut = { [weak clipboard] event in - clipboard?.handleMacShortcut(event) ?? false + for window in windows { window.delegate = self } + for display in displays { + display.onMacShortcut = { [weak clipboard] event in + clipboard?.handleMacShortcut(event) ?? false + } } clipboard?.start() } @@ -938,7 +1043,7 @@ enum DesktopMode { application.delegate = self installApplicationMenu() installSignalHandlers() - window.makeKeyAndOrderFront(nil) + for window in windows { window.makeKeyAndOrderFront(nil) } application.activate() startMachine() application.run() @@ -967,7 +1072,7 @@ enum DesktopMode { keyEquivalent: "f" ) fullScreenItem.keyEquivalentModifierMask = [.command, .control] - fullScreenItem.target = window + fullScreenItem.target = windows.first viewMenu.addItem(fullScreenItem) viewItem.submenu = viewMenu mainMenu.addItem(viewItem) @@ -976,7 +1081,7 @@ enum DesktopMode { } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - window.makeKeyAndOrderFront(nil) + for window in windows { window.makeKeyAndOrderFront(nil) } return true } @@ -991,11 +1096,13 @@ enum DesktopMode { } func windowDidResignKey(_ notification: Notification) { - display.releasePressedInput() + guard let window = notification.object as? NSWindow, + let index = windows.firstIndex(of: window) else { return } + displays[index].releasePressedInput() } func applicationDidResignActive(_ notification: Notification) { - display.releasePressedInput() + for display in displays { display.releasePressedInput() } } private func startMachine() { @@ -1072,7 +1179,7 @@ enum DesktopMode { private func requestGuestShutdown() { guard !stopping else { return } stopping = true - window.orderOut(nil) + for window in windows { window.orderOut(nil) } let machine = self.machine if ShutdownPlan(resolvedDevices: configuration.resolvedDevices) == .immediate { machine.requestStop(.powerOff) @@ -1160,7 +1267,8 @@ enum DesktopMode { raiseSource.setEventHandler { [weak self] in MainActor.assumeIsolated { guard let self else { return } - self.window.makeKeyAndOrderFront(nil) + for window in self.windows { window.makeKeyAndOrderFront(nil) } + self.windows.first?.makeKey() self.application.activate() } } @@ -1616,11 +1724,16 @@ enum DesktopMode { private final class FirstFrameGate: @unchecked Sendable { private let condition = NSCondition() - private var ready = false + private let requiredScanoutCount: Int + private var readyScanoutIDs = Set() - func signal() { + init(requiredScanoutCount: Int = 1) { + self.requiredScanoutCount = max(1, requiredScanoutCount) + } + + func signal(scanoutID: UInt32 = 0) { condition.lock() - ready = true + readyScanoutIDs.insert(scanoutID) condition.broadcast() condition.unlock() } @@ -1628,11 +1741,18 @@ private final class FirstFrameGate: @unchecked Sendable { func wait(timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) condition.lock() - while !ready { + while readyScanoutIDs.count < requiredScanoutCount { if !condition.wait(until: deadline) { break } } - let result = ready + let result = readyScanoutIDs.count >= requiredScanoutCount condition.unlock() return result } } + +private extension UInt64 { + func addingClamped(_ other: UInt64) -> UInt64 { + let (result, overflow) = addingReportingOverflow(other) + return overflow ? .max : result + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift index cfdc42b3..74245947 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopDisplayPlanTests.swift @@ -42,6 +42,93 @@ import Testing #expect(plan.guestUIScaleFactor == 2) } + @Test func resolvedTopologyMapsEveryStableDisplayToOneScanout() throws { + let plans = try DesktopMode.DisplayPlan.resolve(resolvedDevices: .init(displays: [ + .init( + id: "display-0", + widthPixels: 2_560, + heightPixels: 1_600, + backingScaleFactor: 2, + guestUIScaleFactor: 2 + ), + .init( + id: "display-1", + widthPixels: 1_920, + heightPixels: 1_080, + backingScaleFactor: 1, + guestUIScaleFactor: 2 + ), + ])) + + #expect(plans.map(\.id) == ["display-0", "display-1"]) + #expect(plans.map(\.scanoutID) == [0, 1]) + #expect(plans.map(\.windowSize) == [ + .init(width: 1_280, height: 800), + .init(width: 1_920, height: 1_080), + ]) + } + + @Test func topologyRejectsDuplicateIDsAndDivergentGuestScale() { + let primary = DoryVirtualMachineDisplayCapabilityRequest( + id: "display-0", + widthPixels: 1_920, + heightPixels: 1_080, + guestUIScaleFactor: 2 + ) + let duplicate = primary + var divergent = primary + divergent.id = "display-1" + divergent.guestUIScaleFactor = 1 + + #expect(throws: VMError.self) { + _ = try DesktopMode.DisplayPlan.resolve(resolvedDevices: .init(displays: [ + primary, duplicate, + ])) + } + #expect(throws: VMError.self) { + _ = try DesktopMode.DisplayPlan.resolve(resolvedDevices: .init(displays: [ + primary, divergent, + ])) + } + } + + @Test func pointerTopologyMapsEachWindowIntoTheWholeGuestDesktop() { + let topology = DesktopPointerTopology(sizes: [ + .init(width: 1_920, height: 1_080), + .init(width: 1_280, height: 1_024), + ]) + + #expect(topology.normalizedPoint( + scanoutID: 0, + localX: 0, + localY: 0 + ) == .init(x: 0, y: 0)) + #expect(topology.normalizedPoint( + scanoutID: 0, + localX: 1, + localY: 1 + ) == .init(x: 0.6, y: 1)) + #expect(topology.normalizedPoint( + scanoutID: 1, + localX: 0, + localY: 0 + ) == .init(x: 0.6, y: 0)) + let secondaryBottomRight = topology.normalizedPoint( + scanoutID: 1, + localX: 1, + localY: 1 + ) + #expect(secondaryBottomRight.x == 1) + #expect(abs(secondaryBottomRight.y - (1_024.0 / 1_080.0)) < 0.000_001) + + topology.update(scanoutID: 1, width: 1_920, height: 1_080) + #expect(topology.normalizedPoint( + scanoutID: 1, + localX: 0, + localY: 0 + ) == .init(x: 0.5, y: 0)) + } + @Test func invalidResolvedGeometryFailsClosed() { for display in [ DoryVirtualMachineDisplayCapabilityRequest(widthPixels: 0, heightPixels: 1_080), diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 34602100..57073212 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -975,6 +975,64 @@ import Testing #expect(leUInt32(gpu.configSpace, at: 8) == 1) } + @Test func publishesAndResizesEachScanoutIndependently() throws { + let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) + let gpu = VirtioGPU( + hostMemoryBase: 0x1_0000_0000, + scanoutSizes: [ + VirtioGPUScanoutSize(width: 1_920, height: 1_080), + VirtioGPUScanoutSize(width: 1_280, height: 1_024), + ] + ) + var interruptCount = 0 + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: gpu, + memory: memory + ) { interruptCount += 1 } + + var display = try gpuResponse(gpu: gpu, request: gpuRequest( + type: 0x0100, + fenceID: 0, + contextID: 0, + ringIndex: 0 + )) + #expect(leUInt32(gpu.configSpace, at: 8) == 2) + #expect(leUInt32(display, at: 32) == 1_920) + #expect(leUInt32(display, at: 36) == 1_080) + #expect(leUInt32(display, at: 40) == 1) + #expect(leUInt32(display, at: 56) == 1_280) + #expect(leUInt32(display, at: 60) == 1_024) + #expect(leUInt32(display, at: 64) == 1) + + gpu.updateScanoutSize( + scanoutID: 1, + width: 2_560, + height: 1_440, + transport: transport + ) + #expect(interruptCount == 1) + + display = try gpuResponse(gpu: gpu, request: gpuRequest( + type: 0x0100, + fenceID: 0, + contextID: 0, + ringIndex: 0 + )) + #expect(leUInt32(display, at: 32) == 1_920) + #expect(leUInt32(display, at: 36) == 1_080) + #expect(leUInt32(display, at: 56) == 2_560) + #expect(leUInt32(display, at: 60) == 1_440) + + gpu.updateScanoutSize( + scanoutID: 7, + width: 800, + height: 600, + transport: transport + ) + #expect(interruptCount == 1) + } + @Test func hostResizeRaisesConfigInterruptAndUpdatesPreferredMode() throws { let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) let gpu = VirtioGPU( diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift index 3d301c8c..0e976337 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift @@ -172,19 +172,22 @@ public struct DoryVirtualMachineNetworkInterfaceCapabilityRequest: } public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equatable, Hashable { - public static let maximumDimensionPixels: UInt32 = 16_384 + public static let maximumDimensionPixels = DoryVMDisplayConfiguration.maximumDimensionPixels + public var id: String public var widthPixels: UInt32 public var heightPixels: UInt32 public var backingScaleFactor: UInt8 public var guestUIScaleFactor: UInt8 public init( + id: String = "display-0", widthPixels: UInt32, heightPixels: UInt32, backingScaleFactor: UInt8 = 2, guestUIScaleFactor: UInt8 = 2 ) { + self.id = id self.widthPixels = widthPixels self.heightPixels = heightPixels self.backingScaleFactor = backingScaleFactor @@ -192,13 +195,15 @@ public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equ } public var isValid: Bool { - (1...Self.maximumDimensionPixels).contains(widthPixels) + DoryVMDisplayConfiguration.isValidIdentifier(id) + && (1...Self.maximumDimensionPixels).contains(widthPixels) && (1...Self.maximumDimensionPixels).contains(heightPixels) && (1...4).contains(backingScaleFactor) && (1...2).contains(guestUIScaleFactor) } private enum CodingKeys: String, CodingKey { + case id case widthPixels case heightPixels case backingScaleFactor @@ -207,6 +212,7 @@ public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equ public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "display-0" widthPixels = try container.decode(UInt32.self, forKey: .widthPixels) heightPixels = try container.decode(UInt32.self, forKey: .heightPixels) backingScaleFactor = try container.decodeIfPresent( @@ -221,11 +227,15 @@ public struct DoryVirtualMachineDisplayCapabilityRequest: Codable, Sendable, Equ public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if id != "display-0" { + try container.encode(id, forKey: .id) + } try container.encode(widthPixels, forKey: .widthPixels) try container.encode(heightPixels, forKey: .heightPixels) try container.encode(backingScaleFactor, forKey: .backingScaleFactor) try container.encode(guestUIScaleFactor, forKey: .guestUIScaleFactor) } + } public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equatable, Hashable { @@ -233,9 +243,19 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa /// `nil` is compatibility-only for resolved plans authored before stable NIC identity was /// introduced. New production planning always supplies this exact contract. public var networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? - /// Exact initial display geometry. `nil` is retained only for historical capability records - /// that predate display binding; newly planned desktop workspaces always carry this value. - public var display: DoryVirtualMachineDisplayCapabilityRequest? + /// Exact ordered display topology. Empty means headless. + public var displays: [DoryVirtualMachineDisplayCapabilityRequest] + /// Source-compatible primary-display bridge for historical capability callers. + public var display: DoryVirtualMachineDisplayCapabilityRequest? { + get { displays.first } + set { + if let newValue { + if displays.isEmpty { displays = [newValue] } else { displays[0] = newValue } + } else { + displays.removeAll() + } + } + } public var audioInput: Bool public var audioOutput: Bool public var keyboard: Bool @@ -258,6 +278,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT, networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest? = nil, display: DoryVirtualMachineDisplayCapabilityRequest? = nil, + displays: [DoryVirtualMachineDisplayCapabilityRequest]? = nil, audioInput: Bool = false, audioOutput: Bool = false, keyboard: Bool = false, @@ -273,7 +294,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa ) { self.networkAttachment = networkAttachment self.networkInterface = networkInterface - self.display = display + self.displays = displays ?? display.map { [$0] } ?? [] self.audioInput = audioInput self.audioOutput = audioOutput self.keyboard = keyboard @@ -292,6 +313,7 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa case networkAttachment case networkInterface case display + case displays case audioInput case audioOutput case keyboard @@ -316,10 +338,17 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa DoryVirtualMachineNetworkInterfaceCapabilityRequest.self, forKey: .networkInterface ) - display = try container.decodeIfPresent( - DoryVirtualMachineDisplayCapabilityRequest.self, - forKey: .display - ) + if container.contains(.displays) { + displays = try container.decode( + [DoryVirtualMachineDisplayCapabilityRequest].self, + forKey: .displays + ) + } else { + displays = try container.decodeIfPresent( + DoryVirtualMachineDisplayCapabilityRequest.self, + forKey: .display + ).map { [$0] } ?? [] + } audioInput = try container.decode(Bool.self, forKey: .audioInput) audioOutput = try container.decode(Bool.self, forKey: .audioOutput) keyboard = try container.decode(Bool.self, forKey: .keyboard) @@ -347,7 +376,11 @@ public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equa var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(networkAttachment, forKey: .networkAttachment) try container.encodeIfPresent(networkInterface, forKey: .networkInterface) - try container.encodeIfPresent(display, forKey: .display) + if displays.count <= 1 { + try container.encodeIfPresent(displays.first, forKey: .display) + } else { + try container.encode(displays, forKey: .displays) + } try container.encode(audioInput, forKey: .audioInput) try container.encode(audioOutput, forKey: .audioOutput) try container.encode(keyboard, forKey: .keyboard) @@ -470,6 +503,7 @@ public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, H case clipboardFileTransferUnsupported = "clipboard-file-transfer-unsupported" case clockSynchronizationUnsupported = "clock-synchronization-unsupported" case dynamicDisplayUnsupported = "dynamic-display-unsupported" + case displayTopologyUnsupported = "display-topology-unsupported" case gracefulShutdownUnsupported = "graceful-shutdown-unsupported" case intelApplicationTranslationUnavailable = "intel-application-translation-unavailable" case removableUSBHotplugUnsupported = "removable-usb-hotplug-unsupported" @@ -903,8 +937,8 @@ public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, /// The daemon's immutable answer to a capability request. `schemaVersion` versions the wire shape, /// while the evaluator version identifies the support policy used to produce the answer. public struct DoryVirtualMachineCapabilityDescriptor: Codable, Sendable, Equatable, Hashable { - public static let currentSchemaVersion: UInt16 = 2 - public static let appleSiliconEvaluatorVersion: UInt16 = 2 + public static let currentSchemaVersion: UInt16 = 3 + public static let appleSiliconEvaluatorVersion: UInt16 = 3 public var schemaVersion: UInt16 public var evaluatorVersion: UInt16 @@ -1753,6 +1787,24 @@ public enum DoryAppleSiliconCapabilityEvaluator { } let isGraphical = request.graphics != .none + let maximumDisplayCount = request.backend == .doryHypervisor + ? DoryVMDisplayConfiguration.maximumCount : 1 + let displayIDs = Set(devices.displays.map(\.id)) + let rawHVUsesOneGuestUIScale = request.backend != .doryHypervisor + || Set(devices.displays.map(\.guestUIScaleFactor)).count <= 1 + guard devices.displays.allSatisfy(\.isValid), + displayIDs.count == devices.displays.count, + devices.displays.count <= maximumDisplayCount, + rawHVUsesOneGuestUIScale, + isGraphical || devices.displays.isEmpty else { + return unavailable( + tier: tier, + code: .displayTopologyUnsupported, + message: request.backend == .doryHypervisor + ? "The resolved display topology is invalid or exceeds the raw-HV scanout limit." + : "The selected backend supports exactly one display for graphical guests." + ) + } let isLinuxVZ = request.guest.family == .linux && request.backend == .appleVirtualizationFramework let isLinuxRawHV = request.guest.family == .linux diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift index f545e0ef..dfaac925 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift @@ -222,6 +222,11 @@ public struct DoryVMPortForward: Codable, Sendable, Equatable, Hashable { } public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { + public static let maximumCount = 16 + public static let maximumDimensionPixels: UInt32 = 16_384 + + /// Stable virtual connector identity. It survives host monitor changes and window movement. + public var id: String public var enabled: Bool public var widthPixels: UInt32 public var heightPixels: UInt32 @@ -232,6 +237,7 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { public var guestUIScaleFactor: UInt8 public init( + id: String = "display-0", enabled: Bool = true, widthPixels: UInt32 = 1_920, heightPixels: UInt32 = 1_080, @@ -239,6 +245,7 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { backingScaleFactor: UInt8 = 2, guestUIScaleFactor: UInt8 = 2 ) { + self.id = id self.enabled = enabled self.widthPixels = widthPixels self.heightPixels = heightPixels @@ -256,7 +263,23 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { guestUIScaleFactor: 0 ) + public static func isValidIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1...63).contains(bytes.count), let first = bytes.first, + isASCIIAlphaNumeric(first) else { return false } + return bytes.dropFirst().allSatisfy { byte in + isASCIIAlphaNumeric(byte) || byte == 45 || byte == 46 || byte == 95 + } + } + + private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + } + private enum CodingKeys: String, CodingKey { + case id case enabled case widthPixels case heightPixels @@ -267,6 +290,7 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "display-0" enabled = try container.decode(Bool.self, forKey: .enabled) widthPixels = try container.decode(UInt32.self, forKey: .widthPixels) heightPixels = try container.decode(UInt32.self, forKey: .heightPixels) @@ -281,6 +305,7 @@ public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) try container.encode(enabled, forKey: .enabled) try container.encode(widthPixels, forKey: .widthPixels) try container.encode(heightPixels, forKey: .heightPixels) @@ -409,6 +434,9 @@ public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseItera case systemDiskCapacityMismatch = "system-disk-capacity-mismatch" case bootDiskArtifactMismatch = "boot-disk-artifact-mismatch" case invalidDisplayConfiguration = "invalid-display-configuration" + case invalidDisplayIdentifier = "invalid-display-identifier" + case duplicateDisplayIdentifier = "duplicate-display-identifier" + case tooManyDisplays = "too-many-displays" case tooManyPortForwards = "too-many-port-forwards" case invalidPortForwardIdentifier = "invalid-port-forward-identifier" case duplicatePortForwardIdentifier = "duplicate-port-forward-identifier" @@ -449,7 +477,7 @@ public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable { /// passwords, tokens, host filesystem paths, or volatile process state. public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public static let oldestSupportedSchemaVersion: UInt16 = 1 - public static let currentSchemaVersion: UInt16 = 4 + public static let currentSchemaVersion: UInt16 = 5 public static let currentVirtualHardwareABIVersion: UInt16 = 1 public var schemaVersion: UInt16 @@ -464,7 +492,23 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { public var storage: [DoryVMStorageAttachment] public var networkMode: DoryVMNetworkMode public var portForwards: [DoryVMPortForward] - public var display: DoryVMDisplayConfiguration + /// Ordered stable display topology. An empty array is a headless workspace. + public var displays: [DoryVMDisplayConfiguration] + /// Source-compatible primary-display bridge for schema-v1...v4 callers. + public var display: DoryVMDisplayConfiguration { + get { displays.first ?? .disabled } + set { + if newValue.enabled { + if displays.isEmpty { + displays = [newValue] + } else { + displays[0] = newValue + } + } else { + displays.removeAll() + } + } + } public var audio: DoryVMAudioConfiguration public var input: DoryVMInputConfiguration public var shares: [DoryVMShare] @@ -488,6 +532,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { networkMode: DoryVMNetworkMode = .sharedNAT, portForwards: [DoryVMPortForward] = [], display: DoryVMDisplayConfiguration = DoryVMDisplayConfiguration(), + displays: [DoryVMDisplayConfiguration]? = nil, audio: DoryVMAudioConfiguration = DoryVMAudioConfiguration(), input: DoryVMInputConfiguration = DoryVMInputConfiguration(), shares: [DoryVMShare] = [], @@ -509,7 +554,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { self.storage = storage self.networkMode = networkMode self.portForwards = portForwards - self.display = display + self.displays = displays ?? (display.enabled ? [display] : []) self.audio = audio self.input = input self.shares = shares @@ -537,6 +582,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { case networkMode case portForwards case display + case displays case audio case input case shares @@ -641,7 +687,11 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode) portForwards = [] - display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) + let legacyDisplay = try container.decode( + DoryVMDisplayConfiguration.self, + forKey: .display + ) + displays = legacyDisplay.enabled ? [legacyDisplay] : [] audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) shares = legacyShares.map { share in @@ -680,7 +730,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } // Schema 2 is structurally migrated by the attachment decoder above. Its missing storage // source becomes `.userProvided`. Sandbox policy is an optional schema-3 extension and - // explicit port forwards are an additive schema-4 extension. + // explicit port forwards are an additive schema-4 extension. Schema 5 replaces the + // singular display field with an ordered, stable topology. schemaVersion = Self.currentSchemaVersion virtualHardwareABIVersion = try container.decode( UInt16.self, @@ -699,7 +750,18 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { [DoryVMPortForward].self, forKey: .portForwards ) ?? [] - display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display) + if persistedSchema >= 5 { + displays = try container.decode( + [DoryVMDisplayConfiguration].self, + forKey: .displays + ) + } else { + let legacyDisplay = try container.decode( + DoryVMDisplayConfiguration.self, + forKey: .display + ) + displays = legacyDisplay.enabled ? [legacyDisplay] : [] + } audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio) input = try container.decode(DoryVMInputConfiguration.self, forKey: .input) shares = try container.decode([DoryVMShare].self, forKey: .shares) @@ -734,7 +796,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { try container.encode(storage, forKey: .storage) try container.encode(networkMode, forKey: .networkMode) try container.encode(portForwards, forKey: .portForwards) - try container.encode(display, forKey: .display) + try container.encode(displays, forKey: .displays) try container.encode(audio, forKey: .audio) try container.encode(input, forKey: .input) try container.encode(shares, forKey: .shares) @@ -993,18 +1055,27 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable { } private func validateDisplay(into issues: inout [DoryVMDefinitionValidationIssue]) { - let dimensionsArePositive = display.widthPixels > 0 - && display.heightPixels > 0 - && display.pixelsPerInch > 0 - && (1...4).contains(display.backingScaleFactor) - && (1...2).contains(display.guestUIScaleFactor) - let dimensionsAreZero = display.widthPixels == 0 - && display.heightPixels == 0 - && display.pixelsPerInch == 0 - && display.backingScaleFactor == 0 - && display.guestUIScaleFactor == 0 - if (display.enabled && !dimensionsArePositive) || (!display.enabled && !dimensionsAreZero) { - issues.append(issue(.invalidDisplayConfiguration, "display")) + if displays.count > DoryVMDisplayConfiguration.maximumCount { + issues.append(issue(.tooManyDisplays, "displays")) + } + var identifiers = Set() + for (index, display) in displays.enumerated() { + let field = "displays[\(index)]" + if !DoryVMDisplayConfiguration.isValidIdentifier(display.id) { + issues.append(issue(.invalidDisplayIdentifier, "\(field).id")) + } else if !identifiers.insert(display.id).inserted { + issues.append(issue(.duplicateDisplayIdentifier, "\(field).id")) + } + let dimensionsArePositive = display.widthPixels > 0 + && display.widthPixels <= DoryVMDisplayConfiguration.maximumDimensionPixels + && display.heightPixels > 0 + && display.heightPixels <= DoryVMDisplayConfiguration.maximumDimensionPixels + && display.pixelsPerInch > 0 + && (1...4).contains(display.backingScaleFactor) + && (1...2).contains(display.guestUIScaleFactor) + if !display.enabled || !dimensionsArePositive { + issues.append(issue(.invalidDisplayConfiguration, field)) + } } } diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index aeb3a418..054b730a 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -532,7 +532,12 @@ public enum DoryVZConfigurationBuilder { if spec.displayMode == .desktop { let devices = spec.resolvedDevices - let display = devices?.display ?? DoryVMMDisplayDefaults.capability + if let devices, devices.displays.count != 1 { + throw DoryVZMachineError.validation( + "Apple Virtualization.framework requires exactly one resolved display" + ) + } + let display = devices?.displays[0] ?? DoryVMMDisplayDefaults.capability guard display.isValid else { throw DoryVZMachineError.validation( "resolved display geometry is outside the supported pixel bounds" diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift index 4559c8cd..03c93ef6 100644 --- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift +++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift @@ -526,14 +526,15 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda return DoryVirtualMachineDeviceCapabilityRequest( networkAttachment: networkAttachment, networkInterface: .stable(machineID: definition.identity.id), - display: definition.display.enabled - ? DoryVirtualMachineDisplayCapabilityRequest( - widthPixels: definition.display.widthPixels, - heightPixels: definition.display.heightPixels, - backingScaleFactor: definition.display.backingScaleFactor, - guestUIScaleFactor: definition.display.guestUIScaleFactor + displays: definition.displays.map { display in + DoryVirtualMachineDisplayCapabilityRequest( + id: display.id, + widthPixels: display.widthPixels, + heightPixels: display.heightPixels, + backingScaleFactor: display.backingScaleFactor, + guestUIScaleFactor: display.guestUIScaleFactor ) - : nil, + }, audioInput: definition.audio.inputEnabled, audioOutput: definition.audio.outputEnabled, keyboard: definition.input.keyboardEnabled, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift index 3df2f2a5..dfd0e48e 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift @@ -218,8 +218,8 @@ struct VirtualMachineCapabilitiesTests { trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics ) - #expect(descriptor.schemaVersion == 2) - #expect(descriptor.evaluatorVersion == 2) + #expect(descriptor.schemaVersion == 3) + #expect(descriptor.evaluatorVersion == 3) #expect(descriptor.availability.supportTier == .supported) #expect(descriptor.availability.state == .available) #expect(descriptor.availability.reason == nil) @@ -648,7 +648,7 @@ struct VirtualMachineCapabilitiesTests { let json = try #require(String(data: encoded, encoding: .utf8)) #expect(decoded == descriptor) - #expect(json.contains(#""schemaVersion":2"#)) + #expect(json.contains(#""schemaVersion":3"#)) #expect(json.contains(#""family":"macos""#)) #expect(json.contains(#""kind":"macos-restore-image""#)) #expect(json.contains(#""backend":"apple-virtualization-framework""#)) @@ -1127,6 +1127,71 @@ struct VirtualMachineCapabilitiesTests { #expect(descriptor.bootMediaInspectionEvidence == nil) } + @Test("raw-HV admits exact multi-display topology while VZ fails closed") + func multiDisplayTopologyIsBackendExact() { + let displays = [ + DoryVirtualMachineDisplayCapabilityRequest( + id: "display-0", + widthPixels: 2_560, + heightPixels: 1_600 + ), + DoryVirtualMachineDisplayCapabilityRequest( + id: "display-1", + widthPixels: 1_920, + heightPixels: 1_080 + ), + ] + let devices = DoryVirtualMachineDeviceCapabilityRequest(displays: displays) + let rawHV = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + let vz = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .appleVirtualizationFramework, + graphics: .software, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: devices + ) + + #expect(rawHV.availability.isUsable) + #expect(rawHV.resolvedDevices?.displays == displays) + #expect(vz.availability.reason?.code == .displayTopologyUnsupported) + + var duplicate = devices + duplicate.displays[1].id = "display-0" + let invalid = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: duplicate + ) + #expect(invalid.availability.reason?.code == .displayTopologyUnsupported) + + var divergentGuestScale = devices + divergentGuestScale.displays[1].guestUIScaleFactor = 1 + let unsupportedScale = evaluate( + family: .linux, + media: .installedLinuxBootBundle, + source: .userProvided, + backend: .doryHypervisor, + graphics: .software, + mediaArtifactSHA256: Self.guestArtifactSHA256, + devices: divergentGuestScale + ) + #expect(unsupportedScale.availability.reason?.code == .displayTopologyUnsupported) + } + private func evaluate( family: DoryGuestFamily, architecture: DoryGuestArchitecture = .arm64, diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift index 102c351e..9d6da056 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift @@ -7,7 +7,7 @@ struct DoryVirtualMachineDefinitionTests { private let gibibyte: UInt64 = 1_073_741_824 private let nowMilliseconds: Int64 = 1_787_200_000_000 - @Test("schema 4 round trips with stable resolver and timestamp representations") + @Test("schema 5 round trips with stable resolver display and timestamp representations") func currentRoundTrip() throws { let original = linuxDefinition() #expect(original.isValid) @@ -19,7 +19,7 @@ struct DoryVirtualMachineDefinitionTests { #expect(decoded == original) let json = try #require(String(data: data, encoding: .utf8)) - #expect(json.contains("\"schemaVersion\":4")) + #expect(json.contains("\"schemaVersion\":5")) #expect(json.contains("\"virtualHardwareABIVersion\":1")) #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000")) #expect(json.contains("\"namespace\":\"boot\"")) @@ -27,6 +27,8 @@ struct DoryVirtualMachineDefinitionTests { #expect(json.contains("\"guestIdentityIntent\"")) #expect(json.contains("\"backingScaleFactor\":2")) #expect(json.contains("\"guestUIScaleFactor\":2")) + #expect(json.contains("\"id\":\"display-0\"")) + #expect(json.contains("\"displays\"")) #expect(json.contains("\"portForwards\":[]")) #expect(!json.contains("\"bootMedia\"")) #expect(!json.contains("artifactID")) @@ -43,6 +45,11 @@ struct DoryVirtualMachineDefinitionTests { object.removeValue(forKey: "guestIdentityIntent") object.removeValue(forKey: "clipboardPolicy") object.removeValue(forKey: "portForwards") + var display = try #require( + (object.removeValue(forKey: "displays") as? [[String: Any]])?.first + ) + display.removeValue(forKey: "id") + object["display"] = display object["schemaVersion"] = 2 var storage = try #require(object["storage"] as? [[String: Any]]) for index in storage.indices { storage[index].removeValue(forKey: "source") } @@ -55,7 +62,7 @@ struct DoryVirtualMachineDefinitionTests { ) #expect(decoded.guestIdentityIntent == .unspecified) #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional)) - #expect(decoded.schemaVersion == 4) + #expect(decoded.schemaVersion == DoryVirtualMachineDefinition.currentSchemaVersion) #expect(decoded.portForwards.isEmpty) #expect(decoded.storage.allSatisfy { $0.source == .userProvided }) #expect(decoded.display.backingScaleFactor == 2) @@ -352,7 +359,7 @@ struct DoryVirtualMachineDefinitionTests { """#.utf8) let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden) - #expect(migrated.schemaVersion == 4) + #expect(migrated.schemaVersion == DoryVirtualMachineDefinition.currentSchemaVersion) #expect(migrated.portForwards.isEmpty) #expect(migrated.virtualHardwareABIVersion == 1) #expect(migrated.workload == .desktop) @@ -368,7 +375,8 @@ struct DoryVirtualMachineDefinitionTests { let upgraded = try JSONEncoder().encode(migrated) let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8)) - #expect(upgradedJSON.contains("\"schemaVersion\":4")) + #expect(upgradedJSON.contains("\"schemaVersion\":5")) + #expect(upgradedJSON.contains("\"displays\"")) #expect(upgradedJSON.contains("createdAtUnixMilliseconds")) #expect(!upgradedJSON.contains("\"bootMedia\"")) } @@ -704,10 +712,42 @@ struct DoryVirtualMachineDefinitionTests { #expect(definition.isValid) definition.display.backingScaleFactor = 0 - #expect(has(.invalidDisplayConfiguration, "display", in: definition.validate())) + #expect(has(.invalidDisplayConfiguration, "displays[0]", in: definition.validate())) definition.display.backingScaleFactor = 2 definition.display.guestUIScaleFactor = 3 - #expect(has(.invalidDisplayConfiguration, "display", in: definition.validate())) + #expect(has(.invalidDisplayConfiguration, "displays[0]", in: definition.validate())) + } + + @Test("display topology has stable unique connectors and a bounded count") + func displayTopologyValidation() throws { + var definition = linuxDefinition() + definition.displays = [ + DoryVMDisplayConfiguration(id: "display-0", widthPixels: 2_560, heightPixels: 1_600), + DoryVMDisplayConfiguration(id: "display-1", widthPixels: 1_920, heightPixels: 1_080), + ] + #expect(definition.isValid) + + let encoded = try JSONEncoder().encode(definition) + let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: encoded) + #expect(decoded.displays == definition.displays) + #expect(decoded.display == definition.displays[0]) + + definition.displays[1].id = "display-0" + #expect(has(.duplicateDisplayIdentifier, "displays[1].id", in: definition.validate())) + definition.displays[1].id = "../../display" + #expect(has(.invalidDisplayIdentifier, "displays[1].id", in: definition.validate())) + + definition.displays[1] = DoryVMDisplayConfiguration( + id: "display-1", + widthPixels: DoryVMDisplayConfiguration.maximumDimensionPixels + 1, + heightPixels: 1_080 + ) + #expect(has(.invalidDisplayConfiguration, "displays[1]", in: definition.validate())) + + definition.displays = (0...DoryVMDisplayConfiguration.maximumCount).map { + DoryVMDisplayConfiguration(id: "display-\($0)") + } + #expect(has(.tooManyDisplays, "displays", in: definition.validate())) } private func linuxDefinition( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift index 4abacb39..c7572e2b 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift @@ -10,6 +10,22 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { let fixture = try Fixture() var definition = fixture.definition definition.networkMode = .disconnected + definition.displays = [ + DoryVMDisplayConfiguration( + id: "display-0", + widthPixels: 1_920, + heightPixels: 1_080, + backingScaleFactor: 2, + guestUIScaleFactor: 1 + ), + DoryVMDisplayConfiguration( + id: "display-1", + widthPixels: 1_280, + heightPixels: 1_024, + backingScaleFactor: 1, + guestUIScaleFactor: 2 + ), + ] let devices = DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition) @@ -17,12 +33,22 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests { #expect(devices.networkInterface == .stable(machineID: definition.identity.id)) #expect(devices.networkInterface?.maximumTransmissionUnit == 1_280) - #expect(devices.display == DoryVirtualMachineDisplayCapabilityRequest( - widthPixels: definition.display.widthPixels, - heightPixels: definition.display.heightPixels, - backingScaleFactor: definition.display.backingScaleFactor, - guestUIScaleFactor: definition.display.guestUIScaleFactor - )) + #expect(devices.displays == [ + DoryVirtualMachineDisplayCapabilityRequest( + id: "display-0", + widthPixels: 1_920, + heightPixels: 1_080, + backingScaleFactor: 2, + guestUIScaleFactor: 1 + ), + DoryVirtualMachineDisplayCapabilityRequest( + id: "display-1", + widthPixels: 1_280, + heightPixels: 1_024, + backingScaleFactor: 1, + guestUIScaleFactor: 2 + ), + ]) #expect(devices.audioInput == definition.audio.inputEnabled) #expect(devices.audioOutput == definition.audio.outputEnabled) #expect(devices.keyboard == definition.input.keyboardEnabled) @@ -294,6 +320,7 @@ private final class Fixture { hostPort: 2_222, guestPort: 22 )], + display: .disabled, audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false), input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false), lifecycle: DoryVMLifecycleMetadata( diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index f6e9b0dc..288acb01 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -942,6 +942,27 @@ final class DoryVMMKitTests: XCTestCase { XCTAssertFalse(resolvedClipboard.sharesClipboard) XCTAssertTrue(configuration.directorySharingDevices.isEmpty) + var multipleDisplays = devices + multipleDisplays.displays.append(DoryVirtualMachineDisplayCapabilityRequest( + id: "display-1", + widthPixels: 1_280, + heightPixels: 1_024 + )) + XCTAssertThrowsError(try DoryVZConfigurationBuilder.makeConfiguration( + spec: DoryVZMachineSpec( + machineID: "resolved-desktop", + stateDirectory: base, + kernelPath: kernel, + rootfsPath: rootfs, + memoryMB: 4096, + cpuCount: 4, + displayMode: .desktop, + resolvedGraphics: .hostAcceleratedDisplay, + resolvedDevices: multipleDisplays + ), + serialOutput: nil + )) + var invalidClipboardDevices = devices invalidClipboardDevices.clipboard = false XCTAssertThrowsError(try DoryVZConfigurationBuilder.makeConfiguration( From 924ef70c9553b27258575df0031f67990a98d240 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 09:41:59 +0000 Subject: [PATCH 277/338] fix(linux): extract ARM64 zboot kernels --- dory-core-swift/Package.swift | 2 +- .../Sources/DoryCore/DoryCore.swift | 40 +++ .../DoryOperations/DoryInstallerISO.swift | 238 +++++++++++++++++- .../Sources/DorydKit/MachineManager.swift | 10 +- .../Tests/DoryCoreTests/DoryCoreTests.swift | 30 +++ .../DoryInstallerISOTests.swift | 148 ++++++++++- dory-core/Cargo.lock | 29 +++ dory-core/ffi/Cargo.toml | 1 + dory-core/ffi/src/lib.rs | 121 ++++++++- 9 files changed, 598 insertions(+), 21 deletions(-) diff --git a/dory-core-swift/Package.swift b/dory-core-swift/Package.swift index d87c34cc..7dde1456 100644 --- a/dory-core-swift/Package.swift +++ b/dory-core-swift/Package.swift @@ -83,7 +83,7 @@ let package = Package( ), .testTarget( name: "DoryOperationsTests", - dependencies: ["DoryOperations"] + dependencies: ["DoryOperations", "DoryCore"] ), .testTarget( name: "DorydKitTests", diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift index d8b74e5f..52e855a3 100644 --- a/dory-core-swift/Sources/DoryCore/DoryCore.swift +++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift @@ -9,6 +9,29 @@ public enum DoryCore { protoVersion() } + /// Decode one immutable boot-time Zstandard artifact with an explicit output ceiling. This is + /// intentionally not a general streaming/data-plane API; Linux zboot extraction is its only + /// production consumer. + public static func decompressZstd( + _ body: Data, + maximumOutputBytes: Int + ) throws -> Data { + guard maximumOutputBytes > 0 else { + throw DoryCoreArtifactError.invalidOutputBound(maximumOutputBytes) + } + let result = decompressZstdBounded( + body: body, + maximumOutputBytes: UInt64(maximumOutputBytes) + ) + guard result.ok else { + throw DoryCoreArtifactError.decompressionFailed(result.error) + } + guard !result.body.isEmpty, result.body.count <= maximumOutputBytes else { + throw DoryCoreArtifactError.invalidDecodedSize(result.body.count) + } + return result.body + } + /// Start the Rust docker dataplane against a plain unix `dockerd` socket. public static func startDockerDataplane( listenFD: Int32, @@ -100,6 +123,23 @@ public enum DoryCore { } } +public enum DoryCoreArtifactError: Error, LocalizedError, Sendable, Equatable { + case invalidOutputBound(Int) + case decompressionFailed(String) + case invalidDecodedSize(Int) + + public var errorDescription: String? { + switch self { + case let .invalidOutputBound(value): + "The requested artifact output bound is invalid (\(value) bytes)." + case let .decompressionFailed(message): + "The compressed artifact could not be decoded: \(message)" + case let .invalidDecodedSize(value): + "The decoded artifact has an invalid size (\(value) bytes)." + } + } +} + /// A Swift-owned lifetime wrapper around the UniFFI dataplane object. public final class DoryDataplaneHandle: @unchecked Sendable { private let lock = NSLock() diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift index 1ae95072..39d53f21 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift @@ -750,6 +750,10 @@ public struct DoryLinuxInstallerBootAssets: Sendable, Equatable { } } +/// Boot-time decompression seam supplied by the daemon's Rust artifact layer. Keeping the parser +/// in DoryOperations avoids making desired-state contracts depend on the generated FFI module. +public typealias DoryZstdBootArtifactDecompressor = @Sendable (Data, Int) throws -> Data + /// Derives the direct-boot payload for an installed Linux disk from its installer media. The EFI /// VM remains the installation backend; these immutable files let the installed disk move to /// Dory's accelerated VirtIO GPU runtime without attempting to emulate EFI in raw Hypervisor. @@ -767,7 +771,10 @@ public enum DoryLinuxInstallerBootAssetExtractor { ("arch/boot/aarch64/vmlinuz-linux", "arch/boot/aarch64/initramfs-linux.img"), ] - public static func extract(atPath isoPath: String) throws -> DoryLinuxInstallerBootAssets { + public static func extract( + atPath isoPath: String, + zstdDecompressor: DoryZstdBootArtifactDecompressor? = nil + ) throws -> DoryLinuxInstallerBootAssets { var attempted = [String]() for candidate in candidates { attempted.append("\(candidate.kernel) + \(candidate.initrd)") @@ -782,7 +789,11 @@ public enum DoryLinuxInstallerBootAssetExtractor { ) else { continue } - let kernel = try materializeARM64Kernel(compressedKernel, source: candidate.kernel) + let kernel = try materializeARM64Kernel( + compressedKernel, + source: candidate.kernel, + zstdDecompressor: zstdDecompressor + ) return DoryLinuxInstallerBootAssets( kernel: kernel, initrd: initrd, @@ -793,20 +804,208 @@ public enum DoryLinuxInstallerBootAssetExtractor { throw DoryLinuxInstallerBootAssetError.notFound(attempted) } - private static func materializeARM64Kernel(_ data: Data, source: String) throws -> Data { - let kernel: Data + static func materializeARM64Kernel( + _ data: Data, + source: String, + zstdDecompressor: DoryZstdBootArtifactDecompressor?, + depth: Int = 0 + ) throws -> Data { + guard depth <= 2 else { + throw DoryLinuxInstallerBootAssetError.unsupportedKernel(source) + } if data.count >= 2, data[0] == 0x1f, data[1] == 0x8b { - kernel = try gunzip(data, source: source) - } else { - kernel = data + return try materializeARM64Kernel( + gunzip(data, source: source), + source: source, + zstdDecompressor: zstdDecompressor, + depth: depth + 1 + ) } - guard kernel.count >= 0x3c, - littleEndianUInt32(kernel, at: 0x38) == arm64ImageMagic else { - throw DoryLinuxInstallerBootAssetError.unsupportedKernel(source) + if isRawARM64Image(data) { + return data + } + if isLinuxZboot(data) { + return try materializeZbootKernel( + data, + source: source, + zstdDecompressor: zstdDecompressor + ) + } + if let linuxSection = try linuxSection(in: data, source: source) { + return try materializeARM64Kernel( + linuxSection, + source: source, + zstdDecompressor: zstdDecompressor, + depth: depth + 1 + ) + } + throw DoryLinuxInstallerBootAssetError.unsupportedKernel(source) + } + + private static func materializeZbootKernel( + _ data: Data, + source: String, + zstdDecompressor: DoryZstdBootArtifactDecompressor? + ) throws -> Data { + guard data.count >= 0x40, + data[0] == 0x4d, data[1] == 0x5a, + data[2] == 0, data[3] == 0, + data[4] == 0x7a, data[5] == 0x69, + data[6] == 0x6d, data[7] == 0x67, + data[16..<24].allSatisfy({ $0 == 0 }) else { + throw DoryLinuxInstallerBootAssetError.invalidZboot(source) + } + let peOffset = Int(littleEndianUInt32(data, at: 0x3c)) + guard validPEHeader(in: data, at: peOffset, expectedMachine: 0xaa64) else { + throw DoryLinuxInstallerBootAssetError.invalidZboot(source) + } + + let compressionBytes = data[24..<56] + guard let terminator = compressionBytes.firstIndex(of: 0), + terminator > compressionBytes.startIndex, + let compression = String( + bytes: compressionBytes[..= 0x40, + payloadSize > 0, + payloadSize <= maximumCompressedKernelBytes, + let payloadRange = checkedRange( + offset: payloadOffset, + length: payloadSize, + total: data.count + ) else { + throw DoryLinuxInstallerBootAssetError.invalidZboot(source) + } + guard let zstdDecompressor else { + throw DoryLinuxInstallerBootAssetError.zstdDecoderUnavailable(source) + } + let kernel: Data + do { + kernel = try zstdDecompressor(Data(data[payloadRange]), maximumKernelBytes) + } catch { + throw DoryLinuxInstallerBootAssetError.invalidZstd( + source, + error.localizedDescription + ) + } + guard kernel.count <= maximumKernelBytes, isRawARM64Image(kernel) else { + throw DoryLinuxInstallerBootAssetError.invalidZstd( + source, + "decoded bytes are not a raw arm64 Linux Image" + ) } return kernel } + /// Extract the `.linux` payload from an ARM64 PE/COFF wrapper such as Ubuntu's unified EFI + /// kernel image. Section-table and file ranges are validated before any slice is materialized. + private static func linuxSection(in data: Data, source: String) throws -> Data? { + guard data.count >= 0x40, data[0] == 0x4d, data[1] == 0x5a else { + return nil + } + let peOffset = Int(littleEndianUInt32(data, at: 0x3c)) + guard validPEHeader(in: data, at: peOffset, expectedMachine: 0xaa64), + let fileHeaderRange = checkedRange( + offset: peOffset + 4, + length: 20, + total: data.count + ) else { + throw DoryLinuxInstallerBootAssetError.invalidPE(source) + } + let numberOfSections = Int(littleEndianUInt16(data, at: fileHeaderRange.lowerBound + 2)) + let optionalHeaderSize = Int(littleEndianUInt16(data, at: fileHeaderRange.lowerBound + 16)) + guard (1...96).contains(numberOfSections), + let sectionTableOffset = checkedAdd(peOffset + 24, optionalHeaderSize), + let sectionTableRange = checkedRange( + offset: sectionTableOffset, + length: numberOfSections * 40, + total: data.count + ) else { + throw DoryLinuxInstallerBootAssetError.invalidPE(source) + } + + var match: Range? + for sectionIndex in 0.. 0, + rawSize <= maximumCompressedKernelBytes, + let range = checkedRange( + offset: rawOffset, + length: rawSize, + total: data.count + ) else { + throw DoryLinuxInstallerBootAssetError.invalidPE(source) + } + match = range + } + guard let match else { + throw DoryLinuxInstallerBootAssetError.invalidPE(source) + } + return Data(data[match]) + } + + private static func isRawARM64Image(_ data: Data) -> Bool { + data.count >= 0x3c && littleEndianUInt32(data, at: 0x38) == arm64ImageMagic + } + + private static func isLinuxZboot(_ data: Data) -> Bool { + data.count >= 8 + && data[0] == 0x4d && data[1] == 0x5a + && data[2] == 0 && data[3] == 0 + && data[4] == 0x7a && data[5] == 0x69 + && data[6] == 0x6d && data[7] == 0x67 + } + + private static func validPEHeader( + in data: Data, + at offset: Int, + expectedMachine: UInt16 + ) -> Bool { + guard let range = checkedRange(offset: offset, length: 24, total: data.count) else { + return false + } + return data[range.lowerBound] == 0x50 + && data[range.lowerBound + 1] == 0x45 + && data[range.lowerBound + 2] == 0 + && data[range.lowerBound + 3] == 0 + && littleEndianUInt16(data, at: range.lowerBound + 4) == expectedMachine + } + + private static func checkedAdd(_ left: Int, _ right: Int) -> Int? { + let (value, overflow) = left.addingReportingOverflow(right) + return overflow ? nil : value + } + + private static func checkedRange(offset: Int, length: Int, total: Int) -> Range? { + guard offset >= 0, length >= 0, offset <= total, length <= total - offset else { + return nil + } + return offset..<(offset + length) + } + private static func gunzip(_ input: Data, source: String) throws -> Data { guard input.count >= 18 else { throw DoryLinuxInstallerBootAssetError.invalidGzip(source) @@ -853,11 +1052,20 @@ public enum DoryLinuxInstallerBootAssetExtractor { | UInt32(data[offset + 2]) << 16 | UInt32(data[offset + 3]) << 24 } + + private static func littleEndianUInt16(_ data: Data, at offset: Int) -> UInt16 { + UInt16(data[offset]) | UInt16(data[offset + 1]) << 8 + } } public enum DoryLinuxInstallerBootAssetError: Error, LocalizedError, Sendable, Equatable { case notFound([String]) case unsupportedKernel(String) + case invalidPE(String) + case invalidZboot(String) + case unsupportedZbootCompression(String, String) + case zstdDecoderUnavailable(String) + case invalidZstd(String, String) case invalidGzip(String) case kernelTooLarge(String, Int) @@ -867,6 +1075,16 @@ public enum DoryLinuxInstallerBootAssetError: Error, LocalizedError, Sendable, E "Installer ISO does not expose a supported arm64 Linux kernel/initrd pair (tried \(candidates.joined(separator: ", ")))." case let .unsupportedKernel(path): "Installer kernel \(path) is not a raw arm64 Linux Image that Dory can direct-boot." + case let .invalidPE(path): + "Installer kernel \(path) has an invalid ARM64 PE/COFF wrapper." + case let .invalidZboot(path): + "Installer kernel \(path) has an invalid Linux EFI zboot header." + case let .unsupportedZbootCompression(path, compression): + "Installer kernel \(path) uses unsupported zboot compression \(compression)." + case let .zstdDecoderUnavailable(path): + "Installer kernel \(path) requires the bounded Rust Zstandard decoder." + case let .invalidZstd(path, message): + "Installer kernel \(path) has an invalid Zstandard payload: \(message)" case let .invalidGzip(path): "Installer kernel \(path) has an invalid or unsupported gzip payload." case let .kernelTooLarge(path, size): diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 6b034b2d..3176f591 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -8081,7 +8081,15 @@ public final class MachineManager: @unchecked Sendable { ) } do { - let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: installerPath) + let assets = try DoryLinuxInstallerBootAssetExtractor.extract( + atPath: installerPath, + zstdDecompressor: { body, maximumOutputBytes in + try DoryCore.decompressZstd( + body, + maximumOutputBytes: maximumOutputBytes + ) + } + ) let rootDevice = try DoryLinuxInstalledDiskInspector.rootDevice(atPath: machine.rootfsPath) try DoryInstalledLinuxBootBundle.write( assets: assets, diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift index f80b7352..d1a117d7 100644 --- a/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/DoryCoreTests.swift @@ -7,6 +7,36 @@ final class DoryCoreTests: XCTestCase { XCTAssertEqual(DoryCore.protocolVersion(), 1) } + func testBoundedZstdBootArtifactDecompressionCrossesRustFFIExactly() throws { + let compressed = Data([ + 0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x58, 0xd1, 0x00, + 0x00, 0x44, 0x6f, 0x72, 0x79, 0x20, 0x62, 0x6f, + 0x75, 0x6e, 0x64, 0x65, 0x64, 0x20, 0x7a, 0x62, + 0x6f, 0x6f, 0x74, 0x20, 0x66, 0x69, 0x78, 0x74, + 0x75, 0x72, 0x65, 0x2b, 0x80, 0x71, 0x29, + ]) + let expected = Data("Dory bounded zboot fixture".utf8) + + XCTAssertEqual( + try DoryCore.decompressZstd( + compressed, + maximumOutputBytes: expected.count + ), + expected + ) + XCTAssertThrowsError( + try DoryCore.decompressZstd( + compressed, + maximumOutputBytes: expected.count - 1 + ) + ) { error in + guard case let .decompressionFailed(message) = error as? DoryCoreArtifactError else { + return XCTFail("expected bounded decompression failure, got \(error)") + } + XCTAssertTrue(message.contains("exceeds")) + } + } + func testConnectAgentControlOverFDRejectsInvalidFD() { XCTAssertThrowsError(try DoryCore.connectAgentControlOverFD(-1)) } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift index 06c69fd5..30547282 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift @@ -1,7 +1,77 @@ -import DoryOperations +@testable import DoryOperations +import DoryCore import XCTest final class DoryInstallerISOTests: XCTestCase { + func testMaterializesPEWrappedZstdZbootKernelWithExactBounds() throws { + let rawKernel = rawARM64Image(marker: 0xa7) + let compressedPayload = Data("synthetic-zstd-frame".utf8) + let zboot = makeZboot(payload: compressedPayload, compression: "zstd") + let wrapped = makePELinuxWrapper(sections: [(".linux", zboot)]) + + let materialized = try DoryLinuxInstallerBootAssetExtractor.materializeARM64Kernel( + wrapped, + source: "casper/vmlinuz", + zstdDecompressor: { payload, maximumOutputBytes in + XCTAssertEqual(payload, compressedPayload) + XCTAssertEqual(maximumOutputBytes, 256 * 1024 * 1024) + return rawKernel + } + ) + + XCTAssertEqual(materialized, rawKernel) + } + + func testZbootParserRejectsUnavailableDecoderAndMalformedPEAuthority() throws { + let zboot = makeZboot(payload: Data([0x28, 0xb5, 0x2f, 0xfd]), compression: "zstd") + XCTAssertThrowsError( + try DoryLinuxInstallerBootAssetExtractor.materializeARM64Kernel( + zboot, + source: "vmlinuz", + zstdDecompressor: nil + ) + ) { error in + XCTAssertEqual( + error as? DoryLinuxInstallerBootAssetError, + .zstdDecoderUnavailable("vmlinuz") + ) + } + + let duplicate = makePELinuxWrapper(sections: [ + (".linux", zboot), + (".linux", zboot), + ]) + let fallbackRawKernel = rawARM64Image(marker: 0) + XCTAssertThrowsError( + try DoryLinuxInstallerBootAssetExtractor.materializeARM64Kernel( + duplicate, + source: "vmlinuz", + zstdDecompressor: { _, _ in fallbackRawKernel } + ) + ) { error in + XCTAssertEqual( + error as? DoryLinuxInstallerBootAssetError, + .invalidPE("vmlinuz") + ) + } + + var malformed = zboot + putUInt32(UInt32(malformed.count - 2), into: &malformed, at: 8) + putUInt32(8, into: &malformed, at: 12) + XCTAssertThrowsError( + try DoryLinuxInstallerBootAssetExtractor.materializeARM64Kernel( + malformed, + source: "vmlinuz", + zstdDecompressor: { _, _ in fallbackRawKernel } + ) + ) { error in + XCTAssertEqual( + error as? DoryLinuxInstallerBootAssetError, + .invalidZboot("vmlinuz") + ) + } + } + func testDiscoversRootPartitionFromOptInInstalledDisk() throws { guard let path = ProcessInfo.processInfo.environment["DORY_TEST_INSTALLED_DISK"], !path.isEmpty else { @@ -25,7 +95,15 @@ final class DoryInstallerISOTests: XCTestCase { maximumBytes: 128 * 1024 * 1024 ) XCTAssertGreaterThan(compressedKernel.count, 1024 * 1024) - let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: path) + let assets = try DoryLinuxInstallerBootAssetExtractor.extract( + atPath: path, + zstdDecompressor: { body, maximumOutputBytes in + try DoryCore.decompressZstd( + body, + maximumOutputBytes: maximumOutputBytes + ) + } + ) XCTAssertGreaterThan(assets.kernel.count, 1024 * 1024) XCTAssertGreaterThan(assets.initrd.count, 1024 * 1024) @@ -273,4 +351,70 @@ final class DoryInstallerISOTests: XCTestCase { "the rejected ISO must not be copied or cloned into managed staging" ) } + + private func rawARM64Image(marker: UInt8) -> Data { + var data = Data(repeating: marker, count: 4 * 1024) + putUInt32(0x644d_5241, into: &data, at: 0x38) + return data + } + + private func makeZboot(payload: Data, compression: String) -> Data { + let payloadOffset = 0x100 + var data = Data(repeating: 0, count: payloadOffset + payload.count) + data.replaceSubrange(0..<8, with: Data([0x4d, 0x5a, 0, 0, 0x7a, 0x69, 0x6d, 0x67])) + putUInt32(UInt32(payloadOffset), into: &data, at: 8) + putUInt32(UInt32(payload.count), into: &data, at: 12) + let compressionBytes = Data(compression.utf8) + Data([0]) + data.replaceSubrange(24..<(24 + compressionBytes.count), with: compressionBytes) + putUInt32(0x40, into: &data, at: 0x3c) + data.replaceSubrange(0x40..<0x44, with: Data([0x50, 0x45, 0, 0])) + putUInt16(0xaa64, into: &data, at: 0x44) + data.replaceSubrange(payloadOffset..<(payloadOffset + payload.count), with: payload) + return data + } + + private func makePELinuxWrapper(sections: [(String, Data)]) -> Data { + let peOffset = 0x80 + let sectionTableOffset = peOffset + 24 + let dataOffset = 0x400 + let totalPayloadSize = sections.reduce(0) { $0 + $1.1.count } + var data = Data(repeating: 0, count: dataOffset + totalPayloadSize) + data[0] = 0x4d + data[1] = 0x5a + putUInt32(UInt32(peOffset), into: &data, at: 0x3c) + data.replaceSubrange(peOffset..<(peOffset + 4), with: Data([0x50, 0x45, 0, 0])) + putUInt16(0xaa64, into: &data, at: peOffset + 4) + putUInt16(UInt16(sections.count), into: &data, at: peOffset + 6) + putUInt16(0, into: &data, at: peOffset + 20) + + var nextDataOffset = dataOffset + for (index, section) in sections.enumerated() { + let headerOffset = sectionTableOffset + index * 40 + let name = Array(section.0.utf8.prefix(8)) + data.replaceSubrange( + headerOffset..<(headerOffset + name.count), + with: Data(name) + ) + putUInt32(UInt32(section.1.count), into: &data, at: headerOffset + 16) + putUInt32(UInt32(nextDataOffset), into: &data, at: headerOffset + 20) + data.replaceSubrange( + nextDataOffset..<(nextDataOffset + section.1.count), + with: section.1 + ) + nextDataOffset += section.1.count + } + return data + } + + private func putUInt16(_ value: UInt16, into data: inout Data, at offset: Int) { + data[offset] = UInt8(truncatingIfNeeded: value) + data[offset + 1] = UInt8(truncatingIfNeeded: value >> 8) + } + + private func putUInt32(_ value: UInt32, into data: inout Data, at offset: Int) { + data[offset] = UInt8(truncatingIfNeeded: value) + data[offset + 1] = UInt8(truncatingIfNeeded: value >> 8) + data[offset + 2] = UInt8(truncatingIfNeeded: value >> 16) + data[offset + 3] = UInt8(truncatingIfNeeded: value >> 24) + } } diff --git a/dory-core/Cargo.lock b/dory-core/Cargo.lock index 89d1d2e8..d6c26833 100644 --- a/dory-core/Cargo.lock +++ b/dory-core/Cargo.lock @@ -746,6 +746,7 @@ dependencies = [ "thiserror", "tokio", "uniffi", + "zstd", ] [[package]] @@ -2942,3 +2943,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/dory-core/ffi/Cargo.toml b/dory-core/ffi/Cargo.toml index 31f74b81..16ea4820 100644 --- a/dory-core/ffi/Cargo.toml +++ b/dory-core/ffi/Cargo.toml @@ -20,6 +20,7 @@ dory-remote = { path = "../remote" } uniffi = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } +zstd = "0.13.3" [dev-dependencies] serde_json = { workspace = true } diff --git a/dory-core/ffi/src/lib.rs b/dory-core/ffi/src/lib.rs index 07c0bdfe..454191e8 100644 --- a/dory-core/ffi/src/lib.rs +++ b/dory-core/ffi/src/lib.rs @@ -1,12 +1,13 @@ //! `dory-ffi` — seam 2: the UniFFI staticlib the Swift `doryd`/`dory-vmm` link. //! -//! The iron rule: this boundary carries **configuration, file descriptors, and stats — never -//! data-plane bytes**. Swift hands the Rust engine an fd and a compiled config; from then on bytes -//! flow socket-to-socket entirely inside Rust ([`dory_proto::half_close`]), zero per-packet -//! crossings. Panic contract: the release profile builds with `panic = "abort"`, so a panic here -//! kills the embedding process — it does NOT surface as a Swift error. Exported entries therefore -//! avoid panicking on any input; the one deliberate exception is tokio runtime construction, whose -//! failure (thread/fd exhaustion) is treated as fatal. +//! The iron rule: hot-path data-plane bytes never cross this boundary. Swift hands the Rust engine +//! an fd and a compiled config; from then on bytes flow socket-to-socket entirely inside Rust +//! ([`dory_proto::half_close`]), zero per-packet crossings. The sole byte-buffer exception is the +//! bounded, boot-time decompression of an immutable Linux kernel artifact. Panic contract: the +//! release profile builds with `panic = "abort"`, so a panic here kills the embedding process — it +//! does NOT surface as a Swift error. Exported entries therefore avoid panicking on any input; the +//! one deliberate exception is tokio runtime construction, whose failure (thread/fd exhaustion) is +//! treated as fatal. //! //! This slice exposes the small control surface that is ready: the protocol version and the //! create-body rewrite. The `serve(listen_fd, config) -> Handle` entry that hands Rust the captive @@ -53,6 +54,80 @@ pub fn rewrite_create_body(body: Vec, gpu_supported: bool) -> RewriteResult } } +const MAXIMUM_ZSTD_OUTPUT_BYTES: u64 = 256 * 1024 * 1024; + +/// Result of bounded boot-artifact decompression. This record keeps malformed or oversized +/// untrusted media on the ordinary Swift error path instead of allowing an FFI panic. +#[derive(uniffi::Record)] +pub struct BoundedDecompressionResult { + pub ok: bool, + pub body: Vec, + pub error: String, +} + +/// Decompress one Zstandard frame (or a standards-compliant concatenated frame stream) while +/// enforcing both a caller-selected output bound and Dory's immutable 256 MiB kernel ceiling. +/// The decoder window is capped to the same ceiling so a hostile frame cannot demand unbounded +/// history before producing output. +#[uniffi::export] +pub fn decompress_zstd_bounded( + body: Vec, + maximum_output_bytes: u64, +) -> BoundedDecompressionResult { + match decompress_zstd_bounded_inner(&body, maximum_output_bytes) { + Ok(output) => BoundedDecompressionResult { + ok: true, + body: output, + error: String::new(), + }, + Err(error) => BoundedDecompressionResult { + ok: false, + body: Vec::new(), + error, + }, + } +} + +fn decompress_zstd_bounded_inner( + body: &[u8], + maximum_output_bytes: u64, +) -> Result, String> { + use std::io::Read; + + if body.is_empty() { + return Err("compressed payload is empty".to_string()); + } + if maximum_output_bytes == 0 || maximum_output_bytes > MAXIMUM_ZSTD_OUTPUT_BYTES { + return Err(format!( + "maximum output must be between 1 and {MAXIMUM_ZSTD_OUTPUT_BYTES} bytes" + )); + } + + let cursor = std::io::Cursor::new(body); + let mut decoder = zstd::stream::read::Decoder::new(cursor) + .map_err(|error| format!("invalid zstd frame: {error}"))?; + decoder + .window_log_max(28) + .map_err(|error| format!("invalid zstd window: {error}"))?; + + let mut limited = decoder.take(maximum_output_bytes + 1); + let initial_capacity = usize::try_from(maximum_output_bytes.min(16 * 1024 * 1024)) + .map_err(|_| "maximum output does not fit this platform".to_string())?; + let mut output = Vec::with_capacity(initial_capacity); + limited + .read_to_end(&mut output) + .map_err(|error| format!("invalid or truncated zstd payload: {error}"))?; + if output.len() as u64 > maximum_output_bytes { + return Err(format!( + "decompressed payload exceeds {maximum_output_bytes} bytes" + )); + } + if output.is_empty() { + return Err("decompressed payload is empty".to_string()); + } + Ok(output) +} + /// A running docker dataplane, owned by the embedding VMM/doryd process. This is the real seam-2 /// pattern: Swift binds `dory.sock`, hands the listener **fd** and the backend config across the FFI, /// and from then on every byte flows socket-to-socket inside Rust — none crosses back into Swift. @@ -221,6 +296,38 @@ mod tests { assert!(r.error.contains("GPU")); } + #[test] + fn zstd_decompression_is_exact_and_bounded() { + let input = vec![0x5a; 2 * 1024 * 1024]; + let compressed = zstd::stream::encode_all(input.as_slice(), 3).unwrap(); + + let result = decompress_zstd_bounded(compressed.clone(), input.len() as u64); + assert!(result.ok, "{}", result.error); + assert_eq!(result.body, input); + + let oversized = decompress_zstd_bounded(compressed, (input.len() - 1) as u64); + assert!(!oversized.ok); + assert!(oversized.body.is_empty()); + assert!(oversized.error.contains("exceeds")); + } + + #[test] + fn zstd_decompression_rejects_truncation_and_invalid_bounds() { + let input = vec![0x41; 64 * 1024]; + let mut compressed = zstd::stream::encode_all(input.as_slice(), 1).unwrap(); + compressed.truncate(compressed.len() / 2); + + let truncated = decompress_zstd_bounded(compressed, 128 * 1024); + assert!(!truncated.ok); + assert!(truncated.body.is_empty()); + + let zero = decompress_zstd_bounded(vec![1, 2, 3], 0); + assert!(!zero.ok); + let above_hard_limit = + decompress_zstd_bounded(vec![1, 2, 3], MAXIMUM_ZSTD_OUTPUT_BYTES + 1); + assert!(!above_hard_limit.ok); + } + #[test] fn rewrite_adds_extra_hosts() { let r = rewrite_create_body(br#"{"Image":"alpine"}"#.to_vec(), true); From ca89e53fc5b370ba70566d1740458e3240658b09 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 10:11:34 +0000 Subject: [PATCH 278/338] feat(linux): assign guest desktop to Mac display --- Dory/Features/Machines/MachinesView.swift | 44 +++++ Dory/Features/Sheets/NewMachineSheet.swift | 33 ++++ Dory/Models/AppStore.swift | 54 ++++-- Dory/Runtime/Doryd/DorydClient.swift | 83 +++++++++ Dory/Runtime/Machines/HostDisplayChoice.swift | 23 +++ Dory/Runtime/Machines/MachineService.swift | 2 + DoryTests/DorydClientTests.swift | 56 +++++- .../Sources/dory-hv/DesktopMode.swift | 32 +++- .../Sources/dory-hv/main.swift | 15 +- .../DoryMachineDisplayPresentation.swift | 87 +++++++++ .../DoryHostDisplayPresentation.swift | 66 +++++++ .../Sources/DoryVMMKit/DoryVMM.swift | 23 ++- .../DoryVMMDesktopApplication.swift | 49 ++++- .../DoryMachineDisplayPresentationStore.swift | 174 ++++++++++++++++++ .../DoryMachineDisplayPresentationXPC.swift | 70 +++++++ .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 24 +++ .../Sources/DorydKit/MachineManager.swift | 74 ++++++++ .../DoryMachineDisplayPresentationTests.swift | 40 ++++ ...MachineDisplayPresentationStoreTests.swift | 60 ++++++ .../Tests/DorydKitTests/DoryVMMKitTests.swift | 22 +++ .../DorydKitTests/MachineManagerTests.swift | 25 ++- 22 files changed, 1036 insertions(+), 21 deletions(-) create mode 100644 Dory/Runtime/Machines/HostDisplayChoice.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryMachineDisplayPresentation.swift create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryHostDisplayPresentation.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationStore.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationXPC.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryMachineDisplayPresentationTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineDisplayPresentationStoreTests.swift diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 9065fe1f..96e4f079 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1308,6 +1308,8 @@ private struct MachineEditSheet: View { @State private var originalClipboardPolicy: DoryVMClipboardPolicy? @State private var runtimePreference = DoryDesktopVMMPreference.automatic @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic + @State private var hostDisplays: [HostDisplayChoice] = [] + @State private var dedicatedHostDisplayUUID: String? @State private var networkMode = DoryVMNetworkMode.sharedNAT @State private var portForwardRows: [MachinePortForwardDraft] = [] @State private var audioInputEnabled = true @@ -1342,6 +1344,7 @@ private struct MachineEditSheet: View { ) audioBlock runtimeBlock + displayAssignmentBlock intelApplicationTranslationBlock clipboardBlock resourceRow @@ -1384,6 +1387,13 @@ private struct MachineEditSheet: View { initialFileTransferPolicy = fileTransferPolicy runtimePreference = typedSettings.runtimePreference ?? .automatic graphicsPreference = typedSettings.graphicsPreference ?? .automatic + hostDisplays = HostDisplayChoice.connectedDisplays() + let presentation = settings.displayPresentation ?? .windowed + dedicatedHostDisplayUUID = presentation.assignment( + forGuestDisplayID: "display-0" + ).flatMap { + $0.mode == .dedicatedFullscreen ? $0.hostDisplayUUID : nil + } networkMode = typedSettings.networkMode ?? .sharedNAT portForwardRows = typedSettings.portForwards.map(MachinePortForwardDraft.init) originalAudioConfiguration = typedSettings.audioConfiguration @@ -1591,6 +1601,31 @@ private struct MachineEditSheet: View { } } + @ViewBuilder private var displayAssignmentBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 9) { + sectionLabel("MAC DISPLAY") + Picker("Guest presentation", selection: $dedicatedHostDisplayUUID) { + Text("Windowed").tag(String?.none) + ForEach(hostDisplays) { display in + Text("Dedicated — \(display.name)").tag(Optional(display.id)) + } + if let selected = dedicatedHostDisplayUUID, + !hostDisplays.contains(where: { $0.id == selected }) { + Text("Disconnected display — window fallback") + .tag(Optional(selected)) + } + } + .accessibilityIdentifier("edit-machine-host-display") + Text(dedicatedHostDisplayUUID == nil + ? "The Linux desktop opens as a normal Mac window." + : "The guest owns a native full-screen Space on this monitor. Command-Control-F exits full screen; disconnecting it falls back to a normal window.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + @ViewBuilder private var intelApplicationTranslationBlock: some View { if displayMode == .desktop, machine.bootMode != .efi { MachineIntelApplicationTranslationControl( @@ -1811,6 +1846,15 @@ private struct MachineEditSheet: View { mounts: mounts, env: [:], virtualMachineSettings: typedSettings, + displayPresentation: DoryMachineDisplayPresentation( + assignments: dedicatedHostDisplayUUID.map { + [DoryGuestDisplayPresentationAssignment( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: $0 + )] + } ?? [] + ), address: address.trimmingCharacters(in: .whitespacesAndNewlines), displayMode: displayMode, bootMode: machine.bootMode diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index bc1203be..e5815479 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -23,6 +23,8 @@ struct NewMachineSheet: View { @State private var audioInputEnabled = true @State private var audioOutputEnabled = true @State private var intelApplicationTranslationEnabled = false + @State private var hostDisplays: [HostDisplayChoice] = [] + @State private var dedicatedHostDisplayUUID: String? private enum InstallerISOCheck: Equatable { case none @@ -81,6 +83,7 @@ struct NewMachineSheet: View { } .frame(width: 600, height: 600) .background(p.bgWindow) + .onAppear { hostDisplays = HostDisplayChoice.connectedDisplays() } } private var formScreen: some View { @@ -101,6 +104,7 @@ struct NewMachineSheet: View { accessibilityPrefix: "new-machine" ) audioBlock + displayAssignmentBlock intelApplicationTranslationBlock optionsRow advancedSection @@ -578,6 +582,26 @@ struct NewMachineSheet: View { } } + @ViewBuilder private var displayAssignmentBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("MAC DISPLAY") + Picker("Guest presentation", selection: $dedicatedHostDisplayUUID) { + Text("Windowed").tag(String?.none) + ForEach(hostDisplays) { display in + Text("Dedicated — \(display.name)").tag(Optional(display.id)) + } + } + .accessibilityIdentifier("new-machine-host-display") + Text(dedicatedHostDisplayUUID == nil + ? "Open the Linux desktop as a normal Mac window." + : "Give the guest a native full-screen Space on this monitor. Command-Control-F returns to a window.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + @ViewBuilder private var intelApplicationTranslationBlock: some View { if displayMode == .desktop, !customISOInstall { MachineIntelApplicationTranslationControl( @@ -1054,6 +1078,15 @@ struct NewMachineSheet: View { ) ) } + settings.displayPresentation = DoryMachineDisplayPresentation( + assignments: dedicatedHostDisplayUUID.map { + [DoryGuestDisplayPresentationAssignment( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: $0 + )] + } ?? [] + ) return settings } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 860301bc..41bf229a 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -5089,6 +5089,7 @@ final class AppStore { legacyEnvironment: status.environment, displayMode: status.displayMode ), + displayPresentation: status.displayPresentation, address: status.configuredAddress, displayMode: status.displayMode, bootMode: status.bootMode @@ -6374,6 +6375,9 @@ final class AppStore { if copy.virtualMachineSettings == nil { copy.virtualMachineSettings = existing.virtualMachineSettings } + if copy.displayPresentation == nil { + copy.displayPresentation = existing.displayPresentation + } if copy.identity == nil { copy.identity = existing.identity } if copy.address == nil { copy.address = existing.address } return copy @@ -6615,6 +6619,12 @@ final class AppStore { } _ = try await dorydClient.machineCreate(config) createdDefinition = true + if let displayPresentation = settings.displayPresentation { + _ = try await dorydClient.machineDisplayPresentationSet( + name, + presentation: displayPresentation + ) + } appendMachineCreationLog("Definition written. Booting VM…") _ = try await dorydClient.machineStart(name) if let recipe, let provisioningRecipe { @@ -6688,6 +6698,7 @@ final class AppStore { displayMode: $0.displayMode ) }, + displayPresentation: current?.displayPresentation, address: current?.configuredAddress, displayMode: current?.displayMode ?? machine.displayMode, bootMode: current?.bootMode ?? machine.bootMode @@ -6700,18 +6711,39 @@ final class AppStore { let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB let cpus = effectiveSettings.cpus ?? current?.cpuCount let address = Self.trimmedNonEmpty(effectiveSettings.address) - _ = try await dorydClient.machineUpdate( - machine.name, - memoryMB: memory, - cpuCount: cpus, - address: address, - updatesAddress: true, - shares: Self.dorydShares(from: effectiveSettings.mounts), - typedSettingsPatch: DorydMachineTypedSettingsPatch( - baseline: baselineTypedSettings, - desired: desiredTypedSettings + let desiredPresentation = effectiveSettings.displayPresentation + ?? currentSettings.displayPresentation + ?? .windowed + let previousPresentation = currentSettings.displayPresentation ?? .windowed + let presentationChanged = desiredPresentation != previousPresentation + if presentationChanged { + _ = try await dorydClient.machineDisplayPresentationSet( + machine.name, + presentation: desiredPresentation ) - ) + } + do { + _ = try await dorydClient.machineUpdate( + machine.name, + memoryMB: memory, + cpuCount: cpus, + address: address, + updatesAddress: true, + shares: Self.dorydShares(from: effectiveSettings.mounts), + typedSettingsPatch: DorydMachineTypedSettingsPatch( + baseline: baselineTypedSettings, + desired: desiredTypedSettings + ) + ) + } catch { + if presentationChanged { + _ = try? await dorydClient.machineDisplayPresentationSet( + machine.name, + presentation: previousPresentation + ) + } + throw error + } appendMachineCreationLog("Settings applied to doryd VM definition.") activeSheet = nil await refreshMachines() diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 08840c7a..46b46077 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -26,6 +26,7 @@ nonisolated protocol DorydControlXPC { func machineResume(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDisplayPresentationSet(_ machineID: String, presentation: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) @@ -1076,6 +1077,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] var typedSettings: DorydMachineTypedSettings? = nil + var displayPresentation: DoryMachineDisplayPresentation = .windowed var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil var cloneReceipt: DorydMachineCloneReceipt? = nil @@ -2023,6 +2025,24 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineDisplayPresentationSet( + _ machineID: String, + presentation: DoryMachineDisplayPresentation + ) async throws -> DorydMachineStatus { + guard presentation.isValid else { + throw DorydClientError.daemon("invalid display presentation preference") + } + return try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineDisplayPresentationSet( + machineID, + presentation: Self.displayPresentationDictionary(presentation), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + func machineDelete(_ machineID: String) async throws -> DorydCommandResult { try await command { proxy, reply in proxy.machineDelete(machineID, reply: reply) @@ -2843,6 +2863,9 @@ nonisolated final class DorydClient: @unchecked Sendable { guard let typedSettings = machineTypedSettings(from: dictionary) else { return nil } + guard let displayPresentation = machineDisplayPresentation( + from: dictionary["displayPresentation"] + ) else { return nil } guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( from: dictionary, legacyEnvironment: environment @@ -2926,6 +2949,7 @@ nonisolated final class DorydClient: @unchecked Sendable { shares: shares, environment: environment, typedSettings: typedSettings.value, + displayPresentation: displayPresentation, runtimeIdentity: runtimeIdentity, installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, cloneReceipt: cloneReceipt.value, @@ -2933,6 +2957,65 @@ nonisolated final class DorydClient: @unchecked Sendable { ) } + nonisolated private static func machineDisplayPresentation( + from encoded: Any? + ) -> DoryMachineDisplayPresentation? { + guard let encoded else { return .windowed } + guard let dictionary = encoded as? NSDictionary, + dictionary.allKeys.count == 2, + Set(dictionary.allKeys.compactMap { $0 as? String }) + == Set(["schemaVersion", "assignments"]), + let schema = dictionary["schemaVersion"] as? NSNumber, + String(cString: schema.objCType) != "c", + schema.intValue == DoryMachineDisplayPresentation.currentSchemaVersion, + let rows = dictionary["assignments"] as? NSArray else { return nil } + var assignments: [DoryGuestDisplayPresentationAssignment] = [] + assignments.reserveCapacity(rows.count) + for encodedRow in rows { + guard let row = encodedRow as? NSDictionary, + let guestDisplayID = row["guestDisplayID"] as? String, + let rawMode = row["mode"] as? String, + let mode = DoryGuestDisplayPresentationMode(rawValue: rawMode) else { + return nil + } + let expected: Set = mode == .dedicatedFullscreen + ? ["guestDisplayID", "mode", "hostDisplayUUID"] + : ["guestDisplayID", "mode"] + guard row.allKeys.count == expected.count, + Set(row.allKeys.compactMap { $0 as? String }) == expected else { return nil } + let assignment = DoryGuestDisplayPresentationAssignment( + guestDisplayID: guestDisplayID, + mode: mode, + hostDisplayUUID: row["hostDisplayUUID"] as? String + ) + guard assignment.isValid else { return nil } + assignments.append(assignment) + } + let presentation = DoryMachineDisplayPresentation( + schemaVersion: schema.intValue, + assignments: assignments + ) + return presentation.isValid ? presentation.canonicalized : nil + } + + nonisolated private static func displayPresentationDictionary( + _ presentation: DoryMachineDisplayPresentation + ) -> NSDictionary { + [ + "schemaVersion": presentation.schemaVersion, + "assignments": presentation.canonicalized.assignments.map { assignment in + var row: [String: Any] = [ + "guestDisplayID": assignment.guestDisplayID, + "mode": assignment.mode.rawValue, + ] + if let hostDisplayUUID = assignment.hostDisplayUUID { + row["hostDisplayUUID"] = hostDisplayUUID + } + return row as NSDictionary + } as NSArray, + ] + } + private struct ParsedMachineCloneReceipt { var value: DorydMachineCloneReceipt? } diff --git a/Dory/Runtime/Machines/HostDisplayChoice.swift b/Dory/Runtime/Machines/HostDisplayChoice.swift new file mode 100644 index 00000000..7f78779c --- /dev/null +++ b/Dory/Runtime/Machines/HostDisplayChoice.swift @@ -0,0 +1,23 @@ +import AppKit +import CoreGraphics + +nonisolated struct HostDisplayChoice: Identifiable, Hashable, Sendable { + var id: String + var name: String + + @MainActor + static func connectedDisplays() -> [HostDisplayChoice] { + NSScreen.screens.compactMap { screen in + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let number = screen.deviceDescription[key] as? NSNumber, + let unmanaged = CGDisplayCreateUUIDFromDisplayID( + CGDirectDisplayID(number.uint32Value) + ) else { return nil } + let uuid = unmanaged.takeRetainedValue() + return HostDisplayChoice( + id: (CFUUIDCreateString(nil, uuid) as String).lowercased(), + name: screen.localizedName + ) + } + } +} diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index 22d088a1..1bc958f5 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -1,4 +1,5 @@ import Foundation +import DoryOperations nonisolated struct MountPair: Sendable, Hashable { var host: String @@ -27,6 +28,7 @@ nonisolated struct MachineSettings: Sendable, Hashable { /// Closed, non-secret VM intent used by new doryd writes. `env` remains only so older /// machine.json records and container recipes can be read without data loss. var virtualMachineSettings: DorydMachineTypedSettings? = nil + var displayPresentation: DoryMachineDisplayPresentation? = nil var address: String? = nil var displayMode: MachineDisplayMode = .headless var bootMode: MachineBootMode = .linuxKernel diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 65222454..5f1bbf85 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -5,6 +5,30 @@ import Testing @Suite(.serialized) struct DorydClientTests { + @MainActor + @Test func machineDisplayPresentationRoundTripsExactXPCShape() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]) + let status = try await client.machineDisplayPresentationSet( + "dev", + presentation: presentation + ) + #expect(status.displayPresentation == presentation) + #expect(try await client.machineList().first?.displayPresentation == presentation) + } + @MainActor @Test func machineUSBControlRequiresExactResolvedResponse() async throws { let listener = NSXPCListener.anonymous() @@ -2903,6 +2927,13 @@ struct DorydClientTests { guest: "/workspace/app", shareTag: "src" )], + displayPresentation: DoryMachineDisplayPresentation(assignments: [ + DoryGuestDisplayPresentationAssignment( + guestDisplayID: "primary", + mode: .dedicatedFullscreen, + hostDisplayUUID: "4e9b6f86-2b92-4fea-8d35-dcb3ed7c19c9" + ), + ]), address: "192.168.215.41" ) ) @@ -2920,6 +2951,8 @@ struct DorydClientTests { #expect(updateShares.first?["readOnly"] as? Bool == false) #expect(updateShares.first?["tag"] as? String == "src") #expect(service.latestMachineUpdateConfig?["env"] == nil) + #expect(await store.machineSettings("dev").displayPresentation?.assignments.first?.hostDisplayUUID + == "4e9b6f86-2b92-4fea-8d35-dcb3ed7c19c9") machine = try #require(store.machines.first { $0.name == "dev" }) let clearAddressResult = await store.editMachine( @@ -5163,13 +5196,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC { address: address, displayMode: current["displayMode"] as? String ?? "headless", shares: shares, - environment: environment + environment: environment, + displayPresentation: current["displayPresentation"] as? NSDictionary ) machines[machineID] = row lock.unlock() reply(true, row, "") } + func machineDisplayPresentationSet( + _ machineID: String, + presentation: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped") + let row = NSMutableDictionary(dictionary: current) + row["displayPresentation"] = presentation + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) { lock.lock() _machineDeleteCount += 1 @@ -6055,7 +6103,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { displayMode: String = "headless", shares: [NSDictionary] = [], environment: [NSDictionary] = [], - savedState: NSDictionary? = nil + savedState: NSDictionary? = nil, + displayPresentation: NSDictionary? = nil ) -> NSDictionary { var row: [String: Any] = [ "id": id, @@ -6093,6 +6142,9 @@ private final class FakeDorydService: NSObject, DorydControlXPC { if let savedState { row["savedState"] = savedState } + if let displayPresentation { + row["displayPresentation"] = displayPresentation + } return row as NSDictionary } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 3ef7b56a..8d253d0c 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -480,6 +480,7 @@ enum DesktopMode { var resolvedGraphics: DoryGraphicsAccelerationLevel? var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? var resolvedPortForwards: [DoryVMPortForward]? + var displayPresentation: DoryMachineDisplayPresentation = .windowed } enum NetworkPlan: Equatable { @@ -641,6 +642,7 @@ enum DesktopMode { private let mailboxes: [DesktopFrameMailbox] private let displays: [DesktopMetalView] private let windows: [NSWindow] + private let displayAssignments: [DoryGuestDisplayPresentationAssignment?] private let vsock: VirtioVsock private let audio: DoryMacAudioBackend private let gvproxy: Process? @@ -997,6 +999,9 @@ enum DesktopMode { } var windows = [NSWindow]() + let displayAssignments = displayPlans.map { + configuration.displayPresentation.assignment(forGuestDisplayID: $0.id) + } for (index, plan) in displayPlans.enumerated() { let window = NSWindow( contentRect: NSRect(origin: .zero, size: plan.windowSize), @@ -1021,6 +1026,7 @@ enum DesktopMode { windows.append(window) } self.windows = windows + self.displayAssignments = displayAssignments super.init() for window in windows { window.delegate = self } for display in displays { @@ -1045,6 +1051,15 @@ enum DesktopMode { installSignalHandlers() for window in windows { window.makeKeyAndOrderFront(nil) } application.activate() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for (window, assignment) in zip(self.windows, self.displayAssignments) { + _ = DoryHostDisplayPresentation.enterDedicatedFullscreen( + window: window, + assignment: assignment + ) + } + } startMachine() application.run() cleanup() @@ -1068,11 +1083,11 @@ enum DesktopMode { let viewMenu = NSMenu(title: "View") let fullScreenItem = NSMenuItem( title: "Enter Full Screen", - action: #selector(NSWindow.toggleFullScreen(_:)), + action: #selector(toggleFullScreen(_:)), keyEquivalent: "f" ) fullScreenItem.keyEquivalentModifierMask = [.command, .control] - fullScreenItem.target = windows.first + fullScreenItem.target = self viewMenu.addItem(fullScreenItem) viewItem.submenu = viewMenu mainMenu.addItem(viewItem) @@ -1085,6 +1100,19 @@ enum DesktopMode { return true } + func applicationDidChangeScreenParameters(_ notification: Notification) { + for (window, assignment) in zip(windows, displayAssignments) { + _ = DoryHostDisplayPresentation.recoverDisconnectedDisplay( + window: window, + assignment: assignment + ) + } + } + + @objc private func toggleFullScreen(_ sender: Any?) { + (application.keyWindow ?? windows.first)?.toggleFullScreen(sender) + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { requestGuestShutdown() return .terminateCancel diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index d82156cc..4ccfe3aa 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -1,6 +1,7 @@ import DoryHV import DoryCore import DorydKit +import DoryOperations import Foundation signal(SIGPIPE, SIG_IGN) @@ -442,6 +443,7 @@ case "desktop": var resolvedGraphics: DoryGraphicsAccelerationLevel? var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? var resolvedPortForwards: [DoryVMPortForward]? + var displayPresentation: DoryMachineDisplayPresentation = .windowed var iterator = arguments.dropFirst().makeIterator() while let argument = iterator.next() { switch argument { @@ -501,6 +503,16 @@ case "desktop": fail("desktop --resolved-port-forwards requires a valid port-forward contract") } resolvedPortForwards = forwards + case "--display-presentation": + guard let value = iterator.next(), + let data = value.data(using: .utf8), + let presentation = try? JSONDecoder().decode( + DoryMachineDisplayPresentation.self, + from: data + ), presentation.isValid else { + fail("desktop --display-presentation requires a valid host presentation contract") + } + displayPresentation = presentation.canonicalized case "--display-mode": guard iterator.next() == "desktop" else { fail("raw-HV desktop requires --display-mode desktop") } case "--boot-mode": @@ -555,7 +567,8 @@ case "desktop": environment: environment, resolvedGraphics: resolvedGraphics, resolvedDevices: resolvedDevices, - resolvedPortForwards: resolvedPortForwards + resolvedPortForwards: resolvedPortForwards, + displayPresentation: displayPresentation )) } catch { fail("desktop failed: \(error)") diff --git a/dory-core-swift/Sources/DoryOperations/DoryMachineDisplayPresentation.swift b/dory-core-swift/Sources/DoryOperations/DoryMachineDisplayPresentation.swift new file mode 100644 index 00000000..212b5144 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryMachineDisplayPresentation.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Host-only presentation preference for a guest display. This is deliberately excluded from +/// `DoryVirtualMachineDefinition` and `DoryResolvedMachinePlan`: attaching, removing, or moving a +/// Mac display must never change the guest hardware contract or invalidate a launch plan. +public enum DoryGuestDisplayPresentationMode: String, Codable, Sendable, Hashable { + case windowed + case dedicatedFullscreen = "dedicated-fullscreen" +} + +public struct DoryGuestDisplayPresentationAssignment: Codable, Sendable, Hashable { + public var guestDisplayID: String + public var mode: DoryGuestDisplayPresentationMode + /// Lowercase UUID produced by `CGDisplayCreateUUIDFromDisplayID`. A UUID is stable across + /// display reordering and is therefore suitable for remembering the user's chosen monitor. + public var hostDisplayUUID: String? + + public init( + guestDisplayID: String, + mode: DoryGuestDisplayPresentationMode, + hostDisplayUUID: String? = nil + ) { + self.guestDisplayID = guestDisplayID + self.mode = mode + self.hostDisplayUUID = hostDisplayUUID + } + + public var isValid: Bool { + guard DoryVMDisplayConfiguration.isValidIdentifier(guestDisplayID) else { return false } + switch mode { + case .windowed: + return hostDisplayUUID == nil + case .dedicatedFullscreen: + guard let hostDisplayUUID, + let uuid = UUID(uuidString: hostDisplayUUID) else { return false } + return uuid.uuidString.lowercased() == hostDisplayUUID + } + } +} + +public struct DoryMachineDisplayPresentation: Codable, Sendable, Hashable { + public static let currentSchemaVersion = 1 + public static let maximumAssignments = 16 + + public var schemaVersion: Int + public var assignments: [DoryGuestDisplayPresentationAssignment] + + public init( + schemaVersion: Int = currentSchemaVersion, + assignments: [DoryGuestDisplayPresentationAssignment] = [] + ) { + self.schemaVersion = schemaVersion + self.assignments = assignments + } + + public static let windowed = DoryMachineDisplayPresentation() + + public var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + assignments.count <= Self.maximumAssignments, + assignments.allSatisfy(\.isValid) else { return false } + let guestIDs = assignments.map(\.guestDisplayID) + guard Set(guestIDs).count == guestIDs.count else { return false } + let dedicatedHostIDs = assignments.compactMap { assignment -> String? in + assignment.mode == .dedicatedFullscreen ? assignment.hostDisplayUUID : nil + } + return Set(dedicatedHostIDs).count == dedicatedHostIDs.count + } + + public var canonicalized: DoryMachineDisplayPresentation { + DoryMachineDisplayPresentation( + schemaVersion: schemaVersion, + assignments: assignments.sorted { + if $0.guestDisplayID != $1.guestDisplayID { + return $0.guestDisplayID < $1.guestDisplayID + } + return $0.mode.rawValue < $1.mode.rawValue + } + ) + } + + public func assignment( + forGuestDisplayID guestDisplayID: String + ) -> DoryGuestDisplayPresentationAssignment? { + assignments.first { $0.guestDisplayID == guestDisplayID } + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryHostDisplayPresentation.swift b/dory-core-swift/Sources/DoryVMMKit/DoryHostDisplayPresentation.swift new file mode 100644 index 00000000..2604aadf --- /dev/null +++ b/dory-core-swift/Sources/DoryVMMKit/DoryHostDisplayPresentation.swift @@ -0,0 +1,66 @@ +import AppKit +import CoreGraphics +import DoryOperations + +@MainActor +public enum DoryHostDisplayPresentation { + public static func stableUUID(for screen: NSScreen) -> String? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let number = screen.deviceDescription[key] as? NSNumber, + let unmanaged = CGDisplayCreateUUIDFromDisplayID( + CGDirectDisplayID(number.uint32Value) + ) else { return nil } + let uuid = unmanaged.takeRetainedValue() + return (CFUUIDCreateString(nil, uuid) as String).lowercased() + } + + public static func screen( + forStableUUID uuid: String, + screens: [NSScreen] = NSScreen.screens + ) -> NSScreen? { + screens.first { stableUUID(for: $0) == uuid } + } + + /// Moves a guest window onto its requested physical display before native fullscreen begins. + /// Returns false when the display is disconnected; callers then keep a normal window. + @discardableResult + public static func enterDedicatedFullscreen( + window: NSWindow, + assignment: DoryGuestDisplayPresentationAssignment? + ) -> Bool { + guard assignment?.mode == .dedicatedFullscreen, + let uuid = assignment?.hostDisplayUUID, + let screen = screen(forStableUUID: uuid) else { return false } + window.collectionBehavior.insert(.fullScreenPrimary) + window.setFrame(screen.frame, display: false) + window.makeKeyAndOrderFront(nil) + if !window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + return true + } + + /// Exits a now-orphaned fullscreen space when its remembered monitor disappears. + @discardableResult + public static func recoverDisconnectedDisplay( + window: NSWindow, + assignment: DoryGuestDisplayPresentationAssignment? + ) -> Bool { + guard assignment?.mode == .dedicatedFullscreen, + let uuid = assignment?.hostDisplayUUID, + screen(forStableUUID: uuid) == nil else { return false } + if window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + if let main = NSScreen.main { + window.setFrameOrigin(NSPoint( + x: main.visibleFrame.midX - window.frame.width / 2, + y: main.visibleFrame.midY - window.frame.height / 2 + )) + } else { + window.center() + } + window.makeKeyAndOrderFront(nil) + return true + } +} diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift index 054b730a..7d0526a2 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift @@ -55,6 +55,7 @@ public struct DoryVMMArguments: Sendable, Equatable { public var resolvedGraphics: DoryGraphicsAccelerationLevel? public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? public var resolvedPortForwards: [DoryVMPortForward]? + public var displayPresentation: DoryMachineDisplayPresentation = .windowed public init() {} @@ -83,6 +84,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver case invalidResolvedGraphics(String) case invalidResolvedDevices(String) case invalidResolvedPortForwards(String) + case invalidDisplayPresentation(String) case invalidOperationID(String) public var description: String { @@ -119,6 +121,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver return "invalid --resolved-devices value: \(value)" case let .invalidResolvedPortForwards(value): return "invalid --resolved-port-forwards value: \(value)" + case let .invalidDisplayPresentation(value): + return "invalid --display-presentation value: \(value)" case let .invalidOperationID(value): return "invalid --operation-id value: \(value)" } @@ -234,6 +238,22 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments { } catch { throw DoryVMMArgumentError.invalidResolvedPortForwards(rawValue) } + case "--display-presentation": + let rawValue = try value(after: argument, from: raw, index: &index) + do { + let presentation = try JSONDecoder().decode( + DoryMachineDisplayPresentation.self, + from: Data(rawValue.utf8) + ) + guard presentation.isValid else { + throw DoryVMMArgumentError.invalidDisplayPresentation(rawValue) + } + parsed.displayPresentation = presentation.canonicalized + } catch let error as DoryVMMArgumentError { + throw error + } catch { + throw DoryVMMArgumentError.invalidDisplayPresentation(rawValue) + } case "--exit-after-handoff": parsed.exitAfterHandoff = true case "--handoff-only": @@ -1133,7 +1153,8 @@ public enum DoryVMMMain { runtime: runtime, machineID: machineID, environment: arguments.environment, - resolvedDevices: arguments.resolvedDevices + resolvedDevices: arguments.resolvedDevices, + displayPresentation: arguments.displayPresentation ) } } else { diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift index d4fe1708..003489f8 100644 --- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift +++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift @@ -12,6 +12,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow private let clipboard: DoryDesktopClipboardCoordinator? private let dynamicDisplayEnabled: Bool private let backingScaleFactor: CGFloat + private let displayAssignment: DoryGuestDisplayPresentationAssignment? private var pendingDisplayResize: DispatchWorkItem? private var requestedPixelSize: CGSize? private var stopError: String? @@ -20,13 +21,17 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow runtime: DoryVMMRuntime, machineID: String, environment: [String: String], - resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + displayPresentation: DoryMachineDisplayPresentation ) { self.application = NSApplication.shared self.runtime = runtime dynamicDisplayEnabled = resolvedDevices?.dynamicDisplay ?? true let display = resolvedDevices?.display ?? DoryVMMDisplayDefaults.capability + displayAssignment = displayPresentation.assignment( + forGuestDisplayID: display.id + ) backingScaleFactor = CGFloat(display.backingScaleFactor) let windowSize = NSSize( width: max(1, CGFloat(display.widthPixels) / backingScaleFactor), @@ -82,6 +87,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow self.window.center() super.init() self.window.delegate = self + installViewMenu() machineView.onMacShortcut = { [weak clipboard] event in clipboard?.handleMacShortcut(event) ?? false } @@ -91,13 +97,15 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow runtime: DoryVMMRuntime, machineID: String, environment: [String: String], - resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil, + displayPresentation: DoryMachineDisplayPresentation = .windowed ) throws { let controller = DoryVMMDesktopApplication( runtime: runtime, machineID: machineID, environment: environment, - resolvedDevices: resolvedDevices + resolvedDevices: resolvedDevices, + displayPresentation: displayPresentation ) try controller.runUntilStopped() } @@ -109,6 +117,13 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow clipboard?.markGuestReady() window.makeKeyAndOrderFront(nil) application.activate() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + _ = DoryHostDisplayPresentation.enterDedicatedFullscreen( + window: self.window, + assignment: self.displayAssignment + ) + } if dynamicDisplayEnabled { reconfigureDisplayNow() } let runtime = self.runtime @@ -155,6 +170,34 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow return true } + func applicationDidChangeScreenParameters(_ notification: Notification) { + _ = DoryHostDisplayPresentation.recoverDisconnectedDisplay( + window: window, + assignment: displayAssignment + ) + } + + @objc private func toggleFullScreen(_ sender: Any?) { + window.toggleFullScreen(sender) + } + + private func installViewMenu() { + let mainMenu = NSMenu() + let viewRoot = NSMenuItem() + let viewMenu = NSMenu(title: "View") + let fullscreen = NSMenuItem( + title: "Toggle Full Screen", + action: #selector(toggleFullScreen(_:)), + keyEquivalent: "f" + ) + fullscreen.keyEquivalentModifierMask = [.command, .control] + fullscreen.target = self + viewMenu.addItem(fullscreen) + viewRoot.submenu = viewMenu + mainMenu.addItem(viewRoot) + application.mainMenu = mainMenu + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { window.orderOut(nil) return .terminateCancel diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationStore.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationStore.swift new file mode 100644 index 00000000..61fc2adc --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationStore.swift @@ -0,0 +1,174 @@ +import Darwin +import DoryOperations +import Foundation + +enum DoryMachineDisplayPresentationStoreError: Error, Equatable { + case invalidMachineID + case invalidPresentation + case invalidRecord + case filesystem(String) +} + +/// Daemon-owned host presentation preferences. These records intentionally live outside each +/// portable machine directory so snapshots, exports, and plan digests never capture host monitor +/// identities. +final class DoryMachineDisplayPresentationStore: @unchecked Sendable { + private static let directoryName = ".display-presentation-v1" + private static let maximumRecordBytes = 64 * 1_024 + + private let root: String + private let lock = NSLock() + + init(root: String) { + self.root = URL(fileURLWithPath: root) + .appendingPathComponent(Self.directoryName, isDirectory: true) + .standardizedFileURL.path + } + + func read(machineID: String) throws -> DoryMachineDisplayPresentation { + lock.lock() + defer { lock.unlock() } + let path = try recordPath(machineID: machineID) + guard Self.pathExists(path) else { return .windowed } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw Self.filesystem("open display presentation") } + defer { _ = close(descriptor) } + var metadata = stat() + guard fstat(descriptor, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFREG, + metadata.st_nlink == 1, + metadata.st_uid == geteuid(), + (metadata.st_mode & 0o077) == 0, + metadata.st_size >= 0, + metadata.st_size <= Self.maximumRecordBytes else { + throw DoryMachineDisplayPresentationStoreError.invalidRecord + } + var data = Data(count: Int(metadata.st_size)) + let expectedCount = data.count + let readCount = try data.withUnsafeMutableBytes { bytes -> Int in + var total = 0 + while total < expectedCount { + let count = Darwin.read( + descriptor, + bytes.baseAddress!.advanced(by: total), + expectedCount - total + ) + if count > 0 { total += count } + else if count == 0 { break } + else if errno != EINTR { throw Self.filesystem("read display presentation") } + } + return total + } + guard readCount == expectedCount, + let decoded = try? JSONDecoder().decode( + DoryMachineDisplayPresentation.self, + from: data + ), decoded.isValid else { + throw DoryMachineDisplayPresentationStoreError.invalidRecord + } + return decoded.canonicalized + } + + func publish( + _ presentation: DoryMachineDisplayPresentation, + machineID: String + ) throws { + lock.lock() + defer { lock.unlock() } + guard presentation.isValid else { + throw DoryMachineDisplayPresentationStoreError.invalidPresentation + } + try prepareRoot() + let path = try recordPath(machineID: machineID) + let encoded = try JSONEncoder.canonical.encode(presentation.canonicalized) + guard !encoded.isEmpty, encoded.count <= Self.maximumRecordBytes else { + throw DoryMachineDisplayPresentationStoreError.invalidPresentation + } + let temporary = root + "/.tmp-\(UUID().uuidString.lowercased())" + let descriptor = open( + temporary, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { throw Self.filesystem("create display presentation") } + var removeTemporary = true + defer { + _ = close(descriptor) + if removeTemporary { _ = unlink(temporary) } + } + try encoded.withUnsafeBytes { bytes in + var written = 0 + while written < bytes.count { + let count = Darwin.write( + descriptor, + bytes.baseAddress!.advanced(by: written), + bytes.count - written + ) + if count > 0 { written += count } + else if errno != EINTR { throw Self.filesystem("write display presentation") } + } + } + guard fsync(descriptor) == 0, + rename(temporary, path) == 0 else { + throw Self.filesystem("publish display presentation") + } + removeTemporary = false + try syncRoot() + } + + func remove(machineID: String) throws { + lock.lock() + defer { lock.unlock() } + let path = try recordPath(machineID: machineID) + guard Self.pathExists(path) else { return } + guard unlink(path) == 0 else { throw Self.filesystem("remove display presentation") } + try syncRoot() + } + + private func prepareRoot() throws { + if mkdir(root, mode_t(0o700)) != 0, errno != EEXIST { + throw Self.filesystem("create display presentation directory") + } + var metadata = stat() + guard lstat(root, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFDIR, + metadata.st_uid == geteuid(), + (metadata.st_mode & 0o077) == 0 else { + throw DoryMachineDisplayPresentationStoreError.invalidRecord + } + } + + private func syncRoot() throws { + let descriptor = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { throw Self.filesystem("open display presentation directory") } + defer { _ = close(descriptor) } + guard fsync(descriptor) == 0 else { + throw Self.filesystem("sync display presentation directory") + } + } + + private func recordPath(machineID: String) throws -> String { + guard machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + !machineID.hasPrefix(".") else { + throw DoryMachineDisplayPresentationStoreError.invalidMachineID + } + return root + "/\(machineID).json" + } + + private static func pathExists(_ path: String) -> Bool { + var metadata = stat() + return lstat(path, &metadata) == 0 + } + + private static func filesystem(_ operation: String) -> DoryMachineDisplayPresentationStoreError { + .filesystem("\(operation): \(String(cString: strerror(errno)))") + } +} + +private extension JSONEncoder { + static var canonical: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } +} diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationXPC.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationXPC.swift new file mode 100644 index 00000000..7d7d80e4 --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/DoryMachineDisplayPresentationXPC.swift @@ -0,0 +1,70 @@ +import DoryOperations +import Foundation + +enum DoryMachineDisplayPresentationXPCError: Error, Equatable { + case invalidShape +} + +extension DoryMachineDisplayPresentation { + init(xpcDictionary dictionary: NSDictionary) throws { + guard Set(dictionary.allKeys.compactMap { $0 as? String }) + == ["schemaVersion", "assignments"], + dictionary.allKeys.count == 2, + let schema = dictionary["schemaVersion"] as? NSNumber, + String(cString: schema.objCType) != "c", + schema.intValue == Self.currentSchemaVersion, + let rows = dictionary["assignments"] as? NSArray else { + throw DoryMachineDisplayPresentationXPCError.invalidShape + } + var assignments: [DoryGuestDisplayPresentationAssignment] = [] + assignments.reserveCapacity(rows.count) + for encoded in rows { + guard let row = encoded as? NSDictionary, + let guestDisplayID = row["guestDisplayID"] as? String, + let rawMode = row["mode"] as? String, + let mode = DoryGuestDisplayPresentationMode(rawValue: rawMode) else { + throw DoryMachineDisplayPresentationXPCError.invalidShape + } + let expectedKeys: Set = mode == .dedicatedFullscreen + ? ["guestDisplayID", "mode", "hostDisplayUUID"] + : ["guestDisplayID", "mode"] + guard Set(row.allKeys.compactMap { $0 as? String }) == expectedKeys, + row.allKeys.count == expectedKeys.count else { + throw DoryMachineDisplayPresentationXPCError.invalidShape + } + let assignment = DoryGuestDisplayPresentationAssignment( + guestDisplayID: guestDisplayID, + mode: mode, + hostDisplayUUID: row["hostDisplayUUID"] as? String + ) + guard assignment.isValid else { + throw DoryMachineDisplayPresentationXPCError.invalidShape + } + assignments.append(assignment) + } + let presentation = DoryMachineDisplayPresentation( + schemaVersion: schema.intValue, + assignments: assignments + ) + guard presentation.isValid else { + throw DoryMachineDisplayPresentationXPCError.invalidShape + } + self = presentation.canonicalized + } + + var xpcDictionary: NSDictionary { + [ + "schemaVersion": schemaVersion, + "assignments": assignments.map { assignment -> NSDictionary in + var row: [String: Any] = [ + "guestDisplayID": assignment.guestDisplayID, + "mode": assignment.mode.rawValue, + ] + if let hostDisplayUUID = assignment.hostDisplayUUID { + row["hostDisplayUUID"] = hostDisplayUUID + } + return row as NSDictionary + } as NSArray, + ] + } +} diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift index 298a42f1..4d895f4b 100644 --- a/dory-core-swift/Sources/DorydKit/DorydControl.swift +++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift @@ -26,6 +26,7 @@ import Foundation func machineResume(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDisplayPresentationSet(_ machineID: String, presentation: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 57136b69..83ee124d 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -439,6 +439,29 @@ public final class DorydService: NSObject, DorydControl { } } + public func machineDisplayPresentationSet( + _ machineID: String, + presentation: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard let machineManager else { + reply(false, [:], "machine manager is not configured") + return + } + do { + let decoded = try DoryMachineDisplayPresentation( + xpcDictionary: presentation + ) + let status = try machineManager.setDisplayPresentation( + id: machineID, + presentation: decoded + ) + reply(true, status.xpcDictionary, "") + } catch { + reply(false, [:], "\(error)") + } + } + public func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) { guard let machineManager else { reply(false, "machine manager is not configured") @@ -2517,6 +2540,7 @@ private extension DoryMachineStatus { if !diagnosticOverrides.isEmpty { dictionary["diagnosticOverrides"] = diagnosticOverrides.map(\.rawValue) } + dictionary["displayPresentation"] = displayPresentation.xpcDictionary dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary if let installedDesktopPayloadReceipt { dictionary["installedDesktopPayloadReceipt"] = diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift index 3176f591..82937b4d 100644 --- a/dory-core-swift/Sources/DorydKit/MachineManager.swift +++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift @@ -370,6 +370,7 @@ public struct DoryMachineStatus: Sendable, Equatable { public var typedSettings: DoryMachineTypedSettingsSnapshot? public var sandboxPolicy: DoryVMSandboxPolicy? public var diagnosticOverrides: [DoryMachineDiagnosticOverride] + public var displayPresentation: DoryMachineDisplayPresentation public var runtimeIdentity: DoryMachineRuntimeIdentity public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? public var cloneReceipt: DoryMachineCloneReceipt? @@ -408,6 +409,7 @@ public struct DoryMachineStatus: Sendable, Equatable { typedSettings: DoryMachineTypedSettingsSnapshot? = nil, sandboxPolicy: DoryVMSandboxPolicy? = nil, diagnosticOverrides: [DoryMachineDiagnosticOverride] = [], + displayPresentation: DoryMachineDisplayPresentation = .windowed, runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility( virtualHardwareABIVersion: DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion @@ -448,6 +450,7 @@ public struct DoryMachineStatus: Sendable, Equatable { self.typedSettings = typedSettings self.sandboxPolicy = sandboxPolicy self.diagnosticOverrides = diagnosticOverrides + self.displayPresentation = displayPresentation self.runtimeIdentity = runtimeIdentity self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt self.cloneReceipt = cloneReceipt @@ -1005,6 +1008,7 @@ public final class MachineManager: @unchecked Sendable { private let processStarter: ProcessStarter private let workspaceRepository: DoryWorkspaceRepository private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore + private let displayPresentationStore: DoryMachineDisplayPresentationStore private let failureStore: DoryMachineFailureStore private let flightRecorderStore: DoryMachineFlightRecorderStore private let launchPolicy: DoryMachineLaunchPolicy @@ -1078,6 +1082,9 @@ public final class MachineManager: @unchecked Sendable { self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore( root: configuration.stateDirectory ) + self.displayPresentationStore = DoryMachineDisplayPresentationStore( + root: configuration.stateDirectory + ) self.failureStore = DoryMachineFailureStore(root: configuration.stateDirectory) self.flightRecorderStore = DoryMachineFlightRecorderStore( root: configuration.stateDirectory @@ -4079,6 +4086,7 @@ public final class MachineManager: @unchecked Sendable { // finish the still-durable deleting operation on the next daemon start. deletionCommitted = true try? failureStore.clear(id) + try? displayPresentationStore.remove(machineID: id) try releaseResolvedAdmissionStorage( machineID: id, plan: sourceEntry.runtimeIdentity.resolvedPlan @@ -5703,6 +5711,49 @@ public final class MachineManager: @unchecked Sendable { return statusLocked(id: id, entry: entry) } + public func setDisplayPresentation( + id: String, + presentation: DoryMachineDisplayPresentation + ) throws -> DoryMachineStatus { + operationLock.lock() + defer { operationLock.unlock() } + guard presentation.isValid else { + throw MachineManagerError.persistence("invalid display presentation preference") + } + lock.lock() + guard let entry = machines[id] else { + lock.unlock() + throw MachineManagerError.unknownMachine(id) + } + let availableDisplayIDs: Set + if let displays = entry.runtimeIdentity.resolvedPlan?.devices.displays { + availableDisplayIDs = Set(displays.map(\.id)) + } else if entry.configuration.displayMode == .desktop { + availableDisplayIDs = ["display-0"] + } else { + availableDisplayIDs = [] + } + lock.unlock() + guard presentation.assignments.allSatisfy({ + availableDisplayIDs.contains($0.guestDisplayID) + }) else { + throw MachineManagerError.persistence( + "display presentation references a display outside the machine contract" + ) + } + do { + try displayPresentationStore.publish(presentation, machineID: id) + } catch { + throw MachineManagerError.persistence( + "could not persist display presentation: \(error)" + ) + } + guard let status = status(id: id) else { + throw MachineManagerError.unknownMachine(id) + } + return status + } + public func runtimeIdentity(id: String) -> DoryMachineRuntimeIdentity? { lock.lock() defer { lock.unlock() } @@ -5821,6 +5872,8 @@ public final class MachineManager: @unchecked Sendable { legacyEnvironment: entry.configuration.environment, displayMode: entry.configuration.displayMode ) + let displayPresentation = + (try? displayPresentationStore.read(machineID: id)) ?? .windowed if [.starting, .running, .paused].contains(entry.state), entry.process?.isRunningOrRestarting != true { return DoryMachineStatus( @@ -5855,6 +5908,7 @@ public final class MachineManager: @unchecked Sendable { diagnosticOverrides: DoryMachineDiagnosticOverride.configured( in: entry.configuration.environment ), + displayPresentation: displayPresentation, runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt, @@ -5900,6 +5954,7 @@ public final class MachineManager: @unchecked Sendable { diagnosticOverrides: DoryMachineDiagnosticOverride.configured( in: entry.configuration.environment ), + displayPresentation: displayPresentation, runtimeIdentity: entry.runtimeIdentity, installedDesktopPayloadReceipt: entry.configuration.effectiveInstalledDesktopPayloadReceipt, @@ -6360,6 +6415,25 @@ public final class MachineManager: @unchecked Sendable { "--resolved-port-forwards", portForwardContract, ]) } + if machine.displayMode == .desktop { + let presentation: DoryMachineDisplayPresentation + do { + presentation = try displayPresentationStore.read(machineID: machine.id) + } catch { + throw MachineManagerError.persistence( + "display presentation authority is invalid: \(error)" + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let presentationContract = String( + decoding: try encoder.encode(presentation), + as: UTF8.self + ) + arguments.append(contentsOf: [ + "--display-presentation", presentationContract, + ]) + } for share in machine.shares { arguments.append(contentsOf: ["--share", share.argumentValue]) } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryMachineDisplayPresentationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineDisplayPresentationTests.swift new file mode 100644 index 00000000..a1deae49 --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryMachineDisplayPresentationTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import DoryOperations + +@Suite("Host display presentation") +struct DoryMachineDisplayPresentationTests { + @Test("dedicated assignments are exact and canonical") + func canonicalAssignment() { + let first = "00000000-0000-0000-0000-000000000001" + let second = "00000000-0000-0000-0000-000000000002" + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init(guestDisplayID: "display-1", mode: .dedicatedFullscreen, hostDisplayUUID: second), + .init(guestDisplayID: "display-0", mode: .dedicatedFullscreen, hostDisplayUUID: first), + ]) + #expect(presentation.isValid) + #expect(presentation.canonicalized.assignments.map(\.guestDisplayID) + == ["display-0", "display-1"]) + } + + @Test("one physical monitor cannot be owned by two guest displays") + func rejectsDuplicateMonitor() { + let uuid = "00000000-0000-0000-0000-000000000001" + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init(guestDisplayID: "display-0", mode: .dedicatedFullscreen, hostDisplayUUID: uuid), + .init(guestDisplayID: "display-1", mode: .dedicatedFullscreen, hostDisplayUUID: uuid), + ]) + #expect(!presentation.isValid) + } + + @Test("windowed mode cannot smuggle a host monitor identity") + func rejectsWindowedMonitor() { + #expect(!DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .windowed, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]).isValid) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDisplayPresentationStoreTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDisplayPresentationStoreTests.swift new file mode 100644 index 00000000..778fd412 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDisplayPresentationStoreTests.swift @@ -0,0 +1,60 @@ +import Foundation +import XCTest +@testable import DorydKit +import DoryOperations + +final class DoryMachineDisplayPresentationStoreTests: XCTestCase { + func testRoundTripIsCanonicalAndAbsentDefaultsWindowed() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: root) } + let store = DoryMachineDisplayPresentationStore(root: root.path) + XCTAssertEqual(try store.read(machineID: "machine"), .windowed) + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]) + try store.publish(presentation, machineID: "machine") + XCTAssertEqual(try store.read(machineID: "machine"), presentation) + try store.remove(machineID: "machine") + XCTAssertEqual(try store.read(machineID: "machine"), .windowed) + } + + func testMalformedRecordFailsClosed() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let records = root.appendingPathComponent(".display-presentation-v1", isDirectory: true) + try FileManager.default.createDirectory(at: records, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: records.appendingPathComponent("machine.json")) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: records.appendingPathComponent("machine.json").path + ) + defer { try? FileManager.default.removeItem(at: root) } + XCTAssertThrowsError( + try DoryMachineDisplayPresentationStore(root: root.path).read(machineID: "machine") + ) + } + + func testXPCShapeRejectsUnknownKeysAndInvalidUUID() throws { + let valid: NSDictionary = [ + "schemaVersion": 1, + "assignments": [[ + "guestDisplayID": "display-0", + "mode": "dedicated-fullscreen", + "hostDisplayUUID": "00000000-0000-0000-0000-000000000001", + ] as NSDictionary] as NSArray, + ] + XCTAssertEqual( + try DoryMachineDisplayPresentation(xpcDictionary: valid).xpcDictionary, + valid + ) + let unknown = valid.mutableCopy() as! NSMutableDictionary + unknown["path"] = "/tmp/secret" + XCTAssertThrowsError(try DoryMachineDisplayPresentation(xpcDictionary: unknown)) + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift index 288acb01..df70da11 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift @@ -363,6 +363,28 @@ final class DoryVMMKitTests: XCTestCase { } } + func testParsesExactHostDisplayPresentation() throws { + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = String(decoding: try encoder.encode(presentation), as: UTF8.self) + XCTAssertEqual( + try parseDoryVMMArguments([ + "--display-presentation", encoded, + ]).displayPresentation, + presentation + ) + XCTAssertThrowsError(try parseDoryVMMArguments([ + "--display-presentation", "{}", + ])) + } + func testVMMResourcesAreNeverSilentlyClamped() throws { let arguments = try parseDoryVMMArguments([ "--memory-mb", "0", diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift index ebea8a89..9f87850a 100644 --- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift @@ -2441,6 +2441,7 @@ final class MachineManagerTests: XCTestCase { id: "dev", kernelPath: doryTestKernelPath, rootfsPath: doryTestRootfsPath, + displayMode: .desktop, shares: [ DoryMachineShareConfiguration( tag: "src", @@ -2448,13 +2449,35 @@ final class MachineManagerTests: XCTestCase { guestPath: "/workspace/client: app 日本語" ), ], - environment: ["APP_ENV": "dev"] + environment: [ + "APP_ENV": "dev", + DoryDesktopVMMPreference.environmentKey: + DoryDesktopVMMPreference.compatible.rawValue, + ] )) + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]) + _ = try manager.setDisplayPresentation(id: "dev", presentation: presentation) _ = try manager.start(id: "dev") let args = try waitForFileContent(argsPath) XCTAssertTrue(args.contains("--env\nAPP_ENV=dev")) let rows = args.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let presentationFlagIndex = try XCTUnwrap( + rows.firstIndex(of: "--display-presentation") + ) + XCTAssertEqual( + try JSONDecoder().decode( + DoryMachineDisplayPresentation.self, + from: Data(rows[presentationFlagIndex + 1].utf8) + ), + presentation + ) let shareFlagIndex = try XCTUnwrap(rows.firstIndex(of: "--share")) let wireShare = rows[shareFlagIndex + 1] var resolvedSharePath = [CChar](repeating: 0, count: Int(PATH_MAX)) From c9c28dad0383d55d2b4753208f4550c3d5ba2291 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Sat, 22 Aug 2026 15:28:03 +0000 Subject: [PATCH 279/338] fix(linux): restore accelerated desktop app launches --- .../Sources/DoryHV/VirtioGPU.swift | 109 +++++++- .../Sources/DoryHV/VirtioInput.swift | 147 ++++++++-- .../Sources/dory-hv/DesktopMetalDisplay.swift | 99 +++++-- .../Sources/dory-hv/DesktopMode.swift | 81 +++--- .../Tests/DoryHVTests/DeviceLogicTests.swift | 263 ++++++++++++++++++ .../DoryDesktopRuntimeContract.swift | 19 +- .../DoryDesktopRuntimeContractTests.swift | 10 + guest/kernel/dory-desktop.fragment | 28 ++ 8 files changed, 653 insertions(+), 103 deletions(-) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index a26fff58..75d682f4 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -344,6 +344,12 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi var height: UInt32 } + private struct CursorResourceSnapshot { + var width: UInt32 + var height: UInt32 + var bytes: Data + } + private struct ScanoutBinding { enum Source { case resource2D @@ -1084,13 +1090,18 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi try requireLength(request, 56) let resourceID = request.leUInt32(at: 24) let size = request.leUInt64(at: 48) + let resourceAvailable = resources2D[resourceID] == nil + && resources3D[resourceID] == nil + && blobResources[resourceID] == nil guard resourceID != 0, size > 0, size <= UInt64(Int.max), - resources2D[resourceID] == nil, - resources3D[resourceID] == nil, - blobResources[resourceID] == nil else { - throw VMError.invalidConfiguration("invalid virtio-gpu blob resource") + resourceAvailable else { + throw VMError.invalidConfiguration( + "invalid virtio-gpu blob resource " + + "(id=\(resourceID) size=\(size) " + + "available=\(resourceAvailable))" + ) } let entries = try memoryEntries(from: request, count: request.leUInt32(at: 36), offset: 56, transport: transport) try renderer.createBlob( @@ -1462,6 +1473,11 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi throw VMError.invalidConfiguration("invalid virtio-gpu cursor scanout") } if command == Command.moveCursor { + if ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { + FileHandle.standardError.write(Data( + "dory-input: guest cursor move scanout=\(scanoutID) x=\(request.leUInt32(at: 28)) y=\(request.leUInt32(at: 32))\n".utf8 + )) + } return responseHeader(type: Response.okNoData, request: request) } @@ -1471,20 +1487,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi onCursorUpdate?(nil) return responseHeader(type: Response.okNoData, request: request) } - guard let resource = resources2D[resourceID], - resource.format == 1, - resource.width > 0, resource.height > 0, - resource.width <= 256, resource.height <= 256 else { - throw VMError.invalidConfiguration( - "virtio-gpu cursor requires a bounded B8G8R8A8 2D resource" - ) - } + let resource = try copiedCursorResource(resourceID: resourceID) let hotX = request.leUInt32(at: 44) let hotY = request.leUInt32(at: 48) guard hotX < resource.width, hotY < resource.height else { throw VMError.invalidConfiguration("virtio-gpu cursor hotspot is outside the image") } - let pixels = try copiedBytes(for: resource) + if ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { + FileHandle.standardError.write(Data( + "dory-input: guest cursor update scanout=\(scanoutID) x=\(request.leUInt32(at: 28)) y=\(request.leUInt32(at: 32)) size=\(resource.width)x\(resource.height) hotspot=\(hotX),\(hotY)\n".utf8 + )) + } cursorResourceID = resourceID onCursorUpdate?(VirtioGPUCursorUpdate( scanoutID: scanoutID, @@ -1495,12 +1508,80 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi height: resource.height, hotX: hotX, hotY: hotY, - bytes: pixels + bytes: resource.bytes )) return responseHeader(type: Response.okNoData, request: request) } } + private func copiedCursorResource(resourceID: UInt32) throws -> CursorResourceSnapshot { + if let resource = resources2D[resourceID] { + try validateCursorResource( + format: resource.format, + width: resource.width, + height: resource.height + ) + return CursorResourceSnapshot( + width: resource.width, + height: resource.height, + bytes: try copiedBytes(for: resource) + ) + } + + guard let resource = resources3D[resourceID], let renderer else { + throw VMError.invalidConfiguration( + "virtio-gpu cursor references an unknown resource" + ) + } + try validateCursorResource( + format: resource.format, + width: resource.width, + height: resource.height + ) + let stride = UInt64(resource.width) * 4 + let byteCount = stride * UInt64(resource.height) + guard stride <= UInt64(UInt32.max), byteCount <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("virtio-gpu cursor resource is too large") + } + var pixels = Data(count: Int(byteCount)) + try pixels.withUnsafeMutableBytes { bytes in + guard let baseAddress = bytes.baseAddress else { + throw VMError.invalidConfiguration("virtio-gpu 3D cursor has no storage") + } + try renderer.transferFromHost3D( + VirtioGPUTransfer3D( + resourceID: resourceID, + contextID: 0, + level: 0, + stride: UInt32(stride), + layerStride: 0, + offset: 0, + box: [0, 0, 0, resource.width, resource.height, 1] + ), + entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)] + ) + } + return CursorResourceSnapshot( + width: resource.width, + height: resource.height, + bytes: pixels + ) + } + + private func validateCursorResource(format: UInt32, width: UInt32, height: UInt32) throws { + // Linux normally advertises ARGB8888 for the cursor plane, but accelerated Mutter creates + // the 64x64 VirGL cursor resource as B8G8R8X8 and still writes ARGB cursor payload bytes. + // QEMU's VirGL cursor path intentionally copies that payload without format conversion. + guard format == 1 || format == 2, + width > 0, height > 0, + width <= 256, height <= 256 else { + throw VMError.invalidConfiguration( + "virtio-gpu cursor requires a bounded BGRA/BGRX resource " + + "(format=\(format) size=\(width)x\(height))" + ) + } + } + private func copiedBytes(for resource: Resource2D) throws -> Data { let resourceByteCount = Int(UInt64(resource.width) * UInt64(resource.height) * 4) var source = Data(capacity: resourceByteCount) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift index dbbb37cd..56f0a6f1 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift @@ -91,10 +91,25 @@ public struct VirtioInputPressedState: Sendable { } } -/// A combined virtio keyboard, absolute pointer, buttons, and high-resolution wheel device. +/// A virtio keyboard or absolute tablet endpoint. +/// +/// Linux classifies an input node from its complete capability bitmap. A single node advertising +/// both a full keyboard and absolute pointer axes is not equivalent to two HID devices and can be +/// classified as a keyboard by desktop input stacks, leaving its absolute position disconnected +/// from pointer hit-testing. Production desktop VMs therefore attach one `.keyboard` endpoint and +/// one `.absolutePointer` endpoint, matching the device boundary used by QEMU's virtio keyboard and +/// tablet implementations. `.combinedCompatibility` remains available only for existing callers +/// that need the historical wire shape. +/// /// Host input is submitted as whole evdev frames ending in SYN_REPORT; a frame waits until the /// guest has posted enough receive buffers, so Dory never delivers half of a pointer update. public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { + public enum Profile: Sendable, Equatable { + case keyboard + case absolutePointer + case combinedCompatibility + } + public let deviceID: UInt32 = 18 public let deviceFeatures: UInt64 = 0 public let queueCount = 2 @@ -117,15 +132,22 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { } private let lock = NSLock() + private let profile: Profile private weak var transport: VirtioMMIOTransport? private var selectedConfig: UInt8 = 0 private var selectedSubconfig: UInt8 = 0 private var availableEventBuffers = [VirtqueueChain]() private var pendingFrames = [[VirtioInputEvent]]() private let maximumPendingFrames = 256 + private static let tracesPointerButtons = + ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" private let statusHandler: (@Sendable (VirtioInputEvent) -> Void)? - public init(statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil) { + public init( + profile: Profile = .combinedCompatibility, + statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil + ) { + self.profile = profile self.statusHandler = statusHandler } @@ -138,24 +160,31 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { var payload = [UInt8]() switch select { case ConfigSelect.name where subselect == 0: - payload = Array("Dory keyboard and pointer".utf8) + payload = Array(deviceName.utf8) case ConfigSelect.serial where subselect == 0: - payload = Array("dory-input-0".utf8) + payload = Array(deviceSerial.utf8) case ConfigSelect.deviceIDs where subselect == 0: payload.appendLE(UInt16(0x06)) // BUS_VIRTUAL payload.appendLE(UInt16(0xD072)) - payload.appendLE(UInt16(0x0001)) - payload.appendLE(UInt16(0x0001)) + payload.appendLE(deviceProductID) + payload.appendLE(profile == .combinedCompatibility ? UInt16(0x0001) : UInt16(0x0002)) case ConfigSelect.propertyBits where subselect == 0: - payload = [0] + // QEMU's proven virtio-tablet contract omits PROP_BITS entirely. An + // explicit one-byte zero bitmap is not the same wire shape and can + // change how Linux input classifiers interpret an absolute device. + payload = profile == .combinedCompatibility ? [0] : [] case ConfigSelect.eventBits: - payload = Self.eventBitmap(type: subselect) - case ConfigSelect.absoluteInfo where subselect == 0 || subselect == 1: + payload = eventBitmap(type: subselect) + case ConfigSelect.absoluteInfo + where profile != .keyboard && (subselect == 0 || subselect == 1): payload.appendLE(UInt32(0)) payload.appendLE(UInt32(32_767)) payload.appendLE(UInt32(0)) payload.appendLE(UInt32(0)) - payload.appendLE(UInt32(100)) + // Match virtio-tablet's unspecified resolution. Advertising an + // arbitrary physical resolution makes libinput apply dimensions + // that do not describe this normalized virtual desktop. + payload.appendLE(profile == .combinedCompatibility ? UInt32(100) : UInt32(0)) default: break } @@ -220,9 +249,19 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { if complete.last != .synchronize { complete.append(.synchronize) } lock.lock() - pendingFrames.append(complete) + if Self.isAbsolutePointerPositionFrame(complete), + let last = pendingFrames.last, + Self.isAbsolutePointerPositionFrame(last) { + // AppKit may deliver motion faster than a guest replenishes virtio-input + // buffers. Only the newest absolute position matters, and retaining every + // intermediate position can otherwise leave button frames behind stale + // motion for a visibly long time. + pendingFrames[pendingFrames.count - 1] = complete + } else { + pendingFrames.append(complete) + } if pendingFrames.count > maximumPendingFrames { - pendingFrames.removeFirst(pendingFrames.count - maximumPendingFrames) + trimPendingFramesToLimit() } let transport = self.transport lock.unlock() @@ -234,6 +273,27 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { } } + private static func isAbsolutePointerPositionFrame(_ events: [VirtioInputEvent]) -> Bool { + events.count == 3 + && events[0].type == EventType.absolute + && events[0].code == 0 + && events[1].type == EventType.absolute + && events[1].code == 1 + && events[2] == .synchronize + } + + /// Prefer discarding superseded pointer motion over input with semantic state, + /// such as a key or button transition. + private func trimPendingFramesToLimit() { + while pendingFrames.count > maximumPendingFrames { + if let index = pendingFrames.firstIndex(where: Self.isAbsolutePointerPositionFrame) { + pendingFrames.remove(at: index) + } else { + pendingFrames.removeFirst() + } + } + } + private func drainEventQueue(transport: VirtioMMIOTransport) { let queue = transport.queues[0] var invalidBuffers = [VirtqueueChain]() @@ -262,6 +322,13 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { } for (chain, event) in publications { let written = chain.writeBytes(event.bytes) + if Self.tracesPointerButtons, + event.type == UInt16(EventType.key), + (272...276).contains(event.code) { + FileHandle.standardError.write(Data( + "dory-input: published button code=\(event.code) value=\(event.value) bytes=\(written)\n".utf8 + )) + } interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt } if interrupt { transport.notifyUsed() } @@ -284,25 +351,67 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { if interrupt { transport.notifyUsed() } } - private static func eventBitmap(type: UInt8) -> [UInt8] { + private func eventBitmap(type: UInt8) -> [UInt8] { + Self.bitmap(type: type, profile: profile) + } + + private static func bitmap(type: UInt8, profile: Profile) -> [UInt8] { switch type { case EventType.synchronize: return bitmap(codes: [0]) case EventType.key: - // Standard keyboard range plus primary mouse buttons. - return bitmap(codes: Array(1...255) + Array(272...276)) + switch profile { + case .keyboard: + return bitmap(codes: Array(1...255)) + case .absolutePointer: + return bitmap(codes: Array(272...276)) + case .combinedCompatibility: + return bitmap(codes: Array(1...255) + Array(272...276)) + } case EventType.relative: - // Horizontal/vertical wheels and their high-resolution companions. - return bitmap(codes: [6, 8, 11, 12]) + switch profile { + case .keyboard: + return [] + case .absolutePointer: + // QEMU's virtio-tablet exposes only REL_WHEEL. Keep the + // production tablet byte-for-byte compatible with that shape. + return bitmap(codes: [8]) + case .combinedCompatibility: + return bitmap(codes: [6, 8, 11, 12]) + } case EventType.absolute: - return bitmap(codes: [0, 1]) + return profile == .keyboard ? [] : bitmap(codes: [0, 1]) case EventType.led: - return bitmap(codes: [0, 1, 2]) + return profile == .absolutePointer ? [] : bitmap(codes: [0, 1, 2]) default: return [] } } + private var deviceName: String { + switch profile { + case .keyboard: "Dory Virtio Keyboard" + case .absolutePointer: "Dory Virtio Tablet" + case .combinedCompatibility: "Dory keyboard and pointer" + } + } + + private var deviceSerial: String { + switch profile { + case .keyboard: "dory-keyboard-0" + case .absolutePointer: "dory-tablet-0" + case .combinedCompatibility: "dory-input-0" + } + } + + private var deviceProductID: UInt16 { + switch profile { + case .keyboard: 0x0001 + case .absolutePointer: 0x0003 + case .combinedCompatibility: 0x0001 + } + } + private static func bitmap(codes: [Int]) -> [UInt8] { guard let maximum = codes.max(), maximum >= 0 else { return [] } var bytes = [UInt8](repeating: 0, count: maximum / 8 + 1) diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index 36221a92..3d69030e 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -181,7 +181,8 @@ final class DesktopCursorMailbox: @unchecked Sendable { final class DesktopMetalView: MTKView, MTKViewDelegate { private let commandQueue: MTLCommandQueue private let pipeline: MTLRenderPipelineState - private let input: VirtioInput + private let keyboardInput: VirtioInput + private let pointerInput: VirtioInput private let guestBackingScaleFactor: CGFloat private let scanoutID: UInt32 private let pointerTopology: DesktopPointerTopology? @@ -192,14 +193,16 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { private var guestCursorUpdate: VirtioGPUCursorUpdate? private var tracking: NSTrackingArea? private var scrollAccumulator = VirtioInputScrollAccumulator() - private var pressedInput = VirtioInputPressedState() + private var pressedKeyboardInput = VirtioInputPressedState() + private var pressedPointerInput = VirtioInputPressedState() private var resizeGeneration: UInt64 = 0 var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? var onMacShortcut: ((NSEvent) -> Bool)? init( frame: NSRect, - input: VirtioInput, + keyboardInput: VirtioInput, + pointerInput: VirtioInput, guestBackingScaleFactor: CGFloat = 2, scanoutID: UInt32 = 0, pointerTopology: DesktopPointerTopology? = nil @@ -208,7 +211,8 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { let commandQueue = device.makeCommandQueue() else { throw DesktopMetalDisplayError.metalUnavailable } - self.input = input + self.keyboardInput = keyboardInput + self.pointerInput = pointerInput self.guestBackingScaleFactor = guestBackingScaleFactor self.scanoutID = scanoutID self.pointerTopology = pointerTopology @@ -241,6 +245,12 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { override var acceptsFirstResponder: Bool { true } override var isFlipped: Bool { true } + /// A dedicated guest display is often not the key macOS window yet (notably immediately after + /// entering fullscreen). AppKit normally consumes that first click solely to activate the + /// window, which makes GDM's user tile appear unclickable. The guest owns the entire display + /// surface, so activation and input delivery must happen on the same click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func updateTrackingAreas() { if let tracking { removeTrackingArea(tracking) } let replacement = NSTrackingArea( @@ -279,7 +289,15 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { window?.invalidateCursorRects(for: self) return } - let effectivePixelSize = pixelSize ?? drawableSize + // Cursor resources use guest scanout pixels, not the host Metal drawable's backing + // pixels. Those differ on a non-Retina host display when Dory intentionally exposes a + // 2x guest framebuffer. Scaling against drawableSize would make the cursor image and its + // hotspot twice as large as the desktop beneath it. + let effectivePixelSize = pixelSize + ?? (scanoutSize.width > 0 ? scanoutSize : CGSize( + width: bounds.width * guestBackingScaleFactor, + height: bounds.height * guestBackingScaleFactor + )) let cursorScale = bounds.width > 0 ? max(1, effectivePixelSize.width / bounds.width) : CGFloat(1) @@ -399,7 +417,9 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.keyDown(with: event) return } - sendTracked([VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1)]) + sendKeyboardTracked([ + VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1) + ]) } override func keyUp(with event: NSEvent) { @@ -407,7 +427,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.keyUp(with: event) return } - sendTracked([VirtioInputEvent(type: 1, code: code, value: 0)]) + sendKeyboardTracked([VirtioInputEvent(type: 1, code: code, value: 0)]) } override func flagsChanged(with event: NSEvent) { @@ -416,7 +436,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { super.flagsChanged(with: event) return } - sendTracked([ + sendKeyboardTracked([ VirtioInputEvent(type: 1, code: code, value: event.modifierFlags.contains(flag) ? 1 : 0) ]) } @@ -425,15 +445,19 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { override func mouseDragged(with event: NSEvent) { sendPointer(event: event) } override func rightMouseDragged(with event: NSEvent) { sendPointer(event: event) } override func otherMouseDragged(with event: NSEvent) { sendPointer(event: event) } - override func mouseDown(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: true) } - override func mouseUp(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: false) } - override func rightMouseDown(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: true) } - override func rightMouseUp(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: false) } + override func mouseDown(with event: NSEvent) { sendMouseButton(event, code: 272, pressed: true) } + override func mouseUp(with event: NSEvent) { sendMouseButton(event, code: 272, pressed: false) } + override func rightMouseDown(with event: NSEvent) { sendMouseButton(event, code: 273, pressed: true) } + override func rightMouseUp(with event: NSEvent) { sendMouseButton(event, code: 273, pressed: false) } override func otherMouseDown(with event: NSEvent) { - sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: true) + sendMouseButton(event, code: linuxOtherMouseButton(for: event.buttonNumber), pressed: true) } override func otherMouseUp(with event: NSEvent) { - sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: false) + sendMouseButton(event, code: linuxOtherMouseButton(for: event.buttonNumber), pressed: false) + } + + private func sendMouseButton(_ event: NSEvent, code: UInt16, pressed: Bool) { + sendPointer(event: event, button: code, pressed: pressed) } private func linuxOtherMouseButton(for buttonNumber: Int) -> UInt16 { @@ -449,8 +473,8 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { horizontalDelta: event.scrollingDeltaX, verticalDelta: event.scrollingDeltaY, hasPreciseDeltas: event.hasPreciseScrollingDeltas - ) - if !events.isEmpty { sendTracked(events) } + ).filter { $0.type != 2 || $0.code == 8 } + if !events.isEmpty { sendPointerTracked(events) } } private func sendPointer( @@ -468,25 +492,45 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { localX: normalizedX, localY: normalizedY ) ?? CGPoint(x: normalizedX, y: normalizedY) - var events = [ + if button != nil, + ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { + let trace = + "dory-input: map point=\(point.x),\(point.y) bounds=\(bounds.width)x\(bounds.height) " + + "content=\(contentRect.minX),\(contentRect.minY),\(contentRect.width)x\(contentRect.height) " + + "scanout=\(scanoutSize.width)x\(scanoutSize.height) drawable=\(drawableSize.width)x\(drawableSize.height) " + + "local=\(normalizedX),\(normalizedY) guest=\(guestPoint.x),\(guestPoint.y) " + + "button=\(button!) value=\(pressed ? 1 : 0) key=\(window?.isKeyWindow == true)\n" + FileHandle.standardError.write(Data(trace.utf8)) + } + var frame = [ VirtioInputEvent(type: 3, code: 0, value: Int32((guestPoint.x * 32_767).rounded())), VirtioInputEvent(type: 3, code: 1, value: Int32((guestPoint.y * 32_767).rounded())), ] if let button { - events.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) + // A click is one evdev report: exact position and button state become + // visible together at SYN_REPORT. Splitting them lets a compositor + // associate a button transition with an older tablet position. + frame.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) } - sendTracked(events) + sendPointerTracked(frame) } func releasePressedInput() { - let releases = pressedInput.releaseFrame() + let keyboardReleases = pressedKeyboardInput.releaseFrame() + let pointerReleases = pressedPointerInput.releaseFrame() scrollAccumulator = VirtioInputScrollAccumulator() - if !releases.isEmpty { input.send(frame: releases) } + if !keyboardReleases.isEmpty { keyboardInput.send(frame: keyboardReleases) } + if !pointerReleases.isEmpty { pointerInput.send(frame: pointerReleases) } + } + + private func sendKeyboardTracked(_ events: [VirtioInputEvent]) { + for event in events { pressedKeyboardInput.record(event) } + keyboardInput.send(frame: events) } - private func sendTracked(_ events: [VirtioInputEvent]) { - for event in events { pressedInput.record(event) } - input.send(frame: events) + private func sendPointerTracked(_ events: [VirtioInputEvent]) { + for event in events { pressedPointerInput.record(event) } + pointerInput.send(frame: events) } private func scanoutContentRect(in targetSize: CGSize) -> CGRect { @@ -564,13 +608,12 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { height: CGFloat(update.height) / scale ) ) - // VirtIO cursor hotspots are top-left based; NSCursor image coordinates are bottom-left. - let appKitHotY = update.height - 1 - update.hotY + // VirtIO and NSCursor both express cursor hotspots from the image's top-left. return NSCursor( image: cursorImage, hotSpot: NSPoint( x: CGFloat(update.hotX) / scale, - y: CGFloat(appKitHotY) / scale + y: CGFloat(update.hotY) / scale ) ) } @@ -591,7 +634,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { } private func sendControlShortcut(keyCode: UInt16) { - input.send(frame: [ + keyboardInput.send(frame: [ VirtioInputEvent(type: 1, code: 125, value: 0), VirtioInputEvent(type: 1, code: 126, value: 0), VirtioInputEvent(type: 1, code: 29, value: 1), diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 8d253d0c..61f3353b 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -638,7 +638,8 @@ enum DesktopMode { private let serialLog: FileHandle private let machine: Machine private let graphicsBackend: DoryDesktopGraphicsBackend - private let input: VirtioInput + private let keyboardInput: VirtioInput + private let pointerInput: VirtioInput private let mailboxes: [DesktopFrameMailbox] private let displays: [DesktopMetalView] private let windows: [NSWindow] @@ -685,7 +686,6 @@ enum DesktopMode { ) let resolvedGraphics = try Self.resolveGraphics( environment: configuration.environment, - requireVulkan: configuration.genericGuest, exactLevel: configuration.resolvedGraphics ) self.graphicsBackend = resolvedGraphics.backend @@ -700,11 +700,6 @@ enum DesktopMode { resolvedDevices: configuration.resolvedDevices ) if let devices = configuration.resolvedDevices { - guard devices.keyboard == devices.pointer else { - throw VMError.bootFailure( - "raw-HV input is a combined keyboard/pointer device" - ) - } guard devices.audioInput == devices.audioOutput else { throw VMError.bootFailure( "raw-HV audio is a combined input/output device" @@ -736,7 +731,8 @@ enum DesktopMode { )) Self.attachPlatformDevices(to: machine, serialLog: serialLog) - self.input = VirtioInput() + self.keyboardInput = VirtioInput(profile: .keyboard) + self.pointerInput = VirtioInput(profile: .absolutePointer) var mailboxes = [DesktopFrameMailbox]() var cursorMailboxes = [DesktopCursorMailbox]() var displays = [DesktopMetalView]() @@ -748,7 +744,8 @@ enum DesktopMode { let cursorMailbox = DesktopCursorMailbox() let display = try DesktopMetalView( frame: NSRect(origin: .zero, size: plan.windowSize), - input: input, + keyboardInput: keyboardInput, + pointerInput: pointerInput, guestBackingScaleFactor: CGFloat(plan.backingScaleFactor), scanoutID: plan.scanoutID, pointerTopology: pointerTopology @@ -857,7 +854,10 @@ enum DesktopMode { vsock, ] if configuration.resolvedDevices?.keyboard != false { - backends.append(input) + backends.append(keyboardInput) + } + if configuration.resolvedDevices?.pointer != false { + backends.append(pointerInput) } if configuration.resolvedDevices?.audioInput != false { backends.append(sound) @@ -971,7 +971,7 @@ enum DesktopMode { let clipboardControl = DorydKit.AgentControl(configuration: .init( directSocketPath: configuration.agentSocketPath )) - let clipboardInput = self.input + let clipboardInput = self.keyboardInput self.clipboard = DoryDesktopClipboardCoordinator( policy: clipboardPolicy, execute: { argv, stdin, timeoutMs, outputLimitBytes in @@ -1012,7 +1012,27 @@ enum DesktopMode { window.title = displayPlans.count == 1 ? "\(configuration.machineID) — Dory Linux" : "\(configuration.machineID) — Dory Linux — Display \(index + 1)" - window.contentView = displays[index] + // Keep the Metal surface bound to the window's *actual* content layout. A + // dedicated display can transiently remain a normal titled window while AppKit + // enters its fullscreen Space. Installing the fixed-size MTKView directly as the + // content view left its original 1920x1080 bounds clipped inside a 1920x1020 + // visible content area, so the framebuffer and absolute tablet normalized + // different coordinate spaces. The AppKit-managed container always follows the + // titlebar/fullscreen transition; constraints then make display pixels and input + // use the same rectangle. + let contentView = NSView( + frame: NSRect(origin: .zero, size: window.contentLayoutRect.size) + ) + let display = displays[index] + display.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(display) + window.contentView = contentView + NSLayoutConstraint.activate([ + display.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + display.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + display.topAnchor.constraint(equalTo: contentView.topAnchor), + display.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) window.minSize = NSSize(width: 640, height: 400) window.collectionBehavior.insert(.fullScreenPrimary) window.tabbingMode = .disallowed @@ -1216,7 +1236,7 @@ enum DesktopMode { if configuration.genericGuest { // Linux maps KEY_POWER to logind's normal power-button action. This provides a // clean integration-free shutdown path until Dory guest tools are installed. - input.send(frame: [ + keyboardInput.send(frame: [ VirtioInputEvent(type: 1, code: 116, value: 1), VirtioInputEvent(type: 1, code: 116, value: 0), ]) @@ -1607,7 +1627,6 @@ enum DesktopMode { private static func resolveGraphics( environment: [String: String], - requireVulkan: Bool, exactLevel: DoryGraphicsAccelerationLevel? ) throws -> ResolvedGraphics { let preference = try DoryDesktopGraphicsPreference(environment: environment) @@ -1648,34 +1667,20 @@ enum DesktopMode { } } - switch preference { - case .automatic: - if requireVulkan { - do { - return try accelerated(classicOnly: false) - } catch { - throw VMError.bootFailure( - "generic Linux desktop requires Dory's Vulkan-capable Venus renderer; \(error)" - ) - } - } - do { - return try accelerated(classicOnly: false) - } catch let venusError { - log("VirGL2 + Venus unavailable; trying classic VirGL2: \(venusError)") - do { - return try accelerated(classicOnly: true) - } catch let virglError { - log("accelerated graphics unavailable; using software scanout: \(virglError)") - return ResolvedGraphics(backend: .software, renderer: nil) - } - } + switch preference.requiredBackend { case .virgl: return try accelerated(classicOnly: true) - case .virglVenus: - return try accelerated(classicOnly: false) case .software: return ResolvedGraphics(backend: .software, renderer: nil) + case .virglVenus: + do { + return try accelerated(classicOnly: false) + } catch { + throw VMError.bootFailure( + "Linux desktop requires Dory's VirGL2 + Venus GPU runtime; " + + "select software graphics explicitly for compatibility mode: \(error)" + ) + } } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift index 57073212..097fd342 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -904,6 +904,52 @@ import Testing #expect(Array(data[24..<29]) == [0x56, 0x45, 0x4e, 0x55, 0x53]) } + @Test func acceptsGuestLogicalBlobSizesAndRejectsUnalignedMapOffsets() throws { + let renderer = FakeVirtioGPURenderer(capsets: [ + VirtioGPUCapset(id: 4, maxVersion: 0, data: [1]) + ]) + let hostVisibleMemory = try VirtioGPUHostVisibleMemory( + guestBase: GuestLayout.daxWindowBase + ) + let gpu = VirtioGPU( + hostMemoryBase: GuestLayout.daxWindowBase, + renderer: renderer, + hostVisibleMemory: hostVisibleMemory + ) + + // Host-page alignment is an implementation detail handled by Dory's + // guest-kernel aperture allocation and host mapping. Do not invent an + // unstandardized virtio feature bit or extend the device config ABI. + #expect(gpu.deviceFeatures & (1 << 5) == 0) + #expect(gpu.configSpace.count == 16) + + func blobRequest(resourceID: UInt32, size: UInt64) -> [UInt8] { + var request = gpuRequest(type: 0x010C, fenceID: 0, contextID: 3, ringIndex: 0) + request.appendLE(resourceID) + request.appendLE(UInt32(2)) // VIRTIO_GPU_BLOB_MEM_HOST3D + request.appendLE(UInt32(1)) // mappable + request.appendLE(UInt32(0)) + request.appendLE(UInt64(44)) + request.appendLE(size) + return request + } + + #expect(leUInt32(try gpuResponse( + gpu: gpu, + request: blobRequest(resourceID: 7, size: 4_096) + ), at: 0) == 0x1100) + #expect(leUInt32(try gpuResponse( + gpu: gpu, + request: blobRequest(resourceID: 8, size: 135_168) + ), at: 0) == 0x1100) + + var unalignedMap = gpuRequest(type: 0x0208, fenceID: 0, contextID: 3, ringIndex: 0) + unalignedMap.appendLE(UInt32(7)) + unalignedMap.appendLE(UInt32(0)) + unalignedMap.appendLE(UInt64(4_096)) + #expect(leUInt32(try gpuResponse(gpu: gpu, request: unalignedMap), at: 0) == 0x1202) + } + @Test func assignsStableUUIDToRendererResource() throws { let renderer = FakeVirtioGPURenderer(capsets: [ VirtioGPUCapset(id: 4, maxVersion: 2, data: [0x56, 0x45, 0x4e, 0x55, 0x53]) @@ -1075,11 +1121,13 @@ import Testing @Test func cursorQueuePublishesCopiedShapeAndHotspotAndRejectsInvalidUpdates() throws { let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size) let cursorBox = CursorUpdateBox() + let renderer = FakeVirtioGPURenderer(capsets: []) let gpu = VirtioGPU( hostMemoryBase: 0x1_0000_0000, scanoutCount: 1, scanoutWidth: 2_560, scanoutHeight: 1_600, + renderer: renderer, onCursorUpdate: { cursorBox.store($0) } ) let transport = VirtioMMIOTransport( @@ -1236,6 +1284,48 @@ import Testing #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 0, hotX: 0, hotY: 0)), at: 0) == 0x1100) #expect(cursorBox.values.count == countBeforeInvalid + 1) #expect(cursorBox.values.last! == nil) + + let acceleratedPixels: [UInt8] = [ + 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 32, 33, 34, 35, 36, + ] + renderer.setTransferReadback(width: 2, height: 2, pixels: acceleratedPixels) + var create3D = gpuRequest(type: 0x0204, fenceID: 0, contextID: 0, ringIndex: 0) + create3D.appendLE(UInt32(8)) // resource_id + create3D.appendLE(UInt32(2)) // PIPE_TEXTURE_2D + create3D.appendLE(UInt32(2)) // B8G8R8X8_UNORM (accelerated Mutter cursor) + create3D.appendLE(UInt32(1 << 1)) // PIPE_BIND_RENDER_TARGET + create3D.appendLE(UInt32(2)) + create3D.appendLE(UInt32(2)) + create3D.appendLE(UInt32(1)) + create3D.appendLE(UInt32(1)) + create3D.appendLE(UInt32(0)) + create3D.appendLE(UInt32(0)) + create3D.appendLE(UInt32(1)) + create3D.appendLE(UInt32(0)) + #expect(leUInt32(try submitControl(create3D), at: 0) == 0x1100) + + #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 8, hotX: 0, hotY: 1)), at: 0) == 0x1100) + let acceleratedUpdate = try #require(cursorBox.values.last ?? nil) + #expect(acceleratedUpdate.resourceID == 8) + #expect(acceleratedUpdate.width == 2) + #expect(acceleratedUpdate.height == 2) + #expect(acceleratedUpdate.hotX == 0) + #expect(acceleratedUpdate.hotY == 1) + #expect(Array(acceleratedUpdate.bytes) == acceleratedPixels) + #expect(renderer.transferFromHostCalls.last?.box == [0, 0, 0, 2, 2, 1]) + #expect(renderer.transferFromHostCalls.last?.entryLengths == [acceleratedPixels.count]) + + renderer.setTransferReadback( + width: 2, + height: 2, + pixels: [UInt8](repeating: 0xEE, count: acceleratedPixels.count) + ) + #expect(Array(acceleratedUpdate.bytes) == acceleratedPixels) + + let countBeforeInvalidAccelerated = cursorBox.values.count + #expect(leUInt32(try submitCursor(cursorRequest(resourceID: 8, hotX: 0, hotY: 2)), at: 0) == 0x1202) + #expect(cursorBox.values.count == countBeforeInvalidAccelerated) } @Test func scanoutDisableAcceptsTheEmptyRectangleUsedDuringModesets() throws { @@ -2427,6 +2517,72 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { #expect(config.leUInt32(at: 12) == 32_767) } + @Test func combinedCompatibilityPreservesHistoricalWireIdentity() { + let input = VirtioInput(profile: .combinedCompatibility) + + input.writeConfig(offset: 0, value: 0x03, width: 1) // device IDs + input.writeConfig(offset: 1, value: 0, width: 1) + var config = input.configSpace + #expect(config[2] == 8) + #expect(config.leUInt16(at: 8) == 0x06) + #expect(config.leUInt16(at: 10) == 0xD072) + #expect(config.leUInt16(at: 12) == 0x0001) + #expect(config.leUInt16(at: 14) == 0x0001) + + input.writeConfig(offset: 0, value: 0x10, width: 1) // property bits + config = input.configSpace + #expect(config[2] == 1) + #expect(config[8] == 0) + + input.writeConfig(offset: 0, value: 0x12, width: 1) + input.writeConfig(offset: 1, value: 0, width: 1) // ABS_X info + config = input.configSpace + #expect(config.leUInt32(at: 24) == 100) + } + + @Test func productionKeyboardAndTabletAdvertiseDisjointCapabilities() { + let keyboard = VirtioInput(profile: .keyboard) + keyboard.writeConfig(offset: 0, value: 0x11, width: 1) + keyboard.writeConfig(offset: 1, value: 1, width: 1) // EV_KEY + var config = keyboard.configSpace + #expect(config[8 + 3] & (1 << 6) != 0) // KEY_A 30 + #expect(config.count > 8 + 34) + #expect(config[8 + 34] & 1 == 0) // no BTN_LEFT 272 + + keyboard.writeConfig(offset: 1, value: 3, width: 1) // EV_ABS + config = keyboard.configSpace + #expect(config[2] == 0) + + let pointer = VirtioInput(profile: .absolutePointer) + pointer.writeConfig(offset: 0, value: 0x11, width: 1) + pointer.writeConfig(offset: 1, value: 1, width: 1) // EV_KEY + config = pointer.configSpace + #expect(config[8 + 3] & (1 << 6) == 0) // no KEY_A 30 + #expect(config[8 + 34] & 1 != 0) // BTN_LEFT 272 + + pointer.writeConfig(offset: 1, value: 3, width: 1) // EV_ABS + config = pointer.configSpace + #expect(config[8] & 0b11 == 0b11) // ABS_X and ABS_Y + + pointer.writeConfig(offset: 1, value: 2, width: 1) // EV_REL + config = pointer.configSpace + #expect(config[2] == 2) + #expect(config[8] == 0) + #expect(config[9] == 1) // REL_WHEEL 8 only + + pointer.writeConfig(offset: 0, value: 0x10, width: 1) // PROP_BITS + pointer.writeConfig(offset: 1, value: 0, width: 1) + config = pointer.configSpace + #expect(config[2] == 0) // omitted, as in QEMU virtio-tablet + + pointer.writeConfig(offset: 0, value: 0x12, width: 1) + pointer.writeConfig(offset: 1, value: 1, width: 1) // ABS_Y info + config = pointer.configSpace + #expect(config[2] == 20) + #expect(config.leUInt32(at: 12) == 32_767) + #expect(config.leUInt32(at: 24) == 0) // unspecified physical resolution + } + @Test func convertsAppKitScrollDirectionToLinuxWheelDirection() { var scroll = VirtioInputScrollAccumulator() let events = scroll.events( @@ -2524,6 +2680,113 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer { #expect(try memory.read(UInt32.self, at: eventBuffers + 12) == 16_000) #expect(try memory.read(UInt16.self, at: eventBuffers + 16) == 0) } + + @Test func absolutePointerClickPublishesPositionAndButtonInOneAtomicFrame() throws { + let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) + let input = VirtioInput(profile: .absolutePointer) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: input, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + input.deviceReady(transport: transport) + + for index in 0..<4 { + let descriptor = descriptorTable + UInt64(index) * 16 + try memory.write(eventBuffers + UInt64(index) * 8, at: descriptor) + try memory.write(UInt32(8), at: descriptor + 8) + try memory.write(UInt16(2), at: descriptor + 12) + try memory.write(UInt16(0), at: descriptor + 14) + try memory.write(UInt16(index), at: availableRing + 4 + UInt64(index) * 2) + } + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(3), at: availableRing + 2) + + input.send(frame: [ + VirtioInputEvent(type: 3, code: 0, value: 9_000), + VirtioInputEvent(type: 3, code: 1, value: 10_000), + VirtioInputEvent(type: 1, code: 272, value: 1), + ]) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0) + + try memory.write(UInt16(4), at: availableRing + 2) + input.handleKick(queue: 0, transport: transport) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 4) + #expect(try memory.read(UInt16.self, at: eventBuffers) == 3) + #expect(try memory.read(UInt32.self, at: eventBuffers + 4) == 9_000) + #expect(try memory.read(UInt16.self, at: eventBuffers + 8) == 3) + #expect(try memory.read(UInt32.self, at: eventBuffers + 12) == 10_000) + #expect(try memory.read(UInt16.self, at: eventBuffers + 16) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 18) == 272) + #expect(try memory.read(UInt32.self, at: eventBuffers + 20) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 24) == 0) + } + + @Test func pointerMotionDoesNotBlockAButtonFrameBehindStalePositions() throws { + let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size) + let input = VirtioInput() + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase, + backend: input, + memory: memory + ) {} + transport.queues[0].configure( + size: 8, + descriptorTable: descriptorTable, + availRing: availableRing, + usedRing: usedRing + ) + transport.queues[0].setReady(true) + input.deviceReady(transport: transport) + + for index in 0..<5 { + let descriptor = descriptorTable + UInt64(index) * 16 + try memory.write(eventBuffers + UInt64(index) * 8, at: descriptor) + try memory.write(UInt32(8), at: descriptor + 8) + try memory.write(UInt16(2), at: descriptor + 12) // device-writable + try memory.write(UInt16(0), at: descriptor + 14) + try memory.write(UInt16(index), at: availableRing + 4 + UInt64(index) * 2) + } + try memory.write(UInt16(0), at: availableRing) + try memory.write(UInt16(0), at: availableRing + 2) + + input.send(frame: [ + VirtioInputEvent(type: 3, code: 0, value: 1_000), + VirtioInputEvent(type: 3, code: 1, value: 2_000), + ]) + input.send(frame: [ + VirtioInputEvent(type: 3, code: 0, value: 16_000), + VirtioInputEvent(type: 3, code: 1, value: 17_000), + ]) + input.send(frame: [VirtioInputEvent(type: 1, code: 272, value: 1)]) + + try memory.write(UInt16(3), at: availableRing + 2) + input.handleKick(queue: 0, transport: transport) + + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 3) + #expect(try memory.read(UInt16.self, at: eventBuffers) == 3) + #expect(try memory.read(UInt16.self, at: eventBuffers + 2) == 0) + #expect(try memory.read(UInt32.self, at: eventBuffers + 4) == 16_000) + #expect(try memory.read(UInt16.self, at: eventBuffers + 8) == 3) + #expect(try memory.read(UInt16.self, at: eventBuffers + 10) == 1) + #expect(try memory.read(UInt32.self, at: eventBuffers + 12) == 17_000) + + try memory.write(UInt16(5), at: availableRing + 2) + input.handleKick(queue: 0, transport: transport) + + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 5) + #expect(try memory.read(UInt16.self, at: eventBuffers + 24) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 26) == 272) + #expect(try memory.read(UInt32.self, at: eventBuffers + 28) == 1) + #expect(try memory.read(UInt16.self, at: eventBuffers + 32) == 0) + } } @Suite struct PL011Tests { diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift index b428e971..9b002b3c 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift @@ -28,10 +28,10 @@ public enum DoryDesktopVMMPreference: String, CaseIterable, Sendable, Codable { /// Requested graphics behavior for Dory's raw-Hypervisor desktop runtime. /// -/// The default first attempts the combined VirGL2 + Venus backend: VirGL accelerates the desktop -/// compositor while Venus gives Vulkan applications a real Apple-GPU path. Hosts without a -/// qualified Venus renderer fall back to classic VirGL, then to virtio-gpu 2D, so graphics -/// acceleration cannot make the Linux desktop unbootable. +/// The default requires the combined VirGL2 + Venus backend: VirGL accelerates the desktop +/// compositor while Venus gives Vulkan applications a real Apple-GPU path. Dory never silently +/// turns `automatic` into software rendering. Classic VirGL and software scanout remain explicit +/// compatibility choices for guests or hosts that cannot satisfy the default GPU contract. public enum DoryDesktopGraphicsPreference: String, CaseIterable, Sendable, Codable { public static let environmentKey = "DORY_DESKTOP_GRAPHICS" public static let legacyClassicOnlyEnvironmentKey = "DORY_VIRGL_CLASSIC_ONLY" @@ -41,6 +41,17 @@ public enum DoryDesktopGraphicsPreference: String, CaseIterable, Sendable, Codab case virglVenus = "virgl-venus" case software + /// The exact backend promised by this preference. Keeping this mapping in the shared runtime + /// contract prevents a launcher from weakening `automatic` into an unreported compatibility + /// fallback. + public var requiredBackend: DoryDesktopGraphicsBackend { + switch self { + case .automatic, .virglVenus: .virglVenus + case .virgl: .virgl + case .software: .software + } + } + public init(environment: [String: String]) throws { if let raw = environment[Self.environmentKey] { guard let value = Self(rawValue: raw) else { diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift index a9072c24..883de280 100644 --- a/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift +++ b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift @@ -45,4 +45,14 @@ final class DoryDesktopRuntimeContractTests: XCTestCase { XCTAssertNotNil(DoryDesktopGraphicsBackend.virgl.legacyKernelArgument) XCTAssertNil(DoryDesktopGraphicsBackend.software.legacyKernelArgument) } + + func testAutomaticGraphicsRequiresVulkanAccelerationWithoutSilentFallback() { + XCTAssertEqual( + DoryDesktopGraphicsPreference.automatic.requiredBackend, + .virglVenus + ) + XCTAssertEqual(DoryDesktopGraphicsPreference.virglVenus.requiredBackend, .virglVenus) + XCTAssertEqual(DoryDesktopGraphicsPreference.virgl.requiredBackend, .virgl) + XCTAssertEqual(DoryDesktopGraphicsPreference.software.requiredBackend, .software) + } } diff --git a/guest/kernel/dory-desktop.fragment b/guest/kernel/dory-desktop.fragment index 79b75e24..fb8b8cdd 100644 --- a/guest/kernel/dory-desktop.fragment +++ b/guest/kernel/dory-desktop.fragment @@ -22,6 +22,34 @@ CONFIG_USB_HID=y CONFIG_ZSMALLOC=y CONFIG_ZRAM=y +# Ubuntu and other mainstream desktop distributions ship core applications as SquashFS images. +# Their current packages use both XZ and LZO; without these decompressors Firefox, the app store, +# and other snaps are present but cannot mount or launch under Dory's managed kernel. Preserve +# package capabilities and security metadata instead of silently discarding their xattrs. +CONFIG_SQUASHFS_XATTR=y +CONFIG_SQUASHFS_XZ=y +CONFIG_SQUASHFS_LZO=y + +# Ubuntu confines desktop snaps with AppArmor and builds each application's content-provider +# namespace under that security model. Falling back to snapd's unconfined devmode is not a +# desktop compatibility mode: GPU-provider command chains can remain disconnected and apps then +# spin forever without reaching their own entry point. Match the security stack expected by a +# stock Ubuntu desktop kernel so snapped and native applications share the same launch semantics. +CONFIG_SECURITY_NETWORK=y +CONFIG_SECURITY_PATH=y +CONFIG_SECURITY_YAMA=y +CONFIG_SECURITY_SAFESETID=y +CONFIG_SECURITY_LOCKDOWN_LSM=y +CONFIG_SECURITY_LOCKDOWN_LSM_EARLY=y +CONFIG_SECURITY_LANDLOCK=y +CONFIG_SECURITY_APPARMOR=y +CONFIG_SECURITY_APPARMOR_INTROSPECT_POLICY=y +CONFIG_SECURITY_APPARMOR_HASH=y +CONFIG_SECURITY_APPARMOR_HASH_DEFAULT=y +CONFIG_SECURITY_APPARMOR_EXPORT_BINARY=y +CONFIG_DEFAULT_SECURITY_APPARMOR=y +CONFIG_LSM="landlock,lockdown,yama,integrity,apparmor" + # VZVirtioSoundDeviceConfiguration output stream. CONFIG_SOUND=y CONFIG_SND=y From 9eade80352784835bfb00c381e893b043ffd54c0 Mon Sep 17 00:00:00 2001 From: Augustus Otu Date: Wed, 26 Aug 2026 11:13:14 +0000 Subject: [PATCH 280/338] Complete accelerated Linux desktop runtime --- ...test-container-engine-performance-gate.py} | 20 +- .../scripts/test-desktop-linux-live-gate.py | 123 +- .../scripts/test-graphics-backend-resolver.py | 216 + .../scripts/test-graphics-pack-installer.py | 142 + .../test-linux-vm-performance-evidence.py | 524 ++ .github/scripts/test-readiness-gate.py | 30 + .../test-release-candidate-live-smoke.py | 10 +- .../scripts/test-renderer-production-tuple.py | 400 + .../scripts/test-renderer-release-identity.py | 309 + .github/scripts/verify-public-release.py | 2 +- .../verify-release-workflow-contract.py | 20 + .github/workflows/release.yml | 67 +- .github/workflows/tests.yml | 15 +- .gitignore | 43 +- COMPATIBILITY.md | 13 +- Config/DoryFSWorker-Info.plist | 31 + Config/DoryHVRunner-Info.plist | 36 + Config/DoryRendererProductionTuple.json | 624 ++ Config/DoryRendererWorker-Info.plist | 31 + Dory.xcodeproj/project.pbxproj | 584 ++ Dory/Features/Machines/MachinesView.swift | 40 +- .../Sheets/MachineCreationSheet.swift | 40 +- Dory/Features/Sheets/NewMachineSheet.swift | 63 +- Dory/Models/AppStore.swift | 96 +- Dory/Models/Models.swift | 49 +- Dory/Runtime/Docker/DockerEngineRuntime.swift | 28 + Dory/Runtime/Doryd/DorydClient.swift | 201 +- Dory/Runtime/Doryd/DorydLaunchAgent.swift | 4 +- .../Kubernetes/KubernetesProvisioner.swift | 16 +- Dory/Runtime/Shared/SharedVMProvisioner.swift | 15 +- DoryTests/DoryProcessMemoryTests.swift | 2 +- DoryTests/DorydClientTests.swift | 265 +- DoryTests/DorydLaunchAgentTests.swift | 19 + .../KubernetesProvisionerImageTests.swift | 10 +- DoryTests/NewMachineSettingsTests.swift | 25 + DoryTests/RuntimeSupportTests.swift | 8 + LINUX_DESKTOP_PARITY.md | 10 +- .../DoryFSWorker.entitlements | 10 + .../DoryRendererWorker.entitlements | 12 + Packages/ContainerizationEngine/Package.swift | 133 +- .../DoryFSWorkerBootstrap.swift | 1062 +++ .../DoryFSWorkerContracts.swift | 852 ++ .../DoryFSWorkerXPC.swift | 211 + .../FuseProtocol.swift | 23 +- .../LittleEndianBytes.swift | 4 +- .../DoryFSWorkerProcessResources.swift | 42 + .../DoryFSWorkerRootAuthority.swift | 334 + .../DoryFSWorkerService.swift | 562 ++ .../FuseResourceLimits.swift | 239 + .../FuseServer.swift | 1029 +- .../HostFS.swift | 291 +- .../DoryFSWorkerMain.swift | 107 + .../DoryGuestMemoryShim/DoryGuestMemoryShim.c | 230 + .../include/DoryGuestMemoryShim.h | 44 + .../Sources/DoryHV/AgentChannel.swift | 96 +- .../Sources/DoryHV/AgentVsockForward.swift | 148 +- .../DoryHV/BoundedGuestVsockService.swift | 433 + .../DoryHV/BoundedVsockSocketListener.swift | 386 + .../Sources/DoryHV/DaxCoherenceProbe.swift | 315 - .../Sources/DoryHV/DockerSocketBridge.swift | 62 +- .../DoryHV/DoryRendererWorkerBroker.swift | 961 ++ .../DoryRendererWorkerSharedMemory.swift | 234 + .../DoryRendererWorkerVirtioCommandLane.swift | 1886 ++++ .../DoryHV/DoryRendererWorkerXPCChannel.swift | 278 + .../Sources/DoryHV/Fuse/DaxWindow.swift | 129 - .../DoryHV/Fuse/DoryFSWorkerAdmission.swift | 478 + .../DoryHV/Fuse/DoryFSWorkerBroker.swift | 964 ++ .../DoryHV/Fuse/DoryFSWorkerXPCChannel.swift | 319 + .../Fuse/FileBackedDaxMappingBackend.swift | 83 - .../DoryHV/Fuse/HostFSEventRelay.swift | 805 -- .../Fuse/HostShareCoherenceCoordinator.swift | 868 -- .../Sources/DoryHV/GuestFSEventBridge.swift | 18 +- .../Sources/DoryHV/GuestMemory.swift | 276 +- .../GuestMemoryReclaimBootCommand.swift | 4 +- .../DoryHV/GuestVsockSocketBridge.swift | 58 +- .../Sources/DoryHV/HostAIBridge.swift | 187 +- .../DoryHV/HostFileDescriptorLimit.swift | 6 +- .../Sources/DoryHV/HostSSHAgentBridge.swift | 165 +- .../Sources/DoryHV/KernelImage.swift | 10 +- .../Sources/DoryHV/LZFSE.swift | 90 - .../Sources/DoryHV/MMIO.swift | 110 +- .../Sources/DoryHV/MPTable.swift | 1 + .../Sources/DoryHV/Machine.swift | 558 +- .../Sources/DoryHV/MachineBootPayload.swift | 256 + .../Sources/DoryHV/PL011.swift | 94 +- .../Sources/DoryHV/PVHBoot.swift | 1 + .../Sources/DoryHV/RawHVMachineRunner.swift | 271 + .../Sources/DoryHV/Usb/HostUsbDevice.swift | 459 +- .../DoryHV/Usb/UsbControlHandler.swift | 373 +- .../Sources/DoryHV/Usb/UsbControlServer.swift | 1200 ++- .../Sources/DoryHV/Usb/UsbipBridge.swift | 343 +- .../Sources/DoryHV/Usb/UsbipManager.swift | 774 +- .../Sources/DoryHV/Usb/UsbipProtocol.swift | 278 +- .../Sources/DoryHV/Usb/UsbipServer.swift | 69 +- .../Sources/DoryHV/VirglRenderer.swift | 1084 --- .../Sources/DoryHV/VirtioBalloon.swift | 682 +- .../Sources/DoryHV/VirtioBlk.swift | 1785 +++- .../Sources/DoryHV/VirtioFS.swift | 1123 ++- .../Sources/DoryHV/VirtioFSNotification.swift | 1 + .../DoryHV/VirtioFSRequestAdmission.swift | 379 + .../Sources/DoryHV/VirtioGPU.swift | 8364 ++++++++++++++++- .../Sources/DoryHV/VirtioInput.swift | 975 +- .../Sources/DoryHV/VirtioMMIO.swift | 188 +- .../Sources/DoryHV/VirtioNet.swift | 2296 ++++- .../Sources/DoryHV/VirtioRng.swift | 494 +- .../Sources/DoryHV/VirtioSound.swift | 897 +- .../Sources/DoryHV/VirtioVsock.swift | 2200 ++++- .../Sources/DoryHV/Virtqueue.swift | 612 +- .../DoryHV/VsockServiceAdmission.swift | 524 ++ .../Sources/DoryHV/VsockUnixRelay.swift | 375 +- .../Sources/DoryHV/X86BootPlan.swift | 23 + .../Sources/DoryHVUSBShim/DoryHVUSBShim.m | 14 + .../DoryHVUSBShim/include/DoryHVUSBShim.h | 9 + .../DoryRendererWorkerContracts/Exports.swift | 3 + .../DoryRendererWorkerXPC.swift | 54 + .../DoryRendererWorkerService.swift | 479 + .../DoryRendererWorkerServiceMetrics.swift | 40 + .../DoryRendererForeignSession.swift | 731 ++ ...ryRendererProductionArtifactVerifier.swift | 256 + .../DoryRendererWorkerVirglBackend.swift | 1761 ++++ .../DoryRendererWorkerMain.swift | 98 + .../DoryVirglRendererSession.c | 2187 +++++ .../DoryVirglRendererShim.c | 30 + .../include/DoryVirglRendererShim.h | 378 + .../BoundedSerialConsolePublisher.swift | 419 + .../Sources/dory-hv/DesktopMetalDisplay.swift | 2633 +++++- .../Sources/dory-hv/DesktopMode.swift | 1573 +++- .../DesktopRendererRuntimeFailure.swift | 55 + .../dory-hv/DesktopRendererWorkerLaunch.swift | 409 + .../Sources/dory-hv/EngineMode.swift | 384 +- .../dory-hv/RawHVSerialConsoleInput.swift | 908 ++ .../RawHVVirtualHardwareAttachmentPlan.swift | 176 + ...endererBootstrapQualificationCommand.swift | 375 + .../VirtioFSShareConfiguration.swift | 139 +- .../Sources/dory-hv/main.swift | 644 +- .../DoryFSWorkerProcessResourcesTests.swift | 26 + .../DoryFSWorkerRootAuthorityTests.swift | 820 ++ .../FuseResourceQuotaTests.swift | 779 ++ .../FuseServerTests.swift | 588 +- .../HostFSTests.swift | 98 +- .../DoryHVTests/AgentProtocolTests.swift | 78 + .../DoryHVTests/AgentVsockForwardTests.swift | 97 +- .../BoundedSerialConsolePublisherTests.swift | 139 + .../Tests/DoryHVTests/DaxWindowTests.swift | 97 - .../DesktopDeviceTelemetryTests.swift | 356 +- .../DoryHVTests/DesktopDisplayPlanTests.swift | 24 + ...sktopGenericGuestShareReadinessTests.swift | 32 + .../DesktopGuestReadinessBoundaryTests.swift | 102 + .../DesktopRendererWorkerLaunchTests.swift | 399 + .../DesktopRootDiskAuthorityTests.swift | 121 + .../DesktopScanoutResourceLifetimeTests.swift | 536 ++ .../DesktopShutdownPlanTests.swift | 100 + .../Tests/DoryHVTests/DeviceLogicTests.swift | 2142 ++++- .../DoryFSWorkerBootstrapContractTests.swift | 539 ++ .../DoryHVTests/DoryFSWorkerBrokerTests.swift | 883 ++ .../DoryHVTests/DoryFSWorkerTestChannel.swift | 279 + .../DoryRendererWorkerBrokerTests.swift | 4711 ++++++++++ .../DoryRendererWorkerSharedMemoryTests.swift | 88 + .../EngineRuntimePolicyTests.swift | 53 + .../EngineVsockBridgeLifecycleTests.swift | 273 + .../Tests/DoryHVTests/FuseProtocolTests.swift | 1 + .../GuestMemoryReclaimStateTests.swift | 163 + .../Tests/DoryHVTests/GuestMemoryTests.swift | 101 + ...GuestVsockSocketBridgeLifecycleTests.swift | 277 + .../DoryHVTests/HostFSEventRelayTests.swift | 436 - .../HostGuestVsockServiceLifecycleTests.swift | 423 + .../Tests/DoryHVTests/HostPageTests.swift | 1 - .../DoryHVTests/HostSSHAgentBridgeTests.swift | 30 +- .../HostShareCoherenceCoordinatorTests.swift | 743 -- .../DoryHVTests/HostUsbDeviceTests.swift | 59 +- .../DoryHVTests/KernelImageSafetyTests.swift | 39 + .../DoryHVTests/MachineBootPayloadTests.swift | 291 + .../DoryHVTests/MachineHotPathTests.swift | 73 + .../DoryHVTests/RawHVMachineRunnerTests.swift | 131 + .../RawHVSerialConsoleInputTests.swift | 520 + ...HVVirtualHardwareAttachmentPlanTests.swift | 290 + .../UsbControlHardeningTests.swift | 1650 ++++ .../Tests/DoryHVTests/UsbipBridgeTests.swift | 194 +- .../DoryHVTests/UsbipHardeningTests.swift | 683 ++ .../Tests/DoryHVTests/UsbipServerTests.swift | 29 +- .../DoryHVTests/VirglRendererABITests.swift | 17 + .../VirtioBalloonHardeningTests.swift | 532 ++ .../DoryHVTests/VirtioBlkSafetyTests.swift | 1137 +++ .../VirtioFSShareConfigurationTests.swift | 22 +- .../Tests/DoryHVTests/VirtioFSTests.swift | 1808 ++-- .../VirtioInputHardeningTests.swift | 706 ++ .../VirtioMMIOInterruptTests.swift | 116 + .../VirtioMMIOSlotOwnershipTests.swift | 167 + .../DoryHVTests/VirtioNetHardeningTests.swift | 1156 +++ .../Tests/DoryHVTests/VirtioNetTests.swift | 259 +- .../DoryHVTests/VirtioRngHardeningTests.swift | 469 + .../VirtioSoundHardeningTests.swift | 554 ++ .../VirtioVsockHardeningTests.swift | 1091 +++ .../Tests/DoryHVTests/VirtioVsockTests.swift | 209 +- .../DoryHVTests/VirtqueueHardeningTests.swift | 747 ++ .../Tests/DoryHVTests/VirtqueueTests.swift | 32 +- .../VsockServiceAdmissionTests.swift | 201 + .../DoryRendererWorkerContractTests.swift | 734 ++ .../DoryRendererWorkerServiceTests.swift | 431 + ...dererProductionArtifactVerifierTests.swift | 214 + .../DoryRendererWorkerVirglBackendTests.swift | 2227 +++++ .../DoryVirglRendererEnvironmentTests.swift | 60 + .../dory-hv.entitlements | 2 - README.md | 31 +- .../virtiofs-worker-xpc.json | 85 + .../vulkan-13-application-readiness.md | 178 + .../vz-custom-virtio-gpu.json | 241 + ...tainer-engine-performance-qualification.md | 162 + ...nux-capability-and-qualification-matrix.md | 158 + docs/linux-iso-and-gpu-recovery-review.md | 190 + docs/linux-virtual-workspace-architecture.md | 1144 +++ docs/linux-virtual-workspace-delivery-plan.md | 393 + docs/linux-vm-performance-contract.md | 950 ++ docs/virtual-workspace-platform.md | 208 +- dory-core-swift/Package.swift | 53 +- .../Sources/DoryCore/DoryCore.swift | 45 + .../DoryOperations/DoryDataDrive.swift | 46 +- .../DoryDataDriveTransaction.swift | 8 +- .../DoryDesktopHelperExitStatus.swift | 10 + .../DoryDesktopRuntimeContract.swift | 20 +- .../DoryInstalledLinuxBootBundle.swift | 323 +- .../DoryOperations/DoryInstallerISO.swift | 671 +- .../DoryTrustedDirectoryRoot.swift | 439 + .../DoryVirtualMachineArtifactAuthority.swift | 73 +- .../DoryVirtualMachineBackendPlanner.swift | 2 +- .../DoryVirtualMachineCapabilities.swift | 88 +- ...yVirtualMachineQualificationManifest.swift | 81 +- .../EngineStateDirectoryLock.swift | 24 +- .../RuntimeLaunchEnvelope.swift | 634 ++ .../DoryRendererBlobMappingLease.swift | 134 + .../DoryRendererCapabilityReceipt.swift | 296 + .../DoryRendererProductionInventory.swift | 227 + .../DoryRendererScanoutLease.swift | 288 + ...oryRendererSharedTextureScanoutLease.swift | 170 + .../DoryRendererWorkerBootstrap.swift | 216 + .../DoryRendererWorkerCommand.swift | 505 + .../DoryRendererWorkerIdentity.swift | 326 + .../DoryRendererWorkerOperationPayloads.swift | 443 + .../DoryRendererWorkerXPC.swift | 206 + .../LittleEndianBytes.swift | 58 + .../RawHVVirtualHardwareTopology.swift | 516 + ...wHVVirtualHardwareTopologyReconciler.swift | 120 + .../DoryVMContracts/USBControlV1.swift | 469 + .../Sources/DoryVMMKit/DoryVMM.swift | 154 +- .../DoryVMMKit/DoryVMMGVProxyNetwork.swift | 32 +- .../Sources/DorydKit/AgentControl.swift | 31 + .../Sources/DorydKit/DockerEngineProbe.swift | 38 + .../Sources/DorydKit/DockerTier.swift | 19 + ...oryDaemonRendererProductionAuthority.swift | 672 ++ ...emonVirtualMachineLaunchPlanResolver.swift | 63 +- ...onVirtualMachineProductionActivation.swift | 40 +- ...MachineProductionPlanningComposition.swift | 10 +- ...yDaemonVirtualMachineProductionTrust.swift | 279 +- ...yDaemonVirtualMachineRuntimePlanning.swift | 41 +- .../DorydKit/DoryDeviceTelemetry.swift | 85 +- .../DorydKit/DoryHostUSBDiscovery.swift | 8 +- .../DorydKit/DoryLinuxVMCalibrationExec.swift | 267 + .../DoryLinuxVMCalibrationLauncher.swift | 1134 +++ .../DorydKit/DoryLiveChildCodeIdentity.swift | 90 + .../DoryMachineConfigurationMigration.swift | 8 +- .../DoryMachineDiagnosticOverride.swift | 11 - .../DorydKit/DoryMachineFlightRecorder.swift | 17 +- .../DorydKit/DoryMachineStateBroker.swift | 290 + .../DoryMachineTypedWriteAuthority.swift | 5 + .../DorydKit/DoryMachineUSBControl.swift | 625 +- ...yRawHVVirtualHardwareTopologyPlanner.swift | 204 + .../DoryRendererBootstrapQualification.swift | 749 ++ .../DoryRendererCrashSuppression.swift | 319 + .../DoryRendererReleaseIdentity.swift | 178 + .../DorydKit/DoryResolvedMachinePlan.swift | 164 +- .../DoryResolvedMachinePlanRepository.swift | 235 +- .../Sources/DorydKit/DorydConfiguration.swift | 113 +- .../Sources/DorydKit/DorydControl.swift | 1 + .../Sources/DorydKit/DorydService.swift | 80 +- .../Sources/DorydKit/HvProcess.swift | 436 +- .../DorydKit/InheritedDescriptorSpawn.swift | 299 + .../Sources/DorydKit/MachineManager.swift | 3377 +++++-- .../MachineWorkspaceMutationCoordinator.swift | 83 + .../Sources/DorydKit/VmmHandoff.swift | 108 + .../Sources/dory-linux-calibration/main.swift | 133 + dory-core-swift/Sources/doryd/main.swift | 11 +- .../DoryDesktopRuntimeContractTests.swift | 4 +- .../DoryDataDriveSelectionStoreTests.swift | 3 +- .../DoryDataDriveTests.swift | 22 +- .../DoryDesktopHelperExitStatusTests.swift | 11 + .../DoryInstalledLinuxBootBundleTests.swift | 125 +- .../DoryInstallerISOTests.swift | 484 +- .../DoryTrustedDirectoryRootTests.swift | 353 + ...VirtualMachineArtifactAuthorityTests.swift | 111 +- ...oryVirtualMachineBackendPlannerTests.swift | 77 +- .../DoryVirtualMachineCapabilitiesTests.swift | 143 +- .../EngineStateDirectoryLockTests.swift | 17 +- .../RuntimeLaunchEnvelopeTests.swift | 711 ++ .../DoryCodeDirectoryHashTests.swift | 63 + ...DoryRendererProductionInventoryTests.swift | 146 + .../DependencyBoundaryTests.swift | 55 + ...rtualHardwareTopologyReconcilerTests.swift | 171 + .../RawHVVirtualHardwareTopologyTests.swift | 324 + .../USBControlV1Tests.swift | 149 + .../DorydKitTests/AgentControlTests.swift | 64 +- ...onVirtualMachineProductionTrustTests.swift | 764 +- ...onVirtualMachineRuntimePlanningTests.swift | 99 +- .../DoryDeviceTelemetryTests.swift | 207 + .../DoryLinuxVMCalibrationExecTests.swift | 112 + ...ryMachineConfigurationMigrationTests.swift | 1 + .../DoryMachineDiagnosticOverrideTests.swift | 8 +- .../DoryMachineStateBrokerTests.swift | 270 + .../DoryMachineTypedWriteAuthorityTests.swift | 39 + .../DoryMachineUSBControlTests.swift | 262 +- ...yRendererBootstrapQualificationTests.swift | 408 + .../DoryRendererCrashSuppressionTests.swift | 153 + .../DoryRendererReleaseIdentityTests.swift | 210 + .../DoryResolvedMachinePlanTests.swift | 404 +- .../Tests/DorydKitTests/DoryVMMKitTests.swift | 145 + .../DorydConfigurationTests.swift | 118 +- .../DorydKitTests/DorydServiceTests.swift | 109 +- .../DorydKitTests/HealthReporterTests.swift | 54 +- .../Tests/DorydKitTests/HvProcessTests.swift | 232 + .../InheritedDescriptorSpawnerTests.swift | 186 + .../MachineManagerLaunchAdmissionTests.swift | 356 + ...eManagerResolvedPlanIntegrationTests.swift | 720 +- .../DorydKitTests/MachineManagerTests.swift | 630 +- .../MachineManagerUSBControlTests.swift | 276 +- ...ineWorkspaceMutationCoordinatorTests.swift | 54 + ...RuntimeBootAuthorityIntegrationTests.swift | 600 ++ ...RuntimeDiskAuthorityIntegrationTests.swift | 307 + .../Tests/DorydKitTests/VmmHandoffTests.swift | 68 + dory-core/agent/src/dispatch.rs | 14 +- dory-core/agent/src/handler.rs | 47 + dory-core/agent/src/lib.rs | 1 + dory-core/agent/src/virtiofs_mount.rs | 736 ++ dory-core/ffi/src/agent_forward.rs | 41 + dory-core/pb/proto/agent.proto | 20 + dory-core/remote/src/agent_client.rs | 37 +- dory-core/runc-wrapper/src/lib.rs | 225 +- dory-core/runc-wrapper/src/main.rs | 8 +- guest/desktop/apply-update.sh | 13 +- guest/desktop/build-update-bundle.py | 12 +- guest/desktop/build.sh | 11 +- guest/desktop/input-fingerprint.sh | 1 + guest/desktop/install-graphics-pack.sh | 144 + .../etc/environment.d/60-dory-desktop.conf | 4 +- .../rootfs-overlay/etc/gdm3/custom.conf | 5 +- .../10-dory-graphics.conf | 3 + .../usr/lib/dory/configure-graphics-backend | 155 +- .../usr/lib/dory/preflight-graphics-pack | 264 + .../usr/lib/dory/resolve-graphics-backend | 32 + .../firefox-esr/distribution/policies.json | 14 - guest/desktop/verify-build.sh | 250 +- guest/kernel/build.sh | 4 +- guest/kernel/dory-gpu.fragment | 5 +- ...irtio-gpu-wait-for-scanout-producers.patch | 71 + ...-fail-closed-on-producer-fence-error.patch | 67 + guest/kernel/verify-build.sh | 4 + guest/mesa/PINS | 50 +- guest/mesa/README.md | 65 +- guest/mesa/build.sh | 289 +- guest/mesa/dory-vulkan-compositor-probe.c | 1528 +++ guest/mesa/dory-vulkan-probe.c | 1168 ++- guest/mesa/input-fingerprint.sh | 7 +- ...nus-enable-dory-implicit-fencing-wsi.patch | 210 - guest/mesa/verify-build.sh | 144 +- guest/qemu-builder/docker/meta-data | 2 + guest/qemu-builder/docker/user-data | 23 + guest/qemu-builder/probe/meta-data | 2 + guest/qemu-builder/probe/user-data | 10 + ...le-final-virtual-destructor-backport.patch | 29 + ...-shader-state-copy-contract-backport.patch | 13 + ...s1-shader-state-logical-key-backport.patch | 105 + ...-state-constructor-contract-backport.patch | 40 + .../angle-logical-state-copy-backport.patch | 211 + ...etal-blit-logical-base-copy-backport.patch | 26 + ...tal-state-cache-logical-key-backport.patch | 296 + ...pler-state-logical-equality-backport.patch | 18 + ...rlang-trivial-copy-contract-backport.patch | 52 + ...trivial-copy-implementation-backport.patch | 32 + .../angle-shift-count-overflow-backport.patch | 77 + patches/libepoxy-dory-angle-rpath.patch | 17 + patches/moltenvk-dory-native-arrays.patch | 35 +- .../moltenvk-fail-closed-robustness2.patch | 18 + patches/moltenvk-hidden-vulkan-alias.patch | 14 + ...ltenvk-hidden-vulkan-icd-entrypoints.patch | 17 + .../moltenvk-sync-fd-binary-consumption.patch | 15 + ...lrenderer-dory-submit-failure-offset.patch | 130 + ...rglrenderer-linear-modifier-contract.patch | 60 + ...irglrenderer-metal-shareable-scanout.patch | 35 + ...glrenderer-metal-shm-external-memory.patch | 236 + patches/virglrenderer-venus-only-static.patch | 1002 ++ patches/webkit-xcode26-libcpp-hardening.patch | 23 + website/public/docs/performance.md | 14 +- website/public/llms-full.txt | 21 +- 391 files changed, 117208 insertions(+), 12983 deletions(-) rename .github/scripts/{test-release-performance-gate.py => test-container-engine-performance-gate.py} (86%) create mode 100644 .github/scripts/test-graphics-backend-resolver.py create mode 100644 .github/scripts/test-graphics-pack-installer.py create mode 100755 .github/scripts/test-linux-vm-performance-evidence.py create mode 100644 .github/scripts/test-renderer-production-tuple.py create mode 100644 .github/scripts/test-renderer-release-identity.py create mode 100644 Config/DoryFSWorker-Info.plist create mode 100644 Config/DoryHVRunner-Info.plist create mode 100644 Config/DoryRendererProductionTuple.json create mode 100644 Config/DoryRendererWorker-Info.plist create mode 100644 Packages/ContainerizationEngine/DoryFSWorker.entitlements create mode 100644 Packages/ContainerizationEngine/DoryRendererWorker.entitlements create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift rename Packages/ContainerizationEngine/Sources/{DoryHV/Fuse => DoryFSWorkerContracts}/FuseProtocol.swift (97%) rename Packages/ContainerizationEngine/Sources/{DoryHV => DoryFSWorkerContracts}/LittleEndianBytes.swift (94%) create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift rename Packages/ContainerizationEngine/Sources/{DoryHV/Fuse => DoryFSWorkerServiceCore}/FuseServer.swift (62%) rename Packages/ContainerizationEngine/Sources/{DoryHV/Fuse => DoryFSWorkerServiceCore}/HostFS.swift (92%) create mode 100644 Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c create mode 100644 Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift delete mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift create mode 100644 Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c create mode 100644 Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c create mode 100644 Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift rename Packages/ContainerizationEngine/Sources/{DoryHV => dory-hv}/VirtioFSShareConfiguration.swift (61%) create mode 100644 Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseResourceQuotaTests.swift rename Packages/ContainerizationEngine/Tests/{DoryHVTests => DoryFSWorkerServiceCoreTests}/FuseServerTests.swift (84%) rename Packages/ContainerizationEngine/Tests/{DoryHVTests => DoryFSWorkerServiceCoreTests}/HostFSTests.swift (95%) create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/BoundedSerialConsolePublisherTests.swift delete mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DaxWindowTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopGenericGuestShareReadinessTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopGuestReadinessBoundaryTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopRendererWorkerLaunchTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopRootDiskAuthorityTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DesktopScanoutResourceLifetimeTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DoryFSWorkerBootstrapContractTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DoryFSWorkerBrokerTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DoryFSWorkerTestChannel.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DoryRendererWorkerBrokerTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/DoryRendererWorkerSharedMemoryTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/EngineRuntimePolicyTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/EngineVsockBridgeLifecycleTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/GuestMemoryReclaimStateTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/GuestVsockSocketBridgeLifecycleTests.swift delete mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSEventRelayTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/HostGuestVsockServiceLifecycleTests.swift delete mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/HostShareCoherenceCoordinatorTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/KernelImageSafetyTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/MachineBootPayloadTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/MachineHotPathTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/RawHVMachineRunnerTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/RawHVSerialConsoleInputTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/RawHVVirtualHardwareAttachmentPlanTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/UsbControlHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirglRendererABITests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioBalloonHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioBlkSafetyTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioInputHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioMMIOInterruptTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioMMIOSlotOwnershipTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioNetHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioRngHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioSoundHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioVsockHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueHardeningTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/VsockServiceAdmissionTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryRendererWorkerContractsTests/DoryRendererWorkerContractTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryRendererWorkerServiceCoreTests/DoryRendererWorkerServiceTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryRendererWorkerVirglBackendTests/DoryRendererProductionArtifactVerifierTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryRendererWorkerVirglBackendTests/DoryRendererWorkerVirglBackendTests.swift create mode 100644 Packages/ContainerizationEngine/Tests/DoryRendererWorkerVirglBackendTests/DoryVirglRendererEnvironmentTests.swift create mode 100644 docs/architecture-gates/virtiofs-worker-xpc.json create mode 100644 docs/architecture-gates/vulkan-13-application-readiness.md create mode 100644 docs/architecture-gates/vz-custom-virtio-gpu.json create mode 100644 docs/container-engine-performance-qualification.md create mode 100644 docs/linux-capability-and-qualification-matrix.md create mode 100644 docs/linux-iso-and-gpu-recovery-review.md create mode 100644 docs/linux-virtual-workspace-architecture.md create mode 100644 docs/linux-virtual-workspace-delivery-plan.md create mode 100644 docs/linux-vm-performance-contract.md create mode 100644 dory-core-swift/Sources/DoryOperations/DoryDesktopHelperExitStatus.swift create mode 100644 dory-core-swift/Sources/DoryOperations/DoryTrustedDirectoryRoot.swift create mode 100644 dory-core-swift/Sources/DoryOperations/RuntimeLaunchEnvelope.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererBlobMappingLease.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererCapabilityReceipt.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererProductionInventory.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererScanoutLease.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererSharedTextureScanoutLease.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererWorkerBootstrap.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererWorkerCommand.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererWorkerIdentity.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererWorkerOperationPayloads.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/DoryRendererWorkerXPC.swift create mode 100644 dory-core-swift/Sources/DoryRendererWorkerWireContracts/LittleEndianBytes.swift create mode 100644 dory-core-swift/Sources/DoryVMContracts/RawHVVirtualHardwareTopology.swift create mode 100644 dory-core-swift/Sources/DoryVMContracts/RawHVVirtualHardwareTopologyReconciler.swift create mode 100644 dory-core-swift/Sources/DoryVMContracts/USBControlV1.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonRendererProductionAuthority.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryLinuxVMCalibrationExec.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryLinuxVMCalibrationLauncher.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryLiveChildCodeIdentity.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineStateBroker.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryRawHVVirtualHardwareTopologyPlanner.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryRendererBootstrapQualification.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryRendererCrashSuppression.swift create mode 100644 dory-core-swift/Sources/DorydKit/DoryRendererReleaseIdentity.swift create mode 100644 dory-core-swift/Sources/DorydKit/InheritedDescriptorSpawn.swift create mode 100644 dory-core-swift/Sources/DorydKit/MachineWorkspaceMutationCoordinator.swift create mode 100644 dory-core-swift/Sources/dory-linux-calibration/main.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryDesktopHelperExitStatusTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryTrustedDirectoryRootTests.swift create mode 100644 dory-core-swift/Tests/DoryOperationsTests/RuntimeLaunchEnvelopeTests.swift create mode 100644 dory-core-swift/Tests/DoryRendererWorkerWireContractsTests/DoryCodeDirectoryHashTests.swift create mode 100644 dory-core-swift/Tests/DoryRendererWorkerWireContractsTests/DoryRendererProductionInventoryTests.swift create mode 100644 dory-core-swift/Tests/DoryVMContractsTests/DependencyBoundaryTests.swift create mode 100644 dory-core-swift/Tests/DoryVMContractsTests/RawHVVirtualHardwareTopologyReconcilerTests.swift create mode 100644 dory-core-swift/Tests/DoryVMContractsTests/RawHVVirtualHardwareTopologyTests.swift create mode 100644 dory-core-swift/Tests/DoryVMContractsTests/USBControlV1Tests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryLinuxVMCalibrationExecTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineStateBrokerTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryRendererBootstrapQualificationTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryRendererCrashSuppressionTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryRendererReleaseIdentityTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/InheritedDescriptorSpawnerTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerLaunchAdmissionTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineWorkspaceMutationCoordinatorTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/RuntimeBootAuthorityIntegrationTests.swift create mode 100644 dory-core-swift/Tests/DorydKitTests/RuntimeDiskAuthorityIntegrationTests.swift create mode 100644 dory-core/agent/src/virtiofs_mount.rs create mode 100755 guest/desktop/install-graphics-pack.sh create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/display-manager.service.d/10-dory-graphics.conf mode change 100644 => 100755 guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/preflight-graphics-pack create mode 100755 guest/desktop/rootfs-overlay/usr/lib/dory/resolve-graphics-backend delete mode 100644 guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json create mode 100644 guest/kernel/patches/6.12.30/0007-virtio-gpu-wait-for-scanout-producers.patch create mode 100644 guest/kernel/patches/6.12.30/0008-virtio-gpu-fail-closed-on-producer-fence-error.patch create mode 100644 guest/mesa/dory-vulkan-compositor-probe.c delete mode 100644 guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch create mode 100644 guest/qemu-builder/docker/meta-data create mode 100644 guest/qemu-builder/docker/user-data create mode 100644 guest/qemu-builder/probe/meta-data create mode 100644 guest/qemu-builder/probe/user-data create mode 100644 patches/angle-final-virtual-destructor-backport.patch create mode 100644 patches/angle-gles1-shader-state-copy-contract-backport.patch create mode 100644 patches/angle-gles1-shader-state-logical-key-backport.patch create mode 100644 patches/angle-logical-state-constructor-contract-backport.patch create mode 100644 patches/angle-logical-state-copy-backport.patch create mode 100644 patches/angle-metal-blit-logical-base-copy-backport.patch create mode 100644 patches/angle-metal-state-cache-logical-key-backport.patch create mode 100644 patches/angle-sampler-state-logical-equality-backport.patch create mode 100644 patches/angle-shaderlang-trivial-copy-contract-backport.patch create mode 100644 patches/angle-shaderlang-trivial-copy-implementation-backport.patch create mode 100644 patches/angle-shift-count-overflow-backport.patch create mode 100644 patches/libepoxy-dory-angle-rpath.patch create mode 100644 patches/moltenvk-fail-closed-robustness2.patch create mode 100644 patches/moltenvk-hidden-vulkan-alias.patch create mode 100644 patches/moltenvk-hidden-vulkan-icd-entrypoints.patch create mode 100644 patches/moltenvk-sync-fd-binary-consumption.patch create mode 100644 patches/virglrenderer-dory-submit-failure-offset.patch create mode 100644 patches/virglrenderer-linear-modifier-contract.patch create mode 100644 patches/virglrenderer-metal-shareable-scanout.patch create mode 100644 patches/virglrenderer-metal-shm-external-memory.patch create mode 100644 patches/virglrenderer-venus-only-static.patch create mode 100644 patches/webkit-xcode26-libcpp-hardening.patch diff --git a/.github/scripts/test-release-performance-gate.py b/.github/scripts/test-container-engine-performance-gate.py similarity index 86% rename from .github/scripts/test-release-performance-gate.py rename to .github/scripts/test-container-engine-performance-gate.py index 611545e1..6baff1ba 100755 --- a/.github/scripts/test-release-performance-gate.py +++ b/.github/scripts/test-container-engine-performance-gate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Non-mutating arming tests for exact-candidate performance qualification.""" +"""Non-mutating arming tests for exact-candidate container-engine performance qualification.""" from __future__ import annotations @@ -11,10 +11,10 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] -GATE = ROOT / "scripts" / "qualify-release-performance.sh" +GATE = ROOT / "scripts" / "qualify-container-engine-performance.sh" -class ReleasePerformanceGateTests(unittest.TestCase): +class ContainerEnginePerformanceGateTests(unittest.TestCase): def invoke( self, candidate: pathlib.Path, @@ -82,15 +82,19 @@ def test_source_is_shell_valid_and_candidate_bound(self) -> None: 'LIMA_COLIMA_STATE="$HOME/.lima/colima"', "host rebooted during the performance campaign", "engine_state_removed=PASS", + "docs/container-engine-performance-qualification.md", + "dev.dory.container-engine-performance-qualification", + "cannot authorize Linux VM support or", ): self.assertIn(contract, source) + self.assertNotIn("dev.dory.linux-vm-performance-evidence", source) def test_explicit_clean_account_arming_precedes_host_or_filesystem_mutation(self) -> None: with tempfile.TemporaryDirectory(prefix="dory-performance-gate-test.") as raw: temporary = pathlib.Path(raw).resolve() candidate = temporary / "candidate" candidate.mkdir() - workroot = temporary / "dory-release-performance" + workroot = temporary / "dory-container-engine-performance" no_confirmation = self.invoke( candidate, workroot, temporary, confirmation="wrong" ) @@ -113,7 +117,7 @@ def test_candidate_and_workroot_authorities_reject_indirection_or_overlap(self) candidate_link.symlink_to(candidate, target_is_directory=True) indirect = self.invoke( candidate_link, - temporary / "dory-release-performance", + temporary / "dory-container-engine-performance", temporary, ) wrong_name = self.invoke( @@ -121,12 +125,14 @@ def test_candidate_and_workroot_authorities_reject_indirection_or_overlap(self) temporary / "performance-output", temporary, ) - parent = temporary / "dory-release-performance" + parent = temporary / "dory-container-engine-performance" nested_candidate = parent / "candidate" nested_candidate.mkdir(parents=True) overlap = self.invoke(nested_candidate, parent, temporary) self.assertIn("candidate directory must be direct", indirect.stdout) - self.assertIn("dedicated dory-release-performance name", wrong_name.stdout) + self.assertIn( + "dedicated dory-container-engine-performance name", wrong_name.stdout + ) self.assertIn("workroot cannot contain the candidate", overlap.stdout) diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py index 15219f0d..a449a5f9 100644 --- a/.github/scripts/test-desktop-linux-live-gate.py +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -3,7 +3,6 @@ from __future__ import annotations -import hashlib import os import pathlib import subprocess @@ -16,33 +15,98 @@ class DesktopLinuxLiveGateTests(unittest.TestCase): - def test_shell_contract_uses_signed_typed_venus_path(self) -> None: + def test_shell_contract_separates_desktop_recovery_from_venus_qualification(self) -> None: subprocess.run(["bash", "-n", str(GATE)], check=True) text = GATE.read_text(encoding="utf-8") required = ( + "scope=managed-rootfs-only", + "generic_arm64_efi_iso_software_baseline=SEPARATE-GATE", "--component-dir", "component install-candidate", "component verify all --offline --json", "component path linux-desktop dory-desktop-kernel-arm64.lzfse", 'cmp -s "$KERNEL" "$installed_kernel"', "--guest-user dorygate --guest-uid 1550", - '--desktop-distro "$distro" --runtime accelerated --graphics virgl-venus', + '--desktop-distro "$distro" --runtime accelerated --graphics "$graphics_preference"', + "graphics_preference=virgl-venus", + "--require-acceleration", + "--require-release-signature", + "verify-renderer-bootstrap-qualification.py", "--clipboard bidirectional", - "--resolved-graphics hardware-accelerated-3d", + "--resolved-graphics (hardware-accelerated-3d|software)", + "/run/dory/graphics-requested-backend", "virgl2+venus", + "virgl2)", + "software:software)", + "software-ready", + "^venus-unavailable:", + "fallback=virgl2$", "venus-ready:", + "managed_desktop_baseline=PASS", + "GSK_RENDERER=gl", + "MOZ_ENABLE_WAYLAND=0", + "XDG_SESSION_TYPE=x11", + 'run_desktop ubuntu "$UBUNTU_ROOTFS" gdm3 gnome-shell firefox', + "contract=vulkan-1.3-application", + "hardware-device=yes", + "dynamic-rendering=yes", + "synchronization2=yes", + "maintenance4=yes", + "color-atlas-texture-binding=yes", + "color-atlas-copy-dst=yes", '--distribution-installation "$distribution_installation"', '--runtime-installation "$runtime_installation"', '"provenance": "verified-update-bundle"', "stale-update-rejected", "--zed-archive", + 'applicationReadiness"]["applicationTag', + '"v$ZED_VERSION" = "$TUPLE_ZED_TAG"', "zed-native-venus", "/zed.app/libexec/zed-editor", + "for candidate_pid in", + "*crash-handler*) continue", + "^LD_LIBRARY_PATH=.", "zed --version", - "zed_native_venus=NOT-RUN", + "--foreground", + "tr -cs '0-9.'", + "ZED_RESULT=UNAVAILABLE", + "ZED_RESULT=PASS", + "ZED_RESULT=FAIL", + "strict acceleration requires native Ubuntu Venus/Zed PASS", + "zed_native_venus=%s", + "mesa_virgl_desktop=%s", + "renderer_release_signature=%s", + "glxinfo -B", + "glxgears -info", + "OpenGL renderer string:.*virgl", + "llvmpipe|softpipe|swrast|software rasterizer", + "mesa-virgl-desktop", "VK_DRIVER_FILES", - "LD_LIBRARY_PATH", + "VK_ICD_FILENAMES", + "external-sync-fd=yes", + "import-signaled-fd=yes", + "export-sync-fd=yes", + "queue-submit2=yes", + "fence-signal=yes", + "dory-vulkan-probe --wsi=xcb", + "wsi-surface=xcb", + "surface-create=yes", + "present-queue=yes", + "surface-format-policy=first-capability-format", + "surface-format-id=[1-9][0-9]*", + "color-atlas-format=(bgra8|rgba8)-unorm", + "fifo-present=yes", + "swapchain-create=yes", + "swapchain-extent=64x64", + "swapchain-images=[1-9][0-9]*", + "swapchain-acquire=yes", + "swapchain-render=yes", + "queue-present=yes", + "present-idle=yes", + '/opt/dory/mesa/lib/libvulkan_virtio.so\' \\"/proc/\\$zed_pid/maps\\"', + "sleep 30", + "VK_ERROR_DEVICE_LOST", "-u ZED_ALLOW_EMULATED_GPU", 'machine snapshot "$machine"', 'machine restore-snapshot "$machine"', @@ -105,25 +169,47 @@ def test_shell_contract_uses_signed_typed_venus_path(self) -> None: "assert ", "= virgl2\n", "rollback-pass", + 'LD_LIBRARY_PATH="\\$library_path"', + "venus_implicit_fencing=true", + 'venus_implicit_fencing="\\$fencing"', + "dory-vulkan-probe --wsi=wayland", + "wsi-surface=\\$wsi", + "WAYLAND_DISPLAY", + "XDG_SESSION_TYPE=wayland", + "ZED_EXPECTED", + "ZED_QUALIFIED", + "pgrep -n -u dorygate -f '/zed.app/libexec/zed-editor'", + "grep -q '^LD_LIBRARY_PATH='", ) for stale in forbidden: self.assertNotIn(stale, text, stale) - def test_workroot_must_be_inside_runner_temp(self) -> None: + def test_managed_gate_does_not_claim_generic_iso_qualification(self) -> None: + text = GATE.read_text(encoding="utf-8") + self.assertIn("ARM64 EFI ISO installation", text) + self.assertIn("separate end-to-end gate", text) + self.assertIn("generic_arm64_efi_iso_software_baseline=SEPARATE-GATE", text) + self.assertNotIn("--installer-iso", text) + + def test_non_ubuntu_workroot_check_does_not_require_zed_assets(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) - runner = root / "runner" + runner_temp = root / "runner" helpers = root / "helpers" components = root / "components" outside = root / "outside" - for directory in (runner, helpers, components): + for directory in (runner_temp, helpers, components): directory.mkdir() - for helper in ("dorydctl", "dory-hv", "dory-vmm"): + for helper in ("dorydctl", "dory-vmm"): path = helpers / helper path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") path.chmod(0o700) + hv_runner = helpers / "DoryHVRunner.app" / "Contents" / "MacOS" / "dory-hv" + hv_runner.parent.mkdir(parents=True) + hv_runner.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + hv_runner.chmod(0o700) assets = [] - for name in ("Image-desktop", "ubuntu.ext4", "ubuntu-update.tar"): + for name in ("Image-desktop", "debian.ext4", "debian-update.tar"): path = root / name path.write_bytes(name.encode("ascii")) assets.append(path) @@ -137,18 +223,12 @@ def test_workroot_must_be_inside_runner_temp(self) -> None: str(components), "--kernel", str(assets[0]), - "--ubuntu-rootfs", + "--debian-rootfs", str(assets[1]), - "--ubuntu-update", - str(assets[2]), - "--zed-archive", + "--debian-update", str(assets[2]), - "--zed-version", - "1.16.1", - "--zed-sha256", - hashlib.sha256(assets[2].read_bytes()).hexdigest(), "--distro", - "ubuntu", + "debian", "--version", "9.8.7", "--workroot", @@ -157,7 +237,7 @@ def test_workroot_must_be_inside_runner_temp(self) -> None: "EXACT-CANDIDATE-DESKTOPS", ], cwd=ROOT, - env={**os.environ, "RUNNER_TEMP": str(runner)}, + env={**os.environ, "RUNNER_TEMP": str(runner_temp)}, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -166,6 +246,7 @@ def test_workroot_must_be_inside_runner_temp(self) -> None: self.assertEqual(result.returncode, 64, result.stderr) self.assertIn("workroot must be a strict child of RUNNER_TEMP", result.stderr) + self.assertNotIn("Zed", result.stderr) self.assertFalse(outside.exists()) diff --git a/.github/scripts/test-graphics-backend-resolver.py b/.github/scripts/test-graphics-backend-resolver.py new file mode 100644 index 00000000..53216784 --- /dev/null +++ b/.github/scripts/test-graphics-backend-resolver.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import shlex +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RESOLVER = ROOT / "guest/desktop/rootfs-overlay/usr/lib/dory/resolve-graphics-backend" +ACTIVATOR = ROOT / "guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend" +DISPLAY_MANAGER_DROP_IN = ( + ROOT + / "guest/desktop/rootfs-overlay/etc/systemd/system/display-manager.service.d" + / "10-dory-graphics.conf" +) + + +class GraphicsBackendResolverTests(unittest.TestCase): + def resolve(self, command_line: str, expected: str) -> None: + result = subprocess.run( + [str(RESOLVER), command_line], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + self.assertEqual(result.stdout, f"{expected}\n") + self.assertEqual(result.stderr, "") + + def reject(self, command_line: str, message: str) -> None: + result = subprocess.run( + [str(RESOLVER), command_line], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + + def test_resolves_default_versioned_and_legacy_contracts(self) -> None: + self.resolve("quiet root=/dev/vda", "software") + self.resolve("quiet dory.graphics=software", "software") + self.resolve("dory.graphics=virgl", "virgl2") + self.resolve("dory.graphics=virgl-venus", "virgl2+venus") + self.resolve("quiet dory.gpu=venus", "virgl2+venus") + + def test_rejects_duplicate_conflicting_and_unknown_authority(self) -> None: + self.reject( + "dory.graphics=virgl dory.graphics=virgl", + "multiple dory.graphics authorities", + ) + self.reject( + "dory.graphics=virgl-venus dory.gpu=venus", + "versioned and legacy graphics authorities conflict", + ) + self.reject("dory.graphics=future", "unsupported graphics contract") + self.reject("dory.gpu=software", "unsupported legacy graphics contract") + + +class GraphicsBackendActivationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temporary.name) + self.environment = self.root / "etc/environment.d/70-dory-graphics.conf" + self.session = self.root / "etc/X11/Xsession.d/70dory-graphics" + self.requested = self.root / "run/dory/graphics-requested-backend" + self.effective = self.root / "run/dory/graphics-backend" + self.status = self.root / "run/dory/graphics-status" + self.icd = self.root / "opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json" + self.preflight = self.root / "usr/lib/dory/preflight-graphics-pack" + self.cmdline = self.root / "proc/cmdline" + + rewritten = ACTIVATOR.read_text(encoding="utf-8") + assignments = { + "environment_file=/etc/environment.d/70-dory-graphics.conf": + f"environment_file={shlex.quote(str(self.environment))}", + "session_environment=/etc/X11/Xsession.d/70dory-graphics": + f"session_environment={shlex.quote(str(self.session))}", + "requested_file=/run/dory/graphics-requested-backend": + f"requested_file={shlex.quote(str(self.requested))}", + "effective_file=/run/dory/graphics-backend": + f"effective_file={shlex.quote(str(self.effective))}", + "graphics_status=/run/dory/graphics-status": + f"graphics_status={shlex.quote(str(self.status))}", + "venus_icd=/opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json": + f"venus_icd={shlex.quote(str(self.icd))}", + "venus_preflight=/usr/lib/dory/preflight-graphics-pack": + f"venus_preflight={shlex.quote(str(self.preflight))}", + "backend_resolver=/usr/lib/dory/resolve-graphics-backend": + f"backend_resolver={shlex.quote(str(RESOLVER))}", + "kernel_cmdline_file=/proc/cmdline": + f"kernel_cmdline_file={shlex.quote(str(self.cmdline))}", + } + for original, replacement in assignments.items(): + self.assertEqual(rewritten.count(original), 1, original) + rewritten = rewritten.replace(original, replacement) + + self.script = self.root / "configure-graphics-backend" + self.script.write_text(rewritten, encoding="utf-8") + self.script.chmod(0o700) + self.icd.parent.mkdir(parents=True) + self.icd.write_text("{}\n", encoding="utf-8") + self.cmdline.parent.mkdir(parents=True) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def configure_preflight(self, exit_code: int) -> None: + self.preflight.parent.mkdir(parents=True, exist_ok=True) + self.preflight.write_text( + "#!/bin/sh\n" + "printf '%s\\n' 'driver=venus external-sync-fd=yes " + "import-signaled-fd=yes queue-submit2=yes fence-signal=yes'\n" + f"exit {exit_code}\n", + encoding="utf-8", + ) + self.preflight.chmod(0o700) + + def activate(self, command_line: str) -> subprocess.CompletedProcess[str]: + self.cmdline.write_text(f"{command_line}\n", encoding="utf-8") + return subprocess.run( + [str(self.script)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def test_venus_publishes_requested_and_effective_state_after_preflight(self) -> None: + self.configure_preflight(0) + result = self.activate("quiet dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2+venus\n") + self.assertIn("venus-ready: driver=venus", self.status.read_text()) + expected_icd = str(self.icd) + self.assertEqual( + self.environment.read_text().splitlines()[1:], + [ + "GSK_RENDERER=gl", + f"VK_DRIVER_FILES={expected_icd}", + f"VK_ICD_FILENAMES={expected_icd}", + ], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + [ + "export GSK_RENDERER=gl", + f"export VK_DRIVER_FILES={expected_icd}", + f"export VK_ICD_FILENAMES={expected_icd}", + ], + ) + + def test_failed_preflight_falls_back_to_virgl_without_blocking_desktop(self) -> None: + self.configure_preflight(23) + for path in (self.environment, self.session, self.effective): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("stale\n", encoding="utf-8") + result = self.activate("dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2\n") + self.assertEqual( + self.environment.read_text().splitlines()[1:], + ["GSK_RENDERER=gl"], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + ["export GSK_RENDERER=gl"], + ) + self.assertIn("venus-unavailable:", self.status.read_text()) + self.assertIn("fallback=virgl2", self.status.read_text()) + + def test_missing_venus_pack_falls_back_to_virgl(self) -> None: + result = self.activate("dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2\n") + self.assertIn("preflight is missing", self.status.read_text()) + + def test_software_activation_keeps_managed_gtk_policy_without_venus(self) -> None: + self.configure_preflight(0) + result = self.activate("quiet dory.graphics=software") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "software\n") + self.assertEqual(self.effective.read_text(), "software\n") + self.assertEqual(self.status.read_text(), "software-ready\n") + self.assertEqual( + self.environment.read_text().splitlines()[1:], + ["GSK_RENDERER=gl"], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + ["export GSK_RENDERER=gl"], + ) + + def test_conflicting_authority_has_no_requested_or_effective_state(self) -> None: + self.configure_preflight(0) + result = self.activate("dory.graphics=virgl-venus dory.gpu=venus") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.requested.exists()) + self.assertFalse(self.effective.exists()) + self.assertIn("authorities conflict", self.status.read_text()) + + def test_display_manager_orders_after_graphics_without_requiring_it(self) -> None: + drop_in = DISPLAY_MANAGER_DROP_IN.read_text(encoding="utf-8") + self.assertIn("Wants=dory-graphics-backend.service", drop_in) + self.assertIn("After=dory-graphics-backend.service", drop_in) + self.assertNotIn("Requires=dory-graphics-backend.service", drop_in) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-graphics-pack-installer.py b/.github/scripts/test-graphics-pack-installer.py new file mode 100644 index 00000000..5e8d7dd1 --- /dev/null +++ b/.github/scripts/test-graphics-pack-installer.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import pathlib +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +INSTALLER = ROOT / "guest/desktop/install-graphics-pack.sh" +EXPECTED_FILES = { + "lib/libvulkan_virtio.so": b"candidate-icd", + "libexec/dory-vulkan-compositor-probe": b"candidate-compositor-probe", + "libexec/dory-vulkan-probe": b"candidate-probe", + "share/dory/build-packages.txt": b"fixture=1\n", + "share/dory/runtime.env": b"schema=6\npack_layout=single-tree\n", + "share/vulkan/icd.d/virtio_icd.aarch64.json": b"{}\n", +} + + +def archive( + root: pathlib.Path, + name: str, + *, + extra: str | None = None, + symlink: bool = False, + runtime_manifest: bytes | None = None, +) -> pathlib.Path: + source = root / f"{name}-source" / "opt/dory/mesa" + for relative, contents in EXPECTED_FILES.items(): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + if relative == "share/dory/runtime.env" and runtime_manifest is not None: + contents = runtime_manifest + path.write_bytes(contents) + path.chmod(0o755 if relative.startswith("libexec/") else 0o644) + if extra: + path = source / extra + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"stale") + path.chmod(0o644) + if symlink: + (source / "lib/escape.so").symlink_to("/tmp/escape") + tar_path = root / f"{name}.tar" + with tarfile.open(tar_path, "w", format=tarfile.PAX_FORMAT) as output: + output.add(source.parents[1], arcname="./opt", recursive=True) + compressed = root / f"{name}.tar.zst" + subprocess.run( + ["zstd", "-q", "-f", str(tar_path), "-o", str(compressed)], + check=True, + ) + return compressed + + +class GraphicsPackInstallerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="dory-graphics-pack-test-") + self.root = pathlib.Path(self.temporary.name) + self.target = self.root / "target" + self.target.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def install(self, candidate: pathlib.Path, *, succeeds: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [str(INSTALLER), str(candidate), str(self.target), str(os.getuid())], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if succeeds and result.returncode != 0: + self.fail(result.stderr) + if not succeeds and result.returncode == 0: + self.fail("invalid graphics pack unexpectedly installed") + return result + + def test_old_schema_tree_is_replaced_without_stale_dso(self) -> None: + old = self.target / "opt/dory/mesa" + (old / "lib").mkdir(parents=True) + (old / "lib/libxcb-keysyms.so.1.0.0").write_bytes(b"old") + (old / "share/dory").mkdir(parents=True) + (old / "share/dory/runtime.env").write_text("schema=2\n", encoding="utf-8") + + self.install(archive(self.root, "candidate")) + + installed = self.target / "opt/dory/mesa" + actual = { + path.relative_to(installed).as_posix(): path.read_bytes() + for path in installed.rglob("*") + if path.is_file() + } + self.assertEqual(actual, EXPECTED_FILES) + self.assertFalse((self.target / "opt/dory/.mesa-update-transaction").exists()) + + def test_extra_file_and_symlink_are_rejected_without_replacing_old_tree(self) -> None: + old = self.target / "opt/dory/mesa" + old.mkdir(parents=True) + sentinel = old / "sentinel" + sentinel.write_bytes(b"old") + + self.install(archive(self.root, "extra", extra="lib/libxcb-keysyms.so.1"), succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + self.install(archive(self.root, "symlink", symlink=True), succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + + def test_schema_four_candidate_is_rejected_without_replacing_old_tree(self) -> None: + old = self.target / "opt/dory/mesa" + old.mkdir(parents=True) + sentinel = old / "sentinel" + sentinel.write_bytes(b"old") + + candidate = archive( + self.root, + "schema-four", + runtime_manifest=b"schema=4\npack_layout=single-tree\n", + ) + self.install(candidate, succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + + def test_interrupted_old_tree_rename_is_recovered_before_replacement(self) -> None: + transaction = self.target / "opt/dory/.mesa-update-transaction" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "old-sentinel").write_bytes(b"old") + (transaction / "owner").write_text("pid=999999999\n", encoding="utf-8") + + self.install(archive(self.root, "recovery")) + + self.assertFalse(transaction.exists()) + self.assertFalse((self.target / "opt/dory/mesa/old-sentinel").exists()) + self.assertEqual( + (self.target / "opt/dory/mesa/lib/libvulkan_virtio.so").read_bytes(), + b"candidate-icd", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-linux-vm-performance-evidence.py b/.github/scripts/test-linux-vm-performance-evidence.py new file mode 100755 index 00000000..edc498db --- /dev/null +++ b/.github/scripts/test-linux-vm-performance-evidence.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).resolve().parents[2] +TOOL = REPO / "scripts" / "validate-linux-vm-performance-evidence.py" +MATRIX_CELL = "a" * 64 + + +def canonical(value: object) -> bytes: + return ( + json.dumps( + value, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + + +def digest(character: str) -> str: + return character * 64 + + +def frozen_budget( + *, quality: str = "software", budget_id: str = "ux.boot.login-ready.duration" +) -> dict: + return { + "applicability": { + "architecture": "arm64", + "graphicsQuality": quality, + "matrixCellIDs": [MATRIX_CELL], + "qualificationModes": ["release"], + }, + "approval": { + "approvedAt": "2026-08-23T12:34:56Z", + "owner": "Dory performance", + "recordSHA256": digest("b"), + "reviewer": "Dory release", + }, + "baselineEvidenceSHA256": digest("c"), + "direction": "atMost", + "id": budget_id, + "lowerBound": None, + "metricDefinitionRevision": 1, + "rationale": "Synthetic validator fixture; not a product performance threshold.", + "state": "frozen", + "statistic": "p95", + "unit": "milliseconds", + "upperBound": 42.5, + } + + +def frozen_budget_set( + *, quality: str = "software", budget_id: str = "ux.boot.login-ready.duration" +) -> dict: + return { + "architecture": "arm64", + "budgetSetID": "linux-vm-release", + "budgets": [frozen_budget(quality=quality, budget_id=budget_id)], + "kind": "dev.dory.linux-vm-performance-budget-set", + "revision": 1, + "schemaVersion": 1, + "state": "frozen", + } + + +def provisional_budget_set() -> dict: + budget = frozen_budget() + budget.update( + { + "approval": None, + "baselineEvidenceSHA256": None, + "lowerBound": None, + "state": "provisional", + "upperBound": None, + } + ) + budget["applicability"]["qualificationModes"] = ["calibration"] + return { + "architecture": "arm64", + "budgetSetID": "linux-vm-calibration", + "budgets": [budget], + "kind": "dev.dory.linux-vm-performance-budget-set", + "revision": 1, + "schemaVersion": 1, + "state": "provisional", + } + + +def evidence_manifest(*, mode: str = "release", verdict: str = "qualified") -> dict: + return { + "campaign": { + "clockCalibration": "evidence/clocks.json", + "definitionID": "linux-desktop-interactive", + "definitionRevision": 1, + "harnessSHA256": digest("d"), + "matrixCellID": MATRIX_CELL, + "matrixCellDescriptor": "evidence/matrix-cell.json", + "samplingPlan": "evidence/sampling-plan.json", + "workloadSHA256": digest("e"), + }, + "candidate": { + "applicationSHA256": digest("f"), + "budgetSetSHA256": digest("1"), + "codeSignatureEvidence": "evidence/candidate-signatures.json", + "componentCandidateInventorySHA256": digest("3"), + "runtimePlanSHA256": digest("4"), + "sbomSHA256": digest("2"), + "virtualHardwareABIVersion": "arm64-v1", + }, + "fallbacks": "summary/fallbacks.json", + "guest": { + "architecture": "arm64", + "guestToolsSHA256": digest("5"), + "initrdSHA256": None, + "installedSystemIdentity": "evidence/guest-system.json", + "installerSHA256": digest("6"), + "installerSignatureEvidence": "evidence/installer-signature.json", + "kernelSHA256": digest("7"), + "mesaAndRendererClientIdentity": "evidence/guest-graphics.json", + }, + "host": { + "architecture": "arm64", + "displayTopology": "evidence/host-display.json", + "identity": "evidence/host-identity.json", + "noiseControls": "evidence/noise-controls.json", + "powerAndThermalState": "evidence/host-state.json", + "storageTopology": "evidence/host-storage.json", + }, + "kind": "dev.dory.linux-vm-performance-evidence", + "launch": { + "backend": "vz", + "devices": "evidence/devices.json", + "graphics": { + "accelerationEvidence": None, + "accelerationEvidenceSHA256": None, + "fallback": False, + "fallbackReason": None, + "implementation": "software", + "requestedQuality": "software", + "selectedQuality": "software", + "selectionReceiptSHA256": digest("8"), + }, + "graphicsSelectionReceipt": "evidence/graphics-selection.json", + "operationID": "12345678-1234-4abc-8def-1234567890ab", + "planGeneration": 1, + "resources": "evidence/resources.json", + }, + "observations": "raw/observations.jsonl", + "qualificationMode": mode, + "schemaVersion": 1, + "signature": "signatures/evidence-bundle.sig", + "summaries": "summary/metrics.json", + "unavailableEvidence": "summary/unavailable.json", + "verdict": verdict, + } + + +class LinuxVMPerformanceEvidenceTests(unittest.TestCase): + def invoke( + self, + evidence: dict, + budget_set: dict, + *, + success: bool, + rebind: bool = True, + evidence_raw: bytes | None = None, + budget_raw: bytes | None = None, + ) -> subprocess.CompletedProcess[str]: + evidence = copy.deepcopy(evidence) + budget_set = copy.deepcopy(budget_set) + budget_bytes = budget_raw if budget_raw is not None else canonical(budget_set) + if rebind: + evidence["candidate"]["budgetSetSHA256"] = hashlib.sha256( + budget_bytes + ).hexdigest() + evidence_bytes = ( + evidence_raw if evidence_raw is not None else canonical(evidence) + ) + with tempfile.TemporaryDirectory( + prefix="dory-linux-vm-performance." + ) as temporary: + root = pathlib.Path(temporary).resolve() + evidence_path = root / "evidence.json" + budget_path = root / "budget-set.json" + evidence_path.write_bytes(evidence_bytes) + budget_path.write_bytes(budget_bytes) + result = subprocess.run( + [ + "python3", + os.fspath(TOOL), + "--evidence", + os.fspath(evidence_path), + "--budget-set", + os.fspath(budget_path), + ], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if success and result.returncode != 0: + self.fail(f"validator rejected fixture: {result.stderr}") + if not success and result.returncode == 0: + self.fail("validator accepted adversarial fixture") + return result + + def rejected( + self, evidence: dict, budget_set: dict, expected: str, **kwargs: object + ) -> None: + result = self.invoke(evidence, budget_set, success=False, **kwargs) + self.assertIn(expected, result.stderr) + + def test_accepts_canonical_qualified_and_calibration_documents(self) -> None: + release = self.invoke(evidence_manifest(), frozen_budget_set(), success=True) + self.assertIn("structure: PASS", release.stdout) + self.assertRegex(release.stdout, r"budget-set\.sha256=[0-9a-f]{64}") + + calibration = evidence_manifest( + mode="calibration", verdict="not-release-qualifying" + ) + self.invoke(calibration, provisional_budget_set(), success=True) + + def test_rejects_duplicate_keys_and_noncanonical_or_nonfinite_json(self) -> None: + evidence = evidence_manifest() + budgets = frozen_budget_set() + duplicate_evidence = b'{"kind":"duplicate",' + canonical(evidence)[1:] + self.rejected( + evidence, + budgets, + "repeats key 'kind'", + evidence_raw=duplicate_evidence, + ) + + duplicate_budget = b'{"kind":"duplicate",' + canonical(budgets)[1:] + self.rejected( + evidence, + budgets, + "repeats key 'kind'", + budget_raw=duplicate_budget, + ) + + pretty = (json.dumps(evidence, indent=2, sort_keys=True) + "\n").encode() + self.rejected(evidence, budgets, "must be canonical JSON", evidence_raw=pretty) + + nonfinite = canonical(budgets).replace( + b'"upperBound":42.5', b'"upperBound":NaN' + ) + self.rejected(evidence, budgets, "non-finite number", budget_raw=nonfinite) + + def test_schema_kind_mode_verdict_and_shapes_fail_closed(self) -> None: + mutations = [ + ( + lambda value: value.update(schemaVersion=2), + "evidence schema is unsupported", + ), + ( + lambda value: value.update(kind="dev.dory.other"), + "evidence kind is unsupported", + ), + ( + lambda value: value.update(qualificationMode="diagnostic"), + "qualification mode is unsupported", + ), + (lambda value: value.update(verdict="PASS"), "verdict contradicts"), + (lambda value: value.update(unexpected=True), "extra=['unexpected']"), + ( + lambda value: value["candidate"].pop("runtimePlanSHA256"), + "missing=['runtimePlanSHA256']", + ), + ( + lambda value: value["launch"]["graphics"].update(unknown=True), + "extra=['unknown']", + ), + ] + for mutate, expected in mutations: + with self.subTest(expected=expected): + evidence = evidence_manifest() + mutate(evidence) + self.rejected(evidence, frozen_budget_set(), expected) + + calibration = evidence_manifest(mode="calibration", verdict="qualified") + self.rejected(calibration, provisional_budget_set(), "verdict contradicts") + + budget = frozen_budget_set() + budget["schemaVersion"] = True + self.rejected(evidence_manifest(), budget, "budget set schema is unsupported") + budget = frozen_budget_set() + budget["kind"] = "dev.dory.other" + self.rejected(evidence_manifest(), budget, "budget set kind is unsupported") + budget = frozen_budget_set() + budget["unknown"] = True + self.rejected(evidence_manifest(), budget, "extra=['unknown']") + budget = frozen_budget_set() + budget["budgets"][0]["direction"] = "faster" + self.rejected(evidence_manifest(), budget, "direction is unsupported") + budget = frozen_budget_set() + budget["budgets"][0]["approval"]["unknown"] = True + self.rejected(evidence_manifest(), budget, "extra=['unknown']") + + def test_candidate_plan_operation_and_budget_bindings_are_exact(self) -> None: + cases = [ + ( + "candidate", + "componentCandidateInventorySHA256", + digest("A"), + "lowercase SHA-256", + ), + ("candidate", "runtimePlanSHA256", "0" * 64, "all-zero identity"), + ("launch", "operationID", "not-a-uuid", "canonical UUID"), + ("launch", "planGeneration", True, "positive integer"), + ("launch", "planGeneration", 0, "positive integer"), + ] + for owner, key, replacement, expected in cases: + with self.subTest(owner=owner, key=key): + evidence = evidence_manifest() + evidence[owner][key] = replacement + self.rejected(evidence, frozen_budget_set(), expected) + + evidence = evidence_manifest() + evidence["candidate"]["budgetSetSHA256"] = digest("9") + self.rejected(evidence, frozen_budget_set(), "does not bind", rebind=False) + + def test_native_arm64_classification_is_mandatory(self) -> None: + evidence = evidence_manifest() + evidence["guest"]["architecture"] = "x86_64" + self.rejected(evidence, frozen_budget_set(), "native arm64") + + evidence = evidence_manifest() + evidence["host"]["architecture"] = "x86_64" + self.rejected(evidence, frozen_budget_set(), "host architecture must be arm64") + + budgets = frozen_budget_set() + budgets["architecture"] = "x86_64" + self.rejected( + evidence_manifest(), budgets, "budget set architecture must be arm64" + ) + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["architecture"] = "x86_64" + self.rejected(evidence_manifest(), budgets, "architecture must be arm64") + + def test_bundle_references_cannot_escape_alias_or_change_role(self) -> None: + paths = [ + ("../raw/observations.jsonl", "forbidden path component"), + ("/raw/observations.jsonl", "canonical relative POSIX path"), + ("raw\\observations.jsonl", "POSIX separators"), + ("summary/observations.jsonl", "inside raw/"), + ("raw/observations.json", "must end in .jsonl"), + ] + for replacement, expected in paths: + with self.subTest(path=replacement): + evidence = evidence_manifest() + evidence["observations"] = replacement + self.rejected(evidence, frozen_budget_set(), expected) + + evidence = evidence_manifest() + evidence["summaries"] = evidence["fallbacks"] + self.rejected(evidence, frozen_budget_set(), "references must be unique") + + evidence = evidence_manifest() + evidence["signature"] = "evidence/signature.sig" + self.rejected(evidence, frozen_budget_set(), "inside signatures/") + + def test_provisional_or_unapproved_bounds_cannot_qualify(self) -> None: + self.rejected( + evidence_manifest(), + provisional_budget_set(), + "requires a frozen budget set", + ) + + budgets = frozen_budget_set() + budgets["budgets"][0].update( + approval=None, + baselineEvidenceSHA256=None, + lowerBound=None, + state="provisional", + upperBound=None, + ) + self.rejected(evidence_manifest(), budgets, "contains a provisional budget") + + for key, replacement, expected in ( + ("baselineEvidenceSHA256", None, "lowercase SHA-256"), + ("approval", None, "must be an object"), + ("upperBound", None, "must be a JSON number"), + ): + with self.subTest(key=key): + budgets = frozen_budget_set() + budgets["budgets"][0][key] = replacement + self.rejected(evidence_manifest(), budgets, expected) + + budgets = frozen_budget_set() + record = budgets["budgets"][0] + record.update(direction="range", lowerBound=10.0, upperBound=5.0) + self.rejected(evidence_manifest(), budgets, "range bounds are reversed") + + def test_budget_identifiers_and_applicability_are_unambiguous(self) -> None: + budgets = frozen_budget_set() + duplicate = copy.deepcopy(budgets["budgets"][0]) + budgets["budgets"].append(duplicate) + self.rejected(evidence_manifest(), budgets, "sorted and unique") + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["matrixCellIDs"] = [ + digest("b"), + MATRIX_CELL, + ] + self.rejected( + evidence_manifest(), budgets, "matrixCellIDs must be sorted and unique" + ) + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["matrixCellIDs"] = [digest("9")] + self.rejected(evidence_manifest(), budgets, "no applicable frozen budget") + + budgets = frozen_budget_set( + quality="software", budget_id="gpu.accelerated.frame-time" + ) + self.rejected( + evidence_manifest(), budgets, "accelerated GPU metric is misclassified" + ) + + def test_graphics_selection_fallback_and_acceleration_are_not_interchangeable( + self, + ) -> None: + evidence = evidence_manifest() + evidence["launch"]["graphics"]["implementation"] = "virgl-venus" + self.rejected(evidence, frozen_budget_set(), "software implementation") + + evidence = evidence_manifest() + evidence["launch"]["graphics"]["accelerationEvidence"] = ( + "evidence/graphics-acceleration.json" + ) + self.rejected( + evidence, + frozen_budget_set(), + "software graphics cannot reference acceleration evidence", + ) + + evidence = evidence_manifest() + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = digest("9") + self.rejected( + evidence, + frozen_budget_set(), + "software graphics cannot carry acceleration evidence", + ) + + evidence = evidence_manifest() + graphics = evidence["launch"]["graphics"] + graphics.update(requestedQuality="accelerated", selectedQuality="software") + self.rejected( + evidence, frozen_budget_set(), "fallback classification contradicts" + ) + + evidence = evidence_manifest() + graphics = evidence["launch"]["graphics"] + graphics.update( + fallback=True, + fallbackReason="Renderer receipt unavailable", + requestedQuality="accelerated", + selectedQuality="software", + ) + self.rejected( + evidence, + frozen_budget_set(), + "qualified evidence cannot contain a graphics fallback", + ) + + budgets = frozen_budget_set( + quality="accelerated", budget_id="gpu.accelerated.frame-time" + ) + self.rejected(evidence_manifest(), budgets, "graphics quality contradicts") + + def test_structurally_bound_acceleration_requires_rawhv_and_accelerated_budget( + self, + ) -> None: + evidence = evidence_manifest() + evidence["launch"]["backend"] = "rawhv" + evidence["launch"]["graphics"].update( + accelerationEvidence="evidence/graphics-acceleration.json", + accelerationEvidenceSHA256=digest("9"), + implementation="virgl-venus", + requestedQuality="accelerated", + selectedQuality="accelerated", + ) + budgets = frozen_budget_set( + quality="accelerated", budget_id="gpu.accelerated.frame-time" + ) + self.invoke(evidence, budgets, success=True) + + evidence["launch"]["backend"] = "vz" + self.rejected(evidence, budgets, "not available on this backend") + + evidence["launch"]["backend"] = "rawhv" + evidence["launch"]["graphics"]["accelerationEvidence"] = None + self.rejected(evidence, budgets, "must be a string") + + evidence["launch"]["graphics"]["accelerationEvidence"] = ( + "evidence/graphics-acceleration.json" + ) + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = None + self.rejected(evidence, budgets, "lowercase SHA-256") + + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = digest("9") + software_budget = frozen_budget_set(quality="any") + self.rejected(evidence, software_budget, "no applicable accelerated budget") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-readiness-gate.py b/.github/scripts/test-readiness-gate.py index 00d98aa2..c9514d84 100644 --- a/.github/scripts/test-readiness-gate.py +++ b/.github/scripts/test-readiness-gate.py @@ -29,6 +29,9 @@ def test_release_contract_is_fail_closed_and_reproducible(self) -> None: "physical Intel qualification must target exactly the Dory engine", "independently confirmed native Intel host facts", "READINESS_STOP_ORBSTACK_CONFIRMED=STOP-ORBSTACK-FOR-READINESS", + "Rosetta translates applications only inside an eligible ARM64 Linux VM", + "Dory exposes no partial x86 VM mode", + "packaged QEMU TCG backend", 'stat -f %u "$ENGINE_SOCK"', 'docker_e image inspect "$ALPINE_IMAGE"', "container lifecycle + logs + exec + stats", @@ -45,6 +48,8 @@ def test_release_contract_is_fail_closed_and_reproducible(self) -> None: "node:20-alpine", "FROM ubuntu:24.04", "docker_e pull", + "pending dory-vmm Rosetta", + "Rosetta x86-64 machine execution", ): self.assertNotIn(stale, text, stale) @@ -70,6 +75,31 @@ def test_source_mode_defines_helpers_without_creating_evidence(self) -> None: self.assertEqual(payload["runId"], 'quoted"run') self.assertEqual(payload["engines"], 'dory,"other') + def test_x86_guest_boundary_has_no_partial_vm_claim(self) -> None: + public_contracts = ( + ROOT / "README.md", + ROOT / "COMPATIBILITY.md", + ROOT / "website/public/llms-full.txt", + ROOT / "docs/linux-vm-performance-contract.md", + ) + for contract in public_contracts: + text = contract.read_text(encoding="utf-8") + self.assertIn("no partial x86 VM", text, contract) + self.assertIn("packaged QEMU TCG backend", text, contract) + + llms_contract = (ROOT / "website/public/llms-full.txt").read_text(encoding="utf-8") + self.assertIn("`dory vm` is also unavailable and fails closed", llms_contract) + self.assertNotIn("`dory vm` is an in-process framework engine surface", llms_contract) + + app_store = (ROOT / "Dory/Models/AppStore.swift").read_text(encoding="utf-8") + for stale in ( + "Dory's built-in Intel engine needs", + "one-off `dory vm --rosetta` path", + "x86/amd64 emulation enabled", + ): + self.assertNotIn(stale, app_store, stale) + self.assertIn("x86_64 Linux applications inside Dory's ARM64 container", app_store) + def test_mutable_fixture_fails_before_docker_or_socket_access(self) -> None: with tempfile.TemporaryDirectory() as temporary: env = { diff --git a/.github/scripts/test-release-candidate-live-smoke.py b/.github/scripts/test-release-candidate-live-smoke.py index 9fb5e6ba..f5677fc8 100644 --- a/.github/scripts/test-release-candidate-live-smoke.py +++ b/.github/scripts/test-release-candidate-live-smoke.py @@ -44,7 +44,15 @@ def test_live_contract_binds_candidate_and_all_physical_gates(self) -> None: 'ZED_VERSION="1.16.1"', "releases/download/v$ZED_VERSION/zed-linux-aarch64.tar.gz", "384499c75d75c6aab53110dbc1d8856f6f774baaa32dc57b9963f9e29f8d007b", - "zed_native_venus=PASS", + 'managed_desktop_baseline=$MANAGED_DESKTOP_BASELINE_RESULT', + 'mesa_virgl_desktop=$MESA_VIRGL_DESKTOP_RESULT', + 'renderer_release_signature=$RENDERER_RELEASE_SIGNATURE_RESULT', + 'zed_native_venus=$ZED_NATIVE_VENUS_RESULT', + "--require-acceleration", + "--require-release-signature", + "native Ubuntu Venus/Zed application evidence did not pass", + "Mesa VirGL desktop application evidence did not pass", + "renderer release qualification signature was not authenticated", "signed desktop component candidate is unavailable or indirect", ): self.assertIn(proof, text, proof) diff --git a/.github/scripts/test-renderer-production-tuple.py b/.github/scripts/test-renderer-production-tuple.py new file mode 100644 index 00000000..e1acf225 --- /dev/null +++ b/.github/scripts/test-renderer-production-tuple.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Focused static tests for Dory's schema-3 dual-Metal renderer tuple.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import runpy +import subprocess +import tempfile +import unittest +from unittest import mock + + +REPO = pathlib.Path(__file__).resolve().parents[2] +DEFINITION = REPO / "Config/DoryRendererProductionTuple.json" +VERIFIER = REPO / "scripts/renderer-production-tuple.py" +PACKAGE = REPO / "scripts/package-renderer-production-bundle.py" +ASSEMBLER = REPO / "scripts/assemble-renderer-production-worker.sh" +QUALIFICATION_VERIFIER = REPO / "scripts/verify-renderer-bootstrap-qualification.py" + + +def run(*arguments: object, ok: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [str(argument) for argument in arguments], + cwd=REPO, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if ok and result.returncode != 0: + raise AssertionError(result.stdout) + if not ok and result.returncode == 0: + raise AssertionError(f"command unexpectedly succeeded: {arguments}\n{result.stdout}") + return result + + +def tuple_command( + *arguments: object, + definition: pathlib.Path = DEFINITION, + ok: bool = True, +) -> subprocess.CompletedProcess[str]: + return run("python3", VERIFIER, "--definition", definition, *arguments, ok=ok) + + +class StaticTupleTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.definition = json.loads(DEFINITION.read_text(encoding="utf-8")) + + def test_definition_is_exact_schema_3_dual_metal_architecture(self) -> None: + result = tuple_command("verify-definition", "--repo-root", REPO) + canonical = ( + json.dumps( + self.definition, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode() + digest = hashlib.sha256(canonical).hexdigest() + self.assertEqual(result.stdout.strip(), f"definition.sha256={digest}") + self.assertEqual(tuple_command("definition-sha256").stdout.strip(), digest) + self.assertEqual(self.definition["schemaVersion"], 3) + self.assertEqual(self.definition["sourceTuple"], "dory-dual-metal-20260826") + self.assertEqual( + set(self.definition["sources"]), + {"angle", "libepoxy", "mesa", "moltenVK", "virglrenderer"}, + ) + profiles = self.definition["artifactProfiles"] + self.assertEqual( + set(profiles), + { + "rendererBundle", + "rendererQualificationEvidence", + "rendererReleaseQualificationEvidence", + "staticDependencies", + "staticLinkClosure", + }, + ) + self.assertEqual( + profiles["rendererBundle"], + { + "angleMetal": [ + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libEGL.dylib", + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libGLESv2.dylib", + ], + "rendererWorker": [ + "XPCServices/DoryRendererWorker.xpc/Contents/MacOS/DoryRendererWorker" + ], + }, + ) + for component in profiles["rendererBundle"].values(): + self.assertNotIn( + "Resources/renderer-bootstrap-qualification.json", component + ) + self.assertEqual( + profiles["rendererQualificationEvidence"], + {"qualification": ["Resources/renderer-bootstrap-qualification.json"]}, + ) + self.assertEqual( + profiles["rendererReleaseQualificationEvidence"], + { + "qualification": ["Resources/renderer-bootstrap-qualification.json"], + "releaseSignature": [ + "Resources/renderer-bootstrap-qualification.json.sig" + ], + }, + ) + policy = self.definition["virglBuildPolicy"] + self.assertEqual(policy["classicRenderer"], "virgl2-angle-metal") + self.assertEqual(policy["platforms"], ["egl"]) + self.assertEqual(policy["requiredCapsets"], [2, 4]) + self.assertFalse(policy["venusOnly"]) + self.assertTrue(policy["venus"]) + self.assertFalse(policy["vulkanDynamicLoad"]) + + def test_old_schema_and_venus_only_policy_fail_closed(self) -> None: + mutated = json.loads(json.dumps(self.definition)) + mutated["schemaVersion"] = 2 + mutated["virglBuildPolicy"]["venusOnly"] = True + with tempfile.TemporaryDirectory() as temporary: + path = pathlib.Path(temporary) / "old.json" + path.write_text(json.dumps(mutated), encoding="utf-8") + result = tuple_command( + "verify-definition", "--repo-root", REPO, + definition=path, ok=False, + ) + self.assertIn("schema is unsupported", result.stdout) + + def test_every_profile_round_trips_and_tampering_is_rejected(self) -> None: + profiles = self.definition["artifactProfiles"] + for profile, components in profiles.items(): + with self.subTest(profile=profile), tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) / "root" + root.mkdir() + for paths in components.values(): + for relative in paths: + artifact = root.joinpath(*pathlib.PurePosixPath(relative).parts) + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_bytes(f"{profile}:{relative}".encode()) + inventory = pathlib.Path(temporary) / "inventory.json" + tuple_command( + "create-inventory", "--profile", profile, "--root", root, + "--output", inventory, + ) + value = json.loads(inventory.read_text(encoding="utf-8")) + self.assertEqual(value["schemaVersion"], 3) + tuple_command( + "verify-inventory", "--profile", profile, "--root", root, + "--inventory", inventory, + ) + first = next(iter(next(iter(components.values())))) + root.joinpath(*pathlib.PurePosixPath(first).parts).write_bytes(b"tampered") + result = tuple_command( + "verify-inventory", "--profile", profile, "--root", root, + "--inventory", inventory, ok=False, + ) + self.assertIn("bytes differ", result.stdout) + + def test_inventory_rejects_symlink_artifact(self) -> None: + components = self.definition["artifactProfiles"]["staticDependencies"] + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + target = root / "target" + target.write_bytes(b"archive") + symlinked = False + for paths in components.values(): + for relative in paths: + artifact = root.joinpath(*pathlib.PurePosixPath(relative).parts) + artifact.parent.mkdir(parents=True, exist_ok=True) + if not symlinked: + artifact.symlink_to(target) + symlinked = True + else: + artifact.write_bytes(relative.encode()) + result = tuple_command( + "create-inventory", "--profile", "staticDependencies", + "--root", root, "--output", root / "inventory.json", ok=False, + ) + self.assertIn("non-symlink regular file", result.stdout) + + def test_reviewed_meson_graph_requires_classic_and_venus(self) -> None: + options = { + "buildtype": "release", "check-gl-errors": False, + "default_library": "static", "drm-renderers": [], "fuzzer": False, + "minigbm_allocation": False, "neptune": False, "platforms": ["egl"], + "render-server-mode": "thread", "render-server-worker": "thread", + "tests": False, "unstable-apis": True, "venus": True, + "venus-only": False, "video": False, "vtest": False, + "vulkan-dload": False, "vulkan-preload": False, + } + classic = [ + "src/vrend/vrend_renderer.c", + "src/vrend/vrend_winsys.c", + "src/vrend/vrend_winsys_egl.c", + ] + venus = ["src/virglrenderer.c", "src/venus/vkr_renderer.c"] + transport = [ + "server/render_client.c", "server/render_common.c", + "server/render_context.c", "server/render_server.c", + "server/render_socket.c", "server/render_state.c", + "server/render_worker.c", "src/proxy/proxy_client.c", + "src/proxy/proxy_common.c", "src/proxy/proxy_context.c", + "src/proxy/proxy_renderer.c", "src/proxy/proxy_server.c", + "src/proxy/proxy_socket.c", + ] + with tempfile.TemporaryDirectory() as temporary: + build = pathlib.Path(temporary) + (build / "meson-info").mkdir() + (build / "meson-logs").mkdir() + (build / "meson-info/intro-buildoptions.json").write_text( + json.dumps([{"name": name, "value": value} for name, value in options.items()]), + encoding="utf-8", + ) + (build / "config.h").write_text( + "\n".join( + f"#define {macro} 1" for macro in ( + "ENABLE_RENDER_SERVER", "ENABLE_RENDER_SERVER_WORKER_THREAD", + "ENABLE_SAME_PROCESS_RENDER_SERVER", "ENABLE_VENUS", + "HAVE_EPOXY_EGL_H", + ) + ), + encoding="utf-8", + ) + (build / "meson-logs/meson-log.txt").write_text( + "Run-time dependency epoxy found: YES 1.5.11\n" + "Run-time dependency vulkan found: YES 1.4.0\n", + encoding="utf-8", + ) + commands = build / "compile_commands.json" + graph = [ + {"file": f"../virglrenderer/{source}"} + for source in classic + venus + transport + ] + commands.write_text(json.dumps(graph), encoding="utf-8") + tuple_command("verify-meson", "--build-dir", build) + commands.write_text( + json.dumps([ + entry for entry in graph + if not entry["file"].endswith(classic[0]) + ]), + encoding="utf-8", + ) + result = tuple_command("verify-meson", "--build-dir", build, ok=False) + self.assertIn("missing required classic VirGL2/ANGLE source", result.stdout) + commands.write_text( + json.dumps( + graph + [{"file": "../virglrenderer/src/vtest/vtest_renderer.c"}] + ), + encoding="utf-8", + ) + result = tuple_command("verify-meson", "--build-dir", build, ok=False) + self.assertIn("forbidden source fragment", result.stdout) + + def test_build_packaging_and_qualification_consumers_are_dual_and_closed(self) -> None: + dependencies = ( + REPO / "scripts/build-renderer-production-dependencies.sh" + ).read_text() + virgl = (REPO / "scripts/build-virglrenderer.sh").read_text() + package = PACKAGE.read_text() + assembler = ASSEMBLER.read_text() + xcode = (REPO / "scripts/xcode-package-renderer-production.sh").read_text() + abi = (REPO / "scripts/verify-virgl-resource-info-abi.c").read_text() + + for authority in ( + 'install_name_tool -id "@loader_path/$angle_name"', + "libEGL.dylib", "libGLESv2.dylib", "lib/libepoxy.a", + "cmd LC_RPATH", "@loader_path/../Frameworks/libEGL.dylib", + "@loader_path/../Frameworks/libGLESv2.dylib", + ): + self.assertIn(authority, dependencies) + self.assertIn("Source/ThirdParty/ANGLE", dependencies) + self.assertIn("verify_angle_runtime_library", dependencies) + self.assertIn("MVK_HIDE_VULKAN_SYMBOLS=1", dependencies) + + self.assertIn("-Dplatforms=egl", virgl) + self.assertIn("-Dvenus-only=false", virgl) + self.assertNotIn("-Dvenus-only=true", virgl) + self.assertIn("-DDORY_VIRGL_RENDERER_STATIC_LINKED", virgl) + self.assertIn("-DDORY_VIRGL_RENDERER_DUAL_METAL", virgl) + self.assertEqual(virgl.count("-Wl,-force_load"), 3) + self.assertIn('"requiredVirGLCapsets": [2, 4]', virgl) + + self.assertIn("-Xcc -DDORY_VIRGL_RENDERER_STATIC_LINKED", assembler) + self.assertIn("-Xcc -DDORY_VIRGL_RENDERER_DUAL_METAL", assembler) + self.assertEqual(assembler.count("-Xlinker -force_load"), 3) + self.assertLess( + assembler.index( + "for angle_name in libEGL.dylib libGLESv2.dylib; do\n" + " /usr/bin/codesign" + ), + assembler.index('"${CODESIGN_ARGUMENTS[@]}" "$WORKER_BUNDLE"'), + ) + self.assertIn("verify_angle_runtime_closure", package) + self.assertIn("if macho_rpaths(library)", package) + self.assertIn("non-system/non-sibling dependency", package) + self.assertIn("DORY_VIRGL_RENDERER_DUAL_METAL", package) + self.assertIn("rendererReleaseQualificationEvidence", package) + self.assertIn("--require-release-signature", package) + self.assertIn("verify-renderer-bootstrap-qualification.py", package) + self.assertIn("--allow-unsealed-staging", package) + self.assertIn("renderer-virgl-metal-shared-texture-probe.m", virgl) + shareable_scanout_patch = ( + REPO / "patches/virglrenderer-metal-shareable-scanout.patch" + ).read_text() + self.assertIn("newSharedTextureWithDescriptor", shareable_scanout_patch) + self.assertIn("desc->bind & VIRGL_RES_BIND_SCANOUT", shareable_scanout_patch) + self.assertNotIn("desc->bind & PIPE_BIND_SCANOUT", shareable_scanout_patch) + shared_texture_probe = ( + REPO / "scripts/renderer-virgl-metal-shared-texture-probe.m" + ).read_text() + self.assertIn(".bind = VIRGL_RES_BIND_RENDER_TARGET", shared_texture_probe) + self.assertIn("VIRGL_RES_BIND_SCANOUT != PIPE_BIND_SCANOUT", shared_texture_probe) + venus_transport_patch = ( + REPO / "patches/virglrenderer-venus-only-static.patch" + ).read_text() + self.assertIn( + "+ uint32_t proxy_flags = flags | VIRGL_RENDERER_NO_VIRGL;", + venus_transport_patch, + ) + self.assertIn( + '+ if (!getenv("VIRGL_DISABLE_MT"))', + venus_transport_patch, + ) + self.assertIn( + "+ proxy_flags |= VIRGL_RENDERER_THREAD_SYNC;", + venus_transport_patch, + ) + self.assertIn( + "+ ret = proxy_renderer_init(&proxy_cbs, proxy_flags);", + venus_transport_patch, + ) + self.assertIn( + "+#if defined(ENABLE_VENUS_ONLY) && defined(__APPLE__)", + venus_transport_patch, + ) + self.assertNotIn( + "+#if defined(__APPLE__)\n+ has_thread_sync_notification = true;", + venus_transport_patch, + ) + self.assertIn( + "+ if (!has_thread_sync_notification || getenv(\"VIRGL_DISABLE_MT\"))\n" + " flags &= ~VIRGL_RENDERER_THREAD_SYNC;", + venus_transport_patch, + ) + self.assertNotIn("+ flags |= VIRGL_RENDERER_THREAD_SYNC;", venus_transport_patch) + + order = [ + '"$ROOT/scripts/assemble-renderer-production-worker.sh"', + 'python3 "$ROOT/scripts/package-renderer-production-bundle.py" package', + "/usr/bin/codesign \\", + '"$RUNNER_APP/Contents/MacOS/dory-hv" renderer-qualify', + 'install -m0644 "$STAGED_RECEIPT"', + 'python3 "$ROOT/scripts/package-renderer-production-bundle.py" seal-evidence', + ] + positions = [xcode.index(fragment) for fragment in order] + self.assertEqual(positions, sorted(positions)) + self.assertIn("DORY_RENDERER_MANAGED_KERNEL_SHA256", xcode) + self.assertIn("DORY_RENDERER_MANAGED_KERNEL", xcode) + self.assertIn("DORY_RENDERER_QUALIFICATION_MODE", xcode) + self.assertIn("--require-release-signature", xcode) + + for constant in ( + "DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET", + "DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW", + "DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT", + ): + self.assertIn(constant, abi) + + def test_cdhash_shape_is_strict(self) -> None: + namespace = runpy.run_path(str(PACKAGE)) + validate_cdhash = namespace["code_directory_hash"] + error = namespace["PackagingError"] + self.assertEqual(validate_cdhash("a" * 40, "fixture"), "a" * 40) + for invalid in ("a" * 39, "a" * 41, "0" * 40, "g" * 40): + with self.subTest(invalid=invalid), self.assertRaises(error): + validate_cdhash(invalid, "fixture") + + def test_qualification_codesign_requirement_is_an_expression(self) -> None: + namespace = runpy.run_path(str(QUALIFICATION_VERIFIER)) + completed = subprocess.CompletedProcess([], 0, "", "") + with mock.patch.object(namespace["subprocess"], "run", return_value=completed) as run_mock: + namespace["verify_code_identity"]( + pathlib.Path("/tmp/DoryHVRunner.app"), + 'anchor apple generic and identifier "com.pythonxi.Dory.HVRunner"', + check_nested=True, + ) + command = run_mock.call_args.args[0] + requirement_index = command.index("-R") + 1 + self.assertEqual( + command[requirement_index], + '=anchor apple generic and identifier "com.pythonxi.Dory.HVRunner"', + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/scripts/test-renderer-release-identity.py b/.github/scripts/test-renderer-release-identity.py new file mode 100644 index 00000000..7be925a6 --- /dev/null +++ b/.github/scripts/test-renderer-release-identity.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Focused tests for the directed renderer release-identity packaging chain.""" + +from __future__ import annotations + +import argparse +import copy +import importlib.util +import pathlib +import plistlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +HELPER = ROOT / "scripts" / "renderer-release-identity.py" +BUNDLE_ENGINE = ROOT / "scripts" / "bundle-engine.sh" +RELEASE = ROOT / "scripts" / "release.sh" +RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" + + +def load_helper(): + spec = importlib.util.spec_from_file_location("renderer_release_identity", HELPER) + if spec is None or spec.loader is None: + raise RuntimeError("could not load renderer release identity helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +identity = load_helper() + + +def production_details( + *, + identifier: str = identity.RUNNER_IDENTIFIER, + team: str = identity.PRODUCTION_TEAM_IDENTIFIER, + cdhash: str = "a1" * 20, +) -> str: + return "\n".join(( + "Executable=/fixture/DoryHVRunner.app/Contents/MacOS/dory-hv", + f"Identifier={identifier}", + "Format=app bundle with Mach-O thin (arm64)", + "CodeDirectory v=20500 size=1234 flags=0x10000(runtime) hashes=30+7 location=embedded", + "Signature size=9050", + "Authority=Developer ID Application: Dory Fixture (864H636QW4)", + "Authority=Developer ID Certification Authority", + "Authority=Apple Root CA", + f"TeamIdentifier={team}", + "Runtime Version=26.0.0", + "Timestamp=Aug 23, 2026 at 10:00:00 PM", + f"CDHash={cdhash}", + "", + )) + + +class RendererReleaseIdentityTests(unittest.TestCase): + def test_canonical_entitlement_has_exact_shape_and_types(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + self.assertEqual(set(expected), {identity.ENTITLEMENT_NAME}) + nested = expected[identity.ENTITLEMENT_NAME] + self.assertEqual(set(nested), identity.IDENTITY_KEYS) + self.assertIs(type(nested["schema-version"]), int) + for field in ( + "runner-cdhash", + "renderer-worker-cdhash", + "tuple-definition-sha256", + ): + self.assertIs(type(nested[field]), str) + + raw = identity.canonical_entitlement_bytes(expected) + self.assertEqual(raw, identity.canonical_entitlement_bytes(expected)) + self.assertEqual(plistlib.loads(raw), expected) + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) / "doryd.entitlements" + identity.write_entitlements(output, expected) + self.assertEqual(output.read_bytes(), raw) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + + def test_entitlement_rejects_extra_missing_and_mistyped_fields(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + fixtures: list[dict[str, object]] = [] + + extra_top = copy.deepcopy(expected) + extra_top["unreviewed"] = True + fixtures.append(extra_top) + + extra_nested = copy.deepcopy(expected) + extra_nested[identity.ENTITLEMENT_NAME]["unreviewed"] = "value" + fixtures.append(extra_nested) + + missing = copy.deepcopy(expected) + del missing[identity.ENTITLEMENT_NAME]["runner-cdhash"] + fixtures.append(missing) + + for wrong in (True, 1.0, "1"): + mistyped = copy.deepcopy(expected) + mistyped[identity.ENTITLEMENT_NAME]["schema-version"] = wrong + fixtures.append(mistyped) + + mistyped_hash = copy.deepcopy(expected) + mistyped_hash[identity.ENTITLEMENT_NAME]["runner-cdhash"] = b"a1" * 20 + fixtures.append(mistyped_hash) + + for fixture in fixtures: + with self.subTest(fixture=fixture): + with self.assertRaises(identity.ReleaseIdentityError): + identity.validate_release_identity_entitlements(fixture, expected) + + def test_signature_parser_requires_exact_production_identity(self) -> None: + cdhash = identity.parse_production_signature_details( + production_details(), + label="runner", + expected_identifier=identity.RUNNER_IDENTIFIER, + expected_team=identity.PRODUCTION_TEAM_IDENTIFIER, + ) + self.assertEqual(cdhash, "a1" * 20) + + corruptions = { + "wrong identifier": production_details(identifier="unreviewed"), + "wrong team": production_details(team="ABCDEFGHIJ"), + "ad hoc": production_details().replace( + "Authority=Developer ID Application: Dory Fixture (864H636QW4)\n", + "Signature=adhoc\n", + ), + "no hardened runtime": production_details().replace("(runtime)", "(none)"), + "no timestamp": production_details().replace( + "Timestamp=Aug 23, 2026 at 10:00:00 PM\n", "" + ), + "short hash": production_details(cdhash="a1" * 19), + "uppercase hash": production_details(cdhash=("a1" * 20).upper()), + "zero hash": production_details(cdhash="0" * 40), + "two hashes": production_details() + f"CDHash={'ab' * 20}\n", + } + for label, details in corruptions.items(): + with self.subTest(label=label): + with self.assertRaises(identity.ReleaseIdentityError): + identity.parse_production_signature_details( + details, + label="runner", + expected_identifier=identity.RUNNER_IDENTIFIER, + expected_team=identity.PRODUCTION_TEAM_IDENTIFIER, + ) + + def test_tuple_digest_must_come_back_as_one_exact_verifier_value(self) -> None: + digest = "cd" * 32 + self.assertEqual( + identity.parse_tuple_definition_digest(f"definition.sha256={digest}\n"), + digest, + ) + for output in ( + "", + f"definition.sha256={digest.upper()}\n", + f"definition.sha256={digest}\ndefinition.sha256={digest}\n", + f"definition.sha256={'0' * 64}\n", + ): + with self.subTest(output=output): + with self.assertRaises(identity.ReleaseIdentityError): + identity.parse_tuple_definition_digest(output) + + def test_helper_reads_the_live_digest_through_the_tuple_verifier(self) -> None: + expected = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "renderer-production-tuple.py"), + "verify-definition", + "--repo-root", + str(ROOT), + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout + self.assertEqual( + identity.tuple_definition_digest(ROOT), + identity.parse_tuple_definition_digest(expected), + ) + + def test_bundle_and_release_order_are_fail_closed(self) -> None: + subprocess.run(["bash", "-n", str(BUNDLE_ENGINE)], check=True) + subprocess.run(["bash", "-n", str(RELEASE)], check=True) + bundle = BUNDLE_ENGINE.read_text(encoding="utf-8") + release = RELEASE.read_text(encoding="utf-8") + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + execution = bundle.split("\nbundle_doryd_helpers\n", 1)[1] + self.assertLess( + execution.index("bundle_venus_renderer"), + execution.index("finalize_doryd_signature"), + ) + self.assertLess( + execution.index("finalize_doryd_signature"), + execution.index("PAYLOAD_DIGESTS="), + ) + doryd_build = bundle.split("bundle_doryd_helpers() {", 1)[1].split( + "\ninject_debug_toolbox_into_initfs()", 1 + )[0] + self.assertIn("assemble_doryd_for_release_identity", doryd_build) + self.assertNotIn( + 'bundle_swiftpm_executable "dory-core-swift" "$configuration" "doryd"', + doryd_build, + ) + self.assertIn("verify-absent", bundle) + self.assertIn("RawHV hardware 3D fails closed", bundle) + production_signer = bundle.split( + "codesign_production_release_identity() {", 1 + )[1].split("\nfinalize_doryd_signature()", 1)[0] + self.assertIn("/usr/bin/codesign", production_signer) + self.assertIn("--identifier doryd", production_signer) + self.assertNotIn("DORY_ALLOW_ADHOC_SIGN", production_signer) + self.assertNotIn("--sign -", production_signer) + self.assertIn("renderer-release-identity.py\" verify", release) + self.assertIn( + "public releases require the production doryd renderer release identity", + release, + ) + self.assertIn("scripts/renderer-release-identity.py verify", workflow) + self.assertIn( + '--doryd "$extracted/Dory.app/Contents/Helpers/doryd"', workflow + ) + self.assertIn("dual VirGL2 + Venus renderer", bundle) + self.assertIn("dual VirGL2 + Venus renderer", release) + for stale in ("libvirglrenderer.dylib", "libMoltenVK.dylib"): + self.assertNotIn(stale, bundle) + self.assertNotIn(stale, release) + + def test_cli_rejects_nonproduction_team_before_reading_artifacts(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(HELPER), + "create-entitlements", + "--runner-app", + "/nonexistent/DoryHVRunner.app", + "--output", + "/nonexistent/doryd.entitlements", + "--expected-team", + "ABCDEFGHIJ", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("can only bind Dory production team 864H636QW4", result.stdout) + + @unittest.skipUnless(sys.platform == "darwin", "codesign fixture requires macOS") + def test_ad_hoc_doryd_has_no_fabricated_release_identity(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + doryd = pathlib.Path(temporary) / "doryd" + shutil.copyfile("/usr/bin/true", doryd) + doryd.chmod(0o755) + subprocess.run( + ["/usr/bin/codesign", "--force", "--options", "runtime", "--sign", "-", str(doryd)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + identity.verify_absent(argparse.Namespace(doryd=doryd)) + + @unittest.skipUnless(sys.platform == "darwin", "codesign fixture requires macOS") + def test_codesign_preserves_exact_custom_entitlement_shape(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + doryd = root / "doryd" + entitlements = root / "doryd.entitlements" + shutil.copyfile("/usr/bin/true", doryd) + doryd.chmod(0o755) + identity.write_entitlements(entitlements, expected) + subprocess.run( + [ + "/usr/bin/codesign", + "--force", + "--options", + "runtime", + "--entitlements", + str(entitlements), + "--sign", + "-", + str(doryd), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + actual = identity.read_signed_entitlements(doryd, "fixture doryd") + identity.validate_release_identity_entitlements(actual, expected) + with self.assertRaises(identity.ReleaseIdentityError): + identity.verify_absent(argparse.Namespace(doryd=doryd)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/verify-public-release.py b/.github/scripts/verify-public-release.py index 65c620b1..e6914168 100755 --- a/.github/scripts/verify-public-release.py +++ b/.github/scripts/verify-public-release.py @@ -130,7 +130,7 @@ def main() -> None: f"Dory-{version}-app-update.zip", f"dory-engine-{version}-arm64.tar.gz", f"Dory-{version}.cdx.json", - f"Dory-{version}-performance-evidence.zip", + f"Dory-{version}-container-engine-performance-evidence.zip", f"Dory-{version}-reliability-evidence.zip", f"Dory-{version}-reliability-evidence.zip.sha256", "release-manifest.json", diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 7b9852c0..83bfb7b4 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -26,6 +26,12 @@ def require(text: str, value: str, message: str) -> None: pages = workflow.split(" publish-pages:", 1)[1].split("\n # Keeps the Homebrew", 1)[0] bump = workflow.split(" bump-cask:", 1)[1].split("\n verify-public-release:", 1)[0] final = workflow.split(" verify-public-release:", 1)[1] +guest_upload = workflow.split(" - name: Upload same-commit arm64 guest payload", 1)[1].split( + "\n\n prepublication-quality:", 1 +)[0] +guest_download_verification = workflow.split( + " - name: Independently verify every downloaded guest payload", 1 +)[1].split("\n - name: Prove the tracked release source exactly matches the commit", 1)[0] for distro in ("debian", "ubuntu", "kali"): require( @@ -44,6 +50,20 @@ def require(text: str, value: str, message: str) -> None: f"release candidate does not verify the {distro} desktop", ) require(workflow, "guest/out/Image-desktop.zst", "same-commit guest artifact omits the desktop kernel") +for mesa_artifact in ( + "guest/out/dory-mesa-venus-arm64.tar.zst", + "guest/out/dory-mesa-venus-build-arm64.stamp", +): + require( + guest_upload, + mesa_artifact, + f"same-commit guest artifact omits required Mesa payload {mesa_artifact}", + ) +require( + guest_download_verification, + "guest/mesa/verify-build.sh arm64", + "downloaded guest payload does not independently verify its exact Mesa runtime", +) require( workflow, "DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb627bfc..dcd17761 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -303,6 +303,8 @@ jobs: guest/out/Image-desktop.zst guest/out/config-arm64-desktop guest/out/kernel-build-arm64-desktop.stamp + guest/out/dory-mesa-venus-arm64.tar.zst + guest/out/dory-mesa-venus-build-arm64.stamp guest/out/dory-desktop-debian-rootfs-arm64.ext4.zst guest/out/dory-desktop-debian-packages-arm64.txt guest/out/dory-desktop-debian-update-arm64.tar @@ -338,7 +340,12 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py - python3 .github/scripts/test-release-performance-gate.py + python3 .github/scripts/test-container-engine-performance-gate.py + python3 .github/scripts/test-linux-vm-performance-evidence.py + python3 -B scripts/test-linux-vm-performance-bundle.py -v + python3 .github/scripts/test-renderer-production-tuple.py + python3 -B .github/scripts/test-renderer-release-identity.py -v + scripts/test-build-renderer-components.sh python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-vz-native-ipv6-gate.py @@ -353,6 +360,8 @@ jobs: python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-graphics-pack-installer.py + python3 .github/scripts/test-graphics-backend-resolver.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py @@ -469,6 +478,7 @@ jobs: DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 guest/initfs/verify-build.sh arm64 DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 + guest/mesa/verify-build.sh arm64 for distro in debian ubuntu kali; do guest/desktop/verify-build.sh arm64 "$distro" done @@ -882,8 +892,8 @@ jobs: --source-commit "${{ github.sha }}" \ --confirm QUALIFY-EXACT-DORY-RELEASE - performance_qualification: - name: Exact candidate isolated and interleaved performance evidence + container_engine_performance_qualification: + name: Exact candidate container-engine performance evidence needs: release_candidate runs-on: [self-hosted, macOS, arm64, dory, benchmark] timeout-minutes: 720 @@ -898,17 +908,17 @@ jobs: with: name: dory-release-candidate-${{ github.sha }}-${{ github.run_attempt }} path: release-build - - name: Run exact-candidate clean-account campaign + - name: Run exact-candidate clean-account container-engine campaign env: DORY_RELEASE_CLEAN_USER: '1' DORY_RELEASE_BENCHMARK_USER: '1' run: | - scripts/qualify-release-performance.sh \ + scripts/qualify-container-engine-performance.sh \ --candidate-dir release-build \ --version "${{ needs.release_candidate.outputs.version }}" \ --build "${{ github.run_number }}" \ --source-commit "$GITHUB_SHA" \ - --workroot "$RUNNER_TEMP/dory-release-performance" \ + --workroot "$RUNNER_TEMP/dory-container-engine-performance" \ --alpine-image "${{ vars.DORY_RELEASE_ALPINE_IMAGE }}" \ --iperf-image "${{ vars.DORY_BENCH_IPERF_IMAGE }}" \ --node-image "${{ vars.DORY_BENCH_NODE_IMAGE }}" \ @@ -921,14 +931,14 @@ jobs: --download-url "${{ vars.DORY_BENCH_DOWNLOAD_URL }}" \ --download-bytes "${{ vars.DORY_BENCH_DOWNLOAD_BYTES }}" \ --confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA - - name: Retain exact performance evidence for publication + - name: Retain exact container-engine performance evidence for publication uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: dory-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} + name: dory-container-engine-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} retention-days: 90 if-no-files-found: error compression-level: 0 - path: ${{ runner.temp }}/dory-release-performance/Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip + path: ${{ runner.temp }}/dory-container-engine-performance/Dory-${{ needs.release_candidate.outputs.version }}-container-engine-performance-evidence.zip sonoma_vz_certification: name: Exact candidate macOS 14 VZ + IPv6 + LAN/Tailscale source certification @@ -988,7 +998,7 @@ jobs: ssh-add -L >/dev/null scripts/vz-native-ipv6-gate.sh \ --dory-vmm "$SONOMA_APP/Contents/Helpers/dory-vmm" \ - --dory-hv "$SONOMA_APP/Contents/Helpers/dory-hv" \ + --dory-hv "$SONOMA_APP/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" \ --gvproxy "$SONOMA_APP/Contents/Helpers/gvproxy" \ --gvproxy-provenance "$SONOMA_APP/Contents/Resources/gvproxy-provenance.txt" \ --payload-inventory "$SONOMA_APP/Contents/Resources/dory-payload-sha256.txt" \ @@ -1157,7 +1167,7 @@ jobs: publish_release: name: Publish only the exact qualified candidate - needs: [release_candidate, release_qualification, performance_qualification, sonoma_vz_certification, source_preserving_lan_certification, homebrew_cask_audit, homebrew_install_certification] + needs: [release_candidate, release_qualification, container_engine_performance_qualification, sonoma_vz_certification, source_preserving_lan_certification, homebrew_cask_audit, homebrew_install_certification] runs-on: [self-hosted, macOS, arm64, dory, release] timeout-minutes: 120 permissions: @@ -1197,24 +1207,24 @@ jobs: with: name: dory-homebrew-install-evidence-${{ github.sha }}-${{ github.run_attempt }} path: homebrew-install-evidence - - name: Download exact-candidate performance evidence + - name: Download exact-candidate container-engine performance evidence uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: dory-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} - path: performance-evidence - - name: Verify performance evidence binding and internal digests + name: dory-container-engine-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} + path: container-engine-performance-evidence + - name: Verify container-engine performance evidence binding and internal digests env: VERSION: ${{ needs.release_candidate.outputs.version }} BUILD: ${{ github.run_number }} run: | set -euo pipefail - archive="performance-evidence/Dory-$VERSION-performance-evidence.zip" + archive="container-engine-performance-evidence/Dory-$VERSION-container-engine-performance-evidence.zip" test -s "$archive" root="$RUNNER_TEMP/dory-performance-publication" rm -rf "$root" mkdir -p "$root" unzip -q "$archive" -d "$root" - evidence="$root/Dory-$VERSION-performance-evidence" + evidence="$root/Dory-$VERSION-container-engine-performance-evidence" test -s "$evidence/manifest.json" test -s "$evidence/sha256.txt" (cd "$evidence" && shasum -a 256 -c sha256.txt) @@ -1229,7 +1239,8 @@ jobs: raise SystemExit(message) require(isinstance(manifest, dict), "performance manifest must be an object") require(manifest.get("schemaVersion") == 1, "performance manifest schema mismatch") - require(manifest.get("kind") == "dev.dory.performance-qualification", "performance manifest kind mismatch") + require(manifest.get("kind") == "dev.dory.container-engine-performance-qualification", + "container-engine performance manifest kind mismatch") require(manifest.get("status") == "PASS" and manifest.get("releaseQualifying") is True, "performance qualification did not pass") candidate = manifest["candidate"] @@ -1516,6 +1527,10 @@ jobs: codesign --verify --strict --deep "$extracted/Dory.app" codesign -dv --verbose=4 "$extracted/Dory.app" 2>&1 \ | grep -q 'Authority=Developer ID Application' + python3 scripts/renderer-release-identity.py verify \ + --runner-app "$extracted/Dory.app/Contents/Helpers/DoryHVRunner.app" \ + --doryd "$extracted/Dory.app/Contents/Helpers/doryd" \ + --expected-team 864H636QW4 xcrun stapler validate "$extracted/Dory.app" spctl --assess --type execute --verbose=4 "$extracted/Dory.app" \ > "$RUNNER_TEMP/dory-publication-gatekeeper.txt" 2>&1 @@ -1544,11 +1559,11 @@ jobs: done grep -qx 'architecture=arm64' "$vz_manifest" grep -qx 'release_qualifying=true' "$vz_manifest" - grep -qx 'gvproxy_version=v0.8.9-dory1' "$vz_manifest" + grep -qx 'gvproxy_version=v0.8.9-dory3' "$vz_manifest" gvproxy_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/gvproxy" | awk '{print $1}')" grep -qx "gvproxy_sha256=$gvproxy_sha" "$vz_manifest" - grep -qx 'gvproxy_build_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' "$vz_manifest" - grep -qx 'verified_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' \ + grep -qx 'gvproxy_build_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' "$vz_manifest" + grep -qx 'verified_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' \ "$extracted/Dory.app/Contents/Resources/gvproxy-provenance.txt" helper_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/dory-vmm" | awk '{print $1}')" grep -qx "dory_vmm_sha256=$helper_sha" "$vz_manifest" @@ -1566,7 +1581,7 @@ jobs: test ! -s "$(dirname "$vz_manifest")/new-host-panic-reports.txt" lan_manifests="$(find source-lan-evidence -type f -name manifest.txt -print)" test "$(printf '%s\n' "$lan_manifests" | awk 'NF { count++ } END { print count + 0 }')" = 2 - hv_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/dory-hv" | awk '{print $1}')" + hv_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" | awk '{print $1}')" for mode in lan tailscale; do manifest="$(printf '%s\n' "$lan_manifests" | while IFS= read -r candidate; do grep -qx "mode=$mode" "$candidate" && printf '%s\n' "$candidate"; done)" test "$(printf '%s\n' "$manifest" | awk 'NF { count++ } END { print count + 0 }')" = 1 @@ -1586,7 +1601,7 @@ jobs: grep -qx "app_executable_sha256=$app_sha" "$manifest" grep -qx "dory_hv_sha256=$hv_sha" "$manifest" grep -qx "gvproxy_sha256=$gvproxy_sha" "$manifest" - grep -qx 'gvproxy_build_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' "$manifest" + grep -qx 'gvproxy_build_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' "$manifest" grep -Eq '^observed_source_ipv4=([0-9]{1,3}\.){3}[0-9]{1,3}$' "$manifest" grep -Eq '^server_image=.+@sha256:[0-9a-f]{64}$' "$manifest" grep -qx 'memory_pressure_mib=960' "$manifest" @@ -1684,7 +1699,7 @@ jobs: release-build/release-manifest.json release-build/appcast.xml release-build/components/arm64/* - performance-evidence/Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip + container-engine-performance-evidence/Dory-${{ needs.release_candidate.outputs.version }}-container-engine-performance-evidence.zip ${{ runner.temp }}/Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip ${{ runner.temp }}/Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip.sha256 fail_on_unmatched_files: true @@ -1710,7 +1725,7 @@ jobs: - Explicit-scope confirmation or recoverable undo for destructive UI, keyboard, menu, CLI, migration, cleanup, component, and missing-drive paths. - Exact-candidate physical, duration, compatibility, migration, update, security, and - performance evidence bound to the shipped manifest and SBOM. + container-engine performance evidence bound to the shipped manifest and SBOM. **Apple Silicon downloads** @@ -1720,7 +1735,7 @@ jobs: | `Dory-${{ needs.release_candidate.outputs.version }}.dmg` / `.zip` | Compatibility alias for the arm64 build | | `dory-engine-${{ needs.release_candidate.outputs.version }}-arm64.tar.gz` | Headless engine runtime, no GUI — `./dory-engine start`, then `docker context use dory-engine` | | `Dory-${{ needs.release_candidate.outputs.version }}.cdx.json` | CycloneDX 1.6 SBOM for the exact shipped app tree | - | `Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip` | Raw isolated/interleaved benchmark data, provenance, correctness evidence, and generated summaries | + | `Dory-${{ needs.release_candidate.outputs.version }}-container-engine-performance-evidence.zip` | Raw isolated/interleaved container-engine benchmark data, provenance, correctness evidence, and generated summaries; not Linux VM qualification | | `Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip` | Candidate-bound eight-hour resource/file/API and 25-hour unchanged-connection qualification records | **Install** diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 455e9b53..c1675206 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,6 +31,10 @@ jobs: run: | newest="$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)" echo "DEVELOPER_DIR=$newest/Contents/Developer" >> "$GITHUB_ENV" + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - name: Build shared Rust guest-control client + run: scripts/build-dory-ffi-xcframework.sh --if-needed - name: Dory CLI machine create run: .github/scripts/test-dory-machine-create.sh - name: Signed release metadata contract @@ -38,7 +42,12 @@ jobs: python3 .github/scripts/test-release-metadata.py python3 .github/scripts/test-release-output-validator.py python3 .github/scripts/test-homebrew-install-gate.py - python3 .github/scripts/test-release-performance-gate.py + python3 .github/scripts/test-container-engine-performance-gate.py + python3 .github/scripts/test-linux-vm-performance-evidence.py + python3 -B scripts/test-linux-vm-performance-bundle.py -v + python3 .github/scripts/test-renderer-production-tuple.py + python3 -B .github/scripts/test-renderer-release-identity.py -v + scripts/test-build-renderer-components.sh python3 .github/scripts/test-source-preserving-lan-gate.py python3 .github/scripts/test-sparkle-install-relaunch-gate.py python3 .github/scripts/test-vz-native-ipv6-gate.py @@ -53,6 +62,8 @@ jobs: python3 .github/scripts/test-release-sbom.py python3 .github/scripts/test-direct-dmg-install-gate.py python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-graphics-pack-installer.py + python3 .github/scripts/test-graphics-backend-resolver.py python3 .github/scripts/test-machine-resource-reconfiguration-gate.py python3 .github/scripts/test-sandbox-security-gate.py python3 .github/scripts/test-ssh-agent-forwarding-gate.py @@ -90,8 +101,6 @@ jobs: python3 .github/scripts/test-endurance-reliability-soak.py python3 .github/scripts/test-release-candidate-qualifier.py python3 .github/scripts/test-release-qualification-verifier.py - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler run: brew install protobuf - name: Install Go toolchain diff --git a/.gitignore b/.gitignore index 876491ce..80c08a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ TestResults/ # Swift Package Manager .build/ +.build-*/ .swiftpm/ # Package.resolved is committed (app) so CI + contributors pin the same dependency versions. @@ -144,15 +145,52 @@ docs-build/ # Go build artifact (build.sh writes the real output to guest/out/) guest/agent/agent -# Built GitHub Pages site (CI builds website/ and deploys the output directly) plus -# local-only planning notes. Keep only the source platform ADR under docs/. +# Built GitHub Pages site (CI builds website/ and deploys the output directly) plus local-only +# planning notes. Keep the source platform ADR and its Linux execution contracts under docs/. docs/ !/docs/ /docs/* !/docs/virtual-workspace-platform.md +!/docs/linux-virtual-workspace-architecture.md +!/docs/linux-virtual-workspace-delivery-plan.md +!/docs/linux-capability-and-qualification-matrix.md +!/docs/linux-vm-performance-contract.md +!/docs/linux-iso-and-gpu-recovery-review.md +!/docs/container-engine-performance-qualification.md +!/docs/architecture-gates/ +/docs/architecture-gates/* +!/docs/architecture-gates/virtiofs-worker-xpc.json +!/docs/architecture-gates/vz-custom-virtio-gpu.json +!/docs/architecture-gates/vulkan-13-application-readiness.md !website/public/docs/ !website/public/docs/** +# Source-level renderer build and ABI verification are release inputs, not local scripts. +!/scripts/build-renderer-production-dependencies.sh +!/scripts/build-virglrenderer.sh +!/scripts/assemble-renderer-production-worker.sh +!/scripts/renderer-release-identity.py +!/scripts/renderer-production-tuple.py +!/scripts/renderer-build-tools/ +/scripts/renderer-build-tools/* +!/scripts/renderer-build-tools/xcodebuild +!/scripts/verify-virgl-resource-info-abi.c + +# Candidate-bound physical Linux developer smoke (fail-closed; never release qualification). +!/scripts/linux-local-runtime-smoke.sh + +# Canonical Linux VM performance evidence validation, signed-bundle verification, and their +# public-key-only adversarial fixtures are release source. Private collector output and signing +# keys remain excluded. +!/scripts/qualify-container-engine-performance.sh +!/scripts/validate-linux-vm-performance-evidence.py +!/scripts/verify-linux-vm-performance-bundle.py +!/scripts/test-linux-vm-performance-bundle.py +!/scripts/fixtures/ +/scripts/fixtures/* +!/scripts/fixtures/linux-vm-performance-bundle-schema1/ +!/scripts/fixtures/linux-vm-performance-bundle-schema1/** + # Internal engine notes (not published) Packages/ContainerizationEngine/Docs/ @@ -179,7 +217,6 @@ scripts/__pycache__/ /DESTRUCTIVE_ACTIONS.md /DORY_V0.4_RESEARCH_REPORT.md /MACHINE_IMAGE_CONTRACT.md -/PERFORMANCE_QUALIFICATION.md /POST_V0.4_PRODUCT_DESIGNS.md /RELEASE_READINESS.md /SANDBOX_THREAT_MODEL.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 20cb7683..12f5fb3f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -31,7 +31,7 @@ workload data. | Compose | Bundled Compose v2 with profiles, overrides, `.env`, builds, health dependencies, and external resources | | Registry authentication | Docker-compatible login and credential flow | | Bind mounts | Home-directory and `/Volumes` paths are shared at their native macOS paths | -| amd64 images on Apple Silicon | Supported for common development workloads through bundled FEX | +| amd64 images on Apple Silicon | Supported for common application/container workloads through bundled FEX inside Dory's ARM64 engine; this is not an x86_64 guest OS or ISO path | Some specialized Docker extensions and host-specific plugins may assume another product's internal paths. Open an issue with the exact tool and version when that happens. @@ -77,7 +77,7 @@ engine resources. | Development recipes | Curated Node, Python, Go, Rust, Java, Ruby, and DevOps toolsets for Debian and Alpine | | Graphical Linux sessions | Supported with managed Ubuntu GNOME and Debian/Kali Xfce profiles on Apple Silicon | | Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching GNOME or Xfce scaling | -| Desktop Vulkan | Supported through Dory's isolated, capability-probed Venus runtime on Apple-silicon raw-HV; Automatic falls back to classic VirGL and software when unavailable | +| Desktop GPU acceleration | **Unqualified for public release:** the repaired dual VirGL2+Venus tuple passed a 15-minute physical Developer-ID calibration on Apple M2 Pro with GNOME, Firefox, Files, Calculator, Settings, Terminal, sustained VirGL OpenGL, Venus Vulkan WSI, and native-Venus Zed; no rejected resource flush or device loss occurred. Public support remains closed until the same tuple is release-signed/notarized and passes the complete release matrix; software display remains the recovery path | | Existing desktop updates | Signed in-place package, browser, guest-integration, and kernel updates with a retained last-good snapshot and automatic failure/interruption rollback | | Custom arm64 installer ISO | Preview: architecture preflight, exact-media SHA-256/runtime evidence, native EFI boot, private managed ISO copy and recovery console, thin-provisioned disk, persistent machine identity/NVRAM, and attach/eject lifecycle | @@ -89,6 +89,13 @@ Architecture compatibility does not imply runtime qualification: Dory records th host model, and macOS build, blocks known-unstable tuples, and labels unseen combinations as unqualified. +Rosetta translates x86_64 Linux applications inside an eligible ARM64 Linux VM; Dory's current +container path uses FEX for the same application-level boundary. Neither boots an Intel distro, +kernel, or installer. Dory exposes no partial x86 VM mode. A future whole-system x86 product would +require a complete packaged QEMU TCG backend with independently qualified firmware, devices, +lifecycle, security, recovery, and performance, as defined by the +[whole-machine emulation contract](docs/x86-linux-whole-machine-emulation.md). + ## Networking | Capability | Status | @@ -145,6 +152,8 @@ grants; `full` is an explicit unrestricted choice. See the builds. Remembered replay remains unavailable. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. +- Intel/x86_64 Linux installer ISO boot is unavailable; application translation inside an ARM64 + guest does not change that boundary. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are unavailable in 0.4. diff --git a/Config/DoryFSWorker-Info.plist b/Config/DoryFSWorker-Info.plist new file mode 100644 index 00000000..ce3e5752 --- /dev/null +++ b/Config/DoryFSWorker-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Filesystem Worker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DoryFSWorker + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + XPCService + + ServiceType + Application + + + diff --git a/Config/DoryHVRunner-Info.plist b/Config/DoryHVRunner-Info.plist new file mode 100644 index 00000000..d0bdffec --- /dev/null +++ b/Config/DoryHVRunner-Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Linux + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DoryHVRunner + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHighResolutionCapable + + NSLocalNetworkUsageDescription + Dory connects the Linux guest to local host services and published ports. + NSMicrophoneUsageDescription + Dory shares your Mac microphone with Linux desktop machines when you use audio input. + NSPrincipalClass + NSApplication + + diff --git a/Config/DoryRendererProductionTuple.json b/Config/DoryRendererProductionTuple.json new file mode 100644 index 00000000..2667233e --- /dev/null +++ b/Config/DoryRendererProductionTuple.json @@ -0,0 +1,624 @@ +{ + "architecture": "arm64", + "artifactProfiles": { + "rendererBundle": { + "angleMetal": [ + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libEGL.dylib", + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libGLESv2.dylib" + ], + "rendererWorker": [ + "XPCServices/DoryRendererWorker.xpc/Contents/MacOS/DoryRendererWorker" + ] + }, + "rendererQualificationEvidence": { + "qualification": [ + "Resources/renderer-bootstrap-qualification.json" + ] + }, + "rendererReleaseQualificationEvidence": { + "qualification": [ + "Resources/renderer-bootstrap-qualification.json" + ], + "releaseSignature": [ + "Resources/renderer-bootstrap-qualification.json.sig" + ] + }, + "staticDependencies": { + "angleHeaders": [ + "include/ANGLE/EGL/egl.h", + "include/ANGLE/EGL/eglext.h", + "include/ANGLE/EGL/eglplatform.h", + "include/ANGLE/KHR/khrplatform.h" + ], + "angleMetal": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ], + "libepoxy": [ + "include/epoxy/common.h", + "include/epoxy/egl.h", + "include/epoxy/egl_angle_ext_generated.h", + "include/epoxy/egl_generated.h", + "include/epoxy/gl.h", + "include/epoxy/gl_generated.h", + "lib/libepoxy.a" + ], + "moltenVK": [ + "lib/libMoltenVK.a" + ] + }, + "staticLinkClosure": { + "angleHeaders": [ + "include/ANGLE/EGL/egl.h", + "include/ANGLE/EGL/eglext.h", + "include/ANGLE/EGL/eglplatform.h", + "include/ANGLE/KHR/khrplatform.h" + ], + "linkContract": [ + "renderer-static-link.json" + ], + "angleMetal": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ], + "libepoxy": [ + "lib/libepoxy.a" + ], + "libepoxyHeaders": [ + "include/epoxy/common.h", + "include/epoxy/egl.h", + "include/epoxy/egl_angle_ext_generated.h", + "include/epoxy/egl_generated.h", + "include/epoxy/gl.h", + "include/epoxy/gl_generated.h" + ], + "moltenVK": [ + "lib/libMoltenVK.a" + ], + "virglrenderer": [ + "lib/libvirglrenderer.a" + ] + } + }, + "dependencyBuildPolicy": { + "compatibilityPatches": [ + { + "path": "patches/angle-shaderlang-trivial-copy-contract-backport.patch", + "resultBlob": "c2b52771aa71976133988ea026990357567733ca", + "sha256": "cf8b52e104dbc931efb064994c3b41df362b02e7921fb2cba32f48b450b2d4e2", + "source": "angle", + "sourceBlob": "5740d2f8bdd1e3a8aa25888d64e33d1daf9152c8", + "targetPath": "Source/ThirdParty/ANGLE/include/GLSLANG/ShaderLang.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-shift-count-overflow-backport.patch", + "resultBlob": "2f8c007fdf19b55881de0e6b1be753527a406053", + "sha256": "3d8f6b819e8dbb509adbb35c9774664dc125ff0247c835cf1550748a95313ca4", + "source": "angle", + "sourceBlob": "e9592d7f74221bd940ef0dc7df2ade4092c9a959", + "targetPath": "Source/ThirdParty/ANGLE/src/common/bitset_utils.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-shaderlang-trivial-copy-implementation-backport.patch", + "resultBlob": "f14f3c263c58a86501370909bbb5f8340949689a", + "sha256": "1e6a8e5b758ecec0bc1f8de26168a5782c60b764df41526910689cc510b43d43", + "source": "angle", + "sourceBlob": "44290593b5f88f4a0543e5aa117ca99075b7d805", + "targetPath": "Source/ThirdParty/ANGLE/src/compiler/translator/ShaderLang.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-final-virtual-destructor-backport.patch", + "resultBlob": "ed0ae84953bf570359500ddc6fcc14ad0741885d", + "sha256": "bdf2aa8ef0df46f78e4b7fbc10f33de96883cee8b86b346609fee2df762ece73", + "source": "angle", + "sourceBlob": "5939bfb19cbb5682db11edefb3f8bd519b4343f0", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/Fence.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-gles1-shader-state-logical-key-backport.patch", + "resultBlob": "a01628a1ec67aaa0a03770b9beed85d830067827", + "sha256": "6ab78c828ae72a7a7360f56e00259097c7c436b61dc2885823264bdf5a3180fc", + "source": "angle", + "sourceBlob": "6514bb7256cb5d703f239400441e19c6761aced3", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/GLES1Renderer.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-gles1-shader-state-copy-contract-backport.patch", + "resultBlob": "dbaedb48811456372ec58886a62146ea4610d0c6", + "sha256": "43c2f3015a4afc15332385ed8054c96d3cbc8b8a7d13c4198e166909d0a87054", + "source": "angle", + "sourceBlob": "98c7894ad52c14c3fe9e09b8d662f32775ee4bfe", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/GLES1Renderer.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-logical-state-copy-backport.patch", + "resultBlob": "d379d6a6e6b1a172a3806bc25b6ebe9152db0685", + "sha256": "0220a77647e8563ced7be7b7bf461642b2301d75dd2fc7a91148f2a149b52e19", + "source": "angle", + "sourceBlob": "7f9f56de4a38303b45240b38c14f7733da5dc14b", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-logical-state-constructor-contract-backport.patch", + "resultBlob": "d9d7d51a332b23b369cb1b168e09add62031d349", + "sha256": "33904c0a00c1bac9b4b72043338d37f7640e47684e226d569766b3bbb4fab3ed", + "source": "angle", + "sourceBlob": "af30688ebebb614d622e1acb88ddc80068bc1a29", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-sampler-state-logical-equality-backport.patch", + "resultBlob": "36f9e9497f142d8993591935c32b6956e1ca81e4", + "sha256": "8b91ad5dd79eeabbb66ce0c70b5dd0c78096fa1dc7d7adf7b42d02432cc4a720", + "source": "angle", + "sourceBlob": "862a3214a13a47150c29ffd72577bc7b11fe7a04", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.inc", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-metal-blit-logical-base-copy-backport.patch", + "resultBlob": "1e628af0e9364b4ac9714a4fdb0ed4178921742c", + "sha256": "7729a2a31ab493b5f5de3d4351728636a537e2d87bb0675dce42ecbad21ce5f2", + "source": "angle", + "sourceBlob": "860f798cf68bb4fad87bee1ea9cd507d95767fd1", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/FrameBufferMtl.mm", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-metal-state-cache-logical-key-backport.patch", + "resultBlob": "8ab57b5fd510d6ce647d3da645a216609533a608", + "sha256": "6fdb6ef913707c8ae91526bacdd4be9d0b003e76a598d3730f4e76dd9f3b9ecd", + "source": "angle", + "sourceBlob": "7b2e9926b29407d7870b0da20a6591d0982146e7", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_state_cache.mm", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/libepoxy-dory-angle-rpath.patch", + "resultBlob": "b61354a4cfd12478c9a7d73a05c465cb7bed7ff9", + "sha256": "63a6efddab3c20ef014a8d29b434c60d7f18609700edc7b30806d0ec8e584da0", + "source": "libepoxy", + "sourceBlob": "770563c9cb70dc0a6f960968752c747bf5865ea6", + "targetPath": "src/dispatch_common.c", + "upstreamRepository": "https://github.com/utmapp/libepoxy.git", + "upstreamRevision": "15d904dcb1d5a8d626ffe11e8f3339499d6f7b09" + }, + { + "path": "patches/moltenvk-fail-closed-robustness2.patch", + "resultBlob": "110c542637a68618e797e5d8b83cf95f2ee6bb39", + "sha256": "7c90f042dadd6fcb805843faa7b78fef835b16283a744652a2b9ead3e8b0d609", + "source": "moltenVK", + "sourceBlob": "612a527083ab6059c5349414457cd52e439f3cd7", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKDevice.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-hidden-vulkan-alias.patch", + "resultBlob": "878e5ca82468bd1e827c5b28d198ff3be1e12dc5", + "sha256": "be4aec6b7fc08fadc785c1b10bc66439d8882b9a025c0106d7adfac9cefaf646", + "source": "moltenVK", + "sourceBlob": "3071c3f89e9eca8b1020ec2ba38dd30f02b0bb87", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKInstance.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-sync-fd-binary-consumption.patch", + "resultBlob": "51752fd183ae00c55c92bbdd97a4ccab03f0960c", + "sha256": "588504b0474eb2130d273cc2bfb31dc9672fbb9cdc3ad4cdbb621b4cc08274d7", + "source": "moltenVK", + "sourceBlob": "34022876aaa3d333b043049f48db81403f85fc73", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKSync.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-hidden-vulkan-icd-entrypoints.patch", + "resultBlob": "588a5fb8b44f8e7b5b258051f2e819acb80a8bec", + "sha256": "feb558dfc75622bd6ebb5e89b5d4ab5d21bd14b26d442ec8eae791228189de69", + "source": "moltenVK", + "sourceBlob": "198b8473c6bdd9977b805c5a8854cfd1bcd0849c", + "targetPath": "MoltenVK/MoltenVK/Vulkan/vulkan.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-dory-native-arrays.patch", + "resultBlob": "59915f75a5294d5f54647cb5e02c87aa162b6045", + "sha256": "1485bc300e1b8969121b90a34011f53f8c3b1a1c49a2da521b72cc040d2f4877", + "source": "moltenVK", + "sourceBlob": "b52730b2afc1ce97d886fc9a782d10e1bfef6e8d", + "targetPath": "MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + } + ], + "angleMetalProduct": { + "archiveScheme": "ANGLE", + "eglInstallName": "@loader_path/libEGL.dylib", + "glesInstallName": "@loader_path/libGLESv2.dylib", + "runtimePaths": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ] + }, + "libcxxHardeningMode": "extensive", + "libepoxyStaticProduct": { + "archivePath": "lib/libepoxy.a", + "eglResolver": "@loader_path/../Frameworks/libEGL.dylib", + "glesResolver": "@loader_path/../Frameworks/libGLESv2.dylib" + }, + "maximumConcurrentBuildJobs": 3, + "moltenVKStaticProduct": { + "archivePath": "lib/libMoltenVK.a", + "hideVulkanSymbols": true, + "requiredEntrypoint": "vkGetInstanceProcAddr" + }, + "moltenVKSemaphoreQualification": { + "importExportCycles": 2, + "path": "scripts/renderer-moltenvk-semaphore-probe.m", + "sha256": "156a9ffc2e1b435872866e2f2ae381fea32b922c9830c53c0b8256f382ddbd16", + "signalExportCycles": 2, + "style": "mtlEvent" + }, + "moltenVKScanoutCopyQualification": { + "destinationTiling": "linear", + "directLinearColorAttachment": false, + "formats": [ + "bgra8-unorm", + "rgba8-unorm" + ], + "path": "scripts/renderer-moltenvk-scanout-copy-probe.m", + "readback": "mapped-host-visible", + "renderTiling": "optimal", + "sha256": "d9affc819971f5aee4a7140862283126aaae8cc69c2f8f36a2dfe63bf971a61e", + "transfer": "vkCmdCopyImage" + }, + "warningSuppression": false, + "zeroFuzzPatchApplication": true + }, + "dependencySources": { + "moltenVK": { + "cereal": { + "repository": "https://github.com/USCiLab/cereal.git", + "revision": "a56bad8bbb770ee266e930c95d37fff2a5be7fea", + "tree": "31169d00742fa22795e582f47bce5a0eeafecdad" + }, + "spirvCross": { + "repository": "https://github.com/utmapp/SPIRV-Cross.git", + "revision": "939b40b33a44443c404c4078823c406e3c94866f", + "tree": "09cb57c85e770936a6390ed3f891363075a0b67a" + }, + "spirvHeaders": { + "repository": "https://github.com/KhronosGroup/SPIRV-Headers.git", + "revision": "b824a462d4256d720bebb40e78b9eb8f78bbb305", + "tree": "4f5ba6304fde3759b6e42e94f36499802c6f3bdb" + }, + "spirvTools": { + "repository": "https://github.com/KhronosGroup/SPIRV-Tools.git", + "revision": "262bdab48146c937467f826699a40da0fdfc0f1a", + "tree": "0252479f729a6f5f2064a36d89044c5326171566" + }, + "volk": { + "repository": "https://github.com/zeux/Volk.git", + "revision": "59660878571aa99e3c9a366bb1d19fdcd701f0e7", + "tree": "ba6a6ea0d7b581d637a3c8822a71ffb743c0c007" + }, + "vulkanHeaders": { + "repository": "https://github.com/KhronosGroup/Vulkan-Headers.git", + "revision": "6aefb8eb95c8e170d0805fd0f2d02832ec1e099a", + "tree": "b6a7d1d42b0439ba90a2927d94ed098c9111cc2d" + }, + "vulkanTools": { + "repository": "https://github.com/KhronosGroup/Vulkan-Tools.git", + "revision": "013058f74e2356347f8d9317233bc769816c9dfb", + "tree": "9e704d67ff5461b87f360dc194c09b67c805b594" + } + } + }, + "guestMesaBuildPolicy": { + "applicationReadiness": { + "application": "zed", + "applicationRevision": "eb8e1c8b5502b7007465fbbc465f4a736fa39210", + "applicationTag": "v1.16.1", + "atlasFormats": [ + "bgra8-unorm", + "rgba8-unorm" + ], + "atlasUsages": [ + "copy-destination", + "texture-binding" + ], + "backend": "vulkan", + "minimumVulkanAPI": "1.3", + "presentMode": "fifo", + "sourceRepository": "https://github.com/zed-industries/zed.git", + "surfaceExtent": "64x64", + "surfaceFormatPolicy": "first-capability-format", + "wgpuRevision": "e99f5305ded96ff7006f0714d043a7f735bd45c2", + "wgpuVersion": "29.0.4" + }, + "builderImage": "debian:bullseye-slim@sha256:f313b4bd62667092a59b3a664d7d3ab8b5e65f41675f48e81455a15dc5abe792", + "builderSnapshot": "20260713T000000Z", + "compositorProbeNeededSONAMEs": [ + "libc.so.6", + "libvulkan.so.1" + ], + "compositorProfile": "native-vulkan-optimal-copy-compositor-v2", + "compositorProfileSourceCommit": "329a88e72424486180ff3339440fa9f8f711af02", + "compositorProfileSourceTree": "ef4676a2279bd364c645f31cd0e1e1cb238e0e0c", + "glibcSymbolCeiling": "GLIBC_2.31", + "hiddenQueueSubmission": false, + "icdNeededSONAMEs": [ + "libX11-xcb.so.1", + "libc.so.6", + "libdl.so.2", + "libm.so.6", + "libpthread.so.0", + "libwayland-client.so.0", + "libxcb-dri3.so.0", + "libxcb-keysyms.so.1", + "libxcb-present.so.0", + "libxcb-randr.so.0", + "libxcb-shm.so.0", + "libxcb-sync.so.1", + "libxcb-xfixes.so.0", + "libxcb.so.1", + "libxshmfence.so.1", + "libz.so.1", + "libzstd.so.1" + ], + "inputSHA256": "19a55684e03b26053f504982ebbbd85f31d198bcaeb307239689fd11189f17e9", + "libcFamily": "glibc", + "libdrmLinkage": "static-hidden", + "manifestLibraryPath": "../../../lib/libvulkan_virtio.so", + "maxGLIBCSymbol": "GLIBC_2.29", + "mesonVersion": "1.10.0", + "mesonWheelSHA256": "4b27aafce281e652dcb437b28007457411245d975c48b5db3a797d3e93ae1585", + "packLayout": "single-tree", + "patches": [], + "probeNeededSONAMEs": [ + "libc.so.6", + "libvulkan.so.1", + "libwayland-client.so.0", + "libxcb.so.1" + ], + "requiredDeviceExtensions": [ + "VK_KHR_external_semaphore_fd", + "VK_KHR_swapchain" + ], + "requiredInstanceExtensions": [ + "VK_KHR_surface", + "VK_KHR_wayland_surface", + "VK_KHR_xcb_surface" + ], + "requiredVulkan13Features": [ + "dynamicRendering", + "maintenance4", + "synchronization2" + ], + "runtimeArtifactPath": "guest/out/dory-mesa-venus-arm64.tar.zst", + "runtimeDigestContract": "DoryRendererArtifactManifest.guestMesa", + "runtimeManifestSchema": 6, + "runtimeSHA256": "fa12e2bef9855dd382c3cd7f1dcd434f65302fc13471ae06367179f1ad37124c", + "sourceDateEpoch": 1767751301, + "standardWSISemaphorePath": "sync-fd-minus-one", + "vulkanAPI": "1.3", + "waylandProtocolsVersion": "1.41", + "waylandVersion": "1.20.0", + "wsi": [ + "x11", + "wayland" + ], + "wsiSurfaceGate": [ + "xcb", + "wayland" + ] + }, + "kind": "dev.dory.renderer-production-tuple", + "platform": "macos", + "producerFence": { + "failClosedPatch": { + "path": "guest/kernel/patches/6.12.30/0008-virtio-gpu-fail-closed-on-producer-fence-error.patch", + "sha256": "53f0db7b102f53c6d3aee13dc43cf45dbfedd5dabd0c04026b0b0c709c13953d" + }, + "kernelVersion": "6.12.30", + "prepareFramebufferPatch": { + "path": "guest/kernel/patches/6.12.30/0007-virtio-gpu-wait-for-scanout-producers.patch", + "sha256": "fce10edac1be12b24f480141bf5fa5f0df139dec86e5bd3c798f245f48ed5f82" + } + }, + "schemaVersion": 3, + "sourceTuple": "dory-dual-metal-20260826", + "sources": { + "angle": { + "repository": "https://github.com/utmapp/WebKit.git", + "revision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7", + "subdirectory": "Source/ThirdParty/ANGLE", + "tree": "6ff242f54d40a2aaf08ed74b6a4bfe65a8bf447f" + }, + "libepoxy": { + "repository": "https://github.com/utmapp/libepoxy.git", + "revision": "15d904dcb1d5a8d626ffe11e8f3339499d6f7b09", + "tree": "86af2dd5a68cb45140eef8a1165cd5bfd23a03e8" + }, + "mesa": { + "repository": "https://gitlab.freedesktop.org/osy/mesa.git", + "revision": "79bc850d884a1307356ff61c017e58901b90c7e2", + "tree": "585b6604e6ef58585cfc44f7b4d5eab172ddfbbd" + }, + "moltenVK": { + "repository": "https://github.com/utmapp/MoltenVK.git", + "revision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384", + "tree": "14f470cffb6b74c5e72647925a0c7f83ba64abb8" + }, + "virglrenderer": { + "repository": "https://github.com/utmapp/virglrenderer.git", + "revision": "65cc14eb896f121ffc5130ce04815a923a03c41d", + "tree": "94dc34ffde98cf70f0c11fe921bec10a09d3907f" + } + }, + "toolchain": { + "appleClang": "Apple clang version 21.0.0 (clang-2100.1.1.101)", + "cmake": "4.2.3", + "meson": "1.12.0", + "ninja": "1.13.2", + "pkgConfig": "2.5.1", + "xcodeBuild": "17F109", + "xcodeVersion": "26.6" + }, + "virglBuildPolicy": { + "checkGLErrors": false, + "classicRenderer": "virgl2-angle-metal", + "defaultLibrary": "static", + "drmRenderers": [], + "fuzzer": false, + "minimumMacOS": "15.0", + "metalSharedTextureQualification": { + "allocation": "newSharedTextureWithDescriptor", + "crossProcessHandle": true, + "path": "scripts/renderer-virgl-metal-shared-texture-probe.m", + "renderImportReadback": true, + "sha256": "1b939c36c82fa29eb17a6b3c2405f3b4cb1e42ad6196eb5815fbcec66fe7d622", + "storageMode": "private" + }, + "minigbmAllocation": false, + "neptune": false, + "platforms": [ + "egl" + ], + "renderServerMode": "thread", + "renderServerWorker": "thread", + "requiredCapsets": [ + 2, + 4 + ], + "sourcePatches": [ + { + "path": "patches/virglrenderer-venus-only-static.patch", + "sha256": "e067deb5086f70d4781bd4877c1df94cfb95f2f7c4f62c72570996b5dc4c736a", + "targets": { + "config.h.meson": { + "resultBlob": "fc7d181899b2f4ca14ca8672e30f32659084a831", + "sourceBlob": "99ead1ab20f15ebf01eeb3549659334c2b6abc08" + }, + "meson.build": { + "resultBlob": "7ed632df5fa6ed61f433c631d30df8208774cb25", + "sourceBlob": "b863eccba1acff2ffaa5ee9160640721e58043ab" + }, + "meson_options.txt": { + "resultBlob": "d1dbd265391de867e7c35b8c7de0df83f0fa8d6f", + "sourceBlob": "1862ee50f0230d00ec672ac70d175a27c4ff4bcf" + }, + "server/render_protocol.h": { + "resultBlob": "821dca2db428ca2e47f3a0cadaeb31a23d50e783", + "sourceBlob": "9dd4c48f3a6f68b628e6b4eefc1fb0cf8e2d7dab" + }, + "src/gallium/meson.build": { + "resultBlob": "e9a1487dcc51a9f28c6e682d0e095e124eb4d3eb", + "sourceBlob": "ac8f8be23edf640329dd6b9c0e7c1c5392259bb8" + }, + "src/meson.build": { + "resultBlob": "78434d835650c1267d6a65a20dccb354213b56e8", + "sourceBlob": "e6e48ca5bdb157c8ce7a4a8939dc792246e90aa7" + }, + "src/proxy/proxy_context.c": { + "resultBlob": "736b013aa603083c0d76ed338284431aabffcfdf", + "sourceBlob": "9b334532803bd294505dbbfd28afae977b19edbb" + }, + "src/proxy/proxy_context.h": { + "resultBlob": "662fcaac39c98353050d3f047a04c7c03fb40482", + "sourceBlob": "ce29ecaea7fa06f4e6cc8d6d1646099489423aa7" + }, + "src/virglrenderer.c": { + "resultBlob": "96992b73af8f1c39cedf5fcc3626edb2247152e7", + "sourceBlob": "71076727dbafd4a4f9432a3af03cf439ebd7907e" + } + } + }, + { + "path": "patches/virglrenderer-dory-submit-failure-offset.patch", + "sha256": "ee4ba95725e2fd505ee823610cd227a45812389f3611c6fbe93385151bd488a6", + "targets": { + "src/vrend/vrend_decode.c": { + "resultBlob": "bdba09859231455ea0a77c96c87e3d6302418a57", + "sourceBlob": "7774a253bc4532f95edeb92874a8987bc2e3ddd9" + } + } + }, + { + "path": "patches/virglrenderer-linear-modifier-contract.patch", + "sha256": "03e2c23c43e38c080291ba0d5aaf61c03f85bf9702fed7cf0e5324374034639c", + "targets": { + "src/venus/vkr_physical_device.c": { + "resultBlob": "a7a3d3c6710723f232466f36eda84ee1a750a0ff", + "sourceBlob": "e8558156a7b674c78b13b0714120f247a1946739" + } + } + }, + { + "path": "patches/virglrenderer-metal-shm-external-memory.patch", + "sha256": "ec642b18929c9ee66e4b0ab073ebe0b86754bdbcca781f26dea485cbca10a497", + "targets": { + "src/venus/vkr_context.c": { + "resultBlob": "5c79451cab9d6509d2a20d2c3e5e42222e97d010", + "sourceBlob": "2ea589881710405f68819469f455133558b6f9e2" + }, + "src/venus/vkr_context.h": { + "resultBlob": "e765a177f86613a5e6ccc026bbb17057f265329a", + "sourceBlob": "12541a72d2e948b2ebf106702ae403c1a8cc073f" + }, + "src/venus/vkr_device_memory.c": { + "resultBlob": "d65e979dc4875a63c9bb05e81c0eeffab6684e73", + "sourceBlob": "b2e606f738591ecd7561f44750bb921c14f316ef" + }, + "src/venus/vkr_metal_helpers.h": { + "resultBlob": "a92ff8a1f9357655b45714ee438affd3fc58cafc", + "sourceBlob": "4f5208bd1720210c6d89ce4177a45b6887687b77" + }, + "src/venus/vkr_metal_helpers.m": { + "resultBlob": "7cab45751332bdf26bd67176cf43edf2292c1ff7", + "sourceBlob": "f0373ed10bf23bc0a8dde6e9a6a542325b93a4aa" + } + } + }, + { + "path": "patches/virglrenderer-metal-shareable-scanout.patch", + "sha256": "2f7505d21a29361c0cce47a986a678f79823bd22214fcdacf947451aeb9ee658", + "targets": { + "src/vrend/vrend_metal.m": { + "resultBlob": "750a2b44848833bcf2fd668eef969c920979bbe7", + "sourceBlob": "e8ded745d272cecd5b54b6e59ee7739e543c2194" + } + } + } + ], + "tests": false, + "unstableAPIs": true, + "venus": true, + "venusOnly": false, + "video": false, + "vtest": false, + "vulkanDynamicLoad": false + } +} diff --git a/Config/DoryRendererWorker-Info.plist b/Config/DoryRendererWorker-Info.plist new file mode 100644 index 00000000..1f6735f8 --- /dev/null +++ b/Config/DoryRendererWorker-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Renderer Worker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DoryRendererWorker + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + XPCService + + ServiceType + Application + + + diff --git a/Dory.xcodeproj/project.pbxproj b/Dory.xcodeproj/project.pbxproj index ae4147fa..8e7b5e69 100644 --- a/Dory.xcodeproj/project.pbxproj +++ b/Dory.xcodeproj/project.pbxproj @@ -11,6 +11,23 @@ AA00000000000000000000B3 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000B2 /* SwiftTerm */; }; AA00000000000000000000C3 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000C2 /* Sparkle */; }; AA00000000000000000000D3 /* DoryOperations in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000D2 /* DoryOperations */; }; + D0B100000000000000000001 /* DoryHV in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000001 /* DoryHV */; }; + D0B100000000000000000002 /* DoryCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000002 /* DoryCore */; }; + D0B100000000000000000003 /* DorydKit in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000003 /* DorydKit */; }; + D0B100000000000000000004 /* DoryOperations in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000004 /* DoryOperations */; }; + D0B100000000000000000005 /* DoryVMContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000005 /* DoryVMContracts */; }; + D0B100000000000000000006 /* DoryVMMKit in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000006 /* DoryVMMKit */; }; + D0B100000000000000000007 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000002 /* AppKit.framework */; }; + D0B100000000000000000008 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000003 /* AVFAudio.framework */; }; + D0B100000000000000000009 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000004 /* AVFoundation.framework */; }; + D0B10000000000000000000B /* DoryHVRunner.app in Embed Linux VM Runner */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000001 /* DoryHVRunner.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0C100000000000000000001 /* DoryFSWorker.xpc in Embed Filesystem Worker */ = {isa = PBXBuildFile; fileRef = D0C200000000000000000001 /* DoryFSWorker.xpc */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0C100000000000000000002 /* DoryFSWorkerContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0C700000000000000000001 /* DoryFSWorkerContracts */; }; + D0C100000000000000000003 /* DoryFSWorkerServiceCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0C700000000000000000002 /* DoryFSWorkerServiceCore */; }; + D0D100000000000000000001 /* DoryRendererWorker.xpc in Embed Renderer Worker */ = {isa = PBXBuildFile; fileRef = D0D200000000000000000001 /* DoryRendererWorker.xpc */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0D100000000000000000002 /* DoryRendererWorkerContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000001 /* DoryRendererWorkerContracts */; }; + D0D100000000000000000003 /* DoryRendererWorkerServiceCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000002 /* DoryRendererWorkerServiceCore */; }; + D0D100000000000000000004 /* DoryRendererWorkerVirglBackend in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -25,6 +42,39 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + D0B400000000000000000004 /* Embed Linux VM Runner */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Helpers; + dstSubfolderSpec = 1; + files = ( + D0B10000000000000000000B /* DoryHVRunner.app in Embed Linux VM Runner */, + ); + name = "Embed Linux VM Runner"; + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000004 /* Embed Filesystem Worker */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/XPCServices; + dstSubfolderSpec = 1; + files = ( + D0C100000000000000000001 /* DoryFSWorker.xpc in Embed Filesystem Worker */, + ); + name = "Embed Filesystem Worker"; + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000004 /* Embed Renderer Worker */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/XPCServices; + dstSubfolderSpec = 1; + files = ( + D0D100000000000000000001 /* DoryRendererWorker.xpc in Embed Renderer Worker */, + ); + name = "Embed Renderer Worker"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -45,6 +95,27 @@ remoteGlobalIDString = AA100000000000000000000A; remoteInfo = DoryStorageProvider; }; + D0B800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B500000000000000000001; + remoteInfo = DoryHVRunner; + }; + D0C800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0C500000000000000000001; + remoteInfo = DoryFSWorker; + }; + D0D800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D500000000000000000001; + remoteInfo = DoryRendererWorker; + }; 3E705CFD2FE37C7B0094B33C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 3E705CE72FE37C790094B33C /* Project object */; @@ -66,6 +137,12 @@ 3E705CEF2FE37C790094B33C /* Dory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Dory.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3E705CFC2FE37C7B0094B33C /* DoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3E705D062FE37C7B0094B33C /* DoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B200000000000000000001 /* DoryHVRunner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DoryHVRunner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B200000000000000000002 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; + D0B200000000000000000003 /* AVFAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFAudio.framework; path = System/Library/Frameworks/AVFAudio.framework; sourceTree = SDKROOT; }; + D0B200000000000000000004 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + D0C200000000000000000001 /* DoryFSWorker.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = DoryFSWorker.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + D0D200000000000000000001 /* DoryRendererWorker.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = DoryRendererWorker.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -97,6 +174,24 @@ path = DoryUITests; sourceTree = ""; }; + D0B300000000000000000001 /* DoryHVRunner Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryHVRunner Sources"; + path = Packages/ContainerizationEngine/Sources/dory-hv; + sourceTree = ""; + }; + D0C300000000000000000001 /* DoryFSWorker Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryFSWorker Sources"; + path = Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService; + sourceTree = ""; + }; + D0D300000000000000000001 /* DoryRendererWorker Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryRendererWorker Sources"; + path = Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -125,6 +220,41 @@ files = ( ); }; + D0B400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B100000000000000000001 /* DoryHV in Frameworks */, + D0B100000000000000000002 /* DoryCore in Frameworks */, + D0B100000000000000000003 /* DorydKit in Frameworks */, + D0B100000000000000000004 /* DoryOperations in Frameworks */, + D0B100000000000000000005 /* DoryVMContracts in Frameworks */, + D0B100000000000000000006 /* DoryVMMKit in Frameworks */, + D0B100000000000000000007 /* AppKit.framework in Frameworks */, + D0B100000000000000000008 /* AVFAudio.framework in Frameworks */, + D0B100000000000000000009 /* AVFoundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0C100000000000000000002 /* DoryFSWorkerContracts in Frameworks */, + D0C100000000000000000003 /* DoryFSWorkerServiceCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0D100000000000000000002 /* DoryRendererWorkerContracts in Frameworks */, + D0D100000000000000000003 /* DoryRendererWorkerServiceCore in Frameworks */, + D0D100000000000000000004 /* DoryRendererWorkerVirglBackend in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -132,18 +262,35 @@ isa = PBXGroup; children = ( 3E705CF12FE37C790094B33C /* Dory */, + D0B300000000000000000001 /* DoryHVRunner Sources */, + D0C300000000000000000001 /* DoryFSWorker Sources */, + D0D300000000000000000001 /* DoryRendererWorker Sources */, AA1000000000000000000004 /* DoryStorageShared */, AA1000000000000000000005 /* DoryStorageProvider */, 3E705CFF2FE37C7B0094B33C /* DoryTests */, 3E705D092FE37C7B0094B33C /* DoryUITests */, + D0B300000000000000000002 /* Frameworks */, 3E705CF02FE37C790094B33C /* Products */, ); sourceTree = ""; }; + D0B300000000000000000002 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D0B200000000000000000002 /* AppKit.framework */, + D0B200000000000000000003 /* AVFAudio.framework */, + D0B200000000000000000004 /* AVFoundation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 3E705CF02FE37C790094B33C /* Products */ = { isa = PBXGroup; children = ( 3E705CEF2FE37C790094B33C /* Dory.app */, + D0B200000000000000000001 /* DoryHVRunner.app */, + D0C200000000000000000001 /* DoryFSWorker.xpc */, + D0D200000000000000000001 /* DoryRendererWorker.xpc */, AA1000000000000000000003 /* DoryStorageProvider.appex */, 3E705CFC2FE37C7B0094B33C /* DoryTests.xctest */, 3E705D062FE37C7B0094B33C /* DoryUITests.xctest */, @@ -163,11 +310,13 @@ 3E705CED2FE37C790094B33C /* Resources */, AA1000000000000000000009 /* Embed App Extensions */, AA00000000000000000000D0 /* Prune Stale Bundled Helpers */, + D0B400000000000000000004 /* Embed Linux VM Runner */, ); buildRules = ( ); dependencies = ( AA100000000000000000000B /* PBXTargetDependency */, + D0B800000000000000000002 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 3E705CF12FE37C790094B33C /* Dory */, @@ -206,6 +355,86 @@ productReference = AA1000000000000000000003 /* DoryStorageProvider.appex */; productType = "com.apple.product-type.app-extension"; }; + D0B500000000000000000001 /* DoryHVRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryHVRunner" */; + buildPhases = ( + D0B400000000000000000003 /* Sources */, + D0B400000000000000000001 /* Frameworks */, + D0B400000000000000000002 /* Resources */, + D0C400000000000000000004 /* Embed Filesystem Worker */, + D0D400000000000000000004 /* Embed Renderer Worker */, + D0E400000000000000000001 /* Package Exact Renderer Tuple */, + ); + buildRules = ( + ); + dependencies = ( + D0C800000000000000000002 /* PBXTargetDependency */, + D0D800000000000000000002 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + D0B300000000000000000001 /* DoryHVRunner Sources */, + ); + name = DoryHVRunner; + packageProductDependencies = ( + D0B700000000000000000001 /* DoryHV */, + D0B700000000000000000002 /* DoryCore */, + D0B700000000000000000003 /* DorydKit */, + D0B700000000000000000004 /* DoryOperations */, + D0B700000000000000000005 /* DoryVMContracts */, + D0B700000000000000000006 /* DoryVMMKit */, + ); + productName = DoryHVRunner; + productReference = D0B200000000000000000001 /* DoryHVRunner.app */; + productType = "com.apple.product-type.application"; + }; + D0C500000000000000000001 /* DoryFSWorker */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0C600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryFSWorker" */; + buildPhases = ( + D0C400000000000000000003 /* Sources */, + D0C400000000000000000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D0C300000000000000000001 /* DoryFSWorker Sources */, + ); + name = DoryFSWorker; + packageProductDependencies = ( + D0C700000000000000000001 /* DoryFSWorkerContracts */, + D0C700000000000000000002 /* DoryFSWorkerServiceCore */, + ); + productName = DoryFSWorker; + productReference = D0C200000000000000000001 /* DoryFSWorker.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; + D0D500000000000000000001 /* DoryRendererWorker */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0D600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryRendererWorker" */; + buildPhases = ( + D0D400000000000000000003 /* Sources */, + D0D400000000000000000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D0D300000000000000000001 /* DoryRendererWorker Sources */, + ); + name = DoryRendererWorker; + packageProductDependencies = ( + D0D700000000000000000001 /* DoryRendererWorkerContracts */, + D0D700000000000000000002 /* DoryRendererWorkerServiceCore */, + D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */, + ); + productName = DoryRendererWorker; + productReference = D0D200000000000000000001 /* DoryRendererWorker.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; 3E705CFB2FE37C7B0094B33C /* DoryTests */ = { isa = PBXNativeTarget; buildConfigurationList = 3E705D112FE37C7B0094B33C /* Build configuration list for PBXNativeTarget "DoryTests" */; @@ -261,6 +490,15 @@ AA100000000000000000000A = { CreatedOnToolsVersion = 27.0; }; + D0B500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + D0C500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + D0D500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; 3E705CEE2FE37C790094B33C = { CreatedOnToolsVersion = 27.0; }; @@ -285,6 +523,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */, + D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */, AA00000000000000000000B1 /* XCRemoteSwiftPackageReference "SwiftTerm" */, AA00000000000000000000C1 /* XCRemoteSwiftPackageReference "Sparkle" */, ); @@ -294,6 +533,9 @@ projectRoot = ""; targets = ( 3E705CEE2FE37C790094B33C /* Dory */, + D0B500000000000000000001 /* DoryHVRunner */, + D0C500000000000000000001 /* DoryFSWorker */, + D0D500000000000000000001 /* DoryRendererWorker */, AA100000000000000000000A /* DoryStorageProvider */, 3E705CFB2FE37C7B0094B33C /* DoryTests */, 3E705D052FE37C7B0094B33C /* DoryUITests */, @@ -324,9 +566,42 @@ files = ( ); }; + D0B400000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + D0E400000000000000000001 /* Package Exact Renderer Tuple */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Config/DoryRendererProductionTuple.json", + "$(SRCROOT)/Packages/ContainerizationEngine/DoryRendererWorker.entitlements", + "$(SRCROOT)/Packages/ContainerizationEngine/Package.swift", + "$(SRCROOT)/scripts/assemble-renderer-production-worker.sh", + "$(SRCROOT)/scripts/package-renderer-production-bundle.py", + "$(SRCROOT)/scripts/renderer-production-tuple.py", + "$(SRCROOT)/scripts/xcode-package-renderer-production.sh", + ); + name = "Package Exact Renderer Tuple"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "exec \"$SRCROOT/scripts/xcode-package-renderer-production.sh\"\n"; + }; AA00000000000000000000D0 /* Prune Stale Bundled Helpers */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -372,6 +647,27 @@ files = ( ); }; + D0B400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -380,6 +676,21 @@ target = AA100000000000000000000A /* DoryStorageProvider */; targetProxy = AA1000000000000000000002 /* PBXContainerItemProxy */; }; + D0B800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B500000000000000000001 /* DoryHVRunner */; + targetProxy = D0B800000000000000000001 /* PBXContainerItemProxy */; + }; + D0C800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0C500000000000000000001 /* DoryFSWorker */; + targetProxy = D0C800000000000000000001 /* PBXContainerItemProxy */; + }; + D0D800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D500000000000000000001 /* DoryRendererWorker */; + targetProxy = D0D800000000000000000001 /* PBXContainerItemProxy */; + }; 3E705CFE2FE37C7B0094B33C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3E705CEE2FE37C790094B33C /* Dory */; @@ -579,6 +890,8 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 0; + DORY_BUNDLE_VENUS_REQUIRED = 0; ENABLE_APP_SANDBOX = NO; ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -619,6 +932,8 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 1; + DORY_BUNDLE_VENUS_REQUIRED = 1; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -728,6 +1043,189 @@ }; name = Release; }; + D0B600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/dory-hv.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 0; + DORY_BUNDLE_VENUS_REQUIRED = 0; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXECUTABLE_NAME = "dory-hv"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryHVRunner-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name containerizationengine"; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner; + PRODUCT_NAME = DoryHVRunner; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0B600000000000000000002 /* Release configuration for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/dory-hv.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 1; + DORY_BUNDLE_VENUS_REQUIRED = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXECUTABLE_NAME = "dory-hv"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryHVRunner-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name containerizationengine"; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner; + PRODUCT_NAME = DoryHVRunner; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + D0C600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryFSWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryFSWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.FSWorker; + PRODUCT_NAME = DoryFSWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0C600000000000000000002 /* Release configuration for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryFSWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryFSWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.FSWorker; + PRODUCT_NAME = DoryFSWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + D0D600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryRendererWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryRendererWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.RendererWorker; + PRODUCT_NAME = DoryRendererWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0D600000000000000000002 /* Release configuration for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryRendererWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 49; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryRendererWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.5; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.RendererWorker; + PRODUCT_NAME = DoryRendererWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -772,6 +1270,33 @@ ); defaultConfigurationName = Release; }; + D0B600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryHVRunner" */, + D0B600000000000000000002 /* Release configuration for PBXNativeTarget "DoryHVRunner" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0C600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0C600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryFSWorker" */, + D0C600000000000000000002 /* Release configuration for PBXNativeTarget "DoryFSWorker" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0D600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0D600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryRendererWorker" */, + D0D600000000000000000002 /* Release configuration for PBXNativeTarget "DoryRendererWorker" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -779,6 +1304,10 @@ isa = XCLocalSwiftPackageReference; relativePath = "dory-core-swift"; }; + D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ContainerizationEngine; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -816,6 +1345,61 @@ package = AA00000000000000000000C1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + D0B700000000000000000001 /* DoryHV */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryHV; + }; + D0B700000000000000000002 /* DoryCore */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryCore; + }; + D0B700000000000000000003 /* DorydKit */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DorydKit; + }; + D0B700000000000000000004 /* DoryOperations */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryOperations; + }; + D0B700000000000000000005 /* DoryVMContracts */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryVMContracts; + }; + D0B700000000000000000006 /* DoryVMMKit */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryVMMKit; + }; + D0C700000000000000000001 /* DoryFSWorkerContracts */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryFSWorkerContracts; + }; + D0C700000000000000000002 /* DoryFSWorkerServiceCore */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryFSWorkerServiceCore; + }; + D0D700000000000000000001 /* DoryRendererWorkerContracts */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerContracts; + }; + D0D700000000000000000002 /* DoryRendererWorkerServiceCore */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerServiceCore; + }; + D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerVirglBackend; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3E705CE72FE37C790094B33C /* Project object */; diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 96e4f079..bc9ed211 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -125,6 +125,7 @@ private struct MachineCard: View { let machine: Machine @State private var confirmingDelete = false @State private var confirmingToolsRepair = false + @State private var confirmingInstallerMediaChange = false @State private var showingIntegrationHealth = false @State private var showingSerialConsole = false @State private var isTransferDropTargeted = false @@ -305,6 +306,43 @@ private struct MachineCard: View { } message: { Text("Dory will create a last-good snapshot, reinstall the active signed desktop and tools payload, restart the machine, and roll back automatically if verification fails.") } + .confirmationDialog( + installerMediaDialogTitle, + isPresented: $confirmingInstallerMediaChange, + titleVisibility: .visible + ) { + Button(installerMediaActionTitle) { + store.setMachineInstallerMedia( + machine, + attached: !machine.installerMediaAttached + ) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(installerMediaDialogMessage) + } + } + + private var installerMediaDialogTitle: String { + machine.installerMediaAttached + ? "Eject the installer ISO from \(machine.name)?" + : "Attach the installer ISO to \(machine.name)?" + } + + private var installerMediaActionTitle: String { + let action = machine.installerMediaAttached ? "Eject Installer" : "Attach Installer" + return isActive ? action + " and Restart" : action + } + + private var installerMediaDialogMessage: String { + if machine.installerMediaAttached { + return isActive + ? "Dory will request a graceful shutdown, eject the ISO, and restart from the installed virtual disk. Continue only after the Linux installer has finished writing the disk." + : "The next Start will boot from the installed virtual disk without the ISO. Continue only after installation is complete." + } + return isActive + ? "Dory will request a graceful shutdown, attach the read-only installer ISO, and restart into EFI recovery/install media." + : "The next Start will boot with the read-only installer ISO attached." } private var distroBadge: some View { @@ -561,7 +599,7 @@ private struct MachineCard: View { if machine.bootMode == .efi { Divider() Button { - store.setMachineInstallerMedia(machine, attached: !machine.installerMediaAttached) + confirmingInstallerMediaChange = true } label: { Label( machine.installerMediaAttached ? "Eject Installer ISO" : "Attach Installer ISO", diff --git a/Dory/Features/Sheets/MachineCreationSheet.swift b/Dory/Features/Sheets/MachineCreationSheet.swift index 9e4614c0..e8489fbe 100644 --- a/Dory/Features/Sheets/MachineCreationSheet.swift +++ b/Dory/Features/Sheets/MachineCreationSheet.swift @@ -14,7 +14,7 @@ struct MachineCreationSheet: View { statusIcon VStack(alignment: .leading, spacing: 1) { Text(store.machineCreationTitle).font(.system(size: 15, weight: .bold)).foregroundStyle(p.text) - Text(failed ? "Creation failed" : (succeeded ? "Ready" : "Setting up your Linux machine…")) + Text(statusSubtitle) .font(.system(size: 11.5)).foregroundStyle(failed ? p.red : (succeeded ? p.green : p.text3)) } Spacer() @@ -78,14 +78,27 @@ struct MachineCreationSheet: View { .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) }.buttonStyle(.plain) - Button { - openWindow(value: store.terminalSession(for: machine)) - dismissSuccess() - } label: { - Text("Open Terminal").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) - .padding(.horizontal, 18).padding(.vertical, 8) - .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) - }.buttonStyle(.plain) + if machine.bootMode == .efi, machine.displayMode == .desktop { + Button { + store.openMachineDesktop(machine) + dismissSuccess() + } label: { + Text("Open Desktop").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 18).padding(.vertical, 8) + .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(!store.canOpenMachineDesktop(machine)) + } else if store.canOpenMachineTerminal(machine) { + Button { + openWindow(value: store.terminalSession(for: machine)) + dismissSuccess() + } label: { + Text("Open Terminal").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 18).padding(.vertical, 8) + .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) + }.buttonStyle(.plain) + } } } } @@ -99,6 +112,15 @@ struct MachineCreationSheet: View { store.machineCreated = nil } + private var statusSubtitle: String { + if failed { return "Creation failed" } + guard succeeded else { return "Setting up your Linux machine…" } + if store.machineCreated?.bootMode == .efi { + return "VM and display started" + } + return "Ready" + } + @ViewBuilder private var statusIcon: some View { if failed { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 17)).foregroundStyle(p.red) diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index e5815479..54c14c27 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -378,9 +378,9 @@ struct NewMachineSheet: View { } case .unknown: isoStatusRow( - icon: "questionmark.circle.fill", - color: p.amber, - text: "EFI architecture was not recognizable; Dory can try this custom image." + icon: "xmark.octagon.fill", + color: p.red, + text: "Dory could not prove a portable ARM64 EFI loader in this ISO. Choose different media." ) case let .unstable(message): isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message) @@ -505,7 +505,12 @@ struct NewMachineSheet: View { Text("Use 1–32 lowercase letters, numbers, underscores or dashes; start with a letter or underscore.") .font(.system(size: 11)).foregroundStyle(p.red) } - Toggle("Share my Mac home (read-write)", isOn: $shareHome) + Toggle( + customISOInstall + ? "Expose my Mac home to this VM (read-write)" + : "Share my Mac home (read-write)", + isOn: $shareHome + ) .toggleStyle(.switch).tint(p.accent) .font(.system(size: 12.5)).foregroundStyle(p.text) Text(shareHome ? sharedHomeDescription : "No Mac home folder is shared unless you turn this on or add scoped mounts.") @@ -879,11 +884,14 @@ struct NewMachineSheet: View { private func create() { var settings = collectedSettings() - let sharedHomeGuestPath = displayMode == .desktop - ? "/home/\(normalizedGuestUsername)/Mac" - : NSHomeDirectory() - if shareHome, !settings.mounts.contains(where: { $0.guest == sharedHomeGuestPath }) { - settings.mounts.append(MountPair(host: NSHomeDirectory(), guest: sharedHomeGuestPath)) + let homeMount = Self.sharedHomeMount( + home: NSHomeDirectory(), + displayMode: displayMode, + customISOInstall: customISOInstall, + guestUsername: normalizedGuestUsername + ) + if shareHome, !settings.mounts.contains(where: { $0.guest == homeMount.guest }) { + settings.mounts.append(homeMount) } let machineName = name let recipe = customISOInstall ? nil : selectedRecipe @@ -912,7 +920,12 @@ struct NewMachineSheet: View { if hasSecurityScope { url.stopAccessingSecurityScopedResource() } } do { - return (try DoryInstallerISOInspector.mediaIdentity(atPath: selectedPath), nil) + return ( + try DoryInstallerISOInspector.portableEFIMediaIdentity( + atPath: selectedPath + ), + nil + ) } catch { return (nil, error.localizedDescription) } @@ -943,9 +956,9 @@ struct NewMachineSheet: View { private var installerISOCheckBlocksCreate: Bool { switch installerISOCheck { - case .compatible, .unknown: + case .compatible: false - case .none, .checking, .unstable, .incompatible, .failed: + case .none, .checking, .unknown, .unstable, .incompatible, .failed: true } } @@ -1098,6 +1111,25 @@ struct NewMachineSheet: View { "dory-\(AppStore.generatedMachineToken())" } + static func sharedHomeMount( + home: String, + displayMode: MachineDisplayMode, + customISOInstall: Bool, + guestUsername: String + ) -> MountPair { + if displayMode == .desktop, customISOInstall { + return MountPair( + host: home, + guest: "/mnt/dory-mac-home", + shareTag: "mac-home" + ) + } + return MountPair( + host: home, + guest: displayMode == .desktop ? "/home/\(guestUsername)/Mac" : home + ) + } + static func defaultGuestUsername() -> String { let normalized = NSUserName().lowercased().map { character -> Character in character.isLetter || character.isNumber || character == "_" || character == "-" ? character : "-" @@ -1123,8 +1155,11 @@ struct NewMachineSheet: View { } private var sharedHomeDescription: String { - displayMode == .desktop - ? "Your Mac home is available in the desktop at ~/Mac with your Mac user ID." + if displayMode == .desktop, customISOInstall { + return "Dory exposes the virtiofs tag mac-home. After installation, mount it where you want (for example /mnt/dory-mac-home); an arbitrary distro is not configured automatically." + } + return displayMode == .desktop + ? "Your Mac home is available in the Dory-managed desktop at ~/Mac with your Mac user ID." : "Your Mac home is mounted at its native path inside the machine." } diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 41bf229a..253d8dd5 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -273,9 +273,10 @@ final class AppStore { } self.localCATrustManager = localCATrustManager let networkHelperMaintenance = DoryAppDelegate.isNetworkHelperMaintenance() - let realLaunch = !networkHelperMaintenance + let realLaunch = !networkHelperMaintenance && !DoryAppDelegate.isTestHost && env["DORY_SECTION"] == nil && env["DORY_APPEARANCE"] == nil - && env["XCTestConfigurationFilePath"] == nil && env["DORY_UI_TEST"] != "1" + && env["XCTestConfigurationFilePath"] == nil + && env["XCTestSessionIdentifier"] == nil && env["DORY_UI_TEST"] != "1" // Every launch starts disconnected (empty, engine-off) until a real engine connects: the app // ships no demo data. Tests inject their own fixture runtime through the parameter. self.runtime = runtime ?? DisconnectedRuntime() @@ -482,7 +483,7 @@ final class AppStore { if MacHostPlatform.current().isAppleSilicon { "Dory can still run on this Mac by proxying a local Docker-compatible engine such as Docker Desktop, Colima, Rancher Desktop, Podman, or OrbStack." } else { - "Dory's built-in Intel engine needs bundled engine assets and Hypervisor.framework support. This install can still proxy a local Docker-compatible engine such as Colima, Docker Desktop, Rancher Desktop, Podman, or OrbStack." + "Dory does not ship a built-in Intel engine. This install can proxy a local Docker-compatible engine such as Colima, Docker Desktop, Rancher Desktop, Podman, or OrbStack." } } @@ -653,7 +654,8 @@ final class AppStore { private var isAutomationContext: Bool { let env = environment - return env["XCTestConfigurationFilePath"] != nil || env["XCTestSessionIdentifier"] != nil + return DoryAppDelegate.isTestHost + || env["XCTestConfigurationFilePath"] != nil || env["XCTestSessionIdentifier"] != nil || env["DORY_UI_TEST"] == "1" || env["DORY_SECTION"] != nil || env["DORY_SHEET"] != nil || env["DORY_DETAIL_TAB"] != nil || env["DORY_APPEARANCE"] != nil || env["DORY_ONBOARDING"] != nil @@ -749,8 +751,9 @@ final class AppStore { private var shimServer: ShimHTTPServer? var shimSocketPath: String { daemonSocketPath ?? DockerShim.defaultSocketPath } private(set) var shimRunning = false - /// Apple Silicon FEX translation for linux/amd64 images. Enabled on new installations and still - /// user-disableable; Rosetta remains a separate one-off `dory vm --rosetta` path. + /// Apple Silicon FEX translation for x86_64 Linux applications inside Dory's ARM64 container + /// VM. Enabled on new installations and still user-disableable. This does not boot an Intel + /// distro or ISO; Rosetta likewise translates applications inside an eligible ARM64 Linux VM. var rosettaX86Enabled = false /// Opt-in experimental GPU acceleration (virtio-gpu/Venus → virglrenderer → MoltenVK → Metal) for /// Vulkan and AI compute inside containers. Applied transactionally at engine restart; missing @@ -984,10 +987,12 @@ final class AppStore { func connectBackend() async { // Automation launches (UI tests, screenshot harnesses) never boot the real engine: they // exercise the app against the honest disconnected state unless they opt into a backend - // with an explicit DORY_RUNTIME. + // with an explicit runtime, daemon flag, or injected non-Mach-service endpoint. let runtimeOverride = environment["DORY_RUNTIME"] - if isAutomationContext, runtimeOverride == nil, - !(dorydEngineExplicitlyRequested && dorydEngineEnabled && enginePreference == .dory) { + let explicitlyAuthorizedBackend = runtimeOverride != nil + || !dorydClient.usesMachService + || (dorydEngineExplicitlyRequested && dorydEngineEnabled && enginePreference == .dory) + if isAutomationContext, !explicitlyAuthorizedBackend { loadState = .engineOff return } @@ -1043,9 +1048,9 @@ final class AppStore { await connectBackend() } - /// Stops and re-provisions the shared engine so engine-level settings (GPU, amd64 emulation, - /// memory) or a newly installed Venus runtime take effect. The daemon-owned path captures the - /// exact running-container set and explicitly starts only those containers after reconnecting. + /// Stops and re-provisions the shared engine so engine-level settings (GPU, x86_64 application + /// compatibility, memory) or a newly installed Venus runtime take effect. The daemon-owned path + /// captures the exact running-container set and explicitly starts only those containers after reconnecting. func restartEngine() async { guard runtimeKind == .sharedVM || runtimeKind == .disconnected, !isConnecting else { return } sharedVMStatus = "Restarting the engine…" @@ -1816,11 +1821,12 @@ final class AppStore { return getsockopt(descriptor, SOL_SOCKET, SO_ERROR, &socketError, &length) == 0 && socketError == 0 } - /// Toggles the FEX x86/amd64 path and restarts the shared engine so the new mode takes effect. + /// Toggles FEX for x86_64 Linux applications inside the ARM64 container VM and restarts the + /// shared engine. It never exposes an x86_64 guest OS or installer path. func setRosettaX86(_ on: Bool) async { guard on != rosettaX86Enabled else { return } guard !on || MacHostPlatform.current().isAppleSilicon else { - showSettingsFailure("x86/amd64 emulation is an Apple-silicon-only option; amd64 is native on Intel Macs.") + showSettingsFailure("x86_64 Linux application compatibility is available only inside Dory's ARM64 engine on Apple Silicon; Dory does not ship an Intel engine.") return } guard !engineSettingChangeInFlight else { @@ -1845,18 +1851,18 @@ final class AppStore { UserDefaults.standard.set(value, forKey: SharedVMProvisioner.Config.rosettaX86Key) UserDefaults.standard.set(previousGPU, forKey: SharedVMProvisioner.Config.gpuVenusKey) }, - applyingMessage: on ? "Enabling x86/amd64 emulation…" : "Disabling x86/amd64 emulation…", + applyingMessage: on ? "Enabling x86_64 application compatibility…" : "Disabling x86_64 application compatibility…", successMessage: on && previousGPU - ? "x86/amd64 emulation enabled. GPU acceleration was disabled to keep the required 4 KiB page size." - : (on ? "x86/amd64 emulation enabled." : "x86/amd64 emulation disabled.") + ? "x86_64 application compatibility enabled. GPU acceleration was disabled to keep the required 4 KiB page size." + : (on ? "x86_64 application compatibility enabled." : "x86_64 application compatibility disabled.") ) { return } guard runtimeKind == .sharedVM || runtimeKind == .disconnected, !isConnecting else { return } - sharedVMStatus = on ? "Enabling x86/amd64 emulation…" : "Disabling x86/amd64 emulation…" + sharedVMStatus = on ? "Enabling x86_64 application compatibility…" : "Disabling x86_64 application compatibility…" await SharedVMProvisioner.stopEngine() await connectBackend() - showSettingsSuccess(on ? "x86/amd64 emulation enabled." : "x86/amd64 emulation disabled.") + showSettingsSuccess(on ? "x86_64 application compatibility enabled." : "x86_64 application compatibility disabled.") } /// Toggles experimental GPU acceleration (virtio-gpu/Venus) and restarts the shared engine so the @@ -1873,7 +1879,7 @@ final class AppStore { return } guard !on || !rosettaX86Enabled else { - showSettingsFailure("GPU acceleration cannot be enabled while x86/amd64 emulation is on. FEX requires Dory's 4 KiB guest kernel.") + showSettingsFailure("GPU acceleration cannot be enabled while x86_64 application compatibility is on. FEX requires Dory's 4 KiB ARM64 guest kernel.") return } engineSettingChangeInFlight = true @@ -1976,7 +1982,8 @@ final class AppStore { /// long shutdown timeout, let launchd replace doryd with the new explicit environment, then /// reconnect and explicitly restart the exact containers that were running before the stop. /// Any failure restores the persisted value and makes one recovery attempt with the prior - /// configuration so a rejected GPU/amd64 choice cannot strand the engine or user workloads. + /// configuration so a rejected GPU/x86_64-application choice cannot strand the engine or user + /// workloads. private func applyDorydOwnedEngineSetting( previousValue: Value, restore: @MainActor (Value) -> Void, @@ -3450,6 +3457,22 @@ final class AppStore { try? await syncFinderStorageLocation(force: true) return } + await applyRuntimeSnapshot(snap, synchronizeFinderStorage: true) + } + + private func reloadWithoutExtendingEngineIdle() async { + guard runtimeOwnedByDoryd, let docker = runtime as? DockerEngineRuntime, + let payload = try? await dorydClient.engineDashboardSnapshot(), + let snapshot = try? docker.dashboardSnapshot(from: payload) else { + return + } + await applyRuntimeSnapshot(snapshot, synchronizeFinderStorage: false) + } + + private func applyRuntimeSnapshot( + _ snap: RuntimeSnapshot, + synchronizeFinderStorage: Bool + ) async { if containers != snap.containers { containers = snap.containers; syncMachineStats(); noteEngineActivity() } if images != snap.images { images = snap.images; noteEngineActivity() } if volumes != snap.volumes { volumes = snap.volumes } @@ -3465,7 +3488,9 @@ final class AppStore { cpuHistory = cpuHistory.filter { liveIDs.contains($0.key) } let newState: LoadState = snap.engineRunning ? .ready : .engineOff if loadState != newState { loadState = newState } - try? await syncFinderStorageLocation() + if synchronizeFinderStorage { + try? await syncFinderStorageLocation() + } } var canBrowseDoryStorage: Bool { @@ -3488,6 +3513,10 @@ final class AppStore { } private func syncFinderStorageLocation(force: Bool = false) async throws { + // An injected test environment can intentionally omit XCTest's variables while exercising + // a real-shaped runtime. The host-process classification is authoritative: tests must + // never register, hide, materialize, or publish into the user's live File Provider domain. + guard !isAutomationContext else { return } guard canBrowseDoryStorage, let docker = runtime as? DockerEngineRuntime else { await finderStorageLocation.hide() finderStorageLocationActive = false @@ -3538,7 +3567,11 @@ final class AppStore { // or an external docker request wakes it through doryd's data plane. if engineSleeping { return } capEngineLogIfDue() - await reload() + if runtimeOwnedByDoryd { + await reloadWithoutExtendingEngineIdle() + } else { + await reload() + } loadMachines() if runtimeKind == .sharedVM { await loadKubernetes() } await evaluateIdleSleep() @@ -3552,7 +3585,11 @@ final class AppStore { loadMachines() if await syncDorydEngineStateBeforeDockerPoll() { return } if engineSleeping { return } - await reload() + if runtimeOwnedByDoryd { + await reloadWithoutExtendingEngineIdle() + } else { + await reload() + } if runtimeKind == .sharedVM { await loadKubernetes() } } @@ -5268,6 +5305,7 @@ final class AppStore { bootMode: status.bootMode, installerMediaAttached: status.installerMediaAttached, runtimeIdentity: status.runtimeIdentity, + runtimeGraphicsSelection: status.runtimeGraphicsSelection, cloneReceipt: status.cloneReceipt, agentBuild: status.agentBuild, agentProtocolVersion: status.agentProtocolVersion, @@ -5334,7 +5372,7 @@ final class AppStore { } func machineTerminalCommand(_ machine: Machine) -> String? { - guard runtimeOwnedByDoryd else { return nil } + guard canOpenMachineTerminal(machine) else { return nil } return TerminalLauncher.userFacingMachineShellCommand(target: UserFacingMachineShellTarget( machineID: machine.name )) @@ -6637,7 +6675,13 @@ final class AppStore { appendMachineCreationLog("Provisioned \(result.recipeID): \(verify)") } } - appendMachineCreationLog("Machine created and started.") + if settings.bootMode == .efi { + appendMachineCreationLog( + "VM and display started. Complete Linux setup in the desktop window." + ) + } else { + appendMachineCreationLog("Machine created and started.") + } let refreshed = await refreshMachines() machineCreated = refreshed.first { $0.name == name } if machineCreated == nil { diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index ae1f8851..31111db5 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -310,6 +310,7 @@ struct Machine: Identifiable, Hashable, Sendable { var bootMode: MachineBootMode = .linuxKernel var installerMediaAttached: Bool = false var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var runtimeGraphicsSelection: DorydMachineRuntimeGraphicsSelection? = nil var cloneReceipt: DorydMachineCloneReceipt? = nil var agentBuild: String? = nil var agentProtocolVersion: UInt32? = nil @@ -391,15 +392,45 @@ struct Machine: Identifiable, Hashable, Sendable { )) } if displayMode == .desktop { - evidence.append(MachineRuntimeEvidence( - id: "graphics", - label: Self.graphicsLabel(runtimeIdentity.graphics), - systemImage: "display", - tone: runtimeIdentity.graphics == "hardware-accelerated-3d" - ? .positive : .standard, - detail: runtimeIdentity.graphicsQualification?.manifestIdentity - ?? "No separate signed graphics qualification" - )) + if status == .running, let runtimeGraphicsSelection { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: Self.graphicsLabel(runtimeGraphicsSelection.accelerationLevel), + systemImage: "display", + tone: runtimeGraphicsSelection.isQualifiedAcceleration + ? .positive : .standard, + detail: runtimeGraphicsSelection.isQualifiedAcceleration + ? "Live renderer generation \(runtimeGraphicsSelection.rendererGeneration ?? 0)" + : "Live operation-bound software selection" + )) + } else if status == .running, + runtimeIdentity.backend == "apple-virtualization-framework", + runtimeIdentity.graphics == "software" { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Software graphics", + systemImage: "display", + tone: .standard, + detail: "Plan-bound Virtualization.framework display" + )) + } else if status == .running { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Graphics unverified", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: "The running helper did not prove its live graphics selection" + )) + } else { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Planned \(Self.graphicsLabel(runtimeIdentity.graphics))", + systemImage: "display", + tone: .standard, + detail: runtimeIdentity.graphicsQualification?.manifestIdentity + ?? "No live graphics selection while stopped" + )) + } } if runtimeIdentity.selectionDisposition == "approved-fallback" { evidence.append(MachineRuntimeEvidence( diff --git a/Dory/Runtime/Docker/DockerEngineRuntime.swift b/Dory/Runtime/Docker/DockerEngineRuntime.swift index c3840b2f..da361ff7 100644 --- a/Dory/Runtime/Docker/DockerEngineRuntime.swift +++ b/Dory/Runtime/Docker/DockerEngineRuntime.swift @@ -566,6 +566,34 @@ struct DockerEngineRuntime: ContainerRuntime { ) } + /// Decodes the non-waking dashboard payload produced by doryd's private observation path. + /// Runtime statistics are intentionally omitted: collecting them is an active two-sample + /// operation and belongs to an explicit user refresh, not the background idle observer. + func dashboardSnapshot(from payload: [String: Data]) throws -> RuntimeSnapshot { + func required(_ key: String, as type: T.Type) throws -> T { + guard let data = payload[key] else { + throw RuntimeFeatureError.unsupported("doryd dashboard snapshot omitted \(key)") + } + return try decoder.decode(T.self, from: data) + } + + let summaries = try required("containers", as: [DockerContainerSummary].self) + let imageSummaries = try required("images", as: [DockerImageSummary].self) + let volumeList = try required("volumes", as: DockerVolumeList.self) + let networkSummaries = try required("networks", as: [DockerNetwork].self) + let version = try required("version", as: DockerVersion.self) + return RuntimeSnapshot( + containers: summaries.map { map($0, stats: nil) }, + images: imageSummaries.compactMap(mapImage), + volumes: volumeList.volumes?.map(mapVolume) ?? [], + networks: networkSummaries.map(mapNetwork), + pods: [], + machines: [], + engineRunning: true, + engineVersion: version.version ?? "docker" + ) + } + /// Fail closed for migration inventory. The normal dashboard deliberately tolerates optional /// table failures, but silently translating a timed-out `/volumes` or `/networks` request into /// an empty array would produce an image-only partial import and misleading success report. diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 46b46077..15d8ee28 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -7,6 +7,7 @@ nonisolated protocol DorydControlXPC { func protocolVersion(reply: @escaping (UInt32) -> Void) func dorySocketPath(reply: @escaping (String) -> Void) func engineStatus(reply: @escaping (String, String) -> Void) + func engineDashboardSnapshot(reply: @escaping (NSDictionary, String) -> Void) func engineStart(reply: @escaping (Bool, String) -> Void) func engineStop(reply: @escaping (Bool, String) -> Void) func engineSleep(reply: @escaping (Bool, String) -> Void) @@ -667,15 +668,25 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha if let graphicsQualification, !graphicsQualification.isValidGraphicsReference { return false } - guard hostQualification?.isValidHostReference == true else { return false } switch supportTier { case "supported": - guard runtimeQualification?.isValidRuntimeReference == true, - experimentalAuthorizationIdentity == nil else { - return false + if isPortableVZSoftwareBaseline { + guard graphicsQualification == nil, + runtimeQualification == nil, + hostQualification == nil, + experimentalAuthorizationIdentity == nil else { + return false + } + } else { + guard hostQualification?.isValidHostReference == true, + runtimeQualification?.isValidRuntimeReference == true, + experimentalAuthorizationIdentity == nil else { + return false + } } case "experimental": - guard experimentalAuthorizationIdentity?.isSafeEvidenceIdentifier == true, + guard hostQualification?.isValidHostReference == true, + experimentalAuthorizationIdentity?.isSafeEvidenceIdentifier == true, runtimeQualification.map(\.isValidRuntimeReference) ?? true else { return false } @@ -692,11 +703,65 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha } } + /// Portable user-installed Linux intentionally has no exact-media runtime or host + /// qualification. Admit only the narrow non-accelerated VZ contract that the daemon can prove + /// structurally; every managed or accelerated plan still requires signed qualification. + private var isPortableVZSoftwareBaseline: Bool { + guard backend == "apple-virtualization-framework", + backendImplementationIdentifier == "dory.vz-linux.compatibility.v1", + graphics == "software", + selectionDisposition == "primary", + bootMedia?.source == "user-provided", + let kind = bootMedia?.kind, + kind == "installer-iso" || kind == "virtual-disk", + components?.contains(where: { $0.componentIdentifier == "dory-vmm" }) == true else { + return false + } + return true + } + var authorizesRemovableUSBHotplug: Bool { mode == "resolved-plan" && removableUSBHotplug == true && isValid } } +nonisolated struct DorydMachineRuntimeGraphicsSelection: Sendable, Equatable, Hashable { + static let currentSchemaVersion: UInt16 = 1 + var schemaVersion: UInt16 + var operationID: String + var resolvedPlanSHA256: String + var planRevision: UInt64 + var accelerationLevel: String + var backend: String + var rendererGeneration: UInt64? + var rendererWorkerReceiptSHA256: String? + var guestProducerFenceProofSHA256: String? + + var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + operationID.isCanonicalLowercaseUUID, + resolvedPlanSHA256.isLowercaseSHA256, + planRevision > 0 else { return false } + switch (accelerationLevel, backend) { + case ("software", "software"): + return rendererGeneration == nil + && rendererWorkerReceiptSHA256 == nil + && guestProducerFenceProofSHA256 == nil + case ("host-accelerated-display", "virgl"), + ("hardware-accelerated-3d", "virgl-venus"): + return rendererGeneration.map { $0 > 0 } == true + && rendererWorkerReceiptSHA256?.isLowercaseSHA256 == true + && guestProducerFenceProofSHA256?.isLowercaseSHA256 == true + default: + return false + } + } + + var isQualifiedAcceleration: Bool { + isValid && accelerationLevel != "software" + } +} + nonisolated struct DorydMachineUSBAttachment: Sendable, Equatable { var machineID: String var busID: String @@ -731,6 +796,12 @@ nonisolated private extension String { } } + var isCanonicalLowercaseUUID: Bool { + utf8.count == 36 + && self == lowercased() + && UUID(uuidString: self).map { $0.uuidString.lowercased() == self } == true + } + var isSafeEvidenceIdentifier: Bool { let bytes = Array(utf8) guard (1...256).contains(bytes.count) else { return false } @@ -1079,6 +1150,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var typedSettings: DorydMachineTypedSettings? = nil var displayPresentation: DoryMachineDisplayPresentation = .windowed var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var runtimeGraphicsSelection: DorydMachineRuntimeGraphicsSelection? = nil var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil var cloneReceipt: DorydMachineCloneReceipt? = nil var savedState: DorydMachineSavedStateSummary? = nil @@ -1846,6 +1918,26 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func engineDashboardSnapshot() async throws -> [String: Data] { + try await call { proxy, finish in + proxy.engineDashboardSnapshot { dictionary, error in + guard error.isEmpty else { + finish(.failure(DorydClientError.daemon(error))) + return + } + var result: [String: Data] = [:] + for (key, value) in dictionary { + guard let key = key as? String, let data = value as? Data else { + finish(.failure(DorydClientError.daemon("invalid dashboard snapshot payload"))) + return + } + result[key] = data + } + finish(.success(result)) + } + } + } + func engineStart() async throws -> DorydCommandResult { try await withTimeout(atLeast: Self.engineColdStartTimeout).command { proxy, reply in proxy.engineStart(reply: reply) @@ -2859,6 +2951,11 @@ nonisolated final class DorydClient: @unchecked Sendable { let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else { return nil } + guard let runtimeGraphicsSelection = machineRuntimeGraphicsSelection( + from: dictionary["runtimeGraphicsSelection"], + state: state, + runtimeIdentity: runtimeIdentity + ) else { return nil } let environment = machineEnvironment(from: dictionary["env"]) guard let typedSettings = machineTypedSettings(from: dictionary) else { return nil @@ -2951,6 +3048,7 @@ nonisolated final class DorydClient: @unchecked Sendable { typedSettings: typedSettings.value, displayPresentation: displayPresentation, runtimeIdentity: runtimeIdentity, + runtimeGraphicsSelection: runtimeGraphicsSelection.value, installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, cloneReceipt: cloneReceipt.value, savedState: savedState.value @@ -3844,6 +3942,67 @@ nonisolated final class DorydClient: @unchecked Sendable { return identity } + private struct OptionalRuntimeGraphicsSelection { + var value: DorydMachineRuntimeGraphicsSelection? + } + + /// The plan describes what may launch; this receipt describes what the active RawHV helper + /// actually selected. A running resolved RawHV machine without an exact matching receipt is + /// rejected instead of inheriting the plan's acceleration label. + nonisolated private static func machineRuntimeGraphicsSelection( + from encoded: Any?, + state: String, + runtimeIdentity: DorydMachineRuntimeIdentity + ) -> OptionalRuntimeGraphicsSelection? { + guard let encoded else { + if ["running", "paused"].contains(state), + runtimeIdentity.mode == "resolved-plan", + runtimeIdentity.backend == DoryVirtualizationBackendIdentity.doryHypervisor.rawValue { + return nil + } + return OptionalRuntimeGraphicsSelection(value: nil) + } + guard ["starting", "running", "paused"].contains(state), + runtimeIdentity.mode == "resolved-plan", + runtimeIdentity.backend == DoryVirtualizationBackendIdentity.doryHypervisor.rawValue, + let dictionary = encoded as? NSDictionary, + let keys = dictionary.allKeys as? [String], + keys.count == Set(keys).count, + Set(keys).isSubset(of: [ + "schemaVersion", "operationID", "resolvedPlanSHA256", "planRevision", + "accelerationLevel", "backend", "rendererGeneration", + "rendererWorkerReceiptSHA256", "guestProducerFenceProofSHA256", + ]), + let schemaVersion = uint16(dictionary["schemaVersion"]), + let operationID = dictionary["operationID"] as? String, + let resolvedPlanSHA256 = dictionary["resolvedPlanSHA256"] as? String, + let planRevision = strictUInt64(dictionary["planRevision"]), + let accelerationLevel = dictionary["accelerationLevel"] as? String, + let backend = dictionary["backend"] as? String else { + return nil + } + let selection = DorydMachineRuntimeGraphicsSelection( + schemaVersion: schemaVersion, + operationID: operationID, + resolvedPlanSHA256: resolvedPlanSHA256, + planRevision: planRevision, + accelerationLevel: accelerationLevel, + backend: backend, + rendererGeneration: dictionary["rendererGeneration"].flatMap(strictUInt64), + rendererWorkerReceiptSHA256: + dictionary["rendererWorkerReceiptSHA256"] as? String, + guestProducerFenceProofSHA256: + dictionary["guestProducerFenceProofSHA256"] as? String + ) + guard selection.isValid, + selection.resolvedPlanSHA256 == runtimeIdentity.planSHA256, + selection.planRevision == runtimeIdentity.planRevision, + selection.accelerationLevel == runtimeIdentity.graphics else { + return nil + } + return OptionalRuntimeGraphicsSelection(value: selection) + } + /// An absent field is compatible with an older daemon. Once present, every row is a durable /// device-identity claim; silently dropping a malformed row could make the next app edit /// delete a share the user never removed. @@ -4699,8 +4858,8 @@ nonisolated final class DorydClient: @unchecked Sendable { guard rawKeys.count == keys.count, requiredKeys.isSubset(of: keys), keys.subtracting(requiredKeys).isSubset(of: ["sourceBackend"]), - let schemaVersion = uint16(dictionary["schemaVersion"]), - schemaVersion == 1, + let rawSchemaVersion = strictUInt64(dictionary["schemaVersion"]), + rawSchemaVersion == 1, let contentID = dictionary["contentID"] as? String, contentID.isLowercaseSHA256, let sourceMachineID = dictionary["sourceMachineID"] as? String, @@ -4711,10 +4870,13 @@ nonisolated final class DorydClient: @unchecked Sendable { ["arm64", "x86_64"].contains(architecture), let bootMode = dictionary["bootMode"] as? String, ["linux-kernel", "efi"].contains(bootMode), - let diskSizeBytes = uint64(dictionary["diskSizeBytes"]), diskSizeBytes > 0, - let virtualHardwareABIVersion = uint16( + let diskSizeBytes = strictUInt64(dictionary["diskSizeBytes"]), + diskSizeBytes > 0, + let rawVirtualHardwareABIVersion = strictUInt64( dictionary["virtualHardwareABIVersion"] - ), virtualHardwareABIVersion > 0, + ), + rawVirtualHardwareABIVersion > 0, + rawVirtualHardwareABIVersion <= UInt16.max, let sourceRuntimeMode = dictionary["sourceRuntimeMode"] as? String, ["legacy-compatibility", "resolved-plan", "requires-replanning"] .contains(sourceRuntimeMode), @@ -4779,14 +4941,14 @@ nonisolated final class DorydClient: @unchecked Sendable { return nil } return DorydMachineImportAssessment( - schemaVersion: schemaVersion, + schemaVersion: UInt16(rawSchemaVersion), contentID: contentID, sourceMachineID: sourceMachineID, sourceSnapshotID: sourceSnapshotID, architecture: architecture, bootMode: bootMode, diskSizeBytes: diskSizeBytes, - virtualHardwareABIVersion: virtualHardwareABIVersion, + virtualHardwareABIVersion: UInt16(rawVirtualHardwareABIVersion), sourceRuntimeMode: sourceRuntimeMode, sourceBackend: sourceBackend, portable: portable, @@ -4881,19 +5043,22 @@ nonisolated final class DorydClient: @unchecked Sendable { "capabilityVersion", ], keys.count == 5, - let schemaVersion = uint16(raw["schemaVersion"]), + let rawSchemaVersion = strictUInt64(raw["schemaVersion"]), + rawSchemaVersion <= UInt16.max, let receiptID = raw["receiptID"] as? String, let agentBuild = raw["agentBuild"] as? String, - let agentProtocolVersion = uint32(raw["agentProtocolVersion"]), - let capabilityVersion = uint32(raw["capabilityVersion"]) else { + let rawAgentProtocolVersion = strictUInt64(raw["agentProtocolVersion"]), + rawAgentProtocolVersion <= UInt32.max, + let rawCapabilityVersion = strictUInt64(raw["capabilityVersion"]), + rawCapabilityVersion <= UInt32.max else { return nil } let receipt = DorydMachineSnapshotQuiesceReceipt( - schemaVersion: schemaVersion, + schemaVersion: UInt16(rawSchemaVersion), receiptID: receiptID, agentBuild: agentBuild, - agentProtocolVersion: agentProtocolVersion, - capabilityVersion: capabilityVersion + agentProtocolVersion: UInt32(rawAgentProtocolVersion), + capabilityVersion: UInt32(rawCapabilityVersion) ) guard receipt.isValid else { return nil } return ParsedMachineSnapshotQuiesceReceipt(value: receipt) diff --git a/Dory/Runtime/Doryd/DorydLaunchAgent.swift b/Dory/Runtime/Doryd/DorydLaunchAgent.swift index 74f7a9e4..b2824a55 100644 --- a/Dory/Runtime/Doryd/DorydLaunchAgent.swift +++ b/Dory/Runtime/Doryd/DorydLaunchAgent.swift @@ -371,7 +371,9 @@ enum DorydLaunchAgent { configuration: Configuration = Configuration() ) -> String { let vmm = vmmExecutablePath(helpersDirectory: helpersDirectory) - let hv = helpersDirectory.appendingPathComponent("dory-hv").path + let hv = helpersDirectory + .appendingPathComponent("DoryHVRunner.app/Contents/MacOS/dory-hv") + .path let gvproxy = helpersDirectory.appendingPathComponent("gvproxy").path let resourcesDirectory = helpersDirectory .deletingLastPathComponent() diff --git a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift index 7c0c8dc5..e1c468c3 100644 --- a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift +++ b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift @@ -16,7 +16,7 @@ enum KubernetesProvisioner { /// Increment whenever the container's mounts or nested containerd runtime contract changes. /// A mismatched container is never replaced automatically because its writable layer contains /// the user's cluster state. - static let runtimeContract = "2" + static let runtimeContract = "3" static let contractLabel = "dev.dory.kubernetes.contract" static let emulationLabel = "dev.dory.kubernetes.amd64" static let imageLabel = "dev.dory.kubernetes.image" @@ -109,7 +109,6 @@ enum KubernetesProvisioner { private struct HostConfiguration: Encodable { let Privileged = true let PortBindings: [String: [PortBinding]] - let Binds: [String]? } private struct CreateRequest: Encodable { @@ -128,17 +127,15 @@ enum KubernetesProvisioner { "--tls-san=host.docker.internal", ] - /// k3s embeds and pins its own runc. Preserve that exact binary as runc.real and configure only - /// containerd's BinaryName to enter Dory's OCI wrapper. Reusing dockerd's runc.real here caused - /// native k3s workloads to fail because the two runtime stacks are not interchangeable. + /// Dory's engine OCI admission layer interposes the exact nested runc as runc.real and mounts + /// dory-runc into this privileged container. Configure only containerd's BinaryName here; the + /// app must not copy or replace runtime binaries from inside the container after admission. static let fexStartupScript = #""" set -eu test -x /usr/lib/dory/fex/FEX test -x /usr/lib/dory/fex/FEXServer test -x /usr/local/bin/dory-runc - test -x /bin/runc - install -m 0755 /bin/runc /usr/local/bin/runc.real - cmp -s /bin/runc /usr/local/bin/runc.real + test -x /usr/local/bin/runc.real install -d -m 0755 /var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d runtime_config=/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d/10-dory-fex.toml runtime_config_tmp="${runtime_config}.tmp" @@ -359,8 +356,7 @@ enum KubernetesProvisioner { Labels: labels, ExposedPorts: [port: EmptyObject()], HostConfig: HostConfiguration( - PortBindings: [port: [PortBinding(HostPort: "\(apiPort)")]], - Binds: amd64Emulation ? ["\(fexWrapperPath):\(fexWrapperPath):ro"] : nil + PortBindings: [port: [PortBinding(HostPort: "\(apiPort)")]] ) ) // All fields above are JSON-encodable value types. Encoding rather than interpolation keeps diff --git a/Dory/Runtime/Shared/SharedVMProvisioner.swift b/Dory/Runtime/Shared/SharedVMProvisioner.swift index add0516f..1fc2063d 100644 --- a/Dory/Runtime/Shared/SharedVMProvisioner.swift +++ b/Dory/Runtime/Shared/SharedVMProvisioner.swift @@ -657,20 +657,27 @@ nonisolated enum SharedVMProvisioner { } private static func hvHelperBinary() -> String? { + if Bundle.main.bundleURL.pathExtension == "app" { + let runner = bundledHVRunnerExecutable(in: Bundle.main.bundleURL) + return FileManager.default.isExecutableFile(atPath: runner) ? runner : nil + } let environment = ProcessInfo.processInfo.environment if let override = environment["DORY_HV_HELPER"], !override.isEmpty, FileManager.default.isExecutableFile(atPath: override) { return override } - if let helper = bundledHelperPath(named: "dory-hv"), - FileManager.default.isExecutableFile(atPath: helper) { - return helper - } let cwd = FileManager.default.currentDirectoryPath return helperDevCandidates(named: "dory-hv", cwd: cwd).first { FileManager.default.isExecutableFile(atPath: $0) } } + nonisolated static func bundledHVRunnerExecutable(in applicationURL: URL) -> String { + applicationURL + .appendingPathComponent("Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv") + .standardizedFileURL + .path + } + nonisolated static func helperDevCandidates( named helperName: String, cwd: String, diff --git a/DoryTests/DoryProcessMemoryTests.swift b/DoryTests/DoryProcessMemoryTests.swift index 5529872a..5f55de60 100644 --- a/DoryTests/DoryProcessMemoryTests.swift +++ b/DoryTests/DoryProcessMemoryTests.swift @@ -10,7 +10,7 @@ struct DoryProcessMemoryTests { currentPID: 1 ) == .app) #expect(DoryProcessMemorySampler.classify(pid: 11, name: "doryd", path: "/Applications/Dory.app/Contents/Helpers/doryd", currentPID: 1) == .daemon) - #expect(DoryProcessMemorySampler.classify(pid: 12, name: "dory-hv", path: "/Applications/Dory.app/Contents/Helpers/dory-hv", currentPID: 1) == .dockerVM) + #expect(DoryProcessMemorySampler.classify(pid: 12, name: "dory-hv", path: "/Applications/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv", currentPID: 1) == .dockerVM) #expect(DoryProcessMemorySampler.classify(pid: 13, name: "dory-vmm", path: "/Applications/Dory.app/Contents/Helpers/dory-vmm", currentPID: 1) == .machineVM) #expect(DoryProcessMemorySampler.classify(pid: 14, name: "gvproxy", path: "/Applications/Dory.app/Contents/Helpers/gvproxy", currentPID: 1) == .networking) #expect(DoryProcessMemorySampler.classify(pid: 15, name: "Safari", path: "/Applications/Safari.app/Contents/MacOS/Safari", currentPID: 1) == nil) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 5f1bbf85..0b9eef01 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1823,10 +1823,12 @@ struct DorydClientTests { let status = try #require( (try await DorydClient(endpoint: listener.endpoint).machineList()).first ) + #expect(status.runtimeGraphicsSelection?.isQualifiedAcceleration == true) #expect(status.runtimeIdentity.authorizesRemovableUSBHotplug) #expect(UsbPassthroughAvailability.attachSupported(for: status)) let machine = AppStore.machine(fromDoryd: status) #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") + #expect(machine.runtimeGraphicsSelection?.backend == "virgl-venus") #expect(machine.agentProtocolVersion == 1) #expect(machine.agentCapabilities.map(\.id) == [ "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push", @@ -1838,6 +1840,19 @@ struct DorydClientTests { #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.detail == "runtime-qualification-1") + let missingFence = NSMutableDictionary( + dictionary: try #require(service.machineRuntimeGraphicsSelection("dev")) + ) + missingFence.removeObject(forKey: "guestProducerFenceProofSHA256") + service.setMachineRuntimeGraphicsSelection("dev", missingFence) + await #expect(throws: DorydClientError.self) { + _ = try await DorydClient(endpoint: listener.endpoint).machineList() + } + service.setMachineRuntimeGraphicsSelection( + "dev", + try #require(service.defaultRuntimeGraphicsSelection("dev")) + ) + var replanning = machine replanning.runtimeIdentity = DorydMachineRuntimeIdentity( schemaVersion: 1, @@ -1887,6 +1902,32 @@ struct DorydClientTests { #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") } + @Test func portableVZSoftwarePlanIsAcceptedWithoutAccelerationQualification() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + runtimeIdentityOverride: validPortableVZRuntimeIdentity() + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + #expect(status.runtimeIdentity.backend == "apple-virtualization-framework") + #expect(status.runtimeIdentity.graphics == "software") + #expect(status.runtimeIdentity.runtimeQualification == nil) + #expect(status.runtimeIdentity.hostQualification == nil) + #expect(status.runtimeGraphicsSelection == nil) + + let machine = AppStore.machine(fromDoryd: status) + let graphics = try #require(machine.runtimeEvidence.first { $0.id == "graphics" }) + #expect(graphics.label == "Software graphics") + #expect(graphics.detail == "Plan-bound Virtualization.framework display") + #expect(graphics.tone == .standard) + } + @Test func machineIntegrationHealthUsesExactPresentShapeAndRejectsContradictions() async throws { let listener = NSXPCListener.anonymous() let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity()) @@ -1913,6 +1954,7 @@ struct DorydClientTests { .init(id: "clock-sync", version: 1), .init(id: "exec", version: 1), .init(id: "exec-stdin", version: 1), + .init(id: "lifecycle-receipt", version: 1), .init(id: "ports-watch", version: 1), .init(id: "snapshot-quiesce", version: 2), .init(id: "sync-push", version: 2), @@ -2014,7 +2056,7 @@ struct DorydClientTests { _ = try await client.machineList() Issue.record("present malformed capability handshake must fail closed") } catch let error as DorydClientError { - #expect(error.description.contains("invalid doryd response")) + #expect(error.description.contains("invalid machine list")) } } } @@ -2129,7 +2171,6 @@ struct DorydClientTests { #expect(valid.guestQuiesceReceipt?.agentBuild == "dory-agent/test") for fixture in [ - (consistency: "crash-consistent" as Any, receipt: nil as NSDictionary?), (consistency: 1 as Any, receipt: nil as NSDictionary?), (consistency: "guest-quiesced" as Any, receipt: nil as NSDictionary?), (consistency: "cold-stopped" as Any, receipt: receipt), @@ -2413,6 +2454,39 @@ struct DorydClientTests { ] as NSDictionary } + private func validPortableVZRuntimeIdentity() -> NSDictionary { + [ + "schemaVersion": 1, + "mode": "resolved-plan", + "virtualHardwareABIVersion": 1, + "definitionRevision": UInt64(1), + "definitionSHA256": String(repeating: "1", count: 64), + "planRevision": UInt64(1), + "planSHA256": String(repeating: "2", count: 64), + "backend": "apple-virtualization-framework", + "backendImplementationIdentifier": "dory.vz-linux.compatibility.v1", + "backendRuntimeBuildIdentifier": "sha256:" + String(repeating: "3", count: 64), + "supportTier": "supported", + "graphics": "software", + "removableUSBHotplug": false, + "selectionDisposition": "primary", + "components": [[ + "componentIdentifier": "dory-vmm", + "buildIdentifier": "sha256:" + String(repeating: "3", count: 64), + "artifactSHA256": String(repeating: "3", count: 64), + ]], + "bootMedia": [ + "kind": "installer-iso", + "source": "user-provided", + "artifactSHA256": String(repeating: "4", count: 64), + "resolverNamespace": "legacy-artifact", + "resolverIdentifier": "portable-installer", + "inspectionIdentity": "dory-iso-inspector:portable", + "inspectionReportSHA256": String(repeating: "5", count: 64), + ], + ] as NSDictionary + } + private func integrationHealthDictionary( _ health: DoryGuestIntegrationHealth ) throws -> NSDictionary { @@ -2534,7 +2608,7 @@ struct DorydClientTests { let listener = NSXPCListener.anonymous() let service = FakeDorydService(socketPath: socketPath) - let operationID = String(repeating: "r", count: 32) + let operationID = String(repeating: "e", count: 32) let active = service.machineTransferOperationResponse( operationID: operationID, phase: "transferring" @@ -2699,6 +2773,9 @@ struct DorydClientTests { var customInstaller = machine customInstaller.bootMode = .efi + customInstaller.shellSocketPath = "" + #expect(store.machineTerminalCommand(customInstaller) == nil) + #expect(!store.canOpenMachineTerminal(customInstaller)) #expect(!store.canRepairMachineTools(customInstaller)) var headlessMachine = machine @@ -3202,8 +3279,18 @@ struct DorydClientTests { @MainActor @Test func appStoreEditsTypedLeavesWithoutRewritingLegacyEnvironment() async throws { + let base = "/tmp/dory-typed-edit-\(getpid())-\(UInt32.random(in: 0.. NSDictionary? { + lock.lock(); defer { lock.unlock() } + return machines[machineID]?["runtimeGraphicsSelection"] as? NSDictionary + } + + func defaultRuntimeGraphicsSelection(_ machineID: String) -> NSDictionary? { + lock.lock(); defer { lock.unlock() } + guard let identity = machines[machineID]?["runtimeIdentity"] as? NSDictionary else { + return nil + } + return Self.runtimeGraphicsSelection(for: identity) + } + + func setMachineRuntimeGraphicsSelection( + _ machineID: String, + _ selection: NSDictionary? + ) { + lock.lock(); defer { lock.unlock() } + guard let row = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let selection { + row["runtimeGraphicsSelection"] = selection + } else { + row.removeObject(forKey: "runtimeGraphicsSelection") + } + machines[machineID] = row.copy() as? NSDictionary + } + func setEngineStatus(_ state: String, detail: String = "ok") { lock.lock() _engineState = state @@ -4819,6 +4993,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.unlock() } + func setDashboardSnapshot(_ snapshot: [String: Data]) { + lock.lock() + _engineDashboardSnapshot = snapshot + lock.unlock() + } + func setMachineFlightRecorderBatch(_ response: NSDictionary?) { lock.lock() _machineFlightRecorderBatchOverride = response @@ -4917,6 +5097,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(state, detail) } + func engineDashboardSnapshot(reply: @escaping (NSDictionary, String) -> Void) { + lock.lock() + _engineDashboardSnapshotCount += 1 + let snapshot = _engineDashboardSnapshot + lock.unlock() + guard let snapshot else { + reply([:], "dashboard snapshot unavailable in fake service") + return + } + reply(snapshot.mapValues { $0 as NSData } as NSDictionary, "") + } + func engineStart(reply: @escaping (Bool, String) -> Void) { lock.lock() _engineStartCount += 1 @@ -4981,7 +5173,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { cpuCount: Self.int(config["cpuCount"]) ?? 2, address: config["address"] as? String, displayMode: config["displayMode"] as? String ?? "headless", - shares: Self.shareRows(config["shares"]), + shares: Self.machineStatusShareRows(config["shares"]), environment: Self.typedEnvironment(config, baseline: []) ) lock.lock() @@ -6148,6 +6340,43 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return row as NSDictionary } + private static func runtimeGraphicsSelection( + for runtimeIdentity: NSDictionary + ) -> NSDictionary? { + guard runtimeIdentity["mode"] as? String == "resolved-plan", + runtimeIdentity["backend"] as? String == "dory-hypervisor", + let planSHA256 = runtimeIdentity["planSHA256"] as? String, + let planRevision = runtimeIdentity["planRevision"], + let graphics = runtimeIdentity["graphics"] as? String else { + return nil + } + let backend: String + switch graphics { + case "software": + backend = "software" + case "host-accelerated-display": + backend = "virgl" + case "hardware-accelerated-3d": + backend = "virgl-venus" + default: + return nil + } + var selection: [String: Any] = [ + "schemaVersion": UInt16(1), + "operationID": "01234567-89ab-4cde-8f01-23456789abcd", + "resolvedPlanSHA256": planSHA256, + "planRevision": planRevision, + "accelerationLevel": graphics, + "backend": backend, + ] + if graphics != "software" { + selection["rendererGeneration"] = UInt64(1) + selection["rendererWorkerReceiptSHA256"] = String(repeating: "7", count: 64) + selection["guestProducerFenceProofSHA256"] = String(repeating: "8", count: 64) + } + return selection as NSDictionary + } + private static let savedStateRow: NSDictionary = [ "schemaVersion": 1, "backend": "apple-virtualization-framework", @@ -6169,6 +6398,26 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return [] } + /// Machine-create requests carry host authorization bookmarks. Daemon status deliberately + /// projects only non-secret share identity, so the fake must enforce the same XPC boundary. + private static func machineStatusShareRows(_ value: Any?) -> [NSDictionary] { + shareRows(value).compactMap { row in + guard let tag = row["tag"] as? String, + let hostPath = row["hostPath"] as? String, + let guestPath = row["guestPath"] as? String, + let readOnly = row["readOnly"] as? NSNumber, + CFGetTypeID(readOnly) == CFBooleanGetTypeID() else { + return nil + } + return [ + "tag": tag, + "hostPath": hostPath, + "guestPath": guestPath, + "readOnly": readOnly, + ] as NSDictionary + } + } + private static func environmentRows(_ value: Any?) -> [NSDictionary] { if let rows = value as? [NSDictionary] { return rows diff --git a/DoryTests/DorydLaunchAgentTests.swift b/DoryTests/DorydLaunchAgentTests.swift index f94e88a6..6c9f81b5 100644 --- a/DoryTests/DorydLaunchAgentTests.swift +++ b/DoryTests/DorydLaunchAgentTests.swift @@ -446,6 +446,25 @@ struct DorydLaunchAgentTests { #expect(plist.contains("0")) } + @Test func launchAgentBindsRawHVToNestedRunnerApplication() throws { + let plist = DorydLaunchAgent.launchAgentPlist( + program: "/Applications/Dory.app/Contents/Helpers/doryd", + helpersDirectory: URL(fileURLWithPath: "/Applications/Dory.app/Contents/Helpers") + ) + let data = try #require(plist.data(using: .utf8)) + let root = try #require( + try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + as? [String: Any] + ) + let environment = try #require(root["EnvironmentVariables"] as? [String: String]) + + #expect( + environment["DORYD_HV_HELPER"] + == "/Applications/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" + ) + #expect(environment["DORYD_HV_HELPER"] != "/Applications/Dory.app/Contents/Helpers/dory-hv") + } + @Test func launchAgentCanDisableDaemonOwnedDomains() throws { let plist = DorydLaunchAgent.launchAgentPlist( program: "/Applications/Dory.app/Contents/Helpers/doryd", diff --git a/DoryTests/KubernetesProvisionerImageTests.swift b/DoryTests/KubernetesProvisionerImageTests.swift index 91a2ead1..f2854355 100644 --- a/DoryTests/KubernetesProvisionerImageTests.swift +++ b/DoryTests/KubernetesProvisionerImageTests.swift @@ -24,17 +24,15 @@ struct KubernetesProvisionerImageTests { #expect(labels[KubernetesProvisioner.imageLabel] == image) } - @Test func fexCreateRequestMountsOnlyTheReadOnlyWrapper() throws { + @Test func fexCreateRequestDelegatesNestedRuntimeMountsToEngineAdmission() throws { let image = KubeVersionCatalog.latest.image let root = try jsonObject(image: image, amd64Emulation: true) let host = try #require(root["HostConfig"] as? [String: Any]) let labels = try #require(root["Labels"] as? [String: String]) let entrypoint = try #require(root["Entrypoint"] as? [String]) - let binds = try #require(host["Binds"] as? [String]) #expect(entrypoint == ["/bin/sh", "-ec"]) - #expect(binds == ["/usr/local/bin/dory-runc:/usr/local/bin/dory-runc:ro"]) - #expect(!binds.joined().contains("runc.real")) + #expect(host["Binds"] == nil) #expect(labels[KubernetesProvisioner.emulationLabel] == "fex") } @@ -45,8 +43,8 @@ struct KubernetesProvisionerImageTests { #expect(script.contains("test -x /usr/lib/dory/fex/FEX")) #expect(script.contains("test -x /usr/lib/dory/fex/FEXServer")) - #expect(script.contains("install -m 0755 /bin/runc /usr/local/bin/runc.real")) - #expect(script.contains("cmp -s /bin/runc /usr/local/bin/runc.real")) + #expect(script.contains("test -x /usr/local/bin/runc.real")) + #expect(!script.contains("install -m 0755 /bin/runc")) #expect(script.contains("BinaryName = \"/usr/local/bin/dory-runc\"")) #expect(!script.contains("platforms = [")) #expect(script.contains("runtime_config_tmp=")) diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 93082ae0..54fbaeaf 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -65,6 +65,31 @@ struct NewMachineSettingsTests { #expect(DoryInstallerMachinePolicy.defaultMemoryMB == 4_096) } + @Test func customISOHomeShareUsesAnExplicitManualVirtioFSTag() { + let mount = NewMachineSheet.sharedHomeMount( + home: "/Users/tester", + displayMode: .desktop, + customISOInstall: true, + guestUsername: "installer-user" + ) + + #expect(mount.host == "/Users/tester") + #expect(mount.guest == "/mnt/dory-mac-home") + #expect(mount.shareTag == "mac-home") + } + + @Test func managedDesktopHomeShareKeepsTheProvisionedUserPath() { + let mount = NewMachineSheet.sharedHomeMount( + home: "/Users/tester", + displayMode: .desktop, + customISOInstall: false, + guestUsername: "dory-user" + ) + + #expect(mount.guest == "/home/dory-user/Mac") + #expect(mount.shareTag == nil) + } + @Test func collectsResourcesRegardlessOfDisclosure() { let s = NewMachineSheet.buildSettings(cpus: 4, memoryGB: 8, mounts: [MountPair(host: "/Users/u/p", guest: "/Users/u/p")], diff --git a/DoryTests/RuntimeSupportTests.swift b/DoryTests/RuntimeSupportTests.swift index bfea4eaf..b9c68b37 100644 --- a/DoryTests/RuntimeSupportTests.swift +++ b/DoryTests/RuntimeSupportTests.swift @@ -400,6 +400,14 @@ struct RuntimeSupportTests { #expect(!amd64.contains("/repo/Packages/ContainerizationEngine/.build/arm64-apple-macosx/debug/dory-hv")) } + @Test func bundledRawHVUsesNestedRunnerApplication() { + let application = URL(fileURLWithPath: "/Applications/Dory.app", isDirectory: true) + #expect( + SharedVMProvisioner.bundledHVRunnerExecutable(in: application) + == "/Applications/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" + ) + } + @Test func sharedVMEmulationInstallerTargetsRequestedArch() throws { let arm64 = try JSONSerialization.jsonObject(with: SharedVMProvisioner.binfmtInstallBody(for: .arm64)) as? [String: Any] let amd64 = try JSONSerialization.jsonObject(with: SharedVMProvisioner.binfmtInstallBody(for: .amd64)) as? [String: Any] diff --git a/LINUX_DESKTOP_PARITY.md b/LINUX_DESKTOP_PARITY.md index 145c8e61..ee51335a 100644 --- a/LINUX_DESKTOP_PARITY.md +++ b/LINUX_DESKTOP_PARITY.md @@ -1,5 +1,13 @@ # Linux desktop parity contract +> **Status note (2026-08-22):** This document remains the product parity bar, but its narrative +> “Current state” cells are a historical snapshot and must not be used as release support claims. +> Implementation, product reachability, qualification, and support are now tracked separately in +> [`docs/linux-capability-and-qualification-matrix.md`](docs/linux-capability-and-qualification-matrix.md). +> The controlling design and sequencing are +> [`docs/linux-virtual-workspace-architecture.md`](docs/linux-virtual-workspace-architecture.md) and +> [`docs/linux-virtual-workspace-delivery-plan.md`](docs/linux-virtual-workspace-delivery-plan.md). + This document defines Dory's release bar for a Parallels-class Linux experience on Apple Silicon. The comparison target is the Linux feature set in Parallels Desktop 26, not its Windows-only features. A capability is not considered shipped because code or a package exists: it must pass an @@ -26,7 +34,7 @@ Primary references: | Custom Linux | Create an ARM64 Linux VM from a user-selected ISO through EFI, with persistent NVRAM and install media lifecycle | The signed app securely stages protected user-selected media, fingerprints the exact bytes, reports architecture compatibility separately from runtime qualification, imports accepted media into private daemon-managed storage, creates a thin disk, and boots through persistent EFI. Desktop ISO machines use balanced 4-vCPU/4-GB resource defaults rather than treating a CPU count as a compatibility workaround. A private bidirectional serial console gives every EFI boot durable diagnostics and recovery input. The EFI root disk is now native NVMe with fsync semantics; Dory-owned direct-kernel guests retain their VirtIO-block contract. Ubuntu 24.04.3 ARM64 completed installation and booted its persistent desktop after ISO ejection, but a later whole-guest stall during a Chromium snap installation keeps the exact runtime unqualified | | Native and Intel apps | Native ARM64 apps work normally; supported x86_64 Linux applications use Apple's Linux translation runtime with guided setup and clear compatibility reporting | Missing for desktop machines | | Display | Retina rendering, dynamic resolution during resize, full screen, correct scaling, cursor integration, and multi-display support | Single-display Retina, resize, native full-screen participation, and system-key capture exist; full qualification and multi-display are missing | -| Graphics | Hardware-accelerated Linux graphics meeting Parallels' advertised OpenGL level, with a software fallback and an application compatibility suite | Raw-HV desktop VirGL2/Venus rendering and Metal scanout are implemented; GTK4 uses its qualified accelerated GL renderer to avoid newer-renderer texture corruption, Firefox uses its reliable XWayland presentation path, and Files/Settings/Calculator/Firefox are locally normal. The broader OpenGL suite and rebuilt exact-candidate gate remain | +| Graphics | Hardware-accelerated Linux graphics meeting Parallels' advertised OpenGL level, with a software fallback and an application compatibility suite | The isolated dual VirGL2/Venus renderer, real packaged-worker bootstrap receipt, fresh exact live comparison, and candidate-bound crash circuit breaker are implemented. A renderer failure stops the uncertain GPU generation safely and makes the next automatic plan select its declared software recovery level; a hardware-only request stays an error. Hardware 3D remains unqualified until the release-signed candidate passes the physical Mesa VirGL desktop and Venus/Zed sustained-application gates | | Clipboard | Bidirectional text and image clipboard with an explicit off/host-to-guest/guest-to-host/bidirectional policy | Binary-safe host/guest transport, native shortcuts, focus synchronization, Wayland/X11 adapters, and policy UI are implemented; all-distro live and exact-candidate qualification remain | | Drag and drop | Bidirectional file drag and drop between Finder and the Linux desktop with conflict, cancellation, and progress handling | Missing | | Shared folders | Add/remove read-only or read-write Mac folders, stable guest paths, permissions, large-file tests, file watching, and safe runtime updates | Scoped VirtioFS shares exist; runtime mutation and complete compatibility qualification are missing | diff --git a/Packages/ContainerizationEngine/DoryFSWorker.entitlements b/Packages/ContainerizationEngine/DoryFSWorker.entitlements new file mode 100644 index 00000000..5ff2a90f --- /dev/null +++ b/Packages/ContainerizationEngine/DoryFSWorker.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.bookmarks.app-scope + + + diff --git a/Packages/ContainerizationEngine/DoryRendererWorker.entitlements b/Packages/ContainerizationEngine/DoryRendererWorker.entitlements new file mode 100644 index 00000000..6737bc7d --- /dev/null +++ b/Packages/ContainerizationEngine/DoryRendererWorker.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + 864H636QW4.dory-renderer + + + diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift index 3e972078..a851cf24 100644 --- a/Packages/ContainerizationEngine/Package.swift +++ b/Packages/ContainerizationEngine/Package.swift @@ -8,7 +8,30 @@ let package = Package( name: "ContainerizationEngine", platforms: [.macOS(.v15)], products: [ + .library(name: "DoryFSWorkerContracts", targets: ["DoryFSWorkerContracts"]), + .library(name: "DoryFSWorkerServiceCore", targets: ["DoryFSWorkerServiceCore"]), + .library( + name: "DoryRendererWorkerContracts", + targets: ["DoryRendererWorkerContracts"] + ), + .library( + name: "DoryRendererWorkerServiceCore", + targets: ["DoryRendererWorkerServiceCore"] + ), + .library( + name: "DoryRendererWorkerMetalTransport", + targets: ["DoryRendererWorkerMetalTransport"] + ), + .library( + name: "DoryRendererWorkerVirglBackend", + targets: ["DoryRendererWorkerVirglBackend"] + ), + .library(name: "DoryHV", targets: ["DoryHV"]), .executable(name: "dory-hv", targets: ["dory-hv"]), + .executable( + name: "dory-renderer-worker", + targets: ["DoryRendererWorkerXPCService"] + ), ], dependencies: [ // Keep the guest control wire protocol in one implementation. DoryCore embeds the Rust @@ -17,6 +40,60 @@ let package = Package( .package(path: "../../dory-core-swift"), ], targets: [ + // Foundation-only wire contracts shared by the VMM-side broker and signed XPC service. + // SwiftPM builds the protocol leaf and executable test surface; Dory.xcodeproj owns the + // actual sandboxed XPC bundle, nested embed, and inside-out signing graph. + .target(name: "DoryFSWorkerContracts"), + // Path-free worker-side authority acquisition. Keep this leaf independent of the VMM, + // Hypervisor.framework, IOKit, and the daemon so a signed service can embed it directly. + .target( + name: "DoryFSWorkerServiceCore", + dependencies: ["DoryFSWorkerContracts"] + ), + // Path-free binary authority shared by the VMM and the signed renderer service. The + // contract intentionally has no dependency on Hypervisor.framework, AppKit, Metal, or the + // foreign renderer ABI. + .target( + name: "DoryRendererWorkerContracts", + dependencies: [ + .product( + name: "DoryRendererWorkerWireContracts", + package: "dory-core-swift" + ), + ] + ), + // Metal/XPC transport is deliberately outside the Foundation/CryptoKit authority package + // consumed by doryd. Only the signed runner and renderer service depend on this leaf. + .target( + name: "DoryRendererWorkerMetalTransport", + dependencies: ["DoryRendererWorkerContracts"], + linkerSettings: [.linkedFramework("Metal")] + ), + .target( + name: "DoryRendererWorkerServiceCore", + dependencies: [ + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + ] + ), + .target( + name: "DoryVirglRendererShim" + ), + .target( + name: "DoryRendererWorkerVirglBackend", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + "DoryVirglRendererShim", + ], + linkerSettings: [ + .linkedFramework("Metal"), + ] + ), + .target( + name: "DoryGuestMemoryShim" + ), .target( name: "DoryHVUSBShim", linkerSettings: [ @@ -27,41 +104,91 @@ let package = Package( .target( name: "DoryHV", dependencies: [ + "DoryFSWorkerContracts", + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", "DoryHVUSBShim", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), ], linkerSettings: [ .linkedFramework("Hypervisor"), .linkedFramework("CoreServices"), .linkedFramework("IOKit"), .linkedFramework("IOUSBHost"), - .linkedFramework("OpenGL"), ] ), .executableTarget( name: "dory-hv", dependencies: [ + "DoryFSWorkerContracts", "DoryHV", + "DoryRendererWorkerContracts", .product(name: "DoryCore", package: "dory-core-swift"), .product(name: "DorydKit", package: "dory-core-swift"), .product(name: "DoryOperations", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), .product(name: "DoryVMMKit", package: "dory-core-swift"), ], linkerSettings: [ .linkedFramework("AppKit"), .linkedFramework("AVFAudio"), .linkedFramework("AVFoundation"), - .linkedFramework("Metal"), - .linkedFramework("MetalKit"), + ] + ), + .executableTarget( + name: "DoryRendererWorkerXPCService", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + "DoryRendererWorkerServiceCore", + "DoryRendererWorkerVirglBackend", + ] + ), + .testTarget( + name: "DoryFSWorkerServiceCoreTests", + dependencies: [ + "DoryFSWorkerContracts", + "DoryFSWorkerServiceCore", + ] + ), + .testTarget( + name: "DoryRendererWorkerContractsTests", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + ] + ), + .testTarget( + name: "DoryRendererWorkerServiceCoreTests", + dependencies: [ + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + ] + ), + .testTarget( + name: "DoryRendererWorkerVirglBackendTests", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + "DoryRendererWorkerVirglBackend", + "DoryVirglRendererShim", ] ), .testTarget( name: "DoryHVTests", dependencies: [ + "DoryFSWorkerContracts", + "DoryFSWorkerServiceCore", + "DoryRendererWorkerContracts", "DoryHV", + "DoryVirglRendererShim", "dory-hv", .product(name: "DoryCore", package: "dory-core-swift"), .product(name: "DoryOperations", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), ] ), ] diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift new file mode 100644 index 00000000..c546ed81 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift @@ -0,0 +1,1062 @@ +import Foundation + +/// Validation failures for the one-workspace worker bootstrap. The bootstrap is an authority +/// envelope rather than a persistence format, so version 1 rejects every unknown flag, reserved +/// byte, non-canonical ordering, and trailing byte. +public enum DoryFSWorkerBootstrapError: Error, Equatable, Sendable { + case invalidWorkspaceIdentity + case invalidPinnedRootIdentity(field: String) + case invalidWorkerLimits(field: String) + case invalidShareResourceLimits(field: String) + case incompatibleShareLimit(field: String) + case invalidShareCount(limit: Int, actual: Int) + case duplicateShareCapabilityID + case invalidBookmarkSize(limit: Int, actual: Int) + case invalidComponent(String) + case tooManyComponents(limit: Int, actual: Int) + case componentBytesTooLarge(limit: Int, actual: Int) + case duplicateComponent(String) + case overlappingComponent(String) + case bootstrapTooLarge(limit: Int, actual: Int) + case shortBootstrap(minimum: Int, actual: Int) + case invalidBootstrapMagic + case unsupportedBootstrapVersion(UInt16) + case bootstrapLengthMismatch(declared: UInt32, actual: Int) + case truncatedField(String) + case invalidShareRecordLength(UInt32) + case shareRecordLengthMismatch(declared: UInt32, consumed: Int) + case invalidShareFlags(UInt16) + case nonzeroReservedField + case nonCanonicalEncoding + case invalidReceiptShareCount(limit: Int, actual: Int) +} + +/// Daemon-owned identity of the one workspace authorized in a worker process. The all-zero UUID is +/// permanently reserved so an uninitialized launch envelope can never be mistaken for authority. +public struct DoryFSWorkerWorkspaceID: Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) throws { + guard rawValue != Self.zeroUUID else { + throw DoryFSWorkerBootstrapError.invalidWorkspaceIdentity + } + self.rawValue = rawValue + } + + public static func random() -> Self { + while true { + if let value = try? Self(rawValue: UUID()) { return value } + } + } + + private static let zeroUUID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! +} + +/// Descriptor identity sealed by the daemon before bootstrap and compared by the worker after it +/// resolves the opaque bookmark. Generation may legitimately be zero on filesystems that do not +/// expose one; device and inode may not be zero sentinels. +public struct DoryFSPinnedRootIdentity: Equatable, Sendable { + public let device: UInt64 + public let inode: UInt64 + public let generation: UInt64 + + public init(device: UInt64, inode: UInt64, generation: UInt64) throws { + guard device != 0 else { + throw DoryFSWorkerBootstrapError.invalidPinnedRootIdentity(field: "device") + } + guard inode != 0 else { + throw DoryFSWorkerBootstrapError.invalidPinnedRootIdentity(field: "inode") + } + self.device = device + self.inode = inode + self.generation = generation + } +} + +/// Guest-visible ownership policy. UID and GID zero are valid and deliberately have no sentinel +/// meaning: a Linux root-owned view is a normal policy choice, not missing configuration. +public struct DoryFSGuestIdentityPolicy: Equatable, Sendable { + public let uid: UInt32 + public let gid: UInt32 + + public init(uid: UInt32, gid: UInt32) { + self.uid = uid + self.gid = gid + } +} + +/// Per-share ceilings enforced in addition to the workspace-wide worker limits. These values +/// cover admission memory and every long-lived HostFS resource class, including descriptor +/// headroom that must remain unavailable to guest work. +public struct DoryFSShareResourceLimits: Equatable, Sendable { + public static let absoluteMaximumInFlightRequests = 4_096 + public static let absoluteMaximumAggregateBytes = 8 * 1_024 * 1_024 * 1_024 + public static let absoluteMaximumLiveNonRootNodes = 1_048_576 + public static let absoluteMaximumFileHandles = 262_144 + public static let absoluteMaximumDirectoryHandles = 65_536 + public static let absoluteMaximumDirectoryCursorEntries = 4_194_304 + public static let absoluteMaximumDirectoryCursorNameBytes = 512 * 1_024 * 1_024 + public static let absoluteMaximumAdvisoryLockOwners = 65_536 + public static let absoluteMaximumPendingBlockingLocks = 65_536 + public static let absoluteMaximumReservedFileDescriptorHeadroom = 65_536 + + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + public let maximumLiveNonRootNodes: Int + public let maximumFileHandles: Int + public let maximumDirectoryHandles: Int + public let maximumDirectoryCursorEntries: Int + public let maximumDirectoryCursorNameBytes: Int + public let maximumAdvisoryLockOwners: Int + public let maximumPendingBlockingLocks: Int + public let reservedFileDescriptorHeadroom: Int + + public init( + maximumInFlightRequests: Int, + maximumAggregateRequestBytes: Int, + maximumAggregateResponseBytes: Int, + maximumLiveNonRootNodes: Int, + maximumFileHandles: Int, + maximumDirectoryHandles: Int, + maximumDirectoryCursorEntries: Int, + maximumDirectoryCursorNameBytes: Int, + maximumAdvisoryLockOwners: Int, + maximumPendingBlockingLocks: Int, + reservedFileDescriptorHeadroom: Int + ) throws { + try Self.require( + maximumInFlightRequests, + atMost: Self.absoluteMaximumInFlightRequests, + field: "maximumInFlightRequests" + ) + try Self.require( + maximumAggregateRequestBytes, + atMost: Self.absoluteMaximumAggregateBytes, + field: "maximumAggregateRequestBytes" + ) + try Self.require( + maximumAggregateResponseBytes, + atMost: Self.absoluteMaximumAggregateBytes, + field: "maximumAggregateResponseBytes" + ) + try Self.require( + maximumLiveNonRootNodes, + atMost: Self.absoluteMaximumLiveNonRootNodes, + field: "maximumLiveNonRootNodes" + ) + try Self.require( + maximumFileHandles, + atMost: Self.absoluteMaximumFileHandles, + field: "maximumFileHandles" + ) + try Self.require( + maximumDirectoryHandles, + atMost: Self.absoluteMaximumDirectoryHandles, + field: "maximumDirectoryHandles" + ) + try Self.require( + maximumDirectoryCursorEntries, + atMost: Self.absoluteMaximumDirectoryCursorEntries, + field: "maximumDirectoryCursorEntries" + ) + try Self.require( + maximumDirectoryCursorNameBytes, + atMost: Self.absoluteMaximumDirectoryCursorNameBytes, + field: "maximumDirectoryCursorNameBytes" + ) + try Self.require( + maximumAdvisoryLockOwners, + atMost: Self.absoluteMaximumAdvisoryLockOwners, + field: "maximumAdvisoryLockOwners" + ) + try Self.require( + maximumPendingBlockingLocks, + atMost: Self.absoluteMaximumPendingBlockingLocks, + field: "maximumPendingBlockingLocks" + ) + try Self.require( + reservedFileDescriptorHeadroom, + atMost: Self.absoluteMaximumReservedFileDescriptorHeadroom, + field: "reservedFileDescriptorHeadroom" + ) + self.maximumInFlightRequests = maximumInFlightRequests + self.maximumAggregateRequestBytes = maximumAggregateRequestBytes + self.maximumAggregateResponseBytes = maximumAggregateResponseBytes + self.maximumLiveNonRootNodes = maximumLiveNonRootNodes + self.maximumFileHandles = maximumFileHandles + self.maximumDirectoryHandles = maximumDirectoryHandles + self.maximumDirectoryCursorEntries = maximumDirectoryCursorEntries + self.maximumDirectoryCursorNameBytes = maximumDirectoryCursorNameBytes + self.maximumAdvisoryLockOwners = maximumAdvisoryLockOwners + self.maximumPendingBlockingLocks = maximumPendingBlockingLocks + self.reservedFileDescriptorHeadroom = reservedFileDescriptorHeadroom + } + + /// Matches the current production HostFS ceilings while leaving explicit non-guest descriptor + /// capacity for the worker channel, bookmark roots, event streams, and supervisor plumbing. + public static let production: Self = try! Self( + maximumInFlightRequests: 32, + maximumAggregateRequestBytes: 8 * (40 + 1 * 1_024 * 1_024), + maximumAggregateResponseBytes: 8 * (16 + 1 * 1_024 * 1_024), + maximumLiveNonRootNodes: 65_536, + maximumFileHandles: 16_384, + maximumDirectoryHandles: 4_096, + maximumDirectoryCursorEntries: 262_144, + maximumDirectoryCursorNameBytes: 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: 4_096, + maximumPendingBlockingLocks: 1_024, + reservedFileDescriptorHeadroom: 256 + ) + + private static func require(_ value: Int, atMost limit: Int, field: String) throws { + guard value > 0, value <= limit else { + throw DoryFSWorkerBootstrapError.invalidShareResourceLimits(field: field) + } + } +} + +/// Complete immutable authority for one share. There is intentionally no host path or guest mount +/// path: the worker resolves only `securityScopedBookmark`, verifies `expectedRootIdentity`, and +/// labels subsequent traffic solely by `capabilityID`. +public struct DoryFSShareBootstrapAuthority: Equatable, Sendable { + public let capabilityID: DoryFSShareCapabilityID + public let expectedRootIdentity: DoryFSPinnedRootIdentity + public let readOnly: Bool + public let guestIdentity: DoryFSGuestIdentityPolicy + public let resourceLimits: DoryFSShareResourceLimits + public let securityScopedBookmark: Data + public let hiddenComponents: [String] + public let rootHiddenComponents: [String] + + public init( + capabilityID: DoryFSShareCapabilityID, + expectedRootIdentity: DoryFSPinnedRootIdentity, + readOnly: Bool, + guestIdentity: DoryFSGuestIdentityPolicy, + resourceLimits: DoryFSShareResourceLimits, + securityScopedBookmark: Data, + hiddenComponents: [String] = [], + rootHiddenComponents: [String] = [] + ) throws { + guard !securityScopedBookmark.isEmpty, + securityScopedBookmark.count <= DoryFSWorkerBootstrapCodec.maximumBookmarkBytes else { + throw DoryFSWorkerBootstrapError.invalidBookmarkSize( + limit: DoryFSWorkerBootstrapCodec.maximumBookmarkBytes, + actual: securityScopedBookmark.count + ) + } + let hidden = try Self.canonicalComponents(hiddenComponents) + let rootHidden = try Self.canonicalComponents(rootHiddenComponents) + let hiddenSet = Set(hidden) + if let overlap = rootHidden.first(where: hiddenSet.contains) { + throw DoryFSWorkerBootstrapError.overlappingComponent(overlap) + } + let encodedComponentBytes = Self.encodedByteCount(hidden) + + Self.encodedByteCount(rootHidden) + guard encodedComponentBytes <= DoryFSWorkerBootstrapCodec.maximumComponentBytesPerShare else { + throw DoryFSWorkerBootstrapError.componentBytesTooLarge( + limit: DoryFSWorkerBootstrapCodec.maximumComponentBytesPerShare, + actual: encodedComponentBytes + ) + } + self.capabilityID = capabilityID + self.expectedRootIdentity = expectedRootIdentity + self.readOnly = readOnly + self.guestIdentity = guestIdentity + self.resourceLimits = resourceLimits + self.securityScopedBookmark = securityScopedBookmark + self.hiddenComponents = hidden + self.rootHiddenComponents = rootHidden + } + + private static func canonicalComponents(_ source: [String]) throws -> [String] { + guard source.count <= DoryFSWorkerBootstrapCodec.maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: DoryFSWorkerBootstrapCodec.maximumComponentsPerList, + actual: source.count + ) + } + var seen = Set() + var result = [String]() + result.reserveCapacity(source.count) + for component in source { + guard !component.isEmpty, + component != ".", + component != "..", + !component.contains("/"), + !component.utf8.contains(0), + component.utf8.count <= DoryFSWorkerBootstrapCodec.maximumComponentBytes else { + throw DoryFSWorkerBootstrapError.invalidComponent(component) + } + // HostFS compares hidden names case-insensitively. Normalize at the authority boundary + // so visually equivalent/case-variant entries have one exact wire spelling. + let canonical = component + .precomposedStringWithCanonicalMapping + .lowercased() + .precomposedStringWithCanonicalMapping + guard !canonical.isEmpty, + canonical.utf8.count <= DoryFSWorkerBootstrapCodec.maximumComponentBytes else { + throw DoryFSWorkerBootstrapError.invalidComponent(component) + } + guard seen.insert(canonical).inserted else { + throw DoryFSWorkerBootstrapError.duplicateComponent(canonical) + } + result.append(canonical) + } + return result.sorted(by: Self.utf8Precedes) + } + + private static func utf8Precedes(_ lhs: String, _ rhs: String) -> Bool { + lhs.utf8.lexicographicallyPrecedes(rhs.utf8) + } + + private static func encodedByteCount(_ values: [String]) -> Int { + values.reduce(into: 0) { total, value in + total += 2 + value.utf8.count + } + } +} + +/// A complete, immutable one-workspace launch envelope. Construction canonicalizes share order; +/// binary decoding additionally requires that the received bytes were already canonical. +public struct DoryFSWorkerBootstrap: Equatable, Sendable { + public let workspaceID: DoryFSWorkerWorkspaceID + public let generation: DoryFSWorkerGeneration + public let workerLimits: DoryFSWorkerLimits + public let shares: [DoryFSShareBootstrapAuthority] + + public init( + workspaceID: DoryFSWorkerWorkspaceID, + generation: DoryFSWorkerGeneration, + workerLimits: DoryFSWorkerLimits, + shares: [DoryFSShareBootstrapAuthority] + ) throws { + guard (1...DoryFSWorkerBootstrapCodec.maximumShares).contains(shares.count) else { + throw DoryFSWorkerBootstrapError.invalidShareCount( + limit: DoryFSWorkerBootstrapCodec.maximumShares, + actual: shares.count + ) + } + try DoryFSWorkerBootstrapCodec.validate(workerLimits: workerLimits) + var capabilities = Set() + for share in shares { + guard capabilities.insert(share.capabilityID).inserted else { + throw DoryFSWorkerBootstrapError.duplicateShareCapabilityID + } + guard share.resourceLimits.maximumInFlightRequests + <= workerLimits.maximumInFlightRequests else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumInFlightRequests" + ) + } + guard share.resourceLimits.maximumAggregateRequestBytes + <= workerLimits.maximumAggregateRequestBytes else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumAggregateRequestBytes" + ) + } + guard share.resourceLimits.maximumAggregateResponseBytes + <= workerLimits.maximumAggregateResponseBytes else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumAggregateResponseBytes" + ) + } + } + // Reject an oversized authority set before the encoder allocates and copies its opaque + // bookmarks. Per-share limits alone would otherwise permit a bounded but much larger + // intermediate buffer than the version-1 wire envelope. + _ = try DoryFSWorkerBootstrapCodec.encodedBootstrapByteCount(shares: shares) + self.workspaceID = workspaceID + self.generation = generation + self.workerLimits = workerLimits + self.shares = shares.sorted { + Self.uuidBytes($0.capabilityID.rawValue) + .lexicographicallyPrecedes(Self.uuidBytes($1.capabilityID.rawValue)) + } + } + + private static func uuidBytes(_ value: UUID) -> [UInt8] { + let raw = value.uuid + return [ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ] + } +} + +/// Exact receipt returned only after the worker has accepted every share authority. Process IDs +/// are deliberately absent: they are supervisor telemetry, not authorization or receipt identity. +public struct DoryFSWorkerBootstrapReceipt: Equatable, Sendable { + public let workspaceID: DoryFSWorkerWorkspaceID + public let generation: DoryFSWorkerGeneration + public let acceptedShareCount: UInt16 + + public init( + workspaceID: DoryFSWorkerWorkspaceID, + generation: DoryFSWorkerGeneration, + acceptedShareCount: UInt16 + ) throws { + guard (1...DoryFSWorkerBootstrapCodec.maximumShares).contains(Int(acceptedShareCount)) else { + throw DoryFSWorkerBootstrapError.invalidReceiptShareCount( + limit: DoryFSWorkerBootstrapCodec.maximumShares, + actual: Int(acceptedShareCount) + ) + } + self.workspaceID = workspaceID + self.generation = generation + self.acceptedShareCount = acceptedShareCount + } + + public init(accepting bootstrap: DoryFSWorkerBootstrap) { + self.workspaceID = bootstrap.workspaceID + self.generation = bootstrap.generation + self.acceptedShareCount = UInt16(bootstrap.shares.count) + } +} + +/// Exact little-endian version-1 bootstrap and receipt codec. This is intentionally independent +/// of `Codable`, property lists, keyed archives, and Swift object layout. +public enum DoryFSWorkerBootstrapCodec { + public static let version: UInt16 = 1 + public static let bootstrapHeaderByteCount = 88 + public static let shareRecordHeaderByteCount = 128 + public static let receiptByteCount = 40 + public static let maximumShares = 64 + public static let maximumBookmarkBytes = 256 * 1_024 + public static let maximumComponentsPerList = 256 + public static let maximumComponentBytes = 255 + public static let maximumComponentBytesPerShare = 128 * 1_024 + public static let absoluteMaximumBootstrapBytes = 4 * 1_024 * 1_024 + + private static let bootstrapMagic: [UInt8] = [0x44, 0x46, 0x53, 0x42] // DFSB + private static let receiptMagic: [UInt8] = [0x44, 0x46, 0x53, 0x52] // DFSR + private static let absoluteMaximumOperationNanoseconds: UInt64 = 3_600_000_000_000 + + public static func encode(_ bootstrap: DoryFSWorkerBootstrap) throws -> Data { + try validate(workerLimits: bootstrap.workerLimits) + let encodedByteCount = try encodedBootstrapByteCount(shares: bootstrap.shares) + var writer = BootstrapWriter(reservingCapacity: encodedByteCount) + writer.append(bootstrapMagic) + writer.append(version) + writer.append(UInt16(0)) // flags + let totalLengthOffset = writer.count + writer.append(UInt32(0)) + writer.append(UInt16(bootstrap.shares.count)) + writer.append(UInt16(0)) // reserved + writer.append(bootstrap.workspaceID.rawValue) + writer.append(bootstrap.generation.rawValue) + append(bootstrap.workerLimits, to: &writer) + precondition(writer.count == bootstrapHeaderByteCount) + for share in bootstrap.shares { + try append(share, to: &writer) + } + precondition(writer.count == encodedByteCount) + writer.replaceUInt32(at: totalLengthOffset, with: UInt32(writer.count)) + return writer.data + } + + public static func decode(_ data: Data) throws -> DoryFSWorkerBootstrap { + guard data.count <= absoluteMaximumBootstrapBytes else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: data.count + ) + } + guard data.count >= bootstrapHeaderByteCount else { + throw DoryFSWorkerBootstrapError.shortBootstrap( + minimum: bootstrapHeaderByteCount, + actual: data.count + ) + } + var reader = BootstrapReader(data: data) + guard try reader.readBytes(count: 4, field: "magic") == bootstrapMagic else { + throw DoryFSWorkerBootstrapError.invalidBootstrapMagic + } + let decodedVersion = try reader.readUInt16(field: "version") + guard decodedVersion == version else { + throw DoryFSWorkerBootstrapError.unsupportedBootstrapVersion(decodedVersion) + } + guard try reader.readUInt16(field: "flags") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let declaredLength = try reader.readUInt32(field: "totalLength") + guard Int(declaredLength) == data.count else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: declaredLength, + actual: data.count + ) + } + let shareCount = Int(try reader.readUInt16(field: "shareCount")) + guard (1...maximumShares).contains(shareCount) else { + throw DoryFSWorkerBootstrapError.invalidShareCount( + limit: maximumShares, + actual: shareCount + ) + } + guard try reader.readUInt16(field: "reserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let workspaceID = try DoryFSWorkerWorkspaceID( + rawValue: reader.readUUID(field: "workspaceID") + ) + let generation = try DoryFSWorkerGeneration( + rawValue: reader.readUInt64(field: "generation") + ) + let workerLimits = try readWorkerLimits(from: &reader) + var shares = [DoryFSShareBootstrapAuthority]() + shares.reserveCapacity(shareCount) + for index in 0.. Data { + var writer = BootstrapWriter() + writer.append(receiptMagic) + writer.append(version) + writer.append(UInt16(0)) // flags + writer.append(UInt32(receiptByteCount)) + writer.append(receipt.acceptedShareCount) + writer.append(UInt16(0)) // reserved + writer.append(receipt.workspaceID.rawValue) + writer.append(receipt.generation.rawValue) + precondition(writer.count == receiptByteCount) + return writer.data + } + + public static func decodeReceipt(_ data: Data) throws -> DoryFSWorkerBootstrapReceipt { + guard data.count >= receiptByteCount else { + throw DoryFSWorkerBootstrapError.shortBootstrap( + minimum: receiptByteCount, + actual: data.count + ) + } + guard data.count <= receiptByteCount else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: UInt32(receiptByteCount), + actual: data.count + ) + } + var reader = BootstrapReader(data: data) + guard try reader.readBytes(count: 4, field: "receiptMagic") == receiptMagic else { + throw DoryFSWorkerBootstrapError.invalidBootstrapMagic + } + let decodedVersion = try reader.readUInt16(field: "receiptVersion") + guard decodedVersion == version else { + throw DoryFSWorkerBootstrapError.unsupportedBootstrapVersion(decodedVersion) + } + guard try reader.readUInt16(field: "receiptFlags") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let declaredLength = try reader.readUInt32(field: "receiptLength") + guard declaredLength == UInt32(receiptByteCount) else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: declaredLength, + actual: data.count + ) + } + let shareCount = try reader.readUInt16(field: "receiptShareCount") + guard try reader.readUInt16(field: "receiptReserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let receipt = try DoryFSWorkerBootstrapReceipt( + workspaceID: DoryFSWorkerWorkspaceID( + rawValue: reader.readUUID(field: "receiptWorkspaceID") + ), + generation: DoryFSWorkerGeneration( + rawValue: reader.readUInt64(field: "receiptGeneration") + ), + acceptedShareCount: shareCount + ) + guard reader.isAtEnd, encode(receipt) == data else { + throw DoryFSWorkerBootstrapError.nonCanonicalEncoding + } + return receipt + } + + static func validate(workerLimits: DoryFSWorkerLimits) throws { + guard workerLimits.maximumRequestBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumRequestBytes" + ) + } + guard workerLimits.maximumResponseBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumResponseBytes" + ) + } + guard workerLimits.maximumFrameBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "maximumFrameBytes") + } + guard workerLimits.maximumInFlightRequests + <= DoryFSShareResourceLimits.absoluteMaximumInFlightRequests else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumInFlightRequests" + ) + } + guard workerLimits.maximumAggregateRequestBytes + <= DoryFSShareResourceLimits.absoluteMaximumAggregateBytes else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumAggregateRequestBytes" + ) + } + guard workerLimits.maximumAggregateResponseBytes + <= DoryFSShareResourceLimits.absoluteMaximumAggregateBytes else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumAggregateResponseBytes" + ) + } + guard workerLimits.maximumOperationNanoseconds + <= absoluteMaximumOperationNanoseconds else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumOperationNanoseconds" + ) + } + guard workerLimits.maximumDrainNanoseconds + <= absoluteMaximumOperationNanoseconds else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumDrainNanoseconds" + ) + } + } + + fileprivate static func encodedBootstrapByteCount( + shares: [DoryFSShareBootstrapAuthority] + ) throws -> Int { + var total = bootstrapHeaderByteCount + for share in shares { + let componentBytes = encodedComponentBytes(share.hiddenComponents) + + encodedComponentBytes(share.rootHiddenComponents) + let (recordPayload, payloadOverflow) = share.securityScopedBookmark.count + .addingReportingOverflow(componentBytes) + let (recordBytes, recordOverflow) = shareRecordHeaderByteCount + .addingReportingOverflow(recordPayload) + let (nextTotal, totalOverflow) = total.addingReportingOverflow(recordBytes) + guard !payloadOverflow, !recordOverflow, !totalOverflow, + nextTotal <= absoluteMaximumBootstrapBytes else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: payloadOverflow || recordOverflow || totalOverflow + ? Int.max + : nextTotal + ) + } + total = nextTotal + } + return total + } + + private static func append(_ limits: DoryFSWorkerLimits, to writer: inout BootstrapWriter) { + writer.append(UInt32(limits.maximumRequestBytes)) + writer.append(UInt32(limits.maximumResponseBytes)) + writer.append(UInt32(limits.maximumFrameBytes)) + writer.append(UInt32(limits.maximumInFlightRequests)) + writer.append(UInt64(limits.maximumAggregateRequestBytes)) + writer.append(UInt64(limits.maximumAggregateResponseBytes)) + writer.append(limits.maximumOperationNanoseconds) + writer.append(limits.maximumDrainNanoseconds) + } + + private static func append( + _ share: DoryFSShareBootstrapAuthority, + to writer: inout BootstrapWriter + ) throws { + let recordStart = writer.count + writer.append(UInt32(0)) + writer.append(UInt16(share.readOnly ? 1 : 0)) + writer.append(UInt16(0)) // reserved + writer.append(share.capabilityID.rawValue) + writer.append(share.expectedRootIdentity.device) + writer.append(share.expectedRootIdentity.inode) + writer.append(share.expectedRootIdentity.generation) + writer.append(share.guestIdentity.uid) + writer.append(share.guestIdentity.gid) + let limits = share.resourceLimits + writer.append(UInt32(limits.maximumInFlightRequests)) + writer.append(UInt64(limits.maximumAggregateRequestBytes)) + writer.append(UInt64(limits.maximumAggregateResponseBytes)) + writer.append(UInt32(limits.maximumLiveNonRootNodes)) + writer.append(UInt32(limits.maximumFileHandles)) + writer.append(UInt32(limits.maximumDirectoryHandles)) + writer.append(UInt32(limits.maximumDirectoryCursorEntries)) + writer.append(UInt64(limits.maximumDirectoryCursorNameBytes)) + writer.append(UInt32(limits.maximumAdvisoryLockOwners)) + writer.append(UInt32(limits.maximumPendingBlockingLocks)) + writer.append(UInt32(limits.reservedFileDescriptorHeadroom)) + writer.append(UInt32(share.securityScopedBookmark.count)) + writer.append(UInt16(share.hiddenComponents.count)) + writer.append(UInt16(share.rootHiddenComponents.count)) + let componentByteCount = encodedComponentBytes(share.hiddenComponents) + + encodedComponentBytes(share.rootHiddenComponents) + writer.append(UInt32(componentByteCount)) + writer.append(UInt32(0)) // reserved + precondition(writer.count - recordStart == shareRecordHeaderByteCount) + writer.append(Array(share.securityScopedBookmark)) + for component in share.hiddenComponents + share.rootHiddenComponents { + let bytes = Array(component.utf8) + writer.append(UInt16(bytes.count)) + writer.append(bytes) + } + let recordLength = writer.count - recordStart + guard recordLength <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: writer.count + ) + } + writer.replaceUInt32(at: recordStart, with: UInt32(recordLength)) + } + + private static func readWorkerLimits( + from reader: inout BootstrapReader + ) throws -> DoryFSWorkerLimits { + let request = try reader.readUInt32(field: "maximumRequestBytes") + let response = try reader.readUInt32(field: "maximumResponseBytes") + let frame = try reader.readUInt32(field: "maximumFrameBytes") + let inFlight = try reader.readUInt32(field: "maximumInFlightRequests") + let aggregateRequest = try reader.readUInt64(field: "maximumAggregateRequestBytes") + let aggregateResponse = try reader.readUInt64(field: "maximumAggregateResponseBytes") + let operation = try reader.readUInt64(field: "maximumOperationNanoseconds") + let drain = try reader.readUInt64(field: "maximumDrainNanoseconds") + guard aggregateRequest <= UInt64(Int.max), aggregateResponse <= UInt64(Int.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "aggregateBytes") + } + do { + let limits = try DoryFSWorkerLimits( + maximumRequestBytes: Int(request), + maximumResponseBytes: Int(response), + maximumFrameBytes: Int(frame), + maximumInFlightRequests: Int(inFlight), + maximumAggregateRequestBytes: Int(aggregateRequest), + maximumAggregateResponseBytes: Int(aggregateResponse), + maximumOperationNanoseconds: operation, + maximumDrainNanoseconds: drain + ) + try validate(workerLimits: limits) + return limits + } catch let error as DoryFSWorkerBootstrapError { + throw error + } catch let error as DoryFSWorkerContractError { + if case .invalidLimits(let field) = error { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: field) + } + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "unknown") + } + } + + private static func readShare( + from reader: inout BootstrapReader, + index: Int + ) throws -> DoryFSShareBootstrapAuthority { + let recordStart = reader.offset + let recordLength = try reader.readUInt32(field: "share[\(index)].recordLength") + guard recordLength >= UInt32(shareRecordHeaderByteCount), + Int(recordLength) <= reader.remaining + 4 else { + throw DoryFSWorkerBootstrapError.invalidShareRecordLength(recordLength) + } + let recordEnd = recordStart + Int(recordLength) + reader.pushLimit(recordEnd) + defer { reader.popLimit() } + let flags = try reader.readUInt16(field: "share[\(index)].flags") + guard flags & ~UInt16(1) == 0 else { + throw DoryFSWorkerBootstrapError.invalidShareFlags(flags) + } + guard try reader.readUInt16(field: "share[\(index)].reserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let capability = try DoryFSShareCapabilityID( + rawValue: reader.readUUID(field: "share[\(index)].capabilityID") + ) + let root = try DoryFSPinnedRootIdentity( + device: reader.readUInt64(field: "share[\(index)].device"), + inode: reader.readUInt64(field: "share[\(index)].inode"), + generation: reader.readUInt64(field: "share[\(index)].rootGeneration") + ) + let guest = DoryFSGuestIdentityPolicy( + uid: try reader.readUInt32(field: "share[\(index)].guestUID"), + gid: try reader.readUInt32(field: "share[\(index)].guestGID") + ) + let limits = try readShareLimits(from: &reader, index: index) + let bookmarkLength = Int( + try reader.readUInt32(field: "share[\(index)].bookmarkLength") + ) + guard (1...maximumBookmarkBytes).contains(bookmarkLength) else { + throw DoryFSWorkerBootstrapError.invalidBookmarkSize( + limit: maximumBookmarkBytes, + actual: bookmarkLength + ) + } + let hiddenCount = Int( + try reader.readUInt16(field: "share[\(index)].hiddenCount") + ) + let rootHiddenCount = Int( + try reader.readUInt16(field: "share[\(index)].rootHiddenCount") + ) + guard hiddenCount <= maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: maximumComponentsPerList, + actual: hiddenCount + ) + } + guard rootHiddenCount <= maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: maximumComponentsPerList, + actual: rootHiddenCount + ) + } + let declaredComponentBytes = Int( + try reader.readUInt32(field: "share[\(index)].componentBytes") + ) + guard declaredComponentBytes <= maximumComponentBytesPerShare else { + throw DoryFSWorkerBootstrapError.componentBytesTooLarge( + limit: maximumComponentBytesPerShare, + actual: declaredComponentBytes + ) + } + guard try reader.readUInt32(field: "share[\(index)].reserved2") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let bookmark = Data( + try reader.readBytes(count: bookmarkLength, field: "share[\(index)].bookmark") + ) + let componentStart = reader.offset + let hidden = try readComponents(count: hiddenCount, from: &reader, index: index) + let rootHidden = try readComponents( + count: rootHiddenCount, + from: &reader, + index: index + ) + let consumedComponentBytes = reader.offset - componentStart + guard consumedComponentBytes == declaredComponentBytes, + reader.offset == recordEnd else { + throw DoryFSWorkerBootstrapError.shareRecordLengthMismatch( + declared: recordLength, + consumed: reader.offset - recordStart + ) + } + return try DoryFSShareBootstrapAuthority( + capabilityID: capability, + expectedRootIdentity: root, + readOnly: flags & 1 == 1, + guestIdentity: guest, + resourceLimits: limits, + securityScopedBookmark: bookmark, + hiddenComponents: hidden, + rootHiddenComponents: rootHidden + ) + } + + private static func readShareLimits( + from reader: inout BootstrapReader, + index: Int + ) throws -> DoryFSShareResourceLimits { + let inFlight = try reader.readUInt32(field: "share[\(index)].maximumInFlightRequests") + let aggregateRequest = try reader.readUInt64( + field: "share[\(index)].maximumAggregateRequestBytes" + ) + let aggregateResponse = try reader.readUInt64( + field: "share[\(index)].maximumAggregateResponseBytes" + ) + let nodes = try reader.readUInt32(field: "share[\(index)].maximumLiveNonRootNodes") + let fileHandles = try reader.readUInt32(field: "share[\(index)].maximumFileHandles") + let directoryHandles = try reader.readUInt32( + field: "share[\(index)].maximumDirectoryHandles" + ) + let cursorEntries = try reader.readUInt32( + field: "share[\(index)].maximumDirectoryCursorEntries" + ) + let cursorNameBytes = try reader.readUInt64( + field: "share[\(index)].maximumDirectoryCursorNameBytes" + ) + let lockOwners = try reader.readUInt32( + field: "share[\(index)].maximumAdvisoryLockOwners" + ) + let blockingLocks = try reader.readUInt32( + field: "share[\(index)].maximumPendingBlockingLocks" + ) + let descriptorHeadroom = try reader.readUInt32( + field: "share[\(index)].reservedFileDescriptorHeadroom" + ) + let values = [aggregateRequest, aggregateResponse, cursorNameBytes] + guard values.allSatisfy({ $0 <= UInt64(Int.max) }) else { + throw DoryFSWorkerBootstrapError.invalidShareResourceLimits( + field: "wideInteger" + ) + } + return try DoryFSShareResourceLimits( + maximumInFlightRequests: Int(inFlight), + maximumAggregateRequestBytes: Int(aggregateRequest), + maximumAggregateResponseBytes: Int(aggregateResponse), + maximumLiveNonRootNodes: Int(nodes), + maximumFileHandles: Int(fileHandles), + maximumDirectoryHandles: Int(directoryHandles), + maximumDirectoryCursorEntries: Int(cursorEntries), + maximumDirectoryCursorNameBytes: Int(cursorNameBytes), + maximumAdvisoryLockOwners: Int(lockOwners), + maximumPendingBlockingLocks: Int(blockingLocks), + reservedFileDescriptorHeadroom: Int(descriptorHeadroom) + ) + } + + private static func readComponents( + count: Int, + from reader: inout BootstrapReader, + index: Int + ) throws -> [String] { + var result = [String]() + result.reserveCapacity(count) + for componentIndex in 0..") + } + let bytes = try reader.readBytes( + count: length, + field: "share[\(index)].component[\(componentIndex)]" + ) + guard let component = String(bytes: bytes, encoding: .utf8) else { + throw DoryFSWorkerBootstrapError.invalidComponent("") + } + result.append(component) + } + return result + } + + private static func encodedComponentBytes(_ values: [String]) -> Int { + values.reduce(into: 0) { total, value in total += 2 + value.utf8.count } + } +} + +private struct BootstrapWriter { + private(set) var bytes: [UInt8] + + init(reservingCapacity: Int = 0) { + bytes = [] + bytes.reserveCapacity(reservingCapacity) + } + + var count: Int { bytes.count } + var data: Data { Data(bytes) } + + mutating func append(_ values: [UInt8]) { + bytes.append(contentsOf: values) + } + + mutating func append(_ value: UInt16) { + bytes.append(UInt8(truncatingIfNeeded: value)) + bytes.append(UInt8(truncatingIfNeeded: value >> 8)) + } + + mutating func append(_ value: UInt32) { + for shift in stride(from: 0, through: 24, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt32(shift))) + } + } + + mutating func append(_ value: UInt64) { + for shift in stride(from: 0, through: 56, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + mutating func append(_ value: UUID) { + let raw = value.uuid + append([ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ]) + } + + mutating func replaceUInt32(at offset: Int, with value: UInt32) { + for index in 0..<4 { + bytes[offset + index] = UInt8(truncatingIfNeeded: value >> UInt32(index * 8)) + } + } +} + +private struct BootstrapReader { + private let bytes: [UInt8] + private var limits: [Int] + private(set) var offset = 0 + + init(data: Data) { + self.bytes = [UInt8](data) + self.limits = [data.count] + } + + var remaining: Int { limits.last! - offset } + var isAtEnd: Bool { offset == limits.last! } + + mutating func pushLimit(_ value: Int) { + precondition(value >= offset && value <= limits.last!) + limits.append(value) + } + + mutating func popLimit() { + precondition(limits.count > 1) + limits.removeLast() + } + + mutating func readBytes(count: Int, field: String) throws -> [UInt8] { + guard count >= 0, count <= remaining else { + throw DoryFSWorkerBootstrapError.truncatedField(field) + } + let start = offset + offset += count + return Array(bytes[start.. UInt16 { + let value = try readBytes(count: 2, field: field) + return UInt16(value[0]) | (UInt16(value[1]) << 8) + } + + mutating func readUInt32(field: String) throws -> UInt32 { + let value = try readBytes(count: 4, field: field) + var result: UInt32 = 0 + for index in 0..<4 { + result |= UInt32(value[index]) << UInt32(index * 8) + } + return result + } + + mutating func readUInt64(field: String) throws -> UInt64 { + let value = try readBytes(count: 8, field: field) + var result: UInt64 = 0 + for index in 0..<8 { + result |= UInt64(value[index]) << UInt64(index * 8) + } + return result + } + + mutating func readUUID(field: String) throws -> UUID { + let value = try readBytes(count: 16, field: field) + return UUID(uuid: ( + value[0], value[1], value[2], value[3], + value[4], value[5], value[6], value[7], + value[8], value[9], value[10], value[11], + value[12], value[13], value[14], value[15] + )) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift new file mode 100644 index 00000000..3c5718ad --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift @@ -0,0 +1,852 @@ +import Foundation + +/// Versioned, Foundation-only values that may cross the private DoryFS worker channel. The XPC +/// adapter transports one encoded `Data` value per call; neither side relies on Swift object +/// layout, `Codable` key handling, or guest-controlled host paths. +public enum DoryFSWorkerProtocol { + public static let version: UInt16 = 1 +} + +public enum DoryFSWorkerContractError: Error, Equatable, Sendable { + case invalidGeneration + case invalidCapabilityID + case invalidRequestID + case invalidCorrelationID + case invalidDeadline + case invalidLimits(field: String) + case frameTooLarge(limit: Int, actual: Int) + case shortFrame(minimum: Int, actual: Int) + case invalidMagic + case unsupportedVersion(UInt16) + case unknownFrameKind(UInt8) + case unknownOpcodeClass(UInt8) + case unknownReplyDisposition(UInt8) + case unknownRejectionCode(UInt16) + case nonzeroReservedField + case payloadLengthMismatch(declared: UInt32, actual: Int) + case unexpectedField(frame: String, field: String) +} + +public struct DoryFSWorkerGeneration: Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) throws { + guard rawValue != 0 else { throw DoryFSWorkerContractError.invalidGeneration } + self.rawValue = rawValue + } +} + +public struct DoryFSShareCapabilityID: Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) throws { + guard rawValue != Self.zeroUUID else { + throw DoryFSWorkerContractError.invalidCapabilityID + } + self.rawValue = rawValue + } + + public static func random() -> Self { + // UUID() cannot produce the all-zero sentinel in practice. Retain the loop so the type's + // invariant remains unconditional rather than probabilistic. + while true { + if let value = try? Self(rawValue: UUID()) { return value } + } + } + + private static let zeroUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! +} + +public enum DoryFSWorkerOpcodeClass: UInt8, Equatable, Sendable { + /// Connection setup and non-filesystem control work. + case control = 1 + /// Namespace/attribute work that does not modify host state. + case metadata = 2 + /// File or directory payload transfer. + case data = 3 + /// Any operation that may change namespace, data, attributes, mappings, or locks. + case mutation = 4 + /// Priority cancellation control; adapters must not queue it behind normal blocking work. + case interrupt = 5 +} + +public enum DoryFSWorkerRejectionCode: UInt16, Equatable, Sendable { + case invalidRequest = 1 + case staleGeneration = 2 + case unknownShare = 3 + case deadlineExpired = 4 + case resourceExhausted = 5 + case shuttingDown = 6 + case internalFailure = 7 + /// This FUSE connection accepted DESTROY and no longer admits filesystem work. The worker + /// generation remains fail-stop; callers must not reinterpret this as a reconnect invitation. + case connectionTeardown = 8 +} + +/// Immutable launch-envelope bounds. A future worker receives the same values at bootstrap; the +/// broker enforces them before IPC and the worker must independently enforce them after decoding. +public struct DoryFSWorkerLimits: Equatable, Sendable { + public static let absoluteMaximumFrameBytes = 2 * 1_024 * 1_024 + + public let maximumRequestBytes: Int + public let maximumResponseBytes: Int + public let maximumFrameBytes: Int + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + public let maximumOperationNanoseconds: UInt64 + public let maximumDrainNanoseconds: UInt64 + + public init( + maximumRequestBytes: Int, + maximumResponseBytes: Int, + maximumFrameBytes: Int, + maximumInFlightRequests: Int, + maximumAggregateRequestBytes: Int, + maximumAggregateResponseBytes: Int, + maximumOperationNanoseconds: UInt64, + maximumDrainNanoseconds: UInt64 + ) throws { + guard maximumRequestBytes > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumRequestBytes") + } + guard maximumResponseBytes >= 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumResponseBytes") + } + guard maximumFrameBytes >= DoryFSWorkerFrameCodec.headerByteCount, + maximumFrameBytes <= Self.absoluteMaximumFrameBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + let largestPayload = max(maximumRequestBytes, maximumResponseBytes) + guard largestPayload <= maximumFrameBytes - DoryFSWorkerFrameCodec.headerByteCount else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + guard maximumInFlightRequests > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumInFlightRequests") + } + guard maximumAggregateRequestBytes >= maximumRequestBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumAggregateRequestBytes") + } + guard maximumAggregateResponseBytes >= maximumResponseBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumAggregateResponseBytes") + } + guard maximumOperationNanoseconds > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumOperationNanoseconds") + } + guard maximumDrainNanoseconds > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumDrainNanoseconds") + } + self.maximumRequestBytes = maximumRequestBytes + self.maximumResponseBytes = maximumResponseBytes + self.maximumFrameBytes = maximumFrameBytes + self.maximumInFlightRequests = maximumInFlightRequests + self.maximumAggregateRequestBytes = maximumAggregateRequestBytes + self.maximumAggregateResponseBytes = maximumAggregateResponseBytes + self.maximumOperationNanoseconds = maximumOperationNanoseconds + self.maximumDrainNanoseconds = maximumDrainNanoseconds + } + + /// Matches the current one-MiB FUSE payload negotiation while limiting aggregate mailbox + /// reservations to eight maximum-sized requests/replies and at most 32 small concurrent calls. + public static let production: Self = try! Self( + maximumRequestBytes: 40 + 1 * 1_024 * 1_024, + maximumResponseBytes: 16 + 1 * 1_024 * 1_024, + maximumFrameBytes: 1_024 * 1_024 + 128, + maximumInFlightRequests: 32, + maximumAggregateRequestBytes: 8 * (40 + 1 * 1_024 * 1_024), + maximumAggregateResponseBytes: 8 * (16 + 1 * 1_024 * 1_024), + maximumOperationNanoseconds: 30_000_000_000, + maximumDrainNanoseconds: 5_000_000_000 + ) +} + +public struct DoryFSWorkerRequest: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + public let opcodeClass: DoryFSWorkerOpcodeClass + public let responseCapacity: UInt32 + public let deadlineUptimeNanoseconds: UInt64 + public let payload: Data + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + responseCapacity: UInt32, + deadlineUptimeNanoseconds: UInt64, + payload: Data + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + self.opcodeClass = opcodeClass + self.responseCapacity = responseCapacity + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + self.payload = payload + } +} + +public struct DoryFSWorkerInterrupt: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let targetRequestID: UInt64 + public let targetCorrelationID: UInt64 + public let deadlineUptimeNanoseconds: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + targetRequestID: UInt64, + targetCorrelationID: UInt64, + deadlineUptimeNanoseconds: UInt64 + ) throws { + guard targetRequestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard targetCorrelationID != 0 else { + throw DoryFSWorkerContractError.invalidCorrelationID + } + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.targetRequestID = targetRequestID + self.targetCorrelationID = targetCorrelationID + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + } +} + +public struct DoryFSWorkerDrain: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let deadlineUptimeNanoseconds: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + deadlineUptimeNanoseconds: UInt64 + ) throws { + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + } +} + +public struct DoryFSWorkerInvalidation: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID + ) { + self.generation = generation + self.shareCapabilityID = shareCapabilityID + } +} + +/// Completes the second phase of one worker execution after the VMM has either published the +/// response into the exact leased virtqueue chain or proved that publication did not happen. +/// +/// The worker retains any FUSE lookup/handle grants until this acknowledgement arrives. A +/// `.discardPublication` acknowledgement rolls those grants back; connection loss before either +/// acknowledgement is fail-stop for the worker generation. +public struct DoryFSWorkerPublication: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64 + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + } +} + +public enum DoryFSWorkerReplyOutcome: Equatable, Sendable { + case completed(Data) + case rejected(DoryFSWorkerRejectionCode) +} + +public struct DoryFSWorkerReply: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + public let outcome: DoryFSWorkerReplyOutcome + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + outcome: DoryFSWorkerReplyOutcome + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + self.outcome = outcome + } +} + +public struct DoryFSWorkerDrained: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID + ) { + self.generation = generation + self.shareCapabilityID = shareCapabilityID + } +} + +public enum DoryFSWorkerClientFrame: Equatable, Sendable { + case execute(DoryFSWorkerRequest) + case interrupt(DoryFSWorkerInterrupt) + case drain(DoryFSWorkerDrain) + case invalidate(DoryFSWorkerInvalidation) + case commitPublication(DoryFSWorkerPublication) + case discardPublication(DoryFSWorkerPublication) +} + +public enum DoryFSWorkerServiceFrame: Equatable, Sendable { + case reply(DoryFSWorkerReply) + case drained(DoryFSWorkerDrained) +} + +/// Exact version-1 binary framing. All integer fields are little endian, every reserved bit must be +/// zero, and the declared payload must consume the complete frame. This avoids last-key-wins and +/// unknown-field behavior at the future XPC boundary. +public enum DoryFSWorkerFrameCodec { + public static let headerByteCount = 72 + + private static let magic: [UInt8] = [0x44, 0x46, 0x53, 0x31] // "DFS1" + + private enum Kind: UInt8 { + case execute = 1 + case interrupt = 2 + case drain = 3 + case invalidate = 4 + case reply = 5 + case drained = 6 + case commitPublication = 7 + case discardPublication = 8 + } + + public static func encode( + _ frame: DoryFSWorkerClientFrame, + maximumFrameBytes: Int + ) throws -> Data { + switch frame { + case .execute(let request): + return try encodeFrame( + kind: .execute, + generation: request.generation, + capability: request.shareCapabilityID, + requestID: request.requestID, + correlationID: request.correlationID, + deadline: request.deadlineUptimeNanoseconds, + responseCapacity: request.responseCapacity, + opcodeClass: request.opcodeClass.rawValue, + disposition: 0, + rejection: 0, + payload: request.payload, + maximumFrameBytes: maximumFrameBytes + ) + case .interrupt(let interrupt): + return try encodeFrame( + kind: .interrupt, + generation: interrupt.generation, + capability: interrupt.shareCapabilityID, + requestID: interrupt.targetRequestID, + correlationID: interrupt.targetCorrelationID, + deadline: interrupt.deadlineUptimeNanoseconds, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .drain(let drain): + return try encodeFrame( + kind: .drain, + generation: drain.generation, + capability: drain.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: drain.deadlineUptimeNanoseconds, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .invalidate(let invalidation): + return try encodeFrame( + kind: .invalidate, + generation: invalidation.generation, + capability: invalidation.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .commitPublication(let publication): + return try encodePublication( + publication, + kind: .commitPublication, + maximumFrameBytes: maximumFrameBytes + ) + case .discardPublication(let publication): + return try encodePublication( + publication, + kind: .discardPublication, + maximumFrameBytes: maximumFrameBytes + ) + } + } + + public static func encode( + _ frame: DoryFSWorkerServiceFrame, + maximumFrameBytes: Int + ) throws -> Data { + switch frame { + case .reply(let reply): + let disposition: UInt8 + let rejection: UInt16 + let payload: Data + switch reply.outcome { + case .completed(let bytes): + disposition = 1 + rejection = 0 + payload = bytes + case .rejected(let code): + disposition = 2 + rejection = code.rawValue + payload = Data() + } + return try encodeFrame( + kind: .reply, + generation: reply.generation, + capability: reply.shareCapabilityID, + requestID: reply.requestID, + correlationID: reply.correlationID, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: disposition, + rejection: rejection, + payload: payload, + maximumFrameBytes: maximumFrameBytes + ) + case .drained(let drained): + return try encodeFrame( + kind: .drained, + generation: drained.generation, + capability: drained.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + } + } + + public static func decodeClientFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DoryFSWorkerClientFrame { + let decoded = try decodeFrame(data, maximumFrameBytes: maximumFrameBytes) + switch decoded.kind { + case .execute: + guard decoded.disposition == 0, decoded.rejection == 0 else { + throw DoryFSWorkerContractError.unexpectedField(frame: "execute", field: "disposition") + } + guard let opcodeClass = DoryFSWorkerOpcodeClass(rawValue: decoded.opcodeClass) else { + throw DoryFSWorkerContractError.unknownOpcodeClass(decoded.opcodeClass) + } + return .execute(try DoryFSWorkerRequest( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID, + opcodeClass: opcodeClass, + responseCapacity: decoded.responseCapacity, + deadlineUptimeNanoseconds: decoded.deadline, + payload: decoded.payload + )) + case .interrupt: + try requireZero(decoded.responseCapacity, frame: "interrupt", field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: "interrupt", field: "opcodeClass") + try requireZero(decoded.disposition, frame: "interrupt", field: "disposition") + try requireZero(decoded.rejection, frame: "interrupt", field: "rejection") + try requireEmpty(decoded.payload, frame: "interrupt") + return .interrupt(try DoryFSWorkerInterrupt( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + targetRequestID: decoded.requestID, + targetCorrelationID: decoded.correlationID, + deadlineUptimeNanoseconds: decoded.deadline + )) + case .drain: + try requireControlFieldsZero(decoded, frame: "drain", permitDeadline: true) + return .drain(try DoryFSWorkerDrain( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + deadlineUptimeNanoseconds: decoded.deadline + )) + case .invalidate: + try requireControlFieldsZero(decoded, frame: "invalidate", permitDeadline: false) + return .invalidate(DoryFSWorkerInvalidation( + generation: decoded.generation, + shareCapabilityID: decoded.capability + )) + case .commitPublication: + return .commitPublication(try decodePublication(decoded, frame: "commitPublication")) + case .discardPublication: + return .discardPublication(try decodePublication(decoded, frame: "discardPublication")) + case .reply, .drained: + throw DoryFSWorkerContractError.unexpectedField(frame: "client", field: "kind") + } + } + + public static func decodeServiceFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DoryFSWorkerServiceFrame { + let decoded = try decodeFrame(data, maximumFrameBytes: maximumFrameBytes) + switch decoded.kind { + case .reply: + try requireZero(decoded.deadline, frame: "reply", field: "deadline") + try requireZero(decoded.responseCapacity, frame: "reply", field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: "reply", field: "opcodeClass") + let outcome: DoryFSWorkerReplyOutcome + switch decoded.disposition { + case 1: + try requireZero(decoded.rejection, frame: "reply", field: "rejection") + outcome = .completed(decoded.payload) + case 2: + try requireEmpty(decoded.payload, frame: "reply") + guard let rejection = DoryFSWorkerRejectionCode(rawValue: decoded.rejection) else { + throw DoryFSWorkerContractError.unknownRejectionCode(decoded.rejection) + } + outcome = .rejected(rejection) + default: + throw DoryFSWorkerContractError.unknownReplyDisposition(decoded.disposition) + } + return .reply(try DoryFSWorkerReply( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID, + outcome: outcome + )) + case .drained: + try requireControlFieldsZero(decoded, frame: "drained", permitDeadline: false) + return .drained(DoryFSWorkerDrained( + generation: decoded.generation, + shareCapabilityID: decoded.capability + )) + case .execute, .interrupt, .drain, .invalidate, + .commitPublication, .discardPublication: + throw DoryFSWorkerContractError.unexpectedField(frame: "service", field: "kind") + } + } + + private static func encodePublication( + _ publication: DoryFSWorkerPublication, + kind: Kind, + maximumFrameBytes: Int + ) throws -> Data { + try encodeFrame( + kind: kind, + generation: publication.generation, + capability: publication.shareCapabilityID, + requestID: publication.requestID, + correlationID: publication.correlationID, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + } + + private static func decodePublication( + _ decoded: DecodedFrame, + frame: String + ) throws -> DoryFSWorkerPublication { + try requireZero(decoded.deadline, frame: frame, field: "deadline") + try requireZero(decoded.responseCapacity, frame: frame, field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: frame, field: "opcodeClass") + try requireZero(decoded.disposition, frame: frame, field: "disposition") + try requireZero(decoded.rejection, frame: frame, field: "rejection") + try requireEmpty(decoded.payload, frame: frame) + return try DoryFSWorkerPublication( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID + ) + } + + private struct DecodedFrame { + let kind: Kind + let generation: DoryFSWorkerGeneration + let capability: DoryFSShareCapabilityID + let requestID: UInt64 + let correlationID: UInt64 + let deadline: UInt64 + let responseCapacity: UInt32 + let opcodeClass: UInt8 + let disposition: UInt8 + let rejection: UInt16 + let payload: Data + } + + private static func encodeFrame( + kind: Kind, + generation: DoryFSWorkerGeneration, + capability: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + deadline: UInt64, + responseCapacity: UInt32, + opcodeClass: UInt8, + disposition: UInt8, + rejection: UInt16, + payload: Data, + maximumFrameBytes: Int + ) throws -> Data { + try validateMaximumFrameBytes(maximumFrameBytes) + guard payload.count <= Int(UInt32.max) else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: Int.max + ) + } + let (frameSize, overflow) = headerByteCount.addingReportingOverflow(payload.count) + guard !overflow, frameSize <= maximumFrameBytes else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: overflow ? Int.max : frameSize + ) + } + var header = [UInt8]() + header.reserveCapacity(headerByteCount) + header.append(contentsOf: magic) + append(DoryFSWorkerProtocol.version, to: &header) + header.append(kind.rawValue) + header.append(0) // flags + append(generation.rawValue, to: &header) + append(capability.rawValue, to: &header) + append(requestID, to: &header) + append(correlationID, to: &header) + append(deadline, to: &header) + append(responseCapacity, to: &header) + append(UInt32(payload.count), to: &header) + header.append(opcodeClass) + header.append(disposition) + append(rejection, to: &header) + append(UInt32(0), to: &header) // reserved + precondition(header.count == headerByteCount) + + // Keep the fixed header as the only byte-array staging. Building a payload-sized Array and + // then converting that Array into Data duplicated every large FUSE frame before XPC. + var data = Data(capacity: frameSize) + data.append(contentsOf: header) + data.append(payload) + return data + } + + private static func decodeFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DecodedFrame { + try validateMaximumFrameBytes(maximumFrameBytes) + guard data.count <= maximumFrameBytes else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: data.count + ) + } + guard data.count >= headerByteCount else { + throw DoryFSWorkerContractError.shortFrame( + minimum: headerByteCount, + actual: data.count + ) + } + // Decode only the fixed header. The payload remains a bounded Data slice over the received + // frame and can cross the nested RPC/frame decoder without another full-frame allocation. + let header = [UInt8](data.prefix(headerByteCount)) + guard Array(header[0..<4]) == magic else { + throw DoryFSWorkerContractError.invalidMagic + } + let version = readUInt16(header, at: 4) + guard version == DoryFSWorkerProtocol.version else { + throw DoryFSWorkerContractError.unsupportedVersion(version) + } + guard let kind = Kind(rawValue: header[6]) else { + throw DoryFSWorkerContractError.unknownFrameKind(header[6]) + } + guard header[7] == 0, readUInt32(header, at: 68) == 0 else { + throw DoryFSWorkerContractError.nonzeroReservedField + } + let payloadLength = readUInt32(header, at: 60) + let actualPayloadLength = data.count - headerByteCount + guard UInt64(payloadLength) == UInt64(actualPayloadLength) else { + throw DoryFSWorkerContractError.payloadLengthMismatch( + declared: payloadLength, + actual: actualPayloadLength + ) + } + let payloadStart = data.index(data.startIndex, offsetBy: headerByteCount) + return DecodedFrame( + kind: kind, + generation: try DoryFSWorkerGeneration(rawValue: readUInt64(header, at: 8)), + capability: try DoryFSShareCapabilityID(rawValue: readUUID(header, at: 16)), + requestID: readUInt64(header, at: 32), + correlationID: readUInt64(header, at: 40), + deadline: readUInt64(header, at: 48), + responseCapacity: readUInt32(header, at: 56), + opcodeClass: header[64], + disposition: header[65], + rejection: readUInt16(header, at: 66), + payload: data[payloadStart..= headerByteCount, + value <= DoryFSWorkerLimits.absoluteMaximumFrameBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + } + + private static func requireControlFieldsZero( + _ decoded: DecodedFrame, + frame: String, + permitDeadline: Bool + ) throws { + try requireZero(decoded.requestID, frame: frame, field: "requestID") + try requireZero(decoded.correlationID, frame: frame, field: "correlationID") + if !permitDeadline { + try requireZero(decoded.deadline, frame: frame, field: "deadline") + } + try requireZero(decoded.responseCapacity, frame: frame, field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: frame, field: "opcodeClass") + try requireZero(decoded.disposition, frame: frame, field: "disposition") + try requireZero(decoded.rejection, frame: frame, field: "rejection") + try requireEmpty(decoded.payload, frame: frame) + } + + private static func requireZero( + _ value: T, + frame: String, + field: String + ) throws { + guard value == 0 else { + throw DoryFSWorkerContractError.unexpectedField(frame: frame, field: field) + } + } + + private static func requireEmpty(_ data: Data, frame: String) throws { + guard data.isEmpty else { + throw DoryFSWorkerContractError.unexpectedField(frame: frame, field: "payload") + } + } + + private static func append(_ value: UInt16, to bytes: inout [UInt8]) { + bytes.append(UInt8(truncatingIfNeeded: value)) + bytes.append(UInt8(truncatingIfNeeded: value >> 8)) + } + + private static func append(_ value: UInt32, to bytes: inout [UInt8]) { + for shift in stride(from: 0, through: 24, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt32(shift))) + } + } + + private static func append(_ value: UInt64, to bytes: inout [UInt8]) { + for shift in stride(from: 0, through: 56, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + private static func append(_ value: UUID, to bytes: inout [UInt8]) { + let raw = value.uuid + bytes.append(contentsOf: [ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ]) + } + + private static func readUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + UInt16(bytes[offset]) | (UInt16(bytes[offset + 1]) << 8) + } + + private static func readUInt32(_ bytes: [UInt8], at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for index in 0..<4 { + value |= UInt32(bytes[offset + index]) << UInt32(index * 8) + } + return value + } + + private static func readUInt64(_ bytes: [UInt8], at offset: Int) -> UInt64 { + var value: UInt64 = 0 + for index in 0..<8 { + value |= UInt64(bytes[offset + index]) << UInt64(index * 8) + } + return value + } + + private static func readUUID(_ bytes: [UInt8], at offset: Int) -> UUID { + UUID(uuid: ( + bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], + bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7], + bytes[offset + 8], bytes[offset + 9], bytes[offset + 10], bytes[offset + 11], + bytes[offset + 12], bytes[offset + 13], bytes[offset + 14], bytes[offset + 15] + )) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift new file mode 100644 index 00000000..54442d9d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift @@ -0,0 +1,211 @@ +import Foundation + +/// Runner-private XPC identity. The service is embedded in `DoryHVRunner.app`; callers must use +/// this name from that runner's private service namespace rather than searching the outer app. +public enum DoryFSWorkerXPC { + public static let serviceName = "com.pythonxi.Dory.HVRunner.FSWorker" +} + +/// The complete Objective-C/XPC surface. Every argument and reply is one exact bounded binary +/// envelope; no host path, Swift object graph, `Codable` value, or `NSError` crosses the boundary. +@objc(DoryFSWorkerXPCProtocol) +public protocol DoryFSWorkerXPCProtocol: NSObjectProtocol { + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) + func exchange(_ frame: Data, withReply reply: @escaping (Data) -> Void) + func sendOneWay(_ frame: Data) +} + +public enum DoryFSWorkerRPCFailureCode: UInt16, Equatable, Sendable { + case invalidEnvelope = 1 + case bootstrapRejected = 2 + case bootstrapAlreadyAttempted = 3 + case bootstrapRequired = 4 + case staleGeneration = 5 + case unknownShare = 6 + case deadlineExpired = 7 + case resourceExhausted = 8 + case duplicateRequest = 9 + case shuttingDown = 10 + case protocolViolation = 11 + case internalFailure = 12 + case bootstrapBookmarkResolutionFailed = 13 + case bootstrapBookmarkStale = 14 + case bootstrapScopeActivationFailed = 15 + case bootstrapRootOpenFailed = 16 + case bootstrapRootIdentityMismatch = 17 +} + +/// Non-sensitive stage returned when a worker rejects bootstrap authority. The wire result never +/// includes a host path, capability identifier, bookmark bytes, inode, or errno; it exposes only +/// the stage needed to diagnose packaging and sandbox integration without weakening the boundary. +public enum DoryFSWorkerBootstrapRejectionReason: Equatable, Sendable { + case bookmarkResolution + case staleBookmark + case scopeActivation + case rootOpen + case rootIdentity +} + +public extension DoryFSWorkerRPCFailureCode { + var bootstrapRejectionReason: DoryFSWorkerBootstrapRejectionReason? { + switch self { + case .bootstrapBookmarkResolutionFailed: + .bookmarkResolution + case .bootstrapBookmarkStale: + .staleBookmark + case .bootstrapScopeActivationFailed: + .scopeActivation + case .bootstrapRootOpenFailed: + .rootOpen + case .bootstrapRootIdentityMismatch: + .rootIdentity + default: + nil + } + } +} + +public enum DoryFSWorkerRPCResult: Equatable, Sendable { + case success(Data) + case failure(DoryFSWorkerRPCFailureCode) +} + +public enum DoryFSWorkerRPCResultError: Error, Equatable, Sendable { + case frameTooLarge(limit: Int, actual: Int) + case shortFrame(minimum: Int, actual: Int) + case invalidMagic + case unsupportedVersion(UInt16) + case unknownDisposition(UInt8) + case unknownFailureCode(UInt16) + case nonzeroReservedField + case unexpectedFailureCode(UInt16) + case unexpectedPayload + case payloadLengthMismatch(declared: UInt32, actual: Int) +} + +/// Exact outer result envelope used by bootstrap and request/reply RPCs. Inner bootstrap/FUSE +/// frames keep their own independent magic and size validation, so an adapter can never mistake a +/// malformed worker result for an authenticated service reply. +public enum DoryFSWorkerRPCResultCodec { + public static let headerByteCount = 16 + public static let absoluteMaximumPayloadBytes = + DoryFSWorkerBootstrapCodec.absoluteMaximumBootstrapBytes + + private static let magic: [UInt8] = [0x44, 0x46, 0x52, 0x31] // "DFR1" + private static let version: UInt16 = 1 + private static let successDisposition: UInt8 = 1 + private static let failureDisposition: UInt8 = 2 + + public static func encode( + _ result: DoryFSWorkerRPCResult, + maximumPayloadBytes: Int = absoluteMaximumPayloadBytes + ) throws -> Data { + try validateMaximum(maximumPayloadBytes) + let disposition: UInt8 + let failureCode: UInt16 + let payload: Data + switch result { + case .success(let bytes): + disposition = successDisposition + failureCode = 0 + payload = bytes + case .failure(let code): + disposition = failureDisposition + failureCode = code.rawValue + payload = Data() + } + guard payload.count <= maximumPayloadBytes, + payload.count <= Int(UInt32.max) else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: maximumPayloadBytes, + actual: payload.count + ) + } + var header = [UInt8]() + header.reserveCapacity(headerByteCount) + header.append(contentsOf: magic) + header.appendLE(version) + header.append(disposition) + header.append(0) + header.appendLE(failureCode) + header.appendLE(UInt16(0)) + header.appendLE(UInt32(payload.count)) + precondition(header.count == headerByteCount) + + // The inner service frame may be close to one MiB. Stage only this fixed header and append + // the inner Data directly into one pre-sized outer allocation. + var data = Data(capacity: headerByteCount + payload.count) + data.append(contentsOf: header) + data.append(payload) + return data + } + + public static func decode( + _ data: Data, + maximumPayloadBytes: Int = absoluteMaximumPayloadBytes + ) throws -> DoryFSWorkerRPCResult { + try validateMaximum(maximumPayloadBytes) + let maximumFrameBytes = headerByteCount + maximumPayloadBytes + guard data.count <= maximumFrameBytes else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: maximumFrameBytes, + actual: data.count + ) + } + guard data.count >= headerByteCount else { + throw DoryFSWorkerRPCResultError.shortFrame( + minimum: headerByteCount, + actual: data.count + ) + } + let header = [UInt8](data.prefix(headerByteCount)) + guard Array(header[0..<4]) == magic else { + throw DoryFSWorkerRPCResultError.invalidMagic + } + let receivedVersion = header.leUInt16(at: 4) + guard receivedVersion == version else { + throw DoryFSWorkerRPCResultError.unsupportedVersion(receivedVersion) + } + guard header[7] == 0, header.leUInt16(at: 10) == 0 else { + throw DoryFSWorkerRPCResultError.nonzeroReservedField + } + let payloadLength = header.leUInt32(at: 12) + let actualPayloadLength = data.count - headerByteCount + guard UInt64(payloadLength) == UInt64(actualPayloadLength) else { + throw DoryFSWorkerRPCResultError.payloadLengthMismatch( + declared: payloadLength, + actual: actualPayloadLength + ) + } + let failureCode = header.leUInt16(at: 8) + let payloadStart = data.index(data.startIndex, offsetBy: headerByteCount) + let payload = data[payloadStart..= 0, + maximumPayloadBytes <= absoluteMaximumPayloadBytes else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: absoluteMaximumPayloadBytes, + actual: maximumPayloadBytes + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift similarity index 97% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift index 4cf842fc..9abfb52f 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift @@ -50,6 +50,25 @@ public enum FuseOpcode: UInt32, Sendable { case copyFileRange = 47 case setupmapping = 48 case removemapping = 49 + case syncfs = 50 +} + +public extension FuseOpcode { + var workerOpcodeClass: DoryFSWorkerOpcodeClass { + switch self { + case .initOp, .destroy, .notifyReply: + return .control + case .lookup, .forget, .getattr, .readlink, .statfs, .getxattr, + .listxattr, .opendir, .readdir, .releasedir, .getlk, .batchForget: + return .metadata + case .open, .read, .release, .flush, .fsync, .fsyncdir, .lseek, .syncfs: + return .data + case .interrupt: + return .interrupt + default: + return .mutation + } + } } public struct FuseInHeader: Equatable, Sendable { @@ -646,8 +665,8 @@ public enum FuseProtocol { | FuseInitFlag.doReaddirplus.rawValue | FuseInitFlag.parallelDirops.rawValue if writebackCache { // WRITEBACK_CACHE lets the guest coalesce buffered writes, removing the per-write round - // trip on the create storm. Linux ignores FOPEN_NOFLUSH while it is enabled; the runtime - // keeps an env opt-out (DORY_FUSE_WRITEBACK_CACHE=0) for the durability-strict benchmark arm. + // trip on the create storm. Linux ignores FOPEN_NOFLUSH while it is enabled. Production + // leaves this disabled until dirty-page conflict handling can preserve host edits. flags |= FuseInitFlag.writebackCache.rawValue } if killPrivV2 { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift similarity index 94% rename from Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift index 60b239a1..b64799f6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift @@ -1,4 +1,4 @@ -extension Array where Element == UInt8 { +public extension Array where Element == UInt8 { mutating func appendLE(_ value: UInt16) { Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } } @@ -29,7 +29,7 @@ extension Array where Element == UInt8 { } } -extension ArraySlice where Element == UInt8 { +public extension ArraySlice where Element == UInt8 { func leUInt16(at offset: Int) -> UInt16 { let index = startIndex + offset guard offset >= 0, index + 1 < endIndex else { return 0 } diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift new file mode 100644 index 00000000..6fbfb1b9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift @@ -0,0 +1,42 @@ +import Darwin + +public enum DoryFSWorkerProcessResourceError: Error, Equatable, Sendable { + case readFileDescriptorLimit(Int32) + case updateFileDescriptorLimit(Int32) +} + +/// Establishes the descriptor ceiling in the process that actually owns filesystem roots, inode +/// identity pins, open file/directory handles, and advisory-lock descriptors. Raising the VMM's +/// limit cannot affect an XPC service, because resource limits are process-local. +public enum DoryFSWorkerProcessResources { + public static let fileDescriptorCeiling: rlim_t = 262_144 + + static func desiredFileDescriptorSoftLimit( + current: rlim_t, + hard: rlim_t, + ceiling: rlim_t = fileDescriptorCeiling + ) -> rlim_t { + max(current, min(hard, ceiling)) + } + + @discardableResult + public static func raiseFileDescriptorSoftLimit() throws -> rlim_t { + var limit = rlimit() + guard getrlimit(RLIMIT_NOFILE, &limit) == 0 else { + let savedErrno = errno + throw DoryFSWorkerProcessResourceError.readFileDescriptorLimit(savedErrno) + } + + let desired = desiredFileDescriptorSoftLimit( + current: limit.rlim_cur, + hard: limit.rlim_max + ) + guard desired > limit.rlim_cur else { return limit.rlim_cur } + limit.rlim_cur = desired + guard setrlimit(RLIMIT_NOFILE, &limit) == 0 else { + let savedErrno = errno + throw DoryFSWorkerProcessResourceError.updateFileDescriptorLimit(savedErrno) + } + return desired + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift new file mode 100644 index 00000000..c921d3d9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift @@ -0,0 +1,334 @@ +import Darwin +import DoryFSWorkerContracts +import Foundation + +/// Fail-closed errors raised while converting the exact bootstrap envelope into pinned directory +/// authority. Errors identify shares only by their unforgeable capability; host paths and bookmark +/// bytes never cross this API boundary. +public enum DoryFSWorkerRootAuthorityError: Error, Equatable, Sendable { + /// A worker process accepts exactly one bootstrap attempt. A failed attempt is terminal too; + /// the supervisor must replace the process instead of substituting a different authority set. + case bootstrapAlreadyAttempted + case bootstrapNotAccepted + case bookmarkResolutionFailed(DoryFSShareCapabilityID) + case staleBookmark(DoryFSShareCapabilityID) + case nonFileBookmark(DoryFSShareCapabilityID) + case securityScopeDenied(DoryFSShareCapabilityID) + case rootOpenFailed(DoryFSShareCapabilityID, errno: Int32) + case rootInspectionFailed(DoryFSShareCapabilityID, errno: Int32) + case rootIsNotDirectory(DoryFSShareCapabilityID) + case rootIdentityMismatch(DoryFSShareCapabilityID) + case unknownCapability(DoryFSShareCapabilityID) + case descriptorBorrowFailed(DoryFSShareCapabilityID, errno: Int32) +} + +/// Owns the immutable share roots for one worker process. +/// +/// The public surface deliberately has no URL, path, bookmark, mutation, or root-enumeration API. +/// The sole authority-use seam is a synchronous descriptor borrow selected by the typed capability +/// from the accepted bootstrap. Each borrow receives a temporary close-on-exec duplicate; the +/// integer is invalid once the callback returns and closing it cannot revoke the retained root. +public final class DoryFSWorkerRootAuthority: @unchecked Sendable { + private enum Lifecycle { + case uninitialized + case resolving + case accepted([DoryFSShareCapabilityID: OwnedRoot]) + case failed + } + + private static let processBootstrapAdmission = DoryFSWorkerBootstrapAdmission() + + private let resolver: any DoryFSWorkerBookmarkResolving + private let bootstrapAdmission: DoryFSWorkerBootstrapAdmission + private let stateLock = NSLock() + private var lifecycle: Lifecycle = .uninitialized + + /// Uses the process-wide one-shot gate and Foundation's security-scoped bookmark resolver. + public init() { + resolver = DoryFSWorkerFoundationBookmarkResolver.shared + bootstrapAdmission = Self.processBootstrapAdmission + } + + /// Decodes and consumes one exact bootstrap envelope, returning its exact receipt bytes only + /// after every share has resolved, entered scope, opened, and matched its sealed identity. + /// + /// Any error permanently consumes the process bootstrap attempt. All roots and security scopes + /// acquired by the failed transaction are synchronously released before the error is returned. + public func bootstrap(exactBytes: Data) throws -> Data { + guard bootstrapAdmission.claim() else { + throw DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted + } + setLifecycle(.resolving) + + var acquired = [OwnedRoot]() + do { + let bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBytes) + acquired.reserveCapacity(bootstrap.shares.count) + for share in bootstrap.shares { + acquired.append(try acquireRoot(for: share)) + } + + var roots = [DoryFSShareCapabilityID: OwnedRoot]( + minimumCapacity: acquired.count + ) + for root in acquired { + // Duplicate capabilities are rejected by the exact bootstrap codec. Retain the + // check as a local invariant so future codec versions cannot silently overwrite. + guard roots.updateValue(root, forKey: root.capabilityID) == nil else { + throw DoryFSWorkerRootAuthorityError.rootIdentityMismatch(root.capabilityID) + } + } + setLifecycle(.accepted(roots)) + acquired.removeAll(keepingCapacity: false) + return DoryFSWorkerBootstrapCodec.encode( + DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + } catch { + // `OwnedRoot.release()` is idempotent because both this rollback and ARC teardown can + // observe the same temporary owner while unwinding. + for root in acquired.reversed() { + root.release() + } + setLifecycle(.failed) + throw error + } + } + + /// Borrows the pinned directory for one accepted capability during `body` only. + /// + /// The callback is nonescaping and cannot return the descriptor. The temporary duplicate is + /// always closed on callback exit, including thrown exits. Code in the future worker runtime + /// may construct its share engine inside this lexical scope; it must not retain the integer. + public func withBorrowedRootFileDescriptor( + for capabilityID: DoryFSShareCapabilityID, + _ body: (Int32) throws -> Result + ) throws -> Result { + let root = try acceptedRoot(for: capabilityID) + let borrowed = root.duplicateForBorrow() + guard borrowed >= 0 else { + throw DoryFSWorkerRootAuthorityError.descriptorBorrowFailed( + capabilityID, + errno: errno + ) + } + defer { _ = Darwin.close(borrowed) } + return try body(borrowed) + } + + // Dependency injection is intentionally internal: production callers cannot replace bookmark + // semantics or opt out of the process-wide gate. Focused tests use a fresh gate per scenario. + init( + resolver: any DoryFSWorkerBookmarkResolving, + bootstrapAdmission: DoryFSWorkerBootstrapAdmission + ) { + self.resolver = resolver + self.bootstrapAdmission = bootstrapAdmission + } + + private func acquireRoot( + for share: DoryFSShareBootstrapAuthority + ) throws -> OwnedRoot { + let resolution: DoryFSWorkerResolvedBookmark + do { + resolution = try resolver.resolve(share.securityScopedBookmark) + } catch { + throw DoryFSWorkerRootAuthorityError.bookmarkResolutionFailed(share.capabilityID) + } + guard resolution.url.isFileURL else { + throw DoryFSWorkerRootAuthorityError.nonFileBookmark(share.capabilityID) + } + guard resolver.startAccessingSecurityScopedResource(resolution.url) else { + throw DoryFSWorkerRootAuthorityError.securityScopeDenied(share.capabilityID) + } + + var descriptor: Int32 = -1 + var transferred = false + defer { + if !transferred { + if descriptor >= 0 { + _ = Darwin.close(descriptor) + } + resolver.stopAccessingSecurityScopedResource(resolution.url) + } + } + + descriptor = Self.openDirectoryWithoutFollowing(resolution.url) + guard descriptor >= 0 else { + throw DoryFSWorkerRootAuthorityError.rootOpenFailed( + share.capabilityID, + errno: errno + ) + } + + var status = stat() + guard fstat(descriptor, &status) == 0 else { + throw DoryFSWorkerRootAuthorityError.rootInspectionFailed( + share.capabilityID, + errno: errno + ) + } + guard status.st_mode & S_IFMT == S_IFDIR else { + throw DoryFSWorkerRootAuthorityError.rootIsNotDirectory(share.capabilityID) + } + guard UInt64(truncatingIfNeeded: status.st_dev) + == share.expectedRootIdentity.device, + UInt64(truncatingIfNeeded: status.st_ino) + == share.expectedRootIdentity.inode, + UInt64(truncatingIfNeeded: status.st_gen) + == share.expectedRootIdentity.generation else { + // Foundation's stale bit is advisory: resolution still returned a usable URL, and + // Apple's contract asks persistent clients to replace their stored bookmark. This + // worker consumes a one-shot process-transfer bookmark and never stores it, so a stale + // result is safe only when the independently sealed descriptor identity still matches. + // If it does not, distinguish a bookmark that no longer names the sealed root from a + // fresh-bookmark identity race without exposing either path or identity on the wire. + if resolution.isStale { + throw DoryFSWorkerRootAuthorityError.staleBookmark(share.capabilityID) + } + throw DoryFSWorkerRootAuthorityError.rootIdentityMismatch(share.capabilityID) + } + + transferred = true + return OwnedRoot( + capabilityID: share.capabilityID, + descriptor: descriptor, + scopedURL: resolution.url, + resolver: resolver + ) + } + + private func acceptedRoot( + for capabilityID: DoryFSShareCapabilityID + ) throws -> OwnedRoot { + stateLock.lock() + defer { stateLock.unlock() } + guard case .accepted(let roots) = lifecycle else { + throw DoryFSWorkerRootAuthorityError.bootstrapNotAccepted + } + guard let root = roots[capabilityID] else { + throw DoryFSWorkerRootAuthorityError.unknownCapability(capabilityID) + } + return root + } + + private func setLifecycle(_ newValue: Lifecycle) { + stateLock.lock() + lifecycle = newValue + stateLock.unlock() + } + + private static func openDirectoryWithoutFollowing(_ url: URL) -> Int32 { + url.withUnsafeFileSystemRepresentation { representation in + guard let representation else { + errno = EINVAL + return -1 + } + return Darwin.open( + representation, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + } +} + +struct DoryFSWorkerResolvedBookmark { + let url: URL + let isStale: Bool +} + +protocol DoryFSWorkerBookmarkResolving: AnyObject { + func resolve(_ bookmark: Data) throws -> DoryFSWorkerResolvedBookmark + func startAccessingSecurityScopedResource(_ url: URL) -> Bool + func stopAccessingSecurityScopedResource(_ url: URL) +} + +final class DoryFSWorkerBootstrapAdmission: @unchecked Sendable { + private let lock = NSLock() + private var consumed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !consumed else { return false } + consumed = true + return true + } +} + +private final class DoryFSWorkerFoundationBookmarkResolver: + DoryFSWorkerBookmarkResolving, + @unchecked Sendable +{ + static let shared = DoryFSWorkerFoundationBookmarkResolver() + + func resolve(_ bookmark: Data) throws -> DoryFSWorkerResolvedBookmark { + var stale = false + let url = try URL( + resolvingBookmarkData: bookmark, + // The runner sends a one-shot process-transfer bookmark with an implicit ephemeral + // scope. Defer activation so RootAuthority can fail closed on the Bool result and pair + // every successful start with exactly one stop during rollback or teardown. + options: [.withoutUI, .withoutImplicitStartAccessing], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + return DoryFSWorkerResolvedBookmark(url: url, isStale: stale) + } + + func startAccessingSecurityScopedResource(_ url: URL) -> Bool { + url.startAccessingSecurityScopedResource() + } + + func stopAccessingSecurityScopedResource(_ url: URL) { + url.stopAccessingSecurityScopedResource() + } +} + +private final class OwnedRoot: @unchecked Sendable { + let capabilityID: DoryFSShareCapabilityID + + private let releaseLock = NSLock() + private var descriptor: Int32 + private let scopedURL: URL + private let resolver: any DoryFSWorkerBookmarkResolving + + init( + capabilityID: DoryFSShareCapabilityID, + descriptor: Int32, + scopedURL: URL, + resolver: any DoryFSWorkerBookmarkResolving + ) { + self.capabilityID = capabilityID + self.descriptor = descriptor + self.scopedURL = scopedURL + self.resolver = resolver + } + + func duplicateForBorrow() -> Int32 { + releaseLock.lock() + defer { releaseLock.unlock() } + guard descriptor >= 0 else { + errno = EBADF + return -1 + } + return fcntl(descriptor, F_DUPFD_CLOEXEC, 0) + } + + func release() { + releaseLock.lock() + guard descriptor >= 0 else { + releaseLock.unlock() + return + } + let ownedDescriptor = descriptor + descriptor = -1 + releaseLock.unlock() + + _ = Darwin.close(ownedDescriptor) + resolver.stopAccessingSecurityScopedResource(scopedURL) + } + + deinit { + release() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift new file mode 100644 index 00000000..1ca5a11d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift @@ -0,0 +1,562 @@ +import Darwin +import DoryFSWorkerContracts +import Foundation + +/// Data-only execution boundary embedded by the signed XPC service target. This type owns the +/// complete HostFS/FuseServer graph; callers can bootstrap, exchange exact frames, or send +/// priority one-way control frames, but can never obtain a path, descriptor, or server object. +public final class DoryFSWorkerService: @unchecked Sendable { + private enum Lifecycle { + case awaitingBootstrap + case active(Workspace) + case failed + } + + private final class Workspace: @unchecked Sendable { + let generation: DoryFSWorkerGeneration + let limits: DoryFSWorkerLimits + let shares: [DoryFSShareCapabilityID: Share] + + init( + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits, + shares: [DoryFSShareCapabilityID: Share] + ) { + self.generation = generation + self.limits = limits + self.shares = shares + } + } + + private final class Share: @unchecked Sendable { + private enum State { + case active + case draining + case drained + case invalidated + } + + private struct Reservation { + let request: DoryFSWorkerRequest + let requestBytes: Int + let responseBytes: Int + } + + private struct PendingPublication { + let reservation: Reservation + let opcode: FuseOpcode? + let response: [UInt8] + } + + let capabilityID: DoryFSShareCapabilityID + let generation: DoryFSWorkerGeneration + let workerLimits: DoryFSWorkerLimits + let shareLimits: DoryFSShareResourceLimits + let server: FuseServer + + private let lock = NSLock() + private var state: State = .active + private var activeByRequestID = [UInt64: Reservation]() + private var requestIDByCorrelationID = [UInt64: UInt64]() + private var pendingByRequestID = [UInt64: PendingPublication]() + private var aggregateRequestBytes = 0 + private var aggregateResponseBytes = 0 + private var destroyPublicationCommitted = false + private var resetCompleted = false + + init( + authority: DoryFSShareBootstrapAuthority, + generation: DoryFSWorkerGeneration, + workerLimits: DoryFSWorkerLimits, + server: FuseServer + ) { + capabilityID = authority.capabilityID + self.generation = generation + self.workerLimits = workerLimits + shareLimits = authority.resourceLimits + self.server = server + } + + func execute(_ request: DoryFSWorkerRequest) -> DoryFSWorkerServiceFrame { + // Materialize the bounded FUSE payload once. Validation and execution consume the same + // immutable bytes; the former implementation rebuilt a second payload-sized Array for + // every admitted request. + let requestBytes = [UInt8](request.payload) + guard let opcode = validatedFUSEOpcode(request, bytes: requestBytes) else { + return reply(to: request, outcome: .rejected(.invalidRequest)) + } + let rejection = admit(request, opcode: opcode) + if let rejection { + return reply(to: request, outcome: .rejected(rejection)) + } + + let response = server.handle(request: requestBytes) + + let completion = finishExecution( + request: request, + opcode: opcode, + response: response + ) + switch completion { + case .accepted(let response): + return reply(to: request, outcome: .completed(Data(response))) + case .rejected(let code): + return reply(to: request, outcome: .rejected(code)) + } + } + + func interrupt(_ interrupt: DoryFSWorkerInterrupt) { + let admitted = lock.withLock { + guard state == .active || state == .draining, + interrupt.generation == generation, + interrupt.shareCapabilityID == capabilityID, + let requestID = requestIDByCorrelationID[interrupt.targetCorrelationID], + requestID == interrupt.targetRequestID else { return false } + return true + } + if admitted { + server.interrupt(requestUnique: interrupt.targetCorrelationID) + } + } + + func acknowledge( + _ publication: DoryFSWorkerPublication, + committed: Bool + ) { + var shouldReset = false + let pending: PendingPublication? = lock.withLock { + guard state == .active || state == .draining, + publication.generation == generation, + publication.shareCapabilityID == capabilityID, + let pending = pendingByRequestID[publication.requestID], + pending.reservation.request.correlationID == publication.correlationID else { + return nil + } + pendingByRequestID.removeValue(forKey: publication.requestID) + releaseReservationLocked(pending.reservation) + if committed, pending.opcode == .destroy { + destroyPublicationCommitted = true + } + shouldReset = completeDestroyIfQuiescentLocked() + return pending + } + guard let pending else { return } + if committed { + if pending.opcode == .initOp, + let header = try? FuseProtocol.decodeOutHeader(pending.response), + header.error == 0 { + server.markFuseInitCompleted() + } + } else if let opcode = pending.opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: pending.response) + } + if shouldReset { resetIfNeeded() } + } + + func drain(_ drain: DoryFSWorkerDrain) -> DoryFSWorkerServiceFrame? { + let accepted = lock.withLock { + guard state == .active, + drain.generation == generation, + drain.shareCapabilityID == capabilityID, + DispatchTime.now().uptimeNanoseconds < drain.deadlineUptimeNanoseconds, + activeByRequestID.isEmpty, + pendingByRequestID.isEmpty else { return false } + state = .drained + return true + } + guard accepted else { return nil } + resetIfNeeded() + return .drained(DoryFSWorkerDrained( + generation: generation, + shareCapabilityID: capabilityID + )) + } + + func invalidate(_ invalidation: DoryFSWorkerInvalidation) { + guard invalidation.generation == generation, + invalidation.shareCapabilityID == capabilityID else { return } + let pending: [PendingPublication] = lock.withLock { + guard state != .invalidated else { return [] } + state = .invalidated + let values = Array(pendingByRequestID.values) + pendingByRequestID.removeAll(keepingCapacity: false) + for value in values { + releaseReservationLocked(value.reservation) + } + return values + } + server.cancelAllRequests() + for value in pending { + if let opcode = value.opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: value.response) + } + } + resetIfQuiescent() + } + + private enum Completion { + case accepted([UInt8]) + case rejected(DoryFSWorkerRejectionCode) + } + + private func admit( + _ request: DoryFSWorkerRequest, + opcode: FuseOpcode + ) -> DoryFSWorkerRejectionCode? { + let now = DispatchTime.now().uptimeNanoseconds + guard request.generation == generation else { return .staleGeneration } + guard request.shareCapabilityID == capabilityID else { return .unknownShare } + guard request.deadlineUptimeNanoseconds > now, + request.deadlineUptimeNanoseconds - now + <= workerLimits.maximumOperationNanoseconds else { + return .deadlineExpired + } + guard request.payload.count <= workerLimits.maximumRequestBytes, + Int(request.responseCapacity) <= workerLimits.maximumResponseBytes, + request.payload.count <= shareLimits.maximumAggregateRequestBytes, + Int(request.responseCapacity) <= shareLimits.maximumAggregateResponseBytes else { + return .resourceExhausted + } + return lock.withLock { + switch state { + case .active: + break + case .draining, .drained: + return .connectionTeardown + case .invalidated: + return .shuttingDown + } + guard activeByRequestID.count + pendingByRequestID.count + < min( + workerLimits.maximumInFlightRequests, + shareLimits.maximumInFlightRequests + ) else { return .resourceExhausted } + guard activeByRequestID[request.requestID] == nil, + pendingByRequestID[request.requestID] == nil, + requestIDByCorrelationID[request.correlationID] == nil else { + return .invalidRequest + } + let (requestTotal, requestOverflow) = aggregateRequestBytes + .addingReportingOverflow(request.payload.count) + let (responseTotal, responseOverflow) = aggregateResponseBytes + .addingReportingOverflow(Int(request.responseCapacity)) + guard !requestOverflow, + !responseOverflow, + requestTotal <= min( + workerLimits.maximumAggregateRequestBytes, + shareLimits.maximumAggregateRequestBytes + ), + responseTotal <= min( + workerLimits.maximumAggregateResponseBytes, + shareLimits.maximumAggregateResponseBytes + ) else { return .resourceExhausted } + let reservation = Reservation( + request: request, + requestBytes: request.payload.count, + responseBytes: Int(request.responseCapacity) + ) + activeByRequestID[request.requestID] = reservation + requestIDByCorrelationID[request.correlationID] = request.requestID + aggregateRequestBytes = requestTotal + aggregateResponseBytes = responseTotal + if opcode == .destroy { + // No request arriving after DESTROY may acquire new host authority. Work that + // was already admitted is allowed to publish before the committed teardown + // resets every FUSE node/handle owned by this share. + state = .draining + } + return nil + } + } + + private func validatedFUSEOpcode( + _ request: DoryFSWorkerRequest, + bytes: [UInt8] + ) -> FuseOpcode? { + guard let header = try? FuseProtocol.decodeInHeader(bytes), + header.unique == request.correlationID, + header.length >= UInt32(FuseInHeader.byteCount), + Int(header.length) == bytes.count, + let opcode = FuseOpcode(rawValue: header.opcode), + opcode.workerOpcodeClass == request.opcodeClass else { return nil } + return opcode + } + + private func finishExecution( + request: DoryFSWorkerRequest, + opcode: FuseOpcode?, + response: [UInt8] + ) -> Completion { + var shouldReset = false + let result: Completion = lock.withLock { + guard let reservation = activeByRequestID.removeValue( + forKey: request.requestID + ) else { + return .rejected(.shuttingDown) + } + guard state == .active || state == .draining, + DispatchTime.now().uptimeNanoseconds + < request.deadlineUptimeNanoseconds else { + releaseReservationLocked(reservation) + shouldReset = state == .invalidated && activeByRequestID.isEmpty + return .rejected( + state == .invalidated ? .shuttingDown : .deadlineExpired + ) + } + guard response.count <= Int(request.responseCapacity), + response.count <= workerLimits.maximumResponseBytes else { + state = .invalidated + releaseReservationLocked(reservation) + shouldReset = activeByRequestID.isEmpty + return .rejected(.internalFailure) + } + pendingByRequestID[request.requestID] = PendingPublication( + reservation: reservation, + opcode: opcode, + response: response + ) + return .accepted(response) + } + if case .rejected = result, let opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: response) + } + if shouldReset { resetIfNeeded() } + return result + } + + private func completeDestroyIfQuiescentLocked() -> Bool { + guard state == .draining, + destroyPublicationCommitted, + activeByRequestID.isEmpty, + pendingByRequestID.isEmpty else { return false } + state = .drained + return true + } + + private func releaseReservationLocked(_ reservation: Reservation) { + requestIDByCorrelationID.removeValue( + forKey: reservation.request.correlationID + ) + precondition(aggregateRequestBytes >= reservation.requestBytes) + precondition(aggregateResponseBytes >= reservation.responseBytes) + aggregateRequestBytes -= reservation.requestBytes + aggregateResponseBytes -= reservation.responseBytes + } + + private func resetIfQuiescent() { + let ready = lock.withLock { + state == .invalidated + && activeByRequestID.isEmpty + && pendingByRequestID.isEmpty + } + if ready { resetIfNeeded() } + } + + private func resetIfNeeded() { + let shouldReset = lock.withLock { + guard !resetCompleted else { return false } + resetCompleted = true + return true + } + if shouldReset { server.resetConnection() } + } + + private func reply( + to request: DoryFSWorkerRequest, + outcome: DoryFSWorkerReplyOutcome + ) -> DoryFSWorkerServiceFrame { + .reply(try! DoryFSWorkerReply( + generation: request.generation, + shareCapabilityID: request.shareCapabilityID, + requestID: request.requestID, + correlationID: request.correlationID, + outcome: outcome + )) + } + } + + private let rootAuthority: DoryFSWorkerRootAuthority + private let lifecycleLock = NSLock() + private var lifecycle: Lifecycle = .awaitingBootstrap + + public init() { + rootAuthority = DoryFSWorkerRootAuthority() + } + + init(rootAuthority: DoryFSWorkerRootAuthority) { + self.rootAuthority = rootAuthority + } + + public func bootstrap(exactBytes: Data) -> Data { + let claimed = lifecycleLock.withLock { () -> Bool in + guard case .awaitingBootstrap = lifecycle else { return false } + lifecycle = .failed + return true + } + guard claimed else { + return encodeRPC(.failure(.bootstrapAlreadyAttempted)) + } + + do { + let bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBytes) + let receipt = try rootAuthority.bootstrap(exactBytes: exactBytes) + var shares = [DoryFSShareCapabilityID: Share]( + minimumCapacity: bootstrap.shares.count + ) + for authority in bootstrap.shares { + let server = try rootAuthority.withBorrowedRootFileDescriptor( + for: authority.capabilityID + ) { descriptor in + let hostFS = try HostFS( + rootDirectoryFileDescriptor: descriptor, + guestUID: authority.guestIdentity.uid, + guestGID: authority.guestIdentity.gid, + readOnly: authority.readOnly, + hiddenNames: Set(authority.hiddenComponents), + rootHiddenNames: Set(authority.rootHiddenComponents), + resourceLimits: FuseResourceLimits(authority.resourceLimits) + ) + return FuseServer(hostFS: hostFS) + } + shares[authority.capabilityID] = Share( + authority: authority, + generation: bootstrap.generation, + workerLimits: bootstrap.workerLimits, + server: server + ) + } + let workspace = Workspace( + generation: bootstrap.generation, + limits: bootstrap.workerLimits, + shares: shares + ) + lifecycleLock.withLock { lifecycle = .active(workspace) } + return encodeRPC(.success(receipt)) + } catch let error as DoryFSWorkerRootAuthorityError { + return encodeRPC(.failure(error.bootstrapFailureCode)) + } catch { + return encodeRPC(.failure(.bootstrapRejected)) + } + } + + public func exchange(exactFrame: Data) -> Data { + guard let workspace = activeWorkspace() else { + return encodeRPC(.failure(.bootstrapRequired)) + } + let frame: DoryFSWorkerClientFrame + do { + frame = try DoryFSWorkerFrameCodec.decodeClientFrame( + exactFrame, + maximumFrameBytes: workspace.limits.maximumFrameBytes + ) + } catch { + return encodeRPC(.failure(.invalidEnvelope)) + } + + switch frame { + case .execute(let request): + guard let share = workspace.shares[request.shareCapabilityID] else { + return encodeRPC(.failure(.unknownShare)) + } + return encodeServiceFrame(share.execute(request), limits: workspace.limits) + case .drain(let drain): + guard let share = workspace.shares[drain.shareCapabilityID] else { + return encodeRPC(.failure(.unknownShare)) + } + guard let reply = share.drain(drain) else { + return encodeRPC(.failure(.shuttingDown)) + } + return encodeServiceFrame(reply, limits: workspace.limits) + case .interrupt, .invalidate, .commitPublication, .discardPublication: + return encodeRPC(.failure(.protocolViolation)) + } + } + + public func sendOneWay(exactFrame: Data) { + guard let workspace = activeWorkspace(), + let frame = try? DoryFSWorkerFrameCodec.decodeClientFrame( + exactFrame, + maximumFrameBytes: workspace.limits.maximumFrameBytes + ) else { return } + switch frame { + case .interrupt(let interrupt): + workspace.shares[interrupt.shareCapabilityID]?.interrupt(interrupt) + case .invalidate(let invalidation): + workspace.shares[invalidation.shareCapabilityID]?.invalidate(invalidation) + case .commitPublication(let publication): + workspace.shares[publication.shareCapabilityID]?.acknowledge( + publication, + committed: true + ) + case .discardPublication(let publication): + workspace.shares[publication.shareCapabilityID]?.acknowledge( + publication, + committed: false + ) + case .execute, .drain: + break + } + } + + private func activeWorkspace() -> Workspace? { + lifecycleLock.withLock { + guard case .active(let workspace) = lifecycle else { return nil } + return workspace + } + } + + private func encodeServiceFrame( + _ frame: DoryFSWorkerServiceFrame, + limits: DoryFSWorkerLimits + ) -> Data { + do { + return encodeRPC(.success(try DoryFSWorkerFrameCodec.encode( + frame, + maximumFrameBytes: limits.maximumFrameBytes + ))) + } catch { + return encodeRPC(.failure(.internalFailure)) + } + } + + private func encodeRPC(_ result: DoryFSWorkerRPCResult) -> Data { + // Every locally-constructed result is within an absolute compile-time envelope. + (try? DoryFSWorkerRPCResultCodec.encode(result)) ?? Data() + } +} + +extension DoryFSWorkerRootAuthorityError { + var bootstrapFailureCode: DoryFSWorkerRPCFailureCode { + switch self { + case .bootstrapAlreadyAttempted: + .bootstrapAlreadyAttempted + case .bookmarkResolutionFailed, .nonFileBookmark: + .bootstrapBookmarkResolutionFailed + case .staleBookmark: + .bootstrapBookmarkStale + case .securityScopeDenied: + .bootstrapScopeActivationFailed + case .rootOpenFailed, .rootInspectionFailed, .rootIsNotDirectory, + .descriptorBorrowFailed: + .bootstrapRootOpenFailed + case .rootIdentityMismatch: + .bootstrapRootIdentityMismatch + case .bootstrapNotAccepted, .unknownCapability: + .bootstrapRejected + } + } +} + +private extension FuseResourceLimits { + init(_ limits: DoryFSShareResourceLimits) { + self.init( + maximumLiveNonRootNodes: limits.maximumLiveNonRootNodes, + maximumFileHandles: limits.maximumFileHandles, + maximumDirectoryHandles: limits.maximumDirectoryHandles, + maximumDirectoryCursorEntries: limits.maximumDirectoryCursorEntries, + maximumDirectoryCursorNameBytes: limits.maximumDirectoryCursorNameBytes, + maximumAdvisoryLockOwners: limits.maximumAdvisoryLockOwners, + maximumPendingBlockingLocks: limits.maximumPendingBlockingLocks + ) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift new file mode 100644 index 00000000..a761d309 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift @@ -0,0 +1,239 @@ +import Foundation + +/// Immutable per-share resource ceilings. Production uses one explicit profile; tests inject +/// smaller values through `HostFS` without environment switches or process-global state. +public struct FuseResourceLimits: Equatable, Sendable { + public let maximumLiveNonRootNodes: Int + public let maximumFileHandles: Int + public let maximumDirectoryHandles: Int + /// Stable cookie slots retained only after an entry is considered for a guest response. This + /// is aggregate across every open directory in one share, not a per-handle multiplier. + public let maximumDirectoryCursorEntries: Int + /// Aggregate UTF-8 bytes retained by those stable cookie slots. Container overhead is bounded + /// separately by the entry count; names themselves cannot amplify memory beyond this ceiling. + public let maximumDirectoryCursorNameBytes: Int + public let maximumAdvisoryLockOwners: Int + public let maximumPendingBlockingLocks: Int + + /// Per-share logical ceilings for package-manager-scale directory trees. The separate worker + /// process establishes its own bounded descriptor ceiling before accepting XPC authority; + /// raising the VMM process cannot provide that capacity. + public static let production = FuseResourceLimits( + maximumLiveNonRootNodes: 65_536, + maximumFileHandles: 16_384, + maximumDirectoryHandles: 4_096, + maximumDirectoryCursorEntries: 262_144, + maximumDirectoryCursorNameBytes: 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: 4_096, + maximumPendingBlockingLocks: 1_024 + ) + + public init( + maximumLiveNonRootNodes: Int, + maximumFileHandles: Int, + maximumDirectoryHandles: Int, + maximumDirectoryCursorEntries: Int = 262_144, + maximumDirectoryCursorNameBytes: Int = 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: Int, + maximumPendingBlockingLocks: Int + ) { + precondition(maximumLiveNonRootNodes > 0) + precondition(maximumFileHandles > 0) + precondition(maximumDirectoryHandles > 0) + precondition(maximumDirectoryCursorEntries > 0) + precondition(maximumDirectoryCursorNameBytes > 0) + precondition(maximumAdvisoryLockOwners > 0) + precondition(maximumPendingBlockingLocks > 0) + self.maximumLiveNonRootNodes = maximumLiveNonRootNodes + self.maximumFileHandles = maximumFileHandles + self.maximumDirectoryHandles = maximumDirectoryHandles + self.maximumDirectoryCursorEntries = maximumDirectoryCursorEntries + self.maximumDirectoryCursorNameBytes = maximumDirectoryCursorNameBytes + self.maximumAdvisoryLockOwners = maximumAdvisoryLockOwners + self.maximumPendingBlockingLocks = maximumPendingBlockingLocks + } +} + +public enum FuseResourceKind: String, CaseIterable, Equatable, Sendable { + case liveNonRootNodes + case fileHandles + case directoryHandles + case directoryCursorEntries + case directoryCursorNameBytes + case advisoryLockOwners + case pendingBlockingLocks +} + +public struct FuseResourceQuotaError: Error, Equatable, Sendable { + public let resource: FuseResourceKind + public let limit: Int + + public init(resource: FuseResourceKind, limit: Int) { + self.resource = resource + self.limit = limit + } +} + +public struct FuseResourceSnapshot: Equatable, Sendable { + public let limits: FuseResourceLimits + public let liveNonRootNodes: Int + public let fileHandles: Int + public let directoryHandles: Int + public let directoryCursorEntries: Int + public let directoryCursorNameBytes: Int + public let advisoryLockOwners: Int + public let pendingBlockingLocks: Int + + public init( + limits: FuseResourceLimits, + liveNonRootNodes: Int, + fileHandles: Int, + directoryHandles: Int, + directoryCursorEntries: Int, + directoryCursorNameBytes: Int, + advisoryLockOwners: Int, + pendingBlockingLocks: Int + ) { + self.limits = limits + self.liveNonRootNodes = liveNonRootNodes + self.fileHandles = fileHandles + self.directoryHandles = directoryHandles + self.directoryCursorEntries = directoryCursorEntries + self.directoryCursorNameBytes = directoryCursorNameBytes + self.advisoryLockOwners = advisoryLockOwners + self.pendingBlockingLocks = pendingBlockingLocks + } +} + +/// One atomic counter authority for all resources owned by a share. Callers keep the returned token +/// for exactly as long as the admitted resource remains live; explicit release is idempotent and +/// deinit is a rollback fence for every failed partial admission. +final class FuseResourceQuota: @unchecked Sendable { + let limits: FuseResourceLimits + + private let lock = NSLock() + private var counts: [FuseResourceKind: Int] = [:] + + init(limits: FuseResourceLimits) { + self.limits = limits + } + + func acquire(_ resource: FuseResourceKind) throws -> FuseResourceToken { + try lock.withLock { + let current = counts[resource, default: 0] + let limit = limit(for: resource) + guard current < limit else { + throw FuseResourceQuotaError(resource: resource, limit: limit) + } + counts[resource] = current + 1 + return FuseResourceToken(resource: resource, quota: self) + } + } + + /// Atomically reserves one stable directory cookie and its retained UTF-8 name. Keeping the + /// two counters under one lock avoids a partially admitted slot when either aggregate limit is + /// exhausted. + func reserveDirectoryCursorEntry(nameByteCount: Int) throws { + precondition(nameByteCount > 0) + try lock.withLock { + let entryCount = counts[.directoryCursorEntries, default: 0] + guard entryCount < limits.maximumDirectoryCursorEntries else { + throw FuseResourceQuotaError( + resource: .directoryCursorEntries, + limit: limits.maximumDirectoryCursorEntries + ) + } + let nameBytes = counts[.directoryCursorNameBytes, default: 0] + let (newNameBytes, overflow) = nameBytes.addingReportingOverflow(nameByteCount) + guard !overflow, newNameBytes <= limits.maximumDirectoryCursorNameBytes else { + throw FuseResourceQuotaError( + resource: .directoryCursorNameBytes, + limit: limits.maximumDirectoryCursorNameBytes + ) + } + counts[.directoryCursorEntries] = entryCount + 1 + counts[.directoryCursorNameBytes] = newNameBytes + } + } + + func releaseDirectoryCursor(entries: Int, nameBytes: Int) { + guard entries > 0 || nameBytes > 0 else { return } + precondition(entries >= 0 && nameBytes >= 0) + lock.withLock { + let currentEntries = counts[.directoryCursorEntries, default: 0] + let currentNameBytes = counts[.directoryCursorNameBytes, default: 0] + precondition(currentEntries >= entries, "unbalanced directory cursor entry release") + precondition(currentNameBytes >= nameBytes, "unbalanced directory cursor byte release") + updateCount(.directoryCursorEntries, to: currentEntries - entries) + updateCount(.directoryCursorNameBytes, to: currentNameBytes - nameBytes) + } + } + + func snapshot() -> FuseResourceSnapshot { + lock.withLock { + FuseResourceSnapshot( + limits: limits, + liveNonRootNodes: counts[.liveNonRootNodes, default: 0], + fileHandles: counts[.fileHandles, default: 0], + directoryHandles: counts[.directoryHandles, default: 0], + directoryCursorEntries: counts[.directoryCursorEntries, default: 0], + directoryCursorNameBytes: counts[.directoryCursorNameBytes, default: 0], + advisoryLockOwners: counts[.advisoryLockOwners, default: 0], + pendingBlockingLocks: counts[.pendingBlockingLocks, default: 0] + ) + } + } + + fileprivate func release(_ resource: FuseResourceKind) { + lock.withLock { + let current = counts[resource, default: 0] + precondition(current > 0, "unbalanced FUSE resource token release") + updateCount(resource, to: current - 1) + } + } + + private func updateCount(_ resource: FuseResourceKind, to value: Int) { + if value == 0 { + counts.removeValue(forKey: resource) + } else { + counts[resource] = value + } + } + + private func limit(for resource: FuseResourceKind) -> Int { + switch resource { + case .liveNonRootNodes: limits.maximumLiveNonRootNodes + case .fileHandles: limits.maximumFileHandles + case .directoryHandles: limits.maximumDirectoryHandles + case .directoryCursorEntries: limits.maximumDirectoryCursorEntries + case .directoryCursorNameBytes: limits.maximumDirectoryCursorNameBytes + case .advisoryLockOwners: limits.maximumAdvisoryLockOwners + case .pendingBlockingLocks: limits.maximumPendingBlockingLocks + } + } +} + +final class FuseResourceToken: @unchecked Sendable { + let resource: FuseResourceKind + + private let lock = NSLock() + private var quota: FuseResourceQuota? + + fileprivate init(resource: FuseResourceKind, quota: FuseResourceQuota) { + self.resource = resource + self.quota = quota + } + + func release() { + let quota = lock.withLock { () -> FuseResourceQuota? in + let current = self.quota + self.quota = nil + return current + } + quota?.release(resource) + } + + deinit { + release() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift similarity index 62% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift index 84b71900..2295d4c6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift @@ -1,4 +1,5 @@ import Darwin +import DoryFSWorkerContracts import Foundation private struct FuseResponseCachePolicy: Sendable { @@ -71,17 +72,16 @@ private final class FuseCachePolicy: @unchecked Sendable { } } -public final class FuseServer: @unchecked Sendable { +final class FuseServer: @unchecked Sendable { static let maximumCoherentCacheValiditySeconds = FuseCachePolicy.maximumValiditySeconds static let negativeCoherentCacheValiditySeconds = FuseCachePolicy.negativeValiditySeconds private let hostFS: HostFS - private let daxWindow: DaxWindow? + private var resourceQuota: FuseResourceQuota { hostFS.resourceQuota } private let writebackCache: Bool private let killPrivV2: Bool private let fastCreateAttributes: Bool private let cachePolicy = FuseCachePolicy() - private let stats: FuseStats? private let anomalyLog = FuseAnomalyLog() private let lock = NSLock() private var nextFileHandle: UInt64 = 1 @@ -92,6 +92,7 @@ public final class FuseServer: @unchecked Sendable { private let advisoryLockCondition = NSCondition() private var pendingBlockingLocks: Set = [] private var pendingBlockingLockOwners: [UInt64: AdvisoryLockOwnerKey] = [:] + private var pendingBlockingLockTokens: [UInt64: FuseResourceToken] = [:] private var cancelledBlockingLocks: Set = [] var fileOperationLoadedTestHook: (() -> Void)? var directoryOperationLoadedTestHook: (() -> Void)? @@ -106,13 +107,22 @@ public final class FuseServer: @unchecked Sendable { let accessMode: HostFSAccessMode let append: Bool private let hostFS: HostFS - - init(fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, append: Bool, hostFS: HostFS) { + private let resourceToken: FuseResourceToken + + init( + fd: Int32, + nodeID: UInt64, + accessMode: HostFSAccessMode, + append: Bool, + hostFS: HostFS, + resourceToken: FuseResourceToken + ) { self.fd = fd self.nodeID = nodeID self.accessMode = accessMode self.append = append self.hostFS = hostFS + self.resourceToken = resourceToken } deinit { @@ -129,22 +139,49 @@ public final class FuseServer: @unchecked Sendable { private final class OpenDirectoryHandle: @unchecked Sendable { let nodeID: UInt64 - /// Linux treats `fuse_read_in.offset` as a cookie into one open directory stream. Rebuilding - /// the sorted listing for every page makes that cookie unstable when a consumer such as - /// `rm -rf` deletes page one before requesting page two: the remaining entries shift left - /// and are skipped. Keep one enumeration snapshot for the lifetime of the open handle. - /// Slots are never renumbered during this open-directory lifetime. A removed name becomes - /// nil instead of shifting later cookies left; newly discovered names append new slots. - var entries: [HostFSEntry?]? + let cursor: HostFSDirectoryCursor + /// FUSE offsets identify the next entry and must remain stable when names are added or + /// removed. Slots append only as names are incrementally considered for a bounded response; + /// they never contain attributes/nodes and never snapshot undiscovered directory contents. + let operationLock = NSLock() + var cookieNames: [String] = [] + var knownCookieNames: Set = [] + var enumerationExhausted = false + var terminalCursorQuotaError: FuseResourceQuotaError? + var reservedCursorEntries = 0 + var reservedCursorNameBytes = 0 private let hostFS: HostFS - - init(nodeID: UInt64, entries: [HostFSEntry?]? = nil, hostFS: HostFS) { + private let resourceQuota: FuseResourceQuota + private let resourceToken: FuseResourceToken + + init( + nodeID: UInt64, + cursor: HostFSDirectoryCursor, + hostFS: HostFS, + resourceQuota: FuseResourceQuota, + resourceToken: FuseResourceToken + ) { self.nodeID = nodeID - self.entries = entries + self.cursor = cursor self.hostFS = hostFS + self.resourceQuota = resourceQuota + self.resourceToken = resourceToken + } + + func appendCookieName(_ name: String) throws { + let nameBytes = name.utf8.count + try resourceQuota.reserveDirectoryCursorEntry(nameByteCount: nameBytes) + cookieNames.append(name) + knownCookieNames.insert(name) + reservedCursorEntries += 1 + reservedCursorNameBytes += nameBytes } deinit { + resourceQuota.releaseDirectoryCursor( + entries: reservedCursorEntries, + nameBytes: reservedCursorNameBytes + ) hostFS.releaseOpenHandle(nodeID: nodeID) } } @@ -155,6 +192,11 @@ public final class FuseServer: @unchecked Sendable { let flock: Bool } + private struct AdvisoryLockDescriptorAdmission { + let key: AdvisoryLockOwnerKey + let descriptor: AdvisoryLockDescriptor + } + /// macOS process locks alias inside one server process, so every guest lock owner receives an /// independently reopened file description. OFD record locks and flock(2) then preserve the /// kernel's owner isolation even though all FUSE requests execute inside dory-hv. @@ -162,10 +204,17 @@ public final class FuseServer: @unchecked Sendable { let fd: Int32 let usesFlock: Bool let operationLock = NSLock() - - init(fd: Int32, usesFlock: Bool) { + private let resourceToken: FuseResourceToken + /// Protected by `FuseServer.lock`. Transient admissions keep a descriptor registered until + /// all same-owner operations finish; a successful lock makes it persistent until FLUSH or + /// RELEASE. This prevents one failed concurrent request from retiring another's owner. + var activeAdmissions = 0 + var retainedByOwner = false + + init(fd: Int32, usesFlock: Bool, resourceToken: FuseResourceToken) { self.fd = fd self.usesFlock = usesFlock + self.resourceToken = resourceToken } deinit { @@ -232,21 +281,17 @@ public final class FuseServer: @unchecked Sendable { public init( hostFS: HostFS, - daxWindow: DaxWindow? = nil, - writebackCache: Bool? = nil, - killPrivV2: Bool? = nil, - deferReleaseClose _: Bool? = nil, - fastCreateAttributes: Bool? = nil + writebackCache: Bool = false, + killPrivV2: Bool = true, + fastCreateAttributes: Bool = false ) { self.hostFS = hostFS - self.daxWindow = daxWindow - self.writebackCache = writebackCache ?? Self.writebackCacheEnabledFromEnvironment() - self.killPrivV2 = killPrivV2 ?? Self.killPrivV2EnabledFromEnvironment() + self.writebackCache = writebackCache + self.killPrivV2 = killPrivV2 // A synthetic create identity cannot distinguish the original inode from a host atomic // replacement that lands before the first getattr/open. Production always records the real // file key; tests may still opt in explicitly to exercise legacy reconciliation behavior. - self.fastCreateAttributes = fastCreateAttributes ?? false - self.stats = FuseStats.fromEnvironment() + self.fastCreateAttributes = fastCreateAttributes } deinit { @@ -268,6 +313,7 @@ public final class FuseServer: @unchecked Sendable { var fuseInitCompleted: Bool { cachePolicy.isFuseInitCompleted } var coherentCachingActive: Bool { cachePolicy.isActive } + public var resourceSnapshot: FuseResourceSnapshot { resourceQuota.snapshot() } func markFuseInitCompleted() { cachePolicy.markFuseInitCompleted() @@ -290,8 +336,6 @@ public final class FuseServer: @unchecked Sendable { } let payload = request[Int(FuseInHeader.byteCount).. Int { - writeReadResponse(header: header, payload: payload[...], writable: writable) - } - - public func writeReadResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - guard let first = writable.first, first.length >= FuseOutHeader.byteCount else { return 0 } - let totalCapacity = writable.reduce(0) { $0 + $1.length } - func finish(errno: Int32, payloadBytes: Int) -> Int { - let total = FuseOutHeader.byteCount + payloadBytes - first.pointer.storeBytes(of: UInt32(total).littleEndian, toByteOffset: 0, as: UInt32.self) - first.pointer.storeBytes(of: Int32(-FuseProtocol.linuxErrno(errno)).littleEndian, toByteOffset: 4, as: Int32.self) - first.pointer.storeBytes(of: header.unique.littleEndian, toByteOffset: 8, as: UInt64.self) - return total - } - guard payload.count >= 40 else { return finish(errno: EINVAL, payloadBytes: 0) } - let size = min(Int(payload.leUInt32(at: 16)), HostFS.maxReadCount) - guard let signedOffset = off_t(exactly: payload.leUInt64(at: 8)) else { - return finish(errno: EINVAL, payloadBytes: 0) - } - guard let openHandle = loadFile(handle: payload.leUInt64(at: 0)), - openHandle.nodeID == header.nodeID, - openHandle.permitsRead(writebackCache: writebackCache) else { - anomalyLog.log(describeStaleHandle(payload.leUInt64(at: 0), nodeID: header.nodeID, op: "READ(direct)")) - return finish(errno: EBADF, payloadBytes: 0) - } - let dataCapacity = min(size, totalCapacity - FuseOutHeader.byteCount) - guard dataCapacity > 0 else { return finish(errno: 0, payloadBytes: 0) } - - // Build iovecs over the writable bytes AFTER the 16-byte out header. - var iovecs = [iovec]() - var remaining = dataCapacity - var skip = FuseOutHeader.byteCount - for segment in writable where remaining > 0 { - var base = segment.pointer - var length = segment.length - if skip > 0 { - let drop = min(skip, length) - base = base.advanced(by: drop) - length -= drop - skip -= drop - } - guard length > 0 else { continue } - let take = min(length, remaining) - iovecs.append(iovec(iov_base: base, iov_len: take)) - remaining -= take - } - let readCount = preadv(openHandle.fd, iovecs, Int32(iovecs.count), signedOffset) - guard readCount >= 0 else { return finish(errno: errno, payloadBytes: 0) } - return finish(errno: 0, payloadBytes: Int(readCount)) - } - - /// Complete direct LOOKUP response. Handling hits as well as misses here is important: probing for - /// a miss and then falling back on a hit performs the same descriptor-relative stat twice. Require - /// enough output space for either result before touching HostFS so an undersized chain can fall - /// back without registering an entry or acquiring a lookup reference. - public func writeLookupResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - let payloadBytes = 128 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { - return 0 - } - stats?.record(.lookup) - do { - let name = try readCString(payload) - guard let entry = try hostFS.lookupIfExists(parent: header.nodeID, name: name) else { - let validity = cachePolicy.negativeEntryValiditySeconds - guard validity > 0 else { - return writeErrorResponse(unique: header.unique, errno: ENOENT, writable: writable) - } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - writer.append(encodeNegativeEntryOut(validity: validity)) - return writer.written - } - hostFS.retainLookup(nodeID: entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(entry.attributes, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct GETXATTR response for the default policy: Dory does not expose host extended attributes - /// through broad home shares, so the stable answer is ENODATA. This is on the hot path for Alpine's - /// shell-created files. - public func writeGetXattrNoDataResponse(header: FuseInHeader, writable: [VirtqueueSegment]) -> Int { - stats?.record(.getxattr) - // ENOSYS latches fc->no_getxattr guest-side; see the array-path getxattr case. - return writeErrorResponse(unique: header.unique, errno: ENOSYS, writable: writable) - } - - /// Direct GETATTR response. Metadata-heavy create loops ask for attrs after create; emitting the - /// fixed attr payload in place avoids another response allocation on that path. - public func writeGetattrResponse( - header: FuseInHeader, - payload: ArraySlice, - writable: [VirtqueueSegment] - ) -> Int { - stats?.record(.getattr) - let payloadBytes = 104 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - let attrs = try getattrAttributes(header: header, payload: payload) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendAttrOut(attrs, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct CREATE response. The common shell redirection path creates and opens a file, then writes - /// and releases it immediately. This keeps the create response on the same direct path as write and - /// release while preserving the normal fallback for undersized writable chains. - public func writeCreateResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.create) - let payloadBytes = 144 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 16 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let intent = FileOpenIntent(wireFlags: payload.leUInt32(at: 0)) else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 4)) - let name = try readCString(payload.dropFirst(16)) - let created = try hostFS.createFileAndOpen( - parent: header.nodeID, - name: name, - mode: mode, - accessMode: hostAccessMode(for: intent), - preferredIdentityAccessMode: .readWrite, - exclusive: intent.exclusive, - truncate: intent.truncate, - append: intent.append && !writebackCache, - syntheticAttributes: fastCreateAttributes, - retainOpenHandle: true, - ownerUID: header.uid, - ownerGID: header.gid - ) - let handle = storeRetainedFile( - fd: created.fd, - nodeID: created.entry.nodeID, - accessMode: intent.accessMode, - append: intent.append - ) - hostFS.retainLookup(nodeID: created.entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(created.entry.attributes, to: &writer) - appendOpenOut(handle: handle, openFlags: fileOpenFlags, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct MKDIR response. It is cold compared with CREATE, but keeping directory setup on the - /// direct path avoids a stray allocation in create/delete benchmark loops. - public func writeMkdirResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.mkdir) - let payloadBytes = 128 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 8 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 0)) - let name = try readCString(payload.dropFirst(8)) - let entry = try hostFS.mkdir( - parent: header.nodeID, - name: name, - mode: mode, - syntheticAttributes: fastCreateAttributes, - ownerUID: header.uid, - ownerGID: header.gid - ) - hostFS.retainLookup(nodeID: entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(entry.attributes, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct UNLINK/RMDIR response for cleanup-heavy bind mount loops. The host mutation still goes - /// through HostFS; this only avoids allocating and copying the empty success frame. - public func writeRemoveResponse(header: FuseInHeader, opcode: FuseOpcode, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(opcode) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - do { - let name = try readCString(payload) - switch opcode { - case .unlink: - try hostFS.unlink(parent: header.nodeID, name: name) - case .rmdir: - try hostFS.rmdir(parent: header.nodeID, name: name) - default: - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct WRITE response path for the virtio-fs device. The response is a fixed - /// `fuse_out_header + fuse_write_out`, so writing it straight into the guest avoids a tiny - /// allocation and scatter-copy on every small write in metadata-heavy bind workloads. - public func writeWriteResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.write) - let payloadBytes = 8 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 40 else { return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) } - let handle = payload.leUInt64(at: 0) - let offset = payload.leUInt64(at: 8) - let size = Int(payload.leUInt32(at: 16)) - guard payload.count >= 40 + size else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let openHandle = loadFile(handle: handle), - openHandle.nodeID == header.nodeID, - openHandle.permitsWrite else { - anomalyLog.log(describeStaleHandle(handle, nodeID: header.nodeID, op: "WRITE(direct)")) - return writeErrorResponse(unique: header.unique, errno: EBADF, writable: writable) - } - fileOperationLoadedTestHook?() - let written = try payload.withUnsafeBytes { raw -> Int in - let base = raw.baseAddress?.advanced(by: 40) - return try hostFS.write( - handle: openHandle.fd, - offset: offset, - bytes: UnsafeRawBufferPointer(start: base, count: size), - append: openHandle.append && !writebackCache - ) - } - if openHandle.append && !writebackCache { - try hostFS.recordAppendWrite(nodeID: header.nodeID, handle: openHandle.fd) - } else { - hostFS.recordWrite(nodeID: header.nodeID, offset: offset, count: written) - } - killPrivilegeBitsIfRequested(writeFlags: payload.leUInt32(at: 20), fd: openHandle.fd) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - writer.appendLE(UInt32(written)) - writer.appendLE(UInt32(0)) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct RELEASE/RELEASEDIR response path. RELEASE is close-heavy in shell-file-create loops; - /// keeping it allocation-free makes the common success path cheaper without changing close - /// ordering or deferred-close semantics. - public func writeReleaseResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - guard let opcode = FuseOpcode(rawValue: header.opcode), opcode == .release || opcode == .releasedir else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - stats?.record(opcode) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - guard payload.count >= 8 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let handle = payload.leUInt64(at: 0) - if opcode == .release { - if payload.count >= 24 { - releaseAdvisoryLocks(nodeID: header.nodeID, owner: payload.leUInt64(at: 16)) - } - releaseFile(handle: handle) - } else { - releaseDirectory(handle: handle) - } - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } - - public func writeEmptySuccessResponse(unique: UInt64, writable: [VirtqueueSegment]) -> Int { - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: unique, error: 0, payloadByteCount: 0) - return writer.written - } - /// Reverses only server-side lifetime grants from a successful response that never reached the /// used ring. Host namespace/data mutations remain committed; the guest never received the /// node or handle references that would otherwise authorize keeping these resources alive. - func rollbackUnpublishedResponse( - opcode: FuseOpcode, - writable: [VirtqueueSegment], - written: Int - ) { - guard let response = copyEncodedResponse(writable: writable, written: written) else { return } - rollbackUnpublishedResponse(opcode: opcode, response: response) - } - func rollbackUnpublishedResponse(opcode: FuseOpcode, response: [UInt8]) { guard response.count >= FuseOutHeader.byteCount, Int32(bitPattern: response.leUInt32(at: 4)) == 0 else { return } @@ -951,106 +704,6 @@ public final class FuseServer: @unchecked Sendable { wakeAdvisoryLockWaiters() } - private func copyEncodedResponse( - writable: [VirtqueueSegment], - written: Int - ) -> [UInt8]? { - guard written >= FuseOutHeader.byteCount else { return nil } - var response = [UInt8]() - response.reserveCapacity(written) - var remaining = written - for segment in writable where remaining > 0 { - let take = min(segment.length, remaining) - response.append(contentsOf: UnsafeRawBufferPointer(start: segment.pointer, count: take)) - remaining -= take - } - guard remaining == 0 else { return nil } - let declaredLength = Int(response.leUInt32(at: 0)) - guard declaredLength >= FuseOutHeader.byteCount, declaredLength <= response.count else { - return nil - } - if declaredLength < response.count { - response.removeSubrange(declaredLength.. Int { - let errno = FuseProtocol.linuxErrno(rawErrno) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: unique, error: -errno, payloadByteCount: 0) - return writer.written - } - - /// Removes every metadata-cache grant from an already encoded successful response. VirtioFS - /// calls this under its publish fence when a queue-health policy transition overtook a worker. - /// Copying is intentional: this is a cold race path, and it handles TTL fields split - /// across arbitrary writable descriptor segments without weakening the direct hot paths. - @discardableResult - func neutralizeCacheGrants( - opcode: FuseOpcode, - writable: [VirtqueueSegment], - written: Int - ) -> Bool { - guard var response = copyEncodedResponse(writable: writable, written: written) else { return false } - let declaredLength = response.count - // Errors and response kinds without entry/attribute validity cannot grant metadata cache. - guard Int32(bitPattern: response.leUInt32(at: 4)) == 0 else { return true } - - func zeroUInt64(at offset: Int) -> Bool { - guard offset >= 0, offset + MemoryLayout.size <= declaredLength else { - return false - } - response.replaceSubrange(offset..<(offset + MemoryLayout.size), with: repeatElement(0, count: 8)) - return true - } - - let payloadStart = FuseOutHeader.byteCount - switch opcode { - case .lookup, .symlink, .link, .mkdir, .create: - // fuse_entry_out: nodeid, generation, entry_valid, attr_valid, ... - guard zeroUInt64(at: payloadStart + 16), zeroUInt64(at: payloadStart + 24) else { - return false - } - case .getattr, .setattr: - // fuse_attr_out begins with attr_valid. - guard zeroUInt64(at: payloadStart) else { return false } - case .readdirplus: - // Each packed record is fuse_entry_out (128 bytes) followed by fuse_dirent. Records are - // independently 8-byte aligned, so neutralize both validity fields in every entry. - var recordStart = payloadStart - while recordStart < declaredLength { - let nameLengthOffset = recordStart + 128 + 16 - guard nameLengthOffset + 4 <= declaredLength, - zeroUInt64(at: recordStart + 16), - zeroUInt64(at: recordStart + 24) else { - return false - } - let nameLength = Int(response.leUInt32(at: nameLengthOffset)) - let unalignedLength = 128 + 24 + nameLength - let recordLength = (unalignedLength + 7) & ~7 - guard recordLength >= 152, recordStart + recordLength <= declaredLength else { - return false - } - recordStart += recordLength - } - guard recordStart == declaredLength else { return false } - default: - return true - } - - var offset = 0 - for segment in writable where offset < declaredLength { - let take = min(segment.length, declaredLength - offset) - response[offset..<(offset + take)].withUnsafeBytes { source in - segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take) - } - offset += take - } - return offset == declaredLength - } - private func handleWrite(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { guard payload.count >= 40 else { return errorResponse(unique: header.unique, errno: EINVAL) } let handle = payload.leUInt64(at: 0) @@ -1091,26 +744,86 @@ public final class FuseServer: @unchecked Sendable { guard let offset = Int(exactly: payload.leUInt64(at: 8)) else { return errorResponse(unique: header.unique, errno: EINVAL) } - guard let entries = try directoryEntries( - handle: handle, - nodeID: header.nodeID, - refresh: offset == 0 - ) else { + guard let directory = loadDirectory(handle: handle), directory.nodeID == header.nodeID else { anomalyLog.log(describeStaleHandle(handle, nodeID: header.nodeID, op: "READDIRPLUS")) return errorResponse(unique: header.unique, errno: EBADF) } let maxSize = Int(payload.leUInt32(at: 16)) - var data = [UInt8]() - var retainedNodeIDs = [UInt64]() - for (index, optionalEntry) in entries.enumerated().dropFirst(offset) { - guard let entry = optionalEntry else { continue } - let encoded = encodeDirentPlus(entry, offset: UInt64(index + 1)) - guard data.count + encoded.count <= maxSize else { break } - data.append(contentsOf: encoded) - retainedNodeIDs.append(entry.nodeID) - } - hostFS.retainLookups(nodeIDs: retainedNodeIDs) - return successResponse(unique: header.unique, payload: data) + directoryOperationLoadedTestHook?() + + return try directory.operationLock.withLock { + if let terminalError = directory.terminalCursorQuotaError { + throw terminalError + } + guard offset <= directory.cookieNames.count else { + return errorResponse(unique: header.unique, errno: EINVAL) + } + if offset == 0 { + try hostFS.rewindDirectoryCursor(directory.cursor) + directory.enumerationExhausted = false + } + + var data = [UInt8]() + data.reserveCapacity(min(maxSize, 64 * 1_024)) + var retainedNodeIDs: [UInt64] = [] + var slot = offset + + while data.count < maxSize { + if slot == directory.cookieNames.count { + guard !directory.enumerationExhausted else { break } + let nextName: String? + do { + nextName = try hostFS.nextDirectoryName(from: directory.cursor) + } catch { + if data.isEmpty { throw error } + break + } + guard let nextName else { + directory.enumerationExhausted = true + break + } + guard !directory.knownCookieNames.contains(nextName) else { continue } + do { + try directory.appendCookieName(nextName) + } catch let quotaError as FuseResourceQuotaError { + // The host stream has advanced past a name we cannot retain as a stable + // cookie. Latch the cursor rather than silently skipping it. Entries already + // encoded in this reply remain valid; the next page reports EOVERFLOW. + directory.terminalCursorQuotaError = quotaError + if data.isEmpty { throw quotaError } + break + } + } + + let name = directory.cookieNames[slot] + let encodedLength = Self.direntPlusEncodedLength(nameByteCount: name.utf8.count) + guard encodedLength <= maxSize - data.count else { break } + + let entry: HostFSEntry? + do { + entry = try hostFS.lookupIfExists(parent: header.nodeID, name: name) + } catch HostFSError.operationNotSupported { + // Unsupported host special files keep a stable hole in the cookie space. + slot += 1 + continue + } catch { + if data.isEmpty { throw error } + break + } + slot += 1 + guard let entry else { + // A removed name remains a hole so later cookies never shift left. + continue + } + let encoded = encodeDirentPlus(entry, offset: UInt64(slot)) + precondition(encoded.count == encodedLength) + data.append(contentsOf: encoded) + retainedNodeIDs.append(entry.nodeID) + } + + hostFS.retainLookups(nodeIDs: retainedNodeIDs) + return successResponse(unique: header.unique, payload: data) + } } private func handleStatFS(header: FuseInHeader) throws -> [UInt8] { @@ -1162,23 +875,6 @@ public final class FuseServer: @unchecked Sendable { return successResponse(unique: header.unique, payload: []) } - public func writeFlushResponse( - header: FuseInHeader, - payload: ArraySlice, - writable: [VirtqueueSegment] - ) -> Int { - guard payload.count >= 24 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let openHandle = loadFile(handle: payload.leUInt64(at: 0)), - openHandle.nodeID == header.nodeID else { - anomalyLog.log(describeStaleHandle(payload.leUInt64(at: 0), nodeID: header.nodeID, op: "FLUSH(direct)")) - return writeErrorResponse(unique: header.unique, errno: EBADF, writable: writable) - } - releaseAdvisoryLocks(nodeID: header.nodeID, owner: payload.leUInt64(at: 16)) - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } - private func handleCreate(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { guard payload.count >= 16 else { return errorResponse(unique: header.unique, errno: EINVAL) } guard let intent = FileOpenIntent(wireFlags: payload.leUInt32(at: 0)) else { @@ -1186,6 +882,7 @@ public final class FuseServer: @unchecked Sendable { } let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 4)) let name = try readCString(payload.dropFirst(16)) + let handleToken = try resourceQuota.acquire(.fileHandles) let created = try hostFS.createFileAndOpen( parent: header.nodeID, name: name, @@ -1204,7 +901,8 @@ public final class FuseServer: @unchecked Sendable { fd: created.fd, nodeID: created.entry.nodeID, accessMode: intent.accessMode, - append: intent.append + append: intent.append, + resourceToken: handleToken ) let entry = created.entry hostFS.retainLookup(nodeID: entry.nodeID) @@ -1295,12 +993,16 @@ public final class FuseServer: @unchecked Sendable { guard !request.isFlock else { return errorResponse(unique: header.unique, errno: EOPNOTSUPP) } - let descriptor = try advisoryLockDescriptor( + let admission = try advisoryLockDescriptor( nodeID: header.nodeID, owner: request.owner, flock: false, openHandle: openHandle ) + defer { + finishAdvisoryLockDescriptor(admission, retainOwner: false) + } + let descriptor = admission.descriptor var record = try darwinLockRecord(request) let rc = descriptor.operationLock.withLock { fcntl(descriptor.fd, F_OFD_GETLK, &record) @@ -1342,12 +1044,17 @@ public final class FuseServer: @unchecked Sendable { if !request.isFlock, request.type == 1, !openHandle.permitsWrite { return errorResponse(unique: header.unique, errno: EBADF) } - let descriptor = try advisoryLockDescriptor( + let admission = try advisoryLockDescriptor( nodeID: header.nodeID, owner: request.owner, flock: request.isFlock, openHandle: openHandle ) + var retainOwner = false + defer { + finishAdvisoryLockDescriptor(admission, retainOwner: retainOwner) + } + let descriptor = admission.descriptor let rc: Int32 if request.isFlock { var operation: Int32 @@ -1387,6 +1094,7 @@ public final class FuseServer: @unchecked Sendable { } } guard rc == 0 else { throw HostFSError.systemCall(request.isFlock ? "flock" : "F_OFD_SETLK", errno) } + if request.type != 2 { retainOwner = true } if request.type == 2 { wakeAdvisoryLockWaiters() } return successResponse(unique: header.unique, payload: []) } @@ -1402,17 +1110,28 @@ public final class FuseServer: @unchecked Sendable { attempt: () throws -> Int32 ) throws -> Int32 { guard blocking else { return try attempt() } - advisoryLockCondition.withLock { - pendingBlockingLocks.insert(requestUnique) - pendingBlockingLockOwners[requestUnique] = ownerKey - cancelledBlockingLocks.remove(requestUnique) + let resourceToken = try resourceQuota.acquire(.pendingBlockingLocks) + do { + try advisoryLockCondition.withLock { + guard pendingBlockingLocks.insert(requestUnique).inserted else { + throw HostFSError.invalidName("duplicate blocking-lock request identity") + } + pendingBlockingLockOwners[requestUnique] = ownerKey + pendingBlockingLockTokens[requestUnique] = resourceToken + cancelledBlockingLocks.remove(requestUnique) + } + } catch { + resourceToken.release() + throw error } defer { - advisoryLockCondition.withLock { + let token = advisoryLockCondition.withLock { () -> FuseResourceToken? in pendingBlockingLocks.remove(requestUnique) pendingBlockingLockOwners.removeValue(forKey: requestUnique) cancelledBlockingLocks.remove(requestUnique) + return pendingBlockingLockTokens.removeValue(forKey: requestUnique) } + token?.release() } while true { advisoryLockCondition.lock() @@ -1445,28 +1164,52 @@ public final class FuseServer: @unchecked Sendable { private func handleInterrupt(payload: ArraySlice) { guard payload.count >= 8 else { return } let interruptedUnique = payload.leUInt64(at: 0) - advisoryLockCondition.withLock { - guard pendingBlockingLocks.contains(interruptedUnique) else { return } + let token = advisoryLockCondition.withLock { () -> FuseResourceToken? in + guard pendingBlockingLocks.contains(interruptedUnique) else { return nil } + // Keep the request identity registered until its worker observes cancellation. The + // quota token can be returned immediately without allowing a duplicate unique ID to + // erase the cancellation marker while the original request is still unwinding. cancelledBlockingLocks.insert(interruptedUnique) advisoryLockCondition.broadcast() + return pendingBlockingLockTokens.removeValue(forKey: interruptedUnique) } + token?.release() + } + + func interrupt(requestUnique: UInt64) { + var payload = [UInt8]() + payload.appendLE(requestUnique) + handleInterrupt(payload: payload[...]) + } + + func cancelAllRequests() { + cancelAllBlockingLocks() } private func cancelAllBlockingLocks() { - advisoryLockCondition.withLock { + var tokens = advisoryLockCondition.withLock { () -> [FuseResourceToken] in cancelledBlockingLocks.formUnion(pendingBlockingLocks) + let tokens = Array(pendingBlockingLockTokens.values) + pendingBlockingLockTokens.removeAll(keepingCapacity: false) advisoryLockCondition.broadcast() + return tokens } + tokens.removeAll(keepingCapacity: false) } private func cancelBlockingLocks(nodeID: UInt64, owner: UInt64) { - advisoryLockCondition.withLock { + var tokens = advisoryLockCondition.withLock { () -> [FuseResourceToken] in let requests = pendingBlockingLockOwners.compactMap { unique, key in key.nodeID == nodeID && key.owner == owner ? unique : nil } cancelledBlockingLocks.formUnion(requests) + let tokens = requests.compactMap { + pendingBlockingLockTokens.removeValue(forKey: $0) + } advisoryLockCondition.broadcast() + return tokens } + tokens.removeAll(keepingCapacity: false) } private func wakeAdvisoryLockWaiters() { @@ -1478,9 +1221,16 @@ public final class FuseServer: @unchecked Sendable { owner: UInt64, flock: Bool, openHandle: OpenFileHandle - ) throws -> AdvisoryLockDescriptor { + ) throws -> AdvisoryLockDescriptorAdmission { let key = AdvisoryLockOwnerKey(nodeID: nodeID, owner: owner, flock: flock) - if let existing = lock.withLock({ lockOwners[key] }) { return existing } + if let existing = lock.withLock({ () -> AdvisoryLockDescriptorAdmission? in + guard let descriptor = lockOwners[key] else { return nil } + descriptor.activeAdmissions += 1 + return AdvisoryLockDescriptorAdmission(key: key, descriptor: descriptor) + }) { + return existing + } + let resourceToken = try resourceQuota.acquire(.advisoryLockOwners) // HostFS performs a contained openat() and verifies the inode identity, producing a new // open description for each guest lock owner. This is essential on Darwin because simply // opening /dev/fd aliases the source description and collapses different guest owners. @@ -1489,14 +1239,43 @@ public final class FuseServer: @unchecked Sendable { accessMode: openHandle.accessMode, append: false ) - let candidate = AdvisoryLockDescriptor(fd: fd, usesFlock: flock) + let candidate = AdvisoryLockDescriptor( + fd: fd, + usesFlock: flock, + resourceToken: resourceToken + ) return lock.withLock { - if let existing = lockOwners[key] { return existing } + if let existing = lockOwners[key] { + existing.activeAdmissions += 1 + return AdvisoryLockDescriptorAdmission(key: key, descriptor: existing) + } + candidate.activeAdmissions = 1 lockOwners[key] = candidate - return candidate + return AdvisoryLockDescriptorAdmission(key: key, descriptor: candidate) } } + private func finishAdvisoryLockDescriptor( + _ admission: AdvisoryLockDescriptorAdmission, + retainOwner: Bool + ) { + var removed: AdvisoryLockDescriptor? = lock.withLock { + let descriptor = admission.descriptor + precondition(descriptor.activeAdmissions > 0, "unbalanced advisory-lock admission") + descriptor.activeAdmissions -= 1 + if retainOwner { descriptor.retainedByOwner = true } + guard lockOwners[admission.key] === descriptor, + descriptor.activeAdmissions == 0, + !descriptor.retainedByOwner else { + return nil + } + return lockOwners.removeValue(forKey: admission.key) + } + let didRemove = removed != nil + removed = nil + if didRemove { wakeAdvisoryLockWaiters() } + } + private func darwinLockRecord(_ request: FuseLockRequest) throws -> flock { guard request.start <= request.end, request.start <= UInt64(Int64.max) else { @@ -1518,7 +1297,6 @@ public final class FuseServer: @unchecked Sendable { } private func releaseAdvisoryLocks(nodeID: UInt64, owner: UInt64) { - guard owner != 0 else { return } cancelBlockingLocks(nodeID: nodeID, owner: owner) var released: [AdvisoryLockDescriptor] = lock.withLock { let keys = lockOwners.keys.filter { $0.nodeID == nodeID && $0.owner == owner } @@ -1534,49 +1312,6 @@ public final class FuseServer: @unchecked Sendable { return successResponse(unique: header.unique, payload: []) } - private func handleSetupMapping(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { - guard let daxWindow else { - return errorResponse(unique: header.unique, errno: ENOSYS) - } - let request = try FuseProtocol.decodeSetupMappingIn(Array(payload)) - let knownMappingFlags = FuseSetupMappingFlag.read.rawValue - | FuseSetupMappingFlag.write.rawValue - guard request.flags & ~knownMappingFlags == 0 else { - return errorResponse(unique: header.unique, errno: EINVAL) - } - // virtio-fs sends fh = -1 for inode-based DAX mappings; resolve the file from the node id. - // The backend mmaps read-write (Apple's hv_vm_map rejects a read-only host region), so the - // fd must be writable; a read-only file therefore falls back to plain FUSE reads via the - // thrown error. The backend keeps its own mmap, so a temporary open is closed after setup. - let fd: Int32 - var temporaryFD: Int32? - if request.fileHandle == UInt64.max { - fd = try hostFS.openReadWrite(nodeID: header.nodeID) - temporaryFD = fd - } else if let openHandle = loadFile(handle: request.fileHandle), - openHandle.nodeID == header.nodeID, - request.flags & FuseSetupMappingFlag.write.rawValue == 0 - || openHandle.permitsWrite, - request.flags & FuseSetupMappingFlag.read.rawValue == 0 - || openHandle.permitsRead(writebackCache: false) { - fd = openHandle.fd - } else { - return errorResponse(unique: header.unique, errno: EBADF) - } - defer { if let temporaryFD { hostFS.close(handle: temporaryFD) } } - _ = try daxWindow.setup(request, fileDescriptor: fd) - return successResponse(unique: header.unique, payload: []) - } - - private func handleRemoveMapping(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { - guard let daxWindow else { - return errorResponse(unique: header.unique, errno: ENOSYS) - } - let request = try FuseProtocol.decodeRemoveMappingIn(Array(payload)) - try daxWindow.remove(request) - return successResponse(unique: header.unique, payload: []) - } - /// HostFS already reserved the node lifetime atomically with its identity duplicate. This map /// insertion cannot fail, so ownership of both the fd and the open-handle reference transfers /// directly to the returned FUSE handle. @@ -1584,14 +1319,16 @@ public final class FuseServer: @unchecked Sendable { fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, - append: Bool + append: Bool, + resourceToken: FuseResourceToken ) -> UInt64 { lock.withLock { storeFileLocked( fd: fd, nodeID: nodeID, accessMode: accessMode, - append: append + append: append, + resourceToken: resourceToken ) } } @@ -1600,7 +1337,8 @@ public final class FuseServer: @unchecked Sendable { fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, - append: Bool + append: Bool, + resourceToken: FuseResourceToken ) -> UInt64 { let handle = allocateFileHandleLocked() fileHandles[handle] = OpenFileHandle( @@ -1608,16 +1346,31 @@ public final class FuseServer: @unchecked Sendable { nodeID: nodeID, accessMode: accessMode, append: append, - hostFS: hostFS + hostFS: hostFS, + resourceToken: resourceToken ) return handle } private func storeDirectory(nodeID: UInt64) throws -> UInt64 { + let resourceToken = try resourceQuota.acquire(.directoryHandles) try hostFS.retainOpenHandle(nodeID: nodeID) + let cursor: HostFSDirectoryCursor + do { + cursor = try hostFS.openDirectoryCursor(nodeID: nodeID) + } catch { + hostFS.releaseOpenHandle(nodeID: nodeID) + throw error + } return lock.withLock { let handle = allocateDirectoryHandleLocked() - directoryHandles[handle] = OpenDirectoryHandle(nodeID: nodeID, hostFS: hostFS) + directoryHandles[handle] = OpenDirectoryHandle( + nodeID: nodeID, + cursor: cursor, + hostFS: hostFS, + resourceQuota: resourceQuota, + resourceToken: resourceToken + ) return handle } } @@ -1643,42 +1396,6 @@ public final class FuseServer: @unchecked Sendable { lock.withLock { directoryHandles[handle] } } - private func directoryEntries( - handle: UInt64, - nodeID: UInt64, - refresh: Bool - ) throws -> [HostFSEntry?]? { - guard let directory = loadDirectory(handle: handle), directory.nodeID == nodeID else { - return nil - } - directoryOperationLoadedTestHook?() - let cached = lock.withLock { directory.entries } - if !refresh, let entries = cached { return entries } - - // Host enumeration can perform many descriptor-relative stats; never hold the server's - // handle-table lock across it. If two first-page requests race, the first installed - // snapshot wins and both callers use that same stable cookie space. - let discovered = try hostFS.readdirplus(nodeID: nodeID) - return lock.withLock { - if !refresh, let entries = directory.entries { return entries } - if let entries = directory.entries { - var currentByName = Dictionary(uniqueKeysWithValues: discovered.map { ($0.name, $0) }) - var stableSlots = entries.map { previous -> HostFSEntry? in - guard let previous else { return nil } - return currentByName.removeValue(forKey: previous.name) - } - // `discovered` is sorted, so additions are deterministic while existing cookies - // retain their original slot numbers. Replacements update attributes in place. - stableSlots.append(contentsOf: discovered.compactMap { currentByName[$0.name] }) - directory.entries = stableSlots - return stableSlots - } - let initial = discovered.map(Optional.some) - directory.entries = initial - return initial - } - } - private func releaseFile(handle: UInt64) { // Dropping the table's strong reference closes immediately only when no request queue is // still using the handle. Otherwise OpenFileHandle.deinit runs after the last operation. @@ -1786,17 +1503,6 @@ public final class FuseServer: @unchecked Sendable { appendAttr(attrs, to: &data) } - private func appendEntryOut(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - let cache = cachePolicy.responsePolicy - writer.appendLE(attrs.nodeID) - writer.appendLE(UInt64(1)) - writer.appendLE(cache.entryValiditySeconds) // entry_valid - writer.appendLE(cache.attrValiditySeconds) // attr_valid - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(0)) - appendAttr(attrs, to: &writer) - } - private func appendAttrOut(_ attrs: HostFSAttributes, to data: inout [UInt8]) { let cache = cachePolicy.responsePolicy data.appendLE(cache.attrValiditySeconds) // attr_valid @@ -1805,26 +1511,12 @@ public final class FuseServer: @unchecked Sendable { appendAttr(attrs, to: &data) } - private func appendAttrOut(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - let cache = cachePolicy.responsePolicy - writer.appendLE(cache.attrValiditySeconds) // attr_valid - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(0)) - appendAttr(attrs, to: &writer) - } - private func appendOpenOut(handle: UInt64, openFlags: UInt32, to data: inout [UInt8]) { data.appendLE(handle) data.appendLE(openFlags) data.appendLE(UInt32(0)) } - private func appendOpenOut(handle: UInt64, openFlags: UInt32, to writer: inout FuseDirectResponseWriter) { - writer.appendLE(handle) - writer.appendLE(openFlags) - writer.appendLE(UInt32(0)) - } - private func encodeDirentPlus(_ entry: HostFSEntry, offset: UInt64) -> [UInt8] { let name = Array(entry.name.utf8) var data = encodeEntryOut(entry.attributes) @@ -1837,6 +1529,10 @@ public final class FuseServer: @unchecked Sendable { return data } + private static func direntPlusEncodedLength(nameByteCount: Int) -> Int { + (128 + 24 + nameByteCount + 7) & ~7 + } + private func encodeAttr(_ attrs: HostFSAttributes) -> [UInt8] { var data = [UInt8]() data.reserveCapacity(88) @@ -1863,25 +1559,6 @@ public final class FuseServer: @unchecked Sendable { data.appendLE(UInt32(0)) } - private func appendAttr(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - writer.appendLE(attrs.nodeID) - writer.appendLE(attrs.size) - writer.appendLE((attrs.size + 511) / 512) - writer.appendLE(UInt64(bitPattern: attrs.atimeSeconds)) - writer.appendLE(UInt64(bitPattern: attrs.mtimeSeconds)) - writer.appendLE(UInt64(bitPattern: attrs.ctimeSeconds)) - writer.appendLE(attrs.atimeNsec) - writer.appendLE(attrs.mtimeNsec) - writer.appendLE(attrs.ctimeNsec) - writer.appendLE(attrs.mode) - writer.appendLE(attrs.linkCount) - writer.appendLE(attrs.uid) - writer.appendLE(attrs.gid) - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(4096)) - writer.appendLE(UInt32(0)) - } - private func direntType(for attrs: HostFSAttributes) -> UInt32 { if attrs.isDirectory { return 4 } if attrs.isSymlink { return 10 } @@ -1898,24 +1575,6 @@ public final class FuseServer: @unchecked Sendable { try? hostFS.clearPrivilegedBits(handle: fd) } - private static func writebackCacheEnabledFromEnvironment() -> Bool { - // Default OFF: desktop shares are bidirectional. With FUSE writeback enabled, dirty guest - // pages can survive a reverse invalidation and later overwrite a host editor's change even - // after the notification barrier completes. Keep explicit opt-in for isolated experiments; - // production coherence requires write-through until dirty-page conflict handling exists. - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_WRITEBACK_CACHE"]?.lowercased() else { - return false - } - return ["1", "true", "yes", "on"].contains(value) - } - - private static func killPrivV2EnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_KILLPRIV"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } - private var fileOpenFlags: UInt32 { // FOPEN_KEEP_CACHE cannot be revoked from handles that were opened before notification // health degraded. Metadata validity is bounded and fenceable; page-cache retention is not. @@ -1956,14 +1615,19 @@ public final class FuseServer: @unchecked Sendable { return EPROTO case RequestError.badFileDescriptor: return EBADF - case DaxWindowError.unaligned, DaxWindowError.outOfBounds, DaxWindowError.invalidWindow: - return EINVAL - case DaxWindowError.overlap: - return EBUSY - case DaxWindowError.missingMapping: - return ENOENT - case DaxWindowError.mappingFailed, DaxWindowError.unmappingFailed: - return EIO + case let quota as FuseResourceQuotaError: + // Node identities and every handle/lock-owner table entry retain a descriptor or a + // descriptor-backed identity, so EMFILE accurately reports per-share FD exhaustion. + // Blocking-lock admission consumes request capacity instead and is retryable once a + // waiter completes or is interrupted, matching Linux EAGAIN semantics. + switch quota.resource { + case .pendingBlockingLocks: + return EAGAIN + case .directoryCursorEntries, .directoryCursorNameBytes: + return EOVERFLOW + default: + return EMFILE + } default: return EIO } @@ -1988,34 +1652,6 @@ private final class FuseAnomalyLog: @unchecked Sendable { } } -private final class FuseStats: @unchecked Sendable { - private let lock = NSLock() - private var counts: [FuseOpcode: Int] = [:] - private var total = 0 - - static func fromEnvironment() -> FuseStats? { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_STATS"] ?? "" - guard ["1", "true", "yes", "on"].contains(value.lowercased()) else { return nil } - FileHandle.standardError.write(Data("dory-hv: fuse stats enabled\n".utf8)) - return FuseStats() - } - - func record(_ opcode: FuseOpcode) { - let snapshot: (Int, [FuseOpcode: Int])? = lock.withLock { - total += 1 - counts[opcode, default: 0] += 1 - guard total <= 20 || total.isMultiple(of: 100) else { return nil } - return (total, counts) - } - guard let snapshot else { return } - let line = snapshot.1 - .sorted { lhs, rhs in lhs.key.rawValue < rhs.key.rawValue } - .map { "\($0.key)=\($0.value)" } - .joined(separator: " ") - FileHandle.standardError.write(Data("dory-hv: fuse stats total=\(snapshot.0) \(line)\n".utf8)) - } -} - private extension NSLock { func withLock(_ body: () throws -> R) rethrows -> R { lock() @@ -2023,54 +1659,3 @@ private extension NSLock { return try body() } } - -private struct FuseDirectResponseWriter { - private let writable: [VirtqueueSegment] - private var segmentIndex = 0 - private var segmentOffset = 0 - private(set) var written = 0 - - init(writable: [VirtqueueSegment]) { - self.writable = writable - } - - mutating func appendOutHeader(unique: UInt64, error: Int32, payloadByteCount: Int) { - appendLE(UInt32(FuseOutHeader.byteCount + payloadByteCount)) - appendLE(UInt32(bitPattern: error)) - appendLE(unique) - } - - mutating func append(_ bytes: [UInt8]) { - bytes.withUnsafeBytes { append($0) } - } - - mutating func appendLE(_ value: UInt32) { - var value = value.littleEndian - withUnsafeBytes(of: &value) { append($0) } - } - - mutating func appendLE(_ value: UInt64) { - var value = value.littleEndian - withUnsafeBytes(of: &value) { append($0) } - } - - private mutating func append(_ source: UnsafeRawBufferPointer) { - var copied = 0 - while copied < source.count, segmentIndex < writable.count { - let segment = writable[segmentIndex] - let remainingInSegment = segment.length - segmentOffset - if remainingInSegment <= 0 { - segmentIndex += 1 - segmentOffset = 0 - continue - } - let take = min(remainingInSegment, source.count - copied) - segment.pointer - .advanced(by: segmentOffset) - .copyMemory(from: source.baseAddress!.advanced(by: copied), byteCount: take) - copied += take - segmentOffset += take - written += take - } - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift similarity index 92% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift index e6c4513c..8d029f12 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift @@ -1,4 +1,5 @@ import Darwin +import DoryFSWorkerContracts import Foundation public enum HostFSError: Error, Equatable { @@ -156,7 +157,7 @@ public struct HostFSSetattrRequest: Equatable, Sendable { } } -public final class HostFS: @unchecked Sendable { +final class HostFS: @unchecked Sendable { public static let rootNodeID: UInt64 = 1 public static let maxReadCount: Int = 1 << 20 @@ -196,6 +197,8 @@ public final class HostFS: @unchecked Sendable { /// description prevents Darwin from recycling this inode number while the guest can still /// refer to its node ID, including after every pathname has been removed. var identityFD: Int32 + /// Admission for this non-root identity descriptor. Root is structural and has no token. + var resourceToken: FuseResourceToken? = nil /// Number of lookup references handed to the guest in fuse_entry_out records. A detached /// node stays alive until the matching FORGET/BATCH_FORGET requests release these refs. var lookupCount: UInt64 = 0 @@ -222,6 +225,7 @@ public final class HostFS: @unchecked Sendable { /// macOS aliases such as `/var` and `/private/var`. private let hostEventRootPaths: [String] private let rootFD: Int32 + let resourceQuota: FuseResourceQuota private let guestUID: UInt32 private let guestGID: UInt32 private let readOnly: Bool @@ -273,7 +277,7 @@ public final class HostFS: @unchecked Sendable { } } - public init( + public convenience init( rootPath: String, // The host filesystem server runs as the signed-in macOS user. Export that identity by // default so the standard PUID/PGID convention (`id -u` / `id -g`) sees the same owner on @@ -282,7 +286,8 @@ public final class HostFS: @unchecked Sendable { guestGID: UInt32 = getgid(), readOnly: Bool = false, hiddenNames: Set = [], - rootHiddenNames: Set = [] + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production ) throws { var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) guard realpath(rootPath, &resolved) != nil else { @@ -295,10 +300,112 @@ public final class HostFS: @unchecked Sendable { throw HostFSError.invalidRoot(rootPath) } - self.rootPath = root let suppliedRoot = URL(fileURLWithPath: rootPath).standardizedFileURL.path - self.hostEventRootPaths = Array(Set([root, suppliedRoot])).sorted { $0.count > $1.count } - self.rootFD = fd + try self.init( + ownedRootFD: fd, + rootPath: root, + hostEventRootPaths: Array(Set([root, suppliedRoot])).sorted { $0.count > $1.count }, + invalidRootDescription: rootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Constructs the share from an already-authorized directory descriptor. The caller retains + /// its descriptor; HostFS duplicates it with CLOEXEC and never reopens `eventRootPath` for + /// filesystem authority. This is the production seam for a sandbox worker that has resolved a + /// security-scoped bookmark and verified the pinned root identity before FUSE admission. + public convenience init( + rootDirectoryFileDescriptor: Int32, + eventRootPath: String, + guestUID: UInt32 = getuid(), + guestGID: UInt32 = getgid(), + readOnly: Bool = false, + hiddenNames: Set = [], + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production + ) throws { + guard rootDirectoryFileDescriptor >= 0, + eventRootPath.hasPrefix("/"), + !eventRootPath.utf8.contains(0) else { + throw HostFSError.invalidRoot(eventRootPath) + } + let fd = fcntl(rootDirectoryFileDescriptor, F_DUPFD_CLOEXEC, 0) + guard fd >= 0 else { + throw HostFSError.systemCall("duplicate authorized root", errno) + } + let standardizedRoot = URL(fileURLWithPath: eventRootPath).standardizedFileURL.path + try self.init( + ownedRootFD: fd, + rootPath: standardizedRoot, + hostEventRootPaths: [standardizedRoot], + invalidRootDescription: eventRootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Constructs the production share from pinned descriptor authority only. The diagnostic + /// spelling used by the worker-local event machinery is derived from that descriptor with + /// `F_GETPATH`; no caller can pair an authorized descriptor with an unrelated host path. + public convenience init( + rootDirectoryFileDescriptor: Int32, + guestUID: UInt32 = getuid(), + guestGID: UInt32 = getgid(), + readOnly: Bool = false, + hiddenNames: Set = [], + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production + ) throws { + guard rootDirectoryFileDescriptor >= 0 else { + throw HostFSError.invalidRoot("authorized root descriptor") + } + var path = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard fcntl(rootDirectoryFileDescriptor, F_GETPATH, &path) == 0 else { + throw HostFSError.systemCall("inspect authorized root", errno) + } + let pathBytes = path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + let eventRootPath = String(decoding: pathBytes, as: UTF8.self) + guard eventRootPath.hasPrefix("/"), eventRootPath != "/" else { + throw HostFSError.invalidRoot("authorized root descriptor") + } + try self.init( + rootDirectoryFileDescriptor: rootDirectoryFileDescriptor, + eventRootPath: eventRootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Designated initializer taking ownership of `ownedRootFD`, including every failure path. + private init( + ownedRootFD: Int32, + rootPath: String, + hostEventRootPaths: [String], + invalidRootDescription: String, + guestUID: UInt32, + guestGID: UInt32, + readOnly: Bool, + hiddenNames: Set, + rootHiddenNames: Set, + resourceLimits: FuseResourceLimits + ) throws { + self.rootPath = rootPath + self.hostEventRootPaths = hostEventRootPaths + self.rootFD = ownedRootFD + self.resourceQuota = FuseResourceQuota(limits: resourceLimits) self.guestUID = guestUID self.guestGID = guestGID self.readOnly = readOnly @@ -306,14 +413,15 @@ public final class HostFS: @unchecked Sendable { self.rootHiddenNameKeys = Set(rootHiddenNames.map(Self.hiddenNameKey)) var st = stat() - guard fstat(fd, &st) == 0 else { - Darwin.close(fd) - throw HostFSError.invalidRoot(rootPath) + guard fstat(ownedRootFD, &st) == 0, + st.st_mode & S_IFMT == S_IFDIR else { + Darwin.close(ownedRootFD) + throw HostFSError.invalidRoot(invalidRootDescription) } - let identityFD = fcntl(fd, F_DUPFD_CLOEXEC, 0) + let identityFD = fcntl(ownedRootFD, F_DUPFD_CLOEXEC, 0) guard identityFD >= 0 else { let savedErrno = errno - Darwin.close(fd) + Darwin.close(ownedRootFD) throw HostFSError.systemCall("pin root identity", savedErrno) } let attrs = Self.attributes(from: st, nodeID: Self.rootNodeID, uid: guestUID, gid: guestGID) @@ -323,7 +431,8 @@ public final class HostFS: @unchecked Sendable { attachedPaths: [""], attributes: attrs, fileKey: FileKey(st), - identityFD: identityFD + identityFD: identityFD, + resourceToken: nil ) self.idsByFileKey[FileKey(st)] = [Self.rootNodeID] self.idsByRelativePath["", default: []].append(Self.rootNodeID) @@ -333,6 +442,10 @@ public final class HostFS: @unchecked Sendable { lock.withLock { eventObservationHandler = handler } } + public var resourceSnapshot: FuseResourceSnapshot { + resourceQuota.snapshot() + } + private func notifyEventObservation(for relativePath: String) { guard !relativePath.isEmpty else { return } let handler = lock.withLock { eventObservationHandler } @@ -341,6 +454,7 @@ public final class HostFS: @unchecked Sendable { deinit { for node in nodes.values { + node.resourceToken?.release() Darwin.close(node.identityFD) identityPinClosedTestHook?(node.id, node.identityFD) } @@ -741,6 +855,14 @@ public final class HostFS: @unchecked Sendable { } public func lookupIfExists(parent: UInt64, name: String) throws -> HostFSEntry? { + try lookupIfExists(parent: parent, name: name, nodeReservation: nil) + } + + private func lookupIfExists( + parent: UInt64, + name: String, + nodeReservation suppliedReservation: FuseResourceToken? + ) throws -> HostFSEntry? { try validateComponent(name) try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) @@ -781,6 +903,13 @@ public final class HostFS: @unchecked Sendable { return cached } + let nodeReservation: FuseResourceToken + if let suppliedReservation { + nodeReservation = suppliedReservation + } else { + nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) + } + let identity: PinnedIdentity do { identity = try pinIdentity(relativePath: relative, expectedMode: st.st_mode) @@ -788,7 +917,12 @@ public final class HostFS: @unchecked Sendable { detachLookupMiss(relativePath: relative) return nil } - return register(name: name, relativePath: relative, identity: identity) + return register( + name: name, + relativePath: relative, + identity: identity, + resourceToken: nodeReservation + ) } private func cachedLookupEntry( @@ -1479,6 +1613,9 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + // Reserve identity capacity before O_CREAT can mutate the exported namespace. Coalescing + // with an already-pinned inode returns the token automatically after registration. + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let createOptions = (exclusive ? O_EXCL : 0) | (truncate ? O_TRUNC : 0) // Descriptor exhaustion is checked before O_CREAT mutates the namespace so the forced // test path observes no created file. A real F_DUPFD_CLOEXEC failure after creation is @@ -1535,6 +1672,7 @@ public final class HostFS: @unchecked Sendable { relativePath: relative, mode: mode, identity: identity, + resourceToken: nodeReservation, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -1547,6 +1685,7 @@ public final class HostFS: @unchecked Sendable { name: name, relativePath: relative, identity: identity, + resourceToken: nodeReservation, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -1573,6 +1712,7 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let stagingName = temporaryEntryName() let result = stagingName.withCString { pointer in mkdirat(rootFD, pointer, mode_t(mode)) @@ -1598,11 +1738,18 @@ public final class HostFS: @unchecked Sendable { name: name, relativePath: relative, mode: mode, + resourceToken: nodeReservation, ownerUID: ownerUID, ownerGID: ownerGID ) } - let entry = try lookup(parent: parent, name: name) + guard let entry = try lookupIfExists( + parent: parent, + name: name, + nodeReservation: nodeReservation + ) else { + throw HostFSError.notFound(name) + } updateVirtualOwnership(nodeID: entry.nodeID, uid: ownerUID, gid: ownerGID) return HostFSEntry( name: entry.name, @@ -1627,6 +1774,7 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let stagingName = temporaryEntryName() let result = target.withCString { targetPointer in stagingName.withCString { namePointer in @@ -1649,7 +1797,13 @@ public final class HostFS: @unchecked Sendable { _ = unlinkat(rootFD, cPath(stagingName), Self.containedUnlinkFlags) throw HostFSError.systemCall("symlink \(relative)", savedErrno) } - let entry = try lookup(parent: parent, name: name) + guard let entry = try lookupIfExists( + parent: parent, + name: name, + nodeReservation: nodeReservation + ) else { + throw HostFSError.notFound(name) + } updateVirtualOwnership(nodeID: entry.nodeID, uid: ownerUID, gid: ownerGID) return HostFSEntry( name: entry.name, @@ -1862,7 +2016,9 @@ public final class HostFS: @unchecked Sendable { return try lookup(parent: newParent, name: newName) } - public func readdirplus(nodeID: UInt64) throws -> [HostFSEntry] { + /// Opens one identity-checked directory stream. The caller owns stable FUSE cookie slots; this + /// object exposes names incrementally and never snapshots, sorts, or registers the directory. + func openDirectoryCursor(nodeID: UInt64) throws -> HostFSDirectoryCursor { let node = try attachedNode(for: nodeID) guard node.attributes.isDirectory else { throw HostFSError.notDirectory(nodeID) @@ -1893,36 +2049,31 @@ public final class HostFS: @unchecked Sendable { Darwin.close(fd) throw HostFSError.systemCall("fdopendir \(node.relativePath)", savedErrno) } - defer { closedir(directory) } + return HostFSDirectoryCursor( + owner: ObjectIdentifier(self), + nodeID: nodeID, + directory: directory, + pathForErrors: node.relativePath + ) + } - var names: [String] = [] - errno = 0 - while let entry = readdir(directory) { - let length = Int(entry.pointee.d_namlen) - let name = withUnsafeBytes(of: entry.pointee.d_name) { bytes in - String(decoding: bytes.prefix(length), as: UTF8.self) - } - if name != ".", name != "..", !isHiddenName(name, parent: nodeID) { - names.append(name) - } - errno = 0 + func rewindDirectoryCursor(_ cursor: HostFSDirectoryCursor) throws { + guard cursor.owner == ObjectIdentifier(self) else { + throw HostFSError.invalidRoot("directory cursor belongs to another HostFS authority") } - let savedErrno = errno - guard savedErrno == 0 else { - throw HostFSError.systemCall("readdir \(node.relativePath)", savedErrno) + cursor.rewind() + } + + func nextDirectoryName(from cursor: HostFSDirectoryCursor) throws -> String? { + guard cursor.owner == ObjectIdentifier(self) else { + throw HostFSError.invalidRoot("directory cursor belongs to another HostFS authority") } - var entries: [HostFSEntry] = [] - for name in names.sorted() { - do { - entries.append(try lookup(parent: nodeID, name: name)) - } catch HostFSError.operationNotSupported { - // Unsupported host special files are intentionally absent from directory listings - // for the same reason direct lookup rejects them: FUSE cannot safely proxy their - // host-side blocking/IPC semantics. - continue + while let name = try cursor.nextRawName() { + if name != ".", name != "..", !isHiddenName(name, parent: cursor.nodeID) { + return name } } - return entries + return nil } public func statfs() throws -> HostFSStat { @@ -2466,6 +2617,7 @@ public final class HostFS: @unchecked Sendable { private func retireNodeLocked(_ id: UInt64) { guard id != Self.rootNodeID, let node = nodes.removeValue(forKey: id) else { return } + node.resourceToken?.release() removeFileKeyIndexLocked(node.fileKey, nodeID: id) for path in node.tombstonePaths { detachedIDsByRelativePath[path]?.remove(id) @@ -2521,6 +2673,7 @@ public final class HostFS: @unchecked Sendable { name: String, relativePath: String, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil @@ -2626,6 +2779,7 @@ public final class HostFS: @unchecked Sendable { attributes: attrs, fileKey: key, identityFD: identity.fd, + resourceToken: resourceToken, openHandleCount: retainOpenHandle ? 1 : 0 ) insertFileKeyIndexLocked(key, nodeID: id) @@ -2647,6 +2801,7 @@ public final class HostFS: @unchecked Sendable { relativePath: String, mode: UInt16, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil @@ -2657,6 +2812,7 @@ public final class HostFS: @unchecked Sendable { mode: mode, type: UInt32(S_IFREG), identity: identity, + resourceToken: resourceToken, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -2667,6 +2823,7 @@ public final class HostFS: @unchecked Sendable { name: String, relativePath: String, mode: UInt16, + resourceToken: FuseResourceToken, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil ) throws -> HostFSEntry { @@ -2677,6 +2834,7 @@ public final class HostFS: @unchecked Sendable { mode: mode, type: UInt32(S_IFDIR), identity: identity, + resourceToken: resourceToken, ownerUID: ownerUID, ownerGID: ownerGID ) @@ -2688,6 +2846,7 @@ public final class HostFS: @unchecked Sendable { mode: UInt16, type: UInt32, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil @@ -2726,6 +2885,7 @@ public final class HostFS: @unchecked Sendable { attributes: attrs, fileKey: key, identityFD: identity.fd, + resourceToken: resourceToken, openHandleCount: retainOpenHandle ? 1 : 0 ) insertFileKeyIndexLocked(key, nodeID: id) @@ -2991,6 +3151,55 @@ public final class HostFS: @unchecked Sendable { } +/// A live descriptor-relative directory stream. Its lock makes rewind/read indivisible per call; +/// `FuseServer` additionally serializes complete page construction for one guest directory handle. +/// `closedir` owns and closes the descriptor accepted by `fdopendir`. +final class HostFSDirectoryCursor: @unchecked Sendable { + fileprivate let owner: ObjectIdentifier + fileprivate let nodeID: UInt64 + + private let directory: UnsafeMutablePointer + private let pathForErrors: String + private let lock = NSLock() + + fileprivate init( + owner: ObjectIdentifier, + nodeID: UInt64, + directory: UnsafeMutablePointer, + pathForErrors: String + ) { + self.owner = owner + self.nodeID = nodeID + self.directory = directory + self.pathForErrors = pathForErrors + } + + fileprivate func rewind() { + lock.withLock { rewinddir(directory) } + } + + fileprivate func nextRawName() throws -> String? { + try lock.withLock { + errno = 0 + guard let entry = readdir(directory) else { + let savedErrno = errno + guard savedErrno == 0 else { + throw HostFSError.systemCall("readdir \(pathForErrors)", savedErrno) + } + return nil + } + let length = Int(entry.pointee.d_namlen) + return withUnsafeBytes(of: entry.pointee.d_name) { bytes in + String(decoding: bytes.prefix(length), as: UTF8.self) + } + } + } + + deinit { + closedir(directory) + } +} + struct FileKey: Hashable, Sendable { var device: UInt64 var inode: UInt64 diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift new file mode 100644 index 00000000..a4ebf06e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift @@ -0,0 +1,107 @@ +import Darwin +import DoryFSWorkerContracts +import DoryFSWorkerServiceCore +import Foundation +import XPC + +/// The only Objective-C object exported by the filesystem worker. The adapter deliberately adds +/// no object-model API of its own: every request and reply remains an exact bounded binary +/// envelope validated independently by `DoryFSWorkerService`. +private final class DoryFSWorkerXPCAdapter: NSObject, DoryFSWorkerXPCProtocol { + private let service = DoryFSWorkerService() + + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) { + reply(service.bootstrap(exactBytes: request)) + } + + func exchange(_ frame: Data, withReply reply: @escaping (Data) -> Void) { + reply(service.exchange(exactFrame: frame)) + } + + func sendOneWay(_ frame: Data) { + service.sendOneWay(exactFrame: frame) + } +} + +/// Accepts one runner connection for the lifetime of this process. A disconnected worker exits +/// instead of returning to launchd with live roots, handles, locks, or a reusable bootstrap gate. +private final class DoryFSWorkerListenerDelegate: + NSObject, + NSXPCListenerDelegate, + @unchecked Sendable +{ + private static let runnerSigningRequirement = """ + anchor apple generic and identifier "com.pythonxi.Dory.HVRunner" and \ + certificate leaf[subject.OU] = "864H636QW4" + """ + + private let admissionLock = NSLock() + private let adapter = DoryFSWorkerXPCAdapter() + private var acceptedConnection = false + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection + ) -> Bool { + guard connection.processIdentifier > 1, + connection.effectiveUserIdentifier == geteuid(), + connection.effectiveGroupIdentifier == getegid() else { + return false + } + + let claimed = admissionLock.withLock { + guard !acceptedConnection else { return false } + acceptedConnection = true + return true + } + guard claimed else { return false } + + // XPC services are otherwise eligible for launchd's idle SIGKILL between FUSE requests. + // This connection owns process-local root descriptors, FUSE handles, and one immutable + // worker generation, so its entire accepted lifetime is one explicit XPC transaction. + // Invalidation exits the process below; there is deliberately no reconnect/end path. + xpc_transaction_begin() + + // Foundation validates every incoming message against the sender's audit-token-bound code + // identity. This avoids PID-only identity checks and rejects an unsigned, re-signed, or + // different-team process even if it can discover the service name. The requirement is a + // fixed source literal; malformed dynamic requirement strings are intentionally impossible. + connection.setCodeSigningRequirement(Self.runnerSigningRequirement) + + connection.exportedInterface = NSXPCInterface(with: DoryFSWorkerXPCProtocol.self) + connection.exportedObject = adapter + connection.interruptionHandler = Self.terminateProcess + connection.invalidationHandler = Self.terminateProcess + connection.activate() + return true + } + + private static func terminateProcess() { + // `_exit` is intentional: process termination is the deterministic cleanup boundary for + // descriptors and blocking host filesystem operations. No fallback/reconnect is allowed. + Darwin._exit(EXIT_SUCCESS) + } +} + +@main +private enum DoryFSWorkerMain { + // NSXPCListener holds its delegate weakly, so process lifetime owns the one delegate strongly. + private static let listenerDelegate = DoryFSWorkerListenerDelegate() + + static func main() { + do { + try DoryFSWorkerProcessResources.raiseFileDescriptorSoftLimit() + } catch { + // The worker contract permits package-manager-scale descriptor ownership. Continuing + // with launchd's lower inherited soft limit would advertise capacity this process + // cannot honor, so fail before accepting or bootstrapping a workspace authority. + FileHandle.standardError.write(Data( + "dory-fs-worker: descriptor admission unavailable\n".utf8 + )) + Darwin._exit(EXIT_FAILURE) + } + let listener = NSXPCListener.service() + listener.delegate = listenerDelegate + listener.resume() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c new file mode 100644 index 00000000..4fc8164a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c @@ -0,0 +1,230 @@ +#include "DoryGuestMemoryShim.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + DoryGuestMemoryHeaderByteCount = 48, + DoryGuestMemoryCreationAttempts = 16, +}; + +static const uint8_t DoryGuestMemoryMagic[16] = { + 'D', 'O', 'R', 'Y', '-', 'G', 'U', 'E', 'S', 'T', '-', 'R', 'A', 'M', 0, 1, +}; + +static void DoryStoreLittleEndian64(uint8_t *destination, uint64_t value) { + for (int index = 0; index < 8; ++index) { + destination[index] = (uint8_t)(value >> (index * 8)); + } +} + +static uint64_t DoryLoadLittleEndian64(const uint8_t *source) { + uint64_t value = 0; + for (int index = 0; index < 8; ++index) { + value |= ((uint64_t)source[index]) << (index * 8); + } + return value; +} + +uint64_t DoryGuestMemoryBackingDataOffset(void) { + return (uint64_t)getpagesize(); +} + +static int DoryDeclaredFileSize(uint64_t guest_size, uint64_t *declared_file_size) { + uint64_t data_offset = DoryGuestMemoryBackingDataOffset(); + if (guest_size == 0 || guest_size > (uint64_t)INT64_MAX - data_offset) { + errno = EOVERFLOW; + return -1; + } + *declared_file_size = guest_size + data_offset; + return 0; +} + +static void DoryMakeHeader( + uint8_t header[DoryGuestMemoryHeaderByteCount], + uint64_t guest_size, + const DoryGuestMemoryBackingIdentity *identity +) { + memset(header, 0, DoryGuestMemoryHeaderByteCount); + memcpy(header, DoryGuestMemoryMagic, sizeof(DoryGuestMemoryMagic)); + DoryStoreLittleEndian64(header + 16, 1); + DoryStoreLittleEndian64(header + 24, guest_size); + memcpy(header + 32, identity->bytes, sizeof(identity->bytes)); +} + +int DoryCreateGuestMemoryBacking( + uint64_t guest_size, + DoryGuestMemoryBackingIdentity *identity, + uint64_t *declared_file_size +) { + if (identity == NULL || declared_file_size == NULL) { + errno = EINVAL; + return -1; + } + if (DoryDeclaredFileSize(guest_size, declared_file_size) != 0) { + return -1; + } + + int descriptor = -1; + for (int attempt = 0; attempt < DoryGuestMemoryCreationAttempts; ++attempt) { + uint64_t name_nonce = 0; + arc4random_buf(&name_nonce, sizeof(name_nonce)); + char name[32]; + int length = snprintf( + name, + sizeof(name), + "/dory-%016llx", + (unsigned long long)name_nonce + ); + if (length <= 0 || (size_t)length >= sizeof(name)) { + errno = EINVAL; + return -1; + } + descriptor = shm_open(name, O_RDWR | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR); + if (descriptor < 0) { + if (errno == EEXIST) { + continue; + } + return -1; + } + + // Do not return or perform fallible sizing/header work while a name is still visible. + if (shm_unlink(name) != 0) { + int unlink_error = errno; + close(descriptor); + errno = unlink_error; + return -1; + } + break; + } + if (descriptor < 0) { + errno = EEXIST; + return -1; + } + + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (descriptor_flags < 0 + || fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) != 0 + || ftruncate(descriptor, (off_t)*declared_file_size) != 0) { + int setup_error = errno; + close(descriptor); + errno = setup_error; + return -1; + } + + arc4random_buf(identity->bytes, sizeof(identity->bytes)); + uint8_t header[DoryGuestMemoryHeaderByteCount]; + DoryMakeHeader(header, guest_size, identity); + size_t data_offset = (size_t)DoryGuestMemoryBackingDataOffset(); + void *authority_page = mmap( + NULL, + data_offset, + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + 0 + ); + if (authority_page == MAP_FAILED) { + int map_error = errno; + close(descriptor); + errno = map_error; + return -1; + } + memcpy(authority_page, header, sizeof(header)); + if (munmap(authority_page, data_offset) != 0) { + int unmap_error = errno; + close(descriptor); + errno = unmap_error; + return -1; + } + return descriptor; +} + +int DoryReadGuestMemoryBackingIdentity( + int descriptor, + uint64_t declared_file_size, + DoryGuestMemoryBackingIdentity *identity +) { + if (descriptor < 0 || identity == NULL) { + errno = EINVAL; + return 0; + } + uint64_t data_offset = DoryGuestMemoryBackingDataOffset(); + if (declared_file_size <= data_offset || declared_file_size > (uint64_t)INT64_MAX) { + errno = EINVAL; + return 0; + } + + struct stat status; + if (fstat(descriptor, &status) != 0 + || (status.st_mode & S_IFMT) != 0 + || status.st_nlink != 0 + || status.st_size < 0 + || (uint64_t)status.st_size != declared_file_size) { + errno = EINVAL; + return 0; + } + int open_flags = fcntl(descriptor, F_GETFL); + if (open_flags < 0 || (open_flags & O_ACCMODE) != O_RDWR) { + errno = EINVAL; + return 0; + } + + size_t authority_page_size = (size_t)data_offset; + void *authority_page = mmap( + NULL, + authority_page_size, + PROT_READ, + MAP_SHARED, + descriptor, + 0 + ); + if (authority_page == MAP_FAILED) { + return 0; + } + uint8_t header[DoryGuestMemoryHeaderByteCount]; + memcpy(header, authority_page, sizeof(header)); + if (munmap(authority_page, authority_page_size) != 0) { + return 0; + } + if (memcmp(header, DoryGuestMemoryMagic, sizeof(DoryGuestMemoryMagic)) != 0 + || DoryLoadLittleEndian64(header + 16) != 1 + || DoryLoadLittleEndian64(header + 24) != declared_file_size - data_offset) { + errno = EINVAL; + return 0; + } + memcpy(identity->bytes, header + 32, sizeof(identity->bytes)); + return 1; +} + +int DoryGuestMemoryBackingMatches( + int descriptor, + uint64_t declared_file_size, + const DoryGuestMemoryBackingIdentity *identity +) { + if (identity == NULL) { + errno = EINVAL; + return 0; + } + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (descriptor_flags < 0 || (descriptor_flags & FD_CLOEXEC) == 0) { + errno = EINVAL; + return 0; + } + DoryGuestMemoryBackingIdentity actual_identity; + if (!DoryReadGuestMemoryBackingIdentity( + descriptor, + declared_file_size, + &actual_identity + )) { + return 0; + } + return memcmp(identity->bytes, actual_identity.bytes, sizeof(identity->bytes)) == 0; +} diff --git a/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h new file mode 100644 index 00000000..d2febe39 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h @@ -0,0 +1,44 @@ +#ifndef DORY_GUEST_MEMORY_SHIM_H +#define DORY_GUEST_MEMORY_SHIM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint8_t bytes[16]; +} DoryGuestMemoryBackingIdentity; + +/// Creates, immediately unlinks, sizes, and identifies one POSIX shared-memory object. +/// Returns an O_RDWR, close-on-exec descriptor, or -1 with errno set. +int DoryCreateGuestMemoryBacking( + uint64_t guest_size, + DoryGuestMemoryBackingIdentity *identity, + uint64_t *declared_file_size +); + +/// Returns the page-aligned byte offset at which guest RAM begins in the backing object. +uint64_t DoryGuestMemoryBackingDataOffset(void); + +/// Validates an unlinked POSIX shared-memory descriptor and returns its embedded identity. +/// This accepts no regular filesystem file, even when its size and header bytes match. +int DoryReadGuestMemoryBackingIdentity( + int descriptor, + uint64_t declared_file_size, + DoryGuestMemoryBackingIdentity *identity +); + +/// Exact owner-side validation, including identity and close-on-exec state. +int DoryGuestMemoryBackingMatches( + int descriptor, + uint64_t declared_file_size, + const DoryGuestMemoryBackingIdentity *identity +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift index 9035d1b4..47d0fb32 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift @@ -12,6 +12,8 @@ protocol AgentControlRPC: AnyObject, Sendable { func info() throws -> DoryAgentInfo func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot + func virtioFSMount(tag: String, mountPath: String, readOnly: Bool) throws + -> DoryVirtioFSMountReceipt func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws func usbVhciDetach(busID: String, port: UInt32) throws func close() @@ -20,6 +22,11 @@ protocol AgentControlRPC: AnyObject, Sendable { extension DoryAgentControlHandle: AgentControlRPC {} extension AgentControlRPC { + func virtioFSMount(tag: String, mountPath: String, readOnly: Bool) throws + -> DoryVirtioFSMountReceipt { + throw AgentProtocolError.capabilityUnavailable("virtiofs-mount", 1) + } + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws { throw AgentProtocolError.capabilityUnavailable("usb-vhci", 1) } @@ -47,18 +54,12 @@ public enum AgentProtocolError: Error, Equatable, Sendable { public final class AgentChannel: @unchecked Sendable { typealias ClientConnector = @Sendable (Int32) throws -> any AgentControlRPC - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - - init(_ connection: VsockConnection) { - self.connection = connection - } - } - private let lock = NSLock() + private let relayCompletion = DispatchGroup() private let connector: ClientConnector private var connection: VsockConnection? private var relayConnection: VsockConnection? + private var relaySession: VsockUnixRelay.RelaySession? private var client: (any AgentControlRPC)? public init(connection: VsockConnection) { @@ -136,6 +137,28 @@ public final class AgentChannel: @unchecked Sendable { } } + /// Mount a published virtio-fs share through the negotiated guest-tools primitive. Capability + /// validation is part of this operation so a caller cannot accidentally invoke a mutating RPC + /// against an arbitrary generic Linux guest merely because an agent transport answered. + public func mountVirtioFS(_ request: VirtioFSMountRequest) async throws + -> VirtioFSMountReceipt { + try await requireCapability("virtiofs-mount", version: 1) + let proof = try await perform { + try $0.virtioFSMount( + tag: request.tag, + mountPath: request.mountPath, + readOnly: request.readOnly + ) + } + return VirtioFSMountReceipt( + tag: proof.tag, + mountPath: proof.mountPath, + readOnly: proof.readOnly, + alreadyMounted: proof.alreadyMounted, + mountID: proof.mountID + ) + } + public func usbVhciDetach(_ request: UsbAgentDetachRequest) async throws { guard let port = UInt32(exactly: request.port) else { throw AgentProtocolError.invalidVhciPort(request.port) @@ -177,9 +200,20 @@ public final class AgentChannel: @unchecked Sendable { } let rustDescriptor = descriptors[0] let relayDescriptor = descriptors[1] - let connectionBox = ConnectionBox(connection) + relayCompletion.enter() + let completion = relayCompletion + let session = VsockUnixRelay.RelaySession( + client: relayDescriptor, + connection: connection, + completion: { completion.leave() } + ) + relaySession = session + if let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ session.requestStop() }) { + session.requestStop() + } Thread.detachNewThread { - VsockUnixRelay.serve(client: relayDescriptor, connection: connectionBox.connection) + session.run() } do { @@ -193,7 +227,7 @@ public final class AgentChannel: @unchecked Sendable { // Rust owns/closes its socketpair fd on every return path, but a read EOF is only a // half-close to the generic stream relay. Explicitly reset the vsock side so its other // pump cannot wait forever on a silent guest after handshake timeout/failure. - connection.close() + session.requestStop() throw error } } @@ -202,17 +236,23 @@ public final class AgentChannel: @unchecked Sendable { lock.lock() let unclaimedConnection = connection let activeRelayConnection = relayConnection + let activeRelaySession = relaySession let activeClient = client connection = nil relayConnection = nil + relaySession = nil client = nil lock.unlock() // Closing the Rust endpoint first lets a healthy guest observe EOF; resetting the in-process // vsock immediately afterwards deterministically wakes both detached relay pumps even if the // guest never acknowledges shutdown. activeClient?.close() + activeRelaySession?.requestStop() activeRelayConnection?.close() unclaimedConnection?.close() + if activeRelaySession != nil { + _ = relayCompletion.wait(timeout: .now() + 1) + } } } @@ -271,6 +311,40 @@ public struct ClockSyncResult: Equatable, Sendable { } } +public struct VirtioFSMountRequest: Equatable, Sendable { + public var tag: String + public var mountPath: String + public var readOnly: Bool + + public init(tag: String, mountPath: String, readOnly: Bool) { + self.tag = tag + self.mountPath = mountPath + self.readOnly = readOnly + } +} + +public struct VirtioFSMountReceipt: Equatable, Sendable { + public var tag: String + public var mountPath: String + public var readOnly: Bool + public var alreadyMounted: Bool + public var mountID: UInt64 + + public init( + tag: String, + mountPath: String, + readOnly: Bool, + alreadyMounted: Bool, + mountID: UInt64 + ) { + self.tag = tag + self.mountPath = mountPath + self.readOnly = readOnly + self.alreadyMounted = alreadyMounted + self.mountID = mountID + } +} + public struct AgentListenPort: Equatable, Sendable { public var `protocol`: String public var port: UInt16 diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift index 8dcae77c..18d829c6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift @@ -32,18 +32,55 @@ public struct ForwardPreamble: Equatable, Sendable { /// preamble size — the only dialer is our own dataplane, so anything else is a protocol error, /// not something to tolerate. public static func read(from fd: Int32) -> ForwardPreamble? { - guard let lengthBytes = readExactly(4, from: fd) else { return nil } + read(from: fd, deadline: nil) + } + + /// Applies one monotonic deadline to the complete length+body frame. A peer sending one byte + /// before each socket receive timeout therefore cannot retain an admission slot indefinitely. + static func read(from fd: Int32, timeout: TimeInterval) -> ForwardPreamble? { + read( + from: fd, + deadline: ProcessInfo.processInfo.systemUptime + max(0, timeout) + ) + } + + private static func read(from fd: Int32, deadline: TimeInterval?) -> ForwardPreamble? { + guard let lengthBytes = readExactly(4, from: fd, deadline: deadline) else { return nil } let length = UInt32(lengthBytes[0]) | (UInt32(lengthBytes[1]) << 8) | (UInt32(lengthBytes[2]) << 16) | (UInt32(lengthBytes[3]) << 24) guard length == UInt32(bodyByteCount) else { return nil } - guard let body = readExactly(bodyByteCount, from: fd) else { return nil } + guard let body = readExactly(bodyByteCount, from: fd, deadline: deadline) else { return nil } return decode(body) } - private static func readExactly(_ count: Int, from fd: Int32) -> [UInt8]? { + private static func readExactly( + _ count: Int, + from fd: Int32, + deadline: TimeInterval? + ) -> [UInt8]? { var bytes = [UInt8](repeating: 0, count: count) var offset = 0 while offset < count { + if let deadline { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + let requestedMilliseconds = min( + ceil(remaining * 1_000), + Double(Int32.max) + ) + var readiness = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll( + &readiness, + 1, + max(1, Int32(requestedMilliseconds)) + ) + if ready == 0 { return nil } + if ready < 0 { + if errno == EINTR { continue } + return nil + } + if readiness.revents & Int16(POLLNVAL | POLLERR) != 0 { return nil } + } let got = bytes.withUnsafeMutableBytes { raw in Darwin.read(fd, raw.baseAddress!.advanced(by: offset), count - offset) } @@ -67,14 +104,25 @@ public final class AgentVsockForward: @unchecked Sendable { private let socketPath: String private let guestCID: UInt32 private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener /// A dialer that connects but never completes the preamble would otherwise pin a thread forever. - private static let preambleTimeout = timeval(tv_sec: 10, tv_usec: 0) + private static let preambleTimeout: TimeInterval = 10 - public init(socketPath: String, guestCID: UInt32, log: @escaping @Sendable (String) -> Void = { _ in }) { + public init( + socketPath: String, + guestCID: UInt32, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { self.socketPath = socketPath self.guestCID = guestCID self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: 0o600, + endpointLabel: "agent vsock forward", + log: log + ) } /// The maximum UTF-8 byte length accepted by macOS for a filesystem Unix-domain socket path. @@ -85,61 +133,57 @@ public final class AgentVsockForward: @unchecked Sendable { try VsockUnixRelay.validateSocketPath(socketPath) } - private final class VsockBox: @unchecked Sendable { - let vsock: VirtioVsock - init(_ vsock: VirtioVsock) { self.vsock = vsock } - } - public func attach(to vsock: VirtioVsock) throws { - let listener = try VsockUnixRelay.makeListener(socketPath: socketPath, mode: 0o600) - let box = VsockBox(vsock) - let path = socketPath - let log = log - Thread.detachNewThread { [self] in - while true { - let client = accept(listener, nil, nil) - guard client >= 0 else { - if errno == EINTR { continue } - log("agent vsock forward accept failed on \(path): errno \(errno)") - break - } - var noSigpipe: Int32 = 1 - _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) - Thread.detachNewThread { - self.serve(client: client, box: box) - } + let expectedCID = guestCID + let logger = log + try listener.attach(to: vsock, service: .agentForward) { client in + guard let preamble = Self.readPreamble(client: client, log: logger) else { + return nil + } + guard preamble.direction == .hostToGuest else { + logger("agent vsock forward rejected a non-host-to-guest preamble") + return nil + } + guard preamble.cid == expectedCID else { + logger( + "agent vsock forward rejected cid \(preamble.cid) " + + "(guest is \(expectedCID))" + ) + return nil + } + do { + return try vsock.connectIfCapacity(port: preamble.port) + } catch { + logger( + "agent vsock forward rejected guest port \(preamble.port): \(error)" + ) + return nil } - close(listener) } log("agent vsock forward serving \(socketPath)") } - private func serve(client: Int32, box: VsockBox) { - guard let preamble = readPreamble(client: client) else { - close(client) - return - } - guard preamble.direction == .hostToGuest else { - log("agent vsock forward rejected a non-host-to-guest preamble") - close(client) - return - } - guard preamble.cid == guestCID else { - log("agent vsock forward rejected cid \(preamble.cid) (guest is \(guestCID))") - close(client) - return - } - VsockUnixRelay.serve(client: client, connection: box.vsock.connect(port: preamble.port)) + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) } - private func readPreamble(client: Int32) -> ForwardPreamble? { - var timeout = Self.preambleTimeout - _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) - defer { - var forever = timeval(tv_sec: 0, tv_usec: 0) - _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &forever, socklen_t(MemoryLayout.size)) - } - guard let preamble = ForwardPreamble.read(from: client) else { + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() + } + + private static func readPreamble( + client: Int32, + log: @Sendable (String) -> Void + ) -> ForwardPreamble? { + guard let preamble = ForwardPreamble.read( + from: client, + timeout: preambleTimeout + ) else { log("agent vsock forward dropped a connection with a malformed preamble") return nil } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift new file mode 100644 index 00000000..09111c3a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift @@ -0,0 +1,433 @@ +import Darwin +import Foundation + +protocol BoundedGuestVsockSession: AnyObject, Sendable { + func run() + func requestStop() +} + +protocol BoundedHostSocketConnectContext: AnyObject, Sendable { + func claimPendingDescriptor(_ descriptor: Int32) -> Bool + func releasePendingDescriptor(_ descriptor: Int32) + var shouldCancelConnect: Bool { get } +} + +/// A bounded connector can also be exercised outside a bridge session (for validation and focused +/// tests). There is no external cancellation in that case, but the same monotonic deadline remains +/// mandatory and the connector remains the sole descriptor owner until it returns success. +final class UncancelledHostSocketConnectContext: BoundedHostSocketConnectContext, + @unchecked Sendable +{ + func claimPendingDescriptor(_ descriptor: Int32) -> Bool { true } + func releasePendingDescriptor(_ descriptor: Int32) {} + var shouldCancelConnect: Bool { false } +} + +/// One bounded lifecycle for services initiated by an untrusted guest vsock request. +/// +/// Registrations are published all-or-nothing after a one-shot attach reservation. Listener +/// callbacks hold this object weakly; the lifecycle owns only idempotent unregister closures and +/// admitted sessions, so neither VirtioVsock nor a registration token can retain the bridge. +final class BoundedGuestVsockServiceLifecycle: @unchecked Sendable { + private static let maximumStopWait: TimeInterval = 5 + + private let lock = NSLock() + private let attachmentCompletion = DispatchGroup() + private let sessionCompletion = DispatchGroup() + private let endpointLabel: String + private let log: @Sendable (String) -> Void + private var attachmentReserved = false + private var attachmentCompleted = false + private var terminal = false + private var unregisterActions = [@Sendable () -> Void]() + private var sessionReservations = Set() + private var sessions = [UUID: any BoundedGuestVsockSession]() + private var admissionSnapshotProvider: + (@Sendable () -> VirtioVsockServiceAdmissionSnapshot?)? + + init( + endpointLabel: String, + log: @escaping @Sendable (String) -> Void + ) { + self.endpointLabel = endpointLabel + self.log = log + } + + func beginAttachment(to vsock: VirtioVsock) throws { + lock.lock() + defer { lock.unlock() } + guard !terminal, + !attachmentReserved, + !attachmentCompleted, + unregisterActions.isEmpty else { + throw VMError.invalidConfiguration( + "\(endpointLabel) is already attached, attaching, or stopped" + ) + } + admissionSnapshotProvider = { [weak vsock] in + vsock?.serviceAdmissionSnapshot + } + attachmentReserved = true + attachmentCompletion.enter() + } + + /// Commits already-created listener registrations. Returns false only when a concurrent stop + /// won; in that case every registration is closed before attachment completion is published. + func commitAttachment( + unregister: [@Sendable () -> Void] + ) -> Bool { + lock.lock() + guard attachmentReserved, unregisterActions.isEmpty else { + let shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + for action in unregister { action() } + if shouldLeave { attachmentCompletion.leave() } + return false + } + attachmentReserved = false + if terminal { + lock.unlock() + for action in unregister { action() } + attachmentCompletion.leave() + return false + } + attachmentCompleted = true + unregisterActions = unregister + lock.unlock() + attachmentCompletion.leave() + return true + } + + func cancelAttachment(unregister: [@Sendable () -> Void]) { + for action in unregister { action() } + let shouldLeave: Bool + lock.lock() + shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { attachmentCompletion.leave() } + } + + func admit( + _ connection: VsockConnection, + makeSession: ( + _ connection: VsockConnection, + _ completion: @escaping @Sendable () -> Void + ) -> any BoundedGuestVsockSession + ) { + guard let token = reserveSession() else { + connection.close() + return + } + let session = makeSession(connection) { [self] in + finishSession(token: token) + } + let admitted = publishSession(session, token: token) + if admitted, + let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ session.requestStop() }) { + // Reset/quiesce won after transport admission but before the service owner published. + session.requestStop() + } else if !admitted { + session.requestStop() + } + Thread.detachNewThread { session.run() } + } + + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + let registrations: [@Sendable () -> Void] + let activeSessions: [any BoundedGuestVsockSession] + lock.lock() + terminal = true + registrations = unregisterActions + unregisterActions.removeAll() + activeSessions = Array(sessions.values) + lock.unlock() + + // Unregister first so no new callback can start after the session snapshot. A callback + // already in flight sees terminal admission and closes its connection exactly once. + for unregister in registrations { unregister() } + for session in activeSessions { session.requestStop() } + + let deadline = DispatchTime.now() + boundedTimeout + let attachmentFinished = attachmentCompletion.wait(timeout: deadline) == .success + let sessionsFinished = sessionCompletion.wait(timeout: deadline) == .success + if !attachmentFinished || !sessionsFinished { + log( + "\(endpointLabel) teardown did not drain within \(boundedTimeout) seconds" + ) + } + } + + var activeSessionCount: Int { + lock.lock() + defer { lock.unlock() } + return sessionReservations.count + sessions.count + } + + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lock.lock() + let provider = admissionSnapshotProvider + lock.unlock() + guard let provider else { return nil } + return provider() + } + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return terminal + } + + deinit { + stop() + } + + private func reserveSession() -> UUID? { + lock.lock() + defer { lock.unlock() } + guard !terminal else { return nil } + let token = UUID() + sessionReservations.insert(token) + sessionCompletion.enter() + return token + } + + private func publishSession( + _ session: any BoundedGuestVsockSession, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard sessionReservations.remove(token) != nil else { return false } + sessions[token] = session + return !terminal + } + + private func finishSession(token: UUID) { + let owned: Bool + lock.lock() + owned = sessions.removeValue(forKey: token) != nil + lock.unlock() + if owned { sessionCompletion.leave() } + } +} + +/// Connects one admitted guest stream to a host socket, then delegates the established stream to +/// RelaySession. The connector borrows this object to publish its pending descriptor before any +/// nonblocking connect/poll; stop may shutdown it, but the connector remains its sole close owner +/// until ownership transfers to RelaySession. +final class GuestVsockHostSocketRelaySession: BoundedGuestVsockSession, + BoundedHostSocketConnectContext, + @unchecked Sendable +{ + typealias Connector = @Sendable (GuestVsockHostSocketRelaySession) -> Int32? + + private let lock = NSLock() + private let guestConnection: VsockConnection + private let connector: Connector + private var completion: (@Sendable () -> Void)? + private var pendingDescriptor: Int32? + private var relay: VsockUnixRelay.RelaySession? + private var started = false + private var stopRequested = false + private var finished = false + + init( + connection: VsockConnection, + connector: @escaping Connector, + completion: @escaping @Sendable () -> Void + ) { + self.guestConnection = connection + self.connector = connector + self.completion = completion + } + + func run() { + lock.lock() + guard !started else { + lock.unlock() + return + } + started = true + let shouldConnect = !stopRequested + lock.unlock() + guard shouldConnect, let descriptor = connector(self) else { + finish() + return + } + + let relay = VsockUnixRelay.RelaySession( + client: descriptor, + connection: guestConnection + ) + lock.lock() + guard pendingDescriptor == descriptor else { + lock.unlock() + relay.discardBeforeStart() + finish() + return + } + pendingDescriptor = nil + self.relay = relay + let shouldRelay = !stopRequested + lock.unlock() + + if shouldRelay { + relay.run() + } else { + relay.discardBeforeStart() + } + finish() + } + + func requestStop() { + lock.lock() + guard !stopRequested else { + lock.unlock() + return + } + stopRequested = true + if let pendingDescriptor { + // Connector remains close owner. The short poll quantum below provides the bounded wake + // even on Darwin sockets for which shutdown-before-connect returns ENOTCONN. + _ = shutdown(pendingDescriptor, SHUT_RDWR) + } + relay?.requestStop() + guestConnection.close() + lock.unlock() + } + + func claimPendingDescriptor(_ descriptor: Int32) -> Bool { + lock.lock() + defer { lock.unlock() } + guard !stopRequested, pendingDescriptor == nil, relay == nil else { return false } + pendingDescriptor = descriptor + return true + } + + func releasePendingDescriptor(_ descriptor: Int32) { + lock.lock() + if pendingDescriptor == descriptor { pendingDescriptor = nil } + lock.unlock() + } + + var shouldCancelConnect: Bool { + lock.lock() + defer { lock.unlock() } + return stopRequested + } + + private func finish() { + let orphanedDescriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + orphanedDescriptor = pendingDescriptor + pendingDescriptor = nil + relay = nil + callback = completion + completion = nil + guestConnection.close() + if let orphanedDescriptor { close(orphanedDescriptor) } + lock.unlock() + callback?() + } +} + +enum BoundedHostSocketConnector { + private static let connectPollQuantumMilliseconds: Int32 = 50 + private static let maximumConnectTimeout: TimeInterval = 30 + + static func connect( + domain: Int32, + timeout: TimeInterval, + context: any BoundedHostSocketConnectContext, + initiate: (Int32) -> Int32, + verify: (Int32) -> Bool = { _ in true } + ) -> Int32? { + let descriptor = socket(domain, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + let originalFlags = fcntl(descriptor, F_GETFL) + var noSigpipe: Int32 = 1 + guard originalFlags >= 0, + fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0, + setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0, + fcntl(descriptor, F_SETFL, originalFlags | O_NONBLOCK) == 0, + context.claimPendingDescriptor(descriptor) else { + close(descriptor) + return nil + } + + var transferred = false + defer { + if !transferred { + context.releasePendingDescriptor(descriptor) + close(descriptor) + } + } + + let connected = initiate(descriptor) + if connected != 0 { + guard errno == EINPROGRESS else { return nil } + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), maximumConnectTimeout) + : 0 + let deadline = ProcessInfo.processInfo.systemUptime + boundedTimeout + while true { + guard !context.shouldCancelConnect else { return nil } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + let milliseconds = min( + connectPollQuantumMilliseconds, + max(1, Int32(ceil(remaining * 1_000))) + ) + var readiness = pollfd( + fd: descriptor, + events: Int16(POLLOUT), + revents: 0 + ) + let result = poll(&readiness, 1, milliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + return nil + } + if readiness.revents & Int16(POLLNVAL) != 0 { return nil } + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &socketErrorLength + ) == 0, socketError == 0 else { + return nil + } + break + } + } + guard !context.shouldCancelConnect, + verify(descriptor), + fcntl(descriptor, F_SETFL, originalFlags) == 0 else { + return nil + } + transferred = true + return descriptor + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift new file mode 100644 index 00000000..c5d8ff1e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift @@ -0,0 +1,386 @@ +import Darwin +import Foundation + +/// One owned Unix listener with bounded, cancel-safe unix-to-vsock sessions. +/// +/// Attachment is one-shot and reserved before any pathname mutation. Every accepted descriptor +/// consumes admission capacity before its optional protocol preparation starts; preparation and +/// established relay use the same `RelaySession`, so stop can wake either phase without transferring +/// descriptor-close authority between threads. +final class BoundedVsockSocketListener: @unchecked Sendable { + private static let maximumStopWait: TimeInterval = 5 + + private let socketPath: String + private let mode: mode_t? + private let endpointLabel: String + private let log: @Sendable (String) -> Void + private let lifetime: Lifetime + private let admissionLock = NSLock() + private var admissionSnapshotProvider: + (@Sendable () -> VirtioVsockServiceAdmissionSnapshot?)? + + init( + socketPath: String, + mode: mode_t?, + endpointLabel: String, + log: @escaping @Sendable (String) -> Void + ) { + self.socketPath = socketPath + self.mode = mode + self.endpointLabel = endpointLabel + self.log = log + self.lifetime = Lifetime() + } + + func attach( + to vsock: VirtioVsock, + service: VirtioVsockService, + prepareConnection: @escaping @Sendable (Int32) -> VsockConnection? + ) throws { + // Reserve before makeOwnedListener's stale-path unlink. Repeat/concurrent/post-stop attach + // therefore fails without mutating a currently published endpoint. + guard lifetime.reserveAttachment() else { + throw VMError.invalidConfiguration( + "\(endpointLabel) listener is already attached, attaching, or stopped" + ) + } + admissionLock.lock() + admissionSnapshotProvider = { [weak vsock] in + vsock?.serviceAdmissionSnapshot + } + admissionLock.unlock() + var reservationActive = true + do { + let listener = try VsockUnixRelay.makeOwnedListener( + socketPath: socketPath, + mode: mode + ) + guard VsockUnixRelay.makeNonBlocking(listener.descriptor) else { + let code = errno + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + throw UnixSocketListenerError.systemCall( + operation: "make nonblocking", + path: socketPath, + code: code + ) + } + let publication = lifetime.publishListener(listener) + reservationActive = false + switch publication { + case .serving: + break + case .stopped: + lifetime.finishListener(listener, socketPath: socketPath) + throw VMError.invalidConfiguration( + "\(endpointLabel) listener was stopped while attaching" + ) + case .rejected: + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + throw VMError.invalidConfiguration( + "\(endpointLabel) listener publication was rejected" + ) + } + + let path = socketPath + let label = endpointLabel + let logger = log + let state = lifetime + Thread.detachNewThread { + defer { state.finishListener(listener, socketPath: path) } + while true { + guard !state.isStopping else { break } + var readiness = pollfd( + fd: listener.descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let pollResult = poll(&readiness, 1, 100) + if pollResult == 0 { continue } + if pollResult < 0 { + if errno == EINTR { continue } + if !state.isStopping { + logger("\(label) listener poll failed on \(path): errno \(errno)") + } + break + } + guard !state.isStopping else { break } + if readiness.revents & Int16(POLLNVAL | POLLERR | POLLHUP) != 0 { + if !state.isStopping { + logger("\(label) listener became unavailable on \(path)") + } + break + } + guard readiness.revents & Int16(POLLIN) != 0 else { continue } + + let client = accept(listener.descriptor, nil, nil) + guard client >= 0 else { + if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { + continue + } + if !state.isStopping { + logger("\(label) accept failed on \(path): errno \(errno)") + } + break + } + guard !state.isStopping else { + close(client) + break + } + guard Self.configureAcceptedClient(client) else { + close(client) + continue + } + guard let token = state.reserveSession() else { + close(client) + continue + } + let serviceReservation: VirtioVsockServiceReservation + do { + serviceReservation = try vsock.reserveServiceSession(service) + } catch { + state.cancelSessionReservation(token: token) + close(client) + logger("\(label) rejected \(service.rawValue) session: \(error)") + continue + } + let session = VsockUnixRelay.RelaySession( + client: client, + prepareConnection: prepareConnection, + completion: { [state] in state.finishSession(token: token) } + ) + guard state.publishSession(session, token: token) else { + vsock.cancelServiceSession(serviceReservation) + session.discardBeforeStart() + continue + } + guard let serviceLease = vsock.publishServiceSession( + serviceReservation, + requestStop: { session.requestStop() } + ) else { + session.discardBeforeStart() + continue + } + guard state.bindServiceLease(serviceLease, token: token) else { + serviceLease.close() + session.discardBeforeStart() + continue + } + Thread.detachNewThread { session.run() } + } + } + } catch { + if reservationActive { lifetime.cancelAttachmentReservation() } + throw error + } + } + + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + guard lifetime.stop(timeout: boundedTimeout) else { + log( + "\(endpointLabel) teardown did not drain within " + + "\(boundedTimeout) seconds on \(socketPath)" + ) + return + } + } + + var activeSessionCount: Int { lifetime.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + admissionLock.lock() + let provider = admissionSnapshotProvider + admissionLock.unlock() + guard let provider else { return nil } + return provider() + } + + deinit { + stop() + } + + private static func configureAcceptedClient(_ descriptor: Int32) -> Bool { + let statusFlags = fcntl(descriptor, F_GETFL) + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags & ~O_NONBLOCK) == 0, + fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0, + getpeereid(descriptor, &peerUID, &peerGID) == 0, + peerUID == geteuid() else { + return false + } + var noSigpipe: Int32 = 1 + return setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 + } + + private final class Lifetime: @unchecked Sendable { + enum ListenerPublication { + case serving + case stopped + case rejected + } + + private let lock = NSLock() + private let listenerCompletion = DispatchGroup() + private let sessionCompletion = DispatchGroup() + private var attachmentReserved = false + private var listener: VsockUnixRelay.OwnedListener? + private var terminal = false + private var sessionReservations = Set() + private struct SessionRecord { + let relay: VsockUnixRelay.RelaySession + var serviceLease: VirtioVsockServiceLease? + } + private var sessions = [UUID: SessionRecord]() + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return terminal + } + + var activeSessionCount: Int { + lock.lock() + defer { lock.unlock() } + return sessionReservations.count + sessions.count + } + + func reserveAttachment() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !terminal, !attachmentReserved, listener == nil else { return false } + attachmentReserved = true + listenerCompletion.enter() + return true + } + + func cancelAttachmentReservation() { + let shouldLeave: Bool + lock.lock() + shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { listenerCompletion.leave() } + } + + func publishListener( + _ listener: VsockUnixRelay.OwnedListener + ) -> ListenerPublication { + lock.lock() + guard attachmentReserved, self.listener == nil else { + let shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { listenerCompletion.leave() } + return .rejected + } + attachmentReserved = false + self.listener = listener + let publication: ListenerPublication = terminal ? .stopped : .serving + lock.unlock() + return publication + } + + func finishListener( + _ listener: VsockUnixRelay.OwnedListener, + socketPath: String + ) { + let owned: Bool + let activeSessions: [VsockUnixRelay.RelaySession] + lock.lock() + owned = self.listener?.descriptor == listener.descriptor + && self.listener?.pathIdentity == listener.pathIdentity + if owned { self.listener = nil } + terminal = true + activeSessions = sessions.values.map(\.relay) + lock.unlock() + guard owned else { return } + + // Retire pathname ownership before publishing listener completion. A successor can bind + // immediately after stop returns without a stale cleanup deleting its socket. + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + for session in activeSessions { session.requestStop() } + listenerCompletion.leave() + } + + func reserveSession() -> UUID? { + lock.lock() + defer { lock.unlock() } + guard !terminal else { return nil } + let token = UUID() + sessionReservations.insert(token) + sessionCompletion.enter() + return token + } + + func cancelSessionReservation(token: UUID) { + let owned: Bool + lock.lock() + owned = sessionReservations.remove(token) != nil + lock.unlock() + if owned { sessionCompletion.leave() } + } + + func publishSession( + _ session: VsockUnixRelay.RelaySession, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard sessionReservations.remove(token) != nil else { return false } + sessions[token] = SessionRecord(relay: session, serviceLease: nil) + return !terminal + } + + func bindServiceLease( + _ lease: VirtioVsockServiceLease, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard var record = sessions[token], record.serviceLease == nil else { + return false + } + record.serviceLease = lease + sessions[token] = record + return true + } + + func finishSession(token: UUID) { + let record: SessionRecord? + lock.lock() + record = sessions.removeValue(forKey: token) + lock.unlock() + if let record { + record.serviceLease?.close() + sessionCompletion.leave() + } + } + + func stop(timeout: TimeInterval) -> Bool { + let activeSessions: [VsockUnixRelay.RelaySession] + lock.lock() + terminal = true + if let listener { + // Wake poll without closing; the listener thread remains the sole descriptor closer. + _ = shutdown(listener.descriptor, SHUT_RDWR) + } + activeSessions = sessions.values.map(\.relay) + lock.unlock() + for session in activeSessions { session.requestStop() } + + let deadline = DispatchTime.now() + timeout + let listenerFinished = listenerCompletion.wait(timeout: deadline) == .success + let sessionsFinished = sessionCompletion.wait(timeout: deadline) == .success + return listenerFinished && sessionsFinished + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift deleted file mode 100644 index c276d1e5..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift +++ /dev/null @@ -1,315 +0,0 @@ -import Darwin -import Foundation -import Hypervisor - -#if arch(arm64) -/// Track 1.7 DAX go/no-go: proves that a file-backed host mmap mapped into guest physical memory with -/// hv_vm_map stays coherent in both directions. The host writes a pattern into a MAP_SHARED file, maps -/// it at the DAX guest-physical base, and runs a three-instruction guest that reads the pattern and -/// writes a marker back. Success means guest reads see host writes AND host (plus the on-disk file) -/// sees the guest write, the exact property FUSE_SETUPMAPPING relies on. Requires the -/// com.apple.security.hypervisor entitlement; run as a signed helper, not a plain unit test. -public enum DaxCoherenceProbe { - private static let hostPattern: UInt32 = 0xDEAD_BEEF - private static let guestMarker: UInt32 = 0xCAFE_BABE - - public static func run(daxGuestBase: UInt64 = GuestLayout.daxWindowBase) throws -> String { - let mapBytes = Int(DaxWindow.pageSize) - let path = NSTemporaryDirectory() + "dory-dax-probe-\(getpid())" - let fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600) - guard fd >= 0 else { throw VMError.invalidConfiguration("dax probe: open failed errno \(errno)") } - defer { close(fd); unlink(path) } - guard ftruncate(fd, off_t(mapBytes)) == 0 else { - throw VMError.invalidConfiguration("dax probe: ftruncate failed errno \(errno)") - } - guard let fileRegion = mmap(nil, mapBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0), - fileRegion != MAP_FAILED else { - throw VMError.outOfMemory("dax probe: mmap failed errno \(errno)") - } - defer { munmap(fileRegion, mapBytes) } - fileRegion.storeBytes(of: hostPattern.littleEndian, toByteOffset: 0, as: UInt32.self) - - try hvCreateVM() - defer { hv_vm_destroy() } - - let ramBase = GuestLayout.ramBase - let memory = try GuestMemory(guestBase: ramBase, size: UInt64(DaxWindow.pageSize)) - try memory.mapIntoGuest() - try memory.write(UInt32(0xB940_0020), at: ramBase) // ldr w0, [x1] - try memory.write(UInt32(0xB900_0022), at: ramBase + 4) // str w2, [x1] - try memory.write(UInt32(0xD400_0002), at: ramBase + 8) // hvc #0 - - try hvCheck( - hv_vm_map(fileRegion, daxGuestBase, mapBytes, - hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), - "hv_vm_map(dax window)" - ) - - let vcpu = try VCPU() - try vcpu.write(HV_REG_CPSR, 0x3C5) - try vcpu.write(HV_REG_PC, ramBase) - try vcpu.write(HV_REG_X1, daxGuestBase) - try vcpu.write(HV_REG_X2, UInt64(guestMarker)) - - let event = try vcpu.run() - guard case .exception(let syndrome, _, _) = event, - ExceptionClass(syndrome: syndrome) == .hvc64 else { - throw VMError.unexpectedExit("dax probe: expected HVC trap, got \(event)") - } - - let guestRead = UInt32(truncatingIfNeeded: try vcpu.read(HV_REG_X0)) - guard guestRead == hostPattern else { - throw VMError.unexpectedExit( - "dax probe FAILED host->guest: guest read 0x\(String(guestRead, radix: 16)), expected 0x\(String(hostPattern, radix: 16))") - } - - let hostSeesGuestWrite = UInt32(littleEndian: fileRegion.load(fromByteOffset: 0, as: UInt32.self)) - guard hostSeesGuestWrite == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED guest->host: host mmap read 0x\(String(hostSeesGuestWrite, radix: 16)), expected 0x\(String(guestMarker, radix: 16))") - } - - _ = msync(fileRegion, mapBytes, MS_SYNC) - var onDisk: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &onDisk) { pread(fd, $0.baseAddress, 4, 0) } - guard UInt32(littleEndian: onDisk) == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED persistence: on-disk word 0x\(String(UInt32(littleEndian: onDisk), radix: 16)), expected 0x\(String(guestMarker, radix: 16))") - } - - return "dax coherence passed at base 0x\(String(daxGuestBase, radix: 16)): host->guest 0x\(String(hostPattern, radix: 16)) read by guest; guest->host 0x\(String(guestMarker, radix: 16)) visible in host mmap and on disk" - } -} -#else -public enum DaxCoherenceProbe { - private static let hostPattern: UInt32 = 0xDEAD_BEEF - private static let guestMarker: UInt32 = 0xCAFE_BABE - private static let codeAddress: UInt64 = 0x1000 - private static let pml4Address: UInt64 = 0x2000 - private static let pdptAddress: UInt64 = 0x3000 - private static let lowPDAddress: UInt64 = 0x4000 - private static let daxPDAddress: UInt64 = 0x5000 - private static let ramBytes: UInt64 = 2 << 20 - - public static func run(daxGuestBase: UInt64 = GuestLayout.daxWindowBase) throws -> String { - let mapBytes = Int(DaxWindow.pageSize) - guard daxGuestBase.isMultiple(of: UInt64(2 << 20)) else { - throw VMError.invalidConfiguration("x86 dax probe base must be 2 MiB aligned") - } - guard daxGuestBase >= ramBytes else { - throw VMError.invalidConfiguration("x86 dax probe base must not overlap probe RAM") - } - - let path = NSTemporaryDirectory() + "dory-dax-probe-\(getpid())" - let fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600) - guard fd >= 0 else { throw VMError.invalidConfiguration("dax probe: open failed errno \(errno)") } - defer { close(fd); unlink(path) } - guard ftruncate(fd, off_t(mapBytes)) == 0 else { - throw VMError.invalidConfiguration("dax probe: ftruncate failed errno \(errno)") - } - guard let fileRegion = mmap(nil, mapBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0), - fileRegion != MAP_FAILED else { - throw VMError.outOfMemory("dax probe: mmap failed errno \(errno)") - } - defer { munmap(fileRegion, mapBytes) } - fileRegion.storeBytes(of: hostPattern.littleEndian, toByteOffset: 0, as: UInt32.self) - - try hvCreateVM() - defer { hv_vm_destroy() } - - let memory = try GuestMemory(guestBase: 0, size: ramBytes) - try memory.mapIntoGuest() - try writeProbeCode(to: memory, daxGuestBase: daxGuestBase) - try writePageTables(to: memory, daxGuestBase: daxGuestBase) - - try hvCheck( - hv_vm_map(fileRegion, daxGuestBase, mapBytes, hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), - "hv_vm_map(dax window)" - ) - - let vcpu = try VCPU() - try configureLongMode(vcpu) - - guard case .vmExit(let state) = try vcpu.run() else { - throw VMError.unexpectedExit("x86 dax probe returned without a VM exit") - } - guard X86VMExitDecoder.decode(state) == .halt else { - throw VMError.unexpectedExit( - "x86 dax probe expected HLT exit, got reason \(state.reason) qualification 0x\(String(state.qualification, radix: 16))" - ) - } - - let guestRead = UInt32(truncatingIfNeeded: try vcpu.read(HV_X86_RAX)) - guard guestRead == hostPattern else { - throw VMError.unexpectedExit( - "dax probe FAILED host->guest: guest read 0x\(String(guestRead, radix: 16)), expected 0x\(String(hostPattern, radix: 16))" - ) - } - - let hostSeesGuestWrite = UInt32(littleEndian: fileRegion.load(fromByteOffset: 0, as: UInt32.self)) - guard hostSeesGuestWrite == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED guest->host: host mmap read 0x\(String(hostSeesGuestWrite, radix: 16)), expected 0x\(String(guestMarker, radix: 16))" - ) - } - - _ = msync(fileRegion, mapBytes, MS_SYNC) - var onDisk: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &onDisk) { pread(fd, $0.baseAddress, 4, 0) } - guard UInt32(littleEndian: onDisk) == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED persistence: on-disk word 0x\(String(UInt32(littleEndian: onDisk), radix: 16)), expected 0x\(String(guestMarker, radix: 16))" - ) - } - - return "dax coherence passed at base 0x\(String(daxGuestBase, radix: 16)): host->guest 0x\(String(hostPattern, radix: 16)) read by guest; guest->host 0x\(String(guestMarker, radix: 16)) visible in host mmap and on disk" - } - - private static func writeProbeCode(to memory: GuestMemory, daxGuestBase: UInt64) throws { - var code: [UInt8] = [0x48, 0xBB] // movabs rbx, imm64 - code.append(contentsOf: littleEndianBytes(daxGuestBase)) - code.append(contentsOf: [0x8B, 0x03]) // mov eax, dword ptr [rbx] - code.append(0xBA) // mov edx, imm32 - code.append(contentsOf: littleEndianBytes(guestMarker)) - code.append(contentsOf: [0x89, 0x13]) // mov dword ptr [rbx], edx - code.append(0xF4) // hlt - try memory.write(code, at: codeAddress) - } - - private static func writePageTables(to memory: GuestMemory, daxGuestBase: UInt64) throws { - let presentWrite: UInt64 = 0x003 - let hugePresentWrite: UInt64 = 0x083 - let pml4Index = pageTableIndex(daxGuestBase, shift: 39) - let pdptIndex = pageTableIndex(daxGuestBase, shift: 30) - let pdIndex = pageTableIndex(daxGuestBase, shift: 21) - - if pml4Index != 0 { - throw VMError.invalidConfiguration("x86 dax probe base must be below 512 GiB") - } - try memory.write(pml4Entry(pdptAddress, flags: presentWrite), at: pml4Address) - - try memory.write(pml4Entry(lowPDAddress, flags: presentWrite), at: pdptAddress) - try memory.write(hugePageEntry(0, flags: hugePresentWrite), at: lowPDAddress) - - if pdptIndex == 0 { - try memory.write(hugePageEntry(daxGuestBase, flags: hugePresentWrite), at: lowPDAddress + pdIndex * 8) - } else { - try memory.write(pml4Entry(daxPDAddress, flags: presentWrite), at: pdptAddress + pdptIndex * 8) - try memory.write(hugePageEntry(daxGuestBase, flags: hugePresentWrite), at: daxPDAddress + pdIndex * 8) - } - } - - private static func configureLongMode(_ vcpu: VCPU) throws { - try vcpu.write(HV_X86_RIP, codeAddress) - try vcpu.write(HV_X86_RFLAGS, 0x2) - try vcpu.write(HV_X86_RAX, 0) - try vcpu.write(HV_X86_RBX, 0) - try vcpu.write(HV_X86_RDX, 0) - try vcpu.write(HV_X86_RSP, 0x8000) - try vcpu.write(HV_X86_CR3, pml4Address) - try vcpu.write(HV_X86_CR4, 1 << 5) // PAE - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IA32_EFER), 0x500) // LME | LMA - try vcpu.write(HV_X86_CR0, (1 << 31) | 0x21) // PG | PE | NE - - try writeControl(vcpu, field: UInt32(VMCS_CTRL_PIN_BASED), requested: 0) - try writeControl( - vcpu, - field: UInt32(VMCS_CTRL_CPU_BASED), - requested: UInt32(CPU_BASED_HLT | CPU_BASED_SECONDARY_CTLS) - ) - try writeControl( - vcpu, - field: UInt32(VMCS_CTRL_CPU_BASED2), - requested: UInt32(CPU_BASED2_EPT | CPU_BASED2_UNRESTRICTED) - ) - try writeControl(vcpu, field: UInt32(VMCS_CTRL_VMEXIT_CONTROLS), requested: 0) - try writeControl(vcpu, field: UInt32(VMCS_CTRL_VMENTRY_CONTROLS), requested: UInt32(VMENTRY_LOAD_EFER)) - - try writeSegment( - vcpu, - selectorField: UInt32(VMCS_GUEST_CS), - baseField: UInt32(VMCS_GUEST_CS_BASE), - limitField: UInt32(VMCS_GUEST_CS_LIMIT), - accessField: UInt32(VMCS_GUEST_CS_AR), - selector: 0x08, - accessRights: 0xA09B - ) - for (selectorField, baseField, limitField, accessField) in [ - (VMCS_GUEST_SS, VMCS_GUEST_SS_BASE, VMCS_GUEST_SS_LIMIT, VMCS_GUEST_SS_AR), - (VMCS_GUEST_DS, VMCS_GUEST_DS_BASE, VMCS_GUEST_DS_LIMIT, VMCS_GUEST_DS_AR), - (VMCS_GUEST_ES, VMCS_GUEST_ES_BASE, VMCS_GUEST_ES_LIMIT, VMCS_GUEST_ES_AR), - (VMCS_GUEST_FS, VMCS_GUEST_FS_BASE, VMCS_GUEST_FS_LIMIT, VMCS_GUEST_FS_AR), - (VMCS_GUEST_GS, VMCS_GUEST_GS_BASE, VMCS_GUEST_GS_LIMIT, VMCS_GUEST_GS_AR), - ] { - try writeSegment( - vcpu, - selectorField: UInt32(selectorField), - baseField: UInt32(baseField), - limitField: UInt32(limitField), - accessField: UInt32(accessField), - selector: 0x10, - accessRights: 0xC093 - ) - } - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_LIMIT), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_AR), 0x1_0000) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR), 0x18) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_LIMIT), 0x67) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_AR), 0x8B) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_GDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_GDTR_LIMIT), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IDTR_LIMIT), 0x3FF) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_ACTIVITY_STATE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_INTERRUPTIBILITY), 0) - } - - private static func writeControl(_ vcpu: VCPU, field: UInt32, requested: UInt32) throws { - var requiredOne: UInt64 = 0 - var allowedOne: UInt64 = 0 - try hvCheck( - hv_vmx_vcpu_get_cap_write_vmcs(vcpu.handle, field, &requiredOne, &allowedOne), - "hv_vmx_vcpu_get_cap_write_vmcs" - ) - try vcpu.writeVMCS(field, (UInt64(requested) | requiredOne) & allowedOne) - } - - private static func writeSegment( - _ vcpu: VCPU, - selectorField: UInt32, - baseField: UInt32, - limitField: UInt32, - accessField: UInt32, - selector: UInt16, - accessRights: UInt64 - ) throws { - try vcpu.writeVMCS(selectorField, UInt64(selector)) - try vcpu.writeVMCS(baseField, 0) - try vcpu.writeVMCS(limitField, 0xFFFF_FFFF) - try vcpu.writeVMCS(accessField, accessRights) - } - - private static func pml4Entry(_ address: UInt64, flags: UInt64) -> UInt64 { - (address & 0x000F_FFFF_FFFF_F000) | flags - } - - private static func hugePageEntry(_ address: UInt64, flags: UInt64) -> UInt64 { - (address & 0x000F_FFFF_FFE0_0000) | flags - } - - private static func pageTableIndex(_ address: UInt64, shift: UInt64) -> UInt64 { - (address >> shift) & 0x1FF - } - - private static func littleEndianBytes(_ value: UInt64) -> [UInt8] { - withUnsafeBytes(of: value.littleEndian) { Array($0) } - } - - private static func littleEndianBytes(_ value: UInt32) -> [UInt8] { - withUnsafeBytes(of: value.littleEndian) { Array($0) } - } -} -#endif diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift index 5e187976..d3c68c4a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift @@ -1,4 +1,3 @@ -import Darwin import Foundation /// Serves the engine's docker socket (`engine.sock`) from dory-hv itself, relaying every connection @@ -12,10 +11,20 @@ import Foundation public final class DockerSocketBridge: @unchecked Sendable { private let socketPath: String private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener - public init(socketPath: String, log: @escaping @Sendable (String) -> Void = { _ in }) { + public init( + socketPath: String, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { self.socketPath = socketPath self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: nil, + endpointLabel: "docker socket bridge", + log: log + ) } /// Lets the engine reject an impossible Docker endpoint before it creates disks or sidecars. @@ -23,38 +32,29 @@ public final class DockerSocketBridge: @unchecked Sendable { try VsockUnixRelay.validateSocketPath(socketPath) } - private final class VsockBox: @unchecked Sendable { - let vsock: VirtioVsock - init(_ vsock: VirtioVsock) { self.vsock = vsock } - } - - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - init(_ connection: VsockConnection) { self.connection = connection } - } - public func attach(to vsock: VirtioVsock) throws { - let listener = try VsockUnixRelay.makeListener(socketPath: socketPath) - let box = VsockBox(vsock) - let path = socketPath - let log = log - Thread.detachNewThread { - while true { - let client = accept(listener, nil, nil) - guard client >= 0 else { - if errno == EINTR { continue } - log("docker socket bridge accept failed on \(path): errno \(errno)") - break - } - var noSigpipe: Int32 = 1 - _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) - let connection = ConnectionBox(box.vsock.connect(port: VsockPorts.docker)) - Thread.detachNewThread { - VsockUnixRelay.serve(client: client, connection: connection.connection) - } + let logger = log + try listener.attach(to: vsock, service: .docker) { _ in + do { + return try vsock.connectIfCapacity(port: VsockPorts.docker) + } catch { + logger("docker socket bridge rejected guest dial: \(error)") + return nil } - close(listener) } log("docker socket bridge serving \(socketPath) over vsock:\(VsockPorts.docker)") } + + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) + } + + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift new file mode 100644 index 00000000..81ee9edf --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift @@ -0,0 +1,961 @@ +import Darwin +import DoryGuestMemoryShim +import DoryRendererWorkerContracts +import Foundation +import Metal + +public enum DoryRendererWorkerBrokerState: Equatable, Sendable { + case active + case interrupted + case invalidated + case protocolViolation + case outcomeUnknown +} + +/// Closed, non-sensitive location at which a command's foreign-renderer outcome became unknown. +/// These values are safe to surface in runner diagnostics: they contain no renderer-provided text, +/// host path, artifact identity, or guest-controlled payload bytes. +public enum DoryRendererWorkerCommandDiagnosticStage: Equatable, Sendable { + case workerServiceReply + case brokerCommandDeadline +} + +/// Closed terminal result paired with ``DoryRendererWorkerCommandDiagnosticStage``. +public enum DoryRendererWorkerCommandDiagnosticStatus: Equatable, Sendable { + case backendOutcomeUnknown + case deadlineExpired +} + +/// Minimal audit record for an uncertain command. Operation and request identity come from the +/// command admitted by this broker; elapsed time comes from the local monotonic clock. Deliberately +/// do not add arbitrary worker/foreign-library strings to this boundary. +public struct DoryRendererWorkerCommandDiagnostic: Equatable, Sendable { + public let operation: DoryRendererWorkerOperation + public let requestID: UInt64 + public let stage: DoryRendererWorkerCommandDiagnosticStage + public let status: DoryRendererWorkerCommandDiagnosticStatus + public let elapsedNanoseconds: UInt64 + + public init( + operation: DoryRendererWorkerOperation, + requestID: UInt64, + stage: DoryRendererWorkerCommandDiagnosticStage, + status: DoryRendererWorkerCommandDiagnosticStatus, + elapsedNanoseconds: UInt64 + ) { + self.operation = operation + self.requestID = requestID + self.stage = stage + self.status = status + self.elapsedNanoseconds = elapsedNanoseconds + } +} + +public enum DoryRendererWorkerBrokerError: Error, Equatable, Sendable { + case invalidBootstrap(DoryRendererWorkerContractError) + case invalidCommand(DoryRendererWorkerContractError) + case incompleteCapabilityReceipt + case notActive(DoryRendererWorkerBrokerState) + case requestIDExhausted + case inFlightLimit(limit: Int) + case aggregateReferencedBytesLimit(limit: UInt64, requested: UInt64) + case deadlineExpired + case deadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case inputDescriptorCountMismatch(expected: Int, actual: Int) + case invalidInputDescriptor(index: Int) + case workerRejected(DoryRendererWorkerRPCFailureCode) + case channelFailure(DoryRendererWorkerChannelFailure) + case channelFailureDuring( + requestID: UInt64, + operation: DoryRendererWorkerOperation, + failure: DoryRendererWorkerChannelFailure + ) + case malformedReply(DoryRendererWorkerContractError) + case replyIdentityMismatch + case invalidReplyDescriptor(index: Int) + case workerOutcomeUnknown(DoryRendererWorkerCommandDiagnostic) +} + +public struct DoryRendererWorkerFenceReceipt: @unchecked Sendable { + public let workerGeneration: DoryRendererWorkerGeneration + public let contextID: UInt32 + public let flags: UInt32 + public let ringIndex: UInt32 + public let fenceID: UInt64 + public let completionDescriptor: FileHandle +} + +public struct DoryRendererWorkerBlobMapping: @unchecked Sendable { + public let lease: DoryRendererBlobMappingLease + public let sharedMemoryDescriptor: FileHandle +} + +public struct DoryRendererWorkerScanout: @unchecked Sendable { + public let lease: DoryRendererScanoutLease + public let sharedMemoryDescriptor: FileHandle +} + +public struct DoryRendererWorkerSharedTextureScanout: @unchecked Sendable { + public let lease: DoryRendererSharedTextureScanoutLease + public let sharedTextureHandle: MTLSharedTextureHandle +} + +public enum DoryRendererWorkerCommandResult: @unchecked Sendable { + case acknowledged + case resourceCreated(generation: UInt64) + case blobMapping(DoryRendererWorkerBlobMapping) + case fence(DoryRendererWorkerFenceReceipt) + case scanout(DoryRendererWorkerScanout) + case sharedTextureScanout(DoryRendererWorkerSharedTextureScanout) + case reset(successorGeneration: UInt64) +} + +public struct DoryRendererWorkerBrokerSnapshot: Equatable, Sendable { + public let state: DoryRendererWorkerBrokerState + public let generation: DoryRendererWorkerGeneration + public let inFlightCommands: Int + public let maximumObservedInFlightCommands: Int + public let aggregateReferencedBytes: UInt64 + public let submittedBatches: UInt64 + public let controlBytes: UInt64 + public let descriptorBackedCommandBytes: UInt64 + public let rejectedAdmissions: UInt64 + public let protocolViolations: UInt64 + public let lateReplies: UInt64 + /// Accelerated presentation has no byte-copy API. This remains zero by construction. + public let scanoutCopyBytes: UInt64 +} + +private final class DoryRendererWorkerBrokerTerminalRelay: @unchecked Sendable { + typealias Handler = @Sendable ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + ) -> Void + + private let lock = NSLock() + private var terminal: (DoryRendererWorkerBrokerState, DoryRendererWorkerBrokerError)? + private var handlers = [Handler]() + + func install(_ handler: @escaping Handler) { + let immediate = lock.withLock { () -> ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + )? in + if let terminal { return terminal } + handlers.append(handler) + return nil + } + if let immediate { handler(immediate.0, immediate.1) } + } + + func publish( + state: DoryRendererWorkerBrokerState, + error: DoryRendererWorkerBrokerError + ) { + let delivery = lock.withLock { () -> [Handler] in + guard terminal == nil else { return [] } + terminal = (state, error) + let delivery = handlers + handlers.removeAll(keepingCapacity: false) + return delivery + } + for handler in delivery { handler(state, error) } + } +} + +private final class DoryRendererWorkerBootstrapReplyGate: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } +} + +/// VMM-side owner of one authenticated renderer-worker generation. +/// +/// Admission is asynchronous and bounded; the vCPU never performs a synchronous XPC round trip or +/// waits for GPU completion. `submit3D` bytes exist only in descriptor-backed regions. A command +/// deadline, malformed reply, helper interruption, or uncertain foreign outcome revokes the whole +/// generation because the broker cannot prove what renderer state the worker reached. +public actor DoryRendererWorkerBroker { + public static let maximumAdmissionDeadlineNanoseconds: UInt64 = 30_000_000_000 + public static let productionBootstrapTimeoutNanoseconds: UInt64 = 10_000_000_000 + + private struct PendingCommand { + let command: DoryRendererWorkerCommand + let admittedUptimeNanoseconds: UInt64 + let inputDescriptors: [FileHandle] + let referencedBytes: UInt64 + let continuation: CheckedContinuation + var timeoutTask: Task? + } + + /// Immutable bootstrap/receipt authority is safe to inspect without an actor hop. Mutable + /// command admission and channel lifecycle remain actor-isolated. + public nonisolated let bootstrap: DoryRendererWorkerBootstrap + public nonisolated let capabilityReceipt: DoryRendererCapabilityReceipt + + private let channel: any DoryRendererWorkerChannel + private nonisolated let terminalRelay = DoryRendererWorkerBrokerTerminalRelay() + private var state: DoryRendererWorkerBrokerState = .active + private var nextRequestID: UInt64 = 1 + private var pendingByRequestID = [UInt64: PendingCommand]() + private var aggregateReferencedBytes: UInt64 = 0 + private var maximumObservedInFlightCommands = 0 + private var submittedBatches: UInt64 = 0 + private var controlBytes: UInt64 = 0 + private var descriptorBackedCommandBytes: UInt64 = 0 + private var rejectedAdmissions: UInt64 = 0 + private var protocolViolations: UInt64 = 0 + private var lateReplies: UInt64 = 0 + + public init( + bootstrap: DoryRendererWorkerBootstrap, + capabilityReceipt: DoryRendererCapabilityReceipt, + channel: any DoryRendererWorkerChannel + ) throws { + guard capabilityReceipt.productionAccelerationIsAdmissible, + capabilityReceipt.workspaceID == bootstrap.workspaceID, + capabilityReceipt.generation == bootstrap.generation, + capabilityReceipt.sourceTuple == bootstrap.sourceTuple, + capabilityReceipt.producerFenceContract == bootstrap.producerFenceContract, + capabilityReceipt.candidateInventory == bootstrap.artifacts.candidateInventory, + capabilityReceipt.rendererWorkerExecutable + == bootstrap.artifacts.rendererWorkerExecutable else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.incompleteCapabilityReceipt + } + self.bootstrap = bootstrap + self.capabilityReceipt = capabilityReceipt + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + /// Performs one one-shot bootstrap and returns a broker only for a complete production receipt. + /// The exact bytes originate in the operation-bound launch authority; this method never derives + /// artifact identity from paths or environment variables. + public static func connect( + exactBootstrapBytes: Data, + timeoutNanoseconds: UInt64 = productionBootstrapTimeoutNanoseconds + ) async throws -> Self { + let bootstrap: DoryRendererWorkerBootstrap + do { + bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.invalidBootstrap(error) + } + // Decode before endpoint activation so the exact signed worker slice in this generation + // becomes the audit-token requirement. The stable service identifier is discovery only. + let channel = DoryRendererWorkerXPCChannel( + codeDirectoryHash: bootstrap.artifacts.rendererWorkerCodeDirectoryHash + ) + let receiptBytes: Data + do { + receiptBytes = try await performBootstrap( + channel: channel, + exactBytes: exactBootstrapBytes, + timeoutNanoseconds: timeoutNanoseconds + ) + } catch let failure as DoryRendererWorkerChannelFailure { + channel.invalidate() + throw DoryRendererWorkerBrokerError.channelFailure(failure) + } + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try DoryRendererCapabilityReceiptCodec.decode( + receiptBytes, + accepting: bootstrap + ) + } catch let error as DoryRendererWorkerContractError { + channel.invalidate() + throw DoryRendererWorkerBrokerError.invalidBootstrap(error) + } + return try Self( + bootstrap: bootstrap, + capabilityReceipt: receipt, + channel: channel + ) + } + + static func performBootstrap( + channel: any DoryRendererWorkerChannel, + exactBytes: Data, + timeoutNanoseconds: UInt64 + ) async throws -> Data { + guard timeoutNanoseconds > 0 else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.deadlineExpired + } + guard timeoutNanoseconds <= maximumAdmissionDeadlineNanoseconds else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.deadlineTooDistant( + limitNanoseconds: maximumAdmissionDeadlineNanoseconds, + actualNanoseconds: timeoutNanoseconds + ) + } + return try await withCheckedThrowingContinuation { continuation in + let gate = DoryRendererWorkerBootstrapReplyGate() + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + // Cancellation is also a terminal startup result. A completed reply has already + // claimed the gate, so its cancellation wakes this task harmlessly; inherited + // caller cancellation must not leave the checked continuation suspended forever. + guard gate.claim() else { return } + channel.invalidate() + continuation.resume( + throwing: DoryRendererWorkerBrokerError.deadlineExpired + ) + } + channel.bootstrap(exactBytes: exactBytes) { result in + guard gate.claim() else { return } + timeoutTask.cancel() + continuation.resume(with: result) + } + } + } + + /// Sends one typed operation. Shared region descriptors are duplicated at admission and kept + /// alive through the XPC reply, so caller-side close/reuse cannot change an admitted command. + public func execute( + operation: DoryRendererWorkerOperation, + contextID: UInt32 = 0, + resourceID: UInt32 = 0, + resourceGeneration: UInt64 = 0, + sharedRegions: [DoryRendererSharedRegionReference] = [], + descriptors: [FileHandle] = [], + payload: Data = Data(), + deadlineUptimeNanoseconds: UInt64 + ) async throws -> DoryRendererWorkerCommandResult { + guard state == .active else { throw reject(.notActive(state)) } + guard pendingByRequestID.count < bootstrap.limits.maximumInFlightCommands else { + throw reject(.inFlightLimit(limit: bootstrap.limits.maximumInFlightCommands)) + } + guard nextRequestID != 0 else { throw reject(.requestIDExhausted) } + let now = DispatchTime.now().uptimeNanoseconds + guard deadlineUptimeNanoseconds > now else { throw reject(.deadlineExpired) } + let remaining = deadlineUptimeNanoseconds - now + guard remaining <= Self.maximumAdmissionDeadlineNanoseconds else { + throw reject(.deadlineTooDistant( + limitNanoseconds: Self.maximumAdmissionDeadlineNanoseconds, + actualNanoseconds: remaining + )) + } + let requiredDescriptorCount = sharedRegions.isEmpty + ? 0 + : Int(sharedRegions.map(\.descriptorIndex).max()!) + 1 + guard descriptors.count == requiredDescriptorCount else { + throw reject(.inputDescriptorCountMismatch( + expected: requiredDescriptorCount, + actual: descriptors.count + )) + } + let referencedBytes = try Self.sumReferencedBytes(sharedRegions) + let (newAggregate, aggregateOverflow) = aggregateReferencedBytes + .addingReportingOverflow(referencedBytes) + guard !aggregateOverflow, + newAggregate <= bootstrap.limits.maximumReferencedBytes else { + throw reject(.aggregateReferencedBytesLimit( + limit: bootstrap.limits.maximumReferencedBytes, + requested: aggregateOverflow ? UInt64.max : newAggregate + )) + } + + let requestID = nextRequestID + let command: DoryRendererWorkerCommand + do { + command = try DoryRendererWorkerCommand( + generation: bootstrap.generation, + requestID: requestID, + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + sharedRegions: sharedRegions, + payload: payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw reject(.invalidCommand(error)) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicateAndValidate( + descriptors, + references: command.sharedRegions + ) + } catch let error as DoryRendererWorkerBrokerError { + throw reject(error) + } + let frame: Data + do { + frame = try DoryRendererWorkerCommandCodec.encode( + command, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + Self.close(ownedDescriptors) + throw reject(.invalidCommand(error)) + } + + nextRequestID = requestID == UInt64.max ? 0 : requestID + 1 + aggregateReferencedBytes = newAggregate + submittedBatches = Self.saturatingAdd(submittedBatches, 1) + controlBytes = Self.saturatingAdd(controlBytes, UInt64(frame.count)) + if operation == .submit3D { + descriptorBackedCommandBytes = Self.saturatingAdd( + descriptorBackedCommandBytes, + referencedBytes + ) + } + + return try await withCheckedThrowingContinuation { continuation in + pendingByRequestID[requestID] = PendingCommand( + command: command, + admittedUptimeNanoseconds: now, + inputDescriptors: ownedDescriptors, + referencedBytes: referencedBytes, + continuation: continuation, + timeoutTask: nil + ) + maximumObservedInFlightCommands = max( + maximumObservedInFlightCommands, + pendingByRequestID.count + ) + channel.exchange(frame: frame, descriptors: ownedDescriptors) { [weak self] result in + guard let self else { + if case .success(let reply) = result { Self.close(reply.descriptors) } + return + } + Task { await self.receiveReply(result, requestID: requestID) } + } + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expire(requestID: requestID) + } + pendingByRequestID[requestID]?.timeoutTask = timeoutTask + } + } + + public func invalidate() { + transitionToTerminal(.invalidated, error: .notActive(.invalidated)) + channel.invalidate() + } + + /// Installs a one-shot terminal-generation observer without an actor hop. This is required by + /// fence owners: helper death must revoke already-armed descriptors even when no command is + /// currently awaiting an XPC reply. Installation after failure delivers the stored terminal + /// state immediately, so a worker restart cannot miss the edge. + public nonisolated func installTerminalHandler( + _ handler: @escaping @Sendable ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + ) -> Void + ) { + terminalRelay.install(handler) + } + + public func snapshot() -> DoryRendererWorkerBrokerSnapshot { + DoryRendererWorkerBrokerSnapshot( + state: state, + generation: bootstrap.generation, + inFlightCommands: pendingByRequestID.count, + maximumObservedInFlightCommands: maximumObservedInFlightCommands, + aggregateReferencedBytes: aggregateReferencedBytes, + submittedBatches: submittedBatches, + controlBytes: controlBytes, + descriptorBackedCommandBytes: descriptorBackedCommandBytes, + rejectedAdmissions: rejectedAdmissions, + protocolViolations: protocolViolations, + lateReplies: lateReplies, + scanoutCopyBytes: 0 + ) + } + + private func receiveReply( + _ result: Result, + requestID: UInt64 + ) { + guard let pending = pendingByRequestID[requestID] else { + lateReplies = Self.saturatingAdd(lateReplies, 1) + if case .success(let reply) = result { Self.close(reply.descriptors) } + return + } + guard DispatchTime.now().uptimeNanoseconds < pending.command.deadlineUptimeNanoseconds else { + if case .success(let reply) = result { Self.close(reply.descriptors) } + expire(requestID: requestID) + return + } + switch result { + case .failure(.serviceFailure(let code)) where Self.isProvenRejection(code): + let removed = removePending(requestID) + removed?.continuation.resume(throwing: DoryRendererWorkerBrokerError.workerRejected(code)) + case .failure(.serviceFailure(.outcomeUnknown)): + transitionToTerminal( + .outcomeUnknown, + error: .workerOutcomeUnknown(commandDiagnostic( + for: pending, + stage: .workerServiceReply, + status: .backendOutcomeUnknown + )) + ) + channel.invalidate() + case .failure(let failure): + let terminal: DoryRendererWorkerBrokerState = switch failure { + case .interrupted: .interrupted + case .invalidated: .invalidated + case .malformedResult, .descriptorCountMismatch: .protocolViolation + case .unavailable, .serviceFailure: .outcomeUnknown + } + if terminal == .protocolViolation { + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + } + transitionToTerminal( + terminal, + error: .channelFailureDuring( + requestID: requestID, + operation: pending.command.operation, + failure: failure + ) + ) + channel.invalidate() + case .success(let reply): + do { + let decoded = try decodeReply(reply, accepting: pending.command) + let removed = removePending(requestID) + removed?.continuation.resume(returning: decoded) + } catch let error as DoryRendererWorkerBrokerError { + Self.close(reply.descriptors) + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + transitionToTerminal(.protocolViolation, error: error) + channel.invalidate() + } catch { + Self.close(reply.descriptors) + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + transitionToTerminal(.protocolViolation, error: .replyIdentityMismatch) + channel.invalidate() + } + } + } + + private func decodeReply( + _ reply: DoryRendererWorkerChannelReply, + accepting command: DoryRendererWorkerCommand + ) throws -> DoryRendererWorkerCommandResult { + guard command.operation == .acquireScanoutLease + || reply.sharedTextureHandle == nil else { + throw replyMismatch() + } + switch command.operation { + case .createResource3D, .createBlob: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + guard reply.payload.count == 8 else { throw replyMismatch() } + let generation = Self.decodeUInt64(reply.payload) + guard generation != 0 else { throw replyMismatch() } + return .resourceCreated(generation: generation) + + case .mapBlob: + let lease: DoryRendererBlobMappingLease + do { + lease = try DoryRendererBlobMappingLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + try lease.validateOutOfBandDescriptorCount(reply.descriptors.count) + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + try Self.validateReturnedSharedMemory( + reply.descriptors[0], + declaredFileSize: lease.declaredFileSize, + minimumByteCount: lease.mappingByteCount, + index: 0 + ) + return .blobMapping(DoryRendererWorkerBlobMapping( + lease: lease, + sharedMemoryDescriptor: reply.descriptors[0] + )) + + case .createFence: + try Self.requireDescriptorCount(reply.descriptors, expected: 1) + let expected: DoryRendererFencePayload + let received: DoryRendererFencePayload + do { + expected = try DoryRendererFencePayload.decode(command.payload) + received = try DoryRendererFencePayload.decode(reply.payload) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard received == expected else { throw replyMismatch() } + try Self.validateReturnedFence(reply.descriptors[0], index: 0) + return .fence(DoryRendererWorkerFenceReceipt( + workerGeneration: bootstrap.generation, + contextID: command.contextID, + flags: received.flags, + ringIndex: received.ringIndex, + fenceID: received.fenceID, + completionDescriptor: reply.descriptors[0] + )) + + case .acquireScanoutLease: + if let sharedTextureHandle = reply.sharedTextureHandle { + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + let lease: DoryRendererSharedTextureScanoutLease + do { + lease = try DoryRendererSharedTextureScanoutLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + return .sharedTextureScanout(DoryRendererWorkerSharedTextureScanout( + lease: lease, + sharedTextureHandle: sharedTextureHandle + )) + } + let lease: DoryRendererScanoutLease + do { + lease = try DoryRendererScanoutLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + try lease.validateOutOfBandDescriptorCount(reply.descriptors.count) + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + try Self.validateReturnedSharedMemory( + reply.descriptors[0], + declaredFileSize: lease.declaredFileSize, + minimumByteCount: lease.storageOffset + lease.leaseByteCount, + index: 0 + ) + return .scanout(DoryRendererWorkerScanout( + lease: lease, + sharedMemoryDescriptor: reply.descriptors[0] + )) + + case .resetAfterDeviceQuiesce: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + let expected: DoryRendererResetPayload + let received: DoryRendererResetPayload + do { + expected = try DoryRendererResetPayload.decode(command.payload) + received = try DoryRendererResetPayload.decode(reply.payload) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard expected == received else { throw replyMismatch() } + return .reset(successorGeneration: received.successorGeneration) + + case .createContext, .destroyContext, .attachResource, .detachResource, + .submit3D, .attachBacking, .detachBacking, .unrefResource, .unmapBlob, + .transferToHost3D, .transferFromHost3D, .releaseScanoutLease: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + guard reply.payload.isEmpty else { throw replyMismatch() } + return .acknowledged + } + } + + private func expire(requestID: UInt64) { + guard let pending = pendingByRequestID[requestID] else { return } + transitionToTerminal( + .outcomeUnknown, + error: .workerOutcomeUnknown(commandDiagnostic( + for: pending, + stage: .brokerCommandDeadline, + status: .deadlineExpired + )) + ) + channel.invalidate() + } + + private func commandDiagnostic( + for pending: PendingCommand, + stage: DoryRendererWorkerCommandDiagnosticStage, + status: DoryRendererWorkerCommandDiagnosticStatus + ) -> DoryRendererWorkerCommandDiagnostic { + let now = DispatchTime.now().uptimeNanoseconds + return DoryRendererWorkerCommandDiagnostic( + operation: pending.command.operation, + requestID: pending.command.requestID, + stage: stage, + status: status, + elapsedNanoseconds: now >= pending.admittedUptimeNanoseconds + ? now - pending.admittedUptimeNanoseconds + : 0 + ) + } + + private func receiveChannelEvent(_ event: DoryRendererWorkerChannelEvent) { + let (terminal, failure): ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerChannelFailure + ) = switch event { + case .interrupted: (.interrupted, .interrupted) + case .invalidated: (.invalidated, .invalidated) + } + let error: DoryRendererWorkerBrokerError + if let requestID = pendingByRequestID.keys.min(), + let pending = pendingByRequestID[requestID] { + error = .channelFailureDuring( + requestID: requestID, + operation: pending.command.operation, + failure: failure + ) + } else { + error = .notActive(terminal) + } + transitionToTerminal(terminal, error: error) + } + + private func transitionToTerminal( + _ requestedState: DoryRendererWorkerBrokerState, + error: DoryRendererWorkerBrokerError + ) { + guard state == .active else { return } + state = requestedState + terminalRelay.publish(state: requestedState, error: error) + let pending = pendingByRequestID + pendingByRequestID.removeAll(keepingCapacity: false) + aggregateReferencedBytes = 0 + for command in pending.values { + command.timeoutTask?.cancel() + Self.close(command.inputDescriptors) + command.continuation.resume(throwing: error) + } + } + + private func removePending(_ requestID: UInt64) -> PendingCommand? { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return nil } + pending.timeoutTask?.cancel() + Self.close(pending.inputDescriptors) + aggregateReferencedBytes = aggregateReferencedBytes >= pending.referencedBytes + ? aggregateReferencedBytes - pending.referencedBytes + : 0 + return pending + } + + private func reject( + _ error: DoryRendererWorkerBrokerError + ) -> DoryRendererWorkerBrokerError { + rejectedAdmissions = Self.saturatingAdd(rejectedAdmissions, 1) + return error + } + + private func replyMismatch() -> DoryRendererWorkerBrokerError { + .replyIdentityMismatch + } + + private static func isProvenRejection(_ code: DoryRendererWorkerRPCFailureCode) -> Bool { + switch code { + case .deadlineExpired, .resourceExhausted, .commandRejected: + true + case .invalidEnvelope, .bootstrapRejected, .bootstrapAlreadyAttempted, + .bootstrapRequired, .capabilityUnavailable, .staleGeneration, .outcomeUnknown, + .protocolViolation, .internalFailure, .bootstrapArtifactAuthorityFailed, + .bootstrapRendererInitializationFailed, .bootstrapVenusCapabilityFailed, + .bootstrapVenusContextFailed, .bootstrapSharedMemoryExportFailed, + .bootstrapFenceExportFailed, .bootstrapCapabilityReceiptFailed, + .bootstrapVirgl2CapabilityFailed, .bootstrapVirgl2ContextFailed: + false + } + } + + private static func sumReferencedBytes( + _ regions: [DoryRendererSharedRegionReference] + ) throws -> UInt64 { + var total: UInt64 = 0 + for region in regions { + let (next, overflow) = total.addingReportingOverflow(region.length) + guard !overflow else { + throw DoryRendererWorkerBrokerError.aggregateReferencedBytesLimit( + limit: UInt64.max, + requested: UInt64.max + ) + } + total = next + } + return total + } + + private static func duplicateAndValidate( + _ descriptors: [FileHandle], + references: [DoryRendererSharedRegionReference] + ) throws -> [FileHandle] { + var owned = [FileHandle]() + owned.reserveCapacity(descriptors.count) + var identities = Set() + do { + let metadata = Dictionary(grouping: references, by: \.descriptorIndex) + for (index, descriptor) in descriptors.enumerated() { + guard let descriptorReferences = metadata[UInt16(index)], + let reference = descriptorReferences.first, + descriptorReferences.allSatisfy({ + $0.declaredFileSize == reference.declaredFileSize + }) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let source = descriptor.fileDescriptor + var status = stat() + guard source >= 0, + fstat(source, &status) == 0, + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == reference.declaredFileSize else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let descriptorIdentity: DescriptorIdentity + switch status.st_mode & S_IFMT { + case S_IFREG: + descriptorIdentity = .filesystem( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) + case 0: + guard descriptorReferences.allSatisfy({ + $0.offset >= DoryGuestMemoryBackingDataOffset() + }) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + var identity = DoryGuestMemoryBackingIdentity() + guard DoryReadGuestMemoryBackingIdentity( + source, + reference.declaredFileSize, + &identity + ) == 1 else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + descriptorIdentity = .guestMemory( + withUnsafeBytes(of: &identity) { Data($0) } + ) + default: + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let accessMode = fcntl(source, F_GETFL) & O_ACCMODE + guard (reference.access == .readOnly && accessMode == O_RDONLY) + || (reference.access == .readWrite && accessMode == O_RDWR) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + guard identities.insert(descriptorIdentity).inserted else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let duplicate = fcntl(source, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + owned.append(FileHandle(fileDescriptor: duplicate, closeOnDealloc: true)) + } + return owned + } catch { + close(owned) + throw error + } + } + + private enum DescriptorIdentity: Hashable { + case filesystem(device: UInt64, inode: UInt64) + case guestMemory(Data) + } + + private static func requireDescriptorCount( + _ descriptors: [FileHandle], + expected: Int + ) throws { + guard descriptors.count == expected else { + throw DoryRendererWorkerBrokerError.replyIdentityMismatch + } + } + + private static func validateReturnedSharedMemory( + _ descriptor: FileHandle, + declaredFileSize: UInt64, + minimumByteCount: UInt64, + index: Int + ) throws { + let fd = descriptor.fileDescriptor + var status = stat() + guard fd >= 0, + fstat(fd, &status) == 0, + DoryRendererSharedMemoryDescriptorPolicy.accepts(mode: status.st_mode), + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == declaredFileSize, + minimumByteCount <= declaredFileSize, + fcntl(fd, F_GETFL) & O_ACCMODE == O_RDWR else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + try setCloseOnExec(fd, index: index) + } + + private static func validateReturnedFence(_ descriptor: FileHandle, index: Int) throws { + let fd = descriptor.fileDescriptor + var status = stat() + guard fd >= 0, + fstat(fd, &status) == 0, + (status.st_mode & S_IFMT) != S_IFDIR else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + try setCloseOnExec(fd, index: index) + } + + private static func requireDistinctDescriptors( + _ first: FileHandle, + _ second: FileHandle + ) throws { + var firstStatus = stat() + var secondStatus = stat() + guard fstat(first.fileDescriptor, &firstStatus) == 0, + fstat(second.fileDescriptor, &secondStatus) == 0, + firstStatus.st_dev != secondStatus.st_dev + || firstStatus.st_ino != secondStatus.st_ino else { + throw DoryRendererWorkerBrokerError.replyIdentityMismatch + } + } + + private static func setCloseOnExec(_ fd: Int32, index: Int) throws { + let flags = fcntl(fd, F_GETFD) + guard flags >= 0, fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0 else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + } + + private static func decodeUInt64(_ data: Data) -> UInt64 { + data.withUnsafeBytes { bytes in + UInt64(littleEndian: bytes.loadUnaligned(as: UInt64.self)) + } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift new file mode 100644 index 00000000..c77adedc --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift @@ -0,0 +1,234 @@ +import Darwin +import DoryRendererWorkerContracts +import Foundation + +/// Descriptor authorities and their ordered renderer iovec slices. One descriptor may back many +/// discontiguous guest-RAM regions; command streams instead use a separately sealed read-only +/// snapshot so a guest cannot mutate admitted bytes while the worker consumes them. +struct DoryRendererWorkerSharedRegionSet: @unchecked Sendable { + let references: [DoryRendererSharedRegionReference] + let descriptors: [FileHandle] + + static func guestBacking( + entries: [VirtioGPUMemoryEntry], + transport: VirtioMMIOTransport + ) throws -> Self { + guard !entries.isEmpty else { return Self(references: [], descriptors: []) } + let descriptor = try transport.duplicateGuestMemoryBackingDescriptor() + do { + var references = [DoryRendererSharedRegionReference]() + references.reserveCapacity(entries.count) + var expectedFileSize: UInt64? + for entry in entries { + guard entry.length > 0, let guestAddress = entry.guestAddress else { + throw VMError.invalidConfiguration( + "renderer backing is not guest-memory descriptor backed" + ) + } + let bounds = try transport.guestMemoryRegionBounds( + at: guestAddress, + count: UInt64(entry.length) + ) + if let expectedFileSize { + guard expectedFileSize == bounds.declaredFileSize else { + throw VMError.invalidConfiguration( + "renderer backing spans different guest-memory authorities" + ) + } + } else { + expectedFileSize = bounds.declaredFileSize + } + references.append(try DoryRendererSharedRegionReference( + identity: .random(), + descriptorIndex: 0, + access: .readWrite, + offset: bounds.offset, + length: bounds.length, + declaredFileSize: bounds.declaredFileSize + )) + } + return Self(references: references, descriptors: [descriptor]) + } catch { + try? descriptor.close() + throw error + } + } + + /// Copies one immutable submit stream directly from a lease-held virtqueue view into an + /// unlinked shared mapping. No `[UInt8]` or XPC `Data` ever contains command dwords. + static func immutableSubmit3D( + from access: VirtqueueLeaseAccess, + readableOffset: Int, + byteCount: Int, + maximumByteCount: Int + ) throws -> Self { + try immutableSubmit3D( + from: access.segments, + readableByteCount: access.readableByteCount, + readableOffset: readableOffset, + byteCount: byteCount, + maximumByteCount: maximumByteCount + ) + } + + static func immutableSubmit3D( + from segments: [VirtqueueSegment], + readableByteCount: Int, + readableOffset: Int, + byteCount: Int, + maximumByteCount: Int + ) throws -> Self { + guard readableOffset >= 0, + byteCount > 0, + byteCount <= maximumByteCount, + byteCount.isMultiple(of: 4), + readableOffset.isMultiple(of: 8) else { + throw VMError.invalidConfiguration("invalid descriptor-backed submit_3d range") + } + let (end, overflow) = readableOffset.addingReportingOverflow(byteCount) + guard !overflow, end <= readableByteCount else { + throw VMError.invalidConfiguration("submit_3d range exceeds its descriptor chain") + } + + let authority = try ImmutableAuthority(byteCount: byteCount) + do { + var logicalOffset = 0 + var destinationOffset = 0 + for segment in segments where !segment.isDeviceWritable { + let (segmentEnd, segmentOverflow) = logicalOffset.addingReportingOverflow( + segment.length + ) + guard segment.length >= 0, !segmentOverflow else { + throw VMError.invalidConfiguration("invalid submit_3d descriptor length") + } + defer { logicalOffset = segmentEnd } + guard segmentEnd > readableOffset, + logicalOffset < end else { continue } + let sourceStart = max(readableOffset, logicalOffset) + let sourceEnd = min(end, segmentEnd) + let take = sourceEnd - sourceStart + guard take > 0 else { continue } + authority.mapping.advanced(by: destinationOffset).copyMemory( + from: segment.pointer.advanced(by: sourceStart - logicalOffset), + byteCount: take + ) + destinationOffset += take + } + guard destinationOffset == byteCount else { + throw VMError.invalidConfiguration("incomplete submit_3d descriptor snapshot") + } + let descriptor = try authority.finish() + return Self( + references: [try DoryRendererSharedRegionReference( + identity: .random(), + descriptorIndex: 0, + access: .readOnly, + offset: 0, + length: UInt64(byteCount), + declaredFileSize: UInt64(byteCount) + )], + descriptors: [descriptor] + ) + } catch { + authority.abort() + throw error + } + } +} + +private final class ImmutableAuthority { + let mapping: UnsafeMutableRawPointer + + private let writableDescriptor: Int32 + private let readOnlyDescriptor: Int32 + private let byteCount: Int + private var finished = false + + init(byteCount: Int) throws { + let templateURL = URL( + fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true + ).appendingPathComponent("dory-renderer-command.XXXXXX") + var template = templateURL.path.utf8CString + let writable = template.withUnsafeMutableBufferPointer { buffer in + mkstemp(buffer.baseAddress!) + } + guard writable >= 0 else { + throw VMError.outOfMemory("cannot create renderer command authority: errno \(errno)") + } + var readOnly: Int32 = -1 + var mapped: UnsafeMutableRawPointer? + do { + let writableFlags = fcntl(writable, F_GETFD) + guard writableFlags >= 0, + fcntl(writable, F_SETFD, writableFlags | FD_CLOEXEC) == 0, + ftruncate(writable, off_t(byteCount)) == 0 else { + throw VMError.outOfMemory( + "cannot size renderer command authority: errno \(errno)" + ) + } + readOnly = template.withUnsafeBufferPointer { buffer in + open(buffer.baseAddress!, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + } + guard readOnly >= 0 else { + throw VMError.outOfMemory( + "cannot seal renderer command authority: errno \(errno)" + ) + } + let unlinkResult = template.withUnsafeBufferPointer { buffer in + unlink(buffer.baseAddress!) + } + guard unlinkResult == 0 else { + throw VMError.outOfMemory( + "cannot unlink renderer command authority: errno \(errno)" + ) + } + mapped = mmap( + nil, + byteCount, + PROT_READ | PROT_WRITE, + MAP_SHARED, + writable, + 0 + ) + guard mapped != MAP_FAILED, mapped != nil else { + throw VMError.outOfMemory( + "cannot map renderer command authority: errno \(errno)" + ) + } + } catch { + if mapped != nil, mapped != MAP_FAILED { munmap(mapped, byteCount) } + if readOnly >= 0 { close(readOnly) } + close(writable) + _ = template.withUnsafeBufferPointer { buffer in + unlink(buffer.baseAddress!) + } + throw error + } + self.mapping = mapped! + self.writableDescriptor = writable + self.readOnlyDescriptor = readOnly + self.byteCount = byteCount + } + + func finish() throws -> FileHandle { + guard !finished, + mprotect(mapping, byteCount, PROT_READ) == 0 else { + throw VMError.invalidConfiguration("cannot seal renderer command mapping") + } + munmap(mapping, byteCount) + close(writableDescriptor) + finished = true + return FileHandle(fileDescriptor: readOnlyDescriptor, closeOnDealloc: true) + } + + func abort() { + guard !finished else { return } + munmap(mapping, byteCount) + close(writableDescriptor) + close(readOnlyDescriptor) + finished = true + } + + deinit { abort() } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift new file mode 100644 index 00000000..a8bb775e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift @@ -0,0 +1,1886 @@ +import Darwin +import DoryRendererWorkerContracts +import Foundation + +enum DoryRendererWorkerVirtioCommandLaneState: Equatable, Sendable { + case active(deviceGeneration: UInt64) + case revoked(deviceGeneration: UInt64) + case failed(deviceGeneration: UInt64) +} + +enum DoryRendererWorkerVirtioCommandLaneError: Error, Equatable, Sendable { + case notActive(DoryRendererWorkerVirtioCommandLaneState) + case staleDeviceGeneration(expected: UInt64, actual: UInt64) + case commandQueueFull(limit: Int) + case referencedBytesLimit(limit: UInt64, requested: UInt64) + case invalidSubmitRegions + case inputDescriptorDuplicationFailed(index: Int) + case localBlobTeardownRejected + case duplicateFenceID(UInt64) + case unexpectedWorkerReply + case broker(DoryRendererWorkerBrokerError) + + /// True only when this result proves the submit did not mutate renderer state. Callers may + /// publish a guest error for these cases; every other failure must retain/revoke the chain. + var provesNoRendererMutation: Bool { + switch self { + case .commandQueueFull, + .referencedBytesLimit, + .invalidSubmitRegions, + .inputDescriptorDuplicationFailed, + .localBlobTeardownRejected, + .duplicateFenceID: + true + case .broker(let error): + switch error { + case .workerRejected(.deadlineExpired), + .workerRejected(.resourceExhausted), + .workerRejected(.commandRejected), + .inFlightLimit, + .aggregateReferencedBytesLimit, + .deadlineExpired, + .deadlineTooDistant, + .inputDescriptorCountMismatch, + .invalidInputDescriptor, + .invalidCommand: + true + default: + false + } + case .notActive, + .staleDeviceGeneration, + .unexpectedWorkerReply: + false + } + } +} + +struct DoryRendererWorkerVirtioCommandLaneSnapshot: Equatable, Sendable { + let state: DoryRendererWorkerVirtioCommandLaneState + let queuedCommands: Int + let maximumObservedQueuedCommands: Int + let queuedReferencedBytes: UInt64 + let rejectedAdmissions: UInt64 + let completedControlCommands: UInt64 + let completedResourceCommands: UInt64 + let completedSubmissions: UInt64 + let armedFences: Int + let completedFences: UInt64 + let liveScanoutLeases: Int + let acquiredScanoutLeases: UInt64 + let releasedScanoutLeases: UInt64 +} + +enum DoryRendererWorkerVirtioSubmissionDisposition: Equatable, Sendable { + /// The submit was acknowledged and its fence descriptor is armed. Guest completion + /// still belongs exclusively to the later fence-sink edge. + case fenceArmed + /// The worker proved that it rejected the submit before a renderer-visible mutation. + case provenRejected(DoryRendererWorkerVirtioCommandLaneError) + /// The submit crossed, or may have crossed, its mutation boundary without a usable fence. + case outcomeUnknown(DoryRendererWorkerVirtioCommandLaneError) +} + +enum DoryRendererWorkerScanoutAuthority: @unchecked Sendable { + case sharedMemory(DoryRendererWorkerScanout) + case sharedTexture(DoryRendererWorkerSharedTextureScanout) + + var workerGeneration: DoryRendererWorkerGeneration { + switch self { + case .sharedMemory(let value): value.lease.workerGeneration + case .sharedTexture(let value): value.lease.workerGeneration + } + } + + var resourceID: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.resourceID + case .sharedTexture(let value): value.lease.resourceID + } + } + + var resourceGeneration: UInt64 { + switch self { + case .sharedMemory(let value): value.lease.resourceGeneration + case .sharedTexture(let value): value.lease.resourceGeneration + } + } + + var leaseID: DoryRendererScanoutLeaseID { + switch self { + case .sharedMemory(let value): value.lease.leaseID + case .sharedTexture(let value): value.lease.leaseID + } + } + + var releaseToken: DoryRendererScanoutReleaseToken { + switch self { + case .sharedMemory(let value): value.lease.releaseToken + case .sharedTexture(let value): value.lease.releaseToken + } + } + + var pixelFormat: DoryRendererScanoutPixelFormat { + switch self { + case .sharedMemory(let value): value.lease.pixelFormat + case .sharedTexture(let value): value.lease.pixelFormat + } + } + + var width: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.width + case .sharedTexture(let value): value.lease.width + } + } + + var height: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.height + case .sharedTexture(let value): value.lease.height + } + } + + func discardTransport() { + if case .sharedMemory(let value) = self { + try? value.sharedMemoryDescriptor.close() + } + } +} + +enum DoryRendererWorkerScanoutDisposition: @unchecked Sendable { + case acquired(DoryRendererWorkerScanoutAuthority) + /// The worker proved it rejected acquisition before creating a live lease. + case provenRejected(DoryRendererWorkerVirtioCommandLaneError) + /// A live lease may exist and the entire generation was revoked. + case outcomeUnknown(DoryRendererWorkerVirtioCommandLaneError) +} + +/// Ordered asynchronous virtio-gpu command/fence seam for one authenticated worker generation. +/// +/// The vCPU-facing caller performs only bounded local admission. Submit dwords stay in an +/// immutable descriptor-backed authority and commands enter one ordered task chain; no caller +/// waits synchronously for XPC or GPU completion. A worker fence descriptor is armed separately +/// and is the only authority that may publish a guest fence completion. Reset, helper death, or an +/// uncertain result revokes the complete device generation and cancels every outstanding fence. +/// +/// `VirtioGPU` selects this lane only from the authenticated worker bootstrap receipt; there is no +/// in-process fallback once a worker-backed generation has been admitted. +public final class DoryRendererWorkerVirtioCommandLane: @unchecked Sendable { + typealias Completion = @Sendable ( + Result + ) -> Void + typealias ResourceCreationCompletion = @Sendable ( + Result + ) -> Void + typealias BlobMappingCompletion = @Sendable ( + Result + ) -> Void + typealias ScanoutCompletion = @Sendable ( + DoryRendererWorkerScanoutDisposition + ) -> Void + /// Runs on the serialized command lane after admission and every predecessor, but before the + /// worker sees UNMAP_BLOB. Returning false proves that no worker command was sent. + typealias BeforeBlobUnmap = @Sendable () -> Bool + typealias FenceSink = @Sendable ( + _ deviceGeneration: UInt64, + _ contextID: UInt32, + _ ringIndex: UInt32, + _ fenceID: UInt64 + ) -> Void + typealias RuntimeFailureSink = @Sendable ( + _ deviceGeneration: UInt64, + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> Void + + private final class ArmedFence { + let source: DispatchSourceRead + + init(source: DispatchSourceRead) { + self.source = source + } + + func cancel() { source.cancel() } + } + + private struct FenceKey: Hashable { + let workerGeneration: UInt64 + let fenceID: UInt64 + } + + private struct LiveScanoutLease: Equatable, Sendable { + let leaseID: DoryRendererScanoutLeaseID + let resourceID: UInt32 + let resourceGeneration: UInt64 + } + + let capsets: [VirtioGPUCapset] + let workerGeneration: DoryRendererWorkerGeneration + /// Exact authenticated worker admission bound. VirtioGPU uses this instead of a duplicated + /// device-side literal, so a candidate can never admit more regions than its worker accepts. + let maximumSharedRegions: Int + /// Exact authenticated aggregate referenced-byte authority used for target-aware resource + /// admission before a mutating worker command crosses XPC. + let maximumReferencedBytes: UInt64 + + private let broker: DoryRendererWorkerBroker + private let maximumQueuedCommands: Int + private let maximumQueuedReferencedBytes: UInt64 + private let commandDeadlineNanoseconds: UInt64 + private let fenceQueue = DispatchQueue( + label: "dev.dory.renderer-worker.fence-completion", + qos: .userInteractive + ) + private let lock = NSLock() + private var state: DoryRendererWorkerVirtioCommandLaneState + private var commandTail: Task? + private var queuedCommands = 0 + private var maximumObservedQueuedCommands = 0 + private var queuedReferencedBytes: UInt64 = 0 + private var rejectedAdmissions: UInt64 = 0 + private var completedControlCommands: UInt64 = 0 + private var completedResourceCommands: UInt64 = 0 + private var completedSubmissions: UInt64 = 0 + private var completedFences: UInt64 = 0 + private var acquiredScanoutLeases: UInt64 = 0 + private var releasedScanoutLeases: UInt64 = 0 + private var reservedFenceIDs = Set() + private var armedFences = [FenceKey: ArmedFence]() + private var liveScanoutLeases = [DoryRendererScanoutReleaseToken: LiveScanoutLease]() + private var releasingScanoutTokens = Set() + private var fenceSink: FenceSink? + private var runtimeFailureSink: RuntimeFailureSink? + + public init( + broker: DoryRendererWorkerBroker, + deviceGeneration: UInt64, + maximumQueuedCommands: Int? = nil, + maximumQueuedReferencedBytes: UInt64? = nil, + commandDeadlineNanoseconds: UInt64 = 5_000_000_000 + ) throws { + guard deviceGeneration != 0, + broker.capabilityReceipt.productionAccelerationIsAdmissible else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + let limits = broker.bootstrap.limits + let queueLimit = maximumQueuedCommands ?? limits.maximumInFlightCommands + let byteLimit = maximumQueuedReferencedBytes ?? limits.maximumReferencedBytes + guard queueLimit > 0, byteLimit > 0, + commandDeadlineNanoseconds > 0, + commandDeadlineNanoseconds + <= DoryRendererWorkerBroker.maximumAdmissionDeadlineNanoseconds else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + self.broker = broker + self.workerGeneration = broker.bootstrap.generation + self.maximumSharedRegions = limits.maximumSharedRegions + self.maximumReferencedBytes = limits.maximumReferencedBytes + self.capsets = broker.capabilityReceipt.capsets.map { + VirtioGPUCapset( + id: $0.id, + maxVersion: $0.maximumVersion, + data: Array($0.data) + ) + } + self.maximumQueuedCommands = min(queueLimit, limits.maximumInFlightCommands) + self.maximumQueuedReferencedBytes = min(byteLimit, limits.maximumReferencedBytes) + self.commandDeadlineNanoseconds = commandDeadlineNanoseconds + self.state = .active(deviceGeneration: deviceGeneration) + broker.installTerminalHandler { [weak self] _, error in + self?.brokerTerminated(error) + } + } + + func installCallbacks( + fence: @escaping FenceSink, + runtimeFailure: @escaping RuntimeFailureSink + ) { + lock.withLock { + fenceSink = fence + runtimeFailureSink = runtimeFailure + } + } + + /// Exact authenticated source for GET_CAPSET_INFO/GET_CAPSET at the eventual atomic cutover. + /// No second renderer query, cache, environment setting, or legacy object participates. + func capset(id: UInt32, version: UInt32) -> VirtioGPUCapset? { + capsets.first { $0.id == id && version <= $0.maxVersion } + } + + func createContext( + contextID: UInt32, + capsetID: UInt32, + name: String, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0 else { throw reject(.invalidSubmitRegions) } + let payload: DoryRendererContextCreatePayload + do { + payload = try DoryRendererContextCreatePayload( + capsetID: capsetID, + name: name + ) + } catch { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .createContext, + contextID: contextID, + payload: payload.encoded, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func createResource3D( + resourceID: UInt32, + payload: DoryRendererResource3DCreatePayload, + deviceGeneration: UInt64, + completion: @escaping ResourceCreationCompletion + ) throws { + guard resourceID != 0 else { throw reject(.invalidSubmitRegions) } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .createResource3D, + resourceID: resourceID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .resourceCreated(let resourceGeneration) = result, + resourceGeneration != 0 else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(resourceGeneration)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + func destroyContext( + contextID: UInt32, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0 else { throw reject(.invalidSubmitRegions) } + try enqueueAcknowledgedControl( + operation: .destroyContext, + contextID: contextID, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func attachResource( + contextID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .attachResource, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func detachResource( + contextID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .detachResource, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func attachBacking( + resourceID: UInt32, + resourceGeneration: UInt64, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, + resourceGeneration != 0, + !regions.references.isEmpty, + !regions.descriptors.isEmpty, + regions.references.allSatisfy({ $0.access == .readWrite }) else { + throw reject(.invalidSubmitRegions) + } + var referencedBytes: UInt64 = 0 + for region in regions.references { + let (sum, overflow) = referencedBytes.addingReportingOverflow(region.length) + guard !overflow else { throw reject(.invalidSubmitRegions) } + referencedBytes = sum + } + let admittedReferencedBytes = referencedBytes + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: admittedReferencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: admittedReferencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .attachBacking, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + func detachBacking( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .detachBacking, + contextID: 0, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func transferToHost3D( + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try transfer3D( + operation: .transferToHost3D, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func transferFromHost3D( + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try transfer3D( + operation: .transferFromHost3D, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func transfer3D( + operation: DoryRendererWorkerOperation, + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + precondition(operation == .transferToHost3D || operation == .transferFromHost3D) + try enqueueAcknowledgedControl( + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: payload.encoded, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func unrefResource( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .unrefResource, + contextID: 0, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func createBlob( + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererBlobCreatePayload, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping ResourceCreationCompletion + ) throws { + guard resourceID != 0, + regions.references.allSatisfy({ $0.access == .readWrite }), + regions.references.isEmpty == regions.descriptors.isEmpty else { + throw reject(.invalidSubmitRegions) + } + var summedReferencedBytes: UInt64 = 0 + for region in regions.references { + let (sum, overflow) = summedReferencedBytes.addingReportingOverflow(region.length) + guard !overflow else { throw reject(.invalidSubmitRegions) } + summedReferencedBytes = sum + } + let referencedBytes = summedReferencedBytes + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .createBlob, + contextID: contextID, + resourceID: resourceID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .resourceCreated(let resourceGeneration) = result, + resourceGeneration != 0 else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(resourceGeneration)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + func mapBlob( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping BlobMappingCompletion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .mapBlob, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .blobMapping(let mapping) = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + try? mapping.sharedMemoryDescriptor.close() + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(mapping)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + /// Enforces the cross-process blob lifetime order in one serialized authority: + /// + /// 1. every prior worker command completes; + /// 2. the VMM removes the guest HV mapping, munmaps its SHM view, and closes its descriptor; + /// 3. only then may the worker release its renderer-side mapping. + /// + /// Admission failure never invokes `beforeWorkerUnmap`, so the caller may safely retain its + /// local mapping and reject the guest command. Any failure after the closure ran must be treated + /// as an uncertain mapping outcome by the caller, even when the worker proves it did not mutate. + func unmapBlob( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + beforeWorkerUnmap: @escaping BeforeBlobUnmap, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + guard beforeWorkerUnmap() else { + completion(.failure(.localBlobTeardownRejected)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .unmapBlob, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + /// Acquires one SHM scanout lease after the authenticated managed guest's KMS producer wait + /// and RESOURCE_FLUSH boundary. No duplicate renderer fence or per-frame wait is introduced. + func acquireScanoutLease( + resourceID: UInt32, + resourceGeneration: UInt64, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + storageOffset: UInt32, + deviceGeneration: UInt64, + completion: @escaping ScanoutCompletion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.outcomeUnknown(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let acquirePayload = try DoryRendererScanoutAcquirePayload( + width: width, + height: height, + virglFormat: virglFormat, + stride: stride, + storageOffset: storageOffset + ) + let acquireResult = try await self.broker.execute( + operation: .acquireScanoutLease, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: acquirePayload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + let scanout: DoryRendererWorkerScanoutAuthority + switch acquireResult { + case .scanout(let value): + scanout = .sharedMemory(value) + case .sharedTextureScanout(let value): + scanout = .sharedTexture(value) + default: + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + scanout.discardTransport() + completion(.outcomeUnknown(self.inactiveError(actual: deviceGeneration))) + return + } + let accepted = self.lock.withLock { () -> Bool in + guard case .active(let activeGeneration) = self.state, + activeGeneration == deviceGeneration, + self.liveScanoutLeases[scanout.releaseToken] == nil else { + return false + } + self.liveScanoutLeases[scanout.releaseToken] = LiveScanoutLease( + leaseID: scanout.leaseID, + resourceID: resourceID, + resourceGeneration: resourceGeneration + ) + self.acquiredScanoutLeases = Self.saturatingAdd( + self.acquiredScanoutLeases, + 1 + ) + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + return true + } + guard accepted else { + scanout.discardTransport() + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + completion(.acquired(scanout)) + } catch let error as DoryRendererWorkerBrokerError { + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if laneError.provesNoRendererMutation { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.provenRejected(laneError)) + } else { + self.failGeneration(deviceGeneration: deviceGeneration, error: laneError) + completion(.outcomeUnknown(laneError)) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.outcomeUnknown(error)) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + } + } + } + + /// Releases one exact live token. Replay and mismatched resource generations fail before XPC. + func releaseScanoutLease( + _ lease: DoryRendererScanoutLease, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try releaseScanoutLease( + workerGeneration: lease.workerGeneration, + resourceID: lease.resourceID, + resourceGeneration: lease.resourceGeneration, + leaseID: lease.leaseID, + releaseToken: lease.releaseToken, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func releaseScanoutLease( + _ lease: DoryRendererSharedTextureScanoutLease, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try releaseScanoutLease( + workerGeneration: lease.workerGeneration, + resourceID: lease.resourceID, + resourceGeneration: lease.resourceGeneration, + leaseID: lease.leaseID, + releaseToken: lease.releaseToken, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func releaseScanoutLease( + workerGeneration leaseWorkerGeneration: DoryRendererWorkerGeneration, + resourceID: UInt32, + resourceGeneration: UInt64, + leaseID: DoryRendererScanoutLeaseID, + releaseToken: DoryRendererScanoutReleaseToken, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try lock.withLock { + guard case .active(let activeGeneration) = state else { + throw rejectWhileLocked(.notActive(state)) + } + guard activeGeneration == deviceGeneration else { + throw rejectWhileLocked(.staleDeviceGeneration( + expected: activeGeneration, + actual: deviceGeneration + )) + } + guard leaseWorkerGeneration == workerGeneration, + liveScanoutLeases[releaseToken] == LiveScanoutLease( + leaseID: leaseID, + resourceID: resourceID, + resourceGeneration: resourceGeneration + ), + releasingScanoutTokens.insert(releaseToken).inserted else { + throw rejectWhileLocked(.invalidSubmitRegions) + } + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .releaseScanoutLease, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: releaseToken.commandPayload, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + let removed = self.lock.withLock { () -> Bool in + self.releasingScanoutTokens.remove(releaseToken) + guard self.liveScanoutLeases.removeValue( + forKey: releaseToken + ) != nil else { return false } + self.releasedScanoutLeases = Self.saturatingAdd( + self.releasedScanoutLeases, + 1 + ) + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + return true + } + guard removed else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if laneError.provesNoRendererMutation { + _ = self.lock.withLock { + self.releasingScanoutTokens.remove(releaseToken) + } + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + } else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: laneError + ) + } + completion(.failure(laneError)) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + _ = lock.withLock { releasingScanoutTokens.remove(releaseToken) } + throw error + } + } + + func submit3D( + contextID: UInt32, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, + regions.references.count == 1, + regions.descriptors.count == 1, + regions.references[0].access == .readOnly, + regions.references[0].length > 0, + regions.references[0].length.isMultiple(of: 4) else { + throw reject(.invalidSubmitRegions) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + let referencedBytes = regions.references[0].length + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .submit3D, + contextID: contextID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedSubmissions = Self.saturatingAdd( + self.completedSubmissions, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + /// Atomically admits one descriptor-backed submit and its fence registration into the + /// lane's ordered queue. Treating this as one local admission is essential: once the worker + /// acknowledges the submit, backpressure must not prevent the completion boundary itself from + /// being created. Any failure after that acknowledgement is outcome-unknown and revokes the + /// complete worker/device generation. + func submit3DThenCreateFence( + contextID: UInt32, + regions: DoryRendererWorkerSharedRegionSet, + ringIndex: UInt32, + fenceID: UInt64, + contextFence: Bool, + deviceGeneration: UInt64, + completion: @escaping @Sendable ( + DoryRendererWorkerVirtioSubmissionDisposition + ) -> Void + ) throws { + guard contextID != 0, + fenceID != 0, + (contextFence + ? ringIndex <= DoryRendererFencePayload.maximumRingIndex + : ringIndex == 0), + regions.references.count == 1, + regions.descriptors.count == 1, + regions.references[0].access == .readOnly, + regions.references[0].length > 0, + regions.references[0].length.isMultiple(of: 4) else { + throw reject(.invalidSubmitRegions) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + let referencedBytes = regions.references[0].length + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: fenceID + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.outcomeUnknown( + self.inactiveError(actual: deviceGeneration) + )) + return + } + + var submitWasAcknowledged = false + do { + let submit = try await self.broker.execute( + operation: .submit3D, + contextID: contextID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = submit else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + submitWasAcknowledged = true + self.lock.withLock { + self.completedSubmissions = Self.saturatingAdd( + self.completedSubmissions, + 1 + ) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.outcomeUnknown( + self.inactiveError(actual: deviceGeneration) + )) + return + } + + let payload = try DoryRendererFencePayload( + flags: contextFence ? DoryRendererFencePayload.contextTimeline : 0, + ringIndex: contextFence ? ringIndex : 0, + fenceID: fenceID + ) + let fence = try await self.broker.execute( + operation: .createFence, + contextID: contextID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .fence(let receipt) = fence, + receipt.workerGeneration == self.workerGeneration, + receipt.contextID == contextID, + receipt.flags == payload.flags, + receipt.ringIndex == payload.ringIndex, + receipt.fenceID == fenceID else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + try self.arm( + receipt: receipt, + deviceGeneration: deviceGeneration, + completionContextID: contextFence ? contextID : 0, + completionRingIndex: contextFence ? ringIndex : 0 + ) + completion(.fenceArmed) + } catch let error as DoryRendererWorkerBrokerError { + self.releaseFenceReservation(fenceID) + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if !submitWasAcknowledged, laneError.provesNoRendererMutation { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.provenRejected(laneError)) + } else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: laneError + ) + completion(.outcomeUnknown(laneError)) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.releaseFenceReservation(fenceID) + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.outcomeUnknown(error)) + } catch { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + /// Enqueues a global fence behind all prior renderer mutations. Completion reports that the + /// worker returned a validated descriptor; `fenceSink` fires only when that descriptor signals. + func createGlobalFence( + fenceID: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard fenceID != 0 else { throw reject(.invalidSubmitRegions) } + try createFence( + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + /// Enqueues a context fence behind all prior renderer mutations. Completion reports that the + /// worker returned a validated descriptor; `fenceSink` fires only when that descriptor signals. + func createContextFence( + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, + fenceID != 0, + ringIndex <= DoryRendererFencePayload.maximumRingIndex else { + throw reject(.invalidSubmitRegions) + } + try createFence( + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID, + contextFence: true, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func createFence( + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64, + contextFence: Bool, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: fenceID + ) { [weak self] in + guard let self else { return } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let payload = try DoryRendererFencePayload( + flags: contextFence ? DoryRendererFencePayload.contextTimeline : 0, + ringIndex: contextFence ? ringIndex : 0, + fenceID: fenceID + ) + let result = try await self.broker.execute( + operation: .createFence, + contextID: contextID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .fence(let receipt) = result, + receipt.workerGeneration == self.workerGeneration, + receipt.contextID == contextID, + receipt.flags == payload.flags, + receipt.ringIndex == payload.ringIndex, + receipt.fenceID == fenceID else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + try self.arm( + receipt: receipt, + deviceGeneration: deviceGeneration, + completionContextID: contextFence ? contextID : 0, + completionRingIndex: contextFence ? ringIndex : 0 + ) + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.releaseFenceReservation(fenceID) + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.releaseFenceReservation(fenceID) + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.failure(error)) + } catch { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + func revoke(deviceGeneration: UInt64) { + let cancellation: [ArmedFence] = lock.withLock { + guard case .active(let activeGeneration) = state, + activeGeneration == deviceGeneration else { return [] } + state = .revoked(deviceGeneration: activeGeneration) + reservedFenceIDs.removeAll(keepingCapacity: false) + liveScanoutLeases.removeAll(keepingCapacity: false) + releasingScanoutTokens.removeAll(keepingCapacity: false) + let fences = Array(armedFences.values) + armedFences.removeAll(keepingCapacity: false) + return fences + } + for fence in cancellation { fence.cancel() } + Task { await broker.invalidate() } + } + + /// Moves an authenticated but completely unused worker lane onto the transport generation + /// created by an initial virtio device reset. Linux writes Status=0 while probing a device; + /// that protocol transition does not invalidate renderer state when no command, resource, + /// fence, or scanout lease has ever crossed the worker boundary. Once any admission has + /// occurred, reset must retain the normal fail-closed generation-replacement path. + func rebindPristineDeviceGeneration( + from sourceGeneration: UInt64, + to successorGeneration: UInt64 + ) -> Bool { + guard sourceGeneration != 0, + successorGeneration != 0, + sourceGeneration != successorGeneration else { return false } + return lock.withLock { + guard state == .active(deviceGeneration: sourceGeneration), + commandTail == nil, + queuedCommands == 0, + maximumObservedQueuedCommands == 0, + queuedReferencedBytes == 0, + rejectedAdmissions == 0, + completedControlCommands == 0, + completedResourceCommands == 0, + completedSubmissions == 0, + armedFences.isEmpty, + reservedFenceIDs.isEmpty, + completedFences == 0, + liveScanoutLeases.isEmpty, + releasingScanoutTokens.isEmpty, + acquiredScanoutLeases == 0, + releasedScanoutLeases == 0 else { return false } + state = .active(deviceGeneration: successorGeneration) + return true + } + } + + /// Runner-owned terminal cutover boundary. The launch owner is in the `dory-hv` executable + /// module, so it cannot call the lane's internal reset machinery directly. This wrapper keeps + /// the only public operation terminal and generation-bound; it does not expose command or + /// fence mutation APIs across the module boundary. + public func invalidate(deviceGeneration: UInt64) { + revoke(deviceGeneration: deviceGeneration) + } + + func snapshot() -> DoryRendererWorkerVirtioCommandLaneSnapshot { + lock.withLock { + DoryRendererWorkerVirtioCommandLaneSnapshot( + state: state, + queuedCommands: queuedCommands, + maximumObservedQueuedCommands: maximumObservedQueuedCommands, + queuedReferencedBytes: queuedReferencedBytes, + rejectedAdmissions: rejectedAdmissions, + completedControlCommands: completedControlCommands, + completedResourceCommands: completedResourceCommands, + completedSubmissions: completedSubmissions, + armedFences: armedFences.count, + completedFences: completedFences, + liveScanoutLeases: liveScanoutLeases.count, + acquiredScanoutLeases: acquiredScanoutLeases, + releasedScanoutLeases: releasedScanoutLeases + ) + } + } + + private func enqueue( + deviceGeneration: UInt64, + referencedBytes: UInt64, + reservingFenceID fenceID: UInt64?, + operation: @escaping @Sendable () async -> Void + ) throws { + try lock.withLock { + guard case .active(let activeGeneration) = state else { + throw rejectWhileLocked(.notActive(state)) + } + guard activeGeneration == deviceGeneration else { + throw rejectWhileLocked(.staleDeviceGeneration( + expected: activeGeneration, + actual: deviceGeneration + )) + } + guard queuedCommands < maximumQueuedCommands else { + throw rejectWhileLocked(.commandQueueFull(limit: maximumQueuedCommands)) + } + let (newBytes, overflow) = queuedReferencedBytes.addingReportingOverflow( + referencedBytes + ) + guard !overflow, newBytes <= maximumQueuedReferencedBytes else { + throw rejectWhileLocked(.referencedBytesLimit( + limit: maximumQueuedReferencedBytes, + requested: overflow ? UInt64.max : newBytes + )) + } + if let fenceID, !reservedFenceIDs.insert(fenceID).inserted { + throw rejectWhileLocked(.duplicateFenceID(fenceID)) + } + queuedCommands += 1 + maximumObservedQueuedCommands = max(maximumObservedQueuedCommands, queuedCommands) + queuedReferencedBytes = newBytes + let predecessor = commandTail + commandTail = Task { + if let predecessor { await predecessor.value } + await operation() + } + } + } + + private func enqueueAcknowledgedControl( + operation: DoryRendererWorkerOperation, + contextID: UInt32, + resourceID: UInt32 = 0, + resourceGeneration: UInt64 = 0, + payload: Data = Data(), + deviceGeneration: UInt64, + countsAsResourceCommand: Bool = false, + completion: @escaping Completion + ) throws { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: payload, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + if countsAsResourceCommand { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } else { + self.completedControlCommands = Self.saturatingAdd( + self.completedControlCommands, + 1 + ) + } + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + private func finishAdmission(referencedBytes: UInt64) { + lock.withLock { + queuedCommands = max(0, queuedCommands - 1) + queuedReferencedBytes = queuedReferencedBytes >= referencedBytes + ? queuedReferencedBytes - referencedBytes + : 0 + } + } + + private func arm( + receipt: DoryRendererWorkerFenceReceipt, + deviceGeneration: UInt64, + completionContextID: UInt32, + completionRingIndex: UInt32 + ) throws { + let sourceDescriptor = fcntl( + receipt.completionDescriptor.fileDescriptor, + F_DUPFD_CLOEXEC, + 0 + ) + try? receipt.completionDescriptor.close() + guard sourceDescriptor >= 0 else { + throw DoryRendererWorkerVirtioCommandLaneError + .inputDescriptorDuplicationFailed(index: 0) + } + let source = DispatchSource.makeReadSource( + fileDescriptor: sourceDescriptor, + queue: fenceQueue + ) + let key = FenceKey( + workerGeneration: workerGeneration.rawValue, + fenceID: receipt.fenceID + ) + let armed = ArmedFence(source: source) + source.setEventHandler { [weak self] in + self?.fenceBecameReady( + key: key, + deviceGeneration: deviceGeneration, + contextID: completionContextID, + ringIndex: completionRingIndex, + fenceID: receipt.fenceID + ) + } + source.setCancelHandler { Darwin.close(sourceDescriptor) } + let accepted = lock.withLock { () -> Bool in + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration, + armedFences[key] == nil else { return false } + armedFences[key] = armed + return true + } + guard accepted else { + source.cancel() + throw inactiveError(actual: deviceGeneration) + } + source.resume() + } + + private func fenceBecameReady( + key: FenceKey, + deviceGeneration: UInt64, + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) { + let delivery: (ArmedFence, FenceSink)? = lock.withLock { + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration, + key.workerGeneration == workerGeneration.rawValue, + let armed = armedFences.removeValue(forKey: key) else { return nil } + reservedFenceIDs.remove(fenceID) + completedFences = Self.saturatingAdd(completedFences, 1) + guard let fenceSink else { + return (armed, { _, _, _, _ in }) + } + return (armed, fenceSink) + } + guard let delivery else { return } + delivery.0.cancel() + delivery.1(deviceGeneration, contextID, ringIndex, fenceID) + } + + private func releaseFenceReservation(_ fenceID: UInt64) { + _ = lock.withLock { reservedFenceIDs.remove(fenceID) } + } + + private func handleBrokerError( + _ error: DoryRendererWorkerBrokerError, + deviceGeneration: UInt64 + ) { + switch error { + case .workerRejected(.deadlineExpired), + .workerRejected(.resourceExhausted), + .workerRejected(.commandRejected), + .inFlightLimit, + .aggregateReferencedBytesLimit, + .deadlineExpired: + return + default: + failGeneration(deviceGeneration: deviceGeneration, error: .broker(error)) + } + } + + private func brokerTerminated(_ error: DoryRendererWorkerBrokerError) { + let generation: UInt64? = lock.withLock { + guard case .active(let generation) = state else { return nil } + return generation + } + guard let generation else { return } + failGeneration(deviceGeneration: generation, error: .broker(error)) + } + + private func failGeneration( + deviceGeneration: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + let transition: (fences: [ArmedFence], sink: RuntimeFailureSink?)? = lock.withLock { + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration else { return nil } + state = .failed(deviceGeneration: currentGeneration) + reservedFenceIDs.removeAll(keepingCapacity: false) + liveScanoutLeases.removeAll(keepingCapacity: false) + releasingScanoutTokens.removeAll(keepingCapacity: false) + let fences = Array(armedFences.values) + armedFences.removeAll(keepingCapacity: false) + return (fences, runtimeFailureSink) + } + guard let transition else { return } + for fence in transition.fences { fence.cancel() } + transition.sink?(deviceGeneration, error) + Task { await broker.invalidate() } + } + + private func isActive(deviceGeneration: UInt64) -> Bool { + lock.withLock { + guard case .active(let currentGeneration) = state else { return false } + return currentGeneration == deviceGeneration + } + } + + private func inactiveError( + actual deviceGeneration: UInt64 + ) -> DoryRendererWorkerVirtioCommandLaneError { + lock.withLock { + if case .active(let expected) = state, expected != deviceGeneration { + return .staleDeviceGeneration(expected: expected, actual: deviceGeneration) + } + return .notActive(state) + } + } + + private func deadline() -> UInt64 { + let now = DispatchTime.now().uptimeNanoseconds + let (deadline, overflow) = now.addingReportingOverflow(commandDeadlineNanoseconds) + return overflow ? UInt64.max : deadline + } + + private func reject( + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> DoryRendererWorkerVirtioCommandLaneError { + lock.withLock { rejectWhileLocked(error) } + } + + private func rejectWhileLocked( + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> DoryRendererWorkerVirtioCommandLaneError { + rejectedAdmissions = Self.saturatingAdd(rejectedAdmissions, 1) + return error + } + + private static func duplicate(_ descriptors: [FileHandle]) throws -> [FileHandle] { + var owned = [FileHandle]() + owned.reserveCapacity(descriptors.count) + do { + for (index, descriptor) in descriptors.enumerated() { + let duplicate = fcntl(descriptor.fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw DoryRendererWorkerVirtioCommandLaneError + .inputDescriptorDuplicationFailed(index: index) + } + owned.append(FileHandle(fileDescriptor: duplicate, closeOnDealloc: true)) + } + return owned + } catch { + close(owned) + throw error + } + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift new file mode 100644 index 00000000..0e957a42 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift @@ -0,0 +1,278 @@ +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import Foundation +import Metal + +public enum DoryRendererWorkerChannelEvent: Equatable, Sendable { + case interrupted + case invalidated +} + +public enum DoryRendererWorkerChannelFailure: Error, Equatable, Sendable { + case unavailable + case interrupted + case invalidated + case serviceFailure(DoryRendererWorkerRPCFailureCode) + case malformedResult(DoryRendererWorkerContractError) + case descriptorCountMismatch(expected: Int, actual: Int) +} + +public struct DoryRendererWorkerChannelReply: @unchecked Sendable { + public let payload: Data + public let descriptors: [FileHandle] + public let sharedTextureHandle: MTLSharedTextureHandle? + + public init( + payload: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? = nil + ) { + self.payload = payload + self.descriptors = descriptors + self.sharedTextureHandle = sharedTextureHandle + } +} + +/// Transport seam for one authenticated renderer-worker generation. An interruption is terminal: +/// callers must bootstrap a new signed worker and generation instead of reconnecting to an +/// unknown foreign-renderer state. +public protocol DoryRendererWorkerChannel: AnyObject, Sendable { + func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryRendererWorkerChannelEvent) -> Void + ) + func bootstrap( + exactBytes: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) + func exchange( + frame: Data, + descriptors: [FileHandle], + completion: @escaping @Sendable ( + Result + ) -> Void + ) + func invalidate() +} + +/// Runner-local NSXPC adapter. The service name selects a launchd endpoint; the worker's audit +/// token code requirement is the peer authentication authority. No PID, path, environment value, +/// or reconnect heuristic participates in that decision. +public final class DoryRendererWorkerXPCChannel: + NSObject, + DoryRendererWorkerChannel, + @unchecked Sendable +{ + private enum State { + case active + case interrupted + case invalidated + } + + private final class ReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } + } + + private let connection: NSXPCConnection + private let stateLock = NSLock() + private var state: State = .active + private var lifecycleHandlers = [@Sendable (DoryRendererWorkerChannelEvent) -> Void]() + + public init(codeDirectoryHash: DoryCodeDirectoryHash) { + connection = NSXPCConnection(serviceName: DoryRendererWorkerIdentity.serviceName) + super.init() + connection.remoteObjectInterface = DoryRendererWorkerXPCInterface.make() + connection.interruptionHandler = { [weak self] in + self?.transition(to: .interrupted) + } + connection.invalidationHandler = { [weak self] in + self?.transition(to: .invalidated) + } + connection.setCodeSigningRequirement( + DoryRendererWorkerIdentity.exactWorkerCodeSigningRequirement( + codeDirectoryHash: codeDirectoryHash + ) + ) + connection.activate() + } + + public func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryRendererWorkerChannelEvent) -> Void + ) { + let immediate: DoryRendererWorkerChannelEvent? = stateLock.withLock { + switch state { + case .active: + lifecycleHandlers.append(handler) + return nil + case .interrupted: + return .interrupted + case .invalidated: + return .invalidated + } + } + if let immediate { handler(immediate) } + } + + public func bootstrap( + exactBytes: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard isActive else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.interrupted)) + self?.transition(to: .interrupted) + }) as? DoryRendererWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.bootstrap(exactBytes) { [weak self] bytes in + guard once.claim() else { return } + do { + switch try DoryRendererWorkerRPCResultCodec.decode(bytes) { + case let .success(payload, descriptorCount): + guard descriptorCount == 0 else { + completion(.failure(.descriptorCountMismatch( + expected: 0, + actual: Int(descriptorCount) + ))) + self?.invalidate() + return + } + completion(.success(payload)) + case .failure(let code): + completion(.failure(.serviceFailure(code))) + } + } catch let error as DoryRendererWorkerContractError { + completion(.failure(.malformedResult(error))) + self?.invalidate() + } catch { + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func exchange( + frame: Data, + descriptors: [FileHandle], + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard isActive else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.interrupted)) + self?.transition(to: .interrupted) + }) as? DoryRendererWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.exchange(frame, descriptors: descriptors) { + [weak self] bytes, replyDescriptors, sharedTextureHandle in + guard once.claim() else { + Self.close(replyDescriptors) + return + } + do { + switch try DoryRendererWorkerRPCResultCodec.decode(bytes) { + case let .success(payload, descriptorCount): + guard Int(descriptorCount) == replyDescriptors.count else { + Self.close(replyDescriptors) + completion(.failure(.descriptorCountMismatch( + expected: Int(descriptorCount), + actual: replyDescriptors.count + ))) + self?.invalidate() + return + } + completion(.success(DoryRendererWorkerChannelReply( + payload: payload, + descriptors: replyDescriptors, + sharedTextureHandle: sharedTextureHandle + ))) + case .failure(let code): + guard replyDescriptors.isEmpty, sharedTextureHandle == nil else { + Self.close(replyDescriptors) + completion(.failure(.descriptorCountMismatch( + expected: 0, + actual: replyDescriptors.count + ))) + self?.invalidate() + return + } + completion(.failure(.serviceFailure(code))) + } + } catch let error as DoryRendererWorkerContractError { + Self.close(replyDescriptors) + completion(.failure(.malformedResult(error))) + self?.invalidate() + } catch { + Self.close(replyDescriptors) + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func invalidate() { + transition(to: .invalidated) + connection.invalidate() + } + + private var isActive: Bool { + stateLock.withLock { + if case .active = state { return true } + return false + } + } + + private func transition(to requested: State) { + let delivery: ( + handlers: [@Sendable (DoryRendererWorkerChannelEvent) -> Void], + event: DoryRendererWorkerChannelEvent + )? = stateLock.withLock { + guard case .active = state else { return nil } + state = requested + let handlers = lifecycleHandlers + lifecycleHandlers.removeAll(keepingCapacity: false) + switch requested { + case .active: + return nil + case .interrupted: + return (handlers, .interrupted) + case .invalidated: + return (handlers, .invalidated) + } + } + guard let delivery else { return } + for handler in delivery.handlers { handler(delivery.event) } + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift deleted file mode 100644 index ccccef3c..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation - -public enum DaxWindowError: Error, Equatable { - case invalidWindow - case unaligned - case outOfBounds - case overlap - case missingMapping - case mappingFailed(String) - case unmappingFailed(String) -} - -public struct DaxMapping: Equatable, Sendable { - public var fileHandle: UInt64 - public var fileOffset: UInt64 - public var memoryOffset: UInt64 - public var length: UInt64 - public var flags: UInt64 - - public init(fileHandle: UInt64, fileOffset: UInt64, memoryOffset: UInt64, length: UInt64, flags: UInt64 = 0) { - self.fileHandle = fileHandle - self.fileOffset = fileOffset - self.memoryOffset = memoryOffset - self.length = length - self.flags = flags - } -} - -public protocol DaxMappingBackend: AnyObject, Sendable { - func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws - func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws -} - -public final class DaxWindow: @unchecked Sendable { - public static let defaultSize: UInt64 = 4 * 1024 * 1024 * 1024 - public static let pageSize: UInt64 = HostPage.size - - public let guestBase: UInt64 - public let length: UInt64 - private let backend: DaxMappingBackend? - private var mappings: [DaxMapping] = [] - private let lock = NSLock() - - public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize, backend: DaxMappingBackend? = nil) throws { - guard length > 0, guestBase.isMultiple(of: Self.pageSize), length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.invalidWindow - } - self.guestBase = guestBase - self.length = length - self.backend = backend - } - - public var activeMappings: [DaxMapping] { - lock.lock() - defer { lock.unlock() } - return mappings.sorted { $0.memoryOffset < $1.memoryOffset } - } - - public func setup(_ request: FuseSetupMappingIn, fileDescriptor: Int32? = nil) throws -> DaxMapping { - let mapping = DaxMapping( - fileHandle: request.fileHandle, - fileOffset: request.fileOffset, - memoryOffset: request.memoryOffset, - length: request.length, - flags: request.flags - ) - try validate(mapping) - if backend != nil, fileDescriptor == nil { - throw DaxWindowError.mappingFailed("missing file descriptor") - } - lock.lock() - defer { lock.unlock() } - guard !mappings.contains(where: { rangesOverlap($0.memoryOffset, $0.length, mapping.memoryOffset, mapping.length) }) else { - throw DaxWindowError.overlap - } - if let backend, let fileDescriptor { - try backend.map(mapping, fileDescriptor: fileDescriptor, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) - } - mappings.append(mapping) - return mapping - } - - public func remove(_ request: FuseRemoveMappingIn) throws { - lock.lock() - defer { lock.unlock() } - for entry in request.mappings { - guard entry.memoryOffset.isMultiple(of: Self.pageSize), - entry.length > 0, - entry.length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.unaligned - } - let overlapping = mappings.indices.filter { - rangesOverlap(mappings[$0].memoryOffset, mappings[$0].length, entry.memoryOffset, entry.length) - } - for index in overlapping.reversed() { - let mapping = mappings[index] - if let backend { - try backend.unmap(mapping, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) - } - mappings.remove(at: index) - } - } - } - - public func guestAddress(forMemoryOffset offset: UInt64) throws -> UInt64 { - guard offset < length else { throw DaxWindowError.outOfBounds } - return guestBase + offset - } - - private func validate(_ mapping: DaxMapping) throws { - guard mapping.fileOffset.isMultiple(of: Self.pageSize), - mapping.memoryOffset.isMultiple(of: Self.pageSize), - mapping.length > 0, - mapping.length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.unaligned - } - guard mapping.memoryOffset < length, - mapping.length <= length, - mapping.memoryOffset <= length - mapping.length else { - throw DaxWindowError.outOfBounds - } - } - - private func rangesOverlap(_ aStart: UInt64, _ aLength: UInt64, _ bStart: UInt64, _ bLength: UInt64) -> Bool { - let aEnd = aStart + aLength - let bEnd = bStart + bLength - return aStart < bEnd && bStart < aEnd - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift new file mode 100644 index 00000000..f3393833 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift @@ -0,0 +1,478 @@ +import DoryFSWorkerContracts +import Foundation + +/// The exact request-memory ceilings that apply to one share after intersecting its bootstrap +/// authority with the worker-wide envelope. Long-lived HostFS resource limits remain worker-side; +/// these values are the complete VMM admission contract held through used-ring publication. +public struct DoryFSWorkerEffectiveAdmissionLimits: Equatable, Sendable { + public let maximumRequestBytes: Int + public let maximumResponseBytes: Int + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + + init( + worker: DoryFSWorkerLimits, + share: DoryFSShareResourceLimits + ) { + maximumRequestBytes = min( + worker.maximumRequestBytes, + share.maximumAggregateRequestBytes + ) + maximumResponseBytes = min( + worker.maximumResponseBytes, + share.maximumAggregateResponseBytes + ) + maximumInFlightRequests = min( + worker.maximumInFlightRequests, + share.maximumInFlightRequests + ) + maximumAggregateRequestBytes = min( + worker.maximumAggregateRequestBytes, + share.maximumAggregateRequestBytes + ) + maximumAggregateResponseBytes = min( + worker.maximumAggregateResponseBytes, + share.maximumAggregateResponseBytes + ) + } +} + +struct DoryFSWorkerAdmissionShape: Equatable, Sendable { + let requestBytes: Int + let responseBytes: Int +} + +struct DoryFSWorkerAdmissionWaiterID: Hashable, Sendable { + private let rawValue: UUID + + init() { + rawValue = UUID() + } +} + +enum DoryFSWorkerFrontendAdmissionResult: Sendable { + case admitted(DoryFSWorkerAdmissionLease) + case deferred + case rejected(DoryFSWorkerBrokerError) +} + +/// Completes a deferred frontend admission exactly once. A workspace terminal event must be +/// observable here: silently deleting a waiter would leave its virtqueue available forever while +/// the frontend continued to believe a capacity grant was pending. +enum DoryFSWorkerFrontendAdmissionResolution: Sendable { + case granted(DoryFSWorkerAdmissionLease) + case terminated(DoryFSWorkerBrokerError) +} + +/// One workspace reservation. The authority owns the counters; both explicit release and deinit +/// are idempotent so a reset or abandoned deferred queue cannot strand workspace capacity. +final class DoryFSWorkerAdmissionLease: @unchecked Sendable { + fileprivate let identifier: UUID + fileprivate let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + fileprivate let authority: DoryFSWorkerWorkspaceAdmissionAuthority + + fileprivate init( + identifier: UUID, + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape, + authority: DoryFSWorkerWorkspaceAdmissionAuthority + ) { + self.identifier = identifier + self.shareCapabilityID = shareCapabilityID + self.shape = shape + self.authority = authority + } + + func release() { + authority.release(identifier: identifier) + } + + var isValid: Bool { + authority.containsReservation(identifier: identifier) + } + + deinit { + release() + } +} + +struct DoryFSWorkerWorkspaceAdmissionSnapshot: Equatable, Sendable { + let inFlightRequests: Int + let peakInFlightRequests: Int + let aggregateRequestBytes: Int + let aggregateResponseBytes: Int + let deferredWaiters: Int +} + +/// Synchronous workspace-wide admission used before a virtqueue pop. Every broker created from one +/// bootstrap shares this authority. Capacity released by either share is reserved for eligible +/// waiters in FIFO order before their callbacks run, preventing direct kicks from stealing a fair +/// reschedule. A temporarily share-blocked waiter does not head-of-line block another share that is +/// eligible under the workspace envelope. +final class DoryFSWorkerWorkspaceAdmissionAuthority: @unchecked Sendable { + private struct Usage { + var inFlightRequests = 0 + var peakInFlightRequests = 0 + var aggregateRequestBytes = 0 + var aggregateResponseBytes = 0 + } + + private struct Reservation { + let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + } + + private struct Waiter { + let identifier: DoryFSWorkerAdmissionWaiterID + let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + let onResolved: @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + } + + private struct Delivery { + let callback: @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + let resolution: DoryFSWorkerFrontendAdmissionResolution + } + + private let workerLimits: DoryFSWorkerLimits + private let shareLimits: [DoryFSShareCapabilityID: DoryFSShareResourceLimits] + private let lock = NSLock() + private var workspaceUsage = Usage() + private var shareUsage = [DoryFSShareCapabilityID: Usage]() + private var reservations = [UUID: Reservation]() + private var waiters = [Waiter]() + private var waiterIDs = Set() + private var active = true + + init( + workerLimits: DoryFSWorkerLimits, + shareLimits: [DoryFSShareCapabilityID: DoryFSShareResourceLimits] + ) { + precondition(!shareLimits.isEmpty) + self.workerLimits = workerLimits + self.shareLimits = shareLimits + } + + func effectiveLimits( + for shareCapabilityID: DoryFSShareCapabilityID + ) -> DoryFSWorkerEffectiveAdmissionLimits? { + guard let share = shareLimits[shareCapabilityID] else { return nil } + return DoryFSWorkerEffectiveAdmissionLimits(worker: workerLimits, share: share) + } + + func resourceLimits( + for shareCapabilityID: DoryFSShareCapabilityID + ) -> DoryFSShareResourceLimits? { + shareLimits[shareCapabilityID] + } + + func request( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape, + waiterID: DoryFSWorkerAdmissionWaiterID, + onResolved: @escaping @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + ) -> DoryFSWorkerFrontendAdmissionResult { + var deliveries = [Delivery]() + let result: DoryFSWorkerFrontendAdmissionResult = lock.withLock { + guard active else { return .rejected(.invalidAdmissionAuthority) } + if let rejection = permanentRejectionLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .rejected(rejection) + } + if waiters.isEmpty, fitsLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .admitted(reserveLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + if waiterIDs.insert(waiterID).inserted { + waiters.append(Waiter( + identifier: waiterID, + shareCapabilityID: shareCapabilityID, + shape: shape, + onResolved: onResolved + )) + } + deliveries = grantEligibleWaitersLocked() + return .deferred + } + deliver(deliveries) + return result + } + + func acquireImmediately( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Result { + lock.withLock { + guard active else { return .failure(.invalidAdmissionAuthority) } + if let rejection = permanentRejectionLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .failure(rejection) + } + guard fitsLocked(shareCapabilityID: shareCapabilityID, shape: shape) else { + return .failure(saturationErrorLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + return .success(reserveLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + } + + func cancel(waiterID: DoryFSWorkerAdmissionWaiterID) { + lock.withLock { + guard waiterIDs.remove(waiterID) != nil else { return } + waiters.removeAll { $0.identifier == waiterID } + } + } + + func invalidate(error: DoryFSWorkerBrokerError) { + let deliveries: [Delivery] = lock.withLock { + guard active else { return [] } + active = false + let deliveries = waiters.map { + Delivery(callback: $0.onResolved, resolution: .terminated(error)) + } + waiters.removeAll(keepingCapacity: false) + waiterIDs.removeAll(keepingCapacity: false) + reservations.removeAll(keepingCapacity: false) + workspaceUsage = Usage() + shareUsage.removeAll(keepingCapacity: false) + return deliveries + } + deliver(deliveries) + } + + func validates( + _ lease: DoryFSWorkerAdmissionLease, + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Bool { + guard lease.authority === self, + lease.shareCapabilityID == shareCapabilityID, + lease.shape == shape else { return false } + return lock.withLock { + guard let reservation = reservations[lease.identifier] else { return false } + return reservation.shareCapabilityID == shareCapabilityID + && reservation.shape == shape + } + } + + func snapshot( + for shareCapabilityID: DoryFSShareCapabilityID? = nil + ) -> DoryFSWorkerWorkspaceAdmissionSnapshot { + lock.withLock { + let usage = shareCapabilityID.flatMap { shareUsage[$0] } ?? workspaceUsage + let deferred = shareCapabilityID.map { capability in + waiters.lazy.filter { $0.shareCapabilityID == capability }.count + } ?? waiters.count + return DoryFSWorkerWorkspaceAdmissionSnapshot( + inFlightRequests: usage.inFlightRequests, + peakInFlightRequests: usage.peakInFlightRequests, + aggregateRequestBytes: usage.aggregateRequestBytes, + aggregateResponseBytes: usage.aggregateResponseBytes, + deferredWaiters: deferred + ) + } + } + + fileprivate func release(identifier: UUID) { + let deliveries: [Delivery] = lock.withLock { + guard let reservation = reservations.removeValue(forKey: identifier) else { return [] } + releaseUsageLocked( + &workspaceUsage, + shape: reservation.shape + ) + guard var usage = shareUsage[reservation.shareCapabilityID] else { + preconditionFailure("missing filesystem share admission usage") + } + releaseUsageLocked(&usage, shape: reservation.shape) + shareUsage[reservation.shareCapabilityID] = usage + return grantEligibleWaitersLocked() + } + deliver(deliveries) + } + + fileprivate func containsReservation(identifier: UUID) -> Bool { + lock.withLock { active && reservations[identifier] != nil } + } + + private func permanentRejectionLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerBrokerError? { + guard let effective = effectiveLimits(for: shareCapabilityID) else { + return .invalidAdmissionAuthority + } + guard shape.requestBytes >= 0, + shape.requestBytes <= effective.maximumRequestBytes else { + return .requestTooLarge( + limit: effective.maximumRequestBytes, + actual: shape.requestBytes + ) + } + guard shape.responseBytes >= 0, + shape.responseBytes <= effective.maximumResponseBytes else { + return .responseCapacityTooLarge( + limit: effective.maximumResponseBytes, + actual: shape.responseBytes + ) + } + return nil + } + + private func fitsLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Bool { + guard let effective = effectiveLimits(for: shareCapabilityID) else { return false } + let share = shareUsage[shareCapabilityID] ?? Usage() + return adding(shape.requestBytes, to: workspaceUsage.aggregateRequestBytes) + .map { $0 <= workerLimits.maximumAggregateRequestBytes } == true + && adding(shape.responseBytes, to: workspaceUsage.aggregateResponseBytes) + .map { $0 <= workerLimits.maximumAggregateResponseBytes } == true + && workspaceUsage.inFlightRequests < workerLimits.maximumInFlightRequests + && adding(shape.requestBytes, to: share.aggregateRequestBytes) + .map { $0 <= effective.maximumAggregateRequestBytes } == true + && adding(shape.responseBytes, to: share.aggregateResponseBytes) + .map { $0 <= effective.maximumAggregateResponseBytes } == true + && share.inFlightRequests < effective.maximumInFlightRequests + } + + private func saturationErrorLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerBrokerError { + guard let effective = effectiveLimits(for: shareCapabilityID) else { + return .invalidAdmissionAuthority + } + let share = shareUsage[shareCapabilityID] ?? Usage() + if workspaceUsage.inFlightRequests >= workerLimits.maximumInFlightRequests + || share.inFlightRequests >= effective.maximumInFlightRequests { + return .inFlightLimit(limit: effective.maximumInFlightRequests) + } + let workspaceRequest = adding( + shape.requestBytes, + to: workspaceUsage.aggregateRequestBytes + ) ?? Int.max + let shareRequest = adding(shape.requestBytes, to: share.aggregateRequestBytes) ?? Int.max + if workspaceRequest > workerLimits.maximumAggregateRequestBytes + || shareRequest > effective.maximumAggregateRequestBytes { + return .aggregateRequestLimit( + limit: min( + workerLimits.maximumAggregateRequestBytes, + effective.maximumAggregateRequestBytes + ), + requested: max(workspaceRequest, shareRequest) + ) + } + let workspaceResponse = adding( + shape.responseBytes, + to: workspaceUsage.aggregateResponseBytes + ) ?? Int.max + let shareResponse = adding(shape.responseBytes, to: share.aggregateResponseBytes) ?? Int.max + return .aggregateResponseLimit( + limit: min( + workerLimits.maximumAggregateResponseBytes, + effective.maximumAggregateResponseBytes + ), + requested: max(workspaceResponse, shareResponse) + ) + } + + private func reserveLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerAdmissionLease { + precondition(fitsLocked(shareCapabilityID: shareCapabilityID, shape: shape)) + reserveUsageLocked(&workspaceUsage, shape: shape) + var usage = shareUsage[shareCapabilityID] ?? Usage() + reserveUsageLocked(&usage, shape: shape) + shareUsage[shareCapabilityID] = usage + let identifier = UUID() + reservations[identifier] = Reservation( + shareCapabilityID: shareCapabilityID, + shape: shape + ) + return DoryFSWorkerAdmissionLease( + identifier: identifier, + shareCapabilityID: shareCapabilityID, + shape: shape, + authority: self + ) + } + + private func grantEligibleWaitersLocked() -> [Delivery] { + guard active else { return [] } + var deliveries = [Delivery]() + while let index = waiters.firstIndex(where: { + fitsLocked(shareCapabilityID: $0.shareCapabilityID, shape: $0.shape) + }) { + let waiter = waiters.remove(at: index) + waiterIDs.remove(waiter.identifier) + deliveries.append(Delivery( + callback: waiter.onResolved, + resolution: .granted(reserveLocked( + shareCapabilityID: waiter.shareCapabilityID, + shape: waiter.shape + )) + )) + } + return deliveries + } + + private func deliver(_ deliveries: [Delivery]) { + for delivery in deliveries { + DispatchQueue.global(qos: .userInitiated).async { + if case .granted(let lease) = delivery.resolution, + !lease.isValid { + return + } + delivery.callback(delivery.resolution) + } + } + } + + private func reserveUsageLocked( + _ usage: inout Usage, + shape: DoryFSWorkerAdmissionShape + ) { + usage.inFlightRequests += 1 + usage.peakInFlightRequests = max( + usage.peakInFlightRequests, + usage.inFlightRequests + ) + usage.aggregateRequestBytes += shape.requestBytes + usage.aggregateResponseBytes += shape.responseBytes + } + + private func releaseUsageLocked( + _ usage: inout Usage, + shape: DoryFSWorkerAdmissionShape + ) { + precondition(usage.inFlightRequests > 0) + precondition(usage.aggregateRequestBytes >= shape.requestBytes) + precondition(usage.aggregateResponseBytes >= shape.responseBytes) + usage.inFlightRequests -= 1 + usage.aggregateRequestBytes -= shape.requestBytes + usage.aggregateResponseBytes -= shape.responseBytes + } + + private func adding(_ increment: Int, to value: Int) -> Int? { + let (sum, overflow) = value.addingReportingOverflow(increment) + return increment >= 0 && !overflow ? sum : nil + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift new file mode 100644 index 00000000..fba990b2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift @@ -0,0 +1,964 @@ +import DoryFSWorkerContracts +import Foundation + +/// Events supplied by a future XPC adapter's `interruptionHandler` and `invalidationHandler`. +/// Either event is fail-stop for one broker generation; reconnection requires a new broker and a +/// newly bootstrapped worker authority rather than silently reusing lost handle identity. +public enum DoryFSWorkerChannelEvent: Equatable, Sendable { + case interrupted + case invalidated +} + +public enum DoryFSWorkerChannelFailure: Error, Equatable, Sendable { + case unavailable + case interrupted + case invalidated + case serviceFailure(DoryFSWorkerRPCFailureCode) +} + +/// Transport-neutral boundary implemented by a future signed NSXPC/App Sandbox adapter. Each call +/// carries one exact bounded frame. This protocol does not launch a process and an implementation +/// backed by an ordinary child process is not a security boundary. +public protocol DoryFSWorkerChannel: AnyObject, Sendable { + func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) + func send( + frame: Data, + completion: @escaping @Sendable (Result) -> Void + ) + func sendOneWay(frame: Data) + func invalidate() +} + +public enum DoryFSWorkerBrokerState: Equatable, Sendable { + case active + case draining + case drained + case interrupted + case invalidated + case protocolViolation +} + +public enum DoryFSWorkerBrokerError: Error, Equatable, Sendable { + case notActive(DoryFSWorkerBrokerState) + case requestTooLarge(limit: Int, actual: Int) + case responseCapacityTooLarge(limit: Int, actual: Int) + case operationDeadlineExpired + case operationDeadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case drainDeadlineExpired + case drainDeadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case inFlightLimit(limit: Int) + case aggregateRequestLimit(limit: Int, requested: Int) + case aggregateResponseLimit(limit: Int, requested: Int) + case invalidAdmissionAuthority + case duplicateCorrelationID(UInt64) + case invalidCorrelationID + case requestIDExhausted + case unknownCorrelationID(UInt64) + case requestDeadlineExpired(correlationID: UInt64) + case channelFailure(DoryFSWorkerChannelFailure) + case channelInterrupted + case channelInvalidated + case workerRejected(DoryFSWorkerRejectionCode) + case malformedReply(DoryFSWorkerContractError) + case replyIdentityMismatch + case responseTooLarge(limit: Int, actual: Int) + case drainReplyMismatch + case connectionTeardownWithInFlight( + inFlightRequests: Int, + pendingPublications: Int + ) +} + +public struct DoryFSWorkerBrokerSnapshot: Equatable, Sendable { + public let state: DoryFSWorkerBrokerState + public let generation: DoryFSWorkerGeneration + public let inFlightRequests: Int + public let pendingPublications: Int + public let aggregateRequestBytes: Int + public let aggregateResponseReservations: Int + public let rejectedAdmissions: UInt64 + public let completedRequests: UInt64 + public let lateReplies: UInt64 + public let protocolViolations: UInt64 + public let sentInterrupts: UInt64 +} + +/// Host-owned response plus the exact acknowledgement token that retains worker-side grants. +/// The frontend must commit only after the used-ring publication succeeds, or discard on every +/// pre-publication failure. Dropping this value without either acknowledgement fail-stops the +/// broker at the original operation deadline. +public struct DoryFSWorkerExecution: Equatable, Sendable { + public let response: Data + public let publication: DoryFSWorkerPublication + public let acknowledgementDeadlineUptimeNanoseconds: UInt64 + + public init( + response: Data, + publication: DoryFSWorkerPublication, + acknowledgementDeadlineUptimeNanoseconds: UInt64 + ) { + self.response = response + self.publication = publication + self.acknowledgementDeadlineUptimeNanoseconds = + acknowledgementDeadlineUptimeNanoseconds + } +} + +/// VMM-side authority for one immutable share capability and one worker generation. +/// +/// The actor owns every in-flight reservation and validates a complete reply before returning it to +/// the frontend. A deadline does not reclaim capacity and continue using an unresponsive worker: +/// it invalidates the whole channel, because the abandoned worker may still hold descriptors or be +/// completing a mutation. The future supervisor must then terminate that signed worker process. +public actor DoryFSWorkerBroker { + /// Minimal identity retained after the exact request frame has been sent. Keeping the complete + /// `DoryFSWorkerRequest` here also retained its payload beside the encoded XPC frame until + /// publication acknowledgement, doubling large-request residency for no authority benefit. + private struct RequestIdentity { + let requestID: UInt64 + let correlationID: UInt64 + let deadlineUptimeNanoseconds: UInt64 + } + + private struct PendingRequest { + let identity: RequestIdentity + let admissionLease: DoryFSWorkerAdmissionLease + let continuation: CheckedContinuation + var timeoutTask: Task? + } + + private struct PendingPublication { + let identity: RequestIdentity + let admissionLease: DoryFSWorkerAdmissionLease + var timeoutTask: Task? + } + + private struct PendingDrain { + let deadlineUptimeNanoseconds: UInt64 + let continuation: CheckedContinuation + var requestSent: Bool + var timeoutTask: Task? + } + + public nonisolated let limits: DoryFSWorkerLimits + public nonisolated let shareResourceLimits: DoryFSShareResourceLimits + public nonisolated let effectiveAdmissionLimits: DoryFSWorkerEffectiveAdmissionLimits + public nonisolated let shareCapabilityID: DoryFSShareCapabilityID + public nonisolated let generation: DoryFSWorkerGeneration + + private let channel: any DoryFSWorkerChannel + private nonisolated let admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority + private var state: DoryFSWorkerBrokerState = .active + private var nextRequestID: UInt64 = 1 + private var pendingByRequestID = [UInt64: PendingRequest]() + private var pendingPublicationsByRequestID = [UInt64: PendingPublication]() + private var requestIDByCorrelationID = [UInt64: UInt64]() + private var pendingDrain: PendingDrain? + private var rejectedAdmissions: UInt64 = 0 + private var completedRequests: UInt64 = 0 + private var lateReplies: UInt64 = 0 + private var protocolViolations: UInt64 = 0 + private var sentInterrupts: UInt64 = 0 + + public init( + shareCapabilityID: DoryFSShareCapabilityID, + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits = .production, + shareResourceLimits: DoryFSShareResourceLimits = .production, + channel: any DoryFSWorkerChannel + ) { + let authority = DoryFSWorkerWorkspaceAdmissionAuthority( + workerLimits: limits, + shareLimits: [shareCapabilityID: shareResourceLimits] + ) + self.shareCapabilityID = shareCapabilityID + self.generation = generation + self.limits = limits + self.shareResourceLimits = shareResourceLimits + self.effectiveAdmissionLimits = authority.effectiveLimits(for: shareCapabilityID)! + self.admissionAuthority = authority + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + init( + shareCapabilityID: DoryFSShareCapabilityID, + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits, + shareResourceLimits: DoryFSShareResourceLimits, + admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority, + channel: any DoryFSWorkerChannel + ) { + guard let effective = admissionAuthority.effectiveLimits(for: shareCapabilityID), + admissionAuthority.resourceLimits(for: shareCapabilityID) == shareResourceLimits else { + preconditionFailure("filesystem broker share is absent from admission authority") + } + self.shareCapabilityID = shareCapabilityID + self.generation = generation + self.limits = limits + self.shareResourceLimits = shareResourceLimits + self.effectiveAdmissionLimits = effective + self.admissionAuthority = admissionAuthority + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + nonisolated func requestFrontendAdmission( + shape: DoryFSWorkerAdmissionShape, + waiterID: DoryFSWorkerAdmissionWaiterID, + onResolved: @escaping @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + ) -> DoryFSWorkerFrontendAdmissionResult { + admissionAuthority.request( + shareCapabilityID: shareCapabilityID, + shape: shape, + waiterID: waiterID, + onResolved: onResolved + ) + } + + nonisolated func cancelFrontendAdmission( + waiterID: DoryFSWorkerAdmissionWaiterID + ) { + admissionAuthority.cancel(waiterID: waiterID) + } + + nonisolated var workspaceAdmissionSnapshot: DoryFSWorkerWorkspaceAdmissionSnapshot { + admissionAuthority.snapshot() + } + + /// Admits one immutable request frame. `correlationID` is the guest-visible FUSE unique value; + /// the broker allocates a separate never-reused request ID so a late callback cannot alias a + /// later FUSE request that happens to reuse its unique value. + public func execute( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64 + ) async throws -> DoryFSWorkerExecution { + try await executeAdmitted( + correlationID: correlationID, + opcodeClass: opcodeClass, + request: request, + responseCapacity: responseCapacity, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + suppliedAdmissionLease: nil + ) + } + + func execute( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64, + admissionLease: DoryFSWorkerAdmissionLease + ) async throws -> DoryFSWorkerExecution { + try await executeAdmitted( + correlationID: correlationID, + opcodeClass: opcodeClass, + request: request, + responseCapacity: responseCapacity, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + suppliedAdmissionLease: admissionLease + ) + } + + private func executeAdmitted( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64, + suppliedAdmissionLease: DoryFSWorkerAdmissionLease? + ) async throws -> DoryFSWorkerExecution { + // Preserve the broker's exact terminal cause. A terminal transition invalidates the + // workspace authority as part of the same actor turn, so consulting that authority first + // would collapse `.interrupted`, `.drained`, and protocol-failure states into the less + // useful `invalidAdmissionAuthority` error. + guard state == .active else { + suppliedAdmissionLease?.release() + throw reject(.notActive(state)) + } + let shape = DoryFSWorkerAdmissionShape( + requestBytes: request.count, + responseBytes: responseCapacity + ) + let admissionLease: DoryFSWorkerAdmissionLease + if let suppliedAdmissionLease { + admissionLease = suppliedAdmissionLease + } else { + switch admissionAuthority.acquireImmediately( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + case .success(let lease): + admissionLease = lease + case .failure(let error): + throw reject(error) + } + } + guard admissionAuthority.validates( + admissionLease, + shareCapabilityID: shareCapabilityID, + shape: shape + ) else { + admissionLease.release() + throw reject(.invalidAdmissionAuthority) + } + guard correlationID != 0 else { + admissionLease.release() + throw reject(.invalidCorrelationID) + } + guard request.count <= limits.maximumRequestBytes else { + admissionLease.release() + throw reject(.requestTooLarge(limit: limits.maximumRequestBytes, actual: request.count)) + } + guard responseCapacity >= 0, + responseCapacity <= limits.maximumResponseBytes, + let responseCapacity32 = UInt32(exactly: responseCapacity) else { + admissionLease.release() + throw reject(.responseCapacityTooLarge( + limit: limits.maximumResponseBytes, + actual: responseCapacity + )) + } + let now = DispatchTime.now().uptimeNanoseconds + let remaining: UInt64 + do { + remaining = try validateOperationDeadline(deadlineUptimeNanoseconds, now: now) + } catch { + admissionLease.release() + throw error + } + guard requestIDByCorrelationID[correlationID] == nil else { + admissionLease.release() + throw reject(.duplicateCorrelationID(correlationID)) + } + guard nextRequestID != 0 else { + admissionLease.release() + throw reject(.requestIDExhausted) + } + + let requestID = nextRequestID + nextRequestID = requestID == UInt64.max ? 0 : requestID + 1 + let frame: Data + do { + let envelope = try DoryFSWorkerRequest( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: requestID, + correlationID: correlationID, + opcodeClass: opcodeClass, + responseCapacity: responseCapacity32, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + payload: request + ) + frame = try DoryFSWorkerFrameCodec.encode( + .execute(envelope), + maximumFrameBytes: limits.maximumFrameBytes + ) + } catch { + admissionLease.release() + throw error + } + + return try await withCheckedThrowingContinuation { continuation in + pendingByRequestID[requestID] = PendingRequest( + identity: RequestIdentity( + requestID: requestID, + correlationID: correlationID, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds + ), + admissionLease: admissionLease, + continuation: continuation, + timeoutTask: nil + ) + requestIDByCorrelationID[correlationID] = requestID + + channel.send(frame: frame) { [weak self] result in + guard let self else { return } + Task { await self.receiveReply(result, expectedRequestID: requestID) } + } + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expireRequest(requestID) + } + pendingByRequestID[requestID]?.timeoutTask = timeoutTask + } + } + + /// Sends a priority cancellation for the request with the given guest correlation ID. The + /// reservation remains owned until the worker replies or the channel is invalidated. + @discardableResult + public func interrupt( + correlationID: UInt64, + deadlineUptimeNanoseconds: UInt64 + ) throws -> Bool { + guard state == .active || state == .draining else { + throw DoryFSWorkerBrokerError.notActive(state) + } + let now = DispatchTime.now().uptimeNanoseconds + _ = try validateOperationDeadline(deadlineUptimeNanoseconds, now: now) + guard let requestID = requestIDByCorrelationID[correlationID], + let pending = pendingByRequestID[requestID] else { + return false + } + let interrupt = try DoryFSWorkerInterrupt( + generation: generation, + shareCapabilityID: shareCapabilityID, + targetRequestID: requestID, + targetCorrelationID: pending.identity.correlationID, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds + ) + let frame = try DoryFSWorkerFrameCodec.encode( + .interrupt(interrupt), + maximumFrameBytes: limits.maximumFrameBytes + ) + sentInterrupts = saturatingAdd(sentInterrupts, 1) + channel.sendOneWay(frame: frame) + return true + } + + /// Commits worker-side lookup/handle grants only after the exact virtqueue chain has been + /// published into the used ring. + public func commitPublication(_ publication: DoryFSWorkerPublication) throws { + try acknowledgePublication(publication, committed: true) + } + + /// Rolls back worker-side grants when the host response never became guest-visible. + public func discardPublication(_ publication: DoryFSWorkerPublication) throws { + try acknowledgePublication(publication, committed: false) + } + + /// Stops new admission, waits for all admitted work, then requires an authenticated drained + /// acknowledgement for this exact share generation. Timeout is fail-stop. + public func drain(deadlineUptimeNanoseconds: UInt64) async throws { + guard state == .active else { throw DoryFSWorkerBrokerError.notActive(state) } + let now = DispatchTime.now().uptimeNanoseconds + let remaining = try validateDrainDeadline(deadlineUptimeNanoseconds, now: now) + state = .draining + try await withCheckedThrowingContinuation { continuation in + pendingDrain = PendingDrain( + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + continuation: continuation, + requestSent: false, + timeoutTask: nil + ) + sendDrainIfReady() + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expireDrain() + } + pendingDrain?.timeoutTask = timeoutTask + } + } + + /// Invalidates this generation and its channel. The broker is deliberately not restartable; + /// the supervisor must create a new worker, capability bootstrap, and broker generation. + public func invalidate() { + guard state != .invalidated else { return } + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + /// Retires one share after its exact FUSE_DESTROY response was published and committed. The + /// workspace channel is deliberately retained because sibling shares use the same signed XPC + /// worker and may still be completing their own teardown. Any residual request/publication is + /// an ordering violation and remains fail-stop for the complete channel. + func completeConnectionTeardown() throws { + guard state == .active else { throw DoryFSWorkerBrokerError.notActive(state) } + guard pendingByRequestID.isEmpty, pendingPublicationsByRequestID.isEmpty else { + let error = DoryFSWorkerBrokerError.connectionTeardownWithInFlight( + inFlightRequests: pendingByRequestID.count, + pendingPublications: pendingPublicationsByRequestID.count + ) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: error) + channel.invalidate() + throw error + } + state = .drained + } + + public func snapshot() -> DoryFSWorkerBrokerSnapshot { + let admission = admissionAuthority.snapshot(for: shareCapabilityID) + return DoryFSWorkerBrokerSnapshot( + state: state, + generation: generation, + inFlightRequests: admission.inFlightRequests, + pendingPublications: pendingPublicationsByRequestID.count, + aggregateRequestBytes: admission.aggregateRequestBytes, + aggregateResponseReservations: admission.aggregateResponseBytes, + rejectedAdmissions: rejectedAdmissions, + completedRequests: completedRequests, + lateReplies: lateReplies, + protocolViolations: protocolViolations, + sentInterrupts: sentInterrupts + ) + } + + private func receiveReply( + _ result: Result, + expectedRequestID: UInt64 + ) { + guard let pending = pendingByRequestID[expectedRequestID] else { + lateReplies = saturatingAdd(lateReplies, 1) + return + } + guard DispatchTime.now().uptimeNanoseconds + < pending.identity.deadlineUptimeNanoseconds else { + expireRequest(expectedRequestID) + return + } + switch result { + case .failure(.interrupted): + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .failure(.invalidated): + transitionToTerminal(.invalidated, error: .channelInvalidated) + case .failure(let failure): + let current = removePendingRequest(expectedRequestID) + current?.continuation.resume( + throwing: DoryFSWorkerBrokerError.channelFailure(failure) + ) + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + case .success(let bytes): + let serviceFrame: DoryFSWorkerServiceFrame + do { + serviceFrame = try DoryFSWorkerFrameCodec.decodeServiceFrame( + bytes, + maximumFrameBytes: limits.maximumFrameBytes + ) + } catch let error as DoryFSWorkerContractError { + failProtocolViolation(currentRequestID: expectedRequestID, error: .malformedReply(error)) + return + } catch { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .replyIdentityMismatch + ) + return + } + guard case .reply(let reply) = serviceFrame, + reply.generation == generation, + reply.shareCapabilityID == shareCapabilityID, + reply.requestID == expectedRequestID, + reply.correlationID == pending.identity.correlationID else { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .replyIdentityMismatch + ) + return + } + switch reply.outcome { + case .rejected(let rejection): + finishRejectedRequest( + expectedRequestID, + error: .workerRejected(rejection) + ) + case .completed(let response): + let responseLimit = min( + pending.admissionLease.shape.responseBytes, + limits.maximumResponseBytes + ) + guard response.count <= responseLimit else { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .responseTooLarge(limit: responseLimit, actual: response.count) + ) + return + } + finishCompletedRequest( + expectedRequestID, + response: response + ) + } + } + } + + private func finishRejectedRequest( + _ requestID: UInt64, + error: DoryFSWorkerBrokerError + ) { + guard let pending = removePendingRequest(requestID) else { return } + pending.continuation.resume(throwing: error) + sendDrainIfReady() + } + + private func finishCompletedRequest( + _ requestID: UInt64, + response: Data + ) { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return } + pending.timeoutTask?.cancel() + let now = DispatchTime.now().uptimeNanoseconds + guard now < pending.identity.deadlineUptimeNanoseconds else { + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + pending.continuation.resume( + throwing: DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: pending.identity.correlationID + ) + ) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + return + } + let publication: DoryFSWorkerPublication + do { + publication = try DoryFSWorkerPublication( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: pending.identity.requestID, + correlationID: pending.identity.correlationID + ) + } catch { + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + pending.continuation.resume( + throwing: DoryFSWorkerBrokerError.replyIdentityMismatch + ) + failProtocolViolation(currentRequestID: nil, error: .replyIdentityMismatch) + return + } + let remaining = pending.identity.deadlineUptimeNanoseconds - now + pendingPublicationsByRequestID[requestID] = PendingPublication( + identity: pending.identity, + admissionLease: pending.admissionLease, + timeoutTask: nil + ) + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expirePublication(requestID) + } + pendingPublicationsByRequestID[requestID]?.timeoutTask = timeoutTask + completedRequests = saturatingAdd(completedRequests, 1) + pending.continuation.resume(returning: DoryFSWorkerExecution( + response: response, + publication: publication, + acknowledgementDeadlineUptimeNanoseconds: + pending.identity.deadlineUptimeNanoseconds + )) + } + + private func removePendingRequest(_ requestID: UInt64) -> PendingRequest? { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return nil } + pending.timeoutTask?.cancel() + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + return pending + } + + private func releaseReservation( + identity: RequestIdentity, + admissionLease: DoryFSWorkerAdmissionLease + ) { + requestIDByCorrelationID.removeValue(forKey: identity.correlationID) + admissionLease.release() + } + + private func expireRequest(_ requestID: UInt64) { + guard let pending = pendingByRequestID[requestID] else { return } + if let interrupt = try? DoryFSWorkerInterrupt( + generation: generation, + shareCapabilityID: shareCapabilityID, + targetRequestID: requestID, + targetCorrelationID: pending.identity.correlationID, + deadlineUptimeNanoseconds: pending.identity.deadlineUptimeNanoseconds + ), let frame = try? DoryFSWorkerFrameCodec.encode( + .interrupt(interrupt), + maximumFrameBytes: limits.maximumFrameBytes + ) { + sentInterrupts = saturatingAdd(sentInterrupts, 1) + channel.sendOneWay(frame: frame) + } + let expired = removePendingRequest(requestID) + expired?.continuation.resume(throwing: DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: pending.identity.correlationID + )) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + private func acknowledgePublication( + _ publication: DoryFSWorkerPublication, + committed: Bool + ) throws { + guard state == .active || state == .draining else { + throw DoryFSWorkerBrokerError.notActive(state) + } + guard publication.generation == generation, + publication.shareCapabilityID == shareCapabilityID, + let pending = pendingPublicationsByRequestID[publication.requestID], + pending.identity.correlationID == publication.correlationID else { + throw DoryFSWorkerBrokerError.replyIdentityMismatch + } + guard DispatchTime.now().uptimeNanoseconds + < pending.identity.deadlineUptimeNanoseconds else { + expirePublication(publication.requestID) + throw DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: publication.correlationID + ) + } + let frame = try DoryFSWorkerFrameCodec.encode( + committed + ? .commitPublication(publication) + : .discardPublication(publication), + maximumFrameBytes: limits.maximumFrameBytes + ) + guard let removed = pendingPublicationsByRequestID.removeValue( + forKey: publication.requestID + ) else { + throw DoryFSWorkerBrokerError.replyIdentityMismatch + } + removed.timeoutTask?.cancel() + releaseReservation( + identity: removed.identity, + admissionLease: removed.admissionLease + ) + channel.sendOneWay(frame: frame) + sendDrainIfReady() + } + + private func expirePublication(_ requestID: UInt64) { + guard let pending = pendingPublicationsByRequestID.removeValue( + forKey: requestID + ) else { return } + pending.timeoutTask?.cancel() + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + if let publication = try? DoryFSWorkerPublication( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: pending.identity.requestID, + correlationID: pending.identity.correlationID + ), let frame = try? DoryFSWorkerFrameCodec.encode( + .discardPublication(publication), + maximumFrameBytes: limits.maximumFrameBytes + ) { + channel.sendOneWay(frame: frame) + } + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + private func sendDrainIfReady() { + guard state == .draining, + pendingByRequestID.isEmpty, + pendingPublicationsByRequestID.isEmpty, + var drain = pendingDrain, + !drain.requestSent else { return } + drain.requestSent = true + pendingDrain = drain + do { + let frame = try DoryFSWorkerFrameCodec.encode( + .drain(try DoryFSWorkerDrain( + generation: generation, + shareCapabilityID: shareCapabilityID, + deadlineUptimeNanoseconds: drain.deadlineUptimeNanoseconds + )), + maximumFrameBytes: limits.maximumFrameBytes + ) + channel.send(frame: frame) { [weak self] result in + guard let self else { return } + Task { await self.receiveDrainReply(result) } + } + } catch { + failProtocolViolation(currentRequestID: nil, error: .drainReplyMismatch) + } + } + + private func receiveDrainReply(_ result: Result) { + guard state == .draining, let drain = pendingDrain else { + lateReplies = saturatingAdd(lateReplies, 1) + return + } + guard DispatchTime.now().uptimeNanoseconds < drain.deadlineUptimeNanoseconds else { + expireDrain() + return + } + switch result { + case .failure(.interrupted): + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .failure(.invalidated): + transitionToTerminal(.invalidated, error: .channelInvalidated) + case .failure(let failure): + pendingDrain = nil + state = .invalidated + admissionAuthority.invalidate(error: .channelFailure(failure)) + drain.timeoutTask?.cancel() + drain.continuation.resume(throwing: DoryFSWorkerBrokerError.channelFailure(failure)) + sendInvalidationFrame() + channel.invalidate() + case .success(let bytes): + do { + let frame = try DoryFSWorkerFrameCodec.decodeServiceFrame( + bytes, + maximumFrameBytes: limits.maximumFrameBytes + ) + guard case .drained(let ack) = frame, + ack.generation == generation, + ack.shareCapabilityID == shareCapabilityID else { + throw DoryFSWorkerBrokerError.drainReplyMismatch + } + pendingDrain = nil + state = .drained + drain.timeoutTask?.cancel() + drain.continuation.resume() + } catch { + failProtocolViolation(currentRequestID: nil, error: .drainReplyMismatch) + } + } + } + + private func expireDrain() { + guard state == .draining, let drain = pendingDrain else { return } + pendingDrain = nil + state = .invalidated + admissionAuthority.invalidate(error: .drainDeadlineExpired) + drain.timeoutTask?.cancel() + let requests = removeAllPendingRequests() + removeAllPendingPublications() + for pending in requests { + pending.continuation.resume(throwing: DoryFSWorkerBrokerError.channelInvalidated) + } + drain.continuation.resume(throwing: DoryFSWorkerBrokerError.drainDeadlineExpired) + sendInvalidationFrame() + channel.invalidate() + } + + private func receiveChannelEvent(_ event: DoryFSWorkerChannelEvent) { + switch event { + case .interrupted: + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .invalidated: + transitionToTerminal(.invalidated, error: .channelInvalidated) + } + } + + private func failProtocolViolation( + currentRequestID: UInt64?, + error: DoryFSWorkerBrokerError + ) { + protocolViolations = saturatingAdd(protocolViolations, 1) + if let currentRequestID, let current = removePendingRequest(currentRequestID) { + current.continuation.resume(throwing: error) + } + transitionToTerminal(.protocolViolation, error: .channelInvalidated) + sendInvalidationFrame() + channel.invalidate() + } + + private func transitionToTerminal( + _ newState: DoryFSWorkerBrokerState, + error: DoryFSWorkerBrokerError + ) { + guard state == .active || state == .draining || state == .drained else { return } + admissionAuthority.invalidate(error: error) + state = newState + let requests = removeAllPendingRequests() + removeAllPendingPublications() + let drain = pendingDrain + pendingDrain = nil + drain?.timeoutTask?.cancel() + for pending in requests { + pending.continuation.resume(throwing: error) + } + drain?.continuation.resume(throwing: error) + } + + private func removeAllPendingRequests() -> [PendingRequest] { + let requests = Array(pendingByRequestID.values) + for request in requests { + request.timeoutTask?.cancel() + request.admissionLease.release() + } + pendingByRequestID.removeAll(keepingCapacity: true) + requestIDByCorrelationID.removeAll(keepingCapacity: true) + return requests + } + + private func removeAllPendingPublications() { + let publications = Array(pendingPublicationsByRequestID.values) + for publication in publications { + publication.timeoutTask?.cancel() + publication.admissionLease.release() + } + pendingPublicationsByRequestID.removeAll(keepingCapacity: true) + requestIDByCorrelationID.removeAll(keepingCapacity: true) + } + + private func validateOperationDeadline(_ deadline: UInt64, now: UInt64) throws -> UInt64 { + guard deadline > now else { throw reject(.operationDeadlineExpired) } + let remaining = deadline - now + guard remaining <= limits.maximumOperationNanoseconds else { + throw reject(.operationDeadlineTooDistant( + limitNanoseconds: limits.maximumOperationNanoseconds, + actualNanoseconds: remaining + )) + } + return remaining + } + + private func sendInvalidationFrame() { + let invalidation = DoryFSWorkerInvalidation( + generation: generation, + shareCapabilityID: shareCapabilityID + ) + if let frame = try? DoryFSWorkerFrameCodec.encode( + .invalidate(invalidation), + maximumFrameBytes: limits.maximumFrameBytes + ) { + channel.sendOneWay(frame: frame) + } + } + + private func validateDrainDeadline(_ deadline: UInt64, now: UInt64) throws -> UInt64 { + guard deadline > now else { throw DoryFSWorkerBrokerError.drainDeadlineExpired } + let remaining = deadline - now + guard remaining <= limits.maximumDrainNanoseconds else { + throw DoryFSWorkerBrokerError.drainDeadlineTooDistant( + limitNanoseconds: limits.maximumDrainNanoseconds, + actualNanoseconds: remaining + ) + } + return remaining + } + + private func reject(_ error: DoryFSWorkerBrokerError) -> DoryFSWorkerBrokerError { + rejectedAdmissions = saturatingAdd(rejectedAdmissions, 1) + return error + } + + private func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift new file mode 100644 index 00000000..d0816087 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift @@ -0,0 +1,319 @@ +import DoryFSWorkerContracts +import Foundation + +public enum DoryFSWorkerWorkspaceClientError: Error, Equatable, Sendable { + case invalidBootstrap(DoryFSWorkerBootstrapError) + case bootstrapRejected(DoryFSWorkerBootstrapRejectionReason) + case channelFailure(DoryFSWorkerChannelFailure) + case malformedResult(DoryFSWorkerRPCResultError) + case receiptMismatch + case unknownShare(DoryFSShareCapabilityID) + case bootstrapTimedOut +} + +/// One authenticated connection to the runner-local signed filesystem service. The class unwraps +/// only the exact RPC-result envelope; `DoryFSWorkerBroker` remains responsible for validating the +/// inner service frame against its generation, capability, request, correlation, and limits. +public final class DoryFSWorkerXPCChannel: + NSObject, + DoryFSWorkerChannel, + @unchecked Sendable +{ + private static let workerCodeSigningRequirement = + #"anchor apple generic and identifier "com.pythonxi.Dory.HVRunner.FSWorker" and certificate leaf[subject.OU] = "864H636QW4""# + + private enum State { + case active + case interrupted + case invalidated + } + + private final class ReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } + } + + private let connection: NSXPCConnection + private let stateLock = NSLock() + private var state: State = .active + private var lifecycleHandlers = [@Sendable (DoryFSWorkerChannelEvent) -> Void]() + + public override init() { + connection = NSXPCConnection(serviceName: DoryFSWorkerXPC.serviceName) + super.init() + connection.remoteObjectInterface = NSXPCInterface( + with: DoryFSWorkerXPCProtocol.self + ) + connection.interruptionHandler = { [weak self] in + self?.transition(to: .interrupted) + } + connection.invalidationHandler = { [weak self] in + self?.transition(to: .invalidated) + } + connection.setCodeSigningRequirement(Self.workerCodeSigningRequirement) + connection.resume() + } + + public func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) { + let immediate: DoryFSWorkerChannelEvent? = stateLock.withLock { + switch state { + case .active: + lifecycleHandlers.append(handler) + return nil + case .interrupted: + return .interrupted + case .invalidated: + return .invalidated + } + } + if let immediate { handler(immediate) } + } + + public func bootstrap( + exactBytes: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard stateLock.withLock({ if case .active = state { true } else { false } }) else { + completion(.failure(.channelFailure(.unavailable))) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.channelFailure(.unavailable))) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + completion(.failure(.channelFailure(.unavailable))) + transition(to: .interrupted) + return + } + proxy.bootstrap(exactBytes) { [weak self] bytes in + guard once.claim() else { return } + switch Self.unwrapBootstrapResult(bytes) { + case .success(let payload): + completion(.success(payload)) + case .failure(let error): + completion(.failure(error)) + self?.invalidate() + } + } + } + + public func send( + frame: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard stateLock.withLock({ if case .active = state { true } else { false } }) else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.unavailable)) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.exchange(frame) { [weak self] bytes in + guard once.claim() else { return } + do { + switch try DoryFSWorkerRPCResultCodec.decode(bytes) { + case .success(let payload): + completion(.success(payload)) + case .failure(let code): + completion(.failure(.serviceFailure(code))) + } + } catch { + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func sendOneWay(frame: Data) { + guard stateLock.withLock({ if case .active = state { true } else { false } }), + let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { return } + proxy.sendOneWay(frame) + } + + public func invalidate() { + transition(to: .invalidated) + connection.invalidate() + } + + private func transition(to requested: State) { + let delivery: ( + handlers: [@Sendable (DoryFSWorkerChannelEvent) -> Void], + event: DoryFSWorkerChannelEvent + )? = stateLock.withLock { + guard case .active = state else { return nil } + state = requested + let handlers = lifecycleHandlers + lifecycleHandlers.removeAll(keepingCapacity: false) + switch requested { + case .active: + return nil + case .interrupted: + return (handlers, .interrupted) + case .invalidated: + return (handlers, .invalidated) + } + } + if let delivery { + for handler in delivery.handlers { handler(delivery.event) } + } + } + + static func unwrapBootstrapResult( + _ bytes: Data + ) -> Result { + do { + switch try DoryFSWorkerRPCResultCodec.decode(bytes) { + case .success(let payload): + return .success(payload) + case .failure(let code): + if let reason = code.bootstrapRejectionReason { + return .failure(.bootstrapRejected(reason)) + } + return .failure(.channelFailure(.serviceFailure(code))) + } + } catch let error as DoryFSWorkerRPCResultError { + return .failure(.malformedResult(error)) + } catch { + return .failure(.channelFailure(.unavailable)) + } + } +} + +/// Validates a one-shot bootstrap receipt before exposing any share broker. All brokers retain the +/// same workspace channel, so interruption, malformed service data, or one share's fail-stop +/// invalidation tears down the complete worker authority generation. +public final class DoryFSWorkerWorkspaceClient: @unchecked Sendable { + private final class BlockingBootstrap: @unchecked Sendable { + let condition = NSCondition() + var result: Result? + } + public let bootstrap: DoryFSWorkerBootstrap + private let channel: DoryFSWorkerXPCChannel + private let admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority + + private init( + bootstrap: DoryFSWorkerBootstrap, + channel: DoryFSWorkerXPCChannel + ) { + self.bootstrap = bootstrap + self.channel = channel + self.admissionAuthority = DoryFSWorkerWorkspaceAdmissionAuthority( + workerLimits: bootstrap.workerLimits, + shareLimits: Dictionary( + uniqueKeysWithValues: bootstrap.shares.map { + ($0.capabilityID, $0.resourceLimits) + } + ) + ) + } + + public static func connect(exactBootstrapBytes: Data) async throws -> Self { + let bootstrap: DoryFSWorkerBootstrap + do { + bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryFSWorkerBootstrapError { + throw DoryFSWorkerWorkspaceClientError.invalidBootstrap(error) + } + let channel = DoryFSWorkerXPCChannel() + let receiptBytes: Data = try await withCheckedThrowingContinuation { continuation in + channel.bootstrap(exactBytes: exactBootstrapBytes) { result in + continuation.resume(with: result) + } + } + let receipt: DoryFSWorkerBootstrapReceipt + do { + receipt = try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + } catch { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + guard receipt == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + return Self(bootstrap: bootstrap, channel: channel) + } + + public static func connectBlocking( + exactBootstrapBytes: Data, + timeout: TimeInterval = 30 + ) throws -> Self { + let bootstrap: DoryFSWorkerBootstrap + do { + bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryFSWorkerBootstrapError { + throw DoryFSWorkerWorkspaceClientError.invalidBootstrap(error) + } + let channel = DoryFSWorkerXPCChannel() + let blocking = BlockingBootstrap() + channel.bootstrap(exactBytes: exactBootstrapBytes) { result in + blocking.condition.withLock { + guard blocking.result == nil else { return } + blocking.result = result + blocking.condition.broadcast() + } + } + let result: Result? = + blocking.condition.withLock { + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + while blocking.result == nil, blocking.condition.wait(until: deadline) {} + return blocking.result + } + guard let result else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.bootstrapTimedOut + } + let receiptBytes = try result.get() + guard let receipt = try? DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes), + receipt == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + return Self(bootstrap: bootstrap, channel: channel) + } + + public func broker( + for capabilityID: DoryFSShareCapabilityID + ) throws -> DoryFSWorkerBroker { + guard let share = bootstrap.shares.first(where: { $0.capabilityID == capabilityID }) else { + throw DoryFSWorkerWorkspaceClientError.unknownShare(capabilityID) + } + return DoryFSWorkerBroker( + shareCapabilityID: capabilityID, + generation: bootstrap.generation, + limits: bootstrap.workerLimits, + shareResourceLimits: share.resourceLimits, + admissionAuthority: admissionAuthority, + channel: channel + ) + } + + public func invalidate() { + channel.invalidate() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift deleted file mode 100644 index d186fa4e..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Darwin -import Foundation -import Hypervisor - -public final class FileBackedDaxMappingBackend: DaxMappingBackend, @unchecked Sendable { - private struct Region { - var hostAddress: UnsafeMutableRawPointer - var length: Int - } - - private let lock = NSLock() - private var regions: [Key: Region] = [:] - - public init() {} - - public func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws { - let length = try intLength(mapping.length) - let protections = protections(for: mapping.flags) - let hostAddress = mmap(nil, length, protections, MAP_SHARED, fileDescriptor, off_t(mapping.fileOffset)) - guard let hostAddress, hostAddress != MAP_FAILED else { - throw DaxWindowError.mappingFailed("mmap failed: errno \(errno)") - } - - let hvFlags = hv_memory_flags_t(hvFlags(for: mapping.flags)) - let result = hv_vm_map(hostAddress, guestAddress, length, hvFlags) - guard result == HV_SUCCESS else { - munmap(hostAddress, length) - throw DaxWindowError.mappingFailed("hv_vm_map(host=\(hostAddress) gpa=0x\(String(guestAddress, radix: 16)) len=0x\(String(length, radix: 16)) flags=0x\(String(hvFlags, radix: 16))) -> 0x\(String(UInt32(bitPattern: result), radix: 16))") - } - - lock.withLock { - regions[Key(memoryOffset: mapping.memoryOffset, length: mapping.length)] = Region(hostAddress: hostAddress, length: length) - } - } - - public func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws { - let key = Key(memoryOffset: mapping.memoryOffset, length: mapping.length) - guard let region = lock.withLock({ regions.removeValue(forKey: key) }) else { - throw DaxWindowError.unmappingFailed("mapping not found") - } - let unmapResult = hv_vm_unmap(guestAddress, region.length) - let munmapResult = munmap(region.hostAddress, region.length) - guard unmapResult == HV_SUCCESS, munmapResult == 0 else { - throw DaxWindowError.unmappingFailed("hv_vm_unmap \(unmapResult), munmap errno \(errno)") - } - } - - private func intLength(_ value: UInt64) throws -> Int { - guard value <= UInt64(Int.max) else { - throw DaxWindowError.mappingFailed("mapping length overflows Int") - } - return Int(value) - } - - private func protections(for flags: UInt64) -> Int32 { - // Map the host region read+write regardless of the guest's requested access. Apple's - // hv_vm_map rejects a host region that is not writable (HV_ERROR); the guest's stage-2 - // protection below still restricts the guest to what it asked for, so a read-only DAX - // mapping cannot be used by the guest to modify the host file. - return PROT_READ | PROT_WRITE - } - - private func hvFlags(for flags: UInt64) -> UInt32 { - var hvFlags = HV_MEMORY_READ - if flags & FuseSetupMappingFlag.write.rawValue != 0 { - hvFlags |= HV_MEMORY_WRITE - } - return UInt32(hvFlags) - } - - private struct Key: Hashable { - var memoryOffset: UInt64 - var length: UInt64 - } -} - -private extension NSLock { - func withLock(_ body: () throws -> R) rethrows -> R { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift deleted file mode 100644 index 0b035b17..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift +++ /dev/null @@ -1,805 +0,0 @@ -import CoreServices -import Darwin -import Foundation - -public struct HostFSEventChange: Sendable, Equatable { - public var hostPath: String - public var guestPath: String - public var flags: UInt32 - public var eventID: UInt64 - /// Only the pathname reported by FSEvents may invalidate data pages. Namespace fanout to a - /// surviving hard-link alias carries metadata/nlink semantics, never permission to discard - /// that alias's potentially dirty cache. - public var permitsContentInvalidation: Bool - /// The stream reported a changed directory rather than one exact file. The coherence - /// coordinator expands this only over HostFS bindings already known in that directory. - public var isDirectoryAggregate: Bool - - public init( - hostPath: String, - guestPath: String, - flags: UInt32, - eventID: UInt64, - permitsContentInvalidation: Bool = true, - isDirectoryAggregate: Bool = false - ) { - self.hostPath = URL(fileURLWithPath: hostPath).standardizedFileURL.path - self.guestPath = guestPath - self.flags = flags - self.eventID = eventID - self.permitsContentInvalidation = permitsContentInvalidation - self.isDirectoryAggregate = isDirectoryAggregate - } - - /// FSEvents asks clients to rescan after any of these markers. Continuing to serve cached - /// dentries after a dropped event would make the host and guest disagree, so the coherence - /// coordinator must degrade immediately instead of treating this as an ordinary path edit. - public var requiresRescan: Bool { - let mask = UInt32( - kFSEventStreamEventFlagMustScanSubDirs | - kFSEventStreamEventFlagUserDropped | - kFSEventStreamEventFlagKernelDropped | - kFSEventStreamEventFlagEventIdsWrapped | - kFSEventStreamEventFlagRootChanged | - // HostFS pins root/directory descriptors. A detach/remount can replace the filesystem - // identity underneath those fds even when RootChanged is not co-reported. - kFSEventStreamEventFlagMount | - kFSEventStreamEventFlagUnmount - ) - return flags & mask != 0 - } - - /// A same-mode chmod generated solely to wake Linux watchers is expected to return as an inode - /// metadata event. Only this narrow shape is eligible for one-shot echo suppression; content, - /// namespace, xattr, ownership, overflow, and root events always make the round trip. - public var isMetadataOnly: Bool { - // macOS reports fchmod(2) as ItemChangeOwner even when uid/gid are unchanged. Treat both - // metadata classifications as echo-eligible; the path token still prevents an unrelated - // chmod from being discarded. - let metadata = UInt32( - kFSEventStreamEventFlagItemInodeMetaMod | - kFSEventStreamEventFlagItemChangeOwner - ) - let substantive = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed | - kFSEventStreamEventFlagItemModified | - kFSEventStreamEventFlagItemFinderInfoMod | - kFSEventStreamEventFlagItemXattrMod | - kFSEventStreamEventFlagMount | - kFSEventStreamEventFlagUnmount - ) - return !requiresRescan && flags & metadata != 0 && flags & substantive == 0 - } - - /// FSEvents may coalesce remove/create or both sides of an atomic replacement into one path. - /// DELETE is correct only while that name is actually absent; if a new object already occupies - /// it, INVAL_ENTRY preserves the replacement's watch/dentry semantics. - public var representsRemoval: Bool { - let namespaceMask = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - guard flags & namespaceMask != 0 else { - return false - } - var info = stat() - if lstat(hostPath, &info) == 0 { return false } - return errno == ENOENT || errno == ENOTDIR - } -} - -public struct HostFSEventShare: Sendable, Equatable { - public var hostRoot: String - /// FSEvents may return either the spelling used to create its stream or the canonical realpath - /// spelling (for example `/var` versus `/private/var`). Mapping accepts both. - public var hostRootAliases: [String] - public var guestRoot: String - - public init(hostRoot: String, guestRoot: String) { - let supplied = URL(fileURLWithPath: hostRoot).standardizedFileURL.path - let canonical = URL(fileURLWithPath: supplied) - .resolvingSymlinksInPath() - .standardizedFileURL.path - self.hostRoot = supplied - self.hostRootAliases = Array(Set([supplied, canonical])).sorted { $0.count > $1.count } - if guestRoot.count > 1, guestRoot.hasSuffix("/") { - self.guestRoot = String(guestRoot.dropLast()) - } else { - self.guestRoot = guestRoot - } - } - - public func mapHostPathToGuest(_ path: String) -> String? { - guard let relative = relativePath(forHostPath: path) else { return nil } - guard !relative.isEmpty else { return guestRoot } - return guestRoot == "/" ? "/" + relative : guestRoot + "/" + relative - } - - /// Returns the narrowest stable FSEvents root for an accessed path: the first real child of - /// this broad export. Watching `$HOME/Projects` preserves arbitrary bind reachability without - /// subscribing the host daemon to every change anywhere under `$HOME`. - public func topLevelObservationRoot(forHostPath path: String) -> String? { - guard let (normalized, root) = normalizedPathAndRootInsideShare(path), normalized != root else { - return nil - } - let prefix = root == "/" ? "/" : root + "/" - let relative = normalized.dropFirst(prefix.count) - guard let first = relative.split(separator: "/", omittingEmptySubsequences: true).first else { - return nil - } - return root == "/" ? "/" + first : root + "/" + first - } - - /// Predicts the exact path the guest agent will chmod. Missing/deleted entries fall back to the - /// nearest existing regular file or directory, but never above this share root. Symlinks are not - /// nudged because the guest opens its final component with O_NOFOLLOW. - public func nudgeTarget(forHostPath path: String) -> (host: String, guest: String)? { - guard let (initialCandidate, boundary) = normalizedPathAndRootInsideShare(path) else { - return nil - } - var candidate = initialCandidate - while true { - var info = stat() - if lstat(candidate, &info) == 0 { - let kind = info.st_mode & mode_t(S_IFMT) - if kind == mode_t(S_IFREG) || kind == mode_t(S_IFDIR) { - guard let guest = mapHostPathToGuest(candidate) else { return nil } - return (candidate, guest) - } - } else if errno != ENOENT && errno != ENOTDIR && errno != ELOOP { - return nil - } - guard candidate != boundary else { return nil } - let parent = URL(fileURLWithPath: candidate).deletingLastPathComponent().path - guard isPath(parent, inside: boundary), parent != candidate else { return nil } - candidate = parent - } - } - - private func relativePath(forHostPath path: String) -> String? { - guard let (normalized, root) = normalizedPathAndRootInsideShare(path) else { return nil } - if normalized == root { return "" } - let prefix = root == "/" ? "/" : root + "/" - return String(normalized.dropFirst(prefix.count)) - } - - private func normalizedPathAndRootInsideShare(_ path: String) -> (String, String)? { - let normalized = URL(fileURLWithPath: path).standardizedFileURL.path - guard let root = hostRootAliases.first(where: { isPath(normalized, inside: $0) }) else { - return nil - } - return (normalized, root) - } - - private func isPath(_ candidate: String, inside root: String) -> Bool { - if candidate == root { return true } - let prefix = root == "/" ? "/" : root + "/" - return candidate.hasPrefix(prefix) - } -} - -public enum HostFSEventRelayError: Error, Equatable, Sendable { - case streamCreationFailed - case streamStartFailed - case suppressionLedgerFull(limit: Int) - case repeatedSyntheticEcho(path: String) -} - -public enum HostShareCoherenceStartupError: Error, Equatable, Sendable { - case eventRelayUnavailable(productionShareCount: Int) -} - -/// Production host shares are safe only while their host-change observation path is live. This -/// applies to writable shares and read-only shares alike: read-only guest mappings can still retain -/// stale host page cache. Engine startup therefore fails instead of running any production share -/// without the relay; an empty production-share set has no such requirement. -public enum HostShareCoherenceStartupPolicy { - public static func requireEventRelay( - started: Bool, - productionShareCount: Int - ) throws { - guard productionShareCount > 0 else { return } - guard started else { - throw HostShareCoherenceStartupError.eventRelayUnavailable( - productionShareCount: productionShareCount - ) - } - } -} - -public final class FSEventBatcher: @unchecked Sendable { - public typealias SendBatch = @Sendable ([HostFSEventChange]) async throws -> Void - - /// An npm install can emit tens of thousands of distinct FileEvents before the asynchronous - /// coherence round trip finishes. Keep one bounded batch comfortably above that ordinary - /// developer workload: treating it as an observation loss needlessly restarts the VM even - /// though CoreServices delivered every path. A true FSEvents dropped-event marker still takes - /// the existing fail-closed recovery path, and this remains a hard cap against unbounded use. - public static let defaultPendingLimit = 65_536 - - private let shares: [HostFSEventShare] - private let send: SendBatch - private let pendingLimit: Int - private let eventsAreDirectoryAggregates: Bool - private let lock = NSLock() - private var pending: [String: HostFSEventChange] = [:] - private var pendingRequiresRescan = false - private var receivedEventCount: UInt64 = 0 - private var deliveredBatchCount: UInt64 = 0 - private var failedBatchCount: UInt64 = 0 - private var rescanCollapseCount: UInt64 = 0 - - public init( - shares: [HostFSEventShare], - pendingLimit: Int = FSEventBatcher.defaultPendingLimit, - eventsAreDirectoryAggregates: Bool = false, - send: @escaping SendBatch - ) { - // Longest root first makes nested shares deterministic. - self.shares = shares.sorted { $0.hostRoot.count > $1.hostRoot.count } - self.pendingLimit = max(1, pendingLimit) - self.eventsAreDirectoryAggregates = eventsAreDirectoryAggregates - self.send = send - } - - public func enqueue(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard hostPaths.count == flags.count, flags.count == eventIDs.count else { return } - let changes = zip(hostPaths.indices, hostPaths).compactMap { index, path in - mapHostPath(path, flags: flags[index], eventID: eventIDs[index]) - } - guard !changes.isEmpty else { return } - let batchLatestEventID = changes.map(\.eventID).max() ?? 0 - lock.withLock { - receivedEventCount &+= UInt64(changes.count) - for change in changes { - if change.requiresRescan { - FileHandle.standardError.write(Data( - "dory-hv: FSEvents rescan marker path=\(change.hostPath) flags=0x\(String(change.flags, radix: 16)) event=\(change.eventID)\n".utf8 - )) - } - if pendingRequiresRescan { break } - if pending[change.hostPath] == nil, pending.count >= pendingLimit { - let latest = max(batchLatestEventID, pending.values.map(\.eventID).max() ?? 0) - collapseToRescanLocked(latestEventID: latest, reason: "pending-overflow") - break - } - if var existing = pending[change.hostPath] { - existing.flags |= change.flags - existing.eventID = max(existing.eventID, change.eventID) - pending[change.hostPath] = existing - } else { - pending[change.hostPath] = change - } - } - } - } - - public func enqueue(hostPaths: [String]) { - enqueue( - hostPaths: hostPaths, - flags: Array(repeating: 0, count: hostPaths.count), - eventIDs: Array(repeating: 0, count: hostPaths.count) - ) - } - - public func flushNow() async throws { - let changes = lock.withLock { () -> [HostFSEventChange] in - let changes = pending.values.sorted { - $0.guestPath == $1.guestPath ? $0.hostPath < $1.hostPath : $0.guestPath < $1.guestPath - } - pending.removeAll(keepingCapacity: true) - pendingRequiresRescan = false - return changes - } - guard !changes.isEmpty else { return } - do { - try await send(changes) - lock.withLock { deliveredBatchCount &+= 1 } - } catch { - lock.withLock { - failedBatchCount &+= 1 - let latest = max( - changes.map(\.eventID).max() ?? 0, - pending.values.map(\.eventID).max() ?? 0 - ) - if pendingRequiresRescan - || changes.contains(where: \.requiresRescan) - || pending.count + changes.count > pendingLimit { - collapseToRescanLocked(latestEventID: latest, reason: "retry-overflow-or-rescan") - } else { - for change in changes { - if var newer = pending[change.hostPath] { - newer.flags |= change.flags - newer.eventID = max(newer.eventID, change.eventID) - pending[change.hostPath] = newer - } else { - pending[change.hostPath] = change - } - } - } - } - throw error - } - } - - public var hasPending: Bool { - lock.withLock { !pending.isEmpty } - } - - public func discardPending() { - lock.withLock { - pending.removeAll(keepingCapacity: false) - pendingRequiresRescan = false - } - } - - var pendingCount: Int { - lock.withLock { pending.count } - } - - public var diagnostics: FSEventBatcherDiagnostics { - lock.withLock { - FSEventBatcherDiagnostics( - pendingCount: pending.count, - pendingLimit: pendingLimit, - pendingRequiresRescan: pendingRequiresRescan, - receivedEventCount: receivedEventCount, - deliveredBatchCount: deliveredBatchCount, - failedBatchCount: failedBatchCount, - rescanCollapseCount: rescanCollapseCount - ) - } - } - - public func mapHostPathToGuest(_ path: String) -> String? { - shares.lazy.compactMap { $0.mapHostPathToGuest(path) }.first - } - - public func mapHostPath(_ path: String, flags: UInt32, eventID: UInt64) -> HostFSEventChange? { - let normalized = URL(fileURLWithPath: path).standardizedFileURL.path - guard let guest = mapHostPathToGuest(normalized) else { return nil } - return HostFSEventChange( - hostPath: normalized, - guestPath: guest, - flags: flags, - eventID: eventID, - isDirectoryAggregate: eventsAreDirectoryAggregates - ) - } - - private func collapseToRescanLocked(latestEventID: UInt64, reason: String) { - FileHandle.standardError.write(Data( - "dory-hv: FSEvents batch collapsed reason=\(reason) pending=\(pending.count) event=\(latestEventID)\n".utf8 - )) - pending.removeAll(keepingCapacity: true) - pendingRequiresRescan = true - rescanCollapseCount &+= 1 - let flags = UInt32( - kFSEventStreamEventFlagMustScanSubDirs | - kFSEventStreamEventFlagUserDropped - ) - for share in shares { - pending[share.hostRoot] = HostFSEventChange( - hostPath: share.hostRoot, - guestPath: share.guestRoot, - flags: flags, - eventID: latestEventID - ) - } - } -} - -public struct FSEventBatcherDiagnostics: Codable, Equatable, Sendable { - public var pendingCount: Int - public var pendingLimit: Int - public var pendingRequiresRescan: Bool - public var receivedEventCount: UInt64 - public var deliveredBatchCount: UInt64 - public var failedBatchCount: UInt64 - public var rescanCollapseCount: UInt64 -} - -public struct HostFSEventRelayDiagnostics: Codable, Equatable, Sendable { - public var schema: String - public var version: Int - public var generatedAt: Date - public var configuredRoots: [String] - public var observationRoots: [String] - public var running: Bool - public var flushScheduled: Bool - public var consecutiveFailures: Int - public var batcher: FSEventBatcherDiagnostics - - public init( - generatedAt: Date = Date(), - configuredRoots: [String], - observationRoots: [String], - running: Bool, - flushScheduled: Bool, - consecutiveFailures: Int, - batcher: FSEventBatcherDiagnostics - ) { - self.schema = "dev.dory.host-share.resources" - self.version = 1 - self.generatedAt = generatedAt - self.configuredRoots = configuredRoots - self.observationRoots = observationRoots - self.running = running - self.flushScheduled = flushScheduled - self.consecutiveFailures = consecutiveFailures - self.batcher = batcher - } -} - -/// Watches configured host roots and emits loss-aware, retryable batches. This type deliberately -/// does not decide cache policy; the coordinator must invalidate the guest kernel and await its -/// completion barrier before sending a watcher nudge. -public final class HostFSEventRelay: @unchecked Sendable { - public typealias SendBatch = FSEventBatcher.SendBatch - public typealias FailureHandler = @Sendable (any Error) -> Void - @usableFromInline - static let defaultDebounceMilliseconds: UInt64 = 1 - - private let shares: [HostFSEventShare] - private let batcher: FSEventBatcher - private let debounceNanoseconds: UInt64 - private let onFailure: FailureHandler - private let observeRootsOnDemand: Bool - private let queue = DispatchQueue(label: "dev.dory.hostfs.fsevents") - /// CoreServices owns `queue` and can report UserDropped when its callback cannot drain quickly - /// enough. URL normalization, share mapping, and pending-dictionary merging are substantially - /// more expensive than copying one delivered batch, so perform them on a separate ordered queue - /// and return the FSEvents callback promptly. - private let processingQueue = DispatchQueue(label: "dev.dory.hostfs.fsevents.processing") - private let lock = NSLock() - private var streams: [String: FSEventStreamRef] = [:] - private var callbackBoxes: [String: CallbackBox] = [:] - private var flushScheduled = false - private var consecutiveFailures = 0 - private var running = false - private var lifecycleGeneration: UInt64 = 0 - - static let streamCreateFlags = FSEventStreamCreateFlags( - // Directory-level events keep a whole-home share from producing one record per package - // file. HostShareCoherenceCoordinator expands each changed directory only over immediate - // HostFS bindings the guest already knows, retaining precise cache and watcher behavior. - // RootChanged is emitted only with WatchRoot. Without it a renamed/deleted share root - // could silently leave positive cache state attached to an obsolete host directory. - kFSEventStreamCreateFlagWatchRoot | - // HostFS mutations and watcher nudges run in this same dory-hv process. Excluding - // self-originated events prevents guest writes from being reflected back into the - // guest while preserving edits made by editors and tools on macOS. - kFSEventStreamCreateFlagIgnoreSelf | - // Ask FSEvents to mark any self event that still reaches the stream. IgnoreSelf normally - // suppresses it, but relying on that suppression alone lets a package-manager create storm - // be mistaken for an external host edit when the system reports it anyway. - kFSEventStreamCreateFlagMarkSelf | - kFSEventStreamCreateFlagUseCFTypes - ) - - /// `dory-hv` performs the host syscalls for guest FUSE mutations. Those paths are already - /// coherent in its HostFS state and must not be fed back through the host-edit invalidation - /// pipeline. The explicit OwnEvent check is the fail-safe complement to IgnoreSelf. - static func ignoresOwnEvent(_ flags: UInt32) -> Bool { - flags & UInt32(kFSEventStreamEventFlagOwnEvent) != 0 - } - - public init( - shares: [HostFSEventShare], - debounceMilliseconds: UInt64 = HostFSEventRelay.defaultDebounceMilliseconds, - observeRootsOnDemand: Bool = false, - send: @escaping SendBatch, - onFailure: @escaping FailureHandler = { _ in } - ) { - self.shares = shares - self.batcher = FSEventBatcher( - shares: shares, - eventsAreDirectoryAggregates: true, - send: send - ) - self.debounceNanoseconds = debounceMilliseconds * 1_000_000 - self.observeRootsOnDemand = observeRootsOnDemand - self.onFailure = onFailure - } - - deinit { - stop() - } - - @discardableResult - public func start() -> Bool { - guard !shares.isEmpty else { return false } - let alreadyRunning = lock.withLock { running } - if alreadyRunning { return true } - lock.withLock { - lifecycleGeneration &+= 1 - running = true - flushScheduled = false - consecutiveFailures = 0 - } - if observeRootsOnDemand { return true } - for root in shares.map(\.hostRoot) { - guard startStream(root: root) else { - stop() - return false - } - } - return true - } - - /// Adds one narrow observation root without disturbing existing streams. This is synchronous: - /// the FUSE lookup that discovered the path does not return until the stream is live. - @discardableResult - public func observe(hostPath: String) -> Bool { - guard lock.withLock({ running }) else { return false } - let roots = shares.compactMap { $0.topLevelObservationRoot(forHostPath: hostPath) } - guard let root = roots.sorted(by: { $0.count > $1.count }).first else { return true } - if lock.withLock({ streams[root] != nil }) { return true } - return startStream(root: root) - } - - public var observationRoots: [String] { - lock.withLock { streams.keys.sorted() } - } - - public var diagnostics: HostFSEventRelayDiagnostics { - let state = lock.withLock { - ( - roots: streams.keys.sorted(), - running: running, - flushScheduled: flushScheduled, - failures: consecutiveFailures - ) - } - return HostFSEventRelayDiagnostics( - configuredRoots: shares.map(\.hostRoot).sorted(), - observationRoots: state.roots, - running: state.running, - flushScheduled: state.flushScheduled, - consecutiveFailures: state.failures, - batcher: batcher.diagnostics - ) - } - - private func startStream(root: String) -> Bool { - let box = CallbackBox(relay: self) - var context = FSEventStreamContext( - version: 0, - info: Unmanaged.passUnretained(box).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - guard let created = FSEventStreamCreate( - nil, - { _, info, count, eventPaths, eventFlags, eventIDs in - guard let info else { return } - let box = Unmanaged.fromOpaque(info).takeUnretainedValue() - let pathsArray = unsafeBitCast(eventPaths, to: NSArray.self) - var paths = [String]() - var flags = [UInt32]() - var ids = [UInt64]() - paths.reserveCapacity(count) - flags.reserveCapacity(count) - ids.reserveCapacity(count) - for index in 0.. Bool in - guard running, streams[root] == nil else { return false } - streams[root] = created - callbackBoxes[root] = box - return true - } - if !accepted { - FSEventStreamStop(created) - FSEventStreamInvalidate(created) - FSEventStreamRelease(created) - } - return true - } - - public func stop() { - let existing = lock.withLock { () -> (streams: [FSEventStreamRef], boxes: [CallbackBox]) in - let existingStreams = Array(streams.values) - let existingBoxes = Array(callbackBoxes.values) - streams.removeAll() - callbackBoxes.removeAll() - lifecycleGeneration &+= 1 - running = false - flushScheduled = false - consecutiveFailures = 0 - return (existingStreams, existingBoxes) - } - batcher.discardPending() - for stream in existing.streams { - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } - // FSEventStreamContext retains an unretained pointer to each box. The local tuple keeps all - // boxes alive until every corresponding stream has stopped, invalidated, and released. - _ = existing.boxes - } - - public func record(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard lock.withLock({ running }) else { return } - batcher.enqueue(hostPaths: hostPaths, flags: flags, eventIDs: eventIDs) - scheduleFlush() - } - - func recordFromStream(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard let generation = lock.withLock({ running ? lifecycleGeneration : nil }) else { return } - processingQueue.async { [weak self] in - guard let self, - self.lock.withLock({ self.running && self.lifecycleGeneration == generation }) else { - return - } - self.batcher.enqueue(hostPaths: hostPaths, flags: flags, eventIDs: eventIDs) - self.scheduleFlush() - } - } - - public func record(hostPaths: [String]) { - guard lock.withLock({ running }) else { return } - batcher.enqueue(hostPaths: hostPaths) - scheduleFlush() - } - - private func scheduleFlush() { - let scheduled = lock.withLock { () -> (delay: UInt64, generation: UInt64)? in - guard running, !flushScheduled else { return nil } - flushScheduled = true - let backoff = min(UInt64(2_000_000_000), debounceNanoseconds << min(consecutiveFailures, 5)) - return (backoff, lifecycleGeneration) - } - guard let scheduled else { - if !lock.withLock({ running }) { batcher.discardPending() } - return - } - Task.detached { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: scheduled.delay) - guard self.lock.withLock({ - self.running && self.lifecycleGeneration == scheduled.generation - }) else { - self.batcher.discardPending() - return - } - do { - try await self.batcher.flushNow() - self.lock.withLock { - if self.running, self.lifecycleGeneration == scheduled.generation { - self.consecutiveFailures = 0 - } - } - } catch { - let shouldReport = self.lock.withLock { () -> Bool in - guard self.running, - self.lifecycleGeneration == scheduled.generation else { return false } - self.consecutiveFailures += 1 - return true - } - if shouldReport { self.onFailure(error) } - } - let shouldReschedule = self.lock.withLock { () -> Bool in - guard self.running, - self.lifecycleGeneration == scheduled.generation else { return false } - self.flushScheduled = false - return self.batcher.hasPending - } - if shouldReschedule { - self.scheduleFlush() - } else if !self.lock.withLock({ - self.running && self.lifecycleGeneration == scheduled.generation - }) { - self.batcher.discardPending() - } - } - } -} - -/// One-shot ledger for the metadata-only FSEvent produced by a guest watcher nudge. Cache -/// invalidation still runs for a consumed echo; only the second guest nudge is suppressed. -public final class FSEventEchoSuppressor: @unchecked Sendable { - private struct Token { - var sourceEventID: UInt64 - var expiresAt: TimeInterval - var remainingEchoes: Int - } - - private let lock = NSLock() - private let limit: Int - private let lifetimeSeconds: TimeInterval - private var tokens: [String: Token] = [:] - - public init(limit: Int = 8_192, lifetimeSeconds: TimeInterval = 2) { - self.limit = max(1, limit) - self.lifetimeSeconds = max(0.05, lifetimeSeconds) - } - - public func register(hostPath: String, sourceEventID: UInt64, now: TimeInterval = ProcessInfo.processInfo.systemUptime) throws { - let path = URL(fileURLWithPath: hostPath).standardizedFileURL.path - try lock.withLock { - expireLocked(now: now) - guard tokens[path] != nil || tokens.count < limit else { - throw HostFSEventRelayError.suppressionLedgerFull(limit: limit) - } - tokens[path] = Token( - sourceEventID: max(tokens[path]?.sourceEventID ?? 0, sourceEventID), - expiresAt: now + lifetimeSeconds, - // One token per nudge. A small burst on one path can legitimately have several - // in-flight chmod echoes, so count them rather than overwriting a one-shot token. - remainingEchoes: min(32, (tokens[path]?.remainingEchoes ?? 0) + 1) - ) - } - } - - public func consumeIfSyntheticEcho( - _ change: HostFSEventChange, - now: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> Bool { - guard change.isMetadataOnly else { return false } - return lock.withLock { - expireLocked(now: now) - guard var token = tokens[change.hostPath], - change.eventID == 0 || change.eventID > token.sourceEventID else { return false } - token.remainingEchoes -= 1 - if token.remainingEchoes == 0 { - tokens.removeValue(forKey: change.hostPath) - } else { - tokens[change.hostPath] = token - } - return true - } - } - - public func clear() { - lock.withLock { tokens.removeAll(keepingCapacity: false) } - } - - private func expireLocked(now: TimeInterval) { - tokens = tokens.filter { $0.value.expiresAt > now } - } -} - -private final class CallbackBox { - weak var relay: HostFSEventRelay? - - init(relay: HostFSEventRelay) { - self.relay = relay - } -} - -private extension NSLock { - func withLock(_ body: () throws -> R) rethrows -> R { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift deleted file mode 100644 index 26eaf86e..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift +++ /dev/null @@ -1,868 +0,0 @@ -import CoreServices -import Foundation - -public struct HostShareCoherenceEndpoint: @unchecked Sendable { - public let share: HostFSEventShare - public let backend: VirtioFS - public let watcherNudgesEnabled: Bool - - public init( - share: HostFSEventShare, - backend: VirtioFS, - watcherNudgesEnabled: Bool = true - ) { - self.share = share - self.backend = backend - self.watcherNudgesEnabled = watcherNudgesEnabled - } -} - -public enum HostShareCoherenceError: Error, Equatable, Sendable { - case guestNudgeFailed(path: String) - case guestNudgeOperationExpired(operationID: UInt64) -} - -/// Orders host-originated edits so Linux never receives a watcher wakeup while its virtio-fs cache -/// still contains the old object. Batches are actor-serialized; transport failures are retryable, -/// while lossy FSEvents markers permanently return the affected VM to zero-cache safety. When the -/// export root and reverse-notification channel remain intact, loss is recovered in place by -/// invalidating every still-live FUSE identity rather than rebooting the Docker VM. -public actor HostShareCoherenceCoordinator { - /// Host edits fail closed quickly: dirty guest writeback was observed escaping a two-second - /// reverse-notification wait. VirtioFS keeps its generic timeout; coherence uses this tighter - /// deadline so the VM restart boundary wins before delayed dirty pages can overwrite host data. - static let reverseInvalidationFailCloseDeadline: Duration = .seconds(1) - /// A loss-recovery sweep invalidates every known node, so its deadline scales with the sweep - /// size instead of borrowing the per-edit fail-close bound. Caching is already deactivated for - /// the whole recovery window, so a longer publication budget cannot extend staleness. - static let maximumLossRecoveryInvalidationDeadline: Duration = .seconds(60) - /// Recovery handles bursts (one loss marker per relay flush), but a stream that keeps losing - /// events faster than sweeps complete is not converging; fall back to the VM restart boundary. - static let maximumConsecutiveLossRecoveries = 5 - - private let endpoints: [HostShareCoherenceEndpoint] - private let guestEvents: any GuestFSEventSending - private let onDegraded: @Sendable (String) -> Void - private let onRecovered: @Sendable (String) -> Void - private let onFatalRecoveryRequired: @Sendable (String) -> Void - private let relayHealth: RelayDeliveryHealth - private var batchInProgress = false - private var batchWaiters = [CheckedContinuation]() - private var cachingWasActivated = false - private var cacheValidityMayRemain = false - private var cacheExpiryDeadline: TimeInterval? - private var notificationDeliveryBroken = false - private var deliveredNudges = Set() - private var pendingNudgeOperation: PendingNudgeOperation? - private var fatalRecoveryRequested = false - private var consecutiveLossRecoveries = 0 - private(set) public var isDegraded = false - - public init( - endpoints: [HostShareCoherenceEndpoint], - guestEvents: any GuestFSEventSending, - onDegraded: @escaping @Sendable (String) -> Void = { _ in }, - onRecovered: @escaping @Sendable (String) -> Void = { _ in }, - onFatalRecoveryRequired: @escaping @Sendable (String) -> Void = { _ in } - ) { - let sortedEndpoints = endpoints.sorted { $0.share.hostRoot.count > $1.share.hostRoot.count } - self.endpoints = sortedEndpoints - self.guestEvents = guestEvents - self.onDegraded = onDegraded - self.onRecovered = onRecovered - self.onFatalRecoveryRequired = onFatalRecoveryRequired - self.relayHealth = RelayDeliveryHealth { - // This callback runs synchronously on the relay failure path. Revoking response TTLs - // here closes the actor-reentrancy window while the readiness health frame is in flight. - for endpoint in sortedEndpoints { - endpoint.backend.deactivateCoherentCaching() - } - } - } - - /// Turns on bounded positive caching only after both halves of the coherence path are live: - /// every virtio-fs device has negotiated/reposted its stable notification buffers and the guest - /// watcher service answers an empty health frame. A partial activation is rolled back. - /// - /// `false` means "not ready" (or permanently degraded), so callers may poll during guest boot - /// without treating an expected startup race as an engine failure. - public func activateCachingIfReady() async throws -> Bool { - guard !batchInProgress else { return false } - return try await performActivation(requireNoBatch: true) - } - - /// Loss recovery reactivates from inside its own batch, so the batch guard is parameterized: - /// the batch gate already serializes recovery against every other caller. - private func performActivation(requireNoBatch: Bool) async throws -> Bool { - let cacheableEndpoints = endpoints.filter(\.watcherNudgesEnabled) - guard !isDegraded, - pendingNudgeOperation == nil, - !cacheableEndpoints.isEmpty else { return false } - guard let relayGeneration = relayHealth.readyGeneration else { return false } - guard cacheableEndpoints.allSatisfy({ - $0.backend.cacheActivationEligibility.isEligible - }) else { - return false - } - - // Operation zero is reserved for this idempotent empty health probe. Normal batches always - // use a fresh nonzero ID and retain it across any uncertain transport result. - let health = try await guestEvents.send(operationID: 0, paths: []) - guard health.touched == 0, - health.failed == 0, - !isDegraded, - !requireNoBatch || !batchInProgress else { return false } - - let activated = relayHealth.whileReady(generation: relayGeneration) { - for endpoint in cacheableEndpoints { - guard endpoint.backend.activateCoherentCaching() == .activated else { - for rollback in cacheableEndpoints { - rollback.backend.deactivateCoherentCaching() - } - return false - } - } - return true - } - guard activated else { return false } - cachingWasActivated = true - cacheValidityMayRemain = true - cacheExpiryDeadline = nil - return true - } - - /// Relay and lifecycle failures use the same fail-closed transition as delivery failures. - public func markDegraded(_ reason: String) { - degrade(reason) - } - - /// Marks the relay behind synchronously, before actor bookkeeping can race a suspended cache - /// readiness probe. A successful retry marks it caught up again; after caching has ever been - /// enabled, the actor also makes the downgrade permanent for this VM session. - public nonisolated func relayDeliveryFailed(_ reason: String) { - relayHealth.markFailed() - Task { await recordRelayDeliveryFailure(reason) } - } - - /// Called only after the coordinator has completed invalidation and watcher delivery for the - /// relay's pending batch. This is the point at which a startup retry is genuinely caught up. - public nonisolated func relayDeliverySucceeded() { - relayHealth.markSucceeded() - } - - var relayDeliveryIsCaughtUp: Bool { - relayHealth.readyGeneration != nil - } - - private func recordRelayDeliveryFailure(_ reason: String) { - if cachingWasActivated || isDegraded { - degrade(reason) - } - } - - public func process(_ incoming: [HostFSEventChange]) async throws { - guard !incoming.isEmpty else { return } - await beginBatch() - defer { endBatch() } - let requiresRescan = incoming.contains(where: \.requiresRescan) - if requiresRescan { - try await recoverFromEventLoss(latestEventID: incoming.map(\.eventID).max() ?? 0) - return - } - consecutiveLossRecoveries = 0 - let changes = incoming - - let prepared = prepare(changes) - if !notificationDeliveryBroken { - var attemptedNotificationChannel = false - do { - for item in prepared { - // Negative dentries and directory handles are never cached, so an empty list is - // normal during guest boot. Any known positive inode/entry, however, may own - // open-file page cache even while metadata TTL is zero and must be invalidated. - guard !item.invalidations.isEmpty else { continue } - attemptedNotificationChannel = true - let batchLimit = max(1, min(128, item.endpoint.backend.notificationBacklogLimit)) - try await item.endpoint.backend.invalidateAtomically( - item.invalidations, - maximumBatchSize: batchLimit, - timeout: Self.reverseInvalidationFailCloseDeadline - ) - } - } catch { - if attemptedNotificationChannel || cachingWasActivated || isDegraded { - notificationDeliveryBroken = true - requireFatalRecovery( - "host-share reverse invalidation failed; restarting VM to discard uncertain page cache: \(error)" - ) - return - } - } - } else { - // The first failure permanently disabled caching for this VM. Do not add another - // fail-close wait to later host edits; the one-time expiry wait below is enough. - if cacheValidityMayRemain { - try await waitForPreviouslyIssuedCacheValidity() - } - } - - do { - // A prior response may have been lost after the guest performed its fchmod operations. - // Replay that exact ordered request and ID before considering this (possibly merged) - // FSEvents batch. The guest then returns its cached result without repeating the work. - try await deliverPendingNudgeOperation() - var nudgeTargets: [String: NudgeTarget] = [:] - for item in prepared { - guard item.endpoint.watcherNudgesEnabled else { continue } - for change in item.changes { - guard let target = item.endpoint.share.nudgeTarget(forHostPath: change.hostPath) else { - continue - } - // Key by guest path, not host path: one host directory may intentionally be - // exposed at multiple guest aliases and every mount needs its own watcher event. - if var existing = nudgeTargets[target.guest] { - existing.eventID = max(existing.eventID, change.eventID) - nudgeTargets[target.guest] = existing - } else { - nudgeTargets[target.guest] = NudgeTarget( - guestPath: target.guest, - eventID: change.eventID - ) - } - } - } - - let sortedTargets = nudgeTargets.values - .filter { !wasNudgeDelivered($0) } - .sorted { $0.guestPath < $1.guestPath } - for targets in sortedTargets.chunked(maximumCount: GuestFSEventBatchCodec.maximumPaths) { - pendingNudgeOperation = PendingNudgeOperation( - operationID: GuestFSEventOperationIDs.next(), - targets: targets, - createdAt: ProcessInfo.processInfo.systemUptime - ) - try await deliverPendingNudgeOperation() - } - retireDeliveredNudges(coveredBy: Array(nudgeTargets.values)) - } catch { - // Close the readiness gate before returning control to FSEventBatcher. Its external - // failure callback is necessarily a few instructions later, and cache polling must not - // exploit that gap while this batch remains retryable. - relayHealth.markFailed() - if cachingWasActivated || isDegraded { - degrade("host-share watcher delivery failed: \(error)") - } - throw error - } - } - - private struct PreparedEndpoint { - var endpoint: HostShareCoherenceEndpoint - var changes: [HostFSEventChange] - var invalidations: [VirtioFSInvalidation] - } - - private struct NudgeTarget: Sendable { - var guestPath: String - var eventID: UInt64 - } - - private struct NudgeKey: Hashable, Sendable { - var guestPath: String - var eventID: UInt64 - } - - private struct PendingNudgeOperation: Sendable { - var operationID: UInt64 - var targets: [NudgeTarget] - var createdAt: TimeInterval - } - - private func deliverPendingNudgeOperation() async throws { - guard let operation = pendingNudgeOperation else { return } - let age = ProcessInfo.processInfo.systemUptime - operation.createdAt - guard age < GuestFSEventBatchCodec.maximumOperationRetryAgeSeconds else { - // The guest's dedupe entry may expire after 120 seconds. Never cross that boundary and - // risk turning an uncertain prior success into a second watcher event. - requireFatalRecovery( - "guest watcher operation \(operation.operationID) exceeded its safe retry window; restarting VM" - ) - throw HostShareCoherenceError.guestNudgeOperationExpired( - operationID: operation.operationID - ) - } - - let result: GuestFSEventBatchResult - do { - result = try await guestEvents.send( - operationID: operation.operationID, - paths: operation.targets.map(\.guestPath) - ) - } catch let error as GuestFSEventBridgeError { - switch error { - case .operationIDConflict, .dedupeCapacityExhausted, - .tooManyPaths, .invalidOperationID, .invalidPath, .oversizedFrame: - // Conflict/capacity status proves this payload did not execute; local validation - // failures happen before connection I/O. Discard the ID so a later attempt starts - // a logically new, still-safe operation. - pendingNudgeOperation = nil - case .guestExecutionFailed: - // A caught guest panic may have happened after a subset of side effects. Retain the - // ID and fail closed; allocating a new operation could duplicate that unknown work. - requireFatalRecovery( - "guest watcher operation \(operation.operationID) has indeterminate execution; restarting VM" - ) - default: - // Timeout, disconnect, or a malformed response leaves delivery uncertain. Retain - // the exact ID and ordered paths so the guest dedupe store resolves the ambiguity. - break - } - throw error - } - - guard result.pathCount == UInt32(operation.targets.count) else { - throw GuestFSEventBridgeError.invalidResponse - } - // A valid response is durable in the guest dedupe window. It is now safe to retire this - // operation: successful indices are final, while failed indices receive a fresh ID later. - pendingNudgeOperation = nil - let failed = Set(result.failedIndices.map(Int.init)) - for (index, target) in operation.targets.enumerated() where !failed.contains(index) { - recordDeliveredNudge(target) - } - if let failedIndex = result.failedIndices.first.map(Int.init) { - let path = operation.targets[failedIndex].guestPath - throw HostShareCoherenceError.guestNudgeFailed(path: path) - } - } - - private func prepare( - _ changes: [HostFSEventChange], - exactRecoveryEndpoints: Bool = false - ) -> [PreparedEndpoint] { - var grouped: [Int: ( - changes: [HostFSEventChange], - invalidations: [String: VirtioFSInvalidation], - deletedNodeIDs: Set - )] = [:] - for endpointIndex in endpoints.indices { - let endpoint = endpoints[endpointIndex] - var endpointChanges = [String: HostFSEventChange]() - for change in changes { - let matchesEndpoint: Bool - if exactRecoveryEndpoints { - matchesEndpoint = endpoint.share.hostRoot == change.hostPath - && endpoint.share.guestRoot == change.guestPath - } else { - matchesEndpoint = endpoint.share.mapHostPathToGuest(change.hostPath) != nil - } - guard matchesEndpoint else { continue } - let expanded: [HostFSEventChange] - if change.isDirectoryAggregate { - expanded = Self.expandedDirectoryChange(change, endpoint: endpoint) - } else { - expanded = [change] - } - for exact in expanded { - if var existing = endpointChanges[exact.hostPath] { - existing.flags |= exact.flags - existing.eventID = max(existing.eventID, exact.eventID) - endpointChanges[exact.hostPath] = existing - } else { - endpointChanges[exact.hostPath] = exact - } - } - } - - // FSEvents can coalesce a rename to its destination path only and may classify that - // destination as either renamed or created. Reconcile cached bindings whose pinned - // identity no longer belongs at their old path so Linux sees a source-side - // DELETE/ENTRY notification as well as the destination update. Do this only for a - // namespace mutation and never during an exact loss-recovery root batch. - let namespaceMutationMask = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - if !exactRecoveryEndpoints, - endpointChanges.values.contains(where: { $0.flags & namespaceMutationMask != 0 }) { - let sourceEventID = endpointChanges.values.map(\.eventID).max() ?? 0 - let synthesizedFlags = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - for stalePath in endpoint.backend.hostFS - .knownStaleHostPathsForNamespaceReconciliation() - where endpointChanges[stalePath] == nil { - guard let guestPath = endpoint.share.mapHostPathToGuest(stalePath) else { continue } - endpointChanges[stalePath] = HostFSEventChange( - hostPath: stalePath, - guestPath: guestPath, - flags: synthesizedFlags, - eventID: sourceEventID - ) - } - } - - for change in endpointChanges.values.sorted(by: { - $0.hostPath == $1.hostPath ? $0.eventID < $1.eventID : $0.hostPath < $1.hostPath - }) { - let aliasPaths: [String] - if change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 { - // A paired FSEvents rename has distinct source and destination records. Do not - // fan a destination's flags onto the source identity: the source must retain - // its absent-path classification so it can emit FUSE_NOTIFY_DELETE. - aliasPaths = [change.hostPath] - } else { - aliasPaths = endpoint.backend.hostFS - .knownIdentityAliasHostPaths(forHostPath: change.hostPath) - } - for aliasPath in aliasPaths { - guard let guestPath = endpoint.share.mapHostPathToGuest(aliasPath), - let snapshot = endpoint.backend.hostFS - .invalidationSnapshot(forHostPath: aliasPath) else { - continue - } - let effectiveChange = HostFSEventChange( - hostPath: aliasPath, - guestPath: guestPath, - flags: change.flags, - eventID: change.eventID, - permitsContentInvalidation: aliasPath == change.hostPath - ) - // A whole-home share receives unrelated macOS activity continuously. If neither - // the path nor its exact parent has ever been resolved by the guest, Linux cannot - // hold a dentry/inode cache or directory watch, so relaying it only creates noise. - guard !snapshot.nodeIDs.isEmpty || !snapshot.parentNodeIDs.isEmpty else { continue } - grouped[endpointIndex, default: ([], [:], [])].changes.append(effectiveChange) - - let planned = Self.plannedInvalidations( - for: effectiveChange, - snapshot: snapshot - ) - // The host mutation has already happened. Tombstone identities proven stale - // before Linux receives DELETE/INVAL_INODE so an open-file GETATTR that omits - // FUSE_GETATTR_FH observes the old inode's authoritative post-mutation nlink. - endpoint.backend.hostFS.reconcileHostInvalidation( - forHostPath: aliasPath, - staleNodeIDs: snapshot.staleNodeIDs - ) - let deletedNodeIDs = Set(planned.values.compactMap { invalidation -> UInt64? in - guard case .delete(_, let childNodeID, _) = invalidation else { return nil } - return childNodeID - }) - grouped[endpointIndex]?.deletedNodeIDs.formUnion(deletedNodeIDs) - for nodeID in deletedNodeIDs { - grouped[endpointIndex]?.invalidations.removeValue(forKey: "i:\(nodeID)") - } - - for (key, invalidation) in planned { - if case .inode(let nodeID, _, _) = invalidation, - grouped[endpointIndex]?.deletedNodeIDs.contains(nodeID) == true { - continue - } - let existing = grouped[endpointIndex]?.invalidations[key] - grouped[endpointIndex]?.invalidations[key] = Self.mergeInvalidation( - invalidation, - preservingStronger: existing - ) - } - } - } - } - - return grouped.keys.sorted().map { index in - let group = grouped[index]! - var invalidations = group.invalidations - // DELETE disconnects the stale dentry but does not reliably expire an open inode's - // cached attributes. Re-add attribute-only invalidation after cross-alias merging; a - // content invalidation must never win for a final unlinked/replaced identity. - for nodeID in group.deletedNodeIDs { - invalidations["i:\(nodeID)"] = .inode( - nodeID: nodeID, - offset: -1, - length: 0 - ) - } - return PreparedEndpoint( - endpoint: endpoints[index], - changes: group.changes, - invalidations: invalidations.keys.sorted().compactMap { invalidations[$0] } - ) - } - } - - static func expandedDirectoryChange( - _ change: HostFSEventChange, - endpoint: HostShareCoherenceEndpoint - ) -> [HostFSEventChange] { - guard change.isDirectoryAggregate else { return [change] } - let conservativeFlags = UInt32( - kFSEventStreamEventFlagItemModified | - kFSEventStreamEventFlagItemInodeMetaMod - ) - return endpoint.backend.hostFS - .knownHostPaths(inHostDirectory: change.hostPath) - .compactMap { path in - guard let guestPath = endpoint.share.mapHostPathToGuest(path) else { return nil } - return HostFSEventChange( - hostPath: path, - guestPath: guestPath, - flags: conservativeFlags, - eventID: change.eventID - ) - } - } - - /// Builds one path's reverse invalidations without touching coordinator state. Kept internal so - /// tests can lock down the distinction between pathname replacement and inode data lifetime. - static func plannedInvalidations( - for change: HostFSEventChange, - snapshot: HostFSInvalidationSnapshot - ) -> [String: VirtioFSInvalidation] { - var invalidations = [String: VirtioFSInvalidation]() - let representsRemoval = change.representsRemoval - let namespaceMask = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - let deletionCandidates: Set - if representsRemoval { - // Alias fanout carries the original removal flags to every hard-link name. Only the - // pathname whose identity is actually stale was removed; deleting every current alias - // would incorrectly disconnect surviving dentries that share the same node ID. - deletionCandidates = Set(snapshot.staleNodeIDs + snapshot.unverifiedNodeIDs) - } else if change.flags & namespaceMask != 0 { - deletionCandidates = Set(snapshot.staleNodeIDs + snapshot.unverifiedNodeIDs) - } else { - // An inode identity mismatch proves replacement even if FSEvents happened to classify - // the batch as content-only. Synthetic identities have no prior identity to compare. - deletionCandidates = Set(snapshot.staleNodeIDs) - } - let survivingLinks = Set(snapshot.survivingLinkNodeIDs) - // A host rename removes this *dentry* even when the moved inode still has nlink > 0 at - // its new name. Treat it as a DELETE for the source directory entry so Linux watchers see - // the removal. Ordinary nonfinal unlinks retain their entry-only hard-link semantics. - let representsRenameSource = representsRemoval - && change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 - let nonFinalUnlinks = representsRenameSource - ? Set() - : deletionCandidates.intersection(survivingLinks) - let deleteNodeIDs = representsRenameSource - ? deletionCandidates.sorted() - : deletionCandidates.subtracting(survivingLinks).sorted() - - // DELETE detaches a final stale pathname identity and can produce IN_DELETE_SELF. A nonfinal - // hard-link unlink must instead invalidate only that entry plus inode attributes/nlink: the - // shared inode is still live through another name. Final stale objects also need an - // attribute-only inode invalidation because DELETE does not reliably expire an open inode's - // cached nlink. Never data-invalidate stale/final objects: an open fd or mmap must retain its - // old pages and dirty writes must keep their old target. - let staleNodeIDs = Set(snapshot.staleNodeIDs) - let deletedNodeIDs = Set(deleteNodeIDs) - // A namespace event is fanned out to every known hard-link alias. If this alias has no stale - // identity, its inode data did not change merely because another name was replaced/removed; - // touching its pages could spuriously conflict with a dirty mmap. The exact changed path has - // deletion candidates and may still need content invalidation for its current replacement. - // APFS may coalesce a prior create and a later same-inode host write into one event. The - // exact changed pathname must still invalidate its data pages even when that coalesced - // namespace event has no stale identity; otherwise a dirty guest MAP_SHARED folio can - // survive the host write. Alias fanout strips ItemModified above, so this remains local to - // the path that FSEvents actually reported. - let invalidatesContent = change.permitsContentInvalidation - && change.flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 - for nodeID in snapshot.nodeIDs where !deletedNodeIDs.contains(nodeID) { - if nonFinalUnlinks.contains(nodeID) { - invalidations["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) - } else if !staleNodeIDs.contains(nodeID) { - invalidations["i:\(nodeID)"] = invalidatesContent - ? .inode(nodeID: nodeID, offset: 0, length: -1) - // offset < 0 makes fuse_reverse_inval_inode invalidate attributes/ACLs only. - : .inode(nodeID: nodeID, offset: -1, length: 0) - } - } - for nodeID in deleteNodeIDs { - invalidations["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) - } - - guard let name = snapshot.entryName else { return invalidations } - if !snapshot.parentNodeIDs.isEmpty, !deleteNodeIDs.isEmpty { - // DELETE must precede INVAL_ENTRY for this name. ENTRY first would remove the cached - // dentry before Linux can match DELETE's child ID and notify the old inode's watch. - for parentNodeID in snapshot.parentNodeIDs { - for childNodeID in deleteNodeIDs { - invalidations["d:\(parentNodeID):\(childNodeID):\(name)"] = .delete( - parentNodeID: parentNodeID, - childNodeID: childNodeID, - name: name - ) - } - } - } - // ENTRY invalidation is a namespace operation: it is only needed when the name→inode - // mapping may have changed (a stale pinned identity proves replacement; a synthetic - // identity cannot be compared). For a verified-unchanged path the positive dentry still - // maps to the same inode, and Linux's fuse_reverse_inval_entry runs d_invalidate, which - // detaches every mount below the dentry — a host write to a *sibling* file must never - // unmount a container's bind of this directory out from under it. - let identityMayHaveChanged = !snapshot.staleNodeIDs.isEmpty - || !snapshot.unverifiedNodeIDs.isEmpty - let shouldInvalidateEntry = representsRemoval - ? !nonFinalUnlinks.isEmpty - : identityMayHaveChanged - if shouldInvalidateEntry, !snapshot.nodeIDs.isEmpty { - // Negative dentries are never cached. A known old/current identity means the pathname may - // have a positive dentry, so invalidate it after DELETE to reveal the current replacement. - // For a nonfinal hard-link removal ENTRY is the namespace operation: DELETE would falsely - // signal that the shared inode itself died while another link remains. - for parentNodeID in snapshot.parentNodeIDs { - invalidations["e:\(parentNodeID):\(name)"] = .entry( - parentNodeID: parentNodeID, - name: name - ) - } - } - return invalidations - } - - /// Multiple hard-link aliases can plan the same inode. A content invalidation must dominate an - /// attribute-only invalidation regardless of FSEvents batch iteration order. - private static func mergeInvalidation( - _ incoming: VirtioFSInvalidation, - preservingStronger existing: VirtioFSInvalidation? - ) -> VirtioFSInvalidation { - guard let existing else { return incoming } - if case .inode(_, 0, -1) = existing, - case .inode(_, -1, 0) = incoming { - return existing - } - return incoming - } - - private func degrade(_ reason: String) { - // This is synchronous and happens before diagnostics/callbacks: every later response will - // advertise zero validity even if the notification transport is still technically alive. - if cachingWasActivated, cacheValidityMayRemain, cacheExpiryDeadline == nil { - cacheExpiryDeadline = ProcessInfo.processInfo.systemUptime - + Double(VirtioFS.maximumCoherentCacheValiditySeconds) - + 0.1 - } - for endpoint in endpoints { - endpoint.backend.deactivateCoherentCaching() - } - // A loss burst may be reported in several batches. Caching is already disabled after - // the first one, so repeat diagnostics add noise without communicating a new state. - guard !isDegraded else { return } - isDegraded = true - onDegraded(reason) - } - - /// Recovers from FSEvents loss in place instead of restarting the VM. The window is - /// fail-closed for freshness but not for availability: caching is deactivated first (new - /// grants are zero-TTL), every known path whose pinned identity no longer matches the disk - /// gets its identity-verified DELETE/ENTRY invalidation through the ordinary pipeline, and a - /// content+attribute sweep over every known node retires stale pages and attributes that an - /// in-place host write could have left behind. Entry invalidation stays identity-gated: a - /// verified-unchanged dentry must survive because fuse_reverse_inval_entry's d_invalidate - /// detaches container bind mounts beneath it. Negative dentries expire on their own 1 s bound. - /// Once the notification barrier acknowledges the sweep, caching is reactivated. - private func recoverFromEventLoss(latestEventID: UInt64) async throws { - consecutiveLossRecoveries += 1 - guard consecutiveLossRecoveries <= Self.maximumConsecutiveLossRecoveries else { - requireFatalRecovery( - "FSEvents loss recurred \(consecutiveLossRecoveries) times without converging; restarting VM to discard unknown descendant cache state" - ) - return - } - degrade("FSEvents lost host-share changes; recovering in place with caching disabled") - - do { - let synthesizedFlags = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - var staleChanges = [HostFSEventChange]() - for endpoint in endpoints { - for stalePath in endpoint.backend.hostFS.knownStaleHostPathsForNamespaceReconciliation() { - guard let guestPath = endpoint.share.mapHostPathToGuest(stalePath) else { continue } - staleChanges.append(HostFSEventChange( - hostPath: stalePath, - guestPath: guestPath, - flags: synthesizedFlags, - eventID: latestEventID - )) - } - } - let prepared = prepare(staleChanges) - for item in prepared where !item.invalidations.isEmpty { - try await item.endpoint.backend.invalidateAtomically( - item.invalidations, - maximumBatchSize: max(1, min(128, item.endpoint.backend.notificationBacklogLimit)), - timeout: Self.lossRecoveryDeadline(invalidationCount: item.invalidations.count) - ) - } - - for endpoint in endpoints { - let sweep = endpoint.backend.hostFS.knownNodeIDsForLossRecovery().map { - VirtioFSInvalidation.inode(nodeID: $0, offset: 0, length: -1) - } - guard !sweep.isEmpty else { continue } - try await endpoint.backend.invalidateAtomically( - sweep, - maximumBatchSize: max(1, min(128, endpoint.backend.notificationBacklogLimit)), - timeout: Self.lossRecoveryDeadline(invalidationCount: sweep.count) - ) - } - } catch { - requireFatalRecovery( - "host-share loss recovery could not publish its invalidation sweep; restarting VM: \(error)" - ) - return - } - - // The sweep and barrier make the guest coherent again; the degrade latch can lift. If - // reactivation is not currently eligible (for example the notification buffers are being - // reposted), the share simply keeps running with zero-TTL grants, which is correct. - isDegraded = false - cacheExpiryDeadline = nil - let reactivated = (try? await performActivation(requireNoBatch: false)) ?? false - onRecovered( - reactivated - ? "host-share coherence recovered from FSEvents loss; caching reactivated" - : "host-share coherence recovered from FSEvents loss; running uncached until the channel is eligible again" - ) - } - - private static func lossRecoveryDeadline(invalidationCount: Int) -> Duration { - min( - Self.maximumLossRecoveryInvalidationDeadline, - .seconds(5) + .milliseconds(2 * invalidationCount) - ) - } - - private func requireFatalRecovery(_ reason: String) { - // Unknown host edits make every endpoint unsafe, including read-only mounts with stale - // open-file page cache. Establish the publication boundary synchronously and for all - // aliases before diagnostics or the VM-stop callback can run. A late notification ack or - // guest-controlled device reset cannot reopen this one-way backend latch. - for endpoint in endpoints { - endpoint.backend.failStopRequestPublication() - } - degrade(reason) - if !fatalRecoveryRequested { - fatalRecoveryRequested = true - onFatalRecoveryRequired(reason) - } - } - - private func beginBatch() async { - if !batchInProgress { - batchInProgress = true - return - } - await withCheckedContinuation { continuation in - batchWaiters.append(continuation) - } - } - - private func endBatch() { - if batchWaiters.isEmpty { - batchInProgress = false - } else { - // Ownership passes directly to the oldest waiter; keep the gate closed so a new caller - // cannot overtake it between continuation resumption and actor re-entry. - batchWaiters.removeFirst().resume() - } - } - - private func waitForPreviouslyIssuedCacheValidity() async throws { - guard cacheValidityMayRemain else { return } - if cacheExpiryDeadline == nil { - cacheExpiryDeadline = ProcessInfo.processInfo.systemUptime - + Double(VirtioFS.maximumCoherentCacheValiditySeconds) - + 0.1 - } - if let deadline = cacheExpiryDeadline { - let remaining = max(0, deadline - ProcessInfo.processInfo.systemUptime) - if remaining > 0 { - try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } - } - cacheValidityMayRemain = false - cacheExpiryDeadline = nil - } - - private func wasNudgeDelivered(_ target: NudgeTarget) -> Bool { - deliveredNudges.contains(NudgeKey(guestPath: target.guestPath, eventID: target.eventID)) - } - - private func recordDeliveredNudge(_ target: NudgeTarget) { - deliveredNudges.insert(NudgeKey(guestPath: target.guestPath, eventID: target.eventID)) - } - - private func retireDeliveredNudges(coveredBy targets: [NudgeTarget]) { - guard !deliveredNudges.isEmpty, !targets.isEmpty else { return } - let covered = Dictionary(targets.map { ($0.guestPath, $0.eventID) }) { _, newer in newer } - deliveredNudges = deliveredNudges.filter { key in - guard let through = covered[key.guestPath] else { return true } - return key.eventID > through - } - } -} - -/// A small synchronous gate shared by the FSEvents callback and the actor. Holding its lock across -/// activation makes failure-vs-activation ordering total: either activation wins and the failure -/// immediately revokes it, or failure wins and activation is refused for that generation. -private final class RelayDeliveryHealth: @unchecked Sendable { - private let lock = NSLock() - private let onFailure: @Sendable () -> Void - private var generation: UInt64 = 1 - private var caughtUp = true - - init(onFailure: @escaping @Sendable () -> Void) { - self.onFailure = onFailure - } - - var readyGeneration: UInt64? { - lock.withLock { caughtUp ? generation : nil } - } - - func markFailed() { - lock.withLock { - generation &+= 1 - caughtUp = false - onFailure() - } - } - - func markSucceeded() { - lock.withLock { - generation &+= 1 - caughtUp = true - } - } - - func whileReady(generation expected: UInt64, _ body: () -> Bool) -> Bool { - lock.withLock { - guard caughtUp, generation == expected else { return false } - return body() - } - } -} - -private extension Array { - func chunked(maximumCount: Int) -> [[Element]] { - guard !isEmpty else { return [] } - let size = Swift.max(1, maximumCount) - var result = [[Element]]() - result.reserveCapacity((count + size - 1) / size) - var start = 0 - while start < count { - let end = Swift.min(count, start + size) - result.append(Array(self[start.. GuestFSEventBatchResult { let deadline = ProcessInfo.processInfo.systemUptime + Double(timeoutNanoseconds) / 1_000_000_000 - let connection = vsock.connect(port: VsockPorts.fsevents) + let connection: VsockConnection + do { + connection = try vsock.connectForServiceIfCapacity( + port: VsockPorts.fsevents, + service: .fileEvents + ) + } catch let error as VirtioVsockServiceAdmissionError { + throw GuestFSEventBridgeError.serviceAdmissionFailed(error) + } catch let error as VirtioVsockConnectionAdmissionError { + throw GuestFSEventBridgeError.connectionAdmissionFailed(error) + } try cancellation.install(connection) defer { connection.close() } do { @@ -200,6 +214,8 @@ public final class GuestFSEventBridge: GuestFSEventSending, @unchecked Sendable throw GuestFSEventBridgeError.timedOut } catch VsockConnectionWriteError.connectionClosed { throw GuestFSEventBridgeError.connectionClosed + } catch VsockConnectionWriteError.outboundQueueFull { + throw GuestFSEventBridgeError.outboundBackpressure } connection.shutdownSend() let prefix = try readExactly(4, from: connection, deadline: deadline) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift index c94f694e..d874e82b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift @@ -1,4 +1,5 @@ import Darwin +import DoryGuestMemoryShim import Foundation import Hypervisor import Synchronization @@ -24,36 +25,159 @@ public final class ByteCounter: @unchecked Sendable { } } -/// The VM's RAM: one anonymous mmap region in OUR address space, mapped into the guest at a fixed -/// physical base. Owning the pages is the entire point of dory-hv: reclaim is madvise on this -/// region, something Virtualization.framework structurally cannot offer (its guest RAM lives in -/// Apple's XPC process). +/// Raw host pointers never escape these synchronous callbacks. GuestMemory invokes them only while +/// holding its page-state mutex; unchecked Sendable documents that external serialization seam. +struct GuestMemoryReclaimOperations: @unchecked Sendable { + static let production = GuestMemoryReclaimOperations( + unmap: { guestAddress, length in + hv_vm_unmap(guestAddress, length) == HV_SUCCESS + }, + map: { hostAddress, guestAddress, length in + hv_vm_map( + hostAddress, + guestAddress, + length, + hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC) + ) == HV_SUCCESS + }, + markReusable: { hostAddress, length in + madvise(hostAddress, length, MADV_FREE_REUSABLE) == 0 + }, + markInUse: { hostAddress, length in + madvise(hostAddress, length, MADV_FREE_REUSE) == 0 + } + ) + + let unmap: (UInt64, Int) -> Bool + let map: (UnsafeMutableRawPointer, UInt64, Int) -> Bool + let markReusable: (UnsafeMutableRawPointer, Int) -> Bool + let markInUse: (UnsafeMutableRawPointer, Int) -> Bool +} + +/// Exact outcome of a free-page-reporting reclaim attempt. In particular, `unmappedNotReclaimed` +/// is not a rejection: stage-2 ownership was removed and is tracked for fault-time restoration, +/// but macOS did not accept the reusable-memory advice so the bytes must not be counted reclaimed. +public enum GuestMemoryReleaseResult: Equatable, Sendable { + case reclaimed + case unmappedNotReclaimed + case rejected + case unmapFailed + + public var guestMappingWasReleased: Bool { + switch self { + case .reclaimed, .unmappedNotReclaimed: true + case .rejected, .unmapFailed: false + } + } + + public var hostMemoryWasReclaimed: Bool { self == .reclaimed } +} + +/// One owned, bounded view of the VMM's unlinked guest-RAM backing object. The descriptor is an +/// independently closeable authority suitable for transfer to a signed worker. +struct GuestMemorySharedRegion: @unchecked Sendable { + let descriptor: FileHandle + let offset: UInt64 + let length: UInt64 + let declaredFileSize: UInt64 +} + +/// The VM's RAM: one unlinked shared mapping in OUR address space, mapped into the guest at a fixed +/// physical base. The shareable backing lets isolated device workers map only explicitly granted +/// slices while dory-hv retains reclaim and stage-2 ownership. public final class GuestMemory: @unchecked Sendable { + private enum PageMappingState: Equatable { + case mapped + case released(reclaimed: Bool, requiresMarkInUse: Bool) + } + public let guestBase: UInt64 public let size: UInt64 public let hostBase: UnsafeMutableRawPointer + /// Unlinked, process-private shared-memory authority. Renderer workers receive only bounded + /// CLOEXEC duplicates of this descriptor, never a host pointer or a filesystem path. + private let backingDescriptor: Int32 + private let backingDeclaredFileSize: UInt64 + private let backingIdentity: DoryGuestMemoryBackingIdentity public let releasedBytes = ByteCounter() public let restoredBytes = ByteCounter() + public let reclaimUnmapFailures = ByteCounter() + public let reclaimAdviceFailures = ByteCounter() + public let restoreAdviceFailures = ByteCounter() + public let restoreMapFailures = ByteCounter() static let pageSize: UInt64 = HostPage.size - private let releasedPages: Mutex<[Bool]> + private let pageStates: Mutex<[PageMappingState]> + private let reclaimOperations: GuestMemoryReclaimOperations + + public convenience init(guestBase: UInt64, size: UInt64) throws { + try self.init( + guestBase: guestBase, + size: size, + reclaimOperations: .production + ) + } - public init(guestBase: UInt64, size: UInt64) throws { + init( + guestBase: UInt64, + size: UInt64, + reclaimOperations: GuestMemoryReclaimOperations + ) throws { guard size > 0, size % Self.pageSize == 0 else { throw VMError.invalidConfiguration("RAM size must be a positive multiple of the host page size") } - guard let region = mmap(nil, Int(size), PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0), - region != MAP_FAILED else { + guard DoryGuestMemoryBackingDataOffset() == Self.pageSize else { + throw VMError.invalidConfiguration("guest RAM authority page size does not match the host") + } + var identity = DoryGuestMemoryBackingIdentity() + var declaredFileSize: UInt64 = 0 + let descriptor = DoryCreateGuestMemoryBacking( + size, + &identity, + &declaredFileSize + ) + guard descriptor >= 0 else { + throw VMError.outOfMemory( + "cannot create guest RAM shared-memory authority: errno \(errno)" + ) + } + var descriptorIsOwned = true + defer { + if descriptorIsOwned { close(descriptor) } + } + guard DoryGuestMemoryBackingMatches( + descriptor, + declaredFileSize, + &identity + ) == 1 else { + throw VMError.outOfMemory("guest RAM authority failed identity validation") + } + guard let region = mmap( + nil, + Int(size), + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + off_t(DoryGuestMemoryBackingDataOffset()) + ), region != MAP_FAILED else { throw VMError.outOfMemory("mmap of \(size) bytes failed: errno \(errno)") } self.guestBase = guestBase self.size = size self.hostBase = region - self.releasedPages = Mutex([Bool](repeating: false, count: Int(size / Self.pageSize))) + self.backingDescriptor = descriptor + self.backingDeclaredFileSize = declaredFileSize + self.backingIdentity = identity + self.pageStates = Mutex( + [PageMappingState](repeating: .mapped, count: Int(size / Self.pageSize)) + ) + self.reclaimOperations = reclaimOperations + descriptorIsOwned = false } deinit { munmap(hostBase, Int(size)) + close(backingDescriptor) } public func mapIntoGuest() throws { @@ -67,49 +191,75 @@ public final class GuestMemory: @unchecked Sendable { /// range is unmapped from the guest first, then marked reusable; the physical pages leave the /// process footprint immediately. The guest gets the range back lazily via handleRAMFault. @discardableResult - public func releaseRange(guestAddress: UInt64, length: UInt64) -> Bool { + public func releaseRange(guestAddress: UInt64, length: UInt64) -> GuestMemoryReleaseResult { guard contains(guestAddress, count: length), length > 0, - guestAddress % Self.pageSize == 0, length % Self.pageSize == 0 else { return false } + guestAddress % Self.pageSize == 0, length % Self.pageSize == 0 else { return .rejected } let first = Int((guestAddress - guestBase) / Self.pageSize) let count = Int(length / Self.pageSize) let host = hostBase.advanced(by: Int(guestAddress - guestBase)) - // The bitmap flip and the stage-2 unmap happen atomically under one lock, so a concurrent - // restorePage on another vCPU can never observe an unmapped-but-unmarked page (which it - // would misread as a genuine fault and crash). - return releasedPages.withLock { pages -> Bool in - guard hv_vm_unmap(guestAddress, Int(length)) == HV_SUCCESS else { return false } - _ = madvise(host, Int(length), MADV_FREE_REUSABLE) - for page in first.. GuestMemoryReleaseResult in + let end = min(first + count, states.count) + guard first < end, + states[first.. Bool { guard contains(guestAddress, count: 1) else { return false } let pageStart = guestAddress & ~(Self.pageSize - 1) let index = Int((pageStart - guestBase) / Self.pageSize) let host = hostBase.advanced(by: Int(pageStart - guestBase)) - return releasedPages.withLock { pages -> Bool in - guard index < pages.count else { return false } - // Bit clear means a concurrent fault on the same page already remapped it (both - // observed the fault; whoever won the lock first did the mapping). A stage-2 RAM fault - // never occurs on a page we did not unmap, so treating this as already-mapped is safe - // and the guest's retry resolves it. - guard pages[index] else { return true } - _ = madvise(host, Int(Self.pageSize), MADV_FREE_REUSE) - guard hv_vm_map(host, pageStart, Int(Self.pageSize), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC)) == HV_SUCCESS else { + return pageStates.withLock { states -> Bool in + guard index < states.count else { return false } + // Mapped means a concurrent fault on this page already won the lock and restored it. + // The guest retry can proceed without another stage-2 map or accounting change. + guard case .released(let reclaimed, let requiresMarkInUse) = states[index] else { + return true + } + if requiresMarkInUse { + guard reclaimOperations.markInUse(host, Int(Self.pageSize)) else { + restoreAdviceFailures.add(1) + return false + } + // MADV_FREE_REUSE succeeded even if the following stage-2 map does not. Persist + // that sub-state so a retry never repeats a one-way host advice transition. + states[index] = .released(reclaimed: reclaimed, requiresMarkInUse: false) + } + guard reclaimOperations.map(host, pageStart, Int(Self.pageSize)) else { + restoreMapFailures.add(1) return false } - pages[index] = false - restoredBytes.add(Self.pageSize) + states[index] = .mapped + if reclaimed { restoredBytes.add(Self.pageSize) } return true } } @@ -127,6 +277,60 @@ public final class GuestMemory: @unchecked Sendable { return hostBase.advanced(by: Int(guestAddress - guestBase)) } + /// Produces one bounded descriptor slice over guest RAM for an isolated device worker. The + /// file was unlinked before this object became visible, so the returned authority is path-free + /// and disappears when the last duplicate closes. + func duplicateSharedRegion( + at guestAddress: UInt64, + count: UInt64 + ) throws -> GuestMemorySharedRegion { + let bounds = try sharedRegionBounds(at: guestAddress, count: count) + let descriptor = try duplicateSharedBackingDescriptor() + return GuestMemorySharedRegion( + descriptor: descriptor, + offset: bounds.offset, + length: bounds.length, + declaredFileSize: bounds.declaredFileSize + ) + } + + func sharedRegionBounds( + at guestAddress: UInt64, + count: UInt64 + ) throws -> (offset: UInt64, length: UInt64, declaredFileSize: UInt64) { + guard count > 0, contains(guestAddress, count: count) else { + throw VMError.guestMemoryFault(address: guestAddress, count: count) + } + return ( + DoryGuestMemoryBackingDataOffset() + guestAddress - guestBase, + count, + backingDeclaredFileSize + ) + } + + func duplicateSharedBackingDescriptor() throws -> FileHandle { + let duplicate = fcntl(backingDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw VMError.outOfMemory("cannot duplicate guest RAM authority: errno \(errno)") + } + guard sharedBackingDescriptorMatches(duplicate) else { + close(duplicate) + throw VMError.invalidConfiguration("guest RAM authority identity changed") + } + return FileHandle(fileDescriptor: duplicate, closeOnDealloc: true) + } + + /// Tests and cold-path authority handoff use the same exact descriptor identity check. It + /// rejects regular files, stale descriptors, resized objects, and another VM's same-sized RAM. + func sharedBackingDescriptorMatches(_ descriptor: Int32) -> Bool { + var identity = backingIdentity + return DoryGuestMemoryBackingMatches( + descriptor, + backingDeclaredFileSize, + &identity + ) == 1 + } + public func read(_ type: T.Type, at guestAddress: UInt64) throws -> T { let pointer = try hostPointer(at: guestAddress, count: UInt64(MemoryLayout.size)) var value = T.zero diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift index 9c58f44c..35696c5f 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift @@ -6,8 +6,8 @@ public enum GuestMemoryReclaimBootCommand { private static let quietGate = "set -- $(awk '/^cpu /{t=0; for(i=2;i<=NF;i++) t+=$i; print t,$5; exit}' /proc/stat); total=${1:-0}; idle=${2:-0}; quiet=0; if [ ${prev_total:-0} -gt 0 ]; then dt=$((total-prev_total)); di=$((idle-prev_idle)); [ $dt -gt 0 ] && [ $((100 - (di * 100 / dt))) -le 8 ] && quiet=1; fi; prev_total=$total; prev_idle=$idle; running=$(docker -H unix:///var/run/docker.sock ps -q 2>/dev/null | wc -l | tr -d ' '); if [ ${running:-0} -gt 0 ] && [ $quiet -eq 1 ]; then quiet_running_ticks=$((quiet_running_ticks+1)); else quiet_running_ticks=0; fi" - /// Emits the idle-reclaim daemon. `experimentalSenpai` must only be true for the explicit - /// `DORY_ENGINE_RECLAIM_MODE=senpai` opt-in; false preserves the established drop-caches path. + /// Emits the idle-reclaim daemon. `experimentalSenpai` must only be true for the typed + /// `senpai` launch policy; false preserves the established drop-caches path. public static func idleLoop( experimentalSenpai: Bool, pressureMemoryPath: String = "/proc/pressure/memory" diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift index 1183ee22..19101c98 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift @@ -1,4 +1,3 @@ -import Darwin import Foundation /// Publishes one private host Unix socket and relays each accepted connection to a fixed guest @@ -6,16 +5,26 @@ import Foundation public final class GuestVsockSocketBridge: @unchecked Sendable { private let socketPath: String private let guestPort: UInt32 + private let service: VirtioVsockService private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener public init( socketPath: String, guestPort: UInt32, + service: VirtioVsockService, log: @escaping @Sendable (String) -> Void = { _ in } ) { self.socketPath = socketPath self.guestPort = guestPort + self.service = service self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: 0o600, + endpointLabel: "vsock bridge", + log: log + ) } public static func validateSocketPath(_ socketPath: String) throws { @@ -23,44 +32,29 @@ public final class GuestVsockSocketBridge: @unchecked Sendable { } public func attach(to vsock: VirtioVsock) throws { - let listener = try VsockUnixRelay.makeListener(socketPath: socketPath, mode: 0o600) - let path = socketPath let port = guestPort - let log = log - let box = VsockBox(vsock) - Thread.detachNewThread { - while true { - let client = accept(listener, nil, nil) - guard client >= 0 else { - if errno == EINTR { continue } - log("vsock bridge accept failed on \(path): errno \(errno)") - break - } - var noSigpipe: Int32 = 1 - _ = setsockopt( - client, - SOL_SOCKET, - SO_NOSIGPIPE, - &noSigpipe, - socklen_t(MemoryLayout.size) - ) - let connection = ConnectionBox(box.vsock.connect(port: port)) - Thread.detachNewThread { - VsockUnixRelay.serve(client: client, connection: connection.value) - } + let logger = log + try listener.attach(to: vsock, service: service) { _ in + do { + return try vsock.connectIfCapacity(port: port) + } catch { + logger("vsock bridge rejected guest port \(port): \(error)") + return nil } - close(listener) } log("vsock bridge serving \(socketPath) over guest port \(guestPort)") } - private final class VsockBox: @unchecked Sendable { - let vsock: VirtioVsock - init(_ vsock: VirtioVsock) { self.vsock = vsock } + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) } - private final class ConnectionBox: @unchecked Sendable { - let value: VsockConnection - init(_ value: VsockConnection) { self.value = value } + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift index 112ad5e3..55675852 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift @@ -17,6 +17,7 @@ public final class HostAIBridge: @unchecked Sendable { private let ports: [UInt16] private let host: String private let log: @Sendable (String) -> Void + private let lifecycle: BoundedGuestVsockServiceLifecycle public init( ports: [UInt16] = HostAIBridge.defaultPorts, @@ -26,114 +27,128 @@ public final class HostAIBridge: @unchecked Sendable { self.ports = Array(Set(ports)).sorted() self.host = host self.log = log + self.lifecycle = BoundedGuestVsockServiceLifecycle( + endpointLabel: "host AI bridge", + log: log + ) } - public func attach(to vsock: VirtioVsock) { - for port in ports { - vsock.listen(port: UInt32(port)) { [self] connection in - let box = ConnectionBox(connection) - Thread.detachNewThread { - self.serve(connection: box.connection, port: port) + public func attach(to vsock: VirtioVsock) throws { + try lifecycle.beginAttachment(to: vsock) + var unregister = [@Sendable () -> Void]() + do { + let host = self.host + let log = self.log + for port in ports { + let registration = try vsock.registerServiceListener( + port: UInt32(port), + service: .hostAI + ) { [weak lifecycle] connection in + guard let lifecycle else { + connection.close() + return + } + lifecycle.admit(connection) { connection, completion in + GuestVsockHostSocketRelaySession( + connection: connection, + connector: { session in + let descriptor = Self.connectTCP( + host: host, + port: port, + context: session + ) + if descriptor == nil { + log("host AI bridge could not connect to \(host):\(port)") + } + return descriptor + }, + completion: completion + ) + } } + unregister.append { registration.close() } } + guard lifecycle.commitAttachment(unregister: unregister) else { + throw VMError.invalidConfiguration( + "host AI bridge stopped while attaching" + ) + } + } catch { + lifecycle.cancelAttachment(unregister: unregister) + throw error } if !ports.isEmpty { log("host AI bridge ready on ports \(ports.map(String.init).joined(separator: ","))") } } - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - - init(_ connection: VsockConnection) { - self.connection = connection - } + public func stop(timeout: TimeInterval = 1) { + lifecycle.stop(timeout: timeout) } - private func serve(connection: VsockConnection, port: UInt16) { - guard let upstream = Self.connectTCP(host: host, port: port) else { - log("host AI bridge could not connect to \(host):\(port)") - connection.close() - return - } - defer { - connection.close() - shutdown(upstream, SHUT_RDWR) - close(upstream) - } + public var activeSessionCount: Int { + lifecycle.activeSessionCount + } - let group = DispatchGroup() - group.enter() - let box = ConnectionBox(connection) - Thread.detachNewThread { - Self.pumpTCPToVsock(from: upstream, to: box.connection) - group.leave() - } - Self.pumpVsockToTCP(from: connection, to: upstream) - // The guest has stopped sending the request: half-close only the write side to the upstream - // so it sees request-EOF while the reply keeps streaming back on the other pump. The defer - // does the full teardown once pumpTCPToVsock has drained the response. - shutdown(upstream, SHUT_WR) - group.wait() + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lifecycle.serviceAdmissionSnapshot } - private static func pumpTCPToVsock(from fd: Int32, to connection: VsockConnection) { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - while true { - let capacity = buffer.count - let count = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress, capacity) } - if count <= 0 { break } - do { - try connection.write(Array(buffer.prefix(count))) - } catch { - break - } - } + deinit { + lifecycle.stop() } - private static func pumpVsockToTCP(from connection: VsockConnection, to fd: Int32) { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - var pollInterval: useconds_t = 1_000 - let maxPollInterval: useconds_t = 16_000 - while true { - let capacity = buffer.count - let count = (try? buffer.withUnsafeMutableBytes { - try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0.. Int32? { + connectTCP( + host: host, + port: port, + timeoutMilliseconds: timeoutMilliseconds, + context: UncancelledHostSocketConnectContext() + ) } - private static func connectTCP(host: String, port: UInt16) -> Int32? { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } + private static func connectTCP( + host: String, + port: UInt16, + timeoutMilliseconds: Int32 = 2_000, + context: any BoundedHostSocketConnectContext + ) -> Int32? { var address = sockaddr_in() address.sin_family = sa_family_t(AF_INET) address.sin_port = in_port_t(port.bigEndian) - address.sin_addr.s_addr = inet_addr(host) - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { return nil } + return BoundedHostSocketConnector.connect( + domain: AF_INET, + timeout: TimeInterval(max(0, timeoutMilliseconds)) / 1_000, + context: context, + initiate: { descriptor in + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + }, + verify: { descriptor in + var peer = sockaddr_in() + var peerLength = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &peer) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getpeername(descriptor, $0, &peerLength) + } + } + return result == 0 + && peer.sin_family == sa_family_t(AF_INET) + && peer.sin_port == address.sin_port + && peer.sin_addr.s_addr == address.sin_addr.s_addr } - } - guard result == 0 else { - close(fd) - return nil - } - return fd + ) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift index be7a51bf..064183a2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift @@ -5,9 +5,9 @@ public enum HostFileDescriptorLimitError: Error, Equatable { case update(Int32) } -/// Raises launchd's commonly low descriptor soft limit before HostFS starts pinning inode -/// identities. Existing higher limits are preserved; the requested increase is bounded so a -/// malformed hard limit cannot grant the VM process an unreasonable descriptor budget. +/// Raises the helper process's commonly low descriptor soft limit for its VMM devices, sockets, +/// and relays. The filesystem XPC service establishes its own independent limit in ServiceCore; +/// resource limits are process-local and cannot be inherited after launch. public enum HostFileDescriptorLimit { public static let ceiling: rlim_t = 262_144 diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift index 34e17285..cadc200b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift @@ -26,16 +26,21 @@ public final class HostSSHAgentBridge: @unchecked Sendable { private let socketPath: String private let expectedUID: uid_t private let log: @Sendable (String) -> Void + private let lifecycle: BoundedGuestVsockServiceLifecycle public init( socketPath: String, - expectedUID: uid_t = getuid(), + expectedUID: uid_t = geteuid(), log: @escaping @Sendable (String) -> Void = { _ in } ) throws { try Self.validate(socketPath: socketPath) self.socketPath = socketPath self.expectedUID = expectedUID self.log = log + self.lifecycle = BoundedGuestVsockServiceLifecycle( + endpointLabel: "SSH agent bridge", + log: log + ) } public static func validate(socketPath: String) throws { @@ -55,27 +60,69 @@ public final class HostSSHAgentBridge: @unchecked Sendable { } } - public func attach(to vsock: VirtioVsock) { - vsock.listen(port: VsockPorts.sshAgent) { [self] connection in - let box = ConnectionBox(connection) - Thread.detachNewThread { - guard let fd = Self.connectSameUserSocket( - path: self.socketPath, - expectedUID: self.expectedUID - ) else { - self.log("SSH agent bridge rejected an unavailable or non-owned host socket") - box.connection.close() + public func attach(to vsock: VirtioVsock) throws { + try lifecycle.beginAttachment(to: vsock) + var unregister = [@Sendable () -> Void]() + do { + let socketPath = self.socketPath + let expectedUID = self.expectedUID + let log = self.log + let registration = try vsock.registerServiceListener( + port: VsockPorts.sshAgent, + service: .sshAgent + ) { [weak lifecycle] connection in + guard let lifecycle else { + connection.close() return } - VsockUnixRelay.serve(client: fd, connection: box.connection) + lifecycle.admit(connection) { connection, completion in + GuestVsockHostSocketRelaySession( + connection: connection, + connector: { session in + let descriptor = Self.connectSameUserSocket( + path: socketPath, + expectedUID: expectedUID, + context: session + ) + if descriptor == nil { + log( + "SSH agent bridge rejected an unavailable or " + + "non-owned host socket" + ) + } + return descriptor + }, + completion: completion + ) + } + } + unregister.append { registration.close() } + guard lifecycle.commitAttachment(unregister: unregister) else { + throw VMError.invalidConfiguration( + "SSH agent bridge stopped while attaching" + ) } + } catch { + lifecycle.cancelAttachment(unregister: unregister) + throw error } log("SSH agent bridge ready on guest vsock:\(VsockPorts.sshAgent)") } - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - init(_ connection: VsockConnection) { self.connection = connection } + public func stop(timeout: TimeInterval = 1) { + lifecycle.stop(timeout: timeout) + } + + public var activeSessionCount: Int { + lifecycle.activeSessionCount + } + + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lifecycle.serviceAdmissionSnapshot + } + + deinit { + lifecycle.stop() } static func connectSameUserSocket( @@ -83,25 +130,34 @@ public final class HostSSHAgentBridge: @unchecked Sendable { expectedUID: uid_t, timeoutMilliseconds: Int32 = 2_000 ) -> Int32? { + connectSameUserSocket( + path: path, + expectedUID: expectedUID, + timeoutMilliseconds: timeoutMilliseconds, + context: UncancelledHostSocketConnectContext() + ) + } + + private static func connectSameUserSocket( + path: String, + expectedUID: uid_t, + timeoutMilliseconds: Int32 = 2_000, + context: any BoundedHostSocketConnectContext + ) -> Int32? { + let pathBytes = Array(path.utf8) + guard path.hasPrefix("/"), + !pathBytes.contains(0), + pathBytes.count <= VsockUnixRelay.maximumSocketPathByteCount else { + return nil + } var status = stat() guard lstat(path, &status) == 0, status.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), status.st_uid == expectedUID else { return nil } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - let originalFlags = fcntl(fd, F_GETFL, 0) - guard originalFlags >= 0, - fcntl(fd, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { - close(fd) - return nil - } - var noSigpipe: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(path.utf8) withUnsafeMutableBytes(of: &address.sun_path) { destination in pathBytes.withUnsafeBytes { source in destination.baseAddress!.copyMemory( @@ -110,38 +166,31 @@ public final class HostSSHAgentBridge: @unchecked Sendable { ) } } - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) - } - } - if connected != 0 { - guard errno == EINPROGRESS else { - close(fd) - return nil - } - var descriptor = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) - guard poll(&descriptor, 1, max(0, timeoutMilliseconds)) > 0 else { - close(fd) - return nil - } - var socketError: Int32 = 0 - var socketErrorLength = socklen_t(MemoryLayout.size) - guard getsockopt( - fd, - SOL_SOCKET, - SO_ERROR, - &socketError, - &socketErrorLength - ) == 0, socketError == 0 else { - close(fd) - return nil + return BoundedHostSocketConnector.connect( + domain: AF_UNIX, + timeout: TimeInterval(max(0, timeoutMilliseconds)) / 1_000, + context: context, + initiate: { descriptor in + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + }, + verify: { descriptor in + peerUIDMatches(descriptor: descriptor, expectedUID: expectedUID) } - } - guard fcntl(fd, F_SETFL, originalFlags) == 0 else { - close(fd) - return nil - } - return fd + ) + } + + static func peerUIDMatches(descriptor: Int32, expectedUID: uid_t) -> Bool { + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + return getpeereid(descriptor, &peerUID, &peerGID) == 0 + && peerUID == expectedUID } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift index d825c6b9..4bfd5057 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift @@ -10,7 +10,10 @@ public struct KernelImage { private static let magic: UInt32 = 0x644D_5241 // "ARM\x64" public init(contentsOf path: String) throws { - let data = try Data(contentsOf: URL(fileURLWithPath: path)) + try self.init(data: Data(contentsOf: URL(fileURLWithPath: path))) + } + + public init(data: Data) throws { guard data.count > 64 else { throw VMError.bootFailure("kernel image too small: \(data.count) bytes") } @@ -26,7 +29,10 @@ public struct KernelImage { /// Copies the image into guest RAM and returns the entry point. public func load(into memory: GuestMemory) throws -> UInt64 { - let loadAddress = memory.guestBase + textOffset + let (loadAddress, addressOverflowed) = memory.guestBase.addingReportingOverflow(textOffset) + guard !addressOverflowed else { + throw VMError.bootFailure("kernel load address overflows") + } guard memory.contains(loadAddress, count: imageSize) else { throw VMError.bootFailure("kernel does not fit in guest RAM") } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift b/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift deleted file mode 100644 index afe11a54..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Compression -import Foundation - -public enum LZFSEError: Error, CustomStringConvertible { - case openInput(String) - case openOutput(String) - case streamInit - case read - case write - case process - - public var description: String { - switch self { - case .openInput(let path): "cannot open input \(path)" - case .openOutput(let path): "cannot open output \(path)" - case .streamInit: "compression_stream_init failed" - case .read: "read failed" - case .write: "write failed" - case .process: "compression_stream_process failed" - } - } -} - -/// Streaming LZFSE codec over Apple's Compression framework, which ships in every macOS. The engine -/// uses it to compress its kernel/initfs at build time and decompress them at first launch, so there -/// is no external `zstd` binary or dylib to bundle, link, or go missing. -public enum LZFSE { - private static let chunk = 1 << 20 - - public static func compress(source: String, destination: String) throws { - try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_ENCODE) - } - - public static func decompress(source: String, destination: String) throws { - try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_DECODE) - } - - private static func transform(source: String, destination: String, operation: compression_stream_operation) throws { - guard let input = InputStream(fileAtPath: source) else { throw LZFSEError.openInput(source) } - guard let output = OutputStream(toFileAtPath: destination, append: false) else { throw LZFSEError.openOutput(destination) } - input.open() - output.open() - defer { input.close(); output.close() } - - let source = UnsafeMutablePointer.allocate(capacity: chunk) - let sink = UnsafeMutablePointer.allocate(capacity: chunk) - defer { source.deallocate(); sink.deallocate() } - - var stream = compression_stream(dst_ptr: sink, dst_size: chunk, src_ptr: UnsafePointer(source), src_size: 0, state: nil) - guard compression_stream_init(&stream, operation, COMPRESSION_LZFSE) == COMPRESSION_STATUS_OK else { - throw LZFSEError.streamInit - } - defer { compression_stream_destroy(&stream) } - - stream.src_size = 0 - stream.dst_ptr = sink - stream.dst_size = chunk - var inputExhausted = false - - while true { - if stream.src_size == 0, !inputExhausted { - let read = input.read(source, maxLength: chunk) - if read < 0 { throw LZFSEError.read } - if read == 0 { inputExhausted = true } - stream.src_ptr = UnsafePointer(source) - stream.src_size = read - } - - let flags = inputExhausted ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0 - let status = compression_stream_process(&stream, flags) - guard status == COMPRESSION_STATUS_OK || status == COMPRESSION_STATUS_END else { - throw LZFSEError.process - } - - let produced = chunk - stream.dst_size - if produced > 0 { - var offset = 0 - while offset < produced { - let written = output.write(sink + offset, maxLength: produced - offset) - if written <= 0 { throw LZFSEError.write } - offset += written - } - stream.dst_ptr = sink - stream.dst_size = chunk - } - - if status == COMPRESSION_STATUS_END { return } - } - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift index 0e13aa3a..f45e4c6d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift @@ -8,19 +8,119 @@ public protocol MMIODevice: AnyObject { /// Routes guest data aborts to the owning device by physical address. public final class MMIOBus { - private var devices: [MMIODevice] = [] + private struct Region { + let baseAddress: UInt64 + let lastAddress: UInt64 + let device: MMIODevice + + @inline(__always) + func contains(_ address: UInt64) -> Bool { + address >= baseAddress && address <= lastAddress + } + } + + /// Device attachment is a boot-time operation. `seal()` makes that topology immutable before + /// vCPU threads begin reading it concurrently. + private var regions: [Region] = [] + private var isSealed = false public init() {} public func attach(_ device: MMIODevice) { - devices.append(device) + precondition(!isSealed, "MMIO devices must be attached before the bus is sealed") + precondition(device.size > 0, "MMIO device windows must not be empty") + let (lastAddress, overflow) = device.baseAddress.addingReportingOverflow(device.size - 1) + precondition(!overflow, "MMIO device window must fit in the physical address space") + + let insertionIndex = firstRegionIndex(startingAtOrAfter: device.baseAddress) + if insertionIndex > 0 { + precondition( + regions[insertionIndex - 1].lastAddress < device.baseAddress, + "MMIO device windows must not overlap" + ) + } + if insertionIndex < regions.count { + precondition( + lastAddress < regions[insertionIndex].baseAddress, + "MMIO device windows must not overlap" + ) + } + regions.insert( + Region(baseAddress: device.baseAddress, lastAddress: lastAddress, device: device), + at: insertionIndex + ) + } + + /// Freezes the cold-path topology before concurrent vCPU execution starts. + public func seal() { + isSealed = true } + @inline(__always) public func device(for address: UInt64) -> (MMIODevice, UInt64)? { - for device in devices where address >= device.baseAddress && address < device.baseAddress + device.size { - return (device, address - device.baseAddress) + guard let region = region(containing: address) else { return nil } + return (region.device, address - region.baseAddress) + } + + /// The vCPU-local cache makes repeated accesses to one device O(1), while cache misses use a + /// binary search over the immutable region table instead of scanning every attached device. + @inline(__always) + func device( + for address: UInt64, + cache: inout MMIORouteCache + ) -> (MMIODevice, UInt64)? { + precondition(isSealed, "MMIO cached lookup requires a sealed bus") + if let device = cache.device, + address >= cache.baseAddress, + address <= cache.lastAddress { + return (device, address - cache.baseAddress) } - return nil + guard let region = region(containing: address) else { + cache.clear() + return nil + } + cache.baseAddress = region.baseAddress + cache.lastAddress = region.lastAddress + cache.device = region.device + return (region.device, address - region.baseAddress) + } + + @inline(__always) + private func region(containing address: UInt64) -> Region? { + let insertionIndex = firstRegionIndex(startingAtOrAfter: address) + if insertionIndex < regions.count, regions[insertionIndex].baseAddress == address { + return regions[insertionIndex] + } + guard insertionIndex > 0 else { return nil } + let candidate = regions[insertionIndex - 1] + return candidate.contains(address) ? candidate : nil + } + + @inline(__always) + private func firstRegionIndex(startingAtOrAfter address: UInt64) -> Int { + var lowerBound = 0 + var upperBound = regions.count + while lowerBound < upperBound { + let midpoint = lowerBound + (upperBound - lowerBound) / 2 + if regions[midpoint].baseAddress < address { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return lowerBound + } +} + +/// One instance lives on each vCPU stack, so the common repeated-device path needs no lock or +/// shared mutable cache state. +struct MMIORouteCache { + fileprivate var baseAddress: UInt64 = 0 + fileprivate var lastAddress: UInt64 = 0 + fileprivate var device: MMIODevice? + + fileprivate mutating func clear() { + device = nil } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift index bbf293fa..e6b904ac 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public struct MPTableImage: Equatable, Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift index 735e4d83..72221061 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift @@ -1,6 +1,213 @@ import Darwin import Foundation import Hypervisor +import Synchronization + +/// Candidate-bound RawHV host scheduling profile. +/// +/// AppKit owns the user-interactive class. Sustained guest execution and device work are +/// user-initiated: they remain latency-sensitive while yielding the system's highest scheduling +/// class to input and presentation. Revision changes require a new runtime-envelope identity and +/// matched physical responsiveness/workload calibration before release qualification. +public enum RawHVSchedulingPolicy { + public static let revision: UInt16 = 1 + public static let vCPUThreadQualityOfService: QualityOfService = .userInitiated + public static let machineOwnerThreadQualityOfService: QualityOfService = .userInitiated + public static let machineOwnerThreadStackSize = 1 << 21 + public static let blockIOWorkerDispatchQoS: DispatchQoS = .userInitiated + public static let networkIOWorkerDispatchQoS: DispatchQoS = .userInitiated + public static let fileSystemWorkerDispatchQoS: DispatchQoS = .userInitiated + + static func applyToCurrentVCPUThread() { + applyUserInitiated(to: vCPUThreadQualityOfService) + } + + static func applyToCurrentMachineOwnerThread() { + applyUserInitiated(to: machineOwnerThreadQualityOfService) + } + + private static func applyUserInitiated(to qualityOfService: QualityOfService) { + Thread.current.qualityOfService = qualityOfService + _ = pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) + } +} + +/// Guest-visible identity of one occupied virtio-mmio slot. +/// +/// This intentionally carries only the low-level bus identity. Product device roles belong to the +/// resolved virtual-hardware topology contract and must not be inferred from attachment order. +public struct VirtioMMIOSlotIdentity: Equatable, Sendable { + public let slot: Int + public let baseAddress: UInt64 + public let size: UInt64 + public let interrupt: UInt32 + + init(slot: Int, baseAddress: UInt64, size: UInt64, interrupt: UInt32) { + self.slot = slot + self.baseAddress = baseAddress + self.size = size + self.interrupt = interrupt + } +} + +/// Canonical low-level input for the eventual virtual-hardware ABI fingerprint. The resolved +/// topology layer will prefix device roles and capabilities; this layer contributes stable +/// slot/MMIO/IRQ identities in a fixed byte order independent of attachment order. +enum VirtioMMIOLayoutCanonicalizer { + static func fingerprintInput(for identities: [VirtioMMIOSlotIdentity]) -> [UInt8] { + let sorted = identities.sorted { lhs, rhs in + if lhs.slot != rhs.slot { return lhs.slot < rhs.slot } + if lhs.baseAddress != rhs.baseAddress { return lhs.baseAddress < rhs.baseAddress } + if lhs.interrupt != rhs.interrupt { return lhs.interrupt < rhs.interrupt } + return lhs.size < rhs.size + } + var bytes = Array("dory.virtio-mmio.layout".utf8) + bytes.append(0) + appendBigEndian(UInt32(1), to: &bytes) + appendBigEndian(UInt32(sorted.count), to: &bytes) + for identity in sorted { + appendBigEndian(UInt32(identity.slot), to: &bytes) + appendBigEndian(identity.baseAddress, to: &bytes) + appendBigEndian(identity.size, to: &bytes) + appendBigEndian(identity.interrupt, to: &bytes) + } + return bytes + } + + private static func appendBigEndian(_ value: T, to bytes: inout [UInt8]) { + withUnsafeBytes(of: value.bigEndian) { bytes.append(contentsOf: $0) } + } +} + +/// Owns the one-to-one relationship between stable virtio slots and attached MMIO devices. +/// Configuration is serialized even though normal callers attach before vCPU startup, so duplicate +/// concurrent requests cannot leak a second device into `MMIOBus`. +final class VirtioMMIOSlotOwnership { + private struct Attachment { + let device: MMIODevice + let identity: VirtioMMIOSlotIdentity + } + + private struct State { + var attachmentsBySlot: [Int: Attachment] = [:] + var attachedDeviceIdentities: Set = [] + } + + private let lock = NSLock() + private var state = State() + private let maximumSlots: Int + private let baseAddress: UInt64 + private let slotSize: UInt64 + private let firstInterrupt: UInt32 + + init(maximumSlots: Int, baseAddress: UInt64, slotSize: UInt64, firstInterrupt: UInt32) { + precondition(maximumSlots > 0) + precondition(slotSize > 0) + self.maximumSlots = maximumSlots + self.baseAddress = baseAddress + self.slotSize = slotSize + self.firstInterrupt = firstInterrupt + } + + var identities: [VirtioMMIOSlotIdentity] { + lock.lock() + defer { lock.unlock() } + return state.attachmentsBySlot.values.map(\.identity).sorted { $0.slot < $1.slot } + } + + var fingerprintInput: [UInt8] { + VirtioMMIOLayoutCanonicalizer.fingerprintInput(for: identities) + } + + @discardableResult + func attach( + _ device: MMIODevice, + at slot: Int, + attachToBus: (MMIODevice) -> Void + ) throws -> VirtioMMIOSlotIdentity { + let slotIdentity = try identity(for: slot) + lock.lock() + defer { lock.unlock() } + return try attachLocked( + device, + identity: slotIdentity, + attachToBus: attachToBus + ) + } + + private func attachLocked( + _ device: MMIODevice, + identity slotIdentity: VirtioMMIOSlotIdentity, + attachToBus: (MMIODevice) -> Void + ) throws -> VirtioMMIOSlotIdentity { + let slot = slotIdentity.slot + guard device.baseAddress == slotIdentity.baseAddress else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) requires MMIO base 0x\(String(slotIdentity.baseAddress, radix: 16)), got 0x\(String(device.baseAddress, radix: 16))" + ) + } + guard device.size == slotIdentity.size else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) requires MMIO size 0x\(String(slotIdentity.size, radix: 16)), got 0x\(String(device.size, radix: 16))" + ) + } + guard state.attachmentsBySlot[slot] == nil else { + throw VMError.invalidConfiguration("virtio slot \(slot) is already occupied") + } + let deviceIdentity = ObjectIdentifier(device) + guard !state.attachedDeviceIdentities.contains(deviceIdentity) else { + throw VMError.invalidConfiguration("virtio MMIO device is already attached") + } + attachToBus(device) + state.attachmentsBySlot[slot] = Attachment(device: device, identity: slotIdentity) + state.attachedDeviceIdentities.insert(deviceIdentity) + return slotIdentity + } + + private func identity(for slot: Int) throws -> VirtioMMIOSlotIdentity { + guard slot >= 0, slot < maximumSlots else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) is outside 0..<\(maximumSlots)" + ) + } + guard let unsignedSlot = UInt64(exactly: slot) else { + throw VMError.invalidConfiguration("virtio slot \(slot) cannot be represented") + } + let (offset, offsetOverflow) = unsignedSlot.multipliedReportingOverflow(by: slotSize) + let (address, addressOverflow) = baseAddress.addingReportingOverflow(offset) + guard !offsetOverflow, !addressOverflow else { + throw VMError.invalidConfiguration("virtio slot \(slot) MMIO address overflows") + } + guard let interruptOffset = UInt32(exactly: slot) else { + throw VMError.invalidConfiguration("virtio slot \(slot) interrupt cannot be represented") + } + let (interrupt, interruptOverflow) = firstInterrupt.addingReportingOverflow(interruptOffset) + guard !interruptOverflow else { + throw VMError.invalidConfiguration("virtio slot \(slot) interrupt overflows") + } + return VirtioMMIOSlotIdentity( + slot: slot, + baseAddress: address, + size: slotSize, + interrupt: interrupt + ) + } +} + +enum VirtioMMIODeviceTree { + static func appendNodes( + for identities: [VirtioMMIOSlotIdentity], + to fdt: FDTBuilder + ) { + for identity in identities.sorted(by: { $0.slot < $1.slot }) { + fdt.beginNode("virtio_mmio@\(String(identity.baseAddress, radix: 16))") + fdt.property("compatible", string: "virtio,mmio") + fdt.property("reg", cells64: [identity.baseAddress, identity.size]) + fdt.property("interrupts", cells: [0, identity.interrupt, 1]) + fdt.endNode() + } + } +} #if arch(arm64) /// Guest physical layout, modeled on QEMU's virt machine so every address is one Linux has been @@ -15,6 +222,9 @@ public enum GuestLayout { public static let rtcBase: UInt64 = 0x0C09_0000 public static let virtioBase: UInt64 = 0x0C10_0000 public static let virtioSlotSize: UInt64 = 0x200 + /// QEMU's arm64 `virt` platform reserves 32 virtio-mmio transports. Dory preserves that + /// bounded window while allowing holes within it. + public static let virtioSlotCount = 32 public static let virtioFirstIRQ: UInt32 = 16 // SPI numbers 16... (intid 48...) public static let ramBase: UInt64 = 0x8000_0000 public static let dtbOffset: UInt64 = 256 << 20 @@ -26,8 +236,7 @@ public enum GuestLayout { } public struct MachineConfiguration { - public var kernelPath: String - public var initrdPath: String? + public let bootPayload: MachineBootPayload public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int @@ -39,8 +248,19 @@ public struct MachineConfiguration { memoryBytes: UInt64, cpuCount: Int ) { - self.kernelPath = kernelPath - self.initrdPath = initrdPath + self.bootPayload = .legacyPaths(kernel: kernelPath, initrd: initrdPath) + self.commandLine = commandLine + self.memoryBytes = memoryBytes + self.cpuCount = cpuCount + } + + public init( + bootPayload: MachineBootPayload, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = bootPayload self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -53,6 +273,24 @@ public enum GuestStopReason: Sendable { case crash(String) } +/// One-way, lock-free publication from stop ownership into each vCPU's exit loop. +/// +/// `Machine` remains single-run: the condition-protected stop reason, vCPU handles, wakeups, and +/// joins own the lifecycle. This signal only removes that global condition from the common path +/// after a vCPU exit. A releasing request paired with an acquiring read also makes state published +/// before the stop request visible before a vCPU leaves its loop. +final class VCPUStopSignal: Sendable { + private let requested = Atomic(false) + + var isRequested: Bool { + requested.load(ordering: .acquiring) + } + + func request() { + requested.store(true, ordering: .releasing) + } +} + /// The virtual machine: RAM, GIC, devices, and the vCPU threads. SMP: secondaries are created /// eagerly, parked, and released by PSCI CPU_ON. Thread-shared state is guarded by /// `teamCondition`; devices serialize their own guest-facing surfaces. @@ -64,6 +302,12 @@ public final class Machine: @unchecked Sendable { private var dtbAddress: UInt64 = 0 private var sysregLogCount = 0 private let redistributorMMIO: GICRedistributorMMIO + private let virtioSlotOwnership = VirtioMMIOSlotOwnership( + maximumSlots: GuestLayout.virtioSlotCount, + baseAddress: GuestLayout.virtioBase, + slotSize: GuestLayout.virtioSlotSize, + firstInterrupt: GuestLayout.virtioFirstIRQ + ) public init(configuration: MachineConfiguration) throws { try hvCreateVM() @@ -117,8 +361,14 @@ public final class Machine: @unchecked Sendable { /// Pulses a guest system interrupt. On arm64 these are GIC SPIs declared edge-triggered in the DTB. public func raiseGSI(_ gsi: UInt32) { + setGSI(gsi, asserted: true) + } + + /// Drives a level-sensitive guest system interrupt. UART input uses this to keep the PL011 + /// receive line asserted until the guest has drained the pending bytes. + public func setGSI(_ gsi: UInt32, asserted: Bool) { let intid = 32 + gsi - _ = hv_gic_set_spi(intid, true) + _ = hv_gic_set_spi(intid, asserted) } /// Compatibility spelling for arm64 callers; new shared engine code should use `raiseGSI`. @@ -131,22 +381,26 @@ public final class Machine: @unchecked Sendable { } public func loadBootPayload() throws { - let kernel = try KernelImage(contentsOf: configuration.kernelPath) - entryPoint = try kernel.load(into: memory) - dtbAddress = GuestLayout.ramBase + GuestLayout.dtbOffset - guard kernel.textOffset + kernel.imageSize < GuestLayout.dtbOffset else { - throw VMError.bootFailure("kernel image overlaps DTB placement") + try configuration.bootPayload.consumeForGuestLoad { kernelData, loadInitrd in + let kernel = try KernelImage(data: kernelData) + entryPoint = try kernel.load(into: memory) + dtbAddress = GuestLayout.ramBase + GuestLayout.dtbOffset + let (kernelEndOffset, kernelEndOverflowed) = + kernel.textOffset.addingReportingOverflow(kernel.imageSize) + guard !kernelEndOverflowed, + kernelEndOffset < GuestLayout.dtbOffset else { + throw VMError.bootFailure("kernel image overlaps DTB placement") + } + let initrdRange = try loadInitrdIfPresent(try loadInitrd()) + let dtb = try buildDeviceTree(initrdRange: initrdRange) + try memory.write(dtb, at: dtbAddress) } - let initrdRange = try loadInitrdIfPresent() - let dtb = try buildDeviceTree(initrdRange: initrdRange) - try memory.write(dtb, at: dtbAddress) } - private func loadInitrdIfPresent() throws -> Range? { - guard let path = configuration.initrdPath else { return nil } - let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + private func loadInitrdIfPresent(_ data: Data?) throws -> Range? { + guard let data else { return nil } guard !data.isEmpty else { - throw VMError.bootFailure("initrd is empty: \(path)") + throw VMError.bootFailure("initrd is empty") } let start = GuestLayout.ramBase + GuestLayout.initrdOffset let (end, overflowed) = start.addingReportingOverflow(UInt64(data.count)) @@ -155,7 +409,7 @@ public final class Machine: @unchecked Sendable { end <= GuestLayout.ramBase + configuration.memoryBytes else { throw VMError.bootFailure("initrd does not fit in guest memory") } - try memory.write(Array(data), at: start) + try copyBootData(data, at: start) return start.. GuestStopReason { + // Attachment is a cold boot operation. Freeze the sorted routing table before any vCPU + // can read it concurrently, and give each vCPU its own hot lookup cache in `runLoop`. + bus.seal() let count = max(1, configuration.cpuCount) teamHandles = Array(repeating: nil, count: count) secondaryStarts = Array(repeating: nil, count: count) @@ -308,7 +551,7 @@ public final class Machine: @unchecked Sendable { for index in 1.. hv_vm_destroy) never races a live vCPU thread. - stopAll(stopReason ?? .powerOff) + let terminalReason = stopReason + ?? .crash("boot CPU exited without a published stop reason") + stopAll(terminalReason) teamCondition.lock() while finishedSecondaries < count - 1 { teamCondition.wait() } defer { teamCondition.unlock() } - return stopReason ?? .crash("boot CPU exited without a stop reason") + return stopReason ?? terminalReason } private func cpuMain(index: Int) { - Self.applyVCPUQoS() + RawHVSchedulingPolicy.applyToCurrentVCPUThread() defer { if index != 0 { teamCondition.lock() @@ -365,7 +610,7 @@ public final class Machine: @unchecked Sendable { try vcpu.write(HV_REG_X0, start.context) } - runLoop(vcpu: vcpu) + runLoop(vcpu: vcpu, index: index) } catch { stopAll(.crash("cpu\(index) failed: \(error)")) } @@ -399,7 +644,9 @@ public final class Machine: @unchecked Sendable { private func stopAll(_ reason: GuestStopReason) { teamCondition.lock() - if stopReason == nil { stopReason = reason } + let publishesReason = stopReason == nil + if publishesReason { stopReason = reason } + stopSignal.request() // Cancel running vCPUs exactly once: a second pass could touch a handle a finished thread // has already destroyed. var handles: [hv_vcpu_t] = [] @@ -409,6 +656,11 @@ public final class Machine: @unchecked Sendable { } teamCondition.broadcast() teamCondition.unlock() + if publishesReason { + FileHandle.standardError.write( + Data("dory-hv: guest stop reason: \(reason)\n".utf8) + ) + } if !handles.isEmpty { hv_vcpus_exit(&handles, UInt32(handles.count)) } @@ -426,17 +678,20 @@ public final class Machine: @unchecked Sendable { return 0 } - private func runLoop(vcpu: VCPU) { + private func runLoop(vcpu: VCPU, index: Int) { + var mmioRouteCache = MMIORouteCache() while true { - teamCondition.lock() - let stopped = stopReason != nil - teamCondition.unlock() - if stopped { return } + if stopSignal.isRequested { return } do { let event = try vcpu.run() switch event { case .canceled: + if !stopSignal.isRequested { + stopAll(.crash( + "cpu\(index) Hypervisor run was canceled without a stop request" + )) + } return case .vtimerActivated: // With the in-kernel GIC the timer PPI is delivered by the GIC itself; unmask @@ -444,7 +699,10 @@ public final class Machine: @unchecked Sendable { try vcpu.setVTimerMask(false) case .exception(let syndrome, _, let physicalAddress): if let stop = try handleException( - vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress + vcpu: vcpu, + syndrome: syndrome, + physicalAddress: physicalAddress, + mmioRouteCache: &mmioRouteCache ) { stopAll(stop) return @@ -460,19 +718,24 @@ public final class Machine: @unchecked Sendable { } } - private static func applyVCPUQoS() { - Thread.current.qualityOfService = .userInteractive - _ = pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0) - } - - private func handleException(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws -> GuestStopReason? { + private func handleException( + vcpu: VCPU, + syndrome: UInt64, + physicalAddress: UInt64, + mmioRouteCache: inout MMIORouteCache + ) throws -> GuestStopReason? { guard let exceptionClass = ExceptionClass(syndrome: syndrome) else { let pc = try vcpu.read(HV_REG_PC) return .crash("unhandled exception class \(syndrome >> 26), syndrome 0x\(String(syndrome, radix: 16)), pc 0x\(String(pc, radix: 16))") } switch exceptionClass { case .dataAbortLowerEL: - try handleMMIO(vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress) + try handleMMIO( + vcpu: vcpu, + syndrome: syndrome, + physicalAddress: physicalAddress, + routeCache: &mmioRouteCache + ) return nil case .instructionAbortLowerEL: guard restoreIfReleasedRAM(physicalAddress) else { @@ -495,14 +758,19 @@ public final class Machine: @unchecked Sendable { } } - private func handleMMIO(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws { + private func handleMMIO( + vcpu: VCPU, + syndrome: UInt64, + physicalAddress: UInt64, + routeCache: inout MMIORouteCache + ) throws { if restoreIfReleasedRAM(physicalAddress) { return } let abort = DataAbortInfo(syndrome: syndrome) guard abort.isValid else { let pc = try vcpu.read(HV_REG_PC) throw VMError.unexpectedExit("data abort without syndrome info at pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") } - guard let (device, offset) = bus.device(for: physicalAddress) else { + guard let (device, offset) = bus.device(for: physicalAddress, cache: &routeCache) else { let pc = try vcpu.read(HV_REG_PC) throw VMError.unexpectedExit("guest touched unmapped pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") } @@ -623,14 +891,14 @@ public enum GuestLayout { public static let rtcBase = X86GuestLayout.rtcBase public static let virtioBase = X86GuestLayout.virtioBase public static let virtioSlotSize = X86GuestLayout.virtioSlotSize + public static let virtioSlotCount = X86GuestLayout.virtioSlotCount public static let virtioFirstIRQ = UInt32(X86GuestLayout.virtioFirstIRQ) public static let ramBase = X86GuestLayout.ramBase public static let daxWindowBase = X86GuestLayout.daxWindowBase } public struct MachineConfiguration { - public var kernelPath: String - public var initrdPath: String? + public let bootPayload: MachineBootPayload public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int @@ -642,8 +910,19 @@ public struct MachineConfiguration { memoryBytes: UInt64, cpuCount: Int ) { - self.kernelPath = kernelPath - self.initrdPath = initrdPath + self.bootPayload = .legacyPaths(kernel: kernelPath, initrd: initrdPath) + self.commandLine = commandLine + self.memoryBytes = memoryBytes + self.cpuCount = cpuCount + } + + public init( + bootPayload: MachineBootPayload, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = bootPayload self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -665,6 +944,12 @@ public final class Machine: @unchecked Sendable { public private(set) var startInfoAddress: UInt64 = 0 private let stopLock = NSLock() private var stopReason: GuestStopReason? + private let virtioSlotOwnership = VirtioMMIOSlotOwnership( + maximumSlots: GuestLayout.virtioSlotCount, + baseAddress: GuestLayout.virtioBase, + slotSize: GuestLayout.virtioSlotSize, + firstInterrupt: GuestLayout.virtioFirstIRQ + ) public init(configuration: MachineConfiguration) throws { try hvCreateVM() @@ -686,59 +971,67 @@ public final class Machine: @unchecked Sendable { } public func loadBootPayload() throws { - let kernel = try PVHKernelImage(contentsOf: configuration.kernelPath) - entryPoint = try kernel.load(into: memory) - startInfoAddress = X86GuestLayout.pvhStartInfo + try configuration.bootPayload.consumeForGuestLoad { kernelData, loadInitrd in + let kernel = try PVHKernelImage(data: kernelData) + entryPoint = try kernel.load(into: memory) + startInfoAddress = X86GuestLayout.pvhStartInfo + + let initrdData = try loadInitrd() + if let initrdData, initrdData.isEmpty { + throw VMError.bootFailure("initrd is empty") + } + let initrdAddress = X86GuestLayout.initrd + if let initrdData { + let (end, overflowed) = initrdAddress.addingReportingOverflow(UInt64(initrdData.count)) + guard !overflowed, end <= configuration.memoryBytes else { + throw VMError.bootFailure("initrd does not fit in guest memory") + } + try copyBootData(initrdData, at: initrdAddress) + } - let initrdData = try configuration.initrdPath.map { - try Data(contentsOf: URL(fileURLWithPath: $0), options: .mappedIfSafe) - } - if let initrdData, initrdData.isEmpty { - throw VMError.bootFailure("initrd is empty: \(configuration.initrdPath ?? "")") - } - let initrdAddress = X86GuestLayout.initrd - if let initrdData { - let (end, overflowed) = initrdAddress.addingReportingOverflow(UInt64(initrdData.count)) - guard !overflowed, end <= configuration.memoryBytes else { - throw VMError.bootFailure("initrd does not fit in guest memory") + let virtioDevices = try attachedVirtioSlots.map { identity -> X86VirtioMMIODevice in + guard let interrupt = UInt8(exactly: identity.interrupt) else { + throw VMError.invalidConfiguration( + "virtio slot \(identity.slot) interrupt \(identity.interrupt) exceeds x86 IOAPIC encoding" + ) + } + return X86VirtioMMIODevice( + slot: identity.slot, + baseAddress: identity.baseAddress, + size: identity.size, + irq: interrupt + ) } - try memory.write(Array(initrdData), at: initrdAddress) - } + let plan = X86BootPlanBuilder.build( + baseCommandLine: configuration.commandLine, + memoryBytes: configuration.memoryBytes, + virtioDevices: virtioDevices + ) + let pvh = PVHBootBuilder.build( + commandLine: plan.commandLine, + commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine, + modulesPhysicalAddress: X86GuestLayout.pvhModules, + memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap, + modules: initrdData.map { + [PVHModule(physicalAddress: initrdAddress, size: UInt64($0.count))] + } ?? [], + memoryMap: plan.memoryMap + ) + try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo) + try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine) + if !pvh.modules.isEmpty { + try memory.write(Array(pvh.modules), at: X86GuestLayout.pvhModules) + } + try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap) - let plan = X86BootPlanBuilder.build( - baseCommandLine: configuration.commandLine, - memoryBytes: configuration.memoryBytes, - virtioDeviceCount: busDeviceCount - ) - let pvh = PVHBootBuilder.build( - commandLine: plan.commandLine, - commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine, - modulesPhysicalAddress: X86GuestLayout.pvhModules, - memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap, - modules: initrdData.map { - [PVHModule(physicalAddress: initrdAddress, size: UInt64($0.count))] - } ?? [], - memoryMap: plan.memoryMap - ) - try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo) - try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine) - if !pvh.modules.isEmpty { - try memory.write(Array(pvh.modules), at: X86GuestLayout.pvhModules) + let mpTable = MPTableBuilder.build( + tablePhysicalAddress: UInt32(X86GuestLayout.mpConfigurationTable), + cpuCount: configuration.cpuCount, + virtioInterruptPins: plan.virtioDevices.map(\.irq) + ) + try memory.write(Array(mpTable.floatingPointer), at: X86GuestLayout.mpFloatingPointer) + try memory.write(Array(mpTable.configurationTable), at: X86GuestLayout.mpConfigurationTable) } - try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap) - - let mpTable = MPTableBuilder.build( - tablePhysicalAddress: UInt32(X86GuestLayout.mpConfigurationTable), - cpuCount: configuration.cpuCount, - virtioInterruptPins: plan.virtioDevices.map(\.irq) - ) - try memory.write(Array(mpTable.floatingPointer), at: X86GuestLayout.mpFloatingPointer) - try memory.write(Array(mpTable.configurationTable), at: X86GuestLayout.mpConfigurationTable) - } - - public func attachVirtioSlot(_ device: MMIODevice) { - busDeviceCount += 1 - bus.attach(device) } public func attachConsole(_ uart: UART16550) { @@ -773,6 +1066,7 @@ public final class Machine: @unchecked Sendable { if entryPoint == 0 || startInfoAddress == 0 { try loadBootPayload() } + bus.seal() let vcpu = try VCPU() try vcpu.configurePVHEntry(entryPoint: entryPoint, startInfoAddress: startInfoAddress) var executor = X86VMExitExecutor() @@ -906,7 +1200,51 @@ public final class Machine: @unchecked Sendable { ) } } - - private var busDeviceCount = 0 } #endif + +extension GuestStopReason: CustomStringConvertible { + public var description: String { + switch self { + case .powerOff: + "guest requested power off" + case .reset: + "guest requested reset" + case .crash(let detail): + "guest crash: \(detail)" + } + } +} + +private extension Machine { + /// Copies immutable boot bytes directly into guest RAM without materializing a second + /// full-sized `[UInt8]` buffer. + func copyBootData(_ data: Data, at guestAddress: UInt64) throws { + guard !data.isEmpty else { return } + let destination = try memory.hostPointer( + at: guestAddress, + count: UInt64(data.count) + ) + data.withUnsafeBytes { source in + destination.copyMemory(from: source.baseAddress!, byteCount: source.count) + } + } +} + +public extension Machine { + var attachedVirtioSlots: [VirtioMMIOSlotIdentity] { + virtioSlotOwnership.identities + } + + var virtioMMIOLayoutFingerprintInput: [UInt8] { + virtioSlotOwnership.fingerprintInput + } + + @discardableResult + func attachVirtioSlot( + _ device: MMIODevice, + at slot: Int + ) throws -> VirtioMMIOSlotIdentity { + try virtioSlotOwnership.attach(device, at: slot) { bus.attach($0) } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift new file mode 100644 index 00000000..64dd4860 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift @@ -0,0 +1,256 @@ +import CryptoKit +import Darwin +import Foundation + +/// Exact authority for one anonymous, read-only boot blob inherited from the daemon. +/// +/// `maximumByteCount` is a local allocation ceiling, not caller-controlled evidence. The child +/// validates the descriptor before allocating and uses `pread` so supervised restarts never share +/// or depend on an open-file-description offset. +public struct MachineInheritedBootBlob: Sendable, Equatable { + public let descriptor: Int32 + public let byteCount: UInt64 + public let sha256: String + public let maximumByteCount: UInt64 + + public init( + descriptor: Int32, + byteCount: UInt64, + sha256: String, + maximumByteCount: UInt64 + ) { + self.descriptor = descriptor + self.byteCount = byteCount + self.sha256 = sha256 + self.maximumByteCount = maximumByteCount + } +} + +/// Single-use ownership of resolved boot bytes. +/// +/// Every `MachineBootPayload` copy shares this authority. Consumption removes the authority's +/// references before invoking the guest-memory loader and permanently retires the authority when +/// that loader returns or throws. Consequently a retained `MachineConfiguration` cannot keep a +/// second kernel/initrd copy alive or replay partially loaded resolved authority. +public final class MachineImmutableBootAuthority: @unchecked Sendable, Equatable { + private struct Bytes { + let kernel: Data + let initrd: Data? + } + + private enum State { + case available(Bytes) + case consuming + case consumed + } + + private let lock = NSLock() + private var state: State + + fileprivate init(kernel: Data, initrd: Data?) { + self.state = .available(Bytes(kernel: kernel, initrd: initrd)) + } + + public static func == ( + lhs: MachineImmutableBootAuthority, + rhs: MachineImmutableBootAuthority + ) -> Bool { + lhs === rhs + } + + fileprivate func consumeForGuestLoad( + _ load: (Data, () throws -> Data?) throws -> Void + ) throws { + let bytes: Bytes + lock.lock() + switch state { + case .available(let available): + bytes = available + state = .consuming + lock.unlock() + case .consuming: + lock.unlock() + throw VMError.invalidConfiguration( + "resolved immutable boot payload is already being consumed" + ) + case .consumed: + lock.unlock() + throw VMError.invalidConfiguration( + "resolved immutable boot payload has already been consumed" + ) + } + + defer { + lock.lock() + state = .consumed + lock.unlock() + } + try load(bytes.kernel) { bytes.initrd } + } + + /// Test-visible accounting of bytes retained by the authority itself. The count becomes zero + /// before guest loading begins and remains zero after either success or failure. + var retainedByteCount: UInt64 { + lock.lock() + defer { lock.unlock() } + guard case .available(let bytes) = state else { return 0 } + return UInt64(bytes.kernel.count) + UInt64(bytes.initrd?.count ?? 0) + } + + var isConsumed: Bool { + lock.lock() + defer { lock.unlock() } + guard case .consumed = state else { return false } + return true + } +} + +/// A typed split between repeatable legacy pathname boot and one-shot resolved immutable-byte boot. +public enum MachineBootPayload: Sendable, Equatable { + case legacyPaths(kernel: String, initrd: String?) + case immutableBytes(authority: MachineImmutableBootAuthority) + + /// Source-compatible construction spelling for callers with already-verified bytes. Copies of + /// the returned enum share one consumable authority rather than retaining independent `Data`. + public static func immutableBytes(kernel: Data, initrd: Data?) -> Self { + .immutableBytes( + authority: MachineImmutableBootAuthority(kernel: kernel, initrd: initrd) + ) + } + + public static func inheritedReadOnlyDescriptors( + kernel: MachineInheritedBootBlob, + initrd: MachineInheritedBootBlob? + ) throws -> Self { + var descriptors = [kernel.descriptor] + if let initrd { descriptors.append(initrd.descriptor) } + let ownedDescriptors = Set(descriptors.filter { $0 >= 3 }) + defer { ownedDescriptors.forEach { Darwin.close($0) } } + guard descriptors.allSatisfy({ $0 >= 3 }), + Set(descriptors).count == descriptors.count else { + throw VMError.invalidConfiguration( + "resolved boot descriptors must be unique inherited descriptors" + ) + } + let kernelData = try readExactAnonymousBlob(kernel, kind: "linuxKernel") + let initrdData = try initrd.map { + try readExactAnonymousBlob($0, kind: "linuxInitrd") + } + return .immutableBytes(kernel: kernelData, initrd: initrdData) + } + + func consumeForGuestLoad( + _ load: (Data, () throws -> Data?) throws -> Void + ) throws { + switch self { + case .legacyPaths(let kernelPath, let initrdPath): + let kernel = try Data(contentsOf: URL(fileURLWithPath: kernelPath)) + try load(kernel) { + try Self.readLegacyInitrd(at: initrdPath) + } + case .immutableBytes(let authority): + try authority.consumeForGuestLoad(load) + } + } + + var retainedImmutableByteCount: UInt64 { + guard case .immutableBytes(let authority) = self else { return 0 } + return authority.retainedByteCount + } + + var immutableBytesWereConsumed: Bool { + guard case .immutableBytes(let authority) = self else { return false } + return authority.isConsumed + } + + private static func readLegacyInitrd(at path: String?) throws -> Data? { + guard let path else { return nil } + let data = try Data( + contentsOf: URL(fileURLWithPath: path), + options: .mappedIfSafe + ) + guard !data.isEmpty else { + throw VMError.bootFailure("initrd is empty: \(path)") + } + return data + } + + private static func readExactAnonymousBlob( + _ authority: MachineInheritedBootBlob, + kind: String + ) throws -> Data { + guard authority.byteCount > 0, + authority.byteCount <= authority.maximumByteCount, + let allocationCount = Int(exactly: authority.byteCount), + isLowercaseSHA256(authority.sha256) else { + throw VMError.invalidConfiguration( + "resolved \(kind) metadata is outside its allocation or digest bounds" + ) + } + let accessFlags = fcntl(authority.descriptor, F_GETFL) + var before = stat() + guard accessFlags >= 0, + accessFlags & O_ACCMODE == O_RDONLY, + fstat(authority.descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_uid == geteuid(), + before.st_nlink == 0, + before.st_size > 0, + UInt64(before.st_size) == authority.byteCount, + before.st_mode & 0o077 == 0 else { + throw VMError.invalidConfiguration( + "resolved \(kind) is not the exact private anonymous read-only blob" + ) + } + + var data = Data(count: allocationCount) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw VMError.bootFailure("resolved \(kind) allocation is empty") + } + var offset = 0 + while offset < raw.count { + let result = pread( + authority.descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if result > 0 { + offset += result + } else if result < 0, errno == EINTR { + continue + } else { + throw VMError.bootFailure( + "resolved \(kind) changed or ended during exact descriptor read" + ) + } + } + } + + var after = stat() + guard fstat(authority.descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw VMError.bootFailure( + "resolved \(kind) identity changed during descriptor read" + ) + } + let actual = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard actual == authority.sha256 else { + throw VMError.bootFailure("resolved \(kind) failed exact SHA-256 validation") + } + return data + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift index 45939834..a6cc4d17 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift @@ -1,7 +1,11 @@ import Foundation -/// ARM PrimeCell PL011 UART, transmit-only. Console output lands on the supplied sink; the guest -/// sees an always-empty receive FIFO and an always-ready transmit FIFO. +/// ARM PrimeCell PL011 UART with a bounded host-to-guest receive queue. +/// +/// Console output lands on the supplied sink. Host input is delivered through ``receive(_:)`` and +/// raises the level-high UART interrupt while both data and a guest receive-interrupt mask are +/// present. Raw-HV uses this path for the same private recovery-console contract as the VZ backend, +/// so a guest remains diagnosable even when Dory Tools are missing or broken. public final class PL011: MMIODevice { public let baseAddress: UInt64 public let size: UInt64 = 0x1000 @@ -12,27 +16,58 @@ public final class PL011: MMIODevice { private var fractionalBaud: UInt64 = 0 private var interruptMask: UInt64 = 0 private var fifoLevel: UInt64 = 0x12 + private var receiveBytes = [UInt8]() + private var receiveOffset = 0 + private var interruptAsserted = false + private let lock = NSLock() private let sink: (UInt8) -> Void + private let setInterrupt: (Bool) -> Void + + private static let receiveInterrupt: UInt64 = 1 << 4 + private static let receiveTimeoutInterrupt: UInt64 = 1 << 6 + private static let maximumBufferedReceiveBytes = 64 * 1_024 private static let peripheralID: [UInt64] = [0x11, 0x10, 0x14, 0x00] private static let cellID: [UInt64] = [0x0D, 0xF0, 0x05, 0xB1] - public init(baseAddress: UInt64, sink: @escaping (UInt8) -> Void) { + public init( + baseAddress: UInt64, + sink: @escaping (UInt8) -> Void, + setInterrupt: @escaping (Bool) -> Void = { _ in } + ) { self.baseAddress = baseAddress self.sink = sink + self.setInterrupt = setInterrupt } public func read(offset: UInt64, width: Int) -> UInt64 { + lock.lock() + defer { lock.unlock() } switch offset { - case 0x00: return 0 - case 0x18: return 0x90 // FR: TXFE | RXFE + case 0x00: + guard receiveOffset < receiveBytes.count else { return 0 } + let byte = receiveBytes[receiveOffset] + receiveOffset += 1 + if receiveOffset == receiveBytes.count { + receiveBytes.removeAll(keepingCapacity: true) + receiveOffset = 0 + } else if receiveOffset >= 4_096 { + receiveBytes.removeFirst(receiveOffset) + receiveOffset = 0 + } + refreshInterruptLocked() + return UInt64(byte) + case 0x18: + // FR: TXFE is always set; RXFE is set only when host input has drained. + return receiveOffset < receiveBytes.count ? 0x80 : 0x90 case 0x24: return integerBaud case 0x28: return fractionalBaud case 0x2C: return lineControl case 0x30: return control case 0x34: return fifoLevel case 0x38: return interruptMask - case 0x3C, 0x40: return 0 // RIS, MIS + case 0x3C: return rawInterruptStatusLocked + case 0x40: return rawInterruptStatusLocked & interruptMask case 0xFE0...0xFEC: return Self.peripheralID[Int((offset - 0xFE0) / 4)] case 0xFF0...0xFFC: return Self.cellID[Int((offset - 0xFF0) / 4)] default: return 0 @@ -40,15 +75,56 @@ public final class PL011: MMIODevice { } public func write(offset: UInt64, value: UInt64, width: Int) { + if offset == 0x00 { + sink(UInt8(truncatingIfNeeded: value)) + return + } + lock.lock() + defer { lock.unlock() } switch offset { - case 0x00: sink(UInt8(truncatingIfNeeded: value)) case 0x24: integerBaud = value case 0x28: fractionalBaud = value case 0x2C: lineControl = value case 0x30: control = value case 0x34: fifoLevel = value - case 0x38: interruptMask = value - default: break // ICR and friends: write-ignored + case 0x38: + interruptMask = value + refreshInterruptLocked() + case 0x44: + // RX is level-derived from queued bytes, so clearing ICR cannot hide unread input. + refreshInterruptLocked() + default: break + } + } + + /// Queues one bounded input frame for the guest UART. Returns false without changing state + /// when the frame would exceed the private recovery console's memory bound. + @discardableResult + public func receive(_ bytes: [UInt8]) -> Bool { + guard !bytes.isEmpty else { return true } + lock.lock() + defer { lock.unlock() } + let unread = receiveBytes.count - receiveOffset + guard bytes.count <= Self.maximumBufferedReceiveBytes - unread else { return false } + if receiveOffset > 0 { + receiveBytes.removeFirst(receiveOffset) + receiveOffset = 0 } + receiveBytes.append(contentsOf: bytes) + refreshInterruptLocked() + return true + } + + private var rawInterruptStatusLocked: UInt64 { + receiveOffset < receiveBytes.count + ? Self.receiveInterrupt | Self.receiveTimeoutInterrupt + : 0 + } + + private func refreshInterruptLocked() { + let shouldAssert = rawInterruptStatusLocked & interruptMask != 0 + guard shouldAssert != interruptAsserted else { return } + interruptAsserted = shouldAssert + setInterrupt(shouldAssert) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift index d35cf4ce..0eec938a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public struct PVHMemoryMapEntry: Equatable, Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift b/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift new file mode 100644 index 00000000..81b3629a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift @@ -0,0 +1,271 @@ +import Darwin +import Foundation + +/// Lifecycle failures for the dedicated RawHV owner thread. +/// +/// A runner is deliberately single-use. Hypervisor.framework vCPUs cannot migrate between host +/// threads, so retrying the same closure on another thread would turn a failed launch into an +/// ambiguous ownership transfer. +public enum RawHVMachineRunnerError: Error, Equatable, Sendable, CustomStringConvertible { + case alreadyStarted + case notStarted + case waitFromOwnerThread + case threadCreationFailed(Int32) + case threadJoinFailed(Int32) + + public var description: String { + switch self { + case .alreadyStarted: + return "RawHV machine runner is single-use and has already started" + case .notStarted: + return "RawHV machine runner has not started" + case .waitFromOwnerThread: + return "RawHV machine owner thread cannot join itself" + case .threadCreationFailed(let code): + return "cannot create RawHV machine owner thread: pthread error \(code)" + case .threadJoinFailed(let code): + return "cannot join RawHV machine owner thread: pthread error \(code)" + } + } +} + +private final class RawHVOwnerThreadBootstrap: @unchecked Sendable { + private let body: @Sendable () -> Void + + init(body: @escaping @Sendable () -> Void) { + self.body = body + } + + func run() { + body() + } +} + +/// A single-use pthread lifecycle with exactly one operation owner and exactly one native join. +/// +/// This is internal so focused tests can prove the threading contract without creating a live +/// Hypervisor.framework VM. Production callers use `RawHVMachineRunner` below. +final class RawHVOwnerThread: @unchecked Sendable { + typealias Completion = @Sendable (Result) -> Void + + private enum Phase { + case ready + case running + case finished + case joining + case joined + case startFailed(Int32) + case joinFailed(Int32) + } + + private let condition = NSCondition() + private let name: String + private let stackSize: Int + private let operation: @Sendable () throws -> Output + + private var phase = Phase.ready + private var nativeThread: pthread_t? + private var ownerThread: pthread_t? + private var result: Result? + private var completion: Completion? + + init( + name: String, + stackSize: Int = RawHVSchedulingPolicy.machineOwnerThreadStackSize, + operation: @escaping @Sendable () throws -> Output + ) { + precondition(!name.isEmpty) + precondition(stackSize >= PTHREAD_STACK_MIN) + self.name = String(name.prefix(63)) + self.stackSize = stackSize + self.operation = operation + } + + func start(completion: Completion? = nil) throws { + var attributes = pthread_attr_t() + let attributeResult = pthread_attr_init(&attributes) + guard attributeResult == 0 else { + throw RawHVMachineRunnerError.threadCreationFailed(attributeResult) + } + defer { pthread_attr_destroy(&attributes) } + + let stackResult = pthread_attr_setstacksize(&attributes, stackSize) + guard stackResult == 0 else { + throw RawHVMachineRunnerError.threadCreationFailed(stackResult) + } + + condition.lock() + guard case .ready = phase else { + condition.unlock() + throw RawHVMachineRunnerError.alreadyStarted + } + self.completion = completion + + // Keep this owner alive independently of its caller until the native entry point returns. + // Holding `condition` across pthread_create closes the race where a very short operation + // could publish `.finished` before the creator stores the pthread handle. + let bootstrap = RawHVOwnerThreadBootstrap { [self] in threadMain() } + let context = Unmanaged.passRetained(bootstrap).toOpaque() + var createdThread: pthread_t? + let createResult = pthread_create( + &createdThread, + &attributes, + { rawContext -> UnsafeMutableRawPointer? in + let bootstrap = Unmanaged + .fromOpaque(rawContext) + .takeRetainedValue() + bootstrap.run() + return nil + }, + context + ) + guard createResult == 0, let createdThread else { + Unmanaged.fromOpaque(context).release() + phase = .startFailed(createResult == 0 ? EINVAL : createResult) + self.completion = nil + condition.broadcast() + condition.unlock() + throw RawHVMachineRunnerError.threadCreationFailed( + createResult == 0 ? EINVAL : createResult + ) + } + nativeThread = createdThread + phase = .running + condition.unlock() + } + + /// Waits for the operation and performs the one native `pthread_join`. + /// + /// Concurrent waiters are allowed: one performs the join and all others observe the identical + /// result after that join completes. The operation error itself is also replayable, so a second + /// lifecycle observer cannot accidentally consume it. + func wait() throws -> Output { + condition.lock() + if let ownerThread, pthread_equal(pthread_self(), ownerThread) != 0 { + condition.unlock() + throw RawHVMachineRunnerError.waitFromOwnerThread + } + + while true { + switch phase { + case .ready: + condition.unlock() + throw RawHVMachineRunnerError.notStarted + case .startFailed(let code): + condition.unlock() + throw RawHVMachineRunnerError.threadCreationFailed(code) + case .running: + condition.wait() + case .joining: + condition.wait() + case .joinFailed(let code): + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(code) + case .joined: + let result = self.result + condition.unlock() + return try requiredResult(result).get() + case .finished: + guard let nativeThread else { + phase = .joinFailed(EINVAL) + condition.broadcast() + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(EINVAL) + } + phase = .joining + condition.unlock() + + let joinResult = pthread_join(nativeThread, nil) + + condition.lock() + if joinResult == 0 { + phase = .joined + self.nativeThread = nil + } else { + phase = .joinFailed(joinResult) + } + condition.broadcast() + if joinResult != 0 { + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(joinResult) + } + let result = self.result + condition.unlock() + return try requiredResult(result).get() + } + } + } + + private func threadMain() { + RawHVSchedulingPolicy.applyToCurrentMachineOwnerThread() + pthread_setname_np(name) + + condition.lock() + ownerThread = pthread_self() + condition.unlock() + + let completed = Result { try operation() } + + condition.lock() + result = completed + phase = .finished + let completion = self.completion + self.completion = nil + condition.broadcast() + condition.unlock() + + // Completion may only publish onto another executor; lifecycle destruction still waits for + // `pthread_join`, which does not complete until this callback and the entry point return. + completion?(completed) + } + + private func requiredResult( + _ result: Result? + ) -> Result { + guard let result else { + preconditionFailure("joined RawHV owner thread did not publish a result") + } + return result + } +} + +/// Runs `Machine.run()` on a dedicated, non-libdispatch pthread. +/// +/// `Machine.run()` makes the owner thread the boot vCPU. Secondary vCPUs already have one +/// Foundation thread each and are joined by `Machine.run()` before it returns. Joining this runner +/// therefore establishes the final boundary before device and VM teardown. +public final class RawHVMachineRunner: @unchecked Sendable { + public typealias Completion = @Sendable (Result) -> Void + + private let machine: Machine + private let owner: RawHVOwnerThread + + public init(machine: Machine, threadName: String) { + self.machine = machine + owner = RawHVOwnerThread(name: threadName) { + try machine.run() + } + } + + public func start(completion: Completion? = nil) throws { + try owner.start(completion: completion) + } + + @discardableResult + public func wait() throws -> GuestStopReason { + try owner.wait() + } + + @discardableResult + public func runToCompletion() throws -> GuestStopReason { + try start() + return try wait() + } + + /// Publishes the terminal reason, exits every live vCPU, and joins the owner thread. + @discardableResult + public func stopAndWait(_ reason: GuestStopReason) throws -> GuestStopReason { + machine.requestStop(reason) + return try wait() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift index 837e7a6c..3d135ac7 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift @@ -31,19 +31,12 @@ public enum HostUsbDiscoveryError: Error, Equatable, Sendable { case matchingFailed(kern_return_t) } -public enum HostUsbOpenMode: Equatable, Sendable { +public enum HostUsbOpenMode: Hashable, Sendable { case userAuthorized case seize case capture } -public struct HostUsbOpenPlan: Equatable, Sendable { - public var mode: HostUsbOpenMode - public var authorize: Bool - public var requiresPrivilegedHelperForClaimedDevice: Bool - public var optionNames: [String] -} - public enum HostUsbOpenError: Error, Equatable, Sendable { case notFound(String) case authorizationFailed(kern_return_t) @@ -51,29 +44,15 @@ public enum HostUsbOpenError: Error, Equatable, Sendable { } public enum HostUsbDeviceFactory: Sendable { - public static func plan(mode: HostUsbOpenMode) -> HostUsbOpenPlan { - switch mode { - case .userAuthorized: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: []) - case .seize: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: ["deviceSeize"]) - case .capture: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: true, optionNames: ["deviceCapture"]) - } - } - public static func open(busID: String, mode: HostUsbOpenMode = .userAuthorized) throws -> HostUsbDevice { let (candidate, service) = try findService(busID: busID) defer { IOObjectRelease(service) } - let plan = plan(mode: mode) - if plan.authorize { - let kr = IOServiceAuthorize(service, UInt32(kIOServiceInteractionAllowed)) - guard kr == KERN_SUCCESS else { throw HostUsbOpenError.authorizationFailed(kr) } - } + let kr = IOServiceAuthorize(service, UInt32(kIOServiceInteractionAllowed)) + guard kr == KERN_SUCCESS else { throw HostUsbOpenError.authorizationFailed(kr) } guard let device = DoryIOUSBHostCreateDevice(service, options(for: mode), nil) else { throw HostUsbOpenError.openDeviceFailed } - let opened = collectPipes(deviceService: service, mode: mode) + let opened = collectPipes(deviceService: service) let retained: [IOUSBHostObject] = [device] + opened.interfaces let backend = IOUSBHostDeviceBackend(controlObject: device, pipes: opened.pipes, retainedObjects: retained) return HostUsbDevice(descriptor: candidate.descriptor, backend: backend) @@ -100,7 +79,7 @@ public enum HostUsbDeviceFactory: Sendable { throw HostUsbOpenError.notFound(busID) } - private static func collectPipes(deviceService: io_service_t, mode: HostUsbOpenMode) -> (interfaces: [IOUSBHostInterface], pipes: [UInt8: IOUSBHostPipe]) { + private static func collectPipes(deviceService: io_service_t) -> (interfaces: [IOUSBHostInterface], pipes: [UInt8: IOUSBHostPipe]) { var iterator: io_iterator_t = 0 guard IORegistryEntryCreateIterator(deviceService, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { return ([], [:]) @@ -301,27 +280,88 @@ public enum HostUsbTransferError: Error, Equatable, Sendable { case failed(errno: Int32) } +public enum HostUsbTransferKind: Equatable, Sendable { + case bulk + case interrupt +} + public protocol HostUsbBackend: Sendable { func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult - func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult + func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, kind: HostUsbTransferKind, timeout: TimeInterval) throws -> HostUsbTransferResult func abort(endpointAddress: UInt8?) throws } public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { + /// Linux `EREMOTEIO`, used on the USB/IP wire for `URB_SHORT_NOT_OK`. + private static let linuxRemoteIO: Int32 = 121 + + private struct RequestKey: Hashable { + var contextID: UUID + var sequenceNumber: UInt32 + } + + private struct ActiveRequest { + var endpointAddress: UInt8 + var reservedBytes: UInt64 + } + public let descriptor: UsbipDeviceDescriptor private let backend: any HostUsbBackend private let timeout: TimeInterval + private let shutdownTimeout: TimeInterval + private let maxConcurrentRequests: Int + private let maxInFlightBytes: UInt64 + private let state = NSCondition() + private var activeRequests: [RequestKey: ActiveRequest] = [:] + private var inFlightBytes: UInt64 = 0 + private var abortingEndpoints = Set() + private var shuttingDown = false - public init(descriptor: UsbipDeviceDescriptor, backend: any HostUsbBackend, timeout: TimeInterval = 5) { + public init( + descriptor: UsbipDeviceDescriptor, + backend: any HostUsbBackend, + timeout: TimeInterval = 5, + maxConcurrentRequests: Int = 8, + maxInFlightBytes: UInt64 = 16 * 1024 * 1024, + shutdownTimeout: TimeInterval = 2 + ) { self.descriptor = descriptor self.backend = backend - self.timeout = timeout + self.timeout = Self.finiteDeadline(timeout, fallback: 5) + self.maxConcurrentRequests = max(1, maxConcurrentRequests) + self.maxInFlightBytes = max(1, maxInFlightBytes) + self.shutdownTimeout = Self.finiteDeadline(shutdownTimeout, fallback: 2) } - public func submit(_ command: UsbipSubmitCommand) throws -> UsbipSubmitReply { - guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { + public func submit(_ command: UsbipSubmitCommand, context: UsbipRequestContext) throws -> UsbipSubmitReply { + do { + try UsbipSubmitCommand.validateTransferFlags( + command.transferFlags, + direction: command.header.direction + ) + } catch { + return reply(for: command, status: -EINVAL, actualLength: 0, data: []) + } + // Accept both non-iso values produced/required by Linux USB/IP; positive counts are + // isochronous and remain unsupported by this backend. + guard command.numberOfPackets == 0 || command.numberOfPackets == UInt32.max else { return reply(for: command, status: -EPIPE, actualLength: 0, data: []) } + guard command.header.endpoint <= 15, + command.transferBufferLength <= UsbipSubmitCommand.maxTransferBytes, + (command.header.direction == .in || command.transferBuffer.count == Int(command.transferBufferLength)) else { + return reply(for: command, status: -EINVAL, actualLength: 0, data: []) + } + + let endpointAddress = command.header.endpoint == 0 + ? UInt8(0) + : Self.endpointAddress(number: command.header.endpoint, direction: command.header.direction) + let key = RequestKey(contextID: context.id, sequenceNumber: command.header.sequenceNumber) + let reservation = UInt64(command.transferBufferLength) + if let admissionError = admit(key: key, endpointAddress: endpointAddress, bytes: reservation) { + return reply(for: command, status: -admissionError, actualLength: 0, data: []) + } + defer { finish(key: key) } do { let result: HostUsbTransferResult @@ -335,29 +375,93 @@ public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { payload: command.header.direction == .out ? command.transferBuffer : [], expectedLength: command.transferBufferLength, direction: command.header.direction, - timeout: command.header.endpoint == 0 ? timeout : (command.interval == 0 ? 0 : timeout) + kind: command.interval == 0 ? .bulk : .interrupt, + timeout: timeout ) } - return reply(for: command, status: result.status, actualLength: result.actualLength, data: result.data) + let validated = try validate(result: result, for: command) + return reply(for: command, status: validated.status, actualLength: validated.actualLength, data: validated.data) } catch let error as HostUsbTransferError { return reply(for: command, status: Self.usbipStatus(for: error), actualLength: 0, data: []) + } catch { + return reply(for: command, status: -EIO, actualLength: 0, data: []) } } - public func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply { + public func unlink(_ command: UsbipUnlinkCommand, context: UsbipRequestContext) throws -> UsbipUnlinkReply { let status: Int32 - do { - try backend.abort(endpointAddress: nil) - status = 0 - } catch let error as HostUsbTransferError { - status = Self.usbipStatus(for: error) + let targetKey = RequestKey(contextID: context.id, sequenceNumber: command.unlinkSequenceNumber) + state.lock() + if let target = activeRequests[targetKey] { + let endpointIsShared = activeRequests.contains { key, request in + key != targetKey && request.endpointAddress == target.endpointAddress + } + if endpointIsShared || abortingEndpoints.contains(target.endpointAddress) { + state.unlock() + status = -EBUSY + } else { + abortingEndpoints.insert(target.endpointAddress) + state.unlock() + do { + // Endpoint zero means only default-control requests. nil is reserved for + // terminal device shutdown and is never used by an untrusted UNLINK. + try backend.abort(endpointAddress: target.endpointAddress) + status = 0 + } catch let error as HostUsbTransferError { + status = Self.usbipStatus(for: error) + } catch { + status = -EIO + } + state.lock() + abortingEndpoints.remove(target.endpointAddress) + state.broadcast() + state.unlock() + } + } else { + state.unlock() + status = -ENOENT } let header = UsbipHeaderBasic(command: .retUnlink, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) return UsbipUnlinkReply(header: header, status: status) } + public func closeSession(_ context: UsbipRequestContext) { + // The synchronous stream pump cannot race-proof a disconnect cancellation against a new + // request entering the same pipe. Fail closed: leave it to the finite host deadline instead + // of risking an abort of a later or unrelated URB. Explicit UNLINK uses the locked identity + // check above; terminal detach uses shutdown(). + } + + public func shutdown() { + state.lock() + if shuttingDown { + state.unlock() + return + } + shuttingDown = true + state.broadcast() + state.unlock() + + let deadline = Date().addingTimeInterval(shutdownTimeout) + Self.abort(backend, endpointAddress: nil, timeout: shutdownTimeout) + state.lock() + while !activeRequests.isEmpty, state.wait(until: deadline) {} + state.unlock() + } + + deinit { + state.lock() + let alreadyShuttingDown = shuttingDown + shuttingDown = true + state.unlock() + if !alreadyShuttingDown { + Self.abort(backend, endpointAddress: nil, timeout: shutdownTimeout) + } + } + nonisolated public static func endpointAddress(number: UInt32, direction: UsbipDirection) -> UInt8 { - UInt8(number & 0x0f) | (direction == .in ? 0x80 : 0x00) + precondition(number <= 15) + return UInt8(number) | (direction == .in ? 0x80 : 0x00) } private func reply(for command: UsbipSubmitCommand, status: Int32, actualLength: UInt32, data: [UInt8]) -> UsbipSubmitReply { @@ -372,14 +476,76 @@ public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { case .failed(let errno): -abs(errno) } } + + private func admit(key: RequestKey, endpointAddress: UInt8, bytes: UInt64) -> Int32? { + state.lock() + defer { state.unlock() } + guard !shuttingDown else { return ENODEV } + guard activeRequests[key] == nil else { return EALREADY } + guard activeRequests.count < maxConcurrentRequests else { return EBUSY } + guard !abortingEndpoints.contains(endpointAddress) else { return EBUSY } + let (newTotal, overflow) = inFlightBytes.addingReportingOverflow(bytes) + guard !overflow, newTotal <= maxInFlightBytes else { return EBUSY } + inFlightBytes = newTotal + activeRequests[key] = ActiveRequest(endpointAddress: endpointAddress, reservedBytes: bytes) + return nil + } + + private func finish(key: RequestKey) { + state.lock() + if let removed = activeRequests.removeValue(forKey: key) { + inFlightBytes -= removed.reservedBytes + } + state.broadcast() + state.unlock() + } + + private func validate(result: HostUsbTransferResult, for command: UsbipSubmitCommand) throws -> HostUsbTransferResult { + guard result.actualLength <= command.transferBufferLength else { + throw HostUsbTransferError.failed(errno: EPROTO) + } + if command.header.direction == .in { + guard result.data.count >= Int(result.actualLength), + result.data.count <= Int(command.transferBufferLength) else { + throw HostUsbTransferError.failed(errno: EPROTO) + } + let shortTransferRejected = result.status == 0 + && command.transferFlags & UsbipTransferFlag.shortNotOK != 0 + && result.actualLength < command.transferBufferLength + return HostUsbTransferResult( + status: shortTransferRejected ? -Self.linuxRemoteIO : result.status, + actualLength: result.actualLength, + data: Array(result.data.prefix(Int(result.actualLength))) + ) + } + return HostUsbTransferResult(status: result.status, actualLength: result.actualLength) + } + + private nonisolated static func finiteDeadline(_ value: TimeInterval, fallback: TimeInterval) -> TimeInterval { + guard value.isFinite, value > 0 else { return fallback } + return min(max(value, 0.01), 60) + } + + private nonisolated static func abort( + _ backend: any HostUsbBackend, + endpointAddress: UInt8?, + timeout: TimeInterval + ) { + let completion = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + try? backend.abort(endpointAddress: endpointAddress) + completion.signal() + } + _ = completion.wait(timeout: .now() + timeout) + } } public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { private let controlObject: IOUSBHostObject? private let pipes: [UInt8: IOUSBHostPipe] - private let retainedObjects: [IOUSBHostObject] + private let lifetime: IOUSBObjectLifetime private let controlHandler: (@Sendable (HostUsbControlSetup, [UInt8], UsbipDirection, TimeInterval) throws -> HostUsbTransferResult)? - private let lock = NSLock() + private let abortLock = NSLock() public init( controlObject: IOUSBHostObject? = nil, @@ -389,16 +555,10 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { ) { self.controlObject = controlObject self.pipes = pipes - self.retainedObjects = retainedObjects + self.lifetime = IOUSBObjectLifetime(objects: retainedObjects) self.controlHandler = controlHandler } - deinit { - for object in retainedObjects { - DoryIOUSBHostDestroyObject(object, []) - } - } - public func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { if let controlHandler { return try controlHandler(setup, payload, direction, timeout) @@ -409,73 +569,118 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { if direction == .out { data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) } - var transferred = 0 let request = setup.ioUSBDeviceRequest() - let ok = locked { - DoryIOUSBHostSendDeviceRequest(controlObject, request, data, &transferred, timeout, nil) + let lease = IOUSBRequestLease(data: data, resource: controlObject, lifetime: lifetime) + let result = try HostUsbDeadlineWaiter.perform( + timeout: timeout, + enqueue: { completion in + DoryIOUSBHostEnqueueDeviceRequest(controlObject, request, data, timeout, nil) { status, count in + _ = lease + completion(status, Int(count)) + } + }, + abort: { [self] in abortControlRequests() } + ) + guard result.status == kIOReturnSuccess else { + throw HostUsbTransferError.failed(errno: Self.errno(for: result.status)) + } + let transferred = result.count + guard transferred >= 0, transferred <= data.length, UInt32(exactly: transferred) != nil else { + throw HostUsbTransferError.failed(errno: EPROTO) } - guard ok else { throw HostUsbTransferError.failed(errno: EIO) } let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) } - public func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + public func transfer( + endpointAddress: UInt8, + payload: [UInt8], + expectedLength: UInt32, + direction: UsbipDirection, + kind: HostUsbTransferKind, + timeout: TimeInterval + ) throws -> HostUsbTransferResult { guard let pipe = pipes[endpointAddress] else { throw HostUsbTransferError.endpointNotFound(endpointAddress) } + let actualKind = try Self.transferKind(endpointAttributes: pipe.descriptors.pointee.descriptor.bmAttributes) + guard actualKind == kind else { throw HostUsbTransferError.failed(errno: EPROTO) } let length = direction == .in ? Int(expectedLength) : payload.count guard let data = NSMutableData(length: length) else { throw HostUsbTransferError.failed(errno: ENOMEM) } if direction == .out { data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) } - let completion = IOUSBCompletionBox() - let status = locked { - let semaphore = DispatchSemaphore(value: 0) - do { - try pipe.enqueueIORequest(with: data, completionTimeout: timeout) { status, count in - completion.set(status: status, count: count) - semaphore.signal() + let lease = IOUSBRequestLease(data: data, resource: pipe, lifetime: lifetime) + // IOUSBHost requires a zero framework timeout for interrupt pipes. Dory still applies the + // finite outer deadline below and synchronously asks the exact pipe to abort on expiry. + let frameworkTimeout = kind == .interrupt ? 0 : timeout + let result = try HostUsbDeadlineWaiter.perform( + timeout: timeout, + enqueue: { completion in + do { + try pipe.enqueueIORequest(with: data, completionTimeout: frameworkTimeout) { status, count in + _ = lease + completion(status, count) + } + return true + } catch { + return false } - } catch { - return kIOReturnError + }, + abort: { [self, lease] in + guard let retainedPipe = lease.resource as? IOUSBHostPipe else { return } + abortPipe(retainedPipe) } - semaphore.wait() - return completion.result.status + ) + guard result.status == kIOReturnSuccess else { + throw HostUsbTransferError.failed(errno: Self.errno(for: result.status)) + } + let transferred = result.count + guard transferred >= 0, transferred <= data.length, UInt32(exactly: transferred) != nil else { + throw HostUsbTransferError.failed(errno: EPROTO) } - guard status == kIOReturnSuccess else { throw HostUsbTransferError.failed(errno: Self.errno(for: status)) } - let transferred = completion.result.count let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) } public func abort(endpointAddress: UInt8?) throws { if let endpointAddress { + if endpointAddress == 0 { + guard controlObject != nil else { + throw HostUsbTransferError.endpointNotFound(endpointAddress) + } + guard abortControlRequests() else { throw HostUsbTransferError.failed(errno: EIO) } + return + } guard let pipe = pipes[endpointAddress] else { throw HostUsbTransferError.endpointNotFound(endpointAddress) } - let ok = locked { - DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) - } + let ok = abortPipe(pipe) guard ok else { throw HostUsbTransferError.failed(errno: EIO) } return } var ok = true - if let controlObject { - ok = locked { - DoryIOUSBHostAbortDeviceRequests(controlObject, IOUSBHostAbortOption.synchronous, nil) - } + if controlObject != nil { + ok = abortControlRequests() } guard ok else { throw HostUsbTransferError.failed(errno: EIO) } for pipe in pipes.values { - let pipeOK = locked { - DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) - } + let pipeOK = abortPipe(pipe) guard pipeOK else { throw HostUsbTransferError.failed(errno: EIO) } } } - private func locked(_ body: () throws -> T) rethrows -> T { - lock.lock() - defer { lock.unlock() } - return try body() + @discardableResult + private func abortControlRequests() -> Bool { + guard let controlObject else { return true } + abortLock.lock() + defer { abortLock.unlock() } + return DoryIOUSBHostAbortDeviceRequests(controlObject, IOUSBHostAbortOption.synchronous, nil) + } + + @discardableResult + private func abortPipe(_ pipe: IOUSBHostPipe) -> Bool { + abortLock.lock() + defer { abortLock.unlock() } + return DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) } nonisolated static func errno(for status: IOReturn) -> Int32 { @@ -492,23 +697,109 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { default: EIO } } + + /// USB endpoint descriptor bits 0...1 define control, isochronous, bulk, or interrupt. Dory + /// never trusts the guest's interval field to reclassify the physical host pipe. + nonisolated static func transferKind(endpointAttributes: UInt8) throws -> HostUsbTransferKind { + switch endpointAttributes & 0x03 { + case 0x02: .bulk + case 0x03: .interrupt + case 0x01: throw HostUsbTransferError.failed(errno: ENOTSUP) + default: throw HostUsbTransferError.failed(errno: EPROTO) + } + } +} + +struct HostUsbIOCompletion: Equatable, Sendable { + var status: IOReturn + var count: Int +} + +enum HostUsbDeadlineWaiter { + typealias Completion = @Sendable (IOReturn, Int) -> Void + + static func perform( + timeout: TimeInterval, + enqueue: (_ completion: @escaping Completion) -> Bool, + abort: @escaping @Sendable () -> Void + ) throws -> HostUsbIOCompletion { + let finiteTimeout = timeout.isFinite && timeout > 0 ? min(timeout, 60) : 5 + let completion = IOUSBCompletionBox() + guard enqueue({ status, count in completion.complete(status: status, count: count) }) else { + throw HostUsbTransferError.failed(errno: EIO) + } + if completion.wait(timeout: finiteTimeout) { + return completion.result + } + + // Never let a synchronous IOUSBHost abort turn the watchdog into another unbounded wait. + // The closure retains the exact object/request lifetime until abort returns, and a late + // completion only touches the locked completion box. + let abortFinished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + abort() + abortFinished.signal() + } + _ = completion.wait(timeout: min(0.25, finiteTimeout)) + _ = abortFinished.wait(timeout: .now() + min(0.25, finiteTimeout)) + throw HostUsbTransferError.failed(errno: ETIMEDOUT) + } } private final class IOUSBCompletionBox: @unchecked Sendable { private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) private var storedStatus: IOReturn = kIOReturnError private var storedCount = 0 + private var completed = false - func set(status: IOReturn, count: Int) { + func complete(status: IOReturn, count: Int) { lock.lock() + guard !completed else { lock.unlock(); return } storedStatus = status storedCount = count + completed = true lock.unlock() + semaphore.signal() } - var result: (status: IOReturn, count: Int) { + func wait(timeout: TimeInterval) -> Bool { + lock.lock() + let alreadyCompleted = completed + lock.unlock() + if alreadyCompleted { return true } + return semaphore.wait(timeout: .now() + timeout) == .success + } + + var result: HostUsbIOCompletion { lock.lock() defer { lock.unlock() } - return (storedStatus, storedCount) + return HostUsbIOCompletion(status: storedStatus, count: storedCount) + } +} + +private final class IOUSBObjectLifetime: @unchecked Sendable { + private let objects: [IOUSBHostObject] + + init(objects: [IOUSBHostObject]) { + self.objects = objects + } + + deinit { + for object in objects { + DoryIOUSBHostDestroyObject(object, []) + } + } +} + +private final class IOUSBRequestLease: @unchecked Sendable { + let data: NSMutableData + let resource: AnyObject + let lifetime: IOUSBObjectLifetime + + init(data: NSMutableData, resource: AnyObject, lifetime: IOUSBObjectLifetime) { + self.data = data + self.resource = resource + self.lifetime = lifetime } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift index 90686371..6b07773b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift @@ -1,14 +1,7 @@ +import DoryVMContracts import Foundation -public struct UsbAttachOutcome: Equatable, Sendable, Codable { - public var busID: String - public var port: Int - public var vsockPort: UInt32 - public var deviceID: UInt32 - public var speed: UInt32 -} - -public struct UsbAgentAttachRequest: Equatable, Sendable, Encodable { +public struct UsbAgentAttachRequest: Equatable, Sendable { public var busid: String public var port: Int public var vsock_port: UInt32 @@ -16,7 +9,7 @@ public struct UsbAgentAttachRequest: Equatable, Sendable, Encodable { public var speed: UInt32 } -public struct UsbAgentDetachRequest: Equatable, Sendable, Encodable { +public struct UsbAgentDetachRequest: Equatable, Sendable { public var busid: String public var port: Int } @@ -24,14 +17,46 @@ public struct UsbAgentDetachRequest: Equatable, Sendable, Encodable { public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible { case alreadyAttached(String) case notAttached(String) + case transitionInProgress(busID: String, operation: String) + case invalidBusID(String) + case deviceIdentityMismatch(expected: String, actual: String) + case invalidDeviceIdentity(busID: String, busNumber: UInt32, deviceNumber: UInt32) + case invalidAttachmentMetadata(busID: String, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) + case openModeNotAllowed(HostUsbOpenMode) + case managerStoppedDuringTransition(String) + case mutationRejected(operation: DoryUSBControlV1.Operation, busID: String, detail: String) + case outcomeUnknown(operation: DoryUSBControlV1.Operation, busID: String, detail: String) case guestAgentRPCUnavailable + public var failureDisposition: DoryUSBControlV1.FailureDisposition { + if case .outcomeUnknown = self { return .outcomeUnknown } + return .rejected + } + public var description: String { switch self { case .alreadyAttached(let busID): return "USB device is already attached: \(busID)" case .notAttached(let busID): return "USB device is not attached: \(busID)" + case let .transitionInProgress(busID, operation): + return "USB device \(busID) is already \(operation)" + case .invalidBusID(let busID): + return "USB bus ID is not canonical: \(busID)" + case let .deviceIdentityMismatch(expected, actual): + return "USB device identity changed while opening (expected \(expected), got \(actual))" + case let .invalidDeviceIdentity(busID, busNumber, deviceNumber): + return "USB device \(busID) has an invalid USB/IP identity \(busNumber):\(deviceNumber)" + case let .invalidAttachmentMetadata(busID, vsockPort, deviceID, speed): + return "USB device \(busID) has invalid attachment metadata (vsock port \(vsockPort), device ID \(deviceID), speed \(speed))" + case .openModeNotAllowed(let mode): + return "USB open mode is not authorized by the engine policy: \(mode)" + case .managerStoppedDuringTransition(let busID): + return "USB manager stopped during the \(busID) transition; the operation was rolled back" + case let .mutationRejected(operation, busID, detail): + return "USB \(operation.rawValue) was rejected for \(busID): \(detail)" + case let .outcomeUnknown(operation, busID, detail): + return "USB \(operation.rawValue) outcome is unknown for \(busID): \(detail)" case .guestAgentRPCUnavailable: return "USB attach/detach is unavailable: the guest does not expose usb-vhci@1" } @@ -44,91 +69,367 @@ public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible /// fails) is unit-testable without real hardware, a socket, or a running guest. public final class UsbControlHandler: @unchecked Sendable { private let manager: UsbipManager + private let allowedOpenModes: Set private let ensureSupported: () async throws -> Void private let openDevice: (String, HostUsbOpenMode) throws -> any UsbipExportedDevice private let notifyAttach: (UsbAgentAttachRequest) async throws -> Void private let notifyDetach: (UsbAgentDetachRequest) async throws -> Void private let lock = NSLock() - private var portByBusID: [String: Int] = [:] + private enum AttachmentState: Equatable { + case attaching(port: Int) + case attached(port: Int) + /// The host claim and USB/IP registration are deliberately retained because guest attach + /// may have committed and its compensating detach did not establish a terminal state. + case uncertain(port: Int) + case detaching(port: Int, priorWasUncertain: Bool) + + var port: Int { + switch self { + case .attaching(let port), .attached(let port), .uncertain(let port): + return port + case .detaching(let port, _): return port + } + } + + var operation: String { + switch self { + case .attaching: return "attaching" + case .attached: return "attached" + case .uncertain: return "in an uncertain attach state" + case .detaching: return "detaching" + } + } + } + + private var attachmentByBusID: [String: AttachmentState] = [:] private var usedPorts = Set() public init( manager: UsbipManager, + allowedOpenModes: Set = [.userAuthorized], ensureSupported: @escaping () async throws -> Void = {}, openDevice: @escaping (String, HostUsbOpenMode) throws -> any UsbipExportedDevice, notifyAttach: @escaping (UsbAgentAttachRequest) async throws -> Void, notifyDetach: @escaping (UsbAgentDetachRequest) async throws -> Void ) { self.manager = manager + self.allowedOpenModes = allowedOpenModes self.ensureSupported = ensureSupported self.openDevice = openDevice self.notifyAttach = notifyAttach self.notifyDetach = notifyDetach } - public func attach(busID: String, mode: HostUsbOpenMode = .userAuthorized) async throws -> UsbAttachOutcome { + public func attach( + busID: String, + mode: HostUsbOpenMode = .userAuthorized + ) async throws -> DoryUSBControlV1.Attachment { // Capability is checked before opening or claiming the host device. A missing guest RPC must // fail closed; briefly seizing hardware and rolling back is still an observable disruption. + guard DoryUSBControlV1.BusID.isValid(busID) else { + throw UsbControlError.invalidBusID(busID) + } + guard allowedOpenModes.contains(mode) else { + throw UsbControlError.openModeNotAllowed(mode) + } + let mutation = try manager.beginControlMutation(operation: .attach, busID: busID) + defer { manager.finishControlMutation(mutation) } + // Capability negotiation is also an admitted, potentially uncancellable RPC. Keep it under + // the manager generation lease so stop cannot report a clean drain while it is still running. try await ensureSupported() + guard manager.isControlMutationCurrent(mutation) else { + throw UsbControlError.managerStoppedDuringTransition(busID) + } let port = try lock.withLock { () -> Int in - guard portByBusID[busID] == nil else { throw UsbControlError.alreadyAttached(busID) } - return allocatePortLocked(for: busID) + guard let current = attachmentByBusID[busID] else { + let port = allocatePortLocked() + attachmentByBusID[busID] = .attaching(port: port) + return port + } + switch current { + case .attached: + throw UsbControlError.alreadyAttached(busID) + case .uncertain: + throw UsbControlError.outcomeUnknown( + operation: .attach, + busID: busID, + detail: "a prior attach may have committed; detach it before attaching again" + ) + case .attaching, .detaching: + throw UsbControlError.transitionInProgress( + busID: busID, + operation: current.operation + ) + } } let device: any UsbipExportedDevice do { device = try openDevice(busID, mode) } catch { - lock.withLock { releasePortLocked(busID) } + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } throw error } - manager.register(device) let descriptor = device.descriptor + guard descriptor.busID == busID else { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.deviceIdentityMismatch( + expected: busID, + actual: descriptor.busID + ) + } + guard descriptor.busNumber <= UInt32(UInt16.max), + descriptor.deviceNumber > 0, + descriptor.deviceNumber <= UInt32(UInt16.max) else { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.invalidDeviceIdentity( + busID: busID, + busNumber: descriptor.busNumber, + deviceNumber: descriptor.deviceNumber + ) + } + let deviceID = (descriptor.busNumber << 16) | descriptor.deviceNumber + let attachment: DoryUSBControlV1.Attachment + do { + attachment = try DoryUSBControlV1.Attachment( + port: port, + vsockPort: manager.port, + deviceID: deviceID, + speed: descriptor.speed + ) + } catch { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.invalidAttachmentMetadata( + busID: busID, + vsockPort: manager.port, + deviceID: deviceID, + speed: descriptor.speed + ) + } + do { + try manager.register(device, under: mutation) + } catch { + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw error + } let request = UsbAgentAttachRequest( busid: busID, port: port, - vsock_port: manager.port, - device_id: (descriptor.busNumber << 16) | descriptor.deviceNumber, - speed: descriptor.speed + vsock_port: attachment.vsockPort, + device_id: attachment.deviceID, + speed: attachment.speed ) + guard manager.isControlMutationCurrent(mutation) else { + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.managerStoppedDuringTransition(busID) + } do { try await notifyAttach(request) } catch { - // The guest could not attach — undo the host-side claim so the device returns to macOS. - manager.unregister(busID: busID) - lock.withLock { releasePortLocked(busID) } - throw error + try await compensateAttachUncertainty( + busID: busID, + port: port, + mutation: mutation, + rejectionDetail: "guest attach RPC failed: \(error)" + ) } - return UsbAttachOutcome(busID: busID, port: port, vsockPort: request.vsock_port, deviceID: request.device_id, speed: request.speed) + let committed = manager.withCurrentControlMutation(mutation) { + lock.withLock { + guard case .attaching(let currentPort) = attachmentByBusID[busID], + currentPort == port else { + preconditionFailure("USB attach state changed without owning the transition") + } + attachmentByBusID[busID] = .attached(port: port) + } + return true + } ?? false + guard committed else { + try await compensateAttachUncertainty( + busID: busID, + port: port, + mutation: mutation, + rejectionDetail: "manager stopped after the guest attach RPC committed" + ) + } + return attachment } public func detach(busID: String) async throws { + guard DoryUSBControlV1.BusID.isValid(busID) else { + throw UsbControlError.invalidBusID(busID) + } + let mutation = try manager.beginControlMutation(operation: .detach, busID: busID) + defer { manager.finishControlMutation(mutation) } try await ensureSupported() - let port = try lock.withLock { () -> Int in - guard let port = portByBusID[busID] else { throw UsbControlError.notAttached(busID) } - return port + guard manager.isControlMutationCurrent(mutation) else { + throw UsbControlError.managerStoppedDuringTransition(busID) + } + let transition = try lock.withLock { () -> (port: Int, priorWasUncertain: Bool) in + guard let state = attachmentByBusID[busID] else { + throw UsbControlError.notAttached(busID) + } + let port: Int + let priorWasUncertain: Bool + switch state { + case .attached(let currentPort): + port = currentPort + priorWasUncertain = false + case .uncertain(let currentPort): + port = currentPort + priorWasUncertain = true + case .attaching, .detaching: + throw UsbControlError.transitionInProgress( + busID: busID, + operation: state.operation + ) + } + attachmentByBusID[busID] = .detaching( + port: port, + priorWasUncertain: priorWasUncertain + ) + return (port, priorWasUncertain) + } + let port = transition.port + guard manager.isControlMutationCurrent(mutation) else { + lock.withLock { + restoreAfterFailedDetachLocked( + busID, + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + } + throw UsbControlError.managerStoppedDuringTransition(busID) + } + do { + try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) + } catch { + // A failed RPC does not prove whether vhci-detach committed. Retaining both the host + // claim and the prior certainty lets an explicit later detach safely reconcile it. + lock.withLock { + restoreAfterFailedDetachLocked( + busID, + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + } + let retentionDetail: String + do { + try manager.preserveClaimForReconciliation(under: mutation) + retentionDetail = "" + } catch { + retentionDetail = "; preserving the host claim failed: \(error)" + } + throw UsbControlError.outcomeUnknown( + operation: .detach, + busID: busID, + detail: "guest detach RPC failed: \(error)\(retentionDetail)" + ) + } + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { + rollbackLocked( + busID, + expected: .detaching( + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + ) } - try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) - manager.unregister(busID: busID) - lock.withLock { releasePortLocked(busID) } } public var attachedBusIDs: [String] { - lock.withLock { portByBusID.keys.sorted() } + lock.withLock { + attachmentByBusID.compactMap { busID, state in + switch state { + case .attached, .uncertain: return busID + case .attaching, .detaching: return nil + } + }.sorted() + } + } + + public var uncertainBusIDs: [String] { + lock.withLock { + attachmentByBusID.compactMap { busID, state in + if case .uncertain = state { return busID } + return nil + }.sorted() + } + } + + private func compensateAttachUncertainty( + busID: String, + port: Int, + mutation: UsbipManagerControlMutationLease, + rejectionDetail: String + ) async throws -> Never { + do { + try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) + } catch { + let retentionDetail: String + do { + try manager.preserveClaimForReconciliation(under: mutation) + retentionDetail = "" + } catch { + retentionDetail = "; preserving the host claim failed: \(error)" + } + lock.withLock { + guard attachmentByBusID[busID] == .attaching(port: port) else { + preconditionFailure("USB attach compensation lost transition ownership") + } + attachmentByBusID[busID] = .uncertain(port: port) + } + throw UsbControlError.outcomeUnknown( + operation: .attach, + busID: busID, + detail: "\(rejectionDetail); guest detach compensation failed: \(error)\(retentionDetail)" + ) + } + // Only a successful guest detach establishes the pre-attach state. Release the host claim + // afterwards; reversing this order would make an uncertain guest attachment unrecoverable. + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.mutationRejected( + operation: .attach, + busID: busID, + detail: rejectionDetail + ) + } + + private func restoreAfterFailedDetachLocked( + _ busID: String, + port: Int, + priorWasUncertain: Bool + ) { + let expected = AttachmentState.detaching( + port: port, + priorWasUncertain: priorWasUncertain + ) + guard attachmentByBusID[busID] == expected else { + preconditionFailure("USB detach rollback lost transition ownership") + } + attachmentByBusID[busID] = priorWasUncertain + ? .uncertain(port: port) + : .attached(port: port) } - private func allocatePortLocked(for busID: String) -> Int { + private func allocatePortLocked() -> Int { var port = 0 while usedPorts.contains(port) { port += 1 } usedPorts.insert(port) - portByBusID[busID] = port return port } - private func releasePortLocked(_ busID: String) { - if let port = portByBusID.removeValue(forKey: busID) { - usedPorts.remove(port) + private func rollbackLocked(_ busID: String, expected: AttachmentState) { + guard attachmentByBusID[busID] == expected else { + preconditionFailure("USB transition rollback lost ownership") } + attachmentByBusID.removeValue(forKey: busID) + usedPorts.remove(expected.port) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift index 0ea8f951..a31d37b1 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift @@ -1,265 +1,1065 @@ import Darwin +import DoryVMContracts import Foundation -public struct UsbControlRequest: Codable, Equatable, Sendable { - public var cmd: String // "attach" | "detach" - public var busid: String - public var mode: String? // "userAuthorized" | "seize" | "capture" (attach only) +/// Serves the engine's USB control protocol on a unix socket. The engine owns the device claim, the +/// UsbipManager, and the guest agent channel. One request is accepted per connection. +public final class UsbControlServer: @unchecked Sendable { + private static let endpointMutationLock = NSLock() + private static let productionMaximumSessions = 8 + private static let maximumConfiguredSessions = 64 + private static let maximumConfiguredTimeout: TimeInterval = 30 - public init(cmd: String, busid: String, mode: String? = nil) { - self.cmd = cmd - self.busid = busid - self.mode = mode + private let path: String + private let handler: UsbControlHandler + private let lock = NSLock() + private let maximumSessions: Int + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let beforeStaleEndpointUnlinkValidation: @Sendable () throws -> Void + private let afterListenerBindValidation: @Sendable () throws -> Void + private let log: @Sendable (String) -> Void + private var currentRun: UsbControlServerRun? + + public convenience init(path: String, handler: UsbControlHandler) { + self.init( + path: path, + handler: handler, + maximumSessions: Self.productionMaximumSessions, + frameTimeout: 5, + expectedPeerUID: geteuid(), + peerUIDResolver: UsbControlSocketIO.peerUID, + beforeStaleEndpointUnlinkValidation: {}, + afterListenerBindValidation: {}, + log: { NSLog("%@", $0) } + ) } -} -public struct UsbControlResponse: Codable, Equatable, Sendable { - public var ok: Bool - public var port: Int? - public var vsockPort: UInt32? - public var deviceID: UInt32? - public var speed: UInt32? - public var error: String? + init( + path: String, + handler: UsbControlHandler, + maximumSessions: Int, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + beforeStaleEndpointUnlinkValidation: @escaping @Sendable () throws -> Void = {}, + afterListenerBindValidation: @escaping @Sendable () throws -> Void = {}, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + precondition((1...Self.maximumConfiguredSessions).contains(maximumSessions)) + precondition( + frameTimeout.isFinite + && frameTimeout > 0 + && frameTimeout <= Self.maximumConfiguredTimeout + ) + self.path = path + self.handler = handler + self.maximumSessions = maximumSessions + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.beforeStaleEndpointUnlinkValidation = beforeStaleEndpointUnlinkValidation + self.afterListenerBindValidation = afterListenerBindValidation + self.log = log + } - public static func success(_ outcome: UsbAttachOutcome) -> UsbControlResponse { - UsbControlResponse(ok: true, port: outcome.port, vsockPort: outcome.vsockPort, deviceID: outcome.deviceID, speed: outcome.speed, error: nil) + public func start() throws { + lock.lock() + guard currentRun == nil else { + lock.unlock() + throw UsbControlServerError.alreadyStarted + } + let owned: UsbControlOwnedListener + do { + owned = try Self.makeOwnedListener( + path: path, + expectedUID: geteuid(), + beforeStaleEndpointUnlinkValidation: beforeStaleEndpointUnlinkValidation, + afterListenerBindValidation: afterListenerBindValidation + ) + } catch { + lock.unlock() + throw error + } + let run = UsbControlServerRun( + listener: owned, + maximumSessions: maximumSessions, + handler: handler, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + peerUIDResolver: peerUIDResolver, + log: log + ) + currentRun = run + lock.unlock() + Thread.detachNewThread { [path, log] in + Self.acceptLoop(run: run, path: path, log: log) + } } - public static func ok() -> UsbControlResponse { UsbControlResponse(ok: true, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: nil) } - public static func failure(_ message: String) -> UsbControlResponse { UsbControlResponse(ok: false, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: message) } -} + /// Stop never closes the listener or a client from the calling thread. It only issues shutdown + /// to wake their owner threads, then waits to the supplied absolute bound. + @discardableResult + public func stop(timeout: TimeInterval = 5) -> Bool { + lock.lock() + guard let run = currentRun else { + lock.unlock() + return true + } + lock.unlock() + run.requestStop() + let bounded = timeout.isFinite + ? min(max(0, timeout), Self.maximumConfiguredTimeout) + : 5 + let drained = run.waitUntilDrained(timeout: bounded) + if drained { + lock.lock() + if currentRun === run { currentRun = nil } + lock.unlock() + } else { + log("USB control server did not drain within \(bounded) seconds") + } + return drained + } -/// Codec for the newline-delimited JSON control protocol. Pure and unit-tested; the socket layer only -/// moves bytes. -public enum UsbControlCodec { - public static func encodeRequest(_ request: UsbControlRequest) throws -> Data { - var data = try JSONEncoder().encode(request) - data.append(0x0a) - return data + var activeSessionCount: Int { + lock.lock(); defer { lock.unlock() } + return currentRun?.activeSessionCount ?? 0 } - public static func decodeRequest(_ line: Data) throws -> UsbControlRequest { - try JSONDecoder().decode(UsbControlRequest.self, from: line) + var rejectedSessionCount: UInt64 { + lock.lock(); defer { lock.unlock() } + return currentRun?.rejectedSessionCount ?? 0 } - public static func encodeResponse(_ response: UsbControlResponse) throws -> Data { - var data = try JSONEncoder().encode(response) - data.append(0x0a) - return data + deinit { + _ = stop() } - public static func decodeResponse(_ line: Data) throws -> UsbControlResponse { - try JSONDecoder().decode(UsbControlResponse.self, from: line) + private static func acceptLoop( + run: UsbControlServerRun, + path: String, + log: @escaping @Sendable (String) -> Void + ) { + let descriptor = run.listener.descriptor + defer { + endpointMutationLock.lock() + retireEndpointIfOwned( + path: path, + identity: run.listener.identity, + parentIdentity: run.listener.parentIdentity + ) + // The listener worker is the sole close owner. The lifetime object serializes this + // close with stop's shutdown so a reused descriptor can never be targeted. + run.listenerOwnerDidFinish() + endpointMutationLock.unlock() + } + while true { + if run.isStopping { return } + var readiness = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + let result = poll(&readiness, 1, 100) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + if !run.isStopping { log("USB control listener poll failed: errno \(errno)") } + return + } + if readiness.revents & Int16(POLLNVAL) != 0 { + if !run.isStopping { log("USB control listener became invalid") } + return + } + if readiness.revents & Int16(POLLIN) == 0 { + if run.isStopping { return } + continue + } + while true { + let client = accept(descriptor, nil, nil) + if client < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { break } + if !run.isStopping { log("USB control accept failed: errno \(errno)") } + return + } + guard UsbControlSocketIO.configureOwnedSocket(client, nonBlocking: true) else { + let code = errno + close(client) + log("USB control accepted socket setup failed: errno \(code)") + continue + } + if !run.admit(client) { close(client) } + } + } } - public static func mode(from raw: String?) -> HostUsbOpenMode { - switch raw { - case "seize": return .seize - case "capture": return .capture - default: return .userAuthorized + private static func makeOwnedListener( + path: String, + expectedUID: uid_t, + beforeStaleEndpointUnlinkValidation: @Sendable () throws -> Void, + afterListenerBindValidation: @Sendable () throws -> Void + ) throws -> UsbControlOwnedListener { + try UsbControlSocketIO.validateAbsolutePath(path) + endpointMutationLock.lock() + defer { endpointMutationLock.unlock() } + let parent = try UsbControlSocketIO.privateParentIdentity( + forEndpointPath: path, + expectedUID: expectedUID + ) + + var existing = stat() + if lstat(path, &existing) == 0 { + guard existing.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), + existing.st_uid == expectedUID else { + throw UsbControlServerError.untrustedEndpoint(path) + } + let initialIdentity = UsbControlSocketIO.endpointIdentity(existing) + guard !UsbControlSocketIO.endpointAcceptsConnections(path) else { + throw UsbControlServerError.endpointInUse(path) + } + try beforeStaleEndpointUnlinkValidation() + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity, + UsbControlSocketIO.endpointIdentity(path) == initialIdentity else { + throw UsbControlServerError.untrustedEndpoint(path) + } + guard unlink(path) == 0 else { + throw UsbControlServerError.systemCall( + operation: "remove stale USB control socket", + code: errno + ) + } + } else if errno != ENOENT { + throw UsbControlServerError.systemCall( + operation: "inspect USB control socket", + code: errno + ) + } + + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw UsbControlServerError.systemCall(operation: "create USB control socket", code: errno) } + var publishedIdentity: UsbControlEndpointIdentity? + do { + guard UsbControlSocketIO.configureOwnedSocket(descriptor, nonBlocking: true) else { + throw UsbControlServerError.systemCall( + operation: "configure USB control listener", + code: errno + ) + } + var address = try UsbControlSocketIO.address(for: path) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bound == 0 else { + throw UsbControlServerError.systemCall(operation: "bind USB control socket", code: errno) + } + guard let identity = UsbControlSocketIO.endpointIdentity(path), + identity.owner == expectedUID else { + throw UsbControlServerError.untrustedEndpoint(path) + } + publishedIdentity = identity + try afterListenerBindValidation() + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity else { + throw UsbControlServerError.untrustedParentDirectory(parent.path) + } + guard chmod(path, 0o600) == 0 else { + throw UsbControlServerError.systemCall(operation: "chmod USB control socket", code: errno) + } + guard Darwin.listen(descriptor, Int32(maximumConfiguredSessions)) == 0 else { + throw UsbControlServerError.systemCall(operation: "listen on USB control socket", code: errno) + } + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity, + UsbControlSocketIO.endpointIdentity(path) == identity else { + throw UsbControlServerError.untrustedEndpoint(path) + } + return UsbControlOwnedListener( + descriptor: descriptor, + identity: identity, + parentIdentity: parent.identity + ) + } catch { + if let publishedIdentity { + retireEndpointIfOwned( + path: path, + identity: publishedIdentity, + parentIdentity: parent.identity + ) + } + close(descriptor) + throw error + } + } + + private static func retireEndpointIfOwned( + path: String, + identity: UsbControlEndpointIdentity, + parentIdentity: UsbControlDirectoryIdentity + ) { + let parentPath = (path as NSString).deletingLastPathComponent + guard UsbControlSocketIO.privateDirectoryIdentity(parentPath) == parentIdentity, + UsbControlSocketIO.endpointIdentity(path) == identity else { return } + _ = unlink(path) } } -/// Serves the `dory usb attach/detach` control protocol on a unix socket in the engine process (which -/// owns the device claim, the UsbipManager, and the guest agent channel). One request per connection. -public final class UsbControlServer: @unchecked Sendable { - private let path: String - private let handler: UsbControlHandler - private let queue = DispatchQueue(label: "dory.usb.control") +public enum UsbControlServerError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidPath(String) + case untrustedParentDirectory(String) + case untrustedEndpoint(String) + case endpointInUse(String) + case alreadyStarted + case frameTooLarge(limit: Int) + case incompleteFrame + case unexpectedTrailingBytes + case timedOut(operation: String) + case peerIdentity(expected: uid_t, actual: uid_t?) + case systemCall(operation: String, code: Int32) + + public var description: String { + switch self { + case .invalidPath(let path): return "invalid absolute USB control socket path: \(path)" + case .untrustedParentDirectory(let path): + return "USB control socket parent is not an owner-private stable directory: \(path)" + case .untrustedEndpoint(let path): return "refusing untrusted USB control endpoint: \(path)" + case .endpointInUse(let path): return "USB control endpoint is already active: \(path)" + case .alreadyStarted: return "USB control server is already started" + case .frameTooLarge(let limit): return "USB control frame exceeds \(limit) bytes" + case .incompleteFrame: return "USB control frame ended without a newline" + case .unexpectedTrailingBytes: return "USB control frame contains bytes after its newline" + case .timedOut(let operation): return "USB control \(operation) timed out" + case let .peerIdentity(expected, actual): + return "USB control peer UID mismatch (expected \(expected), got \(actual.map(String.init) ?? "unknown"))" + case let .systemCall(operation, code): + return "\(operation) failed: errno \(code) (\(String(cString: strerror(code))))" + } + } +} + +private struct UsbControlEndpointIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthSeconds: Int64 + let birthNanoseconds: Int64 + let owner: uid_t +} + +private struct UsbControlDirectoryIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthSeconds: Int64 + let birthNanoseconds: Int64 + let owner: uid_t + let permissions: mode_t +} + +private struct UsbControlOwnedListener: Sendable { + let descriptor: Int32 + let identity: UsbControlEndpointIdentity + let parentIdentity: UsbControlDirectoryIdentity +} + +/// Serializes cancellation shutdown with the sole owner's final close. The descriptor value is +/// never used outside this lease for either operation, preventing shutdown-after-close FD reuse. +final class UsbControlDescriptorLifetime: @unchecked Sendable { + typealias DescriptorOperation = @Sendable (Int32) -> Void + private let lock = NSLock() - private var listenFD: Int32 = -1 - private var boundIdentity: (device: dev_t, inode: ino_t)? + private var descriptor: Int32? + private let shutdownOperation: DescriptorOperation + private let closeOperation: DescriptorOperation - public init(path: String, handler: UsbControlHandler) { - self.path = path - self.handler = handler + init( + descriptor: Int32, + shutdownOperation: @escaping DescriptorOperation = { + _ = Darwin.shutdown($0, SHUT_RDWR) + }, + closeOperation: @escaping DescriptorOperation = { _ = Darwin.close($0) } + ) { + self.descriptor = descriptor + self.shutdownOperation = shutdownOperation + self.closeOperation = closeOperation } - public func start() throws { + /// A worker may borrow the stable value while it remains the sole close owner. + var borrowedDescriptor: Int32? { + lock.lock(); defer { lock.unlock() } + return descriptor + } + + func requestShutdown() { lock.lock() - defer { lock.unlock() } - guard listenFD < 0 else { + if let descriptor { shutdownOperation(descriptor) } + lock.unlock() + } + + func closeByOwner() { + lock.lock() + guard let descriptor else { + lock.unlock() return } - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let capacity = withUnsafeBytes(of: &address.sun_path) { $0.count } - guard path.first == "/", path.utf8.count < capacity else { - throw UsbControlServerError.socket("invalid unix socket path") - } - var stale = stat() - if lstat(path, &stale) == 0 { - guard stale.st_mode & S_IFMT == S_IFSOCK, - stale.st_uid == geteuid(), - unlink(path) == 0 else { - throw UsbControlServerError.socket("refusing to replace untrusted path \(path)") - } - } else if errno != ENOENT { - throw UsbControlServerError.socket("lstat \(path): errno \(errno)") - } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } - Self.copyPath(path, into: &address) - let bindResult = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard bindResult == 0 else { close(fd); throw UsbControlServerError.socket("bind \(path): errno \(errno)") } - guard chmod(path, 0o600) == 0 else { - close(fd) - unlink(path) - throw UsbControlServerError.socket("chmod \(path): errno \(errno)") - } - guard listen(fd, 8) == 0 else { - close(fd) - unlink(path) - throw UsbControlServerError.socket("listen: errno \(errno)") - } - var bound = stat() - guard lstat(path, &bound) == 0, - bound.st_mode & S_IFMT == S_IFSOCK, - bound.st_uid == geteuid() else { - close(fd) - unlink(path) - throw UsbControlServerError.socket("could not bind trusted socket \(path)") - } - listenFD = fd - boundIdentity = (bound.st_dev, bound.st_ino) - queue.async { [weak self] in self?.acceptLoop(listenFD: fd) } - } - - public func stop() { - lock.lock() - let descriptor = listenFD - listenFD = -1 - let identity = boundIdentity - boundIdentity = nil + self.descriptor = nil + closeOperation(descriptor) lock.unlock() - if descriptor >= 0 { close(descriptor) } - guard let identity else { return } - var current = stat() - if lstat(path, ¤t) == 0, - current.st_dev == identity.device, - current.st_ino == identity.inode { - unlink(path) - } } +} - private func acceptLoop(listenFD: Int32) { - while true { - let client = accept(listenFD, nil, nil) - guard client >= 0 else { return } - handleClient(client) +private final class UsbControlServerRun: @unchecked Sendable { + let listener: UsbControlOwnedListener + + private let condition = NSCondition() + private let maximumSessions: Int + private let handler: UsbControlHandler + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let log: @Sendable (String) -> Void + private let listenerDescriptorLifetime: UsbControlDescriptorLifetime + private var sessions: [UUID: UsbControlServerSession] = [:] + private var stopping = false + private var listenerFinished = false + private var rejectedSessions: UInt64 = 0 + + init( + listener: UsbControlOwnedListener, + maximumSessions: Int, + handler: UsbControlHandler, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + log: @escaping @Sendable (String) -> Void + ) { + self.listener = listener + self.maximumSessions = maximumSessions + self.handler = handler + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.log = log + self.listenerDescriptorLifetime = UsbControlDescriptorLifetime( + descriptor: listener.descriptor + ) + } + + var isStopping: Bool { + condition.lock(); defer { condition.unlock() } + return stopping + } + + var activeSessionCount: Int { + condition.lock(); defer { condition.unlock() } + return sessions.count + } + + var rejectedSessionCount: UInt64 { + condition.lock(); defer { condition.unlock() } + return rejectedSessions + } + + /// Returns false without taking ownership; the listener thread must close rejected descriptors. + func admit(_ descriptor: Int32) -> Bool { + condition.lock() + guard !stopping, sessions.count < maximumSessions else { + if rejectedSessions < UInt64.max { rejectedSessions += 1 } + condition.unlock() + return false } + let token = UUID() + let session = UsbControlServerSession( + descriptor: descriptor, + handler: handler, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + peerUIDResolver: peerUIDResolver, + log: log, + completion: { [weak self] in self?.sessionFinished(token) } + ) + sessions[token] = session + condition.unlock() + Thread.detachNewThread { session.run() } + return true } - private func handleClient(_ fd: Int32) { - defer { close(fd) } - guard let line = Self.readLine(fd) else { return } - let response: UsbControlResponse - if let request = try? UsbControlCodec.decodeRequest(line) { - response = runHandler(request) - } else { - response = .failure("malformed control request") + func requestStop() { + condition.lock() + if stopping { + condition.unlock() + return } - if let data = try? UsbControlCodec.encodeResponse(response) { - _ = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + stopping = true + let active = Array(sessions.values) + condition.broadcast() + condition.unlock() + + // Keep descriptors allocated; shutdown only wakes the threads that remain sole close owners. + listenerDescriptorLifetime.requestShutdown() + for session in active { session.requestStop() } + } + + func listenerOwnerDidFinish() { + condition.lock() + listenerDescriptorLifetime.closeByOwner() + listenerFinished = true + condition.broadcast() + condition.unlock() + } + + func waitUntilDrained(timeout: TimeInterval) -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + max(0, timeout) + condition.lock() + defer { condition.unlock() } + while !listenerFinished || !sessions.isEmpty { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return false } + _ = condition.wait(until: Date().addingTimeInterval(min(remaining, 0.05))) } + return true + } + + private func sessionFinished(_ token: UUID) { + condition.lock() + sessions.removeValue(forKey: token) + condition.broadcast() + condition.unlock() + } +} + +private final class UsbControlServerSession: @unchecked Sendable { + private let lock = NSLock() + private let handler: UsbControlHandler + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let log: @Sendable (String) -> Void + private let descriptorLifetime: UsbControlDescriptorLifetime + private var completion: (@Sendable () -> Void)? + private var started = false + private var stopRequested = false + private var finished = false + + init( + descriptor: Int32, + handler: UsbControlHandler, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + log: @escaping @Sendable (String) -> Void, + completion: @escaping @Sendable () -> Void + ) { + self.descriptorLifetime = UsbControlDescriptorLifetime(descriptor: descriptor) + self.handler = handler + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.log = log + self.completion = completion } - private func runHandler(_ request: UsbControlRequest) -> UsbControlResponse { - let semaphore = DispatchSemaphore(value: 0) - let box = ResultBox() + func run() { + lock.lock() + guard !started, let descriptor = descriptorLifetime.borrowedDescriptor else { + lock.unlock() + return + } + started = true + let shouldRun = !stopRequested + lock.unlock() + guard shouldRun else { + finish() + return + } + defer { finish() } + + let actualUID = peerUIDResolver(descriptor) + guard actualUID == expectedPeerUID else { + log(UsbControlServerError.peerIdentity(expected: expectedPeerUID, actual: actualUID).description) + return + } + + let request: DoryUSBControlV1.Request + do { + let deadline = ProcessInfo.processInfo.systemUptime + frameTimeout + let frame = try UsbControlSocketIO.readFrame( + descriptor: descriptor, + deadline: deadline + ) + request = try DoryUSBControlV1.decodeRequest(frame) + } catch { + guard !isStopping else { return } + writeFailure( + "malformed control request: \(error)", + disposition: .rejected, + descriptor: descriptor + ) + return + } + + let result = UsbControlAsyncResult() let handler = self.handler - Task { - let response: UsbControlResponse + // Reserve the mutation while holding the same lock used by requestStop. If stop wins first, + // no new hardware/RPC operation starts; if this reservation wins, stop continues tracking + // the session until the exact result arrives. + lock.lock() + guard !stopRequested, !finished else { + lock.unlock() + return + } + let operation = Task { + let response: DoryUSBControlV1.Response do { - switch request.cmd { - case "attach": - let outcome = try await handler.attach(busID: request.busid, mode: UsbControlCodec.mode(from: request.mode)) - response = .success(outcome) - case "detach": - try await handler.detach(busID: request.busid) - response = .ok() - default: - response = .failure("unknown command \(request.cmd)") + switch request { + case let .attach(busID, mode): + response = .attachSuccess( + try await handler.attach( + busID: busID.rawValue, + mode: Self.hostOpenMode(mode) + ) + ) + case let .detach(busID): + try await handler.detach(busID: busID.rawValue) + response = .detachSuccess } } catch { - response = .failure("\(error)") + let disposition = (error as? UsbControlError)?.failureDisposition ?? .rejected + response = .failure( + disposition: disposition, + error: DoryUSBControlV1.sanitizedFailureMessage(String(describing: error)) + ) } - box.value = response - semaphore.signal() + result.complete(response) + } + lock.unlock() + + // Deliberately no server-side operation timeout: the injected guest/hardware operations are + // not generally cancellable. The session remains tracked until their exact result arrives. + let response = withExtendedLifetime(operation) { result.wait() } + lock.lock() + let shouldReply = !stopRequested + lock.unlock() + guard shouldReply else { return } + do { + var payload = try DoryUSBControlV1.encodeResponse(response) + payload.append(0x0a) + try UsbControlSocketIO.writeAll( + descriptor: descriptor, + bytes: payload, + deadline: ProcessInfo.processInfo.systemUptime + frameTimeout + ) + } catch { + if !isStopping { log("USB control response write failed: \(error)") } + } + } + + func requestStop() { + lock.lock() + guard !stopRequested, !finished else { + lock.unlock() + return } - semaphore.wait() // one control request per connection; the accept loop serializes them - return box.value ?? .failure("no result") + stopRequested = true + lock.unlock() + descriptorLifetime.requestShutdown() } - private final class ResultBox: @unchecked Sendable { - var value: UsbControlResponse? + private var isStopping: Bool { + lock.lock(); defer { lock.unlock() } + return stopRequested || finished } - private static func readLine(_ fd: Int32) -> Data? { - var data = Data() - var byte: UInt8 = 0 - while data.count < 8192 { - let n = Darwin.read(fd, &byte, 1) - guard n == 1 else { return data.isEmpty ? nil : data } - if byte == 0x0a { return data } - data.append(byte) + private func writeFailure( + _ message: String, + disposition: DoryUSBControlV1.FailureDisposition, + descriptor: Int32 + ) { + do { + var payload = try DoryUSBControlV1.encodeResponse(.failure( + disposition: disposition, + error: DoryUSBControlV1.sanitizedFailureMessage(message) + )) + payload.append(0x0a) + try UsbControlSocketIO.writeAll( + descriptor: descriptor, + bytes: payload, + deadline: ProcessInfo.processInfo.systemUptime + frameTimeout + ) + } catch { + log("USB control error response write failed: \(error)") } - return data } - private static func copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + private static func hostOpenMode(_ mode: DoryUSBControlV1.OpenMode) -> HostUsbOpenMode { + switch mode { + case .userAuthorized: .userAuthorized + case .seize: .seize + case .capture: .capture } } -} -public enum UsbControlServerError: Error, Equatable, Sendable { - case socket(String) + private func finish() { + let callback: (@Sendable () -> Void)? + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + callback = completion + completion = nil + descriptorLifetime.closeByOwner() + lock.unlock() + callback?() + } } -/// The guest agent's usb.attach/usb.detach reply. We only need to know the call succeeded, so the -/// fields (`attached`/`detached`, `busid`, `port`) are optional and unused. -public struct UsbAgentReply: Decodable, Sendable { - public var busid: String? - public var port: Int? +private final class UsbControlAsyncResult: @unchecked Sendable { + private let condition = NSCondition() + private var response: DoryUSBControlV1.Response? + + func complete(_ response: DoryUSBControlV1.Response) { + condition.lock() + guard self.response == nil else { + condition.unlock() + return + } + self.response = response + condition.broadcast() + condition.unlock() + } + + func wait() -> DoryUSBControlV1.Response { + condition.lock() + while true { + if let response { + condition.unlock() + return response + } + condition.wait() + } + } } -/// Client used by `dory-hv usb attach/detach`: connect to the engine's control socket, send one -/// request, read one response. -public enum UsbControlClient { - public static func send(_ request: UsbControlRequest, socketPath: String) throws -> UsbControlResponse { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } - defer { close(fd) } +enum UsbControlSocketIO { + static let maximumFrameBytes = DoryUSBControlV1.maximumFrameBytes + typealias ReadOperation = (Int32, UnsafeMutableRawPointer?, Int) -> Int + typealias WriteOperation = (Int32, UnsafeRawPointer?, Int) -> Int + + static var maximumSocketPathBytes: Int { + let address = sockaddr_un() + return MemoryLayout.size(ofValue: address.sun_path) - 1 + } + + static func validateAbsolutePath(_ path: String) throws { + let bytes = Array(path.utf8) + let standardized = (path as NSString).standardizingPath + guard path.hasPrefix("/"), path == standardized, + (path as NSString).lastPathComponent.isEmpty == false, + !bytes.isEmpty, !bytes.contains(0), + bytes.count <= maximumSocketPathBytes else { + throw UsbControlServerError.invalidPath(path) + } + } + + fileprivate static func privateParentIdentity( + forEndpointPath path: String, + expectedUID: uid_t + ) throws -> (path: String, identity: UsbControlDirectoryIdentity) { + let parentPath = (path as NSString).deletingLastPathComponent + guard let identity = privateDirectoryIdentity(parentPath), + identity.owner == expectedUID, + identity.permissions & 0o077 == 0, + identity.permissions & 0o300 == 0o300 else { + throw UsbControlServerError.untrustedParentDirectory(parentPath) + } + return (parentPath, identity) + } + + static func address(for path: String) throws -> sockaddr_un { + try validateAbsolutePath(path) var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) - UsbControlServer_copyPath(socketPath, into: &address) - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard connected == 0 else { throw UsbControlServerError.socket("connect \(socketPath): errno \(errno) (is the engine running?)") } - let payload = try UsbControlCodec.encodeRequest(request) - _ = payload.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } - var response = Data() - var byte: UInt8 = 0 - while response.count < 8192 { - let n = Darwin.read(fd, &byte, 1) - guard n == 1 else { break } - if byte == 0x0a { break } - response.append(byte) - } - return try UsbControlCodec.decodeResponse(response) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + return address + } + + static func configureOwnedSocket(_ descriptor: Int32, nonBlocking: Bool) -> Bool { + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + return false + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { return false } + guard nonBlocking else { return true } + let statusFlags = fcntl(descriptor, F_GETFL) + return statusFlags >= 0 + && fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 + } + + static func peerUID(_ descriptor: Int32) -> uid_t? { + var uid: uid_t = 0 + var gid: gid_t = 0 + return getpeereid(descriptor, &uid, &gid) == 0 ? uid : nil + } + + fileprivate static func endpointIdentity(_ path: String) -> UsbControlEndpointIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK) else { return nil } + return endpointIdentity(info) } -} -private func UsbControlServer_copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + fileprivate static func endpointIdentity(_ info: stat) -> UsbControlEndpointIdentity { + return UsbControlEndpointIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthSeconds: Int64(info.st_birthtimespec.tv_sec), + birthNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid + ) + } + + fileprivate static func privateDirectoryIdentity( + _ path: String + ) -> UsbControlDirectoryIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return UsbControlDirectoryIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthSeconds: Int64(info.st_birthtimespec.tv_sec), + birthNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid, + permissions: info.st_mode & 0o777 + ) + } + + /// Fail safe: only ECONNREFUSED/ENOENT proves an owner-private socket is stale enough to remove. + static func endpointAcceptsConnections(_ path: String) -> Bool { + guard let address = try? address(for: path) else { return true } + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return true } + defer { close(descriptor) } + guard configureOwnedSocket(descriptor, nonBlocking: true) else { return true } + var mutableAddress = address + let result = withUnsafePointer(to: &mutableAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { return true } + let code = errno + if code == ECONNREFUSED || code == ENOENT { return false } + guard code == EINPROGRESS || code == EAGAIN else { return true } + let deadline = ProcessInfo.processInfo.systemUptime + 0.1 + do { + try wait( + descriptor: descriptor, + events: Int16(POLLOUT), + deadline: deadline, + operation: "probe endpoint" + ) + } catch { + return true + } + var socketError: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &length + ) == 0 else { return true } + return socketError != ECONNREFUSED && socketError != ENOENT + } + + static func readFrame( + descriptor: Int32, + deadline: TimeInterval + ) throws -> Data { + try readFrame( + descriptor: descriptor, + deadline: deadline, + readOperation: { Darwin.read($0, $1, $2) } + ) + } + + static func readFrame( + descriptor: Int32, + deadline: TimeInterval, + readOperation: ReadOperation + ) throws -> Data { + var frame = Data() + var buffer = [UInt8](repeating: 0, count: 4 * 1024) + while true { + try requireTimeRemaining(deadline: deadline, operation: "request frame") + let remainingCapacity = maximumFrameBytes - frame.count + 1 + guard remainingCapacity > 0 else { + throw UsbControlServerError.frameTooLarge(limit: maximumFrameBytes) + } + let requested = min(buffer.count, remainingCapacity) + let count = buffer.withUnsafeMutableBytes { + readOperation(descriptor, $0.baseAddress, requested) + } + try requireTimeRemaining(deadline: deadline, operation: "request frame") + if count > 0 { + guard count <= requested else { + throw UsbControlServerError.systemCall(operation: "read USB control frame", code: EIO) + } + let bytes = buffer[0.. 0 { + guard count <= buffer.count - offset else { + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: EIO + ) + } + offset += count + continue + } + if count == 0 { + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: EPIPE + ) + } + let code = errno + if code == EINTR { continue } + if code == EAGAIN || code == EWOULDBLOCK { + try wait( + descriptor: descriptor, + events: Int16(POLLOUT), + deadline: deadline, + operation: "response frame" + ) + continue + } + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: code + ) + } + } + } + + static func wait( + descriptor: Int32, + events: Int16, + deadline: TimeInterval, + operation: String + ) throws { + while true { + let remaining = try requireTimeRemaining(deadline: deadline, operation: operation) + var readiness = pollfd(fd: descriptor, events: events, revents: 0) + let milliseconds = max(1, min(50, Int32(ceil(remaining * 1_000)))) + let result = poll(&readiness, 1, milliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + throw UsbControlServerError.systemCall(operation: "poll USB control socket", code: errno) + } + if readiness.revents & Int16(POLLNVAL) != 0 { + throw UsbControlServerError.systemCall(operation: "poll USB control socket", code: EBADF) + } + return + } + } + + @discardableResult + private static func requireTimeRemaining( + deadline: TimeInterval, + operation: String + ) throws -> TimeInterval { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + throw UsbControlServerError.timedOut(operation: operation) + } + return remaining } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift index 07fb6201..64119029 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift @@ -6,100 +6,345 @@ import Foundation enum UsbipCommandFraming { static let fixedHeaderByteCount = UsbipSubmitCommand.headerByteCount - static func outPayloadLength(_ header: [UInt8]) -> Int { - guard header.count >= fixedHeaderByteCount else { return 0 } - func be32(_ offset: Int) -> UInt32 { - (UInt32(header[offset]) << 24) | (UInt32(header[offset + 1]) << 16) - | (UInt32(header[offset + 2]) << 8) | UInt32(header[offset + 3]) + enum Frame: Equatable { + case submit(UsbipSubmitCommand.HeaderMetadata) + case unlink + } + + static func inspect(_ header: [UInt8]) throws -> Frame { + guard header.count == fixedHeaderByteCount else { + if header.count < fixedHeaderByteCount { throw UsbipProtocolError.shortFrame } + throw UsbipProtocolError.invalidFrameLength(expected: fixedHeaderByteCount, actual: header.count) + } + let basic = try UsbipHeaderBasic(decoding: header) + switch basic.command { + case .cmdSubmit: + return .submit(try UsbipSubmitCommand.inspectHeader(header)) + case .cmdUnlink: + _ = try UsbipUnlinkCommand(decoding: header) + return .unlink + case .retSubmit, .retUnlink: + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: basic.command) } - guard be32(0) == UsbipOperation.cmdSubmit.rawValue, - be32(12) == UsbipDirection.out.rawValue else { return 0 } - return Int(min(be32(24), UsbipSubmitCommand.maxTransferBytes)) } } /// Bridges one guest usbip vsock connection to one claimed host USB device. The guest agent dials /// `VsockPorts.usbip` and performs the OP_REQ_IMPORT handshake; this bridge answers via `UsbipServer`, /// then pumps USBIP_CMD_SUBMIT/UNLINK frames to the device and writes the replies back — until the -/// guest closes the connection (`isPeerClosed`), at which point `onClose` fires so the engine releases -/// the device. The serve loop runs on its own queue, never the vsock dispatch queue, because a host +/// guest closes the connection (`isPeerClosed`), at which point `onClose` fires so the manager releases +/// the connection and any import lease it owns. The serve loop runs on its own queue, never the vsock +/// dispatch queue, because a host /// device submit blocks on the transfer completing. public final class UsbipBridge: @unchecked Sendable { + private static let maximumHandshakeTimeout: TimeInterval = 30 + private static let maximumWriteTimeoutNanoseconds: UInt64 = 30_000_000_000 + private let connection: VsockConnection private let server: UsbipServer - private let onClose: () -> Void + private let stateLock = NSLock() private let queue: DispatchQueue + private let context = UsbipRequestContext() + private let handshakeTimeout: TimeInterval + private let writeTimeoutNanoseconds: UInt64 + private let authorizeImport: @Sendable (String) -> Bool + private let log: @Sendable (String) -> Void + private var onClose: (@Sendable () -> Void)? + private var started = false + private var stopRequested = false + private var finished = false /// Backed by a server that may export several claimed devices; the busID is read from the guest's /// OP_REQ_IMPORT frame, so one listener can serve whichever device the guest asked for. - public init(connection: VsockConnection, server: UsbipServer, label: String = "shared", onClose: @escaping () -> Void = {}) { + public init( + connection: VsockConnection, + server: UsbipServer, + label: String = "shared", + handshakeTimeout: TimeInterval = 5, + writeTimeoutNanoseconds: UInt64 = 5_000_000_000, + authorizeImport: @escaping @Sendable (String) -> Bool, + log: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) }, + onClose: @escaping @Sendable () -> Void = {} + ) { + precondition( + handshakeTimeout.isFinite + && handshakeTimeout > 0 + && handshakeTimeout <= Self.maximumHandshakeTimeout + ) + precondition( + writeTimeoutNanoseconds > 0 + && writeTimeoutNanoseconds <= Self.maximumWriteTimeoutNanoseconds + ) self.connection = connection self.server = server self.onClose = onClose + self.handshakeTimeout = handshakeTimeout + self.writeTimeoutNanoseconds = writeTimeoutNanoseconds + self.authorizeImport = authorizeImport + self.log = log self.queue = DispatchQueue(label: "dory.usbip.bridge.\(label)") } /// Single-device convenience. - public convenience init(connection: VsockConnection, device: any UsbipExportedDevice, onClose: @escaping () -> Void = {}) { - self.init(connection: connection, server: UsbipServer(devices: [device]), label: device.descriptor.busID, onClose: onClose) + public convenience init( + connection: VsockConnection, + device: any UsbipExportedDevice, + handshakeTimeout: TimeInterval = 5, + log: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) }, + onClose: @escaping @Sendable () -> Void = {} + ) { + let ownedBusID = device.descriptor.busID + self.init( + connection: connection, + server: UsbipServer(devices: [device]), + label: ownedBusID, + handshakeTimeout: handshakeTimeout, + authorizeImport: { $0 == ownedBusID }, + log: log, + onClose: onClose + ) } public func start() { - queue.async { self.serve() } + guard claimRun() else { return } + queue.async { self.runClaimed() } + } + + /// Wakes an idle import/command read. `VsockConnection.close` is idempotent and is the only + /// cancellation primitive needed; `finish` remains the sole completion owner. + public func requestStop() { + stateLock.lock() + guard !stopRequested, !finished else { + stateLock.unlock() + return + } + stopRequested = true + let finishWithoutRun = !started + stateLock.unlock() + connection.close() + if finishWithoutRun { finish(importedBusID: nil) } } /// Runs the serve loop synchronously; returns when the connection ends. Exposed for the loopback /// integration test to drive the bridge without a real queue/thread. public func serve() { - defer { - connection.close() - onClose() + guard claimRun() else { return } + runClaimed() + } + + private func claimRun() -> Bool { + stateLock.lock() + guard !started, !finished else { + stateLock.unlock() + return false } - guard let importFrame = readExact(UsbipImportRequest.byteCount), - let busID = (try? UsbipImportRequest(decoding: importFrame))?.busID, - let importReply = try? server.handleImport(importFrame) else { return } - write(importReply) + started = true + let canRun = !stopRequested + stateLock.unlock() + if !canRun { finish(importedBusID: nil) } + return canRun + } + + private func runClaimed() { + var importedBusID: String? + defer { finish(importedBusID: importedBusID) } + let importDeadline = ProcessInfo.processInfo.systemUptime + handshakeTimeout + guard let importFrame = readExact( + UsbipImportRequest.byteCount, + deadline: importDeadline + ) else { return } + let busID: String + let importReply: [UInt8] + do { + busID = try UsbipImportRequest(decoding: importFrame).busID + } catch { + log("USB/IP import request rejected: \(error)") + return + } + guard authorizeImport(busID) else { + log("USB/IP import authorization expired for \(busID)") + _ = write(UsbipImportReply(status: 1, device: nil).encoded()) + return + } + do { + importReply = try server.handleImport(importFrame) + } catch { + log("USB/IP import request rejected: \(error)") + return + } + guard write(importReply) else { return } + do { + guard try UsbipOperationHeader(decoding: importReply).status == 0 else { return } + } catch { + log("USB/IP generated an invalid import reply: \(error)") + return + } + importedBusID = busID while true { - guard let header = readExact(UsbipCommandFraming.fixedHeaderByteCount) else { return } + guard let header = readExact( + UsbipCommandFraming.fixedHeaderByteCount, + deadline: nil + ) else { return } + let framing: UsbipCommandFraming.Frame + do { + framing = try UsbipCommandFraming.inspect(header) + } catch { + log("USB/IP command header rejected: \(error)") + return + } var frame = header - let extra = UsbipCommandFraming.outPayloadLength(header) - if extra > 0 { - guard let payload = readExact(extra) else { return } - frame += payload + switch framing { + case .submit(let metadata): + if metadata.isIsochronous { + // The fixed header is sufficient to reject isochronous URBs. Close afterwards: + // an OUT payload may already be on the stream and must never be reinterpreted + // as another command header. + let reply: [UInt8] + do { + reply = try server.handleURB(header, busID: busID, context: context) + } catch { + log("USB/IP isochronous rejection failed: \(error)") + return + } + _ = write(reply) + return + } + if metadata.outPayloadByteCount > 0 { + let totalCount = UsbipCommandFraming.fixedHeaderByteCount + + metadata.outPayloadByteCount + guard totalCount <= UsbipCommandFraming.fixedHeaderByteCount + + Int(UsbipSubmitCommand.maxTransferBytes) else { return } + frame = [UInt8](repeating: 0, count: totalCount) + frame.replaceSubrange(0.. Bool { + do { + try connection.write(bytes, timeoutNanoseconds: writeTimeoutNanoseconds) + return true + } catch { + if !shouldStop { log("USB/IP response write failed: \(error)") } + return false + } } /// Reads exactly `count` bytes, polling the non-blocking vsock connection with backoff. Returns /// nil on EOF (the peer closed with fewer than `count` bytes remaining). "No data yet" is a wait, /// not an error, so an idle device is never torn down — only a real peer close ends the loop. - private func readExact(_ count: Int) -> [UInt8]? { - guard count > 0 else { return [] } - var result = [UInt8]() - result.reserveCapacity(count) - var buffer = [UInt8](repeating: 0, count: count) - var pollInterval: useconds_t = 1_000 - let maxPollInterval: useconds_t = 16_000 - while result.count < count { - let read = (try? buffer.withUnsafeMutableBytes { - try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0..<(count - result.count)])) - }) ?? 0 - if read == 0 { - if connection.isPeerClosed { return nil } - usleep(pollInterval) - pollInterval = min(pollInterval * 2, maxPollInterval) + private func readExact(_ count: Int, deadline: TimeInterval?) -> [UInt8]? { + guard count >= 0, + count <= UsbipCommandFraming.fixedHeaderByteCount + + Int(UsbipSubmitCommand.maxTransferBytes) else { + log("USB/IP refused an out-of-range frame allocation of \(count) bytes") + return nil + } + var result = [UInt8](repeating: 0, count: count) + guard fillExact(&result, range: 0.., + deadline: TimeInterval? + ) -> Bool { + guard range.lowerBound >= 0, + range.upperBound <= bytes.count else { return false } + var offset = range.lowerBound + while offset < range.upperBound { + if shouldStop { return false } + if let deadline, + ProcessInfo.processInfo.systemUptime >= deadline { + log("USB/IP import handshake timed out") + return false + } + let count: Int + do { + count = try bytes.withUnsafeMutableBytes { buffer in + try connection.read( + into: UnsafeMutableRawBufferPointer( + rebasing: buffer[offset..= deadline { + log("USB/IP import handshake timed out") + return false + } + guard count >= 0, count <= range.upperBound - offset else { + log("USB/IP stream returned an invalid read length of \(count)") + return false + } + if count > 0 { + offset += count continue } - result.append(contentsOf: buffer.prefix(read)) - pollInterval = 1_000 + if connection.isPeerClosed { return false } + let timeoutNanoseconds: UInt64? + if let deadline { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + log("USB/IP import handshake timed out") + return false + } + timeoutNanoseconds = UInt64(min(remaining, 0.05) * 1_000_000_000) + } else { + timeoutNanoseconds = 50_000_000 + } + _ = connection.waitForReadable(timeoutNanoseconds: timeoutNanoseconds) } - return result + return true + } + + private var shouldStop: Bool { + stateLock.lock(); defer { stateLock.unlock() } + return stopRequested || finished + } + + private func finish(importedBusID: String?) { + let completion: (@Sendable () -> Void)? + stateLock.lock() + guard !finished else { + stateLock.unlock() + return + } + finished = true + completion = onClose + onClose = nil + stateLock.unlock() + + server.closeSession(context, busID: importedBusID) + connection.close() + completion?() + } + + deinit { + requestStop() } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift index c1c0b910..96aee631 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift @@ -1,54 +1,780 @@ +import DoryVMContracts import Foundation -/// Owns the engine's usbip listener and the set of currently-claimed host devices. `dory usb attach` -/// claims a device (via the control plane) and `register`s it here; the guest agent then dials -/// `VsockPorts.usbip`, and the accepted connection is served by a `UsbipBridge` backed by whatever -/// devices are registered — the guest's OP_REQ_IMPORT busID selects which one. Detach `unregister`s it. +public enum UsbipManagerError: Error, Equatable, Sendable { + case duplicateBusID(String) + case deviceCapacityReached(limit: Int) + case listenerAlreadyAttached + case invalidControlMutationLease + case controlMutationBusIDMismatch(expected: String, actual: String) + case claimLeaseMismatch(String) + case claimNotRegistered(String) + case stopped +} + +/// Result of crossing the VM-owner's terminal guest-execution boundary. Once the guest can no +/// longer execute, an uncertain vhci result no longer requires a guest detach RPC: destroying the +/// VM has established the detached guest state. If a host-side mutation is still admitted, the +/// manager retains itself and every claim until that mutation drains and terminal retirement runs. +public enum UsbipManagerGuestTerminationOutcome: Equatable, Sendable { + case completed + case authorityRetained(retainedClaimBusIDs: [String]) +} + +/// Exact authority for one host-device/guest-agent mutation. It binds the operation and bus ID as +/// well as the manager generation, so a lease can neither mutate another physical claim nor turn a +/// post-stop detach reconciliation into a new attach admission. +struct UsbipManagerControlMutationLease: Sendable, Equatable { + fileprivate let id: UUID + fileprivate let operation: DoryUSBControlV1.Operation + fileprivate let busID: String + fileprivate let authority: Authority + + fileprivate enum Authority: Sendable, Equatable { + case active(lifecycleGeneration: UUID, claimGeneration: UUID?) + case reconciliation(claimGeneration: UUID) + } +} + +/// Owns one guest-initiated USB/IP listener, every admitted bridge, and every claimed host device. +/// Listener registration is one-shot: replacing it in place would make teardown generation-unsafe. public final class UsbipManager: @unchecked Sendable { + private enum ListenerState { + case idle + case attaching + case attached(VirtioVsockListenerRegistration) + case stopped + } + + private enum ControlLifecycle { + case active + /// New mutations are closed while stop waits every mutation admitted by the prior epoch. + case quiescing + /// The data path is stopped. Only detach reconciliation for an explicitly uncertain claim + /// may be admitted. + case quiesced + } + + private enum ClaimDisposition: Equatable { + case provisional(attachMutationID: UUID) + case stable + case uncertain + } + + private struct DeviceRecord { + let device: any UsbipExportedDevice + let generation: UUID + var disposition: ClaimDisposition + } + + private enum BridgeAuthorization { + case awaitingImport + case imported(busID: String, deviceGeneration: UUID) + case invalidated + } + + private struct BridgeRecord { + let snapshotGenerations: [String: UUID] + let completion: DispatchGroup + var bridge: UsbipBridge? + var authorization: BridgeAuthorization + } + + private struct DeviceRetirement { + let busID: String + let record: DeviceRecord + let affectedBridges: [(bridge: UsbipBridge?, completion: DispatchGroup)] + } + private let lock = NSLock() - private var devices: [String: any UsbipExportedDevice] = [:] + private let stopLock = NSLock() + private let listenerAttachmentCompletion = DispatchGroup() + private let controlMutationCompletion = DispatchGroup() + private let bridgeCompletion = DispatchGroup() + private let deviceShutdownCompletion = DispatchGroup() + private let deviceShutdownQueue = DispatchQueue( + label: "dory.usbip.device-shutdown", + attributes: .concurrent + ) + private let terminalRetirementQueue = DispatchQueue( + label: "dory.usbip.terminal-retirement", + qos: .userInitiated + ) + private var devices: [String: DeviceRecord] = [:] + private var bridgeRecords: [UUID: BridgeRecord] = [:] + private var controlMutationLeases: [UUID: UsbipManagerControlMutationLease] = [:] + private var lifecycleGeneration = UUID() + private var listenerState: ListenerState = .idle + private var controlLifecycle: ControlLifecycle = .active + private var guestExecutionEnded = false + private var terminalRetirementScheduled = false private let vsockPort: UInt32 + private let maxActiveConnections: Int + private let maxClaimedDevices: Int + private let stopWaitLimit: TimeInterval + private let log: @Sendable (String) -> Void + private var rejectedConnections: UInt64 = 0 - public init(vsockPort: UInt32 = VsockPorts.usbip) { + public init( + vsockPort: UInt32 = VsockPorts.usbip, + maxActiveConnections: Int = 8, + maxClaimedDevices: Int = 32, + stopWaitLimit: TimeInterval = 5, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + precondition((1...64).contains(maxActiveConnections)) + precondition((1...256).contains(maxClaimedDevices)) + precondition(stopWaitLimit.isFinite && stopWaitLimit > 0 && stopWaitLimit <= 30) self.vsockPort = vsockPort + self.maxActiveConnections = maxActiveConnections + self.maxClaimedDevices = maxClaimedDevices + self.stopWaitLimit = stopWaitLimit + self.log = log } public var port: UInt32 { vsockPort } - /// Registers the listener on the engine's vsock so guest usbip dials are served on their own - /// bridge queue (never the vsock dispatch queue). - public func attachListener(to vsock: VirtioVsock) { - vsock.listen(port: vsockPort) { [weak self] connection in - guard let self else { connection.close(); return } - let exported = self.exportedDevices() - guard !exported.isEmpty else { connection.close(); return } - UsbipBridge(connection: connection, server: UsbipServer(devices: exported)).start() + /// Registers exactly one listener generation. The retained token is closed by `stop`/deinit; + /// a duplicate call never replaces the active handler. The in-progress registration is itself + /// tracked so stop cannot return while a newly created token is still able to publish. + public func attachListener(to vsock: VirtioVsock) throws { + lock.lock() + switch listenerState { + case .idle: + listenerState = .attaching + listenerAttachmentCompletion.enter() + case .attaching, .attached: + lock.unlock() + throw UsbipManagerError.listenerAlreadyAttached + case .stopped: + lock.unlock() + throw UsbipManagerError.stopped + } + lock.unlock() + defer { listenerAttachmentCompletion.leave() } + + let registration: VirtioVsockListenerRegistration + do { + registration = try vsock.registerServiceListener( + port: vsockPort, + service: .usbip + ) { [weak self] connection in + guard let self else { + connection.close() + return + } + self.accept(connection) + } + } catch { + lock.lock() + if case .attaching = listenerState { listenerState = .idle } + lock.unlock() + throw error } + + lock.lock() + guard case .attaching = listenerState else { + let stopped: Bool + if case .stopped = listenerState { stopped = true } else { stopped = false } + lock.unlock() + registration.close() + throw stopped ? UsbipManagerError.stopped : UsbipManagerError.listenerAlreadyAttached + } + listenerState = .attached(registration) + lock.unlock() } - public func register(_ device: any UsbipExportedDevice) { - lock.lock(); defer { lock.unlock() } - devices[device.descriptor.busID] = device + /// Publishes the physical claim acquired by one exact attach mutation. Every rejection closes + /// the passed device, including stale/cross-bus authority, so a caller cannot accidentally leak + /// a host claim after the manager refuses ownership. + func register( + _ device: any UsbipExportedDevice, + under lease: UsbipManagerControlMutationLease + ) throws { + let busID = device.descriptor.busID + let rejection: UsbipManagerError? + lock.lock() + if controlMutationLeases[lease.id] != lease || !controlMutationIsCurrentLocked(lease) { + rejection = .invalidControlMutationLease + } else if lease.operation != .attach { + rejection = .claimLeaseMismatch(lease.busID) + } else if lease.busID != busID { + rejection = .controlMutationBusIDMismatch(expected: lease.busID, actual: busID) + } else if case .stopped = listenerState { + rejection = .stopped + } else if devices[busID] != nil { + rejection = .duplicateBusID(busID) + } else if devices.count >= maxClaimedDevices { + rejection = .deviceCapacityReached(limit: maxClaimedDevices) + } else { + devices[busID] = DeviceRecord( + device: device, + generation: UUID(), + disposition: .provisional(attachMutationID: lease.id) + ) + rejection = nil + } + lock.unlock() + if let rejection { + device.shutdown() + throw rejection + } } + /// Removes only the claim generation authorized by this exact attach-compensation or detach + /// lease. The bus ID comes from the sealed lease, preventing cross-bus cleanup. @discardableResult - public func unregister(busID: String) -> (any UsbipExportedDevice)? { + func unregisterClaim( + under lease: UsbipManagerControlMutationLease + ) throws -> (any UsbipExportedDevice) { + let retirement: DeviceRetirement + lock.lock() + do { + try validateClaimAuthorityLocked(lease) + guard let current = devices[lease.busID] else { + throw UsbipManagerError.claimNotRegistered(lease.busID) + } + try validateClaimGenerationLocked(current, for: lease) + retirement = removeDeviceLocked(busID: lease.busID, expectedGeneration: current.generation) + } catch { + lock.unlock() + throw error + } + lock.unlock() + retireSynchronously(retirement) + return retirement.record.device + } + + /// Marks a still-owned claim as outcome-unknown before the handler publishes that uncertainty. + /// Stop preserves these exact claim generations and only a later detach lease may retire them. + func preserveClaimForReconciliation( + under lease: UsbipManagerControlMutationLease + ) throws { lock.lock(); defer { lock.unlock() } - return devices.removeValue(forKey: busID) + try validateClaimAuthorityLocked(lease) + guard var current = devices[lease.busID] else { + throw UsbipManagerError.claimNotRegistered(lease.busID) + } + try validateClaimGenerationLocked(current, for: lease) + current.disposition = .uncertain + devices[lease.busID] = current } - public func exportedDevice(busID: String) -> (any UsbipExportedDevice)? { + func exportedDevice(busID: String) -> (any UsbipExportedDevice)? { lock.lock(); defer { lock.unlock() } - return devices[busID] + return devices[busID]?.device } - public func exportedDevices() -> [any UsbipExportedDevice] { + func exportedDevices() -> [any UsbipExportedDevice] { lock.lock(); defer { lock.unlock() } - return Array(devices.values) + return devices.values.map(\.device) } - public var claimedBusIDs: [String] { + var claimedBusIDs: [String] { lock.lock(); defer { lock.unlock() } return devices.keys.sorted() } + + var activeConnectionCount: Int { + lock.lock(); defer { lock.unlock() } + return bridgeRecords.count + } + + var rejectedConnectionCount: UInt64 { + lock.lock(); defer { lock.unlock() } + return rejectedConnections + } + + var isStopped: Bool { + lock.lock(); defer { lock.unlock() } + if case .stopped = listenerState { return true } + return false + } + + /// Admits an operation in the active epoch, or an exact detach reconciliation for an uncertain + /// claim after data-path quiescence. A stopped manager never admits a new attach. + func beginControlMutation( + operation: DoryUSBControlV1.Operation, + busID: String + ) throws -> UsbipManagerControlMutationLease { + lock.lock() + guard !guestExecutionEnded else { + lock.unlock() + throw UsbipManagerError.stopped + } + let authority: UsbipManagerControlMutationLease.Authority + switch controlLifecycle { + case .active: + authority = .active( + lifecycleGeneration: lifecycleGeneration, + claimGeneration: devices[busID]?.generation + ) + case .quiescing: + lock.unlock() + throw UsbipManagerError.stopped + case .quiesced: + guard operation == .detach, + let record = devices[busID], + record.disposition == .uncertain else { + lock.unlock() + throw UsbipManagerError.stopped + } + authority = .reconciliation(claimGeneration: record.generation) + } + let lease = UsbipManagerControlMutationLease( + id: UUID(), + operation: operation, + busID: busID, + authority: authority + ) + controlMutationLeases[lease.id] = lease + controlMutationCompletion.enter() + lock.unlock() + return lease + } + + func isControlMutationCurrent(_ lease: UsbipManagerControlMutationLease) -> Bool { + lock.lock(); defer { lock.unlock() } + return controlMutationIsCurrentLocked(lease) + } + + /// Linearizes an attach commit against stop and makes its claim provably stable in that same + /// critical section. Stop can therefore distinguish commit-before-stop from an invalidated + /// attach that still requires compensation. + func withCurrentControlMutation( + _ lease: UsbipManagerControlMutationLease, + _ body: () -> Result + ) -> Result? { + lock.lock(); defer { lock.unlock() } + guard controlMutationIsCurrentLocked(lease) else { return nil } + let result = body() + if lease.operation == .attach, + var record = devices[lease.busID], + record.disposition == .provisional(attachMutationID: lease.id) { + record.disposition = .stable + devices[lease.busID] = record + } + return result + } + + func finishControlMutation(_ lease: UsbipManagerControlMutationLease) { + lock.lock() + let owned = controlMutationLeases.removeValue(forKey: lease.id) == lease + // Fail closed if an attach exits without either committing, unregistering, or explicitly + // publishing uncertainty. Retaining authority is safer than releasing a claim whose + // guest-side outcome was never established. + if owned, + lease.operation == .attach, + var record = devices[lease.busID], + record.disposition == .provisional(attachMutationID: lease.id) { + record.disposition = .uncertain + devices[lease.busID] = record + } + lock.unlock() + precondition(owned, "USB/IP control mutation lease finished more than once") + controlMutationCompletion.leave() + } + + /// Executes one serialized quiescence transaction. Admission closes and the active generation + /// is invalidated before waiting admitted mutations. Only after they finish are stable claims + /// retired; outcome-unknown claims remain registered and make the result false until an exact + /// later detach reconciliation removes them. + @discardableResult + public func stop(timeout: TimeInterval? = nil) -> Bool { + quiesce(timeout: timeout, retireAllClaimsAfterGuestTermination: false) + } + + private func quiesce( + timeout: TimeInterval?, + retireAllClaimsAfterGuestTermination: Bool + ) -> Bool { + stopLock.lock() + defer { stopLock.unlock() } + let requested = timeout ?? stopWaitLimit + let bounded = requested.isFinite ? min(max(0, requested), stopWaitLimit) : stopWaitLimit + let deadline = ProcessInfo.processInfo.systemUptime + bounded + let registration: VirtioVsockListenerRegistration? + let activeBridges: [UsbipBridge] + + lock.lock() + switch controlLifecycle { + case .active: + controlLifecycle = .quiescing + lifecycleGeneration = UUID() + if case .attached(let current) = listenerState { registration = current } + else { registration = nil } + listenerState = .stopped + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + case .quiescing: + registration = nil + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + case .quiesced: + // Temporarily close reconciliation admission so the mutation group has a stable epoch. + controlLifecycle = .quiescing + registration = nil + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + } + lock.unlock() + + registration?.close() + for bridge in activeBridges { bridge.requestStop() } + + let listenerDrained = wait(listenerAttachmentCompletion, until: deadline) + let mutationsDrained = wait(controlMutationCompletion, until: deadline) + let bridgesDrained = wait(bridgeCompletion, until: deadline) + var safeRetirements: [DeviceRetirement] = [] + let unresolvedBusIDs: [String] + lock.lock() + if mutationsDrained { + // A provisional record after the group drains is an internal invariant breach. Preserve + // it as uncertain instead of silently releasing physical authority. + for busID in Array(devices.keys) { + guard var record = devices[busID] else { continue } + if case .provisional = record.disposition { + record.disposition = .uncertain + devices[busID] = record + } + } + let safeClaims = devices.compactMap { busID, record in + if retireAllClaimsAfterGuestTermination || record.disposition == .stable { + return (busID, record.generation) + } + return nil + } + safeRetirements.reserveCapacity(safeClaims.count) + for (busID, generation) in safeClaims { + safeRetirements.append( + removeDeviceLocked(busID: busID, expectedGeneration: generation) + ) + } + } + unresolvedBusIDs = devices.keys.sorted() + controlLifecycle = .quiesced + lock.unlock() + + let shutdownCompletion = deviceShutdownCompletion + for retirement in safeRetirements { + for affected in retirement.affectedBridges { affected.bridge?.requestStop() } + deviceShutdownQueue.async { + retirement.record.device.shutdown() + shutdownCompletion.leave() + } + } + let devicesDrained = wait(deviceShutdownCompletion, until: deadline) + let drained = listenerDrained + && mutationsDrained + && bridgesDrained + && devicesDrained + && unresolvedBusIDs.isEmpty + if !drained { + if !unresolvedBusIDs.isEmpty { + log("USB/IP quiescence retained outcome-unknown claims pending detach: \(unresolvedBusIDs.joined(separator: ", "))") + } else { + log("USB/IP teardown did not drain within \(bounded) seconds") + } + } + return drained + } + + /// Crosses the owner-proven boundary where the VM has either never started or `Machine.run()` + /// has returned. Guest execution can no longer retain a vhci attachment, so outcome-unknown + /// claims are now safe to retire without another guest RPC. A bounded initial quiescence keeps + /// normal shutdown responsive. If an admitted host mutation or terminal drain outlives it, one + /// self-retaining worker completes that exact retirement; releasing the external owner cannot + /// release a physical claim underneath the mutation. + public func stopAfterGuestExecutionEnded( + timeout: TimeInterval? = nil + ) -> UsbipManagerGuestTerminationOutcome { + lock.lock() + guestExecutionEnded = true + lock.unlock() + + if quiesce(timeout: timeout, retireAllClaimsAfterGuestTermination: true) { + return .completed + } + + let retainedClaimBusIDs: [String] + let scheduleRetirement: Bool + lock.lock() + retainedClaimBusIDs = devices.keys.sorted() + if terminalRetirementScheduled { + scheduleRetirement = false + } else { + terminalRetirementScheduled = true + scheduleRetirement = true + } + lock.unlock() + + if scheduleRetirement { + terminalRetirementQueue.async { [self] in + completeTerminalRetirement() + } + } + return .authorityRetained(retainedClaimBusIDs: retainedClaimBusIDs) + } + + deinit { + _ = stop() + } + + private func accept(_ connection: VsockConnection) { + let token = UUID() + let exported: [any UsbipExportedDevice] + lock.lock() + guard case .attached = listenerState, + bridgeRecords.count < maxActiveConnections else { + incrementRejectedConnectionsLocked() + lock.unlock() + connection.close() + return + } + guard !devices.isEmpty else { + incrementRejectedConnectionsLocked() + lock.unlock() + connection.close() + return + } + exported = devices.values.map(\.device) + let snapshotGenerations = devices.mapValues(\.generation) + let completion = DispatchGroup() + completion.enter() + bridgeCompletion.enter() + bridgeRecords[token] = BridgeRecord( + snapshotGenerations: snapshotGenerations, + completion: completion, + bridge: nil, + authorization: .awaitingImport + ) + lock.unlock() + + let bridge = UsbipBridge( + connection: connection, + server: UsbipServer(devices: exported), + authorizeImport: { [weak self] busID in + self?.authorizeImport(busID: busID, bridgeToken: token) ?? false + }, + log: log, + onClose: { [weak self] in self?.bridgeFinished(token: token) } + ) + + lock.lock() + let shouldStart: Bool + if var record = bridgeRecords[token] { + record.bridge = bridge + bridgeRecords[token] = record + if case .attached = listenerState, + case .awaitingImport = record.authorization { + shouldStart = true + } else { + shouldStart = false + } + } else { + shouldStart = false + } + lock.unlock() + + if let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ bridge.requestStop() }) { + bridge.requestStop() + } + + if shouldStart { bridge.start() } + else { bridge.requestStop() } + } + + private func authorizeImport(busID: String, bridgeToken: UUID) -> Bool { + lock.lock(); defer { lock.unlock() } + guard case .attached = listenerState, + var bridge = bridgeRecords[bridgeToken], + case .awaitingImport = bridge.authorization, + let expectedGeneration = bridge.snapshotGenerations[busID], + devices[busID]?.generation == expectedGeneration, + !bridgeRecords.contains(where: { token, record in + guard token != bridgeToken else { return false } + if case let .imported(importedBusID, importedGeneration) = record.authorization { + return importedBusID == busID + && importedGeneration == expectedGeneration + } + return false + }) else { + return false + } + bridge.authorization = .imported( + busID: busID, + deviceGeneration: expectedGeneration + ) + bridgeRecords[bridgeToken] = bridge + return true + } + + private func bridgeFinished(token: UUID) { + let completion: DispatchGroup? + lock.lock() + completion = bridgeRecords.removeValue(forKey: token)?.completion + lock.unlock() + guard let completion else { return } + completion.leave() + bridgeCompletion.leave() + } + + private func controlMutationIsCurrentLocked( + _ lease: UsbipManagerControlMutationLease + ) -> Bool { + guard controlMutationLeases[lease.id] == lease else { return false } + switch lease.authority { + case .active(let admittedGeneration, _): + guard case .active = controlLifecycle else { return false } + return lifecycleGeneration == admittedGeneration + case .reconciliation(let claimGeneration): + guard lease.operation == .detach, + let record = devices[lease.busID], + record.generation == claimGeneration, + record.disposition == .uncertain else { return false } + switch controlLifecycle { + case .active: return false + case .quiescing, .quiesced: return true + } + } + } + + private func validateClaimAuthorityLocked( + _ lease: UsbipManagerControlMutationLease + ) throws { + guard controlMutationLeases[lease.id] == lease else { + throw UsbipManagerError.invalidControlMutationLease + } + } + + private func validateClaimGenerationLocked( + _ record: DeviceRecord, + for lease: UsbipManagerControlMutationLease + ) throws { + switch lease.operation { + case .attach: + guard record.disposition == .provisional(attachMutationID: lease.id) else { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + case .detach: + let expectedGeneration: UUID? + switch lease.authority { + case .active(_, let claimGeneration): expectedGeneration = claimGeneration + case .reconciliation(let claimGeneration): expectedGeneration = claimGeneration + } + guard expectedGeneration == record.generation else { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + if case .provisional = record.disposition { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + } + } + + /// Removes the live registry generation while holding `lock`, invalidates every bridge whose + /// immutable snapshot retained it, and transfers shutdown ownership to a retirement object. + private func removeDeviceLocked( + busID: String, + expectedGeneration: UUID + ) -> DeviceRetirement { + guard let current = devices[busID], current.generation == expectedGeneration else { + preconditionFailure("USB/IP claim generation changed during serialized retirement") + } + devices.removeValue(forKey: busID) + deviceShutdownCompletion.enter() + let affectedTokens = bridgeRecords.compactMap { token, bridge -> UUID? in + switch bridge.authorization { + case .awaitingImport: + return bridge.snapshotGenerations[busID] == current.generation ? token : nil + case let .imported(importedBusID, generation): + return importedBusID == busID && generation == current.generation ? token : nil + case .invalidated: + return nil + } + } + var affected: [(bridge: UsbipBridge?, completion: DispatchGroup)] = [] + affected.reserveCapacity(affectedTokens.count) + for token in affectedTokens { + guard var bridge = bridgeRecords[token] else { continue } + bridge.authorization = .invalidated + bridgeRecords[token] = bridge + affected.append((bridge.bridge, bridge.completion)) + } + return DeviceRetirement( + busID: busID, + record: current, + affectedBridges: affected + ) + } + + private func retireSynchronously(_ retirement: DeviceRetirement) { + for affected in retirement.affectedBridges { affected.bridge?.requestStop() } + // Keep the claim strongly held while terminal abort/drain runs. HostUsbDevice bounds this + // wait and preserves late-completion object lifetime if the framework misses its deadline. + retirement.record.device.shutdown() + deviceShutdownCompletion.leave() + let deadline = ProcessInfo.processInfo.systemUptime + stopWaitLimit + let drained = retirement.affectedBridges.allSatisfy { + wait($0.completion, until: deadline) + } + if !drained { + log("USB/IP connections for \(retirement.busID) did not drain within \(stopWaitLimit) seconds") + } + } + + /// Runs only after the guest-execution boundary has permanently closed admission. This wait is + /// intentionally not timed out: abandoning the worker would abandon physical claim authority. + /// All production control RPCs and host requests have their own finite deadlines; an internal + /// invariant failure therefore leaks authority fail-closed instead of releasing it unsafely. + private func completeTerminalRetirement() { + stopLock.lock() + defer { stopLock.unlock() } + + listenerAttachmentCompletion.wait() + controlMutationCompletion.wait() + + let retirements: [DeviceRetirement] + let activeBridges: [UsbipBridge] + lock.lock() + precondition(guestExecutionEnded) + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + let liveClaimGenerations = devices.map { ($0.key, $0.value.generation) } + retirements = liveClaimGenerations.map { busID, generation in + removeDeviceLocked(busID: busID, expectedGeneration: generation) + } + controlLifecycle = .quiesced + lock.unlock() + + for bridge in activeBridges { bridge.requestStop() } + for retirement in retirements { + retirement.record.device.shutdown() + deviceShutdownCompletion.leave() + } + + bridgeCompletion.wait() + deviceShutdownCompletion.wait() + log("USB/IP terminal guest boundary retired all host claim authority") + } + + private func incrementRejectedConnectionsLocked() { + if rejectedConnections < UInt64.max { rejectedConnections += 1 } + } + + private func wait(_ group: DispatchGroup, until deadline: TimeInterval) -> Bool { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + return group.wait(timeout: .now()) == .success + } + return group.wait(timeout: .now() + remaining) == .success + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift index bbe89ad5..084bc679 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift @@ -2,8 +2,27 @@ import Foundation public enum UsbipProtocolError: Error, Equatable { case shortFrame + case invalidFrameLength(expected: Int, actual: Int) case invalidString + case invalidVersion(UInt16) + case unexpectedOpCode(UInt16) + case nonzeroOperationStatus(UInt32) + case unknownOperation(UInt32) + case unknownDirection(UInt32) + case unexpectedOperation(expected: UsbipOperation, actual: UsbipOperation) + case invalidEndpoint(UInt32) + case invalidDeviceID(UInt32) + case unexpectedDeviceID(expected: UInt32, actual: UInt32) + case invalidSequenceNumber(UInt32) + case invalidStartFrame(UInt32) + case invalidInterval(UInt32) + case invalidSetup + case nonzeroReservedField + case unsupportedIsochronous(UInt32) case transferBufferTooLarge(UInt32) + case unknownTransferFlags(UInt32) + case transferDirectionFlagMismatch(flags: UInt32, direction: UsbipDirection) + case unsupportedTransferFlags(UInt32) } public enum UsbipOperation: UInt32, Sendable { @@ -18,6 +37,50 @@ public enum UsbipDirection: UInt32, Sendable { case `in` = 1 } +/// Stable USB/IP UAPI flag values. Allocation/DMA flags describe the sending kernel's buffer +/// bookkeeping and have no remote semantic on Dory's copied buffers; they are accepted explicitly. +/// Flags whose wire-visible behavior Dory cannot reproduce are rejected by `inspectHeader`. +public enum UsbipTransferFlag { + public static let shortNotOK: UInt32 = 0x0000_0001 + public static let isoAsSoonAsPossible: UInt32 = 0x0000_0002 + public static let noTransferDMAMap: UInt32 = 0x0000_0004 + public static let zeroPacket: UInt32 = 0x0000_0040 + public static let noInterrupt: UInt32 = 0x0000_0080 + public static let freeBuffer: UInt32 = 0x0000_0100 + public static let directionIn: UInt32 = 0x0000_0200 + public static let dmaMapSingle: UInt32 = 0x0001_0000 + public static let dmaMapPage: UInt32 = 0x0002_0000 + public static let dmaMapScatterGather: UInt32 = 0x0004_0000 + public static let mapLocal: UInt32 = 0x0008_0000 + public static let setupMapSingle: UInt32 = 0x0010_0000 + public static let setupMapLocal: UInt32 = 0x0020_0000 + public static let dmaScatterGatherCombined: UInt32 = 0x0040_0000 + public static let alignedTemporaryBuffer: UInt32 = 0x0080_0000 + + /// Safe to ignore after the stream copied setup/data into Dory-owned memory. + public static let senderMemoryManagement: UInt32 = noTransferDMAMap + | freeBuffer + | dmaMapSingle + | dmaMapPage + | dmaMapScatterGather + | mapLocal + | setupMapSingle + | setupMapLocal + | dmaScatterGatherCombined + | alignedTemporaryBuffer + + /// A host-controller interrupt scheduling hint; Dory completes synchronously and preserves the + /// completion result, so accepting it does not change guest-visible transfer semantics. + public static let senderSchedulingHints: UInt32 = noInterrupt + + public static let known: UInt32 = shortNotOK + | isoAsSoonAsPossible + | senderMemoryManagement + | zeroPacket + | senderSchedulingHints + | directionIn +} + public enum UsbipOpCode: UInt16, Sendable { case reqImport = 0x8003 case repImport = 0x0003 @@ -102,7 +165,7 @@ public struct UsbipDeviceDescriptor: Codable, Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + try bytes.requireExactCount(Self.byteCount) self.init( path: try bytes.cString(at: 0, length: 256), busID: try bytes.cString(at: 256, length: 32), @@ -151,10 +214,20 @@ public struct UsbipImportRequest: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + try bytes.requireExactCount(Self.byteCount) let header = try UsbipOperationHeader(decoding: bytes) - guard header.code == UsbipOpCode.reqImport.rawValue else { throw UsbipProtocolError.shortFrame } - self.init(busID: try bytes.cString(at: 8, length: 32)) + guard header.version == UsbipOperationHeader.version else { + throw UsbipProtocolError.invalidVersion(header.version) + } + guard header.code == UsbipOpCode.reqImport.rawValue else { + throw UsbipProtocolError.unexpectedOpCode(header.code) + } + guard header.status == 0 else { + throw UsbipProtocolError.nonzeroOperationStatus(header.status) + } + let busID = try bytes.cString(at: 8, length: 32) + guard Self.isValidBusID(busID) else { throw UsbipProtocolError.invalidString } + self.init(busID: busID) } public func encoded() -> [UInt8] { @@ -162,6 +235,16 @@ public struct UsbipImportRequest: Equatable, Sendable { bytes.appendCString(busID, width: 32) return bytes } + + /// Canonical USB/IP bus-ID grammar shared by the guest import and local control boundaries. + static func isValidBusID(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count < 32 else { return false } + return value.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x41 && $0 <= 0x5a) + || ($0 >= 0x61 && $0 <= 0x7a) || $0 == 0x2d || $0 == 0x2e + || $0 == 0x3a || $0 == 0x5f + } + } } public struct UsbipImportReply: Equatable, Sendable { @@ -201,12 +284,36 @@ public struct UsbipHeaderBasic: Equatable, Sendable { public init(decoding bytes: [UInt8]) throws { guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + let rawOperation = bytes.beUInt32(at: 0) + guard let operation = UsbipOperation(rawValue: rawOperation) else { + throw UsbipProtocolError.unknownOperation(rawOperation) + } + let sequenceNumber = bytes.beUInt32(at: 4) + guard sequenceNumber != 0 else { + throw UsbipProtocolError.invalidSequenceNumber(sequenceNumber) + } + let deviceID = bytes.beUInt32(at: 8) + let rawDirection = bytes.beUInt32(at: 12) + guard let direction = UsbipDirection(rawValue: rawDirection) else { + throw UsbipProtocolError.unknownDirection(rawDirection) + } + let endpoint = bytes.beUInt32(at: 16) + guard endpoint <= 15 else { throw UsbipProtocolError.invalidEndpoint(endpoint) } + switch operation { + case .cmdSubmit, .cmdUnlink: + guard deviceID != 0 else { throw UsbipProtocolError.invalidDeviceID(deviceID) } + case .retSubmit, .retUnlink: + guard deviceID == 0 else { throw UsbipProtocolError.invalidDeviceID(deviceID) } + guard direction == .out, endpoint == 0 else { + throw UsbipProtocolError.invalidEndpoint(endpoint) + } + } self.init( - command: UsbipOperation(rawValue: bytes.beUInt32(at: 0)) ?? .cmdSubmit, - sequenceNumber: bytes.beUInt32(at: 4), - deviceID: bytes.beUInt32(at: 8), - direction: UsbipDirection(rawValue: bytes.beUInt32(at: 12)) ?? .out, - endpoint: bytes.beUInt32(at: 16) + command: operation, + sequenceNumber: sequenceNumber, + deviceID: deviceID, + direction: direction, + endpoint: endpoint ) } @@ -225,6 +332,27 @@ public struct UsbipSubmitCommand: Equatable, Sendable { public static let headerByteCount = 48 public static let maxTransferBytes: UInt32 = 4 * 1024 * 1024 + public struct HeaderMetadata: Equatable, Sendable { + public var header: UsbipHeaderBasic + public var transferFlags: UInt32 + public var transferBufferLength: UInt32 + public var startFrame: UInt32 + public var numberOfPackets: UInt32 + public var interval: UInt32 + public var setup: [UInt8] + + public var isIsochronous: Bool { + // Linux's protocol document reserves -1 for non-iso, while its current + // usbip_pack_cmd_submit() copies the non-iso URB value (0) verbatim. + // Both are canonical Linux peer encodings; every positive packet count is iso. + numberOfPackets != 0 && numberOfPackets != UInt32.max + } + + public var outPayloadByteCount: Int { + header.direction == .out ? Int(transferBufferLength) : 0 + } + } + public var header: UsbipHeaderBasic public var transferFlags: UInt32 public var transferBufferLength: UInt32 @@ -246,27 +374,95 @@ public struct UsbipSubmitCommand: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { + let metadata = try Self.inspectHeader(bytes) + guard !metadata.isIsochronous else { + throw UsbipProtocolError.unsupportedIsochronous(metadata.numberOfPackets) + } + let expectedByteCount = Self.headerByteCount + metadata.outPayloadByteCount + try bytes.requireExactCount(expectedByteCount) + self.init( + header: metadata.header, + transferFlags: metadata.transferFlags, + transferBufferLength: metadata.transferBufferLength, + startFrame: metadata.startFrame, + numberOfPackets: metadata.numberOfPackets, + interval: metadata.interval, + setup: metadata.setup, + transferBuffer: Array(bytes[48.. HeaderMetadata { guard bytes.count >= Self.headerByteCount else { throw UsbipProtocolError.shortFrame } let header = try UsbipHeaderBasic(decoding: bytes) - let rawTransferLength = bytes.beUInt32(at: 24) - guard rawTransferLength <= Self.maxTransferBytes else { - throw UsbipProtocolError.transferBufferTooLarge(rawTransferLength) + guard header.command == .cmdSubmit else { + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: header.command) } - let transferLength = Int(rawTransferLength) - let payloadLength = header.direction == .out ? transferLength : 0 - guard bytes.count >= Self.headerByteCount + payloadLength else { throw UsbipProtocolError.shortFrame } - self.init( + let transferFlags = bytes.beUInt32(at: 20) + try validateTransferFlags(transferFlags, direction: header.direction) + let transferLength = bytes.beUInt32(at: 24) + guard transferLength <= Self.maxTransferBytes else { + throw UsbipProtocolError.transferBufferTooLarge(transferLength) + } + let startFrame = bytes.beUInt32(at: 28) + let numberOfPackets = bytes.beUInt32(at: 32) + let isIsochronous = numberOfPackets != 0 && numberOfPackets != UInt32.max + guard isIsochronous || startFrame == 0 || startFrame == UInt32.max else { + throw UsbipProtocolError.invalidStartFrame(startFrame) + } + let interval = bytes.beUInt32(at: 36) + guard interval <= UInt32(UInt8.max) else { + throw UsbipProtocolError.invalidInterval(interval) + } + let setup = Array(bytes[40..<48]) + if header.endpoint == 0 { + let setupDirection: UsbipDirection = setup[0] & 0x80 == 0 ? .out : .in + let setupLength = UInt32(setup[6]) | (UInt32(setup[7]) << 8) + guard setupDirection == header.direction, setupLength == transferLength else { + throw UsbipProtocolError.invalidSetup + } + } else if setup.contains(where: { $0 != 0 }) { + throw UsbipProtocolError.invalidSetup + } + return HeaderMetadata( header: header, - transferFlags: bytes.beUInt32(at: 20), - transferBufferLength: UInt32(transferLength), - startFrame: bytes.beUInt32(at: 28), - numberOfPackets: bytes.beUInt32(at: 32), - interval: bytes.beUInt32(at: 36), - setup: Array(bytes[40..<48]), - transferBuffer: Array(bytes[48..<(48 + payloadLength)]) + transferFlags: transferFlags, + transferBufferLength: transferLength, + startFrame: startFrame, + numberOfPackets: numberOfPackets, + interval: interval, + setup: setup ) } + public static func validateTransferFlags( + _ flags: UInt32, + direction: UsbipDirection + ) throws { + let unknown = flags & ~UsbipTransferFlag.known + guard unknown == 0 else { throw UsbipProtocolError.unknownTransferFlags(unknown) } + let flagSaysIn = flags & UsbipTransferFlag.directionIn != 0 + guard flagSaysIn == (direction == .in) else { + throw UsbipProtocolError.transferDirectionFlagMismatch( + flags: flags, + direction: direction + ) + } + let unsupported = flags + & (UsbipTransferFlag.isoAsSoonAsPossible | UsbipTransferFlag.zeroPacket) + guard unsupported == 0 else { + throw UsbipProtocolError.unsupportedTransferFlags(unsupported) + } + guard direction == .in || flags & UsbipTransferFlag.shortNotOK == 0 else { + throw UsbipProtocolError.unsupportedTransferFlags( + flags & UsbipTransferFlag.shortNotOK + ) + } + } + public func encoded() -> [UInt8] { var bytes = header.encoded() bytes.appendBE(transferFlags) @@ -331,8 +527,22 @@ public struct UsbipUnlinkCommand: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } - self.init(header: try UsbipHeaderBasic(decoding: bytes), unlinkSequenceNumber: bytes.beUInt32(at: 20)) + try bytes.requireExactCount(Self.byteCount) + let header = try UsbipHeaderBasic(decoding: bytes) + guard header.command == .cmdUnlink else { + throw UsbipProtocolError.unexpectedOperation(expected: .cmdUnlink, actual: header.command) + } + guard header.direction == .out, header.endpoint == 0 else { + throw UsbipProtocolError.invalidEndpoint(header.endpoint) + } + let unlinkSequenceNumber = bytes.beUInt32(at: 20) + guard unlinkSequenceNumber != 0, unlinkSequenceNumber != header.sequenceNumber else { + throw UsbipProtocolError.invalidSequenceNumber(unlinkSequenceNumber) + } + guard bytes[24.. [UInt8] { @@ -363,6 +573,13 @@ public struct UsbipUnlinkReply: Equatable, Sendable { } private extension Array where Element == UInt8 { + func requireExactCount(_ expected: Int) throws { + guard count >= expected else { throw UsbipProtocolError.shortFrame } + guard count == expected else { + throw UsbipProtocolError.invalidFrameLength(expected: expected, actual: count) + } + } + mutating func appendBE(_ value: UInt16) { Swift.withUnsafeBytes(of: value.bigEndian) { append(contentsOf: $0) } } @@ -393,10 +610,19 @@ private extension Array where Element == UInt8 { let end = offset + length guard count >= end else { throw UsbipProtocolError.shortFrame } let slice = self[offset.. UsbipSubmitReply - func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply + func submit(_ command: UsbipSubmitCommand, context: UsbipRequestContext) throws -> UsbipSubmitReply + func unlink(_ command: UsbipUnlinkCommand, context: UsbipRequestContext) throws -> UsbipUnlinkReply + func closeSession(_ context: UsbipRequestContext) + func shutdown() } public enum UsbipServerError: Error, Equatable { case unknownDevice(String) - case unsupportedIsochronous + case invalidDeviceDescriptor(String) } public final class UsbipServer: @unchecked Sendable { private let devicesByBusID: [String: any UsbipExportedDevice] public init(devices: [any UsbipExportedDevice]) { - self.devicesByBusID = Dictionary(uniqueKeysWithValues: devices.map { ($0.descriptor.busID, $0) }) + var mapped: [String: any UsbipExportedDevice] = [:] + for device in devices where mapped[device.descriptor.busID] == nil { + mapped[device.descriptor.busID] = device + } + self.devicesByBusID = mapped } public func handleImport(_ bytes: [UInt8]) throws -> [UInt8] { @@ -27,23 +42,53 @@ public final class UsbipServer: @unchecked Sendable { return UsbipImportReply(status: 0, device: device.descriptor).encoded() } - public func handleURB(_ bytes: [UInt8], busID: String) throws -> [UInt8] { + public func handleURB(_ bytes: [UInt8], busID: String, context: UsbipRequestContext) throws -> [UInt8] { guard let device = devicesByBusID[busID] else { throw UsbipServerError.unknownDevice(busID) } let basic = try UsbipHeaderBasic(decoding: bytes) + try validateDeviceID(basic.deviceID, for: device.descriptor) switch basic.command { case .cmdSubmit: - let command = try UsbipSubmitCommand(decoding: bytes) - guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { - let replyHeader = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) - return UsbipSubmitReply(header: replyHeader, status: -EPIPE, actualLength: 0, numberOfPackets: command.numberOfPackets).encoded() + let metadata = try UsbipSubmitCommand.inspectHeader(bytes) + if metadata.isIsochronous { + let replyHeader = UsbipHeaderBasic( + command: .retSubmit, + sequenceNumber: metadata.header.sequenceNumber, + deviceID: 0, + direction: .out, + endpoint: 0 + ) + return UsbipSubmitReply( + header: replyHeader, + status: -EPIPE, + actualLength: 0, + numberOfPackets: metadata.numberOfPackets + ).encoded() } - return try device.submit(command).encoded() + let command = try UsbipSubmitCommand(decoding: bytes) + return try device.submit(command, context: context).encoded() case .cmdUnlink: - return try device.unlink(try UsbipUnlinkCommand(decoding: bytes)).encoded() + return try device.unlink(try UsbipUnlinkCommand(decoding: bytes), context: context).encoded() case .retSubmit, .retUnlink: - throw UsbipProtocolError.shortFrame + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: basic.command) + } + } + + public func closeSession(_ context: UsbipRequestContext, busID: String?) { + guard let busID, let device = devicesByBusID[busID] else { return } + device.closeSession(context) + } + + private func validateDeviceID(_ actual: UInt32, for descriptor: UsbipDeviceDescriptor) throws { + guard descriptor.busNumber <= UInt32(UInt16.max), + descriptor.deviceNumber > 0, + descriptor.deviceNumber <= UInt32(UInt16.max) else { + throw UsbipServerError.invalidDeviceDescriptor(descriptor.busID) + } + let expected = (descriptor.busNumber << 16) | descriptor.deviceNumber + guard actual == expected else { + throw UsbipProtocolError.unexpectedDeviceID(expected: expected, actual: actual) } } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift deleted file mode 100644 index e1cc8d19..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift +++ /dev/null @@ -1,1084 +0,0 @@ -import Darwin -import Foundation -import OpenGL -import OpenGL.GL3 - -public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { - public let libraryPath: String - public let moltenVKICDPath: String? - public let capsets: [VirtioGPUCapset] - - private let handle: UnsafeMutableRawPointer - private let callbacks: UnsafeMutablePointer - private let glContextManager: VirglCGLContextManager - private let functions: Functions - private let submissionSyncMode: SubmissionSyncMode - private let classicOnly: Bool - private let rendererLock = NSRecursiveLock() - private var pollSource: DispatchSourceRead? - private var pollTimer: DispatchSourceTimer? - // Each guest renderer context gets one rolling GL fence. Submissions only flush the producer - // context; scanout readback waits for the latest fence from every producer in ctx0. This keeps - // shared textures coherent without blocking the vCPU on glFinish after every command buffer. - private var pendingSubmissionSyncs: [UInt32: GLsync] = [:] - // Retain guest-provided labels so a renderer failure identifies Mutter, Firefox, or the Vulkan - // client that supplied the command stream without relying on process IDs. - private var contextLabels: [UInt32: String] = [:] - // Fence creation in classic VirGL must happen in the producer's OpenGL context. Keep each - // context's negotiated capset so Venus fences can stay on their native renderer timeline while - // VirGL fences explicitly restore the producer context after Dory clears thread-local CGL state. - private var contextFlags: [UInt32: UInt32] = [:] - // virglrenderer retains the *iovec array pointer* supplied at resource creation/attach; it - // does not copy those descriptors. Swift's temporary Array storage therefore cannot be used - // for resource backing even though the guest-memory pointers inside each descriptor are - // stable. Keep an owned C allocation alive for exactly as long as the renderer resource. - private var resourceIOVecs: [UInt32: OwnedIOVecs] = [:] - - // The C callbacks have no per-instance cookie routing here, so a single active renderer is - // registered globally (one renderer per engine process). A callback can run synchronously from - // virgl_renderer_poll while rendererLock is held. Calling the virtio device from there would - // invert rendererLock and the transport's queue lock: a vCPU creating the next fence holds the - // queue lock while waiting for rendererLock, and the poll callback would hold rendererLock while - // waiting for that queue lock. Buffer completions here and deliver them only after the renderer - // API boundary releases rendererLock. - private static let fenceLock = NSLock() - nonisolated(unsafe) private static weak var activeRenderer: VirglRenderer? - nonisolated(unsafe) private var fenceSink: ((UInt32, UInt32, UInt64) -> Void)? - nonisolated(unsafe) private var runtimeFailureSink: ((VirtioGPURendererRuntimeFailure) -> Void)? - nonisolated(unsafe) private var pendingFenceSignals: [(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64)] = [] - - public var onFenceSignaled: ((UInt32, UInt32, UInt64) -> Void)? { - get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return fenceSink } - set { Self.fenceLock.lock(); fenceSink = newValue; Self.fenceLock.unlock() } - } - - public var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? { - get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return runtimeFailureSink } - set { Self.fenceLock.lock(); runtimeFailureSink = newValue; Self.fenceLock.unlock() } - } - - fileprivate static func signalFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { - fenceLock.lock() - activeRenderer?.pendingFenceSignals.append((contextID, ringIndex, fenceID)) - fenceLock.unlock() - } - - fileprivate static func signalRuntimeFailure(_ failure: VirtioGPURendererRuntimeFailure) { - fenceLock.lock() - let sink = activeRenderer?.runtimeFailureSink - fenceLock.unlock() - sink?(failure) - } - - static func runtimeFailure(logMessage: String) -> VirtioGPURendererRuntimeFailure? { - guard logMessage.contains("VK_ERROR_DEVICE_LOST") else { return nil } - return .deviceLost("renderer reported VK_ERROR_DEVICE_LOST") - } - - public static func discover( - environment: [String: String] = ProcessInfo.processInfo.environment - ) throws -> VirglRenderer { - let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1" - guard let libraryPath = firstExistingPath(candidates: virglRendererCandidates(environment: environment)) else { - throw VMError.invalidConfiguration( - "accelerated graphics require libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks" - ) - } - let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment)) - guard classicOnly || moltenVKICD != nil else { - throw VMError.invalidConfiguration( - "VirGL + Venus graphics require MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d" - ) - } - return try VirglRenderer( - libraryPath: libraryPath, - moltenVKICDPath: moltenVKICD, - environment: environment - ) - } - - public init( - libraryPath: String, - moltenVKICDPath: String? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment - ) throws { - let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1" - guard FileManager.default.fileExists(atPath: libraryPath) else { - throw VMError.invalidConfiguration("virglrenderer library not found: \(libraryPath)") - } - if !classicOnly { - guard let moltenVKICDPath, - FileManager.default.fileExists(atPath: moltenVKICDPath) else { - throw VMError.invalidConfiguration("VirGL + Venus graphics require a valid MoltenVK ICD") - } - } - guard let handle = dlopen(libraryPath, RTLD_NOW | RTLD_LOCAL) else { - let message = dlerror().map { String(cString: $0) } ?? "unknown dlopen failure" - throw VMError.invalidConfiguration("cannot load virglrenderer at \(libraryPath): \(message)") - } - - do { - let functions = try Functions(handle: handle) - let glContextManager = try VirglCGLContextManager() - if !classicOnly, - dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") == nil { - throw VMError.invalidConfiguration( - "libvirglrenderer at \(libraryPath) is missing Dory's macOS Venus fence fix; rebuild or install the pinned Dory renderer" - ) - } - if !classicOnly, - dlsym(handle, "dory_moltenvk_spirv_native_array_fix") == nil { - throw VMError.invalidConfiguration( - "libvirglrenderer at \(libraryPath) is not linked to Dory's patched MoltenVK runtime; the ICD path alone cannot replace a directly linked MoltenVK dylib" - ) - } - guard functions.resourceMap != nil || functions.resourceGetMapPtr != nil else { - throw VMError.invalidConfiguration( - "libvirglrenderer at \(libraryPath) exports neither virgl_renderer_resource_map nor virgl_renderer_resource_get_map_ptr; Dory's Venus path needs one to expose host-visible blobs to the guest" - ) - } - - if let moltenVKICDPath { - setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1) - } - - if let sym = dlsym(handle, "virgl_set_log_callback") { - typealias SetLogCallback = @convention(c) ( - (@convention(c) (Int32, UnsafePointer?, UnsafeMutableRawPointer?) -> Void)?, - UnsafeMutableRawPointer?, - UnsafeMutableRawPointer? - ) -> Void - unsafeBitCast(sym, to: SetLogCallback.self)(doryVirglLog, nil, nil) - } - - let callbacks = UnsafeMutablePointer.allocate(capacity: 1) - callbacks.initialize(to: VirglRendererCallbacks( - version: 4, - writeFence: doryVirglWriteFence, - createGLContext: doryVirglCreateGLContext, - destroyGLContext: doryVirglDestroyGLContext, - makeCurrent: doryVirglMakeCurrent, - getDRMFD: nil, - writeContextFence: doryVirglWriteContextFence, - getServerFD: nil, - getEGLDisplay: nil - )) - - // Host-allocated HOST3D blobs (the zero-copy map model libkrun uses): do NOT set - // USE_GUEST_VRAM, which would make virglrenderer choose guest-backed storage that returns - // no mappable host pointer. Keep both renderers active: VirGL gives the Linux desktop a - // reliable accelerated OpenGL compositor through CGL, while Venus remains available to - // Vulkan applications and compute workloads. The macOS renderer build does not expose - // the Linux eventfd needed by virglrenderer's threaded/async fence mode, so Dory polls - // its fence timelines explicitly below. This covers both VirGL and Venus contexts. - let flags = classicOnly ? 0 : RendererFlags.venus - let rendererCookie = Unmanaged.passUnretained(glContextManager).toOpaque() - let initStatus = functions.initialize(rendererCookie, flags, UnsafeMutableRawPointer(callbacks)) - guard initStatus == 0 else { - callbacks.deinitialize(count: 1) - callbacks.deallocate() - throw VMError.invalidConfiguration("virgl_renderer_init(VirGL + Venus) failed with status \(initStatus)") - } - - var discoveredCapsets = [VirtioGPUCapset]() - for capsetID in [VirtioGPUCapsetID.virgl, VirtioGPUCapsetID.virgl2, VirtioGPUCapsetID.venus] { - var maxVersion: UInt32 = 0 - var maxSize: UInt32 = 0 - functions.getCapSet(capsetID, &maxVersion, &maxSize) - guard maxSize > 0 else { continue } - var capsetData = [UInt8](repeating: 0, count: Int(maxSize)) - capsetData.withUnsafeMutableBytes { buffer in - functions.fillCaps(capsetID, maxVersion, buffer.baseAddress) - } - discoveredCapsets.append(VirtioGPUCapset( - id: capsetID, - maxVersion: maxVersion, - data: capsetData - )) - } - let hasVirgl2 = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.virgl2 }) - let hasVenus = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.venus }) - guard hasVirgl2, classicOnly || hasVenus else { - functions.cleanup(nil) - callbacks.deinitialize(count: 1) - callbacks.deallocate() - throw VMError.invalidConfiguration( - classicOnly - ? "virglrenderer did not report the VirGL2 capset" - : "virglrenderer did not report both VirGL2 and Venus capsets" - ) - } - - // CGL contexts are current per host thread. Initialization runs on the app thread, - // while virtio notifications can subsequently arrive on any vCPU thread. Leave no - // renderer context attached to the initializer so each command can bind it safely on - // the thread that is actually executing the command. - glContextManager.clearCurrent() - - self.libraryPath = libraryPath - self.moltenVKICDPath = moltenVKICDPath - self.capsets = discoveredCapsets - self.handle = handle - self.callbacks = callbacks - self.glContextManager = glContextManager - self.functions = functions - self.submissionSyncMode = SubmissionSyncMode(environment: environment) - self.classicOnly = classicOnly - Self.fenceLock.lock() - Self.activeRenderer = self - Self.fenceLock.unlock() - FileHandle.standardError.write(Data( - "dory-gpu: renderer mode=\(classicOnly ? "classic" : "virgl+venus") " - .appending("cross-context synchronization mode=\(submissionSyncMode.rawValue)\n").utf8 - )) - startFencePolling() - } catch { - dlclose(handle) - throw error - } - } - - deinit { - pollSource?.cancel() - pollSource = nil - pollTimer?.cancel() - pollTimer = nil - Self.fenceLock.lock() - if Self.activeRenderer === self { Self.activeRenderer = nil } - Self.fenceLock.unlock() - rendererLock.lock() - functions.forceContext0() - deletePendingSubmissionSyncs() - functions.cleanup(nil) - resourceIOVecs.removeAll() - glContextManager.clearCurrent() - rendererLock.unlock() - callbacks.deinitialize(count: 1) - callbacks.deallocate() - dlclose(handle) - } - - /// Registers a host fence so virglrenderer signals it once all GPU work submitted before it has - /// completed. Context fences carry Venus's per-ring ordering; plain fences ride the global ctx0 - /// timeline. - public func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws { - try withRendererContext(forceContextZero: false) { - let capsetID = contextFlags[contextID].map { $0 & 0xff } - // Besides binding ctx0 on this host thread, force_ctx_0 invalidates virglrenderer's - // process-global current-context cache. Without that invalidation a zero-length submit - // to the previous producer can be treated as an already-complete switch even though - // Dory cleared the thread-local CGL context at the prior API boundary. - functions.forceContext0() - if contextID != 0, capsetID != VirtioGPUCapsetID.venus { - // virgl_renderer_create_fence records a ctx0 timeline entry but inserts glFenceSync - // into whichever GL context is current. QEMU naturally leaves the producer current; - // Dory deliberately clears CGL at each API boundary because vCPU calls can migrate - // across host threads. A zero-length submit performs virglrenderer's normal context - // switch without changing guest state, restoring the producer before fence creation. - try check( - functions.submitCommand(nil, Int32(bitPattern: contextID), 0), - "virgl_renderer_submit_cmd(context restore for fence)" - ) - } - if contextFence, let contextCreateFence = functions.contextCreateFence { - try check( - contextCreateFence(contextID, 0, ringIndex, fenceID), - "virgl_renderer_context_create_fence" - ) - return - } - try check( - functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID), - "virgl_renderer_create_fence" - ) - } - } - - public func createContext(id: UInt32, flags: UInt32, name: String) throws { - try withRendererContext { - let status = name.withCString { pointer in - functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer) - } - guard status == 0 else { - throw VMError.invalidConfiguration( - "virgl_renderer_context_create_with_flags failed with status \(status) " - + "(context=\(id), flags=0x\(String(flags, radix: 16)), name=\(name))" - ) - } - contextLabels[id] = name - contextFlags[id] = flags - } - } - - public func destroyContext(id: UInt32) throws { - try withRendererContext { - functions.contextDestroy(id) - contextLabels.removeValue(forKey: id) - contextFlags.removeValue(forKey: id) - } - } - - public func attachResource(contextID: UInt32, resourceID: UInt32) throws { - try withRendererContext { - functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) - } - } - - public func detachResource(contextID: UInt32, resourceID: UInt32) throws { - try withRendererContext { - functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) - } - } - - public func submit3D(contextID: UInt32, command: [UInt8]) throws { - try withRendererContext { - var command = command - while command.count % 4 != 0 { command.append(0) } - let dwordCount = Int32(command.count / 4) - let status = command.withUnsafeMutableBytes { buffer in - functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount) - } - guard status == 0 else { - let label = contextLabels[contextID] ?? "unknown" - let summary = Self.commandSummary(command) - throw VMError.invalidConfiguration( - "virgl_renderer_submit_cmd failed with status \(status) " - + "(context=\(contextID), name=\(label), bytes=\(command.count), commands=\(summary))" - ) - } - // Contexts share textures, but cross-context visibility still needs explicit ordering. - // Keep the latest completion fence for each producer and resolve it at the readback - // boundary. glFinish is retained as a diagnostic mode for isolating driver bugs. - if submissionSyncMode == .finishAfterSubmit { - glFinish() - return - } - if let previous = pendingSubmissionSyncs.removeValue(forKey: contextID) { - glDeleteSync(previous) - } - if let sync = glFenceSync(GLenum(GL_SYNC_GPU_COMMANDS_COMPLETE), 0) { - pendingSubmissionSyncs[contextID] = sync - glFlush() - } else { - // Extremely old/limited OpenGL implementations still remain correct. - glFinish() - } - } - } - - private static func commandSummary(_ command: [UInt8]) -> String { - var offset = 0 - var descriptions = [String]() - while offset + 4 <= command.count, descriptions.count < 24 { - let header = UInt32(command[offset]) - | (UInt32(command[offset + 1]) << 8) - | (UInt32(command[offset + 2]) << 16) - | (UInt32(command[offset + 3]) << 24) - let opcode = header & 0xff - let length = Int(header >> 16) - descriptions.append("\(offset / 4):\(opcode)/\(length)") - let advance = (length + 1) * 4 - guard advance > 0, advance <= command.count - offset else { break } - offset += advance - } - if offset < command.count { descriptions.append("...") } - return descriptions.joined(separator: ",") - } - - public func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws { - try withRendererContext { - var args = VirglRendererResourceCreateArgs( - handle: resource.resourceID, - target: resource.target, - format: resource.format, - bind: resource.bind, - width: resource.width, - height: resource.height, - depth: resource.depth, - arraySize: resource.arraySize, - lastLevel: resource.lastLevel, - samples: resource.samples, - flags: resource.flags - ) - let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries) - let status = withUnsafeMutablePointer(to: &args) { argsPointer in - functions.resourceCreate( - UnsafeMutableRawPointer(argsPointer), - backing?.pointer, - backing?.count ?? 0 - ) - } - try check(status, "virgl_renderer_resource_create") - if let backing { - resourceIOVecs[resource.resourceID] = backing - } - } - } - - public func createBlob( - resourceID: UInt32, - contextID: UInt32, - blobMemory: UInt32, - blobFlags: UInt32, - blobID: UInt64, - size: UInt64, - entries: [VirtioGPUMemoryEntry] - ) throws { - try withRendererContext { - let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries) - var args = VirglRendererResourceCreateBlobArgs( - resourceHandle: resourceID, - contextID: contextID, - blobMemory: blobMemory, - blobFlags: blobFlags, - blobID: blobID, - size: size, - iovecs: backing?.pointer, - iovecCount: backing?.count ?? 0 - ) - let status = withUnsafePointer(to: &args) { argsPointer in - functions.resourceCreateBlob(UnsafeRawPointer(argsPointer)) - } - try check(status, "virgl_renderer_resource_create_blob") - if let backing { - resourceIOVecs[resourceID] = backing - } - } - } - - public func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws { - try withRendererContext { - guard !entries.isEmpty else { - throw VMError.invalidConfiguration("cannot attach empty backing to renderer resource \(resourceID)") - } - let backing = OwnedIOVecs(entries: entries) - let status = functions.resourceAttachIOV( - Int32(bitPattern: resourceID), - backing.pointer, - Int32(backing.count) - ) - try check(status, "virgl_renderer_resource_attach_iov") - resourceIOVecs[resourceID] = backing - } - } - - public func detachBacking(resourceID: UInt32) throws { - try withRendererContext { - var detached: UnsafeMutablePointer? - var count: Int32 = 0 - functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count) - resourceIOVecs.removeValue(forKey: resourceID) - } - } - - public func unrefResource(resourceID: UInt32) throws { - try withRendererContext { - functions.resourceUnref(resourceID) - resourceIOVecs.removeValue(forKey: resourceID) - } - } - - public func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping { - try withRendererContext { - var pointer: UnsafeMutableRawPointer? - var size: UInt64 = 0 - // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns - // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the - // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent. - let requiresRendererUnmap: Bool - if let getPtr = functions.resourceGetMapPtr { - var address: UInt64 = 0 - try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr") - pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) - // get_map_ptr exposes the existing Vulkan allocation without changing virglrenderer's - // resource-map state. Calling resource_unmap for this path is therefore invalid. - requiresRendererUnmap = false - } else if let map = functions.resourceMap { - try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map") - requiresRendererUnmap = true - } else { - throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint") - } - guard let hostPointer = pointer else { - throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)") - } - var mapInfo: UInt32 = 0 - try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info") - return VirtioGPUBlobMapping( - hostPointer: hostPointer, - size: size, - mapInfo: mapInfo & 0x0f, - requiresRendererUnmap: requiresRendererUnmap - ) - } - } - - public func unmapBlob(resourceID: UInt32) throws { - try withRendererContext { - try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap") - } - } - - public func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - try withRendererContext { - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferWriteIOV( - transfer.resourceID, - transfer.contextID, - Int32(bitPattern: transfer.level), - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - count - ) - } - } - try check(status, "virgl_renderer_transfer_write_iov") - } - } - - public func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - try withRendererContext { - try waitForPendingSubmissions() - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferReadIOV( - transfer.resourceID, - transfer.contextID, - transfer.level, - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - Int32(count) - ) - } - } - try check(status, "virgl_renderer_transfer_read_iov") - } - } - - private func waitForPendingSubmissions() throws { - for sync in pendingSubmissionSyncs.values { - switch submissionSyncMode { - case .clientWaitBeforeReadback: - // glWaitSync only orders commands subsequently issued by its *current* context. - // virglrenderer may switch from ctx0 to a resource context during transfer_read, - // so a server-side wait in ctx0 does not protect that readback. A client wait - // establishes completion before any context switch and is valid for shared sync - // objects created by Firefox, Mutter, or another guest GL producer. - try waitForCompletion(sync) - case .serverWaitBeforeReadback: - glWaitSync(sync, 0, GLuint64(GL_TIMEOUT_IGNORED)) - case .finishAfterSubmit: - break - } - glDeleteSync(sync) - } - pendingSubmissionSyncs.removeAll(keepingCapacity: true) - } - - private func waitForCompletion(_ sync: GLsync) throws { - let deadline = ProcessInfo.processInfo.systemUptime + 5 - while true { - let result = glClientWaitSync(sync, 0, 100_000_000) - switch result { - case GLenum(GL_ALREADY_SIGNALED), GLenum(GL_CONDITION_SATISFIED): - return - case GLenum(GL_TIMEOUT_EXPIRED): - guard ProcessInfo.processInfo.systemUptime < deadline else { - throw VMError.invalidConfiguration( - "timed out waiting for a shared OpenGL submission before scanout readback" - ) - } - default: - throw VMError.invalidConfiguration( - "glClientWaitSync failed before scanout readback (status=0x\(String(result, radix: 16)))" - ) - } - } - } - - private func deletePendingSubmissionSyncs() { - for sync in pendingSubmissionSyncs.values { glDeleteSync(sync) } - pendingSubmissionSyncs.removeAll() - } - - /// virglrenderer caches its active context globally because QEMU normally calls it from one - /// event-loop thread. Dory's MMIO notifications can originate on any vCPU thread, while CGL's - /// current context is thread-local. Resetting to ctx0 at each serialized API boundary makes - /// virglrenderer issue the appropriate make-current callback on the actual calling thread. - private func withRendererContext( - forceContextZero: Bool = true, - _ body: () throws -> T - ) throws -> T { - rendererLock.lock() - if forceContextZero { - functions.forceContext0() - } - defer { - glContextManager.clearCurrent() - rendererLock.unlock() - deliverPendingFenceSignals() - } - return try body() - } - - /// Runs the device-facing part of a fence completion outside rendererLock. Besides avoiding the - /// lock inversion documented above, this keeps virglrenderer's C callback short and prevents - /// guest used-ring writes or virtual interrupts from re-entering the renderer while it polls. - private func deliverPendingFenceSignals() { - Self.fenceLock.lock() - let signals = pendingFenceSignals - pendingFenceSignals.removeAll(keepingCapacity: true) - let sink = fenceSink - Self.fenceLock.unlock() - guard let sink else { return } - for signal in signals { - sink(signal.contextID, signal.ringIndex, signal.fenceID) - } - } - - /// virglrenderer's threaded fence worker signals this descriptor when the caller must run - /// `virgl_renderer_poll` (notably to service GL queries before retiring a fence). Without an - /// event-loop subscriber, a compositor can wait forever after submitting its first frame. - private func startFencePolling() { - let descriptor = functions.getPollFD() - let queue = DispatchQueue(label: "dev.dory.virgl.poll", qos: .userInteractive) - let poll: @Sendable () -> Void = { [weak self] in - guard let self else { return } - try? self.withRendererContext { - self.functions.poll() - } - } - if descriptor >= 0 { - let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue) - source.setEventHandler(handler: poll) - pollSource = source - source.resume() - } else { - // CGL builds have no eventfd. One millisecond keeps guest fence latency below a frame - // without burning a vCPU in a busy loop; poll itself is non-blocking. - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now(), repeating: .milliseconds(1), leeway: .milliseconds(1)) - timer.setEventHandler(handler: poll) - pollTimer = timer - timer.resume() - } - } - - private func withIOVecs( - _ entries: [VirtioGPUMemoryEntry], - _ body: (UnsafePointer?, UInt32) throws -> T - ) throws -> T { - let iovecs = entries.map { iovec(iov_base: $0.pointer, iov_len: $0.length) } - if iovecs.isEmpty { - return try body(nil, 0) - } - return try iovecs.withUnsafeBufferPointer { buffer in - try body(buffer.baseAddress, UInt32(buffer.count)) - } - } - - private func check(_ status: Int32, _ operation: String) throws { - guard status == 0 else { - throw VMError.invalidConfiguration("\(operation) failed with status \(status)") - } - } - - private static func virglRendererCandidates(environment: [String: String]) -> [String] { - var candidates = [ - environment["DORY_VIRGLRENDERER_PATH"], - environment["DORY_VIRGLRENDERER"], - Bundle.main.privateFrameworksPath.map { "\($0)/libvirglrenderer.dylib" }, - Bundle.main.resourcePath.map { "\($0)/libvirglrenderer.dylib" }, - ].compactMap { $0?.isEmpty == false ? $0 : nil } - if let executable = CommandLine.arguments.first { - let directory = URL(fileURLWithPath: executable).deletingLastPathComponent().path - candidates.append("\(directory)/../Frameworks/libvirglrenderer.dylib") - candidates.append("\(directory)/libvirglrenderer.dylib") - } - candidates.append(contentsOf: [ - "/opt/homebrew/lib/libvirglrenderer.dylib", - "/usr/local/lib/libvirglrenderer.dylib", - ]) - return candidates - } - - private static func moltenVKICDCandidates(environment: [String: String]) -> [String] { - var candidates = [String]() - if let override = environment["DORY_MOLTENVK_ICD"], !override.isEmpty { - candidates.append(override) - } - if let existing = environment["VK_ICD_FILENAMES"], !existing.isEmpty { - candidates.append(contentsOf: existing.split(separator: ":").map(String.init)) - } - if let executable = CommandLine.arguments.first { - let directory = URL(fileURLWithPath: executable).deletingLastPathComponent().path - candidates.append("\(directory)/../Resources/vulkan/icd.d/MoltenVK_icd.json") - candidates.append("\(directory)/../Resources/MoltenVK_icd.json") - } - candidates.append(contentsOf: [ - Bundle.main.resourcePath.map { "\($0)/vulkan/icd.d/MoltenVK_icd.json" }, - Bundle.main.resourcePath.map { "\($0)/MoltenVK_icd.json" }, - "/opt/homebrew/etc/vulkan/icd.d/MoltenVK_icd.json", - "/opt/homebrew/share/vulkan/icd.d/MoltenVK_icd.json", - "/usr/local/etc/vulkan/icd.d/MoltenVK_icd.json", - "/usr/local/share/vulkan/icd.d/MoltenVK_icd.json", - ].compactMap { $0 }) - return candidates - } - - private static func firstExistingPath(candidates: [String]) -> String? { - candidates.first { FileManager.default.fileExists(atPath: URL(fileURLWithPath: $0).standardizedFileURL.path) } - .map { URL(fileURLWithPath: $0).standardizedFileURL.path } - } -} - -private enum RendererFlags { - static let threadSync: Int32 = 1 << 1 - static let venus: Int32 = 1 << 6 - static let asyncFenceCB: Int32 = 1 << 8 - static let useGuestVRAM: Int32 = 1 << 14 -} - -private enum SubmissionSyncMode: String { - case clientWaitBeforeReadback = "client-wait" - case serverWaitBeforeReadback = "server-wait" - case finishAfterSubmit = "finish" - - init(environment: [String: String]) { - let requested = environment["DORY_VIRGL_SYNC_MODE"]?.lowercased() - self = SubmissionSyncMode(rawValue: requested ?? "") ?? .serverWaitBeforeReadback - } -} - -private final class OwnedIOVecs { - let pointer: UnsafeMutablePointer - let count: UInt32 - - init(entries: [VirtioGPUMemoryEntry]) { - precondition(!entries.isEmpty) - count = UInt32(entries.count) - pointer = .allocate(capacity: entries.count) - for (index, entry) in entries.enumerated() { - pointer.advanced(by: index).initialize(to: iovec( - iov_base: entry.pointer, - iov_len: entry.length - )) - } - } - - deinit { - pointer.deinitialize(count: Int(count)) - pointer.deallocate() - } -} - -private enum VirtioGPUCapsetID { - static let virgl: UInt32 = 1 - static let virgl2: UInt32 = 2 - static let venus: UInt32 = 4 -} - -private typealias WriteFenceCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32) -> Void -private typealias WriteContextFenceCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, UInt64) -> Void -private typealias CreateGLContextCallback = @convention(c) ( - UnsafeMutableRawPointer?, - Int32, - UnsafeMutableRawPointer? -) -> UnsafeMutableRawPointer? -private typealias DestroyGLContextCallback = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Void -private typealias MakeCurrentCallback = @convention(c) (UnsafeMutableRawPointer?, Int32, UnsafeMutableRawPointer?) -> Int32 -private typealias GetDRMFDCallback = @convention(c) (UnsafeMutableRawPointer?) -> Int32 -private typealias GetServerFDCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32) -> Int32 -private typealias GetEGLDisplayCallback = @convention(c) (UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? - -private let doryVirglLog: @convention(c) (Int32, UnsafePointer?, UnsafeMutableRawPointer?) -> Void = { level, message, _ in - let text = message.map { String(cString: $0) } ?? "" - FileHandle.standardError.write(Data("virgl[\(level)]: \(text)\n".utf8)) - // VK_ERROR_DEVICE_LOST is the Vulkan API's explicit host logical-device-loss authority. - // Do not infer loss from generic renderer errors: those can be caused by malformed guest - // command streams and must remain ordinary command failures. - if let failure = VirglRenderer.runtimeFailure(logMessage: text) { - VirglRenderer.signalRuntimeFailure(failure) - } -} - -// ctx0 fences ride the global timeline: write_fence has no context/ring, so they complete under -// (context 0, ring 0). Context fences carry their real coordinates. -private let doryVirglWriteFence: WriteFenceCallback = { _, fence in - VirglRenderer.signalFence(contextID: 0, ringIndex: 0, fenceID: UInt64(fence)) -} -private let doryVirglWriteContextFence: WriteContextFenceCallback = { _, contextID, ringIndex, fenceID in - VirglRenderer.signalFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) -} - -/// virglrenderer's classic 3D path needs the VMM to supply host OpenGL contexts when it is not -/// using EGL or GLX. CGL is the native offscreen OpenGL context API on macOS and remains backed by -/// the Apple GPU, so the guest compositor avoids llvmpipe without adding another window surface. -private final class VirglCGLContextManager: @unchecked Sendable { - private let lock = NSLock() - private let pixelFormat: CGLPixelFormatObj - private var shareContext: CGLContextObj? - - init() throws { - var format: CGLPixelFormatObj? - var formatCount: GLint = 0 - var attributes: [CGLPixelFormatAttribute] = [ - kCGLPFAAccelerated, - kCGLPFAOpenGLProfile, - CGLPixelFormatAttribute(kCGLOGLPVersion_GL4_Core.rawValue), - CGLPixelFormatAttribute(0), - ] - let status = CGLChoosePixelFormat(&attributes, &format, &formatCount) - guard status == kCGLNoError, formatCount > 0, let format else { - throw VMError.invalidConfiguration("cannot create an accelerated macOS OpenGL pixel format (CGL status \(status.rawValue))") - } - pixelFormat = format - } - - deinit { - CGLDestroyPixelFormat(pixelFormat) - } - - func createContext(shared: Bool) -> UnsafeMutableRawPointer? { - lock.lock() - defer { lock.unlock() } - var context: CGLContextObj? - let sharedContext = shared ? shareContext : nil - let status = CGLCreateContext(pixelFormat, sharedContext, &context) - guard status == kCGLNoError, let context else { - FileHandle.standardError.write(Data( - "dory-gpu: CGL context creation failed shared=\(shared) status=\(status.rawValue)\n".utf8 - )) - return nil - } - if shareContext == nil { - shareContext = context - } - return UnsafeMutableRawPointer(context) - } - - func destroyContext(_ pointer: UnsafeMutableRawPointer) { - let context = pointer.assumingMemoryBound(to: CGLContextObj.Pointee.self) - lock.lock() - let wasRoot = shareContext == context - if wasRoot { - shareContext = nil - } - lock.unlock() - if CGLGetCurrentContext() == context { - _ = CGLSetCurrentContext(nil) - } - CGLDestroyContext(context) - } - - func makeCurrent(_ pointer: UnsafeMutableRawPointer?) -> Int32 { - let context = pointer.map { $0.assumingMemoryBound(to: CGLContextObj.Pointee.self) } - return CGLSetCurrentContext(context) == kCGLNoError ? 0 : -1 - } - - func clearCurrent() { - _ = CGLSetCurrentContext(nil) - } -} - -private func doryVirglContextManager(_ cookie: UnsafeMutableRawPointer?) -> VirglCGLContextManager? { - cookie.map { Unmanaged.fromOpaque($0).takeUnretainedValue() } -} - -private let doryVirglCreateGLContext: CreateGLContextCallback = { cookie, _, parameters in - guard let manager = doryVirglContextManager(cookie), let parameters else { return nil } - // C layout: int version; bool shared; 3 bytes padding; int major; int minor; int compat. - let shared = parameters.load(fromByteOffset: 4, as: UInt8.self) != 0 - return manager.createContext(shared: shared) -} - -private let doryVirglDestroyGLContext: DestroyGLContextCallback = { cookie, context in - guard let manager = doryVirglContextManager(cookie), let context else { return } - manager.destroyContext(context) -} - -private let doryVirglMakeCurrent: MakeCurrentCallback = { cookie, _, context in - guard let manager = doryVirglContextManager(cookie) else { return -1 } - return manager.makeCurrent(context) -} - -private struct VirglRendererCallbacks { - var version: Int32 - var writeFence: WriteFenceCallback? - var createGLContext: CreateGLContextCallback? - var destroyGLContext: DestroyGLContextCallback? - var makeCurrent: MakeCurrentCallback? - var getDRMFD: GetDRMFDCallback? - var writeContextFence: WriteContextFenceCallback? - var getServerFD: GetServerFDCallback? - var getEGLDisplay: GetEGLDisplayCallback? -} - -private struct VirglRendererResourceCreateArgs { - var handle: UInt32 - var target: UInt32 - var format: UInt32 - var bind: UInt32 - var width: UInt32 - var height: UInt32 - var depth: UInt32 - var arraySize: UInt32 - var lastLevel: UInt32 - var samples: UInt32 - var flags: UInt32 -} - -private struct VirglRendererResourceCreateBlobArgs { - var resourceHandle: UInt32 - var contextID: UInt32 - var blobMemory: UInt32 - var blobFlags: UInt32 - var blobID: UInt64 - var size: UInt64 - var iovecs: UnsafePointer? - var iovecCount: UInt32 -} - -private struct VirglBox { - var x: UInt32 - var y: UInt32 - var z: UInt32 - var width: UInt32 - var height: UInt32 - var depth: UInt32 - - init(values: [UInt32]) { - let padded = values + Array(repeating: 0, count: max(0, 6 - values.count)) - x = padded[0] - y = padded[1] - z = padded[2] - width = padded[3] - height = padded[4] - depth = padded[5] - } -} - -private struct Functions { - typealias Initialize = @convention(c) (UnsafeMutableRawPointer?, Int32, UnsafeMutableRawPointer?) -> Int32 - typealias Cleanup = @convention(c) (UnsafeMutableRawPointer?) -> Void - typealias GetCapSet = @convention(c) (UInt32, UnsafeMutablePointer?, UnsafeMutablePointer?) -> Void - typealias FillCaps = @convention(c) (UInt32, UInt32, UnsafeMutableRawPointer?) -> Void - typealias ContextCreateWithFlags = @convention(c) (UInt32, UInt32, UInt32, UnsafePointer?) -> Int32 - typealias ContextDestroy = @convention(c) (UInt32) -> Void - typealias ForceContext0 = @convention(c) () -> Void - typealias ContextResource = @convention(c) (Int32, Int32) -> Void - typealias SubmitCommand = @convention(c) (UnsafeMutableRawPointer?, Int32, Int32) -> Int32 - typealias ResourceCreate = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt32) -> Int32 - typealias ResourceCreateBlob = @convention(c) (UnsafeRawPointer?) -> Int32 - typealias ResourceAttachIOV = @convention(c) (Int32, UnsafePointer?, Int32) -> Int32 - typealias ResourceDetachIOV = @convention(c) (Int32, UnsafeMutablePointer?>?, UnsafeMutablePointer?) -> Void - typealias ResourceUnref = @convention(c) (UInt32) -> Void - typealias ResourceMapFixed = @convention(c) (UInt32, UnsafeMutableRawPointer?) -> Int32 - typealias ResourceMap = @convention(c) (UInt32, UnsafeMutablePointer?, UnsafeMutablePointer?) -> Int32 - typealias ResourceGetMapPtr = @convention(c) (UInt32, UnsafeMutablePointer?) -> Int32 - typealias ResourceUnmap = @convention(c) (UInt32) -> Int32 - typealias ResourceGetMapInfo = @convention(c) (UInt32, UnsafeMutablePointer?) -> Int32 - typealias TransferWriteIOV = @convention(c) ( - UInt32, - UInt32, - Int32, - UInt32, - UInt32, - UnsafeRawPointer?, - UInt64, - UnsafePointer?, - UInt32 - ) -> Int32 - typealias TransferReadIOV = @convention(c) ( - UInt32, - UInt32, - UInt32, - UInt32, - UInt32, - UnsafeRawPointer?, - UInt64, - UnsafePointer?, - Int32 - ) -> Int32 - typealias CreateFence = @convention(c) (Int32, UInt32) -> Int32 - typealias ContextCreateFence = @convention(c) (UInt32, UInt32, UInt32, UInt64) -> Int32 - typealias Poll = @convention(c) () -> Void - typealias GetPollFD = @convention(c) () -> Int32 - - let initialize: Initialize - let cleanup: Cleanup - let getCapSet: GetCapSet - let fillCaps: FillCaps - let contextCreateWithFlags: ContextCreateWithFlags - let contextDestroy: ContextDestroy - let forceContext0: ForceContext0 - let contextAttachResource: ContextResource - let contextDetachResource: ContextResource - let submitCommand: SubmitCommand - let resourceCreate: ResourceCreate - let resourceCreateBlob: ResourceCreateBlob - let resourceAttachIOV: ResourceAttachIOV - let resourceDetachIOV: ResourceDetachIOV - let resourceUnref: ResourceUnref - let resourceMapFixed: ResourceMapFixed? - let resourceMap: ResourceMap? - let resourceGetMapPtr: ResourceGetMapPtr? - let resourceUnmap: ResourceUnmap - let resourceGetMapInfo: ResourceGetMapInfo - let transferWriteIOV: TransferWriteIOV - let transferReadIOV: TransferReadIOV - let createFence: CreateFence - let contextCreateFence: ContextCreateFence? - let poll: Poll - let getPollFD: GetPollFD - - init(handle: UnsafeMutableRawPointer) throws { - initialize = try Self.required(handle, "virgl_renderer_init") - cleanup = try Self.required(handle, "virgl_renderer_cleanup") - getCapSet = try Self.required(handle, "virgl_renderer_get_cap_set") - fillCaps = try Self.required(handle, "virgl_renderer_fill_caps") - contextCreateWithFlags = try Self.required(handle, "virgl_renderer_context_create_with_flags") - contextDestroy = try Self.required(handle, "virgl_renderer_context_destroy") - forceContext0 = try Self.required(handle, "virgl_renderer_force_ctx_0") - contextAttachResource = try Self.required(handle, "virgl_renderer_ctx_attach_resource") - contextDetachResource = try Self.required(handle, "virgl_renderer_ctx_detach_resource") - submitCommand = try Self.required(handle, "virgl_renderer_submit_cmd") - resourceCreate = try Self.required(handle, "virgl_renderer_resource_create") - resourceCreateBlob = try Self.required(handle, "virgl_renderer_resource_create_blob") - resourceAttachIOV = try Self.required(handle, "virgl_renderer_resource_attach_iov") - resourceDetachIOV = try Self.required(handle, "virgl_renderer_resource_detach_iov") - resourceUnref = try Self.required(handle, "virgl_renderer_resource_unref") - resourceMapFixed = Self.optional(handle, "virgl_renderer_resource_map_fixed") - resourceMap = Self.optional(handle, "virgl_renderer_resource_map") - resourceGetMapPtr = Self.optional(handle, "virgl_renderer_resource_get_map_ptr") - resourceUnmap = try Self.required(handle, "virgl_renderer_resource_unmap") - resourceGetMapInfo = try Self.required(handle, "virgl_renderer_resource_get_map_info") - transferWriteIOV = try Self.required(handle, "virgl_renderer_transfer_write_iov") - transferReadIOV = try Self.required(handle, "virgl_renderer_transfer_read_iov") - createFence = try Self.required(handle, "virgl_renderer_create_fence") - contextCreateFence = Self.optional(handle, "virgl_renderer_context_create_fence") - poll = try Self.required(handle, "virgl_renderer_poll") - getPollFD = try Self.required(handle, "virgl_renderer_get_poll_fd") - } - - private static func required(_ handle: UnsafeMutableRawPointer, _ name: String) throws -> T { - guard let symbol = dlsym(handle, name) else { - throw VMError.invalidConfiguration("libvirglrenderer missing required symbol \(name)") - } - return unsafeBitCast(symbol, to: T.self) - } - - private static func optional(_ handle: UnsafeMutableRawPointer, _ name: String) -> T? { - guard let symbol = dlsym(handle, name) else { return nil } - return unsafeBitCast(symbol, to: T.self) - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift index c247ba42..d2e255a3 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift @@ -1,71 +1,665 @@ -import Darwin import Foundation +import Synchronization -/// virtio-balloon with free page reporting (VIRTIO_BALLOON_F_REPORTING): the guest batches ranges -/// of free pages onto the reporting queue and `GuestMemory.releaseRange` hands them straight back to -/// macOS via `MADV_FREE_REUSABLE`, which drops them from the process's physical footprint immediately -/// (refaults come back through `MADV_FREE_REUSE`). This is the mechanism Virtualization.framework -/// lacks, and the reason dory-hv exists: the host footprint tracks what the guest is actually using -/// instead of its high-water mark. -public final class VirtioBalloon: VirtioDeviceBackend { +public struct VirtioBalloonStatistics: Equatable, Sendable { + public var reportRequests: UInt64 + public var reportRejected: UInt64 + public var reportBytes: UInt64 + public var reclaimedBytes: UInt64 + public var releaseFailures: UInt64 + public var classicRequests: UInt64 + public var invalidClassicRequests: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var reportProcessingNanoseconds: UInt64 + public var maximumReportProcessingNanoseconds: UInt64 +} + +struct VirtioBalloonLimits: Equatable, Sendable { + /// Linux's page-reporting core currently submits at most 32 scatterlist entries, normally one + /// pageblock (2 MiB on a 4 KiB-page arm64 kernel) per entry. Matching that 64 MiB transaction + /// ceiling bounds host reclaim work without fragmenting a conforming Linux report. + static let production = VirtioBalloonLimits( + maximumReportRanges: 32, + maximumReportBytes: 64 * 1_024 * 1_024, + // A reporting chain can itself cover 64 MiB. One chain per worker turn bounds latency and + // lets notifications for the other compacted queues enter between large reports. + maximumChainsPerWorkerTurn: 1 + ) + + let maximumReportRanges: Int + let maximumReportBytes: Int + let maximumChainsPerWorkerTurn: Int + + init( + maximumReportRanges: Int, + maximumReportBytes: Int, + maximumChainsPerWorkerTurn: Int + ) { + precondition(maximumReportRanges > 0) + precondition(maximumReportBytes >= Int(HostPage.size)) + precondition(maximumChainsPerWorkerTurn > 0) + precondition(maximumChainsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumReportRanges = maximumReportRanges + self.maximumReportBytes = maximumReportBytes + self.maximumChainsPerWorkerTurn = maximumChainsPerWorkerTurn + } +} + +/// virtio-balloon with VIRTIO_BALLOON_F_PAGE_REPORTING. Linux offers isolated free pages as +/// device-writable scatter/gather buffers, waits for the used-ring acknowledgement, and then may +/// reuse them. Dory validates the complete report before any host-memory mutation, releases only +/// fully covered host pages, and acknowledges the report even when macOS elects not to reclaim a +/// particular range (the feature is an optimization, not guest memory ownership transfer). +public final class VirtioBalloon: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 5 - public let queueCount = 3 // inflate, deflate, reporting - public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_BALLOON_F_REPORTING + // On virtio-mmio, Linux compacts optional named queues. With only PAGE_REPORTING negotiated, + // physical queue 2 is reporting_vq after inflateq and deflateq. + public let queueCount = 3 + public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_BALLOON_F_PAGE_REPORTING + public let kickSynchronization: VirtioKickSynchronization = .backendManaged + + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct QueueWorkerState { + var generation: UInt64 = 1 + var scheduled = false + var kickPending = false + } + + private struct WorkerState { + var transport: WeakTransportReference? + var queues = Array(repeating: QueueWorkerState(), count: 3) + } + + private enum PreparedWork { + case empty + case completed(wantsInterrupt: Bool) + case report( + chain: VirtqueueChain, + ranges: [GuestRange], + reportedBytes: Int + ) + case fault + case stale + } + + private enum ReportCompletion { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private struct GuestRange: Equatable, Sendable { + var address: UInt64 + var length: UInt64 + + var end: UInt64 { address + length } + } + + private enum ReportAdmission { + case accepted(ranges: [GuestRange], reportedBytes: Int) + case invalid + case revoked + } private let memory: GuestMemory - private let log: (String) -> Void - public private(set) var reclaimedBytes: UInt64 = 0 - public private(set) var reportEvents: UInt64 = 0 + private let limits: VirtioBalloonLimits + private let releaseRange: @Sendable (UInt64, UInt64) -> GuestMemoryReleaseResult + private let log: @Sendable (String) -> Void + private let submitWork: (@escaping @Sendable () -> Void) -> Void + private let workerStateLock = NSLock() + // Device reset and queue reconfiguration hold the transport lock before entering this fence. + // A worker never waits for the transport lock while holding the fence, which prevents a + // reset/worker lock cycle while still forbidding reclaim from starting after revocation. + private let lifecycleFence = NSLock() + private var workerState = WorkerState() + private let reportRequests = Atomic(0) + private let reportRejected = Atomic(0) + private let reportBytes = Atomic(0) + private let reclaimedByteCount = Atomic(0) + private let releaseFailures = Atomic(0) + private let classicRequests = Atomic(0) + private let invalidClassicRequests = Atomic(0) + private let queueFaults = Atomic(0) + private let boundedDrainStops = Atomic(0) + private let workerTurns = Atomic(0) + private let workerYields = Atomic(0) + private let coalescedWorkerRequests = Atomic(0) + private let revokedWorkerTurns = Atomic(0) + private let reportProcessingNanoseconds = Atomic(0) + private let maximumReportProcessingNanoseconds = Mutex(0) - private static let hostPageSize: UInt64 = HostPage.size + public convenience init( + memory: GuestMemory, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + let worker = DispatchQueue( + label: "dev.dory.virtio-balloon", + qos: .utility + ) + self.init( + memory: memory, + limits: .production, + releaseRange: { [memory] address, length in + memory.releaseRange(guestAddress: address, length: length) + }, + log: log, + submitWork: { operation in worker.async(execute: operation) } + ) + } - public init(memory: GuestMemory, log: @escaping (String) -> Void = { _ in }) { + init( + memory: GuestMemory, + limits: VirtioBalloonLimits, + releaseRange: @escaping @Sendable (UInt64, UInt64) -> GuestMemoryReleaseResult, + log: @escaping @Sendable (String) -> Void = { _ in }, + submitWork: @escaping (@escaping @Sendable () -> Void) -> Void = { operation in + operation() + } + ) { self.memory = memory + self.limits = limits + self.releaseRange = releaseRange self.log = log + self.submitWork = submitWork } public var configSpace: [UInt8] { - // num_pages = 0 (no inflation requested), actual = 0. + // num_pages = 0 (classic inflation is parked), actual = 0. [UInt8](repeating: 0, count: 8) } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[queue] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { + guard transport.queues.indices.contains(queue), queue < queueCount else { return } + let generation = workerStateLock.withLock { () -> UInt64? in + if let reference = workerState.transport { + if let existing = reference.value { + guard existing === transport else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return nil + } + } else { + // A backend is normally owned by exactly one transport. If a synthetic caller + // outlives that transport, revoke every orphaned scheduled turn before binding + // a replacement so its kick cannot be coalesced into a dead worker task. + for index in workerState.queues.indices { + advanceWorkerGenerationLocked(queue: index) + } + workerState.transport = WeakTransportReference(transport) + } + } else { + workerState.transport = WeakTransportReference(transport) + } + if workerState.queues[queue].scheduled { + workerState.queues[queue].kickPending = true + coalescedWorkerRequests.wrappingAdd(1, ordering: .relaxed) + return nil + } + workerState.queues[queue].scheduled = true + return workerState.queues[queue].generation + } + guard let generation else { return } + submitWorkerTurn(queue: queue, generation: generation, transport: transport) + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeWorker(queue: nil, transport: transport) + } + + public func queueStateChanged( + queue: Int, + ready: Bool, + transport: VirtioMMIOTransport + ) { + _ = ready + guard (0.. PreparedWork { + transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return .stale } + let virtqueue = transport.queues[queue] + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { return .empty } + chain = next + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + if queue == 2 { - reclaim(chain: chain) + switch admitReport(chain) { + case .invalid: + reportRejected.wrappingAdd(1, ordering: .relaxed) + case .revoked: + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + case let .accepted(ranges, reportedBytes): + return .report( + chain: chain, + ranges: ranges, + reportedBytes: reportedBytes + ) + } + } else { + processClassicRequest(chain) + } + + do { + return .completed(wantsInterrupt: try virtqueue.push(chain, written: 0)) + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault } - // Inflate and deflate chains complete as no-ops: the ceiling is enforced by RAM size - // and reporting handles elasticity, so the classic balloon stays parked at zero. - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants } - if interrupt { - transport.notifyUsed() + } + + private func completeReport( + _ chain: VirtqueueChain, + ranges: [GuestRange], + reportedBytes: Int, + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> ReportCompletion { + let startedAt = Self.monotonicNanoseconds() + lifecycleFence.lock() + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { + lifecycleFence.unlock() + return .stale + } + processAcceptedReport(ranges: ranges, reportedBytes: reportedBytes) + lifecycleFence.unlock() + + let elapsed = Self.monotonicNanoseconds() &- startedAt + reportProcessingNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + maximumReportProcessingNanoseconds.withLock { maximum in + maximum = max(maximum, elapsed) + } + + return transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return .stale } + do { + return .published(wantsInterrupt: try transport.queues[queue].push( + chain, + written: 0 + )) + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } + } + + private func pendingWork( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return false } + do { + return try transport.queues[queue].pendingCount() > 0 + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return false + } } } - /// Every segment of a reporting chain IS a run of free guest pages. Stage-2 mappings pin the - /// backing pages, so each range is unmapped from the guest and only then marked reusable; - /// GuestMemory.releaseRange does both. The guest tolerates zero-filled refaults on reported - /// pages by contract, and the RAM-fault path in the run loop remaps blocks on first touch. - private func reclaim(chain: VirtqueueChain) { - let hostBase = UInt64(UInt(bitPattern: memory.hostBase)) - for segment in chain.segments { - let start = UInt64(UInt(bitPattern: segment.pointer)) - let end = start + UInt64(segment.length) - let alignedStart = (start + Self.hostPageSize - 1) & ~(Self.hostPageSize - 1) - let alignedEnd = end & ~(Self.hostPageSize - 1) - guard alignedEnd > alignedStart else { continue } - let guestAddress = memory.guestBase + (alignedStart - hostBase) - if memory.releaseRange(guestAddress: guestAddress, length: alignedEnd - alignedStart) { - reclaimedBytes &+= alignedEnd - alignedStart + private func finishWorkerTurn( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport, + wantsInterrupt: Bool, + knownPendingWork: Bool + ) { + if wantsInterrupt { + transport.withQueueLock { + if isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) { + transport.notifyUsed() + } } } - reportEvents &+= 1 - if reportEvents <= 30 || reportEvents % 64 == 0 { - log("balloon: report #\(reportEvents), \(chain.segments.count) ranges, total \(reclaimedBytes >> 20) MiB reclaimed") + + let shouldContinue = workerStateLock.withLock { () -> Bool in + guard isCurrentWorkerLocked( + queue: queue, + generation: generation, + transport: transport + ) else { return false } + let pending = knownPendingWork || workerState.queues[queue].kickPending + workerState.queues[queue].kickPending = false + if !pending { + workerState.queues[queue].scheduled = false + } + return pending + } + if shouldContinue { + workerYields.wrappingAdd(1, ordering: .relaxed) + submitWorkerTurn(queue: queue, generation: generation, transport: transport) + } + } + + private func revokeWorker( + queue: Int?, + transport: VirtioMMIOTransport + ) { + lifecycleFence.lock() + workerStateLock.withLock { + if let existing = workerState.transport?.value, existing !== transport { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return + } + if workerState.transport == nil { + workerState.transport = WeakTransportReference(transport) + } + let indices = queue.map { [$0] } ?? Array(workerState.queues.indices) + for index in indices { + advanceWorkerGenerationLocked(queue: index) + } + } + lifecycleFence.unlock() + } + + private func isCurrentWorker( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerStateLock.withLock { + isCurrentWorkerLocked( + queue: queue, + generation: generation, + transport: transport + ) + } + } + + private func isCurrentWorkerLocked( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerState.queues.indices.contains(queue) + && workerState.transport?.value === transport + && workerState.queues[queue].generation == generation + && workerState.queues[queue].scheduled + } + + private func processClassicRequest(_ chain: VirtqueueChain) { + classicRequests.wrappingAdd(1, ordering: .relaxed) + let valid = chain.withLeaseHeld { access in + // inflateq/deflateq contain a device-readable array of 32-bit balloon PFNs. Dory's + // target is zero, so valid unsolicited arrays are acknowledged as no-ops. + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount > 0 + && access.writableSegmentCount == 0 + && access.readableByteCount > 0 + && access.readableByteCount % MemoryLayout.size == 0 + && access.readableByteCount <= 256 * MemoryLayout.size + } ?? false + if !valid { + invalidClassicRequests.wrappingAdd(1, ordering: .relaxed) } } + + private func processAcceptedReport(ranges: [GuestRange], reportedBytes: Int) { + let event = reportRequests.wrappingAdd(1, ordering: .relaxed).newValue + reportBytes.wrappingAdd(UInt64(reportedBytes), ordering: .relaxed) + var reclaimedThisReport: UInt64 = 0 + for range in ranges { + let result = releaseRange(range.address, range.length) + if result.hostMemoryWasReclaimed { + reclaimedThisReport &+= range.length + } else { + releaseFailures.wrappingAdd(1, ordering: .relaxed) + } + } + if reclaimedThisReport > 0 { + reclaimedByteCount.wrappingAdd(reclaimedThisReport, ordering: .relaxed) + } + if event <= 30 || event % 64 == 0 { + let total = reclaimedByteCount.load(ordering: .relaxed) + log( + "balloon: report #\(event), \(ranges.count) ranges, " + + "\(reportedBytes >> 20) MiB reported, \(total >> 20) MiB reclaimed" + ) + } + } + + private static func monotonicNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + private func admitReport(_ chain: VirtqueueChain) -> ReportAdmission { + chain.withLeaseHeld { access -> ReportAdmission in + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount == 0, + access.writableSegmentCount > 0, + access.writableSegmentCount <= limits.maximumReportRanges, + access.writableByteCount > 0, + access.writableByteCount <= limits.maximumReportBytes else { + return .invalid + } + + let hostBase = UInt64(UInt(bitPattern: memory.hostBase)) + let (hostEnd, hostEndOverflow) = hostBase.addingReportingOverflow(memory.size) + guard !hostEndOverflow else { return .invalid } + + var staged = [GuestRange]() + staged.reserveCapacity(access.writableSegmentCount) + for segment in access.segments { + guard segment.isDeviceWritable, segment.length > 0 else { return .invalid } + let start = UInt64(UInt(bitPattern: segment.pointer)) + let (end, endOverflow) = start.addingReportingOverflow(UInt64(segment.length)) + guard !endOverflow, start >= hostBase, end <= hostEnd else { return .invalid } + + let guestStart = memory.guestBase + (start - hostBase) + let (guestEnd, guestEndOverflow) = guestStart.addingReportingOverflow( + UInt64(segment.length) + ) + guard !guestEndOverflow else { return .invalid } + let alignedStart = Self.roundUpToHostPage(guestStart) + let alignedEnd = guestEnd & ~(HostPage.size - 1) + if let alignedStart, alignedEnd > alignedStart { + staged.append(GuestRange( + address: alignedStart, + length: alignedEnd - alignedStart + )) + } + } + return .accepted( + ranges: Self.mergeOverlappingRanges(staged), + reportedBytes: access.writableByteCount + ) + } ?? .revoked + } + + private static func roundUpToHostPage(_ value: UInt64) -> UInt64? { + let (adjusted, overflow) = value.addingReportingOverflow(HostPage.size - 1) + guard !overflow else { return nil } + return adjusted & ~(HostPage.size - 1) + } + + private static func mergeOverlappingRanges(_ input: [GuestRange]) -> [GuestRange] { + let sorted = input.sorted { + if $0.address == $1.address { return $0.length < $1.length } + return $0.address < $1.address + } + var result = [GuestRange]() + result.reserveCapacity(sorted.count) + for range in sorted { + guard var last = result.last else { + result.append(range) + continue + } + if range.address <= last.end { + let end = max(last.end, range.end) + last.length = end - last.address + result[result.count - 1] = last + } else { + result.append(range) + } + } + return result + } + + public var statistics: VirtioBalloonStatistics { + VirtioBalloonStatistics( + reportRequests: reportRequests.load(ordering: .relaxed), + reportRejected: reportRejected.load(ordering: .relaxed), + reportBytes: reportBytes.load(ordering: .relaxed), + reclaimedBytes: reclaimedByteCount.load(ordering: .relaxed), + releaseFailures: releaseFailures.load(ordering: .relaxed), + classicRequests: classicRequests.load(ordering: .relaxed), + invalidClassicRequests: invalidClassicRequests.load(ordering: .relaxed), + queueFaults: queueFaults.load(ordering: .relaxed), + boundedDrainStops: boundedDrainStops.load(ordering: .relaxed), + workerTurns: workerTurns.load(ordering: .relaxed), + workerYields: workerYields.load(ordering: .relaxed), + coalescedWorkerRequests: coalescedWorkerRequests.load(ordering: .relaxed), + revokedWorkerTurns: revokedWorkerTurns.load(ordering: .relaxed), + reportProcessingNanoseconds: reportProcessingNanoseconds.load(ordering: .relaxed), + maximumReportProcessingNanoseconds: maximumReportProcessingNanoseconds.withLock { $0 } + ) + } + + /// Compatibility snapshots for existing telemetry callers. New code should consume + /// `statistics` so failures and rejected reports are not mistaken for successful reclaim. + public var reclaimedBytes: UInt64 { statistics.reclaimedBytes } + public var reportEvents: UInt64 { statistics.reportRequests } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift index 380b671c..647016b2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift @@ -1,19 +1,273 @@ import Darwin import Foundation +/// I/O, queue, and request event totals wrap modulo 2^64; the legacy flush totals saturate. +/// Queue depth and in-flight transfers are sampled gauges. High-watermarks and maximum latency +/// retain the largest value observed by this backend. public struct VirtioBlkStatistics: Equatable, Sendable { public var flushes: UInt64 public var maximumFlushLatencyNanoseconds: UInt64 public var slowFlushes: UInt64 + public var invalidRequests: UInt64 + public var queuePopFaults: UInt64 + public var completionFaults: UInt64 + public var boundedDrainStops: UInt64 + public var queueWorkTurns: UInt64 + public var queueDepth: UInt64 + public var queueHighWatermark: UInt64 + public var requestCompletions: UInt64 + public var revokedRequests: UInt64 + public var requestServiceLatencyNanoseconds: UInt64 + public var maximumRequestServiceLatencyNanoseconds: UInt64 + public var readRequests: UInt64 + public var writeRequests: UInt64 + public var readBytes: UInt64 + public var writeBytes: UInt64 + public var readSystemCalls: UInt64 + public var writeSystemCalls: UInt64 + public var partialIOSystemCalls: UInt64 + public var interruptedIOSystemCalls: UInt64 + public var failedIOSystemCalls: UInt64 + public var hostIOBudgetExhaustions: UInt64 + public var transferSegments: UInt64 + public var inFlightTransfers: UInt64 + public var maximumInFlightTransfers: UInt64 + public var discardRequests: UInt64 + public var discardRequestedBytes: UInt64 + public var discardHostOperations: UInt64 + public var discardIgnoredRanges: UInt64 + public var writeZeroesRequests: UInt64 + public var writeZeroesRequestedBytes: UInt64 + public var writeZeroesHostWrittenBytes: UInt64 + public var writeZeroesHostOperations: UInt64 + public var rangePartialHostOperations: UInt64 + public var rangeInterruptedHostOperations: UInt64 + public var rangeFailedHostOperations: UInt64 + public var rangeHostOperationBudgetExhaustions: UInt64 + public var rangeSegments: UInt64 + public var rangeTurnBudgetStops: UInt64 public init( flushes: UInt64, maximumFlushLatencyNanoseconds: UInt64, - slowFlushes: UInt64 + slowFlushes: UInt64, + invalidRequests: UInt64 = 0, + queuePopFaults: UInt64 = 0, + completionFaults: UInt64 = 0, + boundedDrainStops: UInt64 = 0, + queueWorkTurns: UInt64 = 0, + queueDepth: UInt64 = 0, + queueHighWatermark: UInt64 = 0, + requestCompletions: UInt64 = 0, + revokedRequests: UInt64 = 0, + requestServiceLatencyNanoseconds: UInt64 = 0, + maximumRequestServiceLatencyNanoseconds: UInt64 = 0, + readRequests: UInt64 = 0, + writeRequests: UInt64 = 0, + readBytes: UInt64 = 0, + writeBytes: UInt64 = 0, + readSystemCalls: UInt64 = 0, + writeSystemCalls: UInt64 = 0, + partialIOSystemCalls: UInt64 = 0, + interruptedIOSystemCalls: UInt64 = 0, + failedIOSystemCalls: UInt64 = 0, + hostIOBudgetExhaustions: UInt64 = 0, + transferSegments: UInt64 = 0, + inFlightTransfers: UInt64 = 0, + maximumInFlightTransfers: UInt64 = 0, + discardRequests: UInt64 = 0, + discardRequestedBytes: UInt64 = 0, + discardHostOperations: UInt64 = 0, + discardIgnoredRanges: UInt64 = 0, + writeZeroesRequests: UInt64 = 0, + writeZeroesRequestedBytes: UInt64 = 0, + writeZeroesHostWrittenBytes: UInt64 = 0, + writeZeroesHostOperations: UInt64 = 0, + rangePartialHostOperations: UInt64 = 0, + rangeInterruptedHostOperations: UInt64 = 0, + rangeFailedHostOperations: UInt64 = 0, + rangeHostOperationBudgetExhaustions: UInt64 = 0, + rangeSegments: UInt64 = 0, + rangeTurnBudgetStops: UInt64 = 0 ) { self.flushes = flushes self.maximumFlushLatencyNanoseconds = maximumFlushLatencyNanoseconds self.slowFlushes = slowFlushes + self.invalidRequests = invalidRequests + self.queuePopFaults = queuePopFaults + self.completionFaults = completionFaults + self.boundedDrainStops = boundedDrainStops + self.queueWorkTurns = queueWorkTurns + self.queueDepth = queueDepth + self.queueHighWatermark = queueHighWatermark + self.requestCompletions = requestCompletions + self.revokedRequests = revokedRequests + self.requestServiceLatencyNanoseconds = requestServiceLatencyNanoseconds + self.maximumRequestServiceLatencyNanoseconds = maximumRequestServiceLatencyNanoseconds + self.readRequests = readRequests + self.writeRequests = writeRequests + self.readBytes = readBytes + self.writeBytes = writeBytes + self.readSystemCalls = readSystemCalls + self.writeSystemCalls = writeSystemCalls + self.partialIOSystemCalls = partialIOSystemCalls + self.interruptedIOSystemCalls = interruptedIOSystemCalls + self.failedIOSystemCalls = failedIOSystemCalls + self.hostIOBudgetExhaustions = hostIOBudgetExhaustions + self.transferSegments = transferSegments + self.inFlightTransfers = inFlightTransfers + self.maximumInFlightTransfers = maximumInFlightTransfers + self.discardRequests = discardRequests + self.discardRequestedBytes = discardRequestedBytes + self.discardHostOperations = discardHostOperations + self.discardIgnoredRanges = discardIgnoredRanges + self.writeZeroesRequests = writeZeroesRequests + self.writeZeroesRequestedBytes = writeZeroesRequestedBytes + self.writeZeroesHostWrittenBytes = writeZeroesHostWrittenBytes + self.writeZeroesHostOperations = writeZeroesHostOperations + self.rangePartialHostOperations = rangePartialHostOperations + self.rangeInterruptedHostOperations = rangeInterruptedHostOperations + self.rangeFailedHostOperations = rangeFailedHostOperations + self.rangeHostOperationBudgetExhaustions = rangeHostOperationBudgetExhaustions + self.rangeSegments = rangeSegments + self.rangeTurnBudgetStops = rangeTurnBudgetStops + } +} + +struct VirtioBlkLimits: Equatable, Sendable { + static let production = VirtioBlkLimits( + maximumTransferBytes: 16 * 1_024 * 1_024, + maximumChainsPerDrain: 64, + maximumTransferBytesPerDrain: 64 * 1_024 * 1_024, + maximumIOVectorsPerSystemCall: 256, + maximumHostIOOperationsPerRequest: 1_024, + maximumDiscardSegmentsPerRequest: 64, + maximumWriteZeroesSegmentsPerRequest: 4, + maximumWriteZeroesBytesPerRequest: 16 * 1_024 * 1_024, + maximumRangeHostOperationsPerRequest: 64, + maximumRangeHostOperationsPerDrain: 64 + ) + + let maximumTransferBytes: Int + let maximumChainsPerDrain: Int + let maximumTransferBytesPerDrain: Int + let maximumIOVectorsPerSystemCall: Int + let maximumHostIOOperationsPerRequest: Int + let maximumDiscardSegmentsPerRequest: Int + let maximumWriteZeroesSegmentsPerRequest: Int + let maximumWriteZeroesBytesPerRequest: Int + let maximumRangeHostOperationsPerRequest: Int + let maximumRangeHostOperationsPerDrain: Int + + init( + maximumTransferBytes: Int, + maximumChainsPerDrain: Int, + maximumTransferBytesPerDrain: Int = 64 * 1_024 * 1_024, + maximumIOVectorsPerSystemCall: Int = 256, + maximumHostIOOperationsPerRequest: Int = 1_024, + maximumDiscardSegmentsPerRequest: Int = 64, + maximumWriteZeroesSegmentsPerRequest: Int = 4, + maximumWriteZeroesBytesPerRequest: Int = 16 * 1_024 * 1_024, + maximumRangeHostOperationsPerRequest: Int = 64, + maximumRangeHostOperationsPerDrain: Int = 64 + ) { + precondition(maximumTransferBytes >= 512) + precondition(maximumTransferBytes % 512 == 0) + precondition(maximumChainsPerDrain > 0) + precondition(maximumChainsPerDrain <= Int(Virtqueue.maximumSize)) + precondition(maximumTransferBytesPerDrain >= maximumTransferBytes) + precondition(maximumIOVectorsPerSystemCall > 0) + precondition(maximumIOVectorsPerSystemCall <= 256) + precondition(maximumHostIOOperationsPerRequest > 0) + precondition((1...256).contains(maximumDiscardSegmentsPerRequest)) + precondition((1...256).contains(maximumWriteZeroesSegmentsPerRequest)) + precondition(maximumWriteZeroesBytesPerRequest >= 512) + precondition(maximumWriteZeroesBytesPerRequest % 512 == 0) + precondition( + maximumWriteZeroesBytesPerRequest / 512 + >= maximumWriteZeroesSegmentsPerRequest + ) + precondition( + UInt64( + maximumWriteZeroesBytesPerRequest + / maximumWriteZeroesSegmentsPerRequest / 512 + ) <= UInt64(UInt32.max) + ) + precondition(maximumRangeHostOperationsPerRequest > 0) + precondition(maximumDiscardSegmentsPerRequest <= maximumRangeHostOperationsPerRequest) + precondition(maximumRangeHostOperationsPerDrain > 0) + self.maximumTransferBytes = maximumTransferBytes + self.maximumChainsPerDrain = maximumChainsPerDrain + self.maximumTransferBytesPerDrain = maximumTransferBytesPerDrain + self.maximumIOVectorsPerSystemCall = maximumIOVectorsPerSystemCall + self.maximumHostIOOperationsPerRequest = maximumHostIOOperationsPerRequest + self.maximumDiscardSegmentsPerRequest = maximumDiscardSegmentsPerRequest + self.maximumWriteZeroesSegmentsPerRequest = maximumWriteZeroesSegmentsPerRequest + self.maximumWriteZeroesBytesPerRequest = maximumWriteZeroesBytesPerRequest + self.maximumRangeHostOperationsPerRequest = maximumRangeHostOperationsPerRequest + self.maximumRangeHostOperationsPerDrain = maximumRangeHostOperationsPerDrain + } +} + +struct VirtioBlkHostIOResult: Equatable, Sendable { + var count: Int + var code: Int32 +} + +struct VirtioBlkIOOperations: @unchecked Sendable { + typealias VectoredOperation = ( + Int32, + UnsafePointer, + Int32, + off_t + ) -> VirtioBlkHostIOResult + + var read: VectoredOperation + var write: VectoredOperation + var monotonicNanoseconds: () -> UInt64 + + static var production: Self { + Self( + read: { descriptor, vectors, count, offset in + let result = Darwin.preadv(descriptor, vectors, count, offset) + return VirtioBlkHostIOResult( + count: result, + code: result < 0 ? errno : 0 + ) + }, + write: { descriptor, vectors, count, offset in + let result = Darwin.pwritev(descriptor, vectors, count, offset) + return VirtioBlkHostIOResult( + count: result, + code: result < 0 ? errno : 0 + ) + }, + monotonicNanoseconds: { DispatchTime.now().uptimeNanoseconds } + ) + } +} + +struct VirtioBlkRangeOperations: @unchecked Sendable { + typealias PunchHoleOperation = (Int32, off_t, off_t) -> VirtioBlkHostIOResult + + var punchHole: PunchHoleOperation + + static var production: Self { + Self(punchHole: { descriptor, offset, length in + var punch = fpunchhole_t( + fp_flags: 0, + reserved: 0, + fp_offset: offset, + fp_length: length + ) + let result = withUnsafeMutablePointer(to: &punch) { + fcntl(descriptor, F_PUNCHHOLE, $0) + } + return VirtioBlkHostIOResult( + count: Int(result), + code: result < 0 ? errno : 0 + ) + }) } } @@ -31,13 +285,14 @@ struct VirtioBlkFlushTelemetryConfiguration { } } -/// virtio-blk backed by a raw disk image. Requests use zero-copy pread/pwrite straight into guest +/// virtio-blk backed by a raw disk image. Requests use zero-copy preadv/pwritev straight into guest /// RAM; disk I/O is drained on dedicated ordered workers so the kicking vCPU is not parked inside -/// host file syscalls during metadata-heavy workloads. The device exposes a small multiqueue setup -/// by default; set `DORY_BLK_QUEUES=1` to force the legacy single-queue shape. +/// host file syscalls during metadata-heavy workloads. Production policy is explicit at the +/// initializer boundary; ambient process environment cannot silently change the guest ABI. public final class VirtioBlk: VirtioDeviceBackend { public let deviceID: UInt32 = 2 public let queueCount: Int + public let kickSynchronization: VirtioKickSynchronization = .backendManaged public var deviceFeatures: UInt64 { var features = Self.Feature.flush if readOnly { @@ -54,23 +309,163 @@ public final class VirtioBlk: VirtioDeviceBackend { private let fileDescriptor: Int32 private let capacitySectors: UInt64 + private let capacityBytes: UInt64 private let identity: String private let readOnly: Bool private let asyncIO: Bool private let discardEnabled: Bool private let discardBlockSize: Int + private let limits: VirtioBlkLimits + private let ioOperations: VirtioBlkIOOperations + private let rangeOperations: VirtioBlkRangeOperations private let ioQueues: [DispatchQueue] + private let ioQueueKey = DispatchSpecificKey() private let drainLock = NSLock() - private var activeDrainers: [Bool] - private var kickGenerations: [UInt64] + private var deviceIsReady = false + private var drainIsTerminal = false + private var queueDrainStates: [QueueDrainState] + private var queueDepthHighWatermark = 0 private let requestCondition = NSCondition() private var inFlightTransfers = 0 + private var maximumInFlightTransferCount = 0 private var flushActive = false private let flushTelemetry: VirtioBlkFlushTelemetryConfiguration private let statisticsLock = NSLock() private var flushCount: UInt64 = 0 private var maximumFlushLatencyNanoseconds: UInt64 = 0 private var slowFlushCount: UInt64 = 0 + private var invalidRequestCount: UInt64 = 0 + private var queuePopFaultCount: UInt64 = 0 + private var completionFaultCount: UInt64 = 0 + private var boundedDrainStopCount: UInt64 = 0 + private var queueWorkTurnCount: UInt64 = 0 + private var requestCompletionCount: UInt64 = 0 + private var revokedRequestCount: UInt64 = 0 + private var requestServiceLatencyNanoseconds: UInt64 = 0 + private var maximumRequestServiceLatencyNanoseconds: UInt64 = 0 + private var readRequestCount: UInt64 = 0 + private var writeRequestCount: UInt64 = 0 + private var readByteCount: UInt64 = 0 + private var writeByteCount: UInt64 = 0 + private var readSystemCallCount: UInt64 = 0 + private var writeSystemCallCount: UInt64 = 0 + private var partialIOSystemCallCount: UInt64 = 0 + private var interruptedIOSystemCallCount: UInt64 = 0 + private var failedIOSystemCallCount: UInt64 = 0 + private var hostIOBudgetExhaustionCount: UInt64 = 0 + private var transferSegmentCount: UInt64 = 0 + private var discardRequestCount: UInt64 = 0 + private var discardRequestedByteCount: UInt64 = 0 + private var discardHostOperationCount: UInt64 = 0 + private var discardIgnoredRangeCount: UInt64 = 0 + private var writeZeroesRequestCount: UInt64 = 0 + private var writeZeroesRequestedByteCount: UInt64 = 0 + private var writeZeroesHostWrittenByteCount: UInt64 = 0 + private var writeZeroesHostOperationCount: UInt64 = 0 + private var rangePartialHostOperationCount: UInt64 = 0 + private var rangeInterruptedHostOperationCount: UInt64 = 0 + private var rangeFailedHostOperationCount: UInt64 = 0 + private var rangeHostOperationBudgetExhaustionCount: UInt64 = 0 + private var rangeSegmentCount: UInt64 = 0 + private var rangeTurnBudgetStopCount: UInt64 = 0 + + private struct QueueDrainState { + var generation: UInt64 = 1 + var transportIdentity: ObjectIdentifier? + var activeGeneration: UInt64? + var kickPending = false + var queueDepth = 0 + + mutating func advanceGeneration() { + generation &+= 1 + if generation == 0 { generation = 1 } + } + + mutating func revoke(replacementTransportIdentity: ObjectIdentifier?) { + advanceGeneration() + transportIdentity = replacementTransportIdentity + activeGeneration = nil + kickPending = false + queueDepth = 0 + } + } + + private struct DrainEpoch: Sendable { + let queue: Int + let generation: UInt64 + let transportIdentity: ObjectIdentifier + } + + private enum DrainBatchOutcome { + case drained + case pending + case fault + case stale + } + + private enum QueuePopOutcome { + case chain(VirtqueueChain) + case empty + case fault + case stale + } + + private enum QueueDepthOutcome { + case depth(Int) + case fault + case stale + } + + private enum QueuePushOutcome { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private struct RequestExecution { + let written: Int + let workBytes: Int + let rangeHostOperations: Int + + init(written: Int, workBytes: Int, rangeHostOperations: Int = 0) { + self.written = written + self.workBytes = workBytes + self.rangeHostOperations = rangeHostOperations + } + } + + private struct HostTransferReceipt { + var status: RequestStatus + var actualBytes = 0 + var systemCalls = 0 + var partialSystemCalls = 0 + var interruptedSystemCalls = 0 + var failedSystemCalls = 0 + var budgetExhaustions = 0 + } + + private struct RangeCommandExecution { + var status: RequestStatus + var requestedBytes: UInt64 = 0 + var hostWrittenBytes = 0 + var hostOperations = 0 + var partialHostOperations = 0 + var interruptedHostOperations = 0 + var failedHostOperations = 0 + var hostOperationBudgetExhaustions = 0 + var segmentCount = 0 + var ignoredDiscardRanges = 0 + var fairnessBytes = 0 + + mutating func mergeHostTransfer(_ receipt: HostTransferReceipt) { + hostWrittenBytes &+= receipt.actualBytes + hostOperations &+= receipt.systemCalls + partialHostOperations &+= receipt.partialSystemCalls + interruptedHostOperations &+= receipt.interruptedSystemCalls + failedHostOperations &+= receipt.failedSystemCalls + hostOperationBudgetExhaustions &+= receipt.budgetExhaustions + } + } private enum Feature { static let readOnly: UInt64 = 1 << 5 // VIRTIO_BLK_F_RO @@ -80,11 +475,11 @@ public final class VirtioBlk: VirtioDeviceBackend { static let writeZeroes: UInt64 = 1 << 14 // VIRTIO_BLK_F_WRITE_ZEROES } - // Per-segment discard/write-zeroes tunables surfaced in config space. Generous single-segment caps - // (2 GiB) keep fstrim from fragmenting into many round trips; punch-hole handles any length. + // DISCARD remains range-efficient because one aligned range is one hole-punch operation. Plain + // WRITE_ZEROES is intentionally advertised as at most 16 MiB across four segments: the host + // must write those bytes to preserve allocation, and the guest splits larger work fairly. private enum Discard { static let maxSectors: UInt32 = 1 << 22 // 2 GiB / 512 - static let maxSegments: UInt32 = 256 static let sectorAlignment: UInt32 = 1 static let entryByteCount = 16 // struct virtio_blk_discard_write_zeroes static let unmapFlag: UInt32 = 1 << 0 // VIRTIO_BLK_WRITE_ZEROES_FLAG_UNMAP @@ -105,11 +500,48 @@ public final class VirtioBlk: VirtioDeviceBackend { case unsupported = 2 } + struct ByteRange: Equatable { + let offset: off_t + let length: off_t + } + + private struct BackingFile { + let descriptor: Int32 + let capacitySectors: UInt64 + let capacityBytes: UInt64 + let discardBlockSize: Int + } + + private struct DiscardOperation { + let range: ByteRange + let deallocate: Bool + } + public convenience init( path: String, identity: String, readOnly: Bool = false, - asyncIO: Bool? = nil, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + path: path, + identity: identity, + readOnly: readOnly, + asyncIO: true, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + /// Synchronous execution exists only as a deterministic unit-test seam. Production callers + /// cannot opt a vCPU back into host file I/O through the public initializer. + convenience init( + path: String, + identity: String, + readOnly: Bool = false, + asyncIO: Bool, queueCount requestedQueueCount: Int? = nil, discard: Bool? = nil ) throws { @@ -124,46 +556,233 @@ public final class VirtioBlk: VirtioDeviceBackend { ) } - init( + convenience init( path: String, identity: String, readOnly: Bool = false, - asyncIO: Bool? = nil, + asyncIO: Bool = true, queueCount requestedQueueCount: Int? = nil, discard: Bool? = nil, - flushTelemetry: VirtioBlkFlushTelemetryConfiguration + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits = .production, + ioOperations: VirtioBlkIOOperations = .production, + rangeOperations: VirtioBlkRangeOperations = .production ) throws { - let descriptor = open(path, readOnly ? O_RDONLY : O_RDWR) + let descriptor = open( + path, + (readOnly ? O_RDONLY : O_RDWR) | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) guard descriptor >= 0 else { throw VMError.invalidConfiguration("cannot open disk image \(path): errno \(errno)") } - var info = stat() - guard fstat(descriptor, &info) == 0 else { + do { + let backing = try Self.inspectOwnedDescriptor( + descriptor, + readOnly: readOnly, + description: "disk image \(path)" + ) + try self.init( + backing: backing, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: flushTelemetry, + limits: limits, + ioOperations: ioOperations, + rangeOperations: rangeOperations + ) + } catch { close(descriptor) - throw VMError.invalidConfiguration("cannot stat disk image \(path)") + throw error } - self.fileDescriptor = descriptor - self.capacitySectors = UInt64(info.st_size) / 512 + } + + /// Creates a block backend from an already-open regular-file descriptor. The descriptor is + /// duplicated with close-on-exec, so the caller retains ownership of the original descriptor. + /// This is the seam a future broker can use to hand an authorized image descriptor to the VMM + /// without making the VMM resolve a path itself. + public convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + identity: identity, + readOnly: readOnly, + asyncIO: true, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + asyncIO: Bool, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + asyncIO: Bool = true, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits = .production, + ioOperations: VirtioBlkIOOperations = .production, + rangeOperations: VirtioBlkRangeOperations = .production + ) throws { + let descriptor = fcntl(fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration("cannot duplicate disk image descriptor: errno \(errno)") + } + do { + let backing = try Self.inspectOwnedDescriptor( + descriptor, + readOnly: readOnly, + description: "disk image descriptor" + ) + try self.init( + backing: backing, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: flushTelemetry, + limits: limits, + ioOperations: ioOperations, + rangeOperations: rangeOperations + ) + } catch { + close(descriptor) + throw error + } + } + + private init( + backing: BackingFile, + identity: String, + readOnly: Bool, + asyncIO: Bool, + queueCount requestedQueueCount: Int?, + discard: Bool?, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits, + ioOperations: VirtioBlkIOOperations, + rangeOperations: VirtioBlkRangeOperations + ) throws { + let resolvedQueueCount = requestedQueueCount ?? 1 + guard (1...16).contains(resolvedQueueCount) else { + throw VMError.invalidConfiguration( + "virtio-blk queue count must be resolved within 1...16" + ) + } + self.fileDescriptor = backing.descriptor + self.capacitySectors = backing.capacitySectors + self.capacityBytes = backing.capacityBytes self.identity = identity self.readOnly = readOnly - self.asyncIO = asyncIO ?? Self.asyncIOEnabledFromEnvironment() + self.asyncIO = asyncIO self.flushTelemetry = flushTelemetry // Discard/write-zeroes only make sense on a writable image; keep them off for read-only shares. - self.discardEnabled = !readOnly && (discard ?? Self.discardEnabledFromEnvironment()) + self.discardEnabled = !readOnly && (discard ?? true) + self.discardBlockSize = backing.discardBlockSize + self.limits = limits + self.ioOperations = ioOperations + self.rangeOperations = rangeOperations + self.queueCount = resolvedQueueCount + self.ioQueues = (0.. BackingFile { + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw VMError.invalidConfiguration("cannot stat \(description): errno \(errno)") + } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw VMError.invalidConfiguration("\(description) is not a regular file") + } + guard info.st_size >= 0 else { + throw VMError.invalidConfiguration("\(description) has an invalid size") + } + + let descriptorFlags = fcntl(descriptor, F_GETFL) + guard descriptorFlags >= 0 else { + throw VMError.invalidConfiguration("cannot inspect \(description) access mode: errno \(errno)") + } + let accessMode = descriptorFlags & O_ACCMODE + guard accessMode != O_WRONLY, readOnly || accessMode == O_RDWR else { + throw VMError.invalidConfiguration("\(description) does not have the required access mode") + } + + // Virtio-blk advertises capacity in complete 512-byte sectors. Freeze the addressable byte + // capacity at construction so later requests cannot grow the image or reach a trailing + // partial sector even if the path is replaced or the backing file is subsequently enlarged. + let fileSize = UInt64(info.st_size) + let capacitySectors = fileSize / 512 + let capacityBytes = capacitySectors * 512 + // F_PUNCHHOLE requires fs-block alignment; capture the backing filesystem's block size so // sub-block discard slivers can be zero-written instead of failing the whole request. var fsInfo = statfs() let blockSize = fstatfs(descriptor, &fsInfo) == 0 ? Int(fsInfo.f_bsize) : 4096 - self.discardBlockSize = blockSize > 0 ? blockSize : 4096 - self.queueCount = Self.clampedQueueCount(requestedQueueCount ?? Self.queueCountFromEnvironment()) - self.ioQueues = (0.. 0 ? blockSize : 4096 + ) } deinit { + drainLock.withLock { + drainIsTerminal = true + deviceIsReady = false + for index in queueDrainStates.indices { + queueDrainStates[index].revoke(replacementTransportIdentity: nil) + } + } + // Enqueued work captures the backend weakly. Join every other queue so no task can acquire + // the backing descriptor after this point; an executing task retains self until its one + // bounded turn has returned, so deinit can only run on that queue after its host I/O ends. + let currentQueue = DispatchQueue.getSpecific(key: ioQueueKey) + for (index, queue) in ioQueues.enumerated() where currentQueue != index { + queue.sync {} + } close(fileDescriptor) } @@ -173,79 +792,374 @@ public final class VirtioBlk: VirtioDeviceBackend { config.append(contentsOf: Array(repeating: 0, count: 26)) // @8..33 withUnsafeBytes(of: UInt16(queueCount).littleEndian) { config.append(contentsOf: $0) } // num_queues @34 guard discardEnabled else { return config } + let maximumDiscardSegments = UInt32(limits.maximumDiscardSegmentsPerRequest) + let maximumWriteZeroesSectors = UInt32( + limits.maximumWriteZeroesBytesPerRequest + / limits.maximumWriteZeroesSegmentsPerRequest / 512 + ) + let maximumWriteZeroesSegments = UInt32( + limits.maximumWriteZeroesSegmentsPerRequest + ) withUnsafeBytes(of: Discard.maxSectors.littleEndian) { config.append(contentsOf: $0) } // max_discard_sectors @36 - withUnsafeBytes(of: Discard.maxSegments.littleEndian) { config.append(contentsOf: $0) } // max_discard_seg @40 + withUnsafeBytes(of: maximumDiscardSegments.littleEndian) { config.append(contentsOf: $0) } // max_discard_seg @40 withUnsafeBytes(of: Discard.sectorAlignment.littleEndian) { config.append(contentsOf: $0) } // discard_sector_alignment @44 - withUnsafeBytes(of: Discard.maxSectors.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_sectors @48 - withUnsafeBytes(of: Discard.maxSegments.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_seg @52 + withUnsafeBytes(of: maximumWriteZeroesSectors.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_sectors @48 + withUnsafeBytes(of: maximumWriteZeroesSegments.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_seg @52 config.append(1) // write_zeroes_may_unmap @56 config.append(contentsOf: [0, 0, 0]) // unused @57..59 return config } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - guard queue >= 0, queue < queueCount else { return } - guard asyncIO else { - drainInline(queue: queue, transport: transport) - return - } - let shouldStart: Bool = drainLock.withLock { - kickGenerations[queue] &+= 1 - guard !activeDrainers[queue] else { return false } - activeDrainers[queue] = true - return true + guard (0.. DrainEpoch? { + let identity = ObjectIdentifier(transport) + return drainLock.withLock { + guard !drainIsTerminal, + deviceIsReady, + queueDrainStates[queue].transportIdentity == identity else { return nil } + queueDrainStates[queue].kickPending = true + guard queueDrainStates[queue].activeGeneration == nil else { return nil } + queueDrainStates[queue].kickPending = false + let generation = queueDrainStates[queue].generation + queueDrainStates[queue].activeGeneration = generation + return DrainEpoch( + queue: queue, + generation: generation, + transportIdentity: identity + ) + } + } + + private func enqueueDrain(_ epoch: DrainEpoch, transport: VirtioMMIOTransport) { + ioQueues[epoch.queue].async { [weak self, weak transport] in + guard let self, let transport else { return } + let outcome = self.drainBatch(epoch: epoch, transport: transport) + self.finishDrain( + epoch, + transport: transport, + outcome: outcome, + mayContinue: true + ) + } + } + + private func finishDrain( + _ epoch: DrainEpoch, + transport: VirtioMMIOTransport, + outcome: DrainBatchOutcome, + mayContinue: Bool + ) { + let shouldContinue = drainLock.withLock { () -> Bool in + guard isCurrentLocked(epoch) else { return false } + let hasNewKick = queueDrainStates[epoch.queue].kickPending + switch outcome { + case .pending where mayContinue: + queueDrainStates[epoch.queue].kickPending = false + return true + case .fault where mayContinue && hasNewKick: + queueDrainStates[epoch.queue].kickPending = false + return true + case .drained where mayContinue && hasNewKick: + queueDrainStates[epoch.queue].kickPending = false + return true + case .drained, .pending, .fault: + queueDrainStates[epoch.queue].activeGeneration = nil + return false + case .stale: + return false + } + } + if shouldContinue { enqueueDrain(epoch, transport: transport) } + } + + private func drainBatch( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> DrainBatchOutcome { + guard isCurrent(epoch) else { return .stale } + recordQueueWorkTurn() var interrupt = false - while let chain = (transport.withQueueLock { (try? virtqueue.pop()) ?? nil }) { - let written = process(chain: chain) - let wants = transport.withQueueLock { (try? virtqueue.push(chain, written: written)) ?? false } - interrupt = interrupt || wants + var handled = 0 + var processedBytes = 0 + var processedRangeHostOperations = 0 + + switch pendingDepth(epoch: epoch, transport: transport) { + case let .depth(depth): + observeQueueDepth(depth, epoch: epoch) + if depth == 0 { return .drained } + case .fault: + recordQueuePopFault() + return .fault + case .stale: + return .stale } - if interrupt { - transport.notifyUsed() + + while handled < limits.maximumChainsPerDrain, + processedBytes < limits.maximumTransferBytesPerDrain, + processedRangeHostOperations < limits.maximumRangeHostOperationsPerDrain { + let chain: VirtqueueChain + switch popNext(epoch: epoch, transport: transport) { + case let .chain(next): + chain = next + case .empty: + observeQueueDepth(0, epoch: epoch) + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .drained + case .fault: + recordQueuePopFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + case .stale: + return .stale + } + handled += 1 + + let startedAt = ioOperations.monotonicNanoseconds() + guard let execution = process(chain: chain, epoch: epoch) else { + recordRevokedRequest() + return .stale + } + let (nextProcessedBytes, byteCountOverflow) = processedBytes.addingReportingOverflow( + execution.workBytes + ) + processedBytes = byteCountOverflow ? Int.max : nextProcessedBytes + let (nextRangeOperations, rangeOperationOverflow) = + processedRangeHostOperations.addingReportingOverflow( + execution.rangeHostOperations + ) + processedRangeHostOperations = rangeOperationOverflow + ? Int.max : nextRangeOperations + switch pushCompletion( + chain, + written: execution.written, + epoch: epoch, + transport: transport + ) { + case let .published(wantsInterrupt): + interrupt = wantsInterrupt || interrupt + recordRequestCompletion(startedAt: startedAt) + case .stale: + recordRevokedRequest() + return .stale + case .fault: + // Host I/O may already have committed. Surface the failed publication as an exact + // outcome-unknown boundary instead of converting it into an empty queue. + recordCompletionFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + } + } + + switch pendingDepth(epoch: epoch, transport: transport) { + case let .depth(depth) where depth > 0: + observeQueueDepth(depth, epoch: epoch) + recordBoundedDrainStop() + if processedRangeHostOperations >= limits.maximumRangeHostOperationsPerDrain { + recordRangeTurnBudgetStop() + } + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .pending + case .depth: + observeQueueDepth(0, epoch: epoch) + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .drained + case .fault: + recordQueuePopFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + case .stale: + return .stale } } - private func drain(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[queue] - while true { - let generation = drainLock.withLock { kickGenerations[queue] } - var interrupt = false - while let chain = (transport.withQueueLock { (try? virtqueue.pop()) ?? nil }) { - let written = process(chain: chain) - let wants = transport.withQueueLock { (try? virtqueue.push(chain, written: written)) ?? false } - interrupt = interrupt || wants + private func popNext( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueuePopOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + return try transport.queues[epoch.queue].pop().map(QueuePopOutcome.chain) ?? .empty + } catch { + return .fault } - if interrupt { - transport.notifyUsed() + } + } + + private func pendingDepth( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueueDepthOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + return .depth(Int(try transport.queues[epoch.queue].pendingCount())) + } catch { + return .fault } - let exit = drainLock.withLock { - guard kickGenerations[queue] == generation else { return false } - activeDrainers[queue] = false - return true + } + } + + private func pushCompletion( + _ chain: VirtqueueChain, + written: Int, + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueuePushOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + switch try transport.queues[epoch.queue].pushOutcome(chain, written: written) { + case let .published(wantsInterrupt): + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + return isCurrent(epoch) ? .fault : .stale + } + } catch { + return isCurrent(epoch) ? .fault : .stale + } + } + } + + private func process( + chain: VirtqueueChain, + epoch: DrainEpoch + ) -> RequestExecution? { + // Disk I/O may outlive the queue kick. Hold the chain lease for every direct guest-pointer + // read/write so reset or QueueReady reconfiguration cannot let the guest repurpose the + // buffer until the host operation and status byte are complete. Admission orders the + // lifecycle lock before the lease: a reset either revokes this work first or waits for the + // one already-admitted bounded request, never allowing post-reset I/O to begin. + drainLock.lock() + guard isCurrentLocked(epoch) else { + drainLock.unlock() + return nil + } + var enteredLease = false + let execution = chain.withLeaseHeld { access in + enteredLease = true + drainLock.unlock() + return process( + segments: access.segments, + containsZeroLengthDescriptor: chain.containsZeroLengthDescriptor + ) + } + if !enteredLease { drainLock.unlock() } + return execution + } + + private func isCurrent(_ epoch: DrainEpoch) -> Bool { + drainLock.withLock { isCurrentLocked(epoch) } + } + + private func isCurrentLocked(_ epoch: DrainEpoch) -> Bool { + !drainIsTerminal + && deviceIsReady + && queueDrainStates.indices.contains(epoch.queue) + && queueDrainStates[epoch.queue].generation == epoch.generation + && queueDrainStates[epoch.queue].activeGeneration == epoch.generation + && queueDrainStates[epoch.queue].transportIdentity == epoch.transportIdentity + } + + private func observeQueueDepth(_ depth: Int, epoch: DrainEpoch) { + drainLock.withLock { + guard isCurrentLocked(epoch) else { return } + queueDrainStates[epoch.queue].queueDepth = max(0, depth) + let aggregate = queueDrainStates.reduce(0) { partial, state in + let (next, overflow) = partial.addingReportingOverflow(state.queueDepth) + return overflow ? Int.max : next } - if exit { break } + queueDepthHighWatermark = max(queueDepthHighWatermark, aggregate) } } - private func process(chain: VirtqueueChain) -> Int { - let segments = chain.segments + private func notifyIfCurrent( + _ wantsInterrupt: Bool, + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) { + if wantsInterrupt, isCurrent(epoch) { transport.notifyUsed() } + } + + private func process( + segments: [VirtqueueSegment], + containsZeroLengthDescriptor: Bool + ) -> RequestExecution { guard segments.count >= 2, - !segments[0].isDeviceWritable, segments[0].length >= 16, + !segments[0].isDeviceWritable, segments[0].length == 16, let statusSegment = segments.last, statusSegment.isDeviceWritable, statusSegment.length >= 1 else { - return 0 + recordInvalidRequest() + return RequestExecution(written: 0, workBytes: 0) + } + + guard !containsZeroLengthDescriptor else { + statusSegment.pointer.storeBytes(of: RequestStatus.ioError.rawValue, as: UInt8.self) + recordInvalidRequest() + return RequestExecution(written: 1, workBytes: 0) } let header = segments[0].pointer let rawType = header.loadUnaligned(fromByteOffset: 0, as: UInt32.self) - let sector = header.loadUnaligned(fromByteOffset: 8, as: UInt64.self) + let sector = UInt64(littleEndian: header.loadUnaligned(fromByteOffset: 8, as: UInt64.self)) let dataSegments = segments[1..<(segments.count - 1)] + var workBytes = dataSegments.reduce(into: 0) { total, segment in + let (next, overflow) = total.addingReportingOverflow(segment.length) + total = overflow ? Int.max : next + } + var rangeHostOperations = 0 var written = 0 let status: RequestStatus @@ -259,47 +1173,98 @@ public final class VirtioBlk: VirtioDeviceBackend { transfer(dataSegments, from: sector, into: &written, reading: false) } case .discard: - status = readOnly ? .ioError : withTransferPermit { - applyDiscardOrWriteZeroes(dataSegments, writeZeroes: false) + if readOnly { + status = .ioError + } else { + let execution = withTransferPermit { + executeDiscardOrWriteZeroes(dataSegments, writeZeroes: false) + } + status = execution.status + workBytes = max(workBytes, execution.fairnessBytes) + rangeHostOperations = execution.hostOperations } case .writeZeroes: - status = readOnly ? .ioError : withTransferPermit { - applyDiscardOrWriteZeroes(dataSegments, writeZeroes: true) + if readOnly { + status = .ioError + } else { + let execution = withTransferPermit { + executeDiscardOrWriteZeroes(dataSegments, writeZeroes: true) + } + status = execution.status + workBytes = max(workBytes, execution.fairnessBytes) + rangeHostOperations = execution.hostOperations } case .flush: - status = flush() + // VIRTIO_BLK_T_FLUSH has no data payload. Reject malformed chains instead of silently + // treating guest-provided buffers as part of a valid durability operation. + if dataSegments.isEmpty { + status = flush() + } else { + recordInvalidRequest() + status = .ioError + } case .getID: - let id = [UInt8](identity.utf8.prefix(20)) - for segment in dataSegments where segment.isDeviceWritable { - let count = min(segment.length, id.count) - id.withUnsafeBytes { segment.pointer.copyMemory(from: $0.baseAddress!, byteCount: count) } - written += count - break - } - status = .ok + written = writeIdentity(into: dataSegments) + if written == 20 { + status = .ok + } else { + recordInvalidRequest() + status = .ioError + } case nil: status = .unsupported } statusSegment.pointer.storeBytes(of: status.rawValue, as: UInt8.self) - return written + 1 + return RequestExecution( + written: written + 1, + workBytes: workBytes, + rangeHostOperations: rangeHostOperations + ) } - private func withTransferPermit(_ body: () -> RequestStatus) -> RequestStatus { + private func writeIdentity(into segments: ArraySlice) -> Int { + let identityByteCount = 20 + guard !segments.isEmpty, + segments.allSatisfy({ $0.isDeviceWritable && $0.length > 0 }) else { return 0 } + var capacity = 0 + for segment in segments { + let (next, overflow) = capacity.addingReportingOverflow(segment.length) + guard !overflow else { return 0 } + capacity = next + } + guard capacity >= identityByteCount else { return 0 } + + var encoded = [UInt8](repeating: 0, count: identityByteCount) + let source = Array(identity.utf8.prefix(identityByteCount)) + encoded.replaceSubrange(0..(_ body: () -> Result) -> Result { requestCondition.lock() while flushActive { requestCondition.wait() } inFlightTransfers += 1 + maximumInFlightTransferCount = max(maximumInFlightTransferCount, inFlightTransfers) requestCondition.unlock() - let status = body() + let result = body() requestCondition.lock() inFlightTransfers -= 1 requestCondition.broadcast() requestCondition.unlock() - return status + return result } func flush() -> RequestStatus { @@ -335,176 +1300,598 @@ public final class VirtioBlk: VirtioDeviceBackend { } public var statistics: VirtioBlkStatistics { - statisticsLock.withLock { + let queueGauges = drainLock.withLock { + ( + depth: queueDrainStates.reduce(0) { $0 + $1.queueDepth }, + highWatermark: queueDepthHighWatermark + ) + } + let transferGauges: (current: Int, maximum: Int) = { + requestCondition.lock() + defer { requestCondition.unlock() } + return (inFlightTransfers, maximumInFlightTransferCount) + }() + return statisticsLock.withLock { VirtioBlkStatistics( flushes: flushCount, maximumFlushLatencyNanoseconds: maximumFlushLatencyNanoseconds, - slowFlushes: slowFlushCount + slowFlushes: slowFlushCount, + invalidRequests: invalidRequestCount, + queuePopFaults: queuePopFaultCount, + completionFaults: completionFaultCount, + boundedDrainStops: boundedDrainStopCount, + queueWorkTurns: queueWorkTurnCount, + queueDepth: UInt64(queueGauges.depth), + queueHighWatermark: UInt64(queueGauges.highWatermark), + requestCompletions: requestCompletionCount, + revokedRequests: revokedRequestCount, + requestServiceLatencyNanoseconds: requestServiceLatencyNanoseconds, + maximumRequestServiceLatencyNanoseconds: + maximumRequestServiceLatencyNanoseconds, + readRequests: readRequestCount, + writeRequests: writeRequestCount, + readBytes: readByteCount, + writeBytes: writeByteCount, + readSystemCalls: readSystemCallCount, + writeSystemCalls: writeSystemCallCount, + partialIOSystemCalls: partialIOSystemCallCount, + interruptedIOSystemCalls: interruptedIOSystemCallCount, + failedIOSystemCalls: failedIOSystemCallCount, + hostIOBudgetExhaustions: hostIOBudgetExhaustionCount, + transferSegments: transferSegmentCount, + inFlightTransfers: UInt64(transferGauges.current), + maximumInFlightTransfers: UInt64(transferGauges.maximum), + discardRequests: discardRequestCount, + discardRequestedBytes: discardRequestedByteCount, + discardHostOperations: discardHostOperationCount, + discardIgnoredRanges: discardIgnoredRangeCount, + writeZeroesRequests: writeZeroesRequestCount, + writeZeroesRequestedBytes: writeZeroesRequestedByteCount, + writeZeroesHostWrittenBytes: writeZeroesHostWrittenByteCount, + writeZeroesHostOperations: writeZeroesHostOperationCount, + rangePartialHostOperations: rangePartialHostOperationCount, + rangeInterruptedHostOperations: rangeInterruptedHostOperationCount, + rangeFailedHostOperations: rangeFailedHostOperationCount, + rangeHostOperationBudgetExhaustions: + rangeHostOperationBudgetExhaustionCount, + rangeSegments: rangeSegmentCount, + rangeTurnBudgetStops: rangeTurnBudgetStopCount ) } } - private func transfer( + private func recordQueuePopFault() { + statisticsLock.withLock { queuePopFaultCount &+= 1 } + } + + private func recordInvalidRequest() { + statisticsLock.withLock { invalidRequestCount &+= 1 } + } + + private func recordCompletionFault() { + statisticsLock.withLock { completionFaultCount &+= 1 } + } + + private func recordBoundedDrainStop() { + statisticsLock.withLock { boundedDrainStopCount &+= 1 } + } + + private func recordRangeTurnBudgetStop() { + statisticsLock.withLock { rangeTurnBudgetStopCount &+= 1 } + } + + private func recordQueueWorkTurn() { + statisticsLock.withLock { queueWorkTurnCount &+= 1 } + } + + private func recordRevokedRequest() { + statisticsLock.withLock { revokedRequestCount &+= 1 } + } + + private func recordRequestCompletion(startedAt: UInt64) { + let finishedAt = ioOperations.monotonicNanoseconds() + let duration = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsLock.withLock { + requestCompletionCount &+= 1 + requestServiceLatencyNanoseconds &+= duration + maximumRequestServiceLatencyNanoseconds = max( + maximumRequestServiceLatencyNanoseconds, + duration + ) + } + } + + private func recordHostTransfer( + reading: Bool, + segments: Int, + receipt: HostTransferReceipt + ) { + statisticsLock.withLock { + if reading { + readRequestCount &+= 1 + readByteCount &+= UInt64(receipt.actualBytes) + readSystemCallCount &+= UInt64(receipt.systemCalls) + } else { + writeRequestCount &+= 1 + writeByteCount &+= UInt64(receipt.actualBytes) + writeSystemCallCount &+= UInt64(receipt.systemCalls) + } + partialIOSystemCallCount &+= UInt64(receipt.partialSystemCalls) + interruptedIOSystemCallCount &+= UInt64(receipt.interruptedSystemCalls) + failedIOSystemCallCount &+= UInt64(receipt.failedSystemCalls) + hostIOBudgetExhaustionCount &+= UInt64(receipt.budgetExhaustions) + transferSegmentCount &+= UInt64(segments) + } + } + + private func recordRangeCommand( + writeZeroes: Bool, + execution: RangeCommandExecution + ) { + statisticsLock.withLock { + if writeZeroes { + writeZeroesRequestCount &+= 1 + writeZeroesRequestedByteCount &+= execution.requestedBytes + writeZeroesHostWrittenByteCount &+= UInt64(execution.hostWrittenBytes) + writeZeroesHostOperationCount &+= UInt64(execution.hostOperations) + } else { + discardRequestCount &+= 1 + discardRequestedByteCount &+= execution.requestedBytes + discardHostOperationCount &+= UInt64(execution.hostOperations) + discardIgnoredRangeCount &+= UInt64(execution.ignoredDiscardRanges) + } + rangePartialHostOperationCount &+= UInt64(execution.partialHostOperations) + rangeInterruptedHostOperationCount &+= UInt64(execution.interruptedHostOperations) + rangeFailedHostOperationCount &+= UInt64(execution.failedHostOperations) + rangeHostOperationBudgetExhaustionCount &+= + UInt64(execution.hostOperationBudgetExhaustions) + rangeSegmentCount &+= UInt64(execution.segmentCount) + } + } + + static func checkedByteRange( + sector: UInt64, + byteCount: UInt64, + capacityBytes: UInt64 + ) -> ByteRange? { + let (byteOffset, offsetOverflow) = sector.multipliedReportingOverflow(by: 512) + guard !offsetOverflow, + byteOffset <= capacityBytes, + byteCount <= capacityBytes - byteOffset, + byteOffset <= UInt64(off_t.max), + byteCount <= UInt64(off_t.max) - byteOffset else { + return nil + } + return ByteRange(offset: off_t(byteOffset), length: off_t(byteCount)) + } + + static func checkedSectorRange( + sector: UInt64, + sectorCount: UInt64, + capacityBytes: UInt64 + ) -> ByteRange? { + let (byteCount, lengthOverflow) = sectorCount.multipliedReportingOverflow(by: 512) + guard !lengthOverflow else { return nil } + return checkedByteRange( + sector: sector, + byteCount: byteCount, + capacityBytes: capacityBytes + ) + } + + func transfer( _ segments: ArraySlice, from sector: UInt64, into written: inout Int, reading: Bool ) -> RequestStatus { - var offset = off_t(UInt64(littleEndian: sector) * 512) + // Validate direction and the complete aggregate range before the first syscall. This avoids + // partially mutating a disk when a later descriptor is malformed or outside capacity. + guard !segments.isEmpty else { return .ioError } + var totalByteCount: UInt64 = 0 for segment in segments { - if reading { - guard segment.isDeviceWritable else { return .ioError } - var done = 0 - while done < segment.length { - let bytes = pread(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) - guard bytes > 0 else { return .ioError } - done += bytes - } - written += segment.length - } else { - guard !segment.isDeviceWritable else { return .ioError } - var done = 0 - while done < segment.length { - let bytes = pwrite(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) - guard bytes > 0 else { return .ioError } - done += bytes + guard segment.length > 0, + segment.isDeviceWritable == reading else { return .ioError } + let (nextByteCount, overflow) = totalByteCount.addingReportingOverflow(UInt64(segment.length)) + guard !overflow else { return .ioError } + totalByteCount = nextByteCount + } + // Virtio block read/write payloads are expressed in complete 512-byte sectors. + guard totalByteCount > 0, + totalByteCount <= UInt64(limits.maximumTransferBytes), + totalByteCount % 512 == 0, + let range = Self.checkedByteRange( + sector: sector, + byteCount: totalByteCount, + capacityBytes: capacityBytes + ) else { return .ioError } + + // Copy only bounded iovec metadata. Guest payload bytes remain zero-copy and protected by + // the surrounding queue lease for the exact duration of every vectored host operation. + let transferSegments = Array(segments) + let receipt = performVectoredTransfer( + transferSegments, + at: range.offset, + reading: reading + ) + if reading { written += receipt.actualBytes } + recordHostTransfer( + reading: reading, + segments: transferSegments.count, + receipt: receipt + ) + return receipt.status + } + + private func performVectoredTransfer( + _ segments: [VirtqueueSegment], + at initialOffset: off_t, + reading: Bool + ) -> HostTransferReceipt { + var receipt = HostTransferReceipt(status: .ioError) + var segmentIndex = 0 + var segmentOffset = 0 + var fileOffset = initialOffset + let operation = reading ? ioOperations.read : ioOperations.write + + while segmentIndex < segments.count { + guard receipt.systemCalls < limits.maximumHostIOOperationsPerRequest else { + receipt.budgetExhaustions &+= 1 + return receipt + } + + var vectors = [iovec]() + vectors.reserveCapacity(limits.maximumIOVectorsPerSystemCall) + var offeredBytes = 0 + var vectorIndex = segmentIndex + var vectorOffset = segmentOffset + while vectorIndex < segments.count, + vectors.count < limits.maximumIOVectorsPerSystemCall { + let segment = segments[vectorIndex] + let length = segment.length - vectorOffset + vectors.append(iovec( + iov_base: segment.pointer + vectorOffset, + iov_len: length + )) + offeredBytes += length + vectorIndex += 1 + vectorOffset = 0 + } + + let result = vectors.withUnsafeBufferPointer { buffer in + operation( + fileDescriptor, + buffer.baseAddress!, + Int32(buffer.count), + fileOffset + ) + } + receipt.systemCalls &+= 1 + + if result.count < 0, result.code == EINTR { + receipt.interruptedSystemCalls &+= 1 + continue + } + guard result.count > 0, result.count <= offeredBytes else { + receipt.failedSystemCalls &+= 1 + return receipt + } + if result.count < offeredBytes { receipt.partialSystemCalls &+= 1 } + + receipt.actualBytes += result.count + fileOffset += off_t(result.count) + var consumed = result.count + while consumed > 0, segmentIndex < segments.count { + let available = segments[segmentIndex].length - segmentOffset + if consumed < available { + segmentOffset += consumed + consumed = 0 + } else { + consumed -= available + segmentIndex += 1 + segmentOffset = 0 } } - offset += off_t(segment.length) } - return .ok + + receipt.status = .ok + return receipt } // Applies a guest DISCARD or WRITE_ZEROES request. The data segments carry a packed array of - // `struct virtio_blk_discard_write_zeroes { le64 sector; le32 num_sectors; le32 flags; }`. Discard - // and unmap-flagged write-zeroes punch a hole (returning blocks to the host and reading back zeros); - // plain write-zeroes overwrites with zeros while keeping the allocation. Ranges are bounds-checked - // in sector space so a malformed guest request cannot touch bytes outside the image. - func applyDiscardOrWriteZeroes(_ segments: ArraySlice, writeZeroes: Bool) -> RequestStatus { - guard discardEnabled else { return .unsupported } + // `struct virtio_blk_discard_write_zeroes { le64 sector; le32 num_sectors; le32 flags; }`. + // Every entry and aggregate bound is validated before the first host operation. DISCARD may be + // ignored when the backing store cannot punch a hole, as permitted by virtio; WRITE_ZEROES must + // still produce zeros and therefore falls back to bounded allocation-preserving pwritev calls. + func applyDiscardOrWriteZeroes( + _ segments: ArraySlice, + writeZeroes: Bool + ) -> RequestStatus { + executeDiscardOrWriteZeroes(segments, writeZeroes: writeZeroes).status + } + + private func executeDiscardOrWriteZeroes( + _ segments: ArraySlice, + writeZeroes: Bool + ) -> RangeCommandExecution { + var execution = RangeCommandExecution(status: .ioError) + defer { recordRangeCommand(writeZeroes: writeZeroes, execution: execution) } + guard discardEnabled else { + execution.status = .unsupported + return execution + } + + let maximumSegments = writeZeroes + ? limits.maximumWriteZeroesSegmentsPerRequest + : limits.maximumDiscardSegmentsPerRequest + let maximumSectorsPerSegment = writeZeroes + ? UInt64( + limits.maximumWriteZeroesBytesPerRequest + / limits.maximumWriteZeroesSegmentsPerRequest / 512 + ) + : UInt64(Discard.maxSectors) var entryCount = 0 + var requestedBytes: UInt64 = 0 + var operations = [DiscardOperation]() + operations.reserveCapacity(maximumSegments) + for segment in segments { guard !segment.isDeviceWritable, segment.length >= Discard.entryByteCount, - segment.length % Discard.entryByteCount == 0 else { return .ioError } + segment.length % Discard.entryByteCount == 0 else { + return execution + } let segmentEntries = segment.length / Discard.entryByteCount - guard segmentEntries <= Int(Discard.maxSegments) - entryCount else { return .ioError } + guard segmentEntries <= maximumSegments - entryCount else { return execution } entryCount += segmentEntries var validationOffset = 0 while validationOffset + Discard.entryByteCount <= segment.length { let base = segment.pointer + validationOffset - let sector = UInt64(littleEndian: base.loadUnaligned(fromByteOffset: 0, as: UInt64.self)) - let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 8, as: UInt32.self))) - let flags = UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 12, as: UInt32.self)) + let sector = UInt64(littleEndian: base.loadUnaligned( + fromByteOffset: 0, + as: UInt64.self + )) + let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned( + fromByteOffset: 8, + as: UInt32.self + ))) + let flags = UInt32(littleEndian: base.loadUnaligned( + fromByteOffset: 12, + as: UInt32.self + )) validationOffset += Discard.entryByteCount - guard numSectors <= UInt64(Discard.maxSectors) else { return .ioError } + guard numSectors <= maximumSectorsPerSegment else { return execution } if writeZeroes { - guard flags & ~Discard.unmapFlag == 0 else { return .unsupported } + guard flags & ~Discard.unmapFlag == 0 else { + execution.status = .unsupported + return execution + } } else { - guard flags == 0 else { return .unsupported } + guard flags == 0 else { + execution.status = .unsupported + return execution + } } - guard sector <= capacitySectors, - numSectors <= capacitySectors - sector else { return .ioError } + guard let range = Self.checkedSectorRange( + sector: sector, + sectorCount: numSectors, + capacityBytes: capacityBytes + ) else { return execution } + let rangeBytes = UInt64(range.length) + let (nextRequestedBytes, overflow) = requestedBytes.addingReportingOverflow( + rangeBytes + ) + guard !overflow, + !writeZeroes + || nextRequestedBytes <= UInt64( + limits.maximumWriteZeroesBytesPerRequest + ) else { return execution } + requestedBytes = nextRequestedBytes + operations.append(DiscardOperation( + range: range, + deallocate: !writeZeroes || (flags & Discard.unmapFlag) != 0 + )) } } - guard entryCount > 0 else { return .ioError } - for segment in segments { - var offset = 0 - while offset + Discard.entryByteCount <= segment.length { - let base = segment.pointer + offset - let sector = UInt64(littleEndian: base.loadUnaligned(fromByteOffset: 0, as: UInt64.self)) - let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 8, as: UInt32.self))) - let flags = UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 12, as: UInt32.self)) - offset += Discard.entryByteCount - - guard numSectors > 0 else { continue } - - let byteOffset = off_t(sector * 512) - let byteLength = off_t(numSectors * 512) - let deallocate = !writeZeroes || (flags & Discard.unmapFlag) != 0 - let ok = deallocate - ? deallocateRange( - offset: byteOffset, - length: byteLength, - zeroFallback: writeZeroes - ) - : writeZerosPreservingAllocation(offset: byteOffset, length: byteLength) - guard ok else { return .ioError } + + guard entryCount > 0 else { return execution } + execution.requestedBytes = requestedBytes + execution.segmentCount = entryCount + if writeZeroes { + execution.fairnessBytes = Int(requestedBytes) + } + + for operation in operations where operation.range.length > 0 { + guard execution.hostOperations < limits.maximumRangeHostOperationsPerRequest else { + execution.hostOperationBudgetExhaustions &+= 1 + return execution + } + let succeeded: Bool + if operation.deallocate { + succeeded = deallocateRange( + offset: operation.range.offset, + length: operation.range.length, + zeroFallback: writeZeroes, + execution: &execution + ) + } else { + succeeded = writeZerosPreservingAllocation( + offset: operation.range.offset, + length: operation.range.length, + execution: &execution + ) } + guard succeeded else { return execution } } - return .ok + execution.status = .ok + return execution } - // Deallocates a byte range so it reads back as zeros and returns blocks to the host. F_PUNCHHOLE - // only accepts fs-block-aligned ranges, so this punches the aligned interior. WRITE_ZEROES must - // still read back as zero and therefore zero-writes unsupported/unaligned pieces; plain DISCARD - // may be ignored by the device and must never inflate a sparse image when hole punching is not - // available (for example, an external exFAT/SMB-backed home directory). - private func deallocateRange(offset: off_t, length: off_t, zeroFallback: Bool) -> Bool { + // Deallocates the aligned interior. Plain DISCARD is allowed to become an observable no-op when + // hole punching is unavailable. WRITE_ZEROES with UNMAP instead zero-writes the complete range + // on punch failure, and always zero-writes unaligned edges after a successful punch. + private func deallocateRange( + offset: off_t, + length: off_t, + zeroFallback: Bool, + execution: inout RangeCommandExecution + ) -> Bool { let block = off_t(discardBlockSize) let end = offset + length - let alignedStart = ((offset + block - 1) / block) * block - let alignedEnd = (end / block) * block + let startRemainder = offset % block + let startAdjustment = startRemainder == 0 ? 0 : block - startRemainder + guard startAdjustment < length else { + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true + } + let alignedStart = offset + startAdjustment + let alignedEnd = end - (end % block) guard alignedEnd > alignedStart else { - return zeroFallback ? writeZerosPreservingAllocation(offset: offset, length: length) : true + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true } - var punch = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: alignedStart, fp_length: alignedEnd - alignedStart) - let punched = withUnsafeMutablePointer(to: &punch) { fcntl(fileDescriptor, F_PUNCHHOLE, $0) } - guard punched == 0 else { - return zeroFallback ? writeZerosPreservingAllocation(offset: offset, length: length) : true + guard execution.hostOperations < limits.maximumRangeHostOperationsPerRequest else { + execution.hostOperationBudgetExhaustions &+= 1 + return false + } + let punch = rangeOperations.punchHole( + fileDescriptor, + alignedStart, + alignedEnd - alignedStart + ) + execution.hostOperations &+= 1 + guard punch.count == 0 else { + execution.failedHostOperations &+= 1 + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true } if zeroFallback, alignedStart > offset, - !writeZerosPreservingAllocation(offset: offset, length: alignedStart - offset) { + !writeZerosPreservingAllocation( + offset: offset, + length: alignedStart - offset, + execution: &execution + ) { return false } if zeroFallback, end > alignedEnd, - !writeZerosPreservingAllocation(offset: alignedEnd, length: end - alignedEnd) { + !writeZerosPreservingAllocation( + offset: alignedEnd, + length: end - alignedEnd, + execution: &execution + ) { return false } return true } - private func writeZerosPreservingAllocation(offset: off_t, length: off_t) -> Bool { - let chunkSize = 64 * 1024 - let zeros = [UInt8](repeating: 0, count: chunkSize) - var remaining = Int(length) - var position = offset - return zeros.withUnsafeBytes { raw in - while remaining > 0 { - let take = min(chunkSize, remaining) - let written = pwrite(fileDescriptor, raw.baseAddress, take, position) - guard written > 0 else { return false } - remaining -= written - position += off_t(written) - } - return true - } + // Writes zeros through repeated references to one bounded 64 KiB buffer. A normal 16 MiB + // request is one 256-iovec pwritev; short results rebuild the exact remaining file range. + private func writeZerosPreservingAllocation( + offset: off_t, + length: off_t, + execution: inout RangeCommandExecution + ) -> Bool { + let remainingBudget = max( + 0, + limits.maximumRangeHostOperationsPerRequest - execution.hostOperations + ) + let receipt = performZeroWrite( + offset: offset, + length: length, + maximumHostOperations: remainingBudget + ) + execution.mergeHostTransfer(receipt) + return receipt.status == .ok } - private static func discardEnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_DISCARD"]?.lowercased() else { - return true + private func performZeroWrite( + offset: off_t, + length: off_t, + maximumHostOperations: Int + ) -> HostTransferReceipt { + var receipt = HostTransferReceipt(status: .ioError) + guard length > 0 else { + receipt.status = .ok + return receipt } - return !["0", "false", "no", "off"].contains(value) - } + let chunkSize = 64 * 1_024 + let zeros = [UInt8](repeating: 0, count: chunkSize) + var remaining = length + var position = offset + return zeros.withUnsafeBytes { zeroBuffer in + while remaining > 0 { + guard receipt.systemCalls < maximumHostOperations else { + receipt.budgetExhaustions &+= 1 + return receipt + } - private static func asyncIOEnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_ASYNC"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } + var vectors = [iovec]() + vectors.reserveCapacity(limits.maximumIOVectorsPerSystemCall) + var offeredBytes = 0 + var vectorRemaining = remaining + while vectorRemaining > 0, + vectors.count < limits.maximumIOVectorsPerSystemCall { + let count = Int(min(off_t(chunkSize), vectorRemaining)) + vectors.append(iovec( + iov_base: UnsafeMutableRawPointer( + mutating: zeroBuffer.baseAddress! + ), + iov_len: count + )) + offeredBytes += count + vectorRemaining -= off_t(count) + } - private static func queueCountFromEnvironment() -> Int { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_QUEUES"].flatMap(Int.init) else { - return min(4, max(1, ProcessInfo.processInfo.activeProcessorCount)) + let result = vectors.withUnsafeBufferPointer { buffer in + ioOperations.write( + fileDescriptor, + buffer.baseAddress!, + Int32(buffer.count), + position + ) + } + receipt.systemCalls &+= 1 + if result.count < 0, result.code == EINTR { + receipt.interruptedSystemCalls &+= 1 + continue + } + guard result.count > 0, result.count <= offeredBytes else { + receipt.failedSystemCalls &+= 1 + return receipt + } + if result.count < offeredBytes { + receipt.partialSystemCalls &+= 1 + } + receipt.actualBytes &+= result.count + remaining -= off_t(result.count) + position += off_t(result.count) + } + receipt.status = .ok + return receipt } - return value } - private static func clampedQueueCount(_ count: Int) -> Int { - min(16, max(1, count)) - } } extension VirtioBlk: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift index 09f59a0d..6bfa374d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift @@ -1,17 +1,24 @@ +import DoryFSWorkerContracts import Foundation public enum VirtioFSError: Error, Equatable { case invalidTag(String) - case invalidDaxWindow } -public struct VirtioFSDaxConfiguration: Equatable, Sendable { - public var guestBase: UInt64 - public var length: UInt64 +/// Typed lifecycle boundary reported by one virtio-fs backend. A committed FUSE_DESTROY followed +/// by device reset is normal guest teardown; every other worker loss/reset remains a VM-fatal +/// authority failure. Callers must never infer this distinction from diagnostic strings. +public enum VirtioFSWorkerLifecycleEvent: Equatable, Sendable { + case connectionTeardown + case failure(String) - public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize) { - self.guestBase = guestBase - self.length = length + public var diagnostic: String { + switch self { + case .connectionTeardown: + "filesystem worker connection teardown committed" + case .failure(let reason): + reason + } } } @@ -51,17 +58,62 @@ public struct VirtioFSStatistics: Equatable, Sendable { } } -public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { +public struct VirtioFSFrontendStatistics: Equatable, Sendable { + public let rejectedRequests: UInt64 + public let executedRequests: UInt64 + public let terminalQueueFaults: UInt64 + + public init(rejectedRequests: UInt64, executedRequests: UInt64, terminalQueueFaults: UInt64) { + self.rejectedRequests = rejectedRequests + self.executedRequests = executedRequests + self.terminalQueueFaults = terminalQueueFaults + } +} + +/// Payload, concurrency, and end-to-end timing counters for admitted request work. Byte counters +/// describe protocol payloads observed at each ownership boundary; they intentionally do not claim +/// that Foundation or XPC performed one physical copy per byte. +public struct VirtioFSPerformanceStatistics: Equatable, Sendable { + public let requestPayloadBytes: UInt64 + public let workerResponsePayloadBytes: UInt64 + public let guestPublishedResponseBytes: UInt64 + public let completedRequests: UInt64 + public let failedRequests: UInt64 + public let inFlightRequests: UInt64 + public let peakInFlightRequests: UInt64 + public let totalRequestLatencyNanoseconds: UInt64 + public let maximumRequestLatencyNanoseconds: UInt64 + + public init( + requestPayloadBytes: UInt64, + workerResponsePayloadBytes: UInt64, + guestPublishedResponseBytes: UInt64, + completedRequests: UInt64, + failedRequests: UInt64, + inFlightRequests: UInt64, + peakInFlightRequests: UInt64, + totalRequestLatencyNanoseconds: UInt64, + maximumRequestLatencyNanoseconds: UInt64 + ) { + self.requestPayloadBytes = requestPayloadBytes + self.workerResponsePayloadBytes = workerResponsePayloadBytes + self.guestPublishedResponseBytes = guestPublishedResponseBytes + self.completedRequests = completedRequests + self.failedRequests = failedRequests + self.inFlightRequests = inFlightRequests + self.peakInFlightRequests = peakInFlightRequests + self.totalRequestLatencyNanoseconds = totalRequestLatencyNanoseconds + self.maximumRequestLatencyNanoseconds = maximumRequestLatencyNanoseconds + } +} + +public final class VirtioFS: VirtioDeviceBackend { public static let tagByteCount = 36 public static let notificationFeature: UInt64 = 1 << 0 - static let traceInvalidations: Bool = { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_TRACE_INVAL"] ?? "" - return ["1", "true", "yes", "on"].contains(value.lowercased()) - }() public static let notificationBufferSize: UInt32 = 4096 /// Upper bound for positive entry and attribute validity in coherent mode. Open-file and /// directory cache flags remain disabled because they cannot be revoked after degradation. - public static let maximumCoherentCacheValiditySeconds: UInt64 = FuseServer.maximumCoherentCacheValiditySeconds + public static let maximumCoherentCacheValiditySeconds: UInt64 = 0 /// The matching guest driver posts 16 page-sized notification buffers. Caching is forbidden /// until this process has seen every stable backing address in the current transport epoch. public static let requiredStableNotificationBufferCountForCaching = 16 @@ -81,18 +133,15 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public let requestQueueCount: Int public let notificationBacklogLimit: Int public let tag: String - public let hostFS: HostFS - public let daxConfiguration: VirtioFSDaxConfiguration? - private let server: FuseServer - private let stats: VirtioFSStats? - private let inlineRequests: Bool + private let broker: DoryFSWorkerBroker + private let onWorkerLifecycle: @Sendable (VirtioFSWorkerLifecycleEvent) -> Void public var deviceFeatures: UInt64 { Self.notificationFeature } - // Small metadata-heavy workloads are latency-bound: dispatching every FUSE request to another - // thread costs more than the host syscall. Inline processing is therefore the default, with an - // environment opt-out for workloads that need the older worker-only behavior. The worker pool is - // still used when inline mode is disabled and remains available for experimentation. - private let workers = DispatchQueue(label: "dory-hv.virtiofs.worker", qos: .userInteractive, attributes: .concurrent) + private let workers = DispatchQueue( + label: "dory-hv.virtiofs.worker", + qos: RawHVSchedulingPolicy.fileSystemWorkerDispatchQoS, + attributes: .concurrent + ) private let drainLock = NSLock() /// The lifecycle epoch currently owned by a drainer, or nil when that queue has no drainer. /// Reset/reconfiguration clears the slot while the old epoch finishes outside the queue lock, @@ -132,7 +181,14 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid // backend's lifetime; success keeps the write fence until every admitted barrier resolves. private let requestGateLock = NSLock() private var requestGateClosed = false + /// The broker's shared workspace authority owns capacity. This frontend tracks only requests + /// that crossed its publication boundary plus per-queue grants already reserved by that shared + /// authority; it never mirrors the workspace counters with a second semaphore. private var activeRequestCount = 0 + private var deferredAdmissionQueues = Set() + private var grantedAdmissions = [Int: DoryFSWorkerAdmissionLease]() + private var admissionWaiterIDs = [DoryFSWorkerAdmissionWaiterID]() + private var frontendAdmissionTerminationReported = false private var requestGateWaiters: [ UUID: CheckedContinuation, Never> ] = [:] @@ -152,74 +208,71 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid private var connectionResetPending = false private var connectionResetInProgress = false private weak var connectionResetTransport: VirtioMMIOTransport? + private var connectionResetEvent: VirtioFSWorkerLifecycleEvent? private let responseFenceTestHookLock = NSLock() private var _responseFenceTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? private let requestGateDrainTestHookLock = NSLock() private var _requestGateDrainTestHook: (@Sendable (RequestGateDrainTestEvent) -> Void)? + private let requestExecutionTestHookLock = NSLock() + private var _requestExecutionTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? + private let hostResponseSnapshotTestHookLock = NSLock() + private var _hostResponseSnapshotTestHook: (@Sendable (FuseInHeader, FuseOpcode, [UInt8]) -> Void)? private let telemetryLock = NSLock() private var invalidationCount: UInt64 = 0 private var invalidationFailureCount: UInt64 = 0 private var hasLatchedInvalidationFailure = false + private var rejectedRequestCount: UInt64 = 0 + private var executedRequestCount: UInt64 = 0 + private var terminalQueueFaultCount: UInt64 = 0 + private var requestPayloadByteCount: UInt64 = 0 + private var workerResponsePayloadByteCount: UInt64 = 0 + private var guestPublishedResponseByteCount: UInt64 = 0 + private var completedRequestCount: UInt64 = 0 + private var failedRequestCount: UInt64 = 0 + private var inFlightRequestCount: UInt64 = 0 + private var peakInFlightRequestCount: UInt64 = 0 + private var totalRequestLatencyNanoseconds: UInt64 = 0 + private var maximumRequestLatencyNanoseconds: UInt64 = 0 + private var requestFrontendTerminallyFaulted = false + private var fuseInitCompleted = false + private var fuseDestroyCommitted = false - public convenience init( - tag: String, - hostFS: HostFS, - daxConfiguration: VirtioFSDaxConfiguration? = nil, - requestQueueCount requestedQueueCount: Int? = nil, - notificationBacklogLimit requestedNotificationBacklogLimit: Int = 256 - ) throws { - try self.init( - tag: tag, - hostFS: hostFS, - daxConfiguration: daxConfiguration, - requestQueueCount: requestedQueueCount, - notificationBacklogLimit: requestedNotificationBacklogLimit, - inlineRequests: nil - ) - } - - init( + public init( tag: String, - hostFS: HostFS, - daxConfiguration: VirtioFSDaxConfiguration? = nil, + broker: DoryFSWorkerBroker, requestQueueCount requestedQueueCount: Int? = nil, notificationBacklogLimit requestedNotificationBacklogLimit: Int = 256, - inlineRequests requestedInlineRequests: Bool? + onWorkerLifecycle: @escaping @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { event in + FileHandle.standardError.write(Data("dory-hv: \(event.diagnostic)\n".utf8)) + } ) throws { let bytes = Array(tag.utf8) guard !bytes.isEmpty, bytes.count < Self.tagByteCount else { throw VirtioFSError.invalidTag(tag) } - if let daxConfiguration { - guard daxConfiguration.guestBase.isMultiple(of: DaxWindow.pageSize), - daxConfiguration.length > 0, - daxConfiguration.length.isMultiple(of: DaxWindow.pageSize) else { - throw VirtioFSError.invalidDaxWindow - } - } self.tag = tag - self.hostFS = hostFS - self.daxConfiguration = daxConfiguration + self.broker = broker + self.onWorkerLifecycle = onWorkerLifecycle self.requestQueueCount = Self.clampedRequestQueueCount( - requestedQueueCount ?? Self.requestQueueCountFromEnvironment() + requestedQueueCount ?? Self.defaultRequestQueueCount() ) self.notificationBacklogLimit = min(4096, max(1, requestedNotificationBacklogLimit)) self.queueCount = self.requestQueueCount + 2 + self.admissionWaiterIDs = (0.. Int { + min(8, max(1, activeProcessorCount)) } public var configSpace: [UInt8] { @@ -241,7 +294,7 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } public var coherentCachingActive: Bool { - server.coherentCachingActive + false } /// Test-only interlock used to stop a request after encoding but before used-ring publication. @@ -258,6 +311,18 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid set { requestGateDrainTestHookLock.withLock { _requestGateDrainTestHook = newValue } } } + /// Test-only proof that admitted work crosses the single generic FuseServer execution seam. + var requestExecutionTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? { + get { requestExecutionTestHookLock.withLock { _requestExecutionTestHook } } + set { requestExecutionTestHookLock.withLock { _requestExecutionTestHook = newValue } } + } + + /// Test-only host-owned response observation. It runs before any guest-memory publication. + var hostResponseSnapshotTestHook: (@Sendable (FuseInHeader, FuseOpcode, [UInt8]) -> Void)? { + get { hostResponseSnapshotTestHookLock.withLock { _hostResponseSnapshotTestHook } } + set { hostResponseSnapshotTestHookLock.withLock { _hostResponseSnapshotTestHook = newValue } } + } + var requestPublicationGateClosed: Bool { requestGateLock.withLock { requestGateClosed } } @@ -266,6 +331,10 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid requestGateLock.withLock { deferredRequestQueues } } + var capacityDeferredRequestQueueSnapshot: Set { + requestGateLock.withLock { deferredAdmissionQueues } + } + /// Enables one-second positive entry/attribute validity only after notification negotiation, a /// ready queue, all 16 stable guest buffers, and FUSE INIT have been observed. Negative dentries, /// KEEP_CACHE, and CACHE_DIR remain disabled in every state. @@ -273,20 +342,14 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func activateCoherentCaching() -> VirtioFSCacheActivationResult { notificationLock.lock() let eligibility = cacheActivationEligibilityLocked() - guard eligibility.isEligible, server.activateCoherentCaching() else { - notificationLock.unlock() - return .ineligible(eligibility) - } - responseCacheEpoch &+= 1 notificationLock.unlock() - return .activated + return .ineligible(eligibility) } /// Synchronously makes every subsequently encoded FUSE response use zero metadata validity. /// KEEP_CACHE and CACHE_DIR are never emitted, including while coherent caching is active. public func deactivateCoherentCaching() { notificationLock.withLock { - server.deactivateCoherentCaching() responseCacheEpoch &+= 1 } } @@ -340,7 +403,13 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func deviceReset(transport: VirtioMMIOTransport) { advanceAllQueueLifecycles() let staleBarriers: [VirtioFSNotificationBarrier] + let hadStartedDriverLifecycle: Bool + let resetEvent: VirtioFSWorkerLifecycleEvent notificationLock.lock() + hadStartedDriverLifecycle = notificationTransport === transport + resetEvent = fuseDestroyCommitted + ? .connectionTeardown + : .failure("filesystem worker generation invalidated by virtio-fs device reset") if notificationTransport === transport { staleBarriers = removeAllNotificationStateLocked() } else { @@ -348,7 +417,15 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } notificationLock.unlock() - beginConnectionReset(transport: transport) + // Linux writes device status 0 while probing an already-reset virtio device, before the + // DRIVER_OK edge that establishes a guest FUSE connection. Retiring the one-shot worker + // there makes every normal boot fail. Once this backend has observed DRIVER_OK, however, + // any reset can strand guest FUSE handles or dirty cache state and remains fail-stop. The + // sole normal exception is a reset after the exact FUSE_DESTROY response was committed; + // that retires only this share so sibling shares can finish on the shared worker channel. + if hadStartedDriverLifecycle { + beginConnectionReset(transport: transport, event: resetEvent) + } fail(staleBarriers, with: .transportReset, transport: transport) } @@ -403,11 +480,6 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid guard !invalidations.isEmpty else { return VirtioFSNotificationBarrier(notificationCount: 0) } - if Self.traceInvalidations { - for invalidation in invalidations { - FileHandle.standardError.write(Data("dory-hv: inval \(invalidation)\n".utf8)) - } - } let frames = try invalidations.map { try $0.encoded() } guard frames.allSatisfy({ $0.count <= Int(Self.notificationBufferSize) }) else { // The public invalidation encoders currently make this unreachable, but preserve the @@ -571,6 +643,32 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } } + public var frontendStatistics: VirtioFSFrontendStatistics { + telemetryLock.withLock { + VirtioFSFrontendStatistics( + rejectedRequests: rejectedRequestCount, + executedRequests: executedRequestCount, + terminalQueueFaults: terminalQueueFaultCount + ) + } + } + + public var performanceStatistics: VirtioFSPerformanceStatistics { + telemetryLock.withLock { + VirtioFSPerformanceStatistics( + requestPayloadBytes: requestPayloadByteCount, + workerResponsePayloadBytes: workerResponsePayloadByteCount, + guestPublishedResponseBytes: guestPublishedResponseByteCount, + completedRequests: completedRequestCount, + failedRequests: failedRequestCount, + inFlightRequests: inFlightRequestCount, + peakInFlightRequests: peakInFlightRequestCount, + totalRequestLatencyNanoseconds: totalRequestLatencyNanoseconds, + maximumRequestLatencyNanoseconds: maximumRequestLatencyNanoseconds + ) + } + } + private func recordInvalidations(_ count: Int) { guard count > 0 else { return } let increment = UInt64(count) @@ -586,6 +684,87 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } } + private func recordRequestRejection() { + telemetryLock.withLock { + rejectedRequestCount = Self.saturatingAdd(rejectedRequestCount, 1) + } + } + + private func recordRequestExecution(requestPayloadBytes: Int) { + telemetryLock.withLock { + executedRequestCount = Self.saturatingAdd(executedRequestCount, 1) + requestPayloadByteCount = Self.saturatingAdd( + requestPayloadByteCount, + UInt64(requestPayloadBytes) + ) + inFlightRequestCount = Self.saturatingAdd(inFlightRequestCount, 1) + peakInFlightRequestCount = max( + peakInFlightRequestCount, + inFlightRequestCount + ) + } + } + + private func recordWorkerResponsePayload(bytes: Int) { + telemetryLock.withLock { + workerResponsePayloadByteCount = Self.saturatingAdd( + workerResponsePayloadByteCount, + UInt64(bytes) + ) + } + } + + private func recordGuestPublishedResponse(bytes: Int) { + telemetryLock.withLock { + guestPublishedResponseByteCount = Self.saturatingAdd( + guestPublishedResponseByteCount, + UInt64(bytes) + ) + } + } + + private func recordRequestCompletion( + admittedAtUptimeNanoseconds: UInt64, + published: Bool + ) { + let completedAt = DispatchTime.now().uptimeNanoseconds + let latency = completedAt >= admittedAtUptimeNanoseconds + ? completedAt - admittedAtUptimeNanoseconds + : 0 + telemetryLock.withLock { + precondition(inFlightRequestCount > 0) + inFlightRequestCount -= 1 + if published { + completedRequestCount = Self.saturatingAdd(completedRequestCount, 1) + } else { + failedRequestCount = Self.saturatingAdd(failedRequestCount, 1) + } + totalRequestLatencyNanoseconds = Self.saturatingAdd( + totalRequestLatencyNanoseconds, + latency + ) + maximumRequestLatencyNanoseconds = max( + maximumRequestLatencyNanoseconds, + latency + ) + } + } + + private func recordTerminalQueueFault(_ error: any Error, queue: Int) { + telemetryLock.withLock { + terminalQueueFaultCount = Self.saturatingAdd(terminalQueueFaultCount, 1) + requestFrontendTerminallyFaulted = true + } + FileHandle.standardError.write(Data( + "dory-hv: virtiofs terminal queue fault queue=\(queue): \(error)\n".utf8 + )) + latchRequestGateFailure(barrier: nil) + } + + private var requestFrontendIsTerminallyFaulted: Bool { + telemetryLock.withLock { requestFrontendTerminallyFaulted } + } + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { let (sum, overflow) = value.addingReportingOverflow(increment) return overflow ? UInt64.max : sum @@ -593,6 +772,7 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func handleKick(queue: Int, transport: VirtioMMIOTransport) { guard queue >= 0, queue < queueCount else { return } + guard !requestFrontendIsTerminallyFaulted else { return } // Queue notification MMIO is intentionally delivered without the transport lock for this // backend. Snapshot only routing/readiness under that lock; every pop/push below takes it // again and verifies the queue lifecycle epoch before touching the ring. @@ -615,13 +795,7 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid // Exactly one drainer owns a queue lifecycle epoch. Kicks on different queues may overlap, // while a same-queue kick only advances the generation so the active drainer sweeps again. guard let lifecycleEpoch = beginQueueDrain(queue: queue) else { return } - if inlineRequests { - drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) - } else { - workers.async { [self] in - drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) - } - } + drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) } private func drain(queue: Int, lifecycleEpoch: UInt64, transport: VirtioMMIOTransport) { @@ -636,35 +810,50 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid return } var shouldNotify = false - while true { - guard beginRequestProcessing( + requestLoop: while true { + let requestAdmission = beginRequestProcessing( queue: queue, lifecycleEpoch: lifecycleEpoch, virtqueue: virtqueue, transport: transport - ) else { + ) + guard case .process(let admissionLease, let previewOpcode) = requestAdmission else { requestGateDrainTestHook?(.deferred(queue: queue)) break } - guard let chain = popChain( + let popResult = popChain( queue: queue, lifecycleEpoch: lifecycleEpoch, virtqueue: virtqueue, transport: transport - ) else { + ) + switch popResult { + case .chain(let chain): + switch process( + chain: chain, + queue: queue, + lifecycleEpoch: lifecycleEpoch, + virtqueue: virtqueue, + transport: transport, + admissionLease: admissionLease, + previewOpcode: previewOpcode + ) { + case .completed(let interruptWanted): + shouldNotify = shouldNotify || interruptWanted + endRequestProcessing() + case .submitted: + break + } + case .empty: + admissionLease?.release() endRequestProcessing() - break - } - if process( - chain: chain, - queue: queue, - lifecycleEpoch: lifecycleEpoch, - virtqueue: virtqueue, - transport: transport - ) { - shouldNotify = true + break requestLoop + case .terminalFault(let error): + admissionLease?.release() + endRequestProcessing() + recordTerminalQueueFault(error, queue: queue) + break requestLoop } - endRequestProcessing() } if shouldNotify { transport.notifyUsed() @@ -717,204 +906,312 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } } + private enum ChainPopResult { + case chain(VirtqueueChain) + case empty + case terminalFault(any Error) + } + + private enum RequestProcessResult { + case completed(interruptWanted: Bool) + case submitted + } + + private enum RequestBeginResult { + case process(DoryFSWorkerAdmissionLease?, previewOpcode: FuseOpcode?) + case deferred + } + private func popChain( queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, transport: VirtioMMIOTransport - ) -> VirtqueueChain? { + ) -> ChainPopResult { transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }), - virtqueue.ready else { return nil } - return (try? virtqueue.pop()) ?? nil + virtqueue.ready else { return .empty } + do { + guard let chain = try virtqueue.pop() else { return .empty } + return .chain(chain) + } catch { + return .terminalFault(error) + } } } - @discardableResult private func process( chain: VirtqueueChain, queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, - transport: VirtioMMIOTransport - ) -> Bool { - let requestEpochs: (notification: UInt64?, cache: UInt64) = notificationLock.withLock { - ( - notificationTransport === transport ? notificationEpoch : nil, - responseCacheEpoch + transport: VirtioMMIOTransport, + admissionLease: DoryFSWorkerAdmissionLease?, + previewOpcode: FuseOpcode? + ) -> RequestProcessResult { + let requestNotificationEpoch: UInt64? = notificationLock.withLock { + notificationTransport === transport ? notificationEpoch : nil + } + guard let admission = chain.withLeaseHeld({ access in + VirtioFSRequestAdmission.inspect( + chain: chain, + access: access, + queue: queue, + maximumRequestBytes: broker.effectiveAdmissionLimits.maximumRequestBytes, + maximumResponseBytes: broker.effectiveAdmissionLimits.maximumResponseBytes ) - } - let request = chain.readBytes() - var written = 0 - var decoded: (header: FuseInHeader, opcode: FuseOpcode)? - var statsStartNanoseconds: UInt64? - var completesFuseInit = false - var lifetimeGrantRolledBack = false - if let header = try? FuseProtocol.decodeInHeader(request), - header.length >= UInt32(FuseInHeader.byteCount), Int(header.length) <= request.count, - let opcode = FuseOpcode(rawValue: header.opcode) { - decoded = (header, opcode) - if stats != nil { - statsStartNanoseconds = DispatchTime.now().uptimeNanoseconds - } - } - if chain.hasWritableSegments, let decoded { - let header = decoded.header - let opcode = decoded.opcode - if opcode == .lookup { - let payload = request[FuseInHeader.byteCount.. NormalizedWorkerResponse { + if !request.expectsReply { + return NormalizedWorkerResponse( + bytes: [], + representsWorkerResponse: serverResponse.isEmpty + ) + } + if request.opcode == .interrupt, serverResponse.isEmpty { + return NormalizedWorkerResponse( + bytes: Self.errorResponse(unique: request.header.unique, errno: 0), + representsWorkerResponse: false + ) + } + guard serverResponse.count >= FuseOutHeader.byteCount, + serverResponse.count <= request.maximumResponseBytes, + let header = try? FuseProtocol.decodeOutHeader(serverResponse), + Int(header.length) == serverResponse.count, + header.unique == request.header.unique else { + return NormalizedWorkerResponse( + bytes: Self.errorResponse(unique: request.header.unique, errno: EIO), + representsWorkerResponse: false + ) } - // `Virtqueue.push` publishes unconditionally once the queue is live; its Bool only reports - // whether the guest wants a used-ring interrupt (VRING_AVAIL_F_NO_INTERRUPT). Track - // publication separately: treating interrupt suppression as a failed publish rolled back - // handle/lookup grants the guest had legitimately received, poisoning rm/npm storms. + return NormalizedWorkerResponse( + bytes: serverResponse, + representsWorkerResponse: true + ) + } + + private struct ResponsePublication { + let pushed: Bool + let interruptWanted: Bool + let failureReason: String? + } + + private func publishResponse( + _ response: [UInt8], + chain: VirtqueueChain, + queue: Int, + lifecycleEpoch: UInt64, + virtqueue: Virtqueue, + transport: VirtioMMIOTransport + ) -> ResponsePublication { var interruptWanted = false - var publishFailureReason: String? + var failureReason: String? let pushed = transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }) else { - publishFailureReason = "lifecycle epoch changed" + failureReason = "lifecycle epoch changed" return false } guard virtqueue.ready else { - publishFailureReason = "queue not ready" + failureReason = "queue not ready" return false } - return notificationLock.withLock { - if responseCacheEpoch != requestEpochs.cache, let decoded { - // Queue-health degradation can overtake a worker outside the register lock. - // Such a response may keep its payload, but it cannot carry a pre-degradation - // entry/attribute validity grant. Normal host edits use the full request gate, - // preserving LOOKUP identities and READ payload ordering as well. - _ = server.neutralizeCacheGrants( - opcode: decoded.opcode, - writable: chain.writableSegments, - written: written - ) + return requestGateLock.withLock { + guard !requestGateFailureLatched else { + failureReason = "request gate latched" + return false } - // A high-level invalidation deadline is a one-way publication boundary. HostFS - // work admitted before that boundary may already have performed its host syscall - // and cannot be rolled back here, but its response must never become guest-visible - // afterward. An already-admitted syscall cannot be canceled and keeps normal host - // last-writer semantics; this fence covers only response publication and new work. - return requestGateLock.withLock { - guard !requestGateFailureLatched else { - publishFailureReason = "request gate latched" - return false - } - do { - interruptWanted = try virtqueue.push(chain, written: written) - return true - } catch { - publishFailureReason = "virtqueue push threw: \(error)" - return false - } + guard virtqueue.isLeaseValid(chain) else { + failureReason = "queue lease changed" + return false + } + guard chain.withLeaseHeld({ access in + access.writeBytes(response) == response.count + }) == true else { + failureReason = "bounded response copy failed" + return false + } + do { + interruptWanted = try virtqueue.push(chain, written: response.count) + return true + } catch { + failureReason = "virtqueue push threw: \(error)" + return false } } } - if pushed, completesFuseInit, let requestNotificationEpoch = requestEpochs.notification { - notificationLock.withLock { - guard notificationTransport === transport, - notificationEpoch == requestNotificationEpoch else { return } - server.markFuseInitCompleted() - } - } - if !pushed, !lifetimeGrantRolledBack, let decoded { - FileHandle.standardError.write(Data( - "dory-hv: virtiofs response unpublished (\(publishFailureReason ?? "unknown")) op=\(decoded.opcode) unique=\(decoded.header.unique) queue=\(queue)\n".utf8 - )) - server.rollbackUnpublishedResponse( - opcode: decoded.opcode, - writable: chain.writableSegments, - written: written - ) - } - if let decoded, let statsStartNanoseconds { - stats?.recordCompletion( - decoded.opcode, - durationNanoseconds: DispatchTime.now().uptimeNanoseconds &- statsStartNanoseconds - ) - } - return pushed && interruptWanted + return ResponsePublication( + pushed: pushed, + interruptWanted: interruptWanted, + failureReason: failureReason + ) + } + + private func failWorker(_ reason: String) { + latchRequestGateFailure(barrier: nil) + onWorkerLifecycle(.failure(reason)) + } + + private static func errorResponse(unique: UInt64, errno: Int32) -> [UInt8] { + FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: errno == 0 ? 0 : -FuseProtocol.linuxErrno(errno), + unique: unique + )) } + } private struct PendingNotification { @@ -1003,16 +1300,18 @@ private extension VirtioFS { } func makeNotificationBuffer(_ chain: VirtqueueChain) -> NotificationBuffer? { - guard chain.readableSegments.isEmpty, - chain.writableSegments.count == 1, - let segment = chain.writableSegments.first, - segment.length >= Int(Self.notificationBufferSize) else { - return nil - } - // The patched guest reuses its kzalloc'd page but virtio may choose a new descriptor head - // when it reposts it. GuestMemory has a stable mapping, so its host pointer is a stable - // identity for the underlying guest buffer across those descriptor changes. - return NotificationBuffer(key: UInt(bitPattern: segment.pointer), chain: chain) + chain.withLeaseHeld { access -> NotificationBuffer? in + guard access.readableSegments.isEmpty, + access.writableSegments.count == 1, + let segment = access.writableSegments.first, + segment.length >= Int(Self.notificationBufferSize) else { + return nil + } + // The patched guest reuses its kzalloc'd page but virtio may choose a new descriptor + // head when it reposts it. Capture only the integer identity while the lease is held; + // the raw segment itself never escapes this callback. + return NotificationBuffer(key: UInt(bitPattern: segment.pointer), chain: chain) + } ?? nil } func pumpNotificationsLocked(queue: Virtqueue, effects: inout NotificationEffects) { @@ -1076,7 +1375,7 @@ private extension VirtioFS { // A QueueReady disable/reconfigure invalidates every retained descriptor immediately. Keep // feature negotiation and FUSE INIT only when the device itself remains live, but require a // fresh complete set of stable buffers before metadata caching can be reactivated. - server.deactivateCoherentCaching(resetFuseInit: resetFuseInit) + if resetFuseInit { fuseInitCompleted = false } responseCacheEpoch &+= 1 notificationEpoch &+= 1 let barriers = notificationBarrierTargets.map(\.barrier) @@ -1100,7 +1399,7 @@ private extension VirtioFS { notificationQueueReady: featureNegotiated && notificationQueueReady, stableNotificationBufferCount: observedNotificationBufferKeys.count, requiredStableNotificationBufferCount: Self.requiredStableNotificationBufferCountForCaching, - fuseInitCompleted: server.fuseInitCompleted + fuseInitCompleted: fuseInitCompleted ) } @@ -1172,38 +1471,113 @@ private extension VirtioFS { try result.get() } - func beginRequestProcessing( + private func beginRequestProcessing( queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, transport: VirtioMMIOTransport - ) -> Bool { - // Resolve, but do not consume, the next opcode before taking requestGateLock. Queue access - // always precedes gate state elsewhere too, avoiding a transport -> gate lock inversion. - let opcode: FuseOpcode? = transport.withQueueLock { + ) -> RequestBeginResult { + // Resolve, but do not consume, the next exact admission shape before taking + // requestGateLock. Queue access always precedes gate state elsewhere too, avoiding a + // transport -> gate lock inversion. Rejected guest requests need no workspace lease. + var queueFault: (any Error)? + let preview: (opcode: FuseOpcode?, shape: DoryFSWorkerAdmissionShape?) = + transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }), - virtqueue.ready, - let chain = try? virtqueue.peek() else { return nil } - let request = chain.readBytes(maximum: FuseInHeader.byteCount) - guard let header = try? FuseProtocol.decodeInHeader(request) else { return nil } - return FuseOpcode(rawValue: header.opcode) + virtqueue.ready else { return (nil, nil) } + let chain: VirtqueueChain + do { + guard let pending = try virtqueue.peek() else { return (nil, nil) } + chain = pending + } catch { + queueFault = error + return (nil, nil) + } + guard let preview = chain.withLeaseHeld({ access in + VirtioFSRequestAdmission.preview( + chain: chain, + access: access, + queue: queue, + maximumRequestBytes: broker.effectiveAdmissionLimits.maximumRequestBytes, + maximumResponseBytes: broker.effectiveAdmissionLimits.maximumResponseBytes + ) + }) else { return (nil, nil) } + guard let requestBytes = preview.requestBytes, + let responseBytes = preview.responseBytes else { + return (preview.opcode, nil) + } + return ( + preview.opcode, + DoryFSWorkerAdmissionShape( + requestBytes: requestBytes, + responseBytes: responseBytes + ) + ) + } + if let queueFault { + recordTerminalQueueFault(queueFault, queue: queue) + return .deferred } return requestGateLock.withLock { guard !connectionResetPending, !requestGateFailureLatched else { + grantedAdmissions.removeValue(forKey: queue)?.release() deferredRequestQueues.insert(queue) - return false + return .deferred } // A delayed write may be writeback copied before the host edit. Keep it in the guest's // available ring until reverse invalidation succeeds or the VM is discarded. All other // object operations must be able to finish so the guest can release VFS locks needed by // its notification worker. Unknown/malformed requests remain fail-closed. if requestGateClosed, - opcode?.mayDrainDuringReverseInvalidation != true { + preview.opcode?.mayDrainDuringReverseInvalidation != true { + grantedAdmissions.removeValue(forKey: queue)?.release() deferredRequestQueues.insert(queue) - return false + return .deferred + } + guard let shape = preview.shape else { + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) + } + if let granted = grantedAdmissions.removeValue(forKey: queue) { + guard granted.shape == shape else { + granted.release() + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) + } + activeRequestCount += 1 + return .process(granted, previewOpcode: preview.opcode) + } + guard !deferredAdmissionQueues.contains(queue) else { return .deferred } + let waiterID = admissionWaiterIDs[queue] + let result = broker.requestFrontendAdmission( + shape: shape, + waiterID: waiterID + ) { [weak self, weak transport] resolution in + switch resolution { + case .granted(let lease): + guard let self, let transport else { + lease.release() + return + } + self.receiveGrantedAdmission(lease, queue: queue, transport: transport) + case .terminated(let error): + self?.receiveTerminatedAdmission(error, queue: queue) + } + } + switch result { + case .admitted(let lease): + activeRequestCount += 1 + return .process(lease, previewOpcode: preview.opcode) + case .deferred: + deferredAdmissionQueues.insert(queue) + return .deferred + case .rejected: + // Exact per-request ceilings were already applied while peeking. If the typed + // authority still rejects, consume this guest request only to publish retryable + // EAGAIN; never reinterpret ordinary capacity as worker loss. + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) } - activeRequestCount += 1 - return true } } @@ -1214,7 +1588,9 @@ private extension VirtioFS { ) = requestGateLock.withLock { precondition(activeRequestCount > 0) activeRequestCount -= 1 - guard activeRequestCount == 0 else { return ([], false) } + guard activeRequestCount == 0 else { + return ([], false) + } let shouldReset = connectionResetPending && !connectionResetInProgress if shouldReset { connectionResetInProgress = true @@ -1224,26 +1600,101 @@ private extension VirtioFS { requestGateWaiters.removeAll(keepingCapacity: true) return (waiters, shouldReset) } - if result.shouldReset { - server.resetConnection() - finishConnectionReset() - } + if result.shouldReset { invalidateWorkerForConnectionReset() } for waiter in result.waiters { waiter.resume(returning: .success(())) } } - func beginConnectionReset(transport: VirtioMMIOTransport) { - let shouldReset = requestGateLock.withLock { + func receiveGrantedAdmission( + _ lease: DoryFSWorkerAdmissionLease, + queue: Int, + transport: VirtioMMIOTransport + ) { + let shouldSchedule = requestGateLock.withLock { + deferredAdmissionQueues.remove(queue) + guard !connectionResetPending, + !requestGateFailureLatched, + grantedAdmissions[queue] == nil else { return false } + grantedAdmissions[queue] = lease + return true + } + guard shouldSchedule else { + lease.release() + return + } + workers.async { [weak self, weak transport] in + guard let self, let transport else { + lease.release() + return + } + self.handleKick(queue: queue, transport: transport) + } + } + + func receiveTerminatedAdmission( + _ error: DoryFSWorkerBrokerError, + queue: Int + ) { + let shouldReport = requestGateLock.withLock { + deferredAdmissionQueues.remove(queue) + guard !frontendAdmissionTerminationReported else { return false } + frontendAdmissionTerminationReported = true + return true + } + guard shouldReport else { return } + failWorker("filesystem worker admission terminated: \(error)") + } + + func beginConnectionReset( + transport: VirtioMMIOTransport, + event: VirtioFSWorkerLifecycleEvent + ) { + let result = requestGateLock.withLock { () -> (Bool, FrontendAdmissionCleanup) in connectionResetPending = true connectionResetTransport = transport - guard activeRequestCount == 0, !connectionResetInProgress else { return false } + requestGateClosed = true + requestGateFailureLatched = true + let cleanup = detachFrontendAdmissionsLocked() + if connectionResetEvent == nil { + connectionResetEvent = event + } + guard activeRequestCount == 0, + !connectionResetInProgress else { return (false, cleanup) } connectionResetInProgress = true - return true + return (true, cleanup) + } + releaseFrontendAdmissions(result.1) + guard result.0 else { return } + invalidateWorkerForConnectionReset() + } + + func invalidateWorkerForConnectionReset() { + Task { [weak self] in + guard let self else { return } + let event = requestGateLock.withLock { + connectionResetEvent + ?? .failure("filesystem worker generation invalidated by virtio-fs device reset") + } + let reportedEvent: VirtioFSWorkerLifecycleEvent + switch event { + case .connectionTeardown: + do { + try await broker.completeConnectionTeardown() + reportedEvent = .connectionTeardown + } catch { + await broker.invalidate() + reportedEvent = .failure( + "filesystem worker connection teardown was not quiescent: \(error)" + ) + } + case .failure: + await broker.invalidate() + reportedEvent = event + } + onWorkerLifecycle(reportedEvent) + finishConnectionReset() } - guard shouldReset else { return } - server.resetConnection() - finishConnectionReset() } func finishConnectionReset() { @@ -1251,6 +1702,7 @@ private extension VirtioFS { guard connectionResetInProgress else { return nil } connectionResetInProgress = false connectionResetPending = false + connectionResetEvent = nil if requestGateClosed { if requestGateTransport == nil { requestGateTransport = connectionResetTransport @@ -1366,7 +1818,7 @@ private extension VirtioFS { /// different executor, so the publication boundary must be established synchronously here. /// Constructing the replacement VM/backend is the only operation that clears this latch. func latchRequestGateFailure(barrier: VirtioFSNotificationBarrier?) { - requestGateLock.withLock { + let cleanup = requestGateLock.withLock { requestGateClosed = true requestGateFailureLatched = true if let barrier { @@ -1374,7 +1826,9 @@ private extension VirtioFS { requestGateBarriers.removeValue(forKey: identifier) requestGateCallerRetainedBarriers.remove(identifier) } + return detachFrontendAdmissionsLocked() } + releaseFrontendAdmissions(cleanup) } func openRequestGateIfResolvedLocked() -> RequestGateRelease? { @@ -1406,23 +1860,30 @@ private extension VirtioFS { } } - static func requestQueueCountFromEnvironment() -> Int { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_QUEUES"].flatMap(Int.init) else { - return min(8, max(1, ProcessInfo.processInfo.activeProcessorCount)) + func detachFrontendAdmissionsLocked() -> FrontendAdmissionCleanup { + let waiters = deferredAdmissionQueues.map { admissionWaiterIDs[$0] } + let leases = Array(grantedAdmissions.values) + deferredAdmissionQueues.removeAll(keepingCapacity: false) + grantedAdmissions.removeAll(keepingCapacity: false) + return FrontendAdmissionCleanup(waiters: waiters, leases: leases) + } + + func releaseFrontendAdmissions(_ cleanup: FrontendAdmissionCleanup) { + for waiter in cleanup.waiters { + broker.cancelFrontendAdmission(waiterID: waiter) } - return value + for lease in cleanup.leases { lease.release() } } static func clampedRequestQueueCount(_ count: Int) -> Int { min(16, max(1, count)) } - static func inlineRequestsFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_INLINE"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } +} + +private struct FrontendAdmissionCleanup { + let waiters: [DoryFSWorkerAdmissionWaiterID] + let leases: [DoryFSWorkerAdmissionLease] } private extension FuseOpcode { @@ -1432,7 +1893,7 @@ private extension FuseOpcode { /// locks and remain behind the boundary too, keeping the exceptional path minimal. var mayDrainDuringReverseInvalidation: Bool { switch self { - case .write, .statfs, .initOp, .destroy, .interrupt, .notifyReply, + case .write, .statfs, .syncfs, .initOp, .destroy, .interrupt, .notifyReply, .forget, .batchForget: false default: @@ -1441,46 +1902,4 @@ private extension FuseOpcode { } } -private final class VirtioFSStats: @unchecked Sendable { - private let tag: String - private let lock = NSLock() - private var counts: [FuseOpcode: Int] = [:] - private var durationNanoseconds: [FuseOpcode: UInt64] = [:] - private var total = 0 - - init(tag: String) { - self.tag = tag - } - - static func fromEnvironment(tag: String) -> VirtioFSStats? { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_STATS"] ?? "" - guard ["1", "true", "yes", "on"].contains(value.lowercased()) else { return nil } - FileHandle.standardError.write(Data("dory-hv: virtiofs stats enabled tag=\(tag)\n".utf8)) - return VirtioFSStats(tag: tag) - } - - func recordCompletion(_ opcode: FuseOpcode, durationNanoseconds elapsed: UInt64) { - let snapshot: (Int, [FuseOpcode: Int], [FuseOpcode: UInt64])? = lock.withLock { - total += 1 - counts[opcode, default: 0] += 1 - durationNanoseconds[opcode, default: 0] &+= elapsed - guard total <= 20 || total.isMultiple(of: 100) else { return nil } - return (total, counts, durationNanoseconds) - } - guard let snapshot else { return } - let line = snapshot.1 - .sorted { lhs, rhs in lhs.key.rawValue < rhs.key.rawValue } - .map { opcode, count in - let nanoseconds = snapshot.2[opcode] ?? 0 - let totalMilliseconds = Double(nanoseconds) / 1_000_000 - let averageMicroseconds = count == 0 ? 0 : Double(nanoseconds) / Double(count) / 1_000 - let totalText = String(format: "%.3f", totalMilliseconds) - let averageText = String(format: "%.1f", averageMicroseconds) - return "\(opcode)=\(count)/\(totalText)ms/\(averageText)us" - } - .joined(separator: " ") - FileHandle.standardError.write(Data("dory-hv: virtiofs stats tag=\(tag) total=\(snapshot.0) \(line)\n".utf8)) - } -} - extension VirtioFS: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift index 605028a3..33a35af4 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation /// Cache invalidations delivered through the negotiated virtio-fs notification queue. diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift new file mode 100644 index 00000000..da7c1301 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift @@ -0,0 +1,379 @@ +import DoryFSWorkerContracts +import Foundation + +/// Host-owned request envelope produced while the exact virtqueue lease is held. Execution code +/// never needs guest pointers, which is the seam a future out-of-process FUSE worker can consume. +struct VirtioFSAdmittedRequest: Sendable { + let bytes: [UInt8] + let header: FuseInHeader + let opcode: FuseOpcode? + let writableCapacity: Int + let maximumResponseBytes: Int + let expectsReply: Bool +} + +enum VirtioFSRequestRejection: Error, Equatable, Sendable, CustomStringConvertible { + case emptyChain + case zeroLengthDescriptor + case readableAfterWritable + case missingReadablePrefix + case missingWritableSuffix + case shortHeader + case requestTooLarge(limit: Int, actual: Int) + case lengthMismatch(declared: UInt32, actual: Int) + case headerChangedDuringSnapshot + case wrongQueue(queue: Int, opcode: UInt32) + case responseTooLarge(limit: Int, requested: Int) + case insufficientResponseCapacity(required: Int, actual: Int) + + var description: String { + switch self { + case .emptyChain: return "empty descriptor chain" + case .zeroLengthDescriptor: return "zero-length descriptor" + case .readableAfterWritable: return "readable descriptor follows writable suffix" + case .missingReadablePrefix: return "missing readable request prefix" + case .missingWritableSuffix: return "missing writable response suffix" + case .shortHeader: return "request is shorter than fuse_in_header" + case let .requestTooLarge(limit, actual): + return "request size \(actual) exceeds \(limit) bytes" + case let .lengthMismatch(declared, actual): + return "fuse_in_header.len \(declared) does not equal readable size \(actual)" + case .headerChangedDuringSnapshot: + return "fuse_in_header changed while taking the host-owned request snapshot" + case let .wrongQueue(queue, opcode): + return "opcode \(opcode) is not permitted on queue \(queue)" + case let .responseTooLarge(limit, requested): + return "response bound \(requested) exceeds \(limit) bytes" + case let .insufficientResponseCapacity(required, actual): + return "response capacity \(actual) is smaller than required \(required)" + } + } +} + +struct VirtioFSRejectedRequest: Sendable { + let reason: VirtioFSRequestRejection + /// A complete host-owned FUSE error frame when the validated writable suffix can hold one. + let response: [UInt8] +} + +enum VirtioFSRequestAdmissionDecision: Sendable { + case execute(VirtioFSAdmittedRequest) + case reject(VirtioFSRejectedRequest) +} + +struct VirtioFSRequestAdmissionPreview: Sendable { + let opcode: FuseOpcode? + let requestBytes: Int? + let responseBytes: Int? +} + +enum VirtioFSRequestAdmission { + /// Dory negotiates a one-MiB FUSE transfer payload. The common header is bounded separately so + /// a legal maximum-sized WRITE remains representable without accepting an unbounded request. + static let maximumPayloadBytes = 1 * 1_024 * 1_024 + static let maximumRequestBytes = FuseInHeader.byteCount + maximumPayloadBytes + static let maximumResponseBytes = FuseOutHeader.byteCount + maximumPayloadBytes + + /// Computes only the exact admission-memory shape while the chain remains guest-owned. This + /// bounded preview reads at most the FUSE header plus the fixed fields needed to size READ and + /// READDIRPLUS replies; the full payload is copied exactly once, after a workspace lease has + /// been reserved and the chain is popped. + static func preview( + chain: VirtqueueChain, + access: VirtqueueLeaseAccess, + queue: Int, + maximumRequestBytes requestLimit: Int = maximumRequestBytes, + maximumResponseBytes responseLimit: Int = maximumResponseBytes + ) -> VirtioFSRequestAdmissionPreview { + var readableBytes = 0 + var writableBytes = 0 + var sawReadable = false + var sawWritable = false + for segment in access.segments { + guard segment.length > 0 else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + if segment.isDeviceWritable { + sawWritable = true + guard let total = checkedAdd(writableBytes, segment.length) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + writableBytes = total + } else { + guard !sawWritable, + let total = checkedAdd(readableBytes, segment.length) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + sawReadable = true + readableBytes = total + } + } + guard !chain.containsZeroLengthDescriptor, + sawReadable, + readableBytes >= FuseInHeader.byteCount else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + let prefix = access.readBytes( + maximum: min(readableBytes, FuseInHeader.byteCount + 32) + ) + guard prefix.count >= FuseInHeader.byteCount, + let header = try? FuseProtocol.decodeInHeader(prefix) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + let opcode = FuseOpcode(rawValue: header.opcode) + guard readableBytes <= requestLimit, + header.length == UInt32(readableBytes) else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + let replylessForget = opcode == .forget || opcode == .batchForget + let isHighPriority = replylessForget || opcode == .interrupt + guard (queue == 0) == isHighPriority, + replylessForget || sawWritable else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + guard let requiredCapacity = try? requiredResponseCapacity( + opcode: opcode, + request: prefix, + maximumResponseBytes: responseLimit + ), requiredCapacity <= responseLimit, + writableBytes >= requiredCapacity else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + return VirtioFSRequestAdmissionPreview( + opcode: opcode, + requestBytes: readableBytes, + responseBytes: opcode == .readlink + ? min(writableBytes, responseLimit) + : requiredCapacity + ) + } + + static func inspect( + chain: VirtqueueChain, + access: VirtqueueLeaseAccess, + queue: Int, + maximumRequestBytes requestLimit: Int = maximumRequestBytes, + maximumResponseBytes responseLimit: Int = maximumResponseBytes + ) -> VirtioFSRequestAdmissionDecision { + guard !access.segments.isEmpty else { return reject(.emptyChain) } + guard !chain.containsZeroLengthDescriptor else { + return reject(.zeroLengthDescriptor) + } + + var sawReadable = false + var sawWritable = false + var readableBytes = 0 + var writableBytes = 0 + for segment in access.segments { + guard segment.length > 0 else { return reject(.zeroLengthDescriptor) } + if segment.isDeviceWritable { + sawWritable = true + guard let total = checkedAdd(writableBytes, segment.length) else { + return reject(.responseTooLarge(limit: maximumResponseBytes, requested: Int.max)) + } + writableBytes = total + } else { + guard !sawWritable else { return reject(.readableAfterWritable) } + sawReadable = true + guard let total = checkedAdd(readableBytes, segment.length) else { + return reject(.requestTooLarge(limit: maximumRequestBytes, actual: Int.max)) + } + readableBytes = total + } + } + guard sawReadable else { return reject(.missingReadablePrefix) } + guard readableBytes >= FuseInHeader.byteCount else { return reject(.shortHeader) } + + let headerBytes = access.readBytes(maximum: FuseInHeader.byteCount) + guard headerBytes.count == FuseInHeader.byteCount, + let observedHeader = try? FuseProtocol.decodeInHeader(headerBytes) else { + return reject(.shortHeader) + } + guard readableBytes <= requestLimit else { + return reject( + .requestTooLarge(limit: requestLimit, actual: readableBytes), + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: E2BIG + ) + } + guard observedHeader.length == UInt32(readableBytes) else { + return reject( + .lengthMismatch(declared: observedHeader.length, actual: readableBytes), + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + + let request = access.readBytes(maximum: readableBytes) + guard request.count == readableBytes else { + return reject(.lengthMismatch(declared: observedHeader.length, actual: request.count)) + } + guard let header = try? FuseProtocol.decodeInHeader(request), + header.length == UInt32(readableBytes) else { + return reject( + .headerChangedDuringSnapshot, + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + guard header == observedHeader else { + return reject( + .headerChangedDuringSnapshot, + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + + // Every decision that can authorize host work is derived from the immutable host-owned + // snapshot above. The bounded preliminary header is used only to prove the copy size. + let opcode = FuseOpcode(rawValue: header.opcode) + let replylessForget = opcode == .forget || opcode == .batchForget + let isHighPriority = replylessForget || opcode == .interrupt + guard (queue == 0) == isHighPriority else { + return reject( + .wrongQueue(queue: queue, opcode: header.opcode), + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EPROTO + ) + } + if !replylessForget, !sawWritable { + return reject(.missingWritableSuffix, header: header, writableCapacity: 0, errno: EIO) + } + let requiredCapacity: Int + do { + requiredCapacity = try requiredResponseCapacity( + opcode: opcode, + request: request, + maximumResponseBytes: responseLimit + ) + } catch let reason as VirtioFSRequestRejection { + return reject( + reason, + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } catch { + return reject( + .responseTooLarge(limit: maximumResponseBytes, requested: Int.max), + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + guard writableBytes >= requiredCapacity else { + return reject( + .insufficientResponseCapacity(required: requiredCapacity, actual: writableBytes), + header: header, + writableCapacity: writableBytes, + errno: EIO + ) + } + guard requiredCapacity <= responseLimit else { + return reject( + .responseTooLarge(limit: responseLimit, requested: requiredCapacity), + header: header, + writableCapacity: writableBytes, + errno: E2BIG + ) + } + + return .execute(VirtioFSAdmittedRequest( + bytes: request, + header: header, + opcode: opcode, + writableCapacity: writableBytes, + maximumResponseBytes: opcode == .readlink + ? min(writableBytes, responseLimit) + : requiredCapacity, + expectsReply: !replylessForget + )) + } + + private static func requiredResponseCapacity( + opcode: FuseOpcode?, + request: [UInt8], + maximumResponseBytes: Int + ) throws -> Int { + guard let opcode else { return FuseOutHeader.byteCount } + switch opcode { + case .forget, .batchForget: + return 0 + case .initOp: + return FuseOutHeader.byteCount + FuseInitOut.byteCount + case .lookup, .symlink, .link, .mkdir: + return FuseOutHeader.byteCount + 128 + case .getattr, .setattr: + return FuseOutHeader.byteCount + 104 + case .open, .opendir: + return FuseOutHeader.byteCount + 16 + case .read, .readdirplus: + let payloadOffset = FuseInHeader.byteCount + guard request.count >= payloadOffset + 20 else { return FuseOutHeader.byteCount } + let payloadBytes = Int(request.leUInt32(at: payloadOffset + 16)) + let required = FuseOutHeader.byteCount + payloadBytes + guard required <= maximumResponseBytes else { + throw VirtioFSRequestRejection.responseTooLarge( + limit: maximumResponseBytes, + requested: required + ) + } + return required + case .write: + return FuseOutHeader.byteCount + 8 + case .statfs: + return FuseOutHeader.byteCount + 80 + case .getlk: + return FuseOutHeader.byteCount + 24 + case .listxattr: + return FuseOutHeader.byteCount + 4 + case .create: + return FuseOutHeader.byteCount + 144 + case .readlink: + // READLINK neither mutates host state nor grants a resource. Its payload is validated + // against the actual writable capacity after the single generic execution. + return FuseOutHeader.byteCount + case .unlink, .rmdir, .rename, .release, .fsync, .syncfs, .setxattr, .getxattr, .flush, + .setlk, .setlkw, .interrupt, .releasedir, .setupmapping, .removemapping: + // These implemented operations have either an empty success or a header-only error. + // INTERRUPT remains reply-capable at the transport boundary even when no matching + // pending server operation requires a payload. + return FuseOutHeader.byteCount + case .readdir, .fsyncdir, .bmap, .destroy, .ioctl, .poll, .notifyReply, .fallocate, + .rename2, .lseek, .copyFileRange: + // The generic FuseServer path explicitly rejects these with a header-only ENOSYS. + // Keeping the classification exhaustive prevents a future mutating implementation + // from silently inheriting an insufficient response preflight. + return FuseOutHeader.byteCount + } + } + + private static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : sum + } + + private static func reject(_ reason: VirtioFSRequestRejection) -> VirtioFSRequestAdmissionDecision { + .reject(VirtioFSRejectedRequest(reason: reason, response: [])) + } + + private static func reject( + _ reason: VirtioFSRequestRejection, + header: FuseInHeader, + writableCapacity: Int, + errno: Int32 + ) -> VirtioFSRequestAdmissionDecision { + guard writableCapacity >= FuseOutHeader.byteCount else { return reject(reason) } + let response = FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: -FuseProtocol.linuxErrno(errno), + unique: header.unique + )) + return .reject(VirtioFSRejectedRequest(reason: reason, response: response)) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 75d682f4..450e27e7 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -1,6 +1,10 @@ import Darwin +import DoryFSWorkerContracts +import DoryRendererWorkerContracts +import Dispatch import Foundation import Hypervisor +import Metal public struct VirtioGPUCapset: Sendable, Equatable { public var id: UInt32 @@ -17,10 +21,18 @@ public struct VirtioGPUCapset: Sendable, Equatable { public struct VirtioGPUMemoryEntry { public var pointer: UnsafeMutableRawPointer public var length: Int + /// Guest-physical identity used to grant the same bytes to an isolated renderer worker. + /// Synthetic host-only entries leave this nil and are never eligible for worker submission. + public var guestAddress: UInt64? - public init(pointer: UnsafeMutableRawPointer, length: Int) { + public init( + pointer: UnsafeMutableRawPointer, + length: Int, + guestAddress: UInt64? = nil + ) { self.pointer = pointer self.length = length + self.guestAddress = guestAddress } } @@ -46,6 +58,47 @@ public struct VirtioGPUBlobMapping { } } +/// VMM-local mapping of a worker-exported SHM blob. The descriptor and mmap lifetime remain bound +/// to one authenticated resource generation; neither a worker pointer nor a path crosses XPC. +private final class DoryRendererWorkerBlobMappingAuthority: @unchecked Sendable { + let lease: DoryRendererBlobMappingLease + let hostPointer: UnsafeMutableRawPointer + let mappedByteCount: Int + private let descriptor: FileHandle + + init(_ mapping: DoryRendererWorkerBlobMapping) throws { + let roundedLength = mapping.lease.mappingByteCount + .roundedUpToMultiple(of: HostPage.size) + guard roundedLength > 0, + roundedLength <= mapping.lease.declaredFileSize, + roundedLength <= UInt64(Int.max) else { + try? mapping.sharedMemoryDescriptor.close() + throw VMError.invalidConfiguration("worker blob mapping exceeds SHM authority") + } + let pointer = mmap( + nil, + Int(roundedLength), + PROT_READ | PROT_WRITE, + MAP_SHARED, + mapping.sharedMemoryDescriptor.fileDescriptor, + 0 + ) + guard pointer != MAP_FAILED, let pointer else { + try? mapping.sharedMemoryDescriptor.close() + throw VMError.outOfMemory("cannot map worker blob SHM: errno \(errno)") + } + self.lease = mapping.lease + self.hostPointer = pointer + self.mappedByteCount = Int(roundedLength) + self.descriptor = mapping.sharedMemoryDescriptor + } + + deinit { + munmap(hostPointer, mappedByteCount) + try? descriptor.close() + } +} + public struct VirtioGPUResourceCreate3D { public var resourceID: UInt32 public var target: UInt32 @@ -103,6 +156,7 @@ public struct VirtioGPUScanoutSize: Sendable, Equatable { public struct VirtioGPUScanoutFrame: Sendable, Equatable { public var scanoutID: UInt32 public var resourceID: UInt32 + public var resourceGeneration: UInt64 public var format: UInt32 public var width: UInt32 public var height: UInt32 @@ -113,6 +167,7 @@ public struct VirtioGPUScanoutFrame: Sendable, Equatable { public init( scanoutID: UInt32, resourceID: UInt32, + resourceGeneration: UInt64 = 0, format: UInt32, width: UInt32, height: UInt32, @@ -122,6 +177,7 @@ public struct VirtioGPUScanoutFrame: Sendable, Equatable { ) { self.scanoutID = scanoutID self.resourceID = resourceID + self.resourceGeneration = resourceGeneration self.format = format self.width = width self.height = height @@ -131,6 +187,537 @@ public struct VirtioGPUScanoutFrame: Sendable, Equatable { } } +/// A renderer-owned OpenGL texture that can be displayed without copying its pixels through host +/// memory. The texture name is valid only in an OpenGL context that shares objects with the +/// renderer. `yOriginTop` is the renderer's authoritative orientation bit and must be preserved by +/// the display backend rather than inferred from the guest format. +public struct VirtioGPUTextureResource: Sendable, Equatable { + public var textureID: UInt32 + public var format: UInt32 + public var width: UInt32 + public var height: UInt32 + public var yOriginTop: Bool + + public init( + textureID: UInt32, + format: UInt32, + width: UInt32, + height: UInt32, + yOriginTop: Bool + ) { + self.textureID = textureID + self.format = format + self.width = width + self.height = height + self.yOriginTop = yOriginTop + } +} + +/// A producer-completion boundary for a renderer-owned texture presentation. +/// +/// `prepareConsumerForPresentation()` is invoked with the display's shared OpenGL context current. +/// A conforming renderer must enqueue a server-side wait for producer completion on that context +/// (without a CPU-wide finish) and return only after subsequent consumer reattachment and reads are +/// ordered behind it. A texture name by itself is deliberately not presentation authority: +/// `glFlush` only submits producer work and does not establish the required cross-context +/// completion dependency. Each synchronization object is a single-use authority: an +/// implementation must atomically choose either a successful consumer preparation or discard, +/// make repeated calls harmless, and destroy its completion primitive on the context that owns it. +public protocol VirtioGPUTexturePresentationSynchronization: AnyObject, Sendable { + func prepareConsumerForPresentation() throws + /// Retires an authority that was coalesced or rejected before the consumer wait was enqueued. + /// This may be called from a producer/mailbox thread; implementations must schedule any + /// context-bound fence destruction on an appropriate renderer context and make it idempotent. + func discardWithoutPresentation() +} + +/// Renderer-issued authority to present one shared texture after an explicit producer-completion +/// handoff. The synchronization object is intentionally opaque to the virtio device and AppKit +/// mailbox. This is the legacy in-process representation; the signed worker contract instead +/// exports either an owned SHM descriptor with immutable linear layout or a secure-coding +/// `MTLSharedTextureHandle`, plus a single-use release token and its qualified producer-completion +/// contract. A process-local GL/Metal texture name by itself is never cross-process authority. +public struct VirtioGPUTexturePresentation: Sendable { + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let texture: VirtioGPUTextureResource + private let synchronization: any VirtioGPUTexturePresentationSynchronization + + public init( + resourceID: UInt32, + resourceGeneration: UInt64, + texture: VirtioGPUTextureResource, + synchronization: any VirtioGPUTexturePresentationSynchronization + ) { + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.texture = texture + self.synchronization = synchronization + } + + public func prepareConsumerForPresentation() throws { + try synchronization.prepareConsumerForPresentation() + } + + public func discardWithoutPresentation() { + synchronization.discardWithoutPresentation() + } +} + +public enum VirtioGPUMetalScanoutPresentationError: Error, Equatable, Sendable { + case retired +} + +public enum VirtioGPUMetalScanoutTransport: Equatable, Sendable { + case sharedMemory + case sharedTexture +} + +/// One consumer's authority to import a worker-issued scanout into Metal without copying pixels. +/// A Venus lease borrows one SHM descriptor; a VirGL2 lease borrows one secure-coding shared-texture +/// handle. The managed guest's producer-complete flush has already run before publication. +public final class VirtioGPUMetalScanoutPresentation: @unchecked Sendable { + public let workerGeneration: DoryRendererWorkerGeneration + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let leaseID: DoryRendererScanoutLeaseID + public let releaseToken: DoryRendererScanoutReleaseToken + public let pixelFormat: DoryRendererScanoutPixelFormat + public let yOriginTop: Bool + public let width: UInt32 + public let height: UInt32 + public let transport: VirtioGPUMetalScanoutTransport + + private let consumerID: UInt32 + private let core: DoryRendererWorkerSharedScanoutCore + private let lock = NSLock() + private var retired = false + + fileprivate init( + scanout: DoryRendererWorkerScanoutAuthority, + consumerID: UInt32, + core: DoryRendererWorkerSharedScanoutCore + ) { + switch scanout { + case .sharedMemory(let value): + transport = .sharedMemory + workerGeneration = value.lease.workerGeneration + resourceID = value.lease.resourceID + resourceGeneration = value.lease.resourceGeneration + leaseID = value.lease.leaseID + releaseToken = value.lease.releaseToken + pixelFormat = value.lease.pixelFormat + yOriginTop = value.lease.yOriginTop + width = value.lease.width + height = value.lease.height + case .sharedTexture(let value): + transport = .sharedTexture + workerGeneration = value.lease.workerGeneration + resourceID = value.lease.resourceID + resourceGeneration = value.lease.resourceGeneration + leaseID = value.lease.leaseID + releaseToken = value.lease.releaseToken + pixelFormat = value.lease.pixelFormat + yOriginTop = value.lease.yOriginTop + width = value.lease.width + height = value.lease.height + } + self.consumerID = consumerID + self.core = core + } + + public func withSharedMemoryScanout( + _ body: (DoryRendererScanoutLease, Int32) throws -> T + ) throws -> T { + let isRetired = lock.withLock { retired } + guard !isRetired else { throw VirtioGPUMetalScanoutPresentationError.retired } + return try core.withSharedMemoryScanout( + consumerID: consumerID, + body + ) + } + + public func withSharedTextureHandle( + _ body: (MTLSharedTextureHandle) throws -> T + ) throws -> T { + let isRetired = lock.withLock { retired } + guard !isRetired else { throw VirtioGPUMetalScanoutPresentationError.retired } + return try core.withSharedTextureHandle( + consumerID: consumerID, + body + ) + } + + /// Retires this display consumer after all Metal and mmap references have been destroyed. + /// The worker release token is sent only after every consumer of the shared lease retires. + public func finishPresentation() { + retire() + } + + /// Retires an update coalesced or rejected before presentation. This is intentionally the same + /// exact lifetime transition as a successfully displayed update. + public func discardWithoutPresentation() { + retire() + } + + deinit { + retire() + } + + private func retire() { + let shouldRetire = lock.withLock { () -> Bool in + guard !retired else { return false } + retired = true + return true + } + if shouldRetire { core.retireConsumer(consumerID) } + } +} + +/// Exactly-once acknowledgement that a worker update reached a committed host Metal command +/// buffer. Merely enqueueing an update in the AppKit mailbox is not sufficient: a following guest +/// modeset could otherwise retire the lease before the main thread imports it. +private final class VirtioGPUMetalScanoutHostSubmission: @unchecked Sendable { + private let lock = NSLock() + private var completion: (@Sendable (Bool) -> Void)? + + init(completion: @escaping @Sendable (Bool) -> Void) { + self.completion = completion + } + + func resolve(accepted: Bool) { + let callback = lock.withLock { () -> (@Sendable (Bool) -> Void)? in + let callback = completion + completion = nil + return callback + } + callback?(accepted) + } + + deinit { + resolve(accepted: false) + } +} + +/// One zero-copy Metal scanout update. `sourceRect` selects the guest scanout from the immutable +/// worker resource; `dirtyRect` is scanout-local damage and never carries frame bytes. +public struct VirtioGPUMetalScanoutUpdate: Sendable, Equatable { + public let scanoutID: UInt32 + public let resourceID: UInt32 + /// VMM display-lifetime identity used to order SET_SCANOUT, release, and ID reuse. + public let resourceGeneration: UInt64 + /// Independent authenticated worker identity returned by CREATE_BLOB and bound into the lease. + public let rendererResourceGeneration: UInt64 + public let presentation: VirtioGPUMetalScanoutPresentation + public let sourceRect: VirtioGPURect + public let dirtyRect: VirtioGPURect + private let hostSubmission: VirtioGPUMetalScanoutHostSubmission + + fileprivate init( + scanoutID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + rendererResourceGeneration: UInt64, + presentation: VirtioGPUMetalScanoutPresentation, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect, + hostSubmission: VirtioGPUMetalScanoutHostSubmission + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.rendererResourceGeneration = rendererResourceGeneration + self.presentation = presentation + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + self.hostSubmission = hostSubmission + } + + /// Completes the guest flush only after the display consumer has imported the lease and + /// committed a Metal command buffer that retains it. + public func acceptHostSubmission() { + hostSubmission.resolve(accepted: true) + } + + /// Fails the guest flush when the display consumer cannot commit the worker frame. The caller + /// must also retire `presentation` after all local references have been destroyed. + public func rejectHostSubmission() { + hostSubmission.resolve(accepted: false) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scanoutID == rhs.scanoutID + && lhs.resourceID == rhs.resourceID + && lhs.resourceGeneration == rhs.resourceGeneration + && lhs.rendererResourceGeneration == rhs.rendererResourceGeneration + && lhs.presentation.leaseID == rhs.presentation.leaseID + && lhs.sourceRect == rhs.sourceRect + && lhs.dirtyRect == rhs.dirtyRect + } +} + +/// Joins one host-submission acknowledgement per scanout for a single RESOURCE_FLUSH. Multi-head +/// guests receive success only after every target has accepted the same worker lease generation. +private final class DoryRendererWorkerHostSubmissionGroup: @unchecked Sendable { + private let lock = NSLock() + private var remaining: Int + private var accepted = true + private var completion: (@Sendable (Bool) -> Void)? + + init(count: Int, completion: @escaping @Sendable (Bool) -> Void) { + precondition(count > 0) + self.remaining = count + self.completion = completion + } + + func resolve(accepted: Bool) { + let result = lock.withLock { () -> ( + (@Sendable (Bool) -> Void), Bool + )? in + guard remaining > 0 else { return nil } + self.accepted = self.accepted && accepted + remaining -= 1 + guard remaining == 0, let completion else { return nil } + self.completion = nil + return (completion, self.accepted) + } + if let result { result.0(result.1) } + } +} + +/// Shared core for one producer-complete worker lease. A resource may be bound to several guest +/// scanouts, so each update receives a distinct consumer handle while the underlying transport +/// authority and release token remain singular. +private final class DoryRendererWorkerSharedScanoutCore: @unchecked Sendable { + typealias Release = @Sendable (DoryRendererWorkerScanoutAuthority) -> Void + typealias Terminal = @Sendable (DoryRendererScanoutReleaseToken) -> Void + + private enum State { + case ready + case terminal + } + + let releaseToken: DoryRendererScanoutReleaseToken + + private let lock = NSLock() + private var state: State = .ready + private var pendingConsumers: Set + private var scanout: DoryRendererWorkerScanoutAuthority? + private var release: Release? + private var terminal: Terminal? + + init( + scanout: DoryRendererWorkerScanoutAuthority, + consumerCount: Int, + release: @escaping Release, + terminal: @escaping Terminal + ) throws { + guard consumerCount > 0, consumerCount <= 16 else { + scanout.discardTransport() + throw VMError.invalidConfiguration("invalid worker scanout consumer count") + } + self.releaseToken = scanout.releaseToken + self.pendingConsumers = Set((0.. VirtioGPUMetalScanoutPresentation? { + lock.withLock { + guard case .ready = state, pendingConsumers.contains(consumerID) else { return nil } + return VirtioGPUMetalScanoutPresentation( + scanout: scanout!, + consumerID: consumerID, + core: self + ) + } + } + + func withSharedMemoryScanout( + consumerID: UInt32, + _ body: (DoryRendererScanoutLease, Int32) throws -> T + ) throws -> T { + lock.lock() + defer { lock.unlock() } + guard case .ready = state, + pendingConsumers.contains(consumerID), + case .sharedMemory(let value)? = scanout, + value.sharedMemoryDescriptor.fileDescriptor >= 0 else { + throw VirtioGPUMetalScanoutPresentationError.retired + } + return try body(value.lease, value.sharedMemoryDescriptor.fileDescriptor) + } + + func withSharedTextureHandle( + consumerID: UInt32, + _ body: (MTLSharedTextureHandle) throws -> T + ) throws -> T { + lock.lock() + defer { lock.unlock() } + guard case .ready = state, + pendingConsumers.contains(consumerID), + case .sharedTexture(let value)? = scanout else { + throw VirtioGPUMetalScanoutPresentationError.retired + } + return try body(value.sharedTextureHandle) + } + + func retireConsumer(_ consumerID: UInt32) { + let terminalAuthority = lock.withLock { () -> ( + DoryRendererWorkerScanoutAuthority?, Release?, Terminal? + )? in + guard case .ready = state, + pendingConsumers.remove(consumerID) != nil else { return nil } + guard pendingConsumers.isEmpty else { return nil } + state = .terminal + let scanout = self.scanout + self.scanout = nil + let release = self.release + self.release = nil + let terminal = self.terminal + self.terminal = nil + return (scanout, release, terminal) + } + guard let terminalAuthority else { return } + terminalAuthority.0?.discardTransport() + terminalAuthority.2?(releaseToken) + if let scanout = terminalAuthority.0 { terminalAuthority.1?(scanout) } + } + + /// Discards every not-yet-published consumer while the worker generation remains valid. + func retireWithoutPresentation() { + let terminalAuthority = terminate(requestWorkerRelease: true) + finish(terminalAuthority) + } + + /// Revokes local transport authority without attempting a token release into an already-revoked worker + /// generation. Used by reset, queue teardown, crash, and generation drift. + func revoke() { + let terminalAuthority = terminate(requestWorkerRelease: false) + finish(terminalAuthority) + } + + private func terminate( + requestWorkerRelease: Bool + ) -> (DoryRendererWorkerScanoutAuthority?, Release?, Terminal?)? { + lock.withLock { + guard case .terminal = state else { + state = .terminal + pendingConsumers.removeAll(keepingCapacity: false) + let scanout = self.scanout + self.scanout = nil + let release = requestWorkerRelease ? self.release : nil + self.release = nil + let terminal = self.terminal + self.terminal = nil + return (scanout, release, terminal) + } + return nil + } + } + + private func finish( + _ authority: (DoryRendererWorkerScanoutAuthority?, Release?, Terminal?)? + ) { + guard let authority else { return } + authority.0?.discardTransport() + authority.2?(releaseToken) + if let scanout = authority.0 { authority.1?(scanout) } + } +} + +private final class DoryRendererWorkerWeakScanoutCore: @unchecked Sendable { + weak var value: DoryRendererWorkerSharedScanoutCore? + + init(_ value: DoryRendererWorkerSharedScanoutCore) { + self.value = value + } +} + +/// One direct renderer-texture presentation. `sourceRect` selects the guest scanout within the +/// backing texture; `dirtyRect` is scanout-local damage. Damage schedules a redraw only—the texture +/// remains the single authoritative surface and no partial framebuffer bytes are copied or queued. +public struct VirtioGPUScanoutTextureUpdate: Sendable, Equatable { + public var scanoutID: UInt32 + public var presentation: VirtioGPUTexturePresentation + public var sourceRect: VirtioGPURect + public var dirtyRect: VirtioGPURect + + public var texture: VirtioGPUTextureResource { presentation.texture } + public var resourceID: UInt32 { presentation.resourceID } + public var resourceGeneration: UInt64 { presentation.resourceGeneration } + + public init( + scanoutID: UInt32, + presentation: VirtioGPUTexturePresentation, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect + ) { + self.scanoutID = scanoutID + self.presentation = presentation + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scanoutID == rhs.scanoutID + && lhs.resourceID == rhs.resourceID + && lhs.resourceGeneration == rhs.resourceGeneration + && lhs.texture == rhs.texture + && lhs.sourceRect == rhs.sourceRect + && lhs.dirtyRect == rhs.dirtyRect + } +} + +/// Acknowledged retirement of one guest resource generation from every configured scanout. +/// Renderer destruction is deferred until each scanout has detached any shared framebuffer +/// attachment and submitted that detach. Acknowledgements are keyed by scanout ID, making retries +/// idempotent and preventing one consumer from accidentally retiring another consumer's lease. +public final class VirtioGPUScanoutResourceRelease: @unchecked Sendable { + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let scanoutCount: UInt32 + + private let lock = NSLock() + private var pendingScanoutIDs: Set + private var completion: (@Sendable () -> Void)? + + init( + resourceID: UInt32, + resourceGeneration: UInt64, + scanoutCount: UInt32, + completion: @escaping @Sendable () -> Void + ) { + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.scanoutCount = scanoutCount + self.pendingScanoutIDs = Set(0.. Void)? = lock.withLock { + guard pendingScanoutIDs.remove(scanoutID) != nil, + pendingScanoutIDs.isEmpty else { + return nil + } + defer { completion = nil } + return completion + } + completed?() + } + + func acknowledgeAll() { + let completed: (@Sendable () -> Void)? = lock.withLock { + pendingScanoutIDs.removeAll() + defer { completion = nil } + return completion + } + completed?() + } +} + public struct VirtioGPUStatistics: Equatable, Sendable { public var fences: UInt64 public var fenceRegistrationFailures: UInt64 @@ -138,6 +725,36 @@ public struct VirtioGPUStatistics: Equatable, Sendable { public var hasTimedOutPendingFence: Bool public var rendererDeviceLosses: UInt64 public var hasLostRendererDevice: Bool + public var queuePendingReadFailures: UInt64 + public var queuePopFailures: UInt64 + public var invalidDescriptorChains: UInt64 + public var oversizedRequests: UInt64 + public var insufficientResponseCapacity: UInt64 + public var queuePushFailures: UInt64 + public var revokedCompletions: UInt64 + public var undeliveredFenceCompletions: UInt64 + public var responseWriteFailures: UInt64 + public var fenceAdmissionRejections: UInt64 + public var queueRevokedFences: UInt64 + public var resetRevokedFences: UInt64 + public var rendererCommandUncertainties: UInt64 + public var revokedUncertainRendererCommands: UInt64 + public var rendererWorkerSnapshotCount: UInt64 + public var rendererWorkerSnapshotBytes: UInt64 + public var rendererWorkerSnapshotNanoseconds: UInt64 + public var rendererWorkerMaximumSnapshotNanoseconds: UInt64 + public var rendererWorkerQueuedCommands: Int + public var rendererWorkerMaximumQueuedCommands: Int + public var rendererWorkerRejectedAdmissions: UInt64 + public var rendererWorkerCompletedControlCommands: UInt64 + public var rendererWorkerCompletedResourceCommands: UInt64 + public var rendererWorkerCompletedSubmissions: UInt64 + public var rendererWorkerArmedFences: Int + public var rendererWorkerCompletedFences: UInt64 + public var rendererWorkerScanoutCopyBytes: UInt64 + /// Guest-backing bytes copied into software scanout updates. This is separate from the worker + /// accelerated path, whose scanout copy count is required to remain exactly zero. + public var softwareScanoutCopiedBytes: UInt64 public init( fences: UInt64, @@ -145,7 +762,35 @@ public struct VirtioGPUStatistics: Equatable, Sendable { fenceTimeouts: UInt64, hasTimedOutPendingFence: Bool, rendererDeviceLosses: UInt64 = 0, - hasLostRendererDevice: Bool = false + hasLostRendererDevice: Bool = false, + queuePendingReadFailures: UInt64 = 0, + queuePopFailures: UInt64 = 0, + invalidDescriptorChains: UInt64 = 0, + oversizedRequests: UInt64 = 0, + insufficientResponseCapacity: UInt64 = 0, + queuePushFailures: UInt64 = 0, + revokedCompletions: UInt64 = 0, + undeliveredFenceCompletions: UInt64 = 0, + responseWriteFailures: UInt64 = 0, + fenceAdmissionRejections: UInt64 = 0, + queueRevokedFences: UInt64 = 0, + resetRevokedFences: UInt64 = 0, + rendererCommandUncertainties: UInt64 = 0, + revokedUncertainRendererCommands: UInt64 = 0, + rendererWorkerSnapshotCount: UInt64 = 0, + rendererWorkerSnapshotBytes: UInt64 = 0, + rendererWorkerSnapshotNanoseconds: UInt64 = 0, + rendererWorkerMaximumSnapshotNanoseconds: UInt64 = 0, + rendererWorkerQueuedCommands: Int = 0, + rendererWorkerMaximumQueuedCommands: Int = 0, + rendererWorkerRejectedAdmissions: UInt64 = 0, + rendererWorkerCompletedControlCommands: UInt64 = 0, + rendererWorkerCompletedResourceCommands: UInt64 = 0, + rendererWorkerCompletedSubmissions: UInt64 = 0, + rendererWorkerArmedFences: Int = 0, + rendererWorkerCompletedFences: UInt64 = 0, + rendererWorkerScanoutCopyBytes: UInt64 = 0, + softwareScanoutCopiedBytes: UInt64 = 0 ) { self.fences = fences self.fenceRegistrationFailures = fenceRegistrationFailures @@ -153,6 +798,37 @@ public struct VirtioGPUStatistics: Equatable, Sendable { self.hasTimedOutPendingFence = hasTimedOutPendingFence self.rendererDeviceLosses = rendererDeviceLosses self.hasLostRendererDevice = hasLostRendererDevice + self.queuePendingReadFailures = queuePendingReadFailures + self.queuePopFailures = queuePopFailures + self.invalidDescriptorChains = invalidDescriptorChains + self.oversizedRequests = oversizedRequests + self.insufficientResponseCapacity = insufficientResponseCapacity + self.queuePushFailures = queuePushFailures + self.revokedCompletions = revokedCompletions + self.undeliveredFenceCompletions = undeliveredFenceCompletions + self.responseWriteFailures = responseWriteFailures + self.fenceAdmissionRejections = fenceAdmissionRejections + self.queueRevokedFences = queueRevokedFences + self.resetRevokedFences = resetRevokedFences + self.rendererCommandUncertainties = rendererCommandUncertainties + self.revokedUncertainRendererCommands = revokedUncertainRendererCommands + self.rendererWorkerSnapshotCount = rendererWorkerSnapshotCount + self.rendererWorkerSnapshotBytes = rendererWorkerSnapshotBytes + self.rendererWorkerSnapshotNanoseconds = rendererWorkerSnapshotNanoseconds + self.rendererWorkerMaximumSnapshotNanoseconds = + rendererWorkerMaximumSnapshotNanoseconds + self.rendererWorkerQueuedCommands = rendererWorkerQueuedCommands + self.rendererWorkerMaximumQueuedCommands = rendererWorkerMaximumQueuedCommands + self.rendererWorkerRejectedAdmissions = rendererWorkerRejectedAdmissions + self.rendererWorkerCompletedControlCommands = + rendererWorkerCompletedControlCommands + self.rendererWorkerCompletedResourceCommands = + rendererWorkerCompletedResourceCommands + self.rendererWorkerCompletedSubmissions = rendererWorkerCompletedSubmissions + self.rendererWorkerArmedFences = rendererWorkerArmedFences + self.rendererWorkerCompletedFences = rendererWorkerCompletedFences + self.rendererWorkerScanoutCopyBytes = rendererWorkerScanoutCopyBytes + self.softwareScanoutCopiedBytes = softwareScanoutCopiedBytes } } @@ -163,6 +839,82 @@ public enum VirtioGPURendererRuntimeFailure: Error, Equatable, Sendable { case deviceLost(String) } +/// A renderer may report reset recovery only when every pre-reset context, resource, mapping, and +/// fence callback has been destroyed or made permanently unreachable before this call returns. +/// Returning `requiresRecreation` keeps the existing renderer object quarantined; the device will +/// reject further renderer-backed guest commands rather than pretend an in-place reset succeeded. +public enum VirtioGPURendererResetResult: Equatable, Sendable { + case ready + case requiresRecreation(String) +} + +public enum VirtioGPURendererHealthFault: Error, Equatable, Sendable { + case commandOutcomeUnknown(operation: String, detail: String) + case fenceRegistrationFailed(String) + case resetRequiresRecreation(String) + case resetFailed(String) + case resourceRetirementFailed(resourceID: UInt32, generation: UInt64, detail: String) + case quiescenceTimedOut(epoch: UInt64) +} + +public enum VirtioGPURendererLifecycleHealth: Equatable, Sendable { + case notConfigured + case ready(epoch: UInt64) + case quiescing(epoch: UInt64) + case failed(epoch: UInt64, fault: VirtioGPURendererHealthFault) +} + +public enum VirtioGPUQuiescenceReason: Equatable, Sendable { + case deviceReset + case shutdown +} + +public enum VirtioGPUQuiescenceOutcome: Equatable, Sendable { + case completed + case failed(VirtioGPURendererHealthFault) +} + +/// Receipt for an asynchronous display/renderer quiescence boundary. MMIO reset waits on this +/// receipt before returning to the guest; process shutdown can request the same boundary without +/// blocking its caller and wait from an appropriate lifecycle queue. +public final class VirtioGPUQuiescence: @unchecked Sendable { + public let epoch: UInt64 + public let reason: VirtioGPUQuiescenceReason + + private let condition = NSCondition() + private var storedOutcome: VirtioGPUQuiescenceOutcome? + + init(epoch: UInt64, reason: VirtioGPUQuiescenceReason) { + self.epoch = epoch + self.reason = reason + } + + public var outcome: VirtioGPUQuiescenceOutcome? { + condition.lock() + defer { condition.unlock() } + return storedOutcome + } + + public func wait(timeout: TimeInterval) -> VirtioGPUQuiescenceOutcome? { + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + condition.lock() + defer { condition.unlock() } + while storedOutcome == nil, condition.wait(until: deadline) {} + return storedOutcome + } + + func complete(_ outcome: VirtioGPUQuiescenceOutcome) { + condition.lock() + guard storedOutcome == nil else { + condition.unlock() + return + } + storedOutcome = outcome + condition.broadcast() + condition.unlock() + } +} + /// A copied guest cursor plane ready for the host window. Cursor bytes are never exposed through /// guest-owned pointers: the device snapshots the complete 32-bit BGRA resource at the /// UPDATE_CURSOR command boundary and carries the guest hotspot with it. @@ -200,8 +952,10 @@ public struct VirtioGPUCursorUpdate: Sendable, Equatable { } } -public protocol VirtioGPURenderer: AnyObject { +public protocol VirtioGPURenderer: AnyObject, Sendable { var capsets: [VirtioGPUCapset] { get } + /// True only when `makeScanoutPresentation` supplies a real producer-completion handoff. + var supportsSynchronizedScanoutPresentation: Bool { get } var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? { get set } func createContext(id: UInt32, flags: UInt32, name: String) throws func destroyContext(id: UInt32) throws @@ -225,6 +979,17 @@ public protocol VirtioGPURenderer: AnyObject { func unmapBlob(resourceID: UInt32) throws func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws + /// Returns a shared texture together with synchronization authority covering all producer work + /// submitted before this call. Implementations must fail closed when they cannot identify the + /// producer context or cannot create a completion primitive usable by the display context. + func makeScanoutPresentation( + resourceID: UInt32, + resourceGeneration: UInt64 + ) throws -> VirtioGPUTexturePresentation + /// Called only after every display release has been acknowledged and every device-tracked + /// renderer resource has been unmapped/unreferenced. `.ready` is a strong epoch barrier: no + /// fence callback or renderer state from the old guest epoch may become observable afterward. + func resetAfterDeviceQuiesce() throws -> VirtioGPURendererResetResult /// Registers a fence that must call `onFenceSignaled` (possibly from another thread) once all /// GPU work submitted before it has completed. Context fences order per (context, ring); plain /// fences ride the global ctx0 timeline and signal as (0, 0, id). @@ -232,46 +997,646 @@ public protocol VirtioGPURenderer: AnyObject { var onFenceSignaled: ((_ contextID: UInt32, _ ringIndex: UInt32, _ fenceID: UInt64) -> Void)? { get set } } -extension VirtioGPUMemoryEntry: @unchecked Sendable {} +public extension VirtioGPURenderer { + var supportsSynchronizedScanoutPresentation: Bool { false } -/// The guest-physical window into which host-visible Venus blobs are mapped. Unlike a normal RAM -/// region this is NOT pre-backed: virglrenderer owns each blob's host memory (a Metal-backed, -/// page-aligned allocation), so on `resource_map` we hv_vm_map that renderer-owned pointer into the -/// window at the guest-requested offset — the same zero-copy model libkrun/krunkit use on macOS. -/// Pre-mapping the whole window would make per-blob hv_vm_map fail (the GPA is already mapped). -public final class VirtioGPUHostVisibleMemory: @unchecked Sendable { - public let guestBase: UInt64 - public let length: UInt64 + func makeScanoutPresentation( + resourceID: UInt32, + resourceGeneration: UInt64 + ) throws -> VirtioGPUTexturePresentation { + throw VirtioGPURendererCommandRejected( + "renderer does not provide synchronized shared-texture presentation" + ) + } - private let lock = NSLock() - private var mappings: [UInt32: (offset: UInt64, size: UInt64)] = [:] + func resetAfterDeviceQuiesce() throws -> VirtioGPURendererResetResult { + .requiresRecreation("renderer does not implement recreate-safe in-place reset") + } +} - public init(guestBase: UInt64, length: UInt64 = 256 * 1024 * 1024) throws { - guard length > 0, - guestBase.isMultiple(of: HostPage.size), - length.isMultiple(of: HostPage.size), - length <= UInt64(Int.max) else { - throw VMError.invalidConfiguration("invalid virtio-gpu host-visible memory window") - } - self.guestBase = guestBase - self.length = length +extension VirtioGPUMemoryEntry: @unchecked Sendable {} + +/// A renderer adapter may use this error only when it can prove that a command was rejected before +/// any renderer-visible mutation. Every other thrown error is conservatively classified as an +/// unknown outcome by `VirtioGPURendererCommandExecutor`. +public struct VirtioGPURendererCommandRejected: Error, Equatable, Sendable { + public let detail: String + + public init(_ detail: String) { + self.detail = detail } +} - deinit { - lock.lock() - for (_, mapping) in mappings { - _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) +enum VirtioGPURendererCommand: @unchecked Sendable { + case createContext(id: UInt32, flags: UInt32, name: String) + case destroyContext(id: UInt32) + case attachResource(contextID: UInt32, resourceID: UInt32) + case detachResource(contextID: UInt32, resourceID: UInt32) + case submit3D(contextID: UInt32, command: [UInt8]) + case createResource3D(VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) + case createBlob( + resourceID: UInt32, + contextID: UInt32, + blobMemory: UInt32, + blobFlags: UInt32, + blobID: UInt64, + size: UInt64, + entries: [VirtioGPUMemoryEntry] + ) + case attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) + case detachBacking(resourceID: UInt32) + case unrefResource(resourceID: UInt32) + case mapBlob(resourceID: UInt32) + case unmapBlob(resourceID: UInt32) + case transferToHost3D(VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) + case transferFromHost3D(VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) + case makeScanoutPresentation(resourceID: UInt32, resourceGeneration: UInt64) + case createFence( + contextID: UInt32, + ringIndex: UInt32, + guestFenceID: UInt64, + contextFence: Bool + ) + case resetAfterDeviceQuiesce(successorGeneration: UInt64) + + var operation: String { + switch self { + case .createContext: "create-context" + case .destroyContext: "destroy-context" + case .attachResource: "attach-resource" + case .detachResource: "detach-resource" + case .submit3D: "submit-3d" + case .createResource3D: "create-resource-3d" + case .createBlob: "create-blob" + case .attachBacking: "attach-backing" + case .detachBacking: "detach-backing" + case .unrefResource: "unref-resource" + case .mapBlob: "map-blob" + case .unmapBlob: "unmap-blob" + case .transferToHost3D: "transfer-to-host-3d" + case .transferFromHost3D: "transfer-from-host-3d" + case .makeScanoutPresentation: "make-scanout-presentation" + case .createFence: "create-fence" + case .resetAfterDeviceQuiesce: "reset-after-device-quiesce" } - lock.unlock() } +} - /// hv_vm_map the renderer-owned `hostPointer` into the window at `offset`. `hostPointer` stays - /// owned by virglrenderer and must never be munmap'd here — it is released via resource_unmap. - public func map(resourceID: UInt32, hostPointer: UnsafeMutableRawPointer, offset: UInt64, size: UInt64) throws { - let mapSize = size.roundedUpToMultiple(of: HostPage.size) - guard offset.isMultiple(of: HostPage.size), - mapSize > 0, offset <= length, mapSize <= length - offset else { - throw VMError.guestMemoryFault(address: guestBase + offset, count: size) +enum VirtioGPURendererCommandValue: @unchecked Sendable { + case none + case blobMapping(VirtioGPUBlobMapping) + case scanoutPresentation(VirtioGPUTexturePresentation) + case reset(VirtioGPURendererResetResult) +} + +enum VirtioGPURendererCommandRejection: Equatable, Sendable { + case invalidInput(operation: String, detail: String) + case staleGeneration(expected: UInt64, actual: UInt64) + case admissionClosed(generation: UInt64) + case renderer(operation: String, detail: String) +} + +struct VirtioGPURendererCommandUncertainty: Equatable, Sendable { + var operation: String + var generation: UInt64 + var detail: String + var runtimeFailure: VirtioGPURendererRuntimeFailure? +} + +enum VirtioGPURendererCommandOutcome: @unchecked Sendable { + case success(VirtioGPURendererCommandValue) + case rejected(VirtioGPURendererCommandRejection) + case outcomeUnknown(VirtioGPURendererCommandUncertainty) +} + +enum VirtioGPURendererCommandPurpose: Sendable { + case guest + case retirement +} + +enum VirtioGPURendererQuiescenceAdmission: Equatable, Sendable { + case admitted(sourceGeneration: UInt64) + case rejected(VirtioGPURendererCommandRejection) +} + +/// The legacy in-process renderer serialization seam. +/// +/// All device-to-renderer calls, including callback installation, pass through this executor. It +/// serializes one renderer generation, owns bounded copies of variable command metadata, assigns +/// non-reused host fence identities, and never guesses that a thrown adapter call was harmless. +/// It is retained only until the signed worker has equivalent operation coverage. Production +/// cutover must replace pointer-bearing commands with the typed renderer-worker envelopes, +/// descriptor-backed regions, SHM scanout leases, and producer-fence/release receipts; helper death +/// is an outcome-unknown generation. This executor and VirglRenderer must be deleted in the same +/// cutover that selects the qualified worker—never left as an accelerated fallback. +final class VirtioGPURendererCommandExecutor: @unchecked Sendable { + private enum State { + case active(UInt64) + case revoked(UInt64) + case uncertain(UInt64) + case quiescing(source: UInt64, successor: UInt64) + } + + private enum FenceRegistrationState { + case registering(signalObserved: Bool) + case registered + } + + private struct FenceCallbackKey: Hashable { + var contextID: UInt32 + var ringIndex: UInt32 + var hostFenceID: UInt64 + } + + private struct FenceRegistration { + var generation: UInt64 + var guestContextID: UInt32 + var guestRingIndex: UInt32 + var guestFenceID: UInt64 + var state: FenceRegistrationState + } + + let capsets: [VirtioGPUCapset] + let supportsSynchronizedScanoutPresentation: Bool + + private let renderer: VirtioGPURenderer + private let maximumCommandBytes: Int + private let maximumMemoryEntries: Int + private let maximumReferencedBytes: UInt64 + private let lock = NSRecursiveLock() + private var state: State + private var nextHostFenceID: UInt64 = 1 + private var fences = [FenceCallbackKey: FenceRegistration]() + private var fenceSink: ((UInt64, UInt32, UInt32, UInt64) -> Void)? + private var runtimeFailureSink: ((UInt64, VirtioGPURendererRuntimeFailure) -> Void)? + + init( + renderer: VirtioGPURenderer, + initialGeneration: UInt64 = 1, + maximumCommandBytes: Int, + maximumMemoryEntries: Int, + maximumReferencedBytes: UInt64 + ) { + self.renderer = renderer + self.capsets = renderer.capsets.map { + VirtioGPUCapset(id: $0.id, maxVersion: $0.maxVersion, data: Array($0.data)) + } + self.supportsSynchronizedScanoutPresentation = + renderer.supportsSynchronizedScanoutPresentation + self.maximumCommandBytes = max(1, maximumCommandBytes) + self.maximumMemoryEntries = max(1, maximumMemoryEntries) + self.maximumReferencedBytes = max(1, maximumReferencedBytes) + self.state = .active(initialGeneration == 0 ? 1 : initialGeneration) + renderer.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in + self?.receiveFence(contextID: contextID, ringIndex: ringIndex, hostFenceID: fenceID) + } + renderer.onRuntimeFailure = { [weak self] failure in + self?.receiveRuntimeFailure(failure) + } + } + + func installCallbacks( + fence: @escaping @Sendable (UInt64, UInt32, UInt32, UInt64) -> Void, + runtimeFailure: @escaping @Sendable (UInt64, VirtioGPURendererRuntimeFailure) -> Void + ) { + lock.withLock { + fenceSink = fence + runtimeFailureSink = runtimeFailure + } + } + + func revokeActiveGeneration() { + lock.withLock { + switch state { + case .active(let generation), .uncertain(let generation): + state = .revoked(generation) + fences.removeAll() + case .revoked, .quiescing: + break + } + } + } + + func beginQuiescence(successorGeneration: UInt64) -> VirtioGPURendererQuiescenceAdmission { + lock.withLock { + let source: UInt64 + switch state { + case .active(let generation), .revoked(let generation), .uncertain(let generation): + source = generation + case .quiescing(let generation, let existingSuccessor): + guard existingSuccessor == successorGeneration else { + return .rejected(.admissionClosed(generation: generation)) + } + return .admitted(sourceGeneration: generation) + } + guard successorGeneration != 0, successorGeneration != source else { + return .rejected(.invalidInput( + operation: "begin-quiescence", + detail: "successor generation must be nonzero and distinct" + )) + } + state = .quiescing(source: source, successor: successorGeneration) + fences.removeAll() + return .admitted(sourceGeneration: source) + } + } + + func execute( + _ requestedCommand: VirtioGPURendererCommand, + generation: UInt64, + purpose: VirtioGPURendererCommandPurpose = .guest + ) -> VirtioGPURendererCommandOutcome { + lock.lock() + defer { lock.unlock() } + + guard let command = boundedCopy(of: requestedCommand) else { + return .rejected(.invalidInput( + operation: requestedCommand.operation, + detail: "renderer command exceeds its configured input bound" + )) + } + if case .resetAfterDeviceQuiesce(let successor) = command { + return executeReset( + generation: generation, + successorGeneration: successor, + operation: command.operation + ) + } + guard admits(generation: generation, purpose: purpose) else { + return generationRejection(for: generation) + } + if case let .createFence(contextID, ringIndex, guestFenceID, contextFence) = command { + return executeFence( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + guestFenceID: guestFenceID, + contextFence: contextFence + ) + } + + do { + let value = try invoke(command) + return .success(value) + } catch let rejection as VirtioGPURendererCommandRejected { + return .rejected(.renderer( + operation: command.operation, + detail: rejection.detail + )) + } catch { + state = .uncertain(generation) + fences.removeAll() + return .outcomeUnknown(uncertainty( + operation: command.operation, + generation: generation, + error: error + )) + } + } + + private func admits( + generation: UInt64, + purpose: VirtioGPURendererCommandPurpose + ) -> Bool { + switch (state, purpose) { + case (.active(let active), .guest), + (.active(let active), .retirement), + (.revoked(let active), .retirement), + (.quiescing(let active, _), .retirement): + return active == generation + case (.revoked, .guest), (.uncertain, _), (.quiescing, .guest): + return false + } + } + + private func generationRejection(for generation: UInt64) -> VirtioGPURendererCommandOutcome { + let actual: UInt64 + switch state { + case .active(let value): + actual = value + case .revoked(let value), .uncertain(let value): + actual = value + case .quiescing(let value, _): + actual = value + } + return actual == generation + ? .rejected(.admissionClosed(generation: generation)) + : .rejected(.staleGeneration(expected: actual, actual: generation)) + } + + private func executeReset( + generation: UInt64, + successorGeneration: UInt64, + operation: String + ) -> VirtioGPURendererCommandOutcome { + guard case .quiescing(let source, let successor) = state, + source == generation, + successor == successorGeneration else { + return generationRejection(for: generation) + } + do { + let result = try renderer.resetAfterDeviceQuiesce() + switch result { + case .ready: + state = .active(successorGeneration) + case .requiresRecreation: + state = .revoked(generation) + } + fences.removeAll() + return .success(.reset(result)) + } catch let rejection as VirtioGPURendererCommandRejected { + state = .revoked(generation) + return .rejected(.renderer(operation: operation, detail: rejection.detail)) + } catch { + state = .uncertain(generation) + fences.removeAll() + return .outcomeUnknown(uncertainty( + operation: operation, + generation: generation, + error: error + )) + } + } + + private func executeFence( + generation: UInt64, + contextID: UInt32, + ringIndex: UInt32, + guestFenceID: UInt64, + contextFence: Bool + ) -> VirtioGPURendererCommandOutcome { + guard nextHostFenceID <= UInt64(UInt32.max) else { + return .rejected(.invalidInput( + operation: "create-fence", + detail: "host fence identity space exhausted" + )) + } + let hostFenceID = nextHostFenceID + nextHostFenceID += 1 + let callbackKey = FenceCallbackKey( + contextID: contextFence ? contextID : 0, + ringIndex: contextFence ? ringIndex : 0, + hostFenceID: hostFenceID + ) + fences[callbackKey] = FenceRegistration( + generation: generation, + guestContextID: contextFence ? contextID : 0, + guestRingIndex: contextFence ? ringIndex : 0, + guestFenceID: guestFenceID, + state: .registering(signalObserved: false) + ) + + do { + try renderer.createFence( + contextID: contextID, + ringIndex: ringIndex, + fenceID: hostFenceID, + contextFence: contextFence + ) + } catch let rejection as VirtioGPURendererCommandRejected { + fences.removeValue(forKey: callbackKey) + return .rejected(.renderer(operation: "create-fence", detail: rejection.detail)) + } catch { + fences.removeValue(forKey: callbackKey) + state = .uncertain(generation) + return .outcomeUnknown(uncertainty( + operation: "create-fence", + generation: generation, + error: error + )) + } + + if var registration = fences[callbackKey] { + switch registration.state { + case .registering(let signalObserved) where signalObserved: + fences.removeValue(forKey: callbackKey) + let sink = fenceSink + // NSRecursiveLock permits a synchronous renderer callback to record completion. + // Deliver only after registration itself has returned success. + sink?( + registration.generation, + registration.guestContextID, + registration.guestRingIndex, + registration.guestFenceID + ) + case .registering: + registration.state = .registered + fences[callbackKey] = registration + case .registered: + break + } + } + return .success(.none) + } + + private func receiveFence(contextID: UInt32, ringIndex: UInt32, hostFenceID: UInt64) { + let sinkAndRegistration: ( + ((UInt64, UInt32, UInt32, UInt64) -> Void), + FenceRegistration + )? = lock.withLock { + let key = FenceCallbackKey( + contextID: contextID, + ringIndex: ringIndex, + hostFenceID: hostFenceID + ) + guard var registration = fences[key] else { return nil } + switch registration.state { + case .registering: + registration.state = .registering(signalObserved: true) + fences[key] = registration + return nil + case .registered: + guard case .active(let activeGeneration) = state, + activeGeneration == registration.generation, + let fenceSink else { + fences.removeValue(forKey: key) + return nil + } + fences.removeValue(forKey: key) + return (fenceSink, registration) + } + } + guard let (sink, registration) = sinkAndRegistration else { return } + sink( + registration.generation, + registration.guestContextID, + registration.guestRingIndex, + registration.guestFenceID + ) + } + + private func receiveRuntimeFailure(_ failure: VirtioGPURendererRuntimeFailure) { + let sinkAndGeneration: (((UInt64, VirtioGPURendererRuntimeFailure) -> Void), UInt64)? = + lock.withLock { + guard case .active(let generation) = state, let runtimeFailureSink else { + return nil + } + return (runtimeFailureSink, generation) + } + guard let (sink, generation) = sinkAndGeneration else { return } + sink(generation, failure) + } + + private func invoke(_ command: VirtioGPURendererCommand) throws -> VirtioGPURendererCommandValue { + switch command { + case let .createContext(id, flags, name): + try renderer.createContext(id: id, flags: flags, name: name) + case .destroyContext(let id): + try renderer.destroyContext(id: id) + case let .attachResource(contextID, resourceID): + try renderer.attachResource(contextID: contextID, resourceID: resourceID) + case let .detachResource(contextID, resourceID): + try renderer.detachResource(contextID: contextID, resourceID: resourceID) + case let .submit3D(contextID, command): + try renderer.submit3D(contextID: contextID, command: command) + case let .createResource3D(resource, entries): + try renderer.createResource3D(resource, entries: entries) + case let .createBlob(resourceID, contextID, memory, flags, blobID, size, entries): + try renderer.createBlob( + resourceID: resourceID, + contextID: contextID, + blobMemory: memory, + blobFlags: flags, + blobID: blobID, + size: size, + entries: entries + ) + case let .attachBacking(resourceID, entries): + try renderer.attachBacking(resourceID: resourceID, entries: entries) + case .detachBacking(let resourceID): + try renderer.detachBacking(resourceID: resourceID) + case .unrefResource(let resourceID): + try renderer.unrefResource(resourceID: resourceID) + case .mapBlob(let resourceID): + return .blobMapping(try renderer.mapBlob(resourceID: resourceID)) + case .unmapBlob(let resourceID): + try renderer.unmapBlob(resourceID: resourceID) + case let .transferToHost3D(transfer, entries): + try renderer.transferToHost3D(transfer, entries: entries) + case let .transferFromHost3D(transfer, entries): + try renderer.transferFromHost3D(transfer, entries: entries) + case let .makeScanoutPresentation(resourceID, resourceGeneration): + return .scanoutPresentation(try renderer.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: resourceGeneration + )) + case .createFence, .resetAfterDeviceQuiesce: + preconditionFailure("special renderer commands must use their lifecycle executor") + } + return .none + } + + private func boundedCopy( + of command: VirtioGPURendererCommand + ) -> VirtioGPURendererCommand? { + switch command { + case let .createContext(id, flags, name): + guard name.utf8.count <= 64 else { return nil } + return .createContext(id: id, flags: flags, name: String(name)) + case let .submit3D(contextID, bytes): + guard bytes.count <= maximumCommandBytes else { return nil } + return .submit3D(contextID: contextID, command: Array(bytes)) + case let .createResource3D(resource, entries): + guard let entries = boundedEntries(entries) else { return nil } + return .createResource3D(resource, entries: entries) + case let .createBlob(resourceID, contextID, memory, flags, blobID, size, entries): + guard size <= maximumReferencedBytes, + let entries = boundedEntries(entries) else { return nil } + return .createBlob( + resourceID: resourceID, + contextID: contextID, + blobMemory: memory, + blobFlags: flags, + blobID: blobID, + size: size, + entries: entries + ) + case let .attachBacking(resourceID, entries): + guard let entries = boundedEntries(entries) else { return nil } + return .attachBacking(resourceID: resourceID, entries: entries) + case let .transferToHost3D(transfer, entries): + guard transfer.box.count == 6, + let entries = boundedEntries(entries) else { return nil } + var copied = transfer + copied.box = Array(transfer.box) + return .transferToHost3D(copied, entries: entries) + case let .transferFromHost3D(transfer, entries): + guard transfer.box.count == 6, + let entries = boundedEntries(entries) else { return nil } + var copied = transfer + copied.box = Array(transfer.box) + return .transferFromHost3D(copied, entries: entries) + default: + return command + } + } + + private func boundedEntries( + _ entries: [VirtioGPUMemoryEntry] + ) -> [VirtioGPUMemoryEntry]? { + guard entries.count <= maximumMemoryEntries else { return nil } + var total: UInt64 = 0 + for entry in entries { + guard entry.length > 0 else { return nil } + let (next, overflow) = total.addingReportingOverflow(UInt64(entry.length)) + guard !overflow, next <= maximumReferencedBytes else { return nil } + total = next + } + return Array(entries) + } + + private func uncertainty( + operation: String, + generation: UInt64, + error: Error + ) -> VirtioGPURendererCommandUncertainty { + VirtioGPURendererCommandUncertainty( + operation: operation, + generation: generation, + detail: String(describing: error), + runtimeFailure: error as? VirtioGPURendererRuntimeFailure + ) + } +} + +/// The guest-physical window into which host-visible Venus blobs are mapped. Unlike a normal RAM +/// region this is NOT pre-backed: virglrenderer owns each blob's host memory (a Metal-backed, +/// page-aligned allocation), so on `resource_map` we hv_vm_map that renderer-owned pointer into the +/// window at the guest-requested offset — the same zero-copy model libkrun/krunkit use on macOS. +/// Pre-mapping the whole window would make per-blob hv_vm_map fail (the GPA is already mapped). +public final class VirtioGPUHostVisibleMemory: @unchecked Sendable { + public let guestBase: UInt64 + public let length: UInt64 + + private let lock = NSLock() + private var mappings: [UInt32: (offset: UInt64, size: UInt64)] = [:] + + public init(guestBase: UInt64, length: UInt64 = 256 * 1024 * 1024) throws { + guard length > 0, + guestBase.isMultiple(of: HostPage.size), + length.isMultiple(of: HostPage.size), + length <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("invalid virtio-gpu host-visible memory window") + } + self.guestBase = guestBase + self.length = length + } + + deinit { + lock.lock() + for (_, mapping) in mappings { + _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) + } + lock.unlock() + } + + /// hv_vm_map the renderer-owned `hostPointer` into the window at `offset`. `hostPointer` stays + /// owned by virglrenderer and must never be munmap'd here — it is released via resource_unmap. + public func map(resourceID: UInt32, hostPointer: UnsafeMutableRawPointer, offset: UInt64, size: UInt64) throws { + let mapSize = size.roundedUpToMultiple(of: HostPage.size) + guard offset.isMultiple(of: HostPage.size), + mapSize > 0, offset <= length, mapSize <= length - offset else { + throw VMError.guestMemoryFault(address: guestBase + offset, count: size) } lock.lock() defer { lock.unlock() } @@ -302,15 +1667,20 @@ private extension UInt64 { } } -/// Experimental virtio-gpu device. +/// Virtio-gpu device with an explicitly gated renderer authority. /// -/// Bootstrap mode keeps the Linux driver bring-up surface deliberately inert. Venus mode advertises -/// the Linux UAPI feature bits only when a host renderer is supplied, then forwards blob/context -/// commands to that renderer. +/// A renderer authority is either the legacy in-process compatibility object or one already +/// authenticated signed-worker lane; two authorities fail closed. Worker capsets and device +/// features come only from its complete capability receipt, while all command and presentation +/// completion remains generation-bound to that lane. public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider, @unchecked Sendable { public let deviceID: UInt32 = 16 public let queueCount = 2 - public let deviceFeatures: UInt64 + public var deviceFeatures: UInt64 { + guard rendererAuthorityIsConfigured, + rendererCapabilitiesAreAdvertised else { return 0 } + return configuredRendererDeviceFeatures + } public let sharedMemoryRegions: [VirtioSharedMemoryRegion] private let scanoutCount: UInt32 @@ -318,9 +1688,15 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private var scanoutSizes: [VirtioGPUScanoutSize] private var pendingDisplayEvents: UInt32 = 0 private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? - private let onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? + private let onScanoutTexture: (@Sendable (VirtioGPUScanoutTextureUpdate) -> Void)? + private let onMetalScanout: (@Sendable (VirtioGPUMetalScanoutUpdate) -> Void)? + private let onScanoutResourceReleased: (@Sendable (VirtioGPUScanoutResourceRelease) -> Void)? + private let onScanoutDisabled: (@Sendable (UInt32) -> Void)? private let onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? - private let renderer: VirtioGPURenderer? + private let onRendererWorkerFailure: (@Sendable (String) -> Void)? + private let rendererExecutor: VirtioGPURendererCommandExecutor? + private let rendererWorkerCandidate: DoryRendererWorkerVirtioCommandLane? + private let configuredRendererDeviceFeatures: UInt64 private let capsets: [VirtioGPUCapset] private let hostVisibleMemory: VirtioGPUHostVisibleMemory? private var resourceEntries: [UInt32: [VirtioGPUMemoryEntry]] = [:] @@ -364,12 +1740,6 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private var resources2D: [UInt32: Resource2D] = [:] private var resources3D: [UInt32: Resource3D] = [:] - private var published3DResources: Set = [] - /// Keep one full-resource readback allocation per renderer resource. A partial renderer - /// transfer writes its first pixel at `offset` and then advances by `stride`; the source box's - /// x/y coordinates are not implicitly added to the destination address. Supplying the matching - /// full-resource offset lets later scanout extraction use ordinary resource coordinates. - private var rendererReadbackBuffers: [UInt32: Data] = [:] private var scanouts: [UInt32: ScanoutBinding] = [:] private var cursorResourceID: UInt32? private var commandFailureCounts: [UInt32: Int] = [:] @@ -379,6 +1749,33 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi /// kicks may arrive concurrently, so serialize command interpretation while leaving descriptor /// dequeue/completion and renderer fence delivery on their existing independent locks. private let commandLock = NSLock() + private struct RendererWorkerSnapshotMetrics { + var count: UInt64 = 0 + var bytes: UInt64 = 0 + var nanoseconds: UInt64 = 0 + var maximumNanoseconds: UInt64 = 0 + } + private let rendererWorkerMetricsLock = NSLock() + private var rendererWorkerSnapshotMetrics = RendererWorkerSnapshotMetrics() + private let rendererWorkerScanoutDiagnosticLock = NSLock() + private var rendererWorkerScanoutDiagnosticStages = Set() + private let softwareScanoutMetricsLock = NSLock() + private var softwareScanoutCopiedBytes: UInt64 = 0 + private let rendererWorkerPresentationLock = NSLock() + private var rendererWorkerPendingScanouts = [ + DoryRendererScanoutReleaseToken: DoryRendererWorkerSharedScanoutCore + ]() + private var rendererWorkerLiveScanouts = [ + DoryRendererScanoutReleaseToken: DoryRendererWorkerWeakScanoutCore + ]() + private let rendererWorkerPresentationQueue = DispatchQueue( + label: "dev.dory.gpu.renderer-worker-presentation", + qos: .userInteractive + ) + private let rendererWorkerResumeQueue = DispatchQueue( + label: "dev.dory.gpu.renderer-worker-queue-resume", + qos: .userInteractive + ) // Real fence signalling: a fenced command's descriptor is held here and completed only when the // renderer signals the fence (from its own thread), per the virtio-gpu contract — responding @@ -389,22 +1786,326 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } private struct PendingFence { + var token: UInt64 var fenceID: UInt64 + var epoch: UInt64 var response: [UInt8] var chain: VirtqueueChain var createdAtMonotonicNanoseconds: UInt64 var timeoutReported: Bool } + private struct FenceRequest { + var key: FenceKey + var contextID: UInt32 + var ringIndex: UInt32 + var fenceID: UInt64 + var contextFence: Bool + } + + private enum FenceAdmission { + case notRequested + case admitted(FenceRequest) + case rejected + } + + private enum FenceDeferralOutcome { + case immediate + case deferred + /// The renderer accepted the command but could not establish its completion boundary. + /// Keep the descriptor owned until reset rather than publishing a false completion. + case outcomeUnknown + } + + private struct RendererCommandOutcomeUnknownSignal: Error { + var uncertainty: VirtioGPURendererCommandUncertainty + } + + private enum CommandProcessingOutcome { + case response([UInt8]) + /// The renderer may have committed the command. The descriptor remains device-owned until + /// queue revocation/reset rather than receiving a fabricated success or rejection. + case outcomeUnknown(VirtioGPURendererCommandUncertainty) + } + + private enum QueueAdmissionRejection { + case invalidDescriptorLayout + case oversizedRequest + case insufficientResponseCapacity + } + + private enum QueueAdmissionOutcome { + case admitted(request: [UInt8], writesResponse: Bool) + case workerControl(WorkerControlAdmission) + case workerCreateResource3D(WorkerCreateResource3DAdmission) + case workerCreateBlob(WorkerCreateBlobAdmission) + case workerAttachBacking(WorkerAttachBackingAdmission) + case workerDetachBacking(WorkerDetachBackingAdmission) + case workerTransfer(WorkerTransferAdmission) + case workerUnref(WorkerUnrefAdmission) + case workerMapBlob(WorkerMapBlobAdmission) + case workerUnmapBlob(WorkerUnmapBlobAdmission) + case workerFlushScanout(WorkerFlushScanoutAdmission) + case workerSubmit(WorkerSubmitAdmission) + case workerRejected(requestHeader: [UInt8]) + case rejected(QueueAdmissionRejection) + case revoked + } + + private struct WorkerSubmitAdmission: @unchecked Sendable { + let requestHeader: [UInt8] + let regions: DoryRendererWorkerSharedRegionSet + let fence: FenceRequest? + } + + private struct WorkerAttachBackingAdmission: @unchecked Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let entries: [VirtioGPUMemoryEntry] + let regions: DoryRendererWorkerSharedRegionSet + let fence: FenceRequest? + } + + private struct WorkerDetachBackingAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let fence: FenceRequest? + } + + private enum WorkerTransferDirection: Sendable { + case toHost + case fromHost + } + + private struct WorkerTransferAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let contextID: UInt32 + let payload: DoryRendererTransfer3DPayload + let direction: WorkerTransferDirection + let fence: FenceRequest? + } + + private struct WorkerUnrefAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let workerResourceGeneration: UInt64 + let displayResourceGeneration: UInt64 + let fence: FenceRequest? + } + + private struct WorkerCreateBlobAdmission: @unchecked Sendable { + let request: [UInt8] + let resourceID: UInt32 + let contextID: UInt32 + let payload: DoryRendererBlobCreatePayload + let entries: [VirtioGPUMemoryEntry] + let regions: DoryRendererWorkerSharedRegionSet + } + + private enum WorkerCreatedResourceKind: Equatable, Sendable { + case resource2D + case resource3D + } + + private struct WorkerCreateResource3DAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let payload: DoryRendererResource3DCreatePayload + let kind: WorkerCreatedResourceKind + let fence: FenceRequest? + } + + private struct WorkerMapBlobAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let hostVisibleOffset: UInt64 + } + + private struct WorkerUnmapBlobAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + } + + private struct WorkerFlushTarget: Sendable { + let scanoutID: UInt32 + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + } + + private struct WorkerScanoutSurface: Equatable, Sendable { + let width: UInt32 + let height: UInt32 + let format: UInt32 + let stride: UInt32 + let offset: UInt32 + } + + private struct WorkerFlushScanoutAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let workerResourceGeneration: UInt64 + let displayResourceGeneration: UInt64 + let surface: WorkerScanoutSurface? + let targets: [WorkerFlushTarget] + let fence: FenceRequest? + } + + private enum WorkerControlOperation: Sendable { + case createContext(name: String, capsetID: UInt32) + case destroyContext + case attachResource(resourceID: UInt32) + case detachResource(resourceID: UInt32) + } + + private struct WorkerControlAdmission: Sendable { + let request: [UInt8] + let contextID: UInt32 + let operation: WorkerControlOperation + } + + private enum QueueCompletionOutcome { + case published(wantsInterrupt: Bool) + case revoked + case failed + } + + private enum QueueDrainOutcome { + case drained(wantsInterrupt: Bool) + case pendingReadFailed + case popFailed(wantsInterrupt: Bool) + case completionFailed(wantsInterrupt: Bool) + } + + private enum TelemetryEvent { + case queuePendingReadFailure + case queuePopFailure + case invalidDescriptorChain + case oversizedRequest + case insufficientResponseCapacity + case queuePushFailure + case revokedCompletion + case undeliveredFenceCompletion + case responseWriteFailure + case fenceAdmissionRejection + case queueRevokedFence + case resetRevokedFence + case rendererCommandUncertainty + case revokedUncertainRendererCommand + } + private let fenceLock = NSLock() private var pendingFences: [FenceKey: [PendingFence]] = [:] + /// Fence creation failed after the renderer command crossed its mutation boundary. These + /// chains remain owned until queue revocation/reset, but are kept out of callback lookup so a + /// late signal from an older fence can never fabricate their completion. + private var uncertainFences = [PendingFence]() + private var uncertainRendererCommandChains = [VirtqueueChain]() private weak var lastTransport: VirtioMMIOTransport? + /// Never reset to zero. Fence callbacks may arrive after MMIO reset; the epoch is rechecked + /// while holding the transport queue lock so a callback already in flight cannot publish into + /// the replacement queue. + private var lifecycleEpoch: UInt64 = 1 + private var nextFenceToken: UInt64 = 1 + private var pendingFenceCount = 0 + private var pendingFenceResponseBytes = 0 + /// QueueReady can revoke a descriptor while its renderer callback remains in flight. Because + /// virglrenderer callbacks do not carry a queue generation, do not admit another renderer + /// fence after such a revocation until the renderer's full reset barrier has completed. + private var fenceAdmissionBlockedUntilDeviceReset = false private let fenceTimeoutNanoseconds: UInt64 private var fenceCount: UInt64 = 0 private var fenceRegistrationFailureCount: UInt64 = 0 private var fenceTimeoutCount: UInt64 = 0 private var rendererDeviceLossCount: UInt64 = 0 private var rendererDeviceLossLatched = false + private var queuePendingReadFailureCount: UInt64 = 0 + private var queuePopFailureCount: UInt64 = 0 + private var invalidDescriptorChainCount: UInt64 = 0 + private var oversizedRequestCount: UInt64 = 0 + private var insufficientResponseCapacityCount: UInt64 = 0 + private var queuePushFailureCount: UInt64 = 0 + private var revokedCompletionCount: UInt64 = 0 + private var undeliveredFenceCompletionCount: UInt64 = 0 + private var responseWriteFailureCount: UInt64 = 0 + private var fenceAdmissionRejectionCount: UInt64 = 0 + private var queueRevokedFenceCount: UInt64 = 0 + private var resetRevokedFenceCount: UInt64 = 0 + private var rendererCommandUncertaintyCount: UInt64 = 0 + private var revokedUncertainRendererCommandCount: UInt64 = 0 + private var resourceGenerations: [UInt32: UInt64] = [:] + /// Exact worker generations returned by authenticated create replies. These are never derived + /// from the independent display/resource lifetime generation above. + private var rendererWorkerResourceGenerations: [UInt32: UInt64] = [:] + /// Context/resource attachment ownership is committed only by authenticated worker replies. + /// RESOURCE_FLUSH derives its context-timeline fence from this single authority and fails closed + /// when a resource is attached to zero or multiple contexts. + private var rendererWorkerResourceContextIDs: [UInt32: Set] = [:] + /// Reserves IDs while create mutations are asynchronous so another vCPU kick cannot race a + /// local 2D/blob allocation into the same guest resource identity. + private var rendererWorkerPendingResourceIDs = Set() + private var rendererWorkerPendingBackingResourceIDs = Set() + private var rendererWorkerPendingMappingResourceIDs = Set() + /// Unique ownership of the one worker command that currently holds controlq ordering. Device + /// generation alone is not an identity: an older pipelined submit can complete while a newer + /// state mutation is pending in the same generation. Only the exact claim may release the + /// barrier after its used entry has been published. + private struct RendererWorkerControlCommandClaim: Equatable, Sendable { + let generation: UInt64 + let token: UInt64 + } + private var rendererWorkerControlCommandClaim: RendererWorkerControlCommandClaim? + private var nextRendererWorkerControlCommandToken: UInt64 = 1 + private var nextResourceGeneration: UInt64 = 1 + private let maximumTrackedResources: Int + private let maximumControlRequestBytes: Int + /// Raw guest scatter/gather descriptors admitted from the virtqueue. Losslessly adjacent + /// entries are normalized, but ordinary Linux GEM/shmem pages can remain physically + /// discontiguous. The resulting list is bounded by the authenticated worker contract and by + /// independent request-byte and referenced-byte ceilings. + private let maximumRawMemoryEntries: Int + private let maximumMemoryEntries: Int + private let maximumRendererReferencedBytes: UInt64 + private let maximumPendingFences: Int + private let maximumPendingFenceResponseBytes: Int + private let maximumCopiedScanoutSurfaceBytes: UInt64 + private let quiescenceTimeout: TimeInterval + + private struct ResourceRetirementKey: Hashable, Sendable { + var resourceID: UInt32 + var generation: UInt64 + } + + private struct QuiescingResource: Sendable { + var key: ResourceRetirementKey + var requiresBlobUnmap: Bool + } + + private struct ActiveQuiescence { + var receipt: VirtioGPUQuiescence + var rendererGeneration: UInt64 + var workerReboundForPristineDeviceReset: Bool + var rendererResources: [QuiescingResource] + var awaitingReleaseAcknowledgements: Set + var priorRetirements: Set + var cleanupScheduled: Bool + } + + /// Resource IDs remain reserved after guest-visible unref/reset until every display has + /// detached its generation and host destruction has completed. The same bound applies with no + /// renderer, preventing an untrusted guest from growing mailbox release storage without limit. + private let lifecycleLock = NSLock() + private let rendererRetirementQueue = DispatchQueue(label: "dev.dory.gpu.resource-retirement") + private var retiringResources: [UInt32: UInt64] = [:] + private var activeQuiescence: ActiveQuiescence? + private var rendererLifecycleHealthState: VirtioGPURendererLifecycleHealth + private var acceptingGuestCommands = true + private var createdContextIDs = Set() private enum HeaderFlag { static let fence: UInt32 = 1 << 0 @@ -447,7 +2148,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi static let okResourceUUID: UInt32 = 0x1105 static let okMapInfo: UInt32 = 0x1106 static let errorUnspecified: UInt32 = 0x1200 - static let errorInvalidParameter: UInt32 = 0x1202 + static let errorInvalidParameter: UInt32 = 0x1205 } private enum Feature { @@ -465,6 +2166,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi var memory: UInt32 var size: UInt64 var mapping: VirtioGPUBlobMapping? + var workerMapping: DoryRendererWorkerBlobMappingAuthority? var guestMapped = false } @@ -479,12 +2181,25 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi scanoutHeight: UInt32 = 800, scanoutSizes: [VirtioGPUScanoutSize]? = nil, renderer: VirtioGPURenderer? = nil, + rendererWorkerCandidate: DoryRendererWorkerVirtioCommandLane? = nil, hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, - traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1", + traceResourceLifecycle: Bool = false, fenceTimeoutNanoseconds: UInt64 = 10_000_000_000, + maximumTrackedResources: Int = 4_096, + maximumControlRequestBytes: Int = 16 * 1_024 * 1_024, + maximumMemoryEntries: Int = DoryRendererWorkerLimits.production.maximumSharedRegions, + maximumRendererReferencedBytes: UInt64 = 8 * 1_024 * 1_024 * 1_024, + maximumPendingFences: Int = 4_096, + maximumPendingFenceResponseBytes: Int = 8 * 1_024 * 1_024, + maximumCopiedScanoutSurfaceBytes: UInt64 = 128 * 1_024 * 1_024, + quiescenceTimeout: TimeInterval = 5, onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil, - onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil, - onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil + onScanoutTexture: (@Sendable (VirtioGPUScanoutTextureUpdate) -> Void)? = nil, + onMetalScanout: (@Sendable (VirtioGPUMetalScanoutUpdate) -> Void)? = nil, + onScanoutResourceReleased: (@Sendable (VirtioGPUScanoutResourceRelease) -> Void)? = nil, + onScanoutDisabled: (@Sendable (UInt32) -> Void)? = nil, + onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil, + onRendererWorkerFailure: (@Sendable (String) -> Void)? = nil ) { let boundedScanoutSizes: [VirtioGPUScanoutSize] if let scanoutSizes { @@ -499,13 +2214,65 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi ) } let boundedScanoutCount = UInt32(boundedScanoutSizes.count) - self.renderer = renderer - self.capsets = renderer?.capsets ?? [] + let boundedControlRequestBytes = min( + 64 * 1_024 * 1_024, + max(96, maximumControlRequestBytes) + ) + // A configuration carrying two independent renderer authorities is never partially + // selected. It stays inert instead of guessing which process owns resource/fence state. + let hasRendererAuthorityConflict = renderer != nil && rendererWorkerCandidate != nil + let selectedWorkerCandidate = hasRendererAuthorityConflict + ? nil + : rendererWorkerCandidate + let rendererRegionLimit = selectedWorkerCandidate?.maximumSharedRegions + ?? DoryRendererWorkerLimits.production.maximumSharedRegions + let boundedMemoryEntries = max(1, min(maximumMemoryEntries, rendererRegionLimit)) + let boundedRawMemoryEntries = max( + 1, + min( + DoryRendererWorkerLimits.absoluteMaximumSharedRegions, + (boundedControlRequestBytes - 32) / 16 + ) + ) + let rendererReferencedByteLimit = selectedWorkerCandidate?.maximumReferencedBytes + ?? DoryRendererWorkerLimits.absoluteMaximumReferencedBytes + let boundedRendererReferencedBytes = max( + 1, + min(maximumRendererReferencedBytes, rendererReferencedByteLimit) + ) + let rendererExecutor = hasRendererAuthorityConflict ? nil : renderer.map { + VirtioGPURendererCommandExecutor( + renderer: $0, + maximumCommandBytes: boundedControlRequestBytes, + maximumMemoryEntries: boundedMemoryEntries, + maximumReferencedBytes: boundedRendererReferencedBytes + ) + } self.traceResourceLifecycle = traceResourceLifecycle self.fenceTimeoutNanoseconds = fenceTimeoutNanoseconds - self.deviceFeatures = renderer == nil - ? 0 - : Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit + self.maximumTrackedResources = max(1, maximumTrackedResources) + // The split-ring parser has a separate 64 MiB absolute chain ceiling. GPU commands are + // intentionally tighter so one untrusted submit cannot force an allocation at that limit. + // Ninety-six bytes keeps every fixed-size core command representable; variable command and + // backing payloads remain available up to the explicit host policy bound. + self.maximumControlRequestBytes = boundedControlRequestBytes + self.maximumRawMemoryEntries = boundedRawMemoryEntries + self.maximumMemoryEntries = boundedMemoryEntries + self.maximumRendererReferencedBytes = boundedRendererReferencedBytes + self.maximumPendingFences = max(1, maximumPendingFences) + self.maximumPendingFenceResponseBytes = max(24, maximumPendingFenceResponseBytes) + self.maximumCopiedScanoutSurfaceBytes = max(4, maximumCopiedScanoutSurfaceBytes) + self.quiescenceTimeout = max(0.1, quiescenceTimeout) + self.rendererExecutor = rendererExecutor + self.rendererWorkerCandidate = selectedWorkerCandidate + self.capsets = rendererExecutor?.capsets ?? selectedWorkerCandidate?.capsets ?? [] + let hasRendererAuthority = rendererExecutor != nil || selectedWorkerCandidate != nil + self.rendererLifecycleHealthState = hasRendererAuthority + ? .ready(epoch: 1) + : .notConfigured + self.configuredRendererDeviceFeatures = hasRendererAuthority + ? Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit + : 0 self.sharedMemoryRegions = [ VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize) ] @@ -513,13 +2280,51 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi self.scanoutSizes = boundedScanoutSizes self.hostVisibleMemory = hostVisibleMemory self.onScanoutFrame = onScanoutFrame + self.onScanoutTexture = onScanoutTexture + self.onMetalScanout = onMetalScanout self.onScanoutResourceReleased = onScanoutResourceReleased + self.onScanoutDisabled = onScanoutDisabled self.onCursorUpdate = onCursorUpdate - renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in - self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) - } - renderer?.onRuntimeFailure = { [weak self] failure in - self?.recordRendererFailure(failure) + self.onRendererWorkerFailure = onRendererWorkerFailure + rendererExecutor?.installCallbacks( + fence: { [weak self] generation, contextID, ringIndex, fenceID in + self?.fenceSignaled( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID + ) + }, + runtimeFailure: { [weak self] generation, failure in + self?.recordRendererFailure(failure, generation: generation) + } + ) + selectedWorkerCandidate?.installCallbacks( + fence: { [weak self] generation, contextID, ringIndex, fenceID in + self?.fenceSignaled( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID + ) + }, + runtimeFailure: { [weak self] generation, error in + self?.rendererWorkerCandidateFailed( + generation: generation, + error: error + ) + } + ) + if hasRendererAuthorityConflict { + if let state = rendererWorkerCandidate?.snapshot().state { + let generation: UInt64 = switch state { + case .active(let value), .revoked(let value), .failed(let value): value + } + rendererWorkerCandidate?.revoke(deviceGeneration: generation) + } + FileHandle.standardError.write(Data( + "dory-gpu: renderer authority conflict; acceleration remains disabled\n".utf8 + )) } } @@ -532,146 +2337,4773 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi config.appendLE(events) // events_read config.appendLE(UInt32(0)) // events_clear config.appendLE(count) // num_scanouts - config.appendLE(UInt32(capsets.count)) // num_capsets + config.appendLE(rendererCapabilitiesAreAdvertised ? UInt32(capsets.count) : 0) // num_capsets return config } - /// Publishes a new preferred scanout size and raises VIRTIO_GPU_EVENT_DISPLAY. The Linux DRM - /// driver responds by re-reading GET_DISPLAY_INFO and issuing a real modeset, so the guest - /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed - /// framebuffer on the host. - public func updateScanoutSize( - scanoutID: UInt32, - width: UInt32, - height: UInt32, + /// Publishes a new preferred scanout size and raises VIRTIO_GPU_EVENT_DISPLAY. The Linux DRM + /// driver responds by re-reading GET_DISPLAY_INFO and issuing a real modeset, so the guest + /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed + /// framebuffer on the host. + public func updateScanoutSize( + scanoutID: UInt32, + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + let updated = VirtioGPUScanoutSize(width: width, height: height) + displayLock.lock() + let index = Int(scanoutID) + let changed = scanoutSizes.indices.contains(index) && scanoutSizes[index] != updated + if changed { + scanoutSizes[index] = updated + pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY + } + displayLock.unlock() + if changed { transport.notifyConfigChange() } + } + + /// Source-compatible primary-scanout resize bridge. + public func updateScanoutSize( + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + updateScanoutSize( + scanoutID: 0, + width: width, + height: height, + transport: transport + ) + } + + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { + guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return } + var cleared: UInt32 = 0 + for byte in 0..> UInt64(byte * 8)) & 0xFF) << UInt32((position - 4) * 8) + } + displayLock.lock() + pendingDisplayEvents &= ~cleared + displayLock.unlock() + } + + /// Clears one complete guest GPU epoch. Display disable is published before generation + /// releases; renderer teardown begins only after every release acknowledgement. Callers that + /// own process shutdown should retain and wait on the returned receipt before destroying the + /// renderer or guest memory. + @discardableResult + public func quiesce(reason: VirtioGPUQuiescenceReason) -> VirtioGPUQuiescence { + commandLock.lock() + defer { commandLock.unlock() } + + if let existing = lifecycleLock.withLock({ activeQuiescence?.receipt }) { + return existing + } + + let localWorkerStateIsPristine = reason == .deviceReset + && resources2D.isEmpty + && resources3D.isEmpty + && blobResources.isEmpty + && resourceEntries.isEmpty + && resourceUUIDs.isEmpty + && resourceGenerations.isEmpty + && rendererWorkerResourceGenerations.isEmpty + && rendererWorkerResourceContextIDs.isEmpty + && rendererWorkerPendingResourceIDs.isEmpty + && rendererWorkerPendingBackingResourceIDs.isEmpty + && rendererWorkerPendingMappingResourceIDs.isEmpty + && rendererWorkerControlCommandClaim == nil + && scanouts.isEmpty + && cursorResourceID == nil + && createdContextIDs.isEmpty + && rendererWorkerPresentationLock.withLock { + rendererWorkerPendingScanouts.isEmpty && rendererWorkerLiveScanouts.isEmpty + } + let (epoch, revokedWorkerGeneration, fenceStateIsPristine) = fenceLock.withLock { + () -> (UInt64, UInt64, Bool) in + let revokedWorkerGeneration = lifecycleEpoch + let fenceStateIsPristine = pendingFenceCount == 0 + && uncertainFences.isEmpty + && uncertainRendererCommandChains.isEmpty + && lastTransport == nil + && !fenceAdmissionBlockedUntilDeviceReset + lifecycleEpoch &+= 1 + if lifecycleEpoch == 0 { lifecycleEpoch = 1 } + recordTelemetryWhileLocked( + .resetRevokedFence, + count: UInt64(pendingFenceCount) + ) + recordTelemetryWhileLocked( + .revokedUncertainRendererCommand, + count: UInt64(uncertainRendererCommandChains.count) + ) + pendingFences.removeAll() + uncertainFences.removeAll() + uncertainRendererCommandChains.removeAll() + pendingFenceCount = 0 + pendingFenceResponseBytes = 0 + lastTransport = nil + fenceAdmissionBlockedUntilDeviceReset = + rendererExecutor != nil || rendererWorkerCandidate != nil + return (lifecycleEpoch, revokedWorkerGeneration, fenceStateIsPristine) + } + let workerReboundForPristineDeviceReset = localWorkerStateIsPristine + && fenceStateIsPristine + && rendererWorkerCandidate?.rebindPristineDeviceGeneration( + from: revokedWorkerGeneration, + to: epoch + ) == true + if !workerReboundForPristineDeviceReset { + rendererWorkerCandidate?.revoke(deviceGeneration: revokedWorkerGeneration) + revokeRendererWorkerScanouts() + } + rendererWorkerControlCommandClaim = nil + + let rendererGeneration: UInt64 + let executorAdmissionFault: VirtioGPURendererHealthFault? + if let rendererExecutor { + switch rendererExecutor.beginQuiescence(successorGeneration: epoch) { + case .admitted(let sourceGeneration): + rendererGeneration = sourceGeneration + executorAdmissionFault = nil + case .rejected(let rejection): + rendererGeneration = epoch + executorAdmissionFault = .resetFailed( + "renderer executor rejected quiescence: \(rejection)" + ) + } + } else { + rendererGeneration = epoch + executorAdmissionFault = nil + } + + let resources = (resources2D.keys.map { resourceID in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: false + ) + } + resources3D.keys.map { resourceID in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: false + ) + } + blobResources.map { resourceID, blob in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: blob.mapping?.requiresRendererUnmap == true + || blob.workerMapping != nil + ) + }).sorted { + if $0.key.resourceID != $1.key.resourceID { + return $0.key.resourceID < $1.key.resourceID + } + return $0.key.generation < $1.key.generation + } + let receipt = VirtioGPUQuiescence(epoch: epoch, reason: reason) + + let existingFault: VirtioGPURendererHealthFault? = lifecycleLock.withLock { + acceptingGuestCommands = false + if let executorAdmissionFault { + rendererLifecycleHealthState = .failed( + epoch: epoch, + fault: executorAdmissionFault + ) + return executorAdmissionFault + } + if case .failed(_, let fault) = rendererLifecycleHealthState { + for resource in resources { + retiringResources[resource.key.resourceID] = resource.key.generation + } + return fault + } + let prior = Set(retiringResources.map { + ResourceRetirementKey(resourceID: $0.key, generation: $0.value) + }) + for resource in resources { + retiringResources[resource.key.resourceID] = resource.key.generation + } + activeQuiescence = ActiveQuiescence( + receipt: receipt, + rendererGeneration: rendererGeneration, + workerReboundForPristineDeviceReset: workerReboundForPristineDeviceReset, + rendererResources: resources, + awaitingReleaseAcknowledgements: Set(resources.map(\.key)), + priorRetirements: prior, + cleanupScheduled: false + ) + rendererLifecycleHealthState = rendererAuthorityIsConfigured + ? .quiescing(epoch: epoch) + : .notConfigured + return nil + } + + let blobResourceIDs = blobResources.keys.sorted() + for resourceID in blobResourceIDs { hostVisibleMemory?.unmap(resourceID: resourceID) } + resources2D.removeAll() + resources3D.removeAll() + blobResources.removeAll() + resourceEntries.removeAll() + resourceUUIDs.removeAll() + resourceGenerations.removeAll() + rendererWorkerResourceGenerations.removeAll() + rendererWorkerResourceContextIDs.removeAll() + rendererWorkerPendingResourceIDs.removeAll() + rendererWorkerPendingBackingResourceIDs.removeAll() + rendererWorkerPendingMappingResourceIDs.removeAll() + rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.removeAll() + } + scanouts.removeAll() + cursorResourceID = nil + createdContextIDs.removeAll() + commandFailureCounts.removeAll() + displayLock.withLock { pendingDisplayEvents = 0 } + + // A disable causes each mailbox to retire a queued direct presentation before the release + // objects below can acknowledge renderer destruction. + for scanoutID in 0.. 0 + || (workerSnapshot?.armedFences ?? 0) > 0 + ) + let revokedFenceGeneration = fenceLock.withLock { () -> UInt64? in + guard workerRequiresRevocation + || pendingFenceCount > 0 + || !uncertainRendererCommandChains.isEmpty else { + return nil + } + let revokedGeneration = lifecycleEpoch + recordTelemetryWhileLocked( + .queueRevokedFence, + count: UInt64(pendingFenceCount) + ) + pendingFences.removeAll() + uncertainFences.removeAll() + recordTelemetryWhileLocked( + .revokedUncertainRendererCommand, + count: UInt64(uncertainRendererCommandChains.count) + ) + uncertainRendererCommandChains.removeAll() + pendingFenceCount = 0 + pendingFenceResponseBytes = 0 + lastTransport = nil + lifecycleEpoch &+= 1 + if lifecycleEpoch == 0 { lifecycleEpoch = 1 } + fenceAdmissionBlockedUntilDeviceReset = true + return revokedGeneration + } + if let revokedFenceGeneration { + if rendererWorkerControlCommandClaim?.generation == revokedFenceGeneration { + rendererWorkerControlCommandClaim = nil + } + rendererExecutor?.revokeActiveGeneration() + rendererWorkerCandidate?.revoke(deviceGeneration: revokedFenceGeneration) + revokeRendererWorkerScanouts() + } + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 0 || queue == 1 else { return } + let outcome = drainQueue(queue: queue, transport: transport) + switch outcome { + case .drained(let wantsInterrupt), + .popFailed(let wantsInterrupt), + .completionFailed(let wantsInterrupt): + if wantsInterrupt { transport.notifyUsed() } + case .pendingReadFailed: + break + } + } + + private func drainQueue( + queue: Int, + transport: VirtioMMIOTransport + ) -> QueueDrainOutcome { + if queue == 0, + rendererWorkerCandidate != nil, + commandLock.withLock({ + rendererWorkerControlCommandClaim != nil + }) { + // The command at the head of the accepted worker stream still owns controlq ordering. + // Unknown outcomes intentionally keep this barrier until the whole device is reset. + return .drained(wantsInterrupt: false) + } + let virtqueue = transport.queues[queue] + let pending: UInt16 + do { + pending = try virtqueue.pendingCount() + } catch { + recordTelemetry(.queuePendingReadFailure) + return .pendingReadFailed + } + + // Process only the validated snapshot. Even if a future SMP guest publishes more work + // while this kick is running, one notification can never monopolize the VCPU indefinitely. + var wantsInterrupt = false + for _ in 0.. RendererWorkerControlCommandClaim? { + commandLock.withLock { + guard rendererWorkerControlCommandClaim == nil else { return nil } + let generation = fenceLock.withLock { lifecycleEpoch } + let claim = RendererWorkerControlCommandClaim( + generation: generation, + token: nextRendererWorkerControlCommandToken + ) + nextRendererWorkerControlCommandToken &+= 1 + if nextRendererWorkerControlCommandToken == 0 { + nextRendererWorkerControlCommandToken = 1 + } + rendererWorkerControlCommandClaim = claim + return claim + } + } + + /// Releases only the exact command claim. A late completion from either a revoked generation + /// or an older pipelined command in the active generation cannot open the queue. + @discardableResult + private func completeRendererWorkerControlCommand( + claim: RendererWorkerControlCommandClaim + ) -> Bool { + commandLock.withLock { + guard rendererWorkerControlCommandClaim == claim else { return false } + rendererWorkerControlCommandClaim = nil + return true + } + } + + private func admit( + chain: VirtqueueChain, + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) -> QueueAdmissionOutcome { + guard !chain.containsZeroLengthDescriptor else { + return .rejected(.invalidDescriptorLayout) + } + return chain.withLeaseHeld { access in + let segments = access.segments + guard !segments.isEmpty else { + return .rejected(.invalidDescriptorLayout) + } + var encounteredWritable = false + for segment in segments { + if segment.isDeviceWritable { + encounteredWritable = true + } else if encounteredWritable { + // Virtio requests are a readable prefix followed by a writable suffix. Never + // normalize writable/readable/writable guest chains into a valid-looking pair. + return .rejected(.invalidDescriptorLayout) + } + } + + let readable = access.readableByteCount + let maximum = cursorQueue ? 56 : maximumControlRequestBytes + guard readable <= maximum else { return .rejected(.oversizedRequest) } + guard cursorQueue ? readable == 56 : readable >= 24 else { + return .rejected(.invalidDescriptorLayout) + } + + if !cursorQueue, rendererWorkerCandidate != nil { + let header = access.readBytes(maximum: 24) + if header.count == 24 { + let command = header.leUInt32(at: 0) + let exactWorkerRequestBytes: Int? = switch command { + case Command.resourceCreate2D: 40 + case Command.resourceUnref, + Command.resourceAssignUUID, + Command.resourceDetachBacking, + Command.ctxAttachResource, + Command.ctxDetachResource, + Command.resourceUnmapBlob: 32 + case Command.ctxDestroy: 24 + case Command.setScanout, Command.resourceFlush: 48 + case Command.transferToHost2D: 56 + case Command.ctxCreate, Command.setScanoutBlob: 96 + case Command.resourceCreate3D, + Command.transferToHost3D, + Command.transferFromHost3D: 72 + case Command.resourceMapBlob: 40 + default: nil + } + if let exactWorkerRequestBytes, readable != exactWorkerRequestBytes { + return .workerRejected(requestHeader: header) + } + if (command == Command.resourceAttachBacking && readable < 32) + || (command == Command.resourceCreateBlob && readable < 56) + || (command == Command.submit3D && readable < 32) { + return .workerRejected(requestHeader: header) + } + if command == Command.resourceCreate2D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard readable == 40 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 40) + let resourceID = request.leUInt32(at: 24) + let format = request.leUInt32(at: 28) + let width = request.leUInt32(at: 32) + let height = request.leUInt32(at: 36) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard request.count == 40, + hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + width > 0, + height > 0, + width <= 16_384, + height <= 16_384, + Self.isSupportedScanoutFormat(format), + let copiedByteCount = Self.rgbaByteCount( + width: width, + height: height + ), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes, + let payload = try? DoryRendererResource3DCreatePayload( + target: 2, + format: format, + bind: (1 << 1) | (1 << 18), + width: width, + height: height, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1, + maximumReferencedBytes: maximumRendererReferencedBytes + ) else { + return .workerRejected(requestHeader: request) + } + return .workerCreateResource3D(WorkerCreateResource3DAdmission( + request: request, + resourceID: resourceID, + payload: payload, + kind: .resource2D, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.resourceCreate3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard readable == 72 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 72) + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard request.count == 72, + hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + let payload = try? DoryRendererResource3DCreatePayload( + target: request.leUInt32(at: 28), + format: request.leUInt32(at: 32), + bind: request.leUInt32(at: 36), + width: request.leUInt32(at: 40), + height: request.leUInt32(at: 44), + depth: request.leUInt32(at: 48), + arraySize: request.leUInt32(at: 52), + lastLevel: request.leUInt32(at: 56), + samples: request.leUInt32(at: 60), + flags: request.leUInt32(at: 64), + maximumReferencedBytes: maximumRendererReferencedBytes + ) else { + return .workerRejected(requestHeader: request) + } + return .workerCreateResource3D(WorkerCreateResource3DAdmission( + request: request, + resourceID: resourceID, + payload: payload, + kind: .resource3D, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.transferToHost3D + || command == Command.transferFromHost3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let request = access.readBytes(maximum: 72) + let resourceID = request.leUInt32(at: 56) + let contextID = request.leUInt32(at: 16) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceGeneration = commandLock.withLock { () -> UInt64? in + guard resourceEntries[resourceID] != nil, + contextID == 0 || createdContextIDs.contains(contextID) else { + return nil + } + return rendererWorkerResourceGenerations[resourceID] + } + guard request.count == 72, + hasValidFenceHeader, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + let resourceGeneration, + let payload = try? DoryRendererTransfer3DPayload( + level: request.leUInt32(at: 60), + stride: request.leUInt32(at: 64), + layerStride: request.leUInt32(at: 68), + offset: request.leUInt64(at: 48), + x: request.leUInt32(at: 24), + y: request.leUInt32(at: 28), + z: request.leUInt32(at: 32), + width: request.leUInt32(at: 36), + height: request.leUInt32(at: 40), + depth: request.leUInt32(at: 44) + ) else { + return .workerRejected(requestHeader: request) + } + return .workerTransfer(WorkerTransferAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + direction: command == Command.transferToHost3D + ? .toHost + : .fromHost, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.ctxCreate || command == Command.ctxDestroy { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard header.leUInt32(at: 4) == 0, + header.leUInt32(at: 16) != 0 else { + return .workerRejected(requestHeader: header) + } + if command == Command.ctxDestroy { + guard readable == 24 else { + return .workerRejected(requestHeader: header) + } + return .workerControl(WorkerControlAdmission( + request: header, + contextID: header.leUInt32(at: 16), + operation: .destroyContext + )) + } + + guard readable == 96 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 96) + guard request.count == 96 else { + return .rejected(.invalidDescriptorLayout) + } + let nameLength = Int(request.leUInt32(at: 24)) + let contextInit = request.leUInt32(at: 28) + let resolvedCapset = Self.rendererContextFlags( + requested: contextInit, + capsets: capsets + ) + guard nameLength <= DoryRendererContextCreatePayload.maximumNameBytes, + contextInit & ~UInt32(0xff) == 0, + resolvedCapset == 2 || resolvedCapset == Capset.venus else { + return .workerRejected(requestHeader: request) + } + let rawName = request[32..<(32 + nameLength)].prefix { $0 != 0 } + let name = rawName.isEmpty + ? "virtio-gpu" + : String(decoding: rawName, as: UTF8.self) + guard !name.utf8.contains(0), + name.utf8.count + <= DoryRendererContextCreatePayload.maximumNameBytes else { + return .workerRejected(requestHeader: request) + } + return .workerControl(WorkerControlAdmission( + request: request, + contextID: header.leUInt32(at: 16), + operation: .createContext(name: name, capsetID: resolvedCapset) + )) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 48 { + let request = access.readBytes(maximum: 48) + if request.count == 48, + request.leUInt32(at: 0) == Command.resourceFlush { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 40) + let workerState = commandLock.withLock { () -> ( + WorkerScanoutSurface?, + UInt64, + UInt64, + [WorkerFlushTarget] + )? in + guard let workerGeneration = + rendererWorkerResourceGenerations[resourceID], + let displayGeneration = resourceGenerations[resourceID], + let requestedRect = try? scanoutRect(from: request, at: 24) else { + return nil + } + var surface: WorkerScanoutSurface? + var targets = [WorkerFlushTarget]() + for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) { + guard binding.resourceID == resourceID else { continue } + guard let candidate = rendererWorkerScanoutSurface( + for: binding + ) else { return nil } + guard Self.contains( + rect: requestedRect, + width: candidate.width, + height: candidate.height + ), surface == nil || surface == candidate else { + return nil + } + surface = candidate + guard let dirty = Self.intersection( + requestedRect, + binding.rect + ) else { continue } + targets.append(WorkerFlushTarget( + scanoutID: scanoutID, + sourceRect: binding.rect, + dirtyRect: VirtioGPURect( + x: dirty.x - binding.rect.x, + y: dirty.y - binding.rect.y, + width: dirty.width, + height: dirty.height + ) + )) + } + return ( + surface, + workerGeneration, + displayGeneration, + targets + ) + } + if let workerState { + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let canonicalHeader = hasValidFenceHeader + && request.leUInt32(at: 16) == 0 + && request[20..<24].allSatisfy({ $0 == 0 }) + && resourceID != 0 + && request.leUInt32(at: 44) == 0 + guard canonicalHeader else { + logRendererWorkerScanoutFailure( + resourceID: resourceID, + stage: "flush-header", + detail: "targets=\(workerState.3.count)" + ) + return .workerRejected(requestHeader: request) + } + return .workerFlushScanout(WorkerFlushScanoutAdmission( + request: request, + resourceID: resourceID, + workerResourceGeneration: workerState.1, + displayResourceGeneration: workerState.2, + surface: workerState.0, + targets: workerState.3, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + // A non-worker 2D/blob flush continues through the established software path; + // malformed or stale worker resource identity fails closed here. + let rejectedWorkerRoute = commandLock.withLock { () -> ( + rendererOwned: Bool, + detail: String + ) in + let rendererOwned = resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || rendererWorkerResourceGenerations[resourceID] != nil + guard rendererOwned else { return (false, "") } + let requestedRect = try? scanoutRect(from: request, at: 24) + let rectDescription = requestedRect.map { + "\($0.x),\($0.y)/\($0.width)x\($0.height)" + } ?? "invalid" + let bindings = scanouts + .filter { $0.value.resourceID == resourceID } + .sorted { $0.key < $1.key } + .map { scanoutID, binding -> String in + switch binding.source { + case .resource2D: + return "\(scanoutID):2d" + case .resource3D: + return "\(scanoutID):3d" + case .blob(let format, let width, let height, let stride, let offset): + return "\(scanoutID):blob/\(format)/\(width)x\(height)/" + + "\(stride)/\(offset)" + } + } + .joined(separator: ",") + let detail = "blob=\(blobResources[resourceID] != nil) worker-generation=" + + "\(rendererWorkerResourceGenerations[resourceID].map(String.init) ?? "missing")" + + " display-generation=" + + "\(resourceGenerations[resourceID].map(String.init) ?? "missing")" + + " contexts=\((rendererWorkerResourceContextIDs[resourceID] ?? []).sorted())" + + " rect=\(rectDescription) bindings=[\(bindings)]" + return (true, detail) + } + if rejectedWorkerRoute.rendererOwned { + logRendererWorkerScanoutFailure( + resourceID: resourceID, + stage: "flush-state-missing", + detail: rejectedWorkerRoute.detail + ) + return .workerRejected(requestHeader: request) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 32 { + let request = access.readBytes(maximum: 32) + if request.count == 32 { + let command = request.leUInt32(at: 0) + if command == Command.resourceUnref { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceState = commandLock.withLock { () -> ( + workerGeneration: UInt64, + displayGeneration: UInt64 + )? in + guard resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || blobResources[resourceID] != nil, + let workerGeneration = + rendererWorkerResourceGenerations[resourceID], + let displayGeneration = resourceGenerations[resourceID], + rendererWorkerResourceContextIDs[resourceID]?.isEmpty + != false, + !rendererWorkerPendingResourceIDs.contains(resourceID), + !rendererWorkerPendingBackingResourceIDs.contains(resourceID), + !rendererWorkerPendingMappingResourceIDs.contains(resourceID), + !isResourceRetiring(resourceID), + blobResources[resourceID]?.guestMapped != true, + blobResources[resourceID]?.workerMapping == nil else { + return nil + } + return (workerGeneration, displayGeneration) + } + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceState else { + return .workerRejected(requestHeader: request) + } + return .workerUnref(WorkerUnrefAdmission( + request: request, + resourceID: resourceID, + workerResourceGeneration: resourceState.workerGeneration, + displayResourceGeneration: resourceState.displayGeneration, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.resourceDetachBacking { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceState = commandLock.withLock { () -> UInt64? in + guard resourceEntries[resourceID] != nil, + rendererWorkerPendingBackingResourceIDs + .contains(resourceID) == false else { return nil } + return rendererWorkerResourceGenerations[resourceID] + } + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceState else { + return .workerRejected(requestHeader: request) + } + return .workerDetachBacking(WorkerDetachBackingAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceState, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.ctxAttachResource + || command == Command.ctxDetachResource { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + contextID != 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0 else { + return .workerRejected(requestHeader: request) + } + let workerRouted = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] != nil + } + guard workerRouted else { + return .workerRejected(requestHeader: request) + } + return .workerControl(WorkerControlAdmission( + request: request, + contextID: contextID, + operation: command == Command.ctxAttachResource + ? .attachResource(resourceID: resourceID) + : .detachResource(resourceID: resourceID) + )) + } + if command == Command.resourceUnmapBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let resourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceGeneration else { + return .workerRejected(requestHeader: request) + } + return .workerUnmapBlob(WorkerUnmapBlobAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration + )) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 40 { + let request = access.readBytes(maximum: 40) + if request.count == 40, + request.leUInt32(at: 0) == Command.resourceMapBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 32 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let resourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceGeneration else { + return .workerRejected(requestHeader: request) + } + return .workerMapBlob(WorkerMapBlobAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + hostVisibleOffset: request.leUInt64(at: 32) + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 56 { + let request = access.readBytes(maximum: 56) + if request.count == 56, + request.leUInt32(at: 0) == Command.transferToHost2D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 48) + let workerState = commandLock.withLock { () -> ( + resource: Resource2D, + generation: UInt64 + )? in + guard let resource = resources2D[resourceID], + !resource.backing.isEmpty, + resourceEntries[resourceID] != nil, + let generation = rendererWorkerResourceGenerations[resourceID] else { + return nil + } + return (resource, generation) + } + let rect = try? scanoutRect(from: request, at: 24) + let offset = request.leUInt64(at: 40) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 52) == 0, + let workerState, + let rect, + Self.contains( + rect: rect, + width: workerState.resource.width, + height: workerState.resource.height + ), + let resourceByteCount = Self.rgbaByteCount( + width: workerState.resource.width, + height: workerState.resource.height + ), + offset < resourceByteCount, + let payload = try? DoryRendererTransfer3DPayload( + level: 0, + stride: 0, + layerStride: 0, + offset: offset, + x: rect.x, + y: rect.y, + z: 0, + width: rect.width, + height: rect.height, + depth: 1 + ) else { + return .workerRejected(requestHeader: request) + } + return .workerTransfer(WorkerTransferAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: workerState.generation, + contextID: 0, + payload: payload, + direction: .toHost, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 56 { + let header = access.readBytes(maximum: 56) + if header.count == 56, + header.leUInt32(at: 0) == Command.resourceCreateBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = header.leUInt32(at: 24) + let entryCount = Int(header.leUInt32(at: 36)) + let (entryBytes, multiplyOverflow) = entryCount + .multipliedReportingOverflow(by: 16) + let (expectedBytes, addOverflow) = 56 + .addingReportingOverflow(entryBytes) + guard header.leUInt32(at: 4) == 0, + header.leUInt64(at: 8) == 0, + header[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + entryCount <= maximumRawMemoryEntries, + !multiplyOverflow, + !addOverflow, + readable == expectedBytes, + let payload = try? DoryRendererBlobCreatePayload( + blobMemory: header.leUInt32(at: 28), + blobFlags: header.leUInt32(at: 32), + blobID: header.leUInt64(at: 40), + size: header.leUInt64(at: 48) + ), + payload.size <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: expectedBytes) + guard request.count == expectedBytes, + let entries = try? memoryEntries( + from: request, + count: UInt32(entryCount), + offset: 56, + transport: transport + ) else { + return .rejected(.invalidDescriptorLayout) + } + var referencedBytes: UInt64 = 0 + for entry in entries { + let (sum, overflow) = referencedBytes.addingReportingOverflow( + UInt64(entry.length) + ) + guard !overflow, sum <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + referencedBytes = sum + } + let regions: DoryRendererWorkerSharedRegionSet + if entries.isEmpty { + regions = DoryRendererWorkerSharedRegionSet( + references: [], + descriptors: [] + ) + } else { + guard let guestBacking = try? DoryRendererWorkerSharedRegionSet + .guestBacking(entries: entries, transport: transport) else { + return .rejected(.invalidDescriptorLayout) + } + regions = guestBacking + } + return .workerCreateBlob(WorkerCreateBlobAdmission( + request: request, + resourceID: resourceID, + contextID: header.leUInt32(at: 16), + payload: payload, + entries: entries, + regions: regions + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 32 { + let header = access.readBytes(maximum: 32) + if header.count == 32, + header.leUInt32(at: 0) == Command.resourceAttachBacking { + let resourceID = header.leUInt32(at: 24) + let workerResourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + if let workerResourceGeneration { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let entryCount = Int(header.leUInt32(at: 28)) + let (entryBytes, multiplyOverflow) = entryCount + .multipliedReportingOverflow(by: 16) + let (expectedBytes, addOverflow) = 32 + .addingReportingOverflow(entryBytes) + let flags = header.leUInt32(at: 4) + let fenceID = header.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard hasValidFenceHeader, + header.leUInt32(at: 16) == 0, + header[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + entryCount > 0, + entryCount <= maximumRawMemoryEntries, + !multiplyOverflow, + !addOverflow, + readable == expectedBytes else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: expectedBytes) + guard request.count == expectedBytes, + let entries = try? memoryEntries( + from: request, + count: UInt32(entryCount), + offset: 32, + transport: transport + ), + !entries.isEmpty else { + return .rejected(.invalidDescriptorLayout) + } + var referencedBytes: UInt64 = 0 + for entry in entries { + let (sum, overflow) = referencedBytes.addingReportingOverflow( + UInt64(entry.length) + ) + guard !overflow, sum <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + referencedBytes = sum + } + guard let regions = try? DoryRendererWorkerSharedRegionSet.guestBacking( + entries: entries, + transport: transport + ) else { + return .rejected(.invalidDescriptorLayout) + } + return .workerAttachBacking(WorkerAttachBackingAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: workerResourceGeneration, + entries: entries, + regions: regions, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + return .workerRejected(requestHeader: header) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 32 { + let header = access.readBytes(maximum: 32) + if header.count == 32, header.leUInt32(at: 0) == Command.submit3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let flags = header.leUInt32(at: 4) + let hasFence = flags & HeaderFlag.fence != 0 + let hasContextTimeline = flags & HeaderFlag.infoRingIndex != 0 + let fenceID = header.leUInt64(at: 8) + let contextID = header.leUInt32(at: 16) + let ringIndex = UInt32(header[20]) + let hasCanonicalRing = !hasContextTimeline + || ringIndex <= DoryRendererFencePayload.maximumRingIndex + let hasCanonicalFencePair = hasFence + ? fenceID != 0 && (hasContextTimeline || ringIndex == 0) + : fenceID == 0 && (hasContextTimeline || ringIndex == 0) + guard flags & ~(HeaderFlag.fence | HeaderFlag.infoRingIndex) == 0, + hasCanonicalRing, + hasCanonicalFencePair, + contextID != 0, + header[21..<24].allSatisfy({ $0 == 0 }), + header.leUInt32(at: 28) == 0 else { + return .workerRejected(requestHeader: header) + } + let commandByteCount = Int(header.leUInt32(at: 24)) + let (end, overflow) = 32.addingReportingOverflow(commandByteCount) + guard !overflow, end == readable else { + return .workerRejected(requestHeader: header) + } + let regions: DoryRendererWorkerSharedRegionSet + let snapshotStarted = DispatchTime.now().uptimeNanoseconds + do { + regions = try DoryRendererWorkerSharedRegionSet.immutableSubmit3D( + from: access, + readableOffset: 32, + byteCount: commandByteCount, + maximumByteCount: maximumControlRequestBytes - 32 + ) + } catch { + return .rejected(.invalidDescriptorLayout) + } + let snapshotFinished = DispatchTime.now().uptimeNanoseconds + let snapshotNanoseconds = snapshotFinished >= snapshotStarted + ? snapshotFinished - snapshotStarted + : 0 + rendererWorkerMetricsLock.withLock { + rendererWorkerSnapshotMetrics.count = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.count, + 1 + ) + rendererWorkerSnapshotMetrics.bytes = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.bytes, + UInt64(commandByteCount) + ) + rendererWorkerSnapshotMetrics.nanoseconds = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.nanoseconds, + snapshotNanoseconds + ) + rendererWorkerSnapshotMetrics.maximumNanoseconds = max( + rendererWorkerSnapshotMetrics.maximumNanoseconds, + snapshotNanoseconds + ) + } + let fence = hasFence ? FenceRequest( + key: hasContextTimeline + ? FenceKey(contextID: contextID, ringIndex: ringIndex) + : FenceKey(contextID: 0, ringIndex: 0), + contextID: contextID, + ringIndex: hasContextTimeline ? ringIndex : 0, + fenceID: fenceID, + contextFence: hasContextTimeline + ) : nil + return .workerSubmit(WorkerSubmitAdmission( + requestHeader: header, + regions: regions, + fence: fence + )) + } + } + let request = access.readBytes(maximum: maximum) + guard request.count == readable else { + return .rejected(.invalidDescriptorLayout) + } + + if cursorQueue { + // Linux's cursor fast path supplies only the outbound 56-byte command. Accept an + // optional response suffix for other conforming drivers, but never require one. + guard !access.hasWritableSegments || access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + return .admitted( + request: request, + writesResponse: access.hasWritableSegments + ) + } + + guard access.hasWritableSegments, + access.writableByteCount >= maximumResponseByteCount(for: request) else { + return .rejected(.insufficientResponseCapacity) + } + return .admitted(request: request, writesResponse: true) + } ?? .revoked + } + + private func maximumResponseByteCount(for request: [UInt8]) -> Int { + guard request.count >= 4 else { return 24 } + switch request.leUInt32(at: 0) { + case Command.getDisplayInfo: + return 24 + 16 * 24 + case Command.getCapsetInfo, Command.resourceAssignUUID: + return 40 + case Command.resourceMapBlob: + return 32 + case Command.getCapset: + guard request.count >= 32 else { return 24 } + guard rendererCapabilitiesAreAdvertised else { return 24 } + let id = request.leUInt32(at: 24) + let version = request.leUInt32(at: 28) + guard let capset = capsets.first(where: { + $0.id == id && version <= $0.maxVersion + }) else { return 24 } + let (total, overflow) = 24.addingReportingOverflow(capset.data.count) + return overflow ? Int.max : total + default: + return 24 + } + } + + private func prepareFenceAdmission( + request: [UInt8], + responseByteCount: Int, + transport: VirtioMMIOTransport + ) -> FenceAdmission { + guard rendererExecutor != nil, request.count >= 24 else { return .notRequested } + let flags = request.leUInt32(at: 4) + guard flags & HeaderFlag.fence != 0 else { return .notRequested } + let fenceID = request.leUInt64(at: 8) + let contextID = request.leUInt32(at: 16) + let ringIndex = UInt32(request[20]) + let contextFence = flags & HeaderFlag.infoRingIndex != 0 + let key = contextFence + ? FenceKey(contextID: contextID, ringIndex: ringIndex) + : FenceKey(contextID: 0, ringIndex: 0) + return fenceLock.withLock { + guard !fenceAdmissionBlockedUntilDeviceReset, + pendingFenceCount < maximumPendingFences, + responseByteCount <= maximumPendingFenceResponseBytes, + pendingFenceResponseBytes + <= maximumPendingFenceResponseBytes - responseByteCount, + lastTransport == nil || lastTransport === transport else { + return .rejected + } + return .admitted(FenceRequest( + key: key, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID, + contextFence: contextFence + )) + } + } + + /// Holds a successfully processed fenced command until the renderer signals its timeline. + /// Presentation itself follows RESOURCE_FLUSH and shares the renderer texture; it does not + /// perform a separate readback operation that needs fence coupling. + private func deferForFence( + admission: FenceAdmission, + response: [UInt8], + chain: VirtqueueChain, + transport: VirtioMMIOTransport + ) -> FenceDeferralOutcome { + guard let rendererExecutor, + case .admitted(let fence) = admission, + response.count >= 4, + response.leUInt32(at: 0) & 0xFF00 == 0x1100 else { + return .immediate + } + // Publish the waiter before creating the renderer fence. With a fast host GPU (and in + // particular after a synchronizing readback), virglrenderer may invoke its completion + // callback from createFence itself or immediately on another thread. Registering after + // createFence loses that edge forever and stalls the guest compositor on its first frame. + let waiter = fenceLock.withLock { () -> PendingFence in + let token = nextFenceToken + nextFenceToken &+= 1 + if nextFenceToken == 0 { nextFenceToken = 1 } + let pending = PendingFence( + token: token, + fenceID: fence.fenceID, + epoch: lifecycleEpoch, + response: response, + chain: chain, + createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + timeoutReported: false + ) + pendingFences[fence.key, default: []].append(pending) + pendingFenceCount += 1 + pendingFenceResponseBytes += response.count + lastTransport = transport + return pending + } + let fenceOutcome = rendererExecutor.execute( + .createFence( + contextID: fence.contextID, + ringIndex: fence.ringIndex, + guestFenceID: fence.fenceID, + contextFence: fence.contextFence + ), + generation: waiter.epoch + ) + switch fenceOutcome { + case .success(.none): + fenceLock.withLock { + fenceCount = Self.saturatingAdd(fenceCount, 1) + } + return .deferred + case .success: + preconditionFailure("create-fence returned an invalid executor payload") + case .rejected(let rejection): + let detail = "renderer fence rejected after command commit: \(rejection)" + fenceLock.withLock { + recordTelemetryWhileLocked(.rendererCommandUncertainty) + if var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == waiter.token }) { + uncertainFences.append(waiting.remove(at: index)) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + } + fenceRegistrationFailureCount = Self.saturatingAdd( + fenceRegistrationFailureCount, + 1 + ) + fenceAdmissionBlockedUntilDeviceReset = true + } + failRendererLifecycle( + .fenceRegistrationFailed(detail), + epoch: waiter.epoch + ) + return .outcomeUnknown + case .outcomeUnknown(let uncertainty): + fenceLock.withLock { + recordTelemetryWhileLocked(.rendererCommandUncertainty) + if var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == waiter.token }) { + uncertainFences.append(waiting.remove(at: index)) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + } + fenceRegistrationFailureCount = Self.saturatingAdd( + fenceRegistrationFailureCount, + 1 + ) + fenceAdmissionBlockedUntilDeviceReset = true + if let failure = uncertainty.runtimeFailure { + recordRendererFailureWhileLocked(failure) + } + } + // The command may already be executing in the renderer. There is no safe successful + // or error completion without a fence, so retain this exact descriptor for reset and + // quarantine new renderer-backed work rather than fabricating completion. + failRendererLifecycle( + .fenceRegistrationFailed(uncertainty.detail), + epoch: waiter.epoch + ) + return .outcomeUnknown + } + } + + private func startRendererWorkerControl( + _ admission: WorkerControlAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerControl( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + do { + switch admission.operation { + case .createContext(let name, let capsetID): + try rendererWorkerCandidate.createContext( + contextID: admission.contextID, + capsetID: capsetID, + name: name, + deviceGeneration: generation, + completion: completion + ) + case .destroyContext: + try rendererWorkerCandidate.destroyContext( + contextID: admission.contextID, + deviceGeneration: generation, + completion: completion + ) + case .attachResource(let resourceID): + guard let resourceGeneration = commandLock.withLock({ + rendererWorkerResourceGenerations[resourceID] + }) else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + try rendererWorkerCandidate.attachResource( + contextID: admission.contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: generation, + completion: completion + ) + case .detachResource(let resourceID): + guard let resourceGeneration = commandLock.withLock({ + rendererWorkerResourceGenerations[resourceID] + }) else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + try rendererWorkerCandidate.detachResource( + contextID: admission.contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: generation, + completion: completion + ) + } + } catch { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + return nil + } + + private func finishRendererWorkerControl( + _ result: Result, + admission: WorkerControlAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration else { return false } + switch admission.operation { + case .createContext(let name, let capsetID): + createdContextIDs.insert(admission.contextID) + traceResourceEvent( + "worker-context-create", + contextID: admission.contextID, + detail: "name=\(name) capset=\(capsetID)" + ) + case .destroyContext: + createdContextIDs.remove(admission.contextID) + for resourceID in Array(rendererWorkerResourceContextIDs.keys) { + rendererWorkerResourceContextIDs[resourceID]?.remove( + admission.contextID + ) + if rendererWorkerResourceContextIDs[resourceID]?.isEmpty == true { + rendererWorkerResourceContextIDs.removeValue(forKey: resourceID) + } + } + traceResourceEvent( + "worker-context-destroy", + contextID: admission.contextID + ) + case .attachResource(let resourceID): + guard rendererWorkerResourceGenerations[resourceID] != nil else { + return false + } + rendererWorkerResourceContextIDs[resourceID, default: []].insert( + admission.contextID + ) + traceResourceEvent( + "worker-attach", + contextID: admission.contextID, + resourceID: resourceID + ) + case .detachResource(let resourceID): + rendererWorkerResourceContextIDs[resourceID]?.remove(admission.contextID) + if rendererWorkerResourceContextIDs[resourceID]?.isEmpty == true { + rendererWorkerResourceContextIDs.removeValue(forKey: resourceID) + } + traceResourceEvent( + "worker-detach", + contextID: admission.contextID, + resourceID: resourceID + ) + } + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerCreateResource3D( + _ admission: WorkerCreateResource3DAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard canAdmitResource(admission.resourceID) else { return false } + return rendererWorkerPendingResourceIDs.insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.createResource3D( + resourceID: admission.resourceID, + payload: admission.payload, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerCreateResource3D( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerCreateResource3D( + _ result: Result, + admission: WorkerCreateResource3DAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let workerResourceGeneration): + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerPendingResourceIDs.remove(admission.resourceID) != nil else { + return false + } + switch admission.kind { + case .resource2D: + resources2D[admission.resourceID] = Resource2D( + format: admission.payload.format, + width: admission.payload.width, + height: admission.payload.height + ) + case .resource3D: + resources3D[admission.resourceID] = Resource3D( + format: admission.payload.format, + width: admission.payload.width, + height: admission.payload.height + ) + } + rendererWorkerResourceGenerations[admission.resourceID] = + workerResourceGeneration + registerResourceGeneration(admission.resourceID) + traceResourceEvent( + admission.kind == .resource2D + ? "worker-create-2d" + : "worker-create-3d", + resourceID: admission.resourceID, + detail: "worker-generation=\(workerResourceGeneration)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerCreateBlob( + _ admission: WorkerCreateBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard canAdmitResource(admission.resourceID) else { return false } + return rendererWorkerPendingResourceIDs.insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.createBlob( + resourceID: admission.resourceID, + contextID: admission.contextID, + payload: admission.payload, + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerCreateBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + private func finishRendererWorkerCreateBlob( + _ result: Result, + admission: WorkerCreateBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let workerResourceGeneration): + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerPendingResourceIDs.remove(admission.resourceID) != nil else { + return false + } + blobResources[admission.resourceID] = BlobResource( + memory: admission.payload.blobMemory, + size: admission.payload.size, + mapping: nil, + workerMapping: nil + ) + resourceEntries[admission.resourceID] = admission.entries + rendererWorkerResourceGenerations[admission.resourceID] = + workerResourceGeneration + registerResourceGeneration(admission.resourceID) + traceResourceEvent( + "worker-create-blob", + contextID: admission.contextID, + resourceID: admission.resourceID, + detail: "worker-generation=\(workerResourceGeneration) " + + "size=\(admission.payload.size)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerAttachBacking( + _ admission: WorkerAttachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + resourceEntries[admission.resourceID] == nil else { return false } + return rendererWorkerPendingBackingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.attachBacking( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerAttachBacking( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerAttachBacking( + _ result: Result, + admission: WorkerAttachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + rendererWorkerPendingBackingResourceIDs + .remove(admission.resourceID) != nil else { return false } + resourceEntries[admission.resourceID] = admission.entries + if var resource2D = resources2D[admission.resourceID] { + resource2D.backing = admission.entries + resources2D[admission.resourceID] = resource2D + } + traceResourceEvent( + "worker-attach-backing", + resourceID: admission.resourceID, + detail: "entries=\(admission.entries.count)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + // The worker may retain the guest-memory authority. Preserve the reservation and chain + // until reset so detach/unref cannot race unknown foreign backing ownership. + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerDetachBacking( + _ admission: WorkerDetachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + resourceEntries[admission.resourceID] != nil else { return false } + return rendererWorkerPendingBackingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.detachBacking( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerDetachBacking( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerDetachBacking( + _ result: Result, + admission: WorkerDetachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + rendererWorkerPendingBackingResourceIDs + .remove(admission.resourceID) != nil else { return false } + resourceEntries.removeValue(forKey: admission.resourceID) + if var resource2D = resources2D[admission.resourceID] { + resource2D.backing = [] + resources2D[admission.resourceID] = resource2D + } + traceResourceEvent( + "worker-detach-backing", + resourceID: admission.resourceID + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + // Foreign backing ownership may have changed. Keep both the local reservation and the + // guest descriptor owned until reset establishes a new renderer generation. + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerTransfer( + _ admission: WorkerTransferAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + commandLock.withLock({ + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration + }) else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerTransfer( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + do { + switch admission.direction { + case .toHost: + try rendererWorkerCandidate.transferToHost3D( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + contextID: admission.contextID, + payload: admission.payload, + deviceGeneration: generation, + completion: completion + ) + case .fromHost: + try rendererWorkerCandidate.transferFromHost3D( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + contextID: admission.contextID, + payload: admission.payload, + deviceGeneration: generation, + completion: completion + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerTransfer( + _ result: Result, + admission: WorkerTransferAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let current = commandLock.withLock { + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration + && fenceLock.withLock { lifecycleEpoch == generation } + } + guard current else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerUnref( + _ admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard rendererWorkerCandidate != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + + let retirementReserved = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + rendererWorkerResourceContextIDs[admission.resourceID]?.isEmpty != false, + !rendererWorkerPendingResourceIDs.contains(admission.resourceID), + !rendererWorkerPendingBackingResourceIDs.contains(admission.resourceID), + !rendererWorkerPendingMappingResourceIDs.contains(admission.resourceID), + lifecycleLock.withLock({ () -> Bool in + guard retiringResources[admission.resourceID] == nil else { return false } + retiringResources[admission.resourceID] = admission.displayResourceGeneration + return true + }) else { return false } + traceResourceEvent( + "worker-unref-release-begin", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.workerResourceGeneration)" + ) + return true + } + guard retirementReserved else { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + // Keep the guest-visible bindings intact while the exact display generation drains. The + // worker cannot unref with a live lease, but a proven pre-mutation rejection must leave + // local state recoverable; destructive binding removal therefore waits for worker ACK. + publishResourceRelease( + resourceID: admission.resourceID, + generation: admission.displayResourceGeneration + ) { [weak self, weak transport] in + guard let self, let transport else { return } + self.submitRendererWorkerUnrefAfterRelease( + admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + return nil + } + + private func submitRendererWorkerUnrefAfterRelease( + _ admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + guard let rendererWorkerCandidate, + commandLock.withLock({ + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration + && resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration + && lifecycleLock.withLock { + retiringResources[admission.resourceID] + == admission.displayResourceGeneration + } + && fenceLock.withLock { lifecycleEpoch == generation } + }) else { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + return + } + do { + try rendererWorkerCandidate.unrefResource( + resourceID: admission.resourceID, + resourceGeneration: admission.workerResourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnref( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + finishRendererWorkerUnref( + .failure(error), + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } catch { + finishRendererWorkerUnref( + .failure(.unexpectedWorkerReply), + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } + + private func finishRendererWorkerUnref( + _ result: Result, + admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let removedState = commandLock.withLock { () -> ( + cursor: Bool, + scanoutIDs: [UInt32] + )? in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + lifecycleLock.withLock({ + retiringResources[admission.resourceID] + == admission.displayResourceGeneration + }) else { return nil } + resources2D.removeValue(forKey: admission.resourceID) + resources3D.removeValue(forKey: admission.resourceID) + blobResources.removeValue(forKey: admission.resourceID) + resourceEntries.removeValue(forKey: admission.resourceID) + resourceUUIDs.removeValue(forKey: admission.resourceID) + resourceGenerations.removeValue(forKey: admission.resourceID) + rendererWorkerResourceGenerations.removeValue(forKey: admission.resourceID) + rendererWorkerResourceContextIDs.removeValue(forKey: admission.resourceID) + let removedCursor = cursorResourceID == admission.resourceID + if removedCursor { cursorResourceID = nil } + let scanoutIDs = scanouts.compactMap { scanoutID, binding in + binding.resourceID == admission.resourceID ? scanoutID : nil + } + scanouts = scanouts.filter { $0.value.resourceID != admission.resourceID } + hostVisibleMemory?.unmap(resourceID: admission.resourceID) + lifecycleLock.withLock { + if retiringResources[admission.resourceID] + == admission.displayResourceGeneration { + retiringResources.removeValue(forKey: admission.resourceID) + } + } + traceResourceEvent( + "worker-unref-complete", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.workerResourceGeneration)" + ) + return (removedCursor, scanoutIDs) + } + guard let removedState else { + recordTelemetry(.revokedCompletion) + return + } + if removedState.cursor { onCursorUpdate?(nil) } + for scanoutID in removedState.scanoutIDs.sorted() { + onScanoutDisabled?(scanoutID) + } + scheduleQuiescenceCleanupIfReady() + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + lifecycleLock.withLock { + if retiringResources[admission.resourceID] + == admission.displayResourceGeneration { + retiringResources.removeValue(forKey: admission.resourceID) + } + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerMapBlob( + _ admission: WorkerMapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + hostVisibleMemory != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.mapping == nil, + blob.workerMapping == nil, + !blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.mapBlob( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerMapBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + private func finishRendererWorkerMapBlob( + _ result: Result< + DoryRendererWorkerBlobMapping, + DoryRendererWorkerVirtioCommandLaneError + >, + admission: WorkerMapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let mapping): + let mapInfo = mapping.lease.mapInfo + let committed: Bool + do { + committed = try commandLock.withLock { () throws -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.size == mapping.lease.mappingByteCount, + rendererWorkerPendingMappingResourceIDs + .contains(admission.resourceID), + let hostVisibleMemory else { + try? mapping.sharedMemoryDescriptor.close() + return false + } + let authority = try DoryRendererWorkerBlobMappingAuthority(mapping) + try hostVisibleMemory.map( + resourceID: admission.resourceID, + hostPointer: authority.hostPointer, + offset: admission.hostVisibleOffset, + size: authority.lease.mappingByteCount + ) + guard var updated = blobResources[admission.resourceID] else { + hostVisibleMemory.unmap(resourceID: admission.resourceID) + return false + } + updated.workerMapping = authority + updated.guestMapped = true + blobResources[admission.resourceID] = updated + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + traceResourceEvent( + "worker-map-blob", + resourceID: admission.resourceID, + detail: "offset=\(admission.hostVisibleOffset) " + + "size=\(authority.lease.mappingByteCount)" + ) + return true + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + return + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + var response = responseHeader( + type: Response.okMapInfo, + request: admission.request + ) + response.appendLE(mapInfo) + response.appendLE(UInt32(0)) + publishRendererWorkerCompletion( + chain: chain, + response: response, + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerUnmapBlob( + _ admission: WorkerUnmapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + hostVisibleMemory != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.unmapBlob( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation, + beforeWorkerUnmap: { [weak self] in + self?.tearDownRendererWorkerBlobMapping( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) ?? false + } + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnmapBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + /// Called only from the worker's serialized lane. The local guest mapping and every VMM-held + /// descriptor/mmap authority are gone before the lane can encode or send UNMAP_BLOB. + private func tearDownRendererWorkerBlobMapping( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64 + ) -> Bool { + commandLock.withLock { + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == deviceGeneration + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[resourceID] == resourceGeneration, + rendererWorkerPendingMappingResourceIDs.contains(resourceID), + let hostVisibleMemory, + var blob = blobResources[resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + hostVisibleMemory.unmap(resourceID: resourceID) + blob.guestMapped = false + blob.workerMapping = nil + blobResources[resourceID] = blob + traceResourceEvent( + "worker-unmap-blob-local-teardown", + resourceID: resourceID, + detail: "worker-generation=\(resourceGeneration)" + ) + return true + } + } + + private func finishRendererWorkerUnmapBlob( + _ result: Result, + admission: WorkerUnmapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping == nil, + !blob.guestMapped, + rendererWorkerPendingMappingResourceIDs + .remove(admission.resourceID) != nil else { return false } + traceResourceEvent( + "worker-unmap-blob", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.resourceGeneration)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + // A pre-teardown rejection is recoverable. Once local authority was removed, even a + // proven worker rejection leaves a cross-process lifetime split and must wait for reset. + let localMappingIsIntact = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .remove(admission.resourceID) != nil + } + if localMappingIsIntact { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + return + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + /// Acquires one descriptor-backed scanout lease after the qualified guest's producer-complete + /// RESOURCE_FLUSH. Metal import and command-buffer submission remain asynchronous and off the + /// vCPU, but the guest chain is not acknowledged until every target has accepted that command + /// buffer. This prevents a following modeset from overtaking the frame's host ownership. + private func startRendererWorkerFlushScanout( + _ admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-candidate" + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let remainsCurrent = commandLock.withLock { + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration + && resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration + } + guard remainsCurrent else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "stale-resource-identity" + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + guard !admission.targets.isEmpty else { + if let fence = admission.fence, let pendingFence { + // A flush of a worker resource that is not currently scanned out has no host + // presentation stage. Its global fence is the complete ordered boundary. + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + return nil + } + return publishCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + queue: transport.queues[0] + ) + } + guard let surface = admission.surface else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-surface" + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + guard onMetalScanout != nil else { + // The accelerated candidate is never allowed to silently fall back through the legacy + // CGL/OpenGL presentation callback. Until the Metal consumer is connected, fail closed. + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-metal-consumer" + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + + do { + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "acquire-submitting" + ) + try rendererWorkerCandidate.acquireScanoutLease( + resourceID: admission.resourceID, + resourceGeneration: admission.workerResourceGeneration, + width: surface.width, + height: surface.height, + virglFormat: surface.format, + stride: surface.stride, + storageOffset: surface.offset, + deviceGeneration: generation + ) { [weak self, weak transport] disposition in + guard let self, let transport else { + if case .acquired(let scanout) = disposition { + scanout.discardTransport() + } + return + } + let dispositionStage = switch disposition { + case .acquired: "acquire-callback-acquired" + case .provenRejected: "acquire-callback-proven-rejected" + case .outcomeUnknown: "acquire-callback-outcome-unknown" + } + self.logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: dispositionStage + ) + self.finishRendererWorkerFlushScanout( + disposition, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "acquire-enqueued" + ) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lane-admission", + detail: String(describing: error) + ) + if error.provesNoRendererMutation { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lane-admission-unexpected", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerFlushScanout( + _ disposition: DoryRendererWorkerScanoutDisposition, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch disposition { + case .provenRejected(let error): + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "worker-proven-rejection", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .outcomeUnknown(let error): + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "worker-outcome-unknown", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + case .acquired(let scanout): + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "lease-acquired" + ) + let expectedPixelFormat: DoryRendererScanoutPixelFormat? = switch admission.surface?.format { + case 1: .bgra8Unorm + case 67: .rgba8Unorm + default: nil + } + guard let surface = admission.surface, + let expectedPixelFormat, + scanout.width == surface.width, + scanout.height == surface.height, + scanout.pixelFormat == expectedPixelFormat, + Self.rendererWorkerScanoutTransportMatches( + scanout, + surface: surface + ) else { + let expected = admission.surface.map { + "\($0.width)x\($0.height)/\($0.format)/\($0.stride)/\($0.offset)" + } ?? "missing" + let actual = Self.rendererWorkerScanoutDescription(scanout) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lease-layout-mismatch", + detail: "expected=\(expected) actual=\(actual)" + ) + scanout.discardTransport() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + let core: DoryRendererWorkerSharedScanoutCore + do { + core = try DoryRendererWorkerSharedScanoutCore( + scanout: scanout, + consumerCount: admission.targets.count, + release: { [weak self] scanout in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 0 + ) + }, + terminal: { [weak self] token in + self?.removeRendererWorkerScanout(token) + } + ) + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + let registered = rendererWorkerPresentationLock.withLock { () -> Bool in + let token = scanout.releaseToken + guard rendererWorkerPendingScanouts[token] == nil else { return false } + if let live = rendererWorkerLiveScanouts[token] { + guard live.value == nil else { return false } + rendererWorkerLiveScanouts.removeValue(forKey: token) + } + rendererWorkerPendingScanouts[token] = core + return true + } + guard registered else { + core.revoke() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "lease-registered" + ) + rendererWorkerPresentationQueue.async { [weak self] in + self?.logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "presentation-queue-entered" + ) + self?.publishRendererWorkerScanout( + core, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } + } + + private func publishRendererWorkerScanout( + _ core: DoryRendererWorkerSharedScanoutCore, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "publication-begin" + ) + let updates = commandLock.withLock { () -> [VirtioGPUMetalScanoutUpdate]? in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + admission.targets.allSatisfy({ target in + guard let binding = scanouts[target.scanoutID], + binding.resourceID == admission.resourceID, + binding.rect == target.sourceRect, + admission.surface == rendererWorkerScanoutSurface( + for: binding + ) else { return false } + return true + }) else { return nil } + + var presentations = [VirtioGPUMetalScanoutPresentation]() + presentations.reserveCapacity(admission.targets.count) + for index in admission.targets.indices { + guard let presentation = core.makePresentation(consumerID: UInt32(index)) else { + return nil + } + presentations.append(presentation) + } + + let movedToLive = rendererWorkerPresentationLock.withLock { () -> Bool in + let token = core.releaseToken + guard rendererWorkerPendingScanouts[token] === core else { return false } + rendererWorkerPendingScanouts.removeValue(forKey: token) + rendererWorkerLiveScanouts[token] = DoryRendererWorkerWeakScanoutCore(core) + return true + } + guard movedToLive else { return nil } + + let submissionGroup = DoryRendererWorkerHostSubmissionGroup( + count: admission.targets.count + ) { [weak self, weak transport] accepted in + guard let self, let transport else { return } + self.finishRendererWorkerHostSubmission( + accepted: accepted, + core: core, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + var updates = [VirtioGPUMetalScanoutUpdate]() + updates.reserveCapacity(admission.targets.count) + for (index, target) in admission.targets.enumerated() { + updates.append(VirtioGPUMetalScanoutUpdate( + scanoutID: target.scanoutID, + resourceID: admission.resourceID, + resourceGeneration: admission.displayResourceGeneration, + rendererResourceGeneration: admission.workerResourceGeneration, + presentation: presentations[index], + sourceRect: target.sourceRect, + dirtyRect: target.dirtyRect, + hostSubmission: VirtioGPUMetalScanoutHostSubmission { accepted in + submissionGroup.resolve(accepted: accepted) + } + )) + } + return updates + } + guard let updates, let onMetalScanout else { + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "host-publication-rejected" + ) + _ = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + } else { + core.revoke() + } + return + } + // The command claim serializes a following SET_SCANOUT until host submission resolves. + // Invoke the external mailbox only after the state snapshot and pending→live transition + // have released commandLock; a synchronous accept/reject may reenter queue completion. + for update in updates { + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "mailbox-submitting" + ) + onMetalScanout(update) + } + } + + private func finishRendererWorkerHostSubmission( + accepted: Bool, + core: DoryRendererWorkerSharedScanoutCore, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: accepted ? "host-submission-accepted" : "host-submission-rejected" + ) + guard accepted else { + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "metal-command-buffer-rejected" + ) + _ = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + } else { + core.revoke() + } + return + } + + if let fence = admission.fence { + guard let pendingFence else { + core.retireWithoutPresentation() + retainUnknownRendererWorkerChain(chain, generation: generation) + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + // RESOURCE_FLUSH is complete only once the Metal consumer has committed its host + // submission. Export the renderer's global fence strictly after that acceptance; the + // pending guest response remains owned until the descriptor signals. + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + return + } + + let completed = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + guard !completed else { return } + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + } else { + core.revoke() + } + } + + private func releaseRendererWorkerScanoutLease( + _ scanout: DoryRendererWorkerScanoutAuthority, + generation: UInt64, + attempt: Int + ) { + guard fenceLock.withLock({ lifecycleEpoch == generation }), + let rendererWorkerCandidate else { return } + do { + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { [weak self] result in + guard let self else { return } + switch result { + case .success: + break + case .failure(let error) + where error.provesNoRendererMutation && attempt == 0: + self.rendererWorkerPresentationQueue.async { [weak self] in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 1 + ) + } + case .failure(let error): + self.quarantineRendererWorkerGeneration(generation, error: error) + } + } + switch scanout { + case .sharedMemory(let value): + try rendererWorkerCandidate.releaseScanoutLease( + value.lease, + deviceGeneration: generation, + completion: completion + ) + case .sharedTexture(let value): + try rendererWorkerCandidate.releaseScanoutLease( + value.lease, + deviceGeneration: generation, + completion: completion + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation && attempt == 0 { + rendererWorkerPresentationQueue.async { [weak self] in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 1 + ) + } + } else { + quarantineRendererWorkerGeneration(generation, error: error) + } + } catch { + quarantineRendererWorkerGeneration(generation, error: .unexpectedWorkerReply) + } + } + + private func removeRendererWorkerScanout( + _ token: DoryRendererScanoutReleaseToken + ) { + rendererWorkerPresentationLock.withLock { + rendererWorkerPendingScanouts.removeValue(forKey: token) + rendererWorkerLiveScanouts.removeValue(forKey: token) + } + } + + private func revokeRendererWorkerScanouts() { + let cores = rendererWorkerPresentationLock.withLock { () -> [ + DoryRendererWorkerSharedScanoutCore + ] in + var unique = [ObjectIdentifier: DoryRendererWorkerSharedScanoutCore]() + for core in rendererWorkerPendingScanouts.values { + unique[ObjectIdentifier(core)] = core + } + for weakCore in rendererWorkerLiveScanouts.values { + if let core = weakCore.value { unique[ObjectIdentifier(core)] = core } + } + rendererWorkerPendingScanouts.removeAll(keepingCapacity: false) + rendererWorkerLiveScanouts.removeAll(keepingCapacity: false) + return Array(unique.values) + } + for core in cores { core.revoke() } + } + + private func retainUnknownRendererWorkerChain( + _ chain: VirtqueueChain, + generation: UInt64 + ) { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + + private func quarantineRendererWorkerGeneration( + _ generation: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + guard fenceLock.withLock({ lifecycleEpoch == generation }) else { return } + rendererWorkerCandidate?.revoke(deviceGeneration: generation) + revokeRendererWorkerScanouts() + rendererWorkerCandidateFailed(generation: generation, error: error) + } + + /// Transfers a previously snapshotted submit authority to the signed-worker lane and returns + /// immediately after bounded local admission. A nil result means the exact descriptor chain + /// is now owned by an asynchronous submit/fence completion path. + private func startRendererWorkerSubmit( + _ admission: WorkerSubmitAdmission, + chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + let successResponse = responseHeader( + type: Response.okNoData, + request: admission.requestHeader + ) + + if let fence = admission.fence { + guard let pending = reserveRendererWorkerFence( + fence, + response: successResponse, + chain: chain, + generation: generation, + transport: transport + ) else { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.submit3DThenCreateFence( + contextID: fence.contextID, + regions: admission.regions, + ringIndex: fence.ringIndex, + fenceID: fence.fenceID, + contextFence: fence.contextFence, + deviceGeneration: generation + ) { [weak self, weak transport] disposition in + guard let self, let transport else { return } + self.finishRendererWorkerFencedSubmit( + disposition, + fence: fence, + pending: pending, + requestHeader: admission.requestHeader, + transport: transport + ) + } + } catch { + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + return nil + } + + do { + try rendererWorkerCandidate.submit3D( + contextID: admission.requestHeader.leUInt32(at: 16), + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnfencedSubmit( + result, + chain: chain, + generation: generation, + requestHeader: admission.requestHeader, + transport: transport + ) + } + } catch { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + return nil + } + + private func reserveRendererWorkerFence( + _ fence: FenceRequest, + response: [UInt8], + chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> PendingFence? { + return fenceLock.withLock { + let fenceIDIsUnique = !pendingFences.values.joined().contains { + $0.fenceID == fence.fenceID && $0.epoch == generation + } && !uncertainFences.contains { + $0.fenceID == fence.fenceID && $0.epoch == generation + } + guard lifecycleEpoch == generation, + (!fence.contextFence || fence.contextID != 0), + fence.contextFence || ( + fence.key == FenceKey(contextID: 0, ringIndex: 0) + && fence.ringIndex == 0 + ), + fence.fenceID != 0, + fenceIDIsUnique, + !fenceAdmissionBlockedUntilDeviceReset, + pendingFenceCount < maximumPendingFences, + response.count <= maximumPendingFenceResponseBytes, + pendingFenceResponseBytes + <= maximumPendingFenceResponseBytes - response.count, + lastTransport == nil || lastTransport === transport else { + return nil + } + let token = nextFenceToken + nextFenceToken &+= 1 + if nextFenceToken == 0 { nextFenceToken = 1 } + let pending = PendingFence( + token: token, + fenceID: fence.fenceID, + epoch: generation, + response: response, + chain: chain, + createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + timeoutReported: false + ) + pendingFences[fence.key, default: []].append(pending) + pendingFenceCount += 1 + pendingFenceResponseBytes += response.count + lastTransport = transport + return pending + } + } + + /// Completes the second stage of an ordinary fenced control command. The mutation has already + /// been authenticated by the worker; only an exported global fence may release the guest + /// response. Any failure from this point is therefore outcome-unknown for the compound command. + private func startRendererWorkerGlobalFenceAfterMutation( + _ fence: FenceRequest, + pending: PendingFence, + claim: RendererWorkerControlCommandClaim, + generation: UInt64, + transport: VirtioMMIOTransport + ) { + guard let rendererWorkerCandidate else { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + return + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + switch result { + case .success: + self.fenceLock.withLock { + self.fenceCount = Self.saturatingAdd(self.fenceCount, 1) + } + let released = transport.withQueueLock { + self.fenceLock.withLock { self.lifecycleEpoch == generation } + && self.completeRendererWorkerControlCommand(claim: claim) + } + if released { self.scheduleRendererWorkerQueueResume(transport) } + case .failure(let error): + _ = self.removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: true + ) + self.quarantineRendererWorkerGeneration(generation, error: error) + } + } + do { + try rendererWorkerCandidate.createGlobalFence( + fenceID: fence.fenceID, + deviceGeneration: generation, + completion: completion + ) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + quarantineRendererWorkerGeneration(generation, error: error) + } catch { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + quarantineRendererWorkerGeneration(generation, error: .unexpectedWorkerReply) + } + } + + private func abandonRendererWorkerMutationFence( + _ fence: FenceRequest?, + pending: PendingFence?, + outcomeUnknown: Bool + ) { + guard let fence, let pending else { return } + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: outcomeUnknown + ) + } + + @discardableResult + private func removeRendererWorkerFence( + _ fence: FenceRequest, + token: UInt64, + makeUncertain: Bool + ) -> PendingFence? { + fenceLock.withLock { + guard var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == token }) else { + return nil + } + let removed = waiting.remove(at: index) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + if makeUncertain { + uncertainFences.append(removed) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } else { + pendingFenceCount = max(0, pendingFenceCount - 1) + pendingFenceResponseBytes = max( + 0, + pendingFenceResponseBytes - removed.response.count + ) + if pendingFenceCount == 0 { lastTransport = nil } + } + return removed + } + } + + private func finishRendererWorkerFencedSubmit( + _ disposition: DoryRendererWorkerVirtioSubmissionDisposition, + fence: FenceRequest, + pending: PendingFence, + requestHeader: [UInt8], transport: VirtioMMIOTransport ) { - let updated = VirtioGPUScanoutSize(width: width, height: height) - displayLock.lock() - let index = Int(scanoutID) - let changed = scanoutSizes.indices.contains(index) && scanoutSizes[index] != updated - if changed { - scanoutSizes[index] = updated - pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY + switch disposition { + case .fenceArmed: + // The pending chain remains owned until the completion descriptor becomes readable. + fenceLock.withLock { + fenceCount = Self.saturatingAdd(fenceCount, 1) + } + scheduleRendererWorkerQueueResume(transport) + return + case .provenRejected: + guard removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: false + ) != nil else { return } + publishRendererWorkerCompletion( + chain: pending.chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: requestHeader + ), + generation: pending.epoch, + transport: transport + ) + case .outcomeUnknown: + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: true + ) } - displayLock.unlock() - if changed { transport.notifyConfigChange() } } - /// Source-compatible primary-scanout resize bridge. - public func updateScanoutSize( - width: UInt32, - height: UInt32, + private func finishRendererWorkerUnfencedSubmit( + _ result: Result, + chain: VirtqueueChain, + generation: UInt64, + requestHeader: [UInt8], transport: VirtioMMIOTransport ) { - updateScanoutSize( - scanoutID: 0, - width: width, - height: height, - transport: transport - ) + switch result { + case .success: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: requestHeader), + generation: generation, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: requestHeader + ), + generation: generation, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } } - public func writeConfig(offset: UInt64, value: UInt64, width: Int) { - guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return } - var cleared: UInt32 = 0 - for byte in 0..> UInt64(byte * 8)) & 0xFF) << UInt32((position - 4) * 8) + @discardableResult + private func publishRendererWorkerCompletion( + chain: VirtqueueChain, + response: [UInt8], + generation: UInt64, + controlClaim: RendererWorkerControlCommandClaim? = nil, + transport: VirtioMMIOTransport + ) -> Bool { + let shouldResume = transport.withQueueLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration else { + recordTelemetry(.revokedCompletion) + return false + } + switch publishCompletion( + chain: chain, + response: response, + queue: transport.queues[0] + ) { + case .published(let wantsInterrupt): + if let controlClaim { + // The queue lock excludes a new kick between used publication and release. + // Token equality excludes completions from older pipelined work in this epoch. + completeRendererWorkerControlCommand(claim: controlClaim) + } + if wantsInterrupt { transport.notifyUsed() } + return true + case .revoked: + return false + case .failed: + recordTelemetry(.undeliveredFenceCompletion) + return false + } } - displayLock.lock() - pendingDisplayEvents &= ~cleared - displayLock.unlock() + // A stale completion must not kick a replacement queue generation. Only a completion that + // published its used entry may continue draining descriptors already available behind it. + if shouldResume { + scheduleRendererWorkerQueueResume(transport) + } + return shouldResume } - public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - guard queue == 0 || queue == 1 else { return } - fenceLock.lock() - lastTransport = transport - fenceLock.unlock() - let virtqueue = transport.queues[queue] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let request = chain.readBytes() - commandLock.lock() - let response = process( - request: request, - cursorQueue: queue == 1, - transport: transport - ) - commandLock.unlock() - if queue == 0, deferForFence(request: request, response: response, chain: chain) { - continue + private func scheduleRendererWorkerQueueResume(_ transport: VirtioMMIOTransport) { + rendererWorkerResumeQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + transport.withQueueLock { + self.handleKick(queue: 0, transport: transport) } - let written = chain.writeBytes(response) - let wants = (try? virtqueue.push(chain, written: written)) ?? false - interrupt = interrupt || wants - } - if interrupt { - transport.notifyUsed() } } - /// Holds a successfully processed, fenced command's descriptor until the renderer signals the - /// fence. Returns false (respond immediately) for unfenced commands, errors — whose response - /// still carries the fence id, which the guest treats as the signal — and fence-registration - /// failures, so a broken fence path degrades to the old eager completion instead of hanging. - private func deferForFence(request: [UInt8], response: [UInt8], chain: VirtqueueChain) -> Bool { - guard let renderer, request.count >= 24, response.count >= 4 else { return false } - let flags = request.leUInt32(at: 4) - guard flags & HeaderFlag.fence != 0 else { return false } - guard response.leUInt32(at: 0) & 0xFF00 == 0x1100 else { return false } - let fenceID = request.leUInt64(at: 8) - let contextID = request.leUInt32(at: 16) - let ringIndex = UInt32(request[20]) - let contextFence = flags & HeaderFlag.infoRingIndex != 0 - // ctx0 fences signal without context/ring coordinates, so they queue under (0, 0). - let key = contextFence - ? FenceKey(contextID: contextID, ringIndex: ringIndex) - : FenceKey(contextID: 0, ringIndex: 0) - // Publish the waiter before creating the renderer fence. With a fast host GPU (and in - // particular after a synchronizing readback), virglrenderer may invoke its completion - // callback from createFence itself or immediately on another thread. Registering after - // createFence loses that edge forever and stalls the guest compositor on its first frame. - fenceLock.lock() - pendingFences[key, default: []].append(PendingFence( - fenceID: fenceID, - response: response, - chain: chain, - createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, - timeoutReported: false - )) - fenceLock.unlock() - do { - try renderer.createFence( - contextID: contextID, - ringIndex: ringIndex, - fenceID: fenceID, - contextFence: contextFence - ) - } catch { - fenceLock.lock() - if var waiting = pendingFences[key] { - waiting.removeAll { $0.fenceID == fenceID } + private func rendererWorkerCandidateFailed( + generation: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + let affected = fenceLock.withLock { () -> Int in + guard lifecycleEpoch == generation else { return 0 } + var count = 0 + for key in Array(pendingFences.keys) { + guard var waiting = pendingFences[key] else { continue } + let uncertain = waiting.filter { $0.epoch == generation } + waiting.removeAll { $0.epoch == generation } pendingFences[key] = waiting.isEmpty ? nil : waiting + uncertainFences.append(contentsOf: uncertain) + count += uncertain.count } - fenceRegistrationFailureCount = Self.saturatingAdd( - fenceRegistrationFailureCount, - 1 + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked( + .rendererCommandUncertainty, + count: UInt64(max(1, count)) ) - recordRendererFailureWhileLocked(error) - fenceLock.unlock() - return false - } - fenceLock.withLock { - fenceCount = Self.saturatingAdd(fenceCount, 1) + return count } - return true + failRendererLifecycle( + .commandOutcomeUnknown( + operation: "renderer-worker", + detail: String(describing: error) + ), + epoch: generation + ) + FileHandle.standardError.write(Data(( + "dory-gpu: renderer worker generation \(generation) failed; " + + "retained \(affected) uncertain fenced chains: \(error)\n" + ).utf8)) + onRendererWorkerFailure?( + "generation \(generation) failed: \(String(describing: error))" + ) } public var statistics: VirtioGPUStatistics { - fenceLock.withLock { + let worker = rendererWorkerCandidate?.snapshot() + let snapshots = rendererWorkerMetricsLock.withLock { + rendererWorkerSnapshotMetrics + } + let softwareCopies = softwareScanoutMetricsLock.withLock { + softwareScanoutCopiedBytes + } + return fenceLock.withLock { let now = DispatchTime.now().uptimeNanoseconds var newlyTimedOut: UInt64 = 0 var hasTimedOutPendingFence = false @@ -691,6 +7123,18 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } pendingFences[key] = waiting } + for index in uncertainFences.indices { + if !uncertainFences[index].timeoutReported { + let started = uncertainFences[index].createdAtMonotonicNanoseconds + let age = now >= started ? now - started : 0 + if age >= fenceTimeoutNanoseconds { + uncertainFences[index].timeoutReported = true + newlyTimedOut = Self.saturatingAdd(newlyTimedOut, 1) + } + } + hasTimedOutPendingFence = hasTimedOutPendingFence + || uncertainFences[index].timeoutReported + } fenceTimeoutCount = Self.saturatingAdd(fenceTimeoutCount, newlyTimedOut) return VirtioGPUStatistics( fences: fenceCount, @@ -698,7 +7142,39 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi fenceTimeouts: fenceTimeoutCount, hasTimedOutPendingFence: hasTimedOutPendingFence, rendererDeviceLosses: rendererDeviceLossCount, - hasLostRendererDevice: rendererDeviceLossLatched + hasLostRendererDevice: rendererDeviceLossLatched, + queuePendingReadFailures: queuePendingReadFailureCount, + queuePopFailures: queuePopFailureCount, + invalidDescriptorChains: invalidDescriptorChainCount, + oversizedRequests: oversizedRequestCount, + insufficientResponseCapacity: insufficientResponseCapacityCount, + queuePushFailures: queuePushFailureCount, + revokedCompletions: revokedCompletionCount, + undeliveredFenceCompletions: undeliveredFenceCompletionCount, + responseWriteFailures: responseWriteFailureCount, + fenceAdmissionRejections: fenceAdmissionRejectionCount, + queueRevokedFences: queueRevokedFenceCount, + resetRevokedFences: resetRevokedFenceCount, + rendererCommandUncertainties: rendererCommandUncertaintyCount, + revokedUncertainRendererCommands: revokedUncertainRendererCommandCount, + rendererWorkerSnapshotCount: snapshots.count, + rendererWorkerSnapshotBytes: snapshots.bytes, + rendererWorkerSnapshotNanoseconds: snapshots.nanoseconds, + rendererWorkerMaximumSnapshotNanoseconds: snapshots.maximumNanoseconds, + rendererWorkerQueuedCommands: worker?.queuedCommands ?? 0, + rendererWorkerMaximumQueuedCommands: + worker?.maximumObservedQueuedCommands ?? 0, + rendererWorkerRejectedAdmissions: worker?.rejectedAdmissions ?? 0, + rendererWorkerCompletedControlCommands: + worker?.completedControlCommands ?? 0, + rendererWorkerCompletedResourceCommands: + worker?.completedResourceCommands ?? 0, + rendererWorkerCompletedSubmissions: worker?.completedSubmissions ?? 0, + rendererWorkerArmedFences: worker?.armedFences ?? 0, + rendererWorkerCompletedFences: worker?.completedFences ?? 0, + // The accelerated presentation contract has no copied-frame operation. + rendererWorkerScanoutCopyBytes: 0, + softwareScanoutCopiedBytes: softwareCopies ) } } @@ -708,45 +7184,520 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return overflow ? UInt64.max : sum } - /// Renderer-thread entry: completes every pending descriptor on the signaled timeline whose - /// fence id is covered (fences signal in creation order within a ring). - private func fenceSignaled(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { + private func recordAdmissionRejection(_ rejection: QueueAdmissionRejection) { + switch rejection { + case .invalidDescriptorLayout: + recordTelemetry(.invalidDescriptorChain) + case .oversizedRequest: + recordTelemetry(.oversizedRequest) + case .insufficientResponseCapacity: + recordTelemetry(.insufficientResponseCapacity) + } + } + + private func recordTelemetry(_ event: TelemetryEvent, count: UInt64 = 1) { + fenceLock.withLock { recordTelemetryWhileLocked(event, count: count) } + } + + private func recordTelemetryWhileLocked( + _ event: TelemetryEvent, + count: UInt64 = 1 + ) { + switch event { + case .queuePendingReadFailure: + queuePendingReadFailureCount = Self.saturatingAdd( + queuePendingReadFailureCount, + count + ) + case .queuePopFailure: + queuePopFailureCount = Self.saturatingAdd(queuePopFailureCount, count) + case .invalidDescriptorChain: + invalidDescriptorChainCount = Self.saturatingAdd( + invalidDescriptorChainCount, + count + ) + case .oversizedRequest: + oversizedRequestCount = Self.saturatingAdd(oversizedRequestCount, count) + case .insufficientResponseCapacity: + insufficientResponseCapacityCount = Self.saturatingAdd( + insufficientResponseCapacityCount, + count + ) + case .queuePushFailure: + queuePushFailureCount = Self.saturatingAdd(queuePushFailureCount, count) + case .revokedCompletion: + revokedCompletionCount = Self.saturatingAdd(revokedCompletionCount, count) + case .undeliveredFenceCompletion: + undeliveredFenceCompletionCount = Self.saturatingAdd( + undeliveredFenceCompletionCount, + count + ) + case .responseWriteFailure: + responseWriteFailureCount = Self.saturatingAdd( + responseWriteFailureCount, + count + ) + case .fenceAdmissionRejection: + fenceAdmissionRejectionCount = Self.saturatingAdd( + fenceAdmissionRejectionCount, + count + ) + case .queueRevokedFence: + queueRevokedFenceCount = Self.saturatingAdd(queueRevokedFenceCount, count) + case .resetRevokedFence: + resetRevokedFenceCount = Self.saturatingAdd(resetRevokedFenceCount, count) + case .rendererCommandUncertainty: + rendererCommandUncertaintyCount = Self.saturatingAdd( + rendererCommandUncertaintyCount, + count + ) + case .revokedUncertainRendererCommand: + revokedUncertainRendererCommandCount = Self.saturatingAdd( + revokedUncertainRendererCommandCount, + count + ) + } + } + + private func publishCompletion( + chain: VirtqueueChain, + response: [UInt8]?, + queue: Virtqueue + ) -> QueueCompletionOutcome { + if let response { + let wroteResponse = chain.withLeaseHeld { access -> Bool in + guard access.writableByteCount >= response.count else { return false } + return access.writeBytes(response) == response.count + } + guard let wroteResponse else { + recordTelemetry(.revokedCompletion) + return .revoked + } + guard wroteResponse else { + recordTelemetry(.responseWriteFailure) + return .failed + } + } + do { + switch try queue.pushOutcome(chain, written: response?.count ?? 0) { + case .published(let wantsInterrupt): + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + recordTelemetry(.revokedCompletion) + return .revoked + } + } catch { + recordTelemetry(.queuePushFailure) + return .failed + } + } + + /// Renderer-thread entry: completes the signaled fence and every earlier admission on its + /// timeline. Registration order is the authority: guest 64-bit ids may wrap or be + /// non-monotonic, and the global renderer callback uses an unrelated 32-bit host token. + private func fenceSignaled( + generation: UInt64, + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) { let key = FenceKey(contextID: contextID, ringIndex: ringIndex) fenceLock.lock() var completed = [PendingFence]() if var waiting = pendingFences[key] { - // The legacy ctx0 API carries 32-bit ids on the callback; compare within that width. - let signaled: (PendingFence) -> Bool = key == FenceKey(contextID: 0, ringIndex: 0) - ? { UInt32(truncatingIfNeeded: $0.fenceID) <= UInt32(truncatingIfNeeded: fenceID) } - : { $0.fenceID <= fenceID } - completed = waiting.filter(signaled) - waiting.removeAll(where: signaled) + if let target = waiting.firstIndex(where: { + $0.epoch == generation && $0.fenceID == fenceID + }) { + var survivors = [PendingFence]() + survivors.reserveCapacity(waiting.count) + for (index, pending) in waiting.enumerated() { + if index <= target && pending.epoch == generation { + completed.append(pending) + } else { + survivors.append(pending) + } + } + waiting = survivors + } pendingFences[key] = waiting.isEmpty ? nil : waiting } let transport = lastTransport + pendingFenceCount = max(0, pendingFenceCount - completed.count) + let completedResponseBytes = completed.reduce(into: 0) { total, pending in + total += pending.response.count + } + pendingFenceResponseBytes = max( + 0, + pendingFenceResponseBytes - completedResponseBytes + ) + if transport == nil, !completed.isEmpty { + recordTelemetryWhileLocked( + .revokedCompletion, + count: UInt64(completed.count) + ) + } fenceLock.unlock() guard !completed.isEmpty, let transport else { return } transport.withQueueLock { + // Reset is serialized by this same transport lock. Recheck the lifecycle only after + // acquiring it: a callback may have removed its waiter, then blocked here while reset + // cleared and reconfigured the queue. + let lifecycle = fenceLock.withLock { (lifecycleEpoch, lastTransport === transport) } + guard lifecycle.1 else { + recordTelemetry(.revokedCompletion, count: UInt64(completed.count)) + return + } + let staleCount = completed.lazy.filter { $0.epoch != lifecycle.0 }.count + if staleCount > 0 { + recordTelemetry(.revokedCompletion, count: UInt64(staleCount)) + } + completed.removeAll { $0.epoch != lifecycle.0 } + guard !completed.isEmpty else { return } var interrupt = false - for pending in completed { - let written = pending.chain.writeBytes(pending.response) - let wants = (try? transport.queues[0].push(pending.chain, written: written)) ?? false - interrupt = interrupt || wants + completionLoop: for (index, pending) in completed.enumerated() { + switch publishCompletion( + chain: pending.chain, + response: pending.response, + queue: transport.queues[0] + ) { + case .published(let wants): + interrupt = interrupt || wants + case .revoked: + let remaining = completed.count - index - 1 + if remaining > 0 { + recordTelemetry(.revokedCompletion, count: UInt64(remaining)) + } + break completionLoop + case .failed: + recordTelemetry( + .undeliveredFenceCompletion, + count: UInt64(completed.count - index) + ) + break completionLoop + } } if interrupt { transport.notifyUsed() } } + fenceLock.withLock { + if pendingFenceCount == 0, lastTransport === transport { + lastTransport = nil + } + } + } + + private func registerResourceGeneration(_ resourceID: UInt32) { + resourceGenerations[resourceID] = nextResourceGeneration + nextResourceGeneration &+= 1 + if nextResourceGeneration == 0 { nextResourceGeneration = 1 } + } + + public var rendererLifecycleHealth: VirtioGPURendererLifecycleHealth { + lifecycleLock.withLock { rendererLifecycleHealthState } + } + + private func isResourceRetiring(_ resourceID: UInt32) -> Bool { + lifecycleLock.withLock { retiringResources[resourceID] != nil } + } + + private func canAdmitResource(_ resourceID: UInt32) -> Bool { + guard resources2D[resourceID] == nil, + resources3D[resourceID] == nil, + blobResources[resourceID] == nil, + !rendererWorkerPendingResourceIDs.contains(resourceID) else { return false } + return lifecycleLock.withLock { + retiringResources[resourceID] == nil + && resources2D.count + resources3D.count + blobResources.count + + retiringResources.count < maximumTrackedResources + } + } + + private func beginResourceRetirement( + resourceID: UInt32, + generation: UInt64, + requiresBlobUnmap: Bool, + rendererGeneration: UInt64 + ) { + let inserted = lifecycleLock.withLock { () -> Bool in + guard retiringResources[resourceID] == nil else { return false } + retiringResources[resourceID] = generation + return true + } + guard inserted else { + FileHandle.standardError.write(Data( + "dory-gpu: duplicate renderer retirement resource=\(resourceID) generation=\(generation)\n".utf8 + )) + return + } + + publishResourceRelease( + resourceID: resourceID, + generation: generation + ) { [self] in + rendererRetirementQueue.async { [self] in + do { + if requiresBlobUnmap, rendererExecutor != nil { + _ = try executeRendererCommand( + .unmapBlob(resourceID: resourceID), + generation: rendererGeneration, + purpose: .retirement + ) + } + if rendererExecutor != nil { + _ = try executeRendererCommand( + .unrefResource(resourceID: resourceID), + generation: rendererGeneration, + purpose: .retirement + ) + } + lifecycleLock.withLock { + if retiringResources[resourceID] == generation { + retiringResources.removeValue(forKey: resourceID) + } + } + scheduleQuiescenceCleanupIfReady() + } catch { + let fault = VirtioGPURendererHealthFault.resourceRetirementFailed( + resourceID: resourceID, + generation: generation, + detail: String(describing: error) + ) + failRendererLifecycle(fault) + FileHandle.standardError.write(Data(( + "dory-gpu: renderer retirement failed resource=\(resourceID) " + + "generation=\(generation): \(error)\n" + ).utf8)) + } + } + } + } + + private func scheduleQuiescenceCleanupIfReady() { + let epoch: UInt64? = lifecycleLock.withLock { + guard var active = activeQuiescence, + !active.cleanupScheduled, + active.awaitingReleaseAcknowledgements.isEmpty, + active.priorRetirements.allSatisfy({ key in + retiringResources[key.resourceID] != key.generation + }) else { return nil } + active.cleanupScheduled = true + activeQuiescence = active + return active.receipt.epoch + } + guard let epoch else { return } + rendererRetirementQueue.async { [self] in + finishQuiescence(epoch: epoch) + } + } + + private func acknowledgeQuiescenceRelease( + _ key: ResourceRetirementKey, + epoch: UInt64 + ) { + lifecycleLock.withLock { + guard var active = activeQuiescence, active.receipt.epoch == epoch else { return } + active.awaitingReleaseAcknowledgements.remove(key) + activeQuiescence = active + } + scheduleQuiescenceCleanupIfReady() + } + + private func finishQuiescence(epoch: UInt64) { + guard let active = lifecycleLock.withLock({ + activeQuiescence?.receipt.epoch == epoch ? activeQuiescence : nil + }) else { return } + + var firstFault: VirtioGPURendererHealthFault? + for resource in active.rendererResources.sorted(by: { + ($0.key.resourceID, $0.key.generation) < ($1.key.resourceID, $1.key.generation) + }) { + do { + if resource.requiresBlobUnmap, rendererExecutor != nil { + _ = try executeRendererCommand( + .unmapBlob(resourceID: resource.key.resourceID), + generation: active.rendererGeneration, + purpose: .retirement + ) + } + if rendererExecutor != nil { + _ = try executeRendererCommand( + .unrefResource(resourceID: resource.key.resourceID), + generation: active.rendererGeneration, + purpose: .retirement + ) + } + lifecycleLock.withLock { + if retiringResources[resource.key.resourceID] == resource.key.generation { + retiringResources.removeValue(forKey: resource.key.resourceID) + } + } + } catch { + if firstFault == nil { + firstFault = .resourceRetirementFailed( + resourceID: resource.key.resourceID, + generation: resource.key.generation, + detail: String(describing: error) + ) + } + } + } + if let firstFault { + failRendererLifecycle(firstFault, epoch: epoch) + return + } + + if rendererExecutor != nil { + do { + let reset = try executeRendererCommand( + .resetAfterDeviceQuiesce(successorGeneration: epoch), + generation: active.rendererGeneration, + purpose: .retirement + ) + guard case .reset(let result) = reset else { + preconditionFailure("renderer reset returned an invalid executor payload") + } + if case .requiresRecreation(let detail) = result { + failRendererLifecycle(.resetRequiresRecreation(detail), epoch: epoch) + return + } + } catch let signal as RendererCommandOutcomeUnknownSignal { + failRendererLifecycle( + .resetFailed(signal.uncertainty.detail), + epoch: epoch + ) + return + } catch { + failRendererLifecycle(.resetFailed(String(describing: error)), epoch: epoch) + return + } + } + + let workerCannotResumeAfterReset = active.receipt.reason == .deviceReset + && rendererWorkerCandidate != nil + && !active.workerReboundForPristineDeviceReset + if active.receipt.reason == .deviceReset, !workerCannotResumeAfterReset { + fenceLock.withLock { + // resetAfterDeviceQuiesce() is the renderer-owned barrier that makes every old + // callback permanently unreachable. Only this boundary can safely reopen fenced + // admission after a QueueReady revocation or fence-registration uncertainty. + fenceAdmissionBlockedUntilDeviceReset = false + } + } + + let receipt: VirtioGPUQuiescence? = lifecycleLock.withLock { + guard activeQuiescence?.receipt.epoch == epoch else { return nil } + let receipt = activeQuiescence?.receipt + activeQuiescence = nil + let workerIsReady = active.receipt.reason == .deviceReset + && active.workerReboundForPristineDeviceReset + rendererLifecycleHealthState = rendererExecutor != nil || workerIsReady + ? .ready(epoch: epoch) + : .notConfigured + acceptingGuestCommands = active.receipt.reason == .deviceReset + && !workerCannotResumeAfterReset + return receipt + } + receipt?.complete(.completed) + if workerCannotResumeAfterReset { + onRendererWorkerFailure?( + "virtio-gpu device reset revoked the one-shot renderer generation" + ) + } + } + + private func failRendererLifecycle( + _ fault: VirtioGPURendererHealthFault, + epoch requestedEpoch: UInt64? = nil + ) { + let currentEpoch = fenceLock.withLock { lifecycleEpoch } + let epoch = requestedEpoch ?? currentEpoch + let receipt: VirtioGPUQuiescence? = lifecycleLock.withLock { + rendererLifecycleHealthState = !rendererAuthorityIsConfigured + ? .notConfigured + : .failed(epoch: epoch, fault: fault) + acceptingGuestCommands = false + guard activeQuiescence?.receipt.epoch == epoch else { return nil } + let receipt = activeQuiescence?.receipt + activeQuiescence = nil + return receipt + } + receipt?.complete(.failed(fault)) + } + + private func publishResourceRelease( + resourceID: UInt32, + generation: UInt64, + completion: @escaping @Sendable () -> Void + ) { + let release = VirtioGPUScanoutResourceRelease( + resourceID: resourceID, + resourceGeneration: generation, + scanoutCount: scanoutCount, + completion: completion + ) + if let onScanoutResourceReleased { + onScanoutResourceReleased(release) + if scanoutCount == 0 { release.acknowledgeAll() } + } else { + release.acknowledgeAll() + } + } + + private func process( + request: [UInt8], + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) -> CommandProcessingOutcome { + do { + return .response(try processResponse( + request: request, + cursorQueue: cursorQueue, + transport: transport + )) + } catch let signal as RendererCommandOutcomeUnknownSignal { + return .outcomeUnknown(signal.uncertainty) + } catch { + logCommandFailure(request: request, error: error) + return .response(responseHeader( + type: Response.errorInvalidParameter, + request: request + )) + } } - private func process(request: [UInt8], cursorQueue: Bool, transport: VirtioMMIOTransport) -> [UInt8] { + private func processResponse( + request: [UInt8], + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) throws -> [UInt8] { guard request.count >= 4 else { return responseHeader(type: Response.errorUnspecified, request: request) } let command = request.leUInt32(at: 0) + let acceptsCommand = lifecycleLock.withLock { acceptingGuestCommands } + if !acceptsCommand, + command != Command.getDisplayInfo, + command != Command.getCapsetInfo, + command != Command.getCapset { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } if cursorQueue { - return cursorCommand(command, request: request) + return try cursorCommand(command, request: request) + } + + if rendererAuthorityIsConfigured, + command != Command.getDisplayInfo, + command != Command.getCapsetInfo, + command != Command.getCapset, + !rendererLifecycleIsReady { + let health = rendererLifecycleHealth + let error = VMError.invalidConfiguration( + "virtio-gpu renderer lifecycle is not ready: \(health)" + ) + logCommandFailure(request: request, error: error) + return responseHeader(type: Response.errorInvalidParameter, request: request) } switch command { @@ -766,20 +7717,19 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } return response case Command.resourceCreate2D: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 40) let resourceID = request.leUInt32(at: 24) let format = request.leUInt32(at: 28) let width = request.leUInt32(at: 32) let height = request.leUInt32(at: 36) guard resourceID != 0, - resources2D[resourceID] == nil, - resources3D[resourceID] == nil, - blobResources[resourceID] == nil, + canAdmitResource(resourceID), width > 0, height > 0, width <= 16_384, height <= 16_384, Self.isSupportedScanoutFormat(format), - UInt64(width) * UInt64(height) * 4 <= UInt64(Int.max) else { + let copiedByteCount = Self.rgbaByteCount(width: width, height: height), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes else { throw VMError.invalidConfiguration("invalid virtio-gpu 2D resource") } // VirGL clients may use a RESOURCE_CREATE_2D allocation as a texture after @@ -787,8 +7737,8 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi // lockstep with the device-side scanout table, matching QEMU's virgl path. Without // this registration virgl_renderer_ctx_attach_resource silently ignores the ID and // a later sampler-view creation fails as an illegal resource. - if let renderer { - try renderer.createResource3D( + if rendererExecutor != nil { + _ = try executeRendererCommand(.createResource3D( VirtioGPUResourceCreate3D( resourceID: resourceID, target: 2, @@ -803,17 +7753,18 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi flags: 1 ), entries: [] - ) + )) } resources2D[resourceID] = Resource2D( format: format, width: width, height: height ) + registerResourceGeneration(resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.setScanout: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 48) let scanoutID = request.leUInt32(at: 40) let resourceID = request.leUInt32(at: 44) @@ -825,23 +7776,77 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi // reject the legitimate disable before inspecting the resource id. if resourceID == 0 { let previous = scanouts.removeValue(forKey: scanoutID) - if let previous, case .blob = previous.source, let renderer { - try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) } + onScanoutDisabled?(scanoutID) return responseHeader(type: Response.okNoData, request: request) } let rect = try scanoutRect(from: request, at: 24) let source: ScanoutBinding.Source + var preparedPresentation: VirtioGPUTexturePresentation? + defer { + // Until ownership is transferred to `publishBoundScanout`, every error path + // must explicitly retire the producer-completion authority. + preparedPresentation?.discardWithoutPresentation() + } let resourceWidth: UInt32 let resourceHeight: UInt32 if let resource = resources2D[resourceID] { source = .resource2D resourceWidth = resource.width resourceHeight = resource.height + if rendererWorkerCandidate != nil { + guard rendererWorkerResourceGenerations[resourceID] != nil, + Self.rendererWorkerScanoutFormat(resource.format) != nil, + onMetalScanout != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer worker scanout is unavailable" + ) + } + } } else if let resource = resources3D[resourceID] { source = .resource3D resourceWidth = resource.width resourceHeight = resource.height + if rendererWorkerCandidate != nil { + guard rendererWorkerResourceGenerations[resourceID] != nil, + Self.rendererWorkerScanoutFormat(resource.format) != nil, + onMetalScanout != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer worker scanout is unavailable" + ) + } + } else { + guard rendererExecutor != nil, + let generation = resourceGenerations[resourceID] else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer is unavailable" + ) + } + let result = try executeRendererCommand(.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: generation + )) + guard case .scanoutPresentation(let presentation) = result else { + preconditionFailure( + "make-scanout-presentation returned an invalid payload" + ) + } + let texture = presentation.texture + guard presentation.resourceID == resourceID, + presentation.resourceGeneration == generation, + texture.textureID != 0, + texture.format == resource.format, + texture.width == resource.width, + texture.height == resource.height else { + presentation.discardWithoutPresentation() + throw VMError.invalidConfiguration( + "virtio-gpu renderer returned inconsistent scanout texture authority" + ) + } + preparedPresentation = presentation + } } else { throw VMError.invalidConfiguration("invalid virtio-gpu scanout resource") } @@ -856,39 +7861,29 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi ), forKey: scanoutID ) - if let previous, case .blob = previous.source, let renderer { - try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) } - // A compositor may render and flush its next buffer before atomically binding it - // to the KMS scanout (notably when returning from a fullscreen/direct-scanout - // surface). A host display that only observes flushes would then keep showing the - // old application buffer forever. Publish the newly bound resource immediately; - // renderer readback waits for outstanding producer work before copying it. - let frames: [VirtioGPUScanoutFrame] - switch source { - case .resource2D: - guard let resource = resources2D[resourceID] else { - throw VMError.invalidConfiguration("virtio-gpu 2D scanout resource disappeared") - } - frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect) - case .resource3D: - guard let renderer, let resource = resources3D[resourceID] else { - throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") - } - frames = try rendererScanoutFrames( - resourceID: resourceID, - resource: resource, - dirtyRect: rect, - renderer: renderer - ) - case .blob: - frames = [] + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] != nil { + // Worker-backed 2D and VirGL2 resources become visible only after + // RESOURCE_FLUSH acquires a producer-complete native Metal texture lease. + // SET_SCANOUT records geometry but must retain the last completed frame until + // that flush arrives. Treating this transient binding change like the + // resource_id=0 disable above makes compositors that rotate scanout buffers + // flash the host clear color between every SET_SCANOUT and RESOURCE_FLUSH. + return responseHeader(type: Response.okNoData, request: request) } - for frame in frames { onScanoutFrame?(frame) } + let presentationToPublish = preparedPresentation + preparedPresentation = nil + try publishBoundScanout( + scanoutID: scanoutID, + preparedPresentation: presentationToPublish + ) return responseHeader(type: Response.okNoData, request: request) } case Command.transferToHost2D: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 56) let rect = try scanoutRect(from: request, at: 24) let offset = request.leUInt64(at: 40) @@ -903,7 +7898,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return responseHeader(type: Response.okNoData, request: request) } case Command.resourceFlush: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 48) let rect = try scanoutRect(from: request, at: 24) let resourceID = request.leUInt32(at: 40) @@ -911,36 +7906,28 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") } - let frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect) - for frame in frames { - onScanoutFrame?(frame) - } - return responseHeader(type: Response.okNoData, request: request) - } - if let resource = resources3D[resourceID] { + try publishScanoutFrames( + resourceID: resourceID, + resource: resource, + dirtyRect: rect + ) + } else if let resource = resources3D[resourceID] { guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource flush") } - guard let renderer else { + guard rendererExecutor != nil else { throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") } - let frames = try rendererScanoutFrames( + try publishRendererDamage(resourceID: resourceID, dirtyRect: rect) + } else { + guard let blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") + } + try publishBlobScanoutFrames( resourceID: resourceID, - resource: resource, - dirtyRect: rect, - renderer: renderer + blob: blob, + dirtyRect: rect ) - for frame in frames { - onScanoutFrame?(frame) - } - return responseHeader(type: Response.okNoData, request: request) - } - guard let blob = blobResources[resourceID] else { - throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") - } - let frames = try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect) - for frame in frames { - onScanoutFrame?(frame) } return responseHeader(type: Response.okNoData, request: request) } @@ -949,7 +7936,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi case Command.getCapset: return capsetResponse(request: request) case Command.resourceAssignUUID: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) guard resources2D[resourceID] != nil @@ -964,57 +7951,103 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return response } case Command.ctxCreate: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { guard request.count >= 96 else { throw VMError.unexpectedExit("short virtio-gpu ctx_create") } let contextID = request.leUInt32(at: 16) let nameLength = min(Int(request.leUInt32(at: 24)), 64) let contextInit = request.leUInt32(at: 28) let nameBytes = request[32..<(32 + nameLength)].prefix { $0 != 0 } let name = String(decoding: nameBytes, as: UTF8.self) - try renderer.createContext( + _ = try executeRendererCommand(.createContext( id: contextID, - flags: Self.rendererContextFlags(requested: contextInit, capsets: renderer.capsets), + flags: Self.rendererContextFlags(requested: contextInit, capsets: capsets), name: name - ) + )) + createdContextIDs.insert(contextID) traceResourceEvent("context-create", contextID: contextID, detail: "name=\(name) init=0x\(String(contextInit, radix: 16))") return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDestroy: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { let contextID = request.leUInt32(at: 16) - try renderer.destroyContext(id: contextID) + _ = try executeRendererCommand(.destroyContext(id: contextID)) + createdContextIDs.remove(contextID) traceResourceEvent("context-destroy", contextID: contextID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxAttachResource: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let contextID = request.leUInt32(at: 16) let resourceID = request.leUInt32(at: 24) traceResourceEvent("attach-begin", contextID: contextID, resourceID: resourceID) - try renderer.attachResource(contextID: contextID, resourceID: resourceID) + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] == nil { + guard createdContextIDs.contains(contextID), + resources2D[resourceID] != nil else { + throw VMError.invalidConfiguration( + "invalid local virtio-gpu context attachment" + ) + } + traceResourceEvent( + "attach-end", + contextID: contextID, + resourceID: resourceID, + detail: "authority=local-2d" + ) + return responseHeader(type: Response.okNoData, request: request) + } + _ = try executeRendererCommand(.attachResource( + contextID: contextID, + resourceID: resourceID + )) traceResourceEvent("attach-end", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDetachResource: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let contextID = request.leUInt32(at: 16) let resourceID = request.leUInt32(at: 24) - try renderer.detachResource(contextID: contextID, resourceID: resourceID) + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] == nil { + guard createdContextIDs.contains(contextID), + resources2D[resourceID] != nil else { + throw VMError.invalidConfiguration( + "invalid local virtio-gpu context detachment" + ) + } + traceResourceEvent( + "detach", + contextID: contextID, + resourceID: resourceID, + detail: "authority=local-2d" + ) + return responseHeader(type: Response.okNoData, request: request) + } + _ = try executeRendererCommand(.detachResource( + contextID: contextID, + resourceID: resourceID + )) traceResourceEvent("detach", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.submit3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let size = Int(request.leUInt32(at: 24)) - guard request.count >= 32 + size else { throw VMError.unexpectedExit("short virtio-gpu submit_3d") } - try renderer.submit3D(contextID: request.leUInt32(at: 16), command: Array(request[32..<(32 + size)])) + let (end, overflow) = 32.addingReportingOverflow(size) + guard !overflow, end <= request.count else { + throw VMError.unexpectedExit("short virtio-gpu submit_3d") + } + _ = try executeRendererCommand(.submit3D( + contextID: request.leUInt32(at: 16), + command: Array(request[32.. 0, size <= UInt64(Int.max), + size <= maximumRendererReferencedBytes, resourceAvailable else { throw VMError.invalidConfiguration( "invalid virtio-gpu blob resource " @@ -1104,7 +8140,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi ) } let entries = try memoryEntries(from: request, count: request.leUInt32(at: 36), offset: 56, transport: transport) - try renderer.createBlob( + _ = try executeRendererCommand(.createBlob( resourceID: resourceID, contextID: request.leUInt32(at: 16), blobMemory: request.leUInt32(at: 28), @@ -1112,13 +8148,15 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi blobID: request.leUInt64(at: 40), size: size, entries: entries - ) + )) resourceEntries[resourceID] = entries blobResources[resourceID] = BlobResource( memory: request.leUInt32(at: 28), size: size, - mapping: nil + mapping: nil, + workerMapping: nil ) + registerResourceGeneration(resourceID) traceResourceEvent( "create-blob", contextID: request.leUInt32(at: 16), @@ -1128,7 +8166,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return responseHeader(type: Response.okNoData, request: request) } case Command.resourceMapBlob: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 40) let resourceID = request.leUInt32(at: 24) let offset = request.leUInt64(at: 32) @@ -1138,7 +8176,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } // virglrenderer owns the blob's host memory; ask it to map, then expose that pointer to // the guest by hv_vm_mapping it into the window at the requested offset. - let mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer) + let mapping = try ensureBlobMapping(resourceID: resourceID) try hostVisibleMemory.map( resourceID: resourceID, hostPointer: mapping.hostPointer, @@ -1155,7 +8193,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return response } case Command.resourceUnmapBlob: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) hostVisibleMemory?.unmap(resourceID: resourceID) @@ -1164,59 +8202,62 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } blob.guestMapped = false blobResources[resourceID] = blob - try releaseBlobMappingIfUnused(resourceID: resourceID, renderer: renderer) + try releaseBlobMappingIfUnused(resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceUnref: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) + let generation = resourceGenerations[resourceID] ?? 0 + let removed2D = resources2D.removeValue(forKey: resourceID) + let removed3D = resources3D.removeValue(forKey: resourceID) + let removedBlob = blobResources.removeValue(forKey: resourceID) + guard removed2D != nil || removed3D != nil || removedBlob != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu unref of unknown or retiring resource \(resourceID)" + ) + } if cursorResourceID == resourceID { cursorResourceID = nil onCursorUpdate?(nil) } resourceUUIDs.removeValue(forKey: resourceID) traceResourceEvent("unref-begin", contextID: request.leUInt32(at: 16), resourceID: resourceID) - if resources2D.removeValue(forKey: resourceID) != nil { - if let renderer { - try renderer.unrefResource(resourceID: resourceID) - } - scanouts = scanouts.filter { $0.value.resourceID != resourceID } - onScanoutResourceReleased?(resourceID) - traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=2d") - return responseHeader(type: Response.okNoData, request: request) - } hostVisibleMemory?.unmap(resourceID: resourceID) - try rendererCommandThrowing { renderer in - if blobResources[resourceID]?.mapping?.requiresRendererUnmap == true { - try renderer.unmapBlob(resourceID: resourceID) - } - try renderer.unrefResource(resourceID: resourceID) - } scanouts = scanouts.filter { $0.value.resourceID != resourceID } resourceEntries.removeValue(forKey: resourceID) - resources3D.removeValue(forKey: resourceID) - published3DResources.remove(resourceID) - rendererReadbackBuffers.removeValue(forKey: resourceID) - blobResources.removeValue(forKey: resourceID) - onScanoutResourceReleased?(resourceID) - traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=renderer") + resourceGenerations.removeValue(forKey: resourceID) + beginResourceRetirement( + resourceID: resourceID, + generation: generation, + requiresBlobUnmap: removedBlob?.mapping?.requiresRendererUnmap == true, + rendererGeneration: fenceLock.withLock { lifecycleEpoch } + ) + let kind = removed2D != nil ? "2d" : (removed3D != nil ? "3d" : "blob") + traceResourceEvent("unref-guest-retired", resourceID: resourceID, detail: "kind=\(kind)") return responseHeader(type: Response.okNoData, request: request) } case Command.transferToHost3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { let transfer = try transfer3D(from: request) - try renderer.transferToHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? []) + _ = try executeRendererCommand(.transferToHost3D( + transfer, + entries: resourceEntries[transfer.resourceID] ?? [] + )) return responseHeader(type: Response.okNoData, request: request) } case Command.transferFromHost3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { let transfer = try transfer3D(from: request) - try renderer.transferFromHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? []) + _ = try executeRendererCommand(.transferFromHost3D( + transfer, + entries: resourceEntries[transfer.resourceID] ?? [] + )) return responseHeader(type: Response.okNoData, request: request) } case Command.setScanoutBlob: - return scanoutCommand(request: request) { + return try scanoutCommand(request: request) { try requireLength(request, 96) let scanoutID = request.leUInt32(at: 40) let resourceID = request.leUInt32(at: 44) @@ -1230,18 +8271,24 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } if resourceID == 0 { let previous = scanouts.removeValue(forKey: scanoutID) - if let previous, case .blob = previous.source, let renderer { - try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) } + onScanoutDisabled?(scanoutID) return responseHeader(type: Response.okNoData, request: request) } let rect = try scanoutRect(from: request, at: 24) + let workerBacked = rendererWorkerResourceGenerations[resourceID] != nil guard let blob = blobResources[resourceID], width > 0, height > 0, width <= 16_384, height <= 16_384, Self.isSupportedScanoutFormat(format), + !workerBacked || Self.rendererWorkerScanoutFormat(format) != nil, + !workerBacked || onMetalScanout != nil, Self.contains(rect: rect, width: width, height: height), stride >= width * 4, + let copiedByteCount = Self.rgbaByteCount(width: width, height: height), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes, request.leUInt32(at: 68) == 0, request.leUInt32(at: 72) == 0, request.leUInt32(at: 76) == 0, @@ -1274,12 +8321,21 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi if let previous, previous.resourceID != resourceID, case .blob = previous.source, - let renderer { - try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer) + rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) } - for frame in try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect) { - onScanoutFrame?(frame) + if workerBacked { + // The producer is renderer-owned HOST3D SHM. SET_SCANOUT_BLOB establishes + // geometry only; RESOURCE_FLUSH acquires the exact worker layout plus its + // context-timeline fence before any Metal consumer can observe the bytes. + onScanoutDisabled?(scanoutID) + return responseHeader(type: Response.okNoData, request: request) } + try publishBlobScanoutFrames( + resourceID: resourceID, + blob: blob, + dirtyRect: rect + ) return responseHeader(type: Response.okNoData, request: request) } default: @@ -1289,6 +8345,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private func capsetInfoResponse(request: [UInt8]) -> [UInt8] { guard request.count >= 32 else { return responseHeader(type: Response.errorInvalidParameter, request: request) } + guard rendererCapabilitiesAreAdvertised else { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } let index = Int(request.leUInt32(at: 24)) guard index < capsets.count else { return responseHeader(type: Response.errorInvalidParameter, request: request) } let capset = capsets[index] @@ -1302,6 +8361,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private func capsetResponse(request: [UInt8]) -> [UInt8] { guard request.count >= 32 else { return responseHeader(type: Response.errorInvalidParameter, request: request) } + guard rendererCapabilitiesAreAdvertised else { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } let id = request.leUInt32(at: 24) let version = request.leUInt32(at: 28) guard let capset = capsets.first(where: { $0.id == id }), @@ -1313,35 +8375,70 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return response } - private func rendererCommand( - request: [UInt8], - _ body: (VirtioGPURenderer) throws -> [UInt8] - ) -> [UInt8] { - guard let renderer else { return responseHeader(type: Response.errorInvalidParameter, request: request) } - do { - return try body(renderer) - } catch { - recordRendererFailure(error) - logCommandFailure(request: request, error: error) - return responseHeader(type: Response.errorInvalidParameter, request: request) + private func executeRendererCommand( + _ command: VirtioGPURendererCommand, + generation explicitGeneration: UInt64? = nil, + purpose: VirtioGPURendererCommandPurpose = .guest + ) throws -> VirtioGPURendererCommandValue { + guard let rendererExecutor else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + let generation = explicitGeneration ?? fenceLock.withLock { lifecycleEpoch } + switch rendererExecutor.execute( + command, + generation: generation, + purpose: purpose + ) { + case .success(let value): + return value + case .rejected(let rejection): + throw VirtioGPURendererCommandRejected(String(describing: rejection)) + case .outcomeUnknown(let uncertainty): + recordTelemetry(.rendererCommandUncertainty) + if let failure = uncertainty.runtimeFailure { + recordRendererFailure(failure, generation: uncertainty.generation) + } + failRendererLifecycle( + .commandOutcomeUnknown( + operation: uncertainty.operation, + detail: uncertainty.detail + ), + epoch: uncertainty.generation + ) + throw RendererCommandOutcomeUnknownSignal(uncertainty: uncertainty) } } - private func rendererCommandThrowing( - _ body: (VirtioGPURenderer) throws -> Void - ) throws { - guard let renderer else { - throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + private var rendererLifecycleIsReady: Bool { + lifecycleLock.withLock { + if case .ready = rendererLifecycleHealthState { return true } + return !rendererAuthorityIsConfigured + } + } + + private var rendererAuthorityIsConfigured: Bool { + rendererExecutor != nil || rendererWorkerCandidate != nil + } + + /// Feature and capset discovery is guest-visible state, so it must close with command + /// admission. In particular, successful shutdown and a renderer that requires recreation are + /// both quarantined rather than masquerading as a newly usable renderer epoch. + private var rendererCapabilitiesAreAdvertised: Bool { + lifecycleLock.withLock { + guard acceptingGuestCommands else { return false } + if case .ready = rendererLifecycleHealthState { return true } + return false } - try body(renderer) } private func scanoutCommand( request: [UInt8], _ body: () throws -> [UInt8] - ) -> [UInt8] { + ) throws -> [UInt8] { do { return try body() + } catch let signal as RendererCommandOutcomeUnknownSignal { + throw signal } catch { recordRendererFailure(error) logCommandFailure(request: request, error: error) @@ -1349,7 +8446,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } } - private func recordRendererFailure(_ error: Error) { + private func recordRendererFailure( + _ error: Error, + generation: UInt64? = nil + ) { + if let generation, + fenceLock.withLock({ lifecycleEpoch != generation }) { + return + } fenceLock.withLock { recordRendererFailureWhileLocked(error) } } @@ -1383,6 +8487,42 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi )) } + /// A malformed compositor can repeat RESOURCE_FLUSH indefinitely. Keep the exact failure + /// boundary needed for physical qualification while emitting at most one line per resource + /// and stage for the current device generation. + private func logRendererWorkerScanoutFailure( + resourceID: UInt32, + stage: String, + detail: String = "" + ) { + let key = "\(resourceID):\(stage)" + let firstOccurrence = rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.insert(key).inserted + } + guard firstOccurrence else { return } + let suffix = detail.isEmpty ? "" : " detail=\(detail)" + FileHandle.standardError.write(Data( + "dory-gpu: worker scanout resource=\(resourceID) stage=\(stage)\(suffix)\n".utf8 + )) + } + + /// Physical qualification crosses the renderer lane, its presentation queue, AppKit, and + /// Metal. Emit each ownership handoff once per resource and device generation so a stalled + /// frame has an exact last-known stage without turning the production frame loop into logging. + private func logRendererWorkerScanoutProgress( + resourceID: UInt32, + stage: String + ) { + let key = "progress:\(resourceID):\(stage)" + let firstOccurrence = rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.insert(key).inserted + } + guard firstOccurrence else { return } + FileHandle.standardError.write(Data( + "dory-gpu: worker scanout progress resource=\(resourceID) stage=\(stage)\n".utf8 + )) + } + private func traceResourceEvent( _ event: String, contextID: UInt32 = 0, @@ -1447,6 +8587,106 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi [1, 2, 3, 4, 67, 68, 121, 134].contains(format) } + /// Alpha and X variants have identical byte/channel layout. The KMS wire format describes + /// whether scanout consumes the high byte, while Venus reports the Vulkan resource's alpha + /// format. Normalize only that semantic padding bit before authenticating the renderer-owned + /// allocation; every other virtio format remains outside the zero-copy Metal contract. + private static func rendererWorkerScanoutFormat(_ format: UInt32) -> UInt32? { + switch format { + case 1, 2: 1 + case 67, 68: 67 + default: nil + } + } + + /// Resolves the one renderer request layout accepted for a bound worker resource. VirGL2 + /// resource3D scanout is a tightly packed native texture; Venus HOST3D blob scanout retains + /// its authenticated linear layout. Admission and post-XPC publication both use this exact + /// projection so they cannot disagree about the surface being presented. + private func rendererWorkerScanoutSurface( + for binding: ScanoutBinding + ) -> WorkerScanoutSurface? { + switch binding.source { + case .resource2D: + guard rendererWorkerResourceGenerations[binding.resourceID] != nil, + let resource = resources2D[binding.resourceID], + let format = Self.rendererWorkerScanoutFormat(resource.format) else { + return nil + } + let (stride, overflow) = resource.width.multipliedReportingOverflow(by: 4) + guard !overflow else { return nil } + return WorkerScanoutSurface( + width: resource.width, + height: resource.height, + format: format, + stride: stride, + offset: 0 + ) + case .resource3D: + guard let resource = resources3D[binding.resourceID], + let format = Self.rendererWorkerScanoutFormat(resource.format) else { + return nil + } + let (stride, overflow) = resource.width.multipliedReportingOverflow(by: 4) + guard !overflow else { return nil } + return WorkerScanoutSurface( + width: resource.width, + height: resource.height, + format: format, + stride: stride, + offset: 0 + ) + case .blob(let format, let width, let height, let stride, let offset): + guard let rendererFormat = Self.rendererWorkerScanoutFormat(format) else { + return nil + } + return WorkerScanoutSurface( + width: width, + height: height, + format: rendererFormat, + stride: stride, + offset: offset + ) + } + } + + private static func rendererWorkerScanoutTransportMatches( + _ scanout: DoryRendererWorkerScanoutAuthority, + surface: WorkerScanoutSurface + ) -> Bool { + switch scanout { + case .sharedMemory(let value): + return value.lease.stride == surface.stride + && value.lease.storageOffset == UInt64(surface.offset) + case .sharedTexture: + let (expectedStride, overflow) = surface.width.multipliedReportingOverflow(by: 4) + return !overflow && surface.stride == expectedStride && surface.offset == 0 + } + } + + private static func rendererWorkerScanoutDescription( + _ scanout: DoryRendererWorkerScanoutAuthority + ) -> String { + switch scanout { + case .sharedMemory(let value): + return "shm/\(value.lease.width)x\(value.lease.height)/" + + "\(value.lease.pixelFormat.rawValue)/\(value.lease.stride)/" + + "\(value.lease.storageOffset)" + case .sharedTexture(let value): + return "metal/\(value.lease.width)x\(value.lease.height)/" + + "\(value.lease.pixelFormat.rawValue)" + } + } + + private static func rgbaByteCount(width: UInt32, height: UInt32) -> UInt64? { + let (pixels, pixelOverflow) = UInt64(width).multipliedReportingOverflow( + by: UInt64(height) + ) + guard !pixelOverflow else { return nil } + let (bytes, byteOverflow) = pixels.multipliedReportingOverflow(by: 4) + return byteOverflow ? nil : bytes + } + private static func scanoutByteRangeIsValid( width: UInt32, height: UInt32, @@ -1462,8 +8702,8 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi && finalPixel <= resourceSize - UInt64(offset) - finalRow } - private func cursorCommand(_ command: UInt32, request: [UInt8]) -> [UInt8] { - scanoutCommand(request: request) { + private func cursorCommand(_ command: UInt32, request: [UInt8]) throws -> [UInt8] { + try scanoutCommand(request: request) { guard command == Command.updateCursor || command == Command.moveCursor else { throw VMError.invalidConfiguration("unsupported virtio-gpu cursor command") } @@ -1473,11 +8713,6 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi throw VMError.invalidConfiguration("invalid virtio-gpu cursor scanout") } if command == Command.moveCursor { - if ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { - FileHandle.standardError.write(Data( - "dory-input: guest cursor move scanout=\(scanoutID) x=\(request.leUInt32(at: 28)) y=\(request.leUInt32(at: 32))\n".utf8 - )) - } return responseHeader(type: Response.okNoData, request: request) } @@ -1493,11 +8728,6 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi guard hotX < resource.width, hotY < resource.height else { throw VMError.invalidConfiguration("virtio-gpu cursor hotspot is outside the image") } - if ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { - FileHandle.standardError.write(Data( - "dory-input: guest cursor update scanout=\(scanoutID) x=\(request.leUInt32(at: 28)) y=\(request.leUInt32(at: 32)) size=\(resource.width)x\(resource.height) hotspot=\(hotX),\(hotY)\n".utf8 - )) - } cursorResourceID = resourceID onCursorUpdate?(VirtioGPUCursorUpdate( scanoutID: scanoutID, @@ -1528,7 +8758,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi ) } - guard let resource = resources3D[resourceID], let renderer else { + if rendererWorkerResourceGenerations[resourceID] != nil, + resources3D[resourceID] != nil { + // A private worker Metal texture is not CPU-readable authority. Until an explicit + // asynchronous worker readback contract exists, fail the cursor update instead of + // acknowledging a stale or fabricated local copy. + throw VMError.invalidConfiguration( + "virtio-gpu worker 3D cursor requires authenticated readback" + ) + } + + guard let resource = resources3D[resourceID], rendererExecutor != nil else { throw VMError.invalidConfiguration( "virtio-gpu cursor references an unknown resource" ) @@ -1548,7 +8788,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi guard let baseAddress = bytes.baseAddress else { throw VMError.invalidConfiguration("virtio-gpu 3D cursor has no storage") } - try renderer.transferFromHost3D( + _ = try executeRendererCommand(.transferFromHost3D( VirtioGPUTransfer3D( resourceID: resourceID, contextID: 0, @@ -1559,7 +8799,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi box: [0, 0, 0, resource.width, resource.height, 1] ), entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)] - ) + )) } return CursorResourceSnapshot( width: resource.width, @@ -1582,41 +8822,65 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } } + /// Cursor snapshots are explicitly bounded to 256×256 and require a complete image. Display + /// damage does not use this full-resource helper; it is extracted directly below. private func copiedBytes(for resource: Resource2D) throws -> Data { - let resourceByteCount = Int(UInt64(resource.width) * UInt64(resource.height) * 4) - var source = Data(capacity: resourceByteCount) - for entry in resource.backing where source.count < resourceByteCount { - let count = min(entry.length, resourceByteCount - source.count) - source.append(entry.pointer.assumingMemoryBound(to: UInt8.self), count: count) - } - guard source.count == resourceByteCount else { - throw VMError.invalidConfiguration("virtio-gpu 2D backing is incomplete") + guard let byteCount = Self.rgbaByteCount( + width: resource.width, + height: resource.height + ), byteCount <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("virtio-gpu 2D backing is too large") } - return source + return try copyBackingRange( + entries: resource.backing, + offset: 0, + count: Int(byteCount) + ) } - private func scanoutFrames( + /// Publishes one copied frame at a time. Returning an array here would keep every full-damage + /// scanout copy alive until the batch completed (up to 16 × the per-frame ceiling). + private func publishScanoutFrames( resourceID: UInt32, resource: Resource2D, dirtyRect: VirtioGPURect - ) throws -> [VirtioGPUScanoutFrame] { - let source = try copiedBytes(for: resource) - - let sourceStride = Int(resource.width) * 4 - var frames = [VirtioGPUScanoutFrame]() + ) throws { + guard let onScanoutFrame else { return } + guard let requiredResourceBytes = Self.rgbaByteCount( + width: resource.width, + height: resource.height + ), Self.backingCovers( + resource.backing, + byteCount: requiredResourceBytes + ) else { + throw VMError.invalidConfiguration("virtio-gpu 2D backing is incomplete") + } + let sourceStride = UInt64(resource.width) * 4 for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) where binding.resourceID == resourceID { guard case .resource2D = binding.source else { continue } guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue } let outputStride = Int(dirty.width) * 4 var pixels = Data(capacity: outputStride * Int(dirty.height)) - for row in 0.. Bool { + var total: UInt64 = 0 + for entry in entries { + guard entry.length >= 0 else { return false } + let (next, overflow) = total.addingReportingOverflow(UInt64(entry.length)) + guard !overflow else { return false } + total = next + if total >= byteCount { return true } + } + return byteCount == 0 + } + + /// Blob scanouts are likewise streamed so the renderer/guest backing plus one output frame is + /// the producer's maximum live copy, independent of the number of scanouts. + private func publishBlobScanoutFrames( resourceID: UInt32, blob: BlobResource, dirtyRect: VirtioGPURect - ) throws -> [VirtioGPUScanoutFrame] { + ) throws { let bindings = scanouts.sorted(by: { $0.key < $1.key }).compactMap { (scanoutID, binding) -> (UInt32, ScanoutBinding, UInt32, UInt32, UInt32, UInt32, UInt32)? in guard binding.resourceID == resourceID, @@ -1646,20 +8926,19 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } return (scanoutID, binding, format, width, height, stride, offset) } - guard !bindings.isEmpty else { return [] } + guard !bindings.isEmpty else { return } let guestEntries = blob.memory == 1 ? (resourceEntries[resourceID] ?? []) : [] let mapping: VirtioGPUBlobMapping? if guestEntries.isEmpty { - guard let renderer else { + guard rendererExecutor != nil else { throw VMError.invalidConfiguration("virtio-gpu blob scanout has no accessible backing") } - mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer) + mapping = try ensureBlobMapping(resourceID: resourceID) } else { mapping = nil } - var frames = [VirtioGPUScanoutFrame]() for (scanoutID, binding, format, width, height, stride, offset) in bindings { guard Self.contains(rect: dirtyRect, width: width, height: height) else { throw VMError.invalidConfiguration("virtio-gpu blob damage exceeds framebuffer") @@ -1690,9 +8969,10 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi )) } } - frames.append(VirtioGPUScanoutFrame( + onScanoutFrame?(VirtioGPUScanoutFrame( scanoutID: scanoutID, resourceID: resourceID, + resourceGeneration: resourceGenerations[resourceID] ?? 0, format: format, width: binding.rect.width, height: binding.rect.height, @@ -1706,95 +8986,151 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi bytes: pixels )) } - return frames } - /// VirGL render targets live in the host OpenGL context rather than guest RAM. Mutter rotates - /// multiple scanout resources and reports damage relative to each individual resource, so the - /// host keeps a Metal texture per resource. Publish the first image in full to initialize that - /// texture, then read and publish only the changed rectangle on subsequent flushes. - private func rendererScanoutFrames( - resourceID: UInt32, - resource: Resource3D, - dirtyRect: VirtioGPURect, - renderer: VirtioGPURenderer - ) throws -> [VirtioGPUScanoutFrame] { - let publishFullResource = !published3DResources.contains(resourceID) - var frames = [VirtioGPUScanoutFrame]() - for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) - where binding.resourceID == resourceID { - guard case .resource3D = binding.source else { continue } - guard let damaged = Self.intersection(dirtyRect, binding.rect) else { continue } - let readRect = publishFullResource ? binding.rect : damaged - let resourceStride = UInt64(resource.width) * 4 - let resourceByteCount = resourceStride * UInt64(resource.height) - let readbackOffset = UInt64(readRect.y) * resourceStride + UInt64(readRect.x) * 4 - let outputStride = UInt64(readRect.width) * 4 - let outputByteCount = outputStride * UInt64(readRect.height) - guard resourceStride <= UInt64(UInt32.max), - resourceByteCount <= UInt64(Int.max), - outputStride <= UInt64(UInt32.max), - outputByteCount <= UInt64(Int.max) else { - throw VMError.invalidConfiguration("virtio-gpu 3D scanout is too large") - } - var readback = rendererReadbackBuffers[resourceID] - ?? Data(count: Int(resourceByteCount)) - if readback.count != Int(resourceByteCount) { - readback = Data(count: Int(resourceByteCount)) - } - try readback.withUnsafeMutableBytes { bytes in - guard let baseAddress = bytes.baseAddress else { - throw VMError.invalidConfiguration("virtio-gpu 3D scanout has no storage") - } - try renderer.transferFromHost3D( - VirtioGPUTransfer3D( - resourceID: resourceID, - contextID: 0, - level: 0, - stride: UInt32(resourceStride), - layerStride: 0, - offset: readbackOffset, - box: [readRect.x, readRect.y, 0, readRect.width, readRect.height, 1] - ), - entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)] - ) - } - rendererReadbackBuffers[resourceID] = readback - - var pixels = Data(count: Int(outputByteCount)) - pixels.withUnsafeMutableBytes { destination in - readback.withUnsafeBytes { source in - guard let destinationBase = destination.baseAddress, - let sourceBase = source.baseAddress else { return } - let rowBytes = Int(outputStride) - for row in 0.. (UInt32, ScanoutBinding, VirtioGPURect)? in + guard binding.resourceID == resourceID, + case .resource3D = binding.source, + let damaged = Self.intersection(dirtyRect, binding.rect) else { + return nil + } + return (scanoutID, binding, damaged) + } + // Acquire every producer-completion authority before publishing any update. A renderer + // failure must leave all scanouts on their previous coherent frame, not partially advance a + // multi-display resource. + var updates = [VirtioGPUScanoutTextureUpdate]() + do { + for (scanoutID, binding, damaged) in targets { + let result = try executeRendererCommand(.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: generation + )) + guard case .scanoutPresentation(let presentation) = result else { + preconditionFailure("make-scanout-presentation returned an invalid payload") + } + guard presentation.resourceID == resourceID, + presentation.resourceGeneration == generation else { + presentation.discardWithoutPresentation() + throw VMError.invalidConfiguration( + "virtio-gpu renderer returned mismatched presentation identity" + ) + } + updates.append(VirtioGPUScanoutTextureUpdate( + scanoutID: scanoutID, + presentation: presentation, + sourceRect: binding.rect, + dirtyRect: VirtioGPURect( + x: damaged.x - binding.rect.x, + y: damaged.y - binding.rect.y, + width: damaged.width, + height: damaged.height + ) + )) + } + } catch { + // Multi-scanout publication is transactional. If acquisition N fails, retire every + // already-acquired authority and publish none of the new generation's damage. + for update in updates { + update.presentation.discardWithoutPresentation() + } + throw error + } + for update in updates { + publishTextureUpdate(update) + } + } + + private func publishTextureUpdate(_ update: VirtioGPUScanoutTextureUpdate) { + if let onScanoutTexture { + onScanoutTexture(update) + } else { + update.presentation.discardWithoutPresentation() } - if !frames.isEmpty { published3DResources.insert(resourceID) } - return frames } private func copyBackingRange( @@ -1831,14 +9167,16 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } private func ensureBlobMapping( - resourceID: UInt32, - renderer: VirtioGPURenderer + resourceID: UInt32 ) throws -> VirtioGPUBlobMapping { guard var blob = blobResources[resourceID] else { throw VMError.invalidConfiguration("virtio-gpu map of unknown blob") } if let mapping = blob.mapping { return mapping } - let mapping = try renderer.mapBlob(resourceID: resourceID) + let result = try executeRendererCommand(.mapBlob(resourceID: resourceID)) + guard case .blobMapping(let mapping) = result else { + preconditionFailure("map-blob returned an invalid executor payload") + } blob.mapping = mapping blobResources[resourceID] = blob return mapping @@ -1847,7 +9185,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi /// Linux creates an implicit default context for primary-node clients before userspace can issue /// VIRTGPU_CONTEXT_INIT. The standard default is VirGL2 when it is advertised; a renderer offering /// one capset has an unambiguous default. Resolving zero here also prevents the kernel from retrying - /// an unsupported legacy context on a Venus-only GPU. + /// an unsupported legacy context when only one non-VirGL capset is advertised. static func rendererContextFlags(requested: UInt32, capsets: [VirtioGPUCapset]) -> UInt32 { let requestedCapset = requested & 0xff if requestedCapset == 0 { @@ -1867,8 +9205,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } private func releaseBlobMappingIfUnused( - resourceID: UInt32, - renderer: VirtioGPURenderer + resourceID: UInt32 ) throws { guard var blob = blobResources[resourceID], let mapping = blob.mapping, @@ -1881,7 +9218,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return } if mapping.requiresRendererUnmap { - try renderer.unmapBlob(resourceID: resourceID) + _ = try executeRendererCommand(.unmapBlob(resourceID: resourceID)) } blob.mapping = nil blobResources[resourceID] = blob @@ -1894,7 +9231,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi transport: VirtioMMIOTransport ) throws -> [VirtioGPUMemoryEntry] { let total = Int(count) - guard request.count >= offset + total * 16 else { + guard total <= maximumRawMemoryEntries else { + throw VMError.invalidConfiguration( + "virtio-gpu raw memory entry limit exceeded: \(total) > \(maximumRawMemoryEntries)" + ) + } + let (entryBytes, multiplyOverflow) = total.multipliedReportingOverflow(by: 16) + let (end, addOverflow) = offset.addingReportingOverflow(entryBytes) + guard !multiplyOverflow, !addOverflow, offset >= 0, end <= request.count else { throw VMError.unexpectedExit("short virtio-gpu memory entry list") } var entries = [VirtioGPUMemoryEntry]() @@ -1905,9 +9249,61 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi let length = request.leUInt32(at: base + 8) guard length > 0 else { continue } let pointer = try transport.hostPointer(at: guestAddress, count: UInt64(length)) - entries.append(VirtioGPUMemoryEntry(pointer: pointer, length: Int(length))) + entries.append(VirtioGPUMemoryEntry( + pointer: pointer, + length: Int(length), + guestAddress: guestAddress + )) + } + return try Self.coalescedMemoryEntries( + entries, + maximumEntries: maximumMemoryEntries + ) + } + + /// Virtio-gpu guests commonly describe a single compositor buffer as one entry per guest page. + /// Guest RAM is one descriptor-backed object in Dory, so adjacent guest addresses whose host + /// pointers are also adjacent are the same renderer iovec and may be joined losslessly. Real + /// Linux shmem allocations are often physically discontiguous; those entries remain distinct + /// and retain their exact byte ordering up to the authenticated worker's explicit limit. + static func coalescedMemoryEntries( + _ entries: [VirtioGPUMemoryEntry], + maximumEntries: Int + ) throws -> [VirtioGPUMemoryEntry] { + let limit = max(1, maximumEntries) + var result = [VirtioGPUMemoryEntry]() + result.reserveCapacity(min(entries.count, limit)) + for entry in entries { + guard entry.length > 0, let guestAddress = entry.guestAddress else { + throw VMError.invalidConfiguration("virtio-gpu memory entry is invalid") + } + if var previous = result.last, + let previousGuestAddress = previous.guestAddress { + let (previousGuestEnd, addressOverflow) = previousGuestAddress + .addingReportingOverflow(UInt64(previous.length)) + if !addressOverflow, + previousGuestEnd == guestAddress, + previous.pointer.advanced(by: previous.length) == entry.pointer { + let (combinedLength, lengthOverflow) = previous.length + .addingReportingOverflow(entry.length) + guard !lengthOverflow else { + throw VMError.invalidConfiguration( + "virtio-gpu memory entry length overflow" + ) + } + previous.length = combinedLength + result[result.count - 1] = previous + continue + } + } + guard result.count < limit else { + throw VMError.invalidConfiguration( + "virtio-gpu normalized memory entry limit exceeded: more than \(limit)" + ) + } + result.append(entry) } - return entries + return result } private func transfer3D(from request: [UInt8]) throws -> VirtioGPUTransfer3D { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift index 56f0a6f1..b6236921 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift @@ -1,5 +1,80 @@ +import DoryFSWorkerContracts import Foundation +/// Event totals wrap modulo 2^64. Depth fields are current gauges; high-watermarks and maximum +/// publication latency retain the largest observation for this device lifetime. +public struct VirtioInputStatistics: Equatable, Sendable { + public var submittedFrames: UInt64 + public var publishedFrames: UInt64 + public var publishedEvents: UInt64 + public var coalescedMotionFrames: UInt64 + public var droppedFrames: UInt64 + public var rejectedFrames: UInt64 + public var stateReconciliationEvents: UInt64 + public var invalidEventBuffers: UInt64 + public var invalidStatusBuffers: UInt64 + public var statusEvents: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var pendingFrameSaturationEvents: UInt64 + public var pendingFrameDepth: UInt64 + public var pendingFrameHighWatermark: UInt64 + public var availableEventBufferDepth: UInt64 + public var availableEventBufferHighWatermark: UInt64 + public var eventQueueDepth: UInt64 + public var eventQueueHighWatermark: UInt64 + public var statusQueueDepth: UInt64 + public var statusQueueHighWatermark: UInt64 + public var publicationLatencyNanoseconds: UInt64 + public var maximumPublicationLatencyNanoseconds: UInt64 +} + +struct VirtioInputLimits: Equatable, Sendable { + static let production = VirtioInputLimits( + maximumEventsPerFrame: 64, + maximumPendingFrames: 256, + maximumChainsPerWorkerTurn: 64, + maximumPublishedEventsPerWorkerTurn: 64 + ) + + let maximumEventsPerFrame: Int + let maximumPendingFrames: Int + let maximumChainsPerWorkerTurn: Int + let maximumPublishedEventsPerWorkerTurn: Int + + init( + maximumEventsPerFrame: Int, + maximumPendingFrames: Int, + maximumChainsPerWorkerTurn: Int, + maximumPublishedEventsPerWorkerTurn: Int = 64 + ) { + precondition(maximumEventsPerFrame >= 2) + precondition(maximumPendingFrames > 0) + precondition(maximumChainsPerWorkerTurn > 0) + precondition(maximumChainsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + precondition(maximumPublishedEventsPerWorkerTurn >= maximumEventsPerFrame) + precondition(maximumPublishedEventsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumEventsPerFrame = maximumEventsPerFrame + self.maximumPendingFrames = maximumPendingFrames + self.maximumChainsPerWorkerTurn = maximumChainsPerWorkerTurn + self.maximumPublishedEventsPerWorkerTurn = maximumPublishedEventsPerWorkerTurn + } +} + +struct VirtioInputWorkerHooks: @unchecked Sendable { + var beforeWorkerTurn: (@Sendable () -> Void)? + var beforeEventPublication: (@Sendable () -> Void)? + + static let none = VirtioInputWorkerHooks( + beforeWorkerTurn: nil, + beforeEventPublication: nil + ) +} + public struct VirtioInputEvent: Sendable, Equatable { public var type: UInt16 public var code: UInt16 @@ -22,8 +97,10 @@ public struct VirtioInputEvent: Sendable, Equatable { } } -/// Converts AppKit's content-direction scroll deltas into Linux evdev wheel events while retaining -/// sub-tick movement. AppKit and evdev use opposite signs for the same visible scroll direction. +/// Converts AppKit scroll deltas into Linux evdev wheel events while retaining sub-tick movement. +/// `NSEvent.scrollingDelta*` already incorporates the host's natural-scrolling preference, and +/// Linux `REL_WHEEL*` uses the same sign for the resulting scroll gesture. Inverting here makes a +/// Linux desktop move opposite to the host gesture and also applies natural scrolling twice. public struct VirtioInputScrollAccumulator: Sendable { private var verticalRemainder: Double = 0 private var horizontalRemainder: Double = 0 @@ -36,32 +113,60 @@ public struct VirtioInputScrollAccumulator: Sendable { hasPreciseDeltas: Bool ) -> [VirtioInputEvent] { let scale: Double = hasPreciseDeltas ? 12 : 120 - let vertical = -verticalDelta * scale - let horizontal = -horizontalDelta * scale - verticalRemainder += vertical - horizontalRemainder += horizontal - - let verticalTicks = Int32(verticalRemainder / 120) - let horizontalTicks = Int32(horizontalRemainder / 120) - verticalRemainder -= Double(verticalTicks) * 120 - horizontalRemainder -= Double(horizontalTicks) * 120 - var result = [VirtioInputEvent]() - let verticalHighResolution = Int32(vertical.rounded()) - let horizontalHighResolution = Int32(horizontal.rounded()) - if verticalHighResolution != 0 { - result.append(VirtioInputEvent(type: 2, code: 11, value: verticalHighResolution)) - } - if verticalTicks != 0 { - result.append(VirtioInputEvent(type: 2, code: 8, value: verticalTicks)) + Self.appendScrollEvents( + delta: verticalDelta, + scale: scale, + remainder: &verticalRemainder, + highResolutionCode: 11, + discreteCode: 8, + to: &result + ) + Self.appendScrollEvents( + delta: horizontalDelta, + scale: scale, + remainder: &horizontalRemainder, + highResolutionCode: 12, + discreteCode: 6, + to: &result + ) + return result + } + + private static func appendScrollEvents( + delta: Double, + scale: Double, + remainder: inout Double, + highResolutionCode: UInt16, + discreteCode: UInt16, + to result: inout [VirtioInputEvent] + ) { + // AppKit values cross a UI/process boundary. NaN, infinity, or a finite value whose scale + // overflows must never poison the retained remainder or trap an integer conversion. + guard delta.isFinite else { return } + let raw = delta * scale + let integerLimit = Double(Int32.max) - 120 + let bounded: Double + if raw.isFinite { + bounded = min(integerLimit, max(-integerLimit, raw)) + } else { + bounded = raw.sign == .minus ? -integerLimit : integerLimit } - if horizontalHighResolution != 0 { - result.append(VirtioInputEvent(type: 2, code: 12, value: horizontalHighResolution)) + + remainder += bounded + let ticks = Int32(remainder / 120) + remainder -= Double(ticks) * 120 + let highResolution = Int32(bounded.rounded()) + if highResolution != 0 { + result.append(VirtioInputEvent( + type: 2, + code: highResolutionCode, + value: highResolution + )) } - if horizontalTicks != 0 { - result.append(VirtioInputEvent(type: 2, code: 6, value: horizontalTicks)) + if ticks != 0 { + result.append(VirtioInputEvent(type: 2, code: discreteCode, value: ticks)) } - return result } } @@ -113,6 +218,7 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 18 public let deviceFeatures: UInt64 = 0 public let queueCount = 2 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged private enum ConfigSelect { static let name: UInt8 = 0x01 @@ -131,24 +237,126 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { static let led: UInt8 = 17 } + private struct PendingFrame { + let events: [VirtioInputEvent] + let submittedAtNanoseconds: UInt64 + } + + private struct PublicationFrame { + let chains: [VirtqueueChain] + let events: [VirtioInputEvent] + let submittedAtNanoseconds: UInt64? + let isReconciliation: Bool + } + + private struct WorkerRequest { + let generation: UInt64 + let transport: VirtioMMIOTransport + } + + private enum WorkerDrainOutcome { + case drained + case more + case fault + case stale + } + private let lock = NSLock() private let profile: Profile + private let limits: VirtioInputLimits + private let workerHooks: VirtioInputWorkerHooks + private let monotonicNanoseconds: @Sendable () -> UInt64 + private let workerQueue = DispatchQueue( + label: "com.dory.virtio-input.worker", + qos: .userInitiated, + autoreleaseFrequency: .workItem + ) + private let workerQueueKey = DispatchSpecificKey() private weak var transport: VirtioMMIOTransport? + private var lifecycleGeneration: UInt64 = 1 + private var terminal = false + private var deviceIsReady = false + private var queueIsReady = [false, false] + private var requestedQueueMask: UInt8 = 0 + private var workerScheduled = false private var selectedConfig: UInt8 = 0 private var selectedSubconfig: UInt8 = 0 private var availableEventBuffers = [VirtqueueChain]() - private var pendingFrames = [[VirtioInputEvent]]() - private let maximumPendingFrames = 256 - private static let tracesPointerButtons = - ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" + private var pendingFrames = [PendingFrame]() + private var desiredPressedCodes = Set() + private var publishedPressedCodes = Set() + private var needsStateReconciliation = false + private var statisticsState = VirtioInputStatistics( + submittedFrames: 0, + publishedFrames: 0, + publishedEvents: 0, + coalescedMotionFrames: 0, + droppedFrames: 0, + rejectedFrames: 0, + stateReconciliationEvents: 0, + invalidEventBuffers: 0, + invalidStatusBuffers: 0, + statusEvents: 0, + queueFaults: 0, + boundedDrainStops: 0, + workerTurns: 0, + workerYields: 0, + coalescedWorkerRequests: 0, + revokedWorkerTurns: 0, + pendingFrameSaturationEvents: 0, + pendingFrameDepth: 0, + pendingFrameHighWatermark: 0, + availableEventBufferDepth: 0, + availableEventBufferHighWatermark: 0, + eventQueueDepth: 0, + eventQueueHighWatermark: 0, + statusQueueDepth: 0, + statusQueueHighWatermark: 0, + publicationLatencyNanoseconds: 0, + maximumPublicationLatencyNanoseconds: 0 + ) private let statusHandler: (@Sendable (VirtioInputEvent) -> Void)? - public init( + public convenience init( profile: Profile = .combinedCompatibility, statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil + ) { + self.init(profile: profile, limits: .production, statusHandler: statusHandler) + } + + init( + profile: Profile, + limits: VirtioInputLimits, + statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil, + workerHooks: VirtioInputWorkerHooks = .none, + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + } ) { self.profile = profile + self.limits = limits self.statusHandler = statusHandler + self.workerHooks = workerHooks + self.monotonicNanoseconds = monotonicNanoseconds + workerQueue.setSpecific(key: workerQueueKey, value: 1) + } + + deinit { + lock.lock() + terminal = true + advanceGenerationLocked() + transport = nil + deviceIsReady = false + queueIsReady = [false, false] + requestedQueueMask = 0 + workerScheduled = false + availableEventBuffers.removeAll() + pendingFrames.removeAll() + updateDepthGaugesLocked() + lock.unlock() + if DispatchQueue.getSpecific(key: workerQueueKey) == nil { + workerQueue.sync {} + } } public var configSpace: [UInt8] { @@ -210,67 +418,126 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { } public func deviceReady(transport: VirtioMMIOTransport) { + let request: WorkerRequest? lock.lock() + guard !terminal else { + lock.unlock() + return + } + advanceGenerationLocked() self.transport = transport + deviceIsReady = true + queueIsReady = transport.queues.map(\.ready) + requestedQueueMask = 0 + workerScheduled = false + request = pendingFrames.isEmpty ? nil : requestWorkerLocked(queueMask: 1) lock.unlock() + enqueueWorker(request) } public func deviceReset(transport: VirtioMMIOTransport) { lock.lock() + advanceGenerationLocked() self.transport = nil + deviceIsReady = false + queueIsReady = [false, false] + requestedQueueMask = 0 + workerScheduled = false availableEventBuffers.removeAll() pendingFrames.removeAll() + desiredPressedCodes.removeAll() + publishedPressedCodes.removeAll() + needsStateReconciliation = false + updateDepthGaugesLocked() + statisticsState.eventQueueDepth = 0 + statisticsState.statusQueueDepth = 0 lock.unlock() } public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { - guard queue == 0 else { return } + guard (0.. maximumPendingFrames { - trimPendingFramesToLimit() - } - let transport = self.transport - lock.unlock() - - if let transport { - transport.withQueueLock { - drainEventQueue(transport: transport) + // The callback is nonblocking and the backlog is bounded. Once saturated, preserve + // every already-admitted semantic frame in exact order and reject the newest frame. + // Pressed-state reconciliation repairs a rejected key/button transition when buffers + // return; scroll transitions are never silently reordered around older input. + statisticsState.pendingFrameSaturationEvents &+= 1 + statisticsState.droppedFrames &+= 1 + if complete.contains(where: { $0.type == UInt16(EventType.key) }) { + needsStateReconciliation = true } } + updateDepthGaugesLocked() + request = requestWorkerLocked(queueMask: 1) + lock.unlock() + enqueueWorker(request) } private static func isAbsolutePointerPositionFrame(_ events: [VirtioInputEvent]) -> Bool { @@ -282,73 +549,555 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { && events[2] == .synchronize } - /// Prefer discarding superseded pointer motion over input with semantic state, - /// such as a key or button transition. - private func trimPendingFramesToLimit() { - while pendingFrames.count > maximumPendingFrames { - if let index = pendingFrames.firstIndex(where: Self.isAbsolutePointerPositionFrame) { - pendingFrames.remove(at: index) - } else { - pendingFrames.removeFirst() + private static func isValidFrame(_ events: [VirtioInputEvent], profile: Profile) -> Bool { + guard events.last == .synchronize, + !events.dropLast().contains(.synchronize) else { return false } + return events.allSatisfy { event in + switch event.type { + case UInt16(EventType.synchronize): + return event == .synchronize + case UInt16(EventType.key): + let supportsCode: Bool + switch profile { + case .keyboard: + supportsCode = (1...255).contains(event.code) + case .absolutePointer: + supportsCode = (272...276).contains(event.code) + case .combinedCompatibility: + supportsCode = (1...255).contains(event.code) + || (272...276).contains(event.code) + } + return supportsCode && (0...2).contains(event.value) + case UInt16(EventType.relative): + guard profile != .keyboard else { return false } + return [UInt16(6), 8, 11, 12].contains(event.code) + case UInt16(EventType.absolute): + return profile != .keyboard + && (event.code == 0 || event.code == 1) + && (0...32_767).contains(event.value) + default: + return false } } } - private func drainEventQueue(transport: VirtioMMIOTransport) { - let queue = transport.queues[0] - var invalidBuffers = [VirtqueueChain]() - while let chain = (try? queue.pop()) ?? nil { - let writableCount = chain.writableSegments.reduce(0) { $0 + $1.length } - if writableCount >= 8 { - availableEventBuffers.append(chain) + private static func applyPressedState( + events: [VirtioInputEvent], + to state: inout Set + ) { + for event in events where event.type == UInt16(EventType.key) { + if event.value == 0 { + state.remove(event.code) } else { - invalidBuffers.append(chain) + state.insert(event.code) } } + } + + private func advanceGenerationLocked() { + lifecycleGeneration = lifecycleGeneration == UInt64.max ? 1 : lifecycleGeneration + 1 + } + private func readyQueueMaskLocked() -> UInt8 { + queueIsReady.enumerated().reduce(into: UInt8(0)) { mask, element in + if element.element { mask |= UInt8(1 << element.offset) } + } + } + + private func scheduleWorker(queueMask: UInt8, transport: VirtioMMIOTransport) { + let request: WorkerRequest? lock.lock() - var publications = [(VirtqueueChain, VirtioInputEvent)]() - while let frame = pendingFrames.first, availableEventBuffers.count >= frame.count { - pendingFrames.removeFirst() - for event in frame { - publications.append((availableEventBuffers.removeFirst(), event)) + guard self.transport === transport else { + lock.unlock() + return + } + request = requestWorkerLocked(queueMask: queueMask) + lock.unlock() + enqueueWorker(request) + } + + private func requestWorkerLocked(queueMask: UInt8) -> WorkerRequest? { + guard !terminal, deviceIsReady, let transport else { return nil } + let admittedMask = queueMask & readyQueueMaskLocked() + guard admittedMask != 0 else { return nil } + requestedQueueMask |= admittedMask + if workerScheduled { + statisticsState.coalescedWorkerRequests &+= 1 + return nil + } + workerScheduled = true + return WorkerRequest(generation: lifecycleGeneration, transport: transport) + } + + private func enqueueWorker(_ request: WorkerRequest?) { + guard let request else { return } + let generation = request.generation + let transport = request.transport + workerQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + self.runWorker(generation: generation, transport: transport) + } + } + + private func runWorker(generation: UInt64, transport: VirtioMMIOTransport) { + let queueMask: UInt8 + lock.lock() + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + return + } + queueMask = requestedQueueMask + requestedQueueMask = 0 + statisticsState.workerTurns &+= 1 + lock.unlock() + + workerHooks.beforeWorkerTurn?() + var continuationMask: UInt8 = 0 + if queueMask & 1 != 0 { + switch drainEventQueueTurn(generation: generation, transport: transport) { + case .more: + continuationMask |= 1 + case .stale: + recordRevokedWorkerTurn() + return + case .drained, .fault: + break + } + } + if queueMask & 2 != 0 { + switch drainStatusQueueTurn(generation: generation, transport: transport) { + case .more: + continuationMask |= 2 + case .stale: + recordRevokedWorkerTurn() + return + case .drained, .fault: + break } } + + let continuation: WorkerRequest? + lock.lock() + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + return + } + requestedQueueMask |= continuationMask + if requestedQueueMask == 0 { + workerScheduled = false + continuation = nil + } else { + statisticsState.workerYields &+= 1 + continuation = WorkerRequest(generation: generation, transport: transport) + } lock.unlock() + enqueueWorker(continuation) + } + + private func isCurrentWorkerLocked( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + !terminal + && deviceIsReady + && workerScheduled + && lifecycleGeneration == generation + && self.transport === transport + } - var interrupt = false - for chain in invalidBuffers { - interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt - } - for (chain, event) in publications { - let written = chain.writeBytes(event.bytes) - if Self.tracesPointerButtons, - event.type == UInt16(EventType.key), - (272...276).contains(event.code) { - FileHandle.standardError.write(Data( - "dory-input: published button code=\(event.code) value=\(event.value) bytes=\(written)\n".utf8 - )) + private func recordRevokedWorkerTurn() { + lock.lock() + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + } + + private func drainEventQueueTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> WorkerDrainOutcome { + transport.withQueueLock { + lock.lock() + let current = isCurrentWorkerLocked(generation: generation, transport: transport) + && queueIsReady[0] + lock.unlock() + guard current else { return .stale } + + let queue = transport.queues[0] + var wantsInterrupt = false + defer { + if wantsInterrupt { transport.notifyUsed() } } - interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt - } - if interrupt { transport.notifyUsed() } - } - - private func drainStatusQueue(transport: VirtioMMIOTransport) { - let queue = transport.queues[1] - var interrupt = false - while let chain = (try? queue.pop()) ?? nil { - let bytes = chain.readBytes(maximum: 8) - if bytes.count == 8 { - statusHandler?(VirtioInputEvent( - type: bytes.leUInt16(at: 0), - code: bytes.leUInt16(at: 2), - value: Int32(bitPattern: bytes.leUInt32(at: 4)) - )) + var popped = 0 + var queueFault = false + + do { + observeGuestQueueDepth(queue: 0, depth: Int(try queue.pendingCount())) + } catch { + recordQueueFault() + return .fault + } + + while popped < limits.maximumChainsPerWorkerTurn { + let chain: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + chain = next + } catch { + recordQueueFault() + queueFault = true + break + } + popped += 1 + let valid = chain.withLeaseHeld { access in + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount == 0 + && access.writableSegmentCount > 0 + && access.writableByteCount >= 8 + } ?? false + if valid { + lock.lock() + availableEventBuffers.append(chain) + updateDepthGaugesLocked() + lock.unlock() + } else { + lock.lock() + statisticsState.invalidEventBuffers &+= 1 + lock.unlock() + do { + wantsInterrupt = try queue.push(chain, written: 0) || wantsInterrupt + } catch { + recordQueueFault() + queueFault = true + break + } + } + } + + let guestDepth: Int + do { + guestDepth = Int(try queue.pendingCount()) + } catch { + recordQueueFault() + queueFault = true + guestDepth = 0 + } + observeGuestQueueDepth(queue: 0, depth: guestDepth) + guard !queueFault else { return .fault } + + var publishedThisTurn = 0 + var publicationFault = false + while publishedThisTurn < limits.maximumPublishedEventsPerWorkerTurn { + let publication: PublicationFrame? + lock.lock() + publication = selectPublicationLocked( + eventBudget: limits.maximumPublishedEventsPerWorkerTurn - publishedThisTurn + ) + lock.unlock() + guard let publication else { break } + + workerHooks.beforeEventPublication?() + var allWritesSucceeded = true + for (chain, event) in zip(publication.chains, publication.events) { + if chain.writeBytes(event.bytes) != 8 { + allWritesSucceeded = false + break + } + } + if !allWritesSucceeded { + lock.lock() + statisticsState.queueFaults &+= 1 + needsStateReconciliation = true + lock.unlock() + } + + var publishedEntireFrame = allWritesSucceeded + for (chain, event) in zip(publication.chains, publication.events) { + do { + wantsInterrupt = try queue.push( + chain, + written: allWritesSucceeded ? 8 : 0 + ) || wantsInterrupt + } catch { + lock.lock() + statisticsState.queueFaults &+= 1 + needsStateReconciliation = true + lock.unlock() + publicationFault = true + publishedEntireFrame = false + break + } + if allWritesSucceeded { + lock.lock() + Self.applyPressedState(events: [event], to: &publishedPressedCodes) + statisticsState.publishedEvents &+= 1 + if publication.isReconciliation, event != .synchronize { + statisticsState.stateReconciliationEvents &+= 1 + } + lock.unlock() + } + } + publishedThisTurn += publication.events.count + if publishedEntireFrame { + recordPublishedFrame(publication) + } + if publicationFault { break } + } + guard !publicationFault else { return .fault } + + lock.lock() + let publishable = hasPublishableFrameLocked() + lock.unlock() + let more = guestDepth > 0 || publishable + if more { + lock.lock() + statisticsState.boundedDrainStops &+= 1 + lock.unlock() + return .more + } + return .drained + } + } + + private func selectPublicationLocked(eventBudget: Int) -> PublicationFrame? { + if let frame = pendingFrames.first { + guard frame.events.count <= eventBudget, + availableEventBuffers.count >= frame.events.count else { return nil } + pendingFrames.removeFirst() + let chains = Array(availableEventBuffers.prefix(frame.events.count)) + availableEventBuffers.removeFirst(frame.events.count) + updateDepthGaugesLocked() + return PublicationFrame( + chains: chains, + events: frame.events, + submittedAtNanoseconds: frame.submittedAtNanoseconds, + isReconciliation: false + ) + } + guard needsStateReconciliation, + let frame = Self.reconciliationFrames( + from: publishedPressedCodes, + to: desiredPressedCodes, + maximumEventsPerFrame: limits.maximumEventsPerFrame + ).first, + frame.count <= eventBudget, + availableEventBuffers.count >= frame.count else { return nil } + let chains = Array(availableEventBuffers.prefix(frame.count)) + availableEventBuffers.removeFirst(frame.count) + updateDepthGaugesLocked() + return PublicationFrame( + chains: chains, + events: frame, + submittedAtNanoseconds: nil, + isReconciliation: true + ) + } + + private func hasPublishableFrameLocked() -> Bool { + if let frame = pendingFrames.first { + return availableEventBuffers.count >= frame.events.count + } + guard needsStateReconciliation, + let frame = Self.reconciliationFrames( + from: publishedPressedCodes, + to: desiredPressedCodes, + maximumEventsPerFrame: limits.maximumEventsPerFrame + ).first else { return false } + return availableEventBuffers.count >= frame.count + } + + private func recordPublishedFrame(_ publication: PublicationFrame) { + let finishedAt = monotonicNanoseconds() + lock.lock() + statisticsState.publishedFrames &+= 1 + if let startedAt = publication.submittedAtNanoseconds { + let latency = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsState.publicationLatencyNanoseconds &+= latency + statisticsState.maximumPublicationLatencyNanoseconds = max( + statisticsState.maximumPublicationLatencyNanoseconds, + latency + ) + } + if pendingFrames.isEmpty, publishedPressedCodes == desiredPressedCodes { + needsStateReconciliation = false + } + lock.unlock() + } + + private func drainStatusQueueTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> WorkerDrainOutcome { + let result: (outcome: WorkerDrainOutcome, events: [VirtioInputEvent]) = + transport.withQueueLock { + lock.lock() + let current = isCurrentWorkerLocked( + generation: generation, + transport: transport + ) && queueIsReady[1] + lock.unlock() + guard current else { return (.stale, []) } + + let queue = transport.queues[1] + var wantsInterrupt = false + defer { + if wantsInterrupt { transport.notifyUsed() } + } + var acceptedEvents = [VirtioInputEvent]() + var popped = 0 + var queueFault = false + do { + observeGuestQueueDepth(queue: 1, depth: Int(try queue.pendingCount())) + } catch { + recordQueueFault() + return (.fault, []) + } + while popped < limits.maximumChainsPerWorkerTurn { + let chain: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + chain = next + } catch { + recordQueueFault() + queueFault = true + break + } + popped += 1 + let bytes = chain.withLeaseHeld { access -> [UInt8]? in + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount > 0, + access.writableSegmentCount == 0, + access.readableByteCount >= 8 else { return nil } + return access.readBytes(maximum: 8) + } ?? nil + var event: VirtioInputEvent? + if let bytes, bytes.count == 8 { + let candidate = VirtioInputEvent( + type: bytes.leUInt16(at: 0), + code: bytes.leUInt16(at: 2), + value: Int32(bitPattern: bytes.leUInt32(at: 4)) + ) + if isSupportedStatusEvent(candidate) { event = candidate } + } + if event == nil { + lock.lock() + statisticsState.invalidStatusBuffers &+= 1 + lock.unlock() + } + do { + wantsInterrupt = try queue.push(chain, written: 0) || wantsInterrupt + } catch { + recordQueueFault() + queueFault = true + break + } + if let event { acceptedEvents.append(event) } + } + + let guestDepth: Int + do { + guestDepth = Int(try queue.pendingCount()) + } catch { + recordQueueFault() + queueFault = true + guestDepth = 0 + } + observeGuestQueueDepth(queue: 1, depth: guestDepth) + if queueFault { return (.fault, acceptedEvents) } + if guestDepth > 0 { + lock.lock() + statisticsState.boundedDrainStops &+= 1 + lock.unlock() + return (.more, acceptedEvents) + } + return (.drained, acceptedEvents) } - interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt + + var delivered = 0 + for event in result.events { + lock.lock() + let current = lifecycleGeneration == generation + && self.transport === transport + && deviceIsReady + && !terminal + lock.unlock() + guard current else { break } + statusHandler?(event) + delivered += 1 } - if interrupt { transport.notifyUsed() } + if delivered > 0 { + lock.lock() + statisticsState.statusEvents &+= UInt64(delivered) + lock.unlock() + } + return result.outcome + } + + private func observeGuestQueueDepth(queue: Int, depth: Int) { + lock.lock() + let value = UInt64(max(0, depth)) + if queue == 0 { + statisticsState.eventQueueDepth = value + statisticsState.eventQueueHighWatermark = max( + statisticsState.eventQueueHighWatermark, + value + ) + } else { + statisticsState.statusQueueDepth = value + statisticsState.statusQueueHighWatermark = max( + statisticsState.statusQueueHighWatermark, + value + ) + } + lock.unlock() + } + + private func updateDepthGaugesLocked() { + statisticsState.pendingFrameDepth = UInt64(pendingFrames.count) + statisticsState.pendingFrameHighWatermark = max( + statisticsState.pendingFrameHighWatermark, + statisticsState.pendingFrameDepth + ) + statisticsState.availableEventBufferDepth = UInt64(availableEventBuffers.count) + statisticsState.availableEventBufferHighWatermark = max( + statisticsState.availableEventBufferHighWatermark, + statisticsState.availableEventBufferDepth + ) + } + + private func isSupportedStatusEvent(_ event: VirtioInputEvent) -> Bool { + profile != .absolutePointer + && event.type == UInt16(EventType.led) + && (0...2).contains(event.code) + && (0...1).contains(event.value) + } + + private static func reconciliationFrames( + from published: Set, + to desired: Set, + maximumEventsPerFrame: Int + ) -> [[VirtioInputEvent]] { + let releases = published.subtracting(desired).sorted().map { + VirtioInputEvent(type: UInt16(EventType.key), code: $0, value: 0) + } + let presses = desired.subtracting(published).sorted().map { + VirtioInputEvent(type: UInt16(EventType.key), code: $0, value: 1) + } + let changes = releases + presses + guard !changes.isEmpty else { return [] } + let payloadLimit = maximumEventsPerFrame - 1 + return stride(from: 0, to: changes.count, by: payloadLimit).map { offset in + var frame = Array(changes[offset.. [UInt8] { @@ -373,9 +1122,9 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { case .keyboard: return [] case .absolutePointer: - // QEMU's virtio-tablet exposes only REL_WHEEL. Keep the - // production tablet byte-for-byte compatible with that shape. - return bitmap(codes: [8]) + // Dory emits both discrete and high-resolution wheel events in each axis. The + // capability bitmap must describe the stream Linux actually receives. + return bitmap(codes: [6, 8, 11, 12]) case .combinedCompatibility: return bitmap(codes: [6, 8, 11, 12]) } @@ -421,4 +1170,10 @@ public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { while bytes.last == 0 { bytes.removeLast() } return bytes } + + public var statistics: VirtioInputStatistics { + lock.lock() + defer { lock.unlock() } + return statisticsState + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift index a183458e..5e0ab66a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift @@ -6,6 +6,9 @@ public struct VirtioMMIOTransportStatistics: Equatable, Sendable { public var queueStateChanges: UInt64 public var usedInterrupts: UInt64 public var configurationInterrupts: UInt64 + /// Calls that actually asserted the transport interrupt after coalescing an already-pending + /// status bit. Request counters above remain the stable record of backend notification demand. + public var emittedInterruptSignals: UInt64 public var deviceResets: UInt64 public init( @@ -13,12 +16,14 @@ public struct VirtioMMIOTransportStatistics: Equatable, Sendable { queueStateChanges: UInt64, usedInterrupts: UInt64, configurationInterrupts: UInt64, + emittedInterruptSignals: UInt64, deviceResets: UInt64 ) { self.queueNotifications = queueNotifications self.queueStateChanges = queueStateChanges self.usedInterrupts = usedInterrupts self.configurationInterrupts = configurationInterrupts + self.emittedInterruptSignals = emittedInterruptSignals self.deviceResets = deviceResets } } @@ -91,45 +96,62 @@ public final class VirtioMMIOTransport: MMIODevice { private var deviceFeatureSelect: UInt32 = 0 private var driverFeatureSelect: UInt32 = 0 private var driverFeatures: UInt64 = 0 - private var queueSelect: Int = 0 + private var queueSelect: UInt64 = 0 private var sharedMemorySelect: UInt32 = 0 private var status: UInt32 = 0 private var interruptStatus: UInt32 = 0 private var configGeneration: UInt32 = 0 private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread - private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)] + private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt64)] private let queueNotificationCount = Atomic(0) private let queueStateChangeCount = Atomic(0) private let usedInterruptCount = Atomic(0) private let configurationInterruptCount = Atomic(0) + private let emittedInterruptSignalCount = Atomic(0) private let deviceResetCount = Atomic(0) private static let magic: UInt64 = 0x7472_6976 // "virt" private static let vendor: UInt64 = 0x792D_726F_64 // "dor-y" - private static let version1Feature: UInt64 = 1 << 32 + + private enum DeviceStatus { + static let driverOK: UInt32 = 1 << 2 + static let featuresOK: UInt32 = 1 << 3 + } + + private var offeredFeatures: UInt64 { + backend.deviceFeatures + | VirtqueueFeature.version1 + | VirtqueueFeature.indirectDescriptors + } + + private var driverFeaturesAreValid: Bool { + driverFeatures & VirtqueueFeature.version1 != 0 + && driverFeatures & ~offeredFeatures == 0 + } public init( baseAddress: UInt64, backend: VirtioDeviceBackend, memory: GuestMemory, + queueLimits: VirtqueueLimits = .hardenedDefault, interrupt: @escaping () -> Void ) { self.baseAddress = baseAddress self.backend = backend self.memory = memory self.interrupt = interrupt - self.queues = (0.. UnsafeMutableRawPointer { try memory.hostPointer(at: guestAddress, count: count) } + /// Returns an independently owned, path-free descriptor slice over guest RAM for an isolated + /// device worker. The transport remains the address-to-backing authority; backends never infer + /// descriptor offsets from raw host pointers. + func duplicateGuestMemoryRegion( + at guestAddress: UInt64, + count: UInt64 + ) throws -> GuestMemorySharedRegion { + try memory.duplicateSharedRegion(at: guestAddress, count: count) + } + + func guestMemoryRegionBounds( + at guestAddress: UInt64, + count: UInt64 + ) throws -> (offset: UInt64, length: UInt64, declaredFileSize: UInt64) { + try memory.sharedRegionBounds(at: guestAddress, count: count) + } + + func duplicateGuestMemoryBackingDescriptor() throws -> FileHandle { + try memory.duplicateSharedBackingDescriptor() + } + /// Runs `body` holding the register lock, so a device backend draining a queue off the vCPU /// thread (virtio-net RX) is serialized against guest MMIO that reconfigures or resets the same /// queue. Recursive: safe to call from inside handleKick, which already holds the lock. @@ -168,12 +209,16 @@ public final class VirtioMMIOTransport: MMIODevice { case 0x008: return UInt64(backend.deviceID) case 0x00C: return Self.vendor case 0x010: - let features = backend.deviceFeatures | Self.version1Feature - return deviceFeatureSelect == 0 ? features & 0xFFFF_FFFF : features >> 32 - case 0x034: return 256 // QueueNumMax + let features = offeredFeatures + switch deviceFeatureSelect { + case 0: return features & 0xFFFF_FFFF + case 1: return (features >> 32) & 0xFFFF_FFFF + default: return 0 + } + case 0x034: return Virtqueue.maximumSize // QueueNumMax case 0x044: - guard queueSelect < queues.count else { return 0 } - return queues[queueSelect].ready ? 1 : 0 + guard let index = selectedQueueIndex else { return 0 } + return queues[index].ready ? 1 : 0 case 0x060: interruptLock.lock() defer { interruptLock.unlock() } @@ -198,10 +243,10 @@ public final class VirtioMMIOTransport: MMIODevice { // filesystem request. Every other backend retains the historical lock boundary below. if offset == 0x050, backend.kickSynchronization == .backendManaged { registerLock.lock() - let queue = Int(value) - let shouldKick = queue >= 0 && queue < queues.count + let queue = Int(exactly: value) + let shouldKick = queue.map(queues.indices.contains) ?? false registerLock.unlock() - if shouldKick { + if shouldKick, let queue { queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } @@ -213,32 +258,38 @@ public final class VirtioMMIOTransport: MMIODevice { switch offset { case 0x014: deviceFeatureSelect = UInt32(truncatingIfNeeded: value) case 0x020: - if driverFeatureSelect == 0 { + switch driverFeatureSelect { + case 0: driverFeatures = (driverFeatures & ~0xFFFF_FFFF) | (value & 0xFFFF_FFFF) - } else { - driverFeatures = (driverFeatures & 0xFFFF_FFFF) | (value << 32) + case 1: + let highWord = value & 0xFFFF_FFFF + driverFeatures = (driverFeatures & 0xFFFF_FFFF) | (highWord << 32) + default: + break } case 0x024: driverFeatureSelect = UInt32(truncatingIfNeeded: value) - case 0x030: queueSelect = Int(value) + case 0x030: queueSelect = value case 0x038: withSelectedQueue { index in - pendingQueueLayout[index].count = UInt16(clamping: value) + pendingQueueLayout[index].count = value } case 0x044: withSelectedQueue { index in queueStateChangeCount.wrappingAdd(1, ordering: .relaxed) - let ready = value & 1 == 1 - if ready { + let requestedReady = value == 1 + let ready: Bool + if requestedReady { let layout = pendingQueueLayout[index] - queues[index].configure( - size: layout.count, + ready = queues[index].configure( + untrustedSize: layout.count, descriptorTable: layout.descriptor, availRing: layout.avail, usedRing: layout.used ) - queues[index].setReady(true) + && queues[index].setReady(true) } else { queues[index].setReady(false) + ready = false } // QueueReady=1 is also a reconfiguration event when the queue was already ready. // Notify on every write so a backend can synchronously revoke retained descriptors @@ -246,8 +297,7 @@ public final class VirtioMMIOTransport: MMIODevice { backend.queueStateChanged(queue: index, ready: ready, transport: self) } case 0x050: - let queue = Int(value) - if queue < queues.count { + if let queue = Int(exactly: value), queues.indices.contains(queue) { queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } @@ -256,11 +306,36 @@ public final class VirtioMMIOTransport: MMIODevice { interruptStatus &= ~UInt32(truncatingIfNeeded: value) interruptLock.unlock() case 0x070: + let previousStatus = status status = UInt32(truncatingIfNeeded: value) if status == 0 { resetDevice() - } else if status & 0x4 != 0 { // DRIVER_OK - negotiatedFeatures = driverFeatures + break + } + + if status & DeviceStatus.featuresOK != 0 { + if driverFeaturesAreValid { + negotiatedFeatures = driverFeatures + for queue in queues { + queue.setNegotiatedFeatures(negotiatedFeatures) + } + } else { + // Virtio 1.2 section 3.1.1: the device clears FEATURES_OK when it cannot accept + // the complete feature set. Never silently mask unsupported driver bits. + status &= ~DeviceStatus.featuresOK + negotiatedFeatures = 0 + for queue in queues { queue.setNegotiatedFeatures(0) } + } + } + + // DRIVER_OK is meaningful only after the driver observes FEATURES_OK still set. Reject + // out-of-order readiness and call the backend once on the accepted rising edge. + if status & DeviceStatus.driverOK != 0, + status & DeviceStatus.featuresOK == 0 { + status &= ~DeviceStatus.driverOK + } + if status & DeviceStatus.driverOK != 0, + previousStatus & DeviceStatus.driverOK == 0 { backend.deviceReady(transport: self) } case 0x0AC: sharedMemorySelect = UInt32(truncatingIfNeeded: value) @@ -281,8 +356,10 @@ public final class VirtioMMIOTransport: MMIODevice { deviceResetCount.wrappingAdd(1, ordering: .relaxed) backend.deviceReset(transport: self) for queue in queues { queue.reset() } - pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: backend.queueCount) + pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: queues.count) + interruptLock.lock() interruptStatus = 0 + interruptLock.unlock() negotiatedFeatures = 0 driverFeatures = 0 } @@ -293,13 +370,41 @@ public final class VirtioMMIOTransport: MMIODevice { queueStateChanges: queueStateChangeCount.load(ordering: .relaxed), usedInterrupts: usedInterruptCount.load(ordering: .relaxed), configurationInterrupts: configurationInterruptCount.load(ordering: .relaxed), + emittedInterruptSignals: emittedInterruptSignalCount.load(ordering: .relaxed), deviceResets: deviceResetCount.load(ordering: .relaxed) ) } + /// Virtio-MMIO exposes one status bit per event class, not an event count. Keep the bit set + /// until InterruptACK and emit only when this event class transitions from not-pending to + /// pending. This suppresses redundant host IRQ injections without losing a distinct event class + /// that becomes pending while another class is already set. + private func markInterruptPending(_ bits: UInt32) -> Bool { + interruptLock.lock() + let newlyPending = bits & ~interruptStatus + interruptStatus |= bits + interruptLock.unlock() + return newlyPending != 0 + } + + private func publishInterruptStatus(_ bits: UInt32) { + guard markInterruptPending(bits) else { return } + emitInterruptSignal() + } + + private func emitInterruptSignal() { + emittedInterruptSignalCount.wrappingAdd(1, ordering: .relaxed) + interrupt() + } + private func withSelectedQueue(_ body: (Int) -> Void) { - guard queueSelect < queues.count else { return } - body(queueSelect) + guard let index = selectedQueueIndex else { return } + body(index) + } + + private var selectedQueueIndex: Int? { + guard queueSelect < UInt64(queues.count) else { return nil } + return Int(queueSelect) } private func merge(_ current: UInt64, low: UInt64) -> UInt64 { @@ -312,10 +417,11 @@ public final class VirtioMMIOTransport: MMIODevice { private func readConfig(offset: UInt64, width: Int) -> UInt64 { let config = backend.configSpace + guard width > 0, let start = Int(exactly: offset), start < config.count else { return 0 } var value: UInt64 = 0 - for byteIndex in 0...size) { + let (position, overflow) = start.addingReportingOverflow(byteIndex) + guard !overflow, position < config.count else { break } value |= UInt64(config[position]) << (8 * byteIndex) } return value diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift index f1cb20a4..c81f892b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift @@ -2,139 +2,547 @@ import Darwin import Foundation import Synchronization +/// Event totals and bounded-resource gauges for one virtio-net backend. Event totals wrap modulo +/// 2^64. Queue depth, high-watermark, and latency maxima are point-in-time/high-water gauges. public struct VirtioNetStatistics: Equatable, Sendable { public var transmitPackets: UInt64 public var transmitBytes: UInt64 public var transmitDrops: UInt64 + public var transmitMalformed: UInt64 + public var transmitOversized: UInt64 + public var transmitInvalidDescriptors: UInt64 + public var transmitBackpressure: UInt64 + public var transmitSocketErrors: UInt64 = 0 + public var transmitRetryWakeups: UInt64 = 0 + public var transmitBoundedDrainStops: UInt64 = 0 + public var transmitCompletions: UInt64 = 0 + public var transmitCompletionLatencyNanoseconds: UInt64 = 0 + public var transmitMaximumCompletionLatencyNanoseconds: UInt64 = 0 + public var transmitOldestPendingLatencyNanoseconds: UInt64 = 0 + public var transmitQueueDepth: UInt64 = 0 + public var transmitQueueHighWatermark: UInt64 = 0 public var receivePackets: UInt64 public var receiveBytes: UInt64 public var receiveDeferred: UInt64 public var receiveDrops: UInt64 + /// Datagram payloads larger than the configured Ethernet ceiling. Kept under the established + /// telemetry name because the bounded recv buffer necessarily truncates their discarded tail. public var receiveTruncations: UInt64 + public var receiveMalformed: UInt64 + public var receiveInvalidDescriptors: UInt64 + public var receiveInsufficientCapacity: UInt64 + public var receiveBacklogDrops: UInt64 + public var receiveInactiveDrops: UInt64 + public var receiveSocketErrors: UInt64 + public var receiveActivationFailures: UInt64 } -/// virtio-net wired to a userspace network stack (gvproxy) over a unix datagram socket, one -/// ethernet frame per datagram (the vfkit protocol). No offloads: VERSION_1 + MAC only, so the -/// 12-byte header is constant and the stack stays trivially small. No host entitlement needed. +/// Immutable per-device work and memory limits. They are resolved when the backend is constructed; +/// the datapath never derives scheduling or capacity from ambient host CPU/global state. +struct VirtioNetLimits: Equatable, Sendable { + static let production = VirtioNetLimits( + maximumDeferredReceiveFrames: 256, + maximumDeferredReceiveBytes: 4 * 1_024 * 1_024, + maximumSocketReceiveOperationsPerTurn: 128, + maximumSocketReceiveBytesPerTurn: 1 * 1_024 * 1_024, + maximumActivationPurgeTurns: 64, + maximumTransmitOperationsPerTurn: 64, + maximumTransmitBytesPerTurn: 256 * 1_024, + minimumTransmitRetryDelayNanoseconds: 250_000, + maximumTransmitRetryDelayNanoseconds: 8_000_000 + ) + + let maximumDeferredReceiveFrames: Int + let maximumDeferredReceiveBytes: Int + let maximumSocketReceiveOperationsPerTurn: Int + let maximumSocketReceiveBytesPerTurn: Int + let maximumActivationPurgeTurns: Int + let maximumTransmitOperationsPerTurn: Int + let maximumTransmitBytesPerTurn: Int + let minimumTransmitRetryDelayNanoseconds: Int + let maximumTransmitRetryDelayNanoseconds: Int + + init( + maximumDeferredReceiveFrames: Int, + maximumDeferredReceiveBytes: Int, + maximumSocketReceiveOperationsPerTurn: Int = 128, + maximumSocketReceiveBytesPerTurn: Int = 1 * 1_024 * 1_024, + maximumActivationPurgeTurns: Int = 64, + maximumTransmitOperationsPerTurn: Int = 64, + maximumTransmitBytesPerTurn: Int = 256 * 1_024, + minimumTransmitRetryDelayNanoseconds: Int = 250_000, + maximumTransmitRetryDelayNanoseconds: Int = 8_000_000 + ) { + self.maximumDeferredReceiveFrames = maximumDeferredReceiveFrames + self.maximumDeferredReceiveBytes = maximumDeferredReceiveBytes + self.maximumSocketReceiveOperationsPerTurn = maximumSocketReceiveOperationsPerTurn + self.maximumSocketReceiveBytesPerTurn = maximumSocketReceiveBytesPerTurn + self.maximumActivationPurgeTurns = maximumActivationPurgeTurns + self.maximumTransmitOperationsPerTurn = maximumTransmitOperationsPerTurn + self.maximumTransmitBytesPerTurn = maximumTransmitBytesPerTurn + self.minimumTransmitRetryDelayNanoseconds = minimumTransmitRetryDelayNanoseconds + self.maximumTransmitRetryDelayNanoseconds = maximumTransmitRetryDelayNanoseconds + } +} + +/// virtio-net wired to a userspace network stack (gvproxy) over a Unix datagram socket, one +/// Ethernet frame per datagram (the vfkit protocol). Dory offers MAC and MTU only: it does not +/// negotiate checksum, segmentation, mergeable-buffer, or guest-offload features. public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 1 public let queueCount = 2 // 0 = receive, 1 = transmit public let deviceFeatures: UInt64 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged /// gvproxy's canonical vfkit guest MAC; its DHCP hands this MAC 192.168.127.2. public static let guestMAC: [UInt8] = [0x5A, 0x94, 0xEF, 0xE4, 0x0C, 0xEE] private static let headerLength = 12 + private static let ethernetHeaderLength = 14 + private static let minimumSupportedMTU = 1_280 + private static let maximumSupportedMTU = 9_000 private static let vfkitMagic: [UInt8] = Array("VFKT".utf8) - /// One full virtqueue of frames absorbs the normal refill gap without letting an otherwise - /// transient RX-buffer shortage turn into TCP loss. Beyond this bound we account and drop, - /// rather than allowing an unbounded host allocation under a hostile or wedged guest. - private static let maximumDeferredReceiveFrames = 256 - private let socketFD: Int32 - private let localSocketPath: String + private static let knownHeaderFlagsMask: UInt8 = 0x07 + private static let socketPathMutationLock = NSLock() + + private struct SocketPathIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + let owner: uid_t + } + + private struct DirectoryIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + let owner: uid_t + let permissions: mode_t + } + + private enum ExistingEndpointProbe { + case live + case stale + case indeterminate(Int32) + } + + private final class SocketOwner: @unchecked Sendable { + let descriptor: Int32 + let localPath: String + let localIdentity: SocketPathIdentity + let parentPath: String + let parentIdentity: DirectoryIdentity + let usedPathnamePeerAuthentication: Bool + private let lock = NSLock() + private var acceptsOperations = true + private var hasRetired = false + + init( + descriptor: Int32, + localPath: String, + localIdentity: SocketPathIdentity, + parentPath: String, + parentIdentity: DirectoryIdentity, + usedPathnamePeerAuthentication: Bool + ) { + self.descriptor = descriptor + self.localPath = localPath + self.localIdentity = localIdentity + self.parentPath = parentPath + self.parentIdentity = parentIdentity + self.usedPathnamePeerAuthentication = usedPathnamePeerAuthentication + } + + func withDescriptor(_ body: (Int32) -> Result) -> Result? { + lock.lock() + defer { lock.unlock() } + guard acceptsOperations, !hasRetired else { return nil } + return body(descriptor) + } + + func disableOperations() { + lock.lock() + acceptsOperations = false + lock.unlock() + } + + func retire() { + lock.lock() + acceptsOperations = false + guard !hasRetired else { + lock.unlock() + return + } + hasRetired = true + lock.unlock() + VirtioNet.retireOwnedSocket( + descriptor: descriptor, + path: localPath, + identity: localIdentity, + parentPath: parentPath, + parentIdentity: parentIdentity + ) + } + } + + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct ReadyTransport { + let generation: UInt64 + let transport: VirtioMMIOTransport + } + + private struct PendingReceiveEpoch: Sendable { + let generation: UInt64 + let transport: WeakTransportReference + let completedPurgeTurns: Int + } + + private struct ReceiveSourceRegistration { + let source: any DispatchSourceRead + let cancellation: DispatchSemaphore + } + + private final class TransmitRetryRegistration: @unchecked Sendable { + let generation: UInt64 + let source: any DispatchSourceTimer + let cancellation = DispatchSemaphore(value: 0) + + init(generation: UInt64, source: any DispatchSourceTimer) { + self.generation = generation + self.source = source + } + } + + private struct TransmitHeadObservation: Sendable { + let lease: VirtqueueLease + let head: UInt16 + let firstObservedNanoseconds: UInt64 + + func matches(_ chain: VirtqueueChain) -> Bool { + lease == chain.lease && head == chain.head + } + } + + private struct TransmitState: Sendable { + var generation: UInt64 = 1 + var transport: WeakTransportReference? + var terminal = false + var drainScheduled = false + var kickPending = false + var retryRegistration: TransmitRetryRegistration? + var consecutiveTransientFailures = 0 + var headObservation: TransmitHeadObservation? + var queueDepth = 0 + var queueHighWatermark = 0 + var maximumCompletionLatencyNanoseconds: UInt64 = 0 + + mutating func advanceGeneration() { + generation &+= 1 + if generation == 0 { generation = 1 } + } + + mutating func observeQueueDepth(_ depth: Int) { + queueDepth = max(0, depth) + queueHighWatermark = max(queueHighWatermark, queueDepth) + } + + mutating func clearLifecycleState() -> TransmitRetryRegistration? { + let retry = retryRegistration + retryRegistration = nil + drainScheduled = false + kickPending = false + consecutiveTransientFailures = 0 + headObservation = nil + queueDepth = 0 + return retry + } + } + + private enum TransmitRejection: Error { + case invalidDescriptor + case malformed + case oversized + } + + private enum TransmitPreparation { + case empty + case frame(chain: VirtqueueChain, bytes: [UInt8], depth: Int) + case rejected(wantsInterrupt: Bool, depth: Int, observedAt: UInt64) + case queueFault(depth: Int) + case stale + } + + private enum TransmitFinalization { + case published(wantsInterrupt: Bool, depth: Int) + case queueFault(depth: Int) + case stale + } + + private struct DeferredFrame { + let generation: UInt64 + let bytes: [UInt8] + } + + private struct ReceiveState { + var generation: UInt64 = 0 + var transport: WeakTransportReference? + var deviceIsReady = false + var receiveQueueIsReady = false + var terminal = false + var deferredFrames = [DeferredFrame]() + var deferredHead = 0 + var deferredBytes = 0 + + var deferredCount: Int { deferredFrames.count - deferredHead } + + mutating func advanceGeneration() -> UInt64 { + generation &+= 1 + // Keep zero as the never-ready sentinel even after the practically unreachable wrap. + if generation == 0 { generation = 1 } + return generation + } + + mutating func clearDeferredFrames() -> Int { + let removed = deferredCount + deferredFrames.removeAll(keepingCapacity: true) + deferredHead = 0 + deferredBytes = 0 + return removed + } + + mutating func dequeueDeferredFrame(generation expectedGeneration: UInt64) { + guard deferredHead < deferredFrames.count, + deferredFrames[deferredHead].generation == expectedGeneration else { return } + let removedBytes = deferredFrames[deferredHead].bytes.count + deferredHead += 1 + deferredBytes -= removedBytes + if deferredHead == deferredFrames.count { + deferredFrames.removeAll(keepingCapacity: true) + deferredHead = 0 + } else if deferredHead >= 64 { + deferredFrames.removeFirst(deferredHead) + deferredHead = 0 + } + } + } + + private enum DescriptorDisposition { + case writable + case wrongDirection + case insufficientCapacity + } + + private enum DeliveryResult { + case delivered(wantsInterrupt: Bool) + case awaitingBuffer(wantsInterrupt: Bool) + case stale + case queueFault(wantsInterrupt: Bool) + } + + private enum DeferredDrainResult: Equatable { + case drained + case waiting + case queueFault + } + + private enum DeferredAdmission { + case accepted + case atCapacity + case stale + } + + private enum ReceivedDatagram { + case frame([UInt8], ReadyTransport?) + case empty(ReadyTransport?) + case unavailable + case retry + case failed(Int32) + } + + private enum InactivePurgeResult { + case drained + case budgetExhausted + case failed(Int32) + } + + private let socketOwner: SocketOwner private let macAddress: [UInt8] - private let maximumTransmissionUnit: UInt16? - private var receiveSource: (any DispatchSourceRead)? - private weak var transport: VirtioMMIOTransport? - private let receiveQueue = DispatchQueue(label: "dory-hv.net.rx") - private var deferredReceiveFrames = [[UInt8]]() - private var deferredReceiveHead = 0 + private let maximumTransmissionUnit: UInt16 + private let maximumEthernetFrameLength: Int + private let limits: VirtioNetLimits + private let transmitQueue = DispatchQueue( + label: "dory-hv.net.tx", + qos: .userInitiated + ) + private let transmitQueueKey = DispatchSpecificKey() + private let transmitState = Mutex(TransmitState()) + private let transmitOperationForTesting: (@Sendable ([UInt8]) -> (count: Int, code: Int32))? + private let receiveQueue = DispatchQueue( + label: "dory-hv.net.rx", + qos: RawHVSchedulingPolicy.networkIOWorkerDispatchQoS + ) + private let receiveQueueKey = DispatchSpecificKey() + /// Serializes recv() with the pre-ready purge. It is never held while taking the transport lock. + private let socketReceiveLock = NSLock() + private let receiveState = Mutex(ReceiveState()) + /// Publishes the source and its cancellation fence as one lifecycle unit. A stale activation + /// callback can race a newer queue epoch, so source creation must be terminal-aware and + /// single-winner independently of transport serialization. + private let receiveSourceLock = NSLock() + private var receiveSourceRegistration: ReceiveSourceRegistration? private let transmitPackets = Atomic(0) private let transmitBytes = Atomic(0) private let transmitDrops = Atomic(0) + private let transmitMalformed = Atomic(0) + private let transmitOversized = Atomic(0) + private let transmitInvalidDescriptors = Atomic(0) + private let transmitBackpressure = Atomic(0) + private let transmitSocketErrors = Atomic(0) + private let transmitRetryWakeups = Atomic(0) + private let transmitBoundedDrainStops = Atomic(0) + private let transmitCompletions = Atomic(0) + private let transmitCompletionLatencyNanoseconds = Atomic(0) private let receivePackets = Atomic(0) private let receiveBytes = Atomic(0) private let receiveDeferred = Atomic(0) private let receiveDrops = Atomic(0) private let receiveTruncations = Atomic(0) + private let receiveMalformed = Atomic(0) + private let receiveInvalidDescriptors = Atomic(0) + private let receiveInsufficientCapacity = Atomic(0) + private let receiveBacklogDrops = Atomic(0) + private let receiveInactiveDrops = Atomic(0) + private let receiveSocketErrors = Atomic(0) + private let receiveActivationFailures = Atomic(0) - public init( + public convenience init( + socketPath: String, + remotePath: String, + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16 + ) throws { + try self.init( + socketPath: socketPath, + remotePath: remotePath, + macAddress: macAddress, + maximumTransmissionUnit: maximumTransmissionUnit, + limits: .production + ) + } + + init( socketPath: String, remotePath: String, macAddress: [UInt8] = VirtioNet.guestMAC, - maximumTransmissionUnit: UInt16? = nil + maximumTransmissionUnit: UInt16, + limits: VirtioNetLimits, + transmitOperationForTesting: (@Sendable ([UInt8]) -> (count: Int, code: Int32))? = nil ) throws { guard macAddress.count == 6 else { throw VMError.invalidConfiguration("a virtio-net MAC address must contain six bytes") } - try Self.validateSocketPath(socketPath) - try Self.validateSocketPath(remotePath) - let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) - guard descriptor >= 0 else { - throw VMError.invalidConfiguration("cannot create datagram socket: errno \(errno)") + guard (Self.minimumSupportedMTU...Self.maximumSupportedMTU) + .contains(Int(maximumTransmissionUnit)) else { + throw VMError.invalidConfiguration( + "virtio-net MTU must be \(Self.minimumSupportedMTU)...\(Self.maximumSupportedMTU) bytes" + ) + } + guard limits.maximumDeferredReceiveFrames > 0, + limits.maximumDeferredReceiveBytes > 0, + limits.maximumSocketReceiveOperationsPerTurn > 0, + limits.maximumActivationPurgeTurns > 0, + limits.maximumTransmitOperationsPerTurn > 0, + limits.maximumTransmitBytesPerTurn > 0, + limits.minimumTransmitRetryDelayNanoseconds > 0, + limits.maximumTransmitRetryDelayNanoseconds + >= limits.minimumTransmitRetryDelayNanoseconds else { + throw VMError.invalidConfiguration("virtio-net work and retry limits are invalid") } - var didBind = false - do { - unlink(socketPath) - var local = sockaddr_un() - local.sun_family = sa_family_t(AF_UNIX) - Self.copyPath(socketPath, into: &local) - let bindResult = withUnsafePointer(to: &local) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in - bind(descriptor, address, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { - throw VMError.invalidConfiguration("cannot bind \(socketPath): errno \(errno)") - } - didBind = true - - var remote = sockaddr_un() - remote.sun_family = sa_family_t(AF_UNIX) - Self.copyPath(remotePath, into: &remote) - let connectResult = withUnsafePointer(to: &remote) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in - connect(descriptor, address, socklen_t(MemoryLayout.size)) - } - } - guard connectResult == 0 else { - throw VMError.invalidConfiguration("cannot connect \(remotePath): errno \(errno)") - } - - // Match vfkit's reference socket sizing. The proxy side has a 4 MiB receive buffer; - // giving Dory 4 MiB on RX keeps short guest scheduling gaps from becoming datagram loss. - try Self.setSocketBuffer(descriptor, option: SO_SNDBUF, bytes: 1 << 20) - try Self.setSocketBuffer(descriptor, option: SO_RCVBUF, bytes: 4 << 20) - - // Queue notifications run under VirtioMMIOTransport's register lock. A blocking send - // here could therefore stop every MMIO access and queue transition for this device if - // gvproxy stopped draining its datagram socket. Keep the descriptor nonblocking for - // its entire operational lifetime; individual TX calls also use MSG_DONTWAIT below as - // defense in depth against an accidental future flags change. - try Self.setNonBlocking(descriptor) - // gvproxy <= 0.8.6 requires this handshake; newer releases deliberately retain support - // for it while also accepting the first Ethernet frame directly. - let magicBytes = Self.vfkitMagic.withUnsafeBytes { - send(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) - } - guard magicBytes == Self.vfkitMagic.count else { - throw VMError.invalidConfiguration("cannot register vfkit peer: errno \(errno)") - } - } catch { - close(descriptor) - if didBind { unlink(socketPath) } - throw error + let maximumFrameLength = Int(maximumTransmissionUnit) + Self.ethernetHeaderLength + guard limits.maximumSocketReceiveBytesPerTurn >= maximumFrameLength + 1 else { + throw VMError.invalidConfiguration( + "virtio-net socket byte budget must hold one maximum-size datagram" + ) + } + guard limits.maximumTransmitBytesPerTurn >= maximumFrameLength else { + throw VMError.invalidConfiguration( + "virtio-net transmit byte budget must hold one maximum-size frame" + ) } - self.socketFD = descriptor - self.localSocketPath = socketPath + let ownedSocket = try Self.makeOwnedConnectedSocket( + socketPath: socketPath, + remotePath: remotePath + ) + + self.socketOwner = ownedSocket self.macAddress = macAddress self.maximumTransmissionUnit = maximumTransmissionUnit - self.deviceFeatures = (1 << 5) | (maximumTransmissionUnit == nil ? 0 : (1 << 3)) + self.maximumEthernetFrameLength = maximumFrameLength + self.limits = limits + self.transmitOperationForTesting = transmitOperationForTesting + self.deviceFeatures = (1 << 5) | (1 << 3) + self.transmitQueue.setSpecific( + key: self.transmitQueueKey, + value: 1 + ) + self.receiveQueue.setSpecific( + key: self.receiveQueueKey, + value: 1 + ) } deinit { - if let receiveSource { - receiveSource.cancel() + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + state.terminal = true + state.advanceGeneration() + state.transport = nil + return state.clearLifecycleState() + } + retry?.source.cancel() + if DispatchQueue.getSpecific(key: transmitQueueKey) == nil { + if let retry { + _ = retry.cancellation.wait(timeout: .now() + 2) + } + // Joins an active bounded drain and every cancellation handler already queued by a + // lifecycle transition before the connected socket can be retired below. + transmitQueue.sync {} + } + receiveState.withLock { + _ = $0.advanceGeneration() + $0.transport = nil + $0.deviceIsReady = false + $0.receiveQueueIsReady = false + $0.terminal = true + _ = $0.clearDeferredFrames() + } + if let registration = receiveSourceRegistrationSnapshot() { + registration.source.cancel() + // Dispatch guarantees the cancel handler follows all source handlers. Bounded waiting + // here gives normal VM teardown deterministic pathname retirement without ever closing + // a descriptor underneath a live handler. If deinit itself runs on the receive queue, + // the handler completes asynchronously to avoid self-deadlock. + if DispatchQueue.getSpecific(key: receiveQueueKey) == nil { + _ = registration.cancellation.wait(timeout: .now() + 2) + } } else { - close(socketFD) - unlink(localSocketPath) + socketOwner.retire() } } public var configSpace: [UInt8] { - guard let maximumTransmissionUnit else { return macAddress } // virtio_net_config uses fixed offsets: MAC[0...5], status[6...7], max queue pairs // [8...9], and MTU[10...11]. Only MAC and MTU are negotiated here. return macAddress + [0, 0, 0, 0] @@ -143,195 +551,1623 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { } public func deviceReady(transport: VirtioMMIOTransport) { - self.transport = transport - guard receiveSource == nil else { return } - let source = DispatchSource.makeReadSource(fileDescriptor: socketFD, queue: receiveQueue) + bindTransmitTransport(transport) + // Frames queued before DRIVER_OK belong to no live device generation. Publish no transport + // until a bounded purge reaches EAGAIN; a burst larger than one work turn continues on the + // receive queue, while a continuously sending peer leaves activation fail-closed. + let transition = receiveState.withLock { + state -> (discarded: Int, accepted: Bool, epoch: PendingReceiveEpoch?) in + guard !state.terminal else { return (0, false, nil) } + let discarded = state.clearDeferredFrames() + state.deviceIsReady = true + state.receiveQueueIsReady = transport.queues[0].ready + state.transport = nil + let generation = state.advanceGeneration() + let epoch = state.receiveQueueIsReady + ? PendingReceiveEpoch( + generation: generation, + transport: WeakTransportReference(transport), + completedPurgeTurns: 0 + ) + : nil + return (discarded, true, epoch) + } + accountInactiveDrops(transition.discarded) + guard transition.accepted else { return } + if let epoch = transition.epoch { + continueReceiveEpochActivation(epoch) + } else { + startReceiveSourceIfNeeded() + } + } + + private func startReceiveSourceIfNeeded() { + receiveSourceLock.lock() + guard receiveSourceRegistration == nil, + receiveState.withLock({ !$0.terminal }) else { + receiveSourceLock.unlock() + return + } + let source = DispatchSource.makeReadSource( + fileDescriptor: socketOwner.descriptor, + queue: receiveQueue + ) source.setEventHandler { [weak self] in self?.drainSocket() } - source.setCancelHandler { [socketFD, localSocketPath] in - close(socketFD) - unlink(localSocketPath) + let socketOwner = socketOwner + let cancellation = DispatchSemaphore(value: 0) + source.setCancelHandler { + socketOwner.retire() + cancellation.signal() } + receiveSourceRegistration = ReceiveSourceRegistration( + source: source, + cancellation: cancellation + ) source.resume() - receiveSource = source - receiveQueue.async { [weak self] in self?.drainSocket() } + receiveSourceLock.unlock() + receiveQueue.async { [weak self] in + self?.drainSocket() + } + } + + private func receiveSourceRegistrationSnapshot() -> ReceiveSourceRegistration? { + receiveSourceLock.lock() + defer { receiveSourceLock.unlock() } + return receiveSourceRegistration + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeTransmitLifecycle(replacementTransport: nil) + let discarded = receiveState.withLock { state -> Int in + _ = state.advanceGeneration() + state.transport = nil + state.deviceIsReady = false + state.receiveQueueIsReady = false + return state.clearDeferredFrames() + } + accountInactiveDrops(discarded) + } + + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + if queue == 1 { + revokeTransmitLifecycle( + replacementTransport: ready ? WeakTransportReference(transport) : nil + ) + return + } + guard queue == 0 else { return } + // Every QueueReady write is a queue-epoch boundary. Revoke pending frames and late + // callbacks from the previous epoch. A newly enabled queue stays fail-closed until a + // socket-serialized, bounded purge proves no disabled-epoch datagrams remain. + let transition = receiveState.withLock { + state -> (discarded: Int, epoch: PendingReceiveEpoch?) in + guard !state.terminal else { return (0, nil) } + let generation = state.advanceGeneration() + state.receiveQueueIsReady = ready + state.transport = nil + let epoch = state.deviceIsReady && ready + ? PendingReceiveEpoch( + generation: generation, + transport: WeakTransportReference(transport), + completedPurgeTurns: 0 + ) + : nil + return (state.clearDeferredFrames(), epoch) + } + accountInactiveDrops(transition.discarded) + if let epoch = transition.epoch { + continueReceiveEpochActivation(epoch) + } + } + + private func continueReceiveEpochActivation(_ epoch: PendingReceiveEpoch) { + socketReceiveLock.lock() + guard isPending(epoch) else { + socketReceiveLock.unlock() + return + } + switch discardQueuedDatagramsAsInactive() { + case .drained: + let activated = receiveState.withLock { state -> Bool in + guard state.generation == epoch.generation, + state.deviceIsReady, + state.receiveQueueIsReady, + !state.terminal, + epoch.transport.value != nil else { return false } + state.transport = epoch.transport + return true + } + socketReceiveLock.unlock() + if activated { startReceiveSourceIfNeeded() } + case .budgetExhausted: + socketReceiveLock.unlock() + let completedTurns = epoch.completedPurgeTurns + 1 + guard completedTurns < limits.maximumActivationPurgeTurns else { + failReceiveActivation() + return + } + let continuation = PendingReceiveEpoch( + generation: epoch.generation, + transport: epoch.transport, + completedPurgeTurns: completedTurns + ) + receiveQueue.async { [weak self] in + self?.continueReceiveEpochActivation(continuation) + } + case let .failed(code): + socketReceiveLock.unlock() + handleSocketReceiveFailure(code) + } + } + + private func isPending(_ epoch: PendingReceiveEpoch) -> Bool { + receiveState.withLock { state in + state.generation == epoch.generation + && state.deviceIsReady + && state.receiveQueueIsReady + && !state.terminal + && epoch.transport.value != nil + } } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { if queue == 0 { - // Linux notifies the receive queue when it replenishes buffers. Drain on the same serial - // queue as the socket source so the saved frame order and socket reads cannot race. - receiveQueue.async { [weak self] in self?.drainSocket() } + // Linux notifies the receive queue when it replenishes buffers. Drain on the same + // serial queue as socket events so deferred and newly received frame order is stable. + receiveQueue.async { [weak self] in + self?.drainSocket() + } return } guard queue == 1 else { return } - let virtqueue = transport.queues[1] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let frame = chain.readBytes() - if frame.count > Self.headerLength { - let payloadCount = frame.count - Self.headerLength - let sent = frame[Self.headerLength...].withUnsafeBytes { buffer in - send(socketFD, buffer.baseAddress, buffer.count, MSG_DONTWAIT) + scheduleTransmitDrain(for: transport) + } + + /// A kick only publishes work to the serial TX executor. VirtioMMIO invokes this backend after + /// releasing its register lock, so a vCPU never performs descriptor walks, copies, send(), or a + /// whole-ring drain on the MMIO write path. + private func scheduleTransmitDrain(for transport: VirtioMMIOTransport) { + let transition = transmitState.withLock { + state -> (generation: UInt64?, cancelled: TransmitRetryRegistration?) in + guard !state.terminal else { return (nil, nil) } + + var cancelled: TransmitRetryRegistration? + if state.transport?.value !== transport { + state.advanceGeneration() + cancelled = state.clearLifecycleState() + state.transport = WeakTransportReference(transport) + } + state.kickPending = true + guard !state.drainScheduled, state.retryRegistration == nil else { + return (nil, cancelled) + } + state.kickPending = false + state.drainScheduled = true + return (state.generation, cancelled) + } + transition.cancelled?.source.cancel() + guard let generation = transition.generation else { return } + enqueueTransmitDrain(generation: generation, transport: transport) + } + + private func enqueueTransmitDrain( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + transmitQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + self.drainTransmitQueue(generation: generation, transport: transport) + } + } + + /// Processes a bounded number of packets and bytes, then yields back to the serial executor. + /// The descriptor publication order is intentionally `peek -> copy -> send -> pop -> push`. + /// A transient send result therefore leaves both lastAvailIndex and the used ring unchanged. + private func drainTransmitQueue( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return } + var operations = 0 + var processedBytes = 0 + var observedDepth = 0 + var wantsInterrupt = false + var stoppedOnQueueFault = false + defer { + if wantsInterrupt { transport.notifyUsed() } + } + + while operations < limits.maximumTransmitOperationsPerTurn { + let observedAt = Self.monotonicNanoseconds() + switch prepareTransmitHead( + generation: generation, + transport: transport, + observedAt: observedAt + ) { + case .empty: + observedDepth = 0 + observeTransmitQueueDepth(0, generation: generation, transport: transport) + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: false + ) + return + case .stale: + return + case let .queueFault(depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + stoppedOnQueueFault = true + case let .rejected(interrupt, depth, firstObserved): + operations += 1 + observedDepth = depth + wantsInterrupt = wantsInterrupt || interrupt + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + recordTransmitCompletionLatency(from: firstObserved) + continue + case let .frame(chain, frame, depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + if operations > 0, + processedBytes > limits.maximumTransmitBytesPerTurn - frame.count { + transmitBoundedDrainStops.wrappingAdd(1, ordering: .relaxed) + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: true + ) + return + } + + guard let attempted = attemptTransmit( + frame, + chain: chain, + generation: generation, + transport: transport, + observedAt: observedAt + ) else { return } + + if Self.isTransientTransmitError(attempted.result) { + if Self.isSocketBackpressure(attempted.result.code) { + transmitBackpressure.wrappingAdd(1, ordering: .relaxed) + } + armTransmitRetry(generation: generation, transport: transport) + return } - if sent == payloadCount { + + let transmitted = attempted.result.count == frame.count + if transmitted { transmitPackets.wrappingAdd(1, ordering: .relaxed) - transmitBytes.wrappingAdd(UInt64(payloadCount), ordering: .relaxed) + transmitBytes.wrappingAdd(UInt64(frame.count), ordering: .relaxed) } else { - // Datagram writes are atomic. EAGAIN/EWOULDBLOCK means gvproxy is applying - // backpressure, while every other short/error result is equally undelivered; - // consume the guest descriptor and account one deterministic packet drop. - transmitDrops.wrappingAdd(1, ordering: .relaxed) + // Datagram sends are atomic. A short nonnegative result is an invariant/socket + // failure and receives the same terminal-per-descriptor treatment as errno. + transmitSocketErrors.wrappingAdd(1, ordering: .relaxed) + } + + switch finalizeTransmitHead( + chain, + generation: generation, + transport: transport + ) { + case .stale: + return + case let .queueFault(depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + stoppedOnQueueFault = true + case let .published(interrupt, depth): + operations += 1 + processedBytes += frame.count + observedDepth = depth + wantsInterrupt = wantsInterrupt || interrupt + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + if !transmitted { + transmitDrops.wrappingAdd(1, ordering: .relaxed) + } + completeTransmitHead( + chain, + generation: generation, + transport: transport, + firstObserved: attempted.firstObserved + ) } - } else { - transmitDrops.wrappingAdd(1, ordering: .relaxed) } - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants + + if stoppedOnQueueFault { break } + } + + if stoppedOnQueueFault { + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: false + ) + return } - if interrupt { - transport.notifyUsed() + if observedDepth > 0 { + transmitBoundedDrainStops.wrappingAdd(1, ordering: .relaxed) } + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: observedDepth > 0 + ) } - private func drainSocket() { - guard let transport else { return } + private func prepareTransmitHead( + generation: UInt64, + transport: VirtioMMIOTransport, + observedAt: UInt64 + ) -> TransmitPreparation { + transport.withQueueLock { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return .stale } + let virtqueue = transport.queues[1] + let depth: Int + do { + depth = Int(try virtqueue.pendingCount()) + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: 0) + } + guard depth > 0 else { return .empty } - var interrupt = false - while deferredReceiveHead < deferredReceiveFrames.count { - let frame = deferredReceiveFrames[deferredReceiveHead] - guard let wantsInterrupt = deliver(frame, transport: transport) else { break } - deferredReceiveHead += 1 - interrupt = interrupt || wantsInterrupt + let chain: VirtqueueChain + do { + guard let candidate = try virtqueue.peek() else { return .empty } + chain = candidate + } catch { + // peek() is non-consuming. pop() advances lastAvailIndex before walking the same + // malformed descriptor, preventing a hostile head from spinning every work turn. + _ = try? virtqueue.pop() + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + let remaining = (try? virtqueue.pendingCount()).map(Int.init) ?? max(0, depth - 1) + return .queueFault(depth: remaining) + } + + let maximumChainBytes = Self.headerLength + maximumEthernetFrameLength + let verdict = chain.withLeaseHeld { access -> Result<[UInt8], TransmitRejection> in + guard access.readableSegmentCount > 0, + access.writableSegmentCount == 0 else { + return .failure(.invalidDescriptor) + } + let readableCount = access.readableByteCount + guard readableCount <= maximumChainBytes else { + return .failure(.oversized) + } + guard readableCount >= Self.headerLength + Self.ethernetHeaderLength else { + return .failure(.malformed) + } + let packet = access.readBytes(maximum: maximumChainBytes) + guard packet.count == readableCount, + Self.isSupportedTransmitHeader(packet) else { + return .failure(.malformed) + } + let frame = Array(packet.dropFirst(Self.headerLength)) + guard frame.count >= Self.ethernetHeaderLength else { + return .failure(.malformed) + } + guard frame.count <= maximumEthernetFrameLength else { + return .failure(.oversized) + } + return .success(frame) + } + guard let verdict else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: depth) + } + + switch verdict { + case let .success(frame): + return .frame(chain: chain, bytes: frame, depth: depth) + case let .failure(reason): + accountTransmitRejection(reason) + do { + guard let popped = try virtqueue.pop(), Self.sameChain(popped, chain) else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: depth) + } + switch try virtqueue.pushOutcome(popped, written: 0) { + case .revoked: + return .stale + case let .published(wantsInterrupt): + let remaining = Int(try virtqueue.pendingCount()) + transmitCompletions.wrappingAdd(1, ordering: .relaxed) + return .rejected( + wantsInterrupt: wantsInterrupt, + depth: remaining, + observedAt: observedAt + ) + } + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: max(0, depth - 1)) + } + } } - compactDeferredFramesIfNeeded() + } - var frame = [UInt8](repeating: 0, count: 65536) - while true { - let received = recv(socketFD, &frame, frame.count, MSG_DONTWAIT) - guard received > 0 else { break } - receivePackets.wrappingAdd(1, ordering: .relaxed) - receiveBytes.wrappingAdd(UInt64(received), ordering: .relaxed) - let receivedFrame = Array(frame[0.. (result: (count: Int, code: Int32), firstObserved: UInt64)? { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled else { return nil } + let firstObserved: UInt64 + if let observation = state.headObservation, observation.matches(chain) { + firstObserved = observation.firstObservedNanoseconds + } else { + firstObserved = observedAt + state.headObservation = TransmitHeadObservation( + lease: chain.lease, + head: chain.head, + firstObservedNanoseconds: observedAt + ) } - if let wantsInterrupt = deliver(receivedFrame, transport: transport) { - interrupt = interrupt || wantsInterrupt + + // The state lock is deliberately held through the nonblocking syscall. Reset and queue + // reconfiguration take registerLock -> transmitState, so they wait for this bounded + // attempt and can then revoke the epoch without a send beginning after the callback. + let result: (count: Int, code: Int32) + if let transmitOperationForTesting { + result = transmitOperationForTesting(frame) } else { - deferOrDrop(receivedFrame) + result = socketOwner.withDescriptor { descriptor in + frame.withUnsafeBytes { buffer in + let count = send(descriptor, buffer.baseAddress, buffer.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + } ?? (-1, EBADF) } - } - if interrupt { - transport.notifyUsed() + return (result, firstObserved) } } - /// Returns nil when the guest has not supplied an RX descriptor yet. - private func deliver(_ frame: [UInt8], transport: VirtioMMIOTransport) -> Bool? { - let virtqueue = transport.queues[0] - return transport.withQueueLock { () -> Bool? in - guard let chain = (try? virtqueue.pop()) ?? nil else { return nil } - var header = [UInt8](repeating: 0, count: Self.headerLength) - header[10] = 1 // num_buffers = 1 - header.append(contentsOf: frame) - let written = chain.writeBytes(header) - if written != Self.headerLength + frame.count { - receiveTruncations.wrappingAdd(1, ordering: .relaxed) + private func finalizeTransmitHead( + _ expected: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> TransmitFinalization { + transport.withQueueLock { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return .stale } + let virtqueue = transport.queues[1] + do { + guard let popped = try virtqueue.pop(), Self.sameChain(popped, expected) else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + let depth = (try? virtqueue.pendingCount()).map(Int.init) ?? 0 + return .queueFault(depth: depth) + } + switch try virtqueue.pushOutcome(popped, written: 0) { + case .revoked: + return .stale + case let .published(wantsInterrupt): + let depth = Int(try virtqueue.pendingCount()) + transmitCompletions.wrappingAdd(1, ordering: .relaxed) + return .published(wantsInterrupt: wantsInterrupt, depth: depth) + } + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + let depth = (try? virtqueue.pendingCount()).map(Int.init) ?? 0 + return .queueFault(depth: depth) } - return (try? virtqueue.push(chain, written: written)) ?? false } } - private func deferOrDrop(_ frame: [UInt8]) { - let pendingCount = deferredReceiveFrames.count - deferredReceiveHead - guard pendingCount < Self.maximumDeferredReceiveFrames else { - receiveDrops.wrappingAdd(1, ordering: .relaxed) - return + private func accountTransmitRejection(_ rejection: TransmitRejection) { + switch rejection { + case .invalidDescriptor: + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + case .malformed: + transmitMalformed.wrappingAdd(1, ordering: .relaxed) + case .oversized: + transmitOversized.wrappingAdd(1, ordering: .relaxed) } - deferredReceiveFrames.append(frame) - receiveDeferred.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) } - private func compactDeferredFramesIfNeeded() { - guard deferredReceiveHead > 0 else { return } - if deferredReceiveHead == deferredReceiveFrames.count { - deferredReceiveFrames.removeAll(keepingCapacity: true) - deferredReceiveHead = 0 - } else if deferredReceiveHead >= 64 { - deferredReceiveFrames.removeFirst(deferredReceiveHead) - deferredReceiveHead = 0 - } + private static func sameChain(_ lhs: VirtqueueChain, _ rhs: VirtqueueChain) -> Bool { + lhs.head == rhs.head && lhs.lease == rhs.lease } - public var statistics: VirtioNetStatistics { - VirtioNetStatistics( - transmitPackets: transmitPackets.load(ordering: .relaxed), - transmitBytes: transmitBytes.load(ordering: .relaxed), - transmitDrops: transmitDrops.load(ordering: .relaxed), - receivePackets: receivePackets.load(ordering: .relaxed), - receiveBytes: receiveBytes.load(ordering: .relaxed), - receiveDeferred: receiveDeferred.load(ordering: .relaxed), - receiveDrops: receiveDrops.load(ordering: .relaxed), - receiveTruncations: receiveTruncations.load(ordering: .relaxed) - ) + private static func isSocketBackpressure(_ code: Int32) -> Bool { + code == EAGAIN || code == EWOULDBLOCK || code == ENOBUFS } - private static func setSocketBuffer(_ descriptor: Int32, option: Int32, bytes: Int32) throws { - var value = bytes - guard setsockopt( - descriptor, - SOL_SOCKET, - option, - &value, - socklen_t(MemoryLayout.size) - ) == 0 else { - throw VMError.invalidConfiguration("cannot set network socket buffer option \(option): errno \(errno)") - } + private static func isTransientTransmitError( + _ result: (count: Int, code: Int32) + ) -> Bool { + result.count < 0 && (isSocketBackpressure(result.code) || result.code == EINTR) } - private static func setNonBlocking(_ descriptor: Int32) throws { - let flags = fcntl(descriptor, F_GETFL, 0) - guard flags >= 0 else { - throw VMError.invalidConfiguration("cannot read network socket flags: errno \(errno)") + private func bindTransmitTransport(_ transport: VirtioMMIOTransport) { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal, state.transport?.value !== transport else { return nil } + state.advanceGeneration() + let retry = state.clearLifecycleState() + state.transport = WeakTransportReference(transport) + return retry } - guard fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { - throw VMError.invalidConfiguration("cannot make network socket nonblocking: errno \(errno)") + retry?.source.cancel() + } + + private func revokeTransmitLifecycle(replacementTransport: WeakTransportReference?) { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal else { return nil } + state.advanceGeneration() + let retry = state.clearLifecycleState() + state.transport = replacementTransport + return retry } + retry?.source.cancel() } - /// Test-only observability for the descriptor-level half of the nonblocking TX invariant. - /// Production sends additionally pass MSG_DONTWAIT, so either defense prevents lock pinning. - var isSocketNonblockingForTesting: Bool { - let flags = fcntl(socketFD, F_GETFL, 0) - return flags >= 0 && flags & O_NONBLOCK != 0 + private func terminateTransmitLifecycle() { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal else { return nil } + state.terminal = true + state.advanceGeneration() + state.transport = nil + return state.clearLifecycleState() + } + retry?.source.cancel() } - private static func validateSocketPath(_ path: String) throws { - var address = sockaddr_un() - let capacity = withUnsafeBytes(of: &address.sun_path) { $0.count } - guard path.utf8.count < capacity else { - throw VMError.invalidConfiguration( - "unix datagram socket path is too long (\(path.utf8.count) bytes, maximum \(capacity - 1)): \(path)" - ) + private func isCurrentTransmitEpoch( + _ generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + transmitState.withLock { + $0.generation == generation + && $0.transport?.value === transport + && !$0.terminal + && $0.drainScheduled } } - private static func copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + private func observeTransmitQueueDepth( + _ depth: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport else { return } + state.observeQueueDepth(depth) } } -} -/// A virtio-net function that remains visible to the guest while its carrier is down. This is -/// deliberately a device, rather than an omitted backend: persistent interface identity and guest + private func completeTransmitHead( + _ chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport, + firstObserved: UInt64 + ) { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport else { return } + if state.headObservation?.matches(chain) == true { + state.headObservation = nil + } + state.consecutiveTransientFailures = 0 + } + recordTransmitCompletionLatency(from: firstObserved) + } + + private func recordTransmitCompletionLatency(from firstObserved: UInt64) { + let elapsed = Self.monotonicNanoseconds() &- firstObserved + transmitCompletionLatencyNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + transmitState.withLock { state in + state.maximumCompletionLatencyNanoseconds = max( + state.maximumCompletionLatencyNanoseconds, + elapsed + ) + } + } + + private func armTransmitRetry( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + let failureCount = transmitState.withLock { state -> Int? in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled, + state.retryRegistration == nil else { return nil } + state.consecutiveTransientFailures += 1 + return state.consecutiveTransientFailures + } + guard let failureCount else { return } + + let delay = transmitRetryDelayNanoseconds(failureCount: failureCount) + let source = DispatchSource.makeTimerSource(queue: transmitQueue) + let registration = TransmitRetryRegistration(generation: generation, source: source) + source.setEventHandler { [weak self, weak transport] in + guard let self, let transport else { return } + self.wakeTransmitRetry(generation: generation, transport: transport) + } + let cancellation = registration.cancellation + source.setCancelHandler { + cancellation.signal() + } + source.schedule( + deadline: .now() + .nanoseconds(delay), + leeway: .nanoseconds(max(1, min(delay / 4, 1_000_000))) + ) + + let installed = transmitState.withLock { state -> Bool in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled, + state.retryRegistration == nil else { return false } + state.retryRegistration = registration + state.drainScheduled = false + return true + } + source.activate() + if !installed { source.cancel() } + } + + private func transmitRetryDelayNanoseconds(failureCount: Int) -> Int { + var delay = limits.minimumTransmitRetryDelayNanoseconds + for _ in 1..= limits.maximumTransmitRetryDelayNanoseconds { break } + let (doubled, overflow) = delay.multipliedReportingOverflow(by: 2) + delay = overflow + ? limits.maximumTransmitRetryDelayNanoseconds + : min(limits.maximumTransmitRetryDelayNanoseconds, doubled) + } + return delay + } + + private func wakeTransmitRetry( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + let registration = transmitState.withLock { state -> TransmitRetryRegistration? in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + !state.drainScheduled, + let registration = state.retryRegistration, + registration.generation == generation else { return nil } + state.retryRegistration = nil + state.kickPending = false + state.drainScheduled = true + return registration + } + guard let registration else { return } + registration.source.cancel() + transmitRetryWakeups.wrappingAdd(1, ordering: .relaxed) + enqueueTransmitDrain(generation: generation, transport: transport) + } + + private func finishTransmitTurn( + generation: UInt64, + transport: VirtioMMIOTransport, + knownPendingWork: Bool + ) { + let continueDraining = transmitState.withLock { state -> Bool in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled else { return false } + if knownPendingWork || state.kickPending { + state.kickPending = false + return true + } + state.drainScheduled = false + return false + } + if continueDraining { + enqueueTransmitDrain(generation: generation, transport: transport) + } + } + + private static func monotonicNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + private static func isSupportedTransmitHeader(_ packet: [UInt8]) -> Bool { + guard packet.count >= headerLength else { return false } + let flags = packet[0] + let gsoType = packet[1] + // Unknown flag bits are ignored as required for extensibility. Known checksum/data-valid/ + // RSC semantics and every GSO type require features Dory does not offer. hdr_len, gso_size, + // csum_start, and csum_offset are deliberately not trusted or interpreted. num_buffers is + // an RX result field and is explicitly unused on transmitted packets, so a device must not + // turn its contents into a TX admission condition. In particular, Linux uses the 12-byte + // VERSION_1 header without initializing those two bytes unless MRG_RXBUF was negotiated. + return flags & knownHeaderFlagsMask == 0 + && gsoType == 0 // VIRTIO_NET_HDR_GSO_NONE + } + + private func drainSocket() { + let deferredResult = drainDeferredFrames() + guard deferredResult != .queueFault else { return } + var backlogIsWaiting = deferredResult == .waiting + var receiveBuffer = [UInt8](repeating: 0, count: maximumEthernetFrameLength + 1) + var receiveOperations = 0 + var receivedBytes = 0 + let maximumBytesBeforeAnotherReceive = + limits.maximumSocketReceiveBytesPerTurn - receiveBuffer.count + + while receiveOperations < limits.maximumSocketReceiveOperationsPerTurn, + receivedBytes <= maximumBytesBeforeAnotherReceive { + receiveOperations += 1 + switch receiveOneDatagram(into: &receiveBuffer) { + case .unavailable: + return + case .retry: + continue + case let .failed(code): + handleSocketReceiveFailure(code) + return + case let .empty(ready): + accountReceivedDatagram(byteCount: 0) + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + if ready == nil { + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + } + case let .frame(frame, ready): + receivedBytes += frame.count + accountReceivedDatagram(byteCount: frame.count) + guard frame.count <= maximumEthernetFrameLength else { + receiveTruncations.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + guard frame.count >= Self.ethernetHeaderLength else { + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + guard let ready else { + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + + // Once an older frame is waiting, every later frame joins the bounded backlog and + // cannot overtake it. Invalid guest buffers are consumed with used length zero. + if backlogIsWaiting || hasDeferredFrame(generation: ready.generation) { + backlogIsWaiting = true + deferOrDrop(frame, generation: ready.generation) + continue + } + switch deliver(frame, ready: ready) { + case let .delivered(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + case let .awaitingBuffer(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + deferOrDrop(frame, generation: ready.generation) + backlogIsWaiting = true + case .stale: + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + case let .queueFault(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return + } + } + } + scheduleReceiveDrainContinuation() + } + + private func scheduleReceiveDrainContinuation() { + guard receiveState.withLock({ !$0.terminal }) else { return } + receiveQueue.async { [weak self] in + self?.drainSocket() + } + } + + /// Drains the bounded host backlog before reading another socket frame, preserving arrival + /// order across yielded socket work turns. + private func drainDeferredFrames() -> DeferredDrainResult { + while let pending = receiveState.withLock({ state -> (DeferredFrame, ReadyTransport)? in + guard state.deferredHead < state.deferredFrames.count, + let transport = state.transport?.value else { return nil } + let frame = state.deferredFrames[state.deferredHead] + guard frame.generation == state.generation else { return nil } + return (frame, ReadyTransport(generation: state.generation, transport: transport)) + }) { + let frame = pending.0 + let ready = pending.1 + switch deliver(frame.bytes, ready: ready) { + case let .delivered(wantsInterrupt): + receiveState.withLock { + $0.dequeueDeferredFrame(generation: ready.generation) + } + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + case let .awaitingBuffer(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return .waiting + case .stale: + return .drained + case let .queueFault(wantsInterrupt): + receiveState.withLock { + $0.dequeueDeferredFrame(generation: ready.generation) + } + // A malformed later descriptor must not hide valid zero-length completions that + // were already published while searching for a usable RX buffer. + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return .queueFault + } + } + return .drained + } + + private func deliver(_ frame: [UInt8], ready: ReadyTransport) -> DeliveryResult { + ready.transport.withQueueLock { + guard isCurrent(ready) else { return .stale } + let virtqueue = ready.transport.queues[0] + var wantsInterrupt = false + + while isCurrent(ready) { + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { + return .awaitingBuffer(wantsInterrupt: wantsInterrupt) + } + chain = next + } catch { + // pop() has consumed this malformed available-ring entry before descriptor + // resolution. Do not spin over subsequent entries in the same drain turn. + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(wantsInterrupt: wantsInterrupt) + } + let requiredCapacity = Self.headerLength + frame.count + let disposition = chain.withLeaseHeld { access -> DescriptorDisposition in + guard access.writableSegmentCount > 0, + access.readableSegmentCount == 0 else { return .wrongDirection } + guard access.writableByteCount >= requiredCapacity else { + return .insufficientCapacity + } + return .writable + } ?? .wrongDirection + + switch disposition { + case .wrongDirection: + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + wantsInterrupt = wantsInterrupt || wants + case .insufficientCapacity: + receiveInsufficientCapacity.wrappingAdd(1, ordering: .relaxed) + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + wantsInterrupt = wantsInterrupt || wants + case .writable: + var packet = [UInt8](repeating: 0, count: Self.headerLength) + packet[10] = 1 // num_buffers = 1; MRG_RXBUF is not offered. + packet.append(contentsOf: frame) + let written = chain.withLeaseHeld { $0.writeBytes(packet) } ?? 0 + // Capacity and every segment were validated under the same queue lifecycle + // lease. A short result can only mean reset revoked publication; used length + // zero prevents the guest from observing a partial packet as delivered. + guard written == packet.count else { + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + return isCurrent(ready) + ? .awaitingBuffer(wantsInterrupt: wantsInterrupt || wants) + : .stale + } + guard let wants = pushReceiveCompletion( + chain, + written: written, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + return .delivered(wantsInterrupt: wantsInterrupt || wants) + } + } + return .stale + } + } + + private func pushReceiveCompletion( + _ chain: VirtqueueChain, + written: Int, + to virtqueue: Virtqueue + ) -> Bool? { + do { + return try virtqueue.push(chain, written: written) + } catch { + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + return nil + } + } + + private func notifyUsedIfCurrent(_ wantsInterrupt: Bool, ready: ReadyTransport) { + guard wantsInterrupt else { return } + ready.transport.withQueueLock { + guard isCurrent(ready) else { return } + ready.transport.notifyUsed() + } + } + + private func isCurrent(_ ready: ReadyTransport) -> Bool { + receiveState.withLock { state in + state.generation == ready.generation + && state.transport?.value === ready.transport + } + } + + private func hasDeferredFrame(generation: UInt64) -> Bool { + receiveState.withLock { state in + state.generation == generation && state.deferredCount > 0 + } + } + + private func deferOrDrop(_ frame: [UInt8], generation: UInt64) { + let admission = receiveState.withLock { state -> DeferredAdmission in + guard state.generation == generation, + state.transport?.value != nil else { return .stale } + guard state.deferredCount < limits.maximumDeferredReceiveFrames else { + return .atCapacity + } + let (newByteCount, overflow) = state.deferredBytes.addingReportingOverflow(frame.count) + guard !overflow, newByteCount <= limits.maximumDeferredReceiveBytes else { + return .atCapacity + } + state.deferredFrames.append(DeferredFrame(generation: generation, bytes: frame)) + state.deferredBytes = newByteCount + return .accepted + } + switch admission { + case .accepted: + receiveDeferred.wrappingAdd(1, ordering: .relaxed) + case .atCapacity: + receiveBacklogDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + case .stale: + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + } + } + + private func receiveOneDatagram(into buffer: inout [UInt8]) -> ReceivedDatagram { + socketReceiveLock.lock() + let ready = receiveState.withLock { state -> ReadyTransport? in + guard let transport = state.transport?.value else { return nil } + return ReadyTransport(generation: state.generation, transport: transport) + } + let receiveResult: (count: Int, code: Int32)? = socketOwner.withDescriptor { descriptor in + buffer.withUnsafeMutableBytes { + let count = recv(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + } + socketReceiveLock.unlock() + + guard let receiveResult else { return .unavailable } + let received = receiveResult.count + let code = receiveResult.code + + if received > 0 { + return .frame(Array(buffer[0.. InactivePurgeResult { + var buffer = [UInt8](repeating: 0, count: maximumEthernetFrameLength + 1) + var receiveOperations = 0 + var receivedBytes = 0 + let maximumBytesBeforeAnotherReceive = + limits.maximumSocketReceiveBytesPerTurn - buffer.count + while receiveOperations < limits.maximumSocketReceiveOperationsPerTurn, + receivedBytes <= maximumBytesBeforeAnotherReceive { + receiveOperations += 1 + guard let result: (count: Int, code: Int32) = socketOwner.withDescriptor({ descriptor in + buffer.withUnsafeMutableBytes { + let count = recv(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + }) else { return .failed(EBADF) } + let received = result.count + if received >= 0 { + receivedBytes += received + accountReceivedDatagram(byteCount: received) + if received > maximumEthernetFrameLength { + receiveTruncations.wrappingAdd(1, ordering: .relaxed) + } else if received < Self.ethernetHeaderLength { + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + } + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + if result.code == EINTR { continue } + if result.code == EAGAIN || result.code == EWOULDBLOCK { return .drained } + return .failed(result.code) + } + return .budgetExhausted + } + + /// A non-transient recv failure is terminal for this connected Unix socket. Keeping a read + /// source active after EBADF/EIO would spin indefinitely, so one path accounts the failure, + /// revokes the device generation, and hands descriptor retirement to the source cancel handler. + /// Internal visibility is an intentional deterministic test seam for an otherwise hard-to- + /// induce Darwin source error. + func handleSocketReceiveFailure(_ code: Int32) { + guard code != EINTR, code != EAGAIN, code != EWOULDBLOCK else { return } + if quiesceReceiveSocket() { + receiveSocketErrors.wrappingAdd(1, ordering: .relaxed) + } + } + + private func failReceiveActivation() { + if quiesceReceiveSocket() { + receiveActivationFailures.wrappingAdd(1, ordering: .relaxed) + } + } + + @discardableResult + private func quiesceReceiveSocket() -> Bool { + socketOwner.disableOperations() + // One connected datagram socket serves both directions. A terminal receive failure revokes + // TX admission too, rather than letting a retry timer consume descriptors against EBADF. + terminateTransmitLifecycle() + let transition = receiveState.withLock { state -> (discarded: Int, isNew: Bool) in + guard !state.terminal else { return (0, false) } + state.terminal = true + _ = state.advanceGeneration() + state.transport = nil + state.deviceIsReady = false + state.receiveQueueIsReady = false + return (state.clearDeferredFrames(), true) + } + guard transition.isNew else { return false } + accountInactiveDrops(transition.discarded) + if let registration = receiveSourceRegistrationSnapshot() { + registration.source.cancel() + } else { + socketOwner.retire() + } + return true + } + + private func accountReceivedDatagram(byteCount: Int) { + receivePackets.wrappingAdd(1, ordering: .relaxed) + receiveBytes.wrappingAdd(UInt64(byteCount), ordering: .relaxed) + } + + private func accountInactiveDrops(_ count: Int) { + guard count > 0 else { return } + let amount = UInt64(count) + receiveInactiveDrops.wrappingAdd(amount, ordering: .relaxed) + receiveDrops.wrappingAdd(amount, ordering: .relaxed) + } + + public var statistics: VirtioNetStatistics { + let now = Self.monotonicNanoseconds() + let transmitGauges = transmitState.withLock { state in + ( + maximumLatency: state.maximumCompletionLatencyNanoseconds, + oldestPendingLatency: state.headObservation.map { + now &- $0.firstObservedNanoseconds + } ?? 0, + queueDepth: UInt64(state.queueDepth), + queueHighWatermark: UInt64(state.queueHighWatermark) + ) + } + return VirtioNetStatistics( + transmitPackets: transmitPackets.load(ordering: .relaxed), + transmitBytes: transmitBytes.load(ordering: .relaxed), + transmitDrops: transmitDrops.load(ordering: .relaxed), + transmitMalformed: transmitMalformed.load(ordering: .relaxed), + transmitOversized: transmitOversized.load(ordering: .relaxed), + transmitInvalidDescriptors: transmitInvalidDescriptors.load(ordering: .relaxed), + transmitBackpressure: transmitBackpressure.load(ordering: .relaxed), + transmitSocketErrors: transmitSocketErrors.load(ordering: .relaxed), + transmitRetryWakeups: transmitRetryWakeups.load(ordering: .relaxed), + transmitBoundedDrainStops: transmitBoundedDrainStops.load(ordering: .relaxed), + transmitCompletions: transmitCompletions.load(ordering: .relaxed), + transmitCompletionLatencyNanoseconds: transmitCompletionLatencyNanoseconds.load( + ordering: .relaxed + ), + transmitMaximumCompletionLatencyNanoseconds: transmitGauges.maximumLatency, + transmitOldestPendingLatencyNanoseconds: transmitGauges.oldestPendingLatency, + transmitQueueDepth: transmitGauges.queueDepth, + transmitQueueHighWatermark: transmitGauges.queueHighWatermark, + receivePackets: receivePackets.load(ordering: .relaxed), + receiveBytes: receiveBytes.load(ordering: .relaxed), + receiveDeferred: receiveDeferred.load(ordering: .relaxed), + receiveDrops: receiveDrops.load(ordering: .relaxed), + receiveTruncations: receiveTruncations.load(ordering: .relaxed), + receiveMalformed: receiveMalformed.load(ordering: .relaxed), + receiveInvalidDescriptors: receiveInvalidDescriptors.load(ordering: .relaxed), + receiveInsufficientCapacity: receiveInsufficientCapacity.load(ordering: .relaxed), + receiveBacklogDrops: receiveBacklogDrops.load(ordering: .relaxed), + receiveInactiveDrops: receiveInactiveDrops.load(ordering: .relaxed), + receiveSocketErrors: receiveSocketErrors.load(ordering: .relaxed), + receiveActivationFailures: receiveActivationFailures.load(ordering: .relaxed) + ) + } + + private static func makeOwnedConnectedSocket( + socketPath: String, + remotePath: String + ) throws -> SocketOwner { + try validateSocketPath(socketPath) + try validateSocketPath(remotePath) + let localParentPath = (socketPath as NSString).deletingLastPathComponent + let remoteParentPath = (remotePath as NSString).deletingLastPathComponent + let localParentIdentity = try validateTrustedParentDirectory(of: socketPath) + let remoteParentIdentity = try validateTrustedParentDirectory(of: remotePath) + guard let remoteIdentity = socketIdentity(at: remotePath) else { + throw VMError.invalidConfiguration( + "virtio-net remote endpoint is not a same-user Unix socket: \(remotePath)" + ) + } + + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + try retireStaleLocalEndpointIfPresent( + socketPath, + parentPath: localParentPath, + parentIdentity: localParentIdentity + ) + + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { + throw systemCallError("create Unix datagram socket", path: socketPath, code: errno) + } + var boundIdentity: SocketPathIdentity? + do { + try configureSocket(descriptor) + var localAddress = try unixAddress(socketPath) + let bindResult = withUnsafePointer(to: &localAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw systemCallError("bind Unix datagram socket", path: socketPath, code: errno) + } + guard chmod(socketPath, 0o600) == 0 else { + throw systemCallError("chmod Unix datagram socket", path: socketPath, code: errno) + } + guard socketPermissions(at: socketPath) == 0o600 else { + throw systemCallError( + "verify private Unix datagram permissions", + path: socketPath, + code: EPERM + ) + } + guard let identity = socketIdentity(at: socketPath) else { + throw systemCallError("capture Unix datagram identity", path: socketPath, code: ESTALE) + } + boundIdentity = identity + guard directoryIdentity(at: localParentPath) == localParentIdentity else { + throw systemCallError("revalidate Unix datagram parent", path: localParentPath, code: ESTALE) + } + + var remoteAddress = try unixAddress(remotePath) + let connectResult = withUnsafePointer(to: &remoteAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard connectResult == 0 else { + throw systemCallError("connect Unix datagram socket", path: remotePath, code: errno) + } + guard socketIdentity(at: remotePath) == remoteIdentity else { + throw systemCallError("revalidate Unix datagram peer", path: remotePath, code: ESTALE) + } + guard directoryIdentity(at: remoteParentPath) == remoteParentIdentity else { + throw systemCallError("revalidate Unix datagram peer parent", path: remoteParentPath, code: ESTALE) + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + let peerCredentialResult = getpeereid(descriptor, &peerUID, &peerGID) + let usedPathnamePeerAuthentication: Bool + if peerCredentialResult == 0 { + guard peerUID == geteuid() else { + throw VMError.invalidConfiguration( + "virtio-net remote endpoint belongs to uid \(peerUID), expected \(geteuid())" + ) + } + usedPathnamePeerAuthentication = false + } else { + let peerCredentialError = errno + // Apple Libc's getpeereid(3) is defined only for SOCK_STREAM. XNU's + // uipc_ctloutput returns EINVAL for LOCAL_PEERCRED on a pathname SOCK_DGRAM that + // has no reciprocal peer association. Only that documented capability absence (or + // ENOTSUP on another Darwin release) may fall back to the already captured and + // post-connect revalidated same-euid socket and trusted-parent identities. + guard peerCredentialError == EINVAL || peerCredentialError == ENOTSUP else { + throw systemCallError( + "authenticate Unix datagram peer", + path: remotePath, + code: peerCredentialError + ) + } + usedPathnamePeerAuthentication = true + } + + try setSocketBuffer(descriptor, option: SO_SNDBUF, bytes: 1 << 20) + try setSocketBuffer(descriptor, option: SO_RCVBUF, bytes: 4 << 20) + + // gvproxy <= 0.8.6 requires this handshake; newer releases retain compatibility. + let magicResult: (count: Int, code: Int32) = vfkitMagic.withUnsafeBytes { + let count = send(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + guard magicResult.count == vfkitMagic.count else { + // Unix datagram sends are atomic, so a nonnegative short result is an invariant + // failure rather than an errno-bearing partial registration. + let code = magicResult.count < 0 ? magicResult.code : EIO + throw systemCallError("register vfkit peer", path: remotePath, code: code) + } + guard socketIdentity(at: socketPath) == identity else { + throw systemCallError("retain Unix datagram identity", path: socketPath, code: ESTALE) + } + guard socketIdentity(at: remotePath) == remoteIdentity else { + throw systemCallError("retain Unix datagram peer identity", path: remotePath, code: ESTALE) + } + guard directoryIdentity(at: localParentPath) == localParentIdentity, + directoryIdentity(at: remoteParentPath) == remoteParentIdentity else { + throw systemCallError("retain Unix datagram parent authority", path: socketPath, code: ESTALE) + } + return SocketOwner( + descriptor: descriptor, + localPath: socketPath, + localIdentity: identity, + parentPath: localParentPath, + parentIdentity: localParentIdentity, + usedPathnamePeerAuthentication: usedPathnamePeerAuthentication + ) + } catch { + if let boundIdentity, + directoryIdentity(at: localParentPath) == localParentIdentity { + unlinkSocketIfOwnedLocked(socketPath, identity: boundIdentity) + } + close(descriptor) + throw error + } + } + + private static func retireStaleLocalEndpointIfPresent( + _ path: String, + parentPath: String, + parentIdentity: DirectoryIdentity + ) throws { + guard directoryIdentity(at: parentPath) == parentIdentity else { + throw systemCallError("revalidate Unix datagram parent", path: parentPath, code: ESTALE) + } + var info = stat() + if lstat(path, &info) != 0 { + guard errno == ENOENT else { + throw systemCallError("inspect Unix datagram path", path: path, code: errno) + } + return + } + guard let identity = socketIdentity(at: path) else { + throw VMError.invalidConfiguration( + "refusing to replace a non-socket, symlink, multiply-linked, or foreign endpoint: \(path)" + ) + } + switch probeExistingDatagramEndpoint(path) { + case .live: + throw VMError.invalidConfiguration("refusing to replace a live Unix datagram endpoint: \(path)") + case let .indeterminate(code): + throw systemCallError("prove Unix datagram endpoint stale", path: path, code: code) + case .stale: + break + } + guard socketIdentity(at: path) == identity else { + throw systemCallError("revalidate stale Unix datagram endpoint", path: path, code: ESTALE) + } + guard directoryIdentity(at: parentPath) == parentIdentity else { + throw systemCallError("revalidate stale Unix datagram parent", path: parentPath, code: ESTALE) + } + guard unlink(path) == 0 else { + throw systemCallError("remove stale Unix datagram endpoint", path: path, code: errno) + } + } + + private static func probeExistingDatagramEndpoint(_ path: String) -> ExistingEndpointProbe { + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + do { + try configureSocket(descriptor) + var address = try unixAddress(path) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { return .live } + let code = errno + if code == ECONNREFUSED || code == ENOENT { return .stale } + return .indeterminate(code) + } catch { + return .indeterminate(errno == 0 ? EIO : errno) + } + } + + private static func retireOwnedSocket( + descriptor: Int32, + path: String, + identity: SocketPathIdentity, + parentPath: String, + parentIdentity: DirectoryIdentity + ) { + socketPathMutationLock.lock() + if directoryIdentity(at: parentPath) == parentIdentity { + unlinkSocketIfOwnedLocked(path, identity: identity) + } + close(descriptor) + socketPathMutationLock.unlock() + } + + private static func unlinkSocketIfOwnedLocked( + _ path: String, + identity: SocketPathIdentity + ) { + guard socketIdentity(at: path) == identity else { return } + _ = unlink(path) + } + + private static func socketIdentity(at path: String) -> SocketPathIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), + info.st_uid == geteuid(), + info.st_nlink == 1 else { return nil } + return SocketPathIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid + ) + } + + private static func socketPermissions(at path: String) -> mode_t? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK) else { return nil } + return info.st_mode & 0o777 + } + + private static func directoryIdentity(at path: String) -> DirectoryIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return DirectoryIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid, + permissions: info.st_mode & 0o7777 + ) + } + + private static func validateSocketPath(_ path: String) throws { + let bytes = Array(path.utf8) + let address = sockaddr_un() + let maximumBytes = MemoryLayout.size(ofValue: address.sun_path) - 1 + guard path.hasPrefix("/"), !bytes.isEmpty else { + throw VMError.invalidConfiguration("Unix datagram socket path must be absolute: \(path)") + } + guard !bytes.contains(0) else { + throw VMError.invalidConfiguration("Unix datagram socket path contains a NUL byte: \(path)") + } + guard (path as NSString).standardizingPath == path else { + throw VMError.invalidConfiguration("Unix datagram socket path is not canonical: \(path)") + } + guard bytes.count <= maximumBytes else { + throw VMError.invalidConfiguration( + "Unix datagram socket path is too long (\(bytes.count) bytes, maximum \(maximumBytes)): \(path)" + ) + } + } + + private static func validateTrustedParentDirectory(of path: String) throws -> DirectoryIdentity { + let parent = (path as NSString).deletingLastPathComponent + var info = stat() + guard lstat(parent, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + info.st_uid == geteuid(), + info.st_mode & 0o022 == 0 else { + throw VMError.invalidConfiguration( + "Unix datagram socket parent must be a same-user, non-writable directory: \(parent)" + ) + } + + // A private leaf is not authority if another user can rename it through a writable + // ancestor. Validate the resolved chain as root/current-user owned and non-writable, with + // the standard sticky-directory exception that makes /private/tmp safe for owned children. + guard let resolvedPointer = realpath(parent, nil) else { + throw systemCallError("resolve Unix datagram parent", path: parent, code: errno) + } + defer { free(resolvedPointer) } + let resolvedParent = String(cString: resolvedPointer) + var ancestorPath = "" + for component in resolvedParent.split(separator: "/") { + ancestorPath += "/" + component + var ancestor = stat() + guard lstat(ancestorPath, &ancestor) == 0, + ancestor.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + ancestor.st_uid == 0 || ancestor.st_uid == geteuid() else { + throw VMError.invalidConfiguration( + "Unix datagram socket ancestor is not trusted: \(ancestorPath)" + ) + } + let isGroupOrWorldWritable = ancestor.st_mode & 0o022 != 0 + let hasStickyOwnershipProtection = ancestor.st_mode & mode_t(S_ISVTX) != 0 + guard !isGroupOrWorldWritable || hasStickyOwnershipProtection else { + throw VMError.invalidConfiguration( + "Unix datagram socket ancestor is writable without sticky protection: \(ancestorPath)" + ) + } + } + guard let identity = directoryIdentity(at: parent) else { + throw systemCallError("capture Unix datagram parent", path: parent, code: ESTALE) + } + return identity + } + + private static func unixAddress(_ path: String) throws -> sockaddr_un { + try validateSocketPath(path) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + return address + } + + private static func configureSocket(_ descriptor: Int32) throws { + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + throw systemCallError("set close-on-exec on network socket", path: "", code: errno) + } + let statusFlags = fcntl(descriptor, F_GETFL) + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 else { + throw systemCallError("make network socket nonblocking", path: "", code: errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw systemCallError("set no-sigpipe on network socket", path: "", code: errno) + } + } + + private static func setSocketBuffer(_ descriptor: Int32, option: Int32, bytes: Int32) throws { + var value = bytes + guard setsockopt( + descriptor, + SOL_SOCKET, + option, + &value, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw systemCallError("set network socket buffer option \(option)", path: "", code: errno) + } + } + + private static func systemCallError(_ operation: String, path: String, code: Int32) -> VMError { + let suffix = path.isEmpty ? "" : " \(path)" + return VMError.invalidConfiguration("cannot \(operation)\(suffix): errno \(code)") + } + + /// Test-only observability for both halves of the descriptor inheritance invariant. + var isSocketNonblockingForTesting: Bool { + let flags = fcntl(socketOwner.descriptor, F_GETFL) + return flags >= 0 && flags & O_NONBLOCK != 0 + } + + var isSocketCloseOnExecForTesting: Bool { + let flags = fcntl(socketOwner.descriptor, F_GETFD) + return flags >= 0 && flags & FD_CLOEXEC != 0 + } + + var usesPathnamePeerAuthenticationForTesting: Bool { + socketOwner.usedPathnamePeerAuthentication + } + + var effectiveMTUForTesting: Int { + maximumEthernetFrameLength - Self.ethernetHeaderLength + } + + var deferredReceiveResourceSnapshotForTesting: (frames: Int, bytes: Int) { + receiveState.withLock { ($0.deferredCount, $0.deferredBytes) } + } + + var isReceiveTerminalForTesting: Bool { + receiveState.withLock { $0.terminal } + } + + var isReceiveActiveForTesting: Bool { + receiveState.withLock { $0.transport?.value != nil } + } + + func synchronizeReceiveQueueForTesting() { + receiveQueue.sync {} + } + + func withReceiveQueueSerializedForTesting( + _ body: () throws -> Result + ) rethrows -> Result { + try receiveQueue.sync(execute: body) + } + + func setTransmitDropCountForTesting(_ value: UInt64) { + transmitDrops.store(value, ordering: .relaxed) + } + + func synchronizeTransmitQueueForTesting() { + transmitQueue.sync {} + } + + @discardableResult + func triggerTransmitRetryForTesting() -> Bool { + guard let target = transmitState.withLock({ state in + state.retryRegistration.flatMap { registration in + state.transport?.value.map { (registration.generation, $0) } + } + }) else { return false } + wakeTransmitRetry(generation: target.0, transport: target.1) + return true + } + + var isTransmitRetryPendingForTesting: Bool { + transmitState.withLock { $0.retryRegistration != nil } + } +} + +/// A virtio-net function that remains visible to the guest while its carrier is down. This is +/// deliberately a device, rather than an omitted backend: persistent interface identity and guest /// configuration survive a later reconnect without accidentally granting host connectivity. public final class VirtioDisconnectedNet: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 1 @@ -341,28 +2177,34 @@ public final class VirtioDisconnectedNet: VirtioDeviceBackend, @unchecked Sendab public init( macAddress: [UInt8] = VirtioNet.guestMAC, - maximumTransmissionUnit: UInt16? = nil + maximumTransmissionUnit: UInt16 ) { precondition(macAddress.count == 6, "a virtio-net MAC address must contain six bytes") + precondition( + (1_280...9_000).contains(Int(maximumTransmissionUnit)), + "virtio-net MTU must be 1280...9000 bytes" + ) // virtio_net_config.mac followed by little-endian status. A zero status keeps LINK_UP // clear, which Linux reports as NO-CARRIER while retaining the interface. - deviceFeatures = (1 << 5) | (1 << 16) - | (maximumTransmissionUnit == nil ? 0 : (1 << 3)) - if let maximumTransmissionUnit { - configSpace = macAddress + [0, 0, 0, 0] - + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), - UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] - } else { - configSpace = macAddress + [0, 0] - } + deviceFeatures = (1 << 5) | (1 << 16) | (1 << 3) + configSpace = macAddress + [0, 0, 0, 0] + + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), + UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { guard queue == 1 else { return } let virtqueue = transport.queues[1] var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - interrupt = ((try? virtqueue.push(chain, written: 0)) ?? false) || interrupt + while true { + do { + guard let chain = try virtqueue.pop() else { break } + interrupt = try virtqueue.push(chain, written: 0) || interrupt + } catch { + // A malformed disconnected TX queue cannot be completed safely; stop this drain + // instead of conflating the fault with an empty queue and walking later entries. + break + } } if interrupt { transport.notifyUsed() } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift index 1b877d87..1bb89baa 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift @@ -1,31 +1,497 @@ import Foundation import Security +import Synchronization + +public struct VirtioRngStatistics: Equatable, Sendable { + public var completedRequests: UInt64 + public var bytesProvided: UInt64 + public var invalidRequests: UInt64 + public var entropyFailures: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var entropyProcessingNanoseconds: UInt64 + public var maximumEntropyProcessingNanoseconds: UInt64 +} + +/// Device-specific work bounds. VirtIO permits the device to return fewer bytes than the guest +/// offered, and Linux currently submits only a cache-line-sized buffer. A fixed partial completion +/// therefore avoids guest-sized entropy work while remaining protocol-correct. The turn bound is +/// intentionally much smaller than the ring: a busy guest yields between batches without needing +/// another notification to make progress. +struct VirtioRngLimits: Equatable, Sendable { + static let production = VirtioRngLimits( + maximumBytesPerRequest: 4 * 1_024, + maximumRequestsPerWorkerTurn: 8 + ) + + let maximumBytesPerRequest: Int + let maximumRequestsPerWorkerTurn: Int + + init(maximumBytesPerRequest: Int, maximumRequestsPerWorkerTurn: Int) { + precondition(maximumBytesPerRequest > 0) + precondition(maximumRequestsPerWorkerTurn > 0) + precondition(maximumRequestsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumBytesPerRequest = maximumBytesPerRequest + self.maximumRequestsPerWorkerTurn = maximumRequestsPerWorkerTurn + } +} /// virtio-entropy: fills guest buffers from the host CSPRNG. Keeps the guest crng healthy in a -/// machine with almost no interrupt-timing entropy. -public final class VirtioRng: VirtioDeviceBackend { +/// machine with almost no interrupt-timing entropy. Queue notifications only schedule a bounded +/// serial worker turn; CSPRNG calls and used-ring publication never run on the notifying vCPU. +public final class VirtioRng: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 4 public let queueCount = 1 public let deviceFeatures: UInt64 = 0 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged public var configSpace: [UInt8] { [] } - public init() {} + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct WorkerState { + var transport: WeakTransportReference? + var generation: UInt64 = 1 + var scheduled = false + var kickPending = false + } + + private enum RequestAdmission { + case accepted(Int) + case invalid + case revoked + } + + private enum PreparedWork { + case empty + case completed(wantsInterrupt: Bool) + case entropy(chain: VirtqueueChain, requestedBytes: Int) + case fault + case stale + } + + private enum EntropyCompletion { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private let limits: VirtioRngLimits + private let fillEntropy: @Sendable (UnsafeMutableRawBufferPointer) -> Bool + private let submitWork: (@escaping @Sendable () -> Void) -> Void + private let monotonicNanoseconds: @Sendable () -> UInt64 + private let workerStateLock = NSLock() + // Reset and QueueReady callbacks hold the transport lock before entering this fence. A worker + // never takes the transport lock while holding it. Thus an in-flight entropy fill and its one + // lease-held guest write finish before lifecycle revocation, without a lock-order cycle. + private let lifecycleFence = NSLock() + private var workerState = WorkerState() + private let completedRequests = Atomic(0) + private let bytesProvided = Atomic(0) + private let invalidRequests = Atomic(0) + private let entropyFailures = Atomic(0) + private let queueFaults = Atomic(0) + private let boundedDrainStops = Atomic(0) + private let workerTurns = Atomic(0) + private let workerYields = Atomic(0) + private let coalescedWorkerRequests = Atomic(0) + private let revokedWorkerTurns = Atomic(0) + private let entropyProcessingNanoseconds = Atomic(0) + private let maximumEntropyProcessingNanoseconds = Mutex(0) + + public convenience init() { + let worker = DispatchQueue(label: "dev.dory.virtio-rng", qos: .utility) + self.init( + limits: .production, + fillEntropy: { buffer in + guard let baseAddress = buffer.baseAddress else { return false } + return SecRandomCopyBytes(kSecRandomDefault, buffer.count, baseAddress) + == errSecSuccess + }, + submitWork: { operation in worker.async(execute: operation) } + ) + } + + init( + limits: VirtioRngLimits, + fillEntropy: @escaping @Sendable (UnsafeMutableRawBufferPointer) -> Bool, + submitWork: @escaping (@escaping @Sendable () -> Void) -> Void, + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + } + ) { + self.limits = limits + self.fillEntropy = fillEntropy + self.submitWork = submitWork + self.monotonicNanoseconds = monotonicNanoseconds + } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[0] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - var written = 0 - for segment in chain.writableSegments { - if SecRandomCopyBytes(kSecRandomDefault, segment.length, segment.pointer) == errSecSuccess { - written += segment.length + guard queue == 0, transport.queues.indices.contains(queue) else { return } + let generation = workerStateLock.withLock { () -> UInt64? in + if let reference = workerState.transport { + if let existing = reference.value { + guard existing === transport else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return nil + } + } else { + // Rebinding a synthetic backend after its transport died must first make every + // closure queued for that transport stale. + advanceWorkerGenerationLocked() + workerState.transport = WeakTransportReference(transport) + } + } else { + workerState.transport = WeakTransportReference(transport) + } + + if workerState.scheduled { + workerState.kickPending = true + coalescedWorkerRequests.wrappingAdd(1, ordering: .relaxed) + return nil + } + workerState.scheduled = true + return workerState.generation + } + guard let generation else { return } + submitWorkerTurn(generation: generation, transport: transport) + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeWorker(transport: transport) + } + + public func queueStateChanged( + queue: Int, + ready: Bool, + transport: VirtioMMIOTransport + ) { + _ = ready + guard queue == 0 else { return } + revokeWorker(transport: transport) + } + + private func submitWorkerTurn(generation: UInt64, transport: VirtioMMIOTransport) { + submitWork { [weak self, weak transport] in + guard let self, let transport else { return } + self.runWorkerTurn(generation: generation, transport: transport) + } + } + + private func runWorkerTurn(generation: UInt64, transport: VirtioMMIOTransport) { + guard beginWorkerTurn(generation: generation, transport: transport) else { + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + workerTurns.wrappingAdd(1, ordering: .relaxed) + var handled = 0 + var wantsInterrupt = false + var stoppedOnFault = false + + while handled < limits.maximumRequestsPerWorkerTurn { + switch prepareWork(generation: generation, transport: transport) { + case .empty: + finishWorkerTurn( + generation: generation, + transport: transport, + wantsInterrupt: wantsInterrupt, + knownPendingWork: false + ) + return + case let .completed(interrupt): + handled += 1 + wantsInterrupt = wantsInterrupt || interrupt + case let .entropy(chain, requestedBytes): + handled += 1 + switch completeEntropy( + chain, + requestedBytes: requestedBytes, + generation: generation, + transport: transport + ) { + case let .published(interrupt): + wantsInterrupt = wantsInterrupt || interrupt + case .fault: + stoppedOnFault = true + case .stale: + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + case .fault: + stoppedOnFault = true + case .stale: + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + if stoppedOnFault { break } + } + + let pending = stoppedOnFault ? false : pendingWork( + generation: generation, + transport: transport + ) + if pending { + boundedDrainStops.wrappingAdd(1, ordering: .relaxed) + } + finishWorkerTurn( + generation: generation, + transport: transport, + wantsInterrupt: wantsInterrupt, + knownPendingWork: pending + ) + } + + private func prepareWork( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> PreparedWork { + transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return .stale + } + let virtqueue = transport.queues[0] + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { return .empty } + chain = next + } catch { + // pop() consumes a malformed available entry before descriptor resolution fails. + // Stop this turn explicitly rather than walking unrelated later entries. + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + + switch admitRequest(chain) { + case .invalid: + invalidRequests.wrappingAdd(1, ordering: .relaxed) + do { + switch try virtqueue.pushOutcome(chain, written: 0) { + case let .published(wantsInterrupt): + return .completed(wantsInterrupt: wantsInterrupt) + case .revoked: + return .stale + } + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + case .revoked: + return .stale + case let .accepted(requestedBytes): + // The popped chain and its exact queue lease are retained across the CSPRNG call. + // Only this chain can be published, and pushOutcome exposes lifecycle revocation. + return .entropy(chain: chain, requestedBytes: requestedBytes) + } + } + } + + private func completeEntropy( + _ chain: VirtqueueChain, + requestedBytes: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> EntropyCompletion { + // Allocate only the typed device ceiling, never the guest chain length. Entropy remains in + // a host-owned snapshot until the fill reports success, so a failed CSPRNG call cannot + // expose partially initialized bytes to the guest. + var entropy = [UInt8](repeating: 0, count: requestedBytes) + lifecycleFence.lock() + guard isCurrentWorker(generation: generation, transport: transport) else { + lifecycleFence.unlock() + return .stale + } + let startedAt = monotonicNanoseconds() + let didFill = entropy.withUnsafeMutableBytes(fillEntropy) + let written = didFill ? (chain.withLeaseHeld { $0.writeBytes(entropy) } ?? 0) : 0 + let elapsed = monotonicNanoseconds() &- startedAt + lifecycleFence.unlock() + + entropyProcessingNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + maximumEntropyProcessingNanoseconds.withLock { maximum in + maximum = max(maximum, elapsed) + } + + if didFill { + guard written == requestedBytes else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } else { + entropyFailures.wrappingAdd(1, ordering: .relaxed) + } + + return transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return .stale + } + do { + switch try transport.queues[0].pushOutcome(chain, written: written) { + case let .published(wantsInterrupt): + if didFill { + completedRequests.wrappingAdd(1, ordering: .relaxed) + bytesProvided.wrappingAdd(UInt64(written), ordering: .relaxed) + } + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + return .stale + } + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } + } + + private func pendingWork(generation: UInt64, transport: VirtioMMIOTransport) -> Bool { + transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return false + } + do { + return try transport.queues[0].pendingCount() > 0 + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return false + } + } + } + + private func finishWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport, + wantsInterrupt: Bool, + knownPendingWork: Bool + ) { + if wantsInterrupt { + transport.withQueueLock { + if isCurrentWorker(generation: generation, transport: transport) { + transport.notifyUsed() } } - let wants = (try? virtqueue.push(chain, written: written)) ?? false - interrupt = interrupt || wants } - if interrupt { - transport.notifyUsed() + + let shouldContinue = workerStateLock.withLock { () -> Bool in + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + return false + } + let pending = knownPendingWork || workerState.kickPending + workerState.kickPending = false + if !pending { + workerState.scheduled = false + } + return pending + } + if shouldContinue { + workerYields.wrappingAdd(1, ordering: .relaxed) + submitWorkerTurn(generation: generation, transport: transport) } } + + private func revokeWorker(transport: VirtioMMIOTransport) { + lifecycleFence.lock() + workerStateLock.withLock { + if let existing = workerState.transport?.value, existing !== transport { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return + } + if workerState.transport == nil { + workerState.transport = WeakTransportReference(transport) + } + advanceWorkerGenerationLocked() + } + lifecycleFence.unlock() + } + + private func advanceWorkerGenerationLocked() { + workerState.generation &+= 1 + if workerState.generation == 0 { + workerState.generation = 1 + } + workerState.scheduled = false + workerState.kickPending = false + } + + private func recordRevokedWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + revokedWorkerTurns.wrappingAdd(1, ordering: .relaxed) + // A typed queue-lease revocation can theoretically precede a lifecycle callback. If the + // worker generation is otherwise still current, revoke it here so no scheduled bit can + // strand later kicks. Normal reset/QueueReady paths have already advanced the generation. + workerStateLock.withLock { + if isCurrentWorkerLocked(generation: generation, transport: transport) { + advanceWorkerGenerationLocked() + } + } + } + + private func isCurrentWorker(generation: UInt64, transport: VirtioMMIOTransport) -> Bool { + workerStateLock.withLock { + isCurrentWorkerLocked(generation: generation, transport: transport) + } + } + + private func beginWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerStateLock.withLock { + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + return false + } + // Notifications coalesced before this turn began are covered by the queue snapshot it + // is about to drain. A later notification sets the bit again and forces a continuation. + workerState.kickPending = false + return true + } + } + + private func isCurrentWorkerLocked( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerState.transport?.value === transport + && workerState.generation == generation + && workerState.scheduled + } + + private func admitRequest(_ chain: VirtqueueChain) -> RequestAdmission { + chain.withLeaseHeld { access -> RequestAdmission in + // VirtIO 1.3 section 5.4.6.1 forbids device-readable entropy buffers. A raw + // zero-length descriptor is also not a usable request, even when another segment in + // the same chain has data. + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount == 0, + access.writableSegmentCount > 0, + access.writableByteCount > 0 else { return .invalid } + return .accepted(min(access.writableByteCount, limits.maximumBytesPerRequest)) + } ?? .revoked + } + + public var statistics: VirtioRngStatistics { + VirtioRngStatistics( + completedRequests: completedRequests.load(ordering: .relaxed), + bytesProvided: bytesProvided.load(ordering: .relaxed), + invalidRequests: invalidRequests.load(ordering: .relaxed), + entropyFailures: entropyFailures.load(ordering: .relaxed), + queueFaults: queueFaults.load(ordering: .relaxed), + boundedDrainStops: boundedDrainStops.load(ordering: .relaxed), + workerTurns: workerTurns.load(ordering: .relaxed), + workerYields: workerYields.load(ordering: .relaxed), + coalescedWorkerRequests: coalescedWorkerRequests.load(ordering: .relaxed), + revokedWorkerTurns: revokedWorkerTurns.load(ordering: .relaxed), + entropyProcessingNanoseconds: entropyProcessingNanoseconds.load(ordering: .relaxed), + maximumEntropyProcessingNanoseconds: maximumEntropyProcessingNanoseconds.withLock { $0 } + ) + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift index 81f34000..4eb1af49 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public enum VirtioSoundDirection: UInt8, Sendable { @@ -55,9 +56,116 @@ public protocol VirtioSoundHost: AnyObject, Sendable { func reset() } -/// VirtIO 1.2 sound device with one stereo-capable output stream and one stereo-capable input -/// stream. The Linux virtio-snd driver sees standard S16_LE 44.1/48 kHz PCM endpoints; actual Mac -/// device conversion and permission handling live behind `VirtioSoundHost`. +public struct VirtioSoundStatistics: Equatable, Sendable { + public var invalidControlChains: UInt64 + public var invalidEventChains: UInt64 + public var invalidPlaybackChains: UInt64 + public var invalidCaptureChains: UInt64 + public var completedPlaybackPeriods: UInt64 + public var completedCapturePeriods: UInt64 + public var timedOutPeriods: UInt64 + public var lateHostCompletions: UInt64 + public var backpressuredPeriods: UInt64 + public var queueFaults: UInt64 + public var publicationFaults: UInt64 + public var boundedDrainStops: UInt64 +} + +/// Frontend work/retention limits. Byte ceilings intentionally match the production Core Audio +/// backend, while the period and kick ceilings are independent untrusted-guest admission bounds. +struct VirtioSoundLimits: Equatable, Sendable { + static let production = VirtioSoundLimits( + maximumBufferBytes: 4 * 1_024 * 1_024, + maximumPeriodBytes: 1 * 1_024 * 1_024, + maximumPeriodsPerStream: 64, + maximumChainsPerKick: 64, + maximumBytesPerKick: 4 * 1_024 * 1_024, + maximumRetainedEventBuffers: 64, + completionTimeout: .seconds(30) + ) + + let maximumBufferBytes: Int + let maximumPeriodBytes: Int + let maximumPeriodsPerStream: Int + let maximumChainsPerKick: Int + let maximumBytesPerKick: Int + let maximumRetainedEventBuffers: Int + let completionTimeout: Duration + + init( + maximumBufferBytes: Int, + maximumPeriodBytes: Int, + maximumPeriodsPerStream: Int, + maximumChainsPerKick: Int, + maximumBytesPerKick: Int, + maximumRetainedEventBuffers: Int, + completionTimeout: Duration + ) { + precondition(maximumBufferBytes > 0) + precondition(maximumPeriodBytes > 0 && maximumPeriodBytes <= maximumBufferBytes) + precondition(maximumPeriodsPerStream > 0) + precondition(maximumChainsPerKick > 0) + precondition(maximumChainsPerKick <= Int(Virtqueue.maximumSize)) + precondition(maximumBytesPerKick >= maximumPeriodBytes) + precondition(maximumRetainedEventBuffers > 0) + precondition(completionTimeout > .zero) + self.maximumBufferBytes = maximumBufferBytes + self.maximumPeriodBytes = maximumPeriodBytes + self.maximumPeriodsPerStream = maximumPeriodsPerStream + self.maximumChainsPerKick = maximumChainsPerKick + self.maximumBytesPerKick = maximumBytesPerKick + self.maximumRetainedEventBuffers = maximumRetainedEventBuffers + self.completionTimeout = completionTimeout + } +} + +protocol VirtioSoundScheduledOperation: AnyObject, Sendable { + func cancel() +} + +protocol VirtioSoundCompletionScheduling: Sendable { + func schedule( + after delay: Duration, + operation: @escaping @Sendable () -> Void + ) -> any VirtioSoundScheduledOperation +} + +private final class TaskVirtioSoundScheduledOperation: + VirtioSoundScheduledOperation, + @unchecked Sendable +{ + private let task: Task + + init(task: Task) { + self.task = task + } + + func cancel() { task.cancel() } + + deinit { task.cancel() } +} + +private struct TaskVirtioSoundCompletionScheduler: VirtioSoundCompletionScheduling { + func schedule( + after delay: Duration, + operation: @escaping @Sendable () -> Void + ) -> any VirtioSoundScheduledOperation { + let task = Task { + do { + try await Task.sleep(for: delay) + } catch { + return + } + guard !Task.isCancelled else { return } + operation() + } + return TaskVirtioSoundScheduledOperation(task: task) + } +} + +/// VirtIO 1.3 sound with one output and one input PCM stream. No optional sound feature is +/// advertised: shared-memory transport, polling, period events, XRUN events, jacks, channel maps, +/// and mixer controls remain unsupported rather than being emulated incompletely. public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 25 public let deviceFeatures: UInt64 = 0 @@ -84,6 +192,18 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { case parameters case prepared case running + case faulted + } + + private enum PendingKind: Sendable { + case playback + case capture + } + + private enum CompletionSource: Equatable { + case host + case hostRejected + case watchdog } private struct Stream { @@ -97,9 +217,23 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { var chain: VirtqueueChain var generation: UInt64 var payloadBytes: Int + var watchdog: (any VirtioSoundScheduledOperation)? + } + + private struct OrderedLayout { + var readableBytes: Int + var writableBytes: Int } private static let streamCount = 2 + private static let controlStatusSize = 4 + private static let pcmTransferHeaderSize = 4 + private static let pcmStatusSize = 8 + private static let eventSize = 8 + private static let queryInfoSize = 16 + private static let setParametersSize = 24 + private static let pcmHeaderSize = 8 + private static let maximumControlRequestSize = setParametersSize private static let pcmInfoSize = 32 private static let s16Format: UInt8 = 5 private static let supportedFormats: UInt64 = 1 << s16Format @@ -108,6 +242,8 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { private let host: VirtioSoundHost private let log: @Sendable (String) -> Void + private let limits: VirtioSoundLimits + private let completionScheduler: any VirtioSoundCompletionScheduling private let lock = NSLock() private var streams = [ Stream(direction: .output), @@ -119,13 +255,56 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { private var streamGenerations = [UInt64](repeating: 1, count: streamCount) private var pendingPlayback = [UInt64: PendingIO]() private var pendingCapture = [UInt64: PendingIO]() + private var retainedEventBuffers = [VirtqueueChain]() + private var terminalQueues = Set() + private var statisticsState = VirtioSoundStatistics( + invalidControlChains: 0, + invalidEventChains: 0, + invalidPlaybackChains: 0, + invalidCaptureChains: 0, + completedPlaybackPeriods: 0, + completedCapturePeriods: 0, + timedOutPeriods: 0, + lateHostCompletions: 0, + backpressuredPeriods: 0, + queueFaults: 0, + publicationFaults: 0, + boundedDrainStops: 0 + ) - public init( + public convenience init( host: VirtioSoundHost, log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.init( + host: host, + log: log, + limits: .production, + completionScheduler: TaskVirtioSoundCompletionScheduler() + ) + } + + init( + host: VirtioSoundHost, + log: @escaping @Sendable (String) -> Void = { _ in }, + limits: VirtioSoundLimits, + completionScheduler: any VirtioSoundCompletionScheduling ) { self.host = host self.log = log + self.limits = limits + self.completionScheduler = completionScheduler + } + + deinit { + let watchdogs = lock.withLock { + let values = Array(pendingPlayback.values) + Array(pendingCapture.values) + pendingPlayback.removeAll() + pendingCapture.removeAll() + retainedEventBuffers.removeAll() + return values.compactMap(\.watchdog) + } + watchdogs.forEach { $0.cancel() } } public var configSpace: [UInt8] { @@ -136,10 +315,15 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { return bytes } + public var statistics: VirtioSoundStatistics { + lock.withLock { statisticsState } + } + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard (0.. 0 || captureCount > 0 { - log("virtio sound queue \(queue) disabled with playback=\(playbackCount), capture=\(captureCount)") + guard (0.. 0 || discardedCapture > 0 { + log( + "virtio sound queue \(queue) reconfigured ready=\(ready) with " + + "playback=\(discardedPlayback), capture=\(discardedCapture) pending" + ) } } private func drainControlQueue(_ transport: VirtioMMIOTransport) { let queue = transport.queues[0] var interrupt = false - while let chain = (try? queue.pop()) ?? nil { - let capacity = chain.writableSegments.reduce(0) { $0 + $1.length } + var handled = 0 + while handled < limits.maximumChainsPerKick { + guard let chain = pop(queue, queueIndex: 0) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> ([UInt8], Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes >= Self.controlStatusSize, + layout.readableBytes <= Self.maximumControlRequestSize, + layout.writableBytes >= Self.controlStatusSize else { return nil } + let request = access.readBytes(maximum: Self.maximumControlRequestSize) + return request.count == layout.readableBytes + ? (request, layout.writableBytes) + : nil + } ?? nil + guard let (request, capacity) = admission else { + lock.withLock { statisticsState.invalidControlChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 0, written: 0, interrupt: &interrupt) else { + break + } + continue + } let response = processControlRequest( - chain.readBytes(maximum: 64 * 1024), + request, responseCapacity: capacity, transport: transport ) - let written = chain.writeBytes(response) - interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt + let written = chain.withLeaseHeld { $0.writeBytes(response) } ?? 0 + guard written == response.count else { + recordPublicationFault(queue: 0, reason: "short control response write") + break + } + guard push(chain, queue: queue, queueIndex: 0, written: written, interrupt: &interrupt) else { + break + } + } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + /// Linux pre-populates eventq with exact writable 8-byte entries. No event-producing feature + /// is advertised, but retaining a bounded validated prefix prevents arbitrary guest chains + /// from becoming hidden state and leaves an explicit admission seam for future event support. + private func drainEventQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[1] + var interrupt = false + var handled = 0 + while handled < limits.maximumChainsPerKick { + guard lock.withLock({ retainedEventBuffers.count < limits.maximumRetainedEventBuffers }) else { + lock.withLock { statisticsState.backpressuredPeriods &+= 1 } + break + } + guard let chain = pop(queue, queueIndex: 1) else { break } + handled += 1 + let valid = chain.withLeaseHeld { access in + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount == 0 + && access.writableSegmentCount > 0 + && access.writableByteCount == Self.eventSize + } ?? false + if valid { + lock.withLock { retainedEventBuffers.append(chain) } + } else { + lock.withLock { statisticsState.invalidEventChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 1, written: 0, interrupt: &interrupt) else { + break + } + } } + recordBoundedStopIfNeeded(handled: handled, queue: queue) if interrupt { transport.notifyUsed() } } private func drainPlaybackQueue(_ transport: VirtioMMIOTransport) { let queue = transport.queues[2] - while let chain = (try? queue.pop()) ?? nil { - let request = chain.readBytes() - guard request.count >= 4 else { - completeImmediately(chain, queue: queue, status: .badMessage, transport: transport) + var interrupt = false + var handled = 0 + var copiedBytes = 0 + while handled < limits.maximumChainsPerKick { + if lock.withLock({ pendingPlayback.count >= limits.maximumPeriodsPerStream }) { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + break + } + guard let chain = pop(queue, queueIndex: 2) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> ([UInt8], Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes > Self.pcmTransferHeaderSize, + layout.readableBytes <= Self.pcmTransferHeaderSize + limits.maximumPeriodBytes, + layout.writableBytes == Self.pcmStatusSize else { return nil } + let request = access.readBytes( + maximum: Self.pcmTransferHeaderSize + limits.maximumPeriodBytes + ) + return request.count == layout.readableBytes + ? (request, layout.readableBytes - Self.pcmTransferHeaderSize) + : nil + } ?? nil + guard let (request, payloadBytes) = admission else { + lock.withLock { statisticsState.invalidPlaybackChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 2, written: 0, interrupt: &interrupt) else { + break + } + continue + } + guard payloadBytes <= limits.maximumBytesPerKick - copiedBytes else { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + guard completePlaybackImmediately(chain, queue: queue, interrupt: &interrupt) else { break } continue } + copiedBytes += payloadBytes let streamID = Int(request.leUInt32(at: 0)) - let audio = Data(request.dropFirst(4)) - let pending: (UInt64, PendingIO, VirtioSoundPCMParameters)? = lock.withLock { + let audio = Data(request[Self.pcmTransferHeaderSize...]) + let reservation: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { guard streamID == 0, streams[streamID].direction == .output, - streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running, + streams[streamID].lifecycle == .prepared + || streams[streamID].lifecycle == .running, let parameters = streams[streamID].parameters, - !audio.isEmpty, - audio.count % parameters.bytesPerFrame == 0 else { return nil } - let id = allocateRequestID() - let value = PendingIO( + payloadBytes <= parameters.periodBytes, + payloadBytes % parameters.bytesPerFrame == 0, + pendingPlayback.count < limits.maximumPeriodsPerStream, + Self.pendingBytes(pendingPlayback) <= parameters.bufferBytes - payloadBytes else { + return nil + } + let requestID = allocateRequestID() + pendingPlayback[requestID] = PendingIO( streamID: streamID, chain: chain, generation: streamGenerations[streamID], - payloadBytes: audio.count + payloadBytes: payloadBytes, + watchdog: nil ) - pendingPlayback[id] = value - return (id, value, parameters) + return (requestID, parameters) } - guard let (requestID, _, parameters) = pending else { - completeImmediately(chain, queue: queue, status: .ioError, transport: transport) + guard let (requestID, parameters) = reservation else { + lock.withLock { statisticsState.invalidPlaybackChains &+= 1 } + guard completePlaybackImmediately(chain, queue: queue, interrupt: &interrupt) else { break } continue } - let accepted = host.enqueuePlayback(audio, parameters: parameters) { [weak self, weak transport] success, latency in + let accepted = host.enqueuePlayback(audio, parameters: parameters) { + [weak self, weak transport] success, latency in guard let self, let transport else { return } self.completePlayback( requestID: requestID, success: success, latencyBytes: latency, + source: .host, transport: transport ) } - if !accepted { + if accepted { + installWatchdog(kind: .playback, requestID: requestID, transport: transport) + } else { completePlayback( requestID: requestID, success: false, latencyBytes: 0, + source: .hostRejected, transport: transport ) } } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } } private func drainCaptureQueue(_ transport: VirtioMMIOTransport) { let queue = transport.queues[3] - while let chain = (try? queue.pop()) ?? nil { - let request = chain.readBytes(maximum: 4) - let writableBytes = chain.writableSegments.reduce(0) { $0 + $1.length } - guard request.count == 4, writableBytes > 8 else { - completeCaptureImmediately( + var interrupt = false + var handled = 0 + var requestedBytes = 0 + while handled < limits.maximumChainsPerKick { + if lock.withLock({ pendingCapture.count >= limits.maximumPeriodsPerStream }) { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + break + } + guard let chain = pop(queue, queueIndex: 3) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> (UInt32, Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes == Self.pcmTransferHeaderSize, + layout.writableBytes > Self.pcmStatusSize, + layout.writableBytes <= limits.maximumPeriodBytes + Self.pcmStatusSize else { + return nil + } + let request = access.readBytes(maximum: Self.pcmTransferHeaderSize) + guard request.count == Self.pcmTransferHeaderSize else { return nil } + return (request.leUInt32(at: 0), layout.writableBytes - Self.pcmStatusSize) + } ?? nil + guard let (rawStreamID, payloadBytes) = admission else { + lock.withLock { statisticsState.invalidCaptureChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 3, written: 0, interrupt: &interrupt) else { + break + } + continue + } + guard payloadBytes <= limits.maximumBytesPerKick - requestedBytes else { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + guard completeCaptureImmediately( chain, - payloadBytes: max(0, writableBytes - 8), + payloadBytes: payloadBytes, queue: queue, - status: .badMessage, - transport: transport - ) + interrupt: &interrupt + ) else { break } continue } - let streamID = Int(request.leUInt32(at: 0)) - let payloadBytes = writableBytes - 8 - let pending: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { + requestedBytes += payloadBytes + let streamID = Int(rawStreamID) + let reservation: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { guard streamID == 1, streams[streamID].direction == .input, - streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running, + streams[streamID].lifecycle == .prepared + || streams[streamID].lifecycle == .running, let parameters = streams[streamID].parameters, - payloadBytes % parameters.bytesPerFrame == 0 else { return nil } - let id = allocateRequestID() - pendingCapture[id] = PendingIO( + payloadBytes <= parameters.periodBytes, + payloadBytes % parameters.bytesPerFrame == 0, + pendingCapture.count < limits.maximumPeriodsPerStream, + Self.pendingBytes(pendingCapture) <= parameters.bufferBytes - payloadBytes else { + return nil + } + let requestID = allocateRequestID() + pendingCapture[requestID] = PendingIO( streamID: streamID, chain: chain, generation: streamGenerations[streamID], - payloadBytes: payloadBytes + payloadBytes: payloadBytes, + watchdog: nil ) - return (id, parameters) + return (requestID, parameters) } - guard let (requestID, parameters) = pending else { - completeCaptureImmediately( + guard let (requestID, parameters) = reservation else { + lock.withLock { statisticsState.invalidCaptureChains &+= 1 } + guard completeCaptureImmediately( chain, payloadBytes: payloadBytes, queue: queue, - status: .ioError, - transport: transport - ) + interrupt: &interrupt + ) else { break } continue } let accepted = host.requestCapture(byteCount: payloadBytes, parameters: parameters) { @@ -287,38 +644,110 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { requestID: requestID, data: data, latencyBytes: latency, + source: .host, transport: transport ) } - if !accepted { + if accepted { + installWatchdog(kind: .capture, requestID: requestID, transport: transport) + } else { completeCapture( requestID: requestID, data: nil, latencyBytes: 0, + source: .hostRejected, transport: transport ) } } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + private func installWatchdog( + kind: PendingKind, + requestID: UInt64, + transport: VirtioMMIOTransport + ) { + let operation = completionScheduler.schedule(after: limits.completionTimeout) { + [weak self, weak transport] in + guard let self, let transport else { return } + switch kind { + case .playback: + self.completePlayback( + requestID: requestID, + success: false, + latencyBytes: 0, + source: .watchdog, + transport: transport + ) + case .capture: + self.completeCapture( + requestID: requestID, + data: nil, + latencyBytes: 0, + source: .watchdog, + transport: transport + ) + } + } + let installed = lock.withLock { + switch kind { + case .playback: + guard var pending = pendingPlayback[requestID] else { return false } + pending.watchdog = operation + pendingPlayback[requestID] = pending + case .capture: + guard var pending = pendingCapture[requestID] else { return false } + pending.watchdog = operation + pendingCapture[requestID] = pending + } + return true + } + if !installed { operation.cancel() } } private func completePlayback( requestID: UInt64, success: Bool, latencyBytes: UInt32, + source: CompletionSource, transport: VirtioMMIOTransport ) { var interrupt = false + var watchdog: (any VirtioSoundScheduledOperation)? + var published = false transport.withQueueLock { let pending: PendingIO? = lock.withLock { guard let value = pendingPlayback.removeValue(forKey: requestID), - value.generation == streamGenerations[value.streamID] else { return nil } + value.generation == streamGenerations[value.streamID] else { + if source == .host { statisticsState.lateHostCompletions &+= 1 } + return nil + } + if source == .watchdog { statisticsState.timedOutPeriods &+= 1 } + watchdog = value.watchdog return value } - guard let pending else { return } + guard let pending, + !isTerminal(2), + transport.queues[2].ready, + transport.queues[2].isLeaseValid(pending.chain) else { return } let status = Self.ioStatus(success ? .ok : .ioError, latencyBytes: latencyBytes) - let written = pending.chain.writeBytes(status) - interrupt = (try? transport.queues[2].push(pending.chain, written: written)) ?? false + let written = pending.chain.withLeaseHeld { $0.writeBytes(status) } ?? 0 + guard written == Self.pcmStatusSize else { + recordPublicationFault(queue: 2, reason: "short playback status write") + return + } + published = push( + pending.chain, + queue: transport.queues[2], + queueIndex: 2, + written: written, + interrupt: &interrupt + ) } + watchdog?.cancel() + if published { lock.withLock { statisticsState.completedPlaybackPeriods &+= 1 } } if interrupt { transport.notifyUsed() } } @@ -326,55 +755,96 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { requestID: UInt64, data: Data?, latencyBytes: UInt32, + source: CompletionSource, transport: VirtioMMIOTransport ) { var interrupt = false + var watchdog: (any VirtioSoundScheduledOperation)? + var published = false transport.withQueueLock { let pending: PendingIO? = lock.withLock { guard let value = pendingCapture.removeValue(forKey: requestID), - value.generation == streamGenerations[value.streamID] else { return nil } + value.generation == streamGenerations[value.streamID] else { + if source == .host { statisticsState.lateHostCompletions &+= 1 } + return nil + } + if source == .watchdog { statisticsState.timedOutPeriods &+= 1 } + watchdog = value.watchdog return value } - guard let pending else { return } + guard let pending, + !isTerminal(3), + transport.queues[3].ready, + transport.queues[3].isLeaseValid(pending.chain) else { return } let validData = data.flatMap { $0.count == pending.payloadBytes ? $0 : nil } - let payload = validData.map(Array.init) ?? [] - let payloadWritten = pending.chain.writeBytes(payload) - let status = Self.ioStatus( - validData == nil ? .ioError : .ok, - latencyBytes: latencyBytes - ) - let statusWritten = pending.chain.writeBytes( - status, - atWritableOffset: pending.payloadBytes + let publication = pending.chain.withLeaseHeld { access -> Int? in + var payloadWritten = 0 + if let validData { + payloadWritten = access.writeBytes(Array(validData)) + guard payloadWritten == pending.payloadBytes else { return nil } + } + let status = Self.ioStatus( + validData == nil ? .ioError : .ok, + latencyBytes: latencyBytes + ) + let statusWritten = access.writeBytes(status, atWritableOffset: pending.payloadBytes) + guard statusWritten == Self.pcmStatusSize else { return nil } + return validData == nil ? Self.pcmStatusSize : payloadWritten + statusWritten + } ?? nil + guard let written = publication else { + recordPublicationFault(queue: 3, reason: "short capture response write") + return + } + published = push( + pending.chain, + queue: transport.queues[3], + queueIndex: 3, + written: written, + interrupt: &interrupt ) - let usedLength = validData == nil ? statusWritten : payloadWritten + statusWritten - interrupt = (try? transport.queues[3].push(pending.chain, written: usedLength)) ?? false } + watchdog?.cancel() + if published { lock.withLock { statisticsState.completedCapturePeriods &+= 1 } } if interrupt { transport.notifyUsed() } } - private func completeImmediately( + private func completePlaybackImmediately( _ chain: VirtqueueChain, queue: Virtqueue, - status: Status, - transport: VirtioMMIOTransport - ) { - let written = chain.writeBytes(Self.ioStatus(status, latencyBytes: 0)) - if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() } + interrupt: inout Bool + ) -> Bool { + let written = chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) + } ?? 0 + return push( + chain, + queue: queue, + queueIndex: 2, + written: written == Self.pcmStatusSize ? written : 0, + interrupt: &interrupt + ) } private func completeCaptureImmediately( _ chain: VirtqueueChain, payloadBytes: Int, queue: Virtqueue, - status: Status, - transport: VirtioMMIOTransport - ) { - let written = chain.writeBytes( - Self.ioStatus(status, latencyBytes: 0), - atWritableOffset: payloadBytes + interrupt: inout Bool + ) -> Bool { + let written = chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0), atWritableOffset: payloadBytes) + } ?? 0 + guard written == Self.pcmStatusSize else { + recordPublicationFault(queue: 3, reason: "short immediate capture status write") + return false + } + return push( + chain, + queue: queue, + queueIndex: 3, + written: written, + interrupt: &interrupt ) - if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() } } private func processControlRequest( @@ -382,52 +852,61 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { responseCapacity: Int, transport: VirtioMMIOTransport? ) -> [UInt8] { - guard request.count >= 4 else { return Self.header(.badMessage) } + guard responseCapacity >= Self.controlStatusSize else { return [] } + guard request.count >= Self.controlStatusSize else { return Self.header(.badMessage) } let code = request.leUInt32(at: 0) switch code { case Request.pcmInfo: + guard request.count == Self.queryInfoSize else { return Self.header(.badMessage) } return pcmInfoResponse(request, responseCapacity: responseCapacity) case Request.pcmSetParameters: - return setParametersResponse(request) + guard request.count == Self.setParametersSize else { return Self.header(.badMessage) } + return setParametersResponse(request, responseCapacity: responseCapacity) case Request.pcmPrepare, Request.pcmRelease, Request.pcmStart, Request.pcmStop: - guard request.count >= 8 else { return Self.header(.badMessage) } - let streamID = Int(request.leUInt32(at: 4)) - return lifecycleResponse(code: code, streamID: streamID, transport: transport) + guard request.count == Self.pcmHeaderSize else { return Self.header(.badMessage) } + return lifecycleResponse( + code: code, + streamID: Int(request.leUInt32(at: 4)), + responseCapacity: responseCapacity, + transport: transport + ) default: return Self.header(.notSupported) } } private func pcmInfoResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] { - guard request.count >= 16 else { return Self.header(.badMessage) } let start = Int(request.leUInt32(at: 4)) let count = Int(request.leUInt32(at: 8)) let itemSize = Int(request.leUInt32(at: 12)) - guard start >= 0, count > 0, start <= Self.streamCount, + guard count > 0, start < Self.streamCount, count <= Self.streamCount - start, - itemSize >= Self.pcmInfoSize, - responseCapacity >= 4 + count * itemSize else { + itemSize == Self.pcmInfoSize else { return Self.header(.badMessage) } + let requiredCapacity = Self.controlStatusSize + count * Self.pcmInfoSize + guard responseCapacity >= requiredCapacity else { return Self.header(.badMessage) } var response = Self.header(.ok) + response.reserveCapacity(requiredCapacity) for streamID in start..<(start + count) { - var item = [UInt8]() - item.appendLE(UInt32(1)) - item.appendLE(UInt32(0)) - item.appendLE(Self.supportedFormats) - item.appendLE(Self.supportedRates) - item.append(streamID == 0 ? VirtioSoundDirection.output.rawValue : VirtioSoundDirection.input.rawValue) - item.append(1) - item.append(2) - item.append(contentsOf: repeatElement(0, count: 5)) - item.append(contentsOf: repeatElement(0, count: itemSize - item.count)) - response.append(contentsOf: item) + response.appendLE(UInt32(1)) + response.appendLE(UInt32(0)) + response.appendLE(Self.supportedFormats) + response.appendLE(Self.supportedRates) + response.append( + streamID == 0 + ? VirtioSoundDirection.output.rawValue + : VirtioSoundDirection.input.rawValue + ) + response.append(1) + response.append(2) + response.append(contentsOf: repeatElement(0, count: 5)) } return response } - private func setParametersResponse(_ request: [UInt8]) -> [UInt8] { - guard request.count >= 24 else { return Self.header(.badMessage) } + private func setParametersResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] { + guard responseCapacity >= Self.controlStatusSize else { return [] } let streamID = Int(request.leUInt32(at: 4)) let bufferBytes = Int(request.leUInt32(at: 8)) let periodBytes = Int(request.leUInt32(at: 12)) @@ -435,23 +914,43 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { let channels = Int(request[20]) let format = request[21] let rate = request[22] - guard streamID >= 0, streamID < streams.count, - bufferBytes > 0, periodBytes > 0, + guard streamID >= 0, streamID < Self.streamCount, + bufferBytes > 0, bufferBytes <= limits.maximumBufferBytes, + periodBytes > 0, periodBytes <= limits.maximumPeriodBytes, + periodBytes <= bufferBytes, bufferBytes % periodBytes == 0, + bufferBytes / periodBytes <= limits.maximumPeriodsPerStream, features == 0, channels >= 1, channels <= 2, format == Self.s16Format, - let sampleRate = Self.rateValues[rate] else { + let sampleRate = Self.rateValues[rate], + request[23] == 0 else { return Self.header(.notSupported) } + let bytesPerFrame = channels * 2 + guard periodBytes % bytesPerFrame == 0, + bufferBytes % bytesPerFrame == 0 else { + return Self.header(.badMessage) + } + let current = lock.withLock { () -> Stream? in + guard pendingPlayback.values.allSatisfy({ $0.streamID != streamID }), + pendingCapture.values.allSatisfy({ $0.streamID != streamID }), + streams[streamID].lifecycle != .running, + streams[streamID].lifecycle != .faulted else { return nil } + return streams[streamID] + } + guard let current else { return Self.header(.ioError) } let parameters = VirtioSoundPCMParameters( bufferBytes: bufferBytes, periodBytes: periodBytes, sampleRate: sampleRate, channels: channels ) - let direction = streams[streamID].direction - guard host.configure(streamID: streamID, direction: direction, parameters: parameters) else { + guard host.configure( + streamID: streamID, + direction: current.direction, + parameters: parameters + ) else { return Self.header(.ioError) } lock.withLock { @@ -464,9 +963,11 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { private func lifecycleResponse( code: UInt32, streamID: Int, + responseCapacity: Int, transport: VirtioMMIOTransport? ) -> [UInt8] { - guard streamID >= 0, streamID < streams.count else { return Self.header(.badMessage) } + guard responseCapacity >= Self.controlStatusSize else { return [] } + guard streamID >= 0, streamID < Self.streamCount else { return Self.header(.badMessage) } let current = lock.withLock { streams[streamID] } switch code { case Request.pcmPrepare: @@ -492,16 +993,27 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { guard current.lifecycle == .prepared || current.lifecycle == .parameters else { return Self.header(.ioError) } + let flushed: Bool + if let transport { + flushed = flushPending(streamID: streamID, transport: transport) + } else { + flushed = lock.withLock { + pendingPlayback.values.allSatisfy { $0.streamID != streamID } + && pendingCapture.values.allSatisfy { $0.streamID != streamID } + } + } host.release(streamID: streamID, direction: current.direction) - if let transport { flushPending(streamID: streamID, transport: transport) } - lock.withLock { streams[streamID].lifecycle = .parameters } + lock.withLock { + streams[streamID].lifecycle = flushed ? .parameters : .faulted + } + guard flushed else { return Self.header(.ioError) } default: return Self.header(.notSupported) } return Self.header(.ok) } - private func flushPending(streamID: Int, transport: VirtioMMIOTransport) { + private func flushPending(streamID: Int, transport: VirtioMMIOTransport) -> Bool { let playback: [PendingIO] let capture: [PendingIO] lock.lock() @@ -511,24 +1023,135 @@ public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { pendingPlayback = pendingPlayback.filter { $0.value.streamID != streamID } pendingCapture = pendingCapture.filter { $0.value.streamID != streamID } lock.unlock() + (playback + capture).compactMap(\.watchdog).forEach { $0.cancel() } if !playback.isEmpty || !capture.isEmpty { log("virtio sound stream \(streamID) released with playback=\(playback.count), capture=\(capture.count) pending") } var interrupt = false + var succeeded = true for pending in playback { - let written = pending.chain.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) - interrupt = ((try? transport.queues[2].push(pending.chain, written: written)) ?? false) || interrupt + guard transport.queues[2].ready, + transport.queues[2].isLeaseValid(pending.chain) else { + succeeded = false + continue + } + let written = pending.chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) + } ?? 0 + guard written == Self.pcmStatusSize, + push( + pending.chain, + queue: transport.queues[2], + queueIndex: 2, + written: written, + interrupt: &interrupt + ) else { + succeeded = false + continue + } } for pending in capture { - let written = pending.chain.writeBytes( - Self.ioStatus(.ioError, latencyBytes: 0), - atWritableOffset: pending.payloadBytes - ) - interrupt = ((try? transport.queues[3].push(pending.chain, written: written)) ?? false) || interrupt + guard transport.queues[3].ready, + transport.queues[3].isLeaseValid(pending.chain) else { + succeeded = false + continue + } + let written = pending.chain.withLeaseHeld { + $0.writeBytes( + Self.ioStatus(.ioError, latencyBytes: 0), + atWritableOffset: pending.payloadBytes + ) + } ?? 0 + guard written == Self.pcmStatusSize, + push( + pending.chain, + queue: transport.queues[3], + queueIndex: 3, + written: written, + interrupt: &interrupt + ) else { + succeeded = false + continue + } } if interrupt { transport.notifyUsed() } + return succeeded + } + + private static func orderedLayout(_ access: VirtqueueLeaseAccess) -> OrderedLayout? { + guard !access.segments.isEmpty else { return nil } + var sawWritable = false + for segment in access.segments { + if segment.isDeviceWritable { + sawWritable = true + } else if sawWritable { + return nil + } + } + return OrderedLayout( + readableBytes: access.readableByteCount, + writableBytes: access.writableByteCount + ) + } + + private static func pendingBytes(_ pending: [UInt64: PendingIO]) -> Int { + pending.values.reduce(into: 0) { total, value in + let (next, overflow) = total.addingReportingOverflow(value.payloadBytes) + total = overflow ? Int.max : next + } + } + + private func pop(_ queue: Virtqueue, queueIndex: Int) -> VirtqueueChain? { + do { + return try queue.pop() + } catch { + recordQueueFault(queue: queueIndex, reason: "descriptor pop failed") + return nil + } + } + + private func push( + _ chain: VirtqueueChain, + queue: Virtqueue, + queueIndex: Int, + written: Int, + interrupt: inout Bool + ) -> Bool { + do { + interrupt = try queue.push(chain, written: written) || interrupt + return true + } catch { + recordPublicationFault(queue: queueIndex, reason: "used-ring publication failed") + return false + } + } + + private func recordQueueFault(queue: Int, reason: String) { + lock.withLock { + statisticsState.queueFaults &+= 1 + terminalQueues.insert(queue) + } + log("virtio sound queue \(queue) terminal fault: \(reason)") + } + + private func recordPublicationFault(queue: Int, reason: String) { + lock.withLock { + statisticsState.publicationFaults &+= 1 + terminalQueues.insert(queue) + } + log("virtio sound queue \(queue) terminal publication fault: \(reason)") + } + + private func recordBoundedStopIfNeeded(handled: Int, queue: Virtqueue) { + if handled == limits.maximumChainsPerKick, queue.hasPending { + lock.withLock { statisticsState.boundedDrainStops &+= 1 } + } + } + + private func isTerminal(_ queue: Int) -> Bool { + lock.withLock { terminalQueues.contains(queue) } } private func allocateRequestID() -> UInt64 { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift index 270c5683..1a570ad2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift @@ -1,6 +1,6 @@ import Foundation -public struct VirtioVsockHeader: Equatable { +public struct VirtioVsockHeader: Equatable, Sendable { public static let byteCount = 44 public var sourceCID: UInt64 @@ -14,7 +14,7 @@ public struct VirtioVsockHeader: Equatable { public var bufferAllocation: UInt32 public var forwardCount: UInt32 - public enum Operation: UInt16 { + public enum Operation: UInt16, Sendable { case invalid = 0 case request = 1 case response = 2 @@ -50,8 +50,10 @@ public struct VirtioVsockHeader: Equatable { } public init(decoding bytes: some Collection) throws { - let data = Array(bytes) - guard data.count >= Self.byteCount else { + // Never materialize an untrusted packet just to decode its fixed-size header. Queue + // admission separately bounds and copies the declared payload after this prefix parses. + let data = Array(bytes.prefix(Self.byteCount)) + guard data.count == Self.byteCount else { throw VMError.invalidConfiguration("short virtio-vsock header") } func le16(_ offset: Int) -> UInt16 { @@ -98,47 +100,21 @@ public struct VirtioVsockHeader: Equatable { return bytes } - public func reply(operation: Operation, length: UInt32 = 0, forwardCount: UInt32? = nil) -> VirtioVsockHeader { - VirtioVsockHeader( - sourceCID: destinationCID, - destinationCID: sourceCID, - sourcePort: destinationPort, - destinationPort: sourcePort, - length: length, - type: type, - operation: operation, - flags: flags, - bufferAllocation: bufferAllocation, - forwardCount: forwardCount ?? self.forwardCount - ) - } } public enum VsockConnectionWriteError: Error, Equatable, Sendable { case timedOut case connectionClosed + case outboundQueueFull } -public protocol VsockConnection: AnyObject { +public protocol VsockConnection: AnyObject, Sendable { func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int func write(_ bytes: [UInt8]) throws - /// Writes with a bounded credit wait. A nil timeout preserves the streaming bridges' existing - /// behavior; bounded control-plane calls use this so a guest that stops returning credit cannot - /// permanently occupy a host worker. func write(_ bytes: [UInt8], timeoutNanoseconds: UInt64?) throws func close() - /// Blocks until a subsequent `read(into:)` can return bytes, or until the peer closes. A nil - /// timeout waits indefinitely. Implementations keep `read(into:)` nonblocking for callers that - /// already manage their own waits. func waitForReadable(timeoutNanoseconds: UInt64?) -> Bool - /// Half-close: signals the peer that this side is done sending (SHUT_WR) while the connection - /// stays open for the peer's remaining data. Bridges relaying a client's request-EOF must use - /// this, not `close()` — a full close truncates the response mid-stream. func shutdownSend() - /// True once the peer has shut the connection down (or it was reset). `read` returns 0 both when - /// no bytes are buffered yet and after the peer is gone, so a long-lived reader (e.g. the USB - /// bridge) needs this to tell "idle" from EOF — without it a claimed device can never be released - /// on guest reboot. var isPeerClosed: Bool { get } } @@ -161,21 +137,233 @@ public extension VsockConnection { public enum VsockPorts { public static let agent: UInt32 = 1024 public static let usbip: UInt32 = 1025 - /// The guest agent's docker-socket proxy: each host connection is piped to /var/run/docker.sock - /// inside the engine VM with full half-close fidelity (the gvproxy unix forward this replaces - /// tears the stream down on a client SHUT_WR, which is how `docker run` attaches output). public static let docker: UInt32 = 1026 - /// Host-edit batches sent only after virtio-fs invalidation has completed. The guest agent turns - /// them into Linux VFS metadata operations so inotify-backed tools receive native events. public static let fsevents: UInt32 = 1028 - /// Guest-side `/run/host-services/ssh-auth.sock` dials this host listener. The bridge connects - /// only to the configured, same-user macOS SSH agent Unix socket. public static let sshAgent: UInt32 = 1029 } +public enum VirtioVsockConfigurationError: Error, Equatable, Sendable { + case invalidGuestCID(UInt32) + case invalidLimit(String) +} + +/// Immutable per-device resource ceilings used by every admission and accounting path. +public struct VirtioVsockLimits: Equatable, Sendable { + /// Linux's virtio transport splits socket writes into at most 64 KiB packet payloads + /// (`VIRTIO_VSOCK_MAX_PKT_BUF_SIZE`). Keeping the same frontend ceiling bounds copies while + /// preserving interoperability with the in-tree guest driver. + public static let linuxMaximumPacketPayloadBytes = 64 * 1024 + + public static let hardenedDefault = VirtioVsockLimits( + maximumConnections: 256, + maximumListeners: 64, + maximumInboundBytesPerConnection: 256 * 1024, + maximumInboundBytesTotal: 16 * 1024 * 1024, + maximumPendingGuestPackets: 1024, + maximumPendingGuestBytes: 8 * 1024 * 1024, + maximumPacketPayloadBytes: linuxMaximumPacketPayloadBytes, + maximumChainsPerKick: Int(Virtqueue.maximumSize), + maximumBytesPerKick: (linuxMaximumPacketPayloadBytes + VirtioVsockHeader.byteCount) + * Int(Virtqueue.maximumSize), + hostPortRange: 49_152...65_535, + shutdownTimeoutNanoseconds: 5_000_000_000, + validated: () + ) + + public let maximumConnections: Int + public let maximumListeners: Int + public let maximumInboundBytesPerConnection: Int + public let maximumInboundBytesTotal: Int + public let maximumPendingGuestPackets: Int + public let maximumPendingGuestBytes: Int + public let maximumPacketPayloadBytes: Int + public let maximumChainsPerKick: Int + public let maximumBytesPerKick: Int + public let hostPortRange: ClosedRange + public let shutdownTimeoutNanoseconds: UInt64 + + public init( + maximumConnections: Int, + maximumListeners: Int, + maximumInboundBytesPerConnection: Int, + maximumInboundBytesTotal: Int, + maximumPendingGuestPackets: Int, + maximumPendingGuestBytes: Int, + maximumPacketPayloadBytes: Int = Self.linuxMaximumPacketPayloadBytes, + maximumChainsPerKick: Int = Int(Virtqueue.maximumSize), + maximumBytesPerKick: Int = (Self.linuxMaximumPacketPayloadBytes + + VirtioVsockHeader.byteCount) * Int(Virtqueue.maximumSize), + hostPortRange: ClosedRange, + shutdownTimeoutNanoseconds: UInt64 = 5_000_000_000 + ) throws { + guard maximumConnections > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumConnections") + } + guard maximumListeners >= 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumListeners") + } + guard maximumInboundBytesPerConnection > 0, + maximumInboundBytesPerConnection <= Int(UInt32.max) else { + throw VirtioVsockConfigurationError.invalidLimit( + "maximumInboundBytesPerConnection" + ) + } + guard maximumInboundBytesTotal > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumInboundBytesTotal") + } + guard maximumPendingGuestPackets > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPendingGuestPackets") + } + guard maximumPendingGuestBytes > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPendingGuestBytes") + } + guard maximumPacketPayloadBytes > 0, + maximumPacketPayloadBytes <= Int(UInt32.max) else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPacketPayloadBytes") + } + guard maximumChainsPerKick > 0, + maximumChainsPerKick <= Int(Virtqueue.maximumSize) else { + throw VirtioVsockConfigurationError.invalidLimit("maximumChainsPerKick") + } + let (maximumPacketBytes, packetOverflow) = maximumPacketPayloadBytes + .addingReportingOverflow(VirtioVsockHeader.byteCount) + guard !packetOverflow, maximumBytesPerKick >= maximumPacketBytes else { + throw VirtioVsockConfigurationError.invalidLimit("maximumBytesPerKick") + } + guard shutdownTimeoutNanoseconds > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("shutdownTimeoutNanoseconds") + } + self.init( + maximumConnections: maximumConnections, + maximumListeners: maximumListeners, + maximumInboundBytesPerConnection: maximumInboundBytesPerConnection, + maximumInboundBytesTotal: maximumInboundBytesTotal, + maximumPendingGuestPackets: maximumPendingGuestPackets, + maximumPendingGuestBytes: maximumPendingGuestBytes, + maximumPacketPayloadBytes: maximumPacketPayloadBytes, + maximumChainsPerKick: maximumChainsPerKick, + maximumBytesPerKick: maximumBytesPerKick, + hostPortRange: hostPortRange, + shutdownTimeoutNanoseconds: shutdownTimeoutNanoseconds, + validated: () + ) + } + + private init( + maximumConnections: Int, + maximumListeners: Int, + maximumInboundBytesPerConnection: Int, + maximumInboundBytesTotal: Int, + maximumPendingGuestPackets: Int, + maximumPendingGuestBytes: Int, + maximumPacketPayloadBytes: Int, + maximumChainsPerKick: Int, + maximumBytesPerKick: Int, + hostPortRange: ClosedRange, + shutdownTimeoutNanoseconds: UInt64, + validated: Void + ) { + self.maximumConnections = maximumConnections + self.maximumListeners = maximumListeners + self.maximumInboundBytesPerConnection = maximumInboundBytesPerConnection + self.maximumInboundBytesTotal = maximumInboundBytesTotal + self.maximumPendingGuestPackets = maximumPendingGuestPackets + self.maximumPendingGuestBytes = maximumPendingGuestBytes + self.maximumPacketPayloadBytes = maximumPacketPayloadBytes + self.maximumChainsPerKick = maximumChainsPerKick + self.maximumBytesPerKick = maximumBytesPerKick + self.hostPortRange = hostPortRange + self.shutdownTimeoutNanoseconds = shutdownTimeoutNanoseconds + } +} + +public enum VirtioVsockConnectionAdmissionError: Error, Equatable, Sendable { + case deviceQuiesced + case connectionCapacityReached(limit: Int) + case hostPortRangeExhausted + case outboundQueueCapacityReached +} + +public enum VirtioVsockListenerRegistrationError: Error, Equatable, Sendable { + case deviceQuiesced + case duplicatePort(UInt32) + case listenerCapacityReached(limit: Int) +} + +public struct VirtioVsockResourceSnapshot: Equatable, Sendable { + public let connections: Int + public let listeners: Int + public let inboundBufferedBytes: Int + public let pendingGuestPackets: Int + public let pendingGuestBytes: Int + public let isQuiesced: Bool +} + +/// Cumulative frontend telemetry. Queue faults are terminal for that queue generation and become +/// admissible again only after QueueReady is rewritten or the device is reset. +public struct VirtioVsockStatistics: Equatable, Sendable { + public var receivedGuestPackets: UInt64 = 0 + public var publishedGuestPackets: UInt64 = 0 + public var invalidTXChains: UInt64 = 0 + public var invalidRXChains: UInt64 = 0 + public var invalidEventChains: UInt64 = 0 + public var malformedGuestPackets: UInt64 = 0 + public var oversizedGuestPackets: UInt64 = 0 + public var rxStarvationEvents: UInt64 = 0 + public var responseBackpressureStops: UInt64 = 0 + public var boundedDrainStops: UInt64 = 0 + public var peerCreditClamps: UInt64 = 0 + public var staleHostOperations: UInt64 = 0 + public var queueFaults: UInt64 = 0 + public var publicationFaults: UInt64 = 0 + public var revokedCompletions: UInt64 = 0 + + public init() {} +} + +/// Closing or releasing the token unregisters only its exact listener generation. +public final class VirtioVsockListenerRegistration: @unchecked Sendable { + public let port: UInt32 + + private let lock = NSLock() + private var closeAction: (@Sendable () -> Void)? + + fileprivate init(port: UInt32, closeAction: @escaping @Sendable () -> Void) { + self.port = port + self.closeAction = closeAction + } + + public func close() { + lock.lock() + let action = closeAction + closeAction = nil + lock.unlock() + action?() + } + + deinit { + close() + } +} + +enum VirtioVsockCreditArithmetic { + /// VirtIO 1.3 section 5.10.6.3 defines free-running u32 counters. Counter subtraction wraps, + /// while an in-flight value larger than the peer's allocation must fail closed. + static func available( + bufferAllocation: UInt32, + transmittedCount: UInt32, + peerForwardCount: UInt32 + ) -> UInt32 { + let inFlight = transmittedCount &- peerForwardCount + guard inFlight <= bufferAllocation else { return 0 } + return bufferAllocation - inFlight + } +} + public final class VirtioVsock: VirtioDeviceBackend { public let deviceID: UInt32 = 19 public let queueCount = 3 + /// VirtIO 1.3 section 5.10.3: stream support is implied with no negotiated feature bits. public let deviceFeatures: UInt64 = 0 public var configSpace: [UInt8] { var bytes = [UInt8]() @@ -184,84 +372,476 @@ public final class VirtioVsock: VirtioDeviceBackend { return bytes } + private static let hostCID: UInt64 = 2 + private static let streamType: UInt16 = 1 + private let guestCID: UInt32 + private let limits: VirtioVsockLimits + private let serviceAdmissionAuthority: VirtioVsockServiceAdmissionAuthority + private let lifecycleResetLock = NSLock() private let stateLock = NSLock() - private var listeners: [UInt32: (VsockConnection) -> Void] = [:] + private var listeners: [UInt32: Listener] = [:] private var connections: [ConnectionKey: InProcessConnection] = [:] - private var pendingGuestPackets: [[UInt8]] = [] - private var nextHostPort: UInt32 = 49_152 + private var pendingGuestPackets: [PendingGuestPacket?] = [] + private var pendingGuestPacketHead = 0 + private var pendingGuestBytes = 0 + private var controlResponseReservations: Set = [] + private var reservedControlResponseBytes = 0 + private var uncommittedTerminalResetKeys: Set = [] + private var inboundBufferedBytes = 0 + private var nextHostPort: UInt32 + private var lifecycleEpoch: UInt64 = 1 + private var isQuiesced = false + private var isResetting = false + private var terminalQueues = Set() + private var statisticsState = VirtioVsockStatistics() + private var closingConnections: [ConnectionKey: ClosingConnection] = [:] + private let shutdownReaperQueue = DispatchQueue(label: "com.dory.vsock.shutdown-reaper") + private var shutdownReaper: DispatchSourceTimer? private weak var lastTransport: VirtioMMIOTransport? - private struct ConnectionKey: Hashable { + private struct ConnectionKey: Hashable, Sendable { var guestPort: UInt32 var hostPort: UInt32 } + private struct Listener { + var registrationID: UUID + var handler: @Sendable (VsockConnection) -> Void + } + + private struct PendingGuestPacket { + var id: UUID + var key: ConnectionKey? + var bytes: [UInt8] + } + + private struct PendingGuestDelivery { + var packetID: UUID + var bytes: [UInt8] + /// Nil means the head packet was delivered completely. A value replaces the exact head + /// after used-ring publication, allowing a large RW packet to be fragmented transactionally. + var replacement: [UInt8]? + } + + private struct ControlResponseReservation { + var id: UUID + var epoch: UInt64 + } + + private struct ClosingConnection { + var id: UUID + var deadlineNanoseconds: UInt64 + } + + private struct GuestPacketResult { + var responses: [[UInt8]] = [] + var invokeListener: (() -> Void)? + var terminalResetKey: ConnectionKey? + } + + private enum TXChainInspection { + case invalid + case packet( + bytes: [UInt8], + header: VirtioVsockHeader, + copiedByteCount: Int + ) + } + + private enum RXPublicationResult { + case published(wantsInterrupt: Bool) + case revoked + case stalePacket + } + + private enum ConnectionOrigin: Equatable { + case guest + case host + } + + private enum HostPacketEnqueueResult: Equatable { + case enqueued + case connectionClosed + case capacityExceeded + } + + private enum InboundReservationResult { + case reserved + case connectionClosed + case globalCapacityExceeded + } + + private enum InboundReceiveResult { + case accepted + case connectionClosed + case perConnectionCapacityExceeded + case globalCapacityExceeded + } + public init(guestCID: UInt32) { + precondition(Self.isValidGuestCID(guestCID), "virtio-vsock guest CID is reserved") + self.guestCID = guestCID + limits = .hardenedDefault + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: .hardenedDefault + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public init( + guestCID: UInt32, + serviceAdmissionLimits: VirtioVsockServiceAdmissionLimits + ) { + precondition(Self.isValidGuestCID(guestCID), "virtio-vsock guest CID is reserved") + self.guestCID = guestCID + limits = .hardenedDefault + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: serviceAdmissionLimits + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public init( + guestCID: UInt32, + limits: VirtioVsockLimits, + serviceAdmissionLimits: VirtioVsockServiceAdmissionLimits = .hardenedDefault + ) throws { + guard Self.isValidGuestCID(guestCID) else { + throw VirtioVsockConfigurationError.invalidGuestCID(guestCID) + } self.guestCID = guestCID + self.limits = limits + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: serviceAdmissionLimits + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public var resourceSnapshot: VirtioVsockResourceSnapshot { + withLock { + VirtioVsockResourceSnapshot( + connections: connections.count, + listeners: listeners.count, + inboundBufferedBytes: inboundBufferedBytes, + pendingGuestPackets: pendingGuestPacketCountLocked + + controlResponseReservations.count, + pendingGuestBytes: pendingGuestBytes + reservedControlResponseBytes, + isQuiesced: isQuiesced + ) + } + } + + public var statistics: VirtioVsockStatistics { + withLock { statisticsState } } - private func withLock(_ body: () -> T) -> T { + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot { + serviceAdmissionAuthority.snapshot + } + + private static func isValidGuestCID(_ cid: UInt32) -> Bool { + cid > 2 && cid != UInt32.max + } + + private func withLock(_ body: () throws -> T) rethrows -> T { stateLock.lock() defer { stateLock.unlock() } - return body() + return try body() } - public func listen(port: UInt32, handler: @escaping (VsockConnection) -> Void) { - withLock { listeners[port] = handler } + func registerListener( + port: UInt32, + handler: @escaping @Sendable (VsockConnection) -> Void + ) throws -> VirtioVsockListenerRegistration { + let registrationID = UUID() + try withLock { + guard !isQuiesced, !isResetting else { + throw VirtioVsockListenerRegistrationError.deviceQuiesced + } + guard listeners[port] == nil else { + throw VirtioVsockListenerRegistrationError.duplicatePort(port) + } + guard listeners.count < limits.maximumListeners else { + throw VirtioVsockListenerRegistrationError.listenerCapacityReached( + limit: limits.maximumListeners + ) + } + listeners[port] = Listener(registrationID: registrationID, handler: handler) + } + return VirtioVsockListenerRegistration(port: port) { [weak self] in + self?.unregisterListener(port: port, registrationID: registrationID) + } + } + + /// Registers a guest-initiated service. Admission is reserved before the untrusted callback is + /// invoked, and the wrapped connection holds that exact service lease until close/peer reset. + public func registerServiceListener( + port: UInt32, + service: VirtioVsockService, + handler: @escaping @Sendable (VsockConnection) -> Void + ) throws -> VirtioVsockListenerRegistration { + try registerListener(port: port) { [weak self] connection in + guard let self else { + connection.close() + return + } + let reservation: VirtioVsockServiceReservation + do { + reservation = try serviceAdmissionAuthority.reserve(service) + } catch { + connection.close() + return + } + guard let lease = serviceAdmissionAuthority.publish( + reservation, + requestStop: { connection.close() } + ) else { + connection.close() + return + } + handler(ServiceOwnedVsockConnection(connection: connection, lease: lease)) + } } - public func connect(port guestPort: UInt32) -> VsockConnection { - let (key, connection) = withLock { () -> (ConnectionKey, InProcessConnection) in - let hostPort = allocateHostPortLocked() + private func unregisterListener(port: UInt32, registrationID: UUID) { + withLock { + guard listeners[port]?.registrationID == registrationID else { return } + listeners.removeValue(forKey: port) + } + } + + /// Reserves a connection slot, collision-free tuple, and REQUEST queue space atomically. + func connectIfCapacity(port guestPort: UInt32) throws -> VsockConnection { + let admitted = try withLock { () throws -> (InProcessConnection, VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting else { + throw VirtioVsockConnectionAdmissionError.deviceQuiesced + } + guard connections.count < limits.maximumConnections else { + throw VirtioVsockConnectionAdmissionError.connectionCapacityReached( + limit: limits.maximumConnections + ) + } + let hostPort = try allocateHostPortLocked(guestPort: guestPort) let key = ConnectionKey(guestPort: guestPort, hostPort: hostPort) - let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount, flags in - self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount, flags: flags) - } onClose: { [weak self] key in - self?.removeConnection(key: key) + let connection = makeConnectionLocked(key: key, origin: .host) + let packet = makeHostPacket( + key: key, + operation: .request, + payload: [], + forwardCount: 0, + flags: 0 + ) + guard appendPendingGuestPacketLocked(packet, key: key) else { + throw VirtioVsockConnectionAdmissionError.outboundQueueCapacityReached } connections[key] = connection - return (key, connection) + return (connection, lastTransport) } - enqueueHostPacket(key: key, operation: .request) - return connection + flushIfAttached(admitted.1) + return admitted.0 } - private func removeConnection(key: ConnectionKey) { - withLock { _ = connections.removeValue(forKey: key) } + /// Admits one host-initiated service session before allocating a transport tuple or REQUEST. + /// The returned connection owns both resources and releases the service lease exactly once. + public func connectForServiceIfCapacity( + port guestPort: UInt32, + service: VirtioVsockService + ) throws -> VsockConnection { + let reservation = try serviceAdmissionAuthority.reserve(service) + do { + let connection = try connectIfCapacity(port: guestPort) + guard let lease = serviceAdmissionAuthority.publish( + reservation, + requestStop: { connection.close() } + ) else { + connection.close() + throw VirtioVsockServiceAdmissionError.lifecycleRevoked(service: service) + } + return ServiceOwnedVsockConnection(connection: connection, lease: lease) + } catch { + serviceAdmissionAuthority.cancel(reservation) + throw error + } + } + + func reserveServiceSession( + _ service: VirtioVsockService + ) throws -> VirtioVsockServiceReservation { + try serviceAdmissionAuthority.reserve(service) + } + + func publishServiceSession( + _ reservation: VirtioVsockServiceReservation, + requestStop: @escaping @Sendable () -> Void + ) -> VirtioVsockServiceLease? { + serviceAdmissionAuthority.publish(reservation, requestStop: requestStop) + } + + func cancelServiceSession(_ reservation: VirtioVsockServiceReservation) { + serviceAdmissionAuthority.cancel(reservation) } - public func drainPendingGuestPackets() -> [[UInt8]] { + func drainPendingGuestPackets() -> [[UInt8]] { withLock { - defer { pendingGuestPackets.removeAll() } - return pendingGuestPackets + let result = pendingGuestPackets.dropFirst(pendingGuestPacketHead) + .compactMap { $0?.bytes } + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + pendingGuestBytes = 0 + return result } } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - withLock { lastTransport = transport } - if queue == 0 { + guard transport.queues.indices.contains(queue) else { return } + let isAdmissible = withLock { () -> Bool in + lastTransport = transport + return !terminalQueues.contains(queue) + } + guard isAdmissible else { return } + + switch queue { + case 0: flushPendingGuestPackets(transport: transport) - return + case 1: + // Section 5.10.6.1 requires progress on incoming TX packets while bounded resources + // remain even if the peer neglected RX. Flush first to reclaim response capacity, then + // drain TX once and make one bounded pass over newly generated replies. + flushPendingGuestPackets(transport: transport) + drainGuestTX(transport: transport) + flushPendingGuestPackets(transport: transport) + case 2: + validateEventQueue(transport: transport) + default: + break } - guard queue == 1 else { return } + } + + private func drainGuestTX(transport: VirtioMMIOTransport) { let virtqueue = transport.queues[1] var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let packet = chain.readBytes() - let responses = (try? receive(packet: packet)) ?? [] - for response in responses { - if let rx = (try? transport.queues[0].pop()) ?? nil { - let written = rx.writeBytes(response) - let wants = (try? transport.queues[0].push(rx, written: written)) ?? false - interrupt = interrupt || wants + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try virtqueue.pendingCount() + } catch { + recordQueueFault(queue: 1) + return + } + let chainBudget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > chainBudget { recordBoundedDrainStop() } + + var handled = 0 + var copiedBytes = 0 + while handled < chainBudget { + let preview: VirtqueueChain + do { + guard let next = try virtqueue.peek() else { break } + preview = next + } catch { + recordQueueFault(queue: 1) + return + } + + let inspection = inspectTXChain(preview) + guard case .packet( + let packet, + let header, + let packetCopyBytes + ) = inspection else { + let rejected: VirtqueueChain + do { + rejected = try popPreviewed(preview, from: virtqueue) + } catch { + recordQueueFault(queue: 1) + return } + handled += 1 + withLock { statisticsState.invalidTXChains &+= 1 } + guard publish( + rejected, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } + continue } - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants - } - if interrupt { - transport.notifyUsed() + + guard copiedBytes <= limits.maximumBytesPerKick - packetCopyBytes else { + recordBoundedDrainStop() + break + } + let reservation: ControlResponseReservation? + if requiresControlResponse(header) { + guard let reserved = reserveControlResponse() else { + withLock { statisticsState.responseBackpressureStops &+= 1 } + break + } + reservation = reserved + } else { + reservation = nil + } + + let chain: VirtqueueChain + do { + chain = try popPreviewed(preview, from: virtqueue) + } catch { + if let reservation { releaseControlResponse(reservation) } + recordQueueFault(queue: 1) + return + } + handled += 1 + copiedBytes += packetCopyBytes + + let result: GuestPacketResult + do { + result = try processGuestPacket( + packet, + transactionEpoch: reservation?.epoch ?? currentLifecycleEpoch + ) + } catch { + if let reservation { releaseControlResponse(reservation) } + withLock { statisticsState.malformedGuestPackets &+= 1 } + guard publish( + chain, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } + continue + } + withLock { statisticsState.receivedGuestPackets &+= 1 } + + let responseCommitted: Bool + if let reservation, let response = result.responses.first { + responseCommitted = commitControlResponse( + response, + reservation: reservation, + terminalResetKey: result.terminalResetKey + ) + } else if let reservation { + releaseControlResponse(reservation) + responseCommitted = result.responses.isEmpty + } else if result.responses.isEmpty { + responseCommitted = true + } else { + // Admission reserves for every packet that can produce a response. Reaching this + // branch is an internal fail-closed invariant violation, not guest backpressure. + responseCommitted = false + recordQueueFault(queue: 1) + } + if responseCommitted { result.invokeListener?() } + guard publish( + chain, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } } } @@ -269,182 +849,1308 @@ public final class VirtioVsock: VirtioDeviceBackend { withLock { lastTransport = transport } } + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + guard transport.queues.indices.contains(queue) else { return } + withLock { + // QueueReady writes establish a new ring generation. Old faults remain counted, while + // the replacement queue receives a fresh admission opportunity. + terminalQueues.remove(queue) + lastTransport = transport + } + } + + /// Clears all transport-owned state. Host listener registrations are configuration authority, + /// so they survive reset as required for listeners by VirtIO 1.3 section 5.10.6.7. + public func deviceReset(transport: VirtioMMIOTransport) { + resetTransportState(preserveListeners: true, remainQuiesced: false) + } + + /// Permanently stops admission and releases connections, queues, bytes, and listener tokens. + @discardableResult + public func quiesce() -> VirtioVsockResourceSnapshot { + resetTransportState(preserveListeners: false, remainQuiesced: true) + return resourceSnapshot + } + + deinit { + resetTransportState(preserveListeners: false, remainQuiesced: true) + } + + private var maximumGuestPacketPayloadBytes: Int { + min(limits.maximumPacketPayloadBytes, limits.maximumInboundBytesPerConnection) + } + + private var currentLifecycleEpoch: UInt64 { + withLock { lifecycleEpoch } + } + + private func inspectTXChain(_ chain: VirtqueueChain) -> TXChainInspection { + // VirtIO 1.3 section 5.10.6.4: every outgoing packet buffer is device-readable. Zero-byte + // descriptors do not carry protocol data and are rejected rather than normalized away. + guard !chain.containsZeroLengthDescriptor, + chain.readableSegmentCount > 0, + chain.writableSegmentCount == 0, + chain.readableByteCount >= VirtioVsockHeader.byteCount else { + return .invalid + } + + let headerBytes = chain.readBytes(maximum: VirtioVsockHeader.byteCount) + guard headerBytes.count == VirtioVsockHeader.byteCount else { return .invalid } + let header: VirtioVsockHeader + do { + header = try VirtioVsockHeader(decoding: headerBytes) + } catch { + return .invalid + } + + guard let payloadByteCount = Int(exactly: header.length) else { + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + if payloadByteCount > maximumGuestPacketPayloadBytes { + // The fixed header is sufficient to produce the protocol RST. Never copy the claimed + // oversized body merely to reject it. + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + let requiredByteCount = VirtioVsockHeader.byteCount + payloadByteCount + guard requiredByteCount <= chain.readableByteCount else { + // Preserve the parsed route so processGuestPacket can generate the required RST without + // copying unavailable or unrelated descriptor memory. + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + let packet = chain.readBytes(maximum: requiredByteCount) + guard packet.count == requiredByteCount else { return .invalid } + return .packet( + bytes: packet, + header: header, + copiedByteCount: packet.count + ) + } + + private func popPreviewed( + _ preview: VirtqueueChain, + from queue: Virtqueue + ) throws -> VirtqueueChain { + guard let chain = try queue.pop(), + chain.head == preview.head, + chain.lease == preview.lease else { + throw VMError.unexpectedExit("virtio-vsock queue changed between admission and pop") + } + return chain + } + + private func requiresControlResponse(_ header: VirtioVsockHeader) -> Bool { + // A well-formed RST is the only input that can never require an outbound packet. Every + // other opcode may need RESPONSE, CREDIT_UPDATE, SHUTDOWN, or a fail-closed RST. + !(header.operation == .reset + && header.sourceCID == UInt64(guestCID) + && header.destinationCID == Self.hostCID + && header.type == Self.streamType + && header.length == 0 + && header.flags == 0) + } + + @discardableResult + private func publish( + _ chain: VirtqueueChain, + written: Int, + on queue: Virtqueue, + queueIndex: Int, + interrupt: inout Bool + ) -> Bool { + do { + switch try queue.pushOutcome(chain, written: written) { + case .published(let wantsInterrupt): + interrupt = interrupt || wantsInterrupt + return true + case .revoked: + withLock { statisticsState.revokedCompletions &+= 1 } + return false + } + } catch { + recordPublicationFault(queue: queueIndex) + return false + } + } + + private func validateEventQueue(transport: VirtioMMIOTransport) { + let queue = transport.queues[2] + var interrupt = false + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try queue.pendingCount() + } catch { + recordQueueFault(queue: 2) + return + } + let budget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > budget { recordBoundedDrainStop() } + + for _ in 0.. 0 + && preview.writableByteCount >= 4 + if isValid { + // No transport-reset event is pending. Leave the valid buffer available to the + // device, exactly as Linux expects, instead of completing/replenishing it in a loop. + return + } + let rejected: VirtqueueChain + do { + rejected = try popPreviewed(preview, from: queue) + } catch { + recordQueueFault(queue: 2) + return + } + withLock { statisticsState.invalidEventChains &+= 1 } + guard publish( + rejected, + written: 0, + on: queue, + queueIndex: 2, + interrupt: &interrupt + ) else { return } + } + } + + private func recordQueueFault(queue: Int) { + withLock { + statisticsState.queueFaults &+= 1 + terminalQueues.insert(queue) + } + } + + private func recordPublicationFault(queue: Int) { + withLock { + statisticsState.publicationFaults &+= 1 + terminalQueues.insert(queue) + } + } + + private func recordBoundedDrainStop() { + withLock { statisticsState.boundedDrainStops &+= 1 } + } + + private func flushIfAttached(_ transport: VirtioMMIOTransport?) { + guard let transport else { return } + transport.withQueueLock { + flushPendingGuestPackets(transport: transport) + } + } + private func flushPendingGuestPackets(transport: VirtioMMIOTransport) { + guard withLock({ !terminalQueues.contains(0) }) else { return } + guard withLock({ pendingGuestPacketCountLocked > 0 }) else { return } + + let queue = transport.queues[0] var interrupt = false - while withLock({ !pendingGuestPackets.isEmpty }), let rx = (try? transport.queues[0].pop()) ?? nil { - let packet = withLock { pendingGuestPackets.removeFirst() } - let written = rx.writeBytes(packet) - let wants = (try? transport.queues[0].push(rx, written: written)) ?? false - interrupt = interrupt || wants + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try queue.pendingCount() + } catch { + recordQueueFault(queue: 0) + return + } + let chainBudget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > chainBudget { recordBoundedDrainStop() } + + var handled = 0 + var publishedBytes = 0 + while handled < chainBudget, + withLock({ pendingGuestPacketCountLocked > 0 }) { + let remainingByteBudget = limits.maximumBytesPerKick - publishedBytes + let minimumDeliveryBytes = withLock { minimumPendingDeliveryBytesLocked() } + guard let minimumDeliveryBytes, + remainingByteBudget >= minimumDeliveryBytes else { + recordBoundedDrainStop() + break + } + + let rx: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + rx = next + } catch { + recordQueueFault(queue: 0) + return + } + handled += 1 + + // VirtIO 1.3 section 5.10.6.4: RX packet chains are device-writable only. A mixed or + // readable chain receives no bytes and cannot consume the pending packet authority. + guard !rx.containsZeroLengthDescriptor, + rx.writableSegmentCount > 0, + rx.readableSegmentCount == 0 else { + withLock { statisticsState.invalidRXChains &+= 1 } + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + continue + } + + let capacity = min(rx.writableByteCount, remainingByteBudget) + let delivery: PendingGuestDelivery? + do { + delivery = try withLock { + try pendingGuestDeliveryLocked(maximumBytes: capacity) + } + } catch { + recordQueueFault(queue: 0) + return + } + guard let delivery else { + withLock { statisticsState.rxStarvationEvents &+= 1 } + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + continue + } + + let outcome: RXPublicationResult + do { + outcome = try withLock { + guard peekPendingGuestPacketLocked()?.id == delivery.packetID else { + return .stalePacket + } + guard rx.writeBytes(delivery.bytes) == delivery.bytes.count else { + throw VMError.unexpectedExit( + "virtio-vsock RX lease revoked during bounded write" + ) + } + switch try queue.pushOutcome(rx, written: delivery.bytes.count) { + case .published(let wantsInterrupt): + commitPendingGuestDeliveryLocked(delivery) + statisticsState.publishedGuestPackets &+= 1 + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + statisticsState.revokedCompletions &+= 1 + return .revoked + } + } + } catch { + recordPublicationFault(queue: 0) + return + } + switch outcome { + case .published(let wantsInterrupt): + publishedBytes += delivery.bytes.count + interrupt = interrupt || wantsInterrupt + case .revoked: + return + case .stalePacket: + // A concurrent direct control path removed the exact pending flow before guest + // memory was touched. Complete this offered buffer empty and preserve newer FIFO. + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + } } - if interrupt { - transport.notifyUsed() + } + + private func minimumPendingDeliveryBytesLocked() -> Int? { + guard let packet = peekPendingGuestPacketLocked() else { return nil } + guard packet.bytes.count > VirtioVsockHeader.byteCount else { + return packet.bytes.count + } + // Every fragment needs a complete header and at least one stream byte. Control packets are + // fixed at one header and are returned in full. + do { + let header = try VirtioVsockHeader(decoding: packet.bytes) + if header.operation == .readWrite { + return VirtioVsockHeader.byteCount + 1 + } + return packet.bytes.count + } catch { + return packet.bytes.count } } - public func receive(packet: [UInt8]) throws -> [[UInt8]] { - let header = try VirtioVsockHeader(decoding: packet.prefix(VirtioVsockHeader.byteCount)) - let payload = Array(packet.dropFirst(VirtioVsockHeader.byteCount)) - let key = ConnectionKey(guestPort: header.sourcePort, hostPort: header.destinationPort) + private func pendingGuestDeliveryLocked( + maximumBytes: Int + ) throws -> PendingGuestDelivery? { + guard let current = peekPendingGuestPacketLocked() else { return nil } + guard maximumBytes >= VirtioVsockHeader.byteCount else { return nil } + if current.bytes.count <= maximumBytes { + return PendingGuestDelivery( + packetID: current.id, + bytes: current.bytes, + replacement: nil + ) + } + + // Linux may provide an RX buffer smaller than a queued stream packet. The vhost transport + // handles this by emitting a shorter RW packet and retaining the rest. Do the same without + // increasing pending packet/byte accounting and without splitting any control opcode. + let header = try VirtioVsockHeader(decoding: current.bytes) + let payload = current.bytes.dropFirst(VirtioVsockHeader.byteCount) + guard header.operation == .readWrite, + header.length == UInt32(payload.count), + maximumBytes > VirtioVsockHeader.byteCount else { return nil } + let fragmentPayloadCount = min( + maximumBytes - VirtioVsockHeader.byteCount, + payload.count + ) + guard fragmentPayloadCount > 0 else { return nil } + + var fragmentHeader = header + fragmentHeader.length = UInt32(fragmentPayloadCount) + let fragmentPayload = payload.prefix(fragmentPayloadCount) + let emitted = fragmentHeader.encoded() + fragmentPayload + + let remainingPayload = payload.dropFirst(fragmentPayloadCount) + var remainingHeader = header + remainingHeader.length = UInt32(remainingPayload.count) + let replacement = remainingHeader.encoded() + remainingPayload + return PendingGuestDelivery( + packetID: current.id, + bytes: emitted, + replacement: replacement + ) + } + + private func commitPendingGuestDeliveryLocked(_ delivery: PendingGuestDelivery) { + guard pendingGuestPacketHead < pendingGuestPackets.count, + let current = pendingGuestPackets[pendingGuestPacketHead], + current.id == delivery.packetID else { return } + if let replacement = delivery.replacement { + pendingGuestPackets[pendingGuestPacketHead]?.bytes = replacement + pendingGuestBytes -= current.bytes.count - replacement.count + } else { + _ = dequeuePendingGuestPacketLocked() + } + } + + func receive(packet: [UInt8]) throws -> [[UInt8]] { + let result = try processGuestPacket(packet, transactionEpoch: nil) + result.invokeListener?() + return result.responses + } + + private func processGuestPacket( + _ packet: [UInt8], + transactionEpoch: UInt64? + ) throws -> GuestPacketResult { + let header = try VirtioVsockHeader( + decoding: packet.prefix(VirtioVsockHeader.byteCount) + ) + let key = ConnectionKey( + guestPort: header.sourcePort, + hostPort: header.destinationPort + ) + let addressIsValid = header.sourceCID == UInt64(guestCID) + && header.destinationCID == Self.hostCID + let typeIsValid = header.type == Self.streamType + let availablePayloadBytes = packet.count - VirtioVsockHeader.byteCount + + guard Int(header.length) <= maximumGuestPacketPayloadBytes else { + withLock { + statisticsState.oversizedGuestPackets &+= 1 + statisticsState.malformedGuestPackets &+= 1 + } + if addressIsValid && typeIsValid { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + // VirtIO 1.3 section 5.10.6 explicitly permits descriptor bytes beyond len. Consume only + // the first len payload bytes, and RST a header that claims unavailable bytes. + guard Int(header.length) <= availablePayloadBytes else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + if addressIsValid && typeIsValid { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard typeIsValid else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + // Section 5.10.6.4.2 requires RST for every unsupported type. + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard addressIsValid else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard fieldsAreValid(header) else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + + if header.bufferAllocation > UInt32(limits.maximumInboundBytesPerConnection) { + // Match Linux's fail-safe policy: a peer may advertise a larger receive window, but it + // cannot make this implementation queue more than its own configured socket buffer. + withLock { statisticsState.peerCreditClamps &+= 1 } + } + + let payloadStart = VirtioVsockHeader.byteCount + let payloadEnd = payloadStart + Int(header.length) + let payload = Array(packet[payloadStart.. InProcessConnection in - let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount, flags in - self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount, flags: flags) - } onClose: { [weak self] key in - self?.removeConnection(key: key) - } - connections[key] = connection - return connection + case .response: + guard connection.acceptResponse( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) else { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - connection.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - listener(connection) - return [header.reply(operation: .response).encoded()] + return GuestPacketResult() case .readWrite: - let connection = withLock { connections[key] } - guard let connection, UInt32(payload.count) <= header.bufferAllocation else { - return [header.reply(operation: .reset).encoded()] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + switch connection.receive(payload) { + case .accepted: + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .creditUpdate, + forwardCount: connection.forwardCount + ), + ]) + case .connectionClosed, .perConnectionCapacityExceeded, + .globalCapacityExceeded: + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - connection.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - connection.receive(payload) - return [header.reply(operation: .creditUpdate, forwardCount: connection.forwardCount).encoded()] case .shutdown: - // A guest half-close (SHUT_WR carries only VIRTIO_VSOCK_SHUTDOWN_SEND) means the guest is - // done sending but can still receive, so keep the connection alive for the host to finish - // streaming its reply and only mark inbound EOF. Any other shutdown tears the connection down. - if header.flags == VsockShutdown.send { - let connection = withLock { connections[key] } - connection?.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - connection?.markPeerSendClosed() - } else { - withLock { connections.removeValue(forKey: key) }?.close() + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + if connection.markPeerShutdown(flags: header.flags) { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - return [header.reply(operation: .shutdown).encoded()] - case .reset: - withLock { connections.removeValue(forKey: key) }?.close() - return [header.reply(operation: .shutdown).encoded()] + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .shutdown, + forwardCount: connection.forwardCount, + flags: header.flags + ), + ]) case .creditRequest: - return [header.reply(operation: .creditUpdate).encoded()] - case .response: - withLock { connections[key] }? - .updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - return [] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .creditUpdate, + forwardCount: connection.forwardCount + ), + ]) case .creditUpdate: - withLock { connections[key] }? - .updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - return [] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult() + case .request, .reset, .invalid: + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + } + + private func fieldsAreValid(_ header: VirtioVsockHeader) -> Bool { + switch header.operation { + case .readWrite: + return header.length > 0 && header.flags == 0 + case .shutdown: + return header.length == 0 + && header.flags != 0 + && header.flags & ~VsockShutdown.all == 0 + case .request, .response, .reset, .creditUpdate, .creditRequest: + return header.length == 0 && header.flags == 0 case .invalid: - return [] + return false + } + } + + private func admitGuestRequest( + header: VirtioVsockHeader, + key: ConnectionKey, + transactionEpoch: UInt64? + ) -> GuestPacketResult { + var rejected: InProcessConnection? + var terminalResetKey: ConnectionKey? + let admission = withLock { () -> (Listener, InProcessConnection)? in + guard transactionEpoch == nil || transactionEpoch == lifecycleEpoch else { + return nil + } + if connections[key] != nil { + rejected = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + return nil + } + guard !isQuiesced, !isResetting, + connections.count < limits.maximumConnections, + !hasPendingGuestPacketLocked(for: key), + !uncommittedTerminalResetKeys.contains(key), + let listener = listeners[header.destinationPort] else { + rejected = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + return nil + } + let connection = makeConnectionLocked(key: key, origin: .guest) + connections[key] = connection + return (listener, connection) + } + rejected?.abort() + guard let (listener, connection) = admission else { + // Section 5.10.6.5 requires RST for a missing listener or insufficient resources. + return GuestPacketResult( + responses: [makeReply(to: header, operation: .reset)], + terminalResetKey: terminalResetKey + ) } + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult( + responses: [makeReply(to: header, operation: .response)], + invokeListener: { listener.handler(connection) } + ) } - private func allocateHostPortLocked() -> UInt32 { - defer { nextHostPort &+= 1 } - return nextHostPort + private func terminalResetResult( + to header: VirtioVsockHeader, + key: ConnectionKey, + transactionEpoch: UInt64? + ) -> GuestPacketResult { + var removed: InProcessConnection? + var terminalResetKey: ConnectionKey? + withLock { + guard transactionEpoch == nil || transactionEpoch == lifecycleEpoch else { return } + removed = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + } + removed?.abort() + return GuestPacketResult( + responses: [makeReply(to: header, operation: .reset)], + terminalResetKey: terminalResetKey + ) } - private func enqueueHostPacket( + private func prepareTerminalResetLocked( key: ConnectionKey, + transactionEpoch: UInt64? + ) -> InProcessConnection? { + let removed = connections.removeValue(forKey: key) + removePendingGuestPacketsLocked(for: key) + closingConnections.removeValue(forKey: key) + if transactionEpoch != nil { uncommittedTerminalResetKeys.insert(key) } + scheduleShutdownReaperLocked() + return removed + } + + private func makeReply( + to header: VirtioVsockHeader, operation: VirtioVsockHeader.Operation, - payload: [UInt8] = [], forwardCount: UInt32 = 0, flags: UInt32 = 0 - ) { - let header = VirtioVsockHeader( - sourceCID: 2, + ) -> [UInt8] { + VirtioVsockHeader( + sourceCID: Self.hostCID, + destinationCID: UInt64(guestCID), + sourcePort: header.destinationPort, + destinationPort: header.sourcePort, + length: 0, + type: header.type, + operation: operation, + flags: flags, + bufferAllocation: UInt32(limits.maximumInboundBytesPerConnection), + forwardCount: forwardCount + ).encoded() + } + + private func makeHostPacket( + key: ConnectionKey, + operation: VirtioVsockHeader.Operation, + payload: [UInt8], + forwardCount: UInt32, + flags: UInt32 + ) -> [UInt8] { + VirtioVsockHeader( + sourceCID: Self.hostCID, destinationCID: UInt64(guestCID), sourcePort: key.hostPort, destinationPort: key.guestPort, length: UInt32(payload.count), operation: operation, flags: flags, + bufferAllocation: UInt32(limits.maximumInboundBytesPerConnection), forwardCount: forwardCount + ).encoded() + payload + } + + private func makeConnectionLocked( + key: ConnectionKey, + origin: ConnectionOrigin + ) -> InProcessConnection { + let id = UUID() + let epoch = lifecycleEpoch + return InProcessConnection( + id: id, + key: key, + origin: origin, + maximumInboundBytes: limits.maximumInboundBytesPerConnection, + send: { [weak self] operation, payload, forwardCount, flags in + self?.enqueueHostPacket( + id: id, + key: key, + epoch: epoch, + operation: operation, + payload: payload, + forwardCount: forwardCount, + flags: flags + ) ?? .connectionClosed + }, + reserveInbound: { [weak self] count in + self?.reserveInboundBytes( + count, + id: id, + key: key, + epoch: epoch + ) ?? .connectionClosed + }, + releaseInbound: { [weak self] count in + self?.releaseInboundBytes(count) + }, + onLocalClose: { [weak self] in + self?.markConnectionClosing(id: id, key: key, epoch: epoch) + } ) - let transport = withLock { () -> VirtioMMIOTransport? in - pendingGuestPackets.append(header.encoded() + payload) - return lastTransport + } + + private func reserveInboundBytes( + _ count: Int, + id: UUID, + key: ConnectionKey, + epoch: UInt64 + ) -> InboundReservationResult { + withLock { + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { return .connectionClosed } + let (next, overflow) = inboundBufferedBytes.addingReportingOverflow(count) + guard !overflow, next <= limits.maximumInboundBytesTotal else { + return .globalCapacityExceeded + } + inboundBufferedBytes = next + return .reserved } - if let transport { - transport.withQueueLock { - flushPendingGuestPackets(transport: transport) + } + + private func releaseInboundBytes(_ count: Int) { + withLock { + inboundBufferedBytes = max(0, inboundBufferedBytes - count) + } + } + + private func enqueueHostPacket( + id: UUID, + key: ConnectionKey, + epoch: UInt64, + operation: VirtioVsockHeader.Operation, + payload: [UInt8], + forwardCount: UInt32, + flags: UInt32 + ) -> HostPacketEnqueueResult { + let result = withLock { () -> (HostPacketEnqueueResult, VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { + statisticsState.staleHostOperations &+= 1 + return (.connectionClosed, nil) + } + let packet = makeHostPacket( + key: key, + operation: operation, + payload: payload, + forwardCount: forwardCount, + flags: flags + ) + guard appendPendingGuestPacketLocked(packet, key: key) else { + return (.capacityExceeded, nil) } + return (.enqueued, lastTransport) + } + flushIfAttached(result.1) + return result.0 + } + + private var pendingGuestPacketCountLocked: Int { + pendingGuestPackets.count - pendingGuestPacketHead + } + + private func reserveControlResponse() -> ControlResponseReservation? { + withLock { + guard !isQuiesced, !isResetting, + pendingGuestPacketCountLocked + controlResponseReservations.count + < limits.maximumPendingGuestPackets else { + return nil + } + let (usedBytes, usedOverflow) = pendingGuestBytes.addingReportingOverflow( + reservedControlResponseBytes + ) + guard !usedOverflow else { return nil } + let (next, overflow) = usedBytes.addingReportingOverflow( + VirtioVsockHeader.byteCount + ) + guard !overflow, next <= limits.maximumPendingGuestBytes else { return nil } + let id = UUID() + controlResponseReservations.insert(id) + reservedControlResponseBytes += VirtioVsockHeader.byteCount + return ControlResponseReservation(id: id, epoch: lifecycleEpoch) + } + } + + private func commitControlResponse( + _ packet: [UInt8], + reservation: ControlResponseReservation, + terminalResetKey: ConnectionKey? + ) -> Bool { + withLock { + guard reservation.epoch == lifecycleEpoch, + controlResponseReservations.remove(reservation.id) != nil else { + return false + } + reservedControlResponseBytes -= VirtioVsockHeader.byteCount + if let terminalResetKey, + uncommittedTerminalResetKeys.remove(terminalResetKey) == nil { + return false + } + guard packet.count <= VirtioVsockHeader.byteCount, + !isQuiesced, !isResetting else { + return false + } + // The count/bytes were reserved while every ordinary append included reservations in + // its capacity check, so this commit cannot be displaced by a concurrent host enqueue. + pendingGuestPackets.append(PendingGuestPacket( + id: UUID(), + key: terminalResetKey, + bytes: packet + )) + pendingGuestBytes += packet.count + return true + } + } + + private func releaseControlResponse(_ reservation: ControlResponseReservation) { + withLock { + guard reservation.epoch == lifecycleEpoch, + controlResponseReservations.remove(reservation.id) != nil else { return } + reservedControlResponseBytes -= VirtioVsockHeader.byteCount + } + } + + private func appendPendingGuestPacketLocked( + _ packet: [UInt8], + key: ConnectionKey? + ) -> Bool { + guard pendingGuestPacketCountLocked + controlResponseReservations.count + < limits.maximumPendingGuestPackets else { + return false + } + let (usedBytes, usedOverflow) = pendingGuestBytes.addingReportingOverflow( + reservedControlResponseBytes + ) + guard !usedOverflow else { return false } + let (nextBytes, overflow) = usedBytes.addingReportingOverflow(packet.count) + guard !overflow, nextBytes <= limits.maximumPendingGuestBytes else { return false } + let (newPendingBytes, pendingOverflow) = pendingGuestBytes.addingReportingOverflow( + packet.count + ) + guard !pendingOverflow else { return false } + pendingGuestPackets.append(PendingGuestPacket(id: UUID(), key: key, bytes: packet)) + pendingGuestBytes = newPendingBytes + return true + } + + private func peekPendingGuestPacketLocked() -> PendingGuestPacket? { + guard pendingGuestPacketHead < pendingGuestPackets.count else { return nil } + return pendingGuestPackets[pendingGuestPacketHead] + } + + @discardableResult + private func dequeuePendingGuestPacketLocked() -> PendingGuestPacket? { + guard pendingGuestPacketHead < pendingGuestPackets.count, + let packet = pendingGuestPackets[pendingGuestPacketHead] else { return nil } + pendingGuestPackets[pendingGuestPacketHead] = nil + pendingGuestPacketHead += 1 + pendingGuestBytes -= packet.bytes.count + compactPendingGuestPacketsLockedIfNeeded() + return packet + } + + private func compactPendingGuestPacketsLockedIfNeeded() { + guard pendingGuestPacketHead > 0 else { return } + if pendingGuestPacketHead == pendingGuestPackets.count { + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + } else if pendingGuestPacketHead >= 64, + pendingGuestPacketHead * 2 >= pendingGuestPackets.count { + pendingGuestPackets.removeFirst(pendingGuestPacketHead) + pendingGuestPacketHead = 0 + } + } + + private func removePendingGuestPacketsLocked(for key: ConnectionKey) { + let kept = pendingGuestPackets.dropFirst(pendingGuestPacketHead) + .compactMap { $0 } + .filter { $0.key != key } + pendingGuestPackets = kept.map(Optional.some) + pendingGuestPacketHead = 0 + pendingGuestBytes = kept.reduce(into: 0) { $0 += $1.bytes.count } + } + + private func hasPendingGuestPacketLocked(for key: ConnectionKey) -> Bool { + pendingGuestPackets.dropFirst(pendingGuestPacketHead).contains { $0?.key == key } + } + + private func allocateHostPortLocked(guestPort: UInt32) throws -> UInt32 { + let lower = limits.hostPortRange.lowerBound + let upper = limits.hostPortRange.upperBound + let rangeCount = UInt64(upper) - UInt64(lower) + 1 + var candidate = nextHostPort + for _ in 0.. InProcessConnection? in + let removed = connections.removeValue(forKey: key) + removePendingGuestPacketsLocked(for: key) + uncommittedTerminalResetKeys.remove(key) + closingConnections.removeValue(forKey: key) + scheduleShutdownReaperLocked() + return removed + } + connection?.abort() + } + + private func markConnectionClosing(id: UUID, key: ConnectionKey, epoch: UInt64) { + withLock { + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { return } + closingConnections[key] = ClosingConnection( + id: id, + deadlineNanoseconds: addingClamped( + DispatchTime.now().uptimeNanoseconds, + limits.shutdownTimeoutNanoseconds + ) + ) + scheduleShutdownReaperLocked() + } + } + + private func scheduleShutdownReaperLocked() { + guard let deadline = closingConnections.values + .map(\.deadlineNanoseconds) + .min() else { + shutdownReaper?.cancel() + shutdownReaper = nil + return + } + + let timer: DispatchSourceTimer + if let shutdownReaper { + timer = shutdownReaper + } else { + let newTimer = DispatchSource.makeTimerSource(queue: shutdownReaperQueue) + newTimer.setEventHandler { [weak self] in + self?.reapExpiredConnections() + } + newTimer.activate() + shutdownReaper = newTimer + timer = newTimer + } + timer.schedule( + deadline: DispatchTime(uptimeNanoseconds: deadline), + leeway: .milliseconds(1) + ) + } + + private func reapExpiredConnections() { + let now = DispatchTime.now().uptimeNanoseconds + let candidates = withLock { () -> [(ConnectionKey, ClosingConnection, InProcessConnection)] in + closingConnections.compactMap { key, closing in + guard closing.deadlineNanoseconds <= now, + let connection = connections[key], + connection.id == closing.id else { return nil } + return (key, closing, connection) + } + } + let candidatesWithCredit = candidates.map { candidate in + (candidate.0, candidate.1, candidate.2, candidate.2.forwardCount) + } + + let outcome = withLock { + () -> ([InProcessConnection], VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting else { + scheduleShutdownReaperLocked() + return ([], nil) + } + var retired = [InProcessConnection]() + var enqueuedReset = false + let retryDelay = max( + 10_000_000, + min(limits.shutdownTimeoutNanoseconds, 100_000_000) + ) + for (key, closing, connection, forwardCount) in candidatesWithCredit { + guard closingConnections[key]?.id == closing.id, + closingConnections[key]?.deadlineNanoseconds ?? UInt64.max <= now, + connections[key]?.id == closing.id else { continue } + let reset = makeHostPacket( + key: key, + operation: .reset, + payload: [], + forwardCount: forwardCount, + flags: 0 + ) + var resetCommitted = appendPendingGuestPacketLocked(reset, key: key) + if !resetCommitted, hasPendingGuestPacketLocked(for: key) { + // At the implementation shutdown deadline this flow is being reset, so its + // not-yet-delivered payload/SHUTDOWN packets are stale authority. Replacing + // them with the terminal RST makes ordinary close retirement wall-bounded. + removePendingGuestPacketsLocked(for: key) + resetCommitted = appendPendingGuestPacketLocked(reset, key: key) + } + guard resetCommitted else { + // Required responses or packets belonging to other flows are never dropped to + // make room. Keep this one bounded tombstone and retry on the single reaper. + closingConnections[key]?.deadlineNanoseconds = addingClamped(now, retryDelay) + continue + } + // The RST is committed before the live tuple is retired. Its keyed pending authority + // prevents either host-port reuse or a guest REQUEST for this tuple until delivery. + connections.removeValue(forKey: key) + closingConnections.removeValue(forKey: key) + retired.append(connection) + enqueuedReset = true + } + scheduleShutdownReaperLocked() + return (retired, enqueuedReset ? lastTransport : nil) + } + for connection in outcome.0 { connection.abort() } + flushIfAttached(outcome.1) + } + + private func addingClamped(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private func resetTransportState( + preserveListeners: Bool, + remainQuiesced: Bool + ) { + lifecycleResetLock.lock() + defer { lifecycleResetLock.unlock() } + let terminallyQuiesced = withLock { () -> Bool in + let terminal = remainQuiesced || isQuiesced + isResetting = true + lifecycleEpoch &+= 1 + return terminal + } + + // Revoke service work before aborting the transport objects it may be using. Stop callbacks + // run outside both authority and device locks; their close paths see isResetting and cannot + // publish a new shutdown tombstone into the replacement generation. + if terminallyQuiesced { + serviceAdmissionAuthority.quiesce() + } else { + serviceAdmissionAuthority.beginReset() + } + + let staleConnections = withLock { () -> [InProcessConnection] in + let stale = Array(connections.values) + connections.removeAll(keepingCapacity: true) + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + pendingGuestBytes = 0 + controlResponseReservations.removeAll(keepingCapacity: true) + reservedControlResponseBytes = 0 + uncommittedTerminalResetKeys.removeAll(keepingCapacity: true) + terminalQueues.removeAll(keepingCapacity: true) + nextHostPort = limits.hostPortRange.lowerBound + lastTransport = nil + closingConnections.removeAll(keepingCapacity: true) + shutdownReaper?.cancel() + shutdownReaper = nil + if !preserveListeners || terminallyQuiesced { + listeners.removeAll(keepingCapacity: true) + } + return stale + } + for connection in staleConnections { connection.abort() } + withLock { + inboundBufferedBytes = 0 + // Quiesce is a terminal host lifecycle decision. A late guest MMIO reset must not + // resurrect admission after teardown has begun. + isQuiesced = terminallyQuiesced + isResetting = false + } + if !terminallyQuiesced { + serviceAdmissionAuthority.finishReset() } } private enum VsockShutdown { - static let receive: UInt32 = 1 // VIRTIO_VSOCK_SHUTDOWN_RCV - static let send: UInt32 = 2 // VIRTIO_VSOCK_SHUTDOWN_SEND + static let receive: UInt32 = 1 + static let send: UInt32 = 2 + static let all = receive | send } - private final class InProcessConnection: VsockConnection { + private final class InProcessConnection: VsockConnection, @unchecked Sendable { + let id: UUID let key: ConnectionKey - private let send: (VirtioVsockHeader.Operation, [UInt8], UInt32, UInt32) -> Void - private let onClose: (ConnectionKey) -> Void - // `receive` runs on the vsock queue while `read`/`close` may run on a bridge's own thread, so - // inbound + isClosed are guarded. Drained bytes still count toward forwardCount (credit), so - // it is tracked separately from what remains buffered. + + private let origin: ConnectionOrigin + private let maximumInboundBytes: Int + private let send: ( + VirtioVsockHeader.Operation, + [UInt8], + UInt32, + UInt32 + ) -> HostPacketEnqueueResult + private let reserveInbound: (Int) -> InboundReservationResult + private let releaseInbound: (Int) -> Void + private let onLocalClose: () -> Void private let condition = NSCondition() + private let writeLock = NSLock() private var inbound = [UInt8]() private var forwardCountValue: UInt32 = 0 private var isClosed = false private var peerSendClosed = false + private var peerReceiveClosed = false private var hostSendClosed = false - // Peer credit: how much the guest socket can still absorb. Writes block while the in-flight - // window is exhausted — without this a fast host writer (a docker build context upload) - // overruns the guest's vsock buffer and the kernel drops the payload mid-stream. The values - // refresh from every guest packet header; 256 KiB matches the kernel's default buf_alloc as - // the pre-handshake estimate. + private var established: Bool private var peerBufferAllocation: UInt32 = 256 * 1024 private var peerForwardCount: UInt32 = 0 private var transmittedCount: UInt32 = 0 - // Linux's virtio-vsock RX buffers are smaller than the socket-level credit window. Keep each - // host->guest packet comfortably below the observed RX descriptor size so the header length - // can never describe more payload than fits in one virtqueue buffer. private static let writeChunk = 4 * 1024 - var forwardCount: UInt32 { condition.lock(); defer { condition.unlock() }; return forwardCountValue } - // True once the guest can no longer send (either a full close or a SHUT_WR half-close). Readers - // draining inbound use it as EOF; the host may still write a reply until a full close. - var isPeerClosed: Bool { condition.lock(); defer { condition.unlock() }; return isClosed || peerSendClosed } + var forwardCount: UInt32 { + condition.lock() + defer { condition.unlock() } + return forwardCountValue + } + + var isEstablished: Bool { + condition.lock() + defer { condition.unlock() } + return established && !isClosed + } + + var isPeerClosed: Bool { + condition.lock() + defer { condition.unlock() } + return isClosed || peerSendClosed + } init( + id: UUID, key: ConnectionKey, - send: @escaping (VirtioVsockHeader.Operation, [UInt8], UInt32, UInt32) -> Void, - onClose: @escaping (ConnectionKey) -> Void + origin: ConnectionOrigin, + maximumInboundBytes: Int, + send: @escaping ( + VirtioVsockHeader.Operation, + [UInt8], + UInt32, + UInt32 + ) -> HostPacketEnqueueResult, + reserveInbound: @escaping (Int) -> InboundReservationResult, + releaseInbound: @escaping (Int) -> Void, + onLocalClose: @escaping () -> Void ) { + self.id = id self.key = key + self.origin = origin + self.maximumInboundBytes = maximumInboundBytes self.send = send - self.onClose = onClose + self.reserveInbound = reserveInbound + self.releaseInbound = releaseInbound + self.onLocalClose = onLocalClose + established = origin == .guest } - func receive(_ bytes: [UInt8]) { + func acceptResponse(bufferAllocation: UInt32, forwardCount: UInt32) -> Bool { condition.lock() - inbound.append(contentsOf: bytes) - forwardCountValue &+= UInt32(bytes.count) + defer { condition.unlock() } + guard origin == .host, !established, !isClosed else { return false } + established = true + peerBufferAllocation = min(bufferAllocation, UInt32(maximumInboundBytes)) + peerForwardCount = forwardCount condition.broadcast() - condition.unlock() + return true } - func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + func receive(_ bytes: [UInt8]) -> InboundReceiveResult { condition.lock() defer { condition.unlock() } + guard established, !isClosed, !peerSendClosed else { + return .connectionClosed + } + let (next, overflow) = inbound.count.addingReportingOverflow(bytes.count) + guard !overflow, next <= maximumInboundBytes else { + return .perConnectionCapacityExceeded + } + switch reserveInbound(bytes.count) { + case .reserved: + inbound.append(contentsOf: bytes) + condition.broadcast() + return .accepted + case .connectionClosed: + return .connectionClosed + case .globalCapacityExceeded: + return .globalCapacityExceeded + } + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + condition.lock() let count = min(buffer.count, inbound.count) - guard count > 0 else { return 0 } + guard count > 0 else { + condition.unlock() + return 0 + } inbound.prefix(count).withUnsafeBytes { source in buffer.baseAddress?.copyMemory(from: source.baseAddress!, byteCount: count) } inbound.removeFirst(count) + // fwd_cnt advances only when the host consumer frees receive bytes. Advancing it when + // data is merely enqueued would falsely grant unlimited credit (VirtIO 1.3 5.10.6.3). + forwardCountValue &+= UInt32(count) + let credit = forwardCountValue + let shouldUpdateCredit = established && !isClosed && !peerSendClosed + condition.unlock() + + releaseInbound(count) + if shouldUpdateCredit { + // If this optional update meets outbound backpressure, CREDIT_REQUEST can recover + // the same current counter later without dropping any payload or required reply. + _ = send(.creditUpdate, [], credit, 0) + } return count } func updatePeerCredit(bufferAllocation: UInt32, forwardCount: UInt32) { condition.lock() - if bufferAllocation > 0 { peerBufferAllocation = bufferAllocation } + peerBufferAllocation = min(bufferAllocation, UInt32(maximumInboundBytes)) peerForwardCount = forwardCount condition.broadcast() condition.unlock() @@ -455,9 +2161,16 @@ public final class VirtioVsock: VirtioDeviceBackend { defer { condition.unlock() } if !inbound.isEmpty || isClosed || peerSendClosed { return true } if let timeoutNanoseconds { - let deadline = Date().addingTimeInterval(Double(timeoutNanoseconds) / 1_000_000_000) + let deadline = ProcessInfo.processInfo.systemUptime + + Double(timeoutNanoseconds) / 1_000_000_000 while inbound.isEmpty && !isClosed && !peerSendClosed { - if !condition.wait(until: deadline) { break } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { break } + // NSCondition exposes a wall-clock Date API only. Short waits plus a monotonic + // outer deadline prevent clock adjustments from extending the caller's bound. + _ = condition.wait( + until: Date().addingTimeInterval(min(remaining, 0.05)) + ) } } else { while inbound.isEmpty && !isClosed && !peerSendClosed { @@ -475,34 +2188,57 @@ public final class VirtioVsock: VirtioDeviceBackend { let deadline = timeoutNanoseconds.map { ProcessInfo.processInfo.systemUptime + Double($0) / 1_000_000_000 } + writeLock.lock() + defer { writeLock.unlock() } var offset = 0 while offset < bytes.count { - let chunk = min(Self.writeChunk, bytes.count - offset) - try waitForCredit(chunk: UInt32(chunk), deadline: deadline) - condition.lock() - let credit = forwardCountValue - transmittedCount &+= UInt32(chunk) - condition.unlock() - send(.readWrite, Array(bytes[offset..<(offset + chunk)]), credit, 0) - offset += chunk + let maximumChunkCount = min(Self.writeChunk, bytes.count - offset) + let reservation = try reserveTransmit( + maximumCount: UInt32(maximumChunkCount), + deadline: deadline + ) + let chunkCount = Int(reservation.count) + let result = send( + .readWrite, + Array(bytes[offset..<(offset + chunkCount)]), + reservation.forwardCount, + 0 + ) + guard result == .enqueued else { + condition.lock() + transmittedCount &-= reservation.count + condition.unlock() + if result == .capacityExceeded { + throw VsockConnectionWriteError.outboundQueueFull + } + throw VsockConnectionWriteError.connectionClosed + } + offset += chunkCount } } - /// Blocks until the peer's receive window admits `chunk` more bytes. The deadline covers the - /// whole write, not each chunk, so a trickle of credit cannot extend a control call forever. - private func waitForCredit(chunk: UInt32, deadline: TimeInterval?) throws { + private func reserveTransmit( + maximumCount: UInt32, + deadline: TimeInterval? + ) throws -> (count: UInt32, forwardCount: UInt32) { while true { condition.lock() - let writable = !isClosed && !hostSendClosed - let inFlight = transmittedCount &- peerForwardCount - let allowance = peerBufferAllocation + let writable = !isClosed && !hostSendClosed && !peerReceiveClosed + let available = VirtioVsockCreditArithmetic.available( + bufferAllocation: peerBufferAllocation, + transmittedCount: transmittedCount, + peerForwardCount: peerForwardCount + ) if !writable { condition.unlock() throw VsockConnectionWriteError.connectionClosed } - if inFlight &+ chunk <= allowance { + if established && available > 0 { + let count = min(maximumCount, available) + transmittedCount &+= count + let credit = forwardCountValue condition.unlock() - return + return (count, credit) } if let deadline { let remaining = deadline - ProcessInfo.processInfo.systemUptime @@ -510,11 +2246,10 @@ public final class VirtioVsock: VirtioDeviceBackend { condition.unlock() throw VsockConnectionWriteError.timedOut } - let signalled = condition.wait(until: Date().addingTimeInterval(remaining)) + _ = condition.wait( + until: Date().addingTimeInterval(min(remaining, 0.05)) + ) condition.unlock() - if !signalled, ProcessInfo.processInfo.systemUptime >= deadline { - throw VsockConnectionWriteError.timedOut - } } else { condition.wait() condition.unlock() @@ -522,35 +2257,78 @@ public final class VirtioVsock: VirtioDeviceBackend { } } - func markPeerSendClosed() { + func markPeerShutdown(flags: UInt32) -> Bool { condition.lock() - peerSendClosed = true + if flags & VsockShutdown.receive != 0 { peerReceiveClosed = true } + if flags & VsockShutdown.send != 0 { peerSendClosed = true } + let complete = peerReceiveClosed && peerSendClosed condition.broadcast() condition.unlock() + return complete } - /// Host-side half-close: tells the guest this end is done sending (VIRTIO_VSOCK_SHUTDOWN_SEND) - /// while its remaining data keeps flowing back. The relay for `docker run`'s attach depends on - /// this — the CLI half-closes as soon as the request is sent and then reads the whole stream. func shutdownSend() { + writeLock.lock() condition.lock() - if isClosed || hostSendClosed { condition.unlock(); return } + if isClosed || hostSendClosed { + condition.unlock() + writeLock.unlock() + return + } hostSendClosed = true let credit = forwardCountValue condition.broadcast() condition.unlock() - send(.shutdown, [], credit, VsockShutdown.send) + let result = send(.shutdown, [], credit, VsockShutdown.send) + if result != .enqueued { + abort() + onLocalClose() + } + writeLock.unlock() } func close() { condition.lock() - if isClosed { condition.unlock(); return } + if isClosed { + condition.unlock() + return + } + // Publish closure before waiting for write serialization. A writer can be asleep in + // reserveTransmit with no timeout; setting this state and broadcasting is what lets it + // release writeLock so teardown cannot deadlock behind peer-credit starvation. isClosed = true - let credit = forwardCountValue + let released = inbound.count + inbound.removeAll(keepingCapacity: false) + let credit = forwardCountValue &+ UInt32(released) + forwardCountValue = credit + condition.broadcast() + condition.unlock() + if released > 0 { releaseInbound(released) } + + // Start the bounded retirement deadline before waiting for an in-flight writer to + // release serialization. The writer was woken above; if it is instead stuck below the + // connection layer, the tuple still fails closed and retires through the reaper. + onLocalClose() + writeLock.lock() + _ = send(.shutdown, [], credit, VsockShutdown.all) + // Keep a bounded tombstone until peer RST/reset; section 5.10.6.5 forbids tuple reuse + // while the peer may still be processing the old connection. + writeLock.unlock() + } + + func abort() { + condition.lock() + if isClosed && inbound.isEmpty { + condition.broadcast() + condition.unlock() + return + } + isClosed = true + let released = inbound.count + inbound.removeAll(keepingCapacity: false) condition.broadcast() condition.unlock() - send(.shutdown, [], credit, VsockShutdown.receive | VsockShutdown.send) - onClose(key) + if released > 0 { releaseInbound(released) } } } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift index a3e17233..8f469274 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift @@ -1,6 +1,68 @@ import Foundation import Synchronization +/// Transport-wide split-ring features implemented by the queue parser. +public enum VirtqueueFeature { + /// VIRTIO_RING_F_INDIRECT_DESC, virtio 1.2 section 2.7.5.3. + public static let indirectDescriptors: UInt64 = 1 << 28 + /// VIRTIO_F_VERSION_1. Virtio-MMIO v2 devices reject negotiation without this feature. + public static let version1: UInt64 = 1 << 32 +} + +/// Opaque identity for the exact queue configuration that produced a descriptor chain. +/// +/// Retaining a chain is safe only while this token is still current. Reset, QueueReady changes, +/// layout reconfiguration, and negotiated-feature changes all revoke previously issued tokens. +public struct VirtqueueLease: Equatable, Hashable, Sendable { + fileprivate let queueIdentity: UUID + public let generation: UInt64 +} + +private struct VirtqueueLeaseState: Sendable { + var queueIdentity: UUID + var generation: UInt64 +} + +private final class VirtqueueLeaseAuthority: Sendable { + private let state = Mutex(VirtqueueLeaseState(queueIdentity: UUID(), generation: 1)) + + var current: VirtqueueLease { + state.withLock { + VirtqueueLease(queueIdentity: $0.queueIdentity, generation: $0.generation) + } + } + + func invalidate() { + state.withLock { + if $0.generation == UInt64.max { + $0.queueIdentity = UUID() + $0.generation = 1 + } else { + $0.generation += 1 + } + } + } + + func validates(_ lease: VirtqueueLease) -> Bool { + state.withLock { + $0.queueIdentity == lease.queueIdentity && $0.generation == lease.generation + } + } + + /// Executes synchronous guest-memory access while preventing reset/reconfiguration from + /// revoking this lease. The body must not escape a pointer or segment beyond the call. + func withValidLease( + _ lease: VirtqueueLease, + _ body: () throws -> Result + ) rethrows -> Result? { + try state.withLock { + guard $0.queueIdentity == lease.queueIdentity, + $0.generation == lease.generation else { return nil } + return try body() + } + } +} + /// One buffer segment of a descriptor chain, resolved to host memory. public struct VirtqueueSegment { public let pointer: UnsafeMutableRawPointer @@ -8,25 +70,82 @@ public struct VirtqueueSegment { public let isDeviceWritable: Bool } -/// A popped descriptor chain: read segments first, then device-writable ones. -public struct VirtqueueChain { - public let head: UInt16 - public let segments: [VirtqueueSegment] +/// A synchronous, lease-held view of one descriptor chain. +/// +/// Instances are created only by `VirtqueueChain.withLeaseHeld`. Raw segments must not escape the +/// callback: reset/reconfiguration is blocked only until that callback returns. The type is +/// deliberately not `Sendable`, as its storage is guest-owned mutable memory. +struct VirtqueueLeaseAccess { + fileprivate let resolvedSegments: [VirtqueueSegment] - public var readableSegments: [VirtqueueSegment] { segments.filter { !$0.isDeviceWritable } } - public var writableSegments: [VirtqueueSegment] { segments.filter { $0.isDeviceWritable } } - public var hasWritableSegments: Bool { segments.contains { $0.isDeviceWritable } } + /// Module-internal raw view for zero-copy device backends. Its validity is bounded by the + /// surrounding `withLeaseHeld` callback. + var segments: [VirtqueueSegment] { resolvedSegments } - public func readBytes(maximum: Int = Int.max) -> [UInt8] { + var readableSegments: [VirtqueueSegment] { + resolvedSegments.filter { !$0.isDeviceWritable } + } + + var writableSegments: [VirtqueueSegment] { + resolvedSegments.filter(\.isDeviceWritable) + } + + var hasWritableSegments: Bool { + resolvedSegments.contains(where: \.isDeviceWritable) + } + + var readableSegmentCount: Int { + resolvedSegments.lazy.filter { !$0.isDeviceWritable }.count + } + + var writableSegmentCount: Int { + resolvedSegments.lazy.filter(\.isDeviceWritable).count + } + + var readableByteCount: Int { + byteCount(deviceWritable: false) + } + + var writableByteCount: Int { + byteCount(deviceWritable: true) + } + + private func byteCount(deviceWritable: Bool) -> Int { + var total = 0 + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + let (next, overflow) = total.addingReportingOverflow(segment.length) + guard segment.length >= 0, !overflow else { return 0 } + total = next + } + return total + } + + func readBytes(maximum: Int = Int.max) -> [UInt8] { + copyBytes(deviceWritable: false, maximum: maximum) + } + + /// Copies an already-encoded response while the queue lease is held. Async backends use this + /// host-owned snapshot to roll back grants after reset without rereading a repurposed buffer. + func copyWritableBytes(maximum: Int = Int.max) -> [UInt8] { + copyBytes(deviceWritable: true, maximum: maximum) + } + + private func copyBytes(deviceWritable: Bool, maximum: Int) -> [UInt8] { + guard maximum > 0 else { return [] } var bytes = [UInt8]() var capacity = 0 - for segment in segments where !segment.isDeviceWritable { + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + guard segment.length >= 0, capacity <= maximum else { return [] } let remaining = maximum - capacity guard remaining > 0 else { break } - capacity += min(segment.length, remaining) + let take = min(segment.length, remaining) + let (nextCapacity, overflow) = capacity.addingReportingOverflow(take) + guard !overflow else { return [] } + capacity = nextCapacity } bytes.reserveCapacity(capacity) - for segment in segments where !segment.isDeviceWritable { + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + guard segment.length >= 0, bytes.count <= maximum else { return [] } let take = min(segment.length, maximum - bytes.count) guard take > 0 else { break } bytes.append(contentsOf: UnsafeRawBufferPointer(start: segment.pointer, count: take)) @@ -35,21 +154,21 @@ public struct VirtqueueChain { } @discardableResult - public func writeBytes(_ bytes: [UInt8]) -> Int { + func writeBytes(_ bytes: [UInt8]) -> Int { writeBytes(bytes, atWritableOffset: 0) } - /// Writes into the concatenated device-writable portion of the chain at a byte offset. - /// Virtio protocols such as virtio-snd place a writable PCM payload before a writable status - /// structure, so the device must address the latter without overwriting the former. @discardableResult - public func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { + func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { guard requestedOffset >= 0 else { return 0 } var sourceOffset = 0 var writableOffset = 0 - for segment in segments where segment.isDeviceWritable { - if writableOffset + segment.length <= requestedOffset { - writableOffset += segment.length + for segment in resolvedSegments where segment.isDeviceWritable { + guard segment.length >= 0 else { return sourceOffset } + let (segmentEnd, overflow) = writableOffset.addingReportingOverflow(segment.length) + guard !overflow else { return sourceOffset } + if segmentEnd <= requestedOffset { + writableOffset = segmentEnd continue } let destinationOffset = max(0, requestedOffset - writableOffset) @@ -63,12 +182,128 @@ public struct VirtqueueChain { ) } sourceOffset += take - writableOffset += segment.length + writableOffset = segmentEnd } return sourceOffset } } +/// A popped descriptor chain in the exact order supplied by the guest. +/// +/// Protocol backends must validate any required readable-prefix/writable-suffix layout; the +/// queue parser deliberately preserves mixed or reversed direction changes so they cannot be +/// normalized into an apparently valid request. +public struct VirtqueueChain: @unchecked Sendable { + public let head: UInt16 + public let lease: VirtqueueLease + /// True when the raw descriptor walk encountered a zero-length data descriptor. The queue + /// parser preserves its historical nonzero segment view, while protocol frontends that require + /// every descriptor to carry data can reject the original chain without guessing. + public let containsZeroLengthDescriptor: Bool + private let resolvedSegments: [VirtqueueSegment] + private let leaseAuthority: VirtqueueLeaseAuthority + + fileprivate init( + head: UInt16, + segments: [VirtqueueSegment], + containsZeroLengthDescriptor: Bool, + lease: VirtqueueLease, + leaseAuthority: VirtqueueLeaseAuthority + ) { + self.head = head + self.resolvedSegments = segments + self.containsZeroLengthDescriptor = containsZeroLengthDescriptor + self.lease = lease + self.leaseAuthority = leaseAuthority + } + + public var isLeaseValid: Bool { leaseAuthority.validates(lease) } + + public var hasWritableSegments: Bool { + withLeaseHeld(\.hasWritableSegments) ?? false + } + + public var readableSegmentCount: Int { + withLeaseHeld(\.readableSegmentCount) ?? 0 + } + + public var writableSegmentCount: Int { + withLeaseHeld(\.writableSegmentCount) ?? 0 + } + + public var readableByteCount: Int { + withLeaseHeld(\.readableByteCount) ?? 0 + } + + public var writableByteCount: Int { + withLeaseHeld(\.writableByteCount) ?? 0 + } + + /// Runs synchronous guest-buffer work under the queue's lifecycle lease. Reset, + /// QueueReady changes, feature renegotiation, and layout reconfiguration wait for this body to + /// finish before revoking the chain. The body must not escape `VirtqueueLeaseAccess` or any raw + /// segment/pointer obtained from it. + func withLeaseHeld( + _ body: (VirtqueueLeaseAccess) throws -> Result + ) rethrows -> Result? { + try leaseAuthority.withValidLease(lease) { + try body(VirtqueueLeaseAccess(resolvedSegments: resolvedSegments)) + } + } + + public func readBytes(maximum: Int = Int.max) -> [UInt8] { + withLeaseHeld { $0.readBytes(maximum: maximum) } ?? [] + } + + @discardableResult + public func writeBytes(_ bytes: [UInt8]) -> Int { + writeBytes(bytes, atWritableOffset: 0) + } + + /// Writes into the concatenated device-writable portion of the chain at a byte offset. + /// Virtio protocols such as virtio-snd place a writable PCM payload before a writable status + /// structure, so the device must address the latter without overwriting the former. + @discardableResult + public func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { + withLeaseHeld { + $0.writeBytes(bytes, atWritableOffset: requestedOffset) + } ?? 0 + } +} + +/// Exact result of attempting to publish a used-ring completion. A revoked chain is a lifecycle +/// outcome, not the same thing as a successfully published completion that suppressed interrupts. +public enum VirtqueuePushOutcome: Equatable, Sendable { + case published(wantsInterrupt: Bool) + case revoked +} + +/// Host-side complexity and memory limits applied while resolving one descriptor chain. +/// +/// These are deliberately independent of protocol-specific limits. A backend may impose a much +/// smaller request size after parsing, while this boundary prevents malformed guest rings from +/// creating unbounded descriptor walks, segment arrays, or host allocations first. +public struct VirtqueueLimits: Equatable, Sendable { + public static let hardenedDefault = VirtqueueLimits() + + public var maximumDescriptorCount: UInt64 + public var maximumSegmentCount: UInt64 + public var maximumSegmentBytes: UInt64 + public var maximumTotalBytes: UInt64 + + public init( + maximumDescriptorCount: UInt64 = 512, + maximumSegmentCount: UInt64 = 256, + maximumSegmentBytes: UInt64 = 64 * 1_024 * 1_024, + maximumTotalBytes: UInt64 = 64 * 1_024 * 1_024 + ) { + self.maximumDescriptorCount = maximumDescriptorCount + self.maximumSegmentCount = maximumSegmentCount + self.maximumSegmentBytes = maximumSegmentBytes + self.maximumTotalBytes = maximumTotalBytes + } +} + /// Split virtqueue (virtio 1.x basic layout). Descriptor chains are resolved against guest RAM /// with bounds checks on every dereference; a malformed address from the guest fails the pop /// rather than touching host memory outside the RAM window. @@ -77,41 +312,151 @@ public struct VirtqueueChain { /// device never race on ring indices in the single-CPU configuration; SMP adds explicit fences /// at the used-index publish below. public final class Virtqueue { + public static let maximumSize: UInt64 = 256 + public private(set) var size: UInt16 = 0 public private(set) var ready = false + public private(set) var negotiatedFeatures: UInt64 = 0 private var descriptorTable: UInt64 = 0 private var availRing: UInt64 = 0 private var usedRing: UInt64 = 0 private var lastAvailIndex: UInt16 = 0 private var usedIndex: UInt16 = 0 private let memory: GuestMemory + private let limits: VirtqueueLimits + private let leaseAuthority = VirtqueueLeaseAuthority() private struct DescriptorFlags { static let next: UInt16 = 1 static let write: UInt16 = 2 static let indirect: UInt16 = 4 + static let known = next | write | indirect + } + + private struct TraversalState { + var descriptorCount: UInt64 = 0 + var segmentCount: UInt64 = 0 + var totalBytes: UInt64 = 0 + var containsZeroLengthDescriptor = false + + mutating func recordDescriptor(limits: VirtqueueLimits) throws { + guard descriptorCount < limits.maximumDescriptorCount else { + throw VMError.unexpectedExit("virtqueue descriptor limit exceeded") + } + descriptorCount += 1 + } + + mutating func recordSegment(byteCount: UInt64, limits: VirtqueueLimits) throws { + guard segmentCount < limits.maximumSegmentCount else { + throw VMError.unexpectedExit("virtqueue segment limit exceeded") + } + guard byteCount <= limits.maximumSegmentBytes else { + throw VMError.unexpectedExit("virtqueue segment byte limit exceeded") + } + let (nextTotal, overflow) = totalBytes.addingReportingOverflow(byteCount) + guard !overflow, nextTotal <= limits.maximumTotalBytes else { + throw VMError.unexpectedExit("virtqueue total byte limit exceeded") + } + segmentCount += 1 + totalBytes = nextTotal + } } - public init(memory: GuestMemory) { + public init(memory: GuestMemory, limits: VirtqueueLimits = .hardenedDefault) { self.memory = memory + self.limits = limits + } + + public var currentLease: VirtqueueLease { leaseAuthority.current } + public var generation: UInt64 { currentLease.generation } + + public func isLeaseValid(_ lease: VirtqueueLease) -> Bool { + leaseAuthority.validates(lease) + } + + public func isLeaseValid(_ chain: VirtqueueChain) -> Bool { + isLeaseValid(chain.lease) + } + + /// Applies the transport-negotiated ring features and revokes chains parsed under an older + /// feature contract. Unsupported backend-specific bits are harmless to this parser. + public func setNegotiatedFeatures(_ features: UInt64) { + guard negotiatedFeatures != features else { return } + leaseAuthority.invalidate() + negotiatedFeatures = features } - public func configure(size: UInt16, descriptorTable: UInt64, availRing: UInt64, usedRing: UInt64) { + /// Applies one complete split-ring layout. Invalid guest layouts leave the queue disabled. + @discardableResult + public func configure( + untrustedSize requestedSize: UInt64, + descriptorTable: UInt64, + availRing: UInt64, + usedRing: UInt64 + ) -> Bool { + leaseAuthority.invalidate() + guard let size = UInt16(exactly: requestedSize), + Self.isValidSize(requestedSize), + descriptorTable % 16 == 0, + availRing % 2 == 0, + usedRing % 4 == 0, + let descriptorBytes = Self.multiplied(requestedSize, by: 16), + let availElementsBytes = Self.multiplied(requestedSize, by: 2), + let availBytes = Self.added(4, to: availElementsBytes), + let usedElementsBytes = Self.multiplied(requestedSize, by: 8), + let usedBytes = Self.added(4, to: usedElementsBytes), + memory.contains(descriptorTable, count: descriptorBytes), + memory.contains(availRing, count: availBytes), + memory.contains(usedRing, count: usedBytes) else { + invalidateConfiguration() + return false + } self.size = size self.descriptorTable = descriptorTable self.availRing = availRing self.usedRing = usedRing + return true } - public func setReady(_ isReady: Bool) { + /// Source-compatible entry point for trusted callers that already hold the transport-sized + /// queue value. It still runs every layout and power-of-two validation above. + @discardableResult + public func configure( + size: UInt16, + descriptorTable: UInt64, + availRing: UInt64, + usedRing: UInt64 + ) -> Bool { + configure( + untrustedSize: UInt64(size), + descriptorTable: descriptorTable, + availRing: availRing, + usedRing: usedRing + ) + } + + @discardableResult + public func setReady(_ isReady: Bool) -> Bool { + leaseAuthority.invalidate() + guard !isReady || Self.isValidSize(UInt64(size)) else { + ready = false + return false + } ready = isReady if isReady { lastAvailIndex = 0 usedIndex = 0 } + return true } public func reset() { + leaseAuthority.invalidate() + negotiatedFeatures = 0 + invalidateConfiguration() + } + + private func invalidateConfiguration() { ready = false size = 0 descriptorTable = 0 @@ -122,9 +467,20 @@ public final class Virtqueue { } public var hasPending: Bool { - guard ready, size > 0 else { return false } - let availIndex = (try? memory.read(UInt16.self, at: availRing + 2)) ?? lastAvailIndex - return availIndex != lastAvailIndex + ((try? pendingCount()) ?? 0) > 0 + } + + /// Returns the validated number of available entries. Unlike the compatibility `hasPending` + /// property, this preserves malformed ring state as an error for hardened device backends. + public func pendingCount() throws -> UInt16 { + guard ready, size > 0 else { return 0 } + let indexAddress = try checkedAdd(availRing, 2, "available-index address") + let availIndex = try memory.read(UInt16.self, at: indexAddress) + let pending = availIndex &- lastAvailIndex + guard pending <= size else { + throw VMError.unexpectedExit("virtqueue available ring overrun") + } + return pending } /// Resolves the next available descriptor without consuming it. @@ -142,75 +498,203 @@ public final class Virtqueue { private func nextChain(consume: Bool) throws -> VirtqueueChain? { guard ready, size > 0 else { return nil } - let availIndex = try memory.read(UInt16.self, at: availRing + 2) - guard availIndex != lastAvailIndex else { return nil } + let lease = currentLease + let availIndexAddress = try checkedAdd(availRing, 2, "available-index address") + let availIndex = try memory.read(UInt16.self, at: availIndexAddress) + let pending = availIndex &- lastAvailIndex + guard pending > 0 else { return nil } + guard pending <= size else { + throw VMError.unexpectedExit("virtqueue available ring overrun") + } let slot = UInt64(lastAvailIndex % size) - let head = try memory.read(UInt16.self, at: availRing + 4 + slot * 2) + let slotOffset = try checkedMultiply(slot, 2, "available-ring slot offset") + let ringOffset = try checkedAdd(4, slotOffset, "available-ring element offset") + let headAddress = try checkedAdd(availRing, ringOffset, "available-ring element address") + let head = try memory.read(UInt16.self, at: headAddress) if consume { lastAvailIndex &+= 1 } var segments = [VirtqueueSegment]() - try walkChain(startingAt: head, table: descriptorTable, tableSize: size, into: &segments, depth: 0) - return VirtqueueChain(head: head, segments: segments) + var traversal = TraversalState() + try walkChain( + startingAt: head, + table: descriptorTable, + tableSize: UInt64(size), + into: &segments, + insideIndirectTable: false, + traversal: &traversal + ) + guard isLeaseValid(lease) else { + throw VMError.unexpectedExit("virtqueue changed while resolving descriptor chain") + } + return VirtqueueChain( + head: head, + segments: segments, + containsZeroLengthDescriptor: traversal.containsZeroLengthDescriptor, + lease: lease, + leaseAuthority: leaseAuthority + ) } private func walkChain( startingAt first: UInt16, table: UInt64, - tableSize: UInt16, + tableSize: UInt64, into segments: inout [VirtqueueSegment], - depth: Int + insideIndirectTable: Bool, + traversal: inout TraversalState ) throws { - guard depth < 4 else { - throw VMError.unexpectedExit("virtqueue indirect descriptor nesting too deep") + guard tableSize > 0 else { + throw VMError.unexpectedExit("virtqueue descriptor table is empty") } - var index = first - var hops = 0 + var index = UInt64(first) + var hops: UInt64 = 0 while true { - guard hops <= Int(tableSize), index < tableSize else { + guard hops < tableSize, index < tableSize else { throw VMError.unexpectedExit("virtqueue descriptor chain out of bounds") } hops += 1 - let base = table + UInt64(index) * 16 + try traversal.recordDescriptor(limits: limits) + let descriptorOffset = try checkedMultiply(index, 16, "descriptor-table offset") + let base = try checkedAdd(table, descriptorOffset, "descriptor address") let address = try memory.read(UInt64.self, at: base) - let length = try memory.read(UInt32.self, at: base + 8) - let flags = try memory.read(UInt16.self, at: base + 12) - let next = try memory.read(UInt16.self, at: base + 14) + let length = try memory.read( + UInt32.self, + at: checkedAdd(base, 8, "descriptor length address") + ) + let flags = try memory.read( + UInt16.self, + at: checkedAdd(base, 12, "descriptor flags address") + ) + let next = try memory.read( + UInt16.self, + at: checkedAdd(base, 14, "descriptor next address") + ) + guard flags & ~DescriptorFlags.known == 0 else { + throw VMError.unexpectedExit("virtqueue descriptor contains unknown flags") + } if flags & DescriptorFlags.indirect != 0 { - let entries = UInt16(length / 16) - try walkChain(startingAt: 0, table: address, tableSize: entries, into: &segments, depth: depth + 1) - } else if length > 0 { - let pointer = try memory.hostPointer(at: address, count: UInt64(length)) - segments.append(VirtqueueSegment( - pointer: pointer, - length: Int(length), - isDeviceWritable: flags & DescriptorFlags.write != 0 - )) + guard negotiatedFeatures & VirtqueueFeature.indirectDescriptors != 0 else { + throw VMError.unexpectedExit( + "virtqueue indirect descriptor feature was not negotiated" + ) + } + guard !insideIndirectTable else { + throw VMError.unexpectedExit("virtqueue nested indirect descriptor") + } + guard flags & DescriptorFlags.next == 0 else { + throw VMError.unexpectedExit("virtqueue indirect descriptor also sets NEXT") + } + guard length > 0, length % 16 == 0, address % 16 == 0 else { + throw VMError.unexpectedExit("virtqueue indirect table has invalid layout") + } + let entryCount = UInt64(length) / 16 + guard entryCount <= limits.maximumDescriptorCount else { + throw VMError.unexpectedExit("virtqueue indirect table exceeds descriptor limit") + } + _ = try memory.hostPointer(at: address, count: UInt64(length)) + try walkChain( + startingAt: 0, + table: address, + tableSize: entryCount, + into: &segments, + insideIndirectTable: true, + traversal: &traversal + ) + break + } else { + if length == 0 { + traversal.containsZeroLengthDescriptor = true + } else { + let byteCount = UInt64(length) + try traversal.recordSegment(byteCount: byteCount, limits: limits) + guard let hostLength = Int(exactly: length) else { + throw VMError.unexpectedExit("virtqueue segment length is not host-representable") + } + let pointer = try memory.hostPointer(at: address, count: UInt64(length)) + segments.append(VirtqueueSegment( + pointer: pointer, + length: hostLength, + isDeviceWritable: flags & DescriptorFlags.write != 0 + )) + } } - if flags & DescriptorFlags.indirect != 0 || flags & DescriptorFlags.next == 0 { break } - index = next + guard flags & DescriptorFlags.next != 0 else { break } + index = UInt64(next) } } /// Returns whether the guest asked for an interrupt for this completion. @discardableResult public func push(_ chain: VirtqueueChain, written: Int) throws -> Bool { - guard ready, size > 0 else { return false } - let slot = UInt64(usedIndex % size) - try memory.write(UInt32(chain.head), at: usedRing + 4 + slot * 8) - try memory.write(UInt32(written), at: usedRing + 8 + slot * 8) - usedIndex &+= 1 - OSMemoryBarrier() // used entries visible before the index publish - try memory.write(usedIndex, at: usedRing + 2) - let availFlags = try memory.read(UInt16.self, at: availRing) - return availFlags & 1 == 0 // VRING_AVAIL_F_NO_INTERRUPT + switch try pushOutcome(chain, written: written) { + case .published(let wantsInterrupt): return wantsInterrupt + case .revoked: return false + } + } + + /// Publishes one used-ring completion while preserving lifecycle revocation as a typed outcome. + /// New asynchronous backends should use this API so reset/reconfiguration is observable rather + /// than conflated with `VRING_AVAIL_F_NO_INTERRUPT`. + public func pushOutcome( + _ chain: VirtqueueChain, + written: Int + ) throws -> VirtqueuePushOutcome { + // A backend may finish after the guest resets or reconfigures the queue. Preserve the + // safe no-op completion contract while refusing to publish that obsolete chain into either + // the replacement queue or a different queue. The typed API exposes that revocation. + let publication = try leaseAuthority.withValidLease(chain.lease) { + guard ready, size > 0 else { return false } + guard let written = UInt32(exactly: written) else { + throw VMError.unexpectedExit("virtqueue used length is not representable") + } + let slot = UInt64(usedIndex % size) + let slotOffset = try checkedMultiply(slot, 8, "used-ring slot offset") + let elementOffset = try checkedAdd(4, slotOffset, "used-ring element offset") + let elementAddress = try checkedAdd(usedRing, elementOffset, "used-ring element address") + try memory.write(UInt32(chain.head), at: elementAddress) + try memory.write(written, at: checkedAdd(elementAddress, 4, "used-ring length address")) + usedIndex &+= 1 + OSMemoryBarrier() // used entries visible before the index publish + try memory.write(usedIndex, at: checkedAdd(usedRing, 2, "used-index address")) + let availFlags = try memory.read(UInt16.self, at: availRing) + return availFlags & 1 == 0 // VRING_AVAIL_F_NO_INTERRUPT + } + guard let publication else { return .revoked } + return .published(wantsInterrupt: publication) + } + + private static func isValidSize(_ size: UInt64) -> Bool { + size > 0 && size <= maximumSize && size & (size - 1) == 0 + } + + private static func added(_ lhs: UInt64, to rhs: UInt64) -> UInt64? { + let (result, overflow) = rhs.addingReportingOverflow(lhs) + return overflow ? nil : result + } + + private static func multiplied(_ lhs: UInt64, by rhs: UInt64) -> UInt64? { + let (result, overflow) = lhs.multipliedReportingOverflow(by: rhs) + return overflow ? nil : result + } + + private func checkedAdd(_ lhs: UInt64, _ rhs: UInt64, _ context: String) throws -> UInt64 { + guard let result = Self.added(rhs, to: lhs) else { + throw VMError.unexpectedExit("virtqueue \(context) overflow") + } + return result + } + + private func checkedMultiply(_ lhs: UInt64, _ rhs: UInt64, _ context: String) throws -> UInt64 { + guard let result = Self.multiplied(lhs, by: rhs) else { + throw VMError.unexpectedExit("virtqueue \(context) overflow") + } + return result } } -extension VirtqueueSegment: @unchecked Sendable {} -extension VirtqueueChain: @unchecked Sendable {} extension Virtqueue: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift new file mode 100644 index 00000000..7bb31a3c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift @@ -0,0 +1,524 @@ +import Foundation + +/// Stable service identities for every host resource reachable through the VM's vsock device. +/// +/// The identity is intentionally not a guest-selected port number. A malicious or compromised +/// peer must not be able to manufacture new accounting buckets and evade a per-service ceiling. +public enum VirtioVsockService: String, CaseIterable, Hashable, Sendable { + /// Short-lived host RPCs to the guest agent, including USB capability/mutation calls. + case agentRPC + /// The private host Unix socket that exposes the guest agent control endpoint. + case agentSocket + /// The private dataplane socket whose authenticated preamble selects a guest port. + case agentForward + case docker + case fileEvents + case hostAI + case sshAgent + case shell + case usbip +} + +public enum VirtioVsockServiceAdmissionConfigurationError: Error, Equatable, Sendable { + case invalidAggregateLimit(Int) + case invalidDefaultServiceLimit(Int) + case invalidServiceLimit(service: VirtioVsockService, limit: Int) + case serviceLimitExceedsAggregate(service: VirtioVsockService, limit: Int, aggregate: Int) +} + +/// Immutable capacity policy shared by every service on one vsock device. +public struct VirtioVsockServiceAdmissionLimits: Equatable, Sendable { + public static let hardenedDefault = VirtioVsockServiceAdmissionLimits( + maximumSessionsTotal: 64, + defaultMaximumSessionsPerService: 16, + serviceOverrides: [ + .agentRPC: 8, + .fileEvents: 8, + .sshAgent: 8, + .shell: 8, + .usbip: 8, + ], + validated: () + ) + + public let maximumSessionsTotal: Int + public let defaultMaximumSessionsPerService: Int + public let serviceOverrides: [VirtioVsockService: Int] + + public init( + maximumSessionsTotal: Int, + defaultMaximumSessionsPerService: Int, + serviceOverrides: [VirtioVsockService: Int] = [:] + ) throws { + guard (1...256).contains(maximumSessionsTotal) else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidAggregateLimit( + maximumSessionsTotal + ) + } + guard (1...maximumSessionsTotal).contains(defaultMaximumSessionsPerService) else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidDefaultServiceLimit( + defaultMaximumSessionsPerService + ) + } + for (service, limit) in serviceOverrides { + guard limit > 0 else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidServiceLimit( + service: service, + limit: limit + ) + } + guard limit <= maximumSessionsTotal else { + throw VirtioVsockServiceAdmissionConfigurationError.serviceLimitExceedsAggregate( + service: service, + limit: limit, + aggregate: maximumSessionsTotal + ) + } + } + self.init( + maximumSessionsTotal: maximumSessionsTotal, + defaultMaximumSessionsPerService: defaultMaximumSessionsPerService, + serviceOverrides: serviceOverrides, + validated: () + ) + } + + public func maximumSessions(for service: VirtioVsockService) -> Int { + serviceOverrides[service] ?? defaultMaximumSessionsPerService + } + + private init( + maximumSessionsTotal: Int, + defaultMaximumSessionsPerService: Int, + serviceOverrides: [VirtioVsockService: Int], + validated: Void + ) { + self.maximumSessionsTotal = maximumSessionsTotal + self.defaultMaximumSessionsPerService = defaultMaximumSessionsPerService + self.serviceOverrides = serviceOverrides + } +} + +/// Typed refusal returned before a host fd, relay thread, or guest connection is admitted. +public enum VirtioVsockServiceAdmissionError: Error, Equatable, Sendable { + case serviceCapacityReached(service: VirtioVsockService, limit: Int) + case aggregateCapacityReached(limit: Int) + case deviceResetting + case deviceQuiesced + case lifecycleRevoked(service: VirtioVsockService) +} + +/// Observable admission state. Counts include reservations that have passed policy but have not yet +/// published their stop callback, so the snapshot never understates resources already committed. +public struct VirtioVsockServiceAdmissionSnapshot: Equatable, Sendable { + public let activeSessionsTotal: Int + public let activeSessionsByService: [VirtioVsockService: Int] + public let serviceCapacityRejections: [VirtioVsockService: UInt64] + public let aggregateCapacityRejections: UInt64 + public let resettingRejections: UInt64 + public let quiescedRejections: UInt64 + public let resetRevocations: UInt64 + public let terminalRevocations: UInt64 + public let latePublicationRejections: UInt64 + public let completedSessions: UInt64 + public let generation: UInt64 + public let isResetting: Bool + public let isQuiesced: Bool +} + +/// A two-phase reservation prevents reset, stop, or a concurrent cap crossing from publishing an +/// unowned late session. It is internal because production callers must use the service-labelled +/// listener/connect APIs on VirtioVsock rather than manually handling admission tokens. +struct VirtioVsockServiceReservation: Sendable { + fileprivate let id: UUID + fileprivate let service: VirtioVsockService + fileprivate let generation: UInt64 +} + +/// Exact ownership of one published service session. Close/deinit is idempotent and generation +/// checked; reset may revoke the authority first, in which case a late close is harmless. +final class VirtioVsockServiceLease: @unchecked Sendable { + private let lock = NSLock() + private weak var authority: VirtioVsockServiceAdmissionAuthority? + private var id: UUID? + private let generation: UInt64 + + fileprivate init( + authority: VirtioVsockServiceAdmissionAuthority, + id: UUID, + generation: UInt64 + ) { + self.authority = authority + self.id = id + self.generation = generation + } + + func close() { + lock.lock() + let ownedID = id + id = nil + let authority = authority + self.authority = nil + lock.unlock() + if let ownedID { + authority?.release(id: ownedID, generation: generation) + } + } + + /// Replaces the provisional transport-close callback with the owning service session's stronger + /// stop action. False means reset/quiesce already revoked the generation. + func replaceStopAction(_ action: @escaping @Sendable () -> Void) -> Bool { + lock.lock() + guard let id else { + lock.unlock() + return false + } + let authority = authority + lock.unlock() + return authority?.replaceStopAction( + id: id, + generation: generation, + action: action + ) ?? false + } + + deinit { + close() + } +} + +/// Per-VM authority shared by guest-initiated listeners, host-initiated connections, and the local +/// Unix relay frontends. It owns no service object, only bounded reservations and idempotent stop +/// callbacks; bridge lifecycles remain responsible for unregistering listeners and draining work. +final class VirtioVsockServiceAdmissionAuthority: @unchecked Sendable { + private enum Phase { + case active + case resetting + case quiesced + } + + private struct ReservationRecord { + let service: VirtioVsockService + let generation: UInt64 + } + + private struct SessionRecord { + let service: VirtioVsockService + let generation: UInt64 + var requestStop: @Sendable () -> Void + } + + private let lock = NSLock() + private let limits: VirtioVsockServiceAdmissionLimits + private var phase: Phase = .active + private var generation: UInt64 = 1 + private var reservations = [UUID: ReservationRecord]() + private var sessions = [UUID: SessionRecord]() + private var serviceCapacityRejections = [VirtioVsockService: UInt64]() + private var aggregateCapacityRejections: UInt64 = 0 + private var resettingRejections: UInt64 = 0 + private var quiescedRejections: UInt64 = 0 + private var resetRevocations: UInt64 = 0 + private var terminalRevocations: UInt64 = 0 + private var latePublicationRejections: UInt64 = 0 + private var completedSessions: UInt64 = 0 + + init(limits: VirtioVsockServiceAdmissionLimits) { + self.limits = limits + } + + func reserve(_ service: VirtioVsockService) throws -> VirtioVsockServiceReservation { + lock.lock() + defer { lock.unlock() } + switch phase { + case .active: + break + case .resetting: + increment(&resettingRejections) + throw VirtioVsockServiceAdmissionError.deviceResetting + case .quiesced: + increment(&quiescedRejections) + throw VirtioVsockServiceAdmissionError.deviceQuiesced + } + + let activeTotal = reservations.count + sessions.count + guard activeTotal < limits.maximumSessionsTotal else { + increment(&aggregateCapacityRejections) + throw VirtioVsockServiceAdmissionError.aggregateCapacityReached( + limit: limits.maximumSessionsTotal + ) + } + let serviceTotal = countLocked(service: service) + let serviceLimit = limits.maximumSessions(for: service) + guard serviceTotal < serviceLimit else { + var count = serviceCapacityRejections[service] ?? 0 + increment(&count) + serviceCapacityRejections[service] = count + throw VirtioVsockServiceAdmissionError.serviceCapacityReached( + service: service, + limit: serviceLimit + ) + } + let id = UUID() + reservations[id] = ReservationRecord(service: service, generation: generation) + return VirtioVsockServiceReservation( + id: id, + service: service, + generation: generation + ) + } + + func cancel(_ reservation: VirtioVsockServiceReservation) { + lock.lock() + if reservations[reservation.id]?.generation == reservation.generation, + reservations[reservation.id]?.service == reservation.service { + reservations.removeValue(forKey: reservation.id) + } + lock.unlock() + } + + func publish( + _ reservation: VirtioVsockServiceReservation, + requestStop: @escaping @Sendable () -> Void + ) -> VirtioVsockServiceLease? { + lock.lock() + guard case .active = phase, + generation == reservation.generation, + let record = reservations.removeValue(forKey: reservation.id), + record.generation == reservation.generation, + record.service == reservation.service else { + // Remove an exact stale reservation if reset has not already done so. + if reservations[reservation.id]?.generation == reservation.generation { + reservations.removeValue(forKey: reservation.id) + } + increment(&latePublicationRejections) + lock.unlock() + return nil + } + sessions[reservation.id] = SessionRecord( + service: reservation.service, + generation: reservation.generation, + requestStop: requestStop + ) + lock.unlock() + return VirtioVsockServiceLease( + authority: self, + id: reservation.id, + generation: reservation.generation + ) + } + + func beginReset() { + revoke(terminal: false) + } + + func finishReset() { + lock.lock() + if case .resetting = phase { phase = .active } + lock.unlock() + } + + func quiesce() { + revoke(terminal: true) + } + + var snapshot: VirtioVsockServiceAdmissionSnapshot { + lock.lock() + defer { lock.unlock() } + var activeByService = [VirtioVsockService: Int]() + for record in reservations.values { + activeByService[record.service, default: 0] += 1 + } + for record in sessions.values { + activeByService[record.service, default: 0] += 1 + } + return VirtioVsockServiceAdmissionSnapshot( + activeSessionsTotal: reservations.count + sessions.count, + activeSessionsByService: activeByService, + serviceCapacityRejections: serviceCapacityRejections, + aggregateCapacityRejections: aggregateCapacityRejections, + resettingRejections: resettingRejections, + quiescedRejections: quiescedRejections, + resetRevocations: resetRevocations, + terminalRevocations: terminalRevocations, + latePublicationRejections: latePublicationRejections, + completedSessions: completedSessions, + generation: generation, + isResetting: { + if case .resetting = phase { return true } + return false + }(), + isQuiesced: { + if case .quiesced = phase { return true } + return false + }() + ) + } + + fileprivate func release(id: UUID, generation: UInt64) { + lock.lock() + if sessions[id]?.generation == generation { + sessions.removeValue(forKey: id) + increment(&completedSessions) + } + lock.unlock() + } + + fileprivate func replaceStopAction( + id: UUID, + generation: UInt64, + action: @escaping @Sendable () -> Void + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard var record = sessions[id], record.generation == generation else { + return false + } + record.requestStop = action + sessions[id] = record + return true + } + + private func revoke(terminal: Bool) { + let stopActions: [@Sendable () -> Void] + lock.lock() + if case .quiesced = phase { + lock.unlock() + return + } + phase = terminal ? .quiesced : .resetting + generation &+= 1 + if generation == 0 { generation = 1 } + let revokedCount = UInt64(reservations.count + sessions.count) + if terminal { + addClamped(revokedCount, to: &terminalRevocations) + } else { + addClamped(revokedCount, to: &resetRevocations) + } + stopActions = sessions.values.map(\.requestStop) + reservations.removeAll(keepingCapacity: true) + sessions.removeAll(keepingCapacity: true) + lock.unlock() + + // Never execute a service callback while holding admission state: every callback is allowed + // to close a VsockConnection, which re-enters the device's transport lifecycle. + for requestStop in stopActions { requestStop() } + } + + private func countLocked(service: VirtioVsockService) -> Int { + reservations.values.reduce(0) { $0 + ($1.service == service ? 1 : 0) } + + sessions.values.reduce(0) { $0 + ($1.service == service ? 1 : 0) } + } + + private func increment(_ value: inout UInt64) { + if value < UInt64.max { value += 1 } + } + + private func addClamped(_ amount: UInt64, to value: inout UInt64) { + let (sum, overflow) = value.addingReportingOverflow(amount) + value = overflow ? UInt64.max : sum + } +} + +/// Holds a service lease for exactly as long as its underlying transport stream remains owned. +/// Reset/quiesce closes the underlying stream via the authority's stop callback; a late wrapper +/// close is harmless because the lease generation has already been revoked. +final class ServiceOwnedVsockConnection: VsockConnection, @unchecked Sendable { + private let lock = NSLock() + private var connection: VsockConnection? + private var lease: VirtioVsockServiceLease? + + init(connection: VsockConnection, lease: VirtioVsockServiceLease) { + self.connection = connection + self.lease = lease + } + + var isPeerClosed: Bool { + let connection = currentConnection + let closed = connection?.isPeerClosed ?? true + if closed { releaseLease() } + return closed + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + guard let connection = currentConnection else { return 0 } + let count = try connection.read(into: buffer) + if count == 0, connection.isPeerClosed { releaseLease() } + return count + } + + func write(_ bytes: [UInt8]) throws { + guard let connection = currentConnection else { + throw VsockConnectionWriteError.connectionClosed + } + do { + try connection.write(bytes) + } catch { + if connection.isPeerClosed { releaseLease() } + throw error + } + } + + func write(_ bytes: [UInt8], timeoutNanoseconds: UInt64?) throws { + guard let connection = currentConnection else { + throw VsockConnectionWriteError.connectionClosed + } + do { + try connection.write(bytes, timeoutNanoseconds: timeoutNanoseconds) + } catch { + if connection.isPeerClosed { releaseLease() } + throw error + } + } + + func waitForReadable(timeoutNanoseconds: UInt64?) -> Bool { + guard let connection = currentConnection else { return true } + let ready = connection.waitForReadable(timeoutNanoseconds: timeoutNanoseconds) + if connection.isPeerClosed { releaseLease() } + return ready + } + + func shutdownSend() { + currentConnection?.shutdownSend() + } + + func close() { + let ownedConnection: VsockConnection? + let ownedLease: VirtioVsockServiceLease? + lock.lock() + ownedConnection = connection + ownedLease = lease + connection = nil + lease = nil + lock.unlock() + ownedConnection?.close() + ownedLease?.close() + } + + func replaceServiceStopAction( + _ action: @escaping @Sendable () -> Void + ) -> Bool { + lock.lock() + let lease = lease + lock.unlock() + return lease?.replaceStopAction(action) ?? false + } + + deinit { + close() + } + + private var currentConnection: VsockConnection? { + lock.lock() + defer { lock.unlock() } + return connection + } + + private func releaseLease() { + lock.lock() + let ownedLease = lease + lease = nil + lock.unlock() + ownedLease?.close() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift index 4a89b7ed..84d3d5e8 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift @@ -5,16 +5,25 @@ import Foundation /// error so required engine endpoints can fail startup with an actionable reason instead of /// degrading into a later timeout. public enum UnixSocketListenerError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidAbsolutePath(path: String) case pathTooLong(path: String, utf8ByteCount: Int, maximumUTF8ByteCount: Int) case embeddedNull(path: String) + case untrustedExistingNode(path: String) + case endpointInUse(path: String) case systemCall(operation: String, path: String, code: Int32) public var description: String { switch self { + case let .invalidAbsolutePath(path): + return "unix socket path must be absolute: \(path)" case let .pathTooLong(path, actual, maximum): return "unix socket path is \(actual) UTF-8 bytes (maximum \(maximum)): \(path)" case let .embeddedNull(path): return "unix socket path contains a NUL byte: \(path)" + case let .untrustedExistingNode(path): + return "refusing to replace a non-socket or non-owned unix endpoint: \(path)" + case let .endpointInUse(path): + return "refusing to replace a live unix endpoint: \(path)" case let .systemCall(operation, path, code): let reason = String(cString: strerror(code)) return "cannot \(operation) unix socket \(path): errno \(code) (\(reason))" @@ -23,10 +32,35 @@ public enum UnixSocketListenerError: Error, Equatable, CustomStringConvertible, } /// The one unix⇄vsock byte relay, shared by every bridge that serves a unix socket in front of a -/// guest vsock stream (`DockerSocketBridge`, `AgentVsockForward`). Both directions preserve +/// guest vsock stream (`GuestVsockSocketBridge`, `DockerSocketBridge`, `AgentVsockForward`). +/// Both directions preserve /// half-close: a client SHUT_WR becomes a vsock SEND-only shutdown, and the guest's send-EOF /// becomes a SHUT_WR back to the client — a full close in either spot truncates docker attach. enum VsockUnixRelay { + struct SocketPathIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + } + + struct OwnedListener: Sendable { + let descriptor: Int32 + let pathIdentity: SocketPathIdentity + } + + private enum ExistingEndpointProbe { + case live + case stale + case indeterminate(Int32) + } + + /// Serializes Dory-owned pathname publication and retirement. Darwin has no conditional-unlink + /// syscall, so the identity check and unlink must share this lock with every in-process bind to + /// prevent one bridge's cleanup from racing another bridge's replacement publication. + private static let socketPathMutationLock = NSLock() + /// Darwin's `sockaddr_un.sun_path` includes its trailing NUL. Validate UTF-8 bytes rather than /// Swift characters: a multibyte path that looks short can still overflow the kernel field. static let maximumSocketPathByteCount: Int = { @@ -36,6 +70,9 @@ enum VsockUnixRelay { static func validateSocketPath(_ socketPath: String) throws { let pathBytes = Array(socketPath.utf8) + guard socketPath.hasPrefix("/") else { + throw UnixSocketListenerError.invalidAbsolutePath(path: socketPath) + } guard !pathBytes.contains(0) else { throw UnixSocketListenerError.embeddedNull(path: socketPath) } @@ -48,16 +85,72 @@ enum VsockUnixRelay { } } - static func makeListener(socketPath: String, mode: mode_t? = nil) throws -> Int32 { + /// Publishes a listener and captures the exact filesystem socket identity while the descriptor + /// is still live. Owners use that identity to avoid deleting a replacement endpoint during + /// asynchronous teardown. + static func makeOwnedListener( + socketPath: String, + mode: mode_t? = nil + ) throws -> OwnedListener { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } try validateSocketPath(socketPath) let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw UnixSocketListenerError.systemCall(operation: "create", path: socketPath, code: errno) } - guard unlink(socketPath) == 0 || errno == ENOENT else { + guard fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 else { + let code = errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "set close-on-exec for", + path: socketPath, + code: code + ) + } + var stale = stat() + if lstat(socketPath, &stale) == 0 { + guard stale.st_mode & S_IFMT == S_IFSOCK, + stale.st_uid == geteuid(), + let staleIdentity = socketPathIdentity(at: socketPath) else { + close(fd) + throw UnixSocketListenerError.untrustedExistingNode(path: socketPath) + } + switch probeExistingListener(socketPath) { + case .live: + close(fd) + throw UnixSocketListenerError.endpointInUse(path: socketPath) + case .indeterminate(let code): + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "prove stale", + path: socketPath, + code: code + ) + case .stale: + break + } + guard socketPathIdentity(at: socketPath) == staleIdentity else { + close(fd) + throw UnixSocketListenerError.untrustedExistingNode(path: socketPath) + } + guard unlink(socketPath) == 0 else { + let code = errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "remove stale", + path: socketPath, + code: code + ) + } + } else if errno != ENOENT { let code = errno close(fd) - throw UnixSocketListenerError.systemCall(operation: "remove stale", path: socketPath, code: code) + throw UnixSocketListenerError.systemCall( + operation: "inspect stale", + path: socketPath, + code: code + ) } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) @@ -78,40 +171,270 @@ enum VsockUnixRelay { close(fd) throw UnixSocketListenerError.systemCall(operation: "bind", path: socketPath, code: code) } + guard let pathIdentity = socketPathIdentity(at: socketPath) else { + let code = errno == 0 ? EIO : errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "inspect bound", + path: socketPath, + code: code + ) + } guard Darwin.listen(fd, 64) == 0 else { let code = errno - // Remove our pathname while the descriptor still owns the bound socket. Closing first - // would let another process bind a replacement that this cleanup could then unlink. - unlink(socketPath) + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) close(fd) throw UnixSocketListenerError.systemCall(operation: "listen on", path: socketPath, code: code) } if let mode, chmod(socketPath, mode) != 0 { let code = errno - // As above, retire the pathname before releasing the descriptor to avoid deleting a - // replacement socket in the close-to-unlink window. - unlink(socketPath) + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) close(fd) throw UnixSocketListenerError.systemCall(operation: "chmod", path: socketPath, code: code) } - return fd + guard socketPathIdentity(at: socketPath) == pathIdentity else { + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "retain identity of", + path: socketPath, + code: ESTALE + ) + } + return OwnedListener(descriptor: fd, pathIdentity: pathIdentity) } - /// Relays until both directions finish, then tears everything down. Takes ownership of both ends. - static func serve(client: Int32, connection: VsockConnection) { - defer { - connection.close() - close(client) - } - let group = DispatchGroup() - group.enter() - let box = ConnectionBox(connection) - Thread.detachNewThread { - pumpVsockToClient(from: box.connection, to: client) - group.leave() - } - pumpClientToVsock(from: client, to: connection) - group.wait() + /// A same-uid socket is not stale merely because a pathname already exists. Only the kernel's + /// explicit no-listener outcomes authorize removal; a live listener, a full backlog, and every + /// ambiguous probe result fail closed. + private static func probeExistingListener(_ socketPath: String) -> ExistingEndpointProbe { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return .indeterminate(errno) + } + let statusFlags = fcntl(descriptor, F_GETFL) + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 else { + return .indeterminate(errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + return .indeterminate(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + pathBytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: pathBytes.count + ) + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + if result == 0 { return .live } + switch errno { + case ECONNREFUSED, ENOENT: + return .stale + default: + return .indeterminate(errno) + } + } + + @discardableResult + static func makeNonBlocking(_ descriptor: Int32) -> Bool { + let flags = fcntl(descriptor, F_GETFL) + return flags >= 0 && fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 + } + + /// Removes only the filesystem node captured for this listener. The listener descriptor must + /// remain open until after this call; that ordering narrows replacement races and ensures a + /// completion signal means pathname ownership has already been surrendered. + static func retireOwnedListener(_ listener: OwnedListener, socketPath: String) { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + unlinkSocketIfOwnedLocked(socketPath, identity: listener.pathIdentity) + close(listener.descriptor) + } + + @discardableResult + private static func unlinkSocketIfOwnedLocked( + _ socketPath: String, + identity: SocketPathIdentity + ) -> Bool { + guard let current = socketPathIdentity(at: socketPath) else { + return errno == ENOENT + } + guard current == identity else { return false } + return unlink(socketPath) == 0 || errno == ENOENT + } + + private static func socketPathIdentity(at socketPath: String) -> SocketPathIdentity? { + var info = stat() + guard lstat(socketPath, &info) == 0, + info.st_mode & S_IFMT == S_IFSOCK, + info.st_uid == geteuid() else { + return nil + } + return SocketPathIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec) + ) + } + + /// One cancel-safe relay. Normal execution keeps the established half-close contract; an + /// explicit bridge stop uses full shutdown only to retire the session. The client descriptor is + /// closed by the relay after both pumps finish, never by the stopping thread, so descriptor reuse + /// cannot redirect a late close or shutdown to an unrelated file. + final class RelaySession: @unchecked Sendable { + private let lock = NSLock() + private var client: Int32? + private var connection: VsockConnection? + private var prepareConnection: (@Sendable (Int32) -> VsockConnection?)? + private var started = false + private var stopRequested = false + private var completion: (@Sendable () -> Void)? + + init( + client: Int32, + connection: VsockConnection, + completion: @escaping @Sendable () -> Void = {} + ) { + self.client = client + self.connection = connection + self.prepareConnection = nil + self.completion = completion + } + + /// Owns an accepted descriptor before a guest connection necessarily exists. The bounded + /// listener uses this for protocol admission (notably AgentVsockForward's preamble): stop() + /// can shutdown the client and wake that preparation without racing its eventual close. + /// `prepareConnection` borrows the descriptor; RelaySession remains its only close owner. + init( + client: Int32, + prepareConnection: @escaping @Sendable (Int32) -> VsockConnection?, + completion: @escaping @Sendable () -> Void = {} + ) { + self.client = client + self.connection = nil + self.prepareConnection = prepareConnection + self.completion = completion + } + + func run() { + let descriptor: Int32 + let existingConnection: VsockConnection? + let preparation: (@Sendable (Int32) -> VsockConnection?)? + lock.lock() + guard !started, let client else { + lock.unlock() + return + } + started = true + descriptor = client + existingConnection = connection + preparation = prepareConnection + prepareConnection = nil + lock.unlock() + + guard let preparedConnection = existingConnection ?? preparation?(descriptor) else { + finish() + return + } + lock.lock() + if connection == nil { connection = preparedConnection } + let shouldRelay = !stopRequested + lock.unlock() + guard shouldRelay else { + finish() + return + } + + let group = DispatchGroup() + group.enter() + let box = ConnectionBox(preparedConnection) + Thread.detachNewThread { + pumpVsockToClient(from: box.connection, to: descriptor) + group.leave() + } + pumpClientToVsock(from: descriptor, to: preparedConnection) + group.wait() + finish() + } + + func requestStop() { + lock.lock() + guard let client, !stopRequested else { + lock.unlock() + return + } + stopRequested = true + // Keep the descriptor allocated until both pumps have observed shutdown. Holding the + // lifecycle lock makes this syscall mutually exclusive with finish's final close. + _ = shutdown(client, SHUT_RDWR) + connection?.close() + lock.unlock() + } + + /// Used only when bridge shutdown wins the race before the relay thread is published. + func discardBeforeStart() { + let descriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + guard !started else { + lock.unlock() + requestStop() + return + } + descriptor = client + client = nil + stopRequested = true + callback = completion + completion = nil + prepareConnection = nil + connection?.close() + connection = nil + if let descriptor { close(descriptor) } + lock.unlock() + callback?() + } + + private func finish() { + let descriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + descriptor = client + client = nil + callback = completion + completion = nil + prepareConnection = nil + connection?.close() + connection = nil + if let descriptor { close(descriptor) } + lock.unlock() + callback?() + } } private final class ConnectionBox: @unchecked Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift index c9988fef..aab22f87 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift @@ -30,6 +30,8 @@ public enum X86GuestLayout { public static let rtcBase: UInt64 = 0x70 public static let virtioBase: UInt64 = 0xD000_0000 public static let virtioSlotSize: UInt64 = 0x1000 + /// The Hypervisor.framework IOAPIC exposes pins 0...23; virtio starts at pin 16. + public static let virtioSlotCount = 8 public static let virtioFirstIRQ: UInt8 = 16 public static let ramBase: UInt64 = 0x0010_0000 public static let mmioHoleBase: UInt64 = virtioBase @@ -64,6 +66,27 @@ public enum X86BootPlanBuilder { irq: UInt8(truncatingIfNeeded: UInt32(X86GuestLayout.virtioFirstIRQ) + UInt32(slot)) ) } + return build( + baseCommandLine: baseCommandLine, + memoryBytes: memoryBytes, + virtioDevices: virtioDevices + ) + } + + /// Builds every x86 boot surface from explicit occupied slots. Sorting here is defensive: the + /// machine ownership table already returns canonical slot order, but callers constructing a + /// diagnostic or golden plan must receive identical command-line and MPTABLE inputs regardless + /// of attachment order. + static func build( + baseCommandLine: String = "root=/dev/vda rw panic=0", + memoryBytes: UInt64, + virtioDevices: [X86VirtioMMIODevice] + ) -> X86BootPlan { + let virtioDevices = virtioDevices.sorted { lhs, rhs in + if lhs.slot != rhs.slot { return lhs.slot < rhs.slot } + if lhs.baseAddress != rhs.baseAddress { return lhs.baseAddress < rhs.baseAddress } + return lhs.irq < rhs.irq + } let commandLine = commandLine(baseCommandLine: baseCommandLine, virtioDevices: virtioDevices) return X86BootPlan( commandLine: commandLine, diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m index 4a4953f6..f98a24b2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m @@ -14,6 +14,20 @@ BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, error:error]; } +BOOL DoryIOUSBHostEnqueueDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *data, + NSTimeInterval timeout, + NSError **error, + DoryIOUSBHostCompletionHandler completionHandler) +{ + return [object enqueueDeviceRequest:request + data:data + completionTimeout:timeout + error:error + completionHandler:completionHandler]; +} + BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, IOUSBHostAbortOption option, NSError **error) diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h index 661fe442..60a87f5e 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h @@ -11,6 +11,15 @@ BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, NSTimeInterval timeout, NSError *_Nullable *_Nullable error); +typedef void (^DoryIOUSBHostCompletionHandler)(IOReturn status, NSUInteger bytesTransferred); + +BOOL DoryIOUSBHostEnqueueDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *_Nullable data, + NSTimeInterval timeout, + NSError *_Nullable *_Nullable error, + DoryIOUSBHostCompletionHandler completionHandler); + BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, IOUSBHostAbortOption option, NSError *_Nullable *_Nullable error); diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift new file mode 100644 index 00000000..a33c731c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift @@ -0,0 +1,3 @@ +// Preserve the public package/Xcode product identity while the daemon and runner share one +// implementation of the renderer bootstrap, receipt, command, and XPC wire protocol. +@_exported import DoryRendererWorkerWireContracts diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift new file mode 100644 index 00000000..8b9c4425 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift @@ -0,0 +1,54 @@ +import DoryRendererWorkerContracts +import Foundation +import Metal + +/// Complete Objective-C/XPC surface for one renderer generation. Exact binary frames and bounded +/// `FileHandle` arrays remain the control/data plane; the only Objective-C graphics object admitted +/// is Metal's secure-coding cross-process texture handle. A foreign renderer pointer never crosses +/// process identity. +@objc(DoryRendererWorkerXPCProtocol) +public protocol DoryRendererWorkerXPCProtocol: NSObjectProtocol { + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) + func exchange( + _ frame: Data, + descriptors: [FileHandle], + withReply reply: @escaping (Data, [FileHandle], MTLSharedTextureHandle?) -> Void + ) +} + +/// Constructs the single transport interface used by both authenticated peers. Explicit class +/// allowlists prevent Foundation from widening either descriptor or Metal-handle authority. +public enum DoryRendererWorkerXPCInterface { + public static func make() -> NSXPCInterface { + let interface = NSXPCInterface(with: DoryRendererWorkerXPCProtocol.self) + let descriptorClasses = NSSet( + objects: NSArray.self, + FileHandle.self + ) as! Set + let textureHandleClasses = NSSet( + objects: MTLSharedTextureHandle.self + ) as! Set + let exchangeSelector = #selector( + DoryRendererWorkerXPCProtocol.exchange(_:descriptors:withReply:) + ) + interface.setClasses( + descriptorClasses, + for: exchangeSelector, + argumentIndex: 1, + ofReply: false + ) + interface.setClasses( + descriptorClasses, + for: exchangeSelector, + argumentIndex: 1, + ofReply: true + ) + interface.setClasses( + textureHandleClasses, + for: exchangeSelector, + argumentIndex: 2, + ofReply: true + ) + return interface + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift new file mode 100644 index 00000000..0bef065b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift @@ -0,0 +1,479 @@ +import Darwin +import DoryGuestMemoryShim +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import Foundation +import Metal + +public enum DoryRendererWorkerBackendExecution: @unchecked Sendable { + case success( + payload: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? = nil + ) + case rejected + case outcomeUnknown +} + +/// Typed, path-free boundary between the foreign renderer backend and the XPC service. Production +/// implementations must collapse implementation details into one of these audited stages; the +/// service never serializes an arbitrary `Error` or foreign-library string. +public enum DoryRendererWorkerBackendActivationError: Error, Equatable, Sendable { + case artifactAuthority + case rendererInitialization + case venusCapability + case venusContext + case virgl2Capability + case virgl2Context + case sharedMemoryExport + case fenceExport + case capabilityReceipt + + var failureCode: DoryRendererWorkerRPCFailureCode { + switch self { + case .artifactAuthority: + .bootstrapArtifactAuthorityFailed + case .rendererInitialization: + .bootstrapRendererInitializationFailed + case .venusCapability: + .bootstrapVenusCapabilityFailed + case .venusContext: + .bootstrapVenusContextFailed + case .virgl2Capability: + .bootstrapVirgl2CapabilityFailed + case .virgl2Context: + .bootstrapVirgl2ContextFailed + case .sharedMemoryExport: + .bootstrapSharedMemoryExportFailed + case .fenceExport: + .bootstrapFenceExportFailed + case .capabilityReceipt: + .bootstrapCapabilityReceiptFailed + } + } +} + +/// Foreign-renderer adapter owned exclusively by the worker process. `execute` is a bounded +/// admission call: it may enqueue descriptor-backed work but may never synchronously wait for GPU +/// completion or copy a command stream/frame into XPC Data. Implementations may not retain an +/// input FileHandle beyond the call unless they duplicate it and make that lifetime part of the +/// resource generation they own. +public protocol DoryRendererWorkerBackend: AnyObject, Sendable { + func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt + func execute( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution + func invalidate() +} + +/// Safe default for a packaged service before the audited foreign backend is linked. It returns an +/// authenticated diagnostic receipt with no acceleration claim; the service immediately closes +/// command admission. This executable can therefore never make a software or legacy renderer look +/// like the requested production tuple. +public final class DoryRendererWorkerFailClosedBackend: + DoryRendererWorkerBackend, + @unchecked Sendable +{ + public init() {} + + public func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + try DoryRendererCapabilityReceipt( + accepting: bootstrap, + features: [.isolatedSignedWorker], + capsets: [] + ) + } + + public func execute( + command _: DoryRendererWorkerCommand, + descriptors _: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + .rejected + } + + public func invalidate() {} +} + +/// One-shot, one-workspace service state machine. Malformed runner traffic, an uncertain backend +/// outcome, or an incomplete activation receipt permanently revokes this process generation. +public final class DoryRendererWorkerService: @unchecked Sendable { + private enum State { + case awaitingBootstrap + case bootstrapping + case active(DoryRendererWorkerBootstrap) + case failed + } + + private enum DescriptorIdentity: Hashable { + case filesystem(device: UInt64, inode: UInt64) + case guestMemory(Data) + } + + private enum ExchangeAdmission { + case admitted(DoryRendererWorkerBootstrap) + case rejected(DoryRendererWorkerRPCFailureCode) + } + + private struct MutableMetrics { + var xpcBatchCount: UInt64 = 0 + var xpcControlBytes: UInt64 = 0 + var descriptorBackedCommandBytes: UInt64 = 0 + var totalAdmissionLatencyNanoseconds: UInt64 = 0 + var maximumAdmissionLatencyNanoseconds: UInt64 = 0 + var maximumQueueDepth = 0 + var backpressureRejections: UInt64 = 0 + var replayRejections: UInt64 = 0 + } + + private let backend: any DoryRendererWorkerBackend + private let lock = NSLock() + private let executionQueue = DispatchQueue( + label: "dev.dory.renderer-worker.admission", + qos: .userInteractive + ) + private var state: State = .awaitingBootstrap + private var pendingCommands = 0 + private var metrics = MutableMetrics() + /// Accessed only on executionQueue. Strict increase gives replay protection without an + /// attacker-controlled, generation-long Set of request identities. + private var highestAdmittedRequestID: UInt64 = 0 + + public init(backend: any DoryRendererWorkerBackend) { + self.backend = backend + } + + public func bootstrap(exactBytes: Data) -> Data { + let claimed = lock.withLock { + guard case .awaitingBootstrap = state else { return false } + state = .bootstrapping + return true + } + guard claimed else { + return failure(.bootstrapAlreadyAttempted) + } + let bootstrap: DoryRendererWorkerBootstrap + do { + bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBytes) + } catch { + failGeneration() + return failure(.invalidEnvelope) + } + do { + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try backend.activate(bootstrap: bootstrap) + } catch let error as DoryRendererWorkerBackendActivationError { + throw error + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let receiptBytes = DoryRendererCapabilityReceiptCodec.encode(receipt) + do { + _ = try DoryRendererCapabilityReceiptCodec.decode( + receiptBytes, + accepting: bootstrap + ) + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + guard receipt.productionAccelerationIsAdmissible else { + failGeneration() + return try DoryRendererWorkerRPCResultCodec.encode( + .success(payload: receiptBytes, descriptorCount: 0) + ) + } + highestAdmittedRequestID = 0 + lock.withLock { state = .active(bootstrap) } + return try DoryRendererWorkerRPCResultCodec.encode( + .success(payload: receiptBytes, descriptorCount: 0) + ) + } catch let error as DoryRendererWorkerBackendActivationError { + failGeneration() + return failure(error.failureCode) + } catch { + failGeneration() + return failure(.bootstrapRejected) + } + } + + public func exchange( + exactFrame: Data, + descriptors: [FileHandle] + ) -> ( + result: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? + ) { + let enqueuedAt = DispatchTime.now().uptimeNanoseconds + let admission = reserveExchange(controlByteCount: exactFrame.count) + guard case .admitted(let bootstrap) = admission else { + guard case .rejected(let code) = admission else { + return (failure(.internalFailure), [], nil) + } + return (failure(code), [], nil) + } + defer { releaseExchange() } + return executionQueue.sync { + recordAdmissionLatency(since: enqueuedAt) + return executeAdmitted( + exactFrame: exactFrame, + descriptors: descriptors, + bootstrap: bootstrap + ) + } + } + + public func metricsSnapshot() -> DoryRendererWorkerServiceMetrics { + lock.withLock { + DoryRendererWorkerServiceMetrics( + xpcBatchCount: metrics.xpcBatchCount, + xpcControlBytes: metrics.xpcControlBytes, + descriptorBackedCommandBytes: metrics.descriptorBackedCommandBytes, + totalAdmissionLatencyNanoseconds: metrics.totalAdmissionLatencyNanoseconds, + maximumAdmissionLatencyNanoseconds: metrics.maximumAdmissionLatencyNanoseconds, + currentQueueDepth: pendingCommands, + maximumQueueDepth: metrics.maximumQueueDepth, + backpressureRejections: metrics.backpressureRejections, + replayRejections: metrics.replayRejections, + scanoutCopyBytes: 0 + ) + } + } + + public func invalidate() { + executionQueue.sync { failGeneration() } + } + + private func executeAdmitted( + exactFrame: Data, + descriptors: [FileHandle], + bootstrap: DoryRendererWorkerBootstrap + ) -> ( + result: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? + ) { + guard case .active(let current) = lock.withLock({ state }), + current.generation == bootstrap.generation else { + return (failure(.capabilityUnavailable), [], nil) + } + do { + let command = try DoryRendererWorkerCommandCodec.decode( + exactFrame, + limits: bootstrap.limits + ) + guard command.generation == bootstrap.generation else { + return (failure(.staleGeneration), [], nil) + } + guard command.deadlineUptimeNanoseconds > DispatchTime.now().uptimeNanoseconds else { + return (failure(.deadlineExpired), [], nil) + } + try command.validateOutOfBandDescriptorCount(descriptors.count) + try validateDescriptors(descriptors, references: command.sharedRegions) + guard command.requestID > highestAdmittedRequestID else { + recordReplayRejection() + failGeneration() + return (failure(.protocolViolation), [], nil) + } + highestAdmittedRequestID = command.requestID + if command.operation == .submit3D { + recordDescriptorBackedCommandBytes(command.sharedRegions[0].length) + } + switch try backend.execute(command: command, descriptors: descriptors) { + case let .success(payload, replyDescriptors, sharedTextureHandle): + guard replyDescriptors.count <= Int(UInt16.max) else { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + guard sharedTextureHandle == nil || ( + command.operation == .acquireScanoutLease && replyDescriptors.isEmpty + ) else { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + let frame = try DoryRendererWorkerRPCResultCodec.encode( + .success( + payload: payload, + descriptorCount: UInt16(replyDescriptors.count) + ), + maximumPayloadBytes: bootstrap.limits.maximumCommandBytes + ) + return (frame, replyDescriptors, sharedTextureHandle) + case .rejected: + return (failure(.commandRejected), [], nil) + case .outcomeUnknown: + failGeneration() + return (failure(.outcomeUnknown), [], nil) + } + } catch { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + } + + private func reserveExchange(controlByteCount: Int) -> ExchangeAdmission { + lock.withLock { + switch state { + case .awaitingBootstrap, .bootstrapping: + return .rejected(.bootstrapRequired) + case .failed: + return .rejected(.capabilityUnavailable) + case .active(let bootstrap): + guard pendingCommands < bootstrap.limits.maximumInFlightCommands else { + metrics.backpressureRejections = Self.saturatingAdd( + metrics.backpressureRejections, + 1 + ) + return .rejected(.resourceExhausted) + } + pendingCommands += 1 + metrics.maximumQueueDepth = max(metrics.maximumQueueDepth, pendingCommands) + metrics.xpcBatchCount = Self.saturatingAdd(metrics.xpcBatchCount, 1) + metrics.xpcControlBytes = Self.saturatingAdd( + metrics.xpcControlBytes, + UInt64(controlByteCount) + ) + return .admitted(bootstrap) + } + } + } + + private func releaseExchange() { + lock.withLock { + if pendingCommands > 0 { pendingCommands -= 1 } + } + } + + private func recordAdmissionLatency(since start: UInt64) { + let now = DispatchTime.now().uptimeNanoseconds + let latency = now >= start ? now - start : 0 + lock.withLock { + metrics.totalAdmissionLatencyNanoseconds = Self.saturatingAdd( + metrics.totalAdmissionLatencyNanoseconds, + latency + ) + metrics.maximumAdmissionLatencyNanoseconds = max( + metrics.maximumAdmissionLatencyNanoseconds, + latency + ) + } + } + + private func recordDescriptorBackedCommandBytes(_ byteCount: UInt64) { + lock.withLock { + metrics.descriptorBackedCommandBytes = Self.saturatingAdd( + metrics.descriptorBackedCommandBytes, + byteCount + ) + } + } + + private func recordReplayRejection() { + lock.withLock { + metrics.replayRejections = Self.saturatingAdd(metrics.replayRejections, 1) + } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private func validateDescriptors( + _ descriptors: [FileHandle], + references: [DoryRendererSharedRegionReference] + ) throws { + var identities = Set() + let metadata = Dictionary(grouping: references, by: \.descriptorIndex) + for (index, descriptor) in descriptors.enumerated() { + guard let descriptorReferences = metadata[UInt16(index)], + let reference = descriptorReferences.first, + descriptorReferences.allSatisfy({ + $0.declaredFileSize == reference.declaredFileSize + }) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let fd = descriptor.fileDescriptor + guard fd >= 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + var status = stat() + guard fstat(fd, &status) == 0, + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == reference.declaredFileSize else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let descriptorIdentity: DescriptorIdentity + switch status.st_mode & S_IFMT { + case S_IFREG: + descriptorIdentity = .filesystem( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) + case 0: + guard descriptorReferences.allSatisfy({ + $0.offset >= DoryGuestMemoryBackingDataOffset() + }) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + var identity = DoryGuestMemoryBackingIdentity() + guard DoryReadGuestMemoryBackingIdentity( + fd, + reference.declaredFileSize, + &identity + ) == 1 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + descriptorIdentity = .guestMemory( + withUnsafeBytes(of: &identity) { Data($0) } + ) + default: + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let openFlags = fcntl(fd, F_GETFL) + guard openFlags >= 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let accessMode = openFlags & O_ACCMODE + switch reference.access { + case .readOnly: + guard accessMode == O_RDONLY else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + case .readWrite: + guard accessMode == O_RDWR else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + } + let descriptorFlags = fcntl(fd, F_GETFD) + guard descriptorFlags >= 0, + fcntl(fd, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + guard identities.insert(descriptorIdentity).inserted else { + throw DoryRendererWorkerContractError.duplicateSharedRegionIdentity + } + } + } + + private func failGeneration() { + let shouldInvalidate = lock.withLock { + guard case .failed = state else { + state = .failed + return true + } + return false + } + if shouldInvalidate { backend.invalidate() } + } + + private func failure(_ code: DoryRendererWorkerRPCFailureCode) -> Data { + (try? DoryRendererWorkerRPCResultCodec.encode(.failure(code))) ?? Data() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift new file mode 100644 index 00000000..dbc8242d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift @@ -0,0 +1,40 @@ +/// Monotonic worker-boundary evidence. Command-stream bytes count descriptor-backed submit3D +/// regions, while XPC control bytes count only the bounded metadata frame. Accelerated scanout is +/// inadmissible if a later backend ever records a copied byte; the current service has no API that +/// can turn a copy into a production capability. +public struct DoryRendererWorkerServiceMetrics: Equatable, Sendable { + public let xpcBatchCount: UInt64 + public let xpcControlBytes: UInt64 + public let descriptorBackedCommandBytes: UInt64 + public let totalAdmissionLatencyNanoseconds: UInt64 + public let maximumAdmissionLatencyNanoseconds: UInt64 + public let currentQueueDepth: Int + public let maximumQueueDepth: Int + public let backpressureRejections: UInt64 + public let replayRejections: UInt64 + public let scanoutCopyBytes: UInt64 + + public init( + xpcBatchCount: UInt64, + xpcControlBytes: UInt64, + descriptorBackedCommandBytes: UInt64, + totalAdmissionLatencyNanoseconds: UInt64, + maximumAdmissionLatencyNanoseconds: UInt64, + currentQueueDepth: Int, + maximumQueueDepth: Int, + backpressureRejections: UInt64, + replayRejections: UInt64, + scanoutCopyBytes: UInt64 + ) { + self.xpcBatchCount = xpcBatchCount + self.xpcControlBytes = xpcControlBytes + self.descriptorBackedCommandBytes = descriptorBackedCommandBytes + self.totalAdmissionLatencyNanoseconds = totalAdmissionLatencyNanoseconds + self.maximumAdmissionLatencyNanoseconds = maximumAdmissionLatencyNanoseconds + self.currentQueueDepth = currentQueueDepth + self.maximumQueueDepth = maximumQueueDepth + self.backpressureRejections = backpressureRejections + self.replayRejections = replayRejections + self.scanoutCopyBytes = scanoutCopyBytes + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift new file mode 100644 index 00000000..5da41a11 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift @@ -0,0 +1,731 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryVirglRendererShim +import Foundation +import Metal + +public enum DoryRendererForeignSessionError: Error, Equatable, Sendable { + case openFailed(Int32) + case callFailed(operation: String, status: Int32) + case submitFailed(status: Int32, diagnostic: DoryRendererForeignSubmitFailure?) + case invalidResult(operation: String) +} + +public enum DoryRendererCreateObjectSubtypeDisposition: UInt32, Equatable, Sendable { + case absent = 0 + case present = 1 + case ambiguous = 2 +} + +/// Exact pinned `virgl_object_type` ordinals accepted from a CREATE_OBJECT command header. +public enum DoryRendererVirglObjectType: UInt32, Equatable, Sendable { + case null = 0 + case blend = 1 + case rasterizer = 2 + case depthStencilAlpha = 3 + case shader = 4 + case vertexElements = 5 + case samplerView = 6 + case samplerState = 7 + case surface = 8 + case query = 9 + case streamOutputTarget = 10 + case multisampleSurface = 11 +} + +/// Closed validation reasons emitted only for a failed pinned VirGL surface CREATE_OBJECT. +public enum DoryRendererVirglSurfaceFailureReason: UInt32, Equatable, Sendable { + case none = 0 + case createPayloadTooShort = 1 + case createHandleZero = 2 + case surfaceLengthMismatch = 3 + case resourceMissing = 4 + case resourceGLObjectMissing = 5 + case formatOutOfRange = 6 + case invalidLayerRange = 7 +} + +/// Closed categories admitted from fixed vrend error prefixes. No suffix or renderer text survives. +public enum DoryRendererVirglSubmitPrecursorCategory: UInt32, Equatable, Sendable { + case none = 0 + case shaderCompileFailed = 1 + case tgsiAssignmentFailed = 2 + case geometryShaderUnsupported = 3 + case tessellationShaderUnsupported = 4 + case computeShaderUnsupported = 5 + case invalidExpectedTokenCount = 6 + case expectedLongContinuation = 7 + case invalidContinuationHandle = 8 + case continuationWithoutOriginal = 9 + case mismatchedContinuation = 10 + case oversizedContinuation = 11 +} + +/// Sanitized vrend submit failure captured by the C shim. The command identifier is admitted only +/// after matching the complete name in the pinned static command table. CREATE_OBJECT subtype comes +/// only from the bounded command-header parser; renderer log text and command payloads are discarded. +public struct DoryRendererForeignSubmitFailure: Equatable, Sendable { + public let decoderDiagnosticIsAvailable: Bool + public let contextID: UInt32 + public let commandID: UInt32 + public let status: Int32 + public let createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition + public let createObjectSubtype: DoryRendererVirglObjectType? + /// Saturating count of CREATE_OBJECT headers (0...255), never a command-stream length. + public let createObjectCandidateCount: UInt32 + /// Closed low-12-bit set of pinned object subtypes observed in CREATE_OBJECT headers. + public let createObjectSubtypeMask: UInt32 + public let surfaceFailureReason: DoryRendererVirglSurfaceFailureReason + public let precursorCategory: DoryRendererVirglSubmitPrecursorCategory + + public init( + contextID: UInt32, + commandID: UInt32, + status: Int32, + createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition = .absent, + createObjectSubtype: DoryRendererVirglObjectType? = nil, + createObjectCandidateCount: UInt32 = 0, + createObjectSubtypeMask: UInt32 = 0, + precursorCategory: DoryRendererVirglSubmitPrecursorCategory = .none + ) { + self.init( + decoderDiagnosticIsAvailable: true, + failedCommandLocationIsExact: true, + contextID: contextID, + commandID: commandID, + status: status, + createObjectSubtypeDisposition: createObjectSubtypeDisposition, + createObjectSubtype: createObjectSubtype, + createObjectCandidateCount: createObjectCandidateCount == 0 + ? (createObjectSubtype == nil ? 0 : 1) + : createObjectCandidateCount, + createObjectSubtypeMask: createObjectSubtypeMask == 0 + ? createObjectSubtype.map { 1 << $0.rawValue } ?? 0 + : createObjectSubtypeMask, + // Only the C shim's exact-tuple sanitizer may admit a surface reason. + surfaceFailureReason: .none, + precursorCategory: precursorCategory + ) + } + + private init( + decoderDiagnosticIsAvailable: Bool, + failedCommandLocationIsExact: Bool, + contextID: UInt32, + commandID: UInt32, + status: Int32, + createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition, + createObjectSubtype: DoryRendererVirglObjectType?, + createObjectCandidateCount: UInt32, + createObjectSubtypeMask: UInt32, + surfaceFailureReason: DoryRendererVirglSurfaceFailureReason, + precursorCategory: DoryRendererVirglSubmitPrecursorCategory + ) { + self.decoderDiagnosticIsAvailable = decoderDiagnosticIsAvailable + self.contextID = decoderDiagnosticIsAvailable ? contextID : 0 + self.commandID = decoderDiagnosticIsAvailable ? commandID : 0 + self.status = decoderDiagnosticIsAvailable ? status : 0 + if decoderDiagnosticIsAvailable && commandID == 1 { + let candidateSummaryIsValid = createObjectCandidateCount <= 255 && + createObjectSubtypeMask & ~0x0fff == 0 + if createObjectSubtypeDisposition == .present, + let createObjectSubtype, + candidateSummaryIsValid, + createObjectCandidateCount > 0, + createObjectSubtypeMask & (1 << createObjectSubtype.rawValue) != 0 { + self.createObjectSubtypeDisposition = .present + self.createObjectSubtype = createObjectSubtype + } else { + self.createObjectSubtypeDisposition = + createObjectSubtypeDisposition == .present || !candidateSummaryIsValid + ? .ambiguous + : createObjectSubtypeDisposition + self.createObjectSubtype = nil + } + self.createObjectCandidateCount = candidateSummaryIsValid + ? createObjectCandidateCount + : 0 + self.createObjectSubtypeMask = candidateSummaryIsValid + ? createObjectSubtypeMask + : 0 + } else { + self.createObjectSubtypeDisposition = .absent + self.createObjectSubtype = nil + self.createObjectCandidateCount = 0 + self.createObjectSubtypeMask = 0 + } + self.surfaceFailureReason = decoderDiagnosticIsAvailable && + failedCommandLocationIsExact && + commandID == 1 && status == EINVAL && self.createObjectSubtype == .surface + ? surfaceFailureReason + : .none + self.precursorCategory = precursorCategory + } + + init?(sanitizing diagnostic: DoryVirglRendererSubmitDiagnostic) { + let decoderDiagnosticIsAvailable = diagnostic.valid == 1 + let precursorCategory = DoryRendererVirglSubmitPrecursorCategory( + rawValue: diagnostic.precursor_category + ) ?? .none + guard decoderDiagnosticIsAvailable || precursorCategory != .none else { return nil } + + let rawDisposition = DoryRendererCreateObjectSubtypeDisposition( + rawValue: diagnostic.create_object_subtype_disposition + ) ?? .ambiguous + let objectType = rawDisposition == .present + ? DoryRendererVirglObjectType(rawValue: diagnostic.create_object_subtype) + : nil + let surfaceFailureReason = DoryRendererVirglSurfaceFailureReason( + rawValue: diagnostic.surface_failure_reason + ) ?? .none + self.init( + decoderDiagnosticIsAvailable: decoderDiagnosticIsAvailable, + failedCommandLocationIsExact: + diagnostic.failed_command_location_disposition == 1, + contextID: diagnostic.context_id, + commandID: diagnostic.command_id, + status: diagnostic.status, + createObjectSubtypeDisposition: rawDisposition, + createObjectSubtype: objectType, + createObjectCandidateCount: diagnostic.create_object_candidate_count, + createObjectSubtypeMask: diagnostic.create_object_subtype_mask, + surfaceFailureReason: surfaceFailureReason, + precursorCategory: precursorCategory + ) + } +} + +public struct DoryRendererForeignCapset: Equatable, Sendable { + public let id: UInt32 + public let maximumVersion: UInt32 + public let bytes: Data + + public init(id: UInt32, maximumVersion: UInt32, bytes: Data) { + self.id = id + self.maximumVersion = maximumVersion + self.bytes = bytes + } +} + +public struct DoryRendererForeignBlobCreate: Equatable, Sendable { + public let resourceID: UInt32 + public let contextID: UInt32 + public let payload: DoryRendererBlobCreatePayload + + public init( + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererBlobCreatePayload + ) { + self.resourceID = resourceID + self.contextID = contextID + self.payload = payload + } +} + +public struct DoryRendererForeignResource3DCreate: Equatable, Sendable { + public let resourceID: UInt32 + public let payload: DoryRendererResource3DCreatePayload + + public init(resourceID: UInt32, payload: DoryRendererResource3DCreatePayload) { + self.resourceID = resourceID + self.payload = payload + } +} + +public struct DoryRendererForeignResourceInfo: Equatable, Sendable { + public let resourceID: UInt32 + public let format: UInt32 + public let width: UInt32 + public let height: UInt32 + public let flags: UInt32 + public let stride: UInt32 + + public init( + resourceID: UInt32, + format: UInt32, + width: UInt32, + height: UInt32, + flags: UInt32, + stride: UInt32 + ) { + self.resourceID = resourceID + self.format = format + self.width = width + self.height = height + self.flags = flags + self.stride = stride + } +} + +public struct DoryRendererForeignExportedBlob: Sendable { + public let type: UInt32 + public let ownedFileDescriptor: Int32 + + public init(type: UInt32, ownedFileDescriptor: Int32) { + self.type = type + self.ownedFileDescriptor = ownedFileDescriptor + } +} + +public protocol DoryRendererForeignSession: AnyObject, Sendable { + func capset(id: UInt32) throws -> DoryRendererForeignCapset + func createContext(id: UInt32, capsetID: UInt32, name: String) throws + func destroyContext(id: UInt32) + func attachResource(contextID: UInt32, resourceID: UInt32) + func detachResource(contextID: UInt32, resourceID: UInt32) + func submit(contextID: UInt32, bytes: UnsafeRawPointer, dwordCount: UInt32) throws + func createBlob( + _ resource: DoryRendererForeignBlobCreate, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws + func createResource3D(_ resource: DoryRendererForeignResource3DCreate) throws + func attachBacking( + resourceID: UInt32, + iovecs: UnsafePointer, + iovecCount: UInt32 + ) throws + func detachBacking(resourceID: UInt32) + func unrefResource(id: UInt32) + func mapInfo(resourceID: UInt32) throws -> UInt32 + func exportBlob(resourceID: UInt32) throws -> DoryRendererForeignExportedBlob + func resourceInfo(resourceID: UInt32) throws -> DoryRendererForeignResourceInfo + func acquireScanoutMetalTexture( + resourceID: UInt32, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + offset: UInt32 + ) throws -> any MTLTexture + func transfer( + toHost: Bool, + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws + func createFence( + contextID: UInt32, + flags: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) throws + /// Creates a classic VirGL ctx0 fence. The shim owns the lossless mapping from this guest + /// 64-bit identity to virglrenderer's collision-safe 32-bit callback token. + func createGlobalFence(fenceID: UInt64) throws + func exportFence(fenceID: UInt64) throws -> Int32 + /// Borrowed renderer event descriptor when threaded sync is available. Darwin may legitimately + /// strip that hint; nil selects the backend's bounded timer pump. The backend never closes a + /// returned descriptor; `invalidate` releases the underlying renderer authority. + func pollDescriptor() throws -> Int32? + func poll() + func invalidate() +} + +public protocol DoryRendererForeignSessionCreating: Sendable { + func create( + attestation: DoryRendererArtifactAttestation + ) throws -> any DoryRendererForeignSession +} + +public struct DoryRendererCForeignSessionFactory: + DoryRendererForeignSessionCreating, + Sendable +{ + public init() {} + + public func create( + attestation _: DoryRendererArtifactAttestation + ) throws -> any DoryRendererForeignSession { + try DoryRendererCForeignSession() + } +} + +private final class DoryRendererCForeignSession: + DoryRendererForeignSession, + @unchecked Sendable +{ + private var session: OpaquePointer? + + init() throws { + var opened: OpaquePointer? + let status = DoryVirglRendererSessionCreate(&opened) + guard status == 0, let opened else { + throw DoryRendererForeignSessionError.openFailed(status) + } + session = opened + } + + deinit { invalidate() } + + func capset(id: UInt32) throws -> DoryRendererForeignCapset { + let session = try requiredSession() + var maximumVersion: UInt32 = 0 + var byteCount = 0 + try Self.check( + DoryVirglRendererGetCapset( + session, + id, + &maximumVersion, + nil, + 0, + &byteCount + ), + "virgl_renderer_get_cap_set" + ) + guard byteCount > 0, + byteCount <= DoryRendererCapsetAttestation.maximumCapsetBytes else { + throw DoryRendererForeignSessionError.invalidResult(operation: "capset-size") + } + var data = Data(count: byteCount) + let status = data.withUnsafeMutableBytes { + DoryVirglRendererGetCapset( + session, + id, + &maximumVersion, + $0.baseAddress, + $0.count, + &byteCount + ) + } + try Self.check(status, "virgl_renderer_fill_caps") + guard data.count == byteCount else { + throw DoryRendererForeignSessionError.invalidResult(operation: "capset-size") + } + return DoryRendererForeignCapset( + id: id, + maximumVersion: maximumVersion, + bytes: data + ) + } + + func createContext(id: UInt32, capsetID: UInt32, name: String) throws { + let session = try requiredSession() + let bytes = Array(name.utf8) + let status = bytes.withUnsafeBufferPointer { buffer in + DoryVirglRendererContextCreate( + session, + id, + capsetID, + buffer.baseAddress.map { UnsafeRawPointer($0).assumingMemoryBound(to: CChar.self) }, + buffer.count + ) + } + try Self.check(status, "virgl_renderer_context_create_with_flags") + } + + func destroyContext(id: UInt32) { + guard let session else { return } + DoryVirglRendererContextDestroy(session, id) + } + + func attachResource(contextID: UInt32, resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererContextAttachResource(session, contextID, resourceID) + } + + func detachResource(contextID: UInt32, resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererContextDetachResource(session, contextID, resourceID) + } + + func submit(contextID: UInt32, bytes: UnsafeRawPointer, dwordCount: UInt32) throws { + var diagnostic = DoryVirglRendererSubmitDiagnostic() + let status = DoryVirglRendererSubmit( + try requiredSession(), + contextID, + bytes, + dwordCount, + &diagnostic + ) + guard status == 0 else { + let failure = DoryRendererForeignSubmitFailure(sanitizing: diagnostic) + throw DoryRendererForeignSessionError.submitFailed( + status: status, + diagnostic: failure + ) + } + } + + func createBlob( + _ resource: DoryRendererForeignBlobCreate, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws { + var arguments = DoryVirglRendererBlobCreateArguments( + resource_handle: resource.resourceID, + context_id: resource.contextID, + blob_memory: resource.payload.blobMemory, + blob_flags: resource.payload.blobFlags, + blob_id: resource.payload.blobID, + size: resource.payload.size, + iovecs: iovecs, + iovec_count: iovecCount + ) + try Self.check( + DoryVirglRendererBlobCreate(try requiredSession(), &arguments), + "virgl_renderer_resource_create_blob" + ) + } + + func createResource3D(_ resource: DoryRendererForeignResource3DCreate) throws { + var arguments = DoryVirglRendererResource3DCreateArguments( + handle: resource.resourceID, + target: resource.payload.target, + format: resource.payload.format, + bind: resource.payload.bind, + width: resource.payload.width, + height: resource.payload.height, + depth: resource.payload.depth, + array_size: resource.payload.arraySize, + last_level: resource.payload.lastLevel, + samples: resource.payload.samples, + flags: resource.payload.flags + ) + try Self.check( + DoryVirglRendererResource3DCreate(try requiredSession(), &arguments), + "virgl_renderer_resource_create" + ) + } + + func attachBacking( + resourceID: UInt32, + iovecs: UnsafePointer, + iovecCount: UInt32 + ) throws { + try Self.check( + DoryVirglRendererResourceAttachBacking( + try requiredSession(), + resourceID, + iovecs, + iovecCount + ), + "virgl_renderer_resource_attach_iov" + ) + } + + func detachBacking(resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererResourceDetachBacking(session, resourceID) + } + + func unrefResource(id: UInt32) { + guard let session else { return } + DoryVirglRendererResourceUnref(session, id) + } + + func mapInfo(resourceID: UInt32) throws -> UInt32 { + var mapInfo: UInt32 = 0 + try Self.check( + DoryVirglRendererResourceGetMapInfo( + try requiredSession(), + resourceID, + &mapInfo + ), + "virgl_renderer_resource_get_map_info" + ) + return mapInfo + } + + func exportBlob(resourceID: UInt32) throws -> DoryRendererForeignExportedBlob { + var type: UInt32 = 0 + var fileDescriptor: Int32 = -1 + try Self.check( + DoryVirglRendererResourceExportBlob( + try requiredSession(), + resourceID, + &type, + &fileDescriptor + ), + "virgl_renderer_resource_export_blob" + ) + guard fileDescriptor >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "export-blob-fd") + } + return DoryRendererForeignExportedBlob( + type: type, + ownedFileDescriptor: fileDescriptor + ) + } + + func resourceInfo(resourceID: UInt32) throws -> DoryRendererForeignResourceInfo { + var info = DoryVirglRendererResourceInfo() + try Self.check( + DoryVirglRendererResourceGetInfo( + try requiredSession(), + resourceID, + &info + ), + "virgl_renderer_resource_get_info" + ) + return DoryRendererForeignResourceInfo( + resourceID: info.handle, + format: info.virgl_format, + width: info.width, + height: info.height, + flags: info.flags, + stride: info.stride + ) + } + + func acquireScanoutMetalTexture( + resourceID: UInt32, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + offset: UInt32 + ) throws -> any MTLTexture { + var retainedTexture: UnsafeMutableRawPointer? + try Self.check( + DoryVirglRendererResourceAcquireScanoutMetalTexture( + try requiredSession(), + resourceID, + width, + height, + virglFormat, + stride, + offset, + &retainedTexture + ), + "virgl_renderer_create_handle_for_scanout" + ) + guard let retainedTexture else { + throw DoryRendererForeignSessionError.invalidResult( + operation: "metal-scanout-texture" + ) + } + let object = Unmanaged.fromOpaque(retainedTexture).takeRetainedValue() + guard let texture = object as? any MTLTexture else { + throw DoryRendererForeignSessionError.invalidResult( + operation: "metal-scanout-texture-type" + ) + } + return texture + } + + func transfer( + toHost: Bool, + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws { + var box = DoryVirglRendererBox( + x: payload.x, + y: payload.y, + z: payload.z, + width: payload.width, + height: payload.height, + depth: payload.depth + ) + let status = toHost + ? DoryVirglRendererTransferToHost( + try requiredSession(), + resourceID, + contextID, + payload.level, + payload.stride, + payload.layerStride, + &box, + payload.offset, + iovecs, + iovecCount + ) + : DoryVirglRendererTransferFromHost( + try requiredSession(), + resourceID, + contextID, + payload.level, + payload.stride, + payload.layerStride, + &box, + payload.offset, + iovecs, + iovecCount + ) + try Self.check( + status, + toHost ? "virgl_renderer_transfer_write_iov" : "virgl_renderer_transfer_read_iov" + ) + } + + func createFence( + contextID: UInt32, + flags: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) throws { + try Self.check( + DoryVirglRendererCreateContextFence( + try requiredSession(), + contextID, + flags, + ringIndex, + fenceID + ), + "virgl_renderer_context_create_fence" + ) + } + + func createGlobalFence(fenceID: UInt64) throws { + try Self.check( + DoryVirglRendererCreateGlobalFence( + try requiredSession(), + fenceID + ), + "virgl_renderer_create_fence" + ) + } + + func exportFence(fenceID: UInt64) throws -> Int32 { + let descriptor = DoryVirglRendererGetFenceFileDescriptor( + try requiredSession(), + fenceID + ) + guard descriptor >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "export-fence-fd") + } + return descriptor + } + + func pollDescriptor() throws -> Int32? { + let descriptor = DoryVirglRendererGetPollFileDescriptor(try requiredSession()) + guard descriptor >= 0 else { return nil } + guard fcntl(descriptor, F_GETFD) >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "renderer-poll-fd") + } + return descriptor + } + + func poll() { + guard let session else { return } + DoryVirglRendererPoll(session) + } + + func invalidate() { + guard let existing = session else { return } + session = nil + DoryVirglRendererSessionDestroy(existing) + } + + private func requiredSession() throws -> OpaquePointer { + guard let session else { + throw DoryRendererForeignSessionError.invalidResult(operation: "closed-session") + } + return session + } + + private static func check(_ status: Int32, _ operation: String) throws { + guard status == 0 else { + throw DoryRendererForeignSessionError.callFailed( + operation: operation, + status: status + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift new file mode 100644 index 00000000..57152725 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift @@ -0,0 +1,256 @@ +import CryptoKit +import Darwin +import DoryRendererWorkerContracts +import Foundation + +public enum DoryRendererProductionArtifactError: Error, Equatable, Sendable { + case invalidBundleLayout + case artifactUnavailable(String) + case executableDigestMismatch +} + +/// Path-free proof that the running worker's exact executable bytes match the daemon-admitted +/// immutable bootstrap. The daemon and packager own canonical inventory verification; the +/// sandboxed worker owns only its signed XPC bundle and never reaches into the parent runner. +public struct DoryRendererArtifactAttestation: Equatable, Sendable { + public let candidateInventory: DoryRendererArtifactDigest + public let rendererWorkerExecutable: DoryRendererArtifactDigest + + public init( + candidateInventory: DoryRendererArtifactDigest, + rendererWorkerExecutable: DoryRendererArtifactDigest + ) { + self.candidateInventory = candidateInventory + self.rendererWorkerExecutable = rendererWorkerExecutable + } +} + +public protocol DoryRendererProductionArtifactVerifying: Sendable { + func verify( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererArtifactAttestation +} + +/// Verifies the exact final worker executable. No path is read from XPC or the environment. Every +/// directory component is opened with `O_NOFOLLOW`; the worker is a bounded regular file whose +/// bytes match bootstrap authority minted from the already-verified candidate inventory. +public struct DoryRendererProductionArtifactVerifier: + DoryRendererProductionArtifactVerifying, + Sendable +{ + public static let maximumArtifactBytes = + DoryRendererProductionInventory.maximumArtifactBytes + public static let workerBundleExecutableRelativePath = "MacOS/DoryRendererWorker" + private let contentsRoot: URL + private let executableRelativePath: String + + /// Production constructor. The XPC bundle must be nested exactly at + /// `Runner.app/Contents/XPCServices/Worker.xpc`; a standalone SwiftPM executable has no + /// production artifact authority and therefore fails closed here. + public init(bundle: Bundle = .main) throws { + guard bundle.bundleURL.pathExtension == "xpc", + let executableURL = bundle.executableURL else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let xpcServices = bundle.bundleURL.deletingLastPathComponent() + guard xpcServices.lastPathComponent == "XPCServices" else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let runnerContents = xpcServices.deletingLastPathComponent().standardizedFileURL + let root = bundle.bundleURL.appendingPathComponent( + "Contents", + isDirectory: true + ).standardizedFileURL + guard runnerContents.lastPathComponent == "Contents", + root.lastPathComponent == "Contents", + let relative = Self.relativePath(of: executableURL, below: root) else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + contentsRoot = root + executableRelativePath = relative + } + + /// Test/packaging constructor. Paths remain fixed below one injected Contents root; callers + /// cannot use this constructor through the XPC wire contract. + public init(contentsRoot: URL, executableRelativePath: String) throws { + guard Self.validRelativePath(executableRelativePath) else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + self.contentsRoot = contentsRoot.standardizedFileURL + self.executableRelativePath = executableRelativePath + } + + public func verify( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererArtifactAttestation { + let rootDescriptor = open( + contentsRoot.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard rootDescriptor >= 0 else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + defer { close(rootDescriptor) } + + let expectedPath = Self.workerBundleExecutableRelativePath + guard executableRelativePath == expectedPath else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let executable = try Self.openSecureRegularFile( + relativePath: expectedPath, + rootDescriptor: rootDescriptor, + maximumBytes: Self.maximumArtifactBytes, + label: "renderer worker executable" + ) + defer { close(executable.fileDescriptor) } + let executableDigest = try Self.hashFileData( + artifact: executable + ) + guard executableDigest == bootstrap.artifacts.rendererWorkerExecutable.bytes else { + throw DoryRendererProductionArtifactError.executableDigestMismatch + } + + return DoryRendererArtifactAttestation( + candidateInventory: bootstrap.artifacts.candidateInventory, + rendererWorkerExecutable: bootstrap.artifacts.rendererWorkerExecutable + ) + } + + private struct OpenedArtifact { + let fileDescriptor: Int32 + let byteCount: UInt64 + let identity: FileIdentity + } + + private struct FileIdentity: Equatable { + let device: dev_t + let inode: ino_t + let size: off_t + let modificationSeconds: Int + let modificationNanoseconds: Int + let changeSeconds: Int + let changeNanoseconds: Int + + init(_ status: stat) { + device = status.st_dev + inode = status.st_ino + size = status.st_size + modificationSeconds = status.st_mtimespec.tv_sec + modificationNanoseconds = status.st_mtimespec.tv_nsec + changeSeconds = status.st_ctimespec.tv_sec + changeNanoseconds = status.st_ctimespec.tv_nsec + } + } + + private static func openSecureRegularFile( + relativePath: String, + rootDescriptor: Int32, + maximumBytes: UInt64, + label: String + ) throws -> OpenedArtifact { + guard validRelativePath(relativePath) else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + let parts = relativePath.split(separator: "/").map(String.init) + var directory = fcntl(rootDescriptor, F_DUPFD_CLOEXEC, 0) + guard directory >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + for part in parts.dropLast() { + let next = openat( + directory, + part, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + close(directory) + guard next >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + directory = next + } + let descriptor = openat( + directory, + parts.last!, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + close(directory) + guard descriptor >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + var status = stat() + guard fstat(descriptor, &status) == 0, + (status.st_mode & S_IFMT) == S_IFREG, + status.st_nlink == 1, + status.st_size > 0, + UInt64(status.st_size) <= maximumBytes, + status.st_mode & (S_IWGRP | S_IWOTH) == 0 else { + close(descriptor) + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + return OpenedArtifact( + fileDescriptor: descriptor, + byteCount: UInt64(status.st_size), + identity: FileIdentity(status) + ) + } + + private static func hashFileData( + artifact: OpenedArtifact + ) throws -> Data { + let fileDescriptor = artifact.fileDescriptor + let byteCount = artifact.byteCount + guard lseek(fileDescriptor, 0, SEEK_SET) == 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + var remaining = byteCount + var hasher = SHA256() + var buffer = [UInt8](repeating: 0, count: 1_024 * 1_024) + while remaining > 0 { + let requested = min(buffer.count, Int(remaining)) + let count = buffer.withUnsafeMutableBytes { + Darwin.read(fileDescriptor, $0.baseAddress, requested) + } + if count < 0, errno == EINTR { continue } + guard count > 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + hasher.update(data: Data(buffer[0.. 0, + UInt64(status.st_size) == artifact.byteCount, + status.st_mode & (S_IWGRP | S_IWOTH) == 0, + FileIdentity(status) == artifact.identity else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + } + + private static func validRelativePath(_ path: String) -> Bool { + guard !path.isEmpty, !path.hasPrefix("/"), !path.hasSuffix("/") else { return false } + let parts = path.split(separator: "/", omittingEmptySubsequences: false) + return parts.allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } + } + + private static func relativePath(of url: URL, below root: URL) -> String? { + let rootPath = root.standardizedFileURL.path + let candidate = url.standardizedFileURL.path + guard candidate.hasPrefix(rootPath + "/") else { return nil } + let relative = String(candidate.dropFirst(rootPath.count + 1)) + return validRelativePath(relative) ? relative : nil + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift new file mode 100644 index 00000000..f1388178 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift @@ -0,0 +1,1761 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryRendererWorkerServiceCore +import DoryVirglRendererShim +import Foundation +import Metal +import OSLog + +public protocol DoryRendererScanoutAlignmentProviding: Sendable { + func minimumLinearTextureAlignment( + pixelFormat: DoryRendererScanoutPixelFormat + ) -> UInt32? +} + +public struct DoryRendererSystemMetalAlignmentProvider: + DoryRendererScanoutAlignmentProviding, + Sendable +{ + public init() {} + + public func minimumLinearTextureAlignment( + pixelFormat: DoryRendererScanoutPixelFormat + ) -> UInt32? { + guard let device = MTLCreateSystemDefaultDevice() else { return nil } + let format: MTLPixelFormat = switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + let alignment = device.minimumLinearTextureAlignment(for: format) + guard alignment >= 4, alignment <= 65_536, + alignment.nonzeroBitCount == 1 else { return nil } + return UInt32(alignment) + } +} + +public struct DoryRendererWorkerVirglBackendSnapshot: Equatable, Sendable { + public let isActive: Bool + public let contextCount: Int + public let resourceCount: Int + public let liveScanoutLeaseCount: Int +} + +/// Closed worker-side failure vocabulary. Raw foreign error text is never logged or returned. +enum DoryRendererForeignExecutionFailureStage: String, Equatable, Sendable { + case foreignSessionOpen = "foreign-session-open" + case foreignCall = "foreign-call" + case foreignResultValidation = "foreign-result-validation" + case backendInternal = "backend-internal" +} + +enum DoryRendererForeignSessionErrorCase: String, Equatable, Sendable { + case openFailed = "open-failed" + case callFailed = "call-failed" + case submitFailed = "submit-failed" + case invalidResult = "invalid-result" + case unexpected = "unexpected" +} + +/// Exact allowlist of operation labels constructed by the production foreign-session adapter. +/// Unknown strings collapse to `unclassified` and never cross the diagnostic boundary. +enum DoryRendererForeignOperationLabel: String, Equatable, Sendable { + case sessionOpen = "session-open" + case getCapset = "virgl_renderer_get_cap_set" + case fillCaps = "virgl_renderer_fill_caps" + case capsetSize = "capset-size" + case createContext = "virgl_renderer_context_create_with_flags" + case submit3D = "virgl_renderer_submit_cmd2" + case createBlob = "virgl_renderer_resource_create_blob" + case createResource3D = "virgl_renderer_resource_create" + case attachBacking = "virgl_renderer_resource_attach_iov" + case mapInfo = "virgl_renderer_resource_get_map_info" + case exportBlob = "virgl_renderer_resource_export_blob" + case exportBlobDescriptor = "export-blob-fd" + case resourceInfo = "virgl_renderer_resource_get_info" + case acquireScanoutTexture = "virgl_renderer_create_handle_for_scanout" + case scanoutTextureResult = "metal-scanout-texture" + case scanoutTextureType = "metal-scanout-texture-type" + case transferToHost = "virgl_renderer_transfer_write_iov" + case transferFromHost = "virgl_renderer_transfer_read_iov" + case createContextFence = "virgl_renderer_context_create_fence" + case createGlobalFence = "virgl_renderer_create_fence" + case exportFenceDescriptor = "export-fence-fd" + case rendererPollDescriptor = "renderer-poll-fd" + case closedSession = "closed-session" + case nonSHMExport = "non-shm-export" + case shmExportStat = "shm-export-stat" + case shmExportBounds = "shm-export-bounds" + case shmExportMapping = "shm-export-mapping" + case shmExportUnmapping = "shm-export-unmapping" + case shmExportCloseOnExec = "shm-export-cloexec" + case resourceGeneration = "resource-generation" + case typedOperationPayload = "typed-operation-payload" + case unclassified +} + +struct DoryRendererForeignExecutionFailureDiagnostic: Equatable, Sendable { + let operation: DoryRendererWorkerOperation + let requestID: UInt64 + let stage: DoryRendererForeignExecutionFailureStage + let errorCase: DoryRendererForeignSessionErrorCase + let foreignOperation: DoryRendererForeignOperationLabel + let statusIsAvailable: Bool + let status: Int32 + let submitDiagnosticIsAvailable: Bool + let virglDecoderDiagnosticIsAvailable: Bool + let submitContextID: UInt32 + let virglCommandID: UInt32 + let virglCommandStatus: Int32 + let createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition + let createObjectSubtype: DoryRendererVirglObjectType? + let createObjectCandidateCount: UInt32 + let createObjectSubtypeMask: UInt32 + let surfaceFailureReason: DoryRendererVirglSurfaceFailureReason + let virglPrecursorCategory: DoryRendererVirglSubmitPrecursorCategory + let elapsedNanoseconds: UInt64 +} + +/// virglrenderer keeps its current EGL/GL context in pthread-local state. A serial DispatchQueue +/// preserves ordering but may execute successive blocks on different pthreads, so it cannot own +/// that context. This lane gives the complete foreign-renderer lifetime one persistent native +/// thread: initialization, every command, explicit polling, inspection, and teardown. +private final class DoryRendererForeignExecutionLane: @unchecked Sendable { + private enum LaneError: Error { + case stopped + } + + private final class Submission: @unchecked Sendable { + private let condition = NSCondition() + private let operation: () throws -> Output + private var result: Result? + + init(operation: @escaping () throws -> Output) { + self.operation = operation + } + + func run() { + let completed = Result { try operation() } + condition.lock() + result = completed + condition.broadcast() + condition.unlock() + } + + func wait() throws -> Output { + condition.lock() + while result == nil { condition.wait() } + let completed = result! + condition.unlock() + return try completed.get() + } + } + + private final class State: @unchecked Sendable { + private let condition = NSCondition() + private var pending = [() -> Void]() + private var owner: pthread_t? + private var started = false + private var stopping = false + private var exited = false + + func run() { + condition.lock() + owner = pthread_self() + started = true + condition.broadcast() + condition.unlock() + + while let operation = next() { + autoreleasepool(invoking: operation) + } + } + + func waitUntilStarted() { + condition.lock() + while !started { condition.wait() } + condition.unlock() + } + + func isOwnerThread() -> Bool { + condition.lock() + let matches = owner.map { pthread_equal(pthread_self(), $0) != 0 } ?? false + condition.unlock() + return matches + } + + func enqueue(_ operation: @escaping () -> Void) -> Bool { + condition.lock() + defer { condition.unlock() } + guard !stopping else { return false } + pending.append(operation) + condition.signal() + return true + } + + func stopAndWait() { + condition.lock() + stopping = true + condition.broadcast() + if owner.map({ pthread_equal(pthread_self(), $0) != 0 }) != true { + while !exited { condition.wait() } + } + condition.unlock() + } + + private func next() -> (() -> Void)? { + condition.lock() + while pending.isEmpty, !stopping { condition.wait() } + guard !pending.isEmpty else { + exited = true + condition.broadcast() + condition.unlock() + return nil + } + let operation = pending.removeFirst() + condition.unlock() + return operation + } + } + + private let state: State + private let thread: Thread + + init() { + let state = State() + self.state = state + let thread = Thread { state.run() } + thread.name = "dory-renderer.foreign-owner" + thread.qualityOfService = .userInteractive + self.thread = thread + thread.start() + state.waitUntilStarted() + } + + deinit { state.stopAndWait() } + + func sync(_ operation: @escaping () throws -> Output) throws -> Output { + if state.isOwnerThread() { return try operation() } + let submission = Submission(operation: operation) + guard state.enqueue({ submission.run() }) else { throw LaneError.stopped } + return try submission.wait() + } +} + +/// Production renderer backend. Activation is one atomic transition: exact bundle bytes are +/// verified, the static dual renderer is opened, real VirGL2 and Venus contexts are exercised, +/// VirGL's shareable Metal texture and Venus SHM paths are imported, and callback-backed fences are +/// observed before a complete receipt can exist. Any uncertain result tears down the entire process +/// generation; there is no software, frame-copy, or synchronous runtime-completion fallback. +public final class DoryRendererWorkerVirglBackend: + DoryRendererWorkerBackend, + @unchecked Sendable +{ + private static let maximumContexts = 4_096 + private static let maximumResources = 65_536 + private static let preflightVirgl2ContextID: UInt32 = 0xffff_fff0 + private static let preflightVenusContextID: UInt32 = 0xffff_fff1 + private static let preflightResourceID: UInt32 = 0xffff_fff2 + private static let preflightVirgl2ResourceID: UInt32 = 0xffff_fff3 + private static let preflightVirgl2BufferResourceID: UInt32 = 0xffff_fff4 + private static let preflightVirgl2Resource2DID: UInt32 = 0xffff_fff5 + private static let preflightVirgl2SurfaceObjectID: UInt32 = 0xffff_fff6 + private static let preflightGlobalFenceID: UInt64 = 0x1_0000_00f1 + private static let preflightVirgl2FenceID: UInt64 = 0xffff_ffff_ffff_ffef + private static let preflightVenusFenceID: UInt64 = 0xffff_ffff_ffff_fff0 + private static let preflightFenceTimeoutMilliseconds: Int32 = 3_000 + private static let logger = Logger( + subsystem: "dev.dory.renderer-worker", + category: "scanout" + ) + private static let executionLogger = Logger( + subsystem: "dev.dory.renderer-worker", + category: "execution" + ) + + private enum State { + case cold + case active(ActiveState) + case failed + } + + private final class ActiveState { + let bootstrap: DoryRendererWorkerBootstrap + let session: any DoryRendererForeignSession + var contexts = [UInt32: UInt32]() + var resources = [UInt32: ResourceState]() + var lastResourceGenerations = [UInt32: UInt64]() + var leases = [UUID: ScanoutLeaseState]() + var loggedScanoutRejections = Set() + var pollDriver: PollDriver? + + init( + bootstrap: DoryRendererWorkerBootstrap, + session: any DoryRendererForeignSession + ) { + self.bootstrap = bootstrap + self.session = session + } + } + + /// virglrenderer uses this descriptor for non-callback event retirement even with asynchronous + /// fence callbacks enabled. Darwin can strip the thread-sync hint and return no descriptor; in + /// that mode a bounded timer supplies the same required pump. The source never owns or closes a + /// borrowed descriptor. + private final class PollDriver: @unchecked Sendable { + private let cancellationLock = NSLock() + private var cancelled = false + private let readSource: DispatchSourceRead? + private let timerSource: DispatchSourceTimer? + + init( + descriptor: Int32?, + handler: @escaping @Sendable () -> Void + ) { + let queue = DispatchQueue( + label: "dev.dory.renderer-worker.foreign-events", + qos: .userInteractive + ) + if let descriptor { + let source = DispatchSource.makeReadSource( + fileDescriptor: descriptor, + queue: queue + ) + readSource = source + timerSource = nil + source.setEventHandler(handler: handler) + source.resume() + } else { + let source = DispatchSource.makeTimerSource(queue: queue) + readSource = nil + timerSource = source + source.schedule( + deadline: .now() + .milliseconds(4), + repeating: .milliseconds(4), + leeway: .milliseconds(1) + ) + source.setEventHandler(handler: handler) + source.resume() + } + } + + func cancel() { + cancellationLock.lock() + defer { cancellationLock.unlock() } + guard !cancelled else { return } + cancelled = true + readSource?.setEventHandler {} + timerSource?.setEventHandler {} + readSource?.cancel() + timerSource?.cancel() + } + + deinit { cancel() } + } + + private struct PreflightResult { + let capsets: [DoryRendererForeignCapset] + let pollDescriptor: Int32? + } + + private final class ResourceState { + let generation: UInt64 + let blobSize: UInt64? + let resource3DBind: UInt32? + var backing: OwnedBacking? + var attachedContexts = Set() + var mapped = false + var liveLeaseIDs = Set() + + init( + generation: UInt64, + blobSize: UInt64?, + resource3DBind: UInt32?, + backing: OwnedBacking? + ) { + self.generation = generation + self.blobSize = blobSize + self.resource3DBind = resource3DBind + self.backing = backing + } + } + + private struct ScanoutLeaseState { + let resourceID: UInt32 + let resourceGeneration: UInt64 + let releaseToken: DoryRendererScanoutReleaseToken + let sharedTextureHandle: MTLSharedTextureHandle? + } + + private let verifier: any DoryRendererProductionArtifactVerifying + private let sessionFactory: any DoryRendererForeignSessionCreating + private let alignmentProvider: any DoryRendererScanoutAlignmentProviding + private let executionLane = DoryRendererForeignExecutionLane() + private let lock = NSLock() + private var state: State = .cold + + public convenience init() throws { + try self.init( + verifier: DoryRendererProductionArtifactVerifier(), + sessionFactory: DoryRendererCForeignSessionFactory(), + alignmentProvider: DoryRendererSystemMetalAlignmentProvider() + ) + } + + public init( + verifier: any DoryRendererProductionArtifactVerifying, + sessionFactory: any DoryRendererForeignSessionCreating, + alignmentProvider: any DoryRendererScanoutAlignmentProviding + ) { + self.verifier = verifier + self.sessionFactory = sessionFactory + self.alignmentProvider = alignmentProvider + } + + deinit { invalidate() } + + public func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + try executionLane.sync { [self] in + try activateOnExecutionLane(bootstrap: bootstrap) + } + } + + private func activateOnExecutionLane( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + lock.lock() + defer { lock.unlock() } + guard case .cold = state else { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + do { + let attestation: DoryRendererArtifactAttestation + do { + attestation = try verifier.verify(bootstrap: bootstrap) + } catch { + throw DoryRendererWorkerBackendActivationError.artifactAuthority + } + let session: any DoryRendererForeignSession + do { + session = try sessionFactory.create(attestation: attestation) + } catch { + throw DoryRendererWorkerBackendActivationError.rendererInitialization + } + do { + let preflight = try Self.preflight(session: session) + let receiptCapsets: [DoryRendererCapsetAttestation] + do { + receiptCapsets = try preflight.capsets.map { capset in + try DoryRendererCapsetAttestation( + id: capset.id, + maximumVersion: capset.maximumVersion, + data: capset.bytes + ) + } + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try DoryRendererCapabilityReceipt( + accepting: bootstrap, + features: .productionAcceleration, + capsets: receiptCapsets + ) + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let active = ActiveState(bootstrap: bootstrap, session: session) + state = .active(active) + active.pollDriver = PollDriver( + descriptor: preflight.pollDescriptor, + handler: { [weak self] in self?.pollForeignEvents() } + ) + return receipt + } catch { + session.invalidate() + throw error + } + } catch { + state = .failed + throw error + } + } + + public func execute( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + try executionLane.sync { [self] in + try executeOnExecutionLane(command: command, descriptors: descriptors) + } + } + + private func executeOnExecutionLane( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + let startedAt = DispatchTime.now().uptimeNanoseconds + lock.lock() + defer { lock.unlock() } + guard case .active(let active) = state else { return .rejected } + do { + return try executeLocked( + command: command, + descriptors: descriptors, + active: active + ) + } catch is DoryRendererWorkerContractError { + throw errorForProtocolViolation() + } catch { + let now = DispatchTime.now().uptimeNanoseconds + let diagnostic = Self.executionFailureDiagnostic( + command: command, + error: error, + elapsedNanoseconds: now >= startedAt ? now - startedAt : 0 + ) + Self.executionLogger.error( + "command-outcome-unknown operation=\(diagnostic.operation.rawValue, privacy: .public) request=\(diagnostic.requestID, privacy: .public) stage=\(diagnostic.stage.rawValue, privacy: .public) error-case=\(diagnostic.errorCase.rawValue, privacy: .public) foreign-operation=\(diagnostic.foreignOperation.rawValue, privacy: .public) status-present=\(diagnostic.statusIsAvailable, privacy: .public) status=\(diagnostic.status, privacy: .public) submit-diagnostic-present=\(diagnostic.submitDiagnosticIsAvailable, privacy: .public) virgl-decoder-diagnostic-present=\(diagnostic.virglDecoderDiagnosticIsAvailable, privacy: .public) submit-context=\(diagnostic.submitContextID, privacy: .public) virgl-command=\(diagnostic.virglCommandID, privacy: .public) virgl-command-status=\(diagnostic.virglCommandStatus, privacy: .public) create-object-subtype-disposition=\(diagnostic.createObjectSubtypeDisposition.rawValue, privacy: .public) create-object-subtype-present=\(diagnostic.createObjectSubtype != nil, privacy: .public) create-object-subtype=\(diagnostic.createObjectSubtype?.rawValue ?? 0, privacy: .public) create-object-candidate-count=\(diagnostic.createObjectCandidateCount, privacy: .public) create-object-subtype-mask=\(diagnostic.createObjectSubtypeMask, privacy: .public) surface-failure-reason=\(diagnostic.surfaceFailureReason.rawValue, privacy: .public) virgl-precursor-category=\(diagnostic.virglPrecursorCategory.rawValue, privacy: .public) elapsed-ns=\(diagnostic.elapsedNanoseconds, privacy: .public)" + ) + teardownLocked(active) + state = .failed + return .outcomeUnknown + } + } + + static func executionFailureDiagnostic( + command: DoryRendererWorkerCommand, + error: any Error, + elapsedNanoseconds: UInt64 + ) -> DoryRendererForeignExecutionFailureDiagnostic { + let stage: DoryRendererForeignExecutionFailureStage + let errorCase: DoryRendererForeignSessionErrorCase + let foreignOperation: DoryRendererForeignOperationLabel + let statusIsAvailable: Bool + let status: Int32 + let submitDiagnostic: DoryRendererForeignSubmitFailure? + switch error as? DoryRendererForeignSessionError { + case .openFailed(let value): + stage = .foreignSessionOpen + errorCase = .openFailed + foreignOperation = .sessionOpen + statusIsAvailable = true + status = value + submitDiagnostic = nil + case .callFailed(let operation, let value): + stage = .foreignCall + errorCase = .callFailed + foreignOperation = DoryRendererForeignOperationLabel(rawValue: operation) + ?? .unclassified + statusIsAvailable = true + status = value + submitDiagnostic = nil + case .submitFailed(let value, let diagnostic): + stage = .foreignCall + errorCase = .submitFailed + foreignOperation = .submit3D + statusIsAvailable = true + status = value + submitDiagnostic = diagnostic + case .invalidResult(let operation): + stage = .foreignResultValidation + errorCase = .invalidResult + foreignOperation = DoryRendererForeignOperationLabel(rawValue: operation) + ?? .unclassified + statusIsAvailable = false + status = 0 + submitDiagnostic = nil + case nil: + stage = .backendInternal + errorCase = .unexpected + foreignOperation = .unclassified + statusIsAvailable = false + status = 0 + submitDiagnostic = nil + } + return DoryRendererForeignExecutionFailureDiagnostic( + operation: command.operation, + requestID: command.requestID, + stage: stage, + errorCase: errorCase, + foreignOperation: foreignOperation, + statusIsAvailable: statusIsAvailable, + status: status, + submitDiagnosticIsAvailable: submitDiagnostic != nil, + virglDecoderDiagnosticIsAvailable: + submitDiagnostic?.decoderDiagnosticIsAvailable ?? false, + submitContextID: submitDiagnostic?.contextID ?? 0, + virglCommandID: submitDiagnostic?.commandID ?? 0, + virglCommandStatus: submitDiagnostic?.status ?? 0, + createObjectSubtypeDisposition: + submitDiagnostic?.createObjectSubtypeDisposition ?? .absent, + createObjectSubtype: submitDiagnostic?.createObjectSubtype, + createObjectCandidateCount: submitDiagnostic?.createObjectCandidateCount ?? 0, + createObjectSubtypeMask: submitDiagnostic?.createObjectSubtypeMask ?? 0, + surfaceFailureReason: submitDiagnostic?.surfaceFailureReason ?? .none, + virglPrecursorCategory: submitDiagnostic?.precursorCategory ?? .none, + elapsedNanoseconds: elapsedNanoseconds + ) + } + + public func invalidate() { + _ = try? executionLane.sync { [self] in + lock.lock() + defer { lock.unlock() } + if case .active(let active) = state { teardownLocked(active) } + state = .failed + } + } + + public func snapshot() -> DoryRendererWorkerVirglBackendSnapshot { + let unavailable = DoryRendererWorkerVirglBackendSnapshot( + isActive: false, + contextCount: 0, + resourceCount: 0, + liveScanoutLeaseCount: 0 + ) + return (try? executionLane.sync { [self] in + lock.withLock { + switch state { + case .cold, .failed: + return unavailable + case .active(let active): + return DoryRendererWorkerVirglBackendSnapshot( + isActive: true, + contextCount: active.contexts.count, + resourceCount: active.resources.count, + liveScanoutLeaseCount: active.leases.count + ) + } + } + }) ?? unavailable + } + + private func executeLocked( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle], + active: ActiveState + ) throws -> DoryRendererWorkerBackendExecution { + switch command.operation { + case .createContext: + guard active.contexts.count < Self.maximumContexts, + active.contexts[command.contextID] == nil else { return .rejected } + let payload = try DoryRendererContextCreatePayload.decode(command.payload) + guard payload.capsetID == 2 || payload.capsetID == 4 else { return .rejected } + try active.session.createContext( + id: command.contextID, + capsetID: payload.capsetID, + name: payload.name + ) + active.contexts[command.contextID] = payload.capsetID + return .success(payload: Data(), descriptors: []) + + case .createResource3D: + guard active.resources.count < Self.maximumResources, + active.resources[command.resourceID] == nil else { return .rejected } + let payload = try DoryRendererResource3DCreatePayload.decode( + command.payload, + maximumReferencedBytes: active.bootstrap.limits.maximumReferencedBytes + ) + try active.session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: command.resourceID, + payload: payload + ) + ) + let generation = try nextResourceGeneration( + resourceID: command.resourceID, + active: active + ) + active.resources[command.resourceID] = ResourceState( + generation: generation, + blobSize: nil, + resource3DBind: payload.bind, + backing: nil + ) + return .success(payload: Self.encodeUInt64(generation), descriptors: []) + + case .destroyContext: + guard active.contexts.removeValue(forKey: command.contextID) != nil else { + return .rejected + } + for (resourceID, resource) in active.resources + where resource.attachedContexts.remove(command.contextID) != nil { + // The local state proved both identities before this void foreign call. + // Detaching first also makes retained backing teardown deterministic. + active.session.detachResource( + contextID: command.contextID, + resourceID: resourceID + ) + } + active.session.destroyContext(id: command.contextID) + return .success(payload: Data(), descriptors: []) + + case .attachResource: + guard active.contexts[command.contextID] != nil, + let resource = matchingResource(command, active: active) else { + return .rejected + } + // Linux GEM open can emit the same context/resource attach more than once. QEMU + // forwards every request and virglrenderer treats an already-attached resource as a + // successful no-op, so preserve that public lifecycle contract while still proving + // both identities and the authenticated resource generation above. + guard resource.attachedContexts.insert(command.contextID).inserted else { + return .success(payload: Data(), descriptors: []) + } + active.session.attachResource( + contextID: command.contextID, + resourceID: command.resourceID + ) + return .success(payload: Data(), descriptors: []) + + case .detachResource: + guard active.contexts[command.contextID] != nil, + let resource = matchingResource(command, active: active) else { + return .rejected + } + // The matching Linux GEM close path and virglrenderer detach callback are likewise + // idempotent. Do not turn harmless duplicate cleanup into a guest-visible GPU fault. + guard resource.attachedContexts.remove(command.contextID) != nil else { + return .success(payload: Data(), descriptors: []) + } + active.session.detachResource( + contextID: command.contextID, + resourceID: command.resourceID + ) + return .success(payload: Data(), descriptors: []) + + case .submit3D: + guard active.contexts[command.contextID] != nil, + let region = command.sharedRegions.first else { return .rejected } + let mapping = try OwnedBacking( + regions: [region], + descriptors: descriptors + ) + guard let baseAddress = mapping.iovecs.pointee.iov_base else { return .rejected } + try active.session.submit( + contextID: command.contextID, + bytes: UnsafeRawPointer(baseAddress), + dwordCount: UInt32(region.length / 4) + ) + return .success(payload: Data(), descriptors: []) + + case .createBlob: + guard active.resources.count < Self.maximumResources, + active.resources[command.resourceID] == nil, + command.contextID == 0 || active.contexts[command.contextID] != nil else { + return .rejected + } + let payload = try DoryRendererBlobCreatePayload.decode(command.payload) + guard payload.size <= active.bootstrap.limits.maximumReferencedBytes else { + return .rejected + } + let backing = command.sharedRegions.isEmpty + ? nil + : try OwnedBacking(regions: command.sharedRegions, descriptors: descriptors) + try active.session.createBlob( + DoryRendererForeignBlobCreate( + resourceID: command.resourceID, + contextID: command.contextID, + payload: payload + ), + iovecs: backing?.iovecs, + iovecCount: backing?.count ?? 0 + ) + let generation = try nextResourceGeneration( + resourceID: command.resourceID, + active: active + ) + active.resources[command.resourceID] = ResourceState( + generation: generation, + blobSize: payload.size, + resource3DBind: nil, + backing: backing + ) + return .success(payload: Self.encodeUInt64(generation), descriptors: []) + + case .attachBacking: + guard let resource = matchingResource(command, active: active), + resource.backing == nil, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + let backing = try OwnedBacking( + regions: command.sharedRegions, + descriptors: descriptors + ) + try active.session.attachBacking( + resourceID: command.resourceID, + iovecs: backing.iovecs, + iovecCount: backing.count + ) + resource.backing = backing + return .success(payload: Data(), descriptors: []) + + case .detachBacking: + guard let resource = matchingResource(command, active: active), + resource.backing != nil, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + active.session.detachBacking(resourceID: command.resourceID) + resource.backing = nil + return .success(payload: Data(), descriptors: []) + + case .unrefResource: + guard let resource = matchingResource(command, active: active), + resource.attachedContexts.isEmpty, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + if resource.backing != nil { + // virglrenderer borrows these iovecs. Keep OwnedBacking (and its mmap regions) + // alive through the foreign detach, then revoke that memory authority before the + // resource handle is unreferenced or becomes eligible for same-ID reuse. + active.session.detachBacking(resourceID: command.resourceID) + resource.backing = nil + } + active.session.unrefResource(id: command.resourceID) + active.resources.removeValue(forKey: command.resourceID) + return .success(payload: Data(), descriptors: []) + + case .mapBlob: + guard let resource = matchingResource(command, active: active), + let blobSize = resource.blobSize, + !resource.mapped else { return .rejected } + let mapInfo = try active.session.mapInfo(resourceID: command.resourceID) & 0x0f + let exported = try active.session.exportBlob(resourceID: command.resourceID) + let validated = try Self.validateExportedSHM( + exported, + minimumBytes: blobSize, + maximumBytes: active.bootstrap.limits.maximumReferencedBytes + ) + do { + let lease = try DoryRendererBlobMappingLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + sharedRegionID: DoryRendererSharedRegionID.random(), + descriptorIndex: 0, + mapInfo: mapInfo, + declaredFileSize: validated.fileSize, + mappingByteCount: blobSize, + limits: active.bootstrap.limits + ) + resource.mapped = true + return .success( + payload: DoryRendererBlobMappingLeaseCodec.encode(lease), + descriptors: [FileHandle( + fileDescriptor: validated.fileDescriptor, + closeOnDealloc: true + )] + ) + } catch { + close(validated.fileDescriptor) + throw error + } + + case .unmapBlob: + guard let resource = matchingResource(command, active: active), + resource.mapped else { return .rejected } + // `mapBlob` exported an owned SHM descriptor; it did not call the process-local + // `virgl_renderer_resource_map`, so no foreign unmap state exists to release here. + resource.mapped = false + return .success(payload: Data(), descriptors: []) + + case .transferToHost3D, .transferFromHost3D: + guard matchingResource(command, active: active) != nil, + command.contextID == 0 || active.contexts[command.contextID] != nil else { + return .rejected + } + let payload = try DoryRendererTransfer3DPayload.decode( + command.payload, + operation: command.operation + ) + let backing = command.sharedRegions.isEmpty + ? nil + : try OwnedBacking(regions: command.sharedRegions, descriptors: descriptors) + try active.session.transfer( + toHost: command.operation == .transferToHost3D, + resourceID: command.resourceID, + contextID: command.contextID, + payload: payload, + iovecs: backing?.iovecs, + iovecCount: backing?.count ?? 0 + ) + return .success(payload: Data(), descriptors: []) + + case .createFence: + let payload = try DoryRendererFencePayload.decode(command.payload) + let hasAuthorizedTimeline = payload.isContextTimeline + ? command.contextID != 0 && active.contexts[command.contextID] != nil + : command.contextID == 0 || active.contexts[command.contextID] != nil + guard hasAuthorizedTimeline else { + // Context-timeline fences require an authenticated live context. Global fences + // order ctx0 resource mutations or a live submit context, but never a stale one. + return .rejected + } + if payload.isContextTimeline { + try active.session.createFence( + contextID: command.contextID, + // The worker bit is protocol metadata. Virgl bit 0 means MERGEABLE, not + // "context timeline", so it must not be forwarded into the foreign ABI. + flags: 0, + ringIndex: payload.ringIndex, + fenceID: payload.fenceID + ) + } else { + try active.session.createGlobalFence(fenceID: payload.fenceID) + } + let brokerDescriptor = try active.session.exportFence(fenceID: payload.fenceID) + return .success( + payload: payload.encoded, + descriptors: [FileHandle( + fileDescriptor: brokerDescriptor, + closeOnDealloc: true + )] + ) + + case .acquireScanoutLease: + guard let resource = matchingResource(command, active: active), + // Dumb-KMS RESOURCE_CREATE_2D scanouts are valid without a VirGL context. + // Blob scanouts remain context-owned, while classic resources must still pass + // the generation, SCANOUT-bind, resource-info, and Metal checks below. + resource.blobSize == nil || !resource.attachedContexts.isEmpty, + active.leases.count < active.bootstrap.limits.maximumLiveScanoutLeases else { + return .rejected + } + let payload = try DoryRendererScanoutAcquirePayload.decode(command.payload) + let pixelFormat: DoryRendererScanoutPixelFormat = switch payload.virglFormat { + case 1: .bgra8Unorm + case 67: .rgba8Unorm + default: throw DoryRendererWorkerContractError.invalidOperationPayload( + operation: .acquireScanoutLease + ) + } + if resource.blobSize == nil { + return try acquireSharedTextureScanout( + command: command, + payload: payload, + pixelFormat: pixelFormat, + resource: resource, + active: active + ) + } + guard let blobSize = resource.blobSize else { return .rejected } + guard let rowAlignment = alignmentProvider.minimumLinearTextureAlignment( + pixelFormat: pixelFormat + ), rowAlignment >= 4, + rowAlignment <= 65_536, + rowAlignment.nonzeroBitCount == 1 else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-metal-row-alignment" + ) + return .rejected + } + // Venus blob resources have no pipe_resource, so virgl_renderer_resource_get_info + // intentionally returns no dimensions or stride. The authenticated VMM supplies the + // SET_SCANOUT_BLOB layout; this process independently proves it fits the exact worker + // allocation and exported SHM object below. + let (minimumStride, minimumStrideOverflow) = UInt64(payload.width) + .multipliedReportingOverflow(by: pixelFormat.bytesPerPixel) + guard !minimumStrideOverflow, + UInt64(payload.stride) >= minimumStride, + payload.stride.isMultiple(of: rowAlignment), + payload.storageOffset.isMultiple(of: rowAlignment) else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-stride expected-min=\(minimumStride) alignment=" + + "\(rowAlignment) actual=\(payload.stride) offset=" + + "\(payload.storageOffset)" + ) + return .rejected + } + let storageOffset = UInt64(payload.storageOffset) + let (leaseBytes, leaseOverflow) = UInt64(payload.stride) + .multipliedReportingOverflow(by: UInt64(payload.height)) + guard !leaseOverflow, + leaseBytes > 0, + leaseBytes <= active.bootstrap.limits.maximumScanoutBytes else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-lease-size bytes=\(leaseBytes)" + ) + return .rejected + } + let (requiredFileBytes, requiredFileBytesOverflow) = storageOffset + .addingReportingOverflow(leaseBytes) + guard !requiredFileBytesOverflow, + requiredFileBytes <= blobSize, + requiredFileBytes <= active.bootstrap.limits.maximumScanoutBytes else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "scanout-exceeds-blob required=\(requiredFileBytes) blob=\(blobSize)" + ) + return .rejected + } + let exported = try active.session.exportBlob(resourceID: command.resourceID) + let validated = try Self.validateExportedSHM( + exported, + minimumBytes: requiredFileBytes, + maximumBytes: active.bootstrap.limits.maximumScanoutBytes + ) + do { + let leaseID = try DoryRendererScanoutLeaseID(rawValue: UUID()) + let releaseToken = try DoryRendererScanoutReleaseToken(rawValue: UUID()) + let lease = try DoryRendererScanoutLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + leaseID: leaseID, + releaseToken: releaseToken, + sharedRegionID: DoryRendererSharedRegionID.random(), + sharedMemoryDescriptorIndex: 0, + synchronization: .managedGuestProducerCompleteFlush, + pixelFormat: pixelFormat, + yOriginTop: true, + width: payload.width, + height: payload.height, + stride: payload.stride, + rowAlignment: rowAlignment, + storageOffset: storageOffset, + declaredFileSize: validated.fileSize, + leaseByteCount: leaseBytes, + limits: active.bootstrap.limits + ) + active.leases[leaseID.rawValue] = ScanoutLeaseState( + resourceID: command.resourceID, + resourceGeneration: resource.generation, + releaseToken: releaseToken, + sharedTextureHandle: nil + ) + resource.liveLeaseIDs.insert(leaseID.rawValue) + return .success( + payload: DoryRendererScanoutLeaseCodec.encode(lease), + descriptors: [ + FileHandle( + fileDescriptor: validated.fileDescriptor, + closeOnDealloc: true + ) + ] + ) + } catch { + close(validated.fileDescriptor) + throw error + } + + case .releaseScanoutLease: + guard let resource = matchingResource(command, active: active) else { + return .rejected + } + let releaseToken = try DoryRendererScanoutReleaseToken.decodeCommandPayload( + command.payload + ) + guard let lease = active.leases.first(where: { + $0.value.resourceID == command.resourceID && + $0.value.resourceGeneration == command.resourceGeneration && + $0.value.releaseToken == releaseToken + }), + resource.liveLeaseIDs.remove(lease.key) != nil else { return .rejected } + active.leases.removeValue(forKey: lease.key) + return .success(payload: Data(), descriptors: []) + + case .resetAfterDeviceQuiesce: + let reset = try DoryRendererResetPayload.decode(command.payload) + guard reset.successorGeneration > active.bootstrap.generation.rawValue else { + return .rejected + } + teardownLocked(active) + state = .failed + return .success(payload: reset.encoded, descriptors: []) + } + } + + private func acquireSharedTextureScanout( + command: DoryRendererWorkerCommand, + payload: DoryRendererScanoutAcquirePayload, + pixelFormat: DoryRendererScanoutPixelFormat, + resource: ResourceState, + active: ActiveState + ) throws -> DoryRendererWorkerBackendExecution { + guard let bind = resource.resource3DBind, + bind & UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT) != 0 else { + return .rejected + } + let (minimumStride, strideOverflow) = payload.width.multipliedReportingOverflow(by: 4) + guard !strideOverflow, + payload.stride == minimumStride, + payload.storageOffset == 0 else { return .rejected } + let info = try active.session.resourceInfo(resourceID: command.resourceID) + guard info.resourceID == command.resourceID, + Self.canonicalScanoutFormat(info.format) == payload.virglFormat, + info.width == payload.width, + info.height == payload.height, + info.flags & ~UInt32(1) == 0 else { return .rejected } + + let texture = try active.session.acquireScanoutMetalTexture( + resourceID: command.resourceID, + width: payload.width, + height: payload.height, + virglFormat: payload.virglFormat, + stride: payload.stride, + offset: payload.storageOffset + ) + let expectedMetalFormat: MTLPixelFormat = switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + guard texture.textureType == .type2D, + texture.pixelFormat == expectedMetalFormat, + texture.width == Int(payload.width), + texture.height == Int(payload.height), + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains(.shaderRead), + let sharedTextureHandle = texture.makeSharedTextureHandle() else { + return .rejected + } + + let leaseID = try DoryRendererScanoutLeaseID(rawValue: UUID()) + let releaseToken = try DoryRendererScanoutReleaseToken(rawValue: UUID()) + let lease = try DoryRendererSharedTextureScanoutLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + leaseID: leaseID, + releaseToken: releaseToken, + synchronization: .managedGuestProducerCompleteFlush, + pixelFormat: pixelFormat, + yOriginTop: info.flags & 1 != 0, + width: payload.width, + height: payload.height, + limits: active.bootstrap.limits + ) + active.leases[leaseID.rawValue] = ScanoutLeaseState( + resourceID: command.resourceID, + resourceGeneration: resource.generation, + releaseToken: releaseToken, + sharedTextureHandle: sharedTextureHandle + ) + resource.liveLeaseIDs.insert(leaseID.rawValue) + return .success( + payload: DoryRendererSharedTextureScanoutLeaseCodec.encode(lease), + descriptors: [], + sharedTextureHandle: sharedTextureHandle + ) + } + + /// Virtio's XRGB/XBGR formats differ from their alpha variants only in whether scanout + /// consumes the high byte. The VMM deliberately carries the alpha-equivalent Metal format + /// across XPC, while virglrenderer reports the resource's original guest format. Compare both + /// sides in that same closed color-layout vocabulary; no other format is admitted. + private static func canonicalScanoutFormat(_ virglFormat: UInt32) -> UInt32? { + switch virglFormat { + case 1, 2: 1 + case 67, 68: 67 + default: nil + } + } + + private static func preflight( + session: any DoryRendererForeignSession + ) throws -> PreflightResult { + let pollDescriptor: Int32? + do { + pollDescriptor = try session.pollDescriptor() + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let virgl2: DoryRendererForeignCapset + do { + virgl2 = try session.capset(id: 2) + } catch { + throw DoryRendererWorkerBackendActivationError.virgl2Capability + } + guard virgl2.maximumVersion > 0, !virgl2.bytes.isEmpty else { + throw DoryRendererWorkerBackendActivationError.virgl2Capability + } + let venus: DoryRendererForeignCapset + do { + venus = try session.capset(id: 4) + } catch { + throw DoryRendererWorkerBackendActivationError.venusCapability + } + // `virgl_renderer_get_cap_set` deliberately reports Venus at outer version zero. The + // returned payload carries the Venus wire/XML/spec versions and is the capability proof. + guard venus.maximumVersion == 0, !venus.bytes.isEmpty else { + throw DoryRendererWorkerBackendActivationError.venusCapability + } + + let resource2DBackingByteCount = 4 * 4 * 4 + let resource2DBacking = UnsafeMutableRawPointer.allocate( + byteCount: resource2DBackingByteCount, + alignment: 16 + ) + resource2DBacking.initializeMemory( + as: UInt8.self, + repeating: 0xa5, + count: resource2DBackingByteCount + ) + defer { resource2DBacking.deallocate() } + + var virgl2Created = false + var virgl2BufferCreated = false + var virgl2Resource2DCreated = false + var virgl2Resource2DBackingAttached = false + var virgl2ResourceCreated = false + var venusCreated = false + var blobCreated = false + defer { + if blobCreated { session.unrefResource(id: preflightResourceID) } + if virgl2ResourceCreated { + session.detachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2ResourceID + ) + session.unrefResource(id: preflightVirgl2ResourceID) + } + if virgl2Resource2DCreated { + session.detachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2Resource2DID + ) + if virgl2Resource2DBackingAttached { + session.detachBacking(resourceID: preflightVirgl2Resource2DID) + } + session.unrefResource(id: preflightVirgl2Resource2DID) + } + if virgl2BufferCreated { + session.unrefResource(id: preflightVirgl2BufferResourceID) + } + if venusCreated { session.destroyContext(id: preflightVenusContextID) } + if virgl2Created { session.destroyContext(id: preflightVirgl2ContextID) } + } + do { + try session.createContext( + id: preflightVirgl2ContextID, + capsetID: 2, + name: "dory-preflight-virgl2" + ) + virgl2Created = true + // Linux creates ordinary PIPE_BUFFER resources immediately after desktop readiness. + // This exact allocation reaches vrend's glGenBuffersARB path, so it must succeed on + // the persistent owner pthread before this worker may advertise VirGL2. + let buffer = try DoryRendererResource3DCreatePayload( + target: 0, // Pinned Gallium ABI: PIPE_BUFFER. + format: 64, // Pinned virgl ABI: VIRGL_FORMAT_R8_UNORM. + bind: 1 << 4, // Pinned Gallium ABI: PIPE_BIND_VERTEX_BUFFER. + width: 4_096, + height: 1, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 0 + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2BufferResourceID, + payload: buffer + ) + ) + virgl2BufferCreated = true + // Match Dory's RESOURCE_CREATE_2D renderer translation exactly, then prove that the + // resulting resource is visible in the VirGL context by creating and destroying a + // surface object. This catches a silently ignored context attachment before VirGL2 is + // advertised to Linux. + let resource2D = try DoryRendererResource3DCreatePayload( + target: 2, // Pinned Gallium ABI: PIPE_TEXTURE_2D. + format: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + bind: UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT), + width: 4, + height: 4, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1 // Pinned virgl ABI: VIRGL_RESOURCE_Y_0_TOP. + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2Resource2DID, + payload: resource2D + ) + ) + virgl2Resource2DCreated = true + session.attachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2Resource2DID + ) + var resource2DBackingIOVec = iovec( + iov_base: resource2DBacking, + iov_len: resource2DBackingByteCount + ) + // Treat a foreign attach error as mutation-uncertain for cleanup: detaching is safe + // before unref and keeps the backing allocation alive through the teardown attempt. + virgl2Resource2DBackingAttached = true + try session.attachBacking( + resourceID: preflightVirgl2Resource2DID, + iovecs: &resource2DBackingIOVec, + iovecCount: 1 + ) + // Match RESOURCE_TRANSFER_TO_HOST_2D: ctx0, natural renderer strides, and the + // resource's retained backing rather than an operation-local iovec override. + try session.transfer( + toHost: true, + resourceID: preflightVirgl2Resource2DID, + contextID: 0, + payload: DoryRendererTransfer3DPayload( + level: 0, + stride: 0, + layerStride: 0, + offset: 0, + x: 0, + y: 0, + z: 0, + width: 4, + height: 4, + depth: 1 + ), + iovecs: nil, + iovecCount: 0 + ) + try submitVirgl2SurfaceLifecycle(session: session) + let resource = try DoryRendererResource3DCreatePayload( + target: 2, + format: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + bind: UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT), + width: 4, + height: 4, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 0 + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2ResourceID, + payload: resource + ) + ) + virgl2ResourceCreated = true + session.attachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2ResourceID + ) + let texture = try session.acquireScanoutMetalTexture( + resourceID: preflightVirgl2ResourceID, + width: 4, + height: 4, + virglFormat: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + stride: 16, + offset: 0 + ) + guard texture.textureType == .type2D, + texture.pixelFormat == .bgra8Unorm, + texture.width == 4, + texture.height == 4, + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains([ + .shaderRead, + .shaderWrite, + .renderTarget, + ]), + let handle = texture.makeSharedTextureHandle(), + let imported = texture.device.makeSharedTexture(handle: handle), + imported.device === texture.device, + imported.pixelFormat == texture.pixelFormat, + imported.width == texture.width, + imported.height == texture.height, + imported.storageMode == .private, + imported.usage == texture.usage else { + throw DoryRendererWorkerBackendActivationError.virgl2Context + } + } catch { + throw DoryRendererWorkerBackendActivationError.virgl2Context + } + do { + try session.createContext( + id: preflightVenusContextID, + capsetID: 4, + name: "dory-preflight-venus" + ) + } catch { + throw DoryRendererWorkerBackendActivationError.venusContext + } + venusCreated = true + let blob: DoryRendererBlobCreatePayload + do { + blob = try DoryRendererBlobCreatePayload( + blobMemory: UInt32(DORY_VIRGL_RENDERER_BLOB_MEMORY_HOST3D), + blobFlags: UInt32(DORY_VIRGL_RENDERER_BLOB_FLAG_MAPPABLE), + // Venus reserves zero for a renderer-allocated, exportable SHM blob. + blobID: 0, + size: UInt64(getpagesize()) + ) + try session.createBlob( + DoryRendererForeignBlobCreate( + resourceID: preflightResourceID, + contextID: preflightVenusContextID, + payload: blob + ), + iovecs: nil, + iovecCount: 0 + ) + } catch { + throw DoryRendererWorkerBackendActivationError.sharedMemoryExport + } + blobCreated = true + let validated: ValidatedSHM + do { + let exported = try session.exportBlob(resourceID: preflightResourceID) + validated = try validateExportedSHM( + exported, + minimumBytes: UInt64(getpagesize()), + maximumBytes: UInt64(getpagesize()) * 16 + ) + } catch { + throw DoryRendererWorkerBackendActivationError.sharedMemoryExport + } + close(validated.fileDescriptor) + + let globalFenceDescriptor: Int32 + do { + // Prove the classic ctx0 callback path preserves a guest identity wider than VirGL's + // 32-bit callback token before a production receipt can advertise acceleration. + try session.createGlobalFence(fenceID: preflightGlobalFenceID) + globalFenceDescriptor = try session.exportFence(fenceID: preflightGlobalFenceID) + defer { close(globalFenceDescriptor) } + try waitForFenceCompletion( + descriptor: globalFenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + + let virgl2FenceDescriptor: Int32 + do { + try session.createFence( + contextID: preflightVirgl2ContextID, + flags: 0, + ringIndex: 0, + fenceID: preflightVirgl2FenceID + ) + virgl2FenceDescriptor = try session.exportFence(fenceID: preflightVirgl2FenceID) + defer { close(virgl2FenceDescriptor) } + try waitForFenceCompletion( + descriptor: virgl2FenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + + let venusFenceDescriptor: Int32 + do { + try session.createFence( + contextID: preflightVenusContextID, + flags: 0, + ringIndex: 0, + fenceID: preflightVenusFenceID + ) + venusFenceDescriptor = try session.exportFence(fenceID: preflightVenusFenceID) + defer { close(venusFenceDescriptor) } + try waitForFenceCompletion( + descriptor: venusFenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + return PreflightResult( + capsets: [virgl2, venus], + pollDescriptor: pollDescriptor + ) + } + + private static func submitVirgl2SurfaceLifecycle( + session: any DoryRendererForeignSession + ) throws { + // Pinned virgl ABI: CREATE_OBJECT/SURFACE has five payload dwords. Destroying the object in + // the same submit keeps activation cleanup complete even before the context is torn down. + let dwords: [UInt32] = [ + (5 << 16) | (8 << 8) | 1, + preflightVirgl2SurfaceObjectID, + preflightVirgl2Resource2DID, + UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + 0, // mip level + 0, // first layer 0, last layer 0 + (1 << 16) | (8 << 8) | 3, + preflightVirgl2SurfaceObjectID, + ] + let byteCount = dwords.count * MemoryLayout.stride + let alignedBytes = UnsafeMutableRawPointer.allocate(byteCount: byteCount, alignment: 8) + defer { alignedBytes.deallocate() } + dwords.withUnsafeBytes { source in + alignedBytes.copyMemory(from: source.baseAddress!, byteCount: byteCount) + } + try session.submit( + contextID: preflightVirgl2ContextID, + bytes: UnsafeRawPointer(alignedBytes), + dwordCount: UInt32(dwords.count) + ) + } + + private static func waitForFenceCompletion( + descriptor: Int32, + session: any DoryRendererForeignSession + ) throws { + guard descriptor >= 0, fcntl(descriptor, F_GETFD) >= 0 else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let started = DispatchTime.now().uptimeNanoseconds + let timeoutNanoseconds = UInt64(preflightFenceTimeoutMilliseconds) * 1_000_000 + while true { + session.poll() + var event = pollfd( + fd: descriptor, + events: Int16(POLLIN | POLLHUP), + revents: 0 + ) + let status = Darwin.poll(&event, 1, 5) + if status > 0 { + guard event.revents & Int16(POLLNVAL | POLLERR) == 0, + event.revents & Int16(POLLIN | POLLHUP) != 0 else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + return + } + if status < 0, errno != EINTR { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let now = DispatchTime.now().uptimeNanoseconds + guard now >= started, now - started < timeoutNanoseconds else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + } + } + + private struct ValidatedSHM { + let fileDescriptor: Int32 + let fileSize: UInt64 + } + + private static func validateExportedSHM( + _ exported: DoryRendererForeignExportedBlob, + minimumBytes: UInt64, + maximumBytes: UInt64 + ) throws -> ValidatedSHM { + let descriptor = exported.ownedFileDescriptor + guard exported.type == UInt32(DORY_VIRGL_RENDERER_BLOB_FD_TYPE_SHM), + descriptor >= 0 else { + if descriptor >= 0 { close(descriptor) } + throw DoryRendererForeignSessionError.invalidResult(operation: "non-shm-export") + } + var status = stat() + guard fstat(descriptor, &status) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-stat") + } + // Darwin POSIX SHM descriptors report only their access mode (normally 0600), without + // S_IFREG or another S_IF* type bit. An unlinked temporary filesystem file still reports + // S_IFREG. Accept exactly those two shapes, then prove the descriptor has the private, + // bounded, read/write-mappable semantics the zero-copy path requires. + guard DoryRendererSharedMemoryDescriptorPolicy.accepts(mode: status.st_mode), + status.st_nlink == 0, + status.st_size > 0, + UInt64(status.st_size) >= minimumBytes, + UInt64(status.st_size) <= maximumBytes else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-bounds") + } + let probeLength = Swift.min(Int(status.st_size), Int(getpagesize())) + let probe = mmap( + nil, + probeLength, + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + 0 + ) + guard probe != MAP_FAILED, let probe else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-mapping") + } + guard munmap(probe, probeLength) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-unmapping") + } + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-cloexec") + } + return ValidatedSHM( + fileDescriptor: descriptor, + fileSize: UInt64(status.st_size) + ) + } + + private func logScanoutRejection( + active: ActiveState, + resourceID: UInt32, + reason: String + ) { + guard active.loggedScanoutRejections.insert(resourceID).inserted else { return } + Self.logger.error( + "scanout-rejected resource=\(resourceID, privacy: .public) reason=\(reason, privacy: .public)" + ) + } + + private func matchingResource( + _ command: DoryRendererWorkerCommand, + active: ActiveState + ) -> ResourceState? { + guard let resource = active.resources[command.resourceID], + resource.generation == command.resourceGeneration else { return nil } + return resource + } + + private func nextResourceGeneration( + resourceID: UInt32, + active: ActiveState + ) throws -> UInt64 { + let previous = active.lastResourceGenerations[resourceID] ?? 0 + let (next, overflow) = previous.addingReportingOverflow(1) + guard !overflow, next != 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "resource-generation") + } + active.lastResourceGenerations[resourceID] = next + return next + } + + private func teardownLocked(_ active: ActiveState) { + active.pollDriver?.cancel() + active.pollDriver = nil + active.leases.removeAll() + active.resources.removeAll() + active.contexts.removeAll() + active.session.invalidate() + } + + private func pollForeignEvents() { + _ = try? executionLane.sync { [self] in + lock.lock() + defer { lock.unlock() } + guard case .active(let active) = state else { return } + active.session.poll() + } + } + + private static func encodeUInt64(_ value: UInt64) -> Data { + Swift.withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private func errorForProtocolViolation() -> Error { + DoryRendererForeignSessionError.invalidResult(operation: "typed-operation-payload") + } +} + +private final class OwnedBacking { + let iovecs: UnsafeMutablePointer + let count: UInt32 + private let mappings: [OwnedMapping] + + init( + regions: [DoryRendererSharedRegionReference], + descriptors: [FileHandle] + ) throws { + guard !regions.isEmpty, + regions.count <= Int(UInt32.max), + descriptors.count == (regions.map(\.descriptorIndex).max().map { Int($0) + 1 } ?? 0) + else { + throw DoryRendererWorkerContractError.invalidSharedRegionCount( + limit: Int(UInt32.max), + actual: regions.count + ) + } + var created = [OwnedMapping]() + created.reserveCapacity(regions.count) + for region in regions { + let index = Int(region.descriptorIndex) + guard descriptors.indices.contains(index) else { + throw DoryRendererWorkerContractError.descriptorCountMismatch( + expected: index + 1, + actual: descriptors.count + ) + } + created.append(try OwnedMapping( + region: region, + source: descriptors[index].fileDescriptor + )) + } + mappings = created + count = UInt32(created.count) + iovecs = .allocate(capacity: created.count) + for (index, mapping) in created.enumerated() { + iovecs.advanced(by: index).initialize(to: iovec( + iov_base: mapping.regionBase, + iov_len: mapping.regionLength + )) + } + } + + deinit { + iovecs.deinitialize(count: Int(count)) + iovecs.deallocate() + } +} + +private final class OwnedMapping { + let mappingBase: UnsafeMutableRawPointer + let mappingLength: Int + let regionBase: UnsafeMutableRawPointer + let regionLength: Int + + init(region: DoryRendererSharedRegionReference, source: Int32) throws { + let descriptor = fcntl(source, F_DUPFD_CLOEXEC, 0) + guard descriptor >= 0 else { throw POSIXError(.EMFILE) } + defer { close(descriptor) } + let pageSize = UInt64(getpagesize()) + let mappingOffset = region.offset - region.offset % pageSize + let delta = region.offset - mappingOffset + let (byteCount, overflow) = delta.addingReportingOverflow(region.length) + guard !overflow, byteCount <= UInt64(Int.max), mappingOffset <= UInt64(Int64.max), + region.length <= UInt64(Int.max) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let protection = region.access == .readOnly ? PROT_READ : PROT_READ | PROT_WRITE + let mapped = mmap( + nil, + Int(byteCount), + protection, + MAP_SHARED, + descriptor, + off_t(mappingOffset) + ) + guard mapped != MAP_FAILED, let mapped else { throw POSIXError(.ENOMEM) } + mappingBase = mapped + mappingLength = Int(byteCount) + regionBase = mapped.advanced(by: Int(delta)) + regionLength = Int(region.length) + } + + deinit { munmap(mappingBase, mappingLength) } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift new file mode 100644 index 00000000..4ad002b2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift @@ -0,0 +1,98 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import DoryRendererWorkerServiceCore +import DoryRendererWorkerVirglBackend +import Foundation +import Metal +import XPC + +private enum DoryRendererWorkerBackendFactory { + static func make() -> any DoryRendererWorkerBackend { + do { + return try DoryRendererWorkerVirglBackend() + } catch { + // A standalone executable, invalid nested-bundle layout, or missing fixed production + // authority must never silently become an in-process or software renderer. + return DoryRendererWorkerFailClosedBackend() + } + } +} + +private final class DoryRendererWorkerXPCAdapter: + NSObject, + DoryRendererWorkerXPCProtocol +{ + private let service = DoryRendererWorkerService( + backend: DoryRendererWorkerBackendFactory.make() + ) + + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) { + reply(service.bootstrap(exactBytes: request)) + } + + func exchange( + _ frame: Data, + descriptors: [FileHandle], + withReply reply: @escaping (Data, [FileHandle], MTLSharedTextureHandle?) -> Void + ) { + let result = service.exchange(exactFrame: frame, descriptors: descriptors) + reply(result.result, result.descriptors, result.sharedTextureHandle) + } +} + +private final class DoryRendererWorkerListenerDelegate: + NSObject, + NSXPCListenerDelegate, + @unchecked Sendable +{ + private let admissionLock = NSLock() + private let adapter = DoryRendererWorkerXPCAdapter() + private var acceptedConnection = false + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection + ) -> Bool { + guard connection.processIdentifier > 1, + connection.effectiveUserIdentifier == geteuid(), + connection.effectiveGroupIdentifier == getegid() else { + return false + } + let claimed = admissionLock.withLock { + guard !acceptedConnection else { return false } + acceptedConnection = true + return true + } + guard claimed else { return false } + // This service owns one renderer generation, foreign-library state, shared mappings, and + // live scanout/fence leases for the complete accepted connection. Keep launchd from + // idle-killing it between bounded command batches; invalidation terminates the process, so + // there is deliberately no reconnect or transaction-end path. + xpc_transaction_begin() + connection.setCodeSigningRequirement( + DoryRendererWorkerIdentity.runnerCodeSigningRequirement + ) + connection.exportedInterface = DoryRendererWorkerXPCInterface.make() + connection.exportedObject = adapter + connection.interruptionHandler = Self.terminate + connection.invalidationHandler = Self.terminate + connection.activate() + return true + } + + private static func terminate() { + Darwin._exit(EXIT_SUCCESS) + } +} + +@main +private enum DoryRendererWorkerMain { + private static let listenerDelegate = DoryRendererWorkerListenerDelegate() + + static func main() { + let listener = NSXPCListener.service() + listener.delegate = listenerDelegate + listener.resume() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c new file mode 100644 index 00000000..2a8ce1c9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c @@ -0,0 +1,2187 @@ +#include "DoryVirglRendererShim.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) +#include +#include +#endif + +enum { + DORY_VIRGL_RENDERER_CALLBACKS_VERSION = 4, + DORY_VIRGL_RENDERER_LOG_LEVEL_ERROR = 3, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_ABSENT = 0, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_PRESENT = 1, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_AMBIGUOUS = 2, + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT = 0, + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT = 1, + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS = 2, + DORY_VIRGL_CREATE_OBJECT_CANDIDATE_COUNT_MAX = 255, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX = 11, + DORY_VIRGL_OBJECT_SURFACE = 8, + DORY_VIRGL_SURFACE_FAILURE_REASON_NONE = 0, + DORY_VIRGL_SURFACE_FAILURE_REASON_MAX = 7, + DORY_VIRGL_PRECURSOR_NONE = 0, + DORY_VIRGL_PRECURSOR_SHADER_COMPILE_FAILED = 1, + DORY_VIRGL_PRECURSOR_TGSI_ASSIGNMENT_FAILED = 2, + DORY_VIRGL_PRECURSOR_GEOMETRY_SHADER_UNSUPPORTED = 3, + DORY_VIRGL_PRECURSOR_TESSELLATION_SHADER_UNSUPPORTED = 4, + DORY_VIRGL_PRECURSOR_COMPUTE_SHADER_UNSUPPORTED = 5, + DORY_VIRGL_PRECURSOR_INVALID_EXPECTED_TOKEN_COUNT = 6, + DORY_VIRGL_PRECURSOR_EXPECTED_LONG_CONTINUATION = 7, + DORY_VIRGL_PRECURSOR_INVALID_CONTINUATION_HANDLE = 8, + DORY_VIRGL_PRECURSOR_CONTINUATION_WITHOUT_ORIGINAL = 9, + DORY_VIRGL_PRECURSOR_MISMATCHED_CONTINUATION = 10, + DORY_VIRGL_PRECURSOR_OVERSIZED_CONTINUATION = 11, +}; + +typedef void (*DoryVirglRendererLogCallback)( + int32_t log_level, + const char *message, + void *user_data +); +typedef void (*DoryVirglRendererFreeDataCallback)(void *user_data); + +/* + * App Sandbox permits POSIX shared-memory names only inside an app group. This short macOS-only + * group is dedicated to the renderer worker and deliberately differs from Dory's data-sharing + * group. virglrenderer's anonymous-file helper reads this fixed integration value when it creates + * timeline and blob SHM; an inherited value must never select a broader group. + */ +static const char dory_renderer_app_sandbox_group[] = + "864H636QW4.dory-renderer"; + +#if defined(DORY_VIRGL_RENDERER_STATIC_LINKED) +extern int32_t virgl_renderer_init( + void *, + int32_t, + DoryVirglRendererCallbacks * +); +extern void virgl_renderer_cleanup(void *); +extern void virgl_renderer_get_cap_set( + uint32_t, + uint32_t *, + uint32_t * +); +extern void virgl_renderer_fill_caps( + uint32_t, + uint32_t, + void * +); +extern int32_t virgl_renderer_context_create_with_flags( + uint32_t, + uint32_t, + uint32_t, + const char * +); +extern void virgl_renderer_context_destroy(uint32_t); +extern void virgl_renderer_ctx_attach_resource( + int32_t, + int32_t +); +extern void virgl_renderer_ctx_detach_resource( + int32_t, + int32_t +); +extern int32_t virgl_renderer_submit_cmd2( + void *, + int32_t, + int32_t, + uint64_t *, + uint32_t +); +extern int32_t virgl_renderer_resource_create_blob( + const DoryVirglRendererBlobCreateArguments * +); +extern int32_t virgl_renderer_resource_create( + DoryVirglRendererResource3DCreateArguments *, + struct iovec *, + uint32_t +); +extern int32_t virgl_renderer_resource_attach_iov( + int32_t, + struct iovec *, + int32_t +); +extern void virgl_renderer_resource_detach_iov( + int32_t, + struct iovec **, + int32_t * +); +extern void virgl_renderer_resource_unref(uint32_t); +extern int32_t virgl_renderer_resource_get_map_info( + uint32_t, + uint32_t * +); +extern int32_t virgl_renderer_resource_export_blob( + uint32_t, + uint32_t *, + int * +); +extern int32_t virgl_renderer_resource_get_info( + int32_t, + DoryVirglRendererResourceInfo * +); +extern int32_t virgl_renderer_create_handle_for_scanout( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + void ** +); +extern void virgl_renderer_release_handle_for_scanout(int32_t, void *); +extern int32_t virgl_renderer_transfer_write_iov( + uint32_t, + uint32_t, + int32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + uint32_t +); +extern int32_t virgl_renderer_transfer_read_iov( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + int32_t +); +extern int32_t virgl_renderer_context_create_fence( + uint32_t, + uint32_t, + uint32_t, + uint64_t +); +extern int32_t virgl_renderer_create_fence(int32_t, uint32_t); +extern int32_t virgl_renderer_get_poll_fd(void); +extern void virgl_renderer_poll(void); +extern void virgl_set_log_callback( + DoryVirglRendererLogCallback, + void *, + DoryVirglRendererFreeDataCallback +); +#endif + +typedef struct DoryVirglRendererFunctions { + int32_t (*initialize)(void *, int32_t, DoryVirglRendererCallbacks *); + void (*cleanup)(void *); + void (*get_cap_set)(uint32_t, uint32_t *, uint32_t *); + void (*fill_caps)(uint32_t, uint32_t, void *); + int32_t (*context_create)(uint32_t, uint32_t, uint32_t, const char *); + void (*context_destroy)(uint32_t); + void (*context_attach_resource)(int32_t, int32_t); + void (*context_detach_resource)(int32_t, int32_t); + int32_t (*submit_cmd2)(void *, int32_t, int32_t, uint64_t *, uint32_t); + int32_t (*blob_create)(const DoryVirglRendererBlobCreateArguments *); + int32_t (*resource_create)( + DoryVirglRendererResource3DCreateArguments *, + struct iovec *, + uint32_t + ); + int32_t (*resource_attach_iov)(int32_t, struct iovec *, int32_t); + void (*resource_detach_iov)(int32_t, struct iovec **, int32_t *); + void (*resource_unref)(uint32_t); + int32_t (*resource_get_map_info)(uint32_t, uint32_t *); + int32_t (*resource_export_blob)(uint32_t, uint32_t *, int *); + int32_t (*resource_get_info)(int32_t, DoryVirglRendererResourceInfo *); + int32_t (*create_handle_for_scanout)( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + void ** + ); + void (*release_handle_for_scanout)(int32_t, void *); + int32_t (*transfer_write_iov)( + uint32_t, + uint32_t, + int32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + uint32_t + ); + int32_t (*transfer_read_iov)( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + int32_t + ); + int32_t (*context_create_fence)(uint32_t, uint32_t, uint32_t, uint64_t); + int32_t (*create_fence)(int32_t, uint32_t); + int32_t (*get_poll_fd)(void); + void (*poll)(void); + void (*set_log_callback)( + DoryVirglRendererLogCallback, + void *, + DoryVirglRendererFreeDataCallback + ); +} DoryVirglRendererFunctions; + +typedef struct DoryVirglRendererFenceCompletion { + uint32_t context_id; + uint32_t ring_index; + uint64_t fence_id; + /* Nonzero only while a global fence is waiting for virglrenderer's 32-bit callback. */ + uint32_t renderer_fence_id; + int read_descriptor; + int write_descriptor; + struct DoryVirglRendererFenceCompletion *next; +} DoryVirglRendererFenceCompletion; + +struct DoryVirglRendererSession { + DoryVirglRendererFunctions functions; + DoryVirglRendererCallbacks callbacks; + pthread_mutex_t fence_lock; + DoryVirglRendererFenceCompletion *fence_head; + DoryVirglRendererFenceCompletion *fence_tail; + uint32_t next_global_renderer_fence_id; + pthread_mutex_t submit_diagnostic_lock; + uint32_t active_submit_context_id; + DoryVirglRendererSubmitDiagnostic submit_diagnostic; + bool fence_lock_initialized; + bool submit_diagnostic_lock_initialized; + bool submit_in_progress; + bool log_callback_installed; + bool renderer_initialized; + bool owns_process_slot; +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + pthread_mutex_t angle_context_lock; + bool angle_context_lock_initialized; + EGLDisplay angle_display; + EGLConfig angle_config; + EGLContext angle_share_context; +#endif +}; + +enum { DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT = 64 }; + +/* Pinned `vrend_debug.c` command_names table. The callback stores only the matched ordinal. */ +static const char *const dory_virgl_context_command_names[ + DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT +] = { + "NOP", + "CREATE_OBJECT", + "BIND_OBJECT", + "DESTROY_OBJECT", + "SET_VIEWPORT_STATE", + "SET_FRAMEBUFFER_STATE", + "SET_VERTEX_BUFFERS", + "CLEAR", + "DRAW_VBO", + "RESOURCE_INLINE_WRITE", + "SET_SAMPLER_VIEWS", + "SET_INDEX_BUFFER", + "SET_CONSTANT_BUFFER", + "SET_STENCIL_REF", + "SET_BLEND_COLOR", + "SET_SCISSOR_STATE", + "BLIT", + "RESOURCE_COPY_REGION", + "BIND_SAMPLER_STATES", + "BEGIN_QUERY", + "END_QUERY", + "GET_QUERY_RESULT", + "SET_POLYGON_STIPPLE", + "SET_CLIP_STATE", + "SET_SAMPLE_MASK", + "SET_STREAMOUT_TARGETS", + "SET_RENDER_CONDITION", + "SET_UNIFORM_BUFFER", + "SET_SUB_CTX", + "CREATE_SUB_CTX", + "DESTROY_SUB_CTX", + "BIND_SHADER", + "SET_TESS_STATE", + "SET_MIN_SAMPLES", + "SET_SHADER_BUFFERS", + "SET_SHADER_IMAGES", + "MEMORY_BARRIER", + "LAUNCH_GRID", + "SET_FRAMEBUFFER_STATE_NO_ATTACH", + "TEXTURE_BARRIER", + "SET_ATOMIC_BUFFERS", + "SET_DEBUG_FLAGS", + "GET_QUERY_RESULT_QBO", + "TRANSFER3D", + "END_TRANSFERS", + "COPY_TRANSFER3D", + "SET_TWEAKS", + "CLEAR_TEXTURE", + "PIPE_RESOURCE_CREATE", + "PIPE_RESOURCE_SET_TYPE", + "GET_MEMORY_INFO", + "SEND_STRING_MARKER", + "LINK_SHADER", + "CREATE_VIDEO_CODEC", + "DESTROY_VIDEO_CODEC", + "CREATE_VIDEO_BUFFER", + "DESTROY_VIDEO_BUFFER", + "BEGIN_FRAME", + "DECODE_MACROBLOCK", + "DECODE_BITSTREAM", + "ENCODE_BITSTREAM", + "END_FRAME", + "CLEAR_SURFACE", + "GET_PIPE_RESOURCE_LAYOUT", +}; + +static bool dory_parse_uint32(const char **cursor, uint32_t *value) +{ + const char *current = *cursor; + if (*current < '0' || *current > '9') + return false; + uint64_t parsed = 0; + do { + parsed = parsed * 10U + (uint64_t)(*current - '0'); + if (parsed > UINT32_MAX) + return false; + current++; + } while (*current >= '0' && *current <= '9'); + *cursor = current; + *value = (uint32_t)parsed; + return true; +} + +static bool dory_parse_canonical_uint32(const char **cursor, uint32_t *value) +{ + const char *start = *cursor; + if (!dory_parse_uint32(cursor, value)) + return false; + return start[0] != '0' || *cursor == start + 1; +} + +static bool dory_parse_int32(const char **cursor, int32_t *value) +{ + const char *current = *cursor; + const bool negative = *current == '-'; + if (negative) + current++; + if (*current < '0' || *current > '9') + return false; + uint64_t parsed = 0; + const uint64_t limit = negative ? (uint64_t)INT32_MAX + 1U : (uint64_t)INT32_MAX; + do { + parsed = parsed * 10U + (uint64_t)(*current - '0'); + if (parsed > limit) + return false; + current++; + } while (*current >= '0' && *current <= '9'); + *cursor = current; + *value = negative + ? (parsed == (uint64_t)INT32_MAX + 1U ? INT32_MIN : -(int32_t)parsed) + : (int32_t)parsed; + return true; +} + +static bool dory_parse_canonical_int32(const char **cursor, int32_t *value) +{ + const char *start = *cursor; + if (!dory_parse_int32(cursor, value)) + return false; + const char *digits = start[0] == '-' ? start + 1 : start; + if (digits[0] == '0' && *cursor != digits + 1) + return false; + return start[0] != '-' || *value != 0; +} + +static bool dory_match_context_command( + const char *name, + size_t length, + uint32_t *command_id +) +{ + for (uint32_t index = 0; + index < DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT; + index++) { + const char *candidate = dory_virgl_context_command_names[index]; + if (strlen(candidate) == length && memcmp(candidate, name, length) == 0) { + *command_id = index; + return true; + } + } + return false; +} + +int32_t DoryVirglRendererClassifySubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (message == NULL || expected_context_id == 0 || diagnostic == NULL) + return -EINVAL; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + + /* No general renderer log is accepted. Bound the read before matching the complete grammar. */ + const size_t message_length = strnlen(message, 161); + if (message_length == 161) + return 0; + static const char prefix[] = "context "; + static const char dispatch[] = " failed to dispatch "; + const char *cursor = message; + if (strncmp(cursor, prefix, sizeof(prefix) - 1U) != 0) + return 0; + cursor += sizeof(prefix) - 1U; + + uint32_t context_id = 0; + if (!dory_parse_uint32(&cursor, &context_id) || + context_id != expected_context_id || + strncmp(cursor, dispatch, sizeof(dispatch) - 1U) != 0) + return 0; + cursor += sizeof(dispatch) - 1U; + + const char *command_start = cursor; + while ((*cursor >= 'A' && *cursor <= 'Z') || + (*cursor >= '0' && *cursor <= '9') || + *cursor == '_') + cursor++; + const size_t command_length = (size_t)(cursor - command_start); + if (command_length == 0 || cursor[0] != ':' || cursor[1] != ' ') + return 0; + cursor += 2; + + int32_t status = 0; + if (!dory_parse_int32(&cursor, &status) || cursor[0] != '\n' || cursor[1] != '\0') + return 0; + uint32_t command_id = 0; + if (!dory_match_context_command(command_start, command_length, &command_id)) + return 0; + + diagnostic->valid = 1; + diagnostic->context_id = context_id; + diagnostic->command_id = command_id; + diagnostic->status = status; + return 1; +} + +int32_t DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (message == NULL || expected_context_id == 0 || diagnostic == NULL) + return -EINVAL; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + + static const char prefix[] = "vrend-dispatch-error context="; + if (strncmp(message, prefix, sizeof(prefix) - 1U) != 0) + return 0; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + + /* The exact generated line is shorter than 161 bytes even at all integer extrema. */ + if (strnlen(message, 161) == 161) + return -EINVAL; + const char *cursor = message + sizeof(prefix) - 1U; + static const char command_literal[] = " command="; + static const char offset_literal[] = " dword-offset="; + static const char ordinal_literal[] = " command-ordinal="; + static const char status_literal[] = " status="; + static const char surface_reason_literal[] = " surface-reason="; + + uint32_t context_id = 0; + uint32_t command_id = 0; + uint32_t dword_offset = 0; + uint32_t command_ordinal = 0; + uint32_t surface_failure_reason = 0; + int32_t status = 0; + if (!dory_parse_canonical_uint32(&cursor, &context_id) || + context_id != expected_context_id || + strncmp(cursor, command_literal, sizeof(command_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(command_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &command_id) || + command_id >= DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT || + strncmp(cursor, offset_literal, sizeof(offset_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(offset_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &dword_offset) || + strncmp(cursor, ordinal_literal, sizeof(ordinal_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(ordinal_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &command_ordinal) || + strncmp(cursor, status_literal, sizeof(status_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(status_literal) - 1U; + if (!dory_parse_canonical_int32(&cursor, &status) || + strncmp( + cursor, + surface_reason_literal, + sizeof(surface_reason_literal) - 1U + ) != 0) + return -EINVAL; + cursor += sizeof(surface_reason_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &surface_failure_reason) || + surface_failure_reason > DORY_VIRGL_SURFACE_FAILURE_REASON_MAX || + cursor[0] != '\n' || cursor[1] != '\0') + return -EINVAL; + + diagnostic->valid = 1; + diagnostic->context_id = context_id; + diagnostic->command_id = command_id; + diagnostic->status = status; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT; + diagnostic->failed_command_dword_offset = dword_offset; + diagnostic->failed_command_ordinal = command_ordinal; + diagnostic->surface_failure_reason = surface_failure_reason; + return 1; +} + +int32_t DoryVirglRendererCorrelateCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (command_bytes == NULL || dword_count == 0 || diagnostic == NULL) + return -EINVAL; + diagnostic->create_object_subtype_disposition = + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_ABSENT; + diagnostic->create_object_subtype = 0; + diagnostic->create_object_candidate_count = 0; + diagnostic->create_object_subtype_mask = 0; + + const uint32_t location_disposition = + diagnostic->failed_command_location_disposition; + const uint32_t expected_offset = diagnostic->failed_command_dword_offset; + const uint32_t expected_ordinal = diagnostic->failed_command_ordinal; + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) + diagnostic->surface_failure_reason = DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + if (location_disposition != DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT && + location_disposition != DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + + uint64_t offset = 0; + uint32_t header_ordinal = 0; + uint32_t match_count = 0; + uint32_t subtype_mask = 0; + bool invalid_subtype = false; + while (offset < dword_count) { + uint32_t header = 0; + memcpy( + &header, + (const uint8_t *)command_bytes + offset * sizeof(uint32_t), + sizeof(header) + ); + const uint32_t command_id = header & 0xffU; + const uint32_t object_type = (header >> 8U) & 0xffU; + const uint32_t payload_dwords = header >> 16U; + const uint64_t next = offset + (uint64_t)payload_dwords + 1U; + if (command_id >= DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT || + next > dword_count) { + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + } + return 0; + } + + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + if (offset == expected_offset || header_ordinal == expected_ordinal) { + if (offset != expected_offset || header_ordinal != expected_ordinal || + diagnostic->valid != 1 || command_id != diagnostic->command_id || + (command_id == 1U && + object_type > DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX)) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + if (command_id == 1U) { + diagnostic->create_object_subtype_disposition = + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_PRESENT; + diagnostic->create_object_subtype = object_type; + diagnostic->create_object_candidate_count = 1; + diagnostic->create_object_subtype_mask = 1U << object_type; + } + if (command_id != 1U || + object_type != DORY_VIRGL_OBJECT_SURFACE || + diagnostic->status != EINVAL || + diagnostic->surface_failure_reason > + DORY_VIRGL_SURFACE_FAILURE_REASON_MAX) + diagnostic->surface_failure_reason = + DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + return 0; + } + if (offset > expected_offset || header_ordinal > expected_ordinal) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + } + + if (command_id == 1U) { + if (match_count < DORY_VIRGL_CREATE_OBJECT_CANDIDATE_COUNT_MAX) + match_count++; + if (object_type <= DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX) { + subtype_mask |= 1U << object_type; + } else { + invalid_subtype = true; + } + } + offset = next; + header_ordinal++; + } + + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + diagnostic->surface_failure_reason = DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + diagnostic->create_object_candidate_count = match_count; + diagnostic->create_object_subtype_mask = invalid_subtype ? 0 : subtype_mask; + return 0; +} + +int32_t DoryVirglRendererClassifyCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (diagnostic == NULL) + return -EINVAL; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT; + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + return DoryVirglRendererCorrelateCreateObjectSubtype( + command_bytes, + dword_count, + diagnostic + ); +} + +uint32_t DoryVirglRendererClassifySubmitPrecursorMessage(const char *message) +{ + if (message == NULL) + return DORY_VIRGL_PRECURSOR_NONE; +#define DORY_PREFIX_CATEGORY(prefix, category) \ + if (strncmp(message, prefix, sizeof(prefix) - 1U) == 0) \ + return category + DORY_PREFIX_CATEGORY( + "Shader failed to compile\n", + DORY_VIRGL_PRECURSOR_SHADER_COMPILE_FAILED + ); + DORY_PREFIX_CATEGORY( + "Error assigning TGSI\n", + DORY_VIRGL_PRECURSOR_TGSI_ASSIGNMENT_FAILED + ); + DORY_PREFIX_CATEGORY( + "Geometry shader not supported\n", + DORY_VIRGL_PRECURSOR_GEOMETRY_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Tesselation shaders not supported\n", + DORY_VIRGL_PRECURSOR_TESSELLATION_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Compute shaders not supported\n", + DORY_VIRGL_PRECURSOR_COMPUTE_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Invalid expected token count\n", + DORY_VIRGL_PRECURSOR_INVALID_EXPECTED_TOKEN_COUNT + ); + DORY_PREFIX_CATEGORY( + "Expected long shader continuation, got new shader\n", + DORY_VIRGL_PRECURSOR_EXPECTED_LONG_CONTINUATION + ); + DORY_PREFIX_CATEGORY( + "Long shader continuation handle invalid\n", + DORY_VIRGL_PRECURSOR_INVALID_CONTINUATION_HANDLE + ); + DORY_PREFIX_CATEGORY( + "Got continuation without original long shader ", + DORY_VIRGL_PRECURSOR_CONTINUATION_WITHOUT_ORIGINAL + ); + DORY_PREFIX_CATEGORY( + "Got mismatched shader continuation ", + DORY_VIRGL_PRECURSOR_MISMATCHED_CONTINUATION + ); + DORY_PREFIX_CATEGORY( + "Got too large shader continuation ", + DORY_VIRGL_PRECURSOR_OVERSIZED_CONTINUATION + ); +#undef DORY_PREFIX_CATEGORY + return DORY_VIRGL_PRECURSOR_NONE; +} + +static void dory_renderer_log_callback( + int32_t log_level, + const char *message, + void *user_data +) +{ + DoryVirglRendererSession *session = user_data; + if (log_level != DORY_VIRGL_RENDERER_LOG_LEVEL_ERROR || + session == NULL || !session->submit_diagnostic_lock_initialized) + return; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + const bool active = session->submit_in_progress; + const uint32_t expected_context_id = session->active_submit_context_id; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + if (!active) + return; + + const uint32_t precursor = + DoryVirglRendererClassifySubmitPrecursorMessage(message); + DoryVirglRendererSubmitDiagnostic classified = {0}; + const int32_t exact_classification = + DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + message, + expected_context_id, + &classified + ); + const int32_t legacy_classification = exact_classification == 0 + ? DoryVirglRendererClassifySubmitDiagnosticMessage( + message, + expected_context_id, + &classified + ) + : 0; + if (precursor == DORY_VIRGL_PRECURSOR_NONE && + exact_classification == 0 && legacy_classification != 1) + return; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + precursor != DORY_VIRGL_PRECURSOR_NONE && + session->submit_diagnostic.precursor_category == DORY_VIRGL_PRECURSOR_NONE) + session->submit_diagnostic.precursor_category = precursor; + if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + exact_classification != 0 && + session->submit_diagnostic.failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + classified.precursor_category = session->submit_diagnostic.precursor_category; + session->submit_diagnostic = classified; + } else if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + legacy_classification == 1 && + session->submit_diagnostic.valid == 0 && + session->submit_diagnostic.failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + classified.precursor_category = session->submit_diagnostic.precursor_category; + session->submit_diagnostic = classified; + } + pthread_mutex_unlock(&session->submit_diagnostic_lock); +} + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) +typedef struct DoryVirglRendererANGLEContext { + EGLDisplay display; + EGLContext context; + EGLSurface surface; +} DoryVirglRendererANGLEContext; + +static int32_t dory_initialize_angle(DoryVirglRendererSession *session) +{ + const PFNEGLGETPLATFORMDISPLAYEXTPROC get_platform_display = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + if (get_platform_display == NULL) + return -ENOTSUP; + + const EGLint display_attributes[] = { + EGL_PLATFORM_ANGLE_TYPE_ANGLE, + EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, + EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE, + EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE, + EGL_NONE, + }; + session->angle_display = get_platform_display( + EGL_PLATFORM_ANGLE_ANGLE, + (void *)(uintptr_t)EGL_DEFAULT_DISPLAY, + display_attributes + ); + if (session->angle_display == EGL_NO_DISPLAY) + return -ENODEV; + + EGLint major = 0; + EGLint minor = 0; + if (eglInitialize(session->angle_display, &major, &minor) != EGL_TRUE) + return -EIO; + if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) + return -EIO; + + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE, + }; + EGLint config_count = 0; + if (eglChooseConfig( + session->angle_display, + config_attributes, + &session->angle_config, + 1, + &config_count + ) != EGL_TRUE || config_count != 1) + return -ENOTSUP; + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + session->angle_share_context = eglCreateContext( + session->angle_display, + session->angle_config, + EGL_NO_CONTEXT, + context_attributes + ); + if (session->angle_share_context == EGL_NO_CONTEXT) + return -EIO; + return 0; +} + +static void dory_terminate_angle(DoryVirglRendererSession *session) +{ + if (session->angle_display != EGL_NO_DISPLAY) { + (void)eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ); + if (session->angle_share_context != EGL_NO_CONTEXT) { + (void)eglDestroyContext( + session->angle_display, + session->angle_share_context + ); + session->angle_share_context = EGL_NO_CONTEXT; + } + (void)eglTerminate(session->angle_display); + session->angle_display = EGL_NO_DISPLAY; + session->angle_config = NULL; + } + if (session->angle_context_lock_initialized) { + pthread_mutex_destroy(&session->angle_context_lock); + session->angle_context_lock_initialized = false; + } +} + +static DoryVirglRendererGLContext dory_create_gl_context( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContextParameters *parameters +) +{ + (void)scanout_index; + DoryVirglRendererSession *session = cookie; + if (session == NULL || parameters == NULL || + session->angle_display == EGL_NO_DISPLAY) + return NULL; + + DoryVirglRendererANGLEContext *owned = calloc(1, sizeof(*owned)); + if (owned == NULL) + return NULL; + + pthread_mutex_lock(&session->angle_context_lock); + /* + * Keep one process-lifetime root instead of making the first vrend context the share-group + * owner. Virgl may destroy and recreate its primary context while sync/blit contexts remain; + * every callback context therefore shares with this root regardless of creation order. + */ + const EGLContext share = session->angle_share_context; + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + owned->context = eglCreateContext( + session->angle_display, + session->angle_config, + share, + context_attributes + ); + const EGLint surface_attributes[] = { + EGL_WIDTH, 1, + EGL_HEIGHT, 1, + EGL_NONE, + }; + owned->surface = owned->context == EGL_NO_CONTEXT + ? EGL_NO_SURFACE + : eglCreatePbufferSurface( + session->angle_display, + session->angle_config, + surface_attributes + ); + pthread_mutex_unlock(&session->angle_context_lock); + + if (owned->context == EGL_NO_CONTEXT || owned->surface == EGL_NO_SURFACE) { + if (owned->surface != EGL_NO_SURFACE) + (void)eglDestroySurface(session->angle_display, owned->surface); + if (owned->context != EGL_NO_CONTEXT) + (void)eglDestroyContext(session->angle_display, owned->context); + free(owned); + return NULL; + } + owned->display = session->angle_display; + return owned; +} + +static void dory_destroy_gl_context(void *cookie, DoryVirglRendererGLContext context) +{ + DoryVirglRendererSession *session = cookie; + DoryVirglRendererANGLEContext *owned = context; + if (session == NULL || owned == NULL) + return; + pthread_mutex_lock(&session->angle_context_lock); + (void)eglDestroySurface(owned->display, owned->surface); + (void)eglDestroyContext(owned->display, owned->context); + pthread_mutex_unlock(&session->angle_context_lock); + free(owned); +} + +static int32_t dory_make_current( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContext context +) +{ + (void)scanout_index; + DoryVirglRendererSession *session = cookie; + if (session == NULL || session->angle_display == EGL_NO_DISPLAY) + return -EINVAL; + if (context == NULL) { + return eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ) == EGL_TRUE ? 0 : -EIO; + } + DoryVirglRendererANGLEContext *owned = context; + return eglMakeCurrent( + owned->display, + owned->surface, + owned->surface, + owned->context + ) == EGL_TRUE ? 0 : -EIO; +} + +static void *dory_get_egl_display(void *cookie) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || session->angle_display == EGL_NO_DISPLAY) + return NULL; + return session->angle_display; +} + +static GLuint dory_compile_self_test_shader(GLenum type, const char *source) +{ + GLuint shader = glCreateShader(type); + if (shader == 0) + return 0; + glShaderSource(shader, 1, &source, NULL); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled != GL_TRUE) { + glDeleteShader(shader); + return 0; + } + return shader; +} + +/* + * This is deliberately an execution test, not a string-only ANGLE probe. The historical Dory + * failure initialized an OpenGL context successfully but then rejected GNOME's first uniform-block + * shader. A production worker therefore proves the exact path GNOME needs before it may return a + * VirGL2 capset: Metal ANGLE, GLES 3, UBO-backed shader compilation/link, instanced drawing, FBO + * rendering, GPU completion, and deterministic readback. + */ +static int32_t dory_run_angle_execution_self_test(DoryVirglRendererSession *session) +{ + const EGLint surface_attributes[] = { + EGL_WIDTH, 4, + EGL_HEIGHT, 4, + EGL_NONE, + }; + EGLSurface surface = eglCreatePbufferSurface( + session->angle_display, + session->angle_config, + surface_attributes + ); + if (surface == EGL_NO_SURFACE) + return -EIO; + + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + EGLContext context = eglCreateContext( + session->angle_display, + session->angle_config, + session->angle_share_context, + context_attributes + ); + if (context == EGL_NO_CONTEXT) { + (void)eglDestroySurface(session->angle_display, surface); + return -EIO; + } + + int32_t result = -EIO; + GLuint vertex_shader = 0; + GLuint fragment_shader = 0; + GLuint program = 0; + GLuint uniform_buffer = 0; + GLuint texture = 0; + GLuint framebuffer = 0; + GLuint vertex_array = 0; + GLsync execution_fence = NULL; + if (eglMakeCurrent( + session->angle_display, + surface, + surface, + context + ) != EGL_TRUE) + goto cleanup; + + GLint major = 0; + GLint minor = 0; + GLint uniform_bindings = 0; + GLint vertex_attributes = 0; + GLint draw_buffers = 0; + glGetIntegerv(GL_MAJOR_VERSION, &major); + glGetIntegerv(GL_MINOR_VERSION, &minor); + glGetIntegerv(GL_MAX_UNIFORM_BUFFER_BINDINGS, &uniform_bindings); + glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &vertex_attributes); + glGetIntegerv(GL_MAX_DRAW_BUFFERS, &draw_buffers); + const char *renderer = (const char *)glGetString(GL_RENDERER); + if (major < 3 || uniform_bindings < 1 || vertex_attributes < 8 || draw_buffers < 1 || + renderer == NULL || strstr(renderer, "ANGLE") == NULL || + strstr(renderer, "Metal") == NULL || + glGetError() != GL_NO_ERROR) { + result = -ENOTSUP; + goto cleanup; + } + (void)minor; + + static const char vertex_source[] = + "#version 300 es\n" + "const vec2 p[3] = vec2[3](vec2(-1.0,-1.0), vec2(3.0,-1.0), " + "vec2(-1.0,3.0));\n" + "void main() { gl_Position = vec4(p[gl_VertexID], 0.0, 1.0); }\n"; + static const char fragment_source[] = + "#version 300 es\n" + "precision highp float;\n" + "layout(std140) uniform DoryColorBlock { vec4 color; };\n" + "out vec4 doryColor;\n" + "void main() { doryColor = color; }\n"; + vertex_shader = dory_compile_self_test_shader(GL_VERTEX_SHADER, vertex_source); + fragment_shader = dory_compile_self_test_shader(GL_FRAGMENT_SHADER, fragment_source); + if (vertex_shader == 0 || fragment_shader == 0) + goto cleanup; + + program = glCreateProgram(); + if (program == 0) + goto cleanup; + glAttachShader(program, vertex_shader); + glAttachShader(program, fragment_shader); + glLinkProgram(program); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked != GL_TRUE) + goto cleanup; + + const GLuint block_index = glGetUniformBlockIndex(program, "DoryColorBlock"); + if (block_index == GL_INVALID_INDEX) + goto cleanup; + glUniformBlockBinding(program, block_index, 0); + static const GLfloat expected_color[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + glGenBuffers(1, &uniform_buffer); + glBindBuffer(GL_UNIFORM_BUFFER, uniform_buffer); + glBufferData(GL_UNIFORM_BUFFER, sizeof(expected_color), expected_color, GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniform_buffer); + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4); + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D( + GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, + texture, + 0 + ); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) + goto cleanup; + + glViewport(0, 0, 4, 4); + glGenVertexArrays(1, &vertex_array); + glBindVertexArray(vertex_array); + glUseProgram(program); + glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 1); + execution_fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + if (execution_fence == NULL) + goto cleanup; + glFlush(); + const GLenum wait_result = glClientWaitSync( + execution_fence, + GL_SYNC_FLUSH_COMMANDS_BIT, + 1000000000ULL + ); + if (wait_result != GL_ALREADY_SIGNALED && wait_result != GL_CONDITION_SATISFIED) + goto cleanup; + glFinish(); + GLubyte pixel[4] = {0, 0, 0, 0}; + glReadPixels(2, 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + if (glGetError() != GL_NO_ERROR) + goto cleanup; + const int expected[4] = {64, 128, 191, 255}; + for (size_t component = 0; component < 4; component++) { + const int difference = (int)pixel[component] - expected[component]; + if (difference < -2 || difference > 2) + goto cleanup; + } + result = 0; + +cleanup: + if (execution_fence != NULL) + glDeleteSync(execution_fence); + if (vertex_array != 0) + glDeleteVertexArrays(1, &vertex_array); + if (framebuffer != 0) + glDeleteFramebuffers(1, &framebuffer); + if (texture != 0) + glDeleteTextures(1, &texture); + if (uniform_buffer != 0) + glDeleteBuffers(1, &uniform_buffer); + if (program != 0) + glDeleteProgram(program); + if (fragment_shader != 0) + glDeleteShader(fragment_shader); + if (vertex_shader != 0) + glDeleteShader(vertex_shader); + (void)eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ); + (void)eglDestroyContext(session->angle_display, context); + (void)eglDestroySurface(session->angle_display, surface); + return result; +} +#endif + +static pthread_mutex_t dory_session_lock = PTHREAD_MUTEX_INITIALIZER; +static bool dory_session_active; + +static void dory_write_fence(void *cookie, uint32_t fence); + +static int32_t dory_make_completion_pipe(int descriptors[2]) +{ + descriptors[0] = -1; + descriptors[1] = -1; + if (pipe(descriptors) != 0) + return errno ? -errno : -EMFILE; + + for (size_t index = 0; index < 2; index++) { + const int flags = fcntl(descriptors[index], F_GETFD); + if (flags < 0 || fcntl(descriptors[index], F_SETFD, flags | FD_CLOEXEC) != 0) { + const int saved_errno = errno; + close(descriptors[0]); + close(descriptors[1]); + descriptors[0] = -1; + descriptors[1] = -1; + return saved_errno ? -saved_errno : -EMFILE; + } + } + return 0; +} + +static void dory_remove_fence_completion_locked( + DoryVirglRendererSession *session, + DoryVirglRendererFenceCompletion *target +) +{ + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL && completion != target) { + previous = completion; + completion = completion->next; + } + if (completion == NULL) + return; + if (previous == NULL) + session->fence_head = completion->next; + else + previous->next = completion->next; + if (session->fence_tail == completion) + session->fence_tail = previous; + if (completion->read_descriptor >= 0) + close(completion->read_descriptor); + if (completion->write_descriptor >= 0) + close(completion->write_descriptor); + free(completion); +} + +static int32_t dory_allocate_global_renderer_fence_id_locked( + DoryVirglRendererSession *session, + uint32_t *out_renderer_fence_id +) +{ + if (out_renderer_fence_id == NULL) + return -EINVAL; + + size_t live_global_fences = 0; + for (DoryVirglRendererFenceCompletion *completion = session->fence_head; + completion != NULL; + completion = completion->next) { + if (completion->context_id == 0 && completion->renderer_fence_id != 0) + live_global_fences++; + } + if (live_global_fences >= UINT32_MAX - 1U) + return -ENOSPC; + + uint32_t candidate = session->next_global_renderer_fence_id; + if (candidate == 0) + candidate = 1; + + /* Among N live ids, at least one of the next N+1 nonzero candidates is free. This keeps the + * allocator bounded even after wrap and never aliases an outstanding renderer callback. */ + for (size_t attempt = 0; attempt <= live_global_fences; attempt++) { + bool collision = false; + for (DoryVirglRendererFenceCompletion *completion = session->fence_head; + completion != NULL; + completion = completion->next) { + if (completion->context_id == 0 && + completion->renderer_fence_id == candidate) { + collision = true; + break; + } + } + if (!collision) { + *out_renderer_fence_id = candidate; + session->next_global_renderer_fence_id = candidate + 1U; + if (session->next_global_renderer_fence_id == 0) + session->next_global_renderer_fence_id = 1; + return 0; + } + candidate++; + if (candidate == 0) + candidate = 1; + } + return -ENOSPC; +} + +static void dory_write_fence(void *cookie, uint32_t renderer_fence_id) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || !session->fence_lock_initialized || renderer_fence_id == 0) + return; + + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *target = session->fence_head; + while (target != NULL && + (target->context_id != 0 || + target->renderer_fence_id != renderer_fence_id)) { + target = target->next; + } + if (target == NULL) { + pthread_mutex_unlock(&session->fence_lock); + return; + } + + /* VirGL's ctx0 timeline may coalesce retirement. Signal every earlier admitted global fence + * through the callback target by registration order; guest ids are deliberately not compared + * numerically because they are independent 64-bit protocol identities and may wrap. */ + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == 0 && completion->renderer_fence_id != 0) { + if (completion->write_descriptor >= 0) { + close(completion->write_descriptor); + completion->write_descriptor = -1; + } + /* The callback mapping is no longer outstanding, even if export races behind it. */ + completion->renderer_fence_id = 0; + } + + const bool reached_target = completion == target; + if (completion->read_descriptor < 0 && completion->write_descriptor < 0) { + if (previous == NULL) + session->fence_head = next; + else + previous->next = next; + if (session->fence_tail == completion) + session->fence_tail = previous; + free(completion); + } else { + previous = completion; + } + if (reached_target) + break; + completion = next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_write_context_fence( + void *cookie, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id +) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || !session->fence_lock_initialized) + return; + + pthread_mutex_lock(&session->fence_lock); + + /* + * An inner render-server fence may coalesce timeline notifications. Confirm this callback is + * one we admitted, then signal every completion registered before it on the same context/ring. + * Registration order, rather than numeric fence ordering, remains correct across id wraparound. + */ + DoryVirglRendererFenceCompletion *target = session->fence_head; + while (target != NULL && + (target->context_id != context_id || + target->ring_index != ring_index || + target->fence_id != fence_id)) { + target = target->next; + } + if (target == NULL) { + pthread_mutex_unlock(&session->fence_lock); + return; + } + + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == context_id && + completion->ring_index == ring_index && + completion->write_descriptor >= 0) { + /* EOF is a one-shot pollable completion edge and cannot block the renderer callback. */ + close(completion->write_descriptor); + completion->write_descriptor = -1; + } + + const bool reached_target = completion == target; + if (completion->read_descriptor < 0 && completion->write_descriptor < 0) { + if (previous == NULL) + session->fence_head = next; + else + previous->next = next; + if (session->fence_tail == completion) + session->fence_tail = previous; + free(completion); + } else { + previous = completion; + } + if (reached_target) + break; + completion = next; + } + + pthread_mutex_unlock(&session->fence_lock); +} + +static int32_t dory_register_fence_completion( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id, + bool allocate_global_renderer_id, + uint32_t *out_renderer_fence_id +) +{ + DoryVirglRendererFenceCompletion *completion = calloc(1, sizeof(*completion)); + if (completion == NULL) + return -ENOMEM; + + int descriptors[2]; + int32_t result = dory_make_completion_pipe(descriptors); + if (result != 0) { + free(completion); + return result; + } + completion->context_id = context_id; + completion->ring_index = ring_index; + completion->fence_id = fence_id; + completion->read_descriptor = descriptors[0]; + completion->write_descriptor = descriptors[1]; + + pthread_mutex_lock(&session->fence_lock); + for (DoryVirglRendererFenceCompletion *existing = session->fence_head; + existing != NULL; + existing = existing->next) { + /* The public one-shot handoff consumes by fence_id, so live ids are process-unique. */ + if (existing->fence_id == fence_id) { + pthread_mutex_unlock(&session->fence_lock); + close(completion->read_descriptor); + close(completion->write_descriptor); + free(completion); + return -EEXIST; + } + } + if (allocate_global_renderer_id) { + result = dory_allocate_global_renderer_fence_id_locked( + session, + &completion->renderer_fence_id + ); + if (result != 0) { + pthread_mutex_unlock(&session->fence_lock); + close(completion->read_descriptor); + close(completion->write_descriptor); + free(completion); + return result; + } + *out_renderer_fence_id = completion->renderer_fence_id; + } + if (session->fence_tail == NULL) + session->fence_head = completion; + else + session->fence_tail->next = completion; + session->fence_tail = completion; + pthread_mutex_unlock(&session->fence_lock); + return 0; +} + +static void dory_cancel_fence_completion( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id +) +{ + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + if (completion->context_id == context_id && + completion->ring_index == ring_index && + completion->fence_id == fence_id) { + dory_remove_fence_completion_locked(session, completion); + break; + } + completion = completion->next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_remove_context_fence_completions( + DoryVirglRendererSession *session, + uint32_t context_id +) +{ + if (!session->fence_lock_initialized) + return; + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == context_id) + dory_remove_fence_completion_locked(session, completion); + completion = next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_destroy_fence_registry(DoryVirglRendererSession *session) +{ + if (!session->fence_lock_initialized) + return; + pthread_mutex_lock(&session->fence_lock); + while (session->fence_head != NULL) + dory_remove_fence_completion_locked(session, session->fence_head); + pthread_mutex_unlock(&session->fence_lock); + pthread_mutex_destroy(&session->fence_lock); + session->fence_lock_initialized = false; +} + +static int32_t dory_claim_process_slot(DoryVirglRendererSession *session) +{ + int32_t result = 0; + pthread_mutex_lock(&dory_session_lock); + if (dory_session_active) { + result = -EBUSY; + } else { + dory_session_active = true; + session->owns_process_slot = true; + } + pthread_mutex_unlock(&dory_session_lock); + return result; +} + +static void dory_release_process_slot(DoryVirglRendererSession *session) +{ + if (!session->owns_process_slot) + return; + pthread_mutex_lock(&dory_session_lock); + dory_session_active = false; + session->owns_process_slot = false; + pthread_mutex_unlock(&dory_session_lock); +} + +static int32_t dory_bind_static_functions(DoryVirglRendererSession *session) +{ +#if defined(DORY_VIRGL_RENDERER_STATIC_LINKED) + session->functions = (DoryVirglRendererFunctions){ + .initialize = virgl_renderer_init, + .cleanup = virgl_renderer_cleanup, + .get_cap_set = virgl_renderer_get_cap_set, + .fill_caps = virgl_renderer_fill_caps, + .context_create = virgl_renderer_context_create_with_flags, + .context_destroy = virgl_renderer_context_destroy, + .context_attach_resource = virgl_renderer_ctx_attach_resource, + .context_detach_resource = virgl_renderer_ctx_detach_resource, + .submit_cmd2 = virgl_renderer_submit_cmd2, + .blob_create = virgl_renderer_resource_create_blob, + .resource_create = virgl_renderer_resource_create, + .resource_attach_iov = virgl_renderer_resource_attach_iov, + .resource_detach_iov = virgl_renderer_resource_detach_iov, + .resource_unref = virgl_renderer_resource_unref, + .resource_get_map_info = virgl_renderer_resource_get_map_info, + .resource_export_blob = virgl_renderer_resource_export_blob, + .resource_get_info = virgl_renderer_resource_get_info, + .create_handle_for_scanout = virgl_renderer_create_handle_for_scanout, + .release_handle_for_scanout = virgl_renderer_release_handle_for_scanout, + .transfer_write_iov = virgl_renderer_transfer_write_iov, + .transfer_read_iov = virgl_renderer_transfer_read_iov, + .context_create_fence = virgl_renderer_context_create_fence, + .create_fence = virgl_renderer_create_fence, + .get_poll_fd = virgl_renderer_get_poll_fd, + .poll = virgl_renderer_poll, + .set_log_callback = virgl_set_log_callback, + }; + return 0; +#else + (void)session; + return -ENOSYS; +#endif +} + +static void dory_destroy_partial_session(DoryVirglRendererSession *session) +{ + if (session == NULL) + return; + if (session->log_callback_installed) { + session->functions.set_log_callback(NULL, NULL, NULL); + session->log_callback_installed = false; + } + if (session->renderer_initialized) { + session->functions.cleanup(session); + session->renderer_initialized = false; + } +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + dory_terminate_angle(session); +#endif + dory_destroy_fence_registry(session); + if (session->submit_diagnostic_lock_initialized) { + pthread_mutex_destroy(&session->submit_diagnostic_lock); + session->submit_diagnostic_lock_initialized = false; + } + dory_release_process_slot(session); + free(session); +} + +int32_t DoryVirglRendererSessionCreate(DoryVirglRendererSession **out_session) +{ + if (out_session == NULL || *out_session != NULL) + return -EINVAL; + + if (setenv( + "APP_SANDBOX_GROUP_ID", + dory_renderer_app_sandbox_group, + 1 + ) != 0) + return errno ? -errno : -EINVAL; + + DoryVirglRendererSession *session = calloc(1, sizeof(*session)); + if (session == NULL) + return -ENOMEM; + + int32_t result = pthread_mutex_init(&session->fence_lock, NULL); + if (result != 0) { + free(session); + return -result; + } + session->fence_lock_initialized = true; + result = pthread_mutex_init(&session->submit_diagnostic_lock, NULL); + if (result != 0) { + dory_destroy_partial_session(session); + return -result; + } + session->submit_diagnostic_lock_initialized = true; + + /* virglrenderer and its external EGL winsys are process-global. Claim before constructing + * either ANGLE or renderer state so two concurrent bootstrap attempts cannot briefly create + * independent share groups and then race for the foreign singleton. */ + result = dory_claim_process_slot(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + result = pthread_mutex_init(&session->angle_context_lock, NULL); + if (result != 0) { + dory_destroy_partial_session(session); + return -result; + } + session->angle_context_lock_initialized = true; + session->angle_display = EGL_NO_DISPLAY; + result = dory_initialize_angle(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + result = dory_run_angle_execution_self_test(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } +#endif + + result = dory_bind_static_functions(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + session->functions.set_log_callback(dory_renderer_log_callback, session, NULL); + session->log_callback_installed = true; + + session->callbacks = (DoryVirglRendererCallbacks){ + .version = DORY_VIRGL_RENDERER_CALLBACKS_VERSION, + .write_fence = dory_write_fence, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + .create_gl_context = dory_create_gl_context, + .destroy_gl_context = dory_destroy_gl_context, + .make_current = dory_make_current, +#else + .create_gl_context = NULL, + .destroy_gl_context = NULL, + .make_current = NULL, +#endif + .get_drm_fd = NULL, + .write_context_fence = dory_write_context_fence, + .get_server_fd = NULL, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + .get_egl_display = dory_get_egl_display, +#else + .get_egl_display = NULL, +#endif + }; + result = session->functions.initialize( + session, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + DORY_VIRGL_RENDERER_DUAL_METAL_INITIALIZATION_FLAGS, +#else + DORY_VIRGL_RENDERER_VENUS_ONLY_INITIALIZATION_FLAGS, +#endif + &session->callbacks + ); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + session->renderer_initialized = true; + *out_session = session; + return 0; +} + +void DoryVirglRendererSessionDestroy(DoryVirglRendererSession *session) +{ + dory_destroy_partial_session(session); +} + +int32_t DoryVirglRendererGetCapset( + DoryVirglRendererSession *session, + uint32_t capset_id, + uint32_t *maximum_version, + void *bytes, + size_t capacity, + size_t *actual_size +) +{ + if (session == NULL || maximum_version == NULL || actual_size == NULL) + return -EINVAL; + uint32_t version = 0; + uint32_t size = 0; + session->functions.get_cap_set(capset_id, &version, &size); + *maximum_version = version; + *actual_size = size; + if (size == 0) + return -ENOTSUP; + if (bytes == NULL) + return capacity == 0 ? 0 : -EINVAL; + if (capacity < size) + return -EMSGSIZE; + session->functions.fill_caps(capset_id, version, bytes); + return 0; +} + +int32_t DoryVirglRendererContextCreate( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + const char *name, + size_t name_length +) +{ + if (session == NULL || context_id == 0 || name == NULL || + name_length == 0 || name_length > UINT32_MAX) + return -EINVAL; + const int32_t result = session->functions.context_create( + context_id, + flags, + (uint32_t)name_length, + name + ); + return result; +} + +void DoryVirglRendererContextDestroy( + DoryVirglRendererSession *session, + uint32_t context_id +) +{ + if (session == NULL || context_id == 0) + return; + session->functions.context_destroy(context_id); + dory_remove_context_fence_completions(session, context_id); +} + +void DoryVirglRendererContextAttachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +) +{ + if (session == NULL || context_id == 0 || resource_id == 0) + return; + session->functions.context_attach_resource((int32_t)context_id, (int32_t)resource_id); +} + +void DoryVirglRendererContextDetachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +) +{ + if (session == NULL || context_id == 0 || resource_id == 0) + return; + session->functions.context_detach_resource((int32_t)context_id, (int32_t)resource_id); +} + +int32_t DoryVirglRendererSubmit( + DoryVirglRendererSession *session, + uint32_t context_id, + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (diagnostic != NULL) + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + if (session == NULL || context_id == 0 || command_bytes == NULL || + diagnostic == NULL || + dword_count == 0 || dword_count > INT32_MAX || + ((uintptr_t)command_bytes & 7U) != 0) + return -EINVAL; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + if (session->submit_in_progress) { + pthread_mutex_unlock(&session->submit_diagnostic_lock); + return -EBUSY; + } + session->active_submit_context_id = context_id; + session->submit_diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + session->submit_in_progress = true; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + + const int32_t result = session->functions.submit_cmd2( + (void *)command_bytes, + (int32_t)context_id, + (int32_t)dword_count, + NULL, + 0 + ); + + pthread_mutex_lock(&session->submit_diagnostic_lock); + session->submit_in_progress = false; + session->active_submit_context_id = 0; + if (session->submit_diagnostic.valid != 0 || + session->submit_diagnostic.precursor_category != DORY_VIRGL_PRECURSOR_NONE || + session->submit_diagnostic.failed_command_location_disposition != + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + *diagnostic = session->submit_diagnostic; + if (diagnostic->failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT && + diagnostic->status != result) { + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + } + if (diagnostic->failed_command_location_disposition != + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT || + (diagnostic->valid != 0 && diagnostic->command_id == 1U)) { + (void)DoryVirglRendererCorrelateCreateObjectSubtype( + command_bytes, + dword_count, + diagnostic + ); + } + } + session->submit_diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + return result; +} + +int32_t DoryVirglRendererBlobCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererBlobCreateArguments *arguments +) +{ + if (session == NULL || arguments == NULL || arguments->resource_handle == 0 || + (arguments->iovecs == NULL) != (arguments->iovec_count == 0)) + return -EINVAL; + const int32_t result = session->functions.blob_create(arguments); + return result; +} + +int32_t DoryVirglRendererResource3DCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererResource3DCreateArguments *arguments +) +{ + if (session == NULL || arguments == NULL || arguments->handle == 0 || + arguments->format == 0 || arguments->width == 0 || + arguments->height == 0 || arguments->depth == 0 || + arguments->array_size == 0) + return -EINVAL; + DoryVirglRendererResource3DCreateArguments mutable_arguments = *arguments; + return session->functions.resource_create(&mutable_arguments, NULL, 0); +} + +int32_t DoryVirglRendererResourceAttachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || iovecs == NULL || + iovec_count == 0 || iovec_count > INT32_MAX) + return -EINVAL; + const int32_t result = session->functions.resource_attach_iov( + (int32_t)resource_id, + (struct iovec *)iovecs, + (int32_t)iovec_count + ); + return result; +} + +void DoryVirglRendererResourceDetachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id +) +{ + if (session == NULL || resource_id == 0) + return; + session->functions.resource_detach_iov((int32_t)resource_id, NULL, NULL); +} + +void DoryVirglRendererResourceUnref( + DoryVirglRendererSession *session, + uint32_t resource_id +) +{ + if (session == NULL || resource_id == 0) + return; + session->functions.resource_unref(resource_id); +} + +int32_t DoryVirglRendererResourceGetMapInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *map_info +) +{ + if (session == NULL || resource_id == 0 || map_info == NULL) + return -EINVAL; + return session->functions.resource_get_map_info(resource_id, map_info); +} + +int32_t DoryVirglRendererResourceExportBlob( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *fd_type, + int32_t *owned_file_descriptor +) +{ + if (session == NULL || resource_id == 0 || fd_type == NULL || + owned_file_descriptor == NULL) + return -EINVAL; + *fd_type = 0; + *owned_file_descriptor = -1; + return session->functions.resource_export_blob( + resource_id, + fd_type, + owned_file_descriptor + ); +} + +int32_t DoryVirglRendererResourceGetInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + DoryVirglRendererResourceInfo *info +) +{ + if (session == NULL || resource_id == 0 || info == NULL) + return -EINVAL; + memset(info, 0, sizeof(*info)); + return session->functions.resource_get_info((int32_t)resource_id, info); +} + +int32_t DoryVirglRendererResourceAcquireScanoutMetalTexture( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t width, + uint32_t height, + uint32_t virgl_format, + uint32_t stride, + uint32_t offset, + void **retained_texture +) +{ + if (session == NULL || resource_id == 0 || width == 0 || height == 0 || + stride == 0 || retained_texture == NULL) + return -EINVAL; + *retained_texture = NULL; + void *handle = NULL; + const int32_t type = session->functions.create_handle_for_scanout( + resource_id, + width, + height, + virgl_format, + 0, + stride, + offset, + &handle + ); + if (type != DORY_VIRGL_RENDERER_NATIVE_HANDLE_METAL_TEXTURE || handle == NULL) { + if (type != 0 && handle != NULL) + session->functions.release_handle_for_scanout(type, handle); + return -ENOTSUP; + } + *retained_texture = handle; + return 0; +} + +int32_t DoryVirglRendererTransferToHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || box == NULL || + (iovecs == NULL) != (iovec_count == 0)) + return -EINVAL; + const int32_t result = session->functions.transfer_write_iov( + resource_id, + context_id, + (int32_t)level, + stride, + layer_stride, + (DoryVirglRendererBox *)box, + offset, + (struct iovec *)iovecs, + iovec_count + ); + return result; +} + +int32_t DoryVirglRendererTransferFromHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || box == NULL || + (iovecs == NULL) != (iovec_count == 0) || iovec_count > INT32_MAX) + return -EINVAL; + const int32_t result = session->functions.transfer_read_iov( + resource_id, + context_id, + level, + stride, + layer_stride, + (DoryVirglRendererBox *)box, + offset, + (struct iovec *)iovecs, + (int32_t)iovec_count + ); + return result; +} + +int32_t DoryVirglRendererCreateContextFence( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + uint32_t ring_index, + uint64_t fence_id +) +{ + if (session == NULL || context_id == 0 || fence_id == 0) + return -EINVAL; + int32_t result = dory_register_fence_completion( + session, + context_id, + ring_index, + fence_id, + false, + NULL + ); + if (result != 0) + return result; + result = session->functions.context_create_fence( + context_id, + flags, + ring_index, + fence_id + ); + if (result != 0) + dory_cancel_fence_completion(session, context_id, ring_index, fence_id); + return result; +} + +int32_t DoryVirglRendererCreateGlobalFence( + DoryVirglRendererSession *session, + uint64_t fence_id +) +{ + if (session == NULL || fence_id == 0 || session->functions.create_fence == NULL) + return -EINVAL; + uint32_t renderer_fence_id = 0; + int32_t result = dory_register_fence_completion( + session, + 0, + 0, + fence_id, + true, + &renderer_fence_id + ); + if (result != 0) + return result; + result = session->functions.create_fence((int32_t)renderer_fence_id, 0); + if (result != 0) + dory_cancel_fence_completion(session, 0, 0, fence_id); + return result; +} + +int32_t DoryVirglRendererGetFenceFileDescriptor( + DoryVirglRendererSession *session, + uint64_t fence_id +) +{ + if (session == NULL || fence_id == 0) + return -1; + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL && completion->fence_id != fence_id) + completion = completion->next; + if (completion == NULL || completion->read_descriptor < 0) { + pthread_mutex_unlock(&session->fence_lock); + return -1; + } + + const int descriptor = completion->read_descriptor; + completion->read_descriptor = -1; + if (completion->write_descriptor < 0) + dory_remove_fence_completion_locked(session, completion); + pthread_mutex_unlock(&session->fence_lock); + return descriptor; +} + +int32_t DoryVirglRendererGetPollFileDescriptor(DoryVirglRendererSession *session) +{ + if (session == NULL || session->functions.get_poll_fd == NULL) + return -1; + return session->functions.get_poll_fd(); +} + +void DoryVirglRendererPoll(DoryVirglRendererSession *session) +{ + if (session == NULL) + return; + session->functions.poll(); +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c new file mode 100644 index 00000000..1f09ed82 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c @@ -0,0 +1,30 @@ +#include "DoryVirglRendererShim.h" + +#include + +_Static_assert(sizeof(DoryVirglRendererResourceInfo) == 40, + "virgl resource-info ABI size changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, handle) == 0, + "virgl resource-info handle offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, tex_id) == 24, + "virgl resource-info texture offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, drm_fourcc) == 32, + "virgl resource-info DRM format offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, fd) == 36, + "virgl resource-info file-descriptor offset changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET == (1u << 1), + "virgl render-target resource-bind ABI changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW == (1u << 3), + "virgl sampler-view resource-bind ABI changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT == (1u << 18), + "virgl scanout resource-bind ABI changed"); + +size_t DoryVirglRendererResourceInfoSize(void) +{ + return sizeof(DoryVirglRendererResourceInfo); +} + +size_t DoryVirglRendererResourceInfoFileDescriptorOffset(void) +{ + return offsetof(DoryVirglRendererResourceInfo, fd); +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h new file mode 100644 index 00000000..6b8a1b90 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h @@ -0,0 +1,378 @@ +#ifndef DORY_VIRGL_RENDERER_SHIM_H +#define DORY_VIRGL_RENDERER_SHIM_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * ABI mirror of virglrenderer.h's struct virgl_renderer_resource_info. + * + * The production worker statically links the exact qualified virglrenderer archives, but this + * package target deliberately does not import the renderer's private build tree. Keep the foreign + * layout in C: Swift must not reproduce it with a private struct because a missing trailing field + * lets virglrenderer overwrite adjacent Swift storage. scripts/build-virglrenderer.sh separately + * compiles this mirror against the pinned upstream header before publishing a renderer artifact. + */ +typedef struct DoryVirglRendererResourceInfo { + uint32_t handle; + uint32_t virgl_format; + uint32_t width; + uint32_t height; + uint32_t depth; + uint32_t flags; + uint32_t tex_id; + uint32_t stride; + int32_t drm_fourcc; + int fd; +} DoryVirglRendererResourceInfo; + +/* + * Exact C-owned mirrors used by the renderer worker. Swift deliberately does not reproduce any + * virglrenderer aggregate: every writable or retained foreign record still needs a compile-checked + * layout boundary even though the renderer is statically linked into the worker. + */ +typedef void *DoryVirglRendererGLContext; + +typedef struct DoryVirglRendererGLContextParameters { + int32_t version; + uint8_t shared; + uint8_t reserved[3]; + int32_t major_version; + int32_t minor_version; + int32_t compatibility_context; +} DoryVirglRendererGLContextParameters; + +typedef struct DoryVirglRendererCallbacks { + int32_t version; + void (*write_fence)(void *cookie, uint32_t fence); + DoryVirglRendererGLContext (*create_gl_context)( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContextParameters *parameters + ); + void (*destroy_gl_context)(void *cookie, DoryVirglRendererGLContext context); + int32_t (*make_current)( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContext context + ); + int32_t (*get_drm_fd)(void *cookie); + void (*write_context_fence)( + void *cookie, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id + ); + int32_t (*get_server_fd)(void *cookie, uint32_t version); + void *(*get_egl_display)(void *cookie); +} DoryVirglRendererCallbacks; + +typedef struct DoryVirglRendererBlobCreateArguments { + uint32_t resource_handle; + uint32_t context_id; + uint32_t blob_memory; + uint32_t blob_flags; + uint64_t blob_id; + uint64_t size; + const struct iovec *iovecs; + uint32_t iovec_count; +} DoryVirglRendererBlobCreateArguments; + +typedef struct DoryVirglRendererResource3DCreateArguments { + uint32_t handle; + uint32_t target; + uint32_t format; + uint32_t bind; + uint32_t width; + uint32_t height; + uint32_t depth; + uint32_t array_size; + uint32_t last_level; + uint32_t samples; + uint32_t flags; +} DoryVirglRendererResource3DCreateArguments; + +typedef struct DoryVirglRendererBox { + uint32_t x; + uint32_t y; + uint32_t z; + uint32_t width; + uint32_t height; + uint32_t depth; +} DoryVirglRendererBox; + +enum { + /* Exact pinned virglrenderer initialization ABI for Dory's dual Metal worker. */ + DORY_VIRGL_RENDERER_USE_EGL = 1 << 0, + DORY_VIRGL_RENDERER_THREAD_SYNC = 1 << 1, + DORY_VIRGL_RENDERER_USE_GLES = 1 << 4, + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB = 1 << 5, + DORY_VIRGL_RENDERER_VENUS = 1 << 6, + DORY_VIRGL_RENDERER_NO_VIRGL = 1 << 7, + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK = 1 << 8, + DORY_VIRGL_RENDERER_RENDER_SERVER = 1 << 9, + DORY_VIRGL_RENDERER_NATIVE_SHARE_TEXTURE = 1 << 12, + DORY_VIRGL_RENDERER_VENUS_ONLY_INITIALIZATION_FLAGS = + DORY_VIRGL_RENDERER_THREAD_SYNC | + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB | + DORY_VIRGL_RENDERER_VENUS | + DORY_VIRGL_RENDERER_NO_VIRGL | + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK | + DORY_VIRGL_RENDERER_RENDER_SERVER, + DORY_VIRGL_RENDERER_DUAL_METAL_INITIALIZATION_FLAGS = + DORY_VIRGL_RENDERER_THREAD_SYNC | + DORY_VIRGL_RENDERER_USE_GLES | + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB | + DORY_VIRGL_RENDERER_VENUS | + DORY_VIRGL_RENDERER_NATIVE_SHARE_TEXTURE | + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK | + DORY_VIRGL_RENDERER_RENDER_SERVER, + DORY_VIRGL_RENDERER_CAPSET_VIRGL2 = 2, + DORY_VIRGL_RENDERER_CAPSET_VENUS = 4, + DORY_VIRGL_RENDERER_BLOB_MEMORY_HOST3D = 0x0002, + DORY_VIRGL_RENDERER_BLOB_FLAG_MAPPABLE = 0x0001, + DORY_VIRGL_RENDERER_BLOB_FLAG_SHAREABLE = 0x0002, + DORY_VIRGL_RENDERER_BLOB_FD_TYPE_SHM = 0x0003, + DORY_VIRGL_RENDERER_NATIVE_HANDLE_METAL_TEXTURE = 2, + /* Exact pinned virglrenderer resource-bind ABI used by the native Metal scanout path. */ + DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET = 1 << 1, + DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW = 1 << 3, + DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT = 1 << 18, + DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM = 1, + DORY_VIRGL_RENDERER_FORMAT_RGBA8_UNORM = 67, +}; + +typedef struct DoryVirglRendererSession DoryVirglRendererSession; + +/* + * Sanitized result of the one exact vrend decoder-error message accepted while a submit is in + * progress. `command_id` is the pinned `enum virgl_context_cmd` ordinal after the callback has + * matched the complete static command name; no raw renderer log bytes cross this boundary. + */ +typedef struct DoryVirglRendererSubmitDiagnostic { + uint32_t valid; + uint32_t context_id; + uint32_t command_id; + int32_t status; + /* + * Future exact decoder location tuple. 0 absent, 1 present, 2 ambiguous/malformed. + * Offset is a dword index and ordinal is a zero-based command-header index. Both must match + * the same header in a complete bounded walk before either can influence subtype reporting. + */ + uint32_t failed_command_location_disposition; + uint32_t failed_command_dword_offset; + uint32_t failed_command_ordinal; + /* 0 absent, 1 present, 2 ambiguous/malformed. Present values are pinned object types 0...11. */ + uint32_t create_object_subtype_disposition; + uint32_t create_object_subtype; + /* Saturating 0...255 CREATE_OBJECT header count; never a payload or stream length. */ + uint32_t create_object_candidate_count; + /* Closed 12-bit set of pinned object types observed in CREATE_OBJECT headers. */ + uint32_t create_object_subtype_mask; + /* + * Closed surface-validation reason from the exact dispatch tuple. Zero is absent/unknown; + * 1...7 are accepted only after correlation proves CREATE_OBJECT subtype SURFACE + EINVAL. + */ + uint32_t surface_failure_reason; + /* Closed fixed-prefix category; zero means no approved precursor was observed. */ + uint32_t precursor_category; +} DoryVirglRendererSubmitDiagnostic; + +/* + * Binds only renderer symbols resolved into the executable itself. The production target defines + * DORY_VIRGL_RENDERER_STATIC_LINKED and force-loads the reviewed archives. Other builds fail closed + * with ENOSYS; packaging rejects unresolved renderer symbols. No path, environment variable, + * loader, or ICD manifest participates in renderer selection. + */ +int32_t DoryVirglRendererSessionCreate(DoryVirglRendererSession **session); +void DoryVirglRendererSessionDestroy(DoryVirglRendererSession *session); + +int32_t DoryVirglRendererGetCapset( + DoryVirglRendererSession *session, + uint32_t capset_id, + uint32_t *maximum_version, + void *bytes, + size_t capacity, + size_t *actual_size +); +int32_t DoryVirglRendererContextCreate( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + const char *name, + size_t name_length +); +void DoryVirglRendererContextDestroy( + DoryVirglRendererSession *session, + uint32_t context_id +); +void DoryVirglRendererContextAttachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +); +void DoryVirglRendererContextDetachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +); +int32_t DoryVirglRendererSubmit( + DoryVirglRendererSession *session, + uint32_t context_id, + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* Pure classifier used by the production callback and focused ABI/security tests. */ +int32_t DoryVirglRendererClassifySubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* Exact additive machine diagnostic; returns -EINVAL for a matching prefix with invalid grammar. */ +int32_t DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +uint32_t DoryVirglRendererClassifySubmitPrecursorMessage(const char *message); +int32_t DoryVirglRendererClassifyCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* + * Correlates an already-sanitized decoder tuple against command headers only. The submitted + * payload is neither retained nor returned. With no tuple, this preserves the legacy unique-header + * classifier; a present tuple additionally requires exact offset + ordinal + opcode agreement. + */ +int32_t DoryVirglRendererCorrelateCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +int32_t DoryVirglRendererBlobCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererBlobCreateArguments *arguments +); +int32_t DoryVirglRendererResource3DCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererResource3DCreateArguments *arguments +); +int32_t DoryVirglRendererResourceAttachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id, + const struct iovec *iovecs, + uint32_t iovec_count +); +void DoryVirglRendererResourceDetachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id +); +void DoryVirglRendererResourceUnref( + DoryVirglRendererSession *session, + uint32_t resource_id +); +int32_t DoryVirglRendererResourceGetMapInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *map_info +); +int32_t DoryVirglRendererResourceExportBlob( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *fd_type, + int32_t *owned_file_descriptor +); +int32_t DoryVirglRendererResourceGetInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + DoryVirglRendererResourceInfo *info +); +/* + * Transfers one retained id for a native-share scanout resource. The caller must + * consume the returned +1 Objective-C reference exactly once. No texture pointer crosses XPC; + * the Swift worker converts it to an MTLSharedTextureHandle first. + */ +int32_t DoryVirglRendererResourceAcquireScanoutMetalTexture( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t width, + uint32_t height, + uint32_t virgl_format, + uint32_t stride, + uint32_t offset, + void **retained_texture +); +int32_t DoryVirglRendererTransferToHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +); +int32_t DoryVirglRendererTransferFromHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +); +int32_t DoryVirglRendererCreateContextFence( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + uint32_t ring_index, + uint64_t fence_id +); +/* + * Registers a classic VirGL ctx0 fence while preserving the guest's full 64-bit identity. The + * shim allocates an independent collision-safe 32-bit renderer callback token and translates the + * callback back to `fence_id`; callers continue to export completion by the original id. + */ +int32_t DoryVirglRendererCreateGlobalFence( + DoryVirglRendererSession *session, + uint64_t fence_id +); +/* + * Transfers the read side of the shim's one-shot callback-backed completion pipe. The descriptor + * becomes readable only after virglrenderer retires this context/ring fence; this deliberately + * does not rely on virgl_renderer_export_fence, which is not a Venus completion API on macOS. + */ +int32_t DoryVirglRendererGetFenceFileDescriptor( + DoryVirglRendererSession *session, + uint64_t fence_id +); +/* + * Returns virglrenderer's borrowed event descriptor, or -1 when Darwin selected explicit polling. + * A nonnegative descriptor must be observed but never closed by the caller. + */ +int32_t DoryVirglRendererGetPollFileDescriptor( + DoryVirglRendererSession *session +); +void DoryVirglRendererPoll(DoryVirglRendererSession *session); + +/* Exported for a Swift test that proves it is using this imported C layout. */ +size_t DoryVirglRendererResourceInfoSize(void); +size_t DoryVirglRendererResourceInfoFileDescriptorOffset(void); + +#ifdef __cplusplus +} +#endif + +#endif /* DORY_VIRGL_RENDERER_SHIM_H */ diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift b/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift new file mode 100644 index 00000000..efa7ae40 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift @@ -0,0 +1,419 @@ +import Darwin +import Foundation + +/// A fixed-capacity FIFO used by ``BoundedSerialConsolePublisher``. +/// +/// Keeping the storage fixed is intentional: a guest can write an unlimited console stream, but +/// it cannot make the host helper grow without bound. The publisher reports rejected bytes rather +/// than blocking a vCPU on host I/O or silently allocating more memory. +struct BoundedSerialByteRing { + private var storage: [UInt8] + private var head = 0 + private(set) var count = 0 + + init(capacity: Int) { + precondition(capacity > 0) + storage = [UInt8](repeating: 0, count: capacity) + } + + var capacity: Int { storage.count } + var isEmpty: Bool { count == 0 } + + @discardableResult + mutating func append(_ byte: UInt8) -> Bool { + guard count < storage.count else { return false } + storage[(head + count) % storage.count] = byte + count += 1 + return true + } + + mutating func removeFirst(maxCount: Int) -> [UInt8] { + precondition(maxCount > 0) + let removalCount = min(count, maxCount) + guard removalCount > 0 else { return [] } + + var result = [UInt8]() + result.reserveCapacity(removalCount) + let firstRun = min(removalCount, storage.count - head) + result.append(contentsOf: storage[head..<(head + firstRun)]) + let secondRun = removalCount - firstRun + if secondRun > 0 { + result.append(contentsOf: storage[0.. Snapshot { + Snapshot( + acceptedBytes: acceptedBytes, + overflowDroppedBytes: overflowDroppedBytes, + rejectedAfterStopBytes: rejectedAfterStopBytes, + processedBytes: processedBytes, + pendingBytes: pendingBytes, + peakPendingBytes: peakPendingBytes, + batches: batches, + writeSystemCalls: writeSystemCalls, + writeFailureCount: writeFailureCount, + firstWriteErrno: firstWriteErrno, + synchronizationFailureCount: synchronizationFailureCount, + firstSynchronizationErrno: firstSynchronizationErrno, + workerExited: workerExited + ) + } + } + + private struct WriteResult { + let systemCalls: UInt64 + let failureErrno: Int32? + } + + private let state: State + private let worker: Thread + + init( + destinations: [Destination], + capacityBytes: Int = 256 * 1_024, + batchBytes: Int = 16 * 1_024, + coalescingInterval: TimeInterval = 0.001 + ) throws { + guard !destinations.isEmpty else { + throw StartError.invalidConfiguration("at least one destination is required") + } + guard capacityBytes > 0 else { + throw StartError.invalidConfiguration("capacity must be positive") + } + guard batchBytes > 0, batchBytes <= capacityBytes else { + throw StartError.invalidConfiguration("batch size must be within the FIFO capacity") + } + guard coalescingInterval >= 0, coalescingInterval.isFinite else { + throw StartError.invalidConfiguration("coalescing interval must be finite and nonnegative") + } + + var ownedDestinations = [OwnedDestination]() + ownedDestinations.reserveCapacity(destinations.count) + for destination in destinations { + // The engine launches child helpers after the console is attached. Use an atomic + // close-on-exec duplicate so serial authority cannot leak across those exec boundaries. + let duplicate = Darwin.fcntl(destination.fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + let savedErrno = errno + for owned in ownedDestinations { Darwin.close(owned.fileDescriptor) } + throw StartError.duplicateDescriptor( + fileDescriptor: destination.fileDescriptor, + errno: savedErrno + ) + } + ownedDestinations.append(OwnedDestination( + fileDescriptor: duplicate, + synchronizeOnStop: destination.synchronizeOnStop + )) + } + + let state = State( + destinations: ownedDestinations, + capacityBytes: capacityBytes, + batchBytes: batchBytes, + coalescingInterval: coalescingInterval + ) + self.state = state + let worker = Thread { Self.runWorker(state) } + worker.name = "dory-hv.serial-output" + worker.qualityOfService = .utility + self.worker = worker + worker.start() + } + + /// Enqueues one byte without waiting for host I/O. `false` means the exact bounded policy + /// rejected it because the FIFO was full or shutdown had already fenced new publication. + @discardableResult + func enqueue(_ byte: UInt8) -> Bool { + state.condition.lock() + defer { state.condition.unlock() } + guard state.accepting else { + state.rejectedAfterStopBytes &+= 1 + return false + } + let wasEmpty = state.ring.isEmpty + guard state.ring.append(byte) else { + state.overflowDroppedBytes &+= 1 + return false + } + state.acceptedBytes &+= 1 + state.peakPendingBytes = max(state.peakPendingBytes, state.pendingBytes) + if wasEmpty || state.ring.count >= state.batchBytes { + state.condition.signal() + } + return true + } + + /// Waits only at an explicit non-vCPU boundary for all bytes accepted before this call. + @discardableResult + func flush(timeout: TimeInterval = 5) -> Snapshot { + state.condition.lock() + let target = state.acceptedBytes + state.forceDrain = true + state.condition.broadcast() + waitLocked( + until: { state.processedBytes >= target || state.workerExited }, + timeout: timeout + ) + let result = state.snapshotLocked() + state.condition.unlock() + return result + } + + /// Fences producers, drains accepted bytes, synchronizes durable destinations, and retires the + /// worker. A timeout is represented by `workerExited == false`; it never turns into an unbounded + /// shutdown wait. + @discardableResult + func stop(timeout: TimeInterval = 5) -> Snapshot { + state.condition.lock() + state.accepting = false + state.stopRequested = true + state.forceDrain = true + state.condition.broadcast() + waitLocked(until: { state.workerExited }, timeout: timeout) + let result = state.snapshotLocked() + state.condition.unlock() + return result + } + + var snapshot: Snapshot { + state.condition.lock() + defer { state.condition.unlock() } + return state.snapshotLocked() + } + + private func waitLocked(until predicate: () -> Bool, timeout: TimeInterval) { + guard !predicate() else { return } + guard timeout > 0, timeout.isFinite else { return } + let deadline = Date().addingTimeInterval(timeout) + while !predicate(), state.condition.wait(until: deadline) {} + } + + private static func runWorker(_ state: State) { + while true { + state.condition.lock() + while state.ring.isEmpty, !state.stopRequested { + state.condition.wait() + } + + if state.ring.isEmpty, state.stopRequested { + state.condition.unlock() + finishWorker(state) + return + } + + if !state.forceDrain, + !state.stopRequested, + state.ring.count < state.batchBytes, + state.coalescingInterval > 0 { + let deadline = Date().addingTimeInterval(state.coalescingInterval) + while state.ring.count < state.batchBytes, + !state.forceDrain, + !state.stopRequested, + state.condition.wait(until: deadline) {} + } + + let batch = state.ring.removeFirst(maxCount: state.batchBytes) + state.inFlightBytes = batch.count + if state.ring.isEmpty { state.forceDrain = false } + state.condition.unlock() + + var writeResults = [WriteResult]() + writeResults.reserveCapacity(state.destinations.count) + for destination in state.destinations { + writeResults.append(writeAll(batch, to: destination.fileDescriptor)) + } + + state.condition.lock() + state.inFlightBytes = 0 + state.processedBytes &+= UInt64(batch.count) + state.batches &+= 1 + if state.ring.isEmpty { state.forceDrain = false } + for result in writeResults { + state.writeSystemCalls &+= result.systemCalls + if let failureErrno = result.failureErrno { + state.writeFailureCount &+= 1 + if state.firstWriteErrno == nil { state.firstWriteErrno = failureErrno } + } + } + state.condition.broadcast() + state.condition.unlock() + } + } + + private static func finishWorker(_ state: State) { + var synchronizationErrors = [Int32]() + for destination in state.destinations where destination.synchronizeOnStop { + while Darwin.fsync(destination.fileDescriptor) != 0 { + if errno == EINTR { continue } + synchronizationErrors.append(errno) + break + } + } + for destination in state.destinations { + Darwin.close(destination.fileDescriptor) + } + + state.condition.lock() + state.synchronizationFailureCount &+= UInt64(synchronizationErrors.count) + if state.firstSynchronizationErrno == nil { + state.firstSynchronizationErrno = synchronizationErrors.first + } + state.workerExited = true + state.condition.broadcast() + state.condition.unlock() + } + + private static func writeAll(_ bytes: [UInt8], to fileDescriptor: Int32) -> WriteResult { + var systemCalls: UInt64 = 0 + var failureErrno: Int32? + bytes.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + systemCalls &+= 1 + let written = Darwin.write( + fileDescriptor, + baseAddress.advanced(by: offset), + rawBuffer.count - offset + ) + if written > 0 { + offset += written + continue + } + if written < 0, errno == EINTR { continue } + failureErrno = written < 0 ? errno : EIO + break + } + } + return WriteResult(systemCalls: systemCalls, failureErrno: failureErrno) + } + + deinit { + _ = stop(timeout: 1) + withExtendedLifetime(worker) {} + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift index 3d69030e..990847a6 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -1,18 +1,33 @@ import AppKit +import CoreFoundation +import Darwin import DoryHV +import DoryRendererWorkerContracts import Foundation -import MetalKit +import Metal +import QuartzCore -enum DesktopMetalDisplayError: Error, CustomStringConvertible { - case metalUnavailable - case shaderCompilation(String) +/// `DesktopMode` enters `NSApplication.run()` from the executable's async `@MainActor` task. +/// That AppKit run loop remains live, but the enclosing dispatch-main task cannot return while the +/// VM is running, so another `DispatchQueue.main.async` block cannot begin. Publish UI work through +/// the main CFRunLoop source that AppKit actually drains instead of queueing behind the app loop. +enum DesktopAppRunLoop { + static func perform(_ operation: @escaping @MainActor @Sendable () -> Void) { + let runLoop = CFRunLoopGetMain() + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue as CFTypeRef) { + MainActor.assumeIsolated { operation() } + } + CFRunLoopWakeUp(runLoop) + } - var description: String { - switch self { - case .metalUnavailable: - return "this Mac does not expose a Metal display device" - case let .shaderCompilation(detail): - return "could not build the Dory display shader: \(detail)" + static func perform( + after delay: TimeInterval, + _ operation: @escaping @MainActor @Sendable () -> Void + ) { + DispatchQueue.global(qos: .userInteractive).asyncAfter( + deadline: .now() + max(0, delay) + ) { + perform(operation) } } } @@ -65,94 +80,132 @@ final class DesktopPointerTopology: @unchecked Sendable { } } -/// Coalesces producer-thread flushes to the newest complete frame and performs one main-thread -/// upload. This keeps a busy compositor from building an unbounded queue of stale 16 MiB frames. +/// Maps a scanout rectangle into the Metal texture coordinates consumed by +/// `DesktopDisplayView`'s flipped AppKit surface. +/// +/// The view and virtio-input tablet both use a top-left origin. A top-origin scanout therefore +/// needs increasing texture Y from the first row to the last row; swapping those endpoints here +/// vertically mirrors the visible desktop while pointer input continues to target the unmirrored +/// guest coordinate. Bottom-origin renderer textures require the opposite ordering. +enum DesktopScanoutTextureCoordinates { + static func sourceUV( + sourceRect: VirtioGPURect, + backingWidth: UInt32, + backingHeight: UInt32, + yOriginTop: Bool + ) -> SIMD4 { + let left = Float(sourceRect.x) / Float(backingWidth) + let right = Float(sourceRect.x + sourceRect.width) / Float(backingWidth) + let firstY = Float(sourceRect.y) / Float(backingHeight) + let secondY = Float(sourceRect.y + sourceRect.height) / Float(backingHeight) + return SIMD4( + left, + yOriginTop ? firstY : secondY, + right, + yOriginTop ? secondY : firstY + ) + } +} + +/// Measures the bounded producer-to-main-thread software presentation lane. Frame counters refer +/// to producer submissions; byte counters distinguish received payload, explicit mailbox copies, +/// display uploads, rejected/destroyed payload, and current backlog. struct DesktopFrameMailboxMetrics: Equatable, Sendable { var presentedFrames: UInt64 var droppedFrames: UInt64 + var budgetRejectedFrames: UInt64 + /// Immutable producer payload observed at the mailbox boundary, including rejected frames. + var receivedFrameBytes: UInt64 + /// Bytes explicitly copied while normalizing stride or merging a software-frame backlog. + var stagingCopyBytes: UInt64 + /// Bytes copied from sparse cells into tightly packed main-thread upload buffers. + var drainCopyBytes: UInt64 + /// Texel payload submitted by successful CPU-to-display texture uploads. + var uploadedFrameBytes: UInt64 + /// Producer payload rejected, destroyed, disabled, or unpresentable before a complete upload. + var droppedFrameBytes: UInt64 + /// Retained payload/cell storage currently waiting for main-thread presentation. + var pendingFrameBytes: UInt64 + /// Number of accepted producer updates represented by the current retained state. + var pendingFrameDepth: UInt64 + + init( + presentedFrames: UInt64, + droppedFrames: UInt64, + budgetRejectedFrames: UInt64 = 0, + receivedFrameBytes: UInt64 = 0, + stagingCopyBytes: UInt64 = 0, + drainCopyBytes: UInt64 = 0, + uploadedFrameBytes: UInt64 = 0, + droppedFrameBytes: UInt64 = 0, + pendingFrameBytes: UInt64 = 0, + pendingFrameDepth: UInt64 = 0 + ) { + self.presentedFrames = presentedFrames + self.droppedFrames = droppedFrames + self.budgetRejectedFrames = budgetRejectedFrames + self.receivedFrameBytes = receivedFrameBytes + self.stagingCopyBytes = stagingCopyBytes + self.drainCopyBytes = drainCopyBytes + self.uploadedFrameBytes = uploadedFrameBytes + self.droppedFrameBytes = droppedFrameBytes + self.pendingFrameBytes = pendingFrameBytes + self.pendingFrameDepth = pendingFrameDepth + } } -final class DesktopFrameMailbox: @unchecked Sendable { +struct DesktopCPUPresentationBudgetMetrics: Equatable, Sendable { + var residentBytes: Int + var peakResidentBytes: Int + var rejectedReservations: UInt64 +} + +/// One process-wide authority bounds retained software-frame payload and sparse accumulator cells +/// across every scanout mailbox. Per-mailbox limits remain useful for fairness, but cannot +/// substitute for this aggregate limit: Linux may bind one framebuffer to all 16 scanouts. +final class DesktopCPUPresentationBudget: @unchecked Sendable { + static let processDefault = DesktopCPUPresentationBudget( + maximumResidentBytes: 256 * 1_024 * 1_024 + ) + private let lock = NSLock() - private var pending = [VirtioGPUScanoutFrame]() - private var deliveryScheduled = false - private var presentedFrameCount: UInt64 = 0 - private var droppedFrameCount: UInt64 = 0 - nonisolated(unsafe) weak var view: DesktopMetalView? + private let maximumResidentBytes: Int + private var residentBytes = 0 + private var peakResidentBytes = 0 + private var rejectedReservations: UInt64 = 0 - func submit(_ frame: VirtioGPUScanoutFrame) { - lock.lock() - if frame.dirtyRect.x == 0, frame.dirtyRect.y == 0, - frame.dirtyRect.width == frame.width, frame.dirtyRect.height == frame.height { - pending = [frame] - } else { - pending.append(frame) - if pending.count > 256 { - let overflow = pending.count - 256 - pending.removeFirst(overflow) - droppedFrameCount = Self.saturatingAdd( - droppedFrameCount, - UInt64(overflow) - ) - } - } - let shouldSchedule = !deliveryScheduled - deliveryScheduled = true - lock.unlock() - guard shouldSchedule else { return } - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - self?.deliver() - } - } + init(maximumResidentBytes: Int) { + self.maximumResidentBytes = max(1, maximumResidentBytes) } - /// Drop presentation storage only when the guest destroys the corresponding virtio-gpu - /// resource. Direct scanout temporarily switches between application and compositor buffers; - /// evicting an older compositor buffer merely because it was not recently visible makes its - /// next partial update appear as a black or torn frame. - func release(resourceID: UInt32) { - lock.lock() - let previousCount = pending.count - pending.removeAll { $0.resourceID == resourceID } - droppedFrameCount = Self.saturatingAdd( - droppedFrameCount, - UInt64(previousCount - pending.count) - ) - lock.unlock() - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - self?.view?.release(resourceID: resourceID) + func replaceReservation(releasing oldBytes: Int, reserving newBytes: Int) -> Bool { + lock.withLock { + guard oldBytes >= 0, oldBytes <= residentBytes, + newBytes >= 0, + newBytes <= maximumResidentBytes - (residentBytes - oldBytes) else { + rejectedReservations = Self.saturatingAdd(rejectedReservations, 1) + return false } + residentBytes = residentBytes - oldBytes + newBytes + peakResidentBytes = max(peakResidentBytes, residentBytes) + return true } } - @MainActor - func deliver() { - lock.lock() - let frames = pending - pending.removeAll(keepingCapacity: true) - deliveryScheduled = false - lock.unlock() - guard !frames.isEmpty else { return } - let presented = view?.present(frames) ?? 0 + func release(_ byteCount: Int) { + guard byteCount > 0 else { return } lock.withLock { - presentedFrameCount = Self.saturatingAdd( - presentedFrameCount, - UInt64(presented) - ) - droppedFrameCount = Self.saturatingAdd( - droppedFrameCount, - UInt64(frames.count - presented) - ) + precondition(byteCount <= residentBytes, "CPU presentation budget over-release") + residentBytes -= byteCount } } - var metrics: DesktopFrameMailboxMetrics { + var metrics: DesktopCPUPresentationBudgetMetrics { lock.withLock { - DesktopFrameMailboxMetrics( - presentedFrames: presentedFrameCount, - droppedFrames: droppedFrameCount + DesktopCPUPresentationBudgetMetrics( + residentBytes: residentBytes, + peakResidentBytes: peakResidentBytes, + rejectedReservations: rejectedReservations ) } } @@ -163,268 +216,1010 @@ final class DesktopFrameMailbox: @unchecked Sendable { } } -/// Moves copied cursor-plane updates from a vCPU thread to AppKit without retaining the guest -/// resource or touching NSCursor off the main thread. -final class DesktopCursorMailbox: @unchecked Sendable { - nonisolated(unsafe) weak var view: DesktopMetalView? +/// Keeps process-wide presentation bytes reserved until the main-thread consumer has finished +/// with the drained `Data`. Moving bytes out of a mailbox must not make the shared budget appear +/// free while the display is still reading those same bytes. +private final class DesktopCPUPresentationDrainReservation: @unchecked Sendable { + private let budget: DesktopCPUPresentationBudget + private let byteCount: Int - func submit(_ update: VirtioGPUCursorUpdate?) { - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - self?.view?.presentCursor(update) + init(budget: DesktopCPUPresentationBudget, byteCount: Int) { + self.budget = budget + self.byteCount = byteCount + } + + deinit { + budget.release(byteCount) + } +} + +/// Stable guest resource identity shared by copied CPU frames and worker-issued Metal surfaces. +/// Resource IDs may be reused, so the ID alone is never sufficient for presentation lifetime. +struct DesktopScanoutResourceIdentity: Hashable, Sendable { + var resourceID: UInt32 + var generation: UInt64 + + init(resourceID: UInt32, generation: UInt64) { + self.resourceID = resourceID + self.generation = generation + } + + init(frame: VirtioGPUScanoutFrame) { + self.init(resourceID: frame.resourceID, generation: frame.resourceGeneration) + } + + init(metalUpdate: VirtioGPUMetalScanoutUpdate) { + self.init( + resourceID: metalUpdate.resourceID, + generation: metalUpdate.resourceGeneration + ) + } +} + +/// Pure presentation lifetime state. It remembers releases independently of the currently bound +/// scanout so a delayed update for a retired generation cannot become visible after its release. +struct DesktopScanoutResourceLifetime { + private enum GenerationState { + case active(UInt64) + case released(through: UInt64) + + var generation: UInt64 { + switch self { + case .active(let generation), .released(let generation): generation } } } -} -@MainActor -final class DesktopMetalView: MTKView, MTKViewDelegate { - private let commandQueue: MTLCommandQueue - private let pipeline: MTLRenderPipelineState - private let keyboardInput: VirtioInput - private let pointerInput: VirtioInput - private let guestBackingScaleFactor: CGFloat - private let scanoutID: UInt32 - private let pointerTopology: DesktopPointerTopology? - private var resourceTextures: [UInt32: MTLTexture] = [:] - private var scanoutTexture: MTLTexture? - private var scanoutSize = CGSize.zero - private var guestCursor = NSCursor.arrow - private var guestCursorUpdate: VirtioGPUCursorUpdate? - private var tracking: NSTrackingArea? - private var scrollAccumulator = VirtioInputScrollAccumulator() - private var pressedKeyboardInput = VirtioInputPressedState() - private var pressedPointerInput = VirtioInputPressedState() - private var resizeGeneration: UInt64 = 0 - var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? - var onMacShortcut: ((NSEvent) -> Bool)? + private var generations: [UInt32: GenerationState] = [:] + private(set) var boundIdentity: DesktopScanoutResourceIdentity? - init( - frame: NSRect, - keyboardInput: VirtioInput, - pointerInput: VirtioInput, - guestBackingScaleFactor: CGFloat = 2, - scanoutID: UInt32 = 0, - pointerTopology: DesktopPointerTopology? = nil - ) throws { - guard let device = MTLCreateSystemDefaultDevice(), - let commandQueue = device.makeCommandQueue() else { - throw DesktopMetalDisplayError.metalUnavailable + func accepts(_ identity: DesktopScanoutResourceIdentity) -> Bool { + guard let state = generations[identity.resourceID] else { return true } + switch state { + case .active(let generation): + return identity.generation >= generation + case .released(let throughGeneration): + return identity.generation > throughGeneration } - self.keyboardInput = keyboardInput - self.pointerInput = pointerInput - self.guestBackingScaleFactor = guestBackingScaleFactor - self.scanoutID = scanoutID - self.pointerTopology = pointerTopology - self.commandQueue = commandQueue - do { - let library = try device.makeLibrary(source: Self.shaderSource, options: nil) - let descriptor = MTLRenderPipelineDescriptor() - descriptor.vertexFunction = library.makeFunction(name: "dory_scanout_vertex") - descriptor.fragmentFunction = library.makeFunction(name: "dory_scanout_fragment") - descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm - self.pipeline = try device.makeRenderPipelineState(descriptor: descriptor) - } catch { - throw DesktopMetalDisplayError.shaderCompilation(String(describing: error)) + } + + @discardableResult + mutating func bind(_ identity: DesktopScanoutResourceIdentity) -> Bool { + guard accepts(identity) else { return false } + generations[identity.resourceID] = .active(identity.generation) + boundIdentity = identity + return true + } + + /// Returns true only when this release retired the currently displayed resource generation. + @discardableResult + mutating func release(resourceID: UInt32, throughGeneration: UInt64) -> Bool { + if (generations[resourceID]?.generation ?? 0) <= throughGeneration { + generations[resourceID] = .released(through: throughGeneration) + } + guard let binding = boundIdentity, + binding.resourceID == resourceID, + binding.generation <= throughGeneration else { + return false } - super.init(frame: frame, device: device) - colorPixelFormat = .bgra8Unorm - clearColor = MTLClearColorMake(0.025, 0.03, 0.04, 1) - framebufferOnly = true - enableSetNeedsDisplay = true - isPaused = true - preferredFramesPerSecond = 60 - delegate = self + boundIdentity = nil + return true } - @available(*, unavailable) - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + mutating func unbind() { + boundIdentity = nil } +} - override var acceptsFirstResponder: Bool { true } - override var isFlipped: Bool { true } +/// Preserves every accepted damage pixel without allocating a complete framebuffer in the +/// mailbox. The normal one-update case retains the producer's immutable `Data` and drains it +/// without a copy. Only a stalled main thread with multiple non-superseding updates activates a +/// sparse accumulator whose cells are derived from one host page. Dirty masks ensure that holes +/// between distant rectangles are never copied or uploaded as if they were valid pixels. +final class DesktopScanoutFrameCoalescer { + enum AppendOutcome: Equatable { + case accepted + case invalid + case budgetExceeded + } - /// A dedicated guest display is often not the key macOS window yet (notably immediately after - /// entering fullscreen). AppKit normally consumes that first click solely to activate the - /// window, which makes GDM's user tile appear unclickable. The guest owns the entire display - /// surface, so activation and input delivery must happen on the same click. - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + fileprivate struct ResourceKey: Hashable { + var resourceID: UInt32 + var resourceGeneration: UInt64 + } - override func updateTrackingAreas() { - if let tracking { removeTrackingArea(tracking) } - let replacement = NSTrackingArea( - rect: bounds, - options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], - owner: self, - userInfo: nil - ) - addTrackingArea(replacement) - tracking = replacement - super.updateTrackingAreas() + fileprivate struct TileKey: Hashable { + var column: UInt32 + var row: UInt32 } - @discardableResult - func present(_ frames: [VirtioGPUScanoutFrame]) -> Int { - var updated = 0 - for frame in frames { - if presentUpdate(frame) { updated += 1 } + fileprivate struct Tile { + var width: Int + var height: Int + var bytes: Data + var dirtyRows: [UInt64] + var dirtyPixelCount: Int + } + + fileprivate final class TiledStorage { + var tiles: [TileKey: Tile] = [:] + var residentBytes = 0 + var dirtyPixelCount = 0 + } + + fileprivate enum Storage { + case single(VirtioGPUScanoutFrame) + case tiled(TiledStorage) + } + + fileprivate struct Surface { + var scanoutID: UInt32 + var format: UInt32 + var width: UInt32 + var height: UInt32 + var storage: Storage + var inputFrameCount: UInt64 + var inputPayloadByteCount: UInt64 + + var residentBytes: Int { + switch storage { + case .single(let frame): frame.bytes.count + case .tiled(let tiled): tiled.residentBytes + } + } + + var uploadByteCount: Int { + switch storage { + case .single(let frame): + Int(frame.dirtyRect.width) * Int(frame.dirtyRect.height) * 4 + case .tiled(let tiled): + tiled.dirtyPixelCount * 4 + } } - if updated > 0 { needsDisplay = true } - return updated } - func release(resourceID: UInt32) { - resourceTextures.removeValue(forKey: resourceID) + struct Metrics: Equatable { + var residentBytes: Int + var peakResidentBytes: Int + var pendingFrameDepth: UInt64 + var peakPendingFrameDepth: UInt64 + var stagingCopyBytes: UInt64 } - func presentCursor(_ update: VirtioGPUCursorUpdate?) { - guestCursorUpdate = update - rebuildGuestCursor() + struct Removal: Equatable { + var frameCount: UInt64 + var payloadByteCount: UInt64 } - private func rebuildGuestCursor(pixelSize: CGSize? = nil) { - guard let update = guestCursorUpdate else { - guestCursor = Self.transparentCursor - window?.invalidateCursorRects(for: self) - return + struct Drain { + struct Batch { + var frameRange: Range + var inputFrameCount: UInt64 + var inputPayloadByteCount: UInt64 } - // Cursor resources use guest scanout pixels, not the host Metal drawable's backing - // pixels. Those differ on a non-Retina host display when Dory intentionally exposes a - // 2x guest framebuffer. Scaling against drawableSize would make the cursor image and its - // hotspot twice as large as the desktop beneath it. - let effectivePixelSize = pixelSize - ?? (scanoutSize.width > 0 ? scanoutSize : CGSize( - width: bounds.width * guestBackingScaleFactor, - height: bounds.height * guestBackingScaleFactor - )) - let cursorScale = bounds.width > 0 - ? max(1, effectivePixelSize.width / bounds.width) - : CGFloat(1) - guestCursor = Self.makeCursor(update, scale: cursorScale) ?? Self.transparentCursor - window?.invalidateCursorRects(for: self) + + var frames: [VirtioGPUScanoutFrame] + var inputFrameCount: UInt64 + var outputByteCount: Int + var copyByteCount: Int + var hasMorePendingFrames: Bool + var batches: [Batch] + fileprivate var reservation: DesktopCPUPresentationDrainReservation? } - override func resetCursorRects() { - addCursorRect(bounds, cursor: guestCursor) + struct PendingDrain { + fileprivate var entries: [(ResourceKey, Surface)] + fileprivate var inputFrameCount: UInt64 + fileprivate var outputByteCount: Int + fileprivate var hasMorePendingFrames: Bool + fileprivate var reservation: DesktopCPUPresentationDrainReservation? + + fileprivate func materialize() -> Drain { + var frames = [VirtioGPUScanoutFrame]() + var batches = [Drain.Batch]() + var copyByteCount = 0 + for (key, surface) in entries { + let start = frames.count + switch surface.storage { + case .single(let frame): + frames.append(frame) + case .tiled(let tiled): + for rect in DesktopScanoutFrameCoalescer.dirtyRectangles(in: tiled) { + frames.append(DesktopScanoutFrameCoalescer.makeFrame( + key: key, + surface: surface, + tiled: tiled, + rect: rect + )) + copyByteCount += Int(rect.width) * Int(rect.height) * 4 + } + } + batches.append(Drain.Batch( + frameRange: start.. Bool { - guard let pixelFormat = Self.pixelFormat(for: frame.format), - let device, - frame.width > 0, frame.height > 0, + private var surfaces: [ResourceKey: Surface] = [:] + private var pendingOrder = [ResourceKey]() + private let maximumSurfaceBytes: Int + private let maximumAggregateSurfaceBytes: Int + private let maximumDrainBytes: Int + private let sharedBudget: DesktopCPUPresentationBudget + private(set) var residentSurfaceBytes = 0 + private var peakResidentSurfaceBytes = 0 + private var pendingFrameDepth: UInt64 = 0 + private var peakPendingFrameDepth: UInt64 = 0 + private var stagingCopyByteCount: UInt64 = 0 + + init( + maximumSurfaceBytes: Int = 128 * 1_024 * 1_024, + maximumAggregateSurfaceBytes: Int = 256 * 1_024 * 1_024, + maximumDrainBytes: Int = 128 * 1_024 * 1_024, + sharedBudget: DesktopCPUPresentationBudget? = nil + ) { + self.maximumSurfaceBytes = max(1, maximumSurfaceBytes) + self.maximumAggregateSurfaceBytes = max( + self.maximumSurfaceBytes, + maximumAggregateSurfaceBytes + ) + // Every admitted surface must be drainable. The aggregate ceiling may be larger, but one + // main-thread delivery never materializes more copied output than this value. + self.maximumDrainBytes = max(self.maximumSurfaceBytes, maximumDrainBytes) + self.sharedBudget = sharedBudget ?? DesktopCPUPresentationBudget( + maximumResidentBytes: self.maximumAggregateSurfaceBytes + ) + } + + deinit { + sharedBudget.release(residentSurfaceBytes) + } + + func append(_ frame: VirtioGPUScanoutFrame) -> Bool { + appendOutcome(frame) == .accepted + } + + func appendOutcome(_ frame: VirtioGPUScanoutFrame) -> AppendOutcome { + let sourceRowBytes = UInt64(frame.dirtyRect.width) * 4 + let requiredSourceBytes = UInt64(frame.stride) * UInt64(frame.dirtyRect.height) + guard frame.width > 0, frame.height > 0, frame.dirtyRect.width > 0, frame.dirtyRect.height > 0, frame.dirtyRect.x <= frame.width, frame.dirtyRect.width <= frame.width - frame.dirtyRect.x, frame.dirtyRect.y <= frame.height, frame.dirtyRect.height <= frame.height - frame.dirtyRect.y, - frame.stride >= frame.dirtyRect.width * 4, - frame.bytes.count >= Int(frame.stride) * Int(frame.dirtyRect.height) else { - return false + UInt64(frame.stride) >= sourceRowBytes, + requiredSourceBytes <= UInt64(Int.max), + UInt64(frame.bytes.count) == requiredSourceBytes else { + return .invalid } - var texture = resourceTextures[frame.resourceID] - if texture?.width != Int(frame.width) - || texture?.height != Int(frame.height) - || texture?.pixelFormat != pixelFormat { - let descriptor = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: pixelFormat, - width: Int(frame.width), - height: Int(frame.height), - mipmapped: false - ) - descriptor.storageMode = .shared - descriptor.usage = [.shaderRead] - texture = device.makeTexture(descriptor: descriptor) - resourceTextures[frame.resourceID] = texture + guard let surfaceByteCount = Self.rgbaByteCount( + width: frame.width, + height: frame.height + ), surfaceByteCount <= UInt64(Int.max), + surfaceByteCount <= UInt64(maximumSurfaceBytes) else { + return .budgetExceeded } - guard let texture else { return false } - frame.bytes.withUnsafeBytes { bytes in - guard let baseAddress = bytes.baseAddress else { return } - texture.replace( - region: MTLRegionMake2D( - Int(frame.dirtyRect.x), - Int(frame.dirtyRect.y), - Int(frame.dirtyRect.width), - Int(frame.dirtyRect.height) - ), - mipmapLevel: 0, - withBytes: baseAddress, - bytesPerRow: Int(frame.stride) - ) + + let normalized = Self.normalized(frame) + let key = ResourceKey( + resourceID: frame.resourceID, + resourceGeneration: frame.resourceGeneration + ) + var surface = surfaces.removeValue(forKey: key) + if let surface, + surface.scanoutID != frame.scanoutID + || surface.format != frame.format + || surface.width != frame.width + || surface.height != frame.height { + surfaces[key] = surface + return .invalid } - scanoutTexture = texture - scanoutSize = CGSize(width: Int(frame.width), height: Int(frame.height)) - return true - } - func draw(in view: MTKView) { - guard let texture = scanoutTexture, - let descriptor = currentRenderPassDescriptor, - let drawable = currentDrawable, - let commandBuffer = commandQueue.makeCommandBuffer(), - let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { - return + let oldResidentBytes = surface?.residentBytes ?? 0 + let newResidentBytes: Int + if let surface { + switch surface.storage { + case .single(let oldFrame): + if Self.contains(normalized.frame.dirtyRect, oldFrame.dirtyRect) { + newResidentBytes = normalized.frame.bytes.count + } else { + newResidentBytes = Self.tileStorageByteCount( + rects: [oldFrame.dirtyRect, normalized.frame.dirtyRect], + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + } + case .tiled(let tiled): + if Self.isFullSurface(normalized.frame.dirtyRect, width: frame.width, height: frame.height) { + newResidentBytes = normalized.frame.bytes.count + } else { + newResidentBytes = tiled.residentBytes + Self.additionalTileStorageByteCount( + rect: normalized.frame.dirtyRect, + excluding: tiled.tiles.keys, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + } + } + } else { + newResidentBytes = normalized.frame.bytes.count } - let contentRect = scanoutContentRect(in: drawableSize) - let viewport = MTLViewport( - originX: contentRect.minX, - originY: contentRect.minY, - width: contentRect.width, - height: contentRect.height, - znear: 0, - zfar: 1 - ) - encoder.setViewport(viewport) - encoder.setRenderPipelineState(pipeline) - encoder.setFragmentTexture(texture, index: 0) - encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) - encoder.endEncoding() - commandBuffer.present(drawable) - commandBuffer.commit() - } + let residentWithoutOld = residentSurfaceBytes - oldResidentBytes + guard newResidentBytes <= maximumAggregateSurfaceBytes - residentWithoutOld else { + if let surface { surfaces[key] = surface } + return .budgetExceeded + } + guard sharedBudget.replaceReservation( + releasing: oldResidentBytes, + reserving: newResidentBytes + ) else { + if let surface { surfaces[key] = surface } + return .budgetExceeded + } - func mtkView(_ view: MTKView, drawableSizeWillChange _: CGSize) { - let guestPixelSize = CGSize( - width: max(1, bounds.width * guestBackingScaleFactor), - height: max(1, bounds.height * guestBackingScaleFactor) - ) - if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: guestPixelSize) } - let width = UInt32(clamping: max(1, Int(guestPixelSize.width.rounded()))) - let height = UInt32(clamping: max(1, Int(guestPixelSize.height.rounded()))) - resizeGeneration &+= 1 - let generation = resizeGeneration - // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce - // receives the final Retina pixel size without reallocating scanout resources per mouse - // event while the window is still moving. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in - MainActor.assumeIsolated { - guard let self, self.resizeGeneration == generation else { return } - self.onDrawableSizeChange?(width, height) + var additionalCopies = normalized.copyByteCount + if var existing = surface { + let nextInputFrameCount = Self.saturatingAdd(existing.inputFrameCount, 1) + switch existing.storage { + case .single(let oldFrame): + if Self.contains(normalized.frame.dirtyRect, oldFrame.dirtyRect) { + existing.storage = .single(normalized.frame) + } else { + let tiled = TiledStorage() + Self.apply(oldFrame, to: tiled, surfaceWidth: frame.width, surfaceHeight: frame.height) + Self.apply( + normalized.frame, + to: tiled, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(oldFrame.dirtyRect.width) * UInt64(oldFrame.dirtyRect.height) * 4 + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(normalized.frame.dirtyRect.width) + * UInt64(normalized.frame.dirtyRect.height) * 4 + ) + existing.storage = .tiled(tiled) + } + case .tiled(let tiled): + if Self.isFullSurface( + normalized.frame.dirtyRect, + width: frame.width, + height: frame.height + ) { + existing.storage = .single(normalized.frame) + } else { + Self.apply( + normalized.frame, + to: tiled, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(normalized.frame.dirtyRect.width) + * UInt64(normalized.frame.dirtyRect.height) * 4 + ) + } } + existing.inputFrameCount = nextInputFrameCount + existing.inputPayloadByteCount = Self.saturatingAdd( + existing.inputPayloadByteCount, + UInt64(frame.bytes.count) + ) + surface = existing + } else { + surface = Surface( + scanoutID: frame.scanoutID, + format: frame.format, + width: frame.width, + height: frame.height, + storage: .single(normalized.frame), + inputFrameCount: 1, + inputPayloadByteCount: UInt64(frame.bytes.count) + ) } + guard let surface else { preconditionFailure("accepted scanout update lost its surface") } + residentSurfaceBytes = residentWithoutOld + surface.residentBytes + peakResidentSurfaceBytes = max(peakResidentSurfaceBytes, residentSurfaceBytes) + pendingFrameDepth = Self.saturatingAdd(pendingFrameDepth, 1) + peakPendingFrameDepth = max(peakPendingFrameDepth, pendingFrameDepth) + stagingCopyByteCount = Self.saturatingAdd(stagingCopyByteCount, additionalCopies) + surfaces[key] = surface + pendingOrder.removeAll { $0 == key } + pendingOrder.append(key) + return .accepted } - override func keyDown(with event: NSEvent) { - if onMacShortcut?(event) == true { return } - if event.modifierFlags.contains(.command), - let character = event.charactersIgnoringModifiers?.lowercased(), - let code = Self.macCommandShortcutMap[character] { - sendControlShortcut(keyCode: code) - return + func remove(resourceID: UInt32, throughGeneration: UInt64) -> UInt64 { + removeOutcome(resourceID: resourceID, throughGeneration: throughGeneration).frameCount + } + + func removeOutcome(resourceID: UInt32, throughGeneration: UInt64) -> Removal { + let matching = surfaces.keys.filter { + $0.resourceID == resourceID && $0.resourceGeneration <= throughGeneration } - guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { - super.keyDown(with: event) - return + var removed: UInt64 = 0 + var removedPayloadBytes: UInt64 = 0 + for key in matching { + let surface = surfaces.removeValue(forKey: key) + if let surface { + residentSurfaceBytes -= surface.residentBytes + sharedBudget.release(surface.residentBytes) + pendingFrameDepth = Self.saturatingSubtract( + pendingFrameDepth, + surface.inputFrameCount + ) + removedPayloadBytes = Self.saturatingAdd( + removedPayloadBytes, + surface.inputPayloadByteCount + ) + } + removed = Self.saturatingAdd( + removed, + surface?.inputFrameCount ?? 0 + ) } - sendKeyboardTracked([ - VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1) - ]) + pendingOrder.removeAll { + $0.resourceID == resourceID && $0.resourceGeneration <= throughGeneration + } + return Removal(frameCount: removed, payloadByteCount: removedPayloadBytes) } - override func keyUp(with event: NSEvent) { - guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { - super.keyUp(with: event) + func discardPending() -> UInt64 { + discardPendingOutcome().frameCount + } + + func discardPendingOutcome() -> Removal { + var discarded: UInt64 = 0 + var discardedPayloadBytes: UInt64 = 0 + for key in pendingOrder { + guard let surface = surfaces.removeValue(forKey: key) else { continue } + discarded = Self.saturatingAdd(discarded, surface.inputFrameCount) + discardedPayloadBytes = Self.saturatingAdd( + discardedPayloadBytes, + surface.inputPayloadByteCount + ) + residentSurfaceBytes -= surface.residentBytes + sharedBudget.release(surface.residentBytes) + } + pendingFrameDepth = 0 + pendingOrder.removeAll(keepingCapacity: true) + return Removal(frameCount: discarded, payloadByteCount: discardedPayloadBytes) + } + + func drain() -> Drain { + takeDrain().materialize() + } + + func takeDrain() -> PendingDrain { + var entries = [(ResourceKey, Surface)]() + var inputFrameCount: UInt64 = 0 + var outputByteCount = 0 + var reservedByteCount = 0 + var remainingOrder = [ResourceKey]() + for key in pendingOrder { + guard let surface = surfaces[key] else { continue } + guard surface.uploadByteCount <= maximumDrainBytes - outputByteCount else { + remainingOrder.append(key) + continue + } + guard let removed = surfaces.removeValue(forKey: key) else { continue } + entries.append((key, removed)) + outputByteCount += removed.uploadByteCount + inputFrameCount = Self.saturatingAdd(inputFrameCount, surface.inputFrameCount) + reservedByteCount += removed.residentBytes + residentSurfaceBytes -= removed.residentBytes + pendingFrameDepth = Self.saturatingSubtract( + pendingFrameDepth, + removed.inputFrameCount + ) + } + pendingOrder = remainingOrder + let reservation = reservedByteCount > 0 + ? DesktopCPUPresentationDrainReservation( + budget: sharedBudget, + byteCount: reservedByteCount + ) + : nil + return PendingDrain( + entries: entries, + inputFrameCount: inputFrameCount, + outputByteCount: outputByteCount, + hasMorePendingFrames: !remainingOrder.isEmpty, + reservation: reservation + ) + } + + var metrics: Metrics { + Metrics( + residentBytes: residentSurfaceBytes, + peakResidentBytes: peakResidentSurfaceBytes, + pendingFrameDepth: pendingFrameDepth, + peakPendingFrameDepth: peakPendingFrameDepth, + stagingCopyBytes: stagingCopyByteCount + ) + } + + private static var tileEdge: UInt32 { + let pagePixels = max(1, Int(HostPage.size) / 4) + var edge = 1 + while edge < 64, (edge * 2) * (edge * 2) <= pagePixels { edge *= 2 } + return UInt32(edge) + } + + private static func normalized( + _ frame: VirtioGPUScanoutFrame + ) -> (frame: VirtioGPUScanoutFrame, copyByteCount: UInt64) { + let tightStride = Int(frame.dirtyRect.width) * 4 + let tightByteCount = tightStride * Int(frame.dirtyRect.height) + if Int(frame.stride) == tightStride, frame.bytes.count == tightByteCount { + return (frame, 0) + } + var output = Data(count: tightByteCount) + output.withUnsafeMutableBytes { destination in + frame.bytes.withUnsafeBytes { source in + guard let destinationBase = destination.baseAddress, + let sourceBase = source.baseAddress else { return } + for row in 0.. Bool { + outer.x <= inner.x + && outer.y <= inner.y + && UInt64(outer.x) + UInt64(outer.width) + >= UInt64(inner.x) + UInt64(inner.width) + && UInt64(outer.y) + UInt64(outer.height) + >= UInt64(inner.y) + UInt64(inner.height) + } + + private static func isFullSurface(_ rect: VirtioGPURect, width: UInt32, height: UInt32) -> Bool { + rect.x == 0 && rect.y == 0 && rect.width == width && rect.height == height + } + + private static func tileKeys(for rect: VirtioGPURect) -> [TileKey] { + let edge = tileEdge + let lastColumn = (rect.x + rect.width - 1) / edge + let lastRow = (rect.y + rect.height - 1) / edge + var keys = [TileKey]() + keys.reserveCapacity( + Int(lastColumn - rect.x / edge + 1) * Int(lastRow - rect.y / edge + 1) + ) + for row in rect.y / edge...lastRow { + for column in rect.x / edge...lastColumn { + keys.append(TileKey(column: column, row: row)) + } + } + return keys + } + + private static func tileByteCount( + key: TileKey, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let originX = key.column * tileEdge + let originY = key.row * tileEdge + let width = min(tileEdge, surfaceWidth - originX) + let height = min(tileEdge, surfaceHeight - originY) + return Int(width) * Int(height) * 4 + } + + private static func tileStorageByteCount( + rects: [VirtioGPURect], + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let keys = Set(rects.flatMap(tileKeys(for:))) + return keys.reduce(0) { + $0 + tileByteCount(key: $1, surfaceWidth: surfaceWidth, surfaceHeight: surfaceHeight) + } + } + + private static func additionalTileStorageByteCount( + rect: VirtioGPURect, + excluding existing: Dictionary.Keys, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let existing = Set(existing) + return tileKeys(for: rect).reduce(0) { total, key in + total + (existing.contains(key) ? 0 : tileByteCount( + key: key, + surfaceWidth: surfaceWidth, + surfaceHeight: surfaceHeight + )) + } + } + + private static func apply( + _ frame: VirtioGPUScanoutFrame, + to tiled: TiledStorage, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) { + let edge = tileEdge + frame.bytes.withUnsafeBytes { source in + guard let sourceBase = source.baseAddress else { return } + for key in tileKeys(for: frame.dirtyRect) { + let originX = key.column * edge + let originY = key.row * edge + let tileWidth = Int(min(edge, surfaceWidth - originX)) + let tileHeight = Int(min(edge, surfaceHeight - originY)) + var tile = tiled.tiles.removeValue(forKey: key) ?? Tile( + width: tileWidth, + height: tileHeight, + bytes: Data(count: tileWidth * tileHeight * 4), + dirtyRows: [UInt64](repeating: 0, count: tileHeight), + dirtyPixelCount: 0 + ) + if tile.dirtyPixelCount == 0 { tiled.residentBytes += tile.bytes.count } + + let intersectionX = max(frame.dirtyRect.x, originX) + let intersectionY = max(frame.dirtyRect.y, originY) + let intersectionRight = min( + UInt64(frame.dirtyRect.x) + UInt64(frame.dirtyRect.width), + UInt64(originX) + UInt64(tileWidth) + ) + let intersectionBottom = min( + UInt64(frame.dirtyRect.y) + UInt64(frame.dirtyRect.height), + UInt64(originY) + UInt64(tileHeight) + ) + let copyWidth = Int(intersectionRight - UInt64(intersectionX)) + let copyHeight = Int(intersectionBottom - UInt64(intersectionY)) + let localX = Int(intersectionX - originX) + let localY = Int(intersectionY - originY) + let sourceX = Int(intersectionX - frame.dirtyRect.x) + let sourceY = Int(intersectionY - frame.dirtyRect.y) + let dirtyMask: UInt64 = copyWidth == 64 + ? .max + : ((UInt64(1) << UInt64(copyWidth)) - 1) << UInt64(localX) + + tile.bytes.withUnsafeMutableBytes { destination in + guard let destinationBase = destination.baseAddress else { return } + for row in 0.. [VirtioGPURect] { + var spansByRow: [UInt32: [HorizontalSpan]] = [:] + let sortedTiles = tiled.tiles.sorted { + ($0.key.row, $0.key.column) < ($1.key.row, $1.key.column) + } + for (key, tile) in sortedTiles { + for localY in 0.. VirtioGPUScanoutFrame { + let stride = Int(rect.width) * 4 + var output = Data(count: stride * Int(rect.height)) + output.withUnsafeMutableBytes { destination in + guard let destinationBase = destination.baseAddress else { return } + for row in 0.. UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private static func saturatingSubtract(_ value: UInt64, _ decrement: UInt64) -> UInt64 { + value >= decrement ? value - decrement : 0 + } + + private static func rgbaByteCount(width: UInt32, height: UInt32) -> UInt64? { + let (pixels, pixelOverflow) = UInt64(width).multipliedReportingOverflow( + by: UInt64(height) + ) + guard !pixelOverflow else { return nil } + let (bytes, byteOverflow) = pixels.multipliedReportingOverflow(by: 4) + return byteOverflow ? nil : bytes + } +} + +struct DesktopCPUFramePresentationResult { + var presented: [Bool] + var uploadedByteCount: UInt64 +} + +/// One AppKit surface owns keyboard, pointer, cursor, resize, and scanout geometry semantics for +/// the qualified Metal display. Presentation subclasses implement only their resource boundary; +/// they cannot silently substitute another renderer when their own validation or device fails. +@MainActor +class DesktopDisplayView: NSView { + private let keyboardInput: VirtioInput + private let pointerInput: VirtioInput + private let guestBackingScaleFactor: CGFloat + let scanoutID: UInt32 + private let pointerTopology: DesktopPointerTopology? + var scanoutSize = CGSize.zero + private var guestCursor = NSCursor.arrow + private var guestCursorUpdate: VirtioGPUCursorUpdate? + private var tracking: NSTrackingArea? + private var scrollAccumulator = VirtioInputScrollAccumulator() + private var pressedKeyboardInput = VirtioInputPressedState() + private var pressedPointerInput = VirtioInputPressedState() + private var resizeGeneration: UInt64 = 0 + var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? + var onMacShortcut: ((NSEvent) -> Bool)? + + init( + frame: NSRect, + keyboardInput: VirtioInput, + pointerInput: VirtioInput, + guestBackingScaleFactor: CGFloat, + scanoutID: UInt32, + pointerTopology: DesktopPointerTopology? + ) { + self.keyboardInput = keyboardInput + self.pointerInput = pointerInput + self.guestBackingScaleFactor = guestBackingScaleFactor + self.scanoutID = scanoutID + self.pointerTopology = pointerTopology + super.init(frame: frame) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + drawableSurfaceDidChange() + needsDisplay = true + } + + /// A dedicated guest display is often not the key macOS window yet (notably immediately after + /// entering fullscreen). Activation and guest input delivery intentionally share that click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func updateTrackingAreas() { + if let tracking { removeTrackingArea(tracking) } + let replacement = NSTrackingArea( + rect: bounds, + options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(replacement) + tracking = replacement + super.updateTrackingAreas() + } + + /// Presentation hooks deliberately reject by default. A mismatched producer/view pairing is a + /// terminal capability error at the mailbox instead of an ambient graphics fallback. + @discardableResult + func present(_ frames: [VirtioGPUScanoutFrame]) -> DesktopCPUFramePresentationResult { + DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + + @discardableResult + func present(_ update: VirtioGPUMetalScanoutUpdate) -> Bool { false } + + func release(resourceID: UInt32, throughGeneration: UInt64) {} + func disable() {} + func drawableSurfaceDidChange() {} + + func presentCursor(_ update: VirtioGPUCursorUpdate?) { + guestCursorUpdate = update + rebuildGuestCursor() + } + + private func rebuildGuestCursor(pixelSize: CGSize? = nil) { + guard let update = guestCursorUpdate else { + guestCursor = Self.transparentCursor + window?.invalidateCursorRects(for: self) + return + } + let effectivePixelSize = pixelSize + ?? (scanoutSize.width > 0 ? scanoutSize : CGSize( + width: bounds.width * guestBackingScaleFactor, + height: bounds.height * guestBackingScaleFactor + )) + let cursorScale = bounds.width > 0 + ? max(1, effectivePixelSize.width / bounds.width) + : CGFloat(1) + guestCursor = Self.makeCursor(update, scale: cursorScale) ?? Self.transparentCursor + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: guestCursor) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + drawableSurfaceDidChange() + let guestPixelSize = CGSize( + width: max(1, bounds.width * guestBackingScaleFactor), + height: max(1, bounds.height * guestBackingScaleFactor) + ) + if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: guestPixelSize) } + let width = UInt32(clamping: max(1, Int(guestPixelSize.width.rounded()))) + let height = UInt32(clamping: max(1, Int(guestPixelSize.height.rounded()))) + resizeGeneration &+= 1 + let generation = resizeGeneration + // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce + // receives the final Retina pixel size without reallocating scanout resources per event. + DesktopAppRunLoop.perform(after: 0.12) { [weak self] in + guard let self, self.resizeGeneration == generation else { return } + self.onDrawableSizeChange?(width, height) + } + needsDisplay = true + } + + override func keyDown(with event: NSEvent) { + if onMacShortcut?(event) == true { return } + if event.modifierFlags.contains(.command), + let character = event.charactersIgnoringModifiers?.lowercased(), + let code = Self.macCommandShortcutMap[character] { + sendControlShortcut(keyCode: code) + return + } + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyDown(with: event) + return + } + sendKeyboardTracked([ + VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1) + ]) + } + + override func keyUp(with event: NSEvent) { + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyUp(with: event) return } sendKeyboardTracked([VirtioInputEvent(type: 1, code: code, value: 0)]) @@ -492,24 +1287,11 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { localX: normalizedX, localY: normalizedY ) ?? CGPoint(x: normalizedX, y: normalizedY) - if button != nil, - ProcessInfo.processInfo.environment["DORY_INPUT_TRACE"] == "1" { - let trace = - "dory-input: map point=\(point.x),\(point.y) bounds=\(bounds.width)x\(bounds.height) " - + "content=\(contentRect.minX),\(contentRect.minY),\(contentRect.width)x\(contentRect.height) " - + "scanout=\(scanoutSize.width)x\(scanoutSize.height) drawable=\(drawableSize.width)x\(drawableSize.height) " - + "local=\(normalizedX),\(normalizedY) guest=\(guestPoint.x),\(guestPoint.y) " - + "button=\(button!) value=\(pressed ? 1 : 0) key=\(window?.isKeyWindow == true)\n" - FileHandle.standardError.write(Data(trace.utf8)) - } var frame = [ VirtioInputEvent(type: 3, code: 0, value: Int32((guestPoint.x * 32_767).rounded())), VirtioInputEvent(type: 3, code: 1, value: Int32((guestPoint.y * 32_767).rounded())), ] if let button { - // A click is one evdev report: exact position and button state become - // visible together at SYN_REPORT. Splitting them lets a compositor - // associate a button transition with an older tablet position. frame.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) } sendPointerTracked(frame) @@ -533,7 +1315,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { pointerInput.send(frame: events) } - private func scanoutContentRect(in targetSize: CGSize) -> CGRect { + func scanoutContentRect(in targetSize: CGSize) -> CGRect { guard scanoutSize.width > 0, scanoutSize.height > 0, targetSize.width > 0, targetSize.height > 0 else { return CGRect(origin: .zero, size: targetSize) @@ -558,14 +1340,6 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { ) } - private static func pixelFormat(for virtioFormat: UInt32) -> MTLPixelFormat? { - switch virtioFormat { - case 1, 2: .bgra8Unorm - case 3, 4, 67, 68, 121, 134: .rgba8Unorm - default: nil - } - } - private static let transparentCursor: NSCursor = { let image = NSImage( size: NSSize(width: 1, height: 1), @@ -608,7 +1382,6 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { height: CGFloat(update.height) / scale ) ) - // VirtIO and NSCursor both express cursor hotspots from the image's top-left. return NSCursor( image: cursorImage, hotSpot: NSPoint( @@ -629,9 +1402,7 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { } } - private static func linuxKeyCode(macKeyCode: UInt16) -> UInt16? { - keyMap[macKeyCode] - } + private static func linuxKeyCode(macKeyCode: UInt16) -> UInt16? { keyMap[macKeyCode] } private func sendControlShortcut(keyCode: UInt16) { keyboardInput.send(frame: [ @@ -644,8 +1415,6 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { ]) } - // Match the Mac muscle memory supported by mature desktop hypervisors while leaving Command+Q - // to the host application. Shift remains physically pressed for gestures such as Command+Shift+T. private static let macCommandShortcutMap: [String: UInt16] = [ "a": 30, "f": 33, "l": 38, "n": 49, "o": 24, "p": 25, "r": 19, "s": 31, "t": 20, "w": 17, "z": 44, @@ -668,36 +1437,1226 @@ final class DesktopMetalView: MTKView, MTKViewDelegate { 119: 107, 120: 60, 121: 109, 122: 59, 123: 105, 124: 106, 125: 108, 126: 103, ] +} + +final class DesktopFrameMailbox: @unchecked Sendable { + private let lock = NSLock() + private let scanoutID: UInt32 + private let coalescer: DesktopScanoutFrameCoalescer + private var pendingMetalUpdate: VirtioGPUMetalScanoutUpdate? + /// Displaced authorities are retired synchronously by their producer callback rather than + /// accumulated while AppKit is stalled. Delivery will not acknowledge any release until these + /// bounded in-flight calls have returned. + private var discardOperationsInFlight = 0 + private var pendingReleases = [VirtioGPUScanoutResourceRelease]() + private var disabled = false + private var deliveryScheduled = false + private var presentedFrameCount: UInt64 = 0 + private var droppedFrameCount: UInt64 = 0 + private var budgetRejectedFrameCount: UInt64 = 0 + private var receivedFrameByteCount: UInt64 = 0 + private var drainCopyByteCount: UInt64 = 0 + private var uploadedFrameByteCount: UInt64 = 0 + private var droppedFrameByteCount: UInt64 = 0 + private var workerScanoutProgressStages = Set() + nonisolated(unsafe) weak var view: DesktopDisplayView? + + init( + scanoutID: UInt32 = 0, + maximumCPUSurfaceBytes: Int = 128 * 1_024 * 1_024, + maximumAggregateCPUSurfaceBytes: Int = 256 * 1_024 * 1_024, + maximumInFlightCPUFrameBytes: Int = 128 * 1_024 * 1_024, + sharedCPUPresentationBudget: DesktopCPUPresentationBudget = .processDefault + ) { + self.scanoutID = scanoutID + self.coalescer = DesktopScanoutFrameCoalescer( + maximumSurfaceBytes: maximumCPUSurfaceBytes, + maximumAggregateSurfaceBytes: maximumAggregateCPUSurfaceBytes, + maximumDrainBytes: maximumInFlightCPUFrameBytes, + sharedBudget: sharedCPUPresentationBudget + ) + } + + func submit(_ frame: VirtioGPUScanoutFrame) { + lock.lock() + receivedFrameByteCount = Self.saturatingAdd( + receivedFrameByteCount, + UInt64(frame.bytes.count) + ) + let outcome = frame.scanoutID == scanoutID + ? coalescer.appendOutcome(frame) + : .invalid + guard outcome == .accepted else { + droppedFrameCount = Self.saturatingAdd(droppedFrameCount, 1) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + UInt64(frame.bytes.count) + ) + if outcome == .budgetExceeded { + budgetRejectedFrameCount = Self.saturatingAdd(budgetRejectedFrameCount, 1) + } + lock.unlock() + return + } + disabled = false + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + guard shouldSchedule else { return } + DesktopAppRunLoop.perform { [weak self] in + self?.deliver() + } + } + + /// A worker update carries descriptor authority rather than frame bytes. Only one unpublished + /// authority may wait for AppKit per scanout; replacement retires the displaced lease before + /// any resource-release acknowledgement can overtake it. + func submit(_ update: VirtioGPUMetalScanoutUpdate) { + logWorkerScanoutProgress(stage: "mailbox-submit") + lock.lock() + guard update.scanoutID == scanoutID else { + lock.unlock() + update.rejectHostSubmission() + update.presentation.discardWithoutPresentation() + return + } + let displaced = pendingMetalUpdate + if displaced != nil { discardOperationsInFlight += 1 } + pendingMetalUpdate = update + disabled = false + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displaced { retireDisplacedPresentation(displaced) } + if shouldSchedule { + logWorkerScanoutProgress(stage: "mailbox-delivery-scheduled") + scheduleDelivery() + } + } + + /// Drop presentation storage only when the guest destroys the corresponding virtio-gpu + /// resource. Direct scanout temporarily switches between application and compositor buffers; + /// evicting an older compositor buffer merely because it was not recently visible makes its + /// next partial update appear as a black or torn frame. + func release(_ release: VirtioGPUScanoutResourceRelease) { + lock.lock() + let removal = coalescer.removeOutcome( + resourceID: release.resourceID, + throughGeneration: release.resourceGeneration + ) + let displacedMetal: VirtioGPUMetalScanoutUpdate? + if let update = pendingMetalUpdate, + update.resourceID == release.resourceID, + update.resourceGeneration <= release.resourceGeneration { + pendingMetalUpdate = nil + displacedMetal = update + discardOperationsInFlight += 1 + } else { + displacedMetal = nil + } + pendingReleases.append(release) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + removal.frameCount + ) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + removal.payloadByteCount + ) + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displacedMetal { retireDisplacedPresentation(displacedMetal) } + if shouldSchedule { scheduleDelivery() } + } + + func disable() { + lock.lock() + let discarded = coalescer.discardPendingOutcome() + let displacedMetal = pendingMetalUpdate + if displacedMetal != nil { discardOperationsInFlight += 1 } + pendingMetalUpdate = nil + disabled = true + droppedFrameCount = Self.saturatingAdd(droppedFrameCount, discarded.frameCount) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + discarded.payloadByteCount + ) + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displacedMetal { retireDisplacedPresentation(displacedMetal) } + if shouldSchedule { scheduleDelivery() } + } + + @MainActor + func deliver() { + lock.lock() + guard discardOperationsInFlight == 0 else { + // The final retiring producer schedules another delivery. Returning keeps AppKit + // responsive and, critically, prevents a release acknowledgement from overtaking it. + deliveryScheduled = false + lock.unlock() + return + } + let pendingDrain = coalescer.takeDrain() + let metalUpdate = pendingMetalUpdate + let releases = pendingReleases + let shouldDisable = disabled + pendingMetalUpdate = nil + pendingReleases.removeAll(keepingCapacity: true) + deliveryScheduled = false + lock.unlock() + if metalUpdate != nil { + logWorkerScanoutProgress(stage: "mailbox-deliver") + } + // Sparse materialization can copy accumulated damage. Keep it off the producer lock so a + // vCPU never waits behind main-thread row packing; the single-update path performs no work. + let drain = pendingDrain.materialize() + for release in releases { + view?.release( + resourceID: release.resourceID, + throughGeneration: release.resourceGeneration + ) + release.acknowledge(scanoutID: scanoutID) + } + if shouldDisable { + view?.disable() + } + let frames = drain.frames + let framePresentation: DesktopCPUFramePresentationResult + if shouldDisable { + framePresentation = DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } else { + framePresentation = view?.present(frames) ?? DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + var presentedInputs: UInt64 = 0 + var droppedInputs: UInt64 = 0 + var droppedInputBytes: UInt64 = 0 + for batch in drain.batches { + let complete = batch.frameRange.allSatisfy { + framePresentation.presented.indices.contains($0) + && framePresentation.presented[$0] + } + if complete { + presentedInputs = Self.saturatingAdd( + presentedInputs, + batch.inputFrameCount + ) + } else { + droppedInputs = Self.saturatingAdd(droppedInputs, batch.inputFrameCount) + droppedInputBytes = Self.saturatingAdd( + droppedInputBytes, + batch.inputPayloadByteCount + ) + } + } + let presentedMetal: Bool + if shouldDisable { + metalUpdate?.rejectHostSubmission() + metalUpdate?.presentation.discardWithoutPresentation() + presentedMetal = false + } else if let metalUpdate { + logWorkerScanoutProgress(stage: "view-present-enter") + presentedMetal = view?.present(metalUpdate) == true + logWorkerScanoutProgress( + stage: presentedMetal ? "view-present-accepted" : "view-present-rejected" + ) + if presentedMetal { + metalUpdate.acceptHostSubmission() + } else { + metalUpdate.rejectHostSubmission() + metalUpdate.presentation.discardWithoutPresentation() + } + } else { + presentedMetal = false + } + lock.withLock { + presentedFrameCount = Self.saturatingAdd( + presentedFrameCount, + presentedInputs + + (presentedMetal ? 1 : 0) + ) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + droppedInputs + ) + drainCopyByteCount = Self.saturatingAdd( + drainCopyByteCount, + UInt64(drain.copyByteCount) + ) + uploadedFrameByteCount = Self.saturatingAdd( + uploadedFrameByteCount, + framePresentation.uploadedByteCount + ) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + droppedInputBytes + ) + } + if drain.hasMorePendingFrames { + lock.lock() + let shouldSchedule = !deliveryScheduled + if shouldSchedule { deliveryScheduled = true } + lock.unlock() + if shouldSchedule { scheduleDelivery() } + } + } + + var metrics: DesktopFrameMailboxMetrics { + lock.withLock { + let coalescerMetrics = coalescer.metrics + return DesktopFrameMailboxMetrics( + presentedFrames: presentedFrameCount, + droppedFrames: droppedFrameCount, + budgetRejectedFrames: budgetRejectedFrameCount, + receivedFrameBytes: receivedFrameByteCount, + stagingCopyBytes: coalescerMetrics.stagingCopyBytes, + drainCopyBytes: drainCopyByteCount, + uploadedFrameBytes: uploadedFrameByteCount, + droppedFrameBytes: droppedFrameByteCount, + pendingFrameBytes: UInt64(max(0, coalescerMetrics.residentBytes)), + pendingFrameDepth: coalescerMetrics.pendingFrameDepth + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private func retireDisplacedPresentation(_ update: VirtioGPUMetalScanoutUpdate) { + update.rejectHostSubmission() + update.presentation.discardWithoutPresentation() + finishDisplacedRetirement() + } + + private func finishDisplacedRetirement() { + lock.lock() + discardOperationsInFlight -= 1 + let shouldSchedule = discardOperationsInFlight == 0 && !deliveryScheduled + if shouldSchedule { deliveryScheduled = true } + lock.unlock() + if shouldSchedule { scheduleDelivery() } + } + + private func scheduleDelivery() { + DesktopAppRunLoop.perform { [weak self] in + self?.deliver() + } + } + + private func logWorkerScanoutProgress(stage: String) { + let firstOccurrence = lock.withLock { + workerScanoutProgressStages.insert(stage).inserted + } + guard firstOccurrence else { return } + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout progress scanout=\(scanoutID) stage=\(stage)\n".utf8 + )) + } +} + +/// Moves copied cursor-plane updates from a vCPU thread to AppKit without retaining the guest +/// resource or touching NSCursor off the main thread. +final class DesktopCursorMailbox: @unchecked Sendable { + nonisolated(unsafe) weak var view: DesktopDisplayView? + + func submit(_ update: VirtioGPUCursorUpdate?) { + DesktopAppRunLoop.perform { [weak self] in + self?.view?.presentCursor(update) + } + } +} + +enum DesktopMetalScanoutLayoutError: Error, Equatable { + case identityMismatch + case invalidGeometry + case metalAlignmentMismatch + case mappedLengthOverflow +} + +/// Transport-independent identity and clipping validated before either a linear SHM texture or a +/// native shared Metal texture can enter the display. The renderer resource generation is kept +/// distinct from the VMM display generation: both must match the update that owns this lease. +struct DesktopMetalScanoutGeometry: Equatable { + let resourceID: UInt32 + let rendererResourceGeneration: UInt64 + let pixelFormat: MTLPixelFormat + let width: Int + let height: Int + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + let yOriginTop: Bool + + init( + presentation: VirtioGPUMetalScanoutPresentation, + expectedScanoutID: UInt32, + updateScanoutID: UInt32, + updateResourceID: UInt32, + updateResourceGeneration: UInt64, + updateRendererResourceGeneration: UInt64, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect + ) throws { + guard updateScanoutID == expectedScanoutID, + updateResourceID == presentation.resourceID, + updateResourceGeneration != 0, + updateRendererResourceGeneration == presentation.resourceGeneration else { + throw DesktopMetalScanoutLayoutError.identityMismatch + } + guard DesktopMetalScanoutLayout.containsForCPU( + sourceRect, + width: presentation.width, + height: presentation.height + ), + DesktopMetalScanoutLayout.containsForCPU( + dirtyRect, + width: sourceRect.width, + height: sourceRect.height + ), + let width = Int(exactly: presentation.width), + let height = Int(exactly: presentation.height) else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let pixelFormat: MTLPixelFormat = switch presentation.pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + self.resourceID = presentation.resourceID + self.rendererResourceGeneration = presentation.resourceGeneration + self.pixelFormat = pixelFormat + self.width = width + self.height = height + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + self.yOriginTop = presentation.yOriginTop + } +} + +/// Revalidates the authenticated worker lease against the concrete host Metal device. The worker +/// contract deliberately permits alignments used by more than one Metal family; publication is +/// accepted only when this device can reconstruct the exact linear texture without a copy. +struct DesktopMetalScanoutLayout: Equatable { + let pixelFormat: MTLPixelFormat + let width: Int + let height: Int + let stride: Int + let storageOffset: Int + let declaredFileSize: Int + let mappedLength: Int + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + let yOriginTop: Bool + + init( + lease: DoryRendererScanoutLease, + geometry: DesktopMetalScanoutGeometry, + minimumLinearTextureAlignment: Int, + pageSize: Int, + maximumBufferLength: Int + ) throws { + guard geometry.resourceID == lease.resourceID, + geometry.rendererResourceGeneration == lease.resourceGeneration, + geometry.width == Int(lease.width), + geometry.height == Int(lease.height), + geometry.yOriginTop == lease.yOriginTop, + geometry.pixelFormat == Self.metalPixelFormat(lease.pixelFormat) else { + throw DesktopMetalScanoutLayoutError.identityMismatch + } + guard minimumLinearTextureAlignment > 0, + let metalAlignment = UInt32(exactly: minimumLinearTextureAlignment), + pageSize > 0, + pageSize.nonzeroBitCount == 1, + lease.stride % metalAlignment == 0, + lease.storageOffset % UInt64(minimumLinearTextureAlignment) == 0 else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + guard Int(exactly: lease.width) != nil, + Int(exactly: lease.height) != nil, + let stride = Int(exactly: lease.stride), + let storageOffset = Int(exactly: lease.storageOffset), + let declaredFileSize = Int(exactly: lease.declaredFileSize), + declaredFileSize > 0 else { + throw DesktopMetalScanoutLayoutError.mappedLengthOverflow + } + let remainder = declaredFileSize & (pageSize - 1) + let padding = remainder == 0 ? 0 : pageSize - remainder + let (mappedLength, overflow) = declaredFileSize.addingReportingOverflow(padding) + guard !overflow, mappedLength > 0, mappedLength <= maximumBufferLength else { + throw DesktopMetalScanoutLayoutError.mappedLengthOverflow + } + self.pixelFormat = geometry.pixelFormat + self.width = geometry.width + self.height = geometry.height + self.stride = stride + self.storageOffset = storageOffset + self.declaredFileSize = declaredFileSize + self.mappedLength = mappedLength + self.sourceRect = geometry.sourceRect + self.dirtyRect = geometry.dirtyRect + self.yOriginTop = geometry.yOriginTop + } + + static func containsForCPU( + _ rect: VirtioGPURect, + width: UInt32, + height: UInt32 + ) -> Bool { + rect.width > 0 + && rect.height > 0 + && rect.x <= width + && rect.width <= width - rect.x + && rect.y <= height + && rect.height <= height - rect.y + } + + private static func metalPixelFormat( + _ pixelFormat: DoryRendererScanoutPixelFormat + ) -> MTLPixelFormat { + switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + } +} + +private final class DesktopMetalWorkerLeaseRetirement: @unchecked Sendable { + private let lock = NSLock() + private let presentation: VirtioGPUMetalScanoutPresentation + private var presented = false + private var retired = false + + init(presentation: VirtioGPUMetalScanoutPresentation) { + self.presentation = presentation + } + + func markPresented() { + lock.withLock { + guard !retired else { return } + presented = true + } + } + + /// Metal calls this only after its last buffer/texture reference has gone away. Unmapping + /// first makes the worker release transition exactly follow GPU completion and local resource + /// destruction, rather than merely following command submission. + func releaseMapping(_ pointer: UnsafeMutableRawPointer, length: Int) { + let outcome = lock.withLock { () -> Bool? in + guard !retired else { return nil } + retired = true + return presented + } + guard let outcome else { return } + _ = munmap(pointer, length) + if outcome { + presentation.finishPresentation() + } else { + presentation.discardWithoutPresentation() + } + } + + /// A native shared texture has no CPU mapping to tear down. Its wrapper calls this only after + /// the command buffer releases its final local texture reference. + func releaseImportedTexture() { + let outcome = lock.withLock { () -> Bool? in + guard !retired else { return nil } + retired = true + return presented + } + guard let outcome else { return } + if outcome { + presentation.finishPresentation() + } else { + presentation.discardWithoutPresentation() + } + } +} + +private final class DesktopMetalWorkerScanout: @unchecked Sendable { + private var retainedTexture: (any MTLTexture)? + var texture: any MTLTexture { + precondition(retainedTexture != nil, "retired Metal worker scanout texture") + return retainedTexture! + } + private let buffer: (any MTLBuffer)? + private let retirement: DesktopMetalWorkerLeaseRetirement + private let retiresImportedTextureOnDeinit: Bool + + init( + texture: any MTLTexture, + buffer: (any MTLBuffer)?, + retirement: DesktopMetalWorkerLeaseRetirement, + retiresImportedTextureOnDeinit: Bool + ) { + self.retainedTexture = texture + self.buffer = buffer + self.retirement = retirement + self.retiresImportedTextureOnDeinit = retiresImportedTextureOnDeinit + } + + func markPresented() { + retirement.markPresented() + if retiresImportedTextureOnDeinit { + retainedTexture = nil + retirement.releaseImportedTexture() + } + } + + deinit { + if retiresImportedTextureOnDeinit { + retainedTexture = nil + retirement.releaseImportedTexture() + } + } +} + +enum DesktopMetalDisplayError: Error, CustomStringConvertible { + case deviceUnavailable + case commandQueueUnavailable + case shaderCompilationFailed(String) + case renderPipelineUnavailable(String) + case samplerUnavailable + + var description: String { + switch self { + case .deviceUnavailable: + "no Metal device is available for the desktop display" + case .commandQueueUnavailable: + "could not create the desktop Metal command queue" + case .shaderCompilationFailed(let detail): + "could not compile the desktop Metal display shader: \(detail)" + case .renderPipelineUnavailable(let detail): + "could not create the desktop Metal render pipeline: \(detail)" + case .samplerUnavailable: + "could not create the desktop Metal sampler" + } + } +} + +/// The production display boundary for both damage-proportional CPU uploads and descriptor-backed +/// worker scanout. Worker pixels remain in their SHM mapping and are sampled directly by Metal; +/// no `Data`, IOSurface, or intermediate frame allocation exists on that path. +@MainActor +final class DesktopMetalView: DesktopDisplayView { + private struct CPUTexture { + let texture: any MTLTexture + let identity: DesktopScanoutResourceIdentity + let format: UInt32 + let width: UInt32 + let height: UInt32 + } + + private let device: any MTLDevice + private let commandQueue: any MTLCommandQueue + private let pipeline: any MTLRenderPipelineState + private let sampler: any MTLSamplerState + private var cpuTextures: [UInt32: CPUTexture] = [:] + private var currentCPUTexture: CPUTexture? + private var resourceLifetime = DesktopScanoutResourceLifetime() + private var deviceFailed = false + private var workerScanoutDiagnosticStages = Set() + var onDeviceFailure: (@Sendable (String) -> Void)? + /// Fires only from a completed Metal command buffer for a worker-issued presentation. The + /// worker update itself is published only after its producer fence signals. + var onWorkerPresentationCompleted: (@Sendable (UInt64) -> Void)? + + override func makeBackingLayer() -> CALayer { + CAMetalLayer() + } + + init( + frame: NSRect, + keyboardInput: VirtioInput, + pointerInput: VirtioInput, + guestBackingScaleFactor: CGFloat = 2, + scanoutID: UInt32 = 0, + pointerTopology: DesktopPointerTopology? = nil, + device requestedDevice: (any MTLDevice)? = nil + ) throws { + guard let device = requestedDevice ?? MTLCreateSystemDefaultDevice() else { + throw DesktopMetalDisplayError.deviceUnavailable + } + guard let commandQueue = device.makeCommandQueue() else { + throw DesktopMetalDisplayError.commandQueueUnavailable + } + let library: any MTLLibrary + do { + library = try device.makeLibrary(source: Self.shaderSource, options: nil) + } catch { + throw DesktopMetalDisplayError.shaderCompilationFailed(error.localizedDescription) + } + guard let vertex = library.makeFunction(name: "doryDesktopVertex"), + let fragment = library.makeFunction(name: "doryDesktopFragment") else { + throw DesktopMetalDisplayError.shaderCompilationFailed("required functions missing") + } + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.label = "Dory desktop scanout" + pipelineDescriptor.vertexFunction = vertex + pipelineDescriptor.fragmentFunction = fragment + pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + let pipeline: any MTLRenderPipelineState + do { + pipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + throw DesktopMetalDisplayError.renderPipelineUnavailable(error.localizedDescription) + } + let samplerDescriptor = MTLSamplerDescriptor() + samplerDescriptor.minFilter = .linear + samplerDescriptor.magFilter = .linear + samplerDescriptor.sAddressMode = .clampToEdge + samplerDescriptor.tAddressMode = .clampToEdge + guard let sampler = device.makeSamplerState(descriptor: samplerDescriptor) else { + throw DesktopMetalDisplayError.samplerUnavailable + } + self.device = device + self.commandQueue = commandQueue + self.pipeline = pipeline + self.sampler = sampler + super.init( + frame: frame, + keyboardInput: keyboardInput, + pointerInput: pointerInput, + guestBackingScaleFactor: guestBackingScaleFactor, + scanoutID: scanoutID, + pointerTopology: pointerTopology + ) + wantsLayer = true + guard let metalLayer = layer as? CAMetalLayer else { + throw DesktopMetalDisplayError.deviceUnavailable + } + metalLayer.device = device + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.maximumDrawableCount = 3 + metalLayer.allowsNextDrawableTimeout = true + metalLayer.presentsWithTransaction = false + drawableSurfaceDidChange() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawableSurfaceDidChange() { + guard let metalLayer = layer as? CAMetalLayer else { return } + let size = convertToBacking(bounds).size + guard size.width > 0, size.height > 0 else { return } + metalLayer.drawableSize = CGSize( + width: max(1, size.width.rounded()), + height: max(1, size.height.rounded()) + ) + metalLayer.contentsScale = window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor + ?? 1 + } + + override func present( + _ frames: [VirtioGPUScanoutFrame] + ) -> DesktopCPUFramePresentationResult { + guard !deviceFailed else { + return DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + var presented = [Bool]() + presented.reserveCapacity(frames.count) + var uploadedBytes: UInt64 = 0 + for frame in frames { + let accepted = upload(frame) + presented.append(accepted) + if accepted { + uploadedBytes = Self.saturatingAdd( + uploadedBytes, + UInt64(frame.dirtyRect.width) * UInt64(frame.dirtyRect.height) * 4 + ) + } + } + if presented.contains(true), let currentCPUTexture { + let committed = render( + texture: currentCPUTexture.texture, + sourceRect: VirtioGPURect( + x: 0, + y: 0, + width: currentCPUTexture.width, + height: currentCPUTexture.height + ), + backingWidth: currentCPUTexture.width, + backingHeight: currentCPUTexture.height, + yOriginTop: true, + workerScanout: nil + ) + if !committed { + presented = [Bool](repeating: false, count: presented.count) + } + } + return DesktopCPUFramePresentationResult( + presented: presented, + uploadedByteCount: uploadedBytes + ) + } + + override func present(_ update: VirtioGPUMetalScanoutUpdate) -> Bool { + logWorkerScanoutProgress(stage: "view-present") + guard !deviceFailed else { + logWorkerScanoutRejection(stage: "device-failed") + return false + } + let geometry: DesktopMetalScanoutGeometry + do { + geometry = try DesktopMetalScanoutGeometry( + presentation: update.presentation, + expectedScanoutID: scanoutID, + updateScanoutID: update.scanoutID, + updateResourceID: update.resourceID, + updateResourceGeneration: update.resourceGeneration, + updateRendererResourceGeneration: update.rendererResourceGeneration, + sourceRect: update.sourceRect, + dirtyRect: update.dirtyRect + ) + } catch { + logWorkerScanoutRejection( + stage: "layout", + detail: String(describing: error) + ) + return false + } + logWorkerScanoutProgress(stage: "layout-validated") + let identity = DesktopScanoutResourceIdentity(metalUpdate: update) + guard resourceLifetime.accepts(identity) else { + logWorkerScanoutRejection(stage: "resource-lifetime-admission") + return false + } + let workerScanout: DesktopMetalWorkerScanout + do { + workerScanout = try importScanout(update: update, geometry: geometry) + } catch { + logWorkerScanoutRejection( + stage: "worker-metal-import", + detail: String(describing: error) + ) + return false + } + logWorkerScanoutProgress(stage: "worker-metal-imported") + guard resourceLifetime.bind(identity) else { + logWorkerScanoutRejection(stage: "resource-lifetime-bind") + return false + } + scanoutSize = CGSize( + width: Int(update.sourceRect.width), + height: Int(update.sourceRect.height) + ) + guard render( + texture: workerScanout.texture, + sourceRect: update.sourceRect, + backingWidth: update.presentation.width, + backingHeight: update.presentation.height, + yOriginTop: geometry.yOriginTop, + workerScanout: workerScanout, + completion: { [onWorkerPresentationCompleted] completed in + guard completed else { return } + onWorkerPresentationCompleted?( + update.presentation.workerGeneration.rawValue + ) + } + ) else { + logWorkerScanoutRejection(stage: "render-submission") + resourceLifetime.unbind() + return false + } + logWorkerScanoutProgress(stage: "metal-command-buffer-committed") + currentCPUTexture = nil + return true + } + + override func release(resourceID: UInt32, throughGeneration: UInt64) { + if let texture = cpuTextures[resourceID], + texture.identity.generation <= throughGeneration { + cpuTextures.removeValue(forKey: resourceID) + } + if currentCPUTexture?.identity.resourceID == resourceID, + let currentCPUTexture, + currentCPUTexture.identity.generation <= throughGeneration { + self.currentCPUTexture = nil + } + if resourceLifetime.release( + resourceID: resourceID, + throughGeneration: throughGeneration + ) { + currentCPUTexture = nil + } + } + + override func disable() { + resourceLifetime.unbind() + currentCPUTexture = nil + _ = render( + texture: nil, + sourceRect: VirtioGPURect(x: 0, y: 0, width: 1, height: 1), + backingWidth: 1, + backingHeight: 1, + yOriginTop: true, + workerScanout: nil + ) + } + + override func draw(_ dirtyRect: NSRect) { + guard let currentCPUTexture else { return } + _ = render( + texture: currentCPUTexture.texture, + sourceRect: VirtioGPURect( + x: 0, + y: 0, + width: currentCPUTexture.width, + height: currentCPUTexture.height + ), + backingWidth: currentCPUTexture.width, + backingHeight: currentCPUTexture.height, + yOriginTop: true, + workerScanout: nil + ) + } + + private func upload(_ frame: VirtioGPUScanoutFrame) -> Bool { + let identity = DesktopScanoutResourceIdentity(frame: frame) + guard let pixelFormat = Self.pixelFormat(for: frame.format), + frame.scanoutID == scanoutID, + frame.width > 0, frame.height > 0, + DesktopMetalScanoutLayout.containsForCPU( + frame.dirtyRect, + width: frame.width, + height: frame.height + ), + UInt64(frame.stride) >= UInt64(frame.dirtyRect.width) * 4, + UInt64(frame.bytes.count) + >= UInt64(frame.stride) * UInt64(frame.dirtyRect.height), + resourceLifetime.accepts(identity) else { + return false + } + var texture = cpuTextures[frame.resourceID] + if texture?.identity != identity + || texture?.width != frame.width + || texture?.height != frame.height + || texture?.format != frame.format { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: pixelFormat, + width: Int(frame.width), + height: Int(frame.height), + mipmapped: false + ) + descriptor.usage = [.shaderRead] + descriptor.storageMode = .shared + guard let created = device.makeTexture(descriptor: descriptor) else { return false } + texture = CPUTexture( + texture: created, + identity: identity, + format: frame.format, + width: frame.width, + height: frame.height + ) + cpuTextures[frame.resourceID] = texture + } + guard let texture else { return false } + let region = MTLRegionMake2D( + Int(frame.dirtyRect.x), + Int(frame.dirtyRect.y), + Int(frame.dirtyRect.width), + Int(frame.dirtyRect.height) + ) + frame.bytes.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + texture.texture.replace( + region: region, + mipmapLevel: 0, + withBytes: baseAddress, + bytesPerRow: Int(frame.stride) + ) + } + guard resourceLifetime.bind(identity) else { return false } + currentCPUTexture = texture + scanoutSize = CGSize(width: Int(frame.width), height: Int(frame.height)) + return true + } + + private func importScanout( + update: VirtioGPUMetalScanoutUpdate, + geometry: DesktopMetalScanoutGeometry + ) throws -> DesktopMetalWorkerScanout { + switch update.presentation.transport { + case .sharedMemory: + return try update.presentation.withSharedMemoryScanout { lease, descriptor in + let layout = try DesktopMetalScanoutLayout( + lease: lease, + geometry: geometry, + minimumLinearTextureAlignment: device.minimumLinearTextureAlignment( + for: geometry.pixelFormat + ), + pageSize: Int(getpagesize()), + maximumBufferLength: device.maxBufferLength + ) + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_size >= 0, + UInt64(status.st_size) == lease.declaredFileSize, + (status.st_mode & S_IFMT) == S_IFREG + || (status.st_mode & S_IFMT) == 0 else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + guard let mapping = mmap( + nil, + layout.mappedLength, + PROT_READ, + MAP_SHARED, + descriptor, + 0 + ), mapping != MAP_FAILED else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let retirement = DesktopMetalWorkerLeaseRetirement( + presentation: update.presentation + ) + guard let buffer = device.makeBuffer( + bytesNoCopy: mapping, + length: layout.mappedLength, + options: [.storageModeShared, .hazardTrackingModeTracked], + deallocator: { pointer, length in + retirement.releaseMapping(pointer, length: length) + } + ) else { + retirement.releaseMapping(mapping, length: layout.mappedLength) + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: layout.pixelFormat, + width: layout.width, + height: layout.height, + mipmapped: false + ) + textureDescriptor.usage = [.shaderRead] + textureDescriptor.storageMode = .shared + guard let texture = buffer.makeTexture( + descriptor: textureDescriptor, + offset: layout.storageOffset, + bytesPerRow: layout.stride + ) else { + throw DesktopMetalScanoutLayoutError.metalAlignmentMismatch + } + return DesktopMetalWorkerScanout( + texture: texture, + buffer: buffer, + retirement: retirement, + retiresImportedTextureOnDeinit: false + ) + } + case .sharedTexture: + return try update.presentation.withSharedTextureHandle { handle in + guard let texture = device.makeSharedTexture(handle: handle), + texture.device === device, + texture.textureType == .type2D, + texture.pixelFormat == geometry.pixelFormat, + texture.width == geometry.width, + texture.height == geometry.height, + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains(.shaderRead) else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + return DesktopMetalWorkerScanout( + texture: texture, + buffer: nil, + retirement: DesktopMetalWorkerLeaseRetirement( + presentation: update.presentation + ), + retiresImportedTextureOnDeinit: true + ) + } + } + } + + private func render( + texture: (any MTLTexture)?, + sourceRect: VirtioGPURect, + backingWidth: UInt32, + backingHeight: UInt32, + yOriginTop: Bool, + workerScanout: DesktopMetalWorkerScanout?, + completion: (@Sendable (Bool) -> Void)? = nil + ) -> Bool { + guard !deviceFailed else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-device-failed") + } + return false + } + guard let metalLayer = layer as? CAMetalLayer else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-layer-unavailable") + } + return false + } + guard metalLayer.device === device else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-device-mismatch") + } + return false + } + guard let drawable = metalLayer.nextDrawable() else { + if workerScanout != nil { + let drawableSize = metalLayer.drawableSize + logWorkerScanoutRejection( + stage: "render-drawable-unavailable", + detail: "window=\(window != nil) visible=\(window?.isVisible ?? false) " + + "bounds=\(Int(bounds.width))x\(Int(bounds.height)) " + + "drawable=\(Int(drawableSize.width))x\(Int(drawableSize.height))" + ) + } + return false + } + guard let commandBuffer = commandQueue.makeCommandBuffer() else { + failDevice("Metal command buffer allocation failed") + return false + } + commandBuffer.label = "Dory desktop present" + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = drawable.texture + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColor( + red: 0.025, + green: 0.03, + blue: 0.04, + alpha: 1 + ) + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { + failDevice("Metal render encoder allocation failed") + return false + } + if let texture { + let target = scanoutContentRect(in: metalLayer.drawableSize) + encoder.setViewport(MTLViewport( + originX: target.minX, + originY: target.minY, + width: target.width, + height: target.height, + znear: 0, + zfar: 1 + )) + var sourceUV = DesktopScanoutTextureCoordinates.sourceUV( + sourceRect: sourceRect, + backingWidth: backingWidth, + backingHeight: backingHeight, + yOriginTop: yOriginTop + ) + encoder.setRenderPipelineState(pipeline) + encoder.setVertexBytes(&sourceUV, length: MemoryLayout>.stride, index: 0) + encoder.setFragmentTexture(texture, index: 0) + encoder.setFragmentSamplerState(sampler, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + } + encoder.endEncoding() + let failureSink = onDeviceFailure + commandBuffer.addCompletedHandler { buffer in + if buffer.status == .completed { + workerScanout?.markPresented() + completion?(true) + } else if let failureSink { + completion?(false) + let reason = buffer.error?.localizedDescription + ?? "Metal presentation ended with status \(buffer.status.rawValue)" + failureSink(reason) + } else { + completion?(false) + } + } + commandBuffer.present(drawable) + commandBuffer.commit() + return true + } + + private func logWorkerScanoutRejection(stage: String, detail: String = "") { + let key = "failure:\(stage)" + guard workerScanoutDiagnosticStages.insert(key).inserted else { return } + let suffix = detail.isEmpty ? "" : " detail=\(detail)" + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout rejected stage=\(stage)\(suffix)\n".utf8 + )) + } + + private func logWorkerScanoutProgress(stage: String) { + let key = "progress:\(stage)" + guard workerScanoutDiagnosticStages.insert(key).inserted else { return } + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout progress scanout=\(scanoutID) stage=\(stage)\n".utf8 + )) + } + + private func failDevice(_ reason: String) { + guard !deviceFailed else { return } + deviceFailed = true + resourceLifetime.unbind() + currentCPUTexture = nil + onDeviceFailure?(reason) + } + + private static func pixelFormat(for virtioFormat: UInt32) -> MTLPixelFormat? { + switch virtioFormat { + case 1, 2: .bgra8Unorm + case 3, 4, 67, 68, 121, 134: .rgba8Unorm + default: nil + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } private static let shaderSource = """ #include using namespace metal; - struct RasterData { + struct DoryDesktopVertexOutput { float4 position [[position]]; float2 textureCoordinate; }; - vertex RasterData dory_scanout_vertex(uint vertexID [[vertex_id]]) { - const float2 positions[4] = { - float2(-1.0, -1.0), float2(1.0, -1.0), - float2(-1.0, 1.0), float2(1.0, 1.0) + vertex DoryDesktopVertexOutput doryDesktopVertex( + uint vertexID [[vertex_id]], + constant float4 &sourceUV [[buffer(0)]]) { + const float2 positions[6] = { + float2(-1.0, -1.0), float2( 1.0, -1.0), float2(-1.0, 1.0), + float2(-1.0, 1.0), float2( 1.0, -1.0), float2( 1.0, 1.0) }; - const float2 coordinates[4] = { - float2(0.0, 1.0), float2(1.0, 1.0), - float2(0.0, 0.0), float2(1.0, 0.0) + const float2 unitCoordinates[6] = { + float2(0.0, 0.0), float2(1.0, 0.0), float2(0.0, 1.0), + float2(0.0, 1.0), float2(1.0, 0.0), float2(1.0, 1.0) }; - RasterData output; + DoryDesktopVertexOutput output; output.position = float4(positions[vertexID], 0.0, 1.0); - output.textureCoordinate = coordinates[vertexID]; + float2 unit = unitCoordinates[vertexID]; + output.textureCoordinate = float2( + mix(sourceUV.x, sourceUV.z, unit.x), + mix(sourceUV.y, sourceUV.w, unit.y)); return output; } - fragment float4 dory_scanout_fragment( - RasterData input [[stage_in]], - texture2d scanout [[texture(0)]]) { - constexpr sampler textureSampler(coord::normalized, filter::linear); - return scanout.sample(textureSampler, input.textureCoordinate); + fragment half4 doryDesktopFragment( + DoryDesktopVertexOutput input [[stage_in]], + texture2d source [[texture(0)]], + sampler sourceSampler [[sampler(0)]]) { + return source.sample(sourceSampler, input.textureCoordinate); } """ } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift index 61f3353b..2d78f7ae 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -3,6 +3,7 @@ import Darwin import DoryCore import DoryHV import DoryOperations +import DoryVMContracts import DorydKit import DoryVMMKit import Foundation @@ -15,8 +16,11 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { var storage: VirtioBlk? var network: VirtioNet? var sharedDirectory: VirtioFS? + var input: VirtioInput? var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? var displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? + var presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? var graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] var previousTransportStatistics: VirtioMMIOTransportStatistics? @@ -62,6 +66,8 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { transport: VirtioMMIOTransport, audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil, displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? = nil, + presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? = nil, graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? = nil ) { let effectiveGraphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? @@ -91,6 +97,14 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { unavailable.append(contentsOf: [ (.displayFrames, .count), (.displayDrops, .count), + (.displayBudgetRejectedFrames, .count), + ]) + } + if presentationBudgetMetrics == nil { + unavailable.append(contentsOf: [ + (.graphicsPresentationResidentBytes, .bytes), + (.graphicsPresentationPeakResidentBytes, .bytes), + (.graphicsPresentationRejectedReservations, .count), ]) } case is VirtioSound: @@ -105,11 +119,22 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { (.transmittedFrames, .count), (.transmittedBytes, .bytes), (.transmitDrops, .count), + (.transmitMalformed, .count), + (.transmitOversized, .count), + (.transmitInvalidDescriptors, .count), + (.transmitBackpressure, .count), (.receivedFrames, .count), (.receivedBytes, .bytes), (.receiveDeferred, .count), (.receiveDrops, .count), (.receiveTruncations, .count), + (.receiveMalformed, .count), + (.receiveInvalidDescriptors, .count), + (.receiveInsufficientCapacity, .count), + (.receiveBacklogDrops, .count), + (.receiveInactiveDrops, .count), + (.receiveSocketErrors, .count), + (.receiveActivationFailures, .count), ] case is VirtioBalloon: kind = .balloon @@ -134,8 +159,10 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { storage: backend as? VirtioBlk, network: backend as? VirtioNet, sharedDirectory: backend as? VirtioFS, + input: backend as? VirtioInput, audioMetrics: audioMetrics, displayMetrics: displayMetrics, + presentationBudgetMetrics: presentationBudgetMetrics, graphicsMetrics: effectiveGraphicsMetrics, unavailableMetrics: unavailable, previousTransportStatistics: nil, @@ -218,24 +245,26 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { .measured(.deviceResets, value: transport.deviceResets), ] if let network = entries[index].network?.statistics { - metrics.append(contentsOf: [ - .measured(.transmittedFrames, value: network.transmitPackets), - .measured(.transmittedBytes, value: network.transmitBytes), - .measured(.transmitDrops, value: network.transmitDrops), - .measured(.receivedFrames, value: network.receivePackets), - .measured(.receivedBytes, value: network.receiveBytes), - .measured(.receiveDeferred, value: network.receiveDeferred), - .measured(.receiveDrops, value: network.receiveDrops), - .measured(.receiveTruncations, value: network.receiveTruncations), - ]) + metrics.append(contentsOf: Self.networkMetrics(network)) } if let storage = entries[index].storage?.statistics { + let (storageQueueFaults, storageQueueFaultOverflow) = + storage.queuePopFaults.addingReportingOverflow(storage.completionFaults) metrics.append(contentsOf: [ .measured(.storageFlushes, value: storage.flushes), .measured( .maximumStorageFlushLatencyNanoseconds, value: storage.maximumFlushLatencyNanoseconds ), + .measured(.storageInvalidRequests, value: storage.invalidRequests), + .measured( + .storageQueueFaults, + value: storageQueueFaultOverflow ? UInt64.max : storageQueueFaults + ), + .measured( + .storageBoundedDrainStops, + value: storage.boundedDrainStops + ), ]) let previousSlowFlushes = entries[index].previousStorageStatistics?.slowFlushes ?? 0 let newSlowFlushes = Self.monotonicDelta( @@ -251,6 +280,30 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { ) health = .degraded } + let previousQueueFaults: UInt64 + if let previous = entries[index].previousStorageStatistics { + let (sum, overflow) = previous.queuePopFaults.addingReportingOverflow( + previous.completionFaults + ) + previousQueueFaults = overflow ? UInt64.max : sum + } else { + previousQueueFaults = 0 + } + let currentQueueFaults = storageQueueFaultOverflow + ? UInt64.max : storageQueueFaults + let newQueueFaults = Self.monotonicDelta( + current: currentQueueFaults, + previous: previousQueueFaults + ) + if newQueueFaults > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .storageQueueFault, + occurrences: newQueueFaults, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } entries[index].previousStorageStatistics = storage } if let audio = entries[index].audioMetrics?() { @@ -273,6 +326,7 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { entries[index].previousAudioDrops = drops } if let share = entries[index].sharedDirectory?.statistics { + let performance = entries[index].sharedDirectory?.performanceStatistics metrics.append(contentsOf: [ .measured(.shareInvalidations, value: share.invalidations), .measured( @@ -280,6 +334,46 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { value: share.invalidationFailures ), ]) + if let performance { + metrics.append(contentsOf: [ + .measured( + .shareRequestPayloadBytes, + value: performance.requestPayloadBytes + ), + .measured( + .shareWorkerResponsePayloadBytes, + value: performance.workerResponsePayloadBytes + ), + .measured( + .shareGuestPublishedResponseBytes, + value: performance.guestPublishedResponseBytes + ), + .measured( + .shareCompletedRequests, + value: performance.completedRequests + ), + .measured( + .shareFailedRequests, + value: performance.failedRequests + ), + .measured( + .shareInFlightRequests, + value: performance.inFlightRequests + ), + .measured( + .sharePeakInFlightRequests, + value: performance.peakInFlightRequests + ), + .measured( + .shareTotalRequestLatencyNanoseconds, + value: performance.totalRequestLatencyNanoseconds + ), + .measured( + .shareMaximumRequestLatencyNanoseconds, + value: performance.maximumRequestLatencyNanoseconds + ), + ]) + } let previousFailures = entries[index].previousShareInvalidationFailures ?? 0 let newFailures = Self.monotonicDelta( @@ -301,10 +395,45 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { entries[index].previousShareInvalidationFailures = share.invalidationFailures } + if let input = entries[index].input?.statistics { + metrics.append(contentsOf: Self.inputMetrics(input)) + } if let display = entries[index].displayMetrics?() { metrics.append(contentsOf: [ .measured(.displayFrames, value: display.presentedFrames), .measured(.displayDrops, value: display.droppedFrames), + .measured( + .displayBudgetRejectedFrames, + value: display.budgetRejectedFrames + ), + .measured( + .displayReceivedFrameBytes, + value: display.receivedFrameBytes + ), + .measured( + .displayStagingCopyBytes, + value: display.stagingCopyBytes + ), + .measured( + .displayDrainCopyBytes, + value: display.drainCopyBytes + ), + .measured( + .displayUploadedFrameBytes, + value: display.uploadedFrameBytes + ), + .measured( + .displayDroppedFrameBytes, + value: display.droppedFrameBytes + ), + .measured( + .displayPendingFrameBytes, + value: display.pendingFrameBytes + ), + .measured( + .displayPendingFrameDepth, + value: display.pendingFrameDepth + ), ]) let previousDrops = entries[index].previousDisplayDrops ?? 0 if Self.monotonicDelta( @@ -315,6 +444,22 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } entries[index].previousDisplayDrops = display.droppedFrames } + if let budget = entries[index].presentationBudgetMetrics?() { + metrics.append(contentsOf: [ + .measured( + .graphicsPresentationResidentBytes, + value: UInt64(max(0, budget.residentBytes)) + ), + .measured( + .graphicsPresentationPeakResidentBytes, + value: UInt64(max(0, budget.peakResidentBytes)) + ), + .measured( + .graphicsPresentationRejectedReservations, + value: budget.rejectedReservations + ), + ]) + } if let graphics = entries[index].graphicsMetrics?() { metrics.append(contentsOf: [ .measured(.graphicsFences, value: graphics.fences), @@ -431,6 +576,107 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } } + /// Keep the public diagnostic schema lossless with respect to the bounded virtio-net backend. + /// Aggregate drops alone cannot distinguish malformed guest chains from host socket pressure, + /// inactive queue epochs, or an activation failure—the exact distinction needed to repair a + /// failed physical qualification without speculative changes to guest configuration. + static func networkMetrics( + _ network: VirtioNetStatistics + ) -> [DoryDeviceTelemetryMetric] { + [ + .measured(.transmittedFrames, value: network.transmitPackets), + .measured(.transmittedBytes, value: network.transmitBytes), + .measured(.transmitDrops, value: network.transmitDrops), + .measured(.transmitMalformed, value: network.transmitMalformed), + .measured(.transmitOversized, value: network.transmitOversized), + .measured( + .transmitInvalidDescriptors, + value: network.transmitInvalidDescriptors + ), + .measured(.transmitBackpressure, value: network.transmitBackpressure), + .measured(.receivedFrames, value: network.receivePackets), + .measured(.receivedBytes, value: network.receiveBytes), + .measured(.receiveDeferred, value: network.receiveDeferred), + .measured(.receiveDrops, value: network.receiveDrops), + .measured(.receiveTruncations, value: network.receiveTruncations), + .measured(.receiveMalformed, value: network.receiveMalformed), + .measured( + .receiveInvalidDescriptors, + value: network.receiveInvalidDescriptors + ), + .measured( + .receiveInsufficientCapacity, + value: network.receiveInsufficientCapacity + ), + .measured(.receiveBacklogDrops, value: network.receiveBacklogDrops), + .measured(.receiveInactiveDrops, value: network.receiveInactiveDrops), + .measured(.receiveSocketErrors, value: network.receiveSocketErrors), + .measured( + .receiveActivationFailures, + value: network.receiveActivationFailures + ), + ] + } + + /// A device-owned snapshot is copied under the input backend's small state lock. It never + /// acquires the transport/register lock, walks the guest ring, or derives input behavior from + /// transport counters. Keeping this projection one-to-one makes queue pressure and scheduling + /// evidence diagnosable without giving the telemetry sampler lifecycle authority. + static func inputMetrics( + _ input: VirtioInputStatistics + ) -> [DoryDeviceTelemetryMetric] { + [ + .measured(.inputSubmittedFrames, value: input.submittedFrames), + .measured(.inputPublishedFrames, value: input.publishedFrames), + .measured(.inputPublishedEvents, value: input.publishedEvents), + .measured(.inputCoalescedMotionFrames, value: input.coalescedMotionFrames), + .measured(.inputDroppedFrames, value: input.droppedFrames), + .measured(.inputRejectedFrames, value: input.rejectedFrames), + .measured( + .inputStateReconciliationEvents, + value: input.stateReconciliationEvents + ), + .measured(.inputInvalidEventBuffers, value: input.invalidEventBuffers), + .measured(.inputInvalidStatusBuffers, value: input.invalidStatusBuffers), + .measured(.inputStatusEvents, value: input.statusEvents), + .measured(.inputQueueFaults, value: input.queueFaults), + .measured(.inputBoundedDrainStops, value: input.boundedDrainStops), + .measured(.inputWorkerTurns, value: input.workerTurns), + .measured(.inputWorkerYields, value: input.workerYields), + .measured(.inputCoalescedWorkerRequests, value: input.coalescedWorkerRequests), + .measured(.inputRevokedWorkerTurns, value: input.revokedWorkerTurns), + .measured( + .inputPendingFrameSaturationEvents, + value: input.pendingFrameSaturationEvents + ), + .measured(.inputPendingFrameDepth, value: input.pendingFrameDepth), + .measured( + .inputPendingFrameHighWatermark, + value: input.pendingFrameHighWatermark + ), + .measured( + .inputAvailableEventBufferDepth, + value: input.availableEventBufferDepth + ), + .measured( + .inputAvailableEventBufferHighWatermark, + value: input.availableEventBufferHighWatermark + ), + .measured(.inputEventQueueDepth, value: input.eventQueueDepth), + .measured(.inputEventQueueHighWatermark, value: input.eventQueueHighWatermark), + .measured(.inputStatusQueueDepth, value: input.statusQueueDepth), + .measured(.inputStatusQueueHighWatermark, value: input.statusQueueHighWatermark), + .measured( + .inputPublicationLatencyNanoseconds, + value: input.publicationLatencyNanoseconds + ), + .measured( + .inputMaximumPublicationLatencyNanoseconds, + value: input.maximumPublicationLatencyNanoseconds + ), + ] + } + private func appendEvent( deviceID: String, kind: DoryDeviceTelemetryEventKind, @@ -456,31 +702,251 @@ final class RawDeviceTelemetryRegistry: @unchecked Sendable { } } +/// One-shot LIFO rollback for side effects acquired by a throwing initializer. Registered actions +/// remain armed until `commit`; an explicit rollback and `deinit` are both safe and idempotent. +final class DesktopInitializationRollback { + private var actions = [() -> Void]() + private var finished = false + + func register(_ action: @escaping () -> Void) { + precondition(!finished, "cannot register initialization rollback after completion") + actions.append(action) + } + + func commit() { + guard !finished else { return } + finished = true + actions.removeAll() + } + + func performIfNeeded() { + guard !finished else { return } + finished = true + let pending = Array(actions.reversed()) + actions.removeAll() + for action in pending { action() } + } + + deinit { + performIfNeeded() + } +} + +enum DesktopGPUShutdownBoundaryResult: Equatable, Sendable { + case completed(epoch: UInt64) + case failed(epoch: UInt64, fault: VirtioGPURendererHealthFault) + case timedOut(epoch: UInt64) + + var logDescription: String { + switch self { + case .completed(let epoch): + return "completed at GPU epoch \(epoch)" + case .failed(let epoch, let fault): + return "failed at GPU epoch \(epoch): \(fault)" + case .timedOut(let epoch): + return "timed out at GPU epoch \(epoch)" + } + } + + var failure: VMError? { + switch self { + case .completed: + return nil + case .failed(let epoch, let fault): + return .unexpectedExit( + "GPU shutdown quiescence failed at epoch \(epoch): \(fault)" + ) + case .timedOut(let epoch): + return .unexpectedExit( + "GPU shutdown quiescence timed out at epoch \(epoch)" + ) + } + } +} + +/// The process-owned destruction boundary for virtio-gpu. Display detachment must run after the +/// device has published every release, but before a worker waits for renderer retirement. Keeping +/// these operations separate prevents AppKit release acknowledgements from being blocked by a +/// wait on the main actor. +enum DesktopGPUShutdownBoundary { + static let timeoutSeconds: TimeInterval = 5 + + static func begin( + quiesce: () -> VirtioGPUQuiescence, + detachPresentations: () -> Void + ) -> VirtioGPUQuiescence { + let receipt = quiesce() + detachPresentations() + return receipt + } + + static func wait( + for receipt: VirtioGPUQuiescence, + timeout: TimeInterval = timeoutSeconds + ) -> DesktopGPUShutdownBoundaryResult { + guard let outcome = receipt.wait(timeout: timeout) else { + return .timedOut(epoch: receipt.epoch) + } + switch outcome { + case .completed: + return .completed(epoch: receipt.epoch) + case .failed(let fault): + return .failed(epoch: receipt.epoch, fault: fault) + } + } +} + +/// Orders guest setup against the first synchronized renderer presentation before readiness is +/// published. Generic media must prove graphics first because it has no Dory-owned boot barrier; +/// managed images must release their display-manager barrier first so a renderer-backed frame can +/// exist. Any failure escapes before `publish`, preserving the fail-closed handoff contract. +enum DesktopGuestReadinessBoundary { + static func complete( + genericGuest: Bool, + prepare: () throws -> Prepared, + waitForSynchronizedPresentation: () throws -> Void, + publish: (Prepared) throws -> Void + ) rethrows { + let prepared: Prepared + if genericGuest { + try waitForSynchronizedPresentation() + prepared = try prepare() + } else { + prepared = try prepare() + try waitForSynchronizedPresentation() + } + try publish(prepared) + } +} + +enum DesktopMachineExecutionState: Equatable, Sendable { + case notStarted + case running + case ended + + var isTerminalBoundary: Bool { + switch self { + case .notStarted, .ended: true + case .running: false + } + } +} + enum DesktopMode { + enum RootDiskBacking: Equatable { + case legacyPath(String) + case resolvedDescriptor(descriptor: Int32, capacityBytes: UInt64) + + var virtualHardwareDiskAuthorityKind: RawHVVirtualHardwareDiskAuthorityKind { + switch self { + case .legacyPath: .legacyPath + case .resolvedDescriptor: .resolvedDescriptor + } + } + + static func resolve( + legacyPath: String?, + runtimeLaunchEnvelope: RuntimeLaunchEnvelope? + ) throws -> Self { + switch (legacyPath, runtimeLaunchEnvelope) { + case let (.some(path), nil) where !path.isEmpty: + return .legacyPath(path) + case let (nil, .some(envelope)): + let slot = try envelope.validatedResolvedRawHVSystemDisk() + guard fcntl(slot.descriptor, F_GETFD) >= 0 else { + throw VMError.invalidConfiguration( + "resolved systemDisk descriptor \(slot.descriptor) is not inherited" + ) + } + return .resolvedDescriptor( + descriptor: slot.descriptor, + capacityBytes: slot.capacityBytes + ) + default: + throw VMError.invalidConfiguration( + "desktop requires exactly one root-disk authority mode" + ) + } + } + + func makeBackend(queueCount: Int) throws -> VirtioBlk { + switch self { + case .legacyPath(let path): + return try VirtioBlk( + path: path, + identity: "dory-rootfs", + queueCount: queueCount + ) + case .resolvedDescriptor(let descriptor, let capacityBytes): + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == geteuid(), + info.st_nlink == 1, + info.st_size > 0, + (info.st_mode & 0o077) == 0, + UInt64(info.st_size) == capacityBytes else { + throw VMError.invalidConfiguration( + "inherited systemDisk failed owner/link/mode/capacity validation" + ) + } + return try VirtioBlk( + fileDescriptor: descriptor, + identity: "dory-rootfs", + queueCount: queueCount + ) + } + } + } + struct Configuration { var machineID: String var operationID: UUID var stateDirectory: String - var kernelPath: String - var initrdPath: String? - var rootfsPath: String + var bootPayload: MachineBootPayload + var rootDisk: RootDiskBacking var rootDevice: String var genericGuest: Bool var gvproxyPath: String var handoffSocketPath: String var agentSocketPath: String var shellSocketPath: String + var consoleSocketPath: String var controlSocketPath: String var usbControlSocketPath: String? var sshAgentSocketPath: String? var memoryMB: UInt64 var cpuCount: Int + /// Exact guest-visible system-disk queue topology. Resolved launches take this value only + /// from the canonical runtime envelope; legacy launches retain one queue. + var systemDiskQueueCount: Int = 1 var shares: [DoryMachineShareConfiguration] var environment: [String: String] + var legacyGraphicsBackend: DoryDesktopGraphicsBackend? = nil var resolvedGraphics: DoryGraphicsAccelerationLevel? + /// Fully authenticated before `DesktopMode.run`; nil for every software, host-display, + /// and legacy launch. The controller never starts or discovers a renderer process. + var rendererWorkerLaunch: DesktopRendererWorkerLaunch? = nil + var resolvedPlanSHA256: String? = nil + var resolvedPlanRevision: UInt64? = nil var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? var resolvedPortForwards: [DoryVMPortForward]? + var rawHVVirtualHardwareTopology: DoryRawHVVirtualHardwareTopology? + var resolvedSystemDiskLogicalID: DoryVirtualDeviceID? = nil var displayPresentation: DoryMachineDisplayPresentation = .windowed + + /// Shares actually materialized by the resolved directory-sharing policy. Guest setup must + /// consume this same inventory so it cannot try to mount a tag whose device was omitted. + var attachedShares: [DoryMachineShareConfiguration] { + resolvedDevices?.directorySharing == false ? [] : shares + } + + var rawHVBootAuthorityKind: RawHVVirtualHardwareBootAuthorityKind { + switch bootPayload { + case .legacyPaths: .legacyPaths + case .immutableBytes: .resolvedImmutableBytes + } + } } enum NetworkPlan: Equatable { @@ -609,6 +1075,29 @@ enum DesktopMode { } } + enum GenericGuestShareReadiness: Equatable, Sendable { + case mounted(Int) + case unavailableMissingCapability([String]) + case unavailableMissingTools([String]) + + var detailSuffix: String { + switch self { + case .mounted(let count): + count == 0 + ? "" + : "; \(count) virtio-fs share(s) proven mounted by Dory Tools" + case .unavailableMissingCapability(let tags): + "; requested virtio-fs shares unavailable because Dory Tools does not advertise virtiofs-mount@1: " + + tags.joined(separator: ", ") + case .unavailableMissingTools(let tags): + tags.isEmpty + ? "" + : "; requested virtio-fs shares unavailable because guest tools are not installed: " + + tags.joined(separator: ", ") + } + } + } + enum ShutdownPlan: Equatable { case guestAssisted case immediate @@ -621,7 +1110,7 @@ enum DesktopMode { private struct ResolvedGraphics { var backend: DoryDesktopGraphicsBackend - var renderer: VirglRenderer? + var rendererWorkerLaunch: DesktopRendererWorkerLaunch? } @MainActor @@ -632,16 +1121,29 @@ enum DesktopMode { @MainActor private final class Controller: NSObject, NSApplicationDelegate, NSWindowDelegate { + private struct MaterializedVirtioBackend { + let request: DoryRawHVVirtualDeviceRequest + let backend: any VirtioDeviceBackend + } + private let configuration: Configuration private let application = NSApplication.shared private let stateLock: EngineStateDirectoryLock private let serialLog: FileHandle + private let serialOutput: BoundedSerialConsolePublisher + #if arch(arm64) + private let serialConsoleInput: RawHVSerialConsoleInput + #endif private let machine: Machine + private let machineRunner: RawHVMachineRunner + private let gpu: VirtioGPU private let graphicsBackend: DoryDesktopGraphicsBackend + private let rendererWorkerLaunch: DesktopRendererWorkerLaunch? + private let rendererRuntimeFailureLatch: DesktopRendererRuntimeFailureLatch? private let keyboardInput: VirtioInput private let pointerInput: VirtioInput private let mailboxes: [DesktopFrameMailbox] - private let displays: [DesktopMetalView] + private let displays: [DesktopDisplayView] private let windows: [NSWindow] private let displayAssignments: [DoryGuestDisplayPresentationAssignment?] private let vsock: VirtioVsock @@ -658,11 +1160,31 @@ enum DesktopMode { private let firstFrame: FirstFrameGate private let deviceTelemetry: RawDeviceTelemetryRegistry private let lifecycleReceiptServer: VmmLifecycleReceiptServer + private let graphicsSelection: DoryRuntimeGraphicsSelection? + private var filesystemWorker: DoryFilesystemWorkerLaunch? private var signalSources = [DispatchSourceSignal]() private var stopError: Error? private var stopping = false + private var gpuShutdownReceipt: VirtioGPUQuiescence? + private var gpuShutdownResult: DesktopGPUShutdownBoundaryResult? + private var gpuShutdownWaitScheduled = false + private var machineExecutionState = DesktopMachineExecutionState.notStarted + private let signalQueue = DispatchQueue( + label: "dev.dory.dory-hv.desktop-signals", + qos: .userInitiated + ) init(configuration: Configuration) throws { + let virtualHardwareAttachmentMode = try RawHVVirtualHardwareAttachmentPlan.launchMode( + diskAuthority: configuration.rootDisk.virtualHardwareDiskAuthorityKind, + bootAuthority: configuration.rawHVBootAuthorityKind, + topology: configuration.rawHVVirtualHardwareTopology, + resolvedGraphics: configuration.resolvedGraphics, + resolvedDevices: configuration.resolvedDevices, + resolvedPortForwards: configuration.resolvedPortForwards, + resolvedSystemDiskLogicalID: configuration.resolvedSystemDiskLogicalID, + directoryShareStableIDs: configuration.shares.map(\.tag) + ) self.configuration = configuration let deviceTelemetry = RawDeviceTelemetryRegistry( machineID: configuration.machineID, @@ -684,11 +1206,25 @@ enum DesktopMode { machineID: configuration.machineID, operationID: configuration.operationID ) + self.serialOutput = try BoundedSerialConsolePublisher(destinations: [ + .init(fileHandle: FileHandle.standardError), + .init(fileHandle: serialLog, synchronizeOnStop: true), + ]) let resolvedGraphics = try Self.resolveGraphics( - environment: configuration.environment, - exactLevel: configuration.resolvedGraphics + legacyBackend: configuration.legacyGraphicsBackend, + exactLevel: configuration.resolvedGraphics, + rendererWorkerLaunch: configuration.rendererWorkerLaunch ) self.graphicsBackend = resolvedGraphics.backend + self.rendererWorkerLaunch = resolvedGraphics.rendererWorkerLaunch + let rendererRuntimeFailureLatch = resolvedGraphics.rendererWorkerLaunch == nil + ? nil : DesktopRendererRuntimeFailureLatch() + self.rendererRuntimeFailureLatch = rendererRuntimeFailureLatch + self.graphicsSelection = try Self.graphicsSelection( + configuration: configuration, + resolvedBackend: resolvedGraphics.backend, + rendererWorkerLaunch: resolvedGraphics.rendererWorkerLaunch + ) let networkPlan = try NetworkPlan(resolvedDevices: configuration.resolvedDevices) let networkInterface = configuration.resolvedDevices?.networkInterface if let networkInterface, !networkInterface.isValid { @@ -716,9 +1252,8 @@ enum DesktopMode { environment: configuration.environment, genericGuest: configuration.genericGuest ) - self.machine = try Machine(configuration: MachineConfiguration( - kernelPath: configuration.kernelPath, - initrdPath: configuration.initrdPath, + let machine = try Machine(configuration: MachineConfiguration( + bootPayload: configuration.bootPayload, commandLine: Self.kernelCommandLine( machineID: configuration.machineID, operationID: configuration.operationID, @@ -729,20 +1264,36 @@ enum DesktopMode { memoryBytes: configuration.memoryMB << 20, cpuCount: configuration.cpuCount )) - Self.attachPlatformDevices(to: machine, serialLog: serialLog) + self.machine = machine + self.machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.desktop.vcpu0" + ) + #if arch(arm64) + let uart = Self.attachPlatformDevices(to: machine, serialOutput: serialOutput) + self.serialConsoleInput = try RawHVSerialConsoleInput( + socketPath: configuration.consoleSocketPath, + uart: uart + ) + #endif self.keyboardInput = VirtioInput(profile: .keyboard) self.pointerInput = VirtioInput(profile: .absolutePointer) + let rendererWorkerLaunch = resolvedGraphics.rendererWorkerLaunch var mailboxes = [DesktopFrameMailbox]() var cursorMailboxes = [DesktopCursorMailbox]() - var displays = [DesktopMetalView]() + var displays = [DesktopDisplayView]() + let presentationBudget = DesktopCPUPresentationBudget.processDefault let pointerTopology = DesktopPointerTopology(sizes: displayPlans.map { VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) }) for plan in displayPlans { - let mailbox = DesktopFrameMailbox() + let mailbox = DesktopFrameMailbox( + scanoutID: plan.scanoutID, + sharedCPUPresentationBudget: presentationBudget + ) let cursorMailbox = DesktopCursorMailbox() - let display = try DesktopMetalView( + let metalDisplay = try DesktopMetalView( frame: NSRect(origin: .zero, size: plan.windowSize), keyboardInput: keyboardInput, pointerInput: pointerInput, @@ -750,6 +1301,27 @@ enum DesktopMode { scanoutID: plan.scanoutID, pointerTopology: pointerTopology ) + metalDisplay.onDeviceFailure = { + [ + weak machine, + weak rendererWorkerLaunch, + rendererRuntimeFailureLatch, + ] reason in + rendererRuntimeFailureLatch?.record( + kind: .metalDevice, + reason: reason + ) + rendererWorkerLaunch?.failSynchronizedPresentation(reason) + rendererWorkerLaunch?.teardown(reason: reason) + machine?.requestStop(.crash("Metal display failed closed: \(reason)")) + } + metalDisplay.onWorkerPresentationCompleted = { + [weak rendererWorkerLaunch] workerGeneration in + rendererWorkerLaunch?.recordSynchronizedPresentation( + workerGeneration: workerGeneration + ) + } + let display: DesktopDisplayView = metalDisplay mailbox.view = display cursorMailbox.view = display mailboxes.append(mailbox) @@ -761,28 +1333,37 @@ enum DesktopMode { let firstFrame = FirstFrameGate(requiredScanoutCount: displayPlans.count) self.firstFrame = firstFrame - let renderer = resolvedGraphics.renderer - let hostVisibleMemory = try renderer.map { _ in - try VirtioGPUHostVisibleMemory(guestBase: GuestLayout.daxWindowBase) - } + let hostVisibleMemory = try rendererWorkerLaunch != nil + ? VirtioGPUHostVisibleMemory(guestBase: GuestLayout.daxWindowBase) + : nil let gpu = VirtioGPU( hostMemoryBase: GuestLayout.daxWindowBase, scanoutSizes: displayPlans.map { VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) }, - renderer: renderer, + rendererWorkerCandidate: rendererWorkerLaunch?.commandLane, hostVisibleMemory: hostVisibleMemory, - traceResourceLifecycle: configuration.environment["DORY_GPU_TRACE_RESOURCES"] == "1", onScanoutFrame: { [mailboxes, firstFrame] frame in guard mailboxes.indices.contains(Int(frame.scanoutID)) else { return } mailboxes[Int(frame.scanoutID)].submit(frame) firstFrame.signal(scanoutID: frame.scanoutID) }, - onScanoutResourceReleased: { [mailboxes] resourceID in + onMetalScanout: { [mailboxes] update in + guard mailboxes.indices.contains(Int(update.scanoutID)) else { + update.presentation.discardWithoutPresentation() + return + } + mailboxes[Int(update.scanoutID)].submit(update) + }, + onScanoutResourceReleased: { [mailboxes] release in for mailbox in mailboxes { - mailbox.release(resourceID: resourceID) + mailbox.release(release) } }, + onScanoutDisabled: { [mailboxes] scanoutID in + guard mailboxes.indices.contains(Int(scanoutID)) else { return } + mailboxes[Int(scanoutID)].disable() + }, onCursorUpdate: { [cursorMailboxes] update in guard let update else { for mailbox in cursorMailboxes { mailbox.submit(nil) } @@ -790,29 +1371,92 @@ enum DesktopMode { } guard cursorMailboxes.indices.contains(Int(update.scanoutID)) else { return } cursorMailboxes[Int(update.scanoutID)].submit(update) + }, + onRendererWorkerFailure: { + [ + weak machine, + weak rendererWorkerLaunch, + rendererRuntimeFailureLatch, + ] reason in + rendererRuntimeFailureLatch?.record( + kind: .worker, + reason: reason + ) + rendererWorkerLaunch?.failSynchronizedPresentation(reason) + rendererWorkerLaunch?.teardown(reason: reason) + machine?.requestStop(.crash( + "renderer worker failed closed: \(reason)" + )) } ) + self.gpu = gpu + let initializationRollback = DesktopInitializationRollback() + defer { initializationRollback.performIfNeeded() } + initializationRollback.register { + let receipt = DesktopGPUShutdownBoundary.begin( + quiesce: { gpu.quiesce(reason: .shutdown) }, + detachPresentations: { + for mailbox in mailboxes { mailbox.deliver() } + } + ) + let result = DesktopGPUShutdownBoundary.wait(for: receipt) + Self.log( + "dory-hv desktop: GPU initialization rollback \(result.logDescription)" + ) + } let vsock = VirtioVsock(guestCID: 3) self.vsock = vsock - let usbipManager = UsbipManager() - usbipManager.attachListener(to: vsock) + initializationRollback.register { _ = vsock.quiesce() } + let usbipManager = UsbipManager(log: Self.log) + try usbipManager.attachListener(to: vsock) + initializationRollback.register { + // Initialization rollback runs before startMachine(), so guest execution never + // acquired vhci state. Use the same explicit terminal boundary as normal teardown. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + Self.log( + "dory-hv desktop: USB/IP initialization retirement retained authority asynchronously (\(detail))" + ) + } + } self.usbipManager = usbipManager let usbControlHandler = UsbControlHandler( manager: usbipManager, + allowedOpenModes: [.userAuthorized], ensureSupported: { - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) }, openDevice: { busID, mode in try HostUsbDeviceFactory.open(busID: busID, mode: mode) }, notifyAttach: { request in - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) try await channel.usbVhciAttach(request) }, notifyDetach: { request in - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) try await channel.usbVhciDetach(request) } @@ -828,6 +1472,11 @@ enum DesktopMode { let runtimeDirectory = (configuration.agentSocketPath as NSString).deletingLastPathComponent try FileManager.default.createDirectory(atPath: runtimeDirectory, withIntermediateDirectories: true) + guard chmod(runtimeDirectory, 0o700) == 0 else { + throw VMError.bootFailure( + "could not secure raw-HV runtime socket directory: \(errno)" + ) + } let token = String(configuration.machineID.prefix(12)) let networkRuntime = try Self.prepareNetwork( plan: networkPlan, @@ -837,6 +1486,13 @@ enum DesktopMode { token: token, resolvedPortForwards: configuration.resolvedPortForwards ) + initializationRollback.register { + networkRuntime.portForwardReconciler?.stop() + if let process = networkRuntime.process { + ChildProcessTerminator.terminateAndReap(process) + } + for path in networkRuntime.socketPaths { unlink(path) } + } self.gvproxy = networkRuntime.process self.networkSocketPaths = networkRuntime.socketPaths self.resolvedPortForwardReconciler = networkRuntime.portForwardReconciler @@ -846,10 +1502,14 @@ enum DesktopMode { } } do { - var backends: [VirtioDeviceBackend] = [ - try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"), + let rootDisk = try configuration.rootDisk.makeBackend( + queueCount: configuration.systemDiskQueueCount + ) + let entropy = VirtioRng() + var backends: [any VirtioDeviceBackend] = [ + rootDisk, gpu, - VirtioRng(), + entropy, balloon, vsock, ] @@ -862,24 +1522,170 @@ enum DesktopMode { if configuration.resolvedDevices?.audioInput != false { backends.append(sound) } - let attachedShares = configuration.resolvedDevices?.directorySharing == false - ? [] : configuration.shares - for share in attachedShares { - let rawShare = try VirtioFSShareConfiguration( + let attachedShares = configuration.attachedShares + let rawShares = try attachedShares.map { share in + try VirtioFSShareConfiguration( tag: share.tag, path: share.hostPath, readOnly: share.readOnly, guestMountPoint: share.guestPath ) - backends.append(try rawShare.makeBackend( - requestQueueCount: min(8, max(1, configuration.cpuCount)) - )) + } + let filesystemWorker = rawShares.isEmpty + ? nil + : try DoryFilesystemWorkerLauncher.startBlocking(shares: rawShares) + self.filesystemWorker = filesystemWorker + if let filesystemWorker { + initializationRollback.register { + filesystemWorker.client.invalidate() + } + } + var shareBackends = [(share: DoryMachineShareConfiguration, + backend: any VirtioDeviceBackend)]() + for (share, rawShare) in zip(attachedShares, rawShares) { + guard let filesystemWorker else { + throw VMError.invalidConfiguration( + "filesystem worker missing for attached share" + ) + } + let backend = try rawShare.makeBackend( + broker: filesystemWorker.broker(for: rawShare), + requestQueueCount: min(8, max(1, configuration.cpuCount)), + onWorkerLifecycle: { [weak machine] event in + Self.log("dory-hv desktop: \(event.diagnostic)") + if case .failure(let reason) = event { + machine?.requestStop(.crash(reason)) + } + } + ) + backends.append(backend) + shareBackends.append((share, backend)) } if let network = networkRuntime.backend { backends.append(network) } - for (slot, backend) in backends.enumerated() { - let transport = Self.attachBackend(backend, to: machine, slot: slot) + + let attachments: [(slot: Int, backend: any VirtioDeviceBackend)] + let resolvedAssignments: [RawHVVirtualHardwareAttachmentAssignment]? + switch virtualHardwareAttachmentMode { + case .legacy: + resolvedAssignments = nil + case .resolved(let assignments): + resolvedAssignments = assignments + } + if let preflightAssignments = resolvedAssignments { + let authorizedDevices = preflightAssignments.map(\.request) + var materialized = [MaterializedVirtioBackend]() + materialized.append(try Self.singletonMaterialization( + role: .systemDisk, + authorizedDevices: authorizedDevices, + backend: rootDisk + )) + materialized.append(try Self.singletonMaterialization( + role: .graphics, + authorizedDevices: authorizedDevices, + backend: gpu + )) + materialized.append(try Self.singletonMaterialization( + role: .entropy, + authorizedDevices: authorizedDevices, + backend: entropy + )) + materialized.append(try Self.singletonMaterialization( + role: .balloon, + authorizedDevices: authorizedDevices, + backend: balloon + )) + materialized.append(try Self.singletonMaterialization( + role: .vsock, + authorizedDevices: authorizedDevices, + backend: vsock + )) + if configuration.resolvedDevices?.keyboard == true { + materialized.append(try Self.singletonMaterialization( + role: .keyboard, + authorizedDevices: authorizedDevices, + backend: keyboardInput + )) + } + if configuration.resolvedDevices?.pointer == true { + materialized.append(try Self.singletonMaterialization( + role: .pointer, + authorizedDevices: authorizedDevices, + backend: pointerInput + )) + } + if configuration.resolvedDevices?.audioInput == true { + materialized.append(try Self.singletonMaterialization( + role: .audio, + authorizedDevices: authorizedDevices, + backend: sound + )) + } + for entry in shareBackends { + materialized.append(MaterializedVirtioBackend( + request: DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .directoryShare, + stableID: entry.share.tag + ), + role: .directoryShare + ), + backend: entry.backend + )) + } + guard let network = networkRuntime.backend, + let networkInterface = configuration.resolvedDevices?.networkInterface else { + throw VMError.invalidConfiguration( + "resolved RawHV topology requires its stable network function" + ) + } + materialized.append(MaterializedVirtioBackend( + request: DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .network, + stableID: networkInterface.id + ), + role: .network + ), + backend: network + )) + guard let topology = configuration.rawHVVirtualHardwareTopology else { + throw VMError.invalidConfiguration( + "resolved RawHV preflight lost its durable topology" + ) + } + let assignments = try RawHVVirtualHardwareAttachmentPlan.assignments( + topology: topology, + materializedDevices: materialized.map(\.request) + ) + guard assignments == preflightAssignments else { + throw VMError.invalidConfiguration( + "materialized RawHV assignments differ from preflight" + ) + } + attachments = try assignments.map { assignment in + guard let materializedBackend = materialized.first(where: { + $0.request == assignment.request + }) else { + throw VMError.invalidConfiguration( + "authorized RawHV device has no materialized backend" + ) + } + return (assignment.mmioSlot, materializedBackend.backend) + } + } else { + attachments = backends.enumerated().map { ($0.offset, $0.element) } + } + + for attachment in attachments { + let slot = attachment.slot + let backend = attachment.backend + let transport = try Self.attachBackend( + backend, + to: machine, + slot: slot + ) let audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? if backend === sound { audioMetrics = { [weak audio] in audio?.runtimeMetrics } @@ -892,7 +1698,8 @@ enum DesktopMode { mailboxes.map(\.metrics).reduce( DesktopFrameMailboxMetrics( presentedFrames: 0, - droppedFrames: 0 + droppedFrames: 0, + budgetRejectedFrames: 0 ) ) { partial, next in DesktopFrameMailboxMetrics( @@ -901,6 +1708,31 @@ enum DesktopMode { ), droppedFrames: partial.droppedFrames.addingClamped( next.droppedFrames + ), + budgetRejectedFrames: + partial.budgetRejectedFrames.addingClamped( + next.budgetRejectedFrames + ), + receivedFrameBytes: partial.receivedFrameBytes.addingClamped( + next.receivedFrameBytes + ), + stagingCopyBytes: partial.stagingCopyBytes.addingClamped( + next.stagingCopyBytes + ), + drainCopyBytes: partial.drainCopyBytes.addingClamped( + next.drainCopyBytes + ), + uploadedFrameBytes: partial.uploadedFrameBytes.addingClamped( + next.uploadedFrameBytes + ), + droppedFrameBytes: partial.droppedFrameBytes.addingClamped( + next.droppedFrameBytes + ), + pendingFrameBytes: partial.pendingFrameBytes.addingClamped( + next.pendingFrameBytes + ), + pendingFrameDepth: partial.pendingFrameDepth.addingClamped( + next.pendingFrameDepth ) ) } @@ -908,12 +1740,20 @@ enum DesktopMode { } else { displayMetrics = nil } + let presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? + if backend === gpu { + presentationBudgetMetrics = { presentationBudget.metrics } + } else { + presentationBudgetMetrics = nil + } deviceTelemetry.register( slot: slot, backend: backend, transport: transport, audioMetrics: audioMetrics, - displayMetrics: displayMetrics + displayMetrics: displayMetrics, + presentationBudgetMetrics: presentationBudgetMetrics ) if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { for (index, display) in displays.enumerated() { @@ -936,25 +1776,38 @@ enum DesktopMode { } } } + if let resolvedAssignments { + guard machine.attachedVirtioSlots.map(\.slot) + == resolvedAssignments.map(\.mmioSlot) else { + throw VMError.invalidConfiguration( + "materialized MMIO layout differs from the durable RawHV topology" + ) + } + } try machine.loadBootPayload() } catch { - if let gvproxy = networkRuntime.process { - ChildProcessTerminator.terminateAndReap(gvproxy) - } - for path in networkSocketPaths { unlink(path) } + initializationRollback.performIfNeeded() throw error } self.agentBridge = GuestVsockSocketBridge( socketPath: configuration.agentSocketPath, guestPort: VsockPorts.agent, + service: .agentSocket, log: Self.log ) self.shellBridge = GuestVsockSocketBridge( socketPath: configuration.shellSocketPath, guestPort: 1027, + service: .shell, log: Self.log ) + let rollbackAgentBridge = self.agentBridge + let rollbackShellBridge = self.shellBridge + initializationRollback.register { + rollbackAgentBridge.stop() + rollbackShellBridge.stop() + } try agentBridge.attach(to: vsock) try shellBridge.attach(to: vsock) if let sshAgentSocketPath = configuration.sshAgentSocketPath { @@ -962,7 +1815,8 @@ enum DesktopMode { socketPath: sshAgentSocketPath, log: Self.log ) - bridge.attach(to: vsock) + initializationRollback.register { bridge.stop() } + try bridge.attach(to: vsock) self.sshAgentBridge = bridge } else { self.sshAgentBridge = nil @@ -1014,7 +1868,7 @@ enum DesktopMode { : "\(configuration.machineID) — Dory Linux — Display \(index + 1)" // Keep the Metal surface bound to the window's *actual* content layout. A // dedicated display can transiently remain a normal titled window while AppKit - // enters its fullscreen Space. Installing the fixed-size MTKView directly as the + // enters its fullscreen Space. Installing a fixed-size display view directly as the // content view left its original 1920x1080 bounds clipped inside a 1920x1020 // visible content area, so the framebuffer and absolute tablet normalized // different coordinate spaces. The AppKit-managed container always follows the @@ -1055,9 +1909,14 @@ enum DesktopMode { } } clipboard?.start() + initializationRollback.commit() } func run() throws { + defer { + ensureGPUShutdownBeforeTeardown() + cleanup() + } try usbControlServer?.start() do { try lifecycleReceiptServer.start() @@ -1071,7 +1930,7 @@ enum DesktopMode { installSignalHandlers() for window in windows { window.makeKeyAndOrderFront(nil) } application.activate() - DispatchQueue.main.async { [weak self] in + DesktopAppRunLoop.perform { [weak self] in guard let self else { return } for (window, assignment) in zip(self.windows, self.displayAssignments) { _ = DoryHostDisplayPresentation.enterDedicatedFullscreen( @@ -1080,9 +1939,8 @@ enum DesktopMode { ) } } - startMachine() + try startMachine() application.run() - cleanup() if let stopError { throw stopError } } @@ -1153,21 +2011,26 @@ enum DesktopMode { for display in displays { display.releasePressedInput() } } - private func startMachine() { + private func startMachine() throws { let machine = self.machine - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - do { - let reason = try machine.run() - DispatchQueue.main.async { - MainActor.assumeIsolated { - self?.finish(error: Self.error(for: reason)) + machineExecutionState = .running + do { + try machineRunner.start { [weak self] result in + DesktopAppRunLoop.perform { + switch result { + case .success(let reason): + self?.finish( + error: Self.error(for: reason), + machineExecutionEnded: true + ) + case .failure(let error): + self?.finish(error: error, machineExecutionEnded: true) } } - } catch { - DispatchQueue.main.async { - MainActor.assumeIsolated { self?.finish(error: error) } - } } + } catch { + machineExecutionState = .notStarted + throw error } let configuration = self.configuration @@ -1176,52 +2039,197 @@ enum DesktopMode { DispatchQueue.global(qos: .userInitiated).async { [weak self] in do { if configuration.genericGuest { - guard firstFrame.wait(timeout: 90) else { - throw VMError.bootFailure( - "generic Linux guest did not publish a graphics frame within 90s" + try DesktopGuestReadinessBoundary.complete( + genericGuest: true, + prepare: { + guard configuration.rendererWorkerLaunch != nil + || firstFrame.wait(timeout: 90) else { + throw VMError.bootFailure( + "generic Linux guest did not publish a graphics frame within 90s" + ) + } + return try Self.prepareGenericGuestIntegration( + configuration: configuration, + timeout: 15 + ) + }, + waitForSynchronizedPresentation: { + if let rendererWorkerLaunch = + configuration.rendererWorkerLaunch { + // Generic media has no Dory-owned display-manager barrier, so + // retain the existing renderer-first readiness contract. + try rendererWorkerLaunch + .waitForFirstSynchronizedPresentation(timeout: 90) + } + }, + publish: { integration in + switch integration { + case let .tools(info, shareState): + DesktopAppRunLoop.perform { [weak self] in + self?.clipboard?.markGuestReady() + } + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: info.agentBuild, + agentProtocolVersion: info.protocolVersion, + agentCapabilities: info.capabilities, + agentSocketPath: configuration.agentSocketPath, + shellSocketPath: configuration.shellSocketPath, + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics and Dory Tools protocol \(info.protocolVersion)\(shareState.detailSuffix)" + ) + ) + case .unavailable: + let shareState = GenericGuestShareReadiness + .unavailableMissingTools( + configuration.attachedShares.map(\.tag) + ) + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: "dory-hv/generic-linux", + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed\(shareState.detailSuffix)" + ) + ) + } + } + ) + return + } + try DesktopGuestReadinessBoundary.complete( + genericGuest: false, + prepare: { + // prepareGuest writes /var/lib/dory/host-configured. The managed + // display manager is deliberately blocked on that marker, so it must + // exist before a renderer-backed presentation can be required. + try Self.prepareGuest(configuration: configuration) + }, + waitForSynchronizedPresentation: { + if let rendererWorkerLaunch = + configuration.rendererWorkerLaunch { + // The immutable receipt and kernel/fence authority select the + // candidate, but handoff still requires a real worker-backed frame + // across the producer-fence wait and Metal completion boundary. + try rendererWorkerLaunch + .waitForFirstSynchronizedPresentation(timeout: 90) + } + }, + publish: { info in + DesktopAppRunLoop.perform { [weak self] in + self?.clipboard?.markGuestReady() + } + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: info.agentBuild, + agentProtocolVersion: info.protocolVersion, + agentCapabilities: info.capabilities, + agentSocketPath: configuration.agentSocketPath, + shellSocketPath: configuration.shellSocketPath, + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" + ) ) } - try VmmHandoffClient.send( - path: configuration.handoffSocketPath, - ready: VmmReadyMessage( - machineID: configuration.machineID, - operationID: DoryOperationIdentity.canonical( - configuration.operationID - ), - agentBuild: "dory-hv/generic-linux", - controlSocketPath: configuration.controlSocketPath, - detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed" - ) + ) + } catch { + configuration.rendererWorkerLaunch?.teardown( + reason: "desktop readiness failed: \(error)" + ) + machine.requestStop(.crash("desktop readiness failed: \(error)")) + DesktopAppRunLoop.perform { + self?.finish(error: error) + } + } + } + } + + private enum GenericGuestIntegration: Sendable { + case tools(DoryAgentInfo, GenericGuestShareReadiness) + case unavailable + } + + private nonisolated static func prepareGenericGuestIntegration( + configuration: Configuration, + timeout: TimeInterval + ) throws -> GenericGuestIntegration { + let deadline = Date().addingTimeInterval(timeout) + var lastToolsError: Error? + repeat { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + var toolsAnswered = false + do { + defer { control.disconnect() } + let info = try control.info() + toolsAnswered = true + guard info.protocolVersion == DoryCore.protocolVersion() else { + throw AgentControlError.incompatibleProtocol( + expected: DoryCore.protocolVersion(), + actual: info.protocolVersion ) - return } - let info = try Self.prepareGuest(configuration: configuration) - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { self?.clipboard?.markGuestReady() } + guard info.capabilitiesAreCanonical else { + throw AgentControlError.invalidCapabilities } - try VmmHandoffClient.send( - path: configuration.handoffSocketPath, - ready: VmmReadyMessage( - machineID: configuration.machineID, - operationID: DoryOperationIdentity.canonical( - configuration.operationID - ), - agentBuild: info.agentBuild, - agentProtocolVersion: info.protocolVersion, - agentCapabilities: info.capabilities, - agentSocketPath: configuration.agentSocketPath, - shellSocketPath: configuration.shellSocketPath, - controlSocketPath: configuration.controlSocketPath, - detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" + guard !configuration.attachedShares.isEmpty else { + return .tools(info, .mounted(0)) + } + guard info.supports("virtiofs-mount", minimumVersion: 1) else { + return .tools( + info, + .unavailableMissingCapability( + configuration.attachedShares.map(\.tag) + ) ) - ) + } + for share in configuration.attachedShares { + _ = try control.virtioFSMount( + tag: share.tag, + mountPath: share.guestPath, + readOnly: share.readOnly + ) + } + return .tools(info, .mounted(configuration.attachedShares.count)) } catch { - machine.requestStop(.crash("desktop readiness failed: \(error)")) - DispatchQueue.main.async { - MainActor.assumeIsolated { self?.finish(error: error) } + if toolsAnswered { + // The typed operation is idempotent. A lost response can therefore retry + // until readiness expires without an Exec fallback or an extra mount layer. + lastToolsError = error } } + Thread.sleep(forTimeInterval: 0.25) + } while Date() < deadline + if let lastToolsError { + throw VMError.bootFailure( + "generic Linux virtio-fs integration failed after Dory Tools answered: \(lastToolsError)" + ) } + return .unavailable } private func requestGuestShutdown() { @@ -1262,8 +2270,69 @@ enum DesktopMode { } } - private func finish(error: Error?) { - if stopError == nil { stopError = error } + private func finish(error: Error?, machineExecutionEnded: Bool = false) { + if machineExecutionEnded { + machineExecutionState = .ended + } + if stopError == nil { + stopError = rendererRuntimeFailureLatch?.failure ?? error + } + let receipt = beginGPUShutdownIfNeeded() + guard !gpuShutdownWaitScheduled else { + stopApplicationAfterGPUShutdown() + return + } + gpuShutdownWaitScheduled = true + DispatchQueue.global(qos: .userInitiated).async { [weak self, receipt] in + let result = DesktopGPUShutdownBoundary.wait(for: receipt) + DesktopAppRunLoop.perform { [weak self] in + guard let self else { return } + self.recordGPUShutdownResult(result) + self.stopApplicationAfterGPUShutdown() + } + } + } + + private func beginGPUShutdownIfNeeded() -> VirtioGPUQuiescence { + if let gpuShutdownReceipt { return gpuShutdownReceipt } + let receipt = DesktopGPUShutdownBoundary.begin( + quiesce: { gpu.quiesce(reason: .shutdown) }, + detachPresentations: { + for mailbox in mailboxes { mailbox.deliver() } + } + ) + gpuShutdownReceipt = receipt + Self.log( + "dory-hv desktop: waiting for GPU shutdown quiescence at epoch \(receipt.epoch)" + ) + return receipt + } + + private func ensureGPUShutdownBeforeTeardown() { + guard gpuShutdownResult == nil else { return } + let receipt = beginGPUShutdownIfNeeded() + // This is the setup-failure and abnormal-run-loop fallback. A final main-actor drain + // makes every release acknowledgement visible before the bounded synchronous wait. + for mailbox in mailboxes { mailbox.deliver() } + recordGPUShutdownResult(DesktopGPUShutdownBoundary.wait(for: receipt)) + } + + private func recordGPUShutdownResult(_ result: DesktopGPUShutdownBoundaryResult) { + guard gpuShutdownResult == nil else { return } + gpuShutdownResult = result + Self.log("dory-hv desktop: GPU shutdown quiescence \(result.logDescription)") + if stopError == nil { + stopError = desktopGPUShutdownFailure( + result, + rendererFailureLatch: rendererRuntimeFailureLatch + ) + } + } + + private func stopApplicationAfterGPUShutdown() { + guard machineExecutionState.isTerminalBoundary, gpuShutdownResult != nil else { + return + } application.stop(nil) if let wakeEvent = NSEvent.otherEvent( with: .applicationDefined, @@ -1281,27 +2350,73 @@ enum DesktopMode { } private func cleanup() { + if machineExecutionState == .running { + machine.requestStop(.crash("AppKit run loop ended before guest execution")) + } + if machineExecutionState != .notStarted { + do { + _ = try machineRunner.wait() + } catch { + Self.log("dory-hv desktop: machine owner-thread join failed: \(error)") + if stopError == nil { stopError = error } + } + machineExecutionState = .ended + } + filesystemWorker?.client.invalidate() + filesystemWorker = nil lifecycleReceiptServer.stop() + #if arch(arm64) + serialConsoleInput.stop() + #endif usbControlServer?.stop() + precondition( + machineExecutionState.isTerminalBoundary, + "desktop USB authority cannot retire while Machine.run() is executing" + ) + // application.run() is stopped only by finish(), which is published after Machine.run() + // returns. The guest is therefore terminal before physical USB authority is released. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + Self.log( + "dory-hv desktop: USB/IP terminal retirement retained authority asynchronously (\(detail))" + ) + } clipboard?.stop() + agentBridge.stop() + shellBridge.stop() + sshAgentBridge?.stop() + _ = vsock.quiesce() resolvedPortForwardReconciler?.stop() signalSources.forEach { $0.cancel() } signalSources.removeAll() if let gvproxy { ChildProcessTerminator.terminateAndReap(gvproxy) } - unlink(configuration.agentSocketPath) - unlink(configuration.shellSocketPath) for path in networkSocketPaths { unlink(path) } + let serialReceipt = serialOutput.stop() + if !serialReceipt.isClean { + Self.log( + "dory-hv desktop: serial publisher retired with faults: " + + serialReceipt.diagnosticSummary + ) + } try? serialLog.close() } private func installSignalHandlers() { for number in [SIGTERM, SIGINT] { signal(number, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: number, queue: .main) + let source = DispatchSource.makeSignalSource( + signal: number, + queue: signalQueue + ) source.setEventHandler { [weak self] in - MainActor.assumeIsolated { self?.requestGuestShutdown() } + DesktopAppRunLoop.perform { self?.requestGuestShutdown() } } source.resume() signalSources.append(source) @@ -1311,9 +2426,12 @@ enum DesktopMode { // reliably activate it from Dory.app. Give the UI a narrow same-user signal that asks // the helper itself to raise its hidden or covered display window. signal(SIGUSR1, SIG_IGN) - let raiseSource = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main) + let raiseSource = DispatchSource.makeSignalSource( + signal: SIGUSR1, + queue: signalQueue + ) raiseSource.setEventHandler { [weak self] in - MainActor.assumeIsolated { + DesktopAppRunLoop.perform { guard let self else { return } for window in self.windows { window.makeKeyAndOrderFront(nil) } self.windows.first?.makeKey() @@ -1372,21 +2490,12 @@ enum DesktopMode { timeoutMs: 30_000, outputLimitBytes: 64 * 1024 ), operation: "guest account configuration") - for share in configuration.shares { - try requireSuccess(control.exec( - argv: ["/bin/mkdir", "-p", share.guestPath], - timeoutMs: 10_000, - outputLimitBytes: 64 * 1024 - ), operation: "create share mount point \(share.guestPath)") - try requireSuccess(control.exec( - argv: [ - "/bin/mount", "-t", "virtiofs", "-o", - share.readOnly ? "ro" : "rw", - share.tag, share.guestPath, - ], - timeoutMs: 30_000, - outputLimitBytes: 64 * 1024 - ), operation: "mount share \(share.tag)") + for share in configuration.attachedShares { + _ = try control.virtioFSMount( + tag: share.tag, + mountPath: share.guestPath, + readOnly: share.readOnly + ) } try requireSuccess(control.exec( argv: ["/usr/bin/touch", "/var/lib/dory/host-configured"], @@ -1456,6 +2565,8 @@ enum DesktopMode { portIntents.contains(where: { $0.exposure == .lan }) { throw VMError.bootFailure("LAN port exposure requires shared NAT") } + let resolvedMTU = networkInterface?.maximumTransmissionUnit + ?? UInt16(DoryNetworkMTU.resolved()) if plan == .disconnected { let mac = networkInterface?.macAddressOctets ?? VirtioNet.guestMAC return NetworkRuntime( @@ -1463,7 +2574,7 @@ enum DesktopMode { socketPaths: [], backend: VirtioDisconnectedNet( macAddress: mac, - maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit + maximumTransmissionUnit: resolvedMTU ), portForwardReconciler: nil ) @@ -1501,8 +2612,7 @@ enum DesktopMode { let process = Process() process.executableURL = URL(fileURLWithPath: gvproxyPath) process.arguments = GVProxyDesktopLaunchPlan.arguments( - mtu: networkInterface.map { Int($0.maximumTransmissionUnit) } - ?? DoryNetworkMTU.resolved(), + mtu: Int(resolvedMTU), datapathSocket: gvproxySocket, apiSocket: apiSocket, configurationPath: configurationPath @@ -1531,7 +2641,7 @@ enum DesktopMode { socketPath: vmNetworkSocket, remotePath: gvproxySocket, macAddress: networkInterface?.macAddressOctets ?? VirtioNet.guestMAC, - maximumTransmissionUnit: networkInterface?.maximumTransmissionUnit + maximumTransmissionUnit: resolvedMTU ), portForwardReconciler: reconciler ) @@ -1597,12 +2707,29 @@ enum DesktopMode { return lhs.localPort < rhs.localPort } + private static func singletonMaterialization( + role: DoryVirtualDeviceRole, + authorizedDevices: [DoryRawHVVirtualDeviceRequest], + backend: any VirtioDeviceBackend + ) throws -> MaterializedVirtioBackend { + let matches = authorizedDevices.filter { $0.role == role } + guard matches.count == 1, let request = matches.first else { + throw VMError.invalidConfiguration( + "authorized RawHV materialization requires exactly one \(role.rawValue) function" + ) + } + return MaterializedVirtioBackend( + request: request, + backend: backend + ) + } + @discardableResult private static func attachBackend( - _ backend: VirtioDeviceBackend, + _ backend: any VirtioDeviceBackend, to machine: Machine, slot: Int - ) -> VirtioMMIOTransport { + ) throws -> VirtioMMIOTransport { let interrupt = GuestLayout.virtioFirstIRQ + UInt32(slot) let transport = VirtioMMIOTransport( baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, @@ -1611,55 +2738,71 @@ enum DesktopMode { ) { [weak machine] in machine?.raiseGSI(interrupt) } - machine.attachVirtioSlot(transport) + try machine.attachVirtioSlot(transport, at: slot) return transport } - private static func attachPlatformDevices(to machine: Machine, serialLog: FileHandle) { - #if arch(arm64) + #if arch(arm64) + private static func attachPlatformDevices( + to machine: Machine, + serialOutput: BoundedSerialConsolePublisher + ) -> PL011 { machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) - machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in - FileHandle.standardError.write(Data([byte])) - try? serialLog.write(contentsOf: Data([byte])) - }) - #endif + let uart = PL011( + baseAddress: GuestLayout.uartBase, + sink: { byte in + serialOutput.enqueue(byte) + }, + setInterrupt: { [weak machine] asserted in + machine?.setGSI(GuestLayout.uartIRQ, asserted: asserted) + } + ) + machine.attachConsole(uart) + return uart } + #endif private static func resolveGraphics( - environment: [String: String], - exactLevel: DoryGraphicsAccelerationLevel? + legacyBackend: DoryDesktopGraphicsBackend?, + exactLevel: DoryGraphicsAccelerationLevel?, + rendererWorkerLaunch: DesktopRendererWorkerLaunch? ) throws -> ResolvedGraphics { - let preference = try DoryDesktopGraphicsPreference(environment: environment) - var rendererEnvironment = ProcessInfo.processInfo.environment - for key in [ - DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey, - "DORY_VIRGL_SYNC_MODE", - "DORY_VIRGLRENDERER_PATH", - "DORY_MOLTENVK_ICD", - ] { - if let value = environment[key] { - rendererEnvironment[key] = value - } - } - - func accelerated(classicOnly: Bool) throws -> ResolvedGraphics { - rendererEnvironment[DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey] = - classicOnly ? "1" : "0" - let renderer = try VirglRenderer.discover(environment: rendererEnvironment) - return ResolvedGraphics( - backend: classicOnly ? .virgl : .virglVenus, - renderer: renderer - ) - } - if let exactLevel { + guard legacyBackend == nil else { + throw VMError.bootFailure( + "resolved graphics cannot coexist with a legacy graphics selection" + ) + } switch exactLevel { case .hardwareAccelerated3D: - return try accelerated(classicOnly: false) + guard let rendererWorkerLaunch else { + throw VMError.bootFailure( + "resolved Venus launch is missing its authenticated renderer worker" + ) + } + return ResolvedGraphics( + backend: .virglVenus, + rendererWorkerLaunch: rendererWorkerLaunch + ) case .hostAcceleratedDisplay: - return try accelerated(classicOnly: true) + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "host-display graphics cannot carry renderer-worker authority" + ) + } + throw VMError.bootFailure( + "resolved host-accelerated display is not implemented by the RawHV Metal display contract" + ) case .software: - return ResolvedGraphics(backend: .software, renderer: nil) + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "software graphics cannot carry renderer-worker authority" + ) + } + return ResolvedGraphics( + backend: .software, + rendererWorkerLaunch: nil + ) case .none: throw VMError.bootFailure( "raw-HV desktop cannot satisfy a no-graphics resolved plan" @@ -1667,23 +2810,84 @@ enum DesktopMode { } } - switch preference.requiredBackend { + guard let legacyBackend else { + throw VMError.bootFailure("desktop launch is missing typed graphics authority") + } + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "legacy graphics cannot carry renderer-worker authority" + ) + } + switch legacyBackend { case .virgl: - return try accelerated(classicOnly: true) + throw VMError.bootFailure( + "legacy in-process VirGL desktop presentation was removed; " + + "launch with a resolved signed renderer worker or select software graphics" + ) case .software: - return ResolvedGraphics(backend: .software, renderer: nil) + return ResolvedGraphics( + backend: .software, + rendererWorkerLaunch: nil + ) case .virglVenus: - do { - return try accelerated(classicOnly: false) - } catch { - throw VMError.bootFailure( - "Linux desktop requires Dory's VirGL2 + Venus GPU runtime; " - + "select software graphics explicitly for compatibility mode: \(error)" - ) - } + throw VMError.bootFailure( + "legacy in-process VirGL2 + Venus desktop presentation was removed; " + + "launch with a resolved signed renderer worker or select software graphics" + ) } } + private static func graphicsSelection( + configuration: Configuration, + resolvedBackend: DoryDesktopGraphicsBackend, + rendererWorkerLaunch: DesktopRendererWorkerLaunch? + ) throws -> DoryRuntimeGraphicsSelection? { + guard let exactLevel = configuration.resolvedGraphics else { + // Legacy software compatibility launches have no immutable plan generation. Their + // display can remain available for migration, but never becomes resolved evidence. + return nil + } + guard let planSHA256 = configuration.resolvedPlanSHA256, + let planRevision = configuration.resolvedPlanRevision, + planRevision > 0 else { + throw VMError.bootFailure( + "resolved graphics selection is missing plan-generation authority" + ) + } + let selection: DoryRuntimeGraphicsSelection + switch (exactLevel, resolvedBackend, rendererWorkerLaunch) { + case (.software, .software, nil): + selection = DoryRuntimeGraphicsSelection.resolvedSoftware( + operationID: configuration.operationID, + resolvedPlanSHA256: planSHA256, + planRevision: planRevision + ) + case let (.hardwareAccelerated3D, .virglVenus, launch?): + selection = DoryRuntimeGraphicsSelection( + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + resolvedPlanSHA256: planSHA256, + planRevision: planRevision, + accelerationLevel: .hardwareAccelerated3D, + backend: .virglVenus, + rendererGeneration: launch.workerGeneration.rawValue, + rendererWorkerReceiptSHA256: + launch.rendererWorkerReceiptSHA256, + guestProducerFenceProofSHA256: + launch.qualifiedProducerFenceAuthoritySHA256 + ) + default: + throw VMError.bootFailure( + "resolved graphics selection lacks its exact renderer authority" + ) + } + guard selection.isValid else { + throw VMError.bootFailure("resolved software graphics selection is invalid") + } + return selection + } + private static func kernelCommandLine( machineID: String, operationID: UUID, @@ -1713,9 +2917,6 @@ enum DesktopMode { // transaction without modifying the guest filesystem. arguments.append("systemd.mask=boot-efi.mount") } - if let legacy = graphicsBackend.legacyKernelArgument { - arguments.append(legacy) - } return arguments.joined(separator: " ") } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift new file mode 100644 index 00000000..05cfe354 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift @@ -0,0 +1,55 @@ +import DoryOperations +import Foundation + +enum DesktopRendererRuntimeFailureKind: String, Equatable, Sendable { + case worker = "renderer-worker" + case metalDevice = "metal-device" + case gpuQuiescence = "gpu-quiescence" +} + +/// Candidate-scoped failure emitted only after hardware acceleration admitted an exact renderer +/// worker. The type is the local classification boundary consumed by `main.swift`; ordinary guest +/// and readiness errors deliberately never become this type. +struct DesktopRendererRuntimeFailure: Error, Equatable, Sendable, CustomStringConvertible { + let kind: DesktopRendererRuntimeFailureKind + let reason: String + + var description: String { "\(kind.rawValue): \(reason)" } +} + +/// Worker and Metal callbacks arrive on different queues. Preserve the first candidate-scoped +/// failure so a secondary teardown fault cannot replace the cause that actually stopped the VM. +final class DesktopRendererRuntimeFailureLatch: @unchecked Sendable { + private let lock = NSLock() + private var storedFailure: DesktopRendererRuntimeFailure? + + func record(kind: DesktopRendererRuntimeFailureKind, reason: String) { + lock.withLock { + guard storedFailure == nil else { return } + storedFailure = DesktopRendererRuntimeFailure(kind: kind, reason: reason) + } + } + + var failure: DesktopRendererRuntimeFailure? { + lock.withLock { storedFailure } + } +} + +func desktopHelperExitStatus(for error: any Error) -> DoryDesktopHelperExitStatus { + error is DesktopRendererRuntimeFailure + ? .rendererCandidateFailure + : .generalFailure +} + +func desktopGPUShutdownFailure( + _ result: DesktopGPUShutdownBoundaryResult, + rendererFailureLatch: DesktopRendererRuntimeFailureLatch? +) -> (any Error)? { + guard let genericFailure = result.failure else { return nil } + guard let rendererFailureLatch else { return genericFailure } + rendererFailureLatch.record( + kind: .gpuQuiescence, + reason: result.logDescription + ) + return rendererFailureLatch.failure ?? genericFailure +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift new file mode 100644 index 00000000..4b7604a0 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift @@ -0,0 +1,409 @@ +import CryptoKit +import Darwin +import DoryHV +import DoryOperations +import DoryRendererWorkerContracts +import DorydKit +import Foundation + +enum DesktopRendererWorkerLaunchError: Error, Equatable, CustomStringConvertible { + case unexpectedBootstrapAuthority + case missingBootstrapAuthority + case invalidBootstrapDescriptor + case invalidBootstrapObject + case bootstrapReadFailed + case bootstrapChangedDuringRead + case bootstrapDigestMismatch + case managedKernelDigestMismatch + case bootstrapQualificationUnavailable + case bootstrapQualificationMismatch + case synchronizedPresentationTimedOut + case synchronizedPresentationFailed(String) + + var description: String { + switch self { + case .unexpectedBootstrapAuthority: + "renderer bootstrap authority is forbidden for this graphics level" + case .missingBootstrapAuthority: + "hardware-accelerated 3D is missing its renderer bootstrap authority" + case .invalidBootstrapDescriptor: + "renderer bootstrap did not arrive in the fixed inherited descriptor slot" + case .invalidBootstrapObject: + "renderer bootstrap is not the exact private anonymous read-only object" + case .bootstrapReadFailed: + "renderer bootstrap changed or ended during its exact descriptor read" + case .bootstrapChangedDuringRead: + "renderer bootstrap object identity changed during its exact descriptor read" + case .bootstrapDigestMismatch: + "renderer bootstrap failed exact SHA-256 validation" + case .managedKernelDigestMismatch: + "renderer producer-fence authority does not bind the admitted guest kernel" + case .bootstrapQualificationUnavailable: + "packaged renderer bootstrap qualification is unavailable or untrusted" + case .bootstrapQualificationMismatch: + "live renderer capabilities do not match the packaged bootstrap qualification" + case .synchronizedPresentationTimedOut: + "the guest did not complete a synchronized worker-backed Metal presentation" + case .synchronizedPresentationFailed(let reason): + "synchronized worker-backed Metal presentation failed: \(reason)" + } + } +} + +/// Thread-safe one-shot bridge between Metal's completion callback and the background readiness +/// publisher. It is deliberately separate from immutable launch receipts so tests and production +/// cannot accidentally satisfy live readiness with a digest alone. +final class DesktopRendererWorkerLiveReadinessGate: @unchecked Sendable { + private enum State { + case waiting + case presented + case published + case failed(String) + } + + private let expectedWorkerGeneration: UInt64 + private let condition = NSCondition() + private var state: State = .waiting + + init(expectedWorkerGeneration: UInt64) { + precondition(expectedWorkerGeneration != 0) + self.expectedWorkerGeneration = expectedWorkerGeneration + } + + func record(workerGeneration receivedGeneration: UInt64) { + condition.lock() + defer { condition.unlock() } + guard case .waiting = state else { return } + guard receivedGeneration == expectedWorkerGeneration else { + state = .failed( + "worker generation drift (expected \(expectedWorkerGeneration), got " + + "\(receivedGeneration))" + ) + condition.broadcast() + return + } + state = .presented + condition.broadcast() + } + + func fail(_ reason: String) { + condition.lock() + defer { condition.unlock() } + switch state { + case .waiting, .presented: + state = .failed(reason) + condition.broadcast() + case .published, .failed: + return + } + } + + func wait(timeout: TimeInterval) throws { + let deadline = Date().addingTimeInterval(max(0, timeout)) + condition.lock() + defer { condition.unlock() } + while case .waiting = state { + guard condition.wait(until: deadline) else { + state = .failed("presentation deadline expired") + throw DesktopRendererWorkerLaunchError.synchronizedPresentationTimedOut + } + } + switch state { + case .presented, .published: + return + case .failed(let reason): + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed(reason) + case .waiting: + preconditionFailure("renderer readiness wait escaped while still waiting") + } + } + + /// Linearizes the live frame edge with handoff publication. A worker failure that wins this + /// lock prevents publication; once this transition wins, the synchronized frame was live at + /// the exact logical readiness boundary and any later failure tears the running VM down. + func claimForPublication() throws { + condition.lock() + defer { condition.unlock() } + switch state { + case .presented: + state = .published + case .published: + return + case .failed(let reason): + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed(reason) + case .waiting: + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed( + "no synchronized presentation is ready for publication" + ) + } + } +} + +/// One authenticated renderer generation prepared before any machine or vCPU starts. +/// +/// The two SHA-256 values are durable launch-authority evidence. They do not claim that a frame +/// reached the display. `waitForFirstSynchronizedPresentation` is the separate live gate that +/// closes only after the worker producer fence and Metal command buffer both complete. +final class DesktopRendererWorkerLaunch: @unchecked Sendable { + typealias Connector = @Sendable (Data) async throws -> DoryRendererWorkerBroker + typealias QualificationProvider = @Sendable () throws + -> DoryVerifiedRendererBootstrapQualification + + static let initialDeviceGeneration: UInt64 = 1 + static let bootstrapReadByteCount = DoryRendererWorkerBootstrapCodec.fixedByteCount + + let broker: DoryRendererWorkerBroker + let commandLane: DoryRendererWorkerVirtioCommandLane + let workerGeneration: DoryRendererWorkerGeneration + let rendererWorkerReceiptSHA256: String + let qualifiedProducerFenceAuthoritySHA256: String + + private let readinessGate: DesktopRendererWorkerLiveReadinessGate + private let teardownLock = NSLock() + private var tornDown = false + + private init( + broker: DoryRendererWorkerBroker, + commandLane: DoryRendererWorkerVirtioCommandLane, + rendererWorkerReceiptSHA256: String, + qualifiedProducerFenceAuthoritySHA256: String + ) { + self.broker = broker + self.commandLane = commandLane + self.workerGeneration = broker.bootstrap.generation + self.readinessGate = DesktopRendererWorkerLiveReadinessGate( + expectedWorkerGeneration: broker.bootstrap.generation.rawValue + ) + self.rendererWorkerReceiptSHA256 = rendererWorkerReceiptSHA256 + self.qualifiedProducerFenceAuthoritySHA256 = + qualifiedProducerFenceAuthoritySHA256 + } + + deinit { + teardown(reason: "renderer launch authority released") + } + + /// Consumes FD6 only for hardware-accelerated 3D. Other resolved graphics levels must not + /// carry or start a renderer worker, even if a stale daemon accidentally leaves the slot open. + static func prepare( + resolvedGraphics: DoryGraphicsAccelerationLevel?, + rendererBootstrapAuthority: RuntimeLaunchEnvelope.InheritedFileDescriptorSlot?, + exactManagedKernelSHA256: String?, + connector: @escaping Connector = { bytes in + try await DoryRendererWorkerBroker.connect(exactBootstrapBytes: bytes) + }, + qualificationProvider: @escaping QualificationProvider = { + try DoryVerifiedRendererBootstrapQualification + .loadRuntimeCandidate() + } + ) async throws -> DesktopRendererWorkerLaunch? { + guard resolvedGraphics == .hardwareAccelerated3D else { + guard rendererBootstrapAuthority == nil else { + if let descriptor = rendererBootstrapAuthority?.descriptor, descriptor >= 3 { + Darwin.close(descriptor) + } + throw DesktopRendererWorkerLaunchError.unexpectedBootstrapAuthority + } + return nil + } + guard let authority = rendererBootstrapAuthority, + let exactManagedKernelSHA256 else { + throw DesktopRendererWorkerLaunchError.missingBootstrapAuthority + } + + let exactBytes = try readAndConsumeBootstrap(authority) + let bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBytes) + guard hexadecimal(bootstrap.artifacts.managedGuestKernel.bytes) + == exactManagedKernelSHA256 else { + throw DesktopRendererWorkerLaunchError.managedKernelDigestMismatch + } + + let broker = try await connector(exactBytes) + do { + guard broker.bootstrap == bootstrap, + broker.capabilityReceipt.productionAccelerationIsAdmissible, + broker.capabilityReceipt.producerFenceContract + == bootstrap.producerFenceContract else { + throw DoryRendererWorkerBrokerError.incompleteCapabilityReceipt + } + let qualification: DoryVerifiedRendererBootstrapQualification + do { + qualification = try qualificationProvider() + } catch { + throw DesktopRendererWorkerLaunchError + .bootstrapQualificationUnavailable + } + try verifyLiveQualification( + bootstrap: bootstrap, + receipt: broker.capabilityReceipt, + qualification: qualification + ) + let lane = try DoryRendererWorkerVirtioCommandLane( + broker: broker, + deviceGeneration: initialDeviceGeneration + ) + let receiptBytes = DoryRendererCapabilityReceiptCodec.encode( + broker.capabilityReceipt + ) + return DesktopRendererWorkerLaunch( + broker: broker, + commandLane: lane, + rendererWorkerReceiptSHA256: sha256(receiptBytes), + qualifiedProducerFenceAuthoritySHA256: + qualifiedProducerFenceAuthoritySHA256(for: bootstrap) + ) + } catch { + await broker.invalidate() + throw error + } + } + + /// A build-time bootstrap is not permission to skip live initialization. The runner compares + /// every stable capability fact returned by this new worker generation before it creates the + /// virtio command lane. Workspace and generation remain live-only and are already bound by + /// `DoryRendererCapabilityReceiptCodec.decode(accepting:)`; the managed kernel is also compared + /// with the packaged candidate receipt because it supplies producer-fence authority. + static func verifyLiveQualification( + bootstrap: DoryRendererWorkerBootstrap, + receipt: DoryRendererCapabilityReceipt, + qualification: DoryVerifiedRendererBootstrapQualification + ) throws { + guard qualification.authorizes( + bootstrap: bootstrap, + liveReceipt: receipt + ) else { + throw DesktopRendererWorkerLaunchError + .bootstrapQualificationMismatch + } + } + + /// Called only by the Metal command-buffer completion edge. The worker core publishes an + /// update only after its producer fence signals, so this exact-generation signal proves both + /// halves of the live synchronized presentation boundary. + func recordSynchronizedPresentation(workerGeneration receivedGeneration: UInt64) { + readinessGate.record(workerGeneration: receivedGeneration) + } + + func failSynchronizedPresentation(_ reason: String) { + readinessGate.fail(reason) + } + + func waitForFirstSynchronizedPresentation(timeout: TimeInterval) throws { + try readinessGate.wait(timeout: timeout) + } + + func claimSynchronizedPresentationForPublication() throws { + try readinessGate.claimForPublication() + } + + func teardown(reason: String = "renderer launch teardown") { + let shouldTearDown = teardownLock.withLock { () -> Bool in + guard !tornDown else { return false } + tornDown = true + return true + } + guard shouldTearDown else { return } + failSynchronizedPresentation(reason) + commandLane.invalidate(deviceGeneration: Self.initialDeviceGeneration) + Task { await broker.invalidate() } + } + + static func qualifiedProducerFenceAuthoritySHA256( + for bootstrap: DoryRendererWorkerBootstrap + ) -> String { + var authority = Data("dory.renderer.qualified-producer-fence-authority.v1\0".utf8) + var contract = bootstrap.producerFenceContract.rawValue.littleEndian + withUnsafeBytes(of: &contract) { authority.append(contentsOf: $0) } + authority.append(bootstrap.artifacts.managedGuestKernel.bytes) + return sha256(authority) + } + + /// The descriptor parameter exists so the exact object reader can be exercised without + /// stealing process-global FD6 in a parallel test runner. Production never supplies it and + /// therefore always requires RuntimeLaunchEnvelope.rendererBootstrapDescriptor. + static func readAndConsumeBootstrap( + _ authority: RuntimeLaunchEnvelope.InheritedFileDescriptorSlot, + requiredDescriptor: Int32 = RuntimeLaunchEnvelope.rendererBootstrapDescriptor + ) throws -> Data { + let descriptor = authority.descriptor + guard descriptor >= 3 else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapDescriptor + } + defer { Darwin.close(descriptor) } + guard authority.name == RuntimeLaunchEnvelope.rendererBootstrapSlotName, + descriptor == requiredDescriptor, + authority.access == .readOnly, + authority.byteCount == UInt64(bootstrapReadByteCount), + authority.logicalDeviceID == nil, + let expectedSHA256 = authority.contentSHA256, + isLowercaseSHA256(expectedSHA256) else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapDescriptor + } + + let accessFlags = fcntl(descriptor, F_GETFL) + var before = stat() + guard accessFlags >= 0, + accessFlags & O_ACCMODE == O_RDONLY, + fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_uid == geteuid(), + before.st_nlink == 0, + before.st_size == off_t(bootstrapReadByteCount), + before.st_mode & 0o077 == 0 else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapObject + } + + var data = Data(count: bootstrapReadByteCount) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw DesktopRendererWorkerLaunchError.bootstrapReadFailed + } + var offset = 0 + while offset < raw.count { + let result = pread( + descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if result > 0 { + offset += result + } else if result < 0, errno == EINTR { + continue + } else { + throw DesktopRendererWorkerLaunchError.bootstrapReadFailed + } + } + } + + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw DesktopRendererWorkerLaunchError.bootstrapChangedDuringRead + } + guard sha256(data) == expectedSHA256 else { + throw DesktopRendererWorkerLaunchError.bootstrapDigestMismatch + } + return data + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func hexadecimal(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift index ab0d4e11..e00b4d75 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift @@ -1,4 +1,5 @@ import DoryCore +import DoryFSWorkerContracts import DoryHV import Foundation import Synchronization @@ -82,6 +83,12 @@ enum EngineMode { var shares: [VirtioFSShareConfiguration] = [] var directIP: DirectIPBridgeConfiguration? var gpuMode: GPUAccelerationMode = .off + /// Launch-plan policy. The helper never reads ambient process environment to select guest + /// reclaim behavior. + var reclaimPolicy: ReclaimPolicy = .dropCaches + /// Explicit queue policy supplied by the daemon. Automatic derives a bounded value from + /// the admitted vCPU count; fixed values are validated when the command line is parsed. + var fuseRequestQueuePolicy: FuseRequestQueuePolicy = .automatic /// Register FEX's seccomp-correct binfmt handler and Dory OCI runtime so /// `--platform linux/amd64` images run on the arm64 engine. var amd64Emulation: Bool = false @@ -102,6 +109,37 @@ enum EngineMode { case venus } + enum ReclaimPolicy: String, Equatable, Sendable { + case dropCaches = "drop-caches" + case senpai + } + + enum FuseRequestQueuePolicy: Equatable, Sendable { + case automatic + case fixed(Int) + + static let maximum = 8 + + init(fixedCount: Int) throws { + guard (1...Self.maximum).contains(fixedCount) else { + throw VMError.invalidConfiguration( + "VirtioFS request queue count must be between 1 and \(Self.maximum)" + ) + } + self = .fixed(fixedCount) + } + + func resolved(cpuCount: Int) -> Int { + switch self { + case .automatic: + return min(Self.maximum, max(1, cpuCount)) + case .fixed(let count): + precondition((1...Self.maximum).contains(count)) + return count + } + } + } + /// gvproxy is launched and stopped under the same lock so a shutdown signal cannot race the /// post-spawn registration window or run cleanup twice. The forced-exit watchdog must call the /// cleanup directly because `exit` does not unwind Swift `defer` blocks. @@ -162,10 +200,6 @@ enum EngineMode { } } - static var reclaimModeIsSenpai: Bool { - (ProcessInfo.processInfo.environment["DORY_ENGINE_RECLAIM_MODE"]?.lowercased() ?? "dropcaches") == "senpai" - } - // P1.2 host-pressure tier: when macOS reports memory pressure, ping the guest's reclaim listener so // it hands memory back exactly when the host needs it (the free-page-reporting moat, on demand). // Mirrors the shutdown channel: a forwarded unix socket → guest tcp 2378. Gated to senpai mode. @@ -263,10 +297,13 @@ enum EngineMode { reason: String, now: @escaping @Sendable () -> Date = Date.init ) async { - let connection = vsock.connect(port: VsockPorts.agent) - let channel = AgentChannel(connection: connection) let hostEpochNanoseconds = Int64((now().timeIntervalSince1970 * 1_000_000_000).rounded()) do { + let connection = try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + let channel = AgentChannel(connection: connection) let result = try await channel.syncClock(hostEpochNanoseconds: hostEpochNanoseconds) note("clock sync \(reason): \(result.synced ? "ok" : "agent declined")") } catch { @@ -452,7 +489,13 @@ enum EngineMode { var gvproxyStats: String? } - static func run(_ configuration: Configuration) async throws { + static func run(_ configuration: Configuration) throws { + guard configuration.gpuMode == .off else { + throw VMError.invalidConfiguration( + "container-engine GPU acceleration is unavailable; the retired in-process " + + "VirGL loader is not a fallback for the isolated Linux desktop worker" + ) + } try DockerSocketBridge.validateSocketPath(configuration.engineSocket) if let forwardSocket = configuration.agentVsockForward { try AgentVsockForward.validateSocketPath(forwardSocket) @@ -525,7 +568,7 @@ enum EngineMode { let bootConfigShare = try writeBootConfiguration(stateDirectory: state, script: guestBootScript( shares: configuration.shares, - gpuMode: configuration.gpuMode, + reclaimPolicy: configuration.reclaimPolicy, amd64Emulation: configuration.amd64Emulation, nativeIPv6: nativeIPv6, bridgeNetwork: bridgeNetwork, @@ -533,6 +576,11 @@ enum EngineMode { allowDockerDataFormat: allowDockerDataFormat ), guestAgentPath: configuration.guestAgentPath) let guestLogShare = try guestLogShareConfiguration(stateDirectory: state) + let filesystemShares = [bootConfigShare, guestLogShare] + configuration.shares + let filesystemWorker = try DoryFilesystemWorkerLauncher.startBlocking( + shares: filesystemShares + ) + defer { filesystemWorker.client.invalidate() } let machine = try Machine(configuration: MachineConfiguration( kernelPath: configuration.kernelPath, @@ -540,62 +588,59 @@ enum EngineMode { memoryBytes: configuration.memoryMB << 20, cpuCount: configuration.cpus )) - attachPlatformDevices(to: machine) + let serialOutput = try BoundedSerialConsolePublisher(destinations: [ + .init(fileHandle: FileHandle.standardOutput), + ]) + defer { + let receipt = serialOutput.stop() + if !receipt.isClean { + note("serial publisher retired with faults: \(receipt.diagnosticSummary)") + } + } + attachPlatformDevices(to: machine, serialOutput: serialOutput) var backends: [VirtioDeviceBackend] = [] backends.append(try VirtioBlk(path: bootRootfs, identity: "dory-rootfs")) backends.append(try VirtioBlk(path: dataDisk, identity: "dory-data")) backends.append(VirtioRng()) backends.append(VirtioBalloon(memory: machine.memory) { note($0) }) - var daxSlot: UInt64 = 0 - if configuration.gpuMode == .venus { - let renderer = try VenusModeRequirement.require { - try VirglRenderer.discover() - } - let hostMemoryBase = GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize - let hostVisibleMemory = try VirtioGPUHostVisibleMemory(guestBase: hostMemoryBase) - daxSlot += 1 - backends.append(VirtioGPU( - hostMemoryBase: hostMemoryBase, - renderer: renderer, - hostVisibleMemory: hostVisibleMemory - )) - note( - "experimental gpu=venus: attached virtio-gpu with virglrenderer " - + "\(renderer.libraryPath) and MoltenVK ICD " - + "\(renderer.moltenVKICDPath ?? "unavailable")" - ) - } let vsock = VirtioVsock(guestCID: 3) + defer { _ = vsock.quiesce() } backends.append(vsock) - HostAIBridge(log: { note($0) }).attach(to: vsock) - sshAgentBridge?.attach(to: vsock) - let requestedFuseQueues = ProcessInfo.processInfo.environment["DORY_FUSE_QUEUES"] - .flatMap(Int.init) ?? configuration.cpus - let fuseRequestQueues = min(8, max(1, requestedFuseQueues)) - backends.append(try bootConfigShare.makeBackend(requestQueueCount: fuseRequestQueues)) - backends.append(try guestLogShare.makeBackend(requestQueueCount: fuseRequestQueues)) - var coherenceEndpoints = [HostShareCoherenceEndpoint]() + let hostAIBridge = HostAIBridge(log: { note($0) }) + defer { + sshAgentBridge?.stop() + hostAIBridge.stop() + } + try hostAIBridge.attach(to: vsock) + try sshAgentBridge?.attach(to: vsock) + let fuseRequestQueues = configuration.fuseRequestQueuePolicy.resolved( + cpuCount: configuration.cpus + ) + let workerLifecycle: @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { event in + note(event.diagnostic) + if case .failure(let reason) = event { + machine.requestStop(.crash(reason)) + } + } + backends.append(try bootConfigShare.makeBackend( + broker: filesystemWorker.broker(for: bootConfigShare), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle + )) + backends.append(try guestLogShare.makeBackend( + broker: filesystemWorker.broker(for: guestLogShare), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle + )) for share in configuration.shares { - let daxBase = share.dax ? GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize : nil - if share.dax { daxSlot += 1 } let backend = try share.makeBackend( - daxGuestBase: daxBase, - requestQueueCount: fuseRequestQueues + broker: filesystemWorker.broker(for: share), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle ) backends.append(backend) - // Read-only shares cannot accept the same-mode watcher nudge, but they still need host - // reverse invalidation so open-file page cache cannot stay stale. Keep metadata caching - // disabled and skip only the fsnotify approximation for those endpoints. - coherenceEndpoints.append(HostShareCoherenceEndpoint( - share: HostFSEventShare( - hostRoot: share.path, - guestRoot: share.guestMountPoint ?? "/mnt/dory/\(share.tag)" - ), - backend: backend, - watcherNudgesEnabled: !share.readOnly - )) - note("sharing \(share.path) as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")\(share.dax ? " (dax)" : "")") + note("sharing authorized capability as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")") } let networkPaths = try GVProxyRuntimePaths( @@ -616,10 +661,11 @@ enum EngineMode { // Install before spawning gvproxy. A signal arriving during the remaining VM setup must use // the watchdog cleanup path rather than taking the default signal action and orphaning it. installGracefulShutdown(shutdownSocket: shutdownSocket) + let networkMTU = DoryNetworkMTU.resolved() let gvproxy = Process() gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath) gvproxy.arguments = [ - "-mtu", String(DoryNetworkMTU.resolved()), + "-mtu", String(networkMTU), "-listen-vfkit", "unixgram://\(datapathSocket)", "-listen", "unix://\(apiSocket)", ] @@ -664,7 +710,11 @@ enum EngineMode { if primaryReady && lanReady { break } usleep(50_000) } - let virtioNet = try VirtioNet(socketPath: networkPaths.vmSocket, remotePath: datapathSocket) + let virtioNet = try VirtioNet( + socketPath: networkPaths.vmSocket, + remotePath: datapathSocket, + maximumTransmissionUnit: UInt16(networkMTU) + ) backends.append(virtioNet) var sourcePreservingLANClient: SourcePreservingLANPrivilegedClient? var sourcePreservingLANSessionID: String? @@ -702,7 +752,7 @@ enum EngineMode { ) { [weak machine] in machine?.raiseGSI(spi) } - machine.attachVirtioSlot(transport) + try machine.attachVirtioSlot(transport, at: slot) } try machine.loadBootPayload() @@ -714,12 +764,29 @@ enum EngineMode { // The Rust dataplane cannot establish its authoritative agent channel without this forward. // Bind it before publishing engine.sock and propagate any listener error out of run(), so a // configured-but-impossible path terminates dory-hv instead of advertising a half-alive VM. + var agentVsockForward: AgentVsockForward? + var dockerSocketBridge: DockerSocketBridge? + defer { + dockerSocketBridge?.stop() + agentVsockForward?.stop() + } if let forwardSocket = configuration.agentVsockForward { - try AgentVsockForward(socketPath: forwardSocket, guestCID: 3, log: { note($0) }).attach(to: vsock) + let bridge = AgentVsockForward( + socketPath: forwardSocket, + guestCID: 3, + log: { note($0) } + ) + agentVsockForward = bridge + try bridge.attach(to: vsock) } // engine.sock is the sole Docker API endpoint, so its listener is just as required as the // dataplane forward. Propagate bind/listen/chmod failures before entering machine.run(). - try DockerSocketBridge(socketPath: configuration.engineSocket, log: { note($0) }).attach(to: vsock) + let dockerBridge = DockerSocketBridge( + socketPath: configuration.engineSocket, + log: { note($0) } + ) + dockerSocketBridge = dockerBridge + try dockerBridge.attach(to: vsock) publishForward(local: shutdownSocket, guestPort: 2377, apiSocket: apiSocket, label: "shutdown channel") publishForward( local: gvproxyHealthSocket, @@ -728,7 +795,7 @@ enum EngineMode { label: "gvproxy datapath canary" ) installClockSyncSignal(vsock: vsock) - if reclaimModeIsSenpai { + if configuration.reclaimPolicy == .senpai { let reclaimSocket = networkPaths.reclaimSocket publishForward(local: reclaimSocket, guestPort: 2378, apiSocket: apiSocket, label: "host-pressure reclaim channel") installHostPressureReclaim(reclaimSocket: reclaimSocket) @@ -772,28 +839,62 @@ enum EngineMode { // over a fresh shared agent-protocol channel for every operation, so an agent restart does // not strand a long-lived control client and capability is revalidated before hardware is // claimed and again immediately before the guest vhci mutation. - let usbipManager = UsbipManager() - usbipManager.attachListener(to: vsock) + let usbipManager = UsbipManager(log: { note($0) }) + try usbipManager.attachListener(to: vsock) + defer { + // This scope cannot unwind while Machine.run() is executing: either startup failed + // before guest execution or run() returned and the guest can no longer retain vhci + // state. That terminal boundary safely resolves outcome-unknown guest attachments. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + note("USB/IP terminal retirement retained authority asynchronously (\(detail))") + } + } let usbControlHandler = UsbControlHandler( manager: usbipManager, + allowedOpenModes: [.userAuthorized], ensureSupported: { - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) }, openDevice: { busID, mode in try HostUsbDeviceFactory.open(busID: busID, mode: mode) }, notifyAttach: { request in - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) try await channel.usbVhciAttach(request) }, notifyDetach: { request in - let channel = AgentChannel(connection: vsock.connect(port: VsockPorts.agent)) + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) try await channel.requireCapability("usb-vhci", version: 1) try await channel.usbVhciDetach(request) } ) - let usbControlServer = UsbControlServer(path: configuration.stateDirectory + "/usb-control.sock", handler: usbControlHandler) - do { try usbControlServer.start() } catch { note("usb control server unavailable: \(error)") } + let usbControlServer = UsbControlServer( + path: state + "/usb-control.sock", + handler: usbControlHandler + ) + try usbControlServer.start() + defer { usbControlServer.stop() } let memory = machine.memory let gauge = DispatchSource.makeTimerSource(queue: .global()) @@ -806,104 +907,13 @@ enum EngineMode { note("network gauge: tx \(network.transmitPackets)p/\(network.transmitBytes)B drops=\(network.transmitDrops), rx \(network.receivePackets)p/\(network.receiveBytes)B deferred=\(network.receiveDeferred) drops=\(network.receiveDrops) truncated=\(network.receiveTruncations)") } gauge.resume() + defer { gauge.cancel() } - var hostFSEventRelay: HostFSEventRelay? - var cacheReadinessTask: Task? - var hostFSEventDiagnosticsTimer: (any DispatchSourceTimer)? - if !coherenceEndpoints.isEmpty { - let activeEndpoints = coherenceEndpoints - let coordinator = HostShareCoherenceCoordinator( - endpoints: activeEndpoints, - guestEvents: GuestFSEventBridge(vsock: vsock), - onDegraded: { note($0) }, - onRecovered: { note($0) }, - onFatalRecoveryRequired: { reason in - note("host-share coherence requires VM restart: \(reason)") - machine.requestStop(.crash(reason)) - } - ) - let relay = HostFSEventRelay( - shares: activeEndpoints.map(\.share), - observeRootsOnDemand: true, - send: { changes in - try await coordinator.process(changes) - coordinator.relayDeliverySucceeded() - }, - onFailure: { error in - let message = String(describing: error) - // This updates the readiness generation and drops response TTLs synchronously; - // actor bookkeeping cannot race cache activation back on for a failed batch. - coordinator.relayDeliveryFailed("host-share event relay failed: \(message)") - note("host-share event relay: \(message)") - } - ) - let relayStarted = relay.start() - try HostShareCoherenceStartupPolicy.requireEventRelay( - started: relayStarted, - productionShareCount: activeEndpoints.count - ) - for endpoint in activeEndpoints { - endpoint.backend.hostFS.setEventObservationHandler { hostPath in - guard relay.observe(hostPath: hostPath) else { - let reason = "failed to start narrow host-share observation for \(hostPath)" - coordinator.relayDeliveryFailed(reason) - note(reason) - machine.requestStop(.crash(reason)) - return - } - } - } - hostFSEventRelay = relay - writeHostShareResourceDiagnostics(relay.diagnostics, stateDirectory: state) - let diagnosticsTimer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - diagnosticsTimer.schedule(deadline: .now() + 5, repeating: 5) - diagnosticsTimer.setEventHandler { - writeHostShareResourceDiagnostics(relay.diagnostics, stateDirectory: state) - } - diagnosticsTimer.resume() - hostFSEventDiagnosticsTimer = diagnosticsTimer - let watcherCount = activeEndpoints.filter(\.watcherNudgesEnabled).count - note("host-share invalidation relay active for \(activeEndpoints.count) share(s), watcher nudges on \(watcherCount)") - // The VM has not entered its run loop yet, so FUSE INIT, the 16 stable notify - // buffers, and the guest agent arrive asynchronously. Poll only the fail-closed - // readiness predicate; no environment flag can bypass these gates. - if activeEndpoints.contains(where: \.watcherNudgesEnabled) { - cacheReadinessTask = Task.detached(priority: .userInitiated) { - var lastError: String? - let deadline = ProcessInfo.processInfo.systemUptime + 120 - while ProcessInfo.processInfo.systemUptime < deadline { - guard !Task.isCancelled else { return } - do { - if try await coordinator.activateCachingIfReady() { - note("host-share coherent metadata cache active (\(VirtioFS.maximumCoherentCacheValiditySeconds)s bounded TTL)") - return - } - } catch { - lastError = String(describing: error) - } - do { - try await Task.sleep(nanoseconds: 100_000_000) - } catch { - return - } - } - let detail = lastError.map { ": \($0)" } ?? "" - note("host-share cache readiness timed out; zero-cache safety retained\(detail)") - } - } - } - defer { - cacheReadinessTask?.cancel() - hostFSEventDiagnosticsTimer?.cancel() - for endpoint in coherenceEndpoints { - endpoint.backend.hostFS.setEventObservationHandler(nil) - } - hostFSEventRelay?.stop() - try? FileManager.default.removeItem(atPath: state + "/host-share-resources.json") - } - - let stop = try machine.run() - gauge.cancel() + let machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.engine.vcpu0" + ) + let stop = try machineRunner.runToCompletion() note("engine stopped: \(stop)") } @@ -962,7 +972,7 @@ enum EngineMode { /// at 16 KiB granularity) has cold pages to hand back when the engine is idle. private static func guestBootScript( shares: [VirtioFSShareConfiguration] = [], - gpuMode: GPUAccelerationMode = .off, + reclaimPolicy: ReclaimPolicy = .dropCaches, amd64Emulation: Bool = false, nativeIPv6: NativeIPv6NetworkPlan? = nil, bridgeNetwork: DoryIPv4BridgeNetwork = try! DoryIPv4BridgeNetwork(), @@ -1054,13 +1064,6 @@ enum EngineMode { "echo 100 > /proc/sys/vm/vfs_cache_pressure 2>/dev/null", "echo 262144 > /proc/sys/vm/min_free_kbytes 2>/dev/null", ] - if gpuMode == .venus { - script += [ - "export DORY_GPU=venus", - "for n in $(seq 1 80); do [ -d /dev/dri ] && break; sleep 0.1; done", - "[ -d /dev/dri ] && chmod a+rw /dev/dri/renderD* /dev/dri/card* 2>/dev/null || echo DORY-GPU-NO-DRI", - ] - } if amd64Emulation { script += BinfmtRegistration.bootCommands() script.append("DORY_AMD64_RUNTIME_ARGS='--add-runtime dory-runc=/usr/local/bin/dory-runc --default-runtime dory-runc'") @@ -1094,15 +1097,16 @@ enum EngineMode { GuestDatapathCanary.listener(), GuestShutdownCommand.listener(), GuestMemoryReclaimBootCommand.hostPressureListener( - experimentalSenpai: reclaimModeIsSenpai + experimentalSenpai: reclaimPolicy == .senpai ), // Idle memory reclaim. Default is a gentle pagecache-only drop_caches when the guest is // quiet (no compaction — it re-faults the pages free-page reporting already handed back; - // no root memory.reclaim — write-rejected on the root cgroup). DORY_ENGINE_RECLAIM_MODE=senpai - // swaps in a coldest-first, working-set-protected feeder (MGLRU min_ttl_ms + DAMON_RECLAIM, - // memory.reclaim fallback) per the research §5. Kept opt-in until the memory A/B lands. + // no root memory.reclaim — write-rejected on the root cgroup). The explicit `senpai` + // launch policy swaps in a coldest-first, working-set-protected feeder (MGLRU + // min_ttl_ms + DAMON_RECLAIM, memory.reclaim fallback) per the research §5. Kept + // opt-in until the memory A/B lands. GuestMemoryReclaimBootCommand.idleLoop( - experimentalSenpai: reclaimModeIsSenpai + experimentalSenpai: reclaimPolicy == .senpai ), // Hand PID 1 to tini (docker-init, shipped in docker:dind) as a reaping init. exec // replaces the boot shell in place, so tini keeps PID 1 while dockerd and the loops @@ -1145,15 +1149,18 @@ enum EngineMode { return "\(console) root=/dev/vda rw panic=0 init=/sbin/init" } - private static func attachPlatformDevices(to machine: Machine) { + private static func attachPlatformDevices( + to machine: Machine, + serialOutput: BoundedSerialConsolePublisher + ) { #if arch(arm64) machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in - FileHandle.standardOutput.write(Data([byte])) + serialOutput.enqueue(byte) }) #else machine.attachConsole(UART16550(basePort: UInt16(truncatingIfNeeded: GuestLayout.uartBase)) { byte in - FileHandle.standardOutput.write(Data([byte])) + serialOutput.enqueue(byte) }) machine.attachRTC(CMOSRTC(basePort: UInt16(truncatingIfNeeded: GuestLayout.rtcBase))) machine.attachResetController(I8042 { [weak machine] in @@ -1167,27 +1174,6 @@ enum EngineMode { "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } - private static func writeHostShareResourceDiagnostics( - _ diagnostics: HostFSEventRelayDiagnostics, - stateDirectory: String - ) { - let destination = URL(fileURLWithPath: stateDirectory) - .appendingPathComponent("host-share-resources.json") - let temporary = destination.appendingPathExtension("tmp-(getpid())") - do { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(diagnostics) - try data.write(to: temporary, options: [.atomic]) - _ = chmod(temporary.path, S_IRUSR | S_IWUSR) - _ = rename(temporary.path, destination.path) - } catch { - try? FileManager.default.removeItem(at: temporary) - note("host-share resource diagnostics unavailable: (error)") - } - } - /// Asks gvproxy to serve a guest TCP port as a host unix socket, retrying until the listener /// lands (dockerd readiness is the app's probe, not ours). private static func publishForward(local socketPath: String, guestPort: Int, apiSocket: String, label: String) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift new file mode 100644 index 00000000..f60180cb --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift @@ -0,0 +1,908 @@ +import Darwin +import DoryHV +import Foundation + +enum RawHVSerialConsoleInputError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidConfiguration(String) + case invalidSocketPath(String) + case untrustedSocketPath(String) + case systemCall(operation: String, path: String, code: Int32) + + var description: String { + switch self { + case .invalidConfiguration(let detail): + return "invalid raw-HV serial console configuration: \(detail)" + case .invalidSocketPath(let detail): + return "invalid raw-HV serial console socket path: \(detail)" + case .untrustedSocketPath(let detail): + return "untrusted raw-HV serial console socket path: \(detail)" + case let .systemCall(operation, path, code): + return "could not \(operation) raw-HV serial console socket \(path): " + + "errno \(code) (\(String(cString: strerror(code))))" + } + } +} + +/// Private Unix-socket input side of raw-HV's durable serial console. +/// +/// Output continues to flow directly into `serial.log`. Each admitted client writes one frame and +/// half-closes its write side. The complete frame is bounded and deadline-governed before any byte +/// reaches the UART, so a slow, oversized, or abandoned peer cannot partially inject a command or +/// retain an admission slot indefinitely. +final class RawHVSerialConsoleInput: @unchecked Sendable { + struct Metrics: Equatable, Sendable { + var activeClientCount = 0 + var acceptedFrameCount: UInt64 = 0 + var acceptedByteCount: UInt64 = 0 + var rejectedPeerCount: UInt64 = 0 + var rejectedCapacityCount: UInt64 = 0 + var rejectedEmptyFrameCount: UInt64 = 0 + var rejectedOversizedFrameCount: UInt64 = 0 + var timedOutFrameCount: UInt64 = 0 + var uartBackpressureCount: UInt64 = 0 + var clientIOFailureCount: UInt64 = 0 + var listenerFailureCount: UInt64 = 0 + } + + /// Deterministic lifecycle observation points for race regression tests. Production uses the + /// no-op defaults; callbacks never make ownership decisions or replace the lifetime lock. + struct LifecycleHooks: Sendable { + var beforeListenerShutdown: @Sendable () -> Void + var beforeListenerRetire: @Sendable () -> Void + + init( + beforeListenerShutdown: @escaping @Sendable () -> Void = {}, + beforeListenerRetire: @escaping @Sendable () -> Void = {} + ) { + self.beforeListenerShutdown = beforeListenerShutdown + self.beforeListenerRetire = beforeListenerRetire + } + } + + static let productionMaximumFrameBytes = 4 * 1_024 + static let productionMaximumConcurrentClients = 8 + static let productionFrameTimeout: TimeInterval = 1 + + private static let maximumConfiguredFrameBytes = 64 * 1_024 + private static let maximumConfiguredClients = 64 + private static let maximumConfiguredTimeout: TimeInterval = 60 + private static let maximumStopWait: TimeInterval = 5 + private static let listenerPollMilliseconds: Int32 = 100 + private static let socketPathMutationLock = NSLock() + + private let lifetime: Lifetime + + init( + socketPath: String, + uart: PL011, + maximumConcurrentClients: Int = productionMaximumConcurrentClients, + maximumFrameBytes: Int = productionMaximumFrameBytes, + frameTimeout: TimeInterval = productionFrameTimeout, + expectedPeerUID: uid_t = geteuid(), + lifecycleHooks: LifecycleHooks = LifecycleHooks(), + log: @escaping @Sendable (String) -> Void = { message in + FileHandle.standardError.write( + Data("dory-hv desktop serial console: \(message)\n".utf8) + ) + } + ) throws { + guard (1...Self.maximumConfiguredClients).contains(maximumConcurrentClients) else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "maximumConcurrentClients must be in 1...\(Self.maximumConfiguredClients)" + ) + } + guard (1...Self.maximumConfiguredFrameBytes).contains(maximumFrameBytes) else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "maximumFrameBytes must be in 1...\(Self.maximumConfiguredFrameBytes)" + ) + } + guard frameTimeout.isFinite, + frameTimeout > 0, + frameTimeout <= Self.maximumConfiguredTimeout else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "frameTimeout must be finite and in (0, \(Self.maximumConfiguredTimeout)]" + ) + } + + let listener = try Self.makeOwnedListener( + socketPath: socketPath, + backlog: maximumConcurrentClients + ) + let lifetime = Lifetime( + listener: listener, + socketPath: socketPath, + uart: uart, + maximumConcurrentClients: maximumConcurrentClients, + maximumFrameBytes: maximumFrameBytes, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + lifecycleHooks: lifecycleHooks, + log: log + ) + self.lifetime = lifetime + + let listenerQueue = DispatchQueue( + label: "dev.dory.dory-hv.serial-console-input.listener.\(listener.descriptor)" + ) + let clientQueue = DispatchQueue( + label: "dev.dory.dory-hv.serial-console-input.clients.\(listener.descriptor)", + attributes: .concurrent + ) + listenerQueue.async { + Self.runListener( + listener, + lifetime: lifetime, + clientQueue: clientQueue + ) + } + } + + var metrics: Metrics { lifetime.metrics } + + /// Wakes the listener and every admitted client, then waits against one bounded teardown + /// deadline. Listener/client worker threads remain the sole owners that close their descriptor. + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + guard lifetime.stop(timeout: boundedTimeout) else { + lifetime.log( + "raw-HV serial console teardown did not drain within " + + "\(boundedTimeout) seconds on \(lifetime.socketPath)" + ) + return + } + } + + deinit { + stop() + } + + private static func runListener( + _ listener: OwnedListener, + lifetime: Lifetime, + clientQueue: DispatchQueue + ) { + defer { lifetime.finishListener(listener) } + while !lifetime.isStopping { + var readiness = pollfd( + fd: listener.descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let result = poll(&readiness, 1, listenerPollMilliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + if !lifetime.isStopping { + lifetime.recordListenerFailure( + "listener poll failed with errno \(errno)" + ) + } + return + } + if lifetime.isStopping { return } + if readiness.revents & Int16(POLLERR | POLLHUP | POLLNVAL) != 0 { + lifetime.recordListenerFailure("listener became unavailable") + return + } + guard readiness.revents & Int16(POLLIN) != 0 else { continue } + + while !lifetime.isStopping { + let client = accept(listener.descriptor, nil, nil) + if client < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { break } + if !lifetime.isStopping { + lifetime.recordListenerFailure( + "accept failed with errno \(errno)" + ) + } + return + } + if let failure = configureAcceptedClient(client) { + close(client) + lifetime.recordClientIOFailure( + "could not \(failure.operation) for accepted serial console client: " + + "errno \(failure.code)" + ) + continue + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(client, &peerUID, &peerGID) == 0 else { + let code = errno + close(client) + lifetime.recordClientIOFailure( + "could not authenticate serial console peer: errno \(code)" + ) + continue + } + guard peerUID == lifetime.expectedPeerUID else { + close(client) + lifetime.recordRejectedPeer(peerUID: peerUID) + continue + } + switch lifetime.admit(client) { + case .stopping: + close(client) + case .atCapacity: + close(client) + lifetime.recordRejectedCapacity() + case .admitted(let admission): + clientQueue.async { + let outcome = admission.session.run() + lifetime.finishClient(token: admission.token, outcome: outcome) + } + } + } + } + } + + private struct ClientConfigurationFailure { + var operation: String + var code: Int32 + } + + private static func configureAcceptedClient( + _ descriptor: Int32 + ) -> ClientConfigurationFailure? { + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return ClientConfigurationFailure(operation: "set close-on-exec", code: errno) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0 else { + return ClientConfigurationFailure(operation: "read descriptor flags", code: errno) + } + guard fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + return ClientConfigurationFailure(operation: "make descriptor nonblocking", code: errno) + } + // Darwin inherits SO_NOSIGPIPE from the listener. Re-setting it after a peer has already + // closed returns EINVAL, so verify the inherited protection instead of rejecting a valid + // one-frame client that disconnected immediately after SHUT_WR. + var noSigpipe: Int32 = 0 + var noSigpipeLength = socklen_t(MemoryLayout.size) + let noSigpipeResult = getsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + &noSigpipeLength + ) + guard noSigpipeResult == 0 else { + return ClientConfigurationFailure( + operation: "verify inherited SIGPIPE protection", + code: errno + ) + } + guard noSigpipe == 1 else { + return ClientConfigurationFailure( + operation: "verify inherited SIGPIPE protection", + code: ENOTSUP + ) + } + return nil + } + + private struct SocketPathIdentity: Equatable, Sendable { + var device: dev_t + var inode: ino_t + var generation: UInt32 + var birthSeconds: Int64 + var birthNanoseconds: Int64 + + init(_ info: stat) { + device = info.st_dev + inode = info.st_ino + generation = info.st_gen + birthSeconds = Int64(info.st_birthtimespec.tv_sec) + birthNanoseconds = Int64(info.st_birthtimespec.tv_nsec) + } + } + + private struct OwnedListener: Sendable { + var descriptor: Int32 + var identity: SocketPathIdentity + } + + private enum ExistingSocketProbe { + case live + case refused + case missing + case indeterminate(Int32) + } + + private static func makeOwnedListener( + socketPath: String, + backlog: Int + ) throws -> OwnedListener { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + try validate(socketPath: socketPath) + + let parent = (socketPath as NSString).deletingLastPathComponent + var parentInfo = stat() + guard lstat(parent, &parentInfo) == 0, + parentInfo.st_mode & S_IFMT == S_IFDIR, + parentInfo.st_uid == geteuid(), + parentInfo.st_mode & 0o022 == 0 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "parent directory must be owned by the engine effective uid and not writable " + + "by group/other: \(parent)" + ) + } + + var staleIdentity: SocketPathIdentity? + var stale = stat() + if lstat(socketPath, &stale) == 0 { + guard stale.st_mode & S_IFMT == S_IFSOCK, + stale.st_uid == geteuid(), + stale.st_nlink == 1 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "refusing to replace a non-socket, multiply-linked, or differently-owned " + + "node: \(socketPath)" + ) + } + staleIdentity = SocketPathIdentity(stale) + } else if errno != ENOENT { + throw systemCall("inspect", path: socketPath) + } + + if staleIdentity != nil { + switch probeExistingSocket(socketPath) { + case .refused, .missing: + break + case .live: + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "refusing to replace a live serial console listener: \(socketPath)" + ) + case .indeterminate(let code): + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "could not prove serial console socket stale (errno \(code)): \(socketPath)" + ) + } + } + + // Allocate and secure the descriptor before removing a trusted stale node. Resource or + // descriptor-configuration failure must not unnecessarily destroy the last endpoint. + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw systemCall("create", path: socketPath) } + var identity: SocketPathIdentity? + do { + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + throw systemCall("set close-on-exec for", path: socketPath) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, + fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw systemCall("make nonblocking", path: socketPath) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw systemCall("disable SIGPIPE for", path: socketPath) + } + if let staleIdentity { + try removeStaleSocketIfUnchanged( + socketPath, + expectedIdentity: staleIdentity + ) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bound == 0 else { throw systemCall("bind", path: socketPath) } + guard let boundIdentity = socketIdentity(at: socketPath), + boundIdentity.owner == geteuid(), + boundIdentity.linkCount == 1 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "bound node did not retain a single owner socket identity: \(socketPath)" + ) + } + identity = boundIdentity.identity + guard chmod(socketPath, 0o600) == 0 else { + throw systemCall("set owner-only mode on", path: socketPath) + } + guard let captured = socketIdentity(at: socketPath), + captured.identity == boundIdentity.identity, + captured.owner == geteuid(), + captured.linkCount == 1, + captured.mode & 0o777 == 0o600 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "bound node did not retain owner-only socket identity: \(socketPath)" + ) + } + guard listen(descriptor, Int32(backlog)) == 0 else { + throw systemCall("listen on", path: socketPath) + } + return OwnedListener(descriptor: descriptor, identity: captured.identity) + } catch { + if let identity { + unlinkIfOwned(socketPath, identity: identity) + } + close(descriptor) + throw error + } + } + + /// A same-uid socket is not necessarily stale. Probe without blocking and fail closed for every + /// outcome except the kernel's explicit "no listener" results. In particular, EINPROGRESS and + /// EAGAIN can mean a live listener or full backlog and must never authorize unlink. + private static func probeExistingSocket(_ socketPath: String) -> ExistingSocketProbe { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return .indeterminate(errno) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, + fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + return .indeterminate(errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + return .indeterminate(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + if result == 0 { return .live } + switch errno { + case ECONNREFUSED: return .refused + case ENOENT: return .missing + default: return .indeterminate(errno) + } + } + + private static func removeStaleSocketIfUnchanged( + _ socketPath: String, + expectedIdentity: SocketPathIdentity + ) throws { + var current = stat() + if lstat(socketPath, ¤t) != 0 { + guard errno == ENOENT else { throw systemCall("reinspect", path: socketPath) } + return + } + guard current.st_mode & S_IFMT == S_IFSOCK, + current.st_uid == geteuid(), + current.st_nlink == 1, + SocketPathIdentity(current) == expectedIdentity else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "stale socket identity changed before replacement: \(socketPath)" + ) + } + guard unlink(socketPath) == 0 else { + throw systemCall("remove stale", path: socketPath) + } + } + + private static func validate(socketPath: String) throws { + let bytes = Array(socketPath.utf8) + let address = sockaddr_un() + let maximum = MemoryLayout.size(ofValue: address.sun_path) - 1 + guard socketPath.first == "/" else { + throw RawHVSerialConsoleInputError.invalidSocketPath("path must be absolute") + } + guard !bytes.contains(0) else { + throw RawHVSerialConsoleInputError.invalidSocketPath("path contains a NUL byte") + } + guard !bytes.isEmpty, bytes.count <= maximum else { + throw RawHVSerialConsoleInputError.invalidSocketPath( + "path is \(bytes.count) UTF-8 bytes; maximum is \(maximum)" + ) + } + guard (socketPath as NSString).standardizingPath == socketPath else { + throw RawHVSerialConsoleInputError.invalidSocketPath( + "path must not contain redundant or parent components" + ) + } + } + + private static func retire(_ listener: OwnedListener, socketPath: String) { + socketPathMutationLock.lock() + unlinkIfOwned(socketPath, identity: listener.identity) + socketPathMutationLock.unlock() + close(listener.descriptor) + } + + @discardableResult + private static func unlinkIfOwned( + _ socketPath: String, + identity: SocketPathIdentity + ) -> Bool { + guard socketIdentity(at: socketPath)?.identity == identity else { return false } + return unlink(socketPath) == 0 || errno == ENOENT + } + + private static func socketIdentity( + at socketPath: String + ) -> (identity: SocketPathIdentity, owner: uid_t, mode: mode_t, linkCount: nlink_t)? { + var info = stat() + guard lstat(socketPath, &info) == 0, + info.st_mode & S_IFMT == S_IFSOCK else { + return nil + } + return ( + SocketPathIdentity(info), + info.st_uid, + info.st_mode, + info.st_nlink + ) + } + + private static func systemCall( + _ operation: String, + path: String + ) -> RawHVSerialConsoleInputError { + .systemCall(operation: operation, path: path, code: errno) + } + + private final class ClientSession: @unchecked Sendable { + enum Outcome: Sendable { + case frame([UInt8]) + case empty + case oversized + case timedOut + case ioFailure(Int32) + case stopped + } + + private let lock = NSLock() + private let maximumFrameBytes: Int + private let deadline: TimeInterval + private var descriptor: Int32? + private var started = false + private var stopping = false + + init( + descriptor: Int32, + maximumFrameBytes: Int, + frameTimeout: TimeInterval + ) { + self.descriptor = descriptor + self.maximumFrameBytes = maximumFrameBytes + self.deadline = ProcessInfo.processInfo.systemUptime + frameTimeout + } + + func run() -> Outcome { + let descriptor: Int32 + lock.lock() + guard !started, let owned = self.descriptor else { + lock.unlock() + return .stopped + } + started = true + descriptor = owned + let shouldRead = !stopping + lock.unlock() + + let outcome = shouldRead ? readFrame(from: descriptor) : .stopped + let finalOutcome = isStopping ? .stopped : outcome + finish() + return finalOutcome + } + + func requestStop() { + lock.lock() + stopping = true + if let descriptor { _ = shutdown(descriptor, SHUT_RDWR) } + lock.unlock() + } + + private var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return stopping + } + + private func readFrame(from descriptor: Int32) -> Outcome { + var frame = [UInt8]() + frame.reserveCapacity(min(maximumFrameBytes, 4 * 1_024)) + var buffer = [UInt8](repeating: 0, count: min(maximumFrameBytes + 1, 4 * 1_024)) + while true { + if isStopping { return .stopped } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return .timedOut } + let milliseconds = Int32(min( + Double(Int32.max), + max(1, ceil(remaining * 1_000)) + )) + var readiness = pollfd( + fd: descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let ready = poll(&readiness, 1, milliseconds) + if ready == 0 { return .timedOut } + if ready < 0 { + if errno == EINTR { continue } + return .ioFailure(errno) + } + if readiness.revents & Int16(POLLERR | POLLNVAL) != 0 { + return isStopping ? .stopped : .ioFailure(ECONNRESET) + } + + let remainingCapacity = maximumFrameBytes - frame.count + let requested = min(buffer.count, remainingCapacity + 1) + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, requested) + } + if count > 0 { + frame.append(contentsOf: buffer.prefix(count)) + if frame.count > maximumFrameBytes { return .oversized } + continue + } + if count == 0 { + return frame.isEmpty ? .empty : .frame(frame) + } + if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { + continue + } + return isStopping ? .stopped : .ioFailure(errno) + } + } + + private func finish() { + lock.lock() + let descriptor = self.descriptor + self.descriptor = nil + if let descriptor { close(descriptor) } + lock.unlock() + } + } + + private final class Lifetime: @unchecked Sendable { + struct Admission: Sendable { + var token: UUID + var session: ClientSession + } + + enum AdmissionDecision: Sendable { + case admitted(Admission) + case atCapacity + case stopping + } + + let socketPath: String + let expectedPeerUID: uid_t + let log: @Sendable (String) -> Void + + private let lock = NSLock() + private let listenerCompletion = DispatchGroup() + private let clientCompletion = DispatchGroup() + private let uart: PL011 + private let maximumConcurrentClients: Int + private let maximumFrameBytes: Int + private let frameTimeout: TimeInterval + private let lifecycleHooks: LifecycleHooks + private var listener: OwnedListener? + private var stopping = false + private var clients = [UUID: ClientSession]() + private var storedMetrics = Metrics() + + init( + listener: OwnedListener, + socketPath: String, + uart: PL011, + maximumConcurrentClients: Int, + maximumFrameBytes: Int, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + lifecycleHooks: LifecycleHooks, + log: @escaping @Sendable (String) -> Void + ) { + self.listener = listener + self.socketPath = socketPath + self.uart = uart + self.maximumConcurrentClients = maximumConcurrentClients + self.maximumFrameBytes = maximumFrameBytes + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.lifecycleHooks = lifecycleHooks + self.log = log + listenerCompletion.enter() + } + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return stopping + } + + var metrics: Metrics { + lock.lock() + defer { lock.unlock() } + var result = storedMetrics + result.activeClientCount = clients.count + return result + } + + func admit(_ descriptor: Int32) -> AdmissionDecision { + lock.lock() + defer { lock.unlock() } + guard !stopping else { return .stopping } + guard clients.count < maximumConcurrentClients else { return .atCapacity } + let token = UUID() + let session = ClientSession( + descriptor: descriptor, + maximumFrameBytes: maximumFrameBytes, + frameTimeout: frameTimeout + ) + clients[token] = session + clientCompletion.enter() + return .admitted(Admission(token: token, session: session)) + } + + func finishClient(token: UUID, outcome: ClientSession.Outcome) { + var diagnostic: String? + lock.lock() + guard clients.removeValue(forKey: token) != nil else { + lock.unlock() + return + } + switch outcome { + case .frame(let bytes) where !stopping: + if uart.receive(bytes) { + increment(&storedMetrics.acceptedFrameCount) + add(UInt64(bytes.count), to: &storedMetrics.acceptedByteCount) + } else { + increment(&storedMetrics.uartBackpressureCount) + diagnostic = "dropped a serial console frame because the UART input queue is full" + } + case .frame: + break + case .empty: + increment(&storedMetrics.rejectedEmptyFrameCount) + diagnostic = "dropped an empty serial console frame" + case .oversized: + increment(&storedMetrics.rejectedOversizedFrameCount) + diagnostic = "dropped an oversized serial console frame" + case .timedOut: + increment(&storedMetrics.timedOutFrameCount) + diagnostic = "dropped a serial console frame after its whole-frame deadline" + case .ioFailure(let code): + increment(&storedMetrics.clientIOFailureCount) + diagnostic = "serial console client read failed with errno \(code)" + case .stopped: + break + } + lock.unlock() + clientCompletion.leave() + if let diagnostic { log(diagnostic) } + } + + func recordRejectedPeer(peerUID: uid_t) { + lock.lock() + increment(&storedMetrics.rejectedPeerCount) + lock.unlock() + log( + "rejected serial console peer uid \(peerUID); " + + "expected \(expectedPeerUID)" + ) + } + + func recordRejectedCapacity() { + lock.lock() + increment(&storedMetrics.rejectedCapacityCount) + lock.unlock() + log("rejected serial console client because admission capacity is full") + } + + func recordClientIOFailure(_ diagnostic: String) { + lock.lock() + increment(&storedMetrics.clientIOFailureCount) + lock.unlock() + log(diagnostic) + } + + func recordListenerFailure(_ diagnostic: String) { + let shouldLog: Bool + lock.lock() + if !stopping { + increment(&storedMetrics.listenerFailureCount) + shouldLog = true + } else { + shouldLog = false + } + lock.unlock() + if shouldLog { log(diagnostic) } + } + + func finishListener(_ owned: OwnedListener) { + let sessions: [ClientSession] + lock.lock() + guard listener?.descriptor == owned.descriptor, + listener?.identity == owned.identity else { + lock.unlock() + return + } + listener = nil + stopping = true + sessions = Array(clients.values) + lock.unlock() + + lifecycleHooks.beforeListenerRetire() + RawHVSerialConsoleInput.retire(owned, socketPath: socketPath) + for session in sessions { session.requestStop() } + listenerCompletion.leave() + } + + func stop(timeout: TimeInterval) -> Bool { + let sessions: [ClientSession] + lock.lock() + stopping = true + if let listener { + // This is a descriptor borrow, not an integer snapshot. finishListener needs the + // same lock before it can remove and retire the listener, so close/reuse cannot win + // between selecting this descriptor and shutdown returning. + lifecycleHooks.beforeListenerShutdown() + _ = shutdown(listener.descriptor, SHUT_RDWR) + } + sessions = Array(clients.values) + lock.unlock() + for session in sessions { session.requestStop() } + + let deadline = DispatchTime.now() + timeout + let listenerFinished = listenerCompletion.wait(timeout: deadline) == .success + let clientsFinished = clientCompletion.wait(timeout: deadline) == .success + return listenerFinished && clientsFinished + } + + private func increment(_ value: inout UInt64) { + if value < UInt64.max { value += 1 } + } + + private func add(_ amount: UInt64, to value: inout UInt64) { + let (result, overflow) = value.addingReportingOverflow(amount) + value = overflow ? UInt64.max : result + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift new file mode 100644 index 00000000..44ca7756 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift @@ -0,0 +1,176 @@ +import DoryOperations +import DoryVMContracts +import Foundation + +enum RawHVVirtualHardwareAttachmentPlanError: Error, Equatable, Sendable { + case duplicateMaterializedDevice(DoryVirtualDeviceID) + case materializedDeviceSetMismatch + case incompleteLaunchAuthority + case resolvedDeviceContractMismatch +} + +enum RawHVVirtualHardwareDiskAuthorityKind: Equatable, Sendable { + case legacyPath + case resolvedDescriptor +} + +enum RawHVVirtualHardwareBootAuthorityKind: Equatable, Sendable { + case legacyPaths + case resolvedImmutableBytes +} + +enum RawHVVirtualHardwareAttachmentMode: Equatable, Sendable { + case legacy + case resolved([RawHVVirtualHardwareAttachmentAssignment]) +} + +struct RawHVVirtualHardwareAttachmentAssignment: Equatable, Sendable { + let request: DoryRawHVVirtualDeviceRequest + let mmioSlot: Int +} + +/// Joins the daemon-authorized sparse topology to the exact device functions the helper actually +/// constructed. Neither construction order nor array order is permitted to choose an MMIO slot. +enum RawHVVirtualHardwareAttachmentPlan { + /// Validates the entire launch-authority tuple and derives the helper's expected device set + /// without consulting the durable topology. This preflight must run before constructing any + /// backend, opening a share, or starting an external network process. + static func launchMode( + diskAuthority: RawHVVirtualHardwareDiskAuthorityKind, + bootAuthority: RawHVVirtualHardwareBootAuthorityKind, + topology: DoryRawHVVirtualHardwareTopology?, + resolvedGraphics: DoryGraphicsAccelerationLevel?, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + resolvedPortForwards: [DoryVMPortForward]?, + resolvedSystemDiskLogicalID: DoryVirtualDeviceID?, + directoryShareStableIDs: [String] + ) throws -> RawHVVirtualHardwareAttachmentMode { + if diskAuthority == .legacyPath, + bootAuthority == .legacyPaths, + topology == nil, + resolvedGraphics == nil, + resolvedDevices == nil, + resolvedPortForwards == nil, + resolvedSystemDiskLogicalID == nil { + return .legacy + } + + guard diskAuthority == .resolvedDescriptor, + bootAuthority == .resolvedImmutableBytes, + let topology, + let resolvedGraphics, + let resolvedDevices, + resolvedPortForwards != nil, + let resolvedSystemDiskLogicalID else { + throw RawHVVirtualHardwareAttachmentPlanError.incompleteLaunchAuthority + } + guard resolvedGraphics != .none, + resolvedDevices.audioInput == resolvedDevices.audioOutput, + resolvedDevices.directorySharing == !directoryShareStableIDs.isEmpty, + let networkInterface = resolvedDevices.networkInterface, + networkInterface.isValid else { + throw RawHVVirtualHardwareAttachmentPlanError.resolvedDeviceContractMismatch + } + + let expectedDevices = try expectedResolvedDevices( + systemDiskLogicalID: resolvedSystemDiskLogicalID, + resolvedDevices: resolvedDevices, + networkStableID: networkInterface.id, + directoryShareStableIDs: directoryShareStableIDs + ) + return .resolved(try assignments( + topology: topology, + materializedDevices: expectedDevices + )) + } + + /// Builds canonical device-function identities from launch inputs owned independently of the + /// topology. Fixed singleton IDs intentionally match the daemon planner's ABI-v1 namespace. + static func expectedResolvedDevices( + systemDiskLogicalID: DoryVirtualDeviceID, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest, + networkStableID: String, + directoryShareStableIDs: [String] + ) throws -> [DoryRawHVVirtualDeviceRequest] { + var requests = [ + DoryRawHVVirtualDeviceRequest( + logicalID: systemDiskLogicalID, + role: .systemDisk + ), + try canonicalFixedRequest(.graphics), + try canonicalFixedRequest(.entropy), + try canonicalFixedRequest(.balloon), + try canonicalFixedRequest(.vsock), + ] + if resolvedDevices.keyboard { + requests.append(try canonicalFixedRequest(.keyboard)) + } + if resolvedDevices.pointer { + requests.append(try canonicalFixedRequest(.pointer)) + } + if resolvedDevices.audioInput { + requests.append(try canonicalFixedRequest(.audio)) + } + requests.append(DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .network, + stableID: networkStableID + ), + role: .network + )) + for stableID in directoryShareStableIDs { + requests.append(DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .directoryShare, + stableID: stableID + ), + role: .directoryShare + )) + } + return requests + } + + static func canonicalFixedRequest( + _ role: DoryVirtualDeviceRole + ) throws -> DoryRawHVVirtualDeviceRequest { + switch role { + case .graphics, .entropy, .balloon, .vsock, .keyboard, .pointer, .audio: + return try DoryRawHVVirtualDeviceRequest( + logicalID: "rawhv-\(role.rawValue)", + role: role + ) + case .systemDisk, .network, .auxiliaryBlock, .removableStorage, + .directoryShare, .usbController: + throw RawHVVirtualHardwareAttachmentPlanError.resolvedDeviceContractMismatch + } + } + + static func assignments( + topology: DoryRawHVVirtualHardwareTopology, + materializedDevices: [DoryRawHVVirtualDeviceRequest] + ) throws -> [RawHVVirtualHardwareAttachmentAssignment] { + var materializedByID = [DoryVirtualDeviceID: DoryRawHVVirtualDeviceRequest]() + for request in materializedDevices { + guard materializedByID.updateValue(request, forKey: request.logicalID) == nil else { + throw RawHVVirtualHardwareAttachmentPlanError.duplicateMaterializedDevice( + request.logicalID + ) + } + } + let authorized = topology.occupiedSlots.map { + DoryRawHVVirtualDeviceRequest(logicalID: $0.logicalID, role: $0.role) + } + guard Set(materializedDevices) == Set(authorized) else { + throw RawHVVirtualHardwareAttachmentPlanError.materializedDeviceSetMismatch + } + return topology.occupiedSlots.map { + RawHVVirtualHardwareAttachmentAssignment( + request: DoryRawHVVirtualDeviceRequest( + logicalID: $0.logicalID, + role: $0.role + ), + mmioSlot: $0.mmioSlot + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift new file mode 100644 index 00000000..0cd25ae2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift @@ -0,0 +1,375 @@ +import CryptoKit +import Darwin +import DoryHV +import DoryRendererWorkerWireContracts +import DorydKit +import Foundation +import Security + +enum RendererBootstrapQualificationCommandError: Error, Equatable { + case usage(String) + case invalidRunnerBundle + case invalidRunnerSignature + case invalidInventoryPath + case invalidInventory + case artifactMismatch(String) + case invalidKernelDigest + case invalidTimestamp(String) + case invalidValidityWindow + case invalidOutputPath + case outputExists + case outputWriteFailed +} + +/// Build-time admission harness for the exact already-signed nested renderer XPC. +/// +/// This command intentionally lives in `dory-hv`: the worker's audit-token policy admits only the +/// expected-team runner, so an unsigned helper executable cannot manufacture bootstrap evidence. +/// The receipt is written outside the intermediate-signed app. Packaging later copies it into +/// Resources and applies the final outer signature. +enum RendererBootstrapQualificationCommand { + private struct Options { + let inventoryPath: String + let managedKernelSHA256: String + let issuedAt: Date + let expiresAt: Date + let outputPath: String + } + + private final class Outcome: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func publish(_ result: Result) { + lock.lock() + value = result + lock.unlock() + } + + func read() -> Result? { + lock.lock() + defer { lock.unlock() } + return value + } + } + + static func run(_ arguments: ArraySlice) throws { + let options = try parse(arguments) + let semaphore = DispatchSemaphore(value: 0) + let outcome = Outcome() + Task.detached { + do { + try await qualify(options) + outcome.publish(.success(())) + } catch { + outcome.publish(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + guard let result = outcome.read() else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + try result.get() + } + + private static func qualify(_ options: Options) async throws { + let bundle = Bundle.main + guard bundle.bundleURL.pathExtension == "app", + bundle.bundleIdentifier == DoryRendererWorkerIdentity.runnerBundleIdentifier else { + throw RendererBootstrapQualificationCommandError.invalidRunnerBundle + } + _ = try verifiedCodeDirectoryHash( + at: bundle.bundleURL, + requirement: DoryRendererWorkerIdentity.runnerCodeSigningRequirement, + checkNestedCode: true + ) + + let contents = bundle.bundleURL.appendingPathComponent("Contents", isDirectory: true) + let requiredInventoryURL = contents.appendingPathComponent( + DoryRendererProductionInventory.relativePath + ).standardizedFileURL + let suppliedInventoryURL = URL( + fileURLWithPath: options.inventoryPath + ).standardizedFileURL + guard suppliedInventoryURL == requiredInventoryURL else { + throw RendererBootstrapQualificationCommandError.invalidInventoryPath + } + let inventoryData = try stableRegularFile( + suppliedInventoryURL, + maximumBytes: UInt64(DoryRendererProductionInventory.maximumEncodedBytes) + ) + let inventory: DoryRendererProductionInventory + do { + inventory = try DoryRendererProductionInventory.decodeCanonical(inventoryData) + } catch { + throw RendererBootstrapQualificationCommandError.invalidInventory + } + + for component in inventory.components.values { + for record in component.files { + let artifactURL = contents.appendingPathComponent(record.path) + let actual = try stableRegularFile( + artifactURL, + maximumBytes: DoryRendererProductionInventory.maximumArtifactBytes + ) + guard UInt64(actual.count) == record.byteCount, + Data(SHA256.hash(data: actual)) == record.sha256.bytes else { + throw RendererBootstrapQualificationCommandError.artifactMismatch( + record.path + ) + } + } + } + + guard let worker = inventory.components["rendererWorker"]?.files.first else { + throw RendererBootstrapQualificationCommandError.invalidInventory + } + let workerBundle = contents.appendingPathComponent( + "XPCServices/DoryRendererWorker.xpc", + isDirectory: true + ) + let workerCodeDirectoryHash = try verifiedCodeDirectoryHash( + at: workerBundle, + requirement: DoryRendererWorkerIdentity.workerCodeSigningRequirement, + checkNestedCode: false + ) + let managedKernel: DoryRendererArtifactDigest + do { + managedKernel = try DoryRendererArtifactDigest( + lowercaseSHA256: options.managedKernelSHA256, + field: "managedGuestKernel" + ) + } catch { + throw RendererBootstrapQualificationCommandError.invalidKernelDigest + } + let guestMesa = try DoryRendererArtifactDigest( + lowercaseSHA256: DoryRendererSourceTuple.guestMesaRuntimeSHA256, + field: "guestMesa" + ) + let bootstrap = try DoryRendererWorkerBootstrap( + workspaceID: DoryRendererWorkspaceID( + rawValue: UUID(uuidString: "d0470000-0000-4000-8000-000000000001")! + ), + generation: DoryRendererWorkerGeneration(rawValue: 1), + sourceTuple: .productionCandidate, + producerFenceContract: .managedLinux61230PrepareFBV1, + requestedCapabilities: .productionAcceleration, + artifacts: DoryRendererArtifactManifest( + candidateInventory: inventory.candidateInventory, + managedGuestKernel: managedKernel, + guestMesa: guestMesa, + rendererWorkerExecutable: worker.sha256, + rendererWorkerCodeDirectoryHash: workerCodeDirectoryHash + ) + ) + let exactBootstrapBytes = DoryRendererWorkerBootstrapCodec.encode(bootstrap) + let broker = try await DoryRendererWorkerBroker.connect( + exactBootstrapBytes: exactBootstrapBytes + ) + do { + let receipt = try DoryVerifiedRendererBootstrapQualification + .makeCandidateReceipt( + bootstrap: bootstrap, + liveReceipt: broker.capabilityReceipt, + issuedAt: options.issuedAt, + expiresAt: options.expiresAt + ) + try writeExclusive(receipt, to: options.outputPath) + await broker.invalidate() + } catch { + await broker.invalidate() + throw error + } + } + + private static func parse(_ arguments: ArraySlice) throws -> Options { + var values = [String: String]() + let allowed: Set = [ + "--inventory", "--managed-kernel-sha256", "--issued-at", "--expires-at", "--output", + ] + var iterator = arguments.makeIterator() + while let option = iterator.next() { + guard allowed.contains(option), values[option] == nil, + let value = iterator.next(), !value.isEmpty else { + throw RendererBootstrapQualificationCommandError.usage(option) + } + values[option] = value + } + guard Set(values.keys) == allowed, + let inventory = values["--inventory"], + let kernel = values["--managed-kernel-sha256"], + let issuedString = values["--issued-at"], + let expiresString = values["--expires-at"], + let output = values["--output"] else { + throw RendererBootstrapQualificationCommandError.usage( + "renderer-qualify requires inventory, kernel digest, issuance, expiry, and output" + ) + } + let issued = try timestamp(issuedString) + let expires = try timestamp(expiresString) + guard expires > issued, + expires.timeIntervalSince(issued) + <= DoryVerifiedRendererBootstrapQualification.maximumValidity else { + throw RendererBootstrapQualificationCommandError.invalidValidityWindow + } + let outputURL = URL(fileURLWithPath: output) + guard outputURL.path == output, + outputURL.lastPathComponent + == DoryVerifiedRendererBootstrapQualification.receiptFilename else { + throw RendererBootstrapQualificationCommandError.invalidOutputPath + } + return Options( + inventoryPath: inventory, + managedKernelSHA256: kernel, + issuedAt: issued, + expiresAt: expires, + outputPath: output + ) + } + + private static func timestamp(_ value: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value), + formatter.string(from: date) == value else { + throw RendererBootstrapQualificationCommandError.invalidTimestamp(value) + } + return date + } + + private static func stableRegularFile( + _ url: URL, + maximumBytes: UInt64 + ) throws -> Data { + let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_nlink == 1, + before.st_size > 0, + UInt64(before.st_size) <= maximumBytes, + before.st_mode & (S_IWGRP | S_IWOTH) == 0 else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + var bytes = Data(count: Int(before.st_size)) + try bytes.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + var offset = 0 + while offset < raw.count { + let count = pread( + descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if count > 0 { + offset += count + } else if count < 0, errno == EINTR { + continue + } else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + } + } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + return bytes + } + + private static func verifiedCodeDirectoryHash( + at url: URL, + requirement requirementString: String, + checkNestedCode: Bool + ) throws -> DoryCodeDirectoryHash { + var code: SecStaticCode? + var requirement: SecRequirement? + guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(), &code) == errSecSuccess, + let code, + SecRequirementCreateWithString( + requirementString as CFString, + SecCSFlags(), + &requirement + ) == errSecSuccess, + let requirement else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + var rawFlags = kSecCSCheckAllArchitectures + if checkNestedCode { rawFlags |= kSecCSCheckNestedCode } + guard SecStaticCodeCheckValidity( + code, + SecCSFlags(rawValue: rawFlags), + requirement + ) == errSecSuccess else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + var information: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess, + let values = information as? [CFString: Any], + let unique = values[kSecCodeInfoUnique] as? Data else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + do { + return try DoryCodeDirectoryHash(bytes: unique) + } catch { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + } + + private static func writeExclusive(_ data: Data, to path: String) throws { + let descriptor = open( + path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o644) + ) + guard descriptor >= 0 else { + if errno == EEXIST { + throw RendererBootstrapQualificationCommandError.outputExists + } + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + defer { close(descriptor) } + var offset = 0 + try data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + while offset < raw.count { + let count = Darwin.write( + descriptor, + base.advanced(by: offset), + raw.count - offset + ) + if count > 0 { + offset += count + } else if count < 0, errno == EINTR { + continue + } else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + } + } + guard fsync(descriptor) == 0 else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift b/Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift similarity index 61% rename from Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift rename to Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift index 0a5fbcc7..db18b223 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift @@ -1,3 +1,6 @@ +import Darwin +import DoryFSWorkerContracts +import DoryHV import Foundation public struct VirtioFSShareConfiguration: Equatable, Sendable { @@ -167,18 +170,140 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { && !name.contains("/") && !name.utf8.contains(0) } - public func makeBackend(daxGuestBase _: UInt64? = nil, requestQueueCount: Int? = nil) throws -> VirtioFS { + public func makeBackend( + broker: DoryFSWorkerBroker, + requestQueueCount: Int? = nil, + onWorkerLifecycle: @escaping @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { _ in } + ) throws -> VirtioFS { // `dax` is mutable for source compatibility. Recheck at the production construction boundary // so a caller cannot parse a safe share, flip the bit, and bypass initializer validation. guard !dax else { throw VMError.invalidConfiguration(Self.daxUnsupportedReason) } - let hostFS = try HostFS( - rootPath: path, - readOnly: readOnly, - hiddenNames: hiddenNames, - rootHiddenNames: rootHiddenNames + return try VirtioFS( + tag: tag, + broker: broker, + requestQueueCount: requestQueueCount, + onWorkerLifecycle: onWorkerLifecycle + ) + } +} + +struct DoryFilesystemWorkerLaunch: @unchecked Sendable { + let client: DoryFSWorkerWorkspaceClient + let capabilityByTag: [String: DoryFSShareCapabilityID] + + func broker(for share: VirtioFSShareConfiguration) throws -> DoryFSWorkerBroker { + guard let capability = capabilityByTag[share.tag] else { + throw VMError.invalidConfiguration( + "filesystem worker has no capability for virtio-fs tag \(share.tag)" + ) + } + return try client.broker(for: capability) + } +} + +enum DoryFilesystemWorkerLauncher { + static func start( + shares: [VirtioFSShareConfiguration] + ) async throws -> DoryFilesystemWorkerLaunch { + let prepared = try prepare(shares: shares) + let client = try await DoryFSWorkerWorkspaceClient.connect( + exactBootstrapBytes: prepared.bytes + ) + return DoryFilesystemWorkerLaunch( + client: client, + capabilityByTag: prepared.capabilities + ) + } + + static func startBlocking( + shares: [VirtioFSShareConfiguration] + ) throws -> DoryFilesystemWorkerLaunch { + let prepared = try prepare(shares: shares) + let client = try DoryFSWorkerWorkspaceClient.connectBlocking( + exactBootstrapBytes: prepared.bytes + ) + return DoryFilesystemWorkerLaunch( + client: client, + capabilityByTag: prepared.capabilities + ) + } + + private static func prepare( + shares: [VirtioFSShareConfiguration] + ) throws -> (bytes: Data, capabilities: [String: DoryFSShareCapabilityID]) { + guard !shares.isEmpty, shares.count <= DoryFSWorkerBootstrapCodec.maximumShares else { + throw VMError.invalidConfiguration("invalid filesystem worker share count") + } + var seenTags = Set() + var capabilities = [String: DoryFSShareCapabilityID]() + var authorities = [DoryFSShareBootstrapAuthority]() + authorities.reserveCapacity(shares.count) + for share in shares { + guard seenTags.insert(share.tag).inserted else { + throw VMError.invalidConfiguration( + "duplicate virtio-fs share tag: \(share.tag)" + ) + } + let descriptor = Darwin.open( + share.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration( + "cannot pin virtio-fs share \(share.tag): errno \(errno)" + ) + } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR else { + let savedErrno = errno + Darwin.close(descriptor) + throw VMError.invalidConfiguration( + "cannot inspect virtio-fs share \(share.tag): errno \(savedErrno)" + ) + } + Darwin.close(descriptor) + + // This authority is transferred to the already-running sandboxed worker and is never + // persisted. A plain bookmark carries Foundation's implicit, ephemeral security scope + // to the resolving process. Explicit `.withSecurityScope` bookmarks are for a + // sandboxed creator to persist and reuse after relaunch; the runner is intentionally + // unsandboxed and must not try to mint that different kind of authority. + let bookmark = try URL(fileURLWithPath: share.path).bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + let capability = DoryFSShareCapabilityID.random() + capabilities[share.tag] = capability + authorities.append(try DoryFSShareBootstrapAuthority( + capabilityID: capability, + expectedRootIdentity: try DoryFSPinnedRootIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ), + readOnly: share.readOnly, + guestIdentity: DoryFSGuestIdentityPolicy(uid: getuid(), gid: getgid()), + resourceLimits: .production, + securityScopedBookmark: bookmark, + hiddenComponents: Array(share.hiddenNames), + rootHiddenComponents: Array(share.rootHiddenNames) + )) + } + let bootstrap = try DoryFSWorkerBootstrap( + workspaceID: .random(), + generation: try DoryFSWorkerGeneration( + rawValue: UInt64.random(in: 1...UInt64.max) + ), + workerLimits: .production, + shares: authorities + ) + return ( + bytes: try DoryFSWorkerBootstrapCodec.encode(bootstrap), + capabilities: capabilities ) - return try VirtioFS(tag: tag, hostFS: hostFS, requestQueueCount: requestQueueCount) } } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index 4ccfe3aa..3fe6eb2c 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -2,6 +2,7 @@ import DoryHV import DoryCore import DorydKit import DoryOperations +import DoryVMContracts import Foundation signal(SIGPIPE, SIG_IGN) @@ -14,9 +15,12 @@ let defaultBootCommandLine = "console=ttyS0 earlyprintk=serial,ttyS0,115200 pani let defaultAgentPingCommandLine = "root=/dev/vda rw panic=0" #endif -func fail(_ message: String) -> Never { +func fail( + _ message: String, + status: DoryDesktopHelperExitStatus = .generalFailure +) -> Never { FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) - exit(1) + exit(status.rawValue) } do { @@ -31,10 +35,7 @@ struct Options { var memoryMB: UInt64 = 2048 var cpus: Int = 1 var commandLine = defaultBootCommandLine - var disks: [String] = [] - var gvproxy: String? var timeoutSeconds: UInt64 = 30 - var shares: [VirtioFSShareConfiguration] = [] } func parseOptions(_ arguments: ArraySlice) -> Options { @@ -47,16 +48,7 @@ func parseOptions(_ arguments: ArraySlice) -> Options { case "--mem-mb": options.memoryMB = iterator.next().flatMap(UInt64.init) ?? options.memoryMB case "--cpus": options.cpus = iterator.next().flatMap(Int.init) ?? options.cpus case "--cmdline": options.commandLine = iterator.next() ?? options.commandLine - case "--disk": if let disk = iterator.next() { options.disks.append(disk) } - case "--gvproxy": options.gvproxy = iterator.next() case "--timeout-sec": options.timeoutSeconds = iterator.next().flatMap(UInt64.init) ?? options.timeoutSeconds - case "--share": - guard let value = iterator.next() else { fail("--share requires tag=/host/path[:ro|:rw][:safe][:at=/guest/path]; DAX host shares are disabled") } - do { - options.shares.append(try VirtioFSShareConfiguration(argument: value)) - } catch { - fail("\(error)") - } default: fail("unknown option \(argument)") } } @@ -67,10 +59,13 @@ private final class AgentPingResultBox: @unchecked Sendable { private let lock = NSLock() private var stored: Result? - func set(_ result: Result) { + @discardableResult + func setIfEmpty(_ result: Result) -> Bool { lock.lock() + defer { lock.unlock() } + guard stored == nil else { return false } stored = result - lock.unlock() + return true } func get() -> Result? { @@ -80,7 +75,7 @@ private final class AgentPingResultBox: @unchecked Sendable { } } -func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: Int) { +func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: Int) throws { let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) let transport = VirtioMMIOTransport( baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, @@ -89,7 +84,7 @@ func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: In ) { [weak machine] in machine?.raiseGSI(spi) } - machine.attachVirtioSlot(transport) + try machine.attachVirtioSlot(transport, at: slot) } func attachPlatformDevices(to machine: Machine, console: FileHandle) { @@ -137,51 +132,82 @@ func runAgentPing(_ options: Options) { vsock, ] for (slot, backend) in backends.enumerated() { - attachBackend(backend, to: machine, slot: slot) + try attachBackend(backend, to: machine, slot: slot) } try machine.loadBootPayload() - let runThread = Thread { - do { - let stop = try machine.run() - FileHandle.standardError.write(Data("dory-hv: guest stopped before agent answered: \(stop)\n".utf8)) - } catch { - FileHandle.standardError.write(Data("dory-hv: guest failed before agent answered: \(error)\n".utf8)) - } - } - runThread.name = "dory-hv.agent-ping.vm" - runThread.start() - let deadline = DispatchTime.now().uptimeNanoseconds + options.timeoutSeconds * 1_000_000_000 let semaphore = DispatchSemaphore(value: 0) + let probeFinished = DispatchSemaphore(value: 0) let result = AgentPingResultBox() - Task.detached { - while DispatchTime.now().uptimeNanoseconds < deadline { - let connection = vsock.connect(port: VsockPorts.agent) - let channel = AgentChannel(connection: connection) + let machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.agent-ping.vcpu0" + ) + try machineRunner.start { machineResult in + let failure: any Error + switch machineResult { + case .success(let reason): + failure = VMError.bootFailure( + "guest stopped before agent answered: \(reason)" + ) + case .failure(let error): + failure = error + } + if result.setIfEmpty(.failure(failure)) { + semaphore.signal() + } + } + + let probeTask = Task.detached { + defer { probeFinished.signal() } + while !Task.isCancelled, + DispatchTime.now().uptimeNanoseconds < deadline { + var connection: VsockConnection? do { + let admitted = try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + connection = admitted + let channel = AgentChannel(connection: admitted) let info = try await channel.info() - result.set(.success(info)) - semaphore.signal() + if result.setIfEmpty(.success(info)) { + semaphore.signal() + } return } catch { - connection.close() - try? await Task.sleep(nanoseconds: 500_000_000) + connection?.close() + do { + try await Task.sleep(nanoseconds: 500_000_000) + } catch { + return + } } } - result.set(.failure(VMError.bootFailure("guest agent did not answer on vsock port 1024 within \(options.timeoutSeconds)s"))) - semaphore.signal() + guard !Task.isCancelled else { return } + if result.setIfEmpty(.failure(VMError.bootFailure( + "guest agent did not answer on vsock port 1024 within \(options.timeoutSeconds)s" + ))) { + semaphore.signal() + } } semaphore.wait() + probeTask.cancel() + probeFinished.wait() switch result.get() { case .success(let info): + _ = try machineRunner.stopAndWait(.powerOff) let data = try JSONEncoder().encode(info) print(String(decoding: data, as: UTF8.self)) - exit(0) case .failure(let error): - fail("\(error)") + _ = try? machineRunner.stopAndWait(.crash("agent-ping failed: \(error)")) + throw error case nil: - fail("agent-ping ended without a result") + _ = try? machineRunner.stopAndWait( + .crash("agent-ping ended without a result") + ) + throw VMError.bootFailure("agent-ping ended without a result") } } catch { fail("\(error)") @@ -190,135 +216,10 @@ func runAgentPing(_ options: Options) { let arguments = Array(CommandLine.arguments.dropFirst()) guard let command = arguments.first else { - fail("usage: dory-hv [options]") + fail("usage: dory-hv [options]") } switch command { -case "data-drive": - guard arguments.count >= 2 else { - fail("usage: dory-hv data-drive [paths]") - } - let operation = arguments[1] - do { - let home = DoryDataDrive.processHome() - switch operation { - case "selected-path": - guard arguments.count == 2 else { - fail("usage: dory-hv data-drive selected-path") - } - guard let path = try DoryDataDriveSelectionStore(home: home).selectedPath() else { - exit(3) - } - print(path) - case "select", "bind-existing": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive \(operation) ") - } - let store = try DoryDataDriveSelectionStore(home: home) - let drive = operation == "select" - ? try store.prepareSelection(requestedRoot: arguments[2]) - : try store.bindExistingSelection(requestedRoot: arguments[2]) - print(try drive.readManifest().id.uuidString.lowercased()) - case "recover-existing": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive recover-existing ") - } - let store = try DoryDataDriveSelectionStore(home: home) - let drive = try store.recoverExistingSelection(requestedRoot: arguments[2]) - print(try drive.readManifest().id.uuidString.lowercased()) - case "resolve": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive resolve ") - } - let drive = try DoryDataDrive(home: home, overrideRoot: arguments[2]) - print(drive.root) - case "prepare": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive prepare ") - } - let drive = try DoryDataDrive(home: home, overrideRoot: arguments[2]) - try drive.prepare() - print(try drive.readManifest().id.uuidString.lowercased()) - case "id": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive id ") - } - let drive = try DoryDataDrive(home: home, overrideRoot: arguments[2]) - try drive.validateManifest() - print(try drive.readManifest().id.uuidString.lowercased()) - case "capacity", "grow": - let store = try DoryDataDriveSelectionStore(home: home) - guard let drive = try store.inspectSelection() else { - fail("no Dory data drive is selected") - } - let usage: DockerDataDiskUsage - if operation == "capacity" { - guard arguments.count == 2 else { - fail("usage: dory-hv data-drive capacity") - } - usage = try DockerDataDisk.usage(at: drive.engineDataDiskPath) - } else { - guard arguments.count == 3, let capacityGiB = Int(arguments[2]) else { - fail("usage: dory-hv data-drive grow ") - } - let driveLock = try EngineStateDirectoryLock( - stateDirectory: drive.root, - lockFileName: "drive.lock" - ) - defer { withExtendedLifetime(driveLock) {} } - usage = try DockerDataDisk.grow( - destination: drive.engineDataDiskPath, - capacityGiB: capacityGiB - ) - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - print(String(decoding: try encoder.encode(usage), as: UTF8.self)) - case "backup": - guard arguments.count == 4 else { - fail("usage: dory-hv data-drive backup ") - } - let drive = try DoryDataDrive(home: home, overrideRoot: arguments[2]) - let result = try DoryDataDriveTransaction.backup(from: drive, to: arguments[3]) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - print(String(decoding: try encoder.encode(result), as: UTF8.self)) - case "verify-backup": - guard arguments.count == 3 else { - fail("usage: dory-hv data-drive verify-backup ") - } - let result = try DoryDataDriveArchive.verifyBackup(at: arguments[2]) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - print(String(decoding: try encoder.encode(result), as: UTF8.self)) - case "restore": - guard arguments.count == 4 else { - fail("usage: dory-hv data-drive restore ") - } - let drive = try DoryDataDrive(home: home, overrideRoot: arguments[3]) - let result = try DoryDataDriveTransaction.restore(at: arguments[2], to: drive) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - print(String(decoding: try encoder.encode(result), as: UTF8.self)) - default: - fail("usage: dory-hv data-drive [paths]") - } - } catch { - fail("data-drive \(operation) failed: \(error)") - } -case "lzfse": - let sub = arguments.dropFirst().first - let paths = Array(arguments.dropFirst(2)) - guard let sub, paths.count == 2 else { fail("usage: dory-hv lzfse ") } - do { - switch sub { - case "compress": try LZFSE.compress(source: paths[0], destination: paths[1]) - case "decompress": try LZFSE.decompress(source: paths[0], destination: paths[1]) - default: fail("usage: dory-hv lzfse ") - } - } catch { - fail("lzfse \(sub) failed: \(error)") - } case "smoke": do { let result = try HVSmoke.run() @@ -332,93 +233,11 @@ case "madvtest": } catch { fail("\(error)") } -case "daxprobe": +case "renderer-qualify": do { - let arg = arguments.dropFirst(1).first - let base = arg.flatMap { UInt64($0.hasPrefix("0x") ? String($0.dropFirst(2)) : $0, radix: 16) } - let result = try DaxCoherenceProbe.run(daxGuestBase: base ?? GuestLayout.daxWindowBase) - print("dory-hv: \(result)") - } catch { - fail("\(error)") - } -case "boot": - let options = parseOptions(arguments.dropFirst()) - guard let kernel = options.kernel else { fail("boot requires --kernel") } - do { - let configuration = MachineConfiguration( - kernelPath: kernel, - commandLine: options.commandLine, - memoryBytes: options.memoryMB << 20, - cpuCount: options.cpus - ) - let machine = try Machine(configuration: configuration) - let console = FileHandle.standardOutput - attachPlatformDevices(to: machine, console: console) - var backends: [VirtioDeviceBackend] = [] - for (slot, diskPath) in options.disks.enumerated() { - backends.append(try VirtioBlk(path: diskPath, identity: "dory-blk\(slot)")) - } - var daxSlot: UInt64 = 0 - for share in options.shares { - let daxBase = share.dax ? GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize : nil - if share.dax { daxSlot += 1 } - backends.append(try share.makeBackend( - daxGuestBase: daxBase, - requestQueueCount: min(8, max(1, options.cpus)) - )) - FileHandle.standardError.write(Data("dory-hv: sharing \(share.path) as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")\(share.dax ? " (dax)" : "")\n".utf8)) - } - backends.append(VirtioRng()) - backends.append(VirtioBalloon(memory: machine.memory) { message in - FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) - }) - backends.append(VirtioVsock(guestCID: 3)) - var gvproxyProcess: Process? - if let gvproxyPath = options.gvproxy { - let networkDirectory = NSHomeDirectory() + "/.dory/hv" - try FileManager.default.createDirectory(atPath: networkDirectory, withIntermediateDirectories: true) - let datapathSocket = networkDirectory + "/net.sock" - let apiSocket = networkDirectory + "/gvproxy-api.sock" - try? FileManager.default.removeItem(atPath: datapathSocket) - try? FileManager.default.removeItem(atPath: apiSocket) - - let process = Process() - process.executableURL = URL(fileURLWithPath: gvproxyPath) - process.arguments = [ - "-mtu", String(DoryNetworkMTU.resolved()), - "-listen-vfkit", "unixgram://\(datapathSocket)", - "-listen", "unix://\(apiSocket)", - ] - process.standardOutput = FileHandle.standardError - process.standardError = FileHandle.standardError - try process.run() - gvproxyProcess = process - for _ in 0..<100 { - if FileManager.default.fileExists(atPath: datapathSocket) { break } - usleep(50_000) - } - let vmSocket = networkDirectory + "/vm-net.sock" - backends.append(try VirtioNet(socketPath: vmSocket, remotePath: datapathSocket)) - FileHandle.standardError.write(Data("dory-hv: networking via gvproxy (\(datapathSocket))\n".utf8)) - - } - defer { gvproxyProcess?.terminate() } - for (slot, backend) in backends.enumerated() { - let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) - let transport = VirtioMMIOTransport( - baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, - backend: backend, - memory: machine.memory - ) { [weak machine] in - machine?.raiseGSI(spi) - } - machine.attachVirtioSlot(transport) - } - try machine.loadBootPayload() - let stop = try machine.run() - print("\ndory-hv: guest stopped: \(stop)") + try RendererBootstrapQualificationCommand.run(arguments.dropFirst()) } catch { - fail("\(error)") + fail("renderer qualification failed: \(error)") } case "desktop": var machineID: String? @@ -427,22 +246,26 @@ case "desktop": var kernel: String? var initrd: String? var rootfs: String? + var runtimeLaunchEnvelope: RuntimeLaunchEnvelope? + var legacyGraphicsBackend: DoryDesktopGraphicsBackend? var rootDevice = "/dev/vda" + var rootDeviceWasSpecified = false var genericGuest = false + var bootMode: String? var gvproxy: String? var handoffSocket: String? var agentSocket: String? var shellSocket: String? + var consoleSocket: String? var controlSocket: String? var usbControlSocket: String? var sshAgentSocket: String? var memoryMB: UInt64 = 6_144 + var memoryWasSpecified = false var cpus = 6 + var cpusWereSpecified = false var shares = [DoryMachineShareConfiguration]() var environment = [String: String]() - var resolvedGraphics: DoryGraphicsAccelerationLevel? - var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? - var resolvedPortForwards: [DoryVMPortForward]? var displayPresentation: DoryMachineDisplayPresentation = .windowed var iterator = arguments.dropFirst().makeIterator() while let argument = iterator.next() { @@ -458,16 +281,43 @@ case "desktop": case "--kernel": kernel = iterator.next() case "--initrd": initrd = iterator.next() case "--rootfs": rootfs = iterator.next() - case "--root-device": rootDevice = iterator.next() ?? rootDevice + case "--runtime-launch-envelope": + guard let value = iterator.next() else { + fail("desktop --runtime-launch-envelope requires a value") + } + do { + runtimeLaunchEnvelope = try RuntimeLaunchEnvelope.decodeResolvedRawHVArgument(value) + } catch { + fail("invalid desktop runtime launch envelope: \(error)") + } + case "--legacy-graphics": + guard let value = iterator.next(), + let backend = DoryDesktopGraphicsBackend(rawValue: value) else { + fail("desktop --legacy-graphics requires software, virgl, or virgl-venus") + } + legacyGraphicsBackend = backend + case "--root-device": + rootDeviceWasSpecified = true + rootDevice = iterator.next() ?? rootDevice case "--generic-guest": genericGuest = true case "--gvproxy": gvproxy = iterator.next() case "--handoff-sock": handoffSocket = iterator.next() case "--agent-sock": agentSocket = iterator.next() case "--shell-sock": shellSocket = iterator.next() + case "--console-sock": consoleSocket = iterator.next() case "--ssh-agent-socket": sshAgentSocket = iterator.next() case "--memory-mb", "--mem-mb": - memoryMB = iterator.next().flatMap(UInt64.init) ?? memoryMB - case "--cpus": cpus = iterator.next().flatMap(Int.init) ?? cpus + guard let value = iterator.next(), let parsed = UInt64(value), parsed > 0 else { + fail("desktop --memory-mb requires a positive integer") + } + memoryMB = parsed + memoryWasSpecified = true + case "--cpus": + guard let value = iterator.next(), let parsed = Int(value), parsed > 0 else { + fail("desktop --cpus requires a positive integer") + } + cpus = parsed + cpusWereSpecified = true case "--share": guard let value = iterator.next() else { fail("desktop --share requires a value") } do { shares.append(try DoryMachineShareConfiguration(argument: value)) } @@ -477,32 +327,6 @@ case "desktop": fail("desktop --env requires KEY=VALUE") } environment[String(value[.. [userAuthorized|seize|capture]") } - let mode: HostUsbOpenMode - switch args.dropFirst().first { - case "seize": mode = .seize - case "capture", nil: mode = .capture - case "userAuthorized", "user": mode = .userAuthorized - case let other?: fail("unknown mode \(other)") - } - do { - FileHandle.standardError.write(Data("dory-hv: claiming \(busID) mode=\(mode)…\n".utf8)) - let device = try HostUsbDeviceFactory.open(busID: busID, mode: mode) - let command = UsbipSubmitCommand( - header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 1, deviceID: 0, direction: .in, endpoint: 0), - transferFlags: 0, - transferBufferLength: 18, - startFrame: 0, - numberOfPackets: 0, - interval: 0, - setup: [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00], // GET_DESCRIPTOR(device, 18) - transferBuffer: [] - ) - let reply = try device.submit(command) - let bytes = reply.transferBuffer - print("CLAIM OK. GET_DESCRIPTOR status=\(reply.status) actualLength=\(reply.actualLength) bytes=\(bytes.count)") - if bytes.count >= 12 { - let vid = UInt16(bytes[8]) | (UInt16(bytes[9]) << 8) - let pid = UInt16(bytes[10]) | (UInt16(bytes[11]) << 8) - print(String(format: "device descriptor: bLength=%d bDescriptorType=%d idVendor=0x%04x idProduct=0x%04x", bytes[0], bytes[1], vid, pid)) - } - } catch { - fail("usb probe failed: \(error)") - } - case "attach": - let attachArgs = Array(arguments.dropFirst(2)) - guard let busID = attachArgs.first else { fail("usage: dory-hv usb attach [userAuthorized|seize|capture]") } - let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" - do { - let response = try UsbControlClient.send(UsbControlRequest(cmd: "attach", busid: busID, mode: attachArgs.dropFirst().first), socketPath: controlSocket) - guard response.ok else { fail("usb attach failed: \(response.error ?? "unknown")") } - print("attached \(busID) on vhci port \(response.port ?? -1)") - } catch { - fail("usb attach failed: \(error)") - } - case "detach": - let detachArgs = Array(arguments.dropFirst(2)) - guard let busID = detachArgs.first else { fail("usage: dory-hv usb detach ") } - let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" - do { - let response = try UsbControlClient.send(UsbControlRequest(cmd: "detach", busid: busID), socketPath: controlSocket) - guard response.ok else { fail("usb detach failed: \(response.error ?? "unknown")") } - print("detached \(busID)") - } catch { - fail("usb detach failed: \(error)") - } default: - fail("usage: dory-hv usb ") + fail("usage: dory-hv usb list") } case "engine": var engineSocket = "\(NSHomeDirectory())/.dory/engine.sock" @@ -667,6 +550,8 @@ case "engine": var directIPv6VirtualNetwork = "fd7d:6f72:7900::/64" var directIPv6HostGateway = "fd7d:6f72:7900::1" var gpuMode = EngineMode.GPUAccelerationMode.off + var reclaimPolicy = EngineMode.ReclaimPolicy.dropCaches + var fuseRequestQueuePolicy = EngineMode.FuseRequestQueuePolicy.automatic var amd64Emulation = false var publishHost = "127.0.0.1" var agentVsockForward: String? @@ -723,6 +608,23 @@ case "engine": gpuMode = parseGPUMode(iterator.next() ?? "") case let value where value.hasPrefix("--gpu="): gpuMode = parseGPUMode(String(value.dropFirst("--gpu=".count))) + case "--memory-reclaim": + guard let value = iterator.next(), + let parsed = EngineMode.ReclaimPolicy(rawValue: value) else { + fail("engine --memory-reclaim requires drop-caches or senpai") + } + reclaimPolicy = parsed + case "--fuse-request-queues": + guard let value = iterator.next(), let count = Int(value) else { + fail("engine --fuse-request-queues requires an integer from 1 through 8") + } + do { + fuseRequestQueuePolicy = try EngineMode.FuseRequestQueuePolicy( + fixedCount: count + ) + } catch { + fail("\(error)") + } case "--amd64": amd64Emulation = true case "--publish-host": @@ -778,25 +680,19 @@ case "engine": ) }, gpuMode: gpuMode, + reclaimPolicy: reclaimPolicy, + fuseRequestQueuePolicy: fuseRequestQueuePolicy, amd64Emulation: amd64Emulation, publishHost: publishHost, agentVsockForward: agentVsockForward, sshAgentSocket: sshAgentSocket, guestAgentPath: guestAgent ) - // Top-level code is implicitly MainActor; a plain Task would inherit it and deadlock behind - // the semaphore below. Detach so the engine runs on the concurrent pool. - let semaphore = DispatchSemaphore(value: 0) - Task.detached { - do { - try await EngineMode.run(configuration) - } catch { - FileHandle.standardError.write(Data("dory-hv: engine failed: \(error)\n".utf8)) - exit(1) - } - semaphore.signal() + do { + try EngineMode.run(configuration) + } catch { + fail("engine failed: \(error)") } - semaphore.wait() default: fail("unknown command \(command)") } diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift new file mode 100644 index 00000000..3c578ccc --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift @@ -0,0 +1,26 @@ +import Darwin +import Testing +@testable import DoryFSWorkerServiceCore + +struct DoryFSWorkerProcessResourcesTests { + @Test func descriptorSoftLimitIsBoundedByHardLimitAndWorkerCeiling() { + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 256, + hard: 1_048_576, + ceiling: 262_144 + ) == 262_144) + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 256, + hard: 32_768, + ceiling: 262_144 + ) == 32_768) + } + + @Test func descriptorSoftLimitNeverLowersAnExistingHigherLimit() { + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 1_048_576, + hard: rlim_t.max, + ceiling: 262_144 + ) == 1_048_576) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift new file mode 100644 index 00000000..0cccc1ab --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift @@ -0,0 +1,820 @@ +import Darwin +import DoryFSWorkerContracts +@testable import DoryFSWorkerServiceCore +import Foundation +import Testing + +@Suite(.serialized) +struct DoryFSWorkerRootAuthorityTests { + @Test func rootAuthorityErrorsMapToBoundedNonSensitiveBootstrapReasons() throws { + let identifier = try capability(index: 1) + let cases: [(DoryFSWorkerRootAuthorityError, DoryFSWorkerRPCFailureCode)] = [ + (.bookmarkResolutionFailed(identifier), .bootstrapBookmarkResolutionFailed), + (.staleBookmark(identifier), .bootstrapBookmarkStale), + (.securityScopeDenied(identifier), .bootstrapScopeActivationFailed), + (.rootOpenFailed(identifier, errno: EACCES), .bootstrapRootOpenFailed), + (.rootInspectionFailed(identifier, errno: EIO), .bootstrapRootOpenFailed), + (.rootIdentityMismatch(identifier), .bootstrapRootIdentityMismatch), + ] + + for (error, code) in cases { + #expect(error.bootstrapFailureCode == code) + } + } + + @Test func acceptsAllSharesThenReturnsExactReceiptAndBoundedDescriptors() throws { + let tree = try TemporaryDirectoryTree() + let firstURL = try tree.makeDirectory("first") + let secondURL = try tree.makeDirectory("second") + let firstBookmark = Data([1, 1, 1]) + let secondBookmark = Data([2, 2, 2]) + let firstIdentity = try pinnedIdentity(of: firstURL) + let secondIdentity = try pinnedIdentity(of: secondURL) + let first = try share( + index: 1, + bookmark: firstBookmark, + identity: firstIdentity + ) + let second = try share( + index: 2, + bookmark: secondBookmark, + identity: secondIdentity + ) + let bootstrap = try makeBootstrap(shares: [second, first]) + let resolver = TestBookmarkResolver([ + firstBookmark: .init(url: firstURL), + secondBookmark: .init(url: secondURL), + ]) + var authority: DoryFSWorkerRootAuthority? = makeAuthority(resolver: resolver) + + let receiptBytes = try authority!.bootstrap( + exactBytes: DoryFSWorkerBootstrapCodec.encode(bootstrap) + ) + + #expect( + try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + #expect(resolver.startAttempts(for: firstURL) == 1) + #expect(resolver.startAttempts(for: secondURL) == 1) + #expect(resolver.activeScopes(for: firstURL) == 1) + #expect(resolver.activeScopes(for: secondURL) == 1) + + var escapedDescriptor: Int32 = -1 + try authority!.withBorrowedRootFileDescriptor(for: first.capabilityID) { descriptor in + escapedDescriptor = descriptor + var status = stat() + #expect(fstat(descriptor, &status) == 0) + let observedIdentity = try identity(status) + #expect(observedIdentity == firstIdentity) + let statusFlags = fcntl(descriptor, F_GETFL) + #expect(statusFlags >= 0) + #expect(statusFlags & O_ACCMODE == O_RDONLY) + let descriptorFlags = fcntl(descriptor, F_GETFD) + #expect(descriptorFlags >= 0) + #expect(descriptorFlags & FD_CLOEXEC != 0) + } + // Capturing the temporary integer cannot extend the lexical borrow. + #expect(!descriptor(escapedDescriptor, stillNames: firstIdentity)) + + let firstBeforeRelease = matchingDescriptors(firstIdentity) + #expect(!firstBeforeRelease.isEmpty) + authority = nil + #expect(resolver.activeScopes(for: firstURL) == 0) + #expect(resolver.activeScopes(for: secondURL) == 0) + #expect(resolver.stopCalls(for: firstURL) == 1) + #expect(resolver.stopCalls(for: secondURL) == 1) + #expect(matchingDescriptors(firstIdentity).isDisjoint(with: firstBeforeRelease)) + } + + @Test func processAdmissionRejectsSecondAuthorityObject() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let bookmark = Data([3]) + let bootstrap = try makeBootstrap(shares: [ + share(index: 3, bookmark: bookmark, identity: pinnedIdentity(of: root)), + ]) + let bytes = try DoryFSWorkerBootstrapCodec.encode(bootstrap) + let resolver = TestBookmarkResolver([bookmark: .init(url: root)]) + let gate = DoryFSWorkerBootstrapAdmission() + let first = DoryFSWorkerRootAuthority( + resolver: resolver, + bootstrapAdmission: gate + ) + let second = DoryFSWorkerRootAuthority( + resolver: resolver, + bootstrapAdmission: gate + ) + + _ = try first.bootstrap(exactBytes: bytes) + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted) { + _ = try second.bootstrap(exactBytes: bytes) + } + #expect(resolver.startAttempts(for: root) == 1) + } + + @Test func malformedExactEnvelopeConsumesTheOnlyBootstrapAttempt() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let bookmark = Data([4]) + let bootstrap = try makeBootstrap(shares: [ + share(index: 4, bookmark: bookmark, identity: pinnedIdentity(of: root)), + ]) + let valid = try DoryFSWorkerBootstrapCodec.encode(bootstrap) + var trailing = valid + trailing.append(0) + let authority = makeAuthority( + resolver: TestBookmarkResolver([bookmark: .init(url: root)]) + ) + + #expect(throws: DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: UInt32(valid.count), + actual: trailing.count + )) { + _ = try authority.bootstrap(exactBytes: trailing) + } + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted) { + _ = try authority.bootstrap(exactBytes: valid) + } + } + + @Test func staleOneShotBookmarkIsAcceptedOnlyWhenPinnedIdentityStillMatches() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let bookmark = Data([5]) + let authorityShare = try share( + index: 5, + bookmark: bookmark, + identity: pinnedIdentity(of: root) + ) + let resolver = TestBookmarkResolver([ + bookmark: .init(url: root, isStale: true), + ]) + var authority: DoryFSWorkerRootAuthority? = makeAuthority(resolver: resolver) + + _ = try authority!.bootstrap(exactBytes: encodedBootstrap([authorityShare])) + #expect(resolver.startAttempts(for: root) == 1) + #expect(resolver.activeScopes(for: root) == 1) + authority = nil + #expect(resolver.stopCalls(for: root) == 1) + #expect(resolver.activeScopes(for: root) == 0) + } + + @Test func staleBookmarkThatNoLongerNamesPinnedIdentityIsRejectedAndReleased() throws { + let tree = try TemporaryDirectoryTree() + let original = try tree.makeDirectory("original") + let replacement = try tree.makeDirectory("replacement") + let bookmark = Data([15]) + let authorityShare = try share( + index: 15, + bookmark: bookmark, + identity: pinnedIdentity(of: original) + ) + let resolver = TestBookmarkResolver([ + bookmark: .init(url: replacement, isStale: true), + ]) + let authority = makeAuthority(resolver: resolver) + + #expect(throws: DoryFSWorkerRootAuthorityError.staleBookmark( + authorityShare.capabilityID + )) { + _ = try authority.bootstrap(exactBytes: encodedBootstrap([authorityShare])) + } + #expect(resolver.startAttempts(for: replacement) == 1) + #expect(resolver.stopCalls(for: replacement) == 1) + #expect(resolver.activeScopes(for: replacement) == 0) + } + + @Test func deniedSecurityScopeNeverOpensOrStopsAnUnstartedScope() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let bookmark = Data([6]) + let authorityShare = try share( + index: 6, + bookmark: bookmark, + identity: pinnedIdentity(of: root) + ) + let resolver = TestBookmarkResolver([ + bookmark: .init(url: root, allowsScope: false), + ]) + let before = matchingDescriptors(try pinnedIdentity(of: root)) + let authority = makeAuthority(resolver: resolver) + + #expect(throws: DoryFSWorkerRootAuthorityError.securityScopeDenied( + authorityShare.capabilityID + )) { + _ = try authority.bootstrap(exactBytes: encodedBootstrap([authorityShare])) + } + #expect(resolver.startAttempts(for: root) == 1) + #expect(resolver.activeScopes(for: root) == 0) + #expect(resolver.stopCalls(for: root) == 0) + #expect(matchingDescriptors(try pinnedIdentity(of: root)) == before) + } + + @Test func rejectsNonDirectoryAndSymbolicLinkRoots() throws { + let tree = try TemporaryDirectoryTree() + let file = try tree.makeFile("ordinary-file") + let target = try tree.makeDirectory("target") + let link = try tree.makeSymbolicLink("link", destination: target) + + try assertRootOpenRejected(url: file, index: 7) + try assertRootOpenRejected(url: link, index: 8, expectedIdentityURL: target) + } + + @Test func rejectsPathReplacementAgainstSealedIdentity() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let sealedIdentity = try pinnedIdentity(of: root) + let preserved = tree.root.appendingPathComponent("preserved", isDirectory: true) + try FileManager.default.moveItem(at: root, to: preserved) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + #expect(try pinnedIdentity(of: root) != sealedIdentity) + + let bookmark = Data([9]) + let authorityShare = try share( + index: 9, + bookmark: bookmark, + identity: sealedIdentity + ) + let resolver = TestBookmarkResolver([bookmark: .init(url: root)]) + let authority = makeAuthority(resolver: resolver) + + #expect(throws: DoryFSWorkerRootAuthorityError.rootIdentityMismatch( + authorityShare.capabilityID + )) { + _ = try authority.bootstrap(exactBytes: encodedBootstrap([authorityShare])) + } + #expect(resolver.startAttempts(for: root) == 1) + #expect(resolver.stopCalls(for: root) == 1) + #expect(resolver.activeScopes(for: root) == 0) + } + + @Test func partialMultiShareFailureRollsBackEveryEarlierRootAndScope() throws { + let tree = try TemporaryDirectoryTree() + let validRoot = try tree.makeDirectory("valid") + let invalidRoot = try tree.makeFile("invalid") + let validBookmark = Data([10]) + let invalidBookmark = Data([11]) + let validIdentity = try pinnedIdentity(of: validRoot) + let validShare = try share( + index: 10, + bookmark: validBookmark, + identity: validIdentity + ) + let invalidShare = try share( + index: 11, + bookmark: invalidBookmark, + identity: pinnedIdentity(of: invalidRoot) + ) + let resolver = TestBookmarkResolver([ + validBookmark: .init(url: validRoot), + invalidBookmark: .init(url: invalidRoot), + ]) + let before = matchingDescriptors(validIdentity) + let authority = makeAuthority(resolver: resolver) + + do { + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([validShare, invalidShare]) + ) + Issue.record("bootstrap unexpectedly accepted a non-directory second share") + } catch let error as DoryFSWorkerRootAuthorityError { + guard case .rootOpenFailed(let capabilityID, _) = error else { + Issue.record("unexpected authority error: \(error)") + return + } + #expect(capabilityID == invalidShare.capabilityID) + } + + #expect(resolver.startAttempts(for: validRoot) == 1) + #expect(resolver.stopCalls(for: validRoot) == 1) + #expect(resolver.activeScopes(for: validRoot) == 0) + #expect(resolver.startAttempts(for: invalidRoot) == 1) + #expect(resolver.stopCalls(for: invalidRoot) == 1) + #expect(resolver.activeScopes(for: invalidRoot) == 0) + #expect(matchingDescriptors(validIdentity) == before) + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapNotAccepted) { + try authority.withBorrowedRootFileDescriptor(for: validShare.capabilityID) { _ in } + } + } + + @Test func unknownCapabilityCannotBorrowOrAlterAcceptedAuthority() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let bookmark = Data([12]) + let accepted = try share( + index: 12, + bookmark: bookmark, + identity: pinnedIdentity(of: root) + ) + let authority = makeAuthority( + resolver: TestBookmarkResolver([bookmark: .init(url: root)]) + ) + _ = try authority.bootstrap(exactBytes: encodedBootstrap([accepted])) + let unknown = try capability(index: 13) + var invoked = false + + #expect(throws: DoryFSWorkerRootAuthorityError.unknownCapability(unknown)) { + try authority.withBorrowedRootFileDescriptor(for: unknown) { _ in + invoked = true + } + } + #expect(!invoked) + + // The accepted root remains usable after an unauthorized lookup. + try authority.withBorrowedRootFileDescriptor(for: accepted.capabilityID) { descriptor in + var status = stat() + #expect(fstat(descriptor, &status) == 0) + } + } + + @Test func thrownBorrowClosesTemporaryDescriptorWithoutRevokingRoot() throws { + enum ProbeError: Error { case stop } + + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let rootIdentity = try pinnedIdentity(of: root) + let bookmark = Data([14]) + let accepted = try share( + index: 14, + bookmark: bookmark, + identity: rootIdentity + ) + let authority = makeAuthority( + resolver: TestBookmarkResolver([bookmark: .init(url: root)]) + ) + _ = try authority.bootstrap(exactBytes: encodedBootstrap([accepted])) + var escaped: Int32 = -1 + + #expect(throws: ProbeError.stop) { + try authority.withBorrowedRootFileDescriptor(for: accepted.capabilityID) { descriptor in + escaped = descriptor + throw ProbeError.stop + } + } + #expect(!descriptor(escaped, stillNames: rootIdentity)) + try authority.withBorrowedRootFileDescriptor(for: accepted.capabilityID) { descriptor in + var status = stat() + #expect(fstat(descriptor, &status) == 0) + let observedIdentity = try identity(status) + #expect(observedIdentity == rootIdentity) + } + } + + private func assertRootOpenRejected( + url: URL, + index: Int, + expectedIdentityURL: URL? = nil + ) throws { + let bookmark = Data([UInt8(index)]) + let authorityShare = try share( + index: index, + bookmark: bookmark, + identity: pinnedIdentity(of: expectedIdentityURL ?? url) + ) + let resolver = TestBookmarkResolver([bookmark: .init(url: url)]) + let authority = makeAuthority(resolver: resolver) + + do { + _ = try authority.bootstrap(exactBytes: encodedBootstrap([authorityShare])) + Issue.record("bootstrap unexpectedly opened a non-directory or symbolic-link root") + } catch let error as DoryFSWorkerRootAuthorityError { + guard case .rootOpenFailed(let capabilityID, _) = error else { + Issue.record("unexpected authority error: \(error)") + return + } + #expect(capabilityID == authorityShare.capabilityID) + } + #expect(resolver.startAttempts(for: url) == 1) + #expect(resolver.stopCalls(for: url) == 1) + #expect(resolver.activeScopes(for: url) == 0) + } +} + +@Suite(.serialized) +struct DoryFSWorkerServiceTeardownTests { + @Test func syncfsFallbackThenCommittedDestroyResetsResourcesAndClosesAdmission() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let file = root.appendingPathComponent("payload.txt") + #expect(FileManager.default.createFile(atPath: file.path, contents: Data([0x44]))) + let fileIdentity = try pinnedIdentity(of: file) + let baselineDescriptors = matchingDescriptors(fileIdentity) + let bookmark = Data([20]) + let authorityShare = try share( + index: 20, + bookmark: bookmark, + identity: pinnedIdentity(of: root) + ) + let rootAuthority = DoryFSWorkerRootAuthority( + resolver: TestBookmarkResolver([bookmark: .init(url: root)]), + bootstrapAdmission: DoryFSWorkerBootstrapAdmission() + ) + let service = DoryFSWorkerService(rootAuthority: rootAuthority) + let bootstrap = try makeBootstrap(shares: [authorityShare]) + let bootstrapBytes = try DoryFSWorkerBootstrapCodec.encode(bootstrap) + + let receiptBytes = try unwrapRPC(service.bootstrap(exactBytes: bootstrapBytes)) + #expect( + try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + + let lookup = try execute( + service: service, + share: authorityShare, + requestID: 1, + unique: 101, + opcode: .lookup, + payload: Array("payload.txt\0".utf8), + responseCapacity: FuseOutHeader.byteCount + 128 + ) + let lookupResponse = try completedResponse(lookup) + let nodeID = [UInt8](lookupResponse).leUInt64(at: FuseOutHeader.byteCount) + try commit(lookup, service: service) + + let open = try execute( + service: service, + share: authorityShare, + requestID: 2, + unique: 102, + opcode: .open, + nodeID: nodeID, + payload: littleEndian(UInt32(O_RDONLY)) + littleEndian(UInt32(0)), + responseCapacity: FuseOutHeader.byteCount + 16 + ) + _ = try completedResponse(open) + try commit(open, service: service) + #expect(matchingDescriptors(fileIdentity).count > baselineDescriptors.count) + + let unknown = try execute( + service: service, + share: authorityShare, + requestID: 3, + unique: 103, + rawOpcode: UInt32.max, + opcodeClass: .control, + responseCapacity: FuseOutHeader.byteCount + ) + #expect(unknown.outcome == .rejected(.invalidRequest)) + + let sync = try execute( + service: service, + share: authorityShare, + requestID: 4, + unique: 104, + opcode: .syncfs, + payload: [UInt8](repeating: 0, count: 8), + responseCapacity: FuseOutHeader.byteCount + ) + let syncResponse = try completedResponse(sync) + #expect( + try FuseProtocol.decodeOutHeader([UInt8](syncResponse)).error + == -FuseProtocol.linuxErrno(ENOSYS) + ) + try commit(sync, service: service) + + let destroy = try execute( + service: service, + share: authorityShare, + requestID: 5, + unique: 105, + opcode: .destroy, + nodeID: 0, + responseCapacity: FuseOutHeader.byteCount + ) + #expect(try FuseProtocol.decodeOutHeader([UInt8](completedResponse(destroy))).error == 0) + try commit(destroy, service: service) + + #expect(matchingDescriptors(fileIdentity) == baselineDescriptors) + let afterDestroy = try execute( + service: service, + share: authorityShare, + requestID: 6, + unique: 106, + opcode: .getattr, + payload: FuseProtocol.encodeGetattrIn(FuseGetattrIn()), + responseCapacity: FuseOutHeader.byteCount + 104 + ) + #expect(afterDestroy.outcome == .rejected(.connectionTeardown)) + } + + private func execute( + service: DoryFSWorkerService, + share: DoryFSShareBootstrapAuthority, + requestID: UInt64, + unique: UInt64, + opcode: FuseOpcode, + nodeID: UInt64 = HostFS.rootNodeID, + payload: [UInt8] = [], + responseCapacity: Int + ) throws -> DoryFSWorkerReply { + try execute( + service: service, + share: share, + requestID: requestID, + unique: unique, + rawOpcode: opcode.rawValue, + opcodeClass: opcode.workerOpcodeClass, + nodeID: nodeID, + payload: payload, + responseCapacity: responseCapacity + ) + } + + private func execute( + service: DoryFSWorkerService, + share: DoryFSShareBootstrapAuthority, + requestID: UInt64, + unique: UInt64, + rawOpcode: UInt32, + opcodeClass: DoryFSWorkerOpcodeClass, + nodeID: UInt64 = HostFS.rootNodeID, + payload: [UInt8] = [], + responseCapacity: Int + ) throws -> DoryFSWorkerReply { + let requestBytes = FuseProtocol.encodeInHeader(FuseInHeader( + length: UInt32(FuseInHeader.byteCount + payload.count), + opcode: rawOpcode, + unique: unique, + nodeID: nodeID, + uid: 1_000, + gid: 1_000, + pid: 42 + )) + payload + let deadline = DispatchTime.now().uptimeNanoseconds + 5_000_000_000 + let request = try DoryFSWorkerRequest( + generation: DoryFSWorkerGeneration(rawValue: 17), + shareCapabilityID: share.capabilityID, + requestID: requestID, + correlationID: unique, + opcodeClass: opcodeClass, + responseCapacity: UInt32(responseCapacity), + deadlineUptimeNanoseconds: deadline, + payload: Data(requestBytes) + ) + let frame = try DoryFSWorkerFrameCodec.encode( + .execute(request), + maximumFrameBytes: DoryFSWorkerLimits.production.maximumFrameBytes + ) + let response = try unwrapRPC(service.exchange(exactFrame: frame)) + guard case .reply(let reply) = try DoryFSWorkerFrameCodec.decodeServiceFrame( + response, + maximumFrameBytes: DoryFSWorkerLimits.production.maximumFrameBytes + ) else { + throw DoryFSWorkerServiceTeardownTestError.unexpectedServiceFrame + } + return reply + } + + private func completedResponse(_ reply: DoryFSWorkerReply) throws -> Data { + guard case .completed(let response) = reply.outcome else { + throw DoryFSWorkerServiceTeardownTestError.unexpectedReply(reply.outcome) + } + return response + } + + private func commit( + _ reply: DoryFSWorkerReply, + service: DoryFSWorkerService + ) throws { + let publication = try DoryFSWorkerPublication( + generation: reply.generation, + shareCapabilityID: reply.shareCapabilityID, + requestID: reply.requestID, + correlationID: reply.correlationID + ) + service.sendOneWay(exactFrame: try DoryFSWorkerFrameCodec.encode( + .commitPublication(publication), + maximumFrameBytes: DoryFSWorkerLimits.production.maximumFrameBytes + )) + } +} + +private enum DoryFSWorkerServiceTeardownTestError: Error { + case unexpectedRPCFailure(DoryFSWorkerRPCFailureCode) + case unexpectedServiceFrame + case unexpectedReply(DoryFSWorkerReplyOutcome) +} + +private func unwrapRPC(_ data: Data) throws -> Data { + switch try DoryFSWorkerRPCResultCodec.decode(data) { + case .success(let payload): + return payload + case .failure(let code): + throw DoryFSWorkerServiceTeardownTestError.unexpectedRPCFailure(code) + } +} + +private func littleEndian(_ value: T) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private final class TestBookmarkResolver: DoryFSWorkerBookmarkResolving { + struct Entry { + let url: URL + let isStale: Bool + let allowsScope: Bool + + init(url: URL, isStale: Bool = false, allowsScope: Bool = true) { + self.url = url + self.isStale = isStale + self.allowsScope = allowsScope + } + } + + enum ResolutionError: Error { case unknownBookmark } + + private let lock = NSLock() + private let entries: [Data: Entry] + private var starts = [URL: Int]() + private var stops = [URL: Int]() + private var active = [URL: Int]() + + init(_ entries: [Data: Entry]) { + self.entries = entries + } + + func resolve(_ bookmark: Data) throws -> DoryFSWorkerResolvedBookmark { + guard let entry = entries[bookmark] else { throw ResolutionError.unknownBookmark } + return DoryFSWorkerResolvedBookmark(url: entry.url, isStale: entry.isStale) + } + + func startAccessingSecurityScopedResource(_ url: URL) -> Bool { + lock.lock() + defer { lock.unlock() } + starts[url, default: 0] += 1 + guard entries.values.first(where: { $0.url == url })?.allowsScope == true else { + return false + } + active[url, default: 0] += 1 + return true + } + + func stopAccessingSecurityScopedResource(_ url: URL) { + lock.lock() + defer { lock.unlock() } + stops[url, default: 0] += 1 + active[url, default: 0] -= 1 + } + + func startAttempts(for url: URL) -> Int { + value(in: starts, for: url) + } + + func stopCalls(for url: URL) -> Int { + value(in: stops, for: url) + } + + func activeScopes(for url: URL) -> Int { + value(in: active, for: url) + } + + private func value(in values: [URL: Int], for url: URL) -> Int { + lock.lock() + defer { lock.unlock() } + return values[url, default: 0] + } +} + +private final class TemporaryDirectoryTree { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-fs-worker-root-authority-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false + ) + } + + func makeDirectory(_ name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + return url + } + + func makeFile(_ name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: false) + guard FileManager.default.createFile(atPath: url.path, contents: Data([0x44])) else { + throw CocoaError(.fileWriteUnknown) + } + return url + } + + func makeSymbolicLink(_ name: String, destination: URL) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: false) + try FileManager.default.createSymbolicLink(at: url, withDestinationURL: destination) + return url + } + + deinit { + try? FileManager.default.removeItem(at: root) + } +} + +private func makeAuthority( + resolver: TestBookmarkResolver +) -> DoryFSWorkerRootAuthority { + DoryFSWorkerRootAuthority( + resolver: resolver, + bootstrapAdmission: DoryFSWorkerBootstrapAdmission() + ) +} + +private func encodedBootstrap( + _ shares: [DoryFSShareBootstrapAuthority] +) throws -> Data { + try DoryFSWorkerBootstrapCodec.encode(makeBootstrap(shares: shares)) +} + +private func makeBootstrap( + shares: [DoryFSShareBootstrapAuthority] +) throws -> DoryFSWorkerBootstrap { + try DoryFSWorkerBootstrap( + workspaceID: DoryFSWorkerWorkspaceID( + rawValue: #require(UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")) + ), + generation: DoryFSWorkerGeneration(rawValue: 17), + workerLimits: .production, + shares: shares + ) +} + +private func share( + index: Int, + bookmark: Data, + identity: DoryFSPinnedRootIdentity +) throws -> DoryFSShareBootstrapAuthority { + try DoryFSShareBootstrapAuthority( + capabilityID: capability(index: index), + expectedRootIdentity: identity, + readOnly: index.isMultiple(of: 2), + guestIdentity: DoryFSGuestIdentityPolicy(uid: 1_000, gid: 1_000), + resourceLimits: .production, + securityScopedBookmark: bookmark, + hiddenComponents: [".git"], + rootHiddenComponents: ["library"] + ) +} + +private func capability(index: Int) throws -> DoryFSShareCapabilityID { + precondition((1...255).contains(index)) + return try DoryFSShareCapabilityID(rawValue: UUID(uuid: ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, UInt8(index) + ))) +} + +private func pinnedIdentity(of url: URL) throws -> DoryFSPinnedRootIdentity { + var status = stat() + let result: Int32 = url.withUnsafeFileSystemRepresentation { representation in + guard let representation else { return Int32(-1) } + return lstat(representation, &status) + } + guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + return try identity(status) +} + +private func identity(_ status: stat) throws -> DoryFSPinnedRootIdentity { + try DoryFSPinnedRootIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ) +} + +private func descriptor( + _ descriptor: Int32, + stillNames expected: DoryFSPinnedRootIdentity +) -> Bool { + guard descriptor >= 0 else { return false } + var status = stat() + guard fstat(descriptor, &status) == 0 else { return false } + return (try? identity(status)) == expected +} + +private func matchingDescriptors( + _ expected: DoryFSPinnedRootIdentity +) -> Set { + var result = Set() + for descriptor in Int32(0).. Bool { + var status = stat() + guard fstat(descriptor, &status) == 0 else { return false } + return (try? identity(status)) == expected +} diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseResourceQuotaTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseResourceQuotaTests.swift new file mode 100644 index 00000000..37859e75 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseResourceQuotaTests.swift @@ -0,0 +1,779 @@ +import Darwin +import DoryFSWorkerContracts +import Dispatch +import Foundation +import Testing +@testable import DoryFSWorkerServiceCore + +struct FuseResourceQuotaTests { + @Test func atomicAdmissionNeverOvershootsAndTokensRecover() throws { + let limits = quotaLimits(nodes: 4) + let quota = FuseResourceQuota(limits: limits) + let results = QuotaAdmissionResults() + + DispatchQueue.concurrentPerform(iterations: 64) { _ in + do { + results.record(token: try quota.acquire(.liveNonRootNodes)) + } catch let error as FuseResourceQuotaError { + results.record(error: error) + } catch { + results.recordUnexpected(error) + } + } + + #expect(results.successCount == limits.maximumLiveNonRootNodes) + #expect(results.errors == Array( + repeating: FuseResourceQuotaError( + resource: .liveNonRootNodes, + limit: limits.maximumLiveNonRootNodes + ), + count: 64 - limits.maximumLiveNonRootNodes + )) + #expect(results.unexpectedErrors.isEmpty) + #expect(quota.snapshot().liveNonRootNodes == limits.maximumLiveNonRootNodes) + + results.releaseAll() + #expect(quota.snapshot().liveNonRootNodes == 0) + } + + @Test func nodeQuotaRejectsBeforeCreateAndRecoversOnForgetAndReset() throws { + let root = try QuotaTestRoot() + try root.write("first", to: "first.txt") + try root.write("second", to: "second.txt") + let limits = quotaLimits(nodes: 1) + let hostFS = try HostFS(rootPath: root.url.path, resourceLimits: limits) + + hostFS.identityPinOpenTestErrno = EMFILE + #expect(throws: HostFSError.systemCall("pin identity first.txt", EMFILE)) { + _ = try hostFS.lookup(parent: HostFS.rootNodeID, name: "first.txt") + } + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 0) + hostFS.identityPinOpenTestErrno = nil + + let first = try hostFS.lookup(parent: HostFS.rootNodeID, name: "first.txt") + hostFS.retainLookup(nodeID: first.nodeID) + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 1) + + #expect(throws: FuseResourceQuotaError(resource: .liveNonRootNodes, limit: 1)) { + _ = try hostFS.lookup(parent: HostFS.rootNodeID, name: "second.txt") + } + #expect(throws: FuseResourceQuotaError(resource: .liveNonRootNodes, limit: 1)) { + _ = try hostFS.createFile(parent: HostFS.rootNodeID, name: "not-created.txt") + } + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("not-created.txt").path)) + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 1) + + hostFS.forgetLookup(nodeID: first.nodeID, count: 1) + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 0) + + let second = try hostFS.lookup(parent: HostFS.rootNodeID, name: "second.txt") + hostFS.retainLookup(nodeID: second.nodeID) + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 1) + + hostFS.resetFuseReferences() + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 0) + } + + @Test func concurrentHostLookupsRespectTheNodeBoundaryAndReset() throws { + let root = try QuotaTestRoot() + let names = (0..<32).map { "entry-\($0).txt" } + for name in names { + try root.write(name, to: name) + } + let limit = 4 + let hostFS = try HostFS( + rootPath: root.url.path, + resourceLimits: quotaLimits(nodes: limit) + ) + let results = ConcurrentLookupResults() + + DispatchQueue.concurrentPerform(iterations: names.count) { index in + do { + let entry = try hostFS.lookup(parent: HostFS.rootNodeID, name: names[index]) + hostFS.retainLookup(nodeID: entry.nodeID) + results.recordSuccess() + } catch let error as FuseResourceQuotaError { + results.record(error: error) + } catch { + results.recordUnexpected(error) + } + } + + #expect(results.successCount == limit) + #expect(results.errors.count == names.count - limit) + #expect(results.errors.allSatisfy { + $0 == FuseResourceQuotaError(resource: .liveNonRootNodes, limit: limit) + }) + #expect(results.unexpectedErrors.isEmpty) + #expect(hostFS.resourceSnapshot.liveNonRootNodes == limit) + + hostFS.resetFuseReferences() + #expect(hostFS.resourceSnapshot.liveNonRootNodes == 0) + } + + @Test func handleQuotasMapToEMFILEAndRecoverOnReleaseAndReset() throws { + let root = try QuotaTestRoot() + try root.write("payload", to: "file.txt") + let hostFS = try HostFS( + rootPath: root.url.path, + resourceLimits: quotaLimits(nodes: 1, files: 1, directories: 1) + ) + let server = FuseServer(hostFS: hostFS) + let lookup = server.handle(request: quotaRequest( + unique: 1, + opcode: .lookup, + nodeID: HostFS.rootNodeID, + payload: Array("file.txt\0".utf8) + )) + #expect(try FuseProtocol.decodeOutHeader(lookup).error == 0) + let nodeID = quotaPayload(from: lookup).leUInt64(at: 0) + + let rejectedCreate = server.handle(request: quotaRequest( + unique: 10, + opcode: .create, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0o644)) + + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) + Array("not-created.txt\0".utf8) + )) + #expect(try FuseProtocol.decodeOutHeader(rejectedCreate).error == -FuseProtocol.linuxErrno(EMFILE)) + #expect(server.resourceSnapshot.fileHandles == 0) + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("not-created.txt").path)) + + let firstOpen = server.handle(request: quotaRequest( + unique: 2, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDONLY)) + quotaBytes(UInt32(0)) + )) + #expect(try FuseProtocol.decodeOutHeader(firstOpen).error == 0) + let fileHandle = quotaPayload(from: firstOpen).leUInt64(at: 0) + let rejectedOpen = server.handle(request: quotaRequest( + unique: 3, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDONLY)) + quotaBytes(UInt32(0)) + )) + #expect(try FuseProtocol.decodeOutHeader(rejectedOpen).error == -FuseProtocol.linuxErrno(EMFILE)) + + let firstOpenDir = server.handle(request: quotaRequest( + unique: 4, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + #expect(try FuseProtocol.decodeOutHeader(firstOpenDir).error == 0) + let directoryHandle = quotaPayload(from: firstOpenDir).leUInt64(at: 0) + let rejectedOpenDir = server.handle(request: quotaRequest( + unique: 5, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + #expect(try FuseProtocol.decodeOutHeader(rejectedOpenDir).error == -FuseProtocol.linuxErrno(EMFILE)) + #expect(server.resourceSnapshot.fileHandles == 1) + #expect(server.resourceSnapshot.directoryHandles == 1) + + let release = server.handle(request: quotaRequest( + unique: 6, + opcode: .release, + nodeID: nodeID, + payload: quotaReleasePayload(handle: fileHandle, owner: 0) + )) + let releaseDir = server.handle(request: quotaRequest( + unique: 7, + opcode: .releasedir, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(directoryHandle) + )) + #expect(try FuseProtocol.decodeOutHeader(release).error == 0) + #expect(try FuseProtocol.decodeOutHeader(releaseDir).error == 0) + #expect(server.resourceSnapshot.fileHandles == 0) + #expect(server.resourceSnapshot.directoryHandles == 0) + + let reopenedFile = server.handle(request: quotaRequest( + unique: 8, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDONLY)) + quotaBytes(UInt32(0)) + )) + let reopenedDirectory = server.handle(request: quotaRequest( + unique: 9, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + #expect(try FuseProtocol.decodeOutHeader(reopenedFile).error == 0) + #expect(try FuseProtocol.decodeOutHeader(reopenedDirectory).error == 0) + #expect(server.resourceSnapshot.fileHandles == 1) + #expect(server.resourceSnapshot.directoryHandles == 1) + + server.resetConnection() + let reset = server.resourceSnapshot + #expect(reset.liveNonRootNodes == 0) + #expect(reset.fileHandles == 0) + #expect(reset.directoryHandles == 0) + #expect(reset.advisoryLockOwners == 0) + #expect(reset.pendingBlockingLocks == 0) + } + + @Test func directoryCursorRetainsOnlyNamesConsideredForBoundedResponses() throws { + let root = try QuotaTestRoot() + for index in 0..<32 { + try root.write("payload", to: String(format: "entry-%04d", index)) + } + let nameLength = "entry-0000".utf8.count + let recordLength = quotaDirentPlusLength(nameByteCount: nameLength) + let server = FuseServer(hostFS: try HostFS( + rootPath: root.url.path, + resourceLimits: quotaLimits(cursorEntries: 64, cursorNameBytes: 1_024) + )) + let opened = server.handle(request: quotaRequest( + unique: 100, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + let handle = quotaPayload(from: opened).leUInt64(at: 0) + + let first = server.handle(request: quotaRequest( + unique: 101, + opcode: .readdirplus, + nodeID: HostFS.rootNodeID, + payload: quotaReadDirPayload( + handle: handle, + offset: 0, + maximumBytes: UInt32(recordLength) + ) + )) + + #expect(try FuseProtocol.decodeOutHeader(first).error == 0) + #expect(quotaPayload(from: first).count == recordLength) + #expect(server.resourceSnapshot.liveNonRootNodes == 1) + #expect(server.resourceSnapshot.directoryCursorEntries == 1) + #expect(server.resourceSnapshot.directoryCursorNameBytes == nameLength) + + let released = server.handle(request: quotaRequest( + unique: 102, + opcode: .releasedir, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(handle) + )) + #expect(try FuseProtocol.decodeOutHeader(released).error == 0) + #expect(server.resourceSnapshot.directoryCursorEntries == 0) + #expect(server.resourceSnapshot.directoryCursorNameBytes == 0) + } + + @Test func directoryCursorQuotaFailsWithEOVERFLOWAndRecoversOnRelease() throws { + let root = try QuotaTestRoot() + for index in 0..<3 { + try root.write("payload", to: String(format: "entry-%04d", index)) + } + let nameLength = "entry-0000".utf8.count + let recordLength = quotaDirentPlusLength(nameByteCount: nameLength) + let server = FuseServer(hostFS: try HostFS( + rootPath: root.url.path, + resourceLimits: quotaLimits(cursorEntries: 1, cursorNameBytes: nameLength) + )) + let opened = server.handle(request: quotaRequest( + unique: 110, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + let handle = quotaPayload(from: opened).leUInt64(at: 0) + let first = server.handle(request: quotaRequest( + unique: 111, + opcode: .readdirplus, + nodeID: HostFS.rootNodeID, + payload: quotaReadDirPayload( + handle: handle, + offset: 0, + maximumBytes: UInt32(recordLength * 2) + ) + )) + let firstPayload = quotaPayload(from: first) + + #expect(try FuseProtocol.decodeOutHeader(first).error == 0) + #expect(firstPayload.count == recordLength) + #expect(server.resourceSnapshot.directoryCursorEntries == 1) + #expect(server.resourceSnapshot.directoryCursorNameBytes == nameLength) + let nextOffset = firstPayload.leUInt64(at: 128 + 8) + let overflow = server.handle(request: quotaRequest( + unique: 112, + opcode: .readdirplus, + nodeID: HostFS.rootNodeID, + payload: quotaReadDirPayload( + handle: handle, + offset: nextOffset, + maximumBytes: UInt32(recordLength) + ) + )) + #expect( + try FuseProtocol.decodeOutHeader(overflow).error + == -FuseProtocol.linuxErrno(EOVERFLOW) + ) + + _ = server.handle(request: quotaRequest( + unique: 113, + opcode: .releasedir, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(handle) + )) + #expect(server.resourceSnapshot.directoryCursorEntries == 0) + #expect(server.resourceSnapshot.directoryCursorNameBytes == 0) + } + + @Test func unpublishedCreateRollbackRecoversNodeAndHandleTokens() throws { + let root = try QuotaTestRoot() + let hostFS = try HostFS( + rootPath: root.url.path, + resourceLimits: quotaLimits(nodes: 1, files: 1) + ) + let server = FuseServer(hostFS: hostFS) + let response = server.handle(request: quotaRequest( + unique: 15, + opcode: .create, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0o644)) + + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) + Array("created.txt\0".utf8) + )) + #expect(try FuseProtocol.decodeOutHeader(response).error == 0) + #expect(server.resourceSnapshot.liveNonRootNodes == 1) + #expect(server.resourceSnapshot.fileHandles == 1) + + server.rollbackUnpublishedResponse(opcode: .create, response: response) + #expect(server.resourceSnapshot.liveNonRootNodes == 0) + #expect(server.resourceSnapshot.fileHandles == 0) + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("created.txt").path)) + } + + @Test func serverShutdownRecoversEveryRegisteredResource() throws { + let root = try QuotaTestRoot() + try root.write("shutdown", to: "shutdown.txt") + let hostFS = try HostFS(rootPath: root.url.path, resourceLimits: quotaLimits()) + + try exerciseQuotaShutdown(hostFS: hostFS) + + let snapshot = hostFS.resourceSnapshot + #expect(snapshot.liveNonRootNodes == 0) + #expect(snapshot.fileHandles == 0) + #expect(snapshot.directoryHandles == 0) + #expect(snapshot.advisoryLockOwners == 0) + #expect(snapshot.pendingBlockingLocks == 0) + } + + @Test func advisoryOwnerQuotaRecoversAfterFlushAndRelease() throws { + let fixture = try QuotaLockFixture(limits: quotaLimits(nodes: 2, files: 1, owners: 1)) + + let firstLock = fixture.server.handle(request: fixture.lockRequest( + unique: 20, + owner: 101, + type: 1, + blocking: false + )) + #expect(try FuseProtocol.decodeOutHeader(firstLock).error == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 1) + + let rejectedOwner = fixture.server.handle(request: fixture.lockRequest( + unique: 21, + owner: 202, + type: 1, + blocking: false + )) + #expect(try FuseProtocol.decodeOutHeader(rejectedOwner).error == -FuseProtocol.linuxErrno(EMFILE)) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 1) + + let flush = fixture.server.handle(request: quotaRequest( + unique: 22, + opcode: .flush, + nodeID: fixture.nodeID, + payload: quotaFlushPayload(handle: fixture.fileHandle, owner: 101) + )) + #expect(try FuseProtocol.decodeOutHeader(flush).error == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 0) + + let replacementOwner = fixture.server.handle(request: fixture.lockRequest( + unique: 23, + owner: 202, + type: 1, + blocking: false + )) + #expect(try FuseProtocol.decodeOutHeader(replacementOwner).error == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 1) + + let release = fixture.server.handle(request: quotaRequest( + unique: 24, + opcode: .release, + nodeID: fixture.nodeID, + payload: quotaReleasePayload(handle: fixture.fileHandle, owner: 202) + )) + #expect(try FuseProtocol.decodeOutHeader(release).error == 0) + #expect(fixture.server.resourceSnapshot.fileHandles == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 0) + } + + @Test func pendingLockQuotaMapsToEAGAINAndRecoversImmediatelyOnInterrupt() throws { + let fixture = try QuotaLockFixture(limits: quotaLimits( + nodes: 2, + files: 1, + owners: 3, + pending: 1 + )) + let ownerLock = fixture.server.handle(request: fixture.lockRequest( + unique: 30, + owner: 301, + type: 1, + blocking: false + )) + #expect(try FuseProtocol.decodeOutHeader(ownerLock).error == 0) + + let waiterResult = QuotaLockedResponse() + let waiterFinished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + waiterResult.store(fixture.server.handle(request: fixture.lockRequest( + unique: 31, + owner: 302, + type: 1, + blocking: true + ))) + waiterFinished.signal() + } + #expect(quotaWaitUntil { fixture.server.resourceSnapshot.pendingBlockingLocks == 1 }) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 2) + + let rejectedWaiter = fixture.server.handle(request: fixture.lockRequest( + unique: 32, + owner: 303, + type: 1, + blocking: true + )) + #expect(try FuseProtocol.decodeOutHeader(rejectedWaiter).error == -FuseProtocol.linuxErrno(EAGAIN)) + #expect(fixture.server.resourceSnapshot.pendingBlockingLocks == 1) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 2) + + let interrupt = fixture.server.handle(request: quotaRequest( + unique: 33, + opcode: .interrupt, + nodeID: HostFS.rootNodeID, + payload: quotaBytes(UInt64(31)) + )) + #expect(interrupt.isEmpty) + #expect(fixture.server.resourceSnapshot.pendingBlockingLocks == 0) + #expect(waiterFinished.wait(timeout: .now() + 2) == .success) + #expect(try FuseProtocol.decodeOutHeader(waiterResult.load()).error == -FuseProtocol.linuxErrno(EINTR)) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 1) + + let releaseOwner = fixture.server.handle(request: quotaRequest( + unique: 34, + opcode: .flush, + nodeID: fixture.nodeID, + payload: quotaFlushPayload(handle: fixture.fileHandle, owner: 301) + )) + #expect(try FuseProtocol.decodeOutHeader(releaseOwner).error == 0) + + let recoveredWaiter = fixture.server.handle(request: fixture.lockRequest( + unique: 35, + owner: 303, + type: 1, + blocking: true + )) + #expect(try FuseProtocol.decodeOutHeader(recoveredWaiter).error == 0) + #expect(fixture.server.resourceSnapshot.pendingBlockingLocks == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 1) + + let release = fixture.server.handle(request: quotaRequest( + unique: 36, + opcode: .release, + nodeID: fixture.nodeID, + payload: quotaReleasePayload(handle: fixture.fileHandle, owner: 303) + )) + #expect(try FuseProtocol.decodeOutHeader(release).error == 0) + #expect(fixture.server.resourceSnapshot.fileHandles == 0) + #expect(fixture.server.resourceSnapshot.advisoryLockOwners == 0) + } +} + +private func quotaLimits( + nodes: Int = 8, + files: Int = 8, + directories: Int = 8, + cursorEntries: Int = 64, + cursorNameBytes: Int = 4_096, + owners: Int = 8, + pending: Int = 8 +) -> FuseResourceLimits { + FuseResourceLimits( + maximumLiveNonRootNodes: nodes, + maximumFileHandles: files, + maximumDirectoryHandles: directories, + maximumDirectoryCursorEntries: cursorEntries, + maximumDirectoryCursorNameBytes: cursorNameBytes, + maximumAdvisoryLockOwners: owners, + maximumPendingBlockingLocks: pending + ) +} + +private func quotaReadDirPayload( + handle: UInt64, + offset: UInt64, + maximumBytes: UInt32 +) -> [UInt8] { + quotaBytes(handle) + quotaBytes(offset) + quotaBytes(maximumBytes) + quotaBytes(UInt32(0)) + + quotaBytes(UInt64(0)) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) +} + +private func quotaDirentPlusLength(nameByteCount: Int) -> Int { + (128 + 24 + nameByteCount + 7) & ~7 +} + +private func quotaRequest( + unique: UInt64, + opcode: FuseOpcode, + nodeID: UInt64, + payload: [UInt8] = [] +) -> [UInt8] { + FuseProtocol.encodeInHeader(FuseInHeader( + length: UInt32(FuseInHeader.byteCount + payload.count), + opcode: opcode.rawValue, + unique: unique, + nodeID: nodeID, + uid: 1000, + gid: 1000, + pid: 42 + )) + payload +} + +private func quotaPayload(from response: [UInt8]) -> [UInt8] { + Array(response.dropFirst(FuseOutHeader.byteCount)) +} + +private func quotaBytes(_ value: UInt32) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func quotaBytes(_ value: UInt64) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func quotaLockPayload( + handle: UInt64, + owner: UInt64, + type: UInt32 +) -> [UInt8] { + quotaBytes(handle) + quotaBytes(owner) + quotaBytes(UInt64(0)) + quotaBytes(UInt64(7)) + + quotaBytes(type) + quotaBytes(UInt32(42)) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) +} + +private func quotaFlushPayload(handle: UInt64, owner: UInt64) -> [UInt8] { + quotaBytes(handle) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) + quotaBytes(owner) +} + +private func quotaReleasePayload(handle: UInt64, owner: UInt64) -> [UInt8] { + quotaFlushPayload(handle: handle, owner: owner) +} + +private func quotaWaitUntil( + timeout: TimeInterval = 2, + condition: () -> Bool +) -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.005) + } + return condition() +} + +private func exerciseQuotaShutdown(hostFS: HostFS) throws { + let server = FuseServer(hostFS: hostFS) + let lookup = server.handle(request: quotaRequest( + unique: 200, + opcode: .lookup, + nodeID: HostFS.rootNodeID, + payload: Array("shutdown.txt\0".utf8) + )) + guard try FuseProtocol.decodeOutHeader(lookup).error == 0 else { + throw QuotaTestFixtureError.lookupFailed + } + let nodeID = quotaPayload(from: lookup).leUInt64(at: 0) + let open = server.handle(request: quotaRequest( + unique: 201, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0)) + )) + guard try FuseProtocol.decodeOutHeader(open).error == 0 else { + throw QuotaTestFixtureError.openFailed + } + let fileHandle = quotaPayload(from: open).leUInt64(at: 0) + let openDirectory = server.handle(request: quotaRequest( + unique: 202, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + guard try FuseProtocol.decodeOutHeader(openDirectory).error == 0 else { + throw QuotaTestFixtureError.openDirectoryFailed + } + let lock = server.handle(request: quotaRequest( + unique: 203, + opcode: .setlk, + nodeID: nodeID, + payload: quotaLockPayload(handle: fileHandle, owner: 901, type: 1) + )) + guard try FuseProtocol.decodeOutHeader(lock).error == 0 else { + throw QuotaTestFixtureError.lockFailed + } + let snapshot = server.resourceSnapshot + guard snapshot.liveNonRootNodes == 1, + snapshot.fileHandles == 1, + snapshot.directoryHandles == 1, + snapshot.advisoryLockOwners == 1 else { + throw QuotaTestFixtureError.unexpectedSnapshot + } +} + +private extension Array where Element == UInt8 { + func leUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } + + func leUInt64(at offset: Int) -> UInt64 { + UInt64(leUInt32(at: offset)) | UInt64(leUInt32(at: offset + 4)) << 32 + } +} + +private final class QuotaTestRoot { + let url: URL + + init() throws { + url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-fuse-quota-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + func write(_ text: String, to relativePath: String) throws { + try text.write(to: url.appendingPathComponent(relativePath), atomically: true, encoding: .utf8) + } +} + +private final class QuotaAdmissionResults: @unchecked Sendable { + private let lock = NSLock() + private var tokens: [FuseResourceToken] = [] + private var recordedErrors: [FuseResourceQuotaError] = [] + private var recordedUnexpectedErrors: [String] = [] + + var successCount: Int { lock.withLock { tokens.count } } + var errors: [FuseResourceQuotaError] { lock.withLock { recordedErrors } } + var unexpectedErrors: [String] { lock.withLock { recordedUnexpectedErrors } } + + func record(token: FuseResourceToken) { + lock.withLock { tokens.append(token) } + } + + func record(error: FuseResourceQuotaError) { + lock.withLock { recordedErrors.append(error) } + } + + func recordUnexpected(_ error: Error) { + lock.withLock { recordedUnexpectedErrors.append(String(describing: error)) } + } + + func releaseAll() { + let admitted = lock.withLock { () -> [FuseResourceToken] in + let admitted = tokens + tokens.removeAll(keepingCapacity: false) + return admitted + } + admitted.forEach { $0.release() } + } +} + +private final class ConcurrentLookupResults: @unchecked Sendable { + private let lock = NSLock() + private var successes = 0 + private var recordedErrors: [FuseResourceQuotaError] = [] + private var recordedUnexpectedErrors: [String] = [] + + var successCount: Int { lock.withLock { successes } } + var errors: [FuseResourceQuotaError] { lock.withLock { recordedErrors } } + var unexpectedErrors: [String] { lock.withLock { recordedUnexpectedErrors } } + + func recordSuccess() { + lock.withLock { successes += 1 } + } + + func record(error: FuseResourceQuotaError) { + lock.withLock { recordedErrors.append(error) } + } + + func recordUnexpected(_ error: Error) { + lock.withLock { recordedUnexpectedErrors.append(String(describing: error)) } + } +} + +private final class QuotaLockedResponse: @unchecked Sendable { + private let lock = NSLock() + private var response: [UInt8] = [] + + func store(_ response: [UInt8]) { + lock.withLock { self.response = response } + } + + func load() -> [UInt8] { + lock.withLock { response } + } +} + +private final class QuotaLockFixture: @unchecked Sendable { + let root: QuotaTestRoot + let server: FuseServer + let nodeID: UInt64 + let fileHandle: UInt64 + + init(limits: FuseResourceLimits) throws { + root = try QuotaTestRoot() + try root.write("lock", to: "lock.txt") + server = FuseServer(hostFS: try HostFS(rootPath: root.url.path, resourceLimits: limits)) + let lookup = server.handle(request: quotaRequest( + unique: 100, + opcode: .lookup, + nodeID: HostFS.rootNodeID, + payload: Array("lock.txt\0".utf8) + )) + guard try FuseProtocol.decodeOutHeader(lookup).error == 0 else { + throw QuotaTestFixtureError.lookupFailed + } + nodeID = quotaPayload(from: lookup).leUInt64(at: 0) + let open = server.handle(request: quotaRequest( + unique: 101, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0)) + )) + guard try FuseProtocol.decodeOutHeader(open).error == 0 else { + throw QuotaTestFixtureError.openFailed + } + fileHandle = quotaPayload(from: open).leUInt64(at: 0) + } + + func lockRequest(unique: UInt64, owner: UInt64, type: UInt32, blocking: Bool) -> [UInt8] { + quotaRequest( + unique: unique, + opcode: blocking ? .setlkw : .setlk, + nodeID: nodeID, + payload: quotaLockPayload(handle: fileHandle, owner: owner, type: type) + ) + } +} + +private enum QuotaTestFixtureError: Error { + case lookupFailed + case openFailed + case openDirectoryFailed + case lockFailed + case unexpectedSnapshot +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift similarity index 84% rename from Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift rename to Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift index b2b14930..36c80380 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift @@ -1,9 +1,38 @@ import Darwin +import DoryFSWorkerContracts import Foundation import Testing -@testable import DoryHV +@testable import DoryFSWorkerServiceCore struct FuseServerTests { + @Test func syncfsFallsBackWithoutFailingConnectionAndDestroyAcknowledgesTeardown() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + + let sync = server.handle(request: request( + unique: 8, + opcode: .syncfs, + nodeID: HostFS.rootNodeID, + payload: [UInt8](repeating: 0, count: 8) + )) + let destroy = server.handle(request: request( + unique: 9, + opcode: .destroy, + nodeID: 0 + )) + + #expect( + try FuseProtocol.decodeOutHeader(sync).error + == -FuseProtocol.linuxErrno(ENOSYS) + ) + #expect(try FuseProtocol.decodeOutHeader(sync).unique == 8) + #expect(try FuseProtocol.decodeOutHeader(destroy) == FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: 0, + unique: 9 + )) + } + @Test func lookupGetattrOpenReadAndReleaseFlow() throws { let root = try TestFuseServerRoot() try root.write("hello dory", to: "hello.txt") @@ -103,11 +132,8 @@ struct FuseServerTests { ) // The old node ID remains a valid inode while LOOKUP/FH refs exist. Atomic replacement // detaches its pathname, but GETATTR returns the pinned old inode instead of leaking ESTALE - // to an ordinary path open. VirtioFS uses this direct response path in production. - let firstPathGetattr = try directGetattrResponse( - server: server, - request: pathGetattrRequest - ) + // to an ordinary path open. + let firstPathGetattr = server.handle(request: pathGetattrRequest) #expect(try FuseProtocol.decodeOutHeader(firstPathGetattr).error == 0) #expect(payload(from: firstPathGetattr).leUInt64(at: 24) == 3) #expect(payload(from: firstPathGetattr).leUInt32(at: 80) == 0) @@ -172,14 +198,9 @@ struct FuseServerTests { )) ) let handleGetattr = server.handle(request: handleGetattrRequest) - let directHandleGetattr = try directGetattrResponse( - server: server, - request: handleGetattrRequest - ) let handleAttributes = payload(from: handleGetattr) #expect(try FuseProtocol.decodeOutHeader(handleGetattr).error == 0) - #expect(directHandleGetattr == handleGetattr) #expect(handleAttributes.leUInt64(at: 16) == oldNodeID) #expect(handleAttributes.leUInt64(at: 24) == 3) #expect(handleAttributes.leUInt32(at: 80) == 0) @@ -251,7 +272,7 @@ struct FuseServerTests { )) } - @Test func getattrFileHandleRejectsMismatchedAndUnknownHandlesOnBothPaths() throws { + @Test func getattrFileHandleRejectsMismatchedAndUnknownHandles() throws { let root = try TestFuseServerRoot() try root.write("first", to: "first.txt") try root.write("second", to: "second.txt") @@ -299,10 +320,8 @@ struct FuseServerTests { ] for invalidRequest in invalidRequests { - let arrayResponse = server.handle(request: invalidRequest) - let directResponse = try directGetattrResponse(server: server, request: invalidRequest) - #expect(try FuseProtocol.decodeOutHeader(arrayResponse).error == -EBADF) - #expect(directResponse == arrayResponse) + let response = server.handle(request: invalidRequest) + #expect(try FuseProtocol.decodeOutHeader(response).error == -EBADF) } } @@ -338,20 +357,13 @@ struct FuseServerTests { let writePayload = bytes(readHandle) + bytes(UInt64(0)) + bytes(UInt32(1)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) + Array("X".utf8) - let arrayWrite = server.handle(request: request( + let rejectedWrite = server.handle(request: request( unique: 333, opcode: .write, nodeID: oldNodeID, payload: writePayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayWrite).error == -EBADF) - let directWrite = try directWriteResponse(server: server, request: request( - unique: 334, - opcode: .write, - nodeID: oldNodeID, - payload: writePayload - )) - #expect(try FuseProtocol.decodeOutHeader(directWrite).error == -EBADF) + #expect(try FuseProtocol.decodeOutHeader(rejectedWrite).error == -EBADF) let truncate = server.handle(request: request( unique: 335, @@ -375,20 +387,13 @@ struct FuseServerTests { let writeHandle = payload(from: writeOpen).leUInt64(at: 0) let readPayload = bytes(writeHandle) + bytes(UInt64(0)) + bytes(UInt32(32)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) - let arrayRead = server.handle(request: request( + let rejectedRead = server.handle(request: request( unique: 337, opcode: .read, nodeID: oldNodeID, payload: readPayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayRead).error == -EBADF) - let directRead = try directReadResponse(server: server, request: request( - unique: 338, - opcode: .read, - nodeID: oldNodeID, - payload: readPayload - )) - #expect(try FuseProtocol.decodeOutHeader(directRead).error == -EBADF) + #expect(try FuseProtocol.decodeOutHeader(rejectedRead).error == -EBADF) let wrongNodeRead = server.handle(request: request( unique: 339, @@ -492,22 +497,14 @@ struct FuseServerTests { let readPayload = bytes(handle) + bytes(UInt64(0)) + bytes(UInt32(32)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) - let arrayRead = server.handle(request: request( + let read = server.handle(request: request( unique: 353, opcode: .read, nodeID: nodeID, payload: readPayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayRead).error == 0) - #expect(String(decoding: payload(from: arrayRead), as: UTF8.self) == "writeback") - let directRead = try directReadResponse(server: server, request: request( - unique: 354, - opcode: .read, - nodeID: nodeID, - payload: readPayload - )) - #expect(try FuseProtocol.decodeOutHeader(directRead).error == 0) - #expect(String(decoding: payload(from: directRead), as: UTF8.self) == "writeback") + #expect(try FuseProtocol.decodeOutHeader(read).error == 0) + #expect(String(decoding: payload(from: read), as: UTF8.self) == "writeback") _ = server.handle(request: request( unique: 355, opcode: .release, @@ -702,18 +699,7 @@ struct FuseServerTests { let hostFS = try HostFS(rootPath: root.url.path) let server = FuseServer(hostFS: hostFS) func rollback(_ response: [UInt8], opcode: FuseOpcode) { - var storage = response - storage.withUnsafeMutableBytes { raw in - server.rollbackUnpublishedResponse( - opcode: opcode, - writable: [VirtqueueSegment( - pointer: raw.baseAddress!, - length: raw.count, - isDeviceWritable: true - )], - written: raw.count - ) - } + server.rollbackUnpublishedResponse(opcode: opcode, response: response) } let droppedLookup = server.handle(request: request( @@ -1024,19 +1010,8 @@ struct FuseServerTests { nodeID: HostFS.rootNodeID, payload: releaseDirectoryIn ) - let releaseDirectoryHeader = try FuseProtocol.decodeInHeader(releaseDirectoryRequest) - let releaseDirectoryPayload = releaseDirectoryRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReleaseResponse( - header: releaseDirectoryHeader, - payload: releaseDirectoryPayload, - writable: [segment] - ) - } - #expect(releaseCount == FuseOutHeader.byteCount) - #expect(try FuseProtocol.decodeOutHeader(releaseDestination).error == 0) + let releasedDirectory = server.handle(request: releaseDirectoryRequest) + #expect(try FuseProtocol.decodeOutHeader(releasedDirectory).error == 0) let readIn = bytes(fileHandle) + bytes(UInt64(0)) + bytes(UInt32(7)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) @@ -1067,7 +1042,7 @@ struct FuseServerTests { )).isEmpty) } - @Test func zeroCopyReadMatchesArrayPath() throws { + @Test func readReturnsRequestedSlice() throws { let root = try TestFuseServerRoot() try root.write("hello dory world", to: "z.txt") let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) @@ -1077,57 +1052,35 @@ struct FuseServerTests { let readIn = bytes(handle) + bytes(UInt64(6)) + bytes(UInt32(4)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) let req = request(unique: 3, opcode: .read, nodeID: nodeID, payload: readIn) - let arrayPath = server.handle(request: req) + let response = server.handle(request: req) - let header = try FuseProtocol.decodeInHeader(req) - let readPayload = Array(req[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReadResponse(header: header, payload: readPayload, writable: [segment]) - } - - #expect(Array(dest[0.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeWriteResponse(header: writeHeader, payload: writePayload, writable: [segment]) - } + let written = server.handle(request: writeRequest) - #expect(writeCount == FuseOutHeader.byteCount + 8) - #expect(try FuseProtocol.decodeOutHeader(Array(writeDest)).error == 0) - #expect(payload(from: Array(writeDest)).leUInt32(at: 0) == UInt32(writeData.count)) + #expect(try FuseProtocol.decodeOutHeader(written).error == 0) + #expect(payload(from: written).leUInt32(at: 0) == UInt32(writeData.count)) let releaseIn = bytes(handle) + bytes(UInt32(0)) + bytes(UInt32(0)) + bytes(UInt64(0)) let releaseRequest = request(unique: 103, opcode: .release, nodeID: nodeID, payload: releaseIn) - let releaseHeader = try FuseProtocol.decodeInHeader(releaseRequest) - let releasePayload = releaseRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReleaseResponse(header: releaseHeader, payload: releasePayload, writable: [segment]) - } + let released = server.handle(request: releaseRequest) - #expect(releaseCount == FuseOutHeader.byteCount) - #expect(try FuseProtocol.decodeOutHeader(Array(releaseDest)).error == 0) - #expect(try String(contentsOf: root.url.appendingPathComponent("direct.txt"), encoding: .utf8) == "hello direct") + #expect(try FuseProtocol.decodeOutHeader(released).error == 0) + #expect(try String(contentsOf: root.url.appendingPathComponent("written.txt"), encoding: .utf8) == "hello dory") } @Test func releaseCannotCloseDescriptorBorrowedByConcurrentWrite() async throws { @@ -1188,89 +1141,60 @@ struct FuseServerTests { #expect(try FuseProtocol.decodeOutHeader(stale).error == -EBADF) } - @Test func directMetadataMissResponsesMatchArrayPath() throws { + @Test func lookupMissHitAndGetxattrResponsesAreCanonical() throws { let root = try TestFuseServerRoot() try root.write("exists", to: "exists.txt") - let arrayServer = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) - let directHostFS = try HostFS(rootPath: root.url.path) - let directServer = FuseServer(hostFS: directHostFS) + let hostFS = try HostFS(rootPath: root.url.path) + let server = FuseServer(hostFS: hostFS) let missingRequest = request(unique: 201, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("missing.txt\0".utf8)) - let missingHeader = try FuseProtocol.decodeInHeader(missingRequest) - let missingPayload = missingRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return directServer.writeLookupResponse(header: missingHeader, payload: missingPayload, writable: [segment]) - } + let missing = server.handle(request: missingRequest) // Misses are always plain ENOENT. A negative dentry could hide a concurrently created path. - #expect(missingCount == FuseOutHeader.byteCount) - #expect(Array(missingDest[0.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return directServer.writeLookupResponse(header: hitHeader, payload: hitPayload, writable: [segment]) - } + let hit = server.handle(request: hitRequest) - #expect(hitCount == FuseOutHeader.byteCount + 128) - #expect(Array(hitDest[0..